@harperfast/harper 5.3.0 → 5.3.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.
Files changed (231) hide show
  1. package/bin/copyDb.ts +5 -1
  2. package/bin/deployRenderer.ts +3 -0
  3. package/components/Application.ts +66 -22
  4. package/components/DESIGN.md +89 -36
  5. package/components/componentLoader.ts +2 -0
  6. package/components/deploymentRecorder.ts +18 -1
  7. package/components/installFingerprint.ts +123 -0
  8. package/components/mcp/DESIGN.md +4 -4
  9. package/components/operations.js +29 -0
  10. package/config/DESIGN.md +5 -5
  11. package/dataLayer/DESIGN.md +37 -22
  12. package/dataLayer/backupRepository.ts +248 -0
  13. package/dataLayer/restoreMarker.ts +30 -75
  14. package/dataLayer/rocksdbBackup.ts +253 -110
  15. package/dist/bin/copyDb.js +3 -0
  16. package/dist/bin/copyDb.js.map +1 -1
  17. package/dist/bin/deployRenderer.js +3 -0
  18. package/dist/bin/deployRenderer.js.map +1 -1
  19. package/dist/components/Application.d.ts +5 -0
  20. package/dist/components/Application.js +51 -19
  21. package/dist/components/Application.js.map +1 -1
  22. package/dist/components/componentLoader.js +2 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/deploymentRecorder.js +18 -1
  25. package/dist/components/deploymentRecorder.js.map +1 -1
  26. package/dist/components/installFingerprint.d.ts +39 -0
  27. package/dist/components/installFingerprint.js +113 -0
  28. package/dist/components/installFingerprint.js.map +1 -0
  29. package/dist/components/operations.d.ts +2 -0
  30. package/dist/components/operations.js +31 -0
  31. package/dist/components/operations.js.map +1 -1
  32. package/dist/dataLayer/backupRepository.d.ts +33 -0
  33. package/dist/dataLayer/backupRepository.js +232 -0
  34. package/dist/dataLayer/backupRepository.js.map +1 -0
  35. package/dist/dataLayer/restoreMarker.d.ts +4 -1
  36. package/dist/dataLayer/restoreMarker.js +29 -68
  37. package/dist/dataLayer/restoreMarker.js.map +1 -1
  38. package/dist/dataLayer/rocksdbBackup.js +221 -112
  39. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/json/systemSchema.json +14 -0
  43. package/dist/resources/DatabaseTransaction.d.ts +140 -11
  44. package/dist/resources/DatabaseTransaction.js +1055 -464
  45. package/dist/resources/DatabaseTransaction.js.map +1 -1
  46. package/dist/resources/IterableEventQueue.d.ts +2 -1
  47. package/dist/resources/IterableEventQueue.js +16 -8
  48. package/dist/resources/IterableEventQueue.js.map +1 -1
  49. package/dist/resources/LMDBTransaction.d.ts +13 -2
  50. package/dist/resources/LMDBTransaction.js +219 -93
  51. package/dist/resources/LMDBTransaction.js.map +1 -1
  52. package/dist/resources/RecordEncoder.js +2 -3
  53. package/dist/resources/RecordEncoder.js.map +1 -1
  54. package/dist/resources/Resource.js +5 -6
  55. package/dist/resources/Resource.js.map +1 -1
  56. package/dist/resources/ResourceInterface.d.ts +3 -1
  57. package/dist/resources/ResourceInterface.js.map +1 -1
  58. package/dist/resources/RocksIndexStore.d.ts +2 -1
  59. package/dist/resources/RocksIndexStore.js +3 -0
  60. package/dist/resources/RocksIndexStore.js.map +1 -1
  61. package/dist/resources/Table.js +116 -12
  62. package/dist/resources/Table.js.map +1 -1
  63. package/dist/resources/analytics/write.js +6 -2
  64. package/dist/resources/analytics/write.js.map +1 -1
  65. package/dist/resources/auditStore.d.ts +71 -17
  66. package/dist/resources/auditStore.js +248 -64
  67. package/dist/resources/auditStore.js.map +1 -1
  68. package/dist/resources/branchDatabase.js +2 -0
  69. package/dist/resources/branchDatabase.js.map +1 -1
  70. package/dist/resources/databases.js +8 -2
  71. package/dist/resources/databases.js.map +1 -1
  72. package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
  73. package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
  74. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
  75. package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
  76. package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
  77. package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
  78. package/dist/resources/models/Models.d.ts +11 -0
  79. package/dist/resources/models/Models.js +95 -4
  80. package/dist/resources/models/Models.js.map +1 -1
  81. package/dist/resources/models/backendRegistry.d.ts +8 -0
  82. package/dist/resources/models/backendRegistry.js +15 -1
  83. package/dist/resources/models/backendRegistry.js.map +1 -1
  84. package/dist/resources/models/bootstrap.d.ts +2 -0
  85. package/dist/resources/models/bootstrap.js +6 -0
  86. package/dist/resources/models/bootstrap.js.map +1 -1
  87. package/dist/resources/models/calibration.d.ts +88 -0
  88. package/dist/resources/models/calibration.js +244 -0
  89. package/dist/resources/models/calibration.js.map +1 -0
  90. package/dist/resources/models/calibrationStore.d.ts +163 -0
  91. package/dist/resources/models/calibrationStore.js +986 -0
  92. package/dist/resources/models/calibrationStore.js.map +1 -0
  93. package/dist/resources/models/decisionStore.d.ts +24 -0
  94. package/dist/resources/models/decisionStore.js +45 -0
  95. package/dist/resources/models/decisionStore.js.map +1 -1
  96. package/dist/resources/models/generativeDecision.js +44 -15
  97. package/dist/resources/models/generativeDecision.js.map +1 -1
  98. package/dist/resources/models/types.d.ts +7 -0
  99. package/dist/resources/recordLock.d.ts +8 -5
  100. package/dist/resources/recordLock.js +6 -4
  101. package/dist/resources/recordLock.js.map +1 -1
  102. package/dist/resources/replayLogs.js +8 -1
  103. package/dist/resources/replayLogs.js.map +1 -1
  104. package/dist/resources/scheduler/engine.d.ts +4 -0
  105. package/dist/resources/scheduler/engine.js +30 -2
  106. package/dist/resources/scheduler/engine.js.map +1 -1
  107. package/dist/resources/search.d.ts +16 -4
  108. package/dist/resources/search.js +117 -13
  109. package/dist/resources/search.js.map +1 -1
  110. package/dist/resources/transaction.js +85 -5
  111. package/dist/resources/transaction.js.map +1 -1
  112. package/dist/resources/transactionBroadcast.d.ts +8 -0
  113. package/dist/resources/transactionBroadcast.js +65 -0
  114. package/dist/resources/transactionBroadcast.js.map +1 -1
  115. package/dist/security/keys.d.ts +1 -1
  116. package/dist/security/keys.js +170 -120
  117. package/dist/security/keys.js.map +1 -1
  118. package/dist/server/http.js +10 -0
  119. package/dist/server/http.js.map +1 -1
  120. package/dist/server/serverHelpers/Request.js +11 -2
  121. package/dist/server/serverHelpers/Request.js.map +1 -1
  122. package/dist/server/serverHelpers/contentTypes.js +8 -3
  123. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +38 -7
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/server/threads/manageThreads.d.ts +3 -3
  127. package/dist/server/threads/manageThreads.js +15 -13
  128. package/dist/server/threads/manageThreads.js.map +1 -1
  129. package/dist/server/threads/threadServer.d.ts +3 -0
  130. package/dist/server/threads/threadServer.js +24 -6
  131. package/dist/server/threads/threadServer.js.map +1 -1
  132. package/dist/upgrade/directives/5-3-0.js +9 -3
  133. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  134. package/dist/utility/durableFile.d.ts +36 -0
  135. package/dist/utility/durableFile.js +106 -0
  136. package/dist/utility/durableFile.js.map +1 -0
  137. package/dist/utility/errors/hdbError.d.ts +4 -0
  138. package/dist/utility/errors/hdbError.js +10 -1
  139. package/dist/utility/errors/hdbError.js.map +1 -1
  140. package/dist/utility/globalSchema.d.ts +9 -0
  141. package/dist/utility/hdbTerms.d.ts +1 -0
  142. package/dist/utility/hdbTerms.js +1 -0
  143. package/dist/utility/hdbTerms.js.map +1 -1
  144. package/dist/validation/configValidator.js +29 -0
  145. package/dist/validation/configValidator.js.map +1 -1
  146. package/index.ts +7 -0
  147. package/json/systemSchema.json +14 -0
  148. package/npm-shrinkwrap.json +22 -22
  149. package/package.json +2 -2
  150. package/resources/DESIGN.md +57 -272
  151. package/resources/DatabaseTransaction.ts +708 -87
  152. package/resources/FULL_TEXT.md +6 -2
  153. package/resources/IterableEventQueue.ts +14 -7
  154. package/resources/LMDBTransaction.ts +212 -84
  155. package/resources/RecordEncoder.ts +2 -3
  156. package/resources/Resource.ts +5 -6
  157. package/resources/ResourceInterface.ts +3 -1
  158. package/resources/RocksIndexStore.ts +5 -0
  159. package/resources/Table.ts +118 -15
  160. package/resources/analytics/write.ts +6 -2
  161. package/resources/audit-retention.md +203 -0
  162. package/resources/auditStore.ts +306 -84
  163. package/resources/branchDatabase.ts +2 -0
  164. package/resources/databases.ts +6 -2
  165. package/resources/indexes/DESIGN.md +120 -177
  166. package/resources/indexes/fullTextNativeBinding.ts +22 -3
  167. package/resources/indexes/fullTextQueryIndex.ts +274 -11
  168. package/resources/models/DESIGN.md +11 -3
  169. package/resources/models/Models.ts +127 -4
  170. package/resources/models/backendRegistry.ts +17 -0
  171. package/resources/models/bootstrap.ts +9 -1
  172. package/resources/models/calibration.ts +298 -0
  173. package/resources/models/calibrationStore.ts +1167 -0
  174. package/resources/models/decisionStore.ts +57 -0
  175. package/resources/models/generativeDecision.ts +54 -16
  176. package/resources/models/types.ts +6 -0
  177. package/resources/record-locks.md +155 -141
  178. package/resources/recordLock.ts +12 -6
  179. package/resources/replayLogs.ts +9 -2
  180. package/resources/scheduler/DESIGN.md +4 -3
  181. package/resources/scheduler/engine.ts +35 -2
  182. package/resources/search.ts +154 -16
  183. package/resources/transaction.ts +50 -5
  184. package/resources/transactionBroadcast.ts +69 -0
  185. package/security/DESIGN.md +9 -30
  186. package/security/keys.ts +175 -127
  187. package/server/DESIGN.md +70 -36
  188. package/server/http.ts +10 -0
  189. package/server/serverHelpers/Request.ts +10 -2
  190. package/server/serverHelpers/contentTypes.ts +5 -3
  191. package/server/serverHelpers/uwsServer.ts +34 -7
  192. package/server/threads/manageThreads.js +16 -14
  193. package/server/threads/threadServer.js +24 -6
  194. package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
  195. package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
  196. package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
  197. package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
  198. package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
  199. package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
  200. package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
  201. package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
  202. package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
  203. package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
  204. package/studio/web/assets/index-QLZw6xrU.css +1 -0
  205. package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
  206. package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
  207. package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
  208. package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
  209. package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
  210. package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
  211. package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
  212. package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
  213. package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
  214. package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
  215. package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
  216. package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
  217. package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
  218. package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
  219. package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
  220. package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
  221. package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
  222. package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
  223. package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
  224. package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
  225. package/studio/web/index.html +2 -2
  226. package/upgrade/directives/5-3-0.ts +10 -3
  227. package/utility/durableFile.ts +96 -0
  228. package/utility/errors/hdbError.ts +9 -0
  229. package/utility/hdbTerms.ts +1 -0
  230. package/validation/configValidator.ts +31 -0
  231. package/studio/web/assets/index-72cEH474.css +0 -1
