@ljwei-stak/dsh-model-router 0.15.0 → 0.16.0

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.
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { DEFAULT_ROUTING_PRESET, normalizeRoutingPreset, presetFloor, presetWeights } from './routing-presets.mjs'
10
10
  import { liveBenchRow } from './livebench.mjs'
11
+ import { solveCandidateAssignments } from './assignment-solver.mjs'
11
12
 
12
13
  export const OBJECTIVE_WEIGHTS = Object.freeze({
13
14
  simple: Object.freeze({ quality: 0.28, cost: 0.45, latency: 0.14, specialty: 0.04, reasoning: 0.07, risk: 0.02 }),
@@ -194,11 +195,17 @@ function specialtyMatch(model, taskType, liveScores = {}) {
194
195
  }
195
196
 
196
197
  function qualityForTask(row, taskType) {
197
- const base = asScore(row?.liveScores?.[taskType]) ?? asScore(row?.liveOverall) ?? row?.metadata?.quality ?? row?.quality ?? 0
198
- // User ratings and reviews nudge a route by at most a few points.
198
+ const base = asScore(row?.liveScores?.[taskType]) ?? asScore(row?.liveOverall) ?? row?.metadata?.quality ?? row?.baseQuality ?? row?.quality ?? 0
199
+ // Legacy programmatic scalar bias only. Production feedback uses an
200
+ // independent preferenceAdjustments utility term, never this quality path.
199
201
  return row?.qualityBias ? clamp(base + row.qualityBias) : base
200
202
  }
201
203
 
204
+ function qualitySourceForTask(row, taskType) {
205
+ return asScore(row?.liveScores?.[taskType]) !== undefined || asScore(row?.liveOverall) !== undefined
206
+ ? 'livebench' : row?.fallbackQualitySource ?? row?.qualitySource ?? 'unknown'
207
+ }
208
+
202
209
  function specialtyForTask(row, taskType) {
203
210
  return specialtyMatch(row.metadata, taskType, row.liveScores)
204
211
  }
@@ -258,23 +265,9 @@ function normalizedCacheRatios(cacheReadRatio = 0, cacheWriteRatio = 0) {
258
265
  return { read, write }
259
266
  }
260
267
 
261
- function effectivePricing(pricing, cacheReadRatio = 0, cacheWriteRatio = 0) {
262
- const { read, write } = normalizedCacheRatios(cacheReadRatio, cacheWriteRatio)
263
- return {
264
- input: (1 - read - write) * Number(pricing.input)
265
- + read * Number(pricing.cacheRead)
266
- + write * Number(pricing.cacheWrite),
267
- output: Number(pricing.output),
268
- }
269
- }
270
-
271
- function costScore(pricing, maxCost, cacheReadRatio = 0, cacheWriteRatio = 0) {
272
- if (pricing === null) return 0
273
- const effective = effectivePricing(pricing, cacheReadRatio, cacheWriteRatio)
274
- const mean = (effective.input + effective.output) / 2
275
- if (maxCost <= 0) return mean === 0 ? 1 : 0
276
- return clamp(1 - mean / maxCost)
277
- }
268
+ // A preference scale, not a vendor quote. Independent of the configured pool,
269
+ // so adding an irrelevant expensive model cannot flatten every cost penalty.
270
+ export const COST_REFERENCE_PRICING = Object.freeze({ input: 1, output: 5, cacheRead: 1, cacheWrite: 1, currency: 'USD' })
278
271
 
279
272
  export function estimateCost(model, text, outputTokens = 900, pricingOverrides = {}, cacheReadRatio = 0, cacheWriteRatio = 0) {
280
273
  const pricing = pricingFor(model, pricingOverrides)
@@ -679,38 +672,26 @@ function reasoningDecision(row, task) {
679
672
  }
680
673
  }
681
674
 
682
- function candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRatio = 0, cacheWriteRatio = 0) {
675
+ function candidateUtility(row, task, weights, cacheReadRatio = 0, cacheWriteRatio = 0, text = '', complexity = 'balanced') {
683
676
  const quality = qualityForTask(row, task.type)
677
+ // Subjective satisfaction is an independent, bounded utility term. It
678
+ // must never raise benchmark quality, quality floors or evidence labels.
679
+ const rawPreference = row.preferenceAdjustments?.[task.type]
680
+ const preferenceAdjustment = Number.isFinite(rawPreference) ? clamp(rawPreference, -0.1, 0.1) : 0
684
681
  const floor = Number(task.qualityFloor ?? taskQualityFloor('complex', task))
685
682
  const qualityGap = Math.max(0, floor - quality)
686
- // Reuse avoids handoff overhead and is preferable when the same affordable
687
- // route is suitable for independent, low-risk work packages.
688
- const duplicatePenalty = 0
689
- const synthesisPreference = task.purpose === 'synthesis' && /deepseek[- ]?v4[- ]?pro/i.test(row.model) ? 0.025 : 0
690
683
  const reasoning = reasoningDecision(row, task)
691
- const cost = clamp(costScore(row.pricing, maxCost, cacheReadRatio, cacheWriteRatio) / Math.sqrt(reasoning.multiplier.output))
684
+ const reference = taskCost({ pricing: COST_REFERENCE_PRICING, reasoningKnown: true, reasoningEfforts: [] }, task, text, complexity, cacheReadRatio, cacheWriteRatio)
685
+ const cost = row.pricing === null ? 0 : 1 / (1 + taskCost(row, task, text, complexity, cacheReadRatio, cacheWriteRatio) / Math.max(reference, 1e-12))
692
686
  const score = weights.quality * quality
693
687
  + weights.cost * cost
694
688
  + weights.latency * (1 - clamp(row.latency * reasoning.multiplier.latency))
695
689
  + weights.specialty * specialtyForTask(row, task.type)
696
690
  + (weights.reasoning ?? 0) * reasoning.reasoningFit
697
691
  - weights.risk * row.risk
698
- - duplicatePenalty
699
692
  - qualityGap * (task.criticality ?? 0.75)
700
- + synthesisPreference
701
- return { score, floor, qualityGap, ...reasoning }
702
- }
703
-
704
- function chooseAssignment(rows, task, weights, maxCost, usedRoutes, preferred, cacheReadRatio = 0, cacheWriteRatio = 0) {
705
- const ordered = rows
706
- .map(row => ({ row, decision: candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRatio, cacheWriteRatio) }))
707
- .sort((left, right) => right.decision.score - left.decision.score)
708
- const feasible = ordered.filter(item => qualityForTask(item.row, task.type) >= item.decision.floor)
709
- const chosen = (preferred === true ? feasible : feasible.filter(item => !usedRoutes.has(routeKey(item.row.provider, item.row.model))))[0]
710
- ?? feasible[0]
711
- ?? ordered[0]
712
- if (chosen === undefined) return { row: null, relaxed: true, floor: 0, qualityGap: 1 }
713
- return { row: chosen.row, relaxed: qualityForTask(chosen.row, task.type) < chosen.decision.floor, floor: chosen.decision.floor, qualityGap: chosen.decision.qualityGap }
693
+ + preferenceAdjustment
694
+ return { score, floor, qualityGap, preferenceAdjustment, ...reasoning }
714
695
  }
715
696
 
716
697
  function taskCost(row, task, text, complexity, cacheReadRatio = 0, cacheWriteRatio = 0) {
@@ -760,10 +741,14 @@ function dominates(left, right, task, text, complexity, cacheReadRatio, cacheWri
760
741
  return noWorse && strictlyBetter
761
742
  }
762
743
 
763
- function candidatePool(rows, task, weights, maxCost, text, complexity, cacheReadRatio, cacheWriteRatio) {
764
- const eligibleRows = task.type === 'vision'
744
+ function eligibleRowsForTask(rows, task) {
745
+ return task.type === 'vision'
765
746
  ? rows.filter(row => row.inputModalities.length === 0 || row.inputModalities.includes('image'))
766
747
  : rows
748
+ }
749
+
750
+ function candidatePool(rows, task, weights, text, complexity, cacheReadRatio, cacheWriteRatio, dependencySensitive = false) {
751
+ const eligibleRows = eligibleRowsForTask(rows, task)
767
752
  const floor = Number(task.qualityFloor ?? 0)
768
753
  const feasible = eligibleRows.filter(row => qualityForTask(row, task.type) >= floor)
769
754
  const source = feasible.length > 0
@@ -772,109 +757,60 @@ function candidatePool(rows, task, weights, maxCost, text, complexity, cacheRead
772
757
  const taskWeights = weightsForTask(weights, task)
773
758
  const scored = source.map(row => ({
774
759
  row,
775
- decision: candidateUtility(row, task, taskWeights, maxCost, new Set(), cacheReadRatio, cacheWriteRatio),
760
+ decision: candidateUtility(row, task, taskWeights, cacheReadRatio, cacheWriteRatio, text, complexity),
776
761
  cost: taskCost(row, task, text, complexity, cacheReadRatio, cacheWriteRatio),
777
762
  }))
778
- const frontier = scored.filter(item => !source.some(other => other !== item.row && dominates(other, item.row, task, text, complexity, cacheReadRatio, cacheWriteRatio)))
763
+ // Local dominance is unsafe when route identity changes a later handoff.
764
+ // Even without dependencies, a pruning proof must preserve actual utility
765
+ // (including route-specific preferences), not just nominal score axes.
766
+ const frontier = dependencySensitive ? scored : scored.filter(item => !scored.some(other => other !== item
767
+ && other.decision.score >= item.decision.score
768
+ && dominates(other.row, item.row, task, text, complexity, cacheReadRatio, cacheWriteRatio)))
779
769
  const essential = [
780
- scored.slice().sort((left, right) => left.cost - right.cost || compareRowsStable(left.row, right.row))[0],
770
+ scored.filter(item => item.row.pricing !== null).sort((left, right) => left.cost - right.cost || compareRowsStable(left.row, right.row))[0],
781
771
  scored.slice().sort((left, right) => right.decision.score - left.decision.score || compareRowsStable(left.row, right.row))[0],
782
772
  scored.slice().sort((left, right) => qualityForTask(right.row, task.type) - qualityForTask(left.row, task.type) || compareRowsStable(left.row, right.row))[0],
783
773
  ].filter(Boolean)
784
- const ordered = [...frontier, ...essential]
774
+ const all = [...frontier, ...essential]
785
775
  .filter((item, index, all) => all.findIndex(candidate => candidate.row === item.row) === index)
786
776
  .sort((left, right) => right.decision.score - left.decision.score || left.cost - right.cost || compareRowsStable(left.row, right.row))
777
+ const anchors = [...new Set(essential)]
778
+ // Reserve anchors before truncation: adding expensive high-score routes
779
+ // must never remove the only budget-feasible, quality-eligible candidate.
780
+ const ordered = [...anchors, ...all.filter(item => !anchors.includes(item))]
787
781
  .slice(0, ROUTING_CANDIDATE_LIMIT)
782
+ .sort((left, right) => right.decision.score - left.decision.score || left.cost - right.cost || compareRowsStable(left.row, right.row))
788
783
  return {
789
784
  options: ordered,
790
785
  relaxed: feasible.length === 0,
791
- pruned: Math.max(0, eligibleRows.length - ordered.length),
786
+ pruned: Math.max(0, scored.length - all.length),
787
+ filtered: Math.max(0, eligibleRows.length - source.length),
788
+ truncated: Math.max(0, all.length - ordered.length),
789
+ dependencySensitive,
792
790
  }
793
791
  }
794
792
 
795
- function stateSignature(state) {
796
- return state.assignments.map(assignment => `${routeKey(assignment.row?.provider, assignment.row?.model)}@${assignment.decision?.reasoningEffort ?? 'provider-default'}`).join('|')
797
- }
798
-
799
- function compareUtilityStates(left, right) {
800
- return left.relaxedCount - right.relaxedCount
801
- || left.qualityShortfall - right.qualityShortfall
802
- || right.score - left.score
803
- || left.cost - right.cost
804
- || left.switches - right.switches
805
- || compareText(stateSignature(left), stateSignature(right))
806
- }
807
-
808
- function compareCostStates(left, right) {
809
- return left.relaxedCount - right.relaxedCount
810
- || left.qualityShortfall - right.qualityShortfall
811
- || left.cost - right.cost
812
- || right.score - left.score
813
- || left.switches - right.switches
814
- || compareText(stateSignature(left), stateSignature(right))
815
- }
816
-
817
- function solveAssignments({ rows, tasks, weights, maxCost, text, complexity, budget, cacheReadRatio, cacheWriteRatio, minimizeCost = false }) {
818
- const pools = tasks.map(task => candidatePool(rows, task, weights, maxCost, text, complexity, cacheReadRatio, cacheWriteRatio))
819
- if (pools.some(pool => pool.options.length === 0)) return null
820
- const suffixMinimum = Array(tasks.length + 1).fill(0)
821
- for (let index = tasks.length - 1; index >= 0; index -= 1) {
822
- const costOptions = Number.isFinite(budget)
823
- ? pools[index].options.filter(option => option.row.pricing !== null)
824
- : pools[index].options
825
- if (costOptions.length === 0) return null
826
- suffixMinimum[index] = suffixMinimum[index + 1] + Math.min(...costOptions.map(option => option.cost))
827
- }
828
- if (Number.isFinite(budget) && suffixMinimum[0] > budget + 1e-12) return null
829
-
830
- let states = [{ assignments: [], routesByTask: new Map(), usedRoutes: new Set(), score: 0, cost: 0, switches: 0, relaxedCount: 0, qualityShortfall: 0 }]
831
- for (let index = 0; index < tasks.length; index += 1) {
832
- const task = tasks[index]
833
- const pool = pools[index]
834
- const expanded = []
835
- for (const state of states) {
836
- for (const option of pool.options) {
837
- if (Number.isFinite(budget) && option.row.pricing === null) continue
838
- const nextCost = state.cost + option.cost
839
- if (Number.isFinite(budget) && nextCost + suffixMinimum[index + 1] > budget + 1e-12) continue
840
- const taskWeights = weightsForTask(weights, task)
841
- const decision = candidateUtility(option.row, task, taskWeights, maxCost, state.usedRoutes, cacheReadRatio, cacheWriteRatio)
842
- const route = routeKey(option.row.provider, option.row.model)
843
- const dependencySwitches = (task.dependsOn ?? []).reduce((count, dependency) => {
844
- const dependencyRoute = state.routesByTask.get(dependency)
845
- return count + (dependencyRoute !== undefined && dependencyRoute !== route ? 1 : 0)
846
- }, 0)
847
- const handoffPenalty = dependencySwitches * 0.015
848
- const qualityShortfall = Math.max(0, decision.floor - qualityForTask(option.row, task.type))
849
- const usedRoutes = new Set(state.usedRoutes)
850
- usedRoutes.add(route)
851
- const routesByTask = new Map(state.routesByTask)
852
- routesByTask.set(task.id, route)
853
- expanded.push({
854
- assignments: [...state.assignments, { task, row: option.row, decision: { ...decision, relaxed: qualityShortfall > 0 }, estimatedCost: option.cost, handoffPenalty }],
855
- routesByTask,
856
- usedRoutes,
857
- score: state.score + decision.score - handoffPenalty,
858
- cost: nextCost,
859
- switches: state.switches + dependencySwitches,
860
- relaxedCount: state.relaxedCount + (qualityShortfall > 0 ? 1 : 0),
861
- qualityShortfall: state.qualityShortfall + qualityShortfall,
862
- })
863
- }
864
- }
865
- if (expanded.length === 0) return null
866
- expanded.sort(minimizeCost ? compareCostStates : compareUtilityStates)
867
- states = expanded.slice(0, ROUTING_BEAM_WIDTH)
868
- }
869
- states.sort(minimizeCost ? compareCostStates : compareUtilityStates)
793
+ function solveAssignments({ rows, tasks, weights, text, complexity, budget, cacheReadRatio, cacheWriteRatio, minimizeCost = false }) {
794
+ const dependencySensitive = tasks.some(task => (task.dependsOn ?? []).length > 0)
795
+ const pools = tasks.map(task => candidatePool(rows, task, weights, text, complexity, cacheReadRatio, cacheWriteRatio, dependencySensitive))
796
+ const result = solveCandidateAssignments({ tasks, pools: pools.map((pool, index) => pool.options.map(option => ({
797
+ route: routeKey(option.row.provider, option.row.model), score: option.decision.score, cost: option.cost,
798
+ qualityShortfall: Math.max(0, option.decision.floor - qualityForTask(option.row, tasks[index].type)),
799
+ pricingKnown: option.row.pricing !== null, payload: option,
800
+ }))), budget, beamWidth: ROUTING_BEAM_WIDTH, minimizeCost })
801
+ if (!result) return null
870
802
  return {
871
- ...states[0],
803
+ ...result,
804
+ assignments: result.choices.map((choice, index) => ({ task: tasks[index], row: choice.payload.row,
805
+ decision: { ...choice.payload.decision, relaxed: choice.qualityShortfall > 0 },
806
+ estimatedCost: choice.cost, handoffPenalty: choice.handoffPenalty })),
807
+ usedRoutes: new Set(result.choices.map(choice => choice.route)),
872
808
  candidatePools: pools,
873
- minimumFeasibleCost: suffixMinimum[0],
809
+ minimumFeasibleCost: result.minimumCostLowerBound,
874
810
  }
875
811
  }
876
812
 
877
- export function buildPlan({ text = '', available = [], mode = 'collective', pricing = {}, liveBench = null, liveBenchError = '', budgetUsd = 0, cacheReadRatio = 0, cacheWriteRatio = 0, preset = DEFAULT_ROUTING_PRESET } = {}) {
813
+ export function buildPlan({ text = '', available = [], mode = 'collective', pricing = {}, liveBench = null, liveBenchError = '', dataVersions = null, learning = null, budgetUsd = 0, cacheReadRatio = 0, cacheWriteRatio = 0, preset = DEFAULT_ROUTING_PRESET } = {}) {
878
814
  const presetId = normalizeRoutingPreset(preset)
879
815
  const firstLine = String(text ?? '').split(/\r?\n/u)[0].trim()
880
816
  const transformOnly = /^(?:请|帮我)?(?:总结|概括|翻译|摘要|解释)(?:以下|下列|下面|这份|这些)/u.test(firstLine)
@@ -894,18 +830,22 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
894
830
  return {
895
831
  provider: String(entry.provider ?? ''),
896
832
  model: String(entry.model ?? ''),
833
+ benchmarkModel: typeof entry.benchmarkModel === 'string' ? entry.benchmarkModel : undefined,
897
834
  reasoningEfforts: [...new Set(reasoningEfforts)],
898
835
  defaultReasoningEffort: entry.defaultReasoningEffort === undefined ? undefined : String(entry.defaultReasoningEffort),
899
836
  reasoningKnown: entry.reasoningKnown === true
900
837
  || (entry.reasoningKnown === undefined && Array.isArray(entry.reasoningEfforts)),
901
838
  quality: asScore(entry.quality),
902
839
  qualityBias: Number.isFinite(entry.qualityBias) ? clamp(entry.qualityBias, -0.05, 0.05) : 0,
840
+ preferenceAdjustments: entry.preferenceAdjustments ?? null,
903
841
  qualitySource: entry.qualitySource === 'user' ? 'user' : 'route',
904
842
  latency: asScore(entry.latency),
905
843
  risk: asScore(entry.risk),
906
844
  specialties: Array.isArray(entry.specialties) ? entry.specialties.filter(item => typeof item === 'string') : null,
907
845
  pricing: normalizePricing({ route: entry.pricing ?? entry.price }).route ?? null,
908
- pricingSource: entry.pricingSource === 'user' ? 'user' : 'route',
846
+ pricingSource: entry.pricingSource === 'user' ? 'user' : entry.pricingSource === 'dynamic' ? 'dynamic' : 'route',
847
+ pricingVersion: entry.pricingVersion ?? null,
848
+ snapshotAsOf: entry.snapshotAsOf ?? null,
909
849
  inputModalities: Array.isArray(entry.inputModalities) ? entry.inputModalities.map(item => String(item).toLowerCase()) : [],
910
850
  }
911
851
  })
@@ -922,10 +862,16 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
922
862
  ...(route.risk === undefined ? {} : { risk: route.risk }),
923
863
  ...(route.specialties === null ? {} : { specialties: route.specialties }),
924
864
  }
925
- const live = liveBenchRow(liveBench, route.model)
865
+ const live = liveBenchRow(liveBench, route.benchmarkModel ?? route.model)
926
866
  const liveScores = live?.scores ?? {}
927
- const liveOverall = asScore(live?.overall)
928
- const quality = clamp((asScore(liveScores?.[taskType]) ?? liveOverall ?? asScore(metadata.quality) ?? 0) + route.qualityBias)
867
+ // A category average cannot provide evidence for an unmeasured task.
868
+ // Legacy cached category rows have no provenance: use their task scores
869
+ // but do not promote an ambiguous overall to cross-task evidence.
870
+ const liveOverall = live?.overallSource === 'explicit' || (live?.overallSource === undefined && Object.keys(liveScores).length === 0)
871
+ ? asScore(live?.overall) : undefined
872
+ const baseQuality = asScore(metadata.quality) ?? 0
873
+ const quality = clamp((asScore(liveScores?.[taskType]) ?? liveOverall ?? baseQuality) + route.qualityBias)
874
+ const fallbackQualitySource = route.quality !== undefined ? route.qualitySource : catalog ? 'catalog-heuristic' : 'unknown'
929
875
  const qualitySource = liveOverall !== undefined || asScore(liveScores?.[taskType]) !== undefined
930
876
  ? 'livebench' : route.quality !== undefined ? route.qualitySource : catalog ? 'catalog-heuristic' : 'unknown'
931
877
  const userPrice = normalizedPrices[normalize(`${route.provider}/${route.model}`)] ?? normalizedPrices[normalize(route.model)]
@@ -940,9 +886,14 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
940
886
  model: route.model,
941
887
  metadata,
942
888
  quality,
889
+ baseQuality,
890
+ fallbackQualitySource,
943
891
  qualityBias: route.qualityBias,
892
+ preferenceAdjustments: route.preferenceAdjustments,
944
893
  qualitySource,
945
894
  pricingSource,
895
+ pricingVersion: route.pricingVersion,
896
+ snapshotAsOf: route.snapshotAsOf,
946
897
  liveScores,
947
898
  liveOverall,
948
899
  latency: metadata.latency ?? 0.5,
@@ -960,10 +911,6 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
960
911
  }, text, 900, {}, cacheReadRatio, cacheWriteRatio),
961
912
  })
