@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.
@@ -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
- applyQualityBiases, budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
17
- routeQualityBiases, spending, storedResults, actualCost, teamExecutionResults,
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 { return await routerStateStore().read() }
173
- catch { return { onboarding: { completedAt: null }, health: null, runs: [] } }
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
- return routes.find(route => route.provider === item?.provider && route.model === item?.model)?.pricing ?? null
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
- /** Routes with user profiles and the gentle quality bias learned from ratings and reviews. */
357
- async function routesWithLearning(ctx, config, signal, runs) {
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
- return applyQualityBiases(configuredRoutesWithProfiles(discovered, config), routeQualityBiases(runs))
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 savedState()
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 savedState()
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 routes = await routesWithLearning(ctx, config, signal, saved.runs)
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
- routes = configuredRoutesWithProfiles(await discoverConfiguredRoutes(ctx, signal), config)
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 savedState()
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.map(item => ({ ...item, answer: item.answer.slice(0, 1_500) })),
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
- biases: routeQualityBiases(saved.runs),
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 = useful, down = not useful, clear). Ratings gently adjust future routing for that exact provider/model.',
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 [discoveredRoutes, installed] = await Promise.all([discoverConfiguredRoutes(ctx, exec.signal), installedToolIds()])
1676
- const routes = configuredRoutesWithProfiles(discoveredRoutes, config)
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
+ }