@@ -3,9 +3,25 @@ import { contextStorage } from '../transaction.ts';
3
3
  import {
4
4
  defineBackend,
5
5
  getBackend,
6
+ getBackendSource,
6
7
  ModelBackendNotFoundError,
7
8
  registerBackend as registerBackendImpl,
9
+ SERVED_SOURCE,
10
+ type ServedSourceHook,
8
11
  } from './backendRegistry.ts';
12
+ import {
13
+ type AppliedCalibration,
14
+ applyFits,
15
+ type CalibrationBudgets,
16
+ type CalibrationRunResult,
17
+ type CalibrationSummary,
18
+ isCalibrationEnabled,
19
+ listCalibrations,
20
+ runCalibration,
21
+ } from './calibrationStore.ts';
22
+ import { populationKey, populationRank } from './calibration.ts';
23
+ import { safeErrorMessage } from '../scheduler/engine.ts';
24
+ import harperLogger from '../../utility/logging/harper_logger.ts';
9
25
  import { getRouter, registerRouter as registerRouterImpl } from './routing.ts';
10
26
  import { getModelCallAnalyticsWriter, type ModelCallAnalyticsWriter, type ModelCallRecord } from './analyticsTable.ts';
11
27
  import { recordAction } from '../analytics/write.ts';