962
913
  }
963
- const maxCost = Math.max(1, ...rows.filter(row => row.pricing !== null).map(row => {
964
- const effective = effectivePricing(row.pricing, cacheReadRatio, cacheWriteRatio)
965
- return effective.input + effective.output
966
- }))
967
914
  // Presets tilt each package's weights and quality floor; balanced is a no-op.
968
915
  const taskNodes = taskPackages(taskType, text, complexity.band).map(task => presetId === DEFAULT_ROUTING_PRESET ? task : {
969
916
  ...task,
@@ -978,7 +925,6 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
978
925
  rows,
979
926
  tasks: taskNodes,
980
927
  weights,
981
- maxCost,
982
928
  text,
983
929
  complexity: complexity.band,
984
930
  budget: Number.POSITIVE_INFINITY,
@@ -990,7 +936,6 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
990
936
  rows,
991
937
  tasks: taskNodes,
992
938
  weights,
993
- maxCost,
994
939
  text,
995
940
  complexity: complexity.band,
996
941
  budget,
@@ -1003,7 +948,6 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1003
948
  rows,
1004
949
  tasks: taskNodes,
1005
950
  weights,
1006
- maxCost,
1007
951
  text,
1008
952
  complexity: complexity.band,
1009
953
  budget: Number.POSITIVE_INFINITY,
@@ -1017,7 +961,8 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1017
961
  const usedRoutes = optimized?.usedRoutes ?? new Set()
1018
962
  const constraintRelaxed = (optimized?.relaxedCount ?? 0) > 0
1019
963
  for (const row of rows) {
1020
- row.score = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band] }, weights, maxCost, new Set(), cacheReadRatio, cacheWriteRatio).score
964
+ row.score = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band] }, weights, cacheReadRatio, cacheWriteRatio, text, complexity.band).score
965
+ if (row.pricing !== null && taskNodes[0]) row.estimatedCost = taskCost(row, taskNodes[0], text, complexity.band, cacheReadRatio, cacheWriteRatio)
1021
966
  }
