instar 1.3.830 → 1.3.832
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/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +83 -12
- package/dist/commands/server.js.map +1 -1
- package/dist/core/AutonomousRealCheckAnnotator.d.ts +105 -0
- package/dist/core/AutonomousRealCheckAnnotator.d.ts.map +1 -0
- package/dist/core/AutonomousRealCheckAnnotator.js +122 -0
- package/dist/core/AutonomousRealCheckAnnotator.js.map +1 -0
- package/dist/core/AutonomousRunStore.d.ts +29 -0
- package/dist/core/AutonomousRunStore.d.ts.map +1 -1
- package/dist/core/AutonomousRunStore.js +38 -0
- package/dist/core/AutonomousRunStore.js.map +1 -1
- package/dist/core/BackupManager.d.ts.map +1 -1
- package/dist/core/BackupManager.js +47 -1
- package/dist/core/BackupManager.js.map +1 -1
- package/dist/core/CircuitBreakingIntelligenceProvider.d.ts +12 -0
- package/dist/core/CircuitBreakingIntelligenceProvider.d.ts.map +1 -1
- package/dist/core/CircuitBreakingIntelligenceProvider.js +56 -4
- package/dist/core/CircuitBreakingIntelligenceProvider.js.map +1 -1
- package/dist/core/CompletionEvaluator.d.ts +62 -2
- package/dist/core/CompletionEvaluator.d.ts.map +1 -1
- package/dist/core/CompletionEvaluator.js +124 -6
- package/dist/core/CompletionEvaluator.js.map +1 -1
- package/dist/core/DecisionQualityRecorderImpl.d.ts +192 -0
- package/dist/core/DecisionQualityRecorderImpl.d.ts.map +1 -0
- package/dist/core/DecisionQualityRecorderImpl.js +363 -0
- package/dist/core/DecisionQualityRecorderImpl.js.map +1 -0
- package/dist/core/IntelligenceRouter.d.ts +33 -0
- package/dist/core/IntelligenceRouter.d.ts.map +1 -1
- package/dist/core/IntelligenceRouter.js +240 -54
- package/dist/core/IntelligenceRouter.js.map +1 -1
- package/dist/core/JudgmentProvenanceLog.d.ts +102 -2
- package/dist/core/JudgmentProvenanceLog.d.ts.map +1 -1
- package/dist/core/JudgmentProvenanceLog.js +239 -11
- package/dist/core/JudgmentProvenanceLog.js.map +1 -1
- package/dist/core/MessagingToneGate.d.ts +30 -0
- package/dist/core/MessagingToneGate.d.ts.map +1 -1
- package/dist/core/MessagingToneGate.js +63 -0
- package/dist/core/MessagingToneGate.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +11 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +32 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
- package/dist/core/WriteDomainRegistry.js +24 -0
- package/dist/core/WriteDomainRegistry.js.map +1 -1
- package/dist/core/decisionGradingPass.d.ts +89 -0
- package/dist/core/decisionGradingPass.d.ts.map +1 -0
- package/dist/core/decisionGradingPass.js +188 -0
- package/dist/core/decisionGradingPass.js.map +1 -0
- package/dist/core/decisionQualityTypes.d.ts +170 -0
- package/dist/core/decisionQualityTypes.d.ts.map +1 -0
- package/dist/core/decisionQualityTypes.js +85 -0
- package/dist/core/decisionQualityTypes.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/machineCoherenceManifest.d.ts.map +1 -1
- package/dist/core/machineCoherenceManifest.js +4 -0
- package/dist/core/machineCoherenceManifest.js.map +1 -1
- package/dist/core/types.d.ts +69 -4
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/data/provenanceCoverage.d.ts +156 -0
- package/dist/data/provenanceCoverage.d.ts.map +1 -0
- package/dist/data/provenanceCoverage.js +674 -0
- package/dist/data/provenanceCoverage.js.map +1 -0
- package/dist/monitoring/ExternalHogDecisionStore.d.ts +256 -0
- package/dist/monitoring/ExternalHogDecisionStore.d.ts.map +1 -0
- package/dist/monitoring/ExternalHogDecisionStore.js +481 -0
- package/dist/monitoring/ExternalHogDecisionStore.js.map +1 -0
- package/dist/monitoring/ExternalHogRealAdapters.d.ts +6 -2
- package/dist/monitoring/ExternalHogRealAdapters.d.ts.map +1 -1
- package/dist/monitoring/ExternalHogRealAdapters.js +2 -2
- package/dist/monitoring/ExternalHogRealAdapters.js.map +1 -1
- package/dist/monitoring/ExternalHogScanTick.d.ts +95 -3
- package/dist/monitoring/ExternalHogScanTick.d.ts.map +1 -1
- package/dist/monitoring/ExternalHogScanTick.js +102 -9
- package/dist/monitoring/ExternalHogScanTick.js.map +1 -1
- package/dist/monitoring/ExternalHogSentinel.d.ts +71 -3
- package/dist/monitoring/ExternalHogSentinel.d.ts.map +1 -1
- package/dist/monitoring/ExternalHogSentinel.js +126 -2
- package/dist/monitoring/ExternalHogSentinel.js.map +1 -1
- package/dist/monitoring/ExternalHogServerPrimitives.d.ts +10 -2
- package/dist/monitoring/ExternalHogServerPrimitives.d.ts.map +1 -1
- package/dist/monitoring/ExternalHogServerPrimitives.js +1 -1
- package/dist/monitoring/ExternalHogServerPrimitives.js.map +1 -1
- package/dist/monitoring/FeatureMetricsLedger.d.ts +304 -0
- package/dist/monitoring/FeatureMetricsLedger.d.ts.map +1 -1
- package/dist/monitoring/FeatureMetricsLedger.js +731 -0
- package/dist/monitoring/FeatureMetricsLedger.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +2 -1
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +55 -1
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +15 -1
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/fileRoutes.d.ts.map +1 -1
- package/dist/server/fileRoutes.js +9 -0
- package/dist/server/fileRoutes.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +334 -5
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +65 -65
- package/src/data/provenanceCoverage.ts +850 -0
- package/src/scaffold/templates/jobs/instar/llm-decision-grading.md +32 -0
- package/src/scaffold/templates.ts +2 -1
- package/upgrades/1.3.831.md +30 -0
- package/upgrades/1.3.832.md +22 -0
- package/upgrades/side-effects/llm-decision-quality-meter.md +95 -0
- package/upgrades/side-effects/messaging-tone-gate-provenance-enrollment.md +68 -0
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
*
|
|
17
17
|
* The per-feature key is the existing IntelligenceOptions.attribution.component
|
|
18
18
|
* tag (e.g. "MessagingToneGate"); calls without one bucket under "unlabeled".
|
|
19
|
+
*
|
|
20
|
+
* Also owns the decision-quality substrate (llm-decision-quality-meter §5.5):
|
|
21
|
+
* the `decision_quality` / `decision_outcomes` / `decision_quality_rollup` /
|
|
22
|
+
* `decision_grading_cursor` tables plus the CANONICAL winning-grade derivation
|
|
23
|
+
* (the `decision_winning_grade` SQL view — the ONE place §5.4's precedence +
|
|
24
|
+
* within-rung rules live; both the read route and the rollup recompute consume
|
|
25
|
+
* it). Same posture as the metrics rows: synchronous WAL writes in isolated
|
|
26
|
+
* try/catch that NEVER throw into a caller, injected now() everywhere.
|
|
19
27
|
*/
|
|
20
28
|
import * as fs from 'node:fs';
|
|
21
29
|
import * as path from 'node:path';
|
|
@@ -55,6 +63,102 @@ const SCHEMA = [
|
|
|
55
63
|
PRIMARY KEY (day, door, model_id)
|
|
56
64
|
)`,
|
|
57
65
|
`CREATE INDEX IF NOT EXISTS idx_spend_token_rollup_day ON spend_token_rollup (day)`,
|
|
66
|
+
// ---- Decision-quality substrate (llm-decision-quality-meter §5.5) ----
|
|
67
|
+
// One ~250B content-free row per settled ENROLLED decision, written for EVERY
|
|
68
|
+
// enrolled settlement regardless of volume class (the volume valve governs the
|
|
69
|
+
// provenance JSONL row only) — so outcome rows always have parents and counts
|
|
70
|
+
// are complete. recheck_count/next_recheck_ts carry the per-decision
|
|
71
|
+
// unknown-regrade backoff state (§5.4.6, DC r3).
|
|
72
|
+
`CREATE TABLE IF NOT EXISTS decision_quality (
|
|
73
|
+
correlation_id TEXT PRIMARY KEY,
|
|
74
|
+
decision_point TEXT NOT NULL,
|
|
75
|
+
feature TEXT,
|
|
76
|
+
ts INTEGER NOT NULL,
|
|
77
|
+
verdict_class TEXT,
|
|
78
|
+
minted_by TEXT,
|
|
79
|
+
volume_class TEXT,
|
|
80
|
+
content_class TEXT,
|
|
81
|
+
machine_id TEXT,
|
|
82
|
+
model TEXT,
|
|
83
|
+
framework TEXT,
|
|
84
|
+
prompt_id TEXT,
|
|
85
|
+
recheck_count INTEGER NOT NULL DEFAULT 0,
|
|
86
|
+
next_recheck_ts INTEGER
|
|
87
|
+
)`,
|
|
88
|
+
// Covering index for the read surface + grading keyset walk (point, ts,
|
|
89
|
+
// correlation_id); a plain ts index carries the retention prune.
|
|
90
|
+
`CREATE INDEX IF NOT EXISTS idx_decision_quality_point_ts ON decision_quality (decision_point, ts, correlation_id)`,
|
|
91
|
+
`CREATE INDEX IF NOT EXISTS idx_decision_quality_ts ON decision_quality (ts)`,
|
|
92
|
+
// Outcome annotations (§5.4): upsert key = (correlation_id, graded_by) — a
|
|
93
|
+
// grader supersedes its own prior grade, never multiplies. evidence_note is
|
|
94
|
+
// ≤500 scrubbed chars and NEVER served by /decision-quality.
|
|
95
|
+
`CREATE TABLE IF NOT EXISTS decision_outcomes (
|
|
96
|
+
correlation_id TEXT NOT NULL,
|
|
97
|
+
graded_by TEXT NOT NULL,
|
|
98
|
+
rule_id TEXT NOT NULL,
|
|
99
|
+
rung TEXT NOT NULL,
|
|
100
|
+
evidence_strength TEXT NOT NULL,
|
|
101
|
+
grade TEXT NOT NULL,
|
|
102
|
+
effective_window_ms INTEGER,
|
|
103
|
+
evidence_note TEXT,
|
|
104
|
+
ts INTEGER NOT NULL,
|
|
105
|
+
UNIQUE (correlation_id, graded_by)
|
|
106
|
+
)`,
|
|
107
|
+
`CREATE INDEX IF NOT EXISTS idx_decision_outcomes_ts ON decision_outcomes (ts)`,
|
|
108
|
+
// Content-free daily aggregate: decision_point × the DECISION's UTC day ×
|
|
109
|
+
// winning-grade counts + event counters. Grades are MUTABLE facts (unlike the
|
|
110
|
+
// spend rollup's immutable increments): each outcome upsert RECOMPUTES its
|
|
111
|
+
// affected bucket from decision_quality ⋈ decision_winning_grade. 'expired'
|
|
112
|
+
// is NOT a column — derived at read.
|
|
113
|
+
`CREATE TABLE IF NOT EXISTS decision_quality_rollup (
|
|
114
|
+
decision_point TEXT NOT NULL,
|
|
115
|
+
day TEXT NOT NULL,
|
|
116
|
+
right_count INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
wrong_count INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
unknown_count INTEGER NOT NULL DEFAULT 0,
|
|
119
|
+
orphan_outcomes INTEGER NOT NULL DEFAULT 0,
|
|
120
|
+
join_miss INTEGER NOT NULL DEFAULT 0,
|
|
121
|
+
dropped_by_budget INTEGER NOT NULL DEFAULT 0,
|
|
122
|
+
PRIMARY KEY (decision_point, day)
|
|
123
|
+
)`,
|
|
124
|
+
`CREATE INDEX IF NOT EXISTS idx_decision_quality_rollup_day ON decision_quality_rollup (day)`,
|
|
125
|
+
// The grading job's durable per-decision-point keyset cursor (§5.5 — a table,
|
|
126
|
+
// not an implicit "fourth thing"), + the per-point P19 recheck backoff.
|
|
127
|
+
`CREATE TABLE IF NOT EXISTS decision_grading_cursor (
|
|
128
|
+
decision_point TEXT PRIMARY KEY,
|
|
129
|
+
cursor_ts INTEGER NOT NULL DEFAULT 0,
|
|
130
|
+
cursor_correlation_id TEXT NOT NULL DEFAULT '',
|
|
131
|
+
next_recheck_ts INTEGER,
|
|
132
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
133
|
+
updated_at INTEGER NOT NULL DEFAULT 0
|
|
134
|
+
)`,
|
|
135
|
+
// THE CANONICAL WINNING-GRADE DERIVATION (§5.5, codex r7 — defined ONCE).
|
|
136
|
+
// Precedence: deterministic-ground-truth > recurrence > llm-interpreter >
|
|
137
|
+
// self-report (a self-report NEVER overrides an independent grader); within
|
|
138
|
+
// a rung, conservative: wrong > unknown > right. Deterministic tie-break
|
|
139
|
+
// (ts DESC, graded_by ASC) affects only WHICH row reports the grade, never
|
|
140
|
+
// the grade itself. Both the /decision-quality reads AND the rollup
|
|
141
|
+
// recompute consume THIS view — a parallel reimplementation of precedence
|
|
142
|
+
// is a correctness bug by construction.
|
|
143
|
+
`CREATE VIEW IF NOT EXISTS decision_winning_grade AS
|
|
144
|
+
SELECT correlation_id, grade, rung, evidence_strength, rule_id, graded_by
|
|
145
|
+
FROM (
|
|
146
|
+
SELECT o.*, ROW_NUMBER() OVER (
|
|
147
|
+
PARTITION BY o.correlation_id
|
|
148
|
+
ORDER BY
|
|
149
|
+
CASE o.rung
|
|
150
|
+
WHEN 'deterministic-ground-truth' THEN 0
|
|
151
|
+
WHEN 'recurrence' THEN 1
|
|
152
|
+
WHEN 'llm-interpreter' THEN 2
|
|
153
|
+
WHEN 'self-report' THEN 3
|
|
154
|
+
ELSE 4 END ASC,
|
|
155
|
+
CASE o.grade WHEN 'wrong' THEN 0 WHEN 'unknown' THEN 1 WHEN 'right' THEN 2 ELSE 3 END ASC,
|
|
156
|
+
o.ts DESC,
|
|
157
|
+
o.graded_by ASC
|
|
158
|
+
) AS rn
|
|
159
|
+
FROM decision_outcomes o
|
|
160
|
+
)
|
|
161
|
+
WHERE rn = 1`,
|
|
58
162
|
];
|
|
59
163
|
/**
|
|
60
164
|
* Columns added after the table's first ship. CREATE TABLE IF NOT EXISTS never
|
|
@@ -75,6 +179,36 @@ const ADDED_COLUMNS = [
|
|
|
75
179
|
];
|
|
76
180
|
/** Batch ceiling for the retention prune (scal-F4): SQLite-portable bounded DELETE. */
|
|
77
181
|
const PRUNE_BATCH = 5000;
|
|
182
|
+
/** FD3 grade enum — validated at the substrate write (invalid → refused, never stored). */
|
|
183
|
+
const QUALITY_GRADES = new Set(['right', 'wrong', 'unknown']);
|
|
184
|
+
/** §5.4.2 rung enum — the closed set the canonical view's precedence CASE knows. */
|
|
185
|
+
const GRADING_RUNGS = new Set([
|
|
186
|
+
'deterministic-ground-truth',
|
|
187
|
+
'recurrence',
|
|
188
|
+
'llm-interpreter',
|
|
189
|
+
'self-report',
|
|
190
|
+
]);
|
|
191
|
+
/**
|
|
192
|
+
* Rollup event-counter columns (closed map — counter names never interpolate
|
|
193
|
+
* user input into SQL). These are EVENT counters, not derivable from the
|
|
194
|
+
* decisions⋈outcomes join, so the bucket recompute/reconcile preserves them.
|
|
195
|
+
*/
|
|
196
|
+
const QUALITY_COUNTER_COLUMNS = {
|
|
197
|
+
orphanOutcomes: 'orphan_outcomes',
|
|
198
|
+
joinMiss: 'join_miss',
|
|
199
|
+
droppedByBudget: 'dropped_by_budget',
|
|
200
|
+
};
|
|
201
|
+
/** Outcome evidence-note hard bound (§5.5 — ≤500 scrubbed chars, never served). */
|
|
202
|
+
const EVIDENCE_NOTE_MAX = 500;
|
|
203
|
+
/** Content-free label clamp: trimmed + bounded; null when absent/empty. */
|
|
204
|
+
function clampLabel(v, max = 128) {
|
|
205
|
+
if (v === undefined || v === null)
|
|
206
|
+
return null;
|
|
207
|
+
const t = String(v).trim();
|
|
208
|
+
if (!t)
|
|
209
|
+
return null;
|
|
210
|
+
return t.length > max ? t.slice(0, max) : t;
|
|
211
|
+
}
|
|
78
212
|
/** UTC day key 'YYYY-MM-DD' for a timestamp (the daily-rollup bucket key). */
|
|
79
213
|
function dayKey(ms) {
|
|
80
214
|
return new Date(ms).toISOString().slice(0, 10);
|
|
@@ -98,8 +232,12 @@ export class FeatureMetricsLedger {
|
|
|
98
232
|
now;
|
|
99
233
|
insertStmt;
|
|
100
234
|
rollupUpsertStmt = null;
|
|
235
|
+
/** Quality substrate (§5.5): null = the substrate failed to prepare; writes no-op. */
|
|
236
|
+
decisionInsertStmt = null;
|
|
237
|
+
outcomeUpsertStmt = null;
|
|
101
238
|
maintainSpendRollup;
|
|
102
239
|
lastRollupReconcileMs = null;
|
|
240
|
+
lastQualityReconcileMsValue = null;
|
|
103
241
|
closed = false;
|
|
104
242
|
constructor(opts) {
|
|
105
243
|
this.now = opts.now ?? (() => Date.now());
|
|
@@ -115,6 +253,17 @@ export class FeatureMetricsLedger {
|
|
|
115
253
|
for (const ddl of SCHEMA)
|
|
116
254
|
this.db.exec(ddl);
|
|
117
255
|
this.ensureAddedColumns();
|
|
256
|
+
// Partial index for the quality join (feature_metrics.verdict_id → correlation
|
|
257
|
+
// id, §5.5). Guarded separately from the SCHEMA loop: verdict_id shipped in the
|
|
258
|
+
// original CREATE (it is NOT in ADDED_COLUMNS), but an index-create failure on
|
|
259
|
+
// an exotic old DB must degrade (slower join) rather than brick the open path.
|
|
260
|
+
try {
|
|
261
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_feature_metrics_verdict_id
|
|
262
|
+
ON feature_metrics (verdict_id) WHERE verdict_id IS NOT NULL`);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// @silent-fallback-ok: the partial index is a read-path optimization only.
|
|
266
|
+
}
|
|
118
267
|
// Close-on-exit registry (SqliteRegistry.ts) — closed once at shutdown.
|
|
119
268
|
registerSqliteHandle(() => { try {
|
|
120
269
|
this.db?.close();
|
|
@@ -143,6 +292,37 @@ export class FeatureMetricsLedger {
|
|
|
143
292
|
this.rollupUpsertStmt = null;
|
|
144
293
|
}
|
|
145
294
|
}
|
|
295
|
+
// Quality substrate (llm-decision-quality-meter §5.5): prepared writes + the
|
|
296
|
+
// bounded BOOT reconcile (mirrors reconcileSpendRollup's boot arm; the PERIODIC
|
|
297
|
+
// arm rides AgentServer's boot+6h prune timer — both are the same idempotent
|
|
298
|
+
// fold). Not option-gated: the tables always exist here and the fold is a cheap
|
|
299
|
+
// indexed no-op on an agent with no enrolled decisions. Isolated try/catch — a
|
|
300
|
+
// quality-substrate failure degrades quality writes to no-ops and must never
|
|
301
|
+
// break the primary metrics insert path.
|
|
302
|
+
try {
|
|
303
|
+
this.decisionInsertStmt = this.db.prepare(`INSERT OR IGNORE INTO decision_quality
|
|
304
|
+
(correlation_id, decision_point, feature, ts, verdict_class, minted_by, volume_class, content_class, machine_id, model, framework, prompt_id)
|
|
305
|
+
VALUES (@correlationId, @decisionPoint, @feature, @ts, @verdictClass, @mintedBy, @volumeClass, @contentClass, @machineId, @model, @framework, @promptId)`);
|
|
306
|
+
this.outcomeUpsertStmt = this.db.prepare(`INSERT INTO decision_outcomes
|
|
307
|
+
(correlation_id, graded_by, rule_id, rung, evidence_strength, grade, effective_window_ms, evidence_note, ts)
|
|
308
|
+
VALUES (@correlationId, @gradedBy, @ruleId, @rung, @evidenceStrength, @grade, @effectiveWindowMs, @evidenceNote, @ts)
|
|
309
|
+
ON CONFLICT(correlation_id, graded_by) DO UPDATE SET
|
|
310
|
+
rule_id = excluded.rule_id,
|
|
311
|
+
rung = excluded.rung,
|
|
312
|
+
evidence_strength = excluded.evidence_strength,
|
|
313
|
+
grade = excluded.grade,
|
|
314
|
+
effective_window_ms = excluded.effective_window_ms,
|
|
315
|
+
evidence_note = excluded.evidence_note,
|
|
316
|
+
ts = excluded.ts`);
|
|
317
|
+
this.reconcileQualityRollup(30);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// @silent-fallback-ok: the quality substrate is observability. A prepare
|
|
321
|
+
// failure leaves recordDecision/upsertOutcome as no-ops (the quality view
|
|
322
|
+
// degrades to what it can read) — never the primary insert path's problem.
|
|
323
|
+
this.decisionInsertStmt = null;
|
|
324
|
+
this.outcomeUpsertStmt = null;
|
|
325
|
+
}
|
|
146
326
|
}
|
|
147
327
|
/** Add post-ship columns to an existing table, idempotently (pragma-guarded). */
|
|
148
328
|
ensureAddedColumns() {
|
|
@@ -672,6 +852,557 @@ export class FeatureMetricsLedger {
|
|
|
672
852
|
return [];
|
|
673
853
|
}
|
|
674
854
|
}
|
|
855
|
+
/* ---------------------------------------------------------------------- *
|
|
856
|
+
* Decision-quality substrate (llm-decision-quality-meter §5.5)
|
|
857
|
+
* ---------------------------------------------------------------------- */
|
|
858
|
+
/**
|
|
859
|
+
* Record one settled ENROLLED decision (the router-settlement write). Write-
|
|
860
|
+
* once per correlation id (INSERT OR IGNORE — the first settlement wins; a
|
|
861
|
+
* duplicate settle is a no-op, never a rewrite). Synchronous WAL insert in an
|
|
862
|
+
* isolated try/catch — NEVER throws into the decision path (the record()
|
|
863
|
+
* posture), strictly ≤1 per settled decision.
|
|
864
|
+
*/
|
|
865
|
+
recordDecision(rec) {
|
|
866
|
+
if (this.closed || !this.decisionInsertStmt)
|
|
867
|
+
return;
|
|
868
|
+
const correlationId = clampLabel(rec.correlationId, 128);
|
|
869
|
+
const decisionPoint = clampLabel(rec.decisionPoint, 128);
|
|
870
|
+
if (!correlationId || !decisionPoint)
|
|
871
|
+
return; // keyless row = unjoinable noise
|
|
872
|
+
try {
|
|
873
|
+
this.decisionInsertStmt.run({
|
|
874
|
+
correlationId,
|
|
875
|
+
decisionPoint,
|
|
876
|
+
feature: clampLabel(rec.feature),
|
|
877
|
+
ts: rec.ts ?? this.now(),
|
|
878
|
+
verdictClass: clampLabel(rec.verdictClass),
|
|
879
|
+
mintedBy: clampLabel(rec.mintedBy),
|
|
880
|
+
volumeClass: clampLabel(rec.volumeClass),
|
|
881
|
+
contentClass: clampLabel(rec.contentClass),
|
|
882
|
+
machineId: clampLabel(rec.machineId, 32),
|
|
883
|
+
model: clampLabel(rec.model),
|
|
884
|
+
framework: clampLabel(rec.framework),
|
|
885
|
+
promptId: clampLabel(rec.promptId),
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
// @silent-fallback-ok: observability must never break the path it observes;
|
|
890
|
+
// a dropped decision row surfaces as joinMiss on the read surface, not a throw.
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Indexed COUNT of decision_quality rows for a point since `sinceMs` — the
|
|
895
|
+
* `budget:<rows/day>` volume-class enforcement read (§5.6: UTC-day window,
|
|
896
|
+
* restart-safe, no new state; rides idx_decision_quality_point_ts). Returns
|
|
897
|
+
* null when the count cannot be produced (closed / substrate degraded) —
|
|
898
|
+
* the caller decides the fail direction, honestly, rather than trusting a
|
|
899
|
+
* fabricated 0.
|
|
900
|
+
*/
|
|
901
|
+
countDecisionsSince(decisionPoint, sinceMs) {
|
|
902
|
+
if (this.closed || !this.decisionInsertStmt)
|
|
903
|
+
return null;
|
|
904
|
+
try {
|
|
905
|
+
const row = this.db
|
|
906
|
+
.prepare(`SELECT COUNT(*) AS n FROM decision_quality WHERE decision_point = ? AND ts >= ?`)
|
|
907
|
+
.get(decisionPoint, sinceMs);
|
|
908
|
+
return Number(row?.n) || 0;
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
// @silent-fallback-ok: a failed budget count degrades to null — the seam
|
|
912
|
+
// treats it as "budget unverifiable" and writes (observability must not
|
|
913
|
+
// starve a decision class on a transient read failure).
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Upsert one outcome annotation (§5.4.4: key = correlationId × gradedBy — a
|
|
919
|
+
* re-run supersedes its own prior grade, never multiplies), then RECOMPUTE
|
|
920
|
+
* the affected (decision_point, decision-UTC-day) rollup bucket from
|
|
921
|
+
* decision_quality ⋈ decision_winning_grade (§5.5 reference implementation —
|
|
922
|
+
* bounded, self-healing; a grade flip decrements the old bucket column and
|
|
923
|
+
* increments the new one by construction). An outcome with no local parent
|
|
924
|
+
* row is an ORPHAN (FD10 cross-machine honesty): stored + counted under the
|
|
925
|
+
* caller's decisionPoint hint, never an error and never a graded decision.
|
|
926
|
+
* Never throws into the caller.
|
|
927
|
+
*/
|
|
928
|
+
upsertOutcome(o) {
|
|
929
|
+
if (this.closed || !this.outcomeUpsertStmt)
|
|
930
|
+
return { applied: false, orphan: false, reason: 'closed' };
|
|
931
|
+
const correlationId = clampLabel(o.correlationId, 128);
|
|
932
|
+
const gradedBy = clampLabel(o.gradedBy, 128);
|
|
933
|
+
const ruleId = clampLabel(o.ruleId, 128);
|
|
934
|
+
if (!correlationId || !gradedBy || !ruleId)
|
|
935
|
+
return { applied: false, orphan: false, reason: 'missing-key' };
|
|
936
|
+
if (!QUALITY_GRADES.has(o.grade))
|
|
937
|
+
return { applied: false, orphan: false, reason: 'invalid-grade' };
|
|
938
|
+
if (!GRADING_RUNGS.has(o.rung))
|
|
939
|
+
return { applied: false, orphan: false, reason: 'invalid-rung' };
|
|
940
|
+
const ts = o.ts ?? this.now();
|
|
941
|
+
try {
|
|
942
|
+
const tx = this.db.transaction(() => {
|
|
943
|
+
this.outcomeUpsertStmt.run({
|
|
944
|
+
correlationId,
|
|
945
|
+
gradedBy,
|
|
946
|
+
ruleId,
|
|
947
|
+
rung: o.rung,
|
|
948
|
+
evidenceStrength: clampLabel(o.evidenceStrength) ?? 'self-report',
|
|
949
|
+
grade: o.grade,
|
|
950
|
+
effectiveWindowMs: o.effectiveWindowMs ?? null,
|
|
951
|
+
evidenceNote: clampLabel(o.evidenceNote, EVIDENCE_NOTE_MAX),
|
|
952
|
+
ts,
|
|
953
|
+
});
|
|
954
|
+
const parent = this.db
|
|
955
|
+
.prepare(`SELECT decision_point AS decisionPoint, ts FROM decision_quality WHERE correlation_id = ?`)
|
|
956
|
+
.get(correlationId);
|
|
957
|
+
if (!parent) {
|
|
958
|
+
this.bumpQualityCounterInTx(clampLabel(o.decisionPoint) ?? 'unknown', dayKey(ts), 'orphanOutcomes', 1);
|
|
959
|
+
return { applied: true, orphan: true };
|
|
960
|
+
}
|
|
961
|
+
// The bucket is the DECISION's UTC day (looked up from decision_quality),
|
|
962
|
+
// never the outcome's — late evidence lands in the day it grades.
|
|
963
|
+
this.recomputeQualityBucketInTx(parent.decisionPoint, dayKey(parent.ts));
|
|
964
|
+
return { applied: true, orphan: false };
|
|
965
|
+
});
|
|
966
|
+
return tx();
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
// @silent-fallback-ok: a dropped upsert is repaired by the bounded reconcile
|
|
970
|
+
// from raw truth — never lost data, never a throw into the grading caller.
|
|
971
|
+
return { applied: false, orphan: false, reason: 'write-failed' };
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Recompute ONE (decision_point, day) bucket's winning-grade counts from
|
|
976
|
+
* truth via the canonical view. Event counters (orphan/joinMiss/dropped) are
|
|
977
|
+
* preserved — they are not derivable from the join. Caller holds the tx.
|
|
978
|
+
*/
|
|
979
|
+
recomputeQualityBucketInTx(decisionPoint, day) {
|
|
980
|
+
const start = dayStartMs(day);
|
|
981
|
+
const agg = this.db
|
|
982
|
+
.prepare(`SELECT
|
|
983
|
+
SUM(CASE WHEN w.grade='right' THEN 1 ELSE 0 END) AS rightCount,
|
|
984
|
+
SUM(CASE WHEN w.grade='wrong' THEN 1 ELSE 0 END) AS wrongCount,
|
|
985
|
+
SUM(CASE WHEN w.grade='unknown' THEN 1 ELSE 0 END) AS unknownCount
|
|
986
|
+
FROM decision_quality q
|
|
987
|
+
JOIN decision_winning_grade w ON w.correlation_id = q.correlation_id
|
|
988
|
+
WHERE q.decision_point = ? AND q.ts >= ? AND q.ts < ?`)
|
|
989
|
+
.get(decisionPoint, start, start + 86_400_000);
|
|
990
|
+
this.db
|
|
991
|
+
.prepare(`INSERT INTO decision_quality_rollup (decision_point, day, right_count, wrong_count, unknown_count)
|
|
992
|
+
VALUES (?, ?, ?, ?, ?)
|
|
993
|
+
ON CONFLICT(decision_point, day) DO UPDATE SET
|
|
994
|
+
right_count = excluded.right_count,
|
|
995
|
+
wrong_count = excluded.wrong_count,
|
|
996
|
+
unknown_count = excluded.unknown_count`)
|
|
997
|
+
.run(decisionPoint, day, Number(agg?.rightCount) || 0, Number(agg?.wrongCount) || 0, Number(agg?.unknownCount) || 0);
|
|
998
|
+
}
|
|
999
|
+
/** In-tx counter bump (column name from the closed map — never interpolated input). */
|
|
1000
|
+
bumpQualityCounterInTx(decisionPoint, day, counter, n) {
|
|
1001
|
+
const col = QUALITY_COUNTER_COLUMNS[counter];
|
|
1002
|
+
this.db
|
|
1003
|
+
.prepare(`INSERT INTO decision_quality_rollup (decision_point, day, ${col})
|
|
1004
|
+
VALUES (?, ?, ?)
|
|
1005
|
+
ON CONFLICT(decision_point, day) DO UPDATE SET ${col} = ${col} + excluded.${col}`)
|
|
1006
|
+
.run(decisionPoint, day, n);
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Increment a rollup EVENT counter (orphanOutcomes / joinMiss /
|
|
1010
|
+
* droppedByBudget) for a decision point's UTC-day bucket. These counters are
|
|
1011
|
+
* additive events (not recomputable from the join) and survive bucket
|
|
1012
|
+
* recompute + reconcile. Never throws.
|
|
1013
|
+
*/
|
|
1014
|
+
bumpQualityCounter(decisionPoint, counter, opts = {}) {
|
|
1015
|
+
if (this.closed)
|
|
1016
|
+
return;
|
|
1017
|
+
const point = clampLabel(decisionPoint) ?? 'unknown';
|
|
1018
|
+
const n = Math.max(1, Math.floor(opts.n ?? 1));
|
|
1019
|
+
try {
|
|
1020
|
+
this.bumpQualityCounterInTx(point, dayKey(opts.ts ?? this.now()), counter, n);
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
// @silent-fallback-ok: a dropped counter bump under-counts an event class;
|
|
1024
|
+
// it must never throw into the settlement/annotation path.
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* THE canonical winning-grade read (§5.5 — the single derivation API). Reads
|
|
1029
|
+
* the `decision_winning_grade` view; chunked IN-lists keep parameter counts
|
|
1030
|
+
* bounded. Fail-open to [].
|
|
1031
|
+
*/
|
|
1032
|
+
getWinningGrades(correlationIds) {
|
|
1033
|
+
if (this.closed || correlationIds.length === 0)
|
|
1034
|
+
return [];
|
|
1035
|
+
try {
|
|
1036
|
+
const out = [];
|
|
1037
|
+
for (let i = 0; i < correlationIds.length; i += 500) {
|
|
1038
|
+
const chunk = correlationIds.slice(i, i + 500);
|
|
1039
|
+
const rows = this.db
|
|
1040
|
+
.prepare(`SELECT correlation_id AS correlationId, grade, rung,
|
|
1041
|
+
evidence_strength AS evidenceStrength, rule_id AS ruleId, graded_by AS gradedBy
|
|
1042
|
+
FROM decision_winning_grade
|
|
1043
|
+
WHERE correlation_id IN (${chunk.map(() => '?').join(',')})`)
|
|
1044
|
+
.all(...chunk);
|
|
1045
|
+
out.push(...rows);
|
|
1046
|
+
}
|
|
1047
|
+
return out;
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
// @silent-fallback-ok: a read-only reporting query — degrade to [].
|
|
1051
|
+
return [];
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Bounded reconcile of the quality rollup's GRADE counts from raw truth
|
|
1056
|
+
* (decision_quality ⋈ decision_winning_grade) over the last `days` of
|
|
1057
|
+
* DECISION days — the §5.5 self-heal for a crash between the outcome upsert
|
|
1058
|
+
* and its bucket recompute (and for a hand-corrupted bucket). Event counters
|
|
1059
|
+
* are preserved. Idempotent fold; runs at boot (constructor) AND from the
|
|
1060
|
+
* AgentServer 6h prune timer. Returns buckets written.
|
|
1061
|
+
*/
|
|
1062
|
+
reconcileQualityRollup(days) {
|
|
1063
|
+
if (this.closed)
|
|
1064
|
+
return 0;
|
|
1065
|
+
try {
|
|
1066
|
+
const cutoffDay = dayKey(this.now() - days * 86_400_000);
|
|
1067
|
+
const cutoffMs = dayStartMs(cutoffDay);
|
|
1068
|
+
const tx = this.db.transaction(() => {
|
|
1069
|
+
// Zero (not DELETE) the window's grade counts so event counters survive
|
|
1070
|
+
// and a bucket whose underlying rows vanished is honestly zeroed.
|
|
1071
|
+
this.db
|
|
1072
|
+
.prepare(`UPDATE decision_quality_rollup SET right_count = 0, wrong_count = 0, unknown_count = 0 WHERE day >= ?`)
|
|
1073
|
+
.run(cutoffDay);
|
|
1074
|
+
const res = this.db
|
|
1075
|
+
.prepare(`INSERT INTO decision_quality_rollup (decision_point, day, right_count, wrong_count, unknown_count)
|
|
1076
|
+
SELECT
|
|
1077
|
+
q.decision_point,
|
|
1078
|
+
strftime('%Y-%m-%d', q.ts/1000, 'unixepoch') AS day,
|
|
1079
|
+
SUM(CASE WHEN w.grade='right' THEN 1 ELSE 0 END),
|
|
1080
|
+
SUM(CASE WHEN w.grade='wrong' THEN 1 ELSE 0 END),
|
|
1081
|
+
SUM(CASE WHEN w.grade='unknown' THEN 1 ELSE 0 END)
|
|
1082
|
+
FROM decision_quality q
|
|
1083
|
+
JOIN decision_winning_grade w ON w.correlation_id = q.correlation_id
|
|
1084
|
+
WHERE q.ts >= ?
|
|
1085
|
+
GROUP BY q.decision_point, day
|
|
1086
|
+
ON CONFLICT(decision_point, day) DO UPDATE SET
|
|
1087
|
+
right_count = excluded.right_count,
|
|
1088
|
+
wrong_count = excluded.wrong_count,
|
|
1089
|
+
unknown_count = excluded.unknown_count`)
|
|
1090
|
+
.run(cutoffMs);
|
|
1091
|
+
return Number(res.changes ?? 0);
|
|
1092
|
+
});
|
|
1093
|
+
const written = tx();
|
|
1094
|
+
this.lastQualityReconcileMsValue = this.now();
|
|
1095
|
+
return written;
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
// @silent-fallback-ok: a reconcile failure leaves the last-good rollup in
|
|
1099
|
+
// place; the next boot/timer tick retries. Raw truth is never touched.
|
|
1100
|
+
return 0;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/** When the quality rollup was last reconciled from raw truth (read-honesty surface). */
|
|
1104
|
+
lastQualityReconcileMs() {
|
|
1105
|
+
return this.lastQualityReconcileMsValue;
|
|
1106
|
+
}
|
|
1107
|
+
/** Quality rollup buckets (per decision_point × UTC day), oldest first. Fail-open to []. */
|
|
1108
|
+
decisionQualityRollupDaily(opts = {}) {
|
|
1109
|
+
try {
|
|
1110
|
+
const where = [];
|
|
1111
|
+
const params = [];
|
|
1112
|
+
if (opts.sinceDays && opts.sinceDays > 0) {
|
|
1113
|
+
where.push('day >= ?');
|
|
1114
|
+
params.push(dayKey(this.now() - opts.sinceDays * 86_400_000));
|
|
1115
|
+
}
|
|
1116
|
+
if (opts.decisionPoint) {
|
|
1117
|
+
where.push('decision_point = ?');
|
|
1118
|
+
params.push(opts.decisionPoint);
|
|
1119
|
+
}
|
|
1120
|
+
const rows = this.db
|
|
1121
|
+
.prepare(`SELECT decision_point AS decisionPoint, day,
|
|
1122
|
+
right_count AS rightCount, wrong_count AS wrongCount, unknown_count AS unknownCount,
|
|
1123
|
+
orphan_outcomes AS orphanOutcomes, join_miss AS joinMiss, dropped_by_budget AS droppedByBudget
|
|
1124
|
+
FROM decision_quality_rollup
|
|
1125
|
+
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
1126
|
+
ORDER BY day ASC, decision_point ASC`)
|
|
1127
|
+
.all(...params);
|
|
1128
|
+
return rows.map((r) => ({
|
|
1129
|
+
decisionPoint: String(r.decisionPoint),
|
|
1130
|
+
day: String(r.day),
|
|
1131
|
+
dayStartMs: dayStartMs(String(r.day)),
|
|
1132
|
+
right: Number(r.rightCount) || 0,
|
|
1133
|
+
wrong: Number(r.wrongCount) || 0,
|
|
1134
|
+
unknown: Number(r.unknownCount) || 0,
|
|
1135
|
+
orphanOutcomes: Number(r.orphanOutcomes) || 0,
|
|
1136
|
+
joinMiss: Number(r.joinMiss) || 0,
|
|
1137
|
+
droppedByBudget: Number(r.droppedByBudget) || 0,
|
|
1138
|
+
}));
|
|
1139
|
+
}
|
|
1140
|
+
catch {
|
|
1141
|
+
// @silent-fallback-ok: a read-only reporting query — degrade to [].
|
|
1142
|
+
return [];
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Keyset walk of `decision_quality` rows for ONE point AFTER the compound
|
|
1147
|
+
* cursor `(ts, correlation_id)` — the P9 grade-pass's bounded, same-ms-safe
|
|
1148
|
+
* page (§5.5: "keyset pagination ORDER BY (ts, correlation_id) with the
|
|
1149
|
+
* compound cursor as the page boundary — same-ms bursts cannot skip rows").
|
|
1150
|
+
* Rides idx_decision_quality_point_ts (decision_point, ts, correlation_id).
|
|
1151
|
+
* Content-free: only the join key + decision ts. Fail-open to [].
|
|
1152
|
+
*/
|
|
1153
|
+
walkDecisionsForGrading(decisionPoint, cursorTs, cursorCorrelationId, limit) {
|
|
1154
|
+
if (this.closed)
|
|
1155
|
+
return [];
|
|
1156
|
+
const cap = Math.max(1, Math.min(10_000, Math.floor(limit) || 1));
|
|
1157
|
+
try {
|
|
1158
|
+
const rows = this.db
|
|
1159
|
+
.prepare(`SELECT correlation_id AS correlationId, ts
|
|
1160
|
+
FROM decision_quality
|
|
1161
|
+
WHERE decision_point = ?
|
|
1162
|
+
AND (ts > ? OR (ts = ? AND correlation_id > ?))
|
|
1163
|
+
ORDER BY ts ASC, correlation_id ASC
|
|
1164
|
+
LIMIT ?`)
|
|
1165
|
+
.all(decisionPoint, cursorTs, cursorTs, cursorCorrelationId, cap);
|
|
1166
|
+
return rows.map((r) => ({ correlationId: String(r.correlationId), ts: Number(r.ts) || 0 }));
|
|
1167
|
+
}
|
|
1168
|
+
catch {
|
|
1169
|
+
// @silent-fallback-ok: a failed keyset read yields an empty page — the
|
|
1170
|
+
// grade-pass makes no progress this run and retries next tick (never a throw).
|
|
1171
|
+
return [];
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Per-decision-point read model for GET /decision-quality (§5.5): decision
|
|
1176
|
+
* COUNT + distinct attribution labels (model/framework/prompt_id) over the
|
|
1177
|
+
* window. Pure indexed reads. Fail-open to [].
|
|
1178
|
+
*/
|
|
1179
|
+
decisionPointStats(opts = {}) {
|
|
1180
|
+
if (this.closed)
|
|
1181
|
+
return [];
|
|
1182
|
+
const sinceMs = this.sinceMsFrom(opts);
|
|
1183
|
+
try {
|
|
1184
|
+
const counts = this.db
|
|
1185
|
+
.prepare(`SELECT decision_point AS dp, COUNT(*) AS n FROM decision_quality WHERE ts >= ? GROUP BY decision_point`)
|
|
1186
|
+
.all(sinceMs);
|
|
1187
|
+
const attr = this.db
|
|
1188
|
+
.prepare(`SELECT DISTINCT decision_point AS dp, model, framework, prompt_id AS promptId
|
|
1189
|
+
FROM decision_quality WHERE ts >= ?`)
|
|
1190
|
+
.all(sinceMs);
|
|
1191
|
+
const models = new Map();
|
|
1192
|
+
const frameworks = new Map();
|
|
1193
|
+
const prompts = new Map();
|
|
1194
|
+
const add = (m, k, v) => {
|
|
1195
|
+
if (!v)
|
|
1196
|
+
return;
|
|
1197
|
+
const s = m.get(k) ?? new Set();
|
|
1198
|
+
s.add(v);
|
|
1199
|
+
m.set(k, s);
|
|
1200
|
+
};
|
|
1201
|
+
for (const r of attr) {
|
|
1202
|
+
add(models, r.dp, r.model);
|
|
1203
|
+
add(frameworks, r.dp, r.framework);
|
|
1204
|
+
add(prompts, r.dp, r.promptId);
|
|
1205
|
+
}
|
|
1206
|
+
return counts.map((c) => ({
|
|
1207
|
+
decisionPoint: String(c.dp),
|
|
1208
|
+
decisions: Number(c.n) || 0,
|
|
1209
|
+
models: Array.from(models.get(c.dp) ?? []).sort(),
|
|
1210
|
+
frameworks: Array.from(frameworks.get(c.dp) ?? []).sort(),
|
|
1211
|
+
promptIds: Array.from(prompts.get(c.dp) ?? []).sort(),
|
|
1212
|
+
}));
|
|
1213
|
+
}
|
|
1214
|
+
catch {
|
|
1215
|
+
// @silent-fallback-ok: read-only reporting — degrade to [].
|
|
1216
|
+
return [];
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Grade breakdown for GET /decision-quality (§5.5): winning grades joined to
|
|
1221
|
+
* their decision point over the window, grouped by (decisionPoint, ruleId,
|
|
1222
|
+
* rung, evidenceStrength, grade). The route pivots these into
|
|
1223
|
+
* by-strength/by-rule/by-rung views (strength FIRST — proof-like and
|
|
1224
|
+
* heuristic grades are never conflated). Consumes the CANONICAL
|
|
1225
|
+
* `decision_winning_grade` view (one derivation, both consumers). Window is on
|
|
1226
|
+
* the DECISION ts. Fail-open to [].
|
|
1227
|
+
*/
|
|
1228
|
+
decisionGradeBreakdown(opts = {}) {
|
|
1229
|
+
if (this.closed)
|
|
1230
|
+
return [];
|
|
1231
|
+
const sinceMs = this.sinceMsFrom(opts);
|
|
1232
|
+
try {
|
|
1233
|
+
const rows = this.db
|
|
1234
|
+
.prepare(`SELECT q.decision_point AS decisionPoint, w.rule_id AS ruleId, w.rung,
|
|
1235
|
+
w.evidence_strength AS evidenceStrength, w.grade, COUNT(*) AS n
|
|
1236
|
+
FROM decision_quality q
|
|
1237
|
+
JOIN decision_winning_grade w ON w.correlation_id = q.correlation_id
|
|
1238
|
+
WHERE q.ts >= ?
|
|
1239
|
+
GROUP BY q.decision_point, w.rule_id, w.rung, w.evidence_strength, w.grade`)
|
|
1240
|
+
.all(sinceMs);
|
|
1241
|
+
return rows.map((r) => ({
|
|
1242
|
+
decisionPoint: String(r.decisionPoint),
|
|
1243
|
+
ruleId: String(r.ruleId),
|
|
1244
|
+
rung: String(r.rung),
|
|
1245
|
+
evidenceStrength: String(r.evidenceStrength),
|
|
1246
|
+
grade: String(r.grade),
|
|
1247
|
+
n: Number(r.n) || 0,
|
|
1248
|
+
}));
|
|
1249
|
+
}
|
|
1250
|
+
catch {
|
|
1251
|
+
// @silent-fallback-ok: read-only reporting — degrade to [].
|
|
1252
|
+
return [];
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Per-point count of EXPIRED decisions for GET /decision-quality (§5.5 read
|
|
1257
|
+
* honesty): in-window decisions whose age exceeds `expiryCutoffMs` (the
|
|
1258
|
+
* evidence-carrier horizon — beyond which no window-close grade can arrive)
|
|
1259
|
+
* AND which carry NO winning grade. `expired` is derived at READ, never a
|
|
1260
|
+
* stored grade. Fail-open to [].
|
|
1261
|
+
*/
|
|
1262
|
+
countExpiredByPoint(opts) {
|
|
1263
|
+
if (this.closed)
|
|
1264
|
+
return [];
|
|
1265
|
+
const sinceMs = this.sinceMsFrom(opts);
|
|
1266
|
+
try {
|
|
1267
|
+
const rows = this.db
|
|
1268
|
+
.prepare(`SELECT q.decision_point AS decisionPoint, COUNT(*) AS n
|
|
1269
|
+
FROM decision_quality q
|
|
1270
|
+
LEFT JOIN decision_winning_grade w ON w.correlation_id = q.correlation_id
|
|
1271
|
+
WHERE q.ts >= ? AND q.ts < ? AND w.correlation_id IS NULL
|
|
1272
|
+
GROUP BY q.decision_point`)
|
|
1273
|
+
.all(sinceMs, opts.expiryCutoffMs);
|
|
1274
|
+
return rows.map((r) => ({ decisionPoint: String(r.decisionPoint), expired: Number(r.n) || 0 }));
|
|
1275
|
+
}
|
|
1276
|
+
catch {
|
|
1277
|
+
// @silent-fallback-ok: read-only reporting — degrade to [].
|
|
1278
|
+
return [];
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
/** The grading job's durable per-decision-point cursor. null = no cursor yet (or read failed). */
|
|
1282
|
+
getGradingCursor(decisionPoint) {
|
|
1283
|
+
if (this.closed)
|
|
1284
|
+
return null;
|
|
1285
|
+
try {
|
|
1286
|
+
const row = this.db
|
|
1287
|
+
.prepare(`SELECT decision_point AS decisionPoint, cursor_ts AS cursorTs,
|
|
1288
|
+
cursor_correlation_id AS cursorCorrelationId,
|
|
1289
|
+
next_recheck_ts AS nextRecheckTs, attempts, updated_at AS updatedAt
|
|
1290
|
+
FROM decision_grading_cursor WHERE decision_point = ?`)
|
|
1291
|
+
.get(decisionPoint);
|
|
1292
|
+
return row ?? null;
|
|
1293
|
+
}
|
|
1294
|
+
catch {
|
|
1295
|
+
// @silent-fallback-ok: a failed cursor read makes the grading pass start
|
|
1296
|
+
// from its last durable boundary next run — never a throw.
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Upsert the grading cursor (keyset boundary + P19 recheck backoff).
|
|
1302
|
+
* `updated_at` is stamped from the injected clock — it is the staleness key
|
|
1303
|
+
* cursor pruning keys on. Never throws.
|
|
1304
|
+
*/
|
|
1305
|
+
setGradingCursor(decisionPoint, c) {
|
|
1306
|
+
if (this.closed)
|
|
1307
|
+
return;
|
|
1308
|
+
const point = clampLabel(decisionPoint);
|
|
1309
|
+
if (!point)
|
|
1310
|
+
return;
|
|
1311
|
+
try {
|
|
1312
|
+
this.db
|
|
1313
|
+
.prepare(`INSERT INTO decision_grading_cursor
|
|
1314
|
+
(decision_point, cursor_ts, cursor_correlation_id, next_recheck_ts, attempts, updated_at)
|
|
1315
|
+
VALUES (@decisionPoint, @cursorTs, @cursorCorrelationId, @nextRecheckTs, @attempts, @updatedAt)
|
|
1316
|
+
ON CONFLICT(decision_point) DO UPDATE SET
|
|
1317
|
+
cursor_ts = excluded.cursor_ts,
|
|
1318
|
+
cursor_correlation_id = excluded.cursor_correlation_id,
|
|
1319
|
+
next_recheck_ts = excluded.next_recheck_ts,
|
|
1320
|
+
attempts = excluded.attempts,
|
|
1321
|
+
updated_at = excluded.updated_at`)
|
|
1322
|
+
.run({
|
|
1323
|
+
decisionPoint: point,
|
|
1324
|
+
cursorTs: c.cursorTs,
|
|
1325
|
+
cursorCorrelationId: clampLabel(c.cursorCorrelationId, 128) ?? '',
|
|
1326
|
+
nextRecheckTs: c.nextRecheckTs ?? null,
|
|
1327
|
+
attempts: Math.max(0, Math.floor(c.attempts ?? 0)),
|
|
1328
|
+
updatedAt: this.now(),
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
catch {
|
|
1332
|
+
// @silent-fallback-ok: a dropped cursor write re-grades a page idempotently
|
|
1333
|
+
// next pass (upserts converge) — never a throw into the grading job.
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Retention prune for `decision_quality` (default horizon 90d via
|
|
1338
|
+
* provenance.quality.decisionRetentionDays). PRUNE_BATCH-bounded. Fail-open.
|
|
1339
|
+
*/
|
|
1340
|
+
pruneDecisionQuality(retentionDays, opts = {}) {
|
|
1341
|
+
if (this.closed || retentionDays <= 0)
|
|
1342
|
+
return 0;
|
|
1343
|
+
const cutoff = this.now() - retentionDays * 86_400_000;
|
|
1344
|
+
return this.batchedPrune(`DELETE FROM decision_quality WHERE rowid IN
|
|
1345
|
+
(SELECT rowid FROM decision_quality WHERE ts < ? LIMIT ${PRUNE_BATCH})`, [cutoff], opts.maxBatches ?? 20);
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Retention prune for `decision_outcomes`. Governed by the same
|
|
1349
|
+
* decisionRetentionDays knob but FLOORED at 30d — grading evidence windows +
|
|
1350
|
+
* slack need the outcomes to outlive the raw metrics horizon, so a
|
|
1351
|
+
* mis-tuned low knob can never starve the grade join. Fail-open.
|
|
1352
|
+
*/
|
|
1353
|
+
pruneDecisionOutcomes(retentionDays, opts = {}) {
|
|
1354
|
+
if (this.closed || retentionDays <= 0)
|
|
1355
|
+
return 0;
|
|
1356
|
+
const cutoff = this.now() - Math.max(30, retentionDays) * 86_400_000;
|
|
1357
|
+
return this.batchedPrune(`DELETE FROM decision_outcomes WHERE rowid IN
|
|
1358
|
+
(SELECT rowid FROM decision_outcomes WHERE ts < ? LIMIT ${PRUNE_BATCH})`, [cutoff], opts.maxBatches ?? 20);
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Retention prune for the quality rollup (provenance.quality.rollupRetentionDays,
|
|
1362
|
+
* default 90) — day-keyed like pruneSpendRollup, batch-bounded like the rest. Fail-open.
|
|
1363
|
+
*/
|
|
1364
|
+
pruneQualityRollup(retentionDays, opts = {}) {
|
|
1365
|
+
if (this.closed || retentionDays <= 0)
|
|
1366
|
+
return 0;
|
|
1367
|
+
const cutoffDay = dayKey(this.now() - retentionDays * 86_400_000);
|
|
1368
|
+
return this.batchedPrune(`DELETE FROM decision_quality_rollup WHERE rowid IN
|
|
1369
|
+
(SELECT rowid FROM decision_quality_rollup WHERE day < ? LIMIT ${PRUNE_BATCH})`, [cutoffDay], opts.maxBatches ?? 20);
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Cursor hygiene: a cursor row whose decision_point is REGISTERED is never
|
|
1373
|
+
* pruned (pass the census's decision-point ids); an UNKNOWN point's cursor is
|
|
1374
|
+
* pruned only once stale past `retentionDays` (updated_at — a live grading
|
|
1375
|
+
* job re-stamps it every pass, so only a de-registered/abandoned point ages
|
|
1376
|
+
* out; re-grading after a cursor loss converges idempotently). Fail-open.
|
|
1377
|
+
*/
|
|
1378
|
+
pruneGradingCursors(retentionDays, opts = {}) {
|
|
1379
|
+
if (this.closed || retentionDays <= 0)
|
|
1380
|
+
return 0;
|
|
1381
|
+
const cutoff = this.now() - retentionDays * 86_400_000;
|
|
1382
|
+
const registered = opts.registeredDecisionPoints ?? [];
|
|
1383
|
+
const notIn = registered.length ? ` AND decision_point NOT IN (${registered.map(() => '?').join(',')})` : '';
|
|
1384
|
+
return this.batchedPrune(`DELETE FROM decision_grading_cursor WHERE rowid IN
|
|
1385
|
+
(SELECT rowid FROM decision_grading_cursor WHERE updated_at < ?${notIn} LIMIT ${PRUNE_BATCH})`, [cutoff, ...registered], opts.maxBatches ?? 20);
|
|
1386
|
+
}
|
|
1387
|
+
/** Shared bounded-DELETE loop (the pruneOlderThan idiom). Fail-open to count-so-far. */
|
|
1388
|
+
batchedPrune(deleteSql, params, maxBatches) {
|
|
1389
|
+
let deleted = 0;
|
|
1390
|
+
try {
|
|
1391
|
+
const stmt = this.db.prepare(deleteSql);
|
|
1392
|
+
for (let i = 0; i < maxBatches; i++) {
|
|
1393
|
+
const n = Number(stmt.run(...params).changes ?? 0);
|
|
1394
|
+
deleted += n;
|
|
1395
|
+
if (n < PRUNE_BATCH)
|
|
1396
|
+
break; // drained
|
|
1397
|
+
}
|
|
1398
|
+
return deleted;
|
|
1399
|
+
}
|
|
1400
|
+
catch {
|
|
1401
|
+
// @silent-fallback-ok: retention prune is best-effort housekeeping — a failed
|
|
1402
|
+
// batch leaves older rows for the next tick; never a throw.
|
|
1403
|
+
return deleted;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
675
1406
|
close() {
|
|
676
1407
|
if (this.closed)
|
|
677
1408
|
return;
|