rcs-context 0.0.0-stage → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +21 -0
- package/README.md +204 -2
- package/condense/chunk.mjs +258 -0
- package/condense/ivr.mjs +104 -0
- package/contextinjector-webui/README.md +43 -0
- package/contextinjector-webui/lib/client.js +3034 -0
- package/contextinjector-webui/lib/index.mjs +1565 -0
- package/contextinjector-webui/lib/trace-read.mjs +180 -0
- package/contextinjector-webui/package.json +21 -0
- package/cordis.patch.yml +25 -0
- package/package.json +67 -4
- package/toolfold/toolfold.mjs +513 -0
- package/tools/agg-cost.mjs +78 -0
- package/tools/measure-prompt-surface.mjs +236 -0
- package/tools/session-econ.mjs +133 -0
- package/web/contextinjector.mjs +3139 -0
- package/web/extreason.mjs +1107 -0
- package/web/ledger-online.mjs +229 -0
- package/web/logcompiler.mjs +369 -0
- package/web/memory-durability.mjs +431 -0
- package/web/memory-janitor.mjs +551 -0
- package/web/memory-ledger-core.mjs +1331 -0
- package/web/memory-ledger.mjs +836 -0
- package/web/memory-observations.mjs +430 -0
- package/web/memory-online-bridge.mjs +688 -0
- package/web/memory-proposal-store.mjs +1054 -0
- package/web/memory-proposals.mjs +2306 -0
- package/web/memory-trace.mjs +533 -0
- package/web/memory-vec.mjs +241 -0
- package/web/memory.mjs +5801 -0
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalJson, routeEvidenceValid, routeRowValid, sha256Canonical,
|
|
3
|
+
} from './memory-ledger-core.mjs'
|
|
4
|
+
import { PROPOSAL_PREDICATES } from './memory-proposals.mjs'
|
|
5
|
+
|
|
6
|
+
// The derived threshold lives in the core so the journal and the ledger's read gate
|
|
7
|
+
// cannot drift. Re-exported here because this module remains the journal's public
|
|
8
|
+
// surface for route capability.
|
|
9
|
+
export { routeEnabled } from './memory-ledger-core.mjs'
|
|
10
|
+
|
|
11
|
+
const RUN_STATUSES = new Set(['succeeded', 'failed'])
|
|
12
|
+
const FAILURE_KINDS = new Set(['transient', 'permanent', 'unknown'])
|
|
13
|
+
const ITEM_VERDICTS = new Set(['accepted', 'candidate', 'rejected'])
|
|
14
|
+
const APPLICATION_STATUSES = new Set(['applied', 'failed', 'closed'])
|
|
15
|
+
const RUN_FIELDS = new Set([
|
|
16
|
+
'runId', 'scope', 'inputHash', 'model', 'modelDigest', 'schemaVersion', 'status',
|
|
17
|
+
'attempt', 'failureKind', 'rawOutput', 'error', 'createdAt', 'evidenceEventKeys', 'items',
|
|
18
|
+
])
|
|
19
|
+
const ITEM_FIELDS = new Set(['verdict', 'predicate', 'payload', 'errors', 'reason'])
|
|
20
|
+
const APPLICATION_FIELDS = new Set([
|
|
21
|
+
'runId', 'scope', 'status', 'assertionCount', 'error', 'updatedAt',
|
|
22
|
+
])
|
|
23
|
+
const ROUTE_FIELDS = new Set([
|
|
24
|
+
'scope', 'model', 'modelDigest', 'predicate', 'schemaVersion', 'sampleCount', 'precision',
|
|
25
|
+
'safetyFailures', 'evidenceJson', 'updatedAt',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
// Stable code for a run identity that is bound to a different scope. Callers
|
|
29
|
+
// must never receive foreign-scope runs or items as if they were their own.
|
|
30
|
+
export const PROPOSAL_SCOPE_MISMATCH = 'PROPOSAL_SCOPE_MISMATCH'
|
|
31
|
+
|
|
32
|
+
export class ProposalScopeMismatchError extends Error {
|
|
33
|
+
constructor(runId, storedScope, requestedScope) {
|
|
34
|
+
super(`proposal run ${runId} belongs to scope ${storedScope}, not ${requestedScope}`)
|
|
35
|
+
this.name = 'ProposalScopeMismatchError'
|
|
36
|
+
this.code = PROPOSAL_SCOPE_MISMATCH
|
|
37
|
+
this.runId = runId
|
|
38
|
+
this.storedScope = storedScope
|
|
39
|
+
this.requestedScope = requestedScope
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nonEmptyString(value) {
|
|
44
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requireNonEmpty(name, value) {
|
|
48
|
+
if (!nonEmptyString(value)) throw new TypeError(`${name} must be a non-empty string`)
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireFinite(name, value) {
|
|
53
|
+
if (!Number.isFinite(value)) throw new TypeError(`${name} must be a finite number`)
|
|
54
|
+
return value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function requireCount(name, value) {
|
|
58
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
59
|
+
throw new TypeError(`${name} must be a non-negative integer`)
|
|
60
|
+
}
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isPlainObject(value) {
|
|
65
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
66
|
+
const prototype = Object.getPrototypeOf(value)
|
|
67
|
+
return prototype === Object.prototype || prototype === null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rejectUnknownFields(fields, allowed, label) {
|
|
71
|
+
for (const key of Object.keys(fields)) {
|
|
72
|
+
if (!allowed.has(key)) throw new TypeError(`unknown ${label} field: ${key}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeItem(item, index) {
|
|
77
|
+
if (!isPlainObject(item)) throw new TypeError(`proposal item ${index} must be an object`)
|
|
78
|
+
rejectUnknownFields(item, ITEM_FIELDS, 'proposal item')
|
|
79
|
+
if (!ITEM_VERDICTS.has(item.verdict)) {
|
|
80
|
+
throw new TypeError(`proposal item ${index} verdict must be accepted, candidate, or rejected`)
|
|
81
|
+
}
|
|
82
|
+
const predicate = item.predicate ?? null
|
|
83
|
+
if (predicate !== null) {
|
|
84
|
+
requireNonEmpty(`proposal item ${index} predicate`, predicate)
|
|
85
|
+
// A rejected row records what the model actually emitted, which may be outside
|
|
86
|
+
// the proposal vocabulary (the engine discovers that only after parsing). An
|
|
87
|
+
// accepted or candidate row is executable state and must name a proposal
|
|
88
|
+
// predicate.
|
|
89
|
+
if (item.verdict !== 'rejected' && !PROPOSAL_PREDICATES.has(predicate)) {
|
|
90
|
+
throw new TypeError(`proposal item ${index} predicate is not a proposal predicate`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const payload = item.payload === undefined ? null : item.payload
|
|
94
|
+
// Canonicalize before the transaction starts: a malformed payload must never
|
|
95
|
+
// leave a run row without its items.
|
|
96
|
+
canonicalJson(payload)
|
|
97
|
+
const errors = item.errors === undefined ? [] : item.errors
|
|
98
|
+
if (!Array.isArray(errors) || errors.some((value) => typeof value !== 'string')) {
|
|
99
|
+
throw new TypeError(`proposal item ${index} errors must be an array of strings`)
|
|
100
|
+
}
|
|
101
|
+
const reason = item.reason ?? null
|
|
102
|
+
if (reason !== null && typeof reason !== 'string') {
|
|
103
|
+
throw new TypeError(`proposal item ${index} reason must be a string or null`)
|
|
104
|
+
}
|
|
105
|
+
return { itemIndex: index, verdict: item.verdict, predicate, payload, errors, reason }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeRun(input) {
|
|
109
|
+
if (!isPlainObject(input)) throw new TypeError('recordRun requires a plain object')
|
|
110
|
+
rejectUnknownFields(input, RUN_FIELDS, 'run')
|
|
111
|
+
const runId = requireNonEmpty('runId', input.runId)
|
|
112
|
+
const scope = requireNonEmpty('scope', input.scope)
|
|
113
|
+
const inputHash = requireNonEmpty('inputHash', input.inputHash)
|
|
114
|
+
const model = requireNonEmpty('model', input.model)
|
|
115
|
+
const modelDigest = requireNonEmpty('modelDigest', input.modelDigest)
|
|
116
|
+
const schemaVersion = requireNonEmpty('schemaVersion', input.schemaVersion)
|
|
117
|
+
if (!RUN_STATUSES.has(input.status)) throw new TypeError('run status must be succeeded or failed')
|
|
118
|
+
const createdAt = requireFinite('createdAt', input.createdAt)
|
|
119
|
+
|
|
120
|
+
// Attempts are explicit so retries never overwrite earlier evidence. A replay
|
|
121
|
+
// of the same run ID is still recognised as the same attempt.
|
|
122
|
+
const attempt = input.attempt === undefined ? 1 : input.attempt
|
|
123
|
+
if (!Number.isInteger(attempt) || attempt < 1) {
|
|
124
|
+
throw new TypeError('attempt must be a positive integer')
|
|
125
|
+
}
|
|
126
|
+
const failureKind = input.failureKind ?? null
|
|
127
|
+
if (failureKind !== null && !FAILURE_KINDS.has(failureKind)) {
|
|
128
|
+
throw new TypeError('failureKind must be transient, permanent, unknown, or null')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const rawOutput = input.rawOutput ?? null
|
|
132
|
+
if (rawOutput !== null && typeof rawOutput !== 'string') {
|
|
133
|
+
throw new TypeError('rawOutput must be a string or null')
|
|
134
|
+
}
|
|
135
|
+
const error = input.error ?? null
|
|
136
|
+
if (error !== null && !nonEmptyString(error)) {
|
|
137
|
+
throw new TypeError('error must be a non-empty string or null')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const rawItems = input.items === undefined ? [] : input.items
|
|
141
|
+
if (!Array.isArray(rawItems)) throw new TypeError('run items must be an array')
|
|
142
|
+
|
|
143
|
+
if (input.status === 'succeeded') {
|
|
144
|
+
if (typeof rawOutput !== 'string') throw new TypeError('succeeded runs require raw output')
|
|
145
|
+
if (error !== null) throw new TypeError('succeeded runs cannot carry an error')
|
|
146
|
+
if (failureKind !== null) throw new TypeError('succeeded runs cannot carry a failure kind')
|
|
147
|
+
} else {
|
|
148
|
+
if (error === null) throw new TypeError('failed runs require an error')
|
|
149
|
+
if (failureKind === null) throw new TypeError('failed runs require a failure kind')
|
|
150
|
+
// A failed attempt has no validated proposal set; allowing items here would
|
|
151
|
+
// let unvalidated model output masquerade as reviewed verdicts.
|
|
152
|
+
if (rawItems.length > 0) throw new TypeError('failed runs carry no proposal items')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const items = rawItems.map((item, index) => normalizeItem(item, index))
|
|
156
|
+
// Evidence-set identity is independent of active facts. Persisting it lets the
|
|
157
|
+
// engine reuse a prior proposal for the same archived messages even after the
|
|
158
|
+
// active fact set changes (for example after a retraction).
|
|
159
|
+
const rawEvidenceKeys = input.evidenceEventKeys === undefined ? [] : input.evidenceEventKeys
|
|
160
|
+
if (!Array.isArray(rawEvidenceKeys) ||
|
|
161
|
+
rawEvidenceKeys.some((value) => typeof value !== 'string' || value.trim() === '')) {
|
|
162
|
+
throw new TypeError('evidenceEventKeys must be an array of non-empty strings')
|
|
163
|
+
}
|
|
164
|
+
const evidenceEventKeys = [...new Set(rawEvidenceKeys)].sort()
|
|
165
|
+
return {
|
|
166
|
+
runId, scope, inputHash, model, modelDigest, schemaVersion,
|
|
167
|
+
status: input.status, attempt, failureKind, rawOutput, error, createdAt,
|
|
168
|
+
evidenceEventKeys, items,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeApplication(input) {
|
|
173
|
+
if (!isPlainObject(input)) throw new TypeError('recordApplication requires a plain object')
|
|
174
|
+
rejectUnknownFields(input, APPLICATION_FIELDS, 'application')
|
|
175
|
+
const runId = requireNonEmpty('runId', input.runId)
|
|
176
|
+
const scope = requireNonEmpty('scope', input.scope)
|
|
177
|
+
if (!APPLICATION_STATUSES.has(input.status)) {
|
|
178
|
+
throw new TypeError('application status must be applied, failed, or closed')
|
|
179
|
+
}
|
|
180
|
+
const assertionCount = requireCount('assertionCount', input.assertionCount)
|
|
181
|
+
const updatedAt = requireFinite('updatedAt', input.updatedAt)
|
|
182
|
+
const error = input.error ?? null
|
|
183
|
+
if (input.status === 'applied' && error !== null) {
|
|
184
|
+
throw new TypeError('applied applications cannot carry an error')
|
|
185
|
+
}
|
|
186
|
+
if ((input.status === 'failed' || input.status === 'closed') && !nonEmptyString(error)) {
|
|
187
|
+
throw new TypeError(`${input.status} applications require a reason`)
|
|
188
|
+
}
|
|
189
|
+
return { runId, scope, status: input.status, assertionCount, error, updatedAt }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeRoute(input) {
|
|
193
|
+
if (!isPlainObject(input)) throw new TypeError('setRoute requires a plain object')
|
|
194
|
+
rejectUnknownFields(input, ROUTE_FIELDS, 'route')
|
|
195
|
+
const scope = requireNonEmpty('scope', input.scope)
|
|
196
|
+
const model = requireNonEmpty('model', input.model)
|
|
197
|
+
const modelDigest = requireNonEmpty('modelDigest', input.modelDigest)
|
|
198
|
+
const predicate = requireNonEmpty('predicate', input.predicate)
|
|
199
|
+
if (!PROPOSAL_PREDICATES.has(predicate)) {
|
|
200
|
+
throw new TypeError('predicate is not a proposal predicate')
|
|
201
|
+
}
|
|
202
|
+
const schemaVersion = requireNonEmpty('schemaVersion', input.schemaVersion)
|
|
203
|
+
const sampleCount = requireCount('sampleCount', input.sampleCount)
|
|
204
|
+
const precision = requireFinite('precision', input.precision)
|
|
205
|
+
if (precision < 0 || precision > 1) throw new TypeError('precision must be between 0 and 1')
|
|
206
|
+
const safetyFailures = requireCount('safetyFailures', input.safetyFailures)
|
|
207
|
+
const updatedAt = requireFinite('updatedAt', input.updatedAt)
|
|
208
|
+
|
|
209
|
+
// Route counters are not caller-entered evidence. They are required to be the
|
|
210
|
+
// exact arithmetic result of an evaluation row that names its corpus, evaluator,
|
|
211
|
+
// and model digest. A hand-entered 20/0.95/0 row without this evidence is refused.
|
|
212
|
+
if (input.evidenceJson === undefined || input.evidenceJson === null) {
|
|
213
|
+
throw new TypeError('setRoute requires evidenceJson counters')
|
|
214
|
+
}
|
|
215
|
+
let evidence
|
|
216
|
+
if (typeof input.evidenceJson === 'string') {
|
|
217
|
+
try {
|
|
218
|
+
evidence = JSON.parse(input.evidenceJson)
|
|
219
|
+
} catch {
|
|
220
|
+
throw new TypeError('evidenceJson must be valid JSON')
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
evidence = input.evidenceJson
|
|
224
|
+
}
|
|
225
|
+
canonicalJson(evidence)
|
|
226
|
+
if (!routeEvidenceValid(evidence)) {
|
|
227
|
+
throw new TypeError('evidenceJson does not match its evaluation counters')
|
|
228
|
+
}
|
|
229
|
+
if (evidence.modelDigest !== modelDigest) {
|
|
230
|
+
throw new TypeError('evidenceJson modelDigest must match the route digest')
|
|
231
|
+
}
|
|
232
|
+
if (sampleCount !== evidence.positiveLabels) {
|
|
233
|
+
throw new TypeError('sampleCount must equal the evidence positiveLabels')
|
|
234
|
+
}
|
|
235
|
+
if (safetyFailures !== evidence.safetyFailures) {
|
|
236
|
+
throw new TypeError('safetyFailures must equal the evidence safetyFailures')
|
|
237
|
+
}
|
|
238
|
+
if (precision !== evidence.tp / (evidence.tp + evidence.fp)) {
|
|
239
|
+
throw new TypeError('precision must equal tp / (tp + fp) from the evidence')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
scope, model, modelDigest, predicate, schemaVersion, sampleCount, precision,
|
|
244
|
+
safetyFailures, evidence, updatedAt,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function proposalIdFor(run, index) {
|
|
249
|
+
return sha256Canonical({
|
|
250
|
+
runId: run.runId,
|
|
251
|
+
scope: run.scope,
|
|
252
|
+
inputHash: run.inputHash,
|
|
253
|
+
model: run.model,
|
|
254
|
+
modelDigest: run.modelDigest,
|
|
255
|
+
schemaVersion: run.schemaVersion,
|
|
256
|
+
index,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function runSignature(run, items) {
|
|
261
|
+
return canonicalJson({
|
|
262
|
+
runId: run.runId,
|
|
263
|
+
scope: run.scope,
|
|
264
|
+
inputHash: run.inputHash,
|
|
265
|
+
model: run.model,
|
|
266
|
+
modelDigest: run.modelDigest,
|
|
267
|
+
schemaVersion: run.schemaVersion,
|
|
268
|
+
status: run.status,
|
|
269
|
+
attempt: run.attempt,
|
|
270
|
+
failureKind: run.failureKind,
|
|
271
|
+
rawOutput: run.rawOutput,
|
|
272
|
+
error: run.error,
|
|
273
|
+
createdAt: run.createdAt,
|
|
274
|
+
evidenceEventKeys: run.evidenceEventKeys ?? [],
|
|
275
|
+
items: items.map((item) => ({
|
|
276
|
+
itemIndex: item.itemIndex,
|
|
277
|
+
verdict: item.verdict,
|
|
278
|
+
predicate: item.predicate,
|
|
279
|
+
payload: item.payload,
|
|
280
|
+
errors: item.errors,
|
|
281
|
+
reason: item.reason,
|
|
282
|
+
})),
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function decodeRun(row) {
|
|
287
|
+
return {
|
|
288
|
+
runId: row.run_id,
|
|
289
|
+
scope: row.scope,
|
|
290
|
+
inputHash: row.input_hash,
|
|
291
|
+
model: row.model,
|
|
292
|
+
modelDigest: row.model_digest,
|
|
293
|
+
schemaVersion: row.schema_version,
|
|
294
|
+
status: row.status,
|
|
295
|
+
attempt: row.attempt,
|
|
296
|
+
failureKind: row.failure_kind ?? null,
|
|
297
|
+
rawOutput: row.raw_output ?? null,
|
|
298
|
+
error: row.error ?? null,
|
|
299
|
+
createdAt: row.created_at,
|
|
300
|
+
evidenceEventKeys: JSON.parse(row.evidence_keys_json ?? '[]'),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function decodeItem(row) {
|
|
305
|
+
return {
|
|
306
|
+
proposalId: row.proposal_id,
|
|
307
|
+
runId: row.run_id,
|
|
308
|
+
scope: row.scope,
|
|
309
|
+
itemIndex: row.item_index,
|
|
310
|
+
verdict: row.verdict,
|
|
311
|
+
predicate: row.predicate ?? null,
|
|
312
|
+
payload: row.payload_json === null ? null : JSON.parse(row.payload_json),
|
|
313
|
+
errors: JSON.parse(row.errors_json),
|
|
314
|
+
reason: row.reason ?? null,
|
|
315
|
+
promotedAt: row.promoted_at ?? null,
|
|
316
|
+
promotedAssertionId: row.promoted_assertion_id ?? null,
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export class ProposalJournal {
|
|
321
|
+
constructor(db) {
|
|
322
|
+
if (!db || typeof db.prepare !== 'function' || typeof db.exec !== 'function') {
|
|
323
|
+
throw new TypeError('ProposalJournal requires an open DatabaseSync instance')
|
|
324
|
+
}
|
|
325
|
+
this.db = db
|
|
326
|
+
this.db.exec('PRAGMA foreign_keys = ON')
|
|
327
|
+
this.db.exec(`
|
|
328
|
+
CREATE TABLE IF NOT EXISTS ledger_proposal_runs (
|
|
329
|
+
run_id TEXT PRIMARY KEY NOT NULL,
|
|
330
|
+
scope TEXT NOT NULL,
|
|
331
|
+
input_hash TEXT NOT NULL,
|
|
332
|
+
model TEXT NOT NULL,
|
|
333
|
+
model_digest TEXT NOT NULL,
|
|
334
|
+
schema_version TEXT NOT NULL,
|
|
335
|
+
status TEXT NOT NULL CHECK (status IN ('succeeded', 'failed')),
|
|
336
|
+
attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt >= 1),
|
|
337
|
+
failure_kind TEXT CHECK (
|
|
338
|
+
failure_kind IS NULL OR failure_kind IN ('transient', 'permanent', 'unknown')
|
|
339
|
+
),
|
|
340
|
+
raw_output TEXT,
|
|
341
|
+
error TEXT,
|
|
342
|
+
created_at REAL NOT NULL,
|
|
343
|
+
evidence_keys_json TEXT NOT NULL DEFAULT '[]',
|
|
344
|
+
CHECK (status = 'succeeded' OR error IS NOT NULL),
|
|
345
|
+
CHECK ((status = 'succeeded' AND failure_kind IS NULL) OR
|
|
346
|
+
(status = 'failed' AND failure_kind IS NOT NULL))
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
CREATE TABLE IF NOT EXISTS ledger_proposals (
|
|
350
|
+
proposal_id TEXT PRIMARY KEY NOT NULL,
|
|
351
|
+
run_id TEXT NOT NULL REFERENCES ledger_proposal_runs(run_id) ON DELETE CASCADE,
|
|
352
|
+
scope TEXT NOT NULL,
|
|
353
|
+
item_index INTEGER NOT NULL CHECK (item_index >= 0),
|
|
354
|
+
verdict TEXT NOT NULL CHECK (verdict IN ('accepted', 'candidate', 'rejected')),
|
|
355
|
+
predicate TEXT,
|
|
356
|
+
payload_json TEXT,
|
|
357
|
+
errors_json TEXT NOT NULL,
|
|
358
|
+
reason TEXT,
|
|
359
|
+
UNIQUE (run_id, item_index)
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
CREATE TABLE IF NOT EXISTS ledger_proposal_routes (
|
|
363
|
+
scope TEXT NOT NULL,
|
|
364
|
+
model TEXT NOT NULL,
|
|
365
|
+
model_digest TEXT NOT NULL,
|
|
366
|
+
predicate TEXT NOT NULL,
|
|
367
|
+
schema_version TEXT NOT NULL,
|
|
368
|
+
sample_count INTEGER NOT NULL CHECK (sample_count >= 0),
|
|
369
|
+
precision REAL NOT NULL,
|
|
370
|
+
safety_failures INTEGER NOT NULL CHECK (safety_failures >= 0),
|
|
371
|
+
evidence_json TEXT,
|
|
372
|
+
updated_at REAL NOT NULL,
|
|
373
|
+
PRIMARY KEY (scope, model, model_digest, predicate, schema_version)
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
CREATE TABLE IF NOT EXISTS ledger_proposal_applications (
|
|
377
|
+
run_id TEXT PRIMARY KEY NOT NULL
|
|
378
|
+
REFERENCES ledger_proposal_runs(run_id) ON DELETE CASCADE,
|
|
379
|
+
scope TEXT NOT NULL,
|
|
380
|
+
status TEXT NOT NULL CHECK (status IN ('applied', 'failed', 'closed')),
|
|
381
|
+
assertion_count INTEGER NOT NULL CHECK (assertion_count >= 0),
|
|
382
|
+
error TEXT,
|
|
383
|
+
updated_at REAL NOT NULL,
|
|
384
|
+
CHECK (status = 'applied' OR error IS NOT NULL)
|
|
385
|
+
);
|
|
386
|
+
`)
|
|
387
|
+
|
|
388
|
+
// Promotion is metadata on the existing candidate row, not a verdict rewrite:
|
|
389
|
+
// audit still sees the candidate, while metrics can tell it was injected.
|
|
390
|
+
const proposalColumns = new Set(
|
|
391
|
+
this.db.prepare("PRAGMA table_info('ledger_proposals')").all()
|
|
392
|
+
.map((column) => column.name),
|
|
393
|
+
)
|
|
394
|
+
if (!proposalColumns.has('promoted_at')) {
|
|
395
|
+
this.db.exec('ALTER TABLE ledger_proposals ADD COLUMN promoted_at REAL')
|
|
396
|
+
}
|
|
397
|
+
if (!proposalColumns.has('promoted_assertion_id')) {
|
|
398
|
+
this.db.exec('ALTER TABLE ledger_proposals ADD COLUMN promoted_assertion_id TEXT')
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// In-place upgrade from the first revision of this journal, which had neither
|
|
402
|
+
// attempt/failure classification nor scope in the successful identity.
|
|
403
|
+
const runColumns = new Set(
|
|
404
|
+
this.db.prepare("PRAGMA table_info('ledger_proposal_runs')").all()
|
|
405
|
+
.map((column) => column.name),
|
|
406
|
+
)
|
|
407
|
+
if (!runColumns.has('attempt')) {
|
|
408
|
+
this.db.exec(
|
|
409
|
+
'ALTER TABLE ledger_proposal_runs ADD COLUMN attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt >= 1)',
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
if (!runColumns.has('failure_kind')) {
|
|
413
|
+
this.db.exec(`ALTER TABLE ledger_proposal_runs ADD COLUMN failure_kind TEXT
|
|
414
|
+
CHECK (failure_kind IS NULL OR failure_kind IN ('transient', 'permanent', 'unknown'))`)
|
|
415
|
+
}
|
|
416
|
+
if (!runColumns.has('model_digest')) {
|
|
417
|
+
this.db.exec("ALTER TABLE ledger_proposal_runs ADD COLUMN model_digest TEXT NOT NULL DEFAULT ''")
|
|
418
|
+
}
|
|
419
|
+
if (!runColumns.has('evidence_keys_json')) {
|
|
420
|
+
this.db.exec(
|
|
421
|
+
"ALTER TABLE ledger_proposal_runs ADD COLUMN evidence_keys_json TEXT NOT NULL DEFAULT '[]'",
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Route identity includes the model digest. A pre-digest table cannot express
|
|
426
|
+
// that key, so it is rebuilt; legacy rows keep an empty digest and can never
|
|
427
|
+
// satisfy the read gate.
|
|
428
|
+
const routeColumns = new Set(
|
|
429
|
+
this.db.prepare("PRAGMA table_info('ledger_proposal_routes')").all()
|
|
430
|
+
.map((column) => column.name),
|
|
431
|
+
)
|
|
432
|
+
if (!routeColumns.has('model_digest')) {
|
|
433
|
+
this.db.exec(`
|
|
434
|
+
ALTER TABLE ledger_proposal_routes RENAME TO ledger_proposal_routes_pre_digest;
|
|
435
|
+
CREATE TABLE ledger_proposal_routes (
|
|
436
|
+
scope TEXT NOT NULL,
|
|
437
|
+
model TEXT NOT NULL,
|
|
438
|
+
model_digest TEXT NOT NULL,
|
|
439
|
+
predicate TEXT NOT NULL,
|
|
440
|
+
schema_version TEXT NOT NULL,
|
|
441
|
+
sample_count INTEGER NOT NULL CHECK (sample_count >= 0),
|
|
442
|
+
precision REAL NOT NULL,
|
|
443
|
+
safety_failures INTEGER NOT NULL CHECK (safety_failures >= 0),
|
|
444
|
+
evidence_json TEXT,
|
|
445
|
+
updated_at REAL NOT NULL,
|
|
446
|
+
PRIMARY KEY (scope, model, model_digest, predicate, schema_version)
|
|
447
|
+
);
|
|
448
|
+
INSERT INTO ledger_proposal_routes (
|
|
449
|
+
scope, model, model_digest, predicate, schema_version, sample_count,
|
|
450
|
+
precision, safety_failures, evidence_json, updated_at
|
|
451
|
+
)
|
|
452
|
+
SELECT scope, model, '', predicate, schema_version, sample_count, precision,
|
|
453
|
+
safety_failures, evidence_json, updated_at
|
|
454
|
+
FROM ledger_proposal_routes_pre_digest;
|
|
455
|
+
DROP TABLE ledger_proposal_routes_pre_digest;
|
|
456
|
+
`)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// `closed` is a permanent not-injectable outcome, distinct from a retryable
|
|
460
|
+
// application failure. Older tables have a CHECK that rejects it.
|
|
461
|
+
const applicationSql = this.db.prepare(
|
|
462
|
+
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ledger_proposal_applications'",
|
|
463
|
+
).get()?.sql ?? ''
|
|
464
|
+
if (!applicationSql.includes("'closed'")) {
|
|
465
|
+
this.db.exec(`
|
|
466
|
+
ALTER TABLE ledger_proposal_applications RENAME TO ledger_proposal_applications_pre_closed;
|
|
467
|
+
CREATE TABLE ledger_proposal_applications (
|
|
468
|
+
run_id TEXT PRIMARY KEY NOT NULL
|
|
469
|
+
REFERENCES ledger_proposal_runs(run_id) ON DELETE CASCADE,
|
|
470
|
+
scope TEXT NOT NULL,
|
|
471
|
+
status TEXT NOT NULL CHECK (status IN ('applied', 'failed', 'closed')),
|
|
472
|
+
assertion_count INTEGER NOT NULL CHECK (assertion_count >= 0),
|
|
473
|
+
error TEXT,
|
|
474
|
+
updated_at REAL NOT NULL,
|
|
475
|
+
CHECK (status = 'applied' OR error IS NOT NULL)
|
|
476
|
+
);
|
|
477
|
+
INSERT INTO ledger_proposal_applications (
|
|
478
|
+
run_id, scope, status, assertion_count, error, updated_at
|
|
479
|
+
)
|
|
480
|
+
SELECT run_id, scope, status, assertion_count, error, updated_at
|
|
481
|
+
FROM ledger_proposal_applications_pre_closed;
|
|
482
|
+
DROP TABLE ledger_proposal_applications_pre_closed;
|
|
483
|
+
`)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Scope, model, and model digest are part of the successful identity: identical
|
|
487
|
+
// counters from a different model build or scope are never a replay.
|
|
488
|
+
this.db.exec(`
|
|
489
|
+
DROP INDEX IF EXISTS ledger_proposal_runs_success_identity;
|
|
490
|
+
DROP INDEX IF EXISTS ledger_proposal_runs_success_scope_identity;
|
|
491
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ledger_proposal_runs_success_scope_identity
|
|
492
|
+
ON ledger_proposal_runs(scope, input_hash, model, model_digest, schema_version)
|
|
493
|
+
WHERE status = 'succeeded';
|
|
494
|
+
CREATE INDEX IF NOT EXISTS ledger_proposal_runs_scope_time
|
|
495
|
+
ON ledger_proposal_runs(scope, created_at);
|
|
496
|
+
CREATE INDEX IF NOT EXISTS ledger_proposal_runs_evidence_identity
|
|
497
|
+
ON ledger_proposal_runs(scope, model, model_digest, schema_version, evidence_keys_json)
|
|
498
|
+
WHERE status = 'succeeded';
|
|
499
|
+
CREATE INDEX IF NOT EXISTS ledger_proposals_scope_verdict
|
|
500
|
+
ON ledger_proposals(scope, verdict);
|
|
501
|
+
CREATE INDEX IF NOT EXISTS ledger_proposal_routes_scope
|
|
502
|
+
ON ledger_proposal_routes(scope, predicate);
|
|
503
|
+
CREATE INDEX IF NOT EXISTS ledger_proposal_applications_scope
|
|
504
|
+
ON ledger_proposal_applications(scope, status);
|
|
505
|
+
`)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
_transaction(write) {
|
|
509
|
+
if (this.db.isTransaction) return write()
|
|
510
|
+
this.db.exec('BEGIN IMMEDIATE')
|
|
511
|
+
try {
|
|
512
|
+
const result = write()
|
|
513
|
+
this.db.exec('COMMIT')
|
|
514
|
+
return result
|
|
515
|
+
} catch (error) {
|
|
516
|
+
this.db.exec('ROLLBACK')
|
|
517
|
+
throw error
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
_itemsForRun(runId) {
|
|
522
|
+
return this.db.prepare(
|
|
523
|
+
'SELECT * FROM ledger_proposals WHERE run_id = ? ORDER BY item_index',
|
|
524
|
+
).all(runId).map(decodeItem)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
_successfulRun(scope, inputHash, model, modelDigest, schemaVersion) {
|
|
528
|
+
const row = this.db.prepare(`
|
|
529
|
+
SELECT * FROM ledger_proposal_runs
|
|
530
|
+
WHERE scope = ? AND input_hash = ? AND model = ? AND model_digest = ?
|
|
531
|
+
AND schema_version = ? AND status = 'succeeded'
|
|
532
|
+
LIMIT 1
|
|
533
|
+
`).get(scope, inputHash, model, modelDigest, schemaVersion) ?? null
|
|
534
|
+
if (!row) return null
|
|
535
|
+
// Defence in depth: even a future query change must not hand a foreign run
|
|
536
|
+
// to the caller.
|
|
537
|
+
if (row.scope !== scope) {
|
|
538
|
+
throw new ProposalScopeMismatchError(row.run_id, row.scope, scope)
|
|
539
|
+
}
|
|
540
|
+
return row
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
findRun({ scope, inputHash, model, modelDigest, schemaVersion } = {}) {
|
|
544
|
+
requireNonEmpty('scope', scope)
|
|
545
|
+
requireNonEmpty('inputHash', inputHash)
|
|
546
|
+
requireNonEmpty('model', model)
|
|
547
|
+
requireNonEmpty('modelDigest', modelDigest)
|
|
548
|
+
requireNonEmpty('schemaVersion', schemaVersion)
|
|
549
|
+
// A successful run is the semantic cache entry. Failed attempts are still
|
|
550
|
+
// observable when nothing succeeded, so callers can tell "never ran" from
|
|
551
|
+
// "ran and failed" instead of retrying blindly.
|
|
552
|
+
const row = this.db.prepare(`
|
|
553
|
+
SELECT * FROM ledger_proposal_runs
|
|
554
|
+
WHERE scope = ? AND input_hash = ? AND model = ? AND model_digest = ?
|
|
555
|
+
AND schema_version = ?
|
|
556
|
+
ORDER BY CASE status WHEN 'succeeded' THEN 0 ELSE 1 END, created_at DESC, run_id DESC
|
|
557
|
+
LIMIT 1
|
|
558
|
+
`).get(scope, inputHash, model, modelDigest, schemaVersion)
|
|
559
|
+
if (!row) return null
|
|
560
|
+
return { run: decodeRun(row), items: this._itemsForRun(row.run_id) }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Evidence-based replay identity: scope, model, model digest, schema version, and
|
|
564
|
+
// the sorted archived event keys. It deliberately excludes active facts, so an
|
|
565
|
+
// old message cannot be re-analysed into a new fact after a user retraction. The
|
|
566
|
+
// earliest successful run for the set is the canonical answer.
|
|
567
|
+
findRunByEvidence({ scope, model, modelDigest, schemaVersion, evidenceEventKeys } = {}) {
|
|
568
|
+
requireNonEmpty('scope', scope)
|
|
569
|
+
requireNonEmpty('model', model)
|
|
570
|
+
requireNonEmpty('modelDigest', modelDigest)
|
|
571
|
+
requireNonEmpty('schemaVersion', schemaVersion)
|
|
572
|
+
if (!Array.isArray(evidenceEventKeys) ||
|
|
573
|
+
evidenceEventKeys.some((value) => typeof value !== 'string' || value.trim() === '')) {
|
|
574
|
+
throw new TypeError('findRunByEvidence requires an array of non-empty evidence keys')
|
|
575
|
+
}
|
|
576
|
+
const normalized = [...new Set(evidenceEventKeys)].sort()
|
|
577
|
+
if (normalized.length === 0) return null
|
|
578
|
+
const row = this.db.prepare(`
|
|
579
|
+
SELECT * FROM ledger_proposal_runs
|
|
580
|
+
WHERE scope = ? AND model = ? AND model_digest = ? AND schema_version = ?
|
|
581
|
+
AND status = 'succeeded' AND evidence_keys_json = ?
|
|
582
|
+
ORDER BY created_at, run_id
|
|
583
|
+
LIMIT 1
|
|
584
|
+
`).get(scope, model, modelDigest, schemaVersion, canonicalJson(normalized)) ?? null
|
|
585
|
+
if (!row) return null
|
|
586
|
+
return { run: decodeRun(row), items: this._itemsForRun(row.run_id) }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
recordRun(input) {
|
|
590
|
+
const run = normalizeRun(input)
|
|
591
|
+
return this._transaction(() => {
|
|
592
|
+
// Only a successful attempt is cacheable evidence; failed attempts are
|
|
593
|
+
// always archived so retries and later successes remain observable.
|
|
594
|
+
if (run.status === 'succeeded') {
|
|
595
|
+
const successful = this._successfulRun(
|
|
596
|
+
run.scope, run.inputHash, run.model, run.modelDigest, run.schemaVersion,
|
|
597
|
+
)
|
|
598
|
+
if (successful) {
|
|
599
|
+
const stored = decodeRun(successful)
|
|
600
|
+
if (stored.scope !== run.scope) {
|
|
601
|
+
throw new ProposalScopeMismatchError(stored.runId, stored.scope, run.scope)
|
|
602
|
+
}
|
|
603
|
+
const items = this._itemsForRun(stored.runId)
|
|
604
|
+
if (stored.runId === run.runId &&
|
|
605
|
+
runSignature(stored, items) !== runSignature(run, run.items)) {
|
|
606
|
+
throw new Error(`successful proposal run is immutable: ${run.runId}`)
|
|
607
|
+
}
|
|
608
|
+
// A different run ID over a successful identity is a replay, not new
|
|
609
|
+
// evidence: the stored run wins and no rows are added.
|
|
610
|
+
return { run: stored, items, duplicate: true }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const sameId = this.db.prepare(
|
|
615
|
+
'SELECT * FROM ledger_proposal_runs WHERE run_id = ?',
|
|
616
|
+
).get(run.runId)
|
|
617
|
+
if (sameId) {
|
|
618
|
+
const stored = decodeRun(sameId)
|
|
619
|
+
if (stored.scope !== run.scope) {
|
|
620
|
+
throw new ProposalScopeMismatchError(stored.runId, stored.scope, run.scope)
|
|
621
|
+
}
|
|
622
|
+
const items = this._itemsForRun(stored.runId)
|
|
623
|
+
if (runSignature(stored, items) !== runSignature(run, run.items)) {
|
|
624
|
+
throw new Error(`proposal run cannot be mutated: ${run.runId}`)
|
|
625
|
+
}
|
|
626
|
+
return { run: stored, items, duplicate: true }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
try {
|
|
630
|
+
this.db.prepare(`
|
|
631
|
+
INSERT INTO ledger_proposal_runs (
|
|
632
|
+
run_id, scope, input_hash, model, model_digest, schema_version, status,
|
|
633
|
+
attempt, failure_kind, raw_output, error, created_at, evidence_keys_json
|
|
634
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
635
|
+
`).run(
|
|
636
|
+
run.runId, run.scope, run.inputHash, run.model, run.modelDigest, run.schemaVersion,
|
|
637
|
+
run.status, run.attempt, run.failureKind, run.rawOutput, run.error, run.createdAt,
|
|
638
|
+
canonicalJson(run.evidenceEventKeys),
|
|
639
|
+
)
|
|
640
|
+
} catch (error) {
|
|
641
|
+
// The partial unique index is the real idempotency guard; if another
|
|
642
|
+
// connection won the race, return its successful run unchanged.
|
|
643
|
+
if (run.status === 'succeeded' && /UNIQUE constraint failed/i.test(String(error?.message))) {
|
|
644
|
+
const winner = this._successfulRun(
|
|
645
|
+
run.scope, run.inputHash, run.model, run.modelDigest, run.schemaVersion,
|
|
646
|
+
)
|
|
647
|
+
if (winner) {
|
|
648
|
+
const stored = decodeRun(winner)
|
|
649
|
+
return { run: stored, items: this._itemsForRun(stored.runId), duplicate: true }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
throw error
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const insertItem = this.db.prepare(`
|
|
656
|
+
INSERT INTO ledger_proposals (
|
|
657
|
+
proposal_id, run_id, scope, item_index, verdict, predicate,
|
|
658
|
+
payload_json, errors_json, reason
|
|
659
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
660
|
+
`)
|
|
661
|
+
for (const item of run.items) {
|
|
662
|
+
insertItem.run(
|
|
663
|
+
proposalIdFor(run, item.itemIndex), run.runId, run.scope, item.itemIndex,
|
|
664
|
+
item.verdict, item.predicate, canonicalJson(item.payload),
|
|
665
|
+
canonicalJson(item.errors), item.reason,
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
run: {
|
|
671
|
+
runId: run.runId,
|
|
672
|
+
scope: run.scope,
|
|
673
|
+
inputHash: run.inputHash,
|
|
674
|
+
model: run.model,
|
|
675
|
+
modelDigest: run.modelDigest,
|
|
676
|
+
schemaVersion: run.schemaVersion,
|
|
677
|
+
status: run.status,
|
|
678
|
+
attempt: run.attempt,
|
|
679
|
+
failureKind: run.failureKind,
|
|
680
|
+
rawOutput: run.rawOutput,
|
|
681
|
+
error: run.error,
|
|
682
|
+
createdAt: run.createdAt,
|
|
683
|
+
evidenceEventKeys: run.evidenceEventKeys,
|
|
684
|
+
},
|
|
685
|
+
items: run.items.map((item) => ({
|
|
686
|
+
proposalId: proposalIdFor(run, item.itemIndex),
|
|
687
|
+
runId: run.runId,
|
|
688
|
+
scope: run.scope,
|
|
689
|
+
itemIndex: item.itemIndex,
|
|
690
|
+
verdict: item.verdict,
|
|
691
|
+
predicate: item.predicate,
|
|
692
|
+
payload: item.payload,
|
|
693
|
+
errors: item.errors,
|
|
694
|
+
reason: item.reason,
|
|
695
|
+
promotedAt: null,
|
|
696
|
+
promotedAssertionId: null,
|
|
697
|
+
})),
|
|
698
|
+
duplicate: false,
|
|
699
|
+
}
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// The journal is written before accepted assertions reach the ledger. This row is
|
|
704
|
+
// how metrics and audit distinguish "analysed and applied" from "analysed but the
|
|
705
|
+
// append failed", instead of either lying about acceptance or losing the run.
|
|
706
|
+
getApplication(runId) {
|
|
707
|
+
requireNonEmpty('runId', runId)
|
|
708
|
+
const row = this.db.prepare(
|
|
709
|
+
'SELECT * FROM ledger_proposal_applications WHERE run_id = ?',
|
|
710
|
+
).get(runId)
|
|
711
|
+
if (!row) return null
|
|
712
|
+
return {
|
|
713
|
+
runId: row.run_id,
|
|
714
|
+
scope: row.scope,
|
|
715
|
+
status: row.status,
|
|
716
|
+
assertionCount: row.assertion_count,
|
|
717
|
+
error: row.error ?? null,
|
|
718
|
+
updatedAt: row.updated_at,
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
recordApplication(input) {
|
|
723
|
+
const application = normalizeApplication(input)
|
|
724
|
+
return this._transaction(() => {
|
|
725
|
+
const run = this.db.prepare(
|
|
726
|
+
'SELECT scope FROM ledger_proposal_runs WHERE run_id = ?',
|
|
727
|
+
).get(application.runId)
|
|
728
|
+
if (!run) {
|
|
729
|
+
throw new TypeError(`application requires an archived run: ${application.runId}`)
|
|
730
|
+
}
|
|
731
|
+
if (run.scope !== application.scope) {
|
|
732
|
+
throw new ProposalScopeMismatchError(
|
|
733
|
+
application.runId, run.scope, application.scope,
|
|
734
|
+
)
|
|
735
|
+
}
|
|
736
|
+
const existing = this.getApplication(application.runId)
|
|
737
|
+
if (existing) {
|
|
738
|
+
if (existing.status === application.status &&
|
|
739
|
+
existing.assertionCount === application.assertionCount &&
|
|
740
|
+
existing.error === application.error) {
|
|
741
|
+
return existing
|
|
742
|
+
}
|
|
743
|
+
// `applied` and `closed` are final. A failed attempt wrote no facts, so a
|
|
744
|
+
// later replay may refine its reason or complete it. Closed records never
|
|
745
|
+
// silently reopen: a route restored later does not turn a permanent closure
|
|
746
|
+
// into an injection without a new proposal run.
|
|
747
|
+
if (existing.status !== 'failed') {
|
|
748
|
+
throw new Error(`proposal application is immutable: ${application.runId}`)
|
|
749
|
+
}
|
|
750
|
+
this.db.prepare(`
|
|
751
|
+
UPDATE ledger_proposal_applications
|
|
752
|
+
SET status = ?, assertion_count = ?, error = ?, updated_at = ?
|
|
753
|
+
WHERE run_id = ?
|
|
754
|
+
`).run(
|
|
755
|
+
application.status, application.assertionCount, application.error,
|
|
756
|
+
application.updatedAt, application.runId,
|
|
757
|
+
)
|
|
758
|
+
return this.getApplication(application.runId)
|
|
759
|
+
}
|
|
760
|
+
this.db.prepare(`
|
|
761
|
+
INSERT INTO ledger_proposal_applications (
|
|
762
|
+
run_id, scope, status, assertion_count, error, updated_at
|
|
763
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
764
|
+
`).run(
|
|
765
|
+
application.runId, application.scope, application.status,
|
|
766
|
+
application.assertionCount, application.error, application.updatedAt,
|
|
767
|
+
)
|
|
768
|
+
return this.getApplication(application.runId)
|
|
769
|
+
})
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Promotion is recorded on the candidate row itself. The verdict stays
|
|
773
|
+
// `candidate` for audit, while metrics stop counting an injected fact as an
|
|
774
|
+
// open candidate. COALESCE keeps the first promotion timestamp on idempotent
|
|
775
|
+
// re-promotion.
|
|
776
|
+
markPromoted({ proposalId, scope, promotedAt, promotedAssertionId } = {}) {
|
|
777
|
+
requireNonEmpty('proposalId', proposalId)
|
|
778
|
+
requireNonEmpty('scope', scope)
|
|
779
|
+
requireFinite('promotedAt', promotedAt)
|
|
780
|
+
requireNonEmpty('promotedAssertionId', promotedAssertionId)
|
|
781
|
+
return this._transaction(() => {
|
|
782
|
+
const row = this.db.prepare(
|
|
783
|
+
'SELECT * FROM ledger_proposals WHERE proposal_id = ?',
|
|
784
|
+
).get(proposalId)
|
|
785
|
+
if (!row) throw new TypeError(`unknown proposal: ${proposalId}`)
|
|
786
|
+
if (row.scope !== scope) {
|
|
787
|
+
throw new ProposalScopeMismatchError(row.run_id, row.scope, scope)
|
|
788
|
+
}
|
|
789
|
+
if (row.verdict !== 'candidate') {
|
|
790
|
+
throw new TypeError('promotion requires a candidate proposal')
|
|
791
|
+
}
|
|
792
|
+
this.db.prepare(`
|
|
793
|
+
UPDATE ledger_proposals
|
|
794
|
+
SET promoted_at = COALESCE(promoted_at, ?),
|
|
795
|
+
promoted_assertion_id = COALESCE(promoted_assertion_id, ?)
|
|
796
|
+
WHERE proposal_id = ? AND scope = ?
|
|
797
|
+
`).run(promotedAt, promotedAssertionId, proposalId, scope)
|
|
798
|
+
return decodeItem(this.db.prepare(
|
|
799
|
+
'SELECT * FROM ledger_proposals WHERE proposal_id = ?',
|
|
800
|
+
).get(proposalId))
|
|
801
|
+
})
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
listProposals(scope, { verdict } = {}) {
|
|
805
|
+
requireNonEmpty('scope', scope)
|
|
806
|
+
if (verdict !== undefined && verdict !== null && !ITEM_VERDICTS.has(verdict)) {
|
|
807
|
+
throw new TypeError('verdict must be accepted, candidate, or rejected')
|
|
808
|
+
}
|
|
809
|
+
const base = `
|
|
810
|
+
SELECT p.* FROM ledger_proposals p
|
|
811
|
+
JOIN ledger_proposal_runs r ON r.run_id = p.run_id
|
|
812
|
+
WHERE p.scope = ?
|
|
813
|
+
`
|
|
814
|
+
const order = ' ORDER BY r.created_at, p.run_id, p.item_index'
|
|
815
|
+
const rows = verdict === undefined || verdict === null
|
|
816
|
+
? this.db.prepare(`${base}${order}`).all(scope)
|
|
817
|
+
: this.db.prepare(`${base} AND p.verdict = ?${order}`).all(scope, verdict)
|
|
818
|
+
return rows.map(decodeItem)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
metrics(scope) {
|
|
822
|
+
requireNonEmpty('scope', scope)
|
|
823
|
+
const byStatus = { succeeded: 0, failed: 0 }
|
|
824
|
+
for (const row of this.db.prepare(
|
|
825
|
+
'SELECT status, count(*) AS count FROM ledger_proposal_runs WHERE scope = ? GROUP BY status',
|
|
826
|
+
).all(scope)) {
|
|
827
|
+
if (Object.hasOwn(byStatus, row.status)) byStatus[row.status] = row.count
|
|
828
|
+
}
|
|
829
|
+
const byVerdict = { accepted: 0, candidate: 0, rejected: 0, promoted: 0 }
|
|
830
|
+
for (const row of this.db.prepare(`
|
|
831
|
+
SELECT verdict, (promoted_assertion_id IS NOT NULL) AS promoted, count(*) AS count
|
|
832
|
+
FROM ledger_proposals WHERE scope = ? GROUP BY verdict, promoted
|
|
833
|
+
`).all(scope)) {
|
|
834
|
+
if (row.promoted === 1 && row.verdict === 'candidate') {
|
|
835
|
+
byVerdict.promoted += row.count
|
|
836
|
+
} else if (Object.hasOwn(byVerdict, row.verdict)) {
|
|
837
|
+
byVerdict[row.verdict] = row.count
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const applications = { applied: 0, failed: 0, closed: 0 }
|
|
841
|
+
for (const row of this.db.prepare(`
|
|
842
|
+
SELECT status, count(*) AS count FROM ledger_proposal_applications
|
|
843
|
+
WHERE scope = ? GROUP BY status
|
|
844
|
+
`).all(scope)) {
|
|
845
|
+
if (Object.hasOwn(applications, row.status)) applications[row.status] = row.count
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const modelRollups = new Map()
|
|
849
|
+
const modelEntry = (model) => {
|
|
850
|
+
if (!modelRollups.has(model)) {
|
|
851
|
+
modelRollups.set(model, {
|
|
852
|
+
runs: { total: 0, succeeded: 0, failed: 0 },
|
|
853
|
+
proposals: { total: 0, accepted: 0, candidate: 0, rejected: 0, promoted: 0 },
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
return modelRollups.get(model)
|
|
857
|
+
}
|
|
858
|
+
for (const row of this.db.prepare(`
|
|
859
|
+
SELECT model, status, count(*) AS count FROM ledger_proposal_runs
|
|
860
|
+
WHERE scope = ? GROUP BY model, status ORDER BY model, status
|
|
861
|
+
`).all(scope)) {
|
|
862
|
+
const entry = modelEntry(row.model)
|
|
863
|
+
entry.runs.total += row.count
|
|
864
|
+
if (Object.hasOwn(entry.runs, row.status)) entry.runs[row.status] = row.count
|
|
865
|
+
}
|
|
866
|
+
for (const row of this.db.prepare(`
|
|
867
|
+
SELECT r.model AS model, p.verdict AS verdict,
|
|
868
|
+
(p.promoted_assertion_id IS NOT NULL) AS promoted, count(*) AS count
|
|
869
|
+
FROM ledger_proposals p
|
|
870
|
+
JOIN ledger_proposal_runs r ON r.run_id = p.run_id
|
|
871
|
+
WHERE p.scope = ? GROUP BY r.model, p.verdict, promoted ORDER BY r.model, p.verdict
|
|
872
|
+
`).all(scope)) {
|
|
873
|
+
const entry = modelEntry(row.model)
|
|
874
|
+
entry.proposals.total += row.count
|
|
875
|
+
if (row.promoted === 1 && row.verdict === 'candidate') {
|
|
876
|
+
entry.proposals.promoted += row.count
|
|
877
|
+
} else if (Object.hasOwn(entry.proposals, row.verdict)) {
|
|
878
|
+
entry.proposals[row.verdict] = row.count
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Items may legally carry no predicate; keep that bucket explicit instead of
|
|
883
|
+
// collapsing it into an ordinary predicate name.
|
|
884
|
+
const predicateRollups = new Map()
|
|
885
|
+
for (const row of this.db.prepare(`
|
|
886
|
+
SELECT predicate, verdict, (promoted_assertion_id IS NOT NULL) AS promoted,
|
|
887
|
+
count(*) AS count FROM ledger_proposals
|
|
888
|
+
WHERE scope = ? GROUP BY predicate, verdict, promoted ORDER BY predicate, verdict
|
|
889
|
+
`).all(scope)) {
|
|
890
|
+
const key = row.predicate ?? '(none)'
|
|
891
|
+
if (!predicateRollups.has(key)) {
|
|
892
|
+
predicateRollups.set(key, {
|
|
893
|
+
predicate: row.predicate ?? null,
|
|
894
|
+
proposals: { total: 0, accepted: 0, candidate: 0, rejected: 0, promoted: 0 },
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
const entry = predicateRollups.get(key)
|
|
898
|
+
entry.proposals.total += row.count
|
|
899
|
+
if (row.promoted === 1 && row.verdict === 'candidate') {
|
|
900
|
+
entry.proposals.promoted += row.count
|
|
901
|
+
} else if (Object.hasOwn(entry.proposals, row.verdict)) {
|
|
902
|
+
entry.proposals[row.verdict] = row.count
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const rejectionReasons = new Map()
|
|
907
|
+
for (const row of this.db.prepare(`
|
|
908
|
+
SELECT reason, count(*) AS count FROM ledger_proposals
|
|
909
|
+
WHERE scope = ? AND verdict = 'rejected' GROUP BY reason ORDER BY reason
|
|
910
|
+
`).all(scope)) {
|
|
911
|
+
rejectionReasons.set(row.reason ?? '(none)', row.count)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const runsTotal = byStatus.succeeded + byStatus.failed
|
|
915
|
+
const proposalsTotal = byVerdict.accepted + byVerdict.candidate +
|
|
916
|
+
byVerdict.rejected + byVerdict.promoted
|
|
917
|
+
// Precedence matters: successful evidence outranks failed attempts, an empty
|
|
918
|
+
// successful run is not the same thing as never having run at all, and the
|
|
919
|
+
// latest application outcome decides whether facts are missing. The state is
|
|
920
|
+
// derived, never sticky: a later successful application recovers it even while
|
|
921
|
+
// an older run's failure remains archived.
|
|
922
|
+
let state = byStatus.succeeded > 0
|
|
923
|
+
? (proposalsTotal > 0 ? 'found' : 'ran-empty')
|
|
924
|
+
: (byStatus.failed > 0 ? 'failed' : 'not-run')
|
|
925
|
+
const latestApplication = this.db.prepare(`
|
|
926
|
+
SELECT status FROM ledger_proposal_applications
|
|
927
|
+
WHERE scope = ? ORDER BY updated_at DESC, rowid DESC LIMIT 1
|
|
928
|
+
`).get(scope)
|
|
929
|
+
// A permanent closure (closed target or evidence that no longer validates) is
|
|
930
|
+
// not an application failure: it never wrote facts and never will under this
|
|
931
|
+
// run, so the scope state stays derived from the runs instead of sticking at
|
|
932
|
+
// apply-failed. Only a retryable failure, including a temporarily revoked
|
|
933
|
+
// route, changes the state.
|
|
934
|
+
if (latestApplication?.status === 'failed') state = 'apply-failed'
|
|
935
|
+
|
|
936
|
+
const last = this.db.prepare(`
|
|
937
|
+
SELECT run_id, status, attempt, failure_kind, created_at FROM ledger_proposal_runs
|
|
938
|
+
WHERE scope = ? ORDER BY created_at DESC, run_id DESC LIMIT 1
|
|
939
|
+
`).get(scope)
|
|
940
|
+
const lastApplication = last
|
|
941
|
+
? this.db.prepare(
|
|
942
|
+
'SELECT status, error FROM ledger_proposal_applications WHERE run_id = ?',
|
|
943
|
+
).get(last.run_id)
|
|
944
|
+
: null
|
|
945
|
+
const lastRun = last
|
|
946
|
+
? {
|
|
947
|
+
runId: last.run_id,
|
|
948
|
+
status: last.status,
|
|
949
|
+
attempt: last.attempt,
|
|
950
|
+
failureKind: last.failure_kind ?? null,
|
|
951
|
+
createdAt: last.created_at,
|
|
952
|
+
proposalCount: this.db.prepare(
|
|
953
|
+
'SELECT count(*) AS count FROM ledger_proposals WHERE run_id = ?',
|
|
954
|
+
).get(last.run_id).count,
|
|
955
|
+
application: lastApplication
|
|
956
|
+
? { status: lastApplication.status, error: lastApplication.error ?? null }
|
|
957
|
+
: null,
|
|
958
|
+
}
|
|
959
|
+
: null
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
scope,
|
|
963
|
+
state,
|
|
964
|
+
runs: { total: runsTotal, succeeded: byStatus.succeeded, failed: byStatus.failed },
|
|
965
|
+
applications: {
|
|
966
|
+
applied: applications.applied,
|
|
967
|
+
failed: applications.failed,
|
|
968
|
+
closed: applications.closed,
|
|
969
|
+
},
|
|
970
|
+
proposals: {
|
|
971
|
+
total: proposalsTotal,
|
|
972
|
+
accepted: byVerdict.accepted,
|
|
973
|
+
candidate: byVerdict.candidate,
|
|
974
|
+
rejected: byVerdict.rejected,
|
|
975
|
+
promoted: byVerdict.promoted,
|
|
976
|
+
},
|
|
977
|
+
byModel: Object.fromEntries(modelRollups),
|
|
978
|
+
byPredicate: Object.fromEntries(predicateRollups),
|
|
979
|
+
rejectionReasons: Object.fromEntries(rejectionReasons),
|
|
980
|
+
lastRun,
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Trusted internal write path: only the offline evaluator may call setRoute.
|
|
985
|
+
// It is never reachable from model output, user input, or request handlers.
|
|
986
|
+
// Evidence counters are validated here, while enablement stays derived on read.
|
|
987
|
+
setRoute(input) {
|
|
988
|
+
const route = normalizeRoute(input)
|
|
989
|
+
this.db.prepare(`
|
|
990
|
+
INSERT INTO ledger_proposal_routes (
|
|
991
|
+
scope, model, model_digest, predicate, schema_version, sample_count, precision,
|
|
992
|
+
safety_failures, evidence_json, updated_at
|
|
993
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
994
|
+
ON CONFLICT(scope, model, model_digest, predicate, schema_version) DO UPDATE SET
|
|
995
|
+
sample_count = excluded.sample_count,
|
|
996
|
+
precision = excluded.precision,
|
|
997
|
+
safety_failures = excluded.safety_failures,
|
|
998
|
+
evidence_json = excluded.evidence_json,
|
|
999
|
+
updated_at = excluded.updated_at
|
|
1000
|
+
`).run(
|
|
1001
|
+
route.scope, route.model, route.modelDigest, route.predicate, route.schemaVersion,
|
|
1002
|
+
route.sampleCount, route.precision, route.safetyFailures,
|
|
1003
|
+
canonicalJson(route.evidence), route.updatedAt,
|
|
1004
|
+
)
|
|
1005
|
+
return this.getRoute({
|
|
1006
|
+
scope: route.scope,
|
|
1007
|
+
model: route.model,
|
|
1008
|
+
modelDigest: route.modelDigest,
|
|
1009
|
+
predicate: route.predicate,
|
|
1010
|
+
schemaVersion: route.schemaVersion,
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
getRoute({ scope, model, modelDigest, predicate, schemaVersion } = {}) {
|
|
1015
|
+
requireNonEmpty('scope', scope)
|
|
1016
|
+
requireNonEmpty('model', model)
|
|
1017
|
+
requireNonEmpty('modelDigest', modelDigest)
|
|
1018
|
+
requireNonEmpty('predicate', predicate)
|
|
1019
|
+
requireNonEmpty('schemaVersion', schemaVersion)
|
|
1020
|
+
const row = this.db.prepare(`
|
|
1021
|
+
SELECT * FROM ledger_proposal_routes
|
|
1022
|
+
WHERE scope = ? AND model = ? AND model_digest = ? AND predicate = ?
|
|
1023
|
+
AND schema_version = ?
|
|
1024
|
+
`).get(scope, model, modelDigest, predicate, schemaVersion)
|
|
1025
|
+
if (!row) return null
|
|
1026
|
+
const evidence = row.evidence_json === null ? null : JSON.parse(row.evidence_json)
|
|
1027
|
+
// Enablement is derived from a fully validated row. A hand-edited counter set,
|
|
1028
|
+
// a mismatched digest, a missing evidence row, or recall below the floor keeps
|
|
1029
|
+
// the route closed even if the stored integers look sufficient.
|
|
1030
|
+
const enabled = routeRowValid({
|
|
1031
|
+
sampleCount: row.sample_count,
|
|
1032
|
+
precision: row.precision,
|
|
1033
|
+
recall: evidence?.recall,
|
|
1034
|
+
safetyFailures: row.safety_failures,
|
|
1035
|
+
evidence,
|
|
1036
|
+
modelDigest: row.model_digest,
|
|
1037
|
+
})
|
|
1038
|
+
return {
|
|
1039
|
+
scope: row.scope,
|
|
1040
|
+
model: row.model,
|
|
1041
|
+
modelDigest: row.model_digest,
|
|
1042
|
+
predicate: row.predicate,
|
|
1043
|
+
schemaVersion: row.schema_version,
|
|
1044
|
+
sampleCount: row.sample_count,
|
|
1045
|
+
precision: row.precision,
|
|
1046
|
+
recall: evidence?.recall ?? null,
|
|
1047
|
+
safetyFailures: row.safety_failures,
|
|
1048
|
+
evidence,
|
|
1049
|
+
updatedAt: row.updated_at,
|
|
1050
|
+
enabled,
|
|
1051
|
+
status: enabled ? 'enabled' : 'disabled',
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|