1022
967
  rows.sort((left, right) => right.score - left.score || compareRowsStable(left, right))
1023
968
  const selectedAssignment = assignments[0]
@@ -1032,7 +977,7 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1032
977
  difficulty: task.difficulty,
1033
978
  recommended: row?.model ?? '待发现模型',
1034
979
  recommendedProvider: row?.provider ?? '',
1035
- qualitySource: row?.qualitySource ?? 'unknown',
980
+ qualitySource: qualitySourceForTask(row, task.type),
1036
981
  pricingSource: row?.pricingSource ?? 'unknown',
1037
982
  recommendedReasoningEffort: decision?.reasoningEffort,
1038
983
  preferredReasoningEffort: decision?.preferredReasoningEffort ?? task.preferredReasoningEffort,
@@ -1060,23 +1005,24 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1060
1005
  cacheWriteTokens: tokens.cacheWriteTokens,
1061
1006
  outputTokens: tokens.outputTokens,
1062
1007
  estimatedCost: row?.pricing === null || row === null ? null : Number(taskEstimate.toFixed(6)),
1063
- quality: row?.qualitySource === 'unknown' || row === null ? null : Number(qualityForTask(row, task.type).toFixed(3)),
1064
- qualitySource: row?.qualitySource ?? 'unknown',
1008
+ quality: qualitySourceForTask(row, task.type) === 'unknown' || row === null ? null : Number(qualityForTask(row, task.type).toFixed(3)),
1009
+ qualitySource: qualitySourceForTask(row, task.type),
1065
1010
  pricingSource: row?.pricingSource ?? 'unknown',
