clawmem 0.20.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/canary.ts ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Embedding-geometry canary (VSEARCH-TRUST-HARDENING (d)).
3
+ *
4
+ * Detects two failure classes the model-name check cannot see (the server may report a
5
+ * literal placeholder name like "embedding"):
6
+ *
7
+ * 1. WRONG geometry (2026-07-10 incident class): the server is stable but produces
8
+ * non-discriminating vectors — e.g. a last-token model served without its EOS anchor.
9
+ * Self-similarity stays ~1.0 throughout, so only PAIR-SEPARATION checks catch it.
10
+ * 2. DRIFTED geometry (2026-06-22 class): the serving stack changed since the vault was
11
+ * embedded (model/quant/pooling swap behind an unchanged name) — caught by comparing
12
+ * stored baseline probe vectors against fresh embeds of the same probes.
13
+ *
14
+ * Probes are formatted through the PRODUCTION templates so the canary measures the geometry
15
+ * retrieval actually uses, and include a terminus control (near-identical texts differing
16
+ * only near the end — an unanchored last-token regime scores these LOW) and a truncation
17
+ * control (prefix vs full text — unstable under the broken geometry). Register coverage
18
+ * matters: the incident's basic-English pairs looked healthy while technical text collapsed.
19
+ *
20
+ * Versioned: changing any probe text bumps CANARY_PROBE_VERSION and invalidates baselines
21
+ * (the profile key embeds the version).
22
+ */
23
+ import { formatDocForEmbedding, formatQueryForEmbedding } from "./llm.ts";
24
+ import { basename } from "path";
25
+ import { createHash } from "crypto";
26
+ import { parseDocument } from "./indexer.ts";
27
+ import { splitDocument } from "./splitter.ts";
28
+ import { canonicalDocId, type Store } from "./store.ts";
29
+
30
+ export const CANARY_PROBE_VERSION = 1;
31
+
32
+ // Probe texts — generic register, deliberately vault-agnostic.
33
+ const REL_A = "the cat sat on the mat";
34
+ const REL_B = "a cat is sitting on a mat";
35
+ const UNREL = "quarterly financial report for fiscal year 2024";
36
+ const TECH_QUERY = "sandbox policy denies manifest read during the verification profile";
37
+ const TECH_ECHO = "The sandbox policy uses a syscall filter so the manifest is unreadable during the verification profile.";
38
+ const TERM_A = "the deploy pipeline reads the manifest file and validates its signature";
39
+ const TERM_B = "the deploy pipeline reads the manifest file and validates its checksum";
40
+ const TRUNC_FULL = `${TECH_ECHO} The filter is compiled at startup and applied to every worker process before any user code runs.`;
41
+
42
+ /** probeId → the exact embed input (production-formatted). */
43
+ export function canaryProbeInputs(): Map<string, string> {
44
+ return new Map<string, string>([
45
+ ["rel_a", formatDocForEmbedding(REL_A)],
46
+ ["rel_b", formatDocForEmbedding(REL_B)],
47
+ ["unrel", formatDocForEmbedding(UNREL)],
48
+ ["tech_query", formatQueryForEmbedding(TECH_QUERY)],
49
+ ["tech_echo", formatDocForEmbedding(TECH_ECHO)],
50
+ ["term_a", formatDocForEmbedding(TERM_A)],
51
+ ["term_b", formatDocForEmbedding(TERM_B)],
52
+ ["trunc_full", formatDocForEmbedding(TRUNC_FULL)],
53
+ ]);
54
+ }
55
+
56
+ export function cosineSim(a: Float32Array, b: Float32Array): number {
57
+ let dot = 0, na = 0, nb = 0;
58
+ const n = Math.min(a.length, b.length);
59
+ for (let i = 0; i < n; i++) { dot += a[i]! * b[i]!; na += a[i]! * a[i]!; nb += b[i]! * b[i]!; }
60
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
61
+ return denom > 0 ? dot / denom : 0;
62
+ }
63
+
64
+ /** Pair-separation margins — each must clear the floor in a healthy geometry. */
65
+ export function canaryMargins(vecs: Map<string, Float32Array>): Record<string, number> {
66
+ const v = (id: string) => vecs.get(id)!;
67
+ const unrelBase = (id: string) => cosineSim(v(id), v("unrel"));
68
+ return {
69
+ // related-pair separation: paraphrases must beat the unrelated pair
70
+ m_rel: cosineSim(v("rel_a"), v("rel_b")) - unrelBase("rel_a"),
71
+ // technical echo separation: a query must land nearer its echo than unrelated text
72
+ m_echo: cosineSim(v("tech_query"), v("tech_echo")) - unrelBase("tech_query"),
73
+ // terminus control: near-identical texts (different ending) must beat unrelated —
74
+ // an unanchored last-token readout fails exactly this
75
+ m_term: cosineSim(v("term_a"), v("term_b")) - unrelBase("term_a"),
76
+ // truncation control: a prefix must stay near its full text
77
+ m_trunc: cosineSim(v("trunc_full"), v("tech_echo")) - unrelBase("trunc_full"),
78
+ };
79
+ }
80
+
81
+ /** Absolute backstop for every margin when no baseline exists (design (d).3). */
82
+ export const CANARY_ABSOLUTE_MARGIN_FLOOR = 0.10;
83
+ /** Relative alert: a margin below this fraction of its baseline is a failure. */
84
+ export const CANARY_BASELINE_RATIO_FLOOR = 0.5;
85
+ /** Self-drift floor: stored baseline probe vs fresh embed of the same input. */
86
+ export const CANARY_DRIFT_FLOOR = 0.98;
87
+
88
+ export interface CanaryCheckResult {
89
+ pass: boolean;
90
+ failures: string[];
91
+ margins: Record<string, number>;
92
+ vectors: Map<string, Float32Array>;
93
+ profileKey: string;
94
+ /** Present only when a stored baseline existed for the profile. */
95
+ driftChecked: boolean;
96
+ }
97
+
98
+ export function canaryProfileKey(model: string, dim: number): string {
99
+ return `v${CANARY_PROBE_VERSION}:${model || "unknown"}:${dim}`;
100
+ }
101
+
102
+ /**
103
+ * Run the full battery: embed every probe, compute margins, evaluate
104
+ * sanity (pair separation vs baseline-calibrated or absolute floors) and
105
+ * drift (vs stored baseline vectors, when available).
106
+ */
107
+ export async function runCanaryBattery(
108
+ embed: (text: string) => Promise<{ embedding: number[] | Float32Array; model?: string } | null>,
109
+ getBaseline: (profileKey: string) => { probes: Map<string, Float32Array>; pairMargins: Record<string, number> } | null,
110
+ opts?: {
111
+ /** Recalibration mode (T9-M3): evaluate INTRINSIC sanity only (absolute floors +
112
+ * mixed-endpoint) — skip every baseline-relative check, since the point of
113
+ * recalibrating is that the old baseline no longer applies. */
114
+ ignoreBaseline?: boolean;
115
+ }
116
+ ): Promise<CanaryCheckResult | { unavailable: true; reason: string }> {
117
+ const inputs = canaryProbeInputs();
118
+ const vectors = new Map<string, Float32Array>();
119
+ const modelsSeen = new Set<string>();
120
+ const dimsSeen = new Set<number>();
121
+ let model = "";
122
+ for (const [id, text] of inputs) {
123
+ let r: { embedding: number[] | Float32Array; model?: string } | null = null;
124
+ try { r = await embed(text); } catch { r = null; }
125
+ if (!r || !r.embedding || r.embedding.length === 0) {
126
+ return { unavailable: true, reason: `probe '${id}' could not be embedded (endpoint unreachable?)` };
127
+ }
128
+ vectors.set(id, r.embedding instanceof Float32Array ? r.embedding : new Float32Array(r.embedding));
129
+ dimsSeen.add(r.embedding.length);
130
+ if (r.model) modelsSeen.add(r.model);
131
+ if (!model && r.model) model = r.model;
132
+ }
133
+ const dim = vectors.get("rel_a")!.length;
134
+ const profileKey = canaryProfileKey(model, dim);
135
+ const baseline = opts?.ignoreBaseline ? null : getBaseline(profileKey);
136
+ const margins = canaryMargins(vectors);
137
+
138
+ const failures: string[] = [];
139
+ // A flapping endpoint answering with mixed dimensions or models across ONE battery is a
140
+ // hard failure (T8-H1): such a server must never be allowed to feed a rebuild.
141
+ if (dimsSeen.size > 1) failures.push(`mixed dimensions across probes (${[...dimsSeen].join(", ")}) — flapping endpoint`);
142
+ if (modelsSeen.size > 1) failures.push(`mixed models across probes (${[...modelsSeen].join(", ")}) — flapping endpoint`);
143
+ for (const [name, value] of Object.entries(margins)) {
144
+ const baseMargin = baseline?.pairMargins?.[name];
145
+ const floor = baseMargin !== undefined && baseMargin > 0
146
+ ? Math.max(CANARY_ABSOLUTE_MARGIN_FLOOR, baseMargin * CANARY_BASELINE_RATIO_FLOOR)
147
+ : CANARY_ABSOLUTE_MARGIN_FLOOR;
148
+ if (value < floor) {
149
+ failures.push(`${name} = ${value.toFixed(3)} < floor ${floor.toFixed(3)}${baseMargin !== undefined ? ` (baseline ${baseMargin.toFixed(3)})` : ""}`);
150
+ }
151
+ }
152
+
153
+ let driftChecked = false;
154
+ if (baseline) {
155
+ driftChecked = true;
156
+ for (const [id, storedVec] of baseline.probes) {
157
+ const fresh = vectors.get(id);
158
+ if (!fresh) continue; // probe set changed (version bump) — baseline is stale, sanity floors govern
159
+ if (fresh.length !== storedVec.length) {
160
+ failures.push(`drift:${id} dimension changed (${storedVec.length} → ${fresh.length})`);
161
+ continue;
162
+ }
163
+ const sim = cosineSim(storedVec, fresh);
164
+ if (sim < CANARY_DRIFT_FLOOR) {
165
+ failures.push(`drift:${id} cos(stored, fresh) = ${sim.toFixed(4)} < ${CANARY_DRIFT_FLOOR} — server geometry changed since last embed`);
166
+ }
167
+ }
168
+ }
169
+
170
+ return { pass: failures.length === 0, failures, margins, vectors, profileKey, driftChecked };
171
+ }
172
+
173
+ /**
174
+ * Preflight gate decision (T8-H1: the gate must FAIL CLOSED before a destructive clear).
175
+ *
176
+ * pass → proceed
177
+ * fail + !forceGeometry → abort (any run — nothing is written against bad geometry)
178
+ * fail + forceGeometry → warn (explicit operator override)
179
+ * unavailable + force → abort unless forceGeometry — a `--force` clear must never
180
+ * proceed on an UNVALIDATED endpoint (the later dimension
181
+ * probe alone can pass a flaky server far enough to destroy
182
+ * the old index and then die on the first fragment)
183
+ * unavailable + !force → warn (incremental runs write nothing destructive first;
184
+ * their embeds fail naturally if the endpoint is down)
185
+ */
186
+ export function canaryGate(
187
+ outcome: CanaryCheckResult | { unavailable: true; reason: string },
188
+ opts: { force: boolean; forceGeometry: boolean }
189
+ ): { action: "proceed" | "warn" | "abort"; reason: string } {
190
+ if ("unavailable" in outcome) {
191
+ if (opts.force && !opts.forceGeometry) {
192
+ return { action: "abort", reason: `geometry canary unavailable (${outcome.reason}) — refusing a destructive --force clear against an unvalidated endpoint (override: --force-geometry)` };
193
+ }
194
+ return { action: "warn", reason: outcome.reason };
195
+ }
196
+ if (!outcome.pass) {
197
+ if (opts.forceGeometry) return { action: "warn", reason: "canary FAILED — continuing under --force-geometry" };
198
+ return { action: "abort", reason: "canary FAILED" };
199
+ }
200
+ return { action: "proceed", reason: "canary passed" };
201
+ }
202
+
203
+ /**
204
+ * Persist the canary baseline ONLY as a first-healthy calibration (T8-M3): repeated
205
+ * healthy runs must not roll the reference (repeated sub-threshold drift could otherwise
206
+ * walk it arbitrarily far). `recalibrate` is the explicit replacement operation.
207
+ */
208
+ export function persistCanaryBaselineIfFirst(
209
+ store: Store,
210
+ state: CanaryCheckResult,
211
+ opts: { recalibrate: boolean; leaseGuard?: { workerName: string; token: string } }
212
+ ): boolean {
213
+ if (!opts.recalibrate && store.getCanaryBaseline(state.profileKey) !== null) return false;
214
+ store.saveCanaryBaseline(
215
+ state.profileKey,
216
+ [...state.vectors.entries()].map(([probeId, embedding]) => ({ probeId, embedding })),
217
+ state.margins,
218
+ opts.leaseGuard
219
+ );
220
+ return true;
221
+ }
222
+
223
+ // Sampled persisted-vs-fresh vector validation (doctor section 11). Reconstructs each
224
+ // sampled fragment from its CANONICAL document via the production pipeline
225
+ // (parseDocument → splitDocument → seq/label alignment → formatDocForEmbedding), then:
226
+ // - fingerprinted rows: fp match → FULL validation (cos ≥ 0.98; below = DEFINITIVE
227
+ // corruption/drift); fp mismatch → DEFINITIVE stale-input. The first definitive
228
+ // failure returns IMMEDIATELY (T8-H2) — doctor is nonzero either way, and continuing
229
+ // could approach a full re-embed's worth of calls on a large corrupt vault.
230
+ // - legacy rows (no fp): STRUCTURAL validation; low cos is INCONCLUSIVE (title
231
+ // provenance is universally unavailable for the tier) — never silently passed.
232
+ // Operational bounds (T8-H2): eligibility is metadata-only (no bodies); bodies hydrate
233
+ // lazily per sampled row; total attempts are capped at target + SAMPLE_REPLACEMENT_BUDGET.
234
+ // Canonical identity (T8-H3): rows are unique per (hash,seq) — alias documents sharing a
235
+ // content hash do NOT multiply eligibility — and reconstruction uses the document whose
236
+ // canonicalDocId matches the stored cv.canonical_id (falling back to a single active
237
+ // alias for null-canonical legacy rows; ambiguity → unreconstructable, never condemned).
238
+ // Contract checks (T8-M4): the reconstructed fragment COUNT must equal the persisted
239
+ // per-hash row count, and the seq-0 quota is tracked through VALIDATED samples.
240
+
241
+ export const SAMPLE_REPLACEMENT_BUDGET = 8;
242
+
243
+ export async function runSampledVectorValidation(
244
+ s: Store,
245
+ embed: (text: string) => Promise<{ embedding: number[] | Float32Array; model?: string } | null>
246
+ ): Promise<{
247
+ eligible: number; target: number; nMin: number; validated: number;
248
+ validatedSeq0: number; seq0Target: number;
249
+ legacyTier: number; unreconstructable: number; inconclusiveLegacy: number; attempts: number;
250
+ definitiveFailures: string[];
251
+ }> {
252
+ type MetaRow = { hash: string; seq: number; fragment_label: string | null; embed_input_fp: string | null; canonical_id: string | null };
253
+ // Metadata-only eligibility — one row per (hash,seq), NO document join (alias docs must
254
+ // not multiply eligibility), NO body hydration.
255
+ const eligibleRows = s.db.prepare(`
256
+ SELECT cv.hash, cv.seq, cv.fragment_label, cv.embed_input_fp, cv.canonical_id
257
+ FROM content_vectors cv
258
+ WHERE EXISTS (
259
+ SELECT 1 FROM documents d
260
+ WHERE d.hash = cv.hash AND d.active = 1 AND d.invalidated_at IS NULL AND d.embed_state = 'synced'
261
+ )
262
+ `).all() as MetaRow[];
263
+ const eligible = eligibleRows.length;
264
+ const target = Math.min(16, eligible);
265
+ const nMin = Math.min(8, eligible);
266
+ const seq0Pool = eligibleRows.filter(r => r.seq === 0);
267
+ const seq0Target = Math.min(4, seq0Pool.length);
268
+ const result = {
269
+ eligible, target, nMin, validated: 0, validatedSeq0: 0, seq0Target,
270
+ legacyTier: 0, unreconstructable: 0, inconclusiveLegacy: 0, attempts: 0,
271
+ definitiveFailures: [] as string[],
272
+ };
273
+ if (eligible === 0) return result;
274
+
275
+ const shuffle = <T,>(arr: T[]) => { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j]!, arr[i]!]; } return arr; };
276
+ // seq-0 rows first until the VALIDATED seq-0 quota is met, then the rest (T8-M4).
277
+ const seq0Order = shuffle([...seq0Pool]);
278
+ const restOrder = shuffle(eligibleRows.filter(r => r.seq !== 0));
279
+ let seq0Idx = 0, restIdx = 0;
280
+ const nextRow = (): MetaRow | null => {
281
+ if (result.validatedSeq0 < seq0Target && seq0Idx < seq0Order.length) return seq0Order[seq0Idx++]!;
282
+ if (restIdx < restOrder.length) return restOrder[restIdx++]!;
283
+ if (seq0Idx < seq0Order.length) return seq0Order[seq0Idx++]!;
284
+ return null;
285
+ };
286
+
287
+ const maxAttempts = target + SAMPLE_REPLACEMENT_BUDGET;
288
+ const bodyStmt = s.db.prepare(`SELECT doc FROM content WHERE hash = ?`);
289
+ // Canonical RESOLUTION consults ALL aliases including inactive ones (T9-M1) — the
290
+ // stored canonical identity may name a since-deactivated alias while an active twin
291
+ // keeps the row eligible; condemning it as unreconstructable would be false. The
292
+ // active-document EXISTS guard above still gates ELIGIBILITY.
293
+ const aliasStmt = s.db.prepare(`SELECT collection, path, title, active FROM documents WHERE hash = ? ORDER BY active DESC, collection, path`);
294
+ const fragCountStmt = s.db.prepare(`SELECT count(*) as c FROM content_vectors WHERE hash = ?`);
295
+ const vecStmt = s.db.prepare(`SELECT embedding FROM vectors_vec WHERE hash_seq = ?`);
296
+
297
+ while (result.validated < target && result.attempts < maxAttempts) {
298
+ const row = nextRow();
299
+ if (!row) break;
300
+ result.attempts++;
301
+
302
+ // Canonical resolution (T8-H3 / T9-M1): the document whose canonicalDocId matches the
303
+ // stored id — searched across ALL aliases (active first). Null-canonical legacy rows
304
+ // fall back to a single ACTIVE alias; ambiguity → unreconstructable, never condemned.
305
+ const aliases = aliasStmt.all(row.hash) as { collection: string; path: string; title: string; active: number }[];
306
+ let canonical = row.canonical_id
307
+ ? aliases.find(a => canonicalDocId(a.collection, a.path) === row.canonical_id)
308
+ : undefined;
309
+ if (!canonical && !row.canonical_id) {
310
+ const activeAliases = aliases.filter(a => a.active === 1);
311
+ if (activeAliases.length === 1) canonical = activeAliases[0];
312
+ }
313
+ if (!canonical) { result.unreconstructable++; continue; }
314
+
315
+ // Reconstruct through the production pipeline (lazy body hydration).
316
+ let fragText: string;
317
+ try {
318
+ const bodyRow = bodyStmt.get(row.hash) as { doc: string } | undefined;
319
+ if (!bodyRow) { result.unreconstructable++; continue; }
320
+ let frontmatter: Record<string, any> | undefined;
321
+ try { frontmatter = parseDocument(bodyRow.doc, canonical.path).meta as any; } catch { /* no frontmatter */ }
322
+ const frags = splitDocument(bodyRow.doc, frontmatter);
323
+ // Per-hash fragment-count contract (T8-M4): splitter output must match what was persisted.
324
+ const persistedCount = (fragCountStmt.get(row.hash) as { c: number }).c;
325
+ if (frags.length !== persistedCount) { result.unreconstructable++; continue; }
326
+ const frag = frags[row.seq];
327
+ if (!frag) { result.unreconstructable++; continue; }
328
+ if ((frag.label ?? null) !== (row.fragment_label ?? null)) { result.unreconstructable++; continue; }
329
+ const docTitle = canonical.title || basename(canonical.path).replace(/\.(md|txt)$/i, "");
330
+ fragText = formatDocForEmbedding(frag.content, frag.label || docTitle);
331
+ } catch { result.unreconstructable++; continue; }
332
+
333
+ const stored = vecStmt.get(`${row.hash}_${row.seq}`) as { embedding: Uint8Array } | undefined;
334
+ if (!stored?.embedding) { result.unreconstructable++; continue; }
335
+ const storedBuf = new Uint8Array(stored.embedding);
336
+ const storedVec = new Float32Array(storedBuf.buffer, storedBuf.byteOffset, storedBuf.byteLength / 4);
337
+
338
+ if (row.embed_input_fp) {
339
+ const fp = createHash("sha256").update(fragText, "utf8").digest("hex");
340
+ if (fp !== row.embed_input_fp) {
341
+ result.definitiveFailures.push(`stale-input: ${canonical.collection}/${canonical.path}#${row.seq} — embed input changed since embed (fingerprint mismatch); re-embed required`);
342
+ return result; // definitive → nonzero regardless of coverage; stop spending embeds (T8-H2)
343
+ }
344
+ const fresh = await embed(fragText).catch(() => null);
345
+ if (!fresh || fresh.embedding.length !== storedVec.length) { result.unreconstructable++; continue; }
346
+ const sim = cosineSim(storedVec, fresh.embedding instanceof Float32Array ? fresh.embedding : new Float32Array(fresh.embedding));
347
+ if (sim < 0.98) {
348
+ result.definitiveFailures.push(`corruption/drift: ${canonical.collection}/${canonical.path}#${row.seq} — fingerprint matches but cos(stored, fresh) = ${sim.toFixed(4)} < 0.98`);
349
+ return result;
350
+ }
351
+ result.validated++;
352
+ if (row.seq === 0) result.validatedSeq0++;
353
+ } else {
354
+ const fresh = await embed(fragText).catch(() => null);
355
+ if (!fresh || fresh.embedding.length !== storedVec.length) { result.unreconstructable++; continue; }
356
+ const sim = cosineSim(storedVec, fresh.embedding instanceof Float32Array ? fresh.embedding : new Float32Array(fresh.embedding));
357
+ if (sim < 0.98) { result.inconclusiveLegacy++; continue; }
358
+ result.legacyTier++;
359
+ result.validated++;
360
+ if (row.seq === 0) result.validatedSeq0++;
361
+ }
362
+ }
363
+ return result;
364
+ }