@@ -214,6 +230,7 @@ export class Models implements ModelsContract {
214
230
  let firstError: unknown = undefined;
215
231
  let hasError = false;
216
232
  for (const backend of resolved.candidates) {
233
+ let completed: GenerateResult | undefined;
217
234
  // Don't spend a backend call on an already-cancelled request — between
218
235
  // candidates the caller may have aborted (and at entry it may already be).
219
236
  signal?.throwIfAborted();
@@ -227,7 +244,7 @@ export class Models implements ModelsContract {
227
244
  // Propagate usage onto the returned GenerateResult so callers (notably the
228
245
  // `toolMode: 'auto'` loop's budget tracker) can read cumulative tokens without
229
246
  // re-querying analytics. Pure pass-through — backend usage is the source of truth.
230
- return result.usage ? { ...result.output, usage: result.usage } : result.output;
247
+ completed = result.usage ? { ...result.output, usage: result.usage } : result.output;
231
248
  } catch (err) {
232
249
  this.#recordFailure(backend, 'generate', opts.model, accounting, opts, attemptStart, err);
233
250
  if (!hasError) {
@@ -236,6 +253,10 @@ export class Models implements ModelsContract {
236
253
  }
237
254
  if (signal?.aborted) throw err; // caller cancelled — stop, surface the abort
238
255
  }
256
+ if (completed) {
257
+ reportServed(opts, backend);
258
+ return completed;
259
+ }
239
260
  }
240
261
  throw firstError;
241
262
  }
@@ -389,6 +410,17 @@ export class Models implements ModelsContract {
389
410
  continue;
390
411
  }
391
412
  const callId = this.#record(backend, 'decide', callOpts.model, accounting, undefined, result, attemptStart);
413
+ const calibration = calibrate(
414
+ call,
415
+ decision,
416
+ backend,
417
+ callOpts.model,
418
+ instructions,
419
+ accounting,
420
+ result.output,
421
+ identity
422
+ );
423
+ if (calibration.applied) decision = calibration.applied.decision;
392
424
  if (!identity) return result.usage ? { ...decision, usage: result.usage } : decision;
393
425
  const id = await this.#persistDecision(
394
426
  callId,
@@ -398,7 +430,8 @@ export class Models implements ModelsContract {
398
430
  accounting,
399
431
  result.output,
400
432
  identity,
401
- decision
433
+ decision,
434
+ calibration
402
435
  );
403
436
  return result.usage ? { id, ...decision, usage: result.usage } : { id, ...decision };
404
437
  }