1066
1011
  handoffPenalty: Number(Number(handoffPenalty ?? 0).toFixed(3)),
1067
1012
  }
1068
1013
  })
1069
1014
  const pricingComplete = assignments.length > 0 && assignments.every(({ row }) => row?.pricing !== null)
1070
- const totalEstimate = pricingComplete ? costBreakdown.reduce((sum, row) => sum + row.estimatedCost, 0) : null
1015
+ const totalEstimate = pricingComplete ? assignments.reduce((sum, { task, row, estimatedCost }) => sum
1016
+ + (estimatedCost ?? taskCost(row, task, text, complexity.band, cacheReadRatio, cacheWriteRatio)), 0) : null
1071
1017
  const baselineRows = assignments.map(({ task }) => {
1072
- const strongest = rows.reduce((best, row) => qualityForTask(row, task.type) > (best === null ? -1 : qualityForTask(best, task.type)) ? row : best, null)
1018
+ const strongest = eligibleRowsForTask(rows, task).reduce((best, row) => qualityForTask(row, task.type) > (best === null ? -1 : qualityForTask(best, task.type)) ? row : best, null)
1073
1019
  return { task, strongest }
1074
1020
  })
1075
1021
  const baselineCost = baselineRows.every(item => item.strongest?.pricing !== null && item.strongest !== null)
