@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
@@ -0,0 +1,1167 @@
1
+ import { getDatabases, isReadOnlyMode, table } from '../databases.ts';
2
+ import { contextStorage, transaction } from '../transaction.ts';
3
+ import type { Context } from '../ResourceInterface.ts';
4
+ import harperLogger from '../../utility/logging/harper_logger.ts';
5
+ import { isApplicationPrimaryWorker } from '../../server/threads/manageThreads.js';
6
+ import { recordAction } from '../analytics/write.ts';
7
+ import {
8
+ internalJobOwner,
9
+ registerInternalJobs,
10
+ safeErrorMessage,
11
+ startSchedulerEngine,
12
+ unregisterInternalJobs,
13
+ } from '../scheduler/engine.ts';
14
+ import { allowedValues, canonicalJson, isObjectSchema } from './decision.ts';
15
+ import { DECISION_RETENTION_MS, getDecisionTables, truthKey, type DecisionRow } from './decisionStore.ts';
16
+ import {
17
+ applyCalibration,
18
+ calibrationKey,
19
+ type CalibrationParams,
20
+ type Example,
21
+ type FitPolicy,
22
+ fitCalibration,
23
+ fitId,
24
+ inputDigester,
25
+ isValidParams,
26
+ NO_MATCH_TRUTH,
27
+ type Population,
28
+ policyDigest,
29
+ type Reliability,
30
+ reliability,
31
+ smoothingEpsilon,
32
+ splitByTime,
33
+ toVector,
34
+ } from './calibration.ts';
35
+ import type { Decision, DecisionLeaf, DecisionOutcome, DecisionSchema, FieldDecision, OutcomeTruth } from './types.ts';
36
+
37
+ const log = harperLogger.forComponent('models').conditional;
38
+
39
+ export const CALIBRATIONS_TABLE = 'hdb_model_calibrations';
40
+ const JOB_NAME = 'models-calibration';
41
+ const CACHE_LIMIT = 1024;
42
+ const CACHE_FRESH_MS = 60_000;
43
+ const LOAD_TIMEOUT_MS = 2_000;
44
+ const FAULT_LOG_INTERVAL_MS = 60_000;
45
+ const SCAN_PAGE = 500;
46
+ const YIELD_EVERY = 500;
47
+ const EXAMPLE_OVERHEAD_BYTES = 200;
48
+ const POPULATION_OVERHEAD_BYTES = 1_000;
49
+ const DAY_MS = 24 * 60 * 60 * 1000;
50
+ const RANK_WIDTH = 16;
51
+ const CURSOR_ID = 'discovery/cursor';
52
+ const LIST_BATCH = 16;
53
+
54
+ export interface CalibrationConfig {
55
+ interval?: number;
56
+ maxDecisions?: number;
57
+ maxPopulations?: number;
58
+ maxExamplesPerKey?: number;
59
+ maxBytes?: number;
60
+ maxLoads?: number;
61
+ maxRunMs?: number;
62
+ minReport?: number;
63
+ minTrain?: number;
64
+ minHeldOut?: number;
65
+ heldOutShare?: number;
66
+ eceMargin?: number;
67
+ maxAgeMs?: number;
68
+ }
69
+
70
+ type Settings = Required<CalibrationConfig>;
71
+
72
+ /** What a single run may override: its budgets. The fit policy always comes from configuration. */
73
+ export type CalibrationBudgets = Pick<
74
+ CalibrationConfig,
75
+ 'maxDecisions' | 'maxPopulations' | 'maxExamplesPerKey' | 'maxBytes' | 'maxRunMs'
76
+ >;
77
+
78
+ const BUDGET_KEYS = ['maxDecisions', 'maxPopulations', 'maxExamplesPerKey', 'maxBytes', 'maxRunMs'] as const;
79
+
80
+ function budgetsOnly(budgets: CalibrationBudgets, configured: Settings): CalibrationBudgets {
81
+ const out: CalibrationBudgets = {};
82
+ for (const key of BUDGET_KEYS) {
83
+ const value = budgets?.[key];
84
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
85
+ out[key] = Math.min(value, configured[key]);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ const DEFAULTS: Settings = {
91
+ interval: DAY_MS,
92
+ maxDecisions: 100_000,
93
+ maxPopulations: 1_000,
94
+ maxExamplesPerKey: 5_000,
95
+ maxBytes: 64 * 1024 * 1024,
96
+ maxLoads: 8,
97
+ maxRunMs: 60_000,
98
+ minReport: 20,
99
+ minTrain: 100,
100
+ minHeldOut: 100,
101
+ heldOutShare: 0.3,
102
+ eceMargin: 0.01,
103
+ maxAgeMs: 30 * DAY_MS,
104
+ };
105
+
106
+ export interface CalibrationReport {
107
+ /** `heldOut` when a fit was evaluated; `all` when there were too few labels to fit, so every labelled decision is described. */
108
+ window: 'heldOut' | 'all';
109
+ labelled: number;
110
+ noMatchCount: number;
111
+ trainCount: number;
112
+ heldOutCount: number;
113
+ raw: Reliability;
114
+ calibrated?: Reliability;
115
+ }
116
+
117
+ export interface CalibrationRow extends Population {
118
+ id: string;
119
+ kind: 'fit';
120
+ key: string;
121
+ rank: string;
122
+ population: string;
123
+ field?: string;
124
+ t: number;
125
+ epsilon: number;
126
+ cutoff: number;
127
+ evidenceAt: number;
128
+ fittedAt: number;
129
+ applyUntil: number;
130
+ expiresAt: number;
131
+ eligible: boolean;
132
+ reason?: 'too-few-labels' | 'no-improvement' | 'no-match-schema';
133
+ policy: FitPolicy;
134
+ policyDigest: string;
135
+ inputDigest: string;
136
+ decisions: number;
137
+ labelled: number;
138
+ report?: CalibrationReport;
139
+ }
140
+
141
+ /** One row per population, rewritten each time it is processed: what a run needs to revisit it. */
142
+ interface PopulationHead extends Population {
143
+ id: string;
144
+ kind: 'population';
145
+ population: string;
146
+ owner: string;
147
+ schema: DecisionSchema;
148
+ lastFittedAt: number;
149
+ expiresAt: number;
150
+ }
151
+
152
+ export interface CalibrationRunResult {
153
+ status: 'completed' | 'failed';
154
+ error?: string;
155
+ scanned: number;
156
+ read: number;
157
+ discovered: number;
158
+ processed: number;
159
+ pending: number;
160
+ written: number;
161
+ eligible: number;
162
+ skipped: number;
163
+ failed: number;
164
+ stoppedBy?: 'maxDecisions' | 'maxPopulations' | 'maxRunMs' | 'maxBytes';
165
+ reachedAt?: number;
166
+ durationMs: number;
167
+ }
168
+
169
+ export interface CalibrationSummary {
170
+ model: string;
171
+ field?: string;
172
+ population: string;
173
+ signature: string;
174
+ instructionsHash?: string;
175
+ schemaHash: string;
176
+ fittedAt: number;
177
+ applyUntil: number;
178
+ eligible: boolean;
179
+ applied: boolean;
180
+ reason?: string;
181
+ decisions: number;
182
+ labelled: number;
183
+ t: number;
184
+ report?: CalibrationReport;
185
+ }
186
+
187
+ export const CALIBRATION_ATTRIBUTES = [
188
+ { name: 'id', isPrimaryKey: true },
189
+ { name: 'kind', type: 'string', indexed: true },
190
+ { name: 'key', type: 'string' },
191
+ { name: 'rank', type: 'string', indexed: true },
192
+ { name: 'population', type: 'string' },
193
+ { name: 'field', type: 'string' },
194
+ { name: 'tenant', type: 'string' },
195
+ { name: 'app', type: 'string' },
196
+ { name: 'model', type: 'string' },
197
+ { name: 'entry', type: 'string' },
198
+ { name: 'signature', type: 'string' },
199
+ { name: 'instructionsHash', type: 'string' },
200
+ { name: 'schemaHash', type: 'string' },
201
+ { name: 'schema' },
202
+ { name: 't', type: 'number' },
203
+ { name: 'epsilon', type: 'number' },
204
+ { name: 'cutoff', type: 'number' },
205
+ { name: 'evidenceAt', type: 'number' },
206
+ { name: 'fittedAt', type: 'number' },
207
+ { name: 'lastFittedAt', type: 'number', indexed: true },
208
+ { name: 'owner', type: 'string', indexed: true },
209
+ { name: 'boundary' },
210
+ { name: 'before', type: 'number' },
211
+ { name: 'applyUntil', type: 'number' },
212
+ { name: 'expiresAt', expiresAt: true, indexed: true },
213
+ { name: 'eligible', type: 'boolean' },
214
+ { name: 'reason', type: 'string' },
215
+ { name: 'policy' },
216
+ { name: 'policyDigest', type: 'string' },
217
+ { name: 'inputDigest', type: 'string' },
218
+ { name: 'decisions', type: 'number' },
219
+ { name: 'labelled', type: 'number' },
220
+ { name: 'report' },
221
+ ];
222
+
223
+ let calibrations: any;
224
+
225
+ /** Declared like the decision tables: a read-only node takes what the catalog holds and declares nothing. */
226
+ export function getCalibrationsTable(readOnly = isReadOnlyMode()): any {
227
+ if (calibrations) return calibrations;
228
+ const handle: any = readOnly
229
+ ? getDatabases().system?.[CALIBRATIONS_TABLE]
230
+ : table({
231
+ table: CALIBRATIONS_TABLE,
232
+ database: 'system',
233
+ // Declared positively, like the decision tables: every node must apply the same versions.
234
+ replicate: true,
235
+ audit: true,
236
+ attributes: CALIBRATION_ATTRIBUTES,
237
+ });
238
+ if (handle) {
239
+ handle.loadAsInstance = false;
240
+ calibrations = handle;
241
+ }
242
+ return handle;
243
+ }
244
+
245
+ export function resetCalibrationsTable(): void {
246
+ calibrations = undefined;
247
+ resetCalibrationCache();
248
+ }
249
+
250
+ export function declareCalibrationsTableAtBoot(): void {
251
+ if (isReadOnlyMode()) return;
252
+ try {
253
+ getCalibrationsTable();
254
+ } catch (err) {
255
+ log.warn?.(`models: calibration table could not be declared at boot: ${safeErrorMessage(err)}`);
256
+ }
257
+ }
258
+
259
+ function freshContext(): Context {
260
+ const user = contextStorage.getStore()?.user;
261
+ return (user ? { user } : {}) as Context;
262
+ }
263
+
264
+ let settings: Settings | undefined;
265
+ let activePolicyDigest: string | undefined;
266
+ let registeredBlock: string | undefined;
267
+
268
+ export function isCalibrationEnabled(): boolean {
269
+ return settings !== undefined;
270
+ }
271
+
272
+ function policyOf(config: Settings): FitPolicy {
273
+ return {
274
+ minReport: config.minReport,
275
+ minTrain: config.minTrain,
276
+ minHeldOut: config.minHeldOut,
277
+ heldOutShare: config.heldOutShare,
278
+ eceMargin: config.eceMargin,
279
+ maxAgeMs: config.maxAgeMs,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Apply the `models.calibration` block. Present: fits apply on every worker, and the application primary
285
+ * worker registers the cluster-once job. Absent: nothing applies and the job is removed.
286
+ */
287
+ export function configureCalibration(
288
+ block: CalibrationConfig | null | undefined,
289
+ primary = isApplicationPrimaryWorker(undefined)
290
+ ): void {
291
+ if (!block) {
292
+ resetCalibrationCache();
293
+ settings = undefined;
294
+ activePolicyDigest = undefined;
295
+ registeredBlock = undefined;
296
+ if (primary) unregisterInternalJobs(JOB_NAME);
297
+ return;
298
+ }
299
+ const next = { ...DEFAULTS, ...definedOnly(block) };
300
+ const digest = canonicalJson(next);
301
+ if (settings && canonicalJson(settings) === digest && (registeredBlock === digest || !primary)) return;
302
+ resetCalibrationCache();
303
+ settings = next;
304
+ activePolicyDigest = policyDigest(policyOf(settings));
305
+ if (!primary || isReadOnlyMode()) return;
306
+ registeredBlock = digest;
307
+ registerInternalJobs(JOB_NAME, [
308
+ {
309
+ name: 'calibrate',
310
+ componentName: internalJobOwner(JOB_NAME),
311
+ intervalMs: settings.interval,
312
+ handler: calibrationJob,
313
+ },
314
+ ]);
315
+ startSchedulerEngine();
316
+ }
317
+
318
+ export async function calibrationJob(): Promise<void> {
319
+ const run = await runCalibration();
320
+ if (run.status === 'failed') throw new Error(`calibration run failed: ${run.error}`);
321
+ }
322
+
323
+ function definedOnly(block: CalibrationConfig): CalibrationConfig {
324
+ return Object.fromEntries(Object.entries(block).filter(([, value]) => value !== undefined)) as CalibrationConfig;
325
+ }
326
+
327
+ interface CacheEntry {
328
+ fit: CalibrationRow | null;
329
+ loadedAt: number;
330
+ }
331
+
332
+ const cache = new Map<string, CacheEntry>();
333
+ const loading = new Map<string, object>();
334
+ let generation = 0;
335
+ let outstandingReads = 0;
336
+ let lastFaultLog = 0;
337
+
338
+ export function resetCalibrationCache(): void {
339
+ generation++;
340
+ cache.clear();
341
+ loading.clear();
342
+ }
343
+
344
+ function invalidate(keys: string[]): void {
345
+ if (keys.length === 0) return;
346
+ generation++;
347
+ for (const key of keys) {
348
+ cache.delete(key);
349
+ loading.delete(key);
350
+ }
351
+ }
352
+
353
+ export function outstandingCalibrationReads(): number {
354
+ return outstandingReads;
355
+ }
356
+
357
+ function remember(key: string, fit: CalibrationRow | null): void {
358
+ cache.delete(key);
359
+ cache.set(key, { fit, loadedAt: Date.now() });
360
+ while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value as string);
361
+ }
362
+
363
+ function logFault(what: string, err: unknown): void {
364
+ const now = Date.now();
365
+ if (now - lastFaultLog < FAULT_LOG_INTERVAL_MS) return;
366
+ lastFaultLog = now;
367
+ log.warn?.(`models: ${what}: ${safeErrorMessage(err)}`);
368
+ }
369
+
370
+ function schemaBytes(schema: unknown): number {
371
+ try {
372
+ return (canonicalJson(schema ?? null) ?? '').length;
373
+ } catch {
374
+ return 0;
375
+ }
376
+ }
377
+
378
+ function ownerOf(tenant: string | undefined): string {
379
+ return tenant == null ? 'none' : `t:${tenant}`;
380
+ }
381
+
382
+ function pad(n: number): string {
383
+ return String(Math.max(0, Math.floor(n))).padStart(RANK_WIDTH, '0');
384
+ }
385
+
386
+ function rankOf(key: string, evidenceAt: number, fittedAt: number, id: string): string {
387
+ return `${key}|${pad(evidenceAt)}|${pad(fittedAt)}|${id}`;
388
+ }
389
+
390
+ /** The newest version of a key: the greatest rank within the key's prefix, one descending index read. */
391
+ async function readNewest(key: string): Promise<CalibrationRow | null> {
392
+ const rows = getCalibrationsTable();
393
+ if (!rows) return null;
394
+ return transaction(freshContext(), async () => {
395
+ for await (const row of rows.search({
396
+ conditions: [{ attribute: 'rank', comparator: 'starts_with', value: `${key}|`, descending: true }],
397
+ limit: 1,
398
+ })) {
399
+ return row as CalibrationRow;
400
+ }
401
+ return null;
402
+ });
403
+ }
404
+
405
+ function scheduleLoad(key: string, read: (key: string) => Promise<CalibrationRow | null> = readNewest): void {
406
+ if (loading.has(key) || !settings || outstandingReads >= settings.maxLoads) return;
407
+ const token = {};
408
+ const startedAt = generation;
409
+ loading.set(key, token);
410
+ outstandingReads++;
411
+ const owns = () => loading.get(key) === token && generation === startedAt;
412
+ const timer = setTimeout(() => {
413
+ if (!owns()) return;
414
+ remember(key, null);
415
+ loading.delete(key);
416
+ }, LOAD_TIMEOUT_MS);
417
+ timer.unref?.();
418
+ let pending: Promise<CalibrationRow | null>;
419
+ try {
420
+ pending = read(key);
421
+ } catch (err) {
422
+ pending = Promise.reject(err);
423
+ }
424
+ pending
425
+ .then(
426
+ (row) => {
427
+ if (owns()) remember(key, row);
428
+ },
429
+ (err) => {
430
+ logFault('calibration lookup failed', err);
431
+ if (owns()) remember(key, null);
432
+ }
433
+ )
434
+ .catch(() => {})
435
+ .finally(() => {
436
+ clearTimeout(timer);
437
+ outstandingReads--;
438
+ if (loading.get(key) === token) loading.delete(key);
439
+ });
440
+ }
441
+
442
+ /** @internal — testing only: replace the storage read the cache loads through. */
443
+ let readForLoads: ((key: string) => Promise<CalibrationRow | null>) | undefined;
444
+ export function setCalibrationReadForTests(read: ((key: string) => Promise<CalibrationRow | null>) | undefined): void {
445
+ readForLoads = read;
446
+ }
447
+
448
+ function isUsable(row: CalibrationRow | null | undefined, now: number, key: string): row is CalibrationRow {
449
+ return (
450
+ !!row &&
451
+ row.kind === 'fit' &&
452
+ row.key === key &&
453
+ typeof row.rank === 'string' &&
454
+ row.rank.startsWith(`${key}|`) &&
455
+ typeof row.population === 'string' &&
456
+ calibrationKey(row.population, row.field) === key &&
457
+ row.eligible === true &&
458
+ row.applyUntil > now &&
459
+ row.policyDigest === activePolicyDigest &&
460
+ isValidParams(row)
461
+ );
462
+ }
463
+
464
+ function cachedFit(key: string, now: number): CalibrationRow | undefined {
465
+ const entry = cache.get(key);
466
+ if (!entry) {
467
+ scheduleLoad(key, readForLoads);
468
+ return undefined;
469
+ }
470
+ cache.delete(key);
471
+ cache.set(key, entry);
472
+ if (now - entry.loadedAt >= CACHE_FRESH_MS) scheduleLoad(key, readForLoads);
473
+ return isUsable(entry.fit, now, key) ? entry.fit : undefined;
474
+ }
475
+
476
+ export interface CalibrationSnapshot {
477
+ field?: string;
478
+ fitId: string;
479
+ t: number;
480
+ epsilon: number;
481
+ }
482
+
483
+ export interface AppliedCalibration<T> {
484
+ decision: Omit<Decision<T>, 'id' | 'usage'>;
485
+ rawDistribution?: DecisionOutcome[];
486
+ rawFields?: Record<string, FieldDecision>;
487
+ snapshot: CalibrationSnapshot[];
488
+ }
489
+
490
+ function hasNoMatchLeaf(schema: DecisionSchema): boolean {
491
+ if (isObjectSchema(schema)) return Object.values(schema.properties).some((leaf) => leaf.noMatch === true);
492
+ return (schema as DecisionLeaf).noMatch === true;
493
+ }
494
+
495
+ /**
496
+ * The decision with every distribution replaced by its population's fit, or undefined when any field lacks
497
+ * a cached usable fit or the order would change: all fields or none. Never waits on storage. A schema with
498
+ * a no-match leaf is never fit-calibrated, because its no-match score would stay raw under `calibrated: true`.
499
+ */
500
+ export function applyFits<T>(
501
+ schema: DecisionSchema,
502
+ decision: Omit<Decision<T>, 'id' | 'usage'>,
503
+ population: string,
504
+ now = Date.now()
505
+ ): AppliedCalibration<T> | undefined {
506
+ if (hasNoMatchLeaf(schema)) return undefined;
507
+ if (isObjectSchema(schema)) {
508
+ const fields = decision.fields;
509
+ if (!fields) return undefined;
510
+ const names = Object.keys(schema.properties);
511
+ const fits = names.map((name) => cachedFit(calibrationKey(population, name), now));
512
+ if (fits.some((fit) => !fit)) return undefined;
513
+ const calibrated: Record<string, FieldDecision> = {};
514
+ const snapshot: CalibrationSnapshot[] = [];
515
+ for (let i = 0; i < names.length; i++) {
516
+ const fit = fits[i] as CalibrationRow;
517
+ const marginal = calibrateMarginal(schema.properties[names[i]], fields[names[i]], fit);
518
+ if (!marginal) return undefined;
519
+ calibrated[names[i]] = marginal;
520
+ snapshot.push({ field: names[i], fitId: fit.id, t: fit.t, epsilon: fit.epsilon });
521
+ }
522
+ return { decision: { ...decision, fields: calibrated, calibrated: true }, rawFields: fields, snapshot };
523
+ }
524
+ const fit = cachedFit(calibrationKey(population, undefined), now);
525
+ if (!fit || !decision.distribution) return undefined;
526
+ const marginal = calibrateMarginal(
527
+ schema as DecisionLeaf,
528
+ { value: decision.value, probability: decision.probability as number, distribution: decision.distribution },
529
+ fit
530
+ );
531
+ if (!marginal) return undefined;
532
+ return {
533
+ decision: { ...decision, probability: marginal.probability, distribution: marginal.distribution, calibrated: true },
534
+ rawDistribution: decision.distribution,
535
+ snapshot: [{ fitId: fit.id, t: fit.t, epsilon: fit.epsilon }],
536
+ };
537
+ }
538
+
539
+ function calibrateMarginal(
540
+ leaf: DecisionLeaf,
541
+ marginal: FieldDecision | undefined,
542
+ fit: CalibrationParams
543
+ ): FieldDecision | undefined {
544
+ if (!marginal) return undefined;
545
+ const values = allowedValues(leaf);
546
+ const vector = toVector(marginal.distribution, values);
547
+ if (!vector) return undefined;
548
+ const out = applyCalibration(vector, fit);
549
+ if (!out.every(Number.isFinite)) return undefined;
550
+ const indexByValue = new Map(values.map((value, i) => [canonicalJson(value), i]));
551
+ const distribution = marginal.distribution.map((entry) => ({
552
+ value: entry.value,
553
+ probability: out[indexByValue.get(canonicalJson(entry.value)) as number],
554
+ }));
555
+ for (let i = 1; i < distribution.length; i++)
556
+ if (distribution[i].probability > distribution[i - 1].probability) return undefined;
557
+ if (canonicalJson(distribution[0].value) !== canonicalJson(marginal.value)) return undefined;
558
+ return { value: marginal.value, probability: distribution[0].probability, distribution };
559
+ }
560
+
561
+ export interface RunDeps {
562
+ now?: () => number;
563
+ }
564
+
565
+ let queue: Promise<unknown> = Promise.resolve();
566
+
567
+ /** One run at a time per process. Resolves with the run's result, an unexpected fault included as a failed run. */
568
+ export function runCalibration(budgets: CalibrationBudgets = {}, deps: RunDeps = {}): Promise<CalibrationRunResult> {
569
+ const next = queue.then(() => runOnce(budgets, deps)).catch((err) => failedRun(err));
570
+ queue = next;
571
+ return next;
572
+ }
573
+
574
+ function failedRun(err: unknown): CalibrationRunResult {
575
+ const error = `unexpected fault: ${safeErrorMessage(err)}`;
576
+ log.error?.(`models: calibration run failed ${error}`);
577
+ return {
578
+ status: 'failed',
579
+ error,
580
+ scanned: 0,
581
+ read: 0,
582
+ discovered: 0,
583
+ processed: 0,
584
+ pending: 0,
585
+ written: 0,
586
+ eligible: 0,
587
+ skipped: 0,
588
+ failed: 0,
589
+ durationMs: 0,
590
+ };
591
+ }
592
+
593
+ interface Discovered {
594
+ head: Omit<PopulationHead, 'lastFittedAt' | 'expiresAt'>;
595
+ lastFittedAt?: number;
596
+ expiresAt?: number;
597
+ bytes: number;
598
+ }
599
+
600
+ async function runOnce(budgets: CalibrationBudgets, deps: RunDeps): Promise<CalibrationRunResult> {
601
+ const now = deps.now ?? Date.now;
602
+ const configured: Settings = { ...DEFAULTS, ...settings };
603
+ const config: Settings = { ...configured, ...budgetsOnly(budgets, configured) };
604
+ const policy = policyOf(config);
605
+ const digestOfPolicy = policyDigest(policy);
606
+ const started = now();
607
+ const deadline = started + config.maxRunMs;
608
+ const result: CalibrationRunResult = {
609
+ status: 'completed',
610
+ scanned: 0,
611
+ read: 0,
612
+ discovered: 0,
613
+ processed: 0,
614
+ pending: 0,
615
+ written: 0,
616
+ eligible: 0,
617
+ skipped: 0,
618
+ failed: 0,
619
+ durationMs: 0,
620
+ };
621
+ const { decisions, outcomes } = getDecisionTables();
622
+ const store = getCalibrationsTable();
623
+ if (!decisions || !outcomes || !store) return finish();
624
+
625
+ const populations = new Map<string, Discovered>();
626
+ const writtenKeys: string[] = [];
627
+ let bytes = 0;
628
+ await discover();
629
+ try {
630
+ const discovered = [...populations.values()];
631
+ const known = await transaction(freshContext(), async () => {
632
+ for (const found of discovered) {
633
+ const head: PopulationHead | undefined = await store.get(found.head.id);
634
+ if (head?.lastFittedAt !== undefined) found.lastFittedAt = head.lastFittedAt;
635
+ else await store.put({ ...found.head, lastFittedAt: 0, expiresAt: found.expiresAt ?? now() + policy.maxAgeMs });
636
+ }
637
+ const out: PopulationHead[] = [];
638
+ for await (const row of store.search({
639
+ conditions: [{ attribute: 'kind', value: 'population' }],
640
+ sort: { attribute: 'lastFittedAt' },
641
+ limit: config.maxPopulations,
642
+ }))
643
+ out.push(row);
644
+ return out;
645
+ });
646
+ for (const head of known) {
647
+ if (populations.has(head.population)) continue;
648
+ const size = POPULATION_OVERHEAD_BYTES + schemaBytes(head.schema);
649
+ if (bytes + size > config.maxBytes) {
650
+ result.stoppedBy ??= 'maxBytes';
651
+ break;
652
+ }
653
+ bytes += size;
654
+ const { lastFittedAt, expiresAt, ...rest } = head;
655
+ populations.set(head.population, { head: rest, lastFittedAt, expiresAt, bytes: size });
656
+ }
657
+ } catch (err) {
658
+ fail('reading known populations', err);
659
+ }
660
+ result.discovered = populations.size;
661
+
662
+ const ordered = processingOrder([...populations.values()]);
663
+ const order = ordered.slice(0, config.maxPopulations);
664
+ if (ordered.length > order.length) {
665
+ result.stoppedBy ??= 'maxPopulations';
666
+ result.pending = ordered.length - order.length;
667
+ }
668
+ let allowance = config.maxDecisions - result.scanned;
669
+ for (let i = 0; i < order.length; i++) {
670
+ if (allowance <= 0) {
671
+ result.stoppedBy ??= 'maxDecisions';
672
+ result.pending += order.length - i;
673
+ break;
674
+ }
675
+ if (now() >= deadline) {
676
+ result.stoppedBy ??= 'maxRunMs';
677
+ result.pending += order.length - i;
678
+ break;
679
+ }
680
+ const outcome = await processPopulation(order[i]);
681
+ if (outcome === 'deferred') {
682
+ await rotate(order[i]);
683
+ result.stoppedBy ??= 'maxDecisions';
684
+ result.pending += order.length - i;
685
+ break;
686
+ }
687
+ if (outcome === 'budget') {
688
+ await rotate(order[i]);
689
+ result.stoppedBy ??= 'maxBytes';
690
+ result.pending += order.length - i;
691
+ break;
692
+ }
693
+ if (outcome === 'deadline') {
694
+ await rotate(order[i]);
695
+ result.stoppedBy ??= 'maxRunMs';
696
+ result.pending += order.length - i;
697
+ break;
698
+ }
699
+ result.processed++;
700
+ }
701
+ invalidate(writtenKeys);
702
+ return finish();
703
+
704
+ async function discover(): Promise<void> {
705
+ const decisionBudget = Math.max(1, Math.floor(config.maxDecisions / 2));
706
+ const newest = await scan(undefined, [], Math.floor(decisionBudget / 2), Math.floor(config.maxPopulations / 2));
707
+ if (newest.fault || newest.stopped) return;
708
+ if (newest.end) return saveCursor(undefined, []);
709
+ let cursor: { before?: unknown; boundary?: unknown } | undefined;
710
+ try {
711
+ cursor = await transaction(freshContext(), () => store.get(CURSOR_ID));
712
+ } catch (err) {
713
+ logFault('calibration could not read its discovery cursor', err);
714
+ }
715
+ const saved = typeof cursor?.before === 'number' ? cursor.before : undefined;
716
+ const savedSeen = Array.isArray(cursor?.boundary) ? (cursor.boundary as string[]) : [];
717
+ const resume = saved !== undefined && (newest.before === undefined || saved <= newest.before);
718
+ const from = resume ? saved : newest.before;
719
+ const seen = !resume
720
+ ? newest.boundary
721
+ : saved === newest.before
722
+ ? [...new Set([...savedSeen, ...newest.boundary])]
723
+ : savedSeen;
724
+ const rest = await scan(from, seen, decisionBudget - result.scanned, config.maxPopulations - populations.size);
725
+ if (rest.fault) return;
726
+ if (rest.capped) result.stoppedBy ??= rest.capped;
727
+ else if (!rest.end && !rest.stopped) result.stoppedBy ??= 'maxDecisions';
728
+ return rest.end ? saveCursor(undefined, []) : saveCursor(rest.before, rest.boundary);
729
+ }
730
+
731
+ async function saveCursor(before: number | undefined, boundary: string[]): Promise<void> {
732
+ try {
733
+ await transaction(freshContext(), () =>
734
+ store.put({ id: CURSOR_ID, kind: 'cursor', before, boundary, expiresAt: now() + DECISION_RETENTION_MS })
735
+ );
736
+ } catch (err) {
737
+ logFault('calibration could not save its discovery cursor', err);
738
+ }
739
+ }
740
+
741
+ async function scan(
742
+ start: number | undefined,
743
+ startSeen: string[],
744
+ budget: number,
745
+ populationBudget: number
746
+ ): Promise<{
747
+ before?: number;
748
+ boundary: string[];
749
+ end: boolean;
750
+ stopped: boolean;
751
+ capped?: 'maxPopulations' | 'maxBytes';
752
+ fault?: boolean;
753
+ }> {
754
+ let before = start;
755
+ let taken = 0;
756
+ let added = 0;
757
+ const seenAtBoundary = new Set<string>(start === undefined ? [] : startSeen);
758
+ while (true) {
759
+ if (taken >= budget) return { before, boundary: [...seenAtBoundary], end: false, stopped: false };
760
+ let page: DecisionRow[];
761
+ const requested = Math.min(SCAN_PAGE, budget - taken) + seenAtBoundary.size;
762
+ let returned = 0;
763
+ try {
764
+ page = await transaction(freshContext(), async () => {
765
+ const rows: DecisionRow[] = [];
766
+ const conditions =
767
+ before === undefined
768
+ ? [{ attribute: 'expiresAt', comparator: 'greater_than', value: 0, descending: true }]
769
+ : [{ attribute: 'expiresAt', comparator: 'less_than_equal', value: before, descending: true }];
770
+ for await (const row of decisions.search({
771
+ conditions,
772
+ limit: requested,
773
+ })) {
774
+ returned++;
775
+ if (!seenAtBoundary.has(row.id)) rows.push(row);
776
+ }
777
+ return rows;
778
+ });
779
+ } catch (err) {
780
+ fail('discovering populations', err);
781
+ return { before, boundary: [...seenAtBoundary], end: false, stopped: true, fault: true };
782
+ }
783
+ if (page.length === 0) return { before, boundary: [...seenAtBoundary], end: true, stopped: false };
784
+ for (const row of page) {
785
+ if (taken >= budget) return { before, boundary: [...seenAtBoundary], end: false, stopped: false };
786
+ if (now() >= deadline) {
787
+ result.stoppedBy ??= 'maxRunMs';
788
+ return { before, boundary: [...seenAtBoundary], end: false, stopped: true };
789
+ }
790
+ const isNew = Boolean(row.population && row.signature) && !populations.has(row.population as string);
791
+ let size = 0;
792
+ if (isNew) {
793
+ if (added >= populationBudget)
794
+ return { before, boundary: [...seenAtBoundary], end: false, stopped: false, capped: 'maxPopulations' };
795
+ size = POPULATION_OVERHEAD_BYTES + schemaBytes(row.schema);
796
+ if (bytes + size > config.maxBytes)
797
+ return { before, boundary: [...seenAtBoundary], end: false, stopped: false, capped: 'maxBytes' };
798
+ }
799
+ taken++;
800
+ result.scanned++;
801
+ result.reachedAt = Math.min(result.reachedAt ?? row.at, row.at);
802
+ if (row.expiresAt !== before) seenAtBoundary.clear();
803
+ before = row.expiresAt;
804
+ seenAtBoundary.add(row.id);
805
+ if (result.scanned % YIELD_EVERY === 0) await new Promise((resolve) => setImmediate(resolve));
806
+ if (!isNew) continue;
807
+ added++;
808
+ bytes += size;
809
+ populations.set(row.population as string, {
810
+ bytes: size,
811
+ expiresAt: row.expiresAt,
812
+ head: {
813
+ id: `population/${row.population}`,
814
+ kind: 'population',
815
+ population: row.population as string,
816
+ owner: ownerOf(row.tenant),
817
+ tenant: row.tenant,
818
+ model: row.model,
819
+ entry: row.entry ?? `registered:${row.backend}`,
820
+ signature: row.signature as string,
821
+ instructionsHash: row.instructionsHash,
822
+ schemaHash: row.schemaHash,
823
+ schema: row.schema,
824
+ },
825
+ });
826
+ }
827
+ if (returned < requested) return { before, boundary: [...seenAtBoundary], end: true, stopped: false };
828
+ }
829
+ }
830
+
831
+ async function processPopulation(found: Discovered): Promise<'done' | 'budget' | 'deadline' | 'failed' | 'deferred'> {
832
+ const { head } = found;
833
+ const schema = head.schema;
834
+ let perKey: Array<{
835
+ field: string | undefined;
836
+ values: readonly unknown[];
837
+ examples: Array<Example & { at: number }>;
838
+ digester: ReturnType<typeof inputDigester>;
839
+ evidenceAt: number;
840
+ cutoff: number;
841
+ }>;
842
+ let noMatchSchema: boolean;
843
+ try {
844
+ const leaves: Array<[string | undefined, DecisionLeaf]> = isObjectSchema(schema)
845
+ ? Object.entries(schema.properties)
846
+ : [[undefined, schema as DecisionLeaf]];
847
+ perKey = leaves.map(([field, leaf]) => ({
848
+ field,
849
+ values: allowedValues(leaf),
850
+ examples: [],
851
+ digester: inputDigester(),
852
+ evidenceAt: 0,
853
+ cutoff: 0,
854
+ }));
855
+ noMatchSchema = hasNoMatchLeaf(schema);
856
+ } catch (err) {
857
+ result.failed++;
858
+ logFault('calibration found a malformed population', err);
859
+ try {
860
+ const touchedAt = now();
861
+ await transaction(freshContext(), () =>
862
+ store.put({ ...head, lastFittedAt: touchedAt, expiresAt: found.expiresAt ?? touchedAt + policy.maxAgeMs })
863
+ );
864
+ } catch {}
865
+ return 'failed';
866
+ }
867
+ const rowBytes = perKey.reduce((sum, key) => sum + EXAMPLE_OVERHEAD_BYTES + key.values.length * 8, 0);
868
+ const sample = Math.min(
869
+ config.maxExamplesPerKey,
870
+ Math.max(1, Math.floor((configured.maxBytes - found.bytes) / rowBytes))
871
+ );
872
+ const limit = Math.min(sample, allowance + 1);
873
+ let rows: DecisionRow[];
874
+ let used = found.bytes;
875
+ let newestExpiry = 0;
876
+ try {
877
+ rows = await transaction(freshContext(), async () => {
878
+ const out: DecisionRow[] = [];
879
+ for await (const row of decisions.search({
880
+ conditions: [
881
+ { attribute: 'populationRank', comparator: 'starts_with', value: `${head.population}|`, descending: true },
882
+ ],
883
+ limit,
884
+ })) {
885
+ out.push(row);
886
+ }
887
+ return out;
888
+ });
889
+ const available = allowance;
890
+ allowance -= rows.length;
891
+ result.read += rows.length;
892
+ if (rows.length > available && available < sample) return 'deferred';
893
+ rows.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
894
+ for (let start = 0; start < rows.length; start += YIELD_EVERY) {
895
+ if (start > 0) {
896
+ await new Promise((resolve) => setImmediate(resolve));
897
+ if (now() >= deadline) return 'deadline';
898
+ }
899
+ const chunk = rows.slice(start, start + YIELD_EVERY);
900
+ const chunkFacts = await transaction(freshContext(), () =>
901
+ Promise.all(chunk.flatMap((row) => perKey.map((key) => outcomes.get(truthKey(row.id, key.field)))))
902
+ );
903
+ for (let r = 0; r < chunk.length; r++) {
904
+ const row = chunk[r];
905
+ if (row.expiresAt > newestExpiry) newestExpiry = row.expiresAt;
906
+ const facts = chunkFacts.slice(r * perKey.length, (r + 1) * perKey.length);
907
+ for (let i = 0; i < perKey.length; i++) {
908
+ const key = perKey[i];
909
+ const distribution =
910
+ key.field === undefined
911
+ ? (row.rawDistribution ?? row.distribution)
912
+ : (row.rawFields ?? row.fields)?.[key.field]?.distribution;
913
+ const probabilities = toVector(distribution as DecisionOutcome[] | undefined, key.values);
914
+ const fact = facts[i] as { state?: OutcomeTruth; at?: number } | undefined;
915
+ key.digester.add([row.id, probabilities ?? null, fact?.state ?? null, fact?.at ?? null]);
916
+ key.evidenceAt = Math.max(key.evidenceAt, row.at, fact?.at ?? 0);
917
+ if (!probabilities || !fact?.state || fact.state.kind === 'unknown') continue;
918
+ let truth: number;
919
+ if (fact.state.kind === 'noMatch') truth = NO_MATCH_TRUTH;
920
+ else {
921
+ const expected = canonicalJson((fact.state as { value: unknown }).value);
922
+ truth = key.values.findIndex((value) => canonicalJson(value) === expected);
923
+ if (truth < 0) continue;
924
+ }
925
+ used += EXAMPLE_OVERHEAD_BYTES + probabilities.length * 8;
926
+ if (used > config.maxBytes) return 'budget';
927
+ const returned = canonicalJson(
928
+ key.field === undefined ? row.value : (row.value as Record<string, unknown> | undefined)?.[key.field]
929
+ );
930
+ const chosen = key.values.findIndex((value) => canonicalJson(value) === returned);
931
+ key.examples.push({ probabilities, truth, at: row.at, chosen: chosen >= 0 ? chosen : undefined });
932
+ key.cutoff = row.at;
933
+ }
934
+ }
935
+ }
936
+ } catch (err) {
937
+ result.failed++;
938
+ logFault('calibration could not read a population', err);
939
+ return 'failed';
940
+ }
941
+
942
+ const epsilon = smoothingEpsilon(head.signature);
943
+ const fittedAt = now();
944
+ const written: CalibrationRow[] = [];
945
+ try {
946
+ for (const key of perKey) {
947
+ const keyId = calibrationKey(head.population, key.field);
948
+ const inputDigest = key.digester.digest();
949
+ const previous = await readNewest(keyId);
950
+ if (previous && previous.inputDigest === inputDigest && previous.policyDigest === digestOfPolicy) {
951
+ result.skipped++;
952
+ continue;
953
+ }
954
+ const labelled = key.examples.length;
955
+ if (!previous && labelled < policy.minReport) {
956
+ result.skipped++;
957
+ continue;
958
+ }
959
+ const inSet = key.examples.filter((example) => example.truth !== NO_MATCH_TRUTH);
960
+ const { train, heldOut } = splitByTime(key.examples, policy.heldOutShare);
961
+ const trainInSet = train.filter((example) => example.truth !== NO_MATCH_TRUTH);
962
+ const heldOutInSet = heldOut.filter((example) => example.truth !== NO_MATCH_TRUTH);
963
+ const enough = trainInSet.length >= policy.minTrain && heldOutInSet.length >= policy.minHeldOut;
964
+ let params: CalibrationParams | undefined;
965
+ if (enough && !noMatchSchema) {
966
+ params = await fitCalibration(trainInSet, epsilon, deadline, now);
967
+ if (!params) return 'deadline';
968
+ }
969
+ const identity = (p: number[]) => p;
970
+ let report: CalibrationReport | undefined;
971
+ let improves = false;
972
+ if (params) {
973
+ const fitted = params;
974
+ const raw = reliability(heldOut, identity);
975
+ const calibrated = reliability(heldOut, (p) => applyCalibration(p, fitted));
976
+ improves = calibrated.ece <= raw.ece - policy.eceMargin && calibrated.nll <= raw.nll;
977
+ report = {
978
+ window: 'heldOut',
979
+ labelled,
980
+ noMatchCount: labelled - inSet.length,
981
+ trainCount: trainInSet.length,
982
+ heldOutCount: heldOutInSet.length,
983
+ raw,
984
+ calibrated,
985
+ };
986
+ } else if (labelled >= policy.minReport) {
987
+ report = {
988
+ window: 'all',
989
+ labelled,
990
+ noMatchCount: labelled - inSet.length,
991
+ trainCount: 0,
992
+ heldOutCount: 0,
993
+ raw: reliability(key.examples, identity),
994
+ };
995
+ }
996
+ const eligible = Boolean(params) && improves;
997
+ const reason: CalibrationRow['reason'] = noMatchSchema
998
+ ? 'no-match-schema'
999
+ : !params
1000
+ ? 'too-few-labels'
1001
+ : improves
1002
+ ? undefined
1003
+ : 'no-improvement';
1004
+ const t = params?.t ?? 1;
1005
+ const id = fitId(keyId, inputDigest, policy, { t, epsilon, eligible, reason: reason ?? null });
1006
+ if (previous?.id === id) {
1007
+ result.skipped++;
1008
+ continue;
1009
+ }
1010
+ const applyUntil = fittedAt + policy.maxAgeMs;
1011
+ const evidenceAt = Math.max(key.evidenceAt, (previous?.evidenceAt ?? 0) + 1);
1012
+ const row: CalibrationRow = {
1013
+ id,
1014
+ kind: 'fit',
1015
+ key: keyId,
1016
+ rank: rankOf(keyId, evidenceAt, fittedAt, id),
1017
+ population: head.population,
1018
+ field: key.field,
1019
+ tenant: head.tenant,
1020
+ model: head.model,
1021
+ entry: head.entry,
1022
+ signature: head.signature,
1023
+ instructionsHash: head.instructionsHash,
1024
+ schemaHash: head.schemaHash,
1025
+ t,
1026
+ epsilon,
1027
+ cutoff: key.cutoff,
1028
+ evidenceAt,
1029
+ fittedAt,
1030
+ applyUntil,
1031
+ expiresAt: applyUntil + DECISION_RETENTION_MS,
1032
+ eligible,
1033
+ reason,
1034
+ policy,
1035
+ policyDigest: digestOfPolicy,
1036
+ inputDigest,
1037
+ decisions: rows.length,
1038
+ labelled,
1039
+ report,
1040
+ };
1041
+ written.push(row);
1042
+ }
1043
+ const headRow: PopulationHead = {
1044
+ ...head,
1045
+ owner: ownerOf(head.tenant),
1046
+ lastFittedAt: fittedAt,
1047
+ expiresAt: rows.length
1048
+ ? Math.max(newestExpiry, fittedAt + policy.maxAgeMs)
1049
+ : (found.expiresAt ?? fittedAt + policy.maxAgeMs),
1050
+ };
1051
+ await transaction(freshContext(), async () => {
1052
+ for (const row of written) await store.put(row);
1053
+ await store.put(headRow);
1054
+ });
1055
+ } catch (err) {
1056
+ result.failed++;
1057
+ logFault('calibration could not fit a population', err);
1058
+ return 'failed';
1059
+ }
1060
+ for (const row of written) writtenKeys.push(row.key);
1061
+ result.written += written.length;
1062
+ result.eligible += written.filter((row) => row.eligible).length;
1063
+ return 'done';
1064
+ }
1065
+
1066
+ /** A population this run could not fit goes to the back of the queue, so it cannot hold up the ones behind it. */
1067
+ async function rotate(found: Discovered): Promise<void> {
1068
+ try {
1069
+ const at = now();
1070
+ await transaction(freshContext(), () =>
1071
+ store.put({ ...found.head, lastFittedAt: at, expiresAt: Math.max(found.expiresAt ?? 0, at + policy.maxAgeMs) })
1072
+ );
1073
+ } catch (err) {
1074
+ logFault('calibration could not requeue a population', err);
1075
+ }
1076
+ }
1077
+
1078
+ function fail(what: string, err: unknown): void {
1079
+ result.status = 'failed';
1080
+ result.error = `${what}: ${safeErrorMessage(err)}`;
1081
+ log.error?.(`models: calibration run failed ${result.error}`);
1082
+ }
1083
+
1084
+ function finish(): CalibrationRunResult {
1085
+ if (result.failed > 0 && result.status === 'completed') {
1086
+ result.status = 'failed';
1087
+ result.error = `${result.failed} population(s) could not be processed`;
1088
+ }
1089
+ result.durationMs = now() - started;
1090
+ try {
1091
+ recordAction(result.durationMs, 'model-calibrate', undefined, result.status);
1092
+ } catch {}
1093
+ log.info?.(
1094
+ `models: calibration run ${result.status}: scanned ${result.scanned} decisions, read ${result.read} for fitting, ${result.discovered} populations, processed ${result.processed}, pending ${result.pending}, wrote ${result.written} (${result.eligible} eligible), skipped ${result.skipped}, failed ${result.failed}${result.stoppedBy ? `, stopped by ${result.stoppedBy}` : ''} in ${result.durationMs} ms`
1095
+ );
1096
+ return result;
1097
+ }
1098
+ }
1099
+
1100
+ export function processingOrder<P extends { head: { population: string }; lastFittedAt?: number }>(
1101
+ populations: P[]
1102
+ ): P[] {
1103
+ return [...populations].sort(
1104
+ (a, b) => (a.lastFittedAt ?? -1) - (b.lastFittedAt ?? -1) || a.head.population.localeCompare(b.head.population)
1105
+ );
1106
+ }
1107
+
1108
+ export async function listCalibrations(
1109
+ tenant: string | undefined,
1110
+ filter: { model?: string } = {}
1111
+ ): Promise<CalibrationSummary[]> {
1112
+ const store = getCalibrationsTable();
1113
+ if (!store) return [];
1114
+ const now = Date.now();
1115
+ const out: CalibrationSummary[] = [];
1116
+ const owned: PopulationHead[] = await transaction(freshContext(), async () => {
1117
+ const rows: PopulationHead[] = [];
1118
+ for await (const row of store.search({
1119
+ conditions: [
1120
+ { attribute: 'owner', value: ownerOf(tenant) },
1121
+ { attribute: 'kind', value: 'population' },
1122
+ ...(filter.model === undefined ? [] : [{ attribute: 'model', value: filter.model }]),
1123
+ ],
1124
+ }))
1125
+ rows.push(row);
1126
+ return rows;
1127
+ });
1128
+ const keys: string[] = [];
1129
+ for (const head of owned) {
1130
+ if ((head.tenant ?? null) !== (tenant ?? null)) continue;
1131
+ if (filter.model !== undefined && head.model !== filter.model) continue;
1132
+ try {
1133
+ const fields: Array<string | undefined> = isObjectSchema(head.schema)
1134
+ ? Object.keys(head.schema.properties)
1135
+ : [undefined];
1136
+ for (const field of fields) keys.push(calibrationKey(head.population, field));
1137
+ } catch {}
1138
+ }
1139
+ const newest: Array<CalibrationRow | null> = [];
1140
+ for (let i = 0; i < keys.length; i += LIST_BATCH) {
1141
+ newest.push(...(await Promise.all(keys.slice(i, i + LIST_BATCH).map((key) => readNewest(key)))));
1142
+ }
1143
+ {
1144
+ for (let i = 0; i < newest.length; i++) {
1145
+ const row = newest[i];
1146
+ if (!row) continue;
1147
+ out.push({
1148
+ model: row.model,
1149
+ field: row.field,
1150
+ population: row.population,
1151
+ signature: row.signature,
1152
+ instructionsHash: row.instructionsHash,
1153
+ schemaHash: row.schemaHash,
1154
+ fittedAt: row.fittedAt,
1155
+ applyUntil: row.applyUntil,
1156
+ eligible: row.eligible,
1157
+ applied: isUsable(row, now, keys[i]),
1158
+ reason: row.reason,
1159
+ decisions: row.decisions,
1160
+ labelled: row.labelled,
1161
+ t: row.t,
1162
+ report: row.report,
1163
+ });
1164
+ }
1165
+ }
1166
+ return out.sort((a, b) => b.fittedAt - a.fittedAt);
1167
+ }