@ljwei-stak/dsh-model-router 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh-plugin/client.js +701 -180
- package/.dsh-plugin/index.mjs +126 -25
- package/.dsh-plugin/shared/adaptive-feedback.mjs +272 -0
- package/.dsh-plugin/shared/assignment-solver.mjs +86 -0
- package/.dsh-plugin/shared/dynamic-data-view.mjs +120 -0
- package/.dsh-plugin/shared/dynamic-data.mjs +372 -0
- package/.dsh-plugin/shared/harness-plan.mjs +5 -2
- package/.dsh-plugin/shared/livebench.mjs +57 -20
- package/.dsh-plugin/shared/model-profiles.mjs +7 -1
- package/.dsh-plugin/shared/official-tools-remote.mjs +3 -1
- package/.dsh-plugin/shared/router-state.mjs +140 -38
- package/.dsh-plugin/shared/router.mjs +115 -152
- package/.dsh-plugin/shared/run-ledger.mjs +37 -11
- package/CHANGELOG.md +29 -0
- package/INSTALLATION_GUIDE.zh.md +21 -15
- package/README.md +14 -6
- package/README.zh.md +14 -6
- package/docs/ROUTING_ADAPTIVE_LEARNING.zh.md +153 -0
- package/docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md +684 -0
- package/docs/ROUTING_LIVEBENCH_DATA_SEARCH.zh.md +74 -0
- package/docs/ROUTING_LIVEBENCH_VALIDATION.zh.md +154 -0
- package/docs/ROUTING_MATURITY.zh.md +76 -0
- package/docs/ROUTING_RESEARCH.zh.md +59 -0
- package/docs/ROUTING_SELFTEST_STEPS.zh.md +192 -0
- package/docs/WORKBENCH_USER_GUIDE.zh.md +25 -3
- package/package.json +19 -2
package/.dsh-plugin/index.mjs
CHANGED
|
@@ -12,9 +12,12 @@ import {
|
|
|
12
12
|
DEFAULT_COOLDOWN_MINUTES, billingOverview, createQuotaTracker, detectQuotaExhaustion, parseQuotaPatterns, vendorKey,
|
|
13
13
|
} from './shared/subscription-billing.mjs'
|
|
14
14
|
import { createRouterState } from './shared/router-state.mjs'
|
|
15
|
+
import { applyFeedbackProfile, buildFeedbackProfile } from './shared/adaptive-feedback.mjs'
|
|
16
|
+
import { applyPricingSnapshot, createDynamicDataRefresher, dynamicDataSummary, routingData } from './shared/dynamic-data.mjs'
|
|
17
|
+
import { projectDynamicData } from './shared/dynamic-data-view.mjs'
|
|
15
18
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
|
|
20
|
+
spending, storedResults, actualCost, teamExecutionResults,
|
|
18
21
|
} from './shared/run-ledger.mjs'
|
|
19
22
|
import { routeBoundaries } from './shared/security-boundaries.mjs'
|
|
20
23
|
import { createPlanFromRoutes } from './shared/harness-plan.mjs'
|
|
@@ -62,6 +65,15 @@ export const Config = z.object({
|
|
|
62
65
|
overBudgetAction: z.union(['downgrade', 'pause']).default('downgrade').volatile(),
|
|
63
66
|
reviewMode: z.union(['off', 'sample', 'always']).default('off').volatile(),
|
|
64
67
|
reviewSampleRate: z.number().min(0).max(1).default(0.2).volatile(),
|
|
68
|
+
feedbackLearningEnabled: z.boolean().default(true).volatile(),
|
|
69
|
+
feedbackHalfLifeDays: z.number().min(1).max(3650).default(30).volatile(),
|
|
70
|
+
feedbackPriorWeight: z.number().min(1).max(1_000_000).default(3).volatile(),
|
|
71
|
+
feedbackMaxAdjustment: z.number().min(0).max(0.1).default(0.04).volatile(),
|
|
72
|
+
feedbackResetAt: z.number().min(0).default(0).volatile(),
|
|
73
|
+
dynamicDataEnabled: z.boolean().default(false).volatile(),
|
|
74
|
+
liveBenchEndpoint: z.string().max(2048).default('https://livebench.ai').volatile(),
|
|
75
|
+
pricingSnapshotEndpoint: z.string().max(2048).default('').volatile(),
|
|
76
|
+
dynamicDataTtlMinutes: z.number().min(1).max(10_080).default(1440).volatile(),
|
|
65
77
|
allowManualReassign: z.boolean().default(true).volatile(),
|
|
66
78
|
confirmUnsandboxedCli: z.boolean().default(true).volatile(),
|
|
67
79
|
subscriptionCooldownMinutes: z.number().step(1).min(1).max(10_080).default(DEFAULT_COOLDOWN_MINUTES).volatile(),
|
|
@@ -169,8 +181,14 @@ export function routerStateStore() {
|
|
|
169
181
|
}
|
|
170
182
|
|
|
171
183
|
async function savedState() {
|
|
172
|
-
try {
|
|
173
|
-
|
|
184
|
+
try {
|
|
185
|
+
const saved = await routerStateStore().read()
|
|
186
|
+
const storageAvailable = !(saved.notices ?? []).some(notice => notice.kind === 'state-recovery-pending')
|
|
187
|
+
return { ...saved, storageAvailable, historyVerified: storageAvailable && saved.historyIncomplete !== true }
|
|
188
|
+
} catch {
|
|
189
|
+
return { onboarding: { completedAt: null }, health: null, runs: [], storageAvailable: false, historyVerified: false,
|
|
190
|
+
notices: [{ kind: 'state-read-failed', at: Date.now(), message: '执行记录暂时无法读取,费用与学习摘要尚未核验;请恢复存储连接后重试。' }] }
|
|
191
|
+
}
|
|
174
192
|
}
|
|
175
193
|
|
|
176
194
|
const NOTICE_DAYS = 7
|
|
@@ -333,6 +351,9 @@ function budgetSettings(config) {
|
|
|
333
351
|
|
|
334
352
|
function budgetFor(config, saved, estimateUsd) {
|
|
335
353
|
const settings = budgetSettings(config)
|
|
354
|
+
if ((saved.storageAvailable === false || saved.historyVerified === false) && (settings.dailyLimitUsd > 0 || settings.monthlyLimitUsd > 0)) {
|
|
355
|
+
throw new Error('无法读取费用历史,不能核验日/月预算;请恢复存储连接后再执行。')
|
|
356
|
+
}
|
|
336
357
|
const spent = spending(saved.runs, undefined, saved.archivedSpending)
|
|
337
358
|
return { ...budgetCheck({ estimateUsd, spent, ...settings }), spent, ...settings }
|
|
338
359
|
}
|
|
@@ -350,13 +371,62 @@ export function billingForResult(result) {
|
|
|
350
371
|
}
|
|
351
372
|
|
|
352
373
|
function routePricing(routes, item) {
|
|
353
|
-
|
|
374
|
+
const route = routes.find(route => route.provider === item?.provider && route.model === item?.model)
|
|
375
|
+
return route?.pricing ? { ...route.pricing, source: route.pricingSource ?? 'route', version: route.pricingVersion ?? null, asOf: route.snapshotAsOf ?? null } : null
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function feedbackOptions(config) {
|
|
379
|
+
return {
|
|
380
|
+
enabled: valueOf(config, 'feedbackLearningEnabled', true) !== false,
|
|
381
|
+
halfLifeDays: valueOf(config, 'feedbackHalfLifeDays', 30),
|
|
382
|
+
priorWeight: valueOf(config, 'feedbackPriorWeight', 3),
|
|
383
|
+
maxAdjustment: valueOf(config, 'feedbackMaxAdjustment', 0.04),
|
|
384
|
+
resetAt: valueOf(config, 'feedbackResetAt', 0),
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function dynamicOptions(config) {
|
|
389
|
+
return {
|
|
390
|
+
enabled: valueOf(config, 'dynamicDataEnabled', false) === true,
|
|
391
|
+
liveBenchEndpoint: valueOf(config, 'liveBenchEndpoint', 'https://livebench.ai'),
|
|
392
|
+
pricingSnapshotEndpoint: valueOf(config, 'pricingSnapshotEndpoint', ''),
|
|
393
|
+
ttlMs: boundedInteger(valueOf(config, 'dynamicDataTtlMinutes', 1440), 1440, 1, 10_080) * 60_000,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let dynamicRefresher = null
|
|
398
|
+
async function routingSavedState(config) {
|
|
399
|
+
const options = dynamicOptions(config)
|
|
400
|
+
let refreshedData = null
|
|
401
|
+
if (options.enabled) {
|
|
402
|
+
dynamicRefresher ??= createDynamicDataRefresher({ store: routerStateStore() })
|
|
403
|
+
// A public-data failure must never turn into a paid probe or prevent
|
|
404
|
+
// ordinary routing. The refresher preserves last-good snapshots.
|
|
405
|
+
try { refreshedData = await dynamicRefresher.refresh(options) } catch { /* state/read errors use the normal fallback */ }
|
|
406
|
+
}
|
|
407
|
+
const saved = await savedState()
|
|
408
|
+
// Config can change while a public request is in flight. Reproject against
|
|
409
|
+
// the current settings, not the options captured before awaiting refresh.
|
|
410
|
+
// Cached source A must not masquerade as a newly configured source B.
|
|
411
|
+
saved.dynamicData = projectDynamicData(refreshedData ?? saved.dynamicData, dynamicOptions(config))
|
|
412
|
+
saved.routingLearning = buildFeedbackProfile(saved.runs, feedbackOptions(config))
|
|
413
|
+
return saved
|
|
354
414
|
}
|
|
355
415
|
|
|
356
|
-
|
|
357
|
-
|
|
416
|
+
function planDataOptions(saved, config) {
|
|
417
|
+
return {
|
|
418
|
+
...routingData(saved.dynamicData, dynamicOptions(config).enabled),
|
|
419
|
+
learning: saved.routingLearning ?? buildFeedbackProfile(saved.runs, feedbackOptions(config)),
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Manual exact-route profiles take precedence over public price snapshots.
|
|
424
|
+
* Only explicit subjective feedback affects utility; reviews stay separate. */
|
|
425
|
+
async function routesWithLearning(ctx, config, signal, runs, dynamicData = null, feedbackProfile = null) {
|
|
358
426
|
const discovered = await discoverConfiguredRoutes(ctx, signal)
|
|
359
|
-
|
|
427
|
+
const profiled = configuredRoutesWithProfiles(discovered, config)
|
|
428
|
+
const priced = applyPricingSnapshot(profiled, dynamicOptions(config).enabled ? dynamicData?.pricing : null)
|
|
429
|
+
return applyFeedbackProfile(priced, feedbackProfile ?? buildFeedbackProfile(runs, feedbackOptions(config)))
|
|
360
430
|
}
|
|
361
431
|
|
|
362
432
|
/** Produce a route recommendation and work packages compatible with official Agent Teams. */
|
|
@@ -366,9 +436,9 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
|
|
|
366
436
|
const mode = options.mode === 'team' ? 'team' : 'single'
|
|
367
437
|
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
368
438
|
const budgetUsd = Math.max(0, finiteNumber(options.budgetUsd, finiteNumber(configuredBudget, 0)))
|
|
369
|
-
const saved = await
|
|
439
|
+
const saved = await routingSavedState(config)
|
|
370
440
|
const [availableRoutes, installed] = await Promise.all([
|
|
371
|
-
routesWithLearning(ctx, config, options.signal, saved.runs),
|
|
441
|
+
routesWithLearning(ctx, config, options.signal, saved.runs, saved.dynamicData, saved.routingLearning),
|
|
372
442
|
options.skipToolProbe === true
|
|
373
443
|
? Promise.resolve(Array.isArray(options.installedToolIds) ? options.installedToolIds : [])
|
|
374
444
|
: installedToolIds(),
|
|
@@ -381,6 +451,7 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
|
|
|
381
451
|
? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
|
|
382
452
|
: loggedOutIds(await currentHealth())
|
|
383
453
|
const plan = createPlanFromRoutes(taskText, availableRoutes, {
|
|
454
|
+
...planDataOptions(saved, config),
|
|
384
455
|
mode, budgetUsd, installedToolIds: installed,
|
|
385
456
|
runnableToolIds: installed.filter(id => executable.has(id)),
|
|
386
457
|
preset: routingPresetOf(config, options.preset),
|
|
@@ -599,8 +670,8 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
|
|
|
599
670
|
if (problem) throw new Error(problem)
|
|
600
671
|
const direct = text(options.provider) || text(options.model)
|
|
601
672
|
if (direct && (!text(options.provider) || !text(options.model))) throw new Error('指定模型需要同时提供 provider 和 model。')
|
|
602
|
-
const saved = await
|
|
603
|
-
const routes = await routesWithLearning(ctx, config, options.signal, saved.runs)
|
|
673
|
+
const saved = await routingSavedState(config)
|
|
674
|
+
const routes = await routesWithLearning(ctx, config, options.signal, saved.runs, saved.dynamicData, saved.routingLearning)
|
|
604
675
|
if (direct && !routes.some(route => route.provider === text(options.provider) && route.model === text(options.model))) {
|
|
605
676
|
throw new Error(`模型 ${text(options.provider)}/${text(options.model)} 不在 Harness 模型目录中;请在模型页添加后重试,或改用自动路由。`)
|
|
606
677
|
}
|
|
@@ -618,10 +689,12 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
|
|
|
618
689
|
const preset = routingPresetOf(config, options.preset)
|
|
619
690
|
const planWith = (presetId, budgetUsd) => direct
|
|
620
691
|
? createPlanFromRoutes(taskText, routes, {
|
|
692
|
+
...planDataOptions(saved, config),
|
|
621
693
|
mode: 'direct', directProvider: options.provider, directModel: options.model,
|
|
622
694
|
installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
|
|
623
695
|
})
|
|
624
696
|
: createPlanFromRoutes(taskText, routes, {
|
|
697
|
+
...planDataOptions(saved, config),
|
|
625
698
|
mode: options.planMode === 'team' ? 'team' : 'single',
|
|
626
699
|
budgetUsd, installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
|
|
627
700
|
})
|
|
@@ -741,7 +814,8 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
|
|
|
741
814
|
if (choice && !['api', 'subscription', 'cancel'].includes(choice)) throw new Error('subscriptionChoice 只能是 api、subscription 或 cancel')
|
|
742
815
|
if (choice && !target.paused) throw new Error('该步骤没有在等待订阅失败的确认;直接重跑即可。')
|
|
743
816
|
if (choice === 'cancel') return { run: await cancelPausedStep(run.id, target.id), execution: null, budget: null, cancelled: true }
|
|
744
|
-
const
|
|
817
|
+
const refreshed = await routingSavedState(config)
|
|
818
|
+
const routes = await routesWithLearning(ctx, config, signal, refreshed.runs, refreshed.dynamicData, refreshed.routingLearning)
|
|
745
819
|
let override = null
|
|
746
820
|
if (text(provider) || text(model)) {
|
|
747
821
|
if (!text(provider) || !text(model)) throw new Error('改派需要同时提供 provider 和 model')
|
|
@@ -954,7 +1028,8 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
|
|
|
954
1028
|
if (text(args.provider) || text(args.model)) {
|
|
955
1029
|
if (!text(args.provider) || !text(args.model)) throw new Error('provider 和 model 必须同时提供')
|
|
956
1030
|
if (toolForProvider(args.provider)?.id !== toolId) throw new Error('所选模型供应商与官方 CLI 工具不匹配')
|
|
957
|
-
|
|
1031
|
+
const refreshed = await routingSavedState(config)
|
|
1032
|
+
routes = await routesWithLearning(ctx, config, signal, refreshed.runs, refreshed.dynamicData, refreshed.routingLearning)
|
|
958
1033
|
route = routes.find(item => item.provider === text(args.provider) && item.model === text(args.model)) ?? null
|
|
959
1034
|
if (!route) throw new Error('所选 provider/model 不在官方模型目录中')
|
|
960
1035
|
modelId = route.cliModel && toolId !== 'zcode'
|
|
@@ -1000,30 +1075,50 @@ export async function recordToolRun({ toolId, task, provider, model, cliModel =
|
|
|
1000
1075
|
}
|
|
1001
1076
|
|
|
1002
1077
|
/** Store a user rating (+1 useful, -1 not useful, 0 clears) for one result. */
|
|
1003
|
-
export async function rateRecordedResult({ runId, packageId, rating } = {}) {
|
|
1078
|
+
export async function rateRecordedResult({ runId, packageId, rating, expectedFinishedAt } = {}, { origin = 'local-explicit-input' } = {}) {
|
|
1004
1079
|
const value = rating === 'up' || rating === 1 ? 1 : rating === 'down' || rating === -1 ? -1 : rating === 'clear' || rating === 0 ? null : undefined
|
|
1005
1080
|
if (value === undefined) throw new Error('rating 只能是 up、down 或 clear')
|
|
1006
1081
|
return routerStateStore().updateRun(text(runId), current => {
|
|
1007
1082
|
const item = current.packages.find(entry => entry.id === text(packageId))
|
|
1008
1083
|
if (!item) throw new Error(`运行记录中没有工作包 ${text(packageId)}`)
|
|
1084
|
+
if (expectedFinishedAt !== undefined && (!Number.isFinite(expectedFinishedAt) || expectedFinishedAt !== item.finishedAt)) {
|
|
1085
|
+
throw new Error('结果已更新,请刷新执行记录后再评价。')
|
|
1086
|
+
}
|
|
1087
|
+
if (value !== null && (item.ran !== true || item.ok !== true || item.paused || item.waiting || item.blocked)) {
|
|
1088
|
+
throw new Error('只能评价已成功执行的结果;失败和暂停不自动视为不满意。')
|
|
1089
|
+
}
|
|
1009
1090
|
item.rating = value
|
|
1091
|
+
item.ratedAt = Date.now()
|
|
1092
|
+
item.feedbackPolicyVersion = 'subjective-utility-v1'
|
|
1093
|
+
item.feedbackOrigin = ['desktop-ui', 'approved-tool'].includes(origin) ? origin : 'local-explicit-input'
|
|
1010
1094
|
})
|
|
1011
1095
|
}
|
|
1012
1096
|
|
|
1013
1097
|
/** Everything the workbench shows: recent runs, spending, budget, learned biases and settings. */
|
|
1014
1098
|
export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
|
|
1015
|
-
const saved = await
|
|
1099
|
+
const saved = await routingSavedState(config)
|
|
1016
1100
|
const spent = spending(saved.runs, undefined, saved.archivedSpending)
|
|
1017
1101
|
const settings = budgetSettings(config)
|
|
1018
1102
|
return {
|
|
1019
1103
|
runs: saved.runs.slice(-limit).reverse().map(run => ({
|
|
1020
1104
|
...run,
|
|
1021
|
-
task: run.task.slice(0, 2_000),
|
|
1022
|
-
packages: run.packages.
|
|
1105
|
+
task: text(run.task).slice(0, 2_000),
|
|
1106
|
+
packages: (Array.isArray(run.packages) ? run.packages : []).filter(item => item && typeof item === 'object')
|
|
1107
|
+
.map(item => ({ ...item, answer: text(item.answer).slice(0, 1_500) })),
|
|
1023
1108
|
})),
|
|
1024
1109
|
spent,
|
|
1025
|
-
budget: { ...settings, ...budgetCheck({ estimateUsd: null, spent, ...settings }) },
|
|
1026
|
-
|
|
1110
|
+
budget: { ...settings, ...budgetCheck({ estimateUsd: null, spent, ...settings }), historyVerified: saved.historyVerified !== false },
|
|
1111
|
+
storageAvailable: saved.storageAvailable !== false,
|
|
1112
|
+
storageNotice: saved.storageAvailable === false ? '执行记录存储暂不可用;下方费用与学习摘要尚未核验,不能当作零花费或没有反馈。'
|
|
1113
|
+
: saved.historyVerified === false ? '此前执行记录已损坏或不完整,当前摘要只包含可读记录;不能视为完整费用历史,请从备份恢复后再核验预算。' : '',
|
|
1114
|
+
// Legacy scalar quality biases are intentionally no longer learned.
|
|
1115
|
+
biases: {},
|
|
1116
|
+
learning: saved.routingLearning,
|
|
1117
|
+
dynamicData: dynamicDataSummary(saved.dynamicData, { ...dynamicOptions(config), now: Date.now() }),
|
|
1118
|
+
routingData: {
|
|
1119
|
+
...routingData(saved.dynamicData, dynamicOptions(config).enabled),
|
|
1120
|
+
pricingSnapshot: dynamicOptions(config).enabled ? saved.dynamicData?.pricing ?? null : null,
|
|
1121
|
+
},
|
|
1027
1122
|
onboarding: saved.onboarding,
|
|
1028
1123
|
settings: {
|
|
1029
1124
|
preset: routingPresetOf(config),
|
|
@@ -1308,7 +1403,7 @@ export function routerRemoteServices(ctx, config, { terminals = null } = {}) {
|
|
|
1308
1403
|
},
|
|
1309
1404
|
completeOnboarding: async () => routerStateStore().completeOnboarding(Date.now()),
|
|
1310
1405
|
ledger: () => ledgerSummary(config),
|
|
1311
|
-
rate: request => rateRecordedResult(request),
|
|
1406
|
+
rate: request => rateRecordedResult(request, { origin: 'desktop-ui' }),
|
|
1312
1407
|
rerun: request => rerunRecordedStep(ctx, config, {
|
|
1313
1408
|
runId: request?.runId, packageId: request?.packageId, provider: request?.provider, model: request?.model,
|
|
1314
1409
|
confirmOverBudget: request?.confirmOverBudget === true, confirmWrite: request?.confirmWrite === true,
|
|
@@ -1337,6 +1432,10 @@ export function apply(ctx, config = {}) {
|
|
|
1337
1432
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
1338
1433
|
const decision = await next()
|
|
1339
1434
|
if (decision.kind !== 'allow') return decision
|
|
1435
|
+
if (exec.name === 'model_router_rate') {
|
|
1436
|
+
return { kind: 'ask', reason: 'Confirm the user\'s subjective rating; agents must not self-rate their results',
|
|
1437
|
+
displayReason: { en: 'Save this as your rating and adjust future recommendations?', zh: '确认这是你对该结果的评价,并用于调整以后的推荐?Agent 不能代替你给自己评分。' } }
|
|
1438
|
+
}
|
|
1340
1439
|
if (exec.name === 'model_router_tool_install') {
|
|
1341
1440
|
const requested = getOfficialTool(text(exec.arguments?.tool))
|
|
1342
1441
|
const label = requested?.label ?? '官方 CLI'
|
|
@@ -1645,15 +1744,16 @@ function registerOfficialToolModels(ctx, config) {
|
|
|
1645
1744
|
}))
|
|
1646
1745
|
ctx.tools.register(defineTool({
|
|
1647
1746
|
name: 'model_router_rate',
|
|
1648
|
-
description: 'Record the user\'s rating of one recorded result (up
|
|
1747
|
+
description: 'Record only the user\'s explicitly stated rating of one recorded result (up, down, clear). Requires approval; never self-rate. Feedback adjusts bounded local subjective utility, not objective quality.',
|
|
1649
1748
|
parameters: {
|
|
1650
1749
|
runId: { type: 'string', required: true, description: 'runId returned by model_router_execute.' },
|
|
1651
1750
|
packageId: { type: 'string', required: true, description: 'Work package id.' },
|
|
1652
1751
|
rating: { type: 'string', required: true, enum: ['up', 'down', 'clear'], description: 'The user\'s rating.' },
|
|
1752
|
+
expectedFinishedAt: { type: 'number', description: 'Completion timestamp of the displayed result; rejects stale feedback after a rerun.' },
|
|
1653
1753
|
},
|
|
1654
1754
|
output: JSON_OUTPUT,
|
|
1655
1755
|
async execute(args) {
|
|
1656
|
-
const run = await rateRecordedResult(args)
|
|
1756
|
+
const run = await rateRecordedResult(args, { origin: 'approved-tool' })
|
|
1657
1757
|
return jsonValue({ ok: true, runId: run.id, packageId: args.packageId, rating: args.rating })
|
|
1658
1758
|
},
|
|
1659
1759
|
}))
|
|
@@ -1672,8 +1772,8 @@ function registerOfficialToolModels(ctx, config) {
|
|
|
1672
1772
|
const { cwd, root, sandboxMode } = await sessionWorkspace(ctx, exec)
|
|
1673
1773
|
const mode = args.mode === 'workspace-write' ? 'workspace-write' : 'read-only'
|
|
1674
1774
|
if (mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑团队执行')
|
|
1675
|
-
const
|
|
1676
|
-
const routes =
|
|
1775
|
+
const saved = await routingSavedState(config)
|
|
1776
|
+
const [routes, installed] = await Promise.all([routesWithLearning(ctx, config, exec.signal, saved.runs, saved.dynamicData, saved.routingLearning), installedToolIds()])
|
|
1677
1777
|
const readiness = await Promise.all(installed.map(id => officialToolReadiness(id, cwd)))
|
|
1678
1778
|
const supported = new Set(readiness.filter(item => item.ready).map(item => item.id))
|
|
1679
1779
|
const capabilities = new Map(officialToolExecutionCapabilities().map(item => [item.id, item]))
|
|
@@ -1688,6 +1788,7 @@ function registerOfficialToolModels(ctx, config) {
|
|
|
1688
1788
|
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
1689
1789
|
const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
|
|
1690
1790
|
const plan = createPlanFromRoutes(args.task, executableRoutes, {
|
|
1791
|
+
...planDataOptions(saved, config),
|
|
1691
1792
|
mode: 'team', budgetUsd, installedToolIds: installed,
|
|
1692
1793
|
runnableToolIds: installed.filter(id => supported.has(id)),
|
|
1693
1794
|
preset: routingPresetOf(config),
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, local subjective-utility personalization. This is not a learned
|
|
3
|
+
* answer-correctness probability, a bandit policy, or an unbiased evaluation.
|
|
4
|
+
* Only explicit human feedback on an executed, successful result is evidence.
|
|
5
|
+
* Model reviews and missing feedback are deliberately not user satisfaction.
|
|
6
|
+
*/
|
|
7
|
+
export const FEEDBACK_POLICY_VERSION = 'subjective-utility-v1'
|
|
8
|
+
export const FEEDBACK_RUN_WINDOW = 200
|
|
9
|
+
/** Cache invalidation cadence; weights themselves still use exact evaluatedAt. */
|
|
10
|
+
export const FEEDBACK_DECAY_REFRESH_MS = 3_600_000
|
|
11
|
+
|
|
12
|
+
const DAY_MS = 86_400_000
|
|
13
|
+
const finite = value => typeof value === 'number' && Number.isFinite(value)
|
|
14
|
+
const clean = value => typeof value === 'string' ? value.trim() : ''
|
|
15
|
+
const routeKey = (provider, model) => `${provider}\u0000${model}`
|
|
16
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
17
|
+
const own = (value, key) => record(value) && Object.hasOwn(value, key) ? value[key] : undefined
|
|
18
|
+
// Code-unit ordering avoids machine/browser locale changing cache identity.
|
|
19
|
+
const compare = (left, right) => left === right ? 0 : left < right ? -1 : 1
|
|
20
|
+
|
|
21
|
+
function boundedOption(options, key, fallback, minimum, maximum = Infinity) {
|
|
22
|
+
const value = options[key] === undefined ? fallback : options[key]
|
|
23
|
+
if (!finite(value) || value < minimum || value > maximum) {
|
|
24
|
+
throw new RangeError(`${key} must be a finite number in [${minimum}, ${maximum}]`)
|
|
25
|
+
}
|
|
26
|
+
return value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizedOptions(options) {
|
|
30
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('options must be an object')
|
|
31
|
+
const enabled = options.enabled === undefined ? true : options.enabled
|
|
32
|
+
if (typeof enabled !== 'boolean') throw new TypeError('enabled must be boolean')
|
|
33
|
+
return {
|
|
34
|
+
enabled,
|
|
35
|
+
now: boundedOption(options, 'now', Date.now(), 0),
|
|
36
|
+
halfLifeDays: boundedOption(options, 'halfLifeDays', 30, 1, 3650),
|
|
37
|
+
priorWeight: boundedOption(options, 'priorWeight', 3, 1, 1_000_000),
|
|
38
|
+
maxAdjustment: boundedOption(options, 'maxAdjustment', 0.04, 0, 0.1),
|
|
39
|
+
resetAt: boundedOption(options, 'resetAt', 0, 0),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** FNV-1a over JS UTF-16 code units: a short cache identity, NOT a security hash. */
|
|
44
|
+
function cacheRevision(value) {
|
|
45
|
+
const serialized = JSON.stringify(value)
|
|
46
|
+
let hash = 0x811c9dc5
|
|
47
|
+
for (let index = 0; index < serialized.length; index += 1) {
|
|
48
|
+
hash = Math.imul(hash ^ serialized.charCodeAt(index), 0x01000193) >>> 0
|
|
49
|
+
}
|
|
50
|
+
return `${FEEDBACK_POLICY_VERSION}:${hash.toString(16).padStart(8, '0')}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function completedAt(run, item) {
|
|
54
|
+
// Stored packages have their own completion time. A historical run may have
|
|
55
|
+
// only a run-level completion timestamp, then its creation timestamp.
|
|
56
|
+
// Invalid explicitly supplied timestamps are not repaired by a fallback.
|
|
57
|
+
for (const candidate of [own(item, 'finishedAt'), own(run, 'finishedAt'), own(run, 'createdAt')]) {
|
|
58
|
+
if (candidate === undefined || candidate === null) continue
|
|
59
|
+
return finite(candidate) && candidate >= 0 ? candidate : null
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function evidenceOf(run, item, options) {
|
|
65
|
+
const reject = reason => ({ event: null, reason })
|
|
66
|
+
const provider = clean(own(item, 'provider'))
|
|
67
|
+
const model = clean(own(item, 'model'))
|
|
68
|
+
if (!provider || !model || provider.includes('\u0000') || model.includes('\u0000')) return reject('invalid-route')
|
|
69
|
+
if (own(item, 'ran') !== true || own(item, 'ok') !== true
|
|
70
|
+
|| item.blocked === true || item.waiting === true || item.paused === true
|
|
71
|
+
|| item.cancelled === true || (item.status !== undefined && !['succeeded', 'fallback'].includes(item.status))) return reject('not-successful')
|
|
72
|
+
const rating = own(item, 'rating')
|
|
73
|
+
if (rating !== 1 && rating !== -1) return reject(rating === undefined || rating === null || rating === 0 ? 'unrated' : 'invalid-rating')
|
|
74
|
+
// A CLI default or a different actual model must not train the recommended
|
|
75
|
+
// model. Aliases are not silently equated; a future verified adapter may do so.
|
|
76
|
+
if (item.channel === 'official-cli' && clean(own(item, 'actualModel')) !== model) return reject('cli-model-unverified')
|
|
77
|
+
const finishedAt = completedAt(run, item)
|
|
78
|
+
if (finishedAt === null) return reject('invalid-time')
|
|
79
|
+
if (finishedAt > options.now) return reject('future-time')
|
|
80
|
+
if (finishedAt <= options.resetAt) return reject('before-reset')
|
|
81
|
+
const suppliedRatedAt = own(item, 'ratedAt')
|
|
82
|
+
if (suppliedRatedAt !== undefined && suppliedRatedAt !== null) {
|
|
83
|
+
if (!finite(suppliedRatedAt) || suppliedRatedAt < finishedAt) return reject('invalid-time')
|
|
84
|
+
if (suppliedRatedAt > options.now) return reject('future-time')
|
|
85
|
+
}
|
|
86
|
+
return { reason: null, event: {
|
|
87
|
+
runId: own(run, 'id'),
|
|
88
|
+
packageId: own(item, 'id'),
|
|
89
|
+
provider,
|
|
90
|
+
model,
|
|
91
|
+
domain: clean(own(item, 'type')) || 'general',
|
|
92
|
+
rating,
|
|
93
|
+
finishedAt,
|
|
94
|
+
ratedAt: suppliedRatedAt ?? finishedAt,
|
|
95
|
+
} }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build a pure profile from the newest 200 runs under one local DSH home.
|
|
100
|
+
* Last occurrence of the same run/package replaces earlier occurrences BEFORE
|
|
101
|
+
* validation: a clear, retry, or invalid latest result removes old evidence.
|
|
102
|
+
* Existing metadata without ran/ok/identifiers/timestamps is not guessed.
|
|
103
|
+
*
|
|
104
|
+
* In one exact provider/model/domain cell:
|
|
105
|
+
* w_i = 2 ** (-(now - completedAt_i) / halfLifeMs)
|
|
106
|
+
* bias = maxAdjustment * sum(w_i * rating_i) / (priorWeight + sum(w_i))
|
|
107
|
+
* Ratings are revised in place, never extra independent observations. Decay
|
|
108
|
+
* uses result completion, so re-rating an old result cannot refresh its age or
|
|
109
|
+
* bypass resetAt. No cross-domain/model-version/user backoff is performed.
|
|
110
|
+
*/
|
|
111
|
+
export function buildFeedbackProfile(runs, options = {}) {
|
|
112
|
+
const settings = normalizedOptions(options)
|
|
113
|
+
const profile = {
|
|
114
|
+
enabled: settings.enabled,
|
|
115
|
+
scope: 'local-dsh-home',
|
|
116
|
+
policyVersion: FEEDBACK_POLICY_VERSION,
|
|
117
|
+
feedbackCount: 0,
|
|
118
|
+
ignoredCount: 0,
|
|
119
|
+
effectiveWeight: 0,
|
|
120
|
+
updatedAt: null,
|
|
121
|
+
halfLifeDays: settings.halfLifeDays,
|
|
122
|
+
priorWeight: settings.priorWeight,
|
|
123
|
+
maxAdjustment: settings.maxAdjustment,
|
|
124
|
+
resetAt: settings.resetAt,
|
|
125
|
+
window: 'last-200-runs',
|
|
126
|
+
adjustments: {},
|
|
127
|
+
cellStats: {},
|
|
128
|
+
exclusionReasons: {},
|
|
129
|
+
evaluatedAt: settings.now,
|
|
130
|
+
decayRefreshMs: FEEDBACK_DECAY_REFRESH_MS,
|
|
131
|
+
decayEpoch: null,
|
|
132
|
+
evidenceRevision: '',
|
|
133
|
+
revisionSemantics: 'evidence-settings-and-hourly-decay-cache-identity-not-security-hash',
|
|
134
|
+
revision: '',
|
|
135
|
+
}
|
|
136
|
+
const exclude = reason => {
|
|
137
|
+
profile.ignoredCount += 1
|
|
138
|
+
profile.exclusionReasons[reason] = (profile.exclusionReasons[reason] ?? 0) + 1
|
|
139
|
+
}
|
|
140
|
+
const latest = new Map()
|
|
141
|
+
if (settings.enabled) {
|
|
142
|
+
for (const run of (Array.isArray(runs) ? runs : []).slice(-FEEDBACK_RUN_WINDOW)) {
|
|
143
|
+
if (!record(run) || (run.packages !== undefined && !Array.isArray(own(run, 'packages')))) {
|
|
144
|
+
exclude('invalid-record')
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
for (const item of Array.isArray(own(run, 'packages')) ? run.packages : []) {
|
|
148
|
+
if (!record(item)) {
|
|
149
|
+
exclude('invalid-record')
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (!clean(own(run, 'id')) || !clean(own(item, 'id'))) {
|
|
153
|
+
exclude('missing-identity')
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const identity = JSON.stringify([run.id, item.id])
|
|
157
|
+
if (latest.has(identity)) exclude('superseded')
|
|
158
|
+
latest.set(identity, { run, item })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const events = []
|
|
163
|
+
for (const { run, item } of latest.values()) {
|
|
164
|
+
const result = evidenceOf(run, item, settings)
|
|
165
|
+
if (result.event) events.push(result.event)
|
|
166
|
+
else exclude(result.reason)
|
|
167
|
+
}
|
|
168
|
+
events.sort((left, right) => compare(JSON.stringify([left.runId, left.packageId]), JSON.stringify([right.runId, right.packageId])))
|
|
169
|
+
const cells = new Map()
|
|
170
|
+
for (const event of events) {
|
|
171
|
+
const weight = 2 ** (-(settings.now - event.finishedAt) / (settings.halfLifeDays * DAY_MS))
|
|
172
|
+
const key = routeKey(event.provider, event.model)
|
|
173
|
+
const domains = cells.get(key) ?? new Map()
|
|
174
|
+
const cell = domains.get(event.domain) ?? { sum: 0, weight: 0, count: 0, positive: 0, negative: 0,
|
|
175
|
+
weightSquaredSum: 0, maxWeight: 0, scaledWeightSum: 0, scaledWeightSquares: 0 }
|
|
176
|
+
cell.sum += weight * event.rating
|
|
177
|
+
cell.weight += weight
|
|
178
|
+
cell.count += 1
|
|
179
|
+
if (event.rating === 1) cell.positive += 1
|
|
180
|
+
else cell.negative += 1
|
|
181
|
+
cell.weightSquaredSum += weight * weight
|
|
182
|
+
// Rescaling prevents old tiny weights squared from underflowing Kish ESS.
|
|
183
|
+
if (weight > cell.maxWeight) {
|
|
184
|
+
const scale = cell.maxWeight / weight
|
|
185
|
+
cell.scaledWeightSum = cell.scaledWeightSum * scale + 1
|
|
186
|
+
cell.scaledWeightSquares = cell.scaledWeightSquares * scale * scale + 1
|
|
187
|
+
cell.maxWeight = weight
|
|
188
|
+
} else if (weight > 0) {
|
|
189
|
+
const scaled = weight / cell.maxWeight
|
|
190
|
+
cell.scaledWeightSum += scaled
|
|
191
|
+
cell.scaledWeightSquares += scaled * scaled
|
|
192
|
+
}
|
|
193
|
+
domains.set(event.domain, cell)
|
|
194
|
+
cells.set(key, domains)
|
|
195
|
+
profile.feedbackCount += 1
|
|
196
|
+
profile.effectiveWeight += weight
|
|
197
|
+
profile.updatedAt = Math.max(profile.updatedAt ?? 0, event.ratedAt)
|
|
198
|
+
}
|
|
199
|
+
// Object.fromEntries safely creates own keys even for opaque identifiers
|
|
200
|
+
// such as '__proto__'; no task text or answer is copied into this profile.
|
|
201
|
+
const sortedCells = [...cells.entries()].sort(([left], [right]) => compare(left, right))
|
|
202
|
+
.map(([key, domains]) => [key, [...domains.entries()].sort(([left], [right]) => compare(left, right))])
|
|
203
|
+
const adjustment = cell => Math.max(-settings.maxAdjustment, Math.min(settings.maxAdjustment,
|
|
204
|
+
settings.maxAdjustment * cell.sum / (settings.priorWeight + cell.weight)))
|
|
205
|
+
profile.adjustments = Object.fromEntries(sortedCells.map(([key, domains]) => [key,
|
|
206
|
+
Object.fromEntries(domains.map(([domain, cell]) => [domain, adjustment(cell)]))]))
|
|
207
|
+
profile.cellStats = Object.fromEntries(sortedCells.map(([key, domains]) => [key,
|
|
208
|
+
Object.fromEntries(domains.map(([domain, cell]) => [domain, {
|
|
209
|
+
feedbackCount: cell.count, positiveCount: cell.positive, negativeCount: cell.negative,
|
|
210
|
+
weightedRatingSum: cell.sum, effectiveWeight: cell.weight, weightSquaredSum: cell.weightSquaredSum,
|
|
211
|
+
// Keep machine output within the mathematical bound despite ULP rounding.
|
|
212
|
+
effectiveSampleSize: cell.scaledWeightSquares > 0
|
|
213
|
+
? Math.min(cell.count, Math.max(0, cell.scaledWeightSum ** 2 / cell.scaledWeightSquares)) : 0,
|
|
214
|
+
shrinkage: cell.weight / (settings.priorWeight + cell.weight), adjustment: adjustment(cell),
|
|
215
|
+
semantics: 'observed-feedback-weight-diagnostics-not-confidence-or-correctness',
|
|
216
|
+
}]))]))
|
|
217
|
+
const { now: evaluatedAt, ...evidenceSettings } = settings
|
|
218
|
+
const identityEvents = events.map(({ ratedAt, ...event }) => event)
|
|
219
|
+
profile.evidenceRevision = cacheRevision({ settings: evidenceSettings, events: identityEvents })
|
|
220
|
+
profile.decayEpoch = events.length > 0 && settings.maxAdjustment > 0
|
|
221
|
+
? Math.floor(evaluatedAt / FEEDBACK_DECAY_REFRESH_MS) : null
|
|
222
|
+
// A decision cache identity, intentionally not the hash of every continuously
|
|
223
|
+
// changing weight. Empty evidence and audit-only re-rating do not erase plans;
|
|
224
|
+
// active preferences invalidate once/hour so long-term decay keeps updating.
|
|
225
|
+
profile.revision = cacheRevision({ evidenceRevision: profile.evidenceRevision, decayEpoch: profile.decayEpoch })
|
|
226
|
+
return profile
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Add separate preference metadata; never overwrite benchmark quality/source.
|
|
231
|
+
* Unknown/catalog quality remains unknown/catalog evidence after applying this
|
|
232
|
+
* profile. The planner must consume the domain-specific adjustment explicitly.
|
|
233
|
+
*/
|
|
234
|
+
export function applyFeedbackProfile(routes, profile) {
|
|
235
|
+
if (!Array.isArray(routes) || !record(profile) || typeof profile.enabled !== 'boolean') return routes
|
|
236
|
+
return routes.map(route => {
|
|
237
|
+
if (!record(route)) return route
|
|
238
|
+
let base = route
|
|
239
|
+
if (Object.hasOwn(route, 'preferenceAdjustments') || Object.hasOwn(route, 'preferenceAdjustmentMeta')) {
|
|
240
|
+
base = { ...route }
|
|
241
|
+
delete base.preferenceAdjustments
|
|
242
|
+
delete base.preferenceAdjustmentMeta
|
|
243
|
+
}
|
|
244
|
+
if (profile.enabled !== true || !record(profile.adjustments)) return base
|
|
245
|
+
const key = routeKey(clean(route.provider), clean(route.model))
|
|
246
|
+
if (!Object.hasOwn(profile.adjustments, key)) return base
|
|
247
|
+
const value = profile.adjustments[key]
|
|
248
|
+
if (!record(value)) return base
|
|
249
|
+
const limit = finite(profile.maxAdjustment) && profile.maxAdjustment >= 0 && profile.maxAdjustment <= 0.1 ? profile.maxAdjustment : 0.1
|
|
250
|
+
const adjustments = Object.fromEntries(Object.entries(value).filter(([, bias]) => finite(bias) && Math.abs(bias) <= limit))
|
|
251
|
+
if (!Object.keys(adjustments).length) return base
|
|
252
|
+
return {
|
|
253
|
+
...base,
|
|
254
|
+
preferenceAdjustments: adjustments,
|
|
255
|
+
preferenceAdjustmentMeta: {
|
|
256
|
+
policyVersion: profile.policyVersion,
|
|
257
|
+
revision: profile.revision,
|
|
258
|
+
scope: profile.scope,
|
|
259
|
+
feedbackCount: profile.feedbackCount,
|
|
260
|
+
effectiveWeight: profile.effectiveWeight,
|
|
261
|
+
updatedAt: profile.updatedAt,
|
|
262
|
+
halfLifeDays: profile.halfLifeDays,
|
|
263
|
+
maxAdjustment: profile.maxAdjustment,
|
|
264
|
+
window: profile.window,
|
|
265
|
+
cellStats: Object.hasOwn(profile.cellStats ?? {}, key) ? profile.cellStats[key] : {},
|
|
266
|
+
evaluatedAt: profile.evaluatedAt,
|
|
267
|
+
decayRefreshMs: profile.decayRefreshMs,
|
|
268
|
+
revisionSemantics: profile.revisionSemantics,
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic budget-constrained DAG assignment, independent of model names.
|
|
3
|
+
* Exact enumeration is bounded by the retained Cartesian candidate space;
|
|
4
|
+
* larger spaces use a beam with a protected least-cost feasible prefix.
|
|
5
|
+
* Optimality concerns this surrogate objective, never actual answer quality.
|
|
6
|
+
*/
|
|
7
|
+
const EPSILON = 1e-12
|
|
8
|
+
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
9
|
+
const signature = state => JSON.stringify(state.choices.map(option => option.route))
|
|
10
|
+
|
|
11
|
+
function compareStates(left, right, minimizeCost = false) {
|
|
12
|
+
return left.relaxedCount - right.relaxedCount
|
|
13
|
+
|| left.qualityShortfall - right.qualityShortfall
|
|
14
|
+
|| (minimizeCost ? left.cost - right.cost : right.score - left.score)
|
|
15
|
+
|| (minimizeCost ? right.score - left.score : left.cost - right.cost)
|
|
16
|
+
|| left.switches - right.switches
|
|
17
|
+
|| compareText(signature(left), signature(right))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function solveCandidateAssignments({ tasks = [], pools = [], budget = Infinity, beamWidth = 256, exactLimit = 4096, handoffPenalty = 0.015, minimizeCost = false } = {}) {
|
|
21
|
+
if (!tasks.length || tasks.length !== pools.length) return null
|
|
22
|
+
const preceding = new Set()
|
|
23
|
+
for (const task of tasks) {
|
|
24
|
+
if (!task.id || preceding.has(task.id) || (task.dependsOn ?? []).some(id => !preceding.has(id))) {
|
|
25
|
+
throw new RangeError('Assignment tasks must have unique ids and known dependencies in topological order')
|
|
26
|
+
}
|
|
27
|
+
preceding.add(task.id)
|
|
28
|
+
}
|
|
29
|
+
const constrained = Number.isFinite(budget)
|
|
30
|
+
const options = pools.map(pool => (Array.isArray(pool) ? pool : []).filter(option =>
|
|
31
|
+
Number.isFinite(option.cost) && option.cost >= 0 && Number.isFinite(option.score)
|
|
32
|
+
&& (!constrained || option.pricingKnown === true)))
|
|
33
|
+
if (options.some(pool => pool.length === 0)) return null
|
|
34
|
+
const suffixMinimum = Array(tasks.length + 1).fill(0)
|
|
35
|
+
let searchSpace = 1
|
|
36
|
+
let searchSpaceCapped = false
|
|
37
|
+
for (let index = tasks.length - 1; index >= 0; index--) {
|
|
38
|
+
suffixMinimum[index] = suffixMinimum[index + 1] + Math.min(...options[index].map(option => option.cost))
|
|
39
|
+
const product = searchSpace * options[index].length
|
|
40
|
+
if (product > Number.MAX_SAFE_INTEGER) searchSpaceCapped = true
|
|
41
|
+
searchSpace = Math.min(Number.MAX_SAFE_INTEGER, product)
|
|
42
|
+
}
|
|
43
|
+
if (constrained && suffixMinimum[0] > budget + EPSILON) return null
|
|
44
|
+
const exact = !searchSpaceCapped && searchSpace <= Math.max(1, exactLimit)
|
|
45
|
+
const width = Math.max(1, Math.floor(beamWidth) || 256)
|
|
46
|
+
const search = { method: exact ? 'exact' : 'beam', exact, searchSpace, searchSpaceCapped, expandedStates: 0, beamPruned: 0 }
|
|
47
|
+
let states = [{ choices: [], routesByTask: new Map(), score: 0, cost: 0, switches: 0, relaxedCount: 0, qualityShortfall: 0 }]
|
|
48
|
+
for (let index = 0; index < tasks.length; index++) {
|
|
49
|
+
const task = tasks[index]
|
|
50
|
+
const expanded = []
|
|
51
|
+
for (const state of states) {
|
|
52
|
+
for (const option of options[index]) {
|
|
53
|
+
const cost = state.cost + option.cost
|
|
54
|
+
if (constrained && cost + suffixMinimum[index + 1] > budget + EPSILON) continue
|
|
55
|
+
const switches = (task.dependsOn ?? []).reduce((count, dependency) => {
|
|
56
|
+
const route = state.routesByTask.get(dependency)
|
|
57
|
+
return count + (route !== undefined && route !== option.route ? 1 : 0)
|
|
58
|
+
}, 0)
|
|
59
|
+
const penalty = switches * handoffPenalty
|
|
60
|
+
const shortfall = Math.max(0, Number(option.qualityShortfall) || 0)
|
|
61
|
+
const routesByTask = new Map(state.routesByTask)
|
|
62
|
+
routesByTask.set(task.id, option.route)
|
|
63
|
+
expanded.push({ choices: [...state.choices, { ...option, handoffPenalty: penalty }], routesByTask,
|
|
64
|
+
score: state.score + option.score - penalty, cost, switches: state.switches + switches,
|
|
65
|
+
relaxedCount: state.relaxedCount + (shortfall > 0 ? 1 : 0), qualityShortfall: state.qualityShortfall + shortfall })
|
|
66
|
+
search.expandedStates++
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!expanded.length) return null
|
|
70
|
+
expanded.sort((left, right) => compareStates(left, right, minimizeCost))
|
|
71
|
+
if (!exact && expanded.length > width) {
|
|
72
|
+
// A high-utility prefix must not crowd out every affordable completion.
|
|
73
|
+
// This also works at width=1; quality relaxation remains lexicographic.
|
|
74
|
+
states = expanded.slice(0, width)
|
|
75
|
+
if (constrained && index < tasks.length - 1) {
|
|
76
|
+
const cheapest = expanded.reduce((best, state) => compareStates(state, best, true) < 0 ? state : best)
|
|
77
|
+
if (!states.includes(cheapest)) states[width - 1] = cheapest
|
|
78
|
+
}
|
|
79
|
+
search.beamPruned += expanded.length - states.length
|
|
80
|
+
} else states = expanded
|
|
81
|
+
}
|
|
82
|
+
states.sort((left, right) => compareStates(left, right, minimizeCost))
|
|
83
|
+
const best = states[0]
|
|
84
|
+
const { routesByTask, ...result } = best
|
|
85
|
+
return { ...result, minimumCostLowerBound: suffixMinimum[0], search }
|
|
86
|
+
}
|