1076
1022
  ? baselineRows.reduce((sum, { task, strongest }) => sum + taskCost(strongest, task, text, complexity.band, cacheReadRatio, cacheWriteRatio), 0)
1077
1023
  : null
1078
- const qualityEvidenceComplete = assignments.length > 0 && assignments.every(({ row }) => ['livebench', 'route', 'user'].includes(row?.qualitySource))
1079
- && baselineRows.every(({ strongest }) => ['livebench', 'route', 'user'].includes(strongest?.qualitySource))
1024
+ const qualityEvidenceComplete = assignments.length > 0 && assignments.every(({ row, task }) => ['livebench', 'route', 'user'].includes(qualitySourceForTask(row, task.type)))
1025
+ && baselineRows.every(({ strongest, task }) => ['livebench', 'route', 'user'].includes(qualitySourceForTask(strongest, task.type)))
1080
1026
  const budgetExceeded = Number(budgetUsd) > 0 && totalEstimate !== null ? totalEstimate > Number(budgetUsd) : null
1081
1027
  const savings = baselineCost === null || totalEstimate === null || !qualityEvidenceComplete
1082
1028
  ? null : baselineCost <= 0 ? 0 : clamp((baselineCost - totalEstimate) / baselineCost)
@@ -1087,7 +1033,7 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1087
1033
  ? unassignableTasks.length > 0