@@ -437,6 +470,7 @@ export class Models implements ModelsContract {
437
470
  let firstFailure: unknown = undefined;
438
471
  let hasFailure = false;
439
472
  for (const backend of resolved.candidates) {
473
+ let completed: (ChoiceScores & { usage?: TokenUsage }) | undefined;
440
474
  signal?.throwIfAborted();
441
475
  const attemptStart = performance.now();
442
476
  try {
@@ -462,7 +496,7 @@ export class Models implements ModelsContract {
462
496
  `Backend '${backend.name}' did not return one finite log-likelihood per choice (${choices.length})`
463
497
  );
464
498
  this.#record(backend, 'scoreChoices', opts.model, accounting, undefined, result, attemptStart);
465
- return result.usage ? { logLikelihoods, usage: result.usage } : { logLikelihoods };
499
+ completed = result.usage ? { logLikelihoods, usage: result.usage } : { logLikelihoods };
466
500
  } catch (err) {
467
501
  // Only this attempt's own decline bills its tokens: the error may travel on as an abort
468
502
  // reason or through the decision adapter, and those rows must not count it again.
@@ -486,6 +520,10 @@ export class Models implements ModelsContract {
486
520
  }
487
521
  if (signal?.aborted) throw err;
488
522
  }
523
+ if (completed) {
524
+ reportServed(opts, backend);
525
+ return completed;
526
+ }
489
527
  }
490
528
  throw hasFailure ? firstFailure : firstError;
491
529
  }
@@ -496,6 +534,20 @@ export class Models implements ModelsContract {
496
534
  DecisionRecord<T> | undefined;
497
535
  }
498
536
 