1088
1034
  ? `图像工作包 ${unassignableTasks.join('、')} 没有可用的图像模型,无法形成完整分配计划。`
1089
1035
  : '尚未发现可用模型,保留 Harness 原始模型选择。'
1090
- : `${complexity.band === 'simple' ? '低复杂度优先成本、响应速度与较低推理开销' : complexity.band === 'balanced' ? '在质量、成本、推理等级、延迟与风险之间平衡' : '高复杂度执行包含推理等级的依赖感知全局约束分配'};任务类型为 ${taskType},已对 ${String(subtasks.length)} 个工作包进行 Pareto 剪枝和有界组合搜索。`
1036
+ : `${complexity.band === 'simple' ? '低复杂度优先成本、响应速度与较低推理开销' : complexity.band === 'balanced' ? '在质量、成本、推理等级、延迟与风险之间平衡' : '高复杂度执行包含推理等级的依赖感知约束分配'};任务类型为 ${taskType},${String(subtasks.length)} 个工作包采用${optimized?.search?.exact ? '保留候选空间内的精确搜索' : '保留候选空间内的有界近似搜索'}。质量分与门槛是未校准的估计,不是答对概率。`
1091
1037
  return {
1092
1038
  mode,
1093
1039
  preset: presetId,
@@ -1098,16 +1044,33 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1098
1044
  taskTypes: [...new Set(taskNodes.map(task => task.type).filter(type => type !== 'reasoning'))],
1099
1045
  objectiveWeights: weights,
1100
1046
  candidates: rows.slice(0, 8).map(row => {
1101
- const decision = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band], preferredReasoningEffort: complexity.band === 'simple' ? 'low' : 'medium' }, weights, maxCost, new Set(), cacheReadRatio, cacheWriteRatio)
1102
- return { provider: row.provider, model: row.model, score: Number(row.score.toFixed(3)), quality: row.qualitySource === 'unknown' ? null : Number(row.quality.toFixed(3)), qualitySource: row.qualitySource, specialty: Number(row.specialty.toFixed(3)), reasoningEffort: decision.reasoningEffort, preferredReasoningEffort: decision.preferredReasoningEffort, reasoningFit: Number(decision.reasoningFit.toFixed(3)), reasoningKnown: row.reasoningKnown, reasoningEfforts: row.reasoningEfforts, estimatedCost: row.estimatedCost === null ? null : Number(row.estimatedCost.toFixed(6)), inputPrice: row.pricing?.input ?? null, outputPrice: row.pricing?.output ?? null, pricingSource: row.pricingSource }
1047
+ const decision = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band], preferredReasoningEffort: complexity.band === 'simple' ? 'low' : 'medium' }, weights, cacheReadRatio, cacheWriteRatio, text, complexity.band)
1048
+ return { provider: row.provider, model: row.model, score: Number(row.score.toFixed(3)), preferenceAdjustment: decision.preferenceAdjustment, quality: row.qualitySource === 'unknown' ? null : Number(row.quality.toFixed(3)), qualitySource: row.qualitySource, specialty: Number(row.specialty.toFixed(3)), reasoningEffort: decision.reasoningEffort, preferredReasoningEffort: decision.preferredReasoningEffort, reasoningFit: Number(decision.reasoningFit.toFixed(3)), reasoningKnown: row.reasoningKnown, reasoningEfforts: row.reasoningEfforts, estimatedCost: row.estimatedCost === null ? null : Number(row.estimatedCost.toFixed(6)), inputPrice: row.pricing?.input ?? null, outputPrice: row.pricing?.output ?? null, pricingSource: row.pricingSource, pricingVersion: row.pricingVersion, pricingAsOf: row.snapshotAsOf }
1103
1049
  }),
1104
- selected: selected === null ? null : { provider: selected.provider, model: selected.model, reasoningEffort: selectedAssignment?.decision?.reasoningEffort, estimatedCost: selected.estimatedCost === null ? null : Number(selected.estimatedCost.toFixed(6)), qualitySource: selected.qualitySource, pricingSource: selected.pricingSource },
1050
+ selected: selected === null ? null : { provider: selected.provider, model: selected.model, reasoningEffort: selectedAssignment?.decision?.reasoningEffort, estimatedCost: selected.estimatedCost === null ? null : Number(selected.estimatedCost.toFixed(6)), qualitySource: qualitySourceForTask(selected, selectedAssignment?.task.type ?? taskType), pricingSource: selected.pricingSource },
1105
1051
  subtasks,
1106
1052
  synthesizer: synthesizer == null ? null : { provider: synthesizer.provider, model: synthesizer.model, reasoningEffort: synthesizerAssignment?.decision?.reasoningEffort },
1107
- estimatedCost: totalEstimate === null ? null : Number(totalEstimate.toFixed(6)),
1053
+ // Preserve precision for downstream budget gates; formatting belongs to UI.
1054
+ estimatedCost: totalEstimate,
1108
1055
  costBreakdown,
1109
1056
  optimization: {
1110
- solver: 'pareto-pruned quality-constrained beam assignment',
1057
+ dataVersions,
1058
+ personalization: learning ? {
1059
+ policyVersion: learning.policyVersion, revision: learning.revision,
1060
+ enabled: learning.enabled, scope: learning.scope, window: learning.window,
1061
+ feedbackCount: learning.feedbackCount, effectiveWeight: learning.effectiveWeight,
1062
+ halfLifeDays: learning.halfLifeDays, priorWeight: learning.priorWeight,
1063
+ maxAdjustment: learning.maxAdjustment, resetAt: learning.resetAt,
1064
+ semantics: 'subjective-utility-not-objective-quality',
1065
+ } : null,
1066
+ solver: 'dependency-safe quality-constrained exact/beam assignment',
1067
+ qualitySemantics: 'uncalibrated-surrogate',
1068
+ costUtility: { method: '1/(1+estimated-task-cost/reference-task-cost)', referencePricing: COST_REFERENCE_PRICING },
1069
+ search: optimized?.search ?? null,
1070
+ optimalityScope: 'retained-candidates-and-surrogate-objective',
1071
+ candidateTruncated: (optimized?.candidatePools ?? []).reduce((sum, pool) => sum + pool.truncated, 0),
1072
+ qualityFiltered: (optimized?.candidatePools ?? []).reduce((sum, pool) => sum + pool.filtered, 0),
1073
+ localParetoDisabled: (optimized?.candidatePools ?? []).some(pool => pool.dependencySensitive),
1111
1074
  qualityFloor: QUALITY_FLOORS[complexity.band],
1112
1075
  budgetUsd: Number(Number(budgetUsd) > 0 ? Number(budgetUsd) : 0),
1113
1076
  cacheReadRatio: normalizedCacheRatios(cacheReadRatio, cacheWriteRatio).read,
@@ -1124,8 +1087,8 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1124
1087
  beamWidth: ROUTING_BEAM_WIDTH,
1125
1088
  budgetFeasible: budget <= 0 ? (pricingComplete ? true : null) : budgetPlan !== null,
1126
1089
  minimumFeasibleCost: minimumFeasibleCost === null ? null : Number(Number(minimumFeasibleCost).toFixed(6)),
1127
- liveBench: liveBench?.fetchedAt
1128
- ? { source: liveBench.source ?? 'livebench', fetchedAt: liveBench.fetchedAt, models: Object.keys(liveBench.models ?? {}).length, stale: String(liveBenchError).length > 0, error: String(liveBenchError || '') }
1090
+ liveBench: (liveBench?.fetchedAt ?? liveBench?.verifiedAt)
1091
+ ? { source: liveBench.source ?? 'livebench', fetchedAt: liveBench.fetchedAt ?? liveBench.verifiedAt, verifiedAt: liveBench.verifiedAt ?? liveBench.fetchedAt, publishedAt: liveBench.publishedAt ?? null, version: liveBench.version ?? null, models: Object.keys(liveBench.models ?? {}).length, stale: String(liveBenchError).length > 0, error: String(liveBenchError || '') }
1129
1092
  : { source: 'experimental-baseline', fetchedAt: null, models: 0, stale: false, error: String(liveBenchError || '') },
1130
1093
  },
1131
1094
  reason,
@@ -256,6 +256,9 @@ function storedPackage(planned, result, pricing, finishedAt, billingFor = billin
256
256
  answer: String(result?.answer ?? '').slice(0, MAX_STORED_ANSWER),
257
257
  answerTruncated: String(result?.answer ?? '').length > MAX_STORED_ANSWER,
258
258
  usage: result?.usage ?? null,
259
+ // Freeze the rates used for this result. Future price updates must not
260
+ // silently rewrite historical spend calculated from usage.
261
+ pricingSnapshot: pricing ? { ...pricing } : null,
259
262
  ...cost,
260
263
  finishedAt,
261
264
  review: planned.review ?? null,
@@ -291,6 +294,8 @@ export function buildRunRecord({ id, createdAt, task, plan, execution, preset =
291
294
  mode: plan?.mode ?? 'single',
292
295
  preset,
293
296
  decision: {
297
+ dataVersions: plan?.optimization?.dataVersions ?? null,
298
+ personalization: plan?.optimization?.personalization ?? null,
294
299
  selected: plan?.selected ? { provider: plan.selected.provider, model: plan.selected.model } : null,
295
300
  reason: String(plan?.reason ?? ''),
296
301
  complexity: plan?.complexity ? { band: plan.complexity.band, value: finite(plan.complexity.value) ? Number(plan.complexity.value.toFixed(3)) : null } : null,
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 — 2026-10-05
4
+
5
+ Stable release under the npm `latest` tag. Fully quit Harness, including the tray process, and restart after upgrading so both the Host and client load the new version. Public data refresh is opt-in; enabling it does not authorize paid model exploration.
6
+
7
+ - Add opt-in versioned public LiveBench/curated pricing snapshots with TTL, failure backoff, last-good fallback and atomic cross-process promotion; explicit manual prices win. Freeze each run's applied rates and data/policy versions, and disable cleared sources immediately without deleting recoverable snapshots.
8
+ - Replace production review/rating quality bias with task-specific, time-decayed, shrinkage-regularized subjective utility. Preserve objective proxy quality floors; expose learning controls/reset and the local DSH_HOME / last-200-runs scope. Agent rating tools require approval; Desktop ratings bind to the displayed result timestamp.
9
+ - Require explicit score scale for dynamic benchmark mirrors and exact benchmark model identity; expose optional benchmarkModel mapping without claiming verified execution effort. Add controlled synthetic adaptation and Host/Client integration tests; no paid exploration, real-user result collection, universal official pricing scraper or actual quality-cost benefit claim.
10
+
11
+ - Use estimated per-package token cost against a fixed reference pricing scale, not the most expensive configured route; an irrelevant expensive model no longer rescales existing preferences. Remove the model-name-specific synthesis bonus.
12
+ - Protect least-cost known-price candidates before truncating pools. Disable local Pareto pruning for dependency-coupled tasks, where route identity changes later handoff penalties.
13
+ - Solve retained candidate spaces of at most 4,096 combinations exactly; larger spaces use bounded beam search with a protected affordable prefix. Expose exact/approximate search, candidate truncation and the surrogate-objective scope instead of claiming global answer-quality optimality.
14
+ - Correct repeated quality-bias application for unknown models. Explain that benchmark/manual scores and quality floors are not calibrated correctness probabilities.
15
+ - Preserve explicit/derived benchmark provenance, exclude image-ineligible savings baselines, and use unrounded totals for budget gates. Report quality filtering, Pareto pruning, and candidate truncation separately.
16
+ - Add independent exhaustive-oracle tests and a label-matrix offline evaluation tool with validation-selected single-model baselines, random routing, hindsight Oracle, actual recorded costs and rare-expert recall. No paid model calls or real quality-improvement claim.
17
+ - Audit the official LiveBench 2026-06-25 aggregate score/cost release with pinned source hashes, complete-coverage checks and descriptive static Pareto profiles. Add continuous-score, domain-stratified whole-group paired bootstrap tooling and document the missing latest per-question cost/quality matrix. Aggregates are not replayed as question outcomes; no real routing-benefit claim or production routing change.
18
+
19
+ Validation: 558 tests, with 555 passed, 3 existing platform-condition skips and no failures. Strict frozen-lockfile installation, generated-client check and actual-component mock-preview compilation passed. Nine controlled synthetic adaptation checks passed; these are mechanism tests, not real model quality-cost gains. Real paid model calls, user-result collection, execution-effort validation and Harness Desktop end-to-end acceptance were not part of this verification. Research artifacts are in the complete GitHub source checkout, not the npm runtime package; historical experiments retain their original development-version identifiers.
20
+
3
21
  ## 0.15.0 — 2026-10-03
4
22
 
5
23
  - Reorganize the workbench into task/execution, model configuration, official tools, and budget/security pages. Keep panels mounted so drafts, installation polling, and terminal sessions survive navigation; add keyboard tab navigation and panel-width responsive layouts.