537
+ /**
538
+ * Fit calibrations from recorded outcomes now, as the periodic job does, over every tenant's decisions:
539
+ * a maintenance operation for trusted code. `budgets` may narrow this run; the fit policy always comes
540
+ * from configuration. Resolves with the run's result; a data fault is reported there, never thrown.
541
+ */
542
+ calibrate(budgets?: CalibrationBudgets): Promise<CalibrationRunResult> {
543
+ return runCalibration(budgets);
544
+ }
545
+
546
+ /** The newest calibration of each field of each population the caller's tenant owns. */
547
+ getCalibrations(filter?: { model?: string }): Promise<CalibrationSummary[]> {
548
+ return listCalibrations(resolveCallContext().accounting.tenantId, filter);
549
+ }
550
+
499
551
  async recordOutcome<T = unknown>(id: string, outcome: OutcomeReport): Promise<DecisionRecord<T>> {
500
552
  checkDecisionId(id);
501
553
  return (await this.#decisionStore.recordOutcome(
@@ -513,7 +565,8 @@ export class Models implements ModelsContract {
513
565
  accounting: AccountingContext,
514
566
  output: DecisionOutput<unknown>,
515
567
  identity: CallIdentity,
516
- decision: Omit<Decision<T>, 'id' | 'usage'>
568
+ decision: Omit<Decision<T>, 'id' | 'usage'>,
569
+ calibration: Calibration<T>
517
570
  ): Promise<string> {
518
571
  const id = newDecisionId();
519
572
  const at = Date.now();
@@ -538,6 +591,16 @@ export class Models implements ModelsContract {
538
591
  calibrated: decision.calibrated,
539
592
  };
540
593
  if (decision.noMatch !== undefined) row.noMatch = decision.noMatch;
594
+ if (calibration.entry !== undefined) row.entry = calibration.entry;
595
+ if (calibration.population !== undefined) {
596
+ row.population = calibration.population;
597
+ row.populationRank = populationRank(calibration.population, row.expiresAt, row.id);
598
+ }
599
+ if (calibration.applied) {
600
+ row.rawDistribution = calibration.applied.rawDistribution;
601
+ row.rawFields = calibration.applied.rawFields;
602
+ row.calibration = calibration.applied.snapshot;
603
+ }
541
604
  try {
542
605
  await this.#decisionStore.persist(row);
543
606
  } catch (err) {
@@ -714,9 +777,69 @@ function toBackendOpts<TOpts extends { model?: string; signal?: AbortSignal }>(
714
777
  ): BackendOpts<TOpts> {
715
778
  const backendOpts = { ...opts, signal, accounting };
716
779
  delete backendOpts.model;
780
+ if (SERVED_SOURCE in backendOpts) delete (backendOpts as Record<symbol, unknown>)[SERVED_SOURCE];
717
781
  return backendOpts;
718
782
  }
719
783
 
784
+ const calibrationLog = harperLogger.forComponent('models').conditional;
785
+ let lastCalibrationFaultLog = 0;
786
+
787
+ function logCalibrationFault(what: string, err: unknown): void {
788
+ const now = Date.now();
789
+ if (now - lastCalibrationFaultLog < 60_000) return;
790
+ lastCalibrationFaultLog = now;
791
+ calibrationLog.warn?.(`models: ${what}: ${safeErrorMessage(err)}`);
792
+ }
793
+
794
+ function reportServed(opts: object, backend: ModelBackend): void {
795
+ const hook = (opts as Record<symbol, unknown>)[SERVED_SOURCE];
796
+ if (typeof hook !== 'function') return;
797
+ try {
798
+ (hook as ServedSourceHook)(getBackendSource(backend));
799
+ } catch (err) {
800
+ logCalibrationFault('a served-source hook failed', err);
801
+ }
802
+ }
803
+
804
+ interface Calibration<T> {
805
+ entry?: string;
806
+ population?: string;
807
+ applied?: AppliedCalibration<T>;
808
+ }
809
+
810
+ const NOT_CALIBRATING: Calibration<never> = {};
811
+
812
+ function calibrate<T>(
813
+ schema: DecisionSchema,
814
+ decision: Omit<Decision<T>, 'id' | 'usage'>,
815
+ backend: ModelBackend,
816
+ model: string | undefined,
817
+ instructions: string | undefined,
818
+ accounting: AccountingContext,
819
+ output: DecisionOutput<unknown>,
820
+ identity: CallIdentity | undefined
821
+ ): Calibration<T> {
822
+ if (!isCalibrationEnabled()) return NOT_CALIBRATING as Calibration<T>;
823
+ try {
824
+ const signature = typeof output.signature === 'string' && output.signature ? output.signature : undefined;
825
+ const entry = getBackendSource(backend) ?? `registered:${backend.name}`;
826
+ if (!signature) return { entry };
827
+ const population = populationKey({
828
+ tenant: accounting.tenantId,
829
+ model: model ?? 'default',
830
+ entry,
831
+ signature,
832
+ instructionsHash: instructions ? hashText(instructions) : undefined,
833
+ schemaHash: identity?.hash ?? hashSchema(schema),
834
+ });
835
+ if (decision.calibrated) return { entry, population };
836
+ return { entry, population, applied: applyFits(schema, decision, population) };
837
+ } catch (err) {
838
+ logCalibrationFault('calibration was not applied', err);
839
+ return NOT_CALIBRATING as Calibration<T>;
840
+ }
841
+ }
842
+
720
843
  function resolveCandidates(kind: ModelKind, model: string | undefined, requires: Capability[]): Resolution {
721
844
  const logicalName = model ?? 'default';
722
845
  const candidates = getRouter().route({ kind, logicalName, requires: [...requires] });
@@ -51,6 +51,23 @@ interface CaptureSlot {
51
51
  active: boolean;
52
52
  }
53
53
 
54
+ const sources = new WeakMap<ModelBackend, string>();
55
+
56
+ /**
57
+ * An internal option key: the decision adapter passes a hook under it on inner calls, and the facade
58
+ * reports each successful attempt's source through it. A symbol, so it is never part of the public options.
59
+ */
60
+ export const SERVED_SOURCE = Symbol('models.servedSource');
61
+ export type ServedSourceHook = (source: string | undefined) => void;
62
+
63
+ export function setBackendSource(backend: ModelBackend, fingerprint: string): void {
64
+ sources.set(backend, fingerprint);
65
+ }
66
+
67
+ export function getBackendSource(backend: ModelBackend): string | undefined {
68
+ return sources.get(backend);
69
+ }
70
+
54
71
  // Async-context scoped: a module-global slot would divert unrelated registrations and collide
55
72
  // concurrent constructions.
56
73
  const captureScope = new AsyncLocalStorage<CaptureSlot>();
@@ -34,7 +34,8 @@ import { registerOllamaBackend, type OllamaBackendConfig } from '../../component
34
34
  import { registerOpenAIBackend, type OpenAIBackendConfig } from '../../components/openai/index.ts';
35
35
  import { registerAnthropicBackend, type AnthropicBackendConfig } from '../../components/anthropic/index.ts';
36
36
  import { registerBedrockBackend, type BedrockBackendConfig } from '../../components/bedrock/index.ts';
37
- import { setModelsConfigHash } from './decisionStore.ts';
37
+ import { setModelsConfigHash, sourceFingerprint } from './decisionStore.ts';
38
+ import { type CalibrationConfig, configureCalibration } from './calibrationStore.ts';
38
39
  import { isAbsolute, resolve as resolvePath } from 'node:path';
39
40
  import { pathToFileURL } from 'node:url';
40
41
  import { createRequire } from 'node:module';
@@ -45,6 +46,7 @@ import {
45
46
  getBackend,
46
47
  removeIfCurrent,
47
48
  replaceIfCurrent,
49
+ setBackendSource,
48
50
  type CapturedInstall,
49
51
  } from './backendRegistry.ts';
50
52
  import { getSharedRootConfigWatcher, RootConfigWatcher } from '../../config/RootConfigWatcher.ts';
@@ -86,6 +88,7 @@ interface ModelsConfig {
86
88
  embedding?: Record<string, ModelEntry>;
87
89
  generative?: Record<string, ModelEntry>;
88
90
  decision?: Record<string, ModelEntry>;
91
+ calibration?: CalibrationConfig | null;
89
92
  }
90
93
 
91
94
  interface RootConfig {
@@ -288,6 +291,10 @@ function publishEntry(
288
291
  ): void {
289
292
  const { kind, logicalName, entry, entryJson } = desiredEntry;
290
293
  const key = slotKey(kind, logicalName);
294
+ const sourceConfig = desiredEntry.configJson ? JSON.parse(desiredEntry.configJson) : entry;
295
+ setBackendSource(backend, sourceFingerprint(kind, logicalName, sourceConfig));
296
+ for (const extra of extras)
297
+ setBackendSource(extra.backend, sourceFingerprint(extra.kind, extra.logicalName, sourceConfig));
291
298
  // Boot overwrites occupants (the documented contract); a reload replaces only what this
292
299
  // projection installed — or a helper the projection installed under this name, which a config
293
300
  // entry outranks — so genuine application overrides survive.
@@ -503,6 +510,7 @@ async function applyModels(block: ModelsConfig | null | undefined, isBoot: boole
503
510
  }
504
511
  }
505
512
  setModelsConfigHash(installedConfiguration());
513
+ configureCalibration(block?.calibration);
506
514
  }
507
515
 
508
516
  function installedConfiguration(): Record<string, unknown> | undefined {
@@ -0,0 +1,298 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
3
+ import { canonicalJson } from './decision.ts';
4
+ import type { DecisionOutcome } from './types.ts';
5
+
6
+ export const SELECTIVE_THRESHOLDS = [0.5, 0.6, 0.7, 0.8, 0.9, 0.95] as const;
7
+ const ECE_BINS = 10;
8
+ const SCORED_EPSILON = 1e-9;
9
+ const DEFAULT_VOTE_SAMPLES = 5;
10
+ const WILSON_Z = 1.96;
11
+ const GOLDEN_ITERATIONS = 60;
12
+ export const MIN_TEMPERATURE = 0.05;
13
+ export const MAX_TEMPERATURE = 20;
14
+ /** The `truth` of an example whose recorded truth is `noMatch`: counted by operational risk only. */
15
+ export const NO_MATCH_TRUTH = -1;
16
+
17
+ export interface CalibrationParams {
18
+ t: number;
19
+ epsilon: number;
20
+ }
21
+
22
+ export interface Example {
23
+ probabilities: number[];
24
+ truth: number;
25
+ /** Index of the value the decision returned; among tied scores it need not be the first. */
26
+ chosen?: number;
27
+ }
28
+
29
+ export interface SelectivePoint {
30
+ threshold: number;
31
+ count: number;
32
+ coverage: number;
33
+ risk: number | null;
34
+ riskUpper: number | null;
35
+ }
36
+
37
+ export interface ReliabilityBin {
38
+ count: number;
39
+ confidence: number | null;
40
+ accuracy: number | null;
41
+ }
42
+
43
+ export interface Reliability {
44
+ ece: number;
45
+ nll: number;
46
+ bins: ReliabilityBin[];
47
+ /** Among examples whose truth is an allowed value: the population a fit is trained on. */
48
+ conditional: SelectivePoint[];
49
+ /** Among all examples, where a `noMatch` truth above the threshold counts as an error. */
50
+ operational: SelectivePoint[];
51
+ }
52
+
53
+ /** What produced a decision's scores and whose outcomes it answers to; any change is a new population. */
54
+ export interface Population {
55
+ tenant?: string;
56
+ app?: string;
57
+ model: string;
58
+ entry: string;
59
+ signature: string;
60
+ instructionsHash?: string;
61
+ schemaHash: string;
62
+ }
63
+
64
+ export interface FitPolicy {
65
+ minReport: number;
66
+ minTrain: number;
67
+ minHeldOut: number;
68
+ heldOutShare: number;
69
+ eceMargin: number;
70
+ maxAgeMs: number;
71
+ }
72
+
73
+ function digest(value: unknown): string {
74
+ return createHash('sha256').update(canonicalJson(value)).digest('hex');
75
+ }
76
+
77
+ export function populationKey(population: Population): string {
78
+ return digest({
79
+ tenant: population.tenant ?? null,
80
+ app: population.app ?? null,
81
+ model: population.model,
82
+ entry: population.entry,
83
+ signature: population.signature,
84
+ instructionsHash: population.instructionsHash ?? null,
85
+ schemaHash: population.schemaHash,
86
+ });
87
+ }
88
+
89
+ /** Orders a population's decisions newest first under one indexed prefix, so a run reads only the newest it needs. */
90
+ export function populationRank(population: string, expiresAt: number, id: string): string {
91
+ return `${population}|${String(Math.max(0, Math.floor(expiresAt))).padStart(16, '0')}|${id}`;
92
+ }
93
+
94
+ export function calibrationKey(population: string, field: string | undefined): string {
95
+ return digest({ population, field: field ?? null });
96
+ }
97
+
98
+ /** Identical inputs, policy and result give an identical id, so a repeated run writes nothing new. */
99
+ export function fitId(key: string, inputDigest: string, policy: FitPolicy, result: object): string {
100
+ return digest({ key, inputDigest, policy, result });
101
+ }
102
+
103
+ export function policyDigest(policy: FitPolicy): string {
104
+ return digest(policy);
105
+ }
106
+
107
+ export function inputDigester(): { add(part: unknown): void; digest(): string } {
108
+ const hash = createHash('sha256');
109
+ return {
110
+ add(part: unknown) {
111
+ hash.update(canonicalJson(part));
112
+ hash.update('\n');
113
+ },
114
+ digest: () => hash.digest('hex'),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Smoothing mass for a distribution: half a vote per value for a voted distribution, so a value no
120
+ * sample chose keeps a finite log; a numerical guard otherwise.
121
+ */
122
+ export function smoothingEpsilon(signature: string | undefined): number {
123
+ if (!signature || !/(^|;)mode=vote(;|$)/.test(signature)) return SCORED_EPSILON;
124
+ const samples = Number(/(?:^|;)samples=(\d+)(?:;|$)/.exec(signature)?.[1]);
125
+ return 0.5 / (Number.isInteger(samples) && samples > 0 ? samples : DEFAULT_VOTE_SAMPLES);
126
+ }
127
+
128
+ /** A replicated row is not trusted to be well formed. */
129
+ export function isValidParams(params: unknown): params is CalibrationParams {
130
+ if (!params || typeof params !== 'object') return false;
131
+ const { t, epsilon } = params as { t?: unknown; epsilon?: unknown };
132
+ return (
133
+ typeof t === 'number' &&
134
+ Number.isFinite(t) &&
135
+ t >= MIN_TEMPERATURE &&
136
+ t <= MAX_TEMPERATURE &&
137
+ typeof epsilon === 'number' &&
138
+ Number.isFinite(epsilon) &&
139
+ epsilon > 0 &&
140
+ epsilon < 1
141
+ );
142
+ }
143
+
144
+ /** Strictly increasing in each value's raw score, so ties stay tied and the winner never moves. */
145
+ export function applyCalibration(probabilities: number[], params: CalibrationParams): number[] {
146
+ const k = probabilities.length;
147
+ const inverse = 1 / params.t;
148
+ const logs = new Array<number>(k);
149
+ let max = -Infinity;
150
+ for (let i = 0; i < k; i++) {
151
+ logs[i] = Math.log((Math.max(0, probabilities[i]) + params.epsilon) / (1 + k * params.epsilon)) * inverse;
152
+ if (logs[i] > max) max = logs[i];
153
+ }
154
+ let total = 0;
155
+ for (let i = 0; i < k; i++) {
156
+ logs[i] = Math.exp(logs[i] - max);
157
+ total += logs[i];
158
+ }
159
+ for (let i = 0; i < k; i++) logs[i] /= total;
160
+ return logs;
161
+ }
162
+
163
+ function meanNll(examples: Example[], params: CalibrationParams): number {
164
+ let total = 0;
165
+ for (const example of examples) {
166
+ total -= Math.log(Math.max(applyCalibration(example.probabilities, params)[example.truth], 1e-15));
167
+ }
168
+ return total / examples.length;
169
+ }
170
+
171
+ /**
172
+ * The temperature minimizing negative log-likelihood on `train` (examples with an allowed-value truth),
173
+ * by golden-section search over `log t`. Yields between iterations; undefined when `deadline` passes first.
174
+ */
175
+ export async function fitCalibration(
176
+ train: Example[],
177
+ epsilon: number,
178
+ deadline: number,
179
+ now: () => number = Date.now
180
+ ): Promise<CalibrationParams | undefined> {
181
+ const f = (x: number) => meanNll(train, { t: Math.exp(x), epsilon });
182
+ const ratio = (Math.sqrt(5) - 1) / 2;
183
+ let a = Math.log(MIN_TEMPERATURE);
184
+ let b = Math.log(MAX_TEMPERATURE);
185
+ let c = b - ratio * (b - a);
186
+ let d = a + ratio * (b - a);
187
+ let fc = f(c);
188
+ let fd = f(d);
189
+ for (let i = 0; i < GOLDEN_ITERATIONS; i++) {
190
+ await yieldToEventLoop();
191
+ if (now() >= deadline) return undefined;
192
+ if (fc < fd) {
193
+ b = d;
194
+ d = c;
195
+ fd = fc;
196
+ c = b - ratio * (b - a);
197
+ fc = f(c);
198
+ } else {
199
+ a = c;
200
+ c = d;
201
+ fc = fd;
202
+ d = a + ratio * (b - a);
203
+ fd = f(d);
204
+ }
205
+ }
206
+ return { t: Math.exp((a + b) / 2), epsilon };
207
+ }
208
+
209
+ function argmax(p: number[]): number {
210
+ let top = 0;
211
+ for (let i = 1; i < p.length; i++) if (p[i] > p[top]) top = i;
212
+ return top;
213
+ }
214
+
215
+ export function wilsonUpper(errors: number, count: number): number | null {
216
+ if (count === 0) return null;
217
+ const p = errors / count;
218
+ const z2 = WILSON_Z * WILSON_Z;
219
+ const centre = p + z2 / (2 * count);
220
+ const margin = WILSON_Z * Math.sqrt((p * (1 - p)) / count + z2 / (4 * count * count));
221
+ return Math.min(1, (centre + margin) / (1 + z2 / count));
222
+ }
223
+
224
+ function selective(tops: Array<{ confidence: number; correct: boolean }>, total: number): SelectivePoint[] {
225
+ return SELECTIVE_THRESHOLDS.map((threshold) => {
226
+ let count = 0;
227
+ let errors = 0;
228
+ for (const top of tops) {
229
+ if (top.confidence < threshold) continue;
230
+ count++;
231
+ if (!top.correct) errors++;
232
+ }
233
+ return {
234
+ threshold,
235
+ count,
236
+ coverage: total ? count / total : 0,
237
+ risk: count ? errors / count : null,
238
+ riskUpper: wilsonUpper(errors, count),
239
+ };
240
+ });
241
+ }
242
+
243
+ export function reliability(examples: Example[], transform: (p: number[]) => number[]): Reliability {
244
+ const sums = Array.from({ length: ECE_BINS }, () => ({ count: 0, confidence: 0, correct: 0 }));
245
+ const inSet: Array<{ confidence: number; correct: boolean }> = [];
246
+ const all: Array<{ confidence: number; correct: boolean }> = [];
247
+ let nll = 0;
248
+ for (const example of examples) {
249
+ const p = transform(example.probabilities);
250
+ const top = example.chosen ?? argmax(p);
251
+ const confidence = p[top];
252
+ if (example.truth === NO_MATCH_TRUTH) {
253
+ all.push({ confidence, correct: false });
254
+ continue;
255
+ }
256
+ const correct = top === example.truth;
257
+ const bin = sums[Math.min(ECE_BINS - 1, Math.floor(confidence * ECE_BINS))];
258
+ bin.count++;
259
+ bin.confidence += confidence;
260
+ bin.correct += correct ? 1 : 0;
261
+ inSet.push({ confidence, correct });
262
+ all.push({ confidence, correct });
263
+ nll -= Math.log(Math.max(p[example.truth], 1e-15));
264
+ }
265
+ const n = inSet.length;
266
+ let ece = 0;
267
+ for (const bin of sums) if (bin.count && n) ece += Math.abs(bin.confidence - bin.correct) / n;
268
+ return {
269
+ ece,
270
+ nll: n ? nll / n : 0,
271
+ bins: sums.map(({ count, confidence, correct }) => ({
272
+ count,
273
+ confidence: count ? confidence / count : null,
274
+ accuracy: count ? correct / count : null,
275
+ })),
276
+ conditional: selective(inSet, n),
277
+ operational: selective(all, all.length),
278
+ };
279
+ }
280
+
281
+ /** Oldest first; the newest `heldOutShare` is held out, so a refit is judged on decisions newer than any it trained on. */
282
+ export function splitByTime<E>(ordered: E[], heldOutShare: number): { train: E[]; heldOut: E[] } {
283
+ const heldOutCount = Math.floor(ordered.length * heldOutShare);
284
+ return {
285
+ train: ordered.slice(0, ordered.length - heldOutCount),
286
+ heldOut: ordered.slice(ordered.length - heldOutCount),
287
+ };
288
+ }
289
+
290
+ export function toVector(
291
+ distribution: DecisionOutcome[] | undefined,
292
+ values: readonly unknown[]
293
+ ): number[] | undefined {
294
+ if (!Array.isArray(distribution)) return undefined;
295
+ const byValue = new Map(distribution.map((entry) => [canonicalJson(entry.value), entry.probability]));
296
+ const vector = values.map((value) => byValue.get(canonicalJson(value)));
297
+ return vector.every((p) => typeof p === 'number' && Number.isFinite(p)) ? (vector as number[]) : undefined;
298
+ }