pi-mega-compact 0.11.13 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/extensions/dashboard-server/routes-health.js +125 -0
  2. package/dist/extensions/dashboard-server/routes.js +1 -0
  3. package/dist/extensions/dashboard-server/server.js +7 -1
  4. package/dist/extensions/mega-config.js +5 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +31 -0
  6. package/dist/extensions/mega-events/health-handler.js +173 -0
  7. package/dist/extensions/mega-runtime/runtime.js +6 -0
  8. package/dist/src/contextHealth/cachePoison.js +183 -0
  9. package/dist/src/contextHealth/drift.js +89 -0
  10. package/dist/src/contextHealth/outputQuality.js +112 -0
  11. package/dist/src/contextHealth.js +110 -0
  12. package/dist/src/store/sqlite/context-health.js +193 -0
  13. package/dist/src/store/sqlite/meta.js +11 -0
  14. package/dist/src/store/sqlite/schema.js +43 -1
  15. package/dist/src/store/sqlite.js +1 -0
  16. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js → AchievementTiles-Cwg_Ab8R.js} +2 -2
  17. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js.map → AchievementTiles-Cwg_Ab8R.js.map} +1 -1
  18. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js → AchievementsTab-Dk32-4Ha.js} +2 -2
  19. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js.map → AchievementsTab-Dk32-4Ha.js.map} +1 -1
  20. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js → CacheTab-B9zR8UgD.js} +2 -2
  21. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js.map → CacheTab-B9zR8UgD.js.map} +1 -1
  22. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js → ConfigTab-DYRl3mJZ.js} +2 -2
  23. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js.map → ConfigTab-DYRl3mJZ.js.map} +1 -1
  24. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js → EventsTab-r_zXcrmZ.js} +2 -2
  25. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js.map → EventsTab-r_zXcrmZ.js.map} +1 -1
  26. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js → GameTab-DjwA_Q1v.js} +2 -2
  27. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js.map → GameTab-DjwA_Q1v.js.map} +1 -1
  28. package/extensions/dashboard-client/dist/assets/HealthTab-BjH73kQr.js +2 -0
  29. package/extensions/dashboard-client/dist/assets/HealthTab-BjH73kQr.js.map +1 -0
  30. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DzeSq9vK.js +2 -0
  31. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DzeSq9vK.js.map +1 -0
  32. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js → MemoryMapTab-BU9iM3ir.js} +2 -2
  33. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js.map → MemoryMapTab-BU9iM3ir.js.map} +1 -1
  34. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js → MetricsTab-lMVodOwY.js} +2 -2
  35. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js.map → MetricsTab-lMVodOwY.js.map} +1 -1
  36. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js → OverviewTab-D-laa6CW.js} +2 -2
  37. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js.map → OverviewTab-D-laa6CW.js.map} +1 -1
  38. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js → ReposTab-5Pn2cOOO.js} +2 -2
  39. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js.map → ReposTab-5Pn2cOOO.js.map} +1 -1
  40. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js → SessionsTab-Dtdmvl2C.js} +2 -2
  41. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js.map → SessionsTab-Dtdmvl2C.js.map} +1 -1
  42. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js → SetupTab-rKGgWpI0.js} +2 -2
  43. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js.map → SetupTab-rKGgWpI0.js.map} +1 -1
  44. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js → TimeSavedCard-B5Lw6fTp.js} +2 -2
  45. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js.map → TimeSavedCard-B5Lw6fTp.js.map} +1 -1
  46. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js → TopicsTab-Ds1IHhPw.js} +2 -2
  47. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js.map → TopicsTab-Ds1IHhPw.js.map} +1 -1
  48. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js → TurnsTab-CG2RDRmh.js} +2 -2
  49. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js.map → TurnsTab-CG2RDRmh.js.map} +1 -1
  50. package/extensions/dashboard-client/dist/assets/health-lSeDFTz7.js +2 -0
  51. package/extensions/dashboard-client/dist/assets/health-lSeDFTz7.js.map +1 -0
  52. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js → index-C9dTXvDQ.js} +11 -11
  53. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js.map → index-C9dTXvDQ.js.map} +1 -1
  54. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js → useSSE-B6l8O-yi.js} +2 -2
  55. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js.map → useSSE-B6l8O-yi.js.map} +1 -1
  56. package/extensions/dashboard-client/dist/index.html +1 -1
  57. package/extensions/dashboard-client/src/App.tsx +5 -1
  58. package/extensions/dashboard-client/src/api/health.ts +80 -0
  59. package/extensions/dashboard-client/src/tabs/HealthTab.tsx +165 -0
  60. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +91 -1
  61. package/extensions/dashboard-server/routes-health.ts +155 -0
  62. package/extensions/dashboard-server/routes.ts +1 -0
  63. package/extensions/dashboard-server/server.ts +6 -0
  64. package/extensions/mega-config.ts +15 -0
  65. package/extensions/mega-events/agent-handlers.ts +31 -0
  66. package/extensions/mega-events/health-handler.ts +218 -0
  67. package/extensions/mega-runtime/runtime.ts +7 -0
  68. package/package.json +1 -1
  69. package/src/contextHealth/cachePoison.ts +243 -0
  70. package/src/contextHealth/drift.ts +107 -0
  71. package/src/contextHealth/outputQuality.ts +139 -0
  72. package/src/contextHealth.ts +191 -0
  73. package/src/store/sqlite/context-health.ts +342 -0
  74. package/src/store/sqlite/meta.ts +16 -0
  75. package/src/store/sqlite/schema.ts +43 -1
  76. package/src/store/sqlite.ts +1 -0
  77. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DNZ1IF4t.js +0 -2
  78. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DNZ1IF4t.js.map +0 -1
@@ -35,6 +35,7 @@ import {
35
35
  import {
36
36
  refreshStripeAssignments as writeStripeAssignments,
37
37
  } from "../../src/cache-stripe-impl.js";
38
+ import { handleTurnEndHealth } from "./health-handler.js";
38
39
 
39
40
  /** Register agent/turn tracking event handlers. */
40
41
  export function registerAgentHandlers(
@@ -296,6 +297,7 @@ export function registerAgentHandlers(
296
297
  pi.on("turn_end", async (event, ctx) => {
297
298
  runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
298
299
  runtime.snapshot(ctx);
300
+ runtime.lastErrorCategory = null;
299
301
 
300
302
  // S53: consume staged recall blocks ONLY if they were actually injected
301
303
  // into a view this turn. If no context event fired (edge: turn ended
@@ -436,6 +438,7 @@ export function registerAgentHandlers(
436
438
  } else {
437
439
  const category = classifyError(event.message);
438
440
  const detail = classifyErrorDetailed(event.message);
441
+ runtime.lastErrorCategory = category;
439
442
  // R3: stateful poisoned-context signal — repeated identical error text
440
443
  // across consecutive turns. The classifier is stateless, so this
441
444
  // upgrade happens here. A 'transient' (or 'poisoned-context') turn
@@ -860,5 +863,33 @@ export function registerAgentHandlers(
860
863
  /* non-fatal: stripe refresh never breaks the agent loop */
861
864
  }
862
865
  }
866
+
867
+ // v0.12: Context Health — compute + persist health score. Non-fatal.
868
+ // Mitigation signals are acted on here (ctx is in scope at turn_end).
869
+ const healthSignal = handleTurnEndHealth(event, runtime, config);
870
+ if (healthSignal.forceCompact) {
871
+ runtime.logger?.info("health_mitigate_compact", {
872
+ composite: healthSignal.composite,
873
+ turnIndex: event.turnIndex,
874
+ });
875
+ try {
876
+ // Force a compaction to flush degraded context. Same race-guarded
877
+ // deferred path as the critical-over escape hatch above.
878
+ if (!piCompactWouldNoop(ctx)) {
879
+ ctx.compact();
880
+ }
881
+ } catch {
882
+ /* non-fatal: mitigation never breaks the agent loop */
883
+ }
884
+ }
885
+ if (healthSignal.breakPrefix) {
886
+ runtime.logger?.info("health_mitigate_prefix_break", {
887
+ turnIndex: event.turnIndex,
888
+ });
889
+ // Invalidate the cached trim so the next context event regenerates
890
+ // from scratch — the prefix change forces a cache miss, bypassing
891
+ // the corrupted KV state.
892
+ runtime.trimCache = null;
893
+ }
863
894
  });
864
895
  }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * health-handler.ts — Context Health turn_end hook (v0.12).
3
+ *
4
+ * Computes a composite 0-1 health score per turn from five sub-scores
5
+ * (drift, output quality, error rate, cache health, cache poison),
6
+ * persists it to the context_health SQLite table, and emits a dashboard
7
+ * event. All paths non-fatal — health monitoring never breaks the agent loop.
8
+ *
9
+ * PREVENT-PI-004: zero network (local TrigramEmbedder + SQLite only).
10
+ */
11
+
12
+ import type { MegaRuntime } from "../mega-runtime.js";
13
+ import type { MegaConfig } from "../mega-config.js";
14
+ import { defaultEmbedder } from "../../src/embedder.js";
15
+ import { computeOutputQuality } from "../../src/contextHealth/outputQuality.js";
16
+ import {
17
+ computeTopicDrift,
18
+ computeErrorEscalation,
19
+ computePrefixInstability,
20
+ computeDriftScore,
21
+ } from "../../src/contextHealth/drift.js";
22
+ import {
23
+ computePrefixHash,
24
+ checkPrefixHash,
25
+ evaluateCachePoison,
26
+ } from "../../src/contextHealth/cachePoison.js";
27
+ import {
28
+ computeHealthScore,
29
+ recordContextHealth,
30
+ type ContextHealthSubScores,
31
+ } from "../../src/contextHealth.js";
32
+ import {
33
+ recordCachePoisonEvent,
34
+ } from "../../src/store/sqlite/context-health.js";
35
+ import { getHealthMitigate } from "../../src/store/sqlite/meta.js";
36
+
37
+ const RING_MAX = 5;
38
+
39
+ /** Extract assistant text from the turn_end event message. */
40
+ function extractAssistantText(event: { message?: { role?: string; content?: unknown } }): string {
41
+ const msg = event.message;
42
+ if (!msg || msg.role !== "assistant") return "";
43
+ const c = msg.content;
44
+ if (typeof c === "string") return c;
45
+ if (Array.isArray(c)) {
46
+ return c
47
+ .map((p) => (p && typeof p === "object" && "text" in p ? String((p as { text?: string }).text ?? "") : ""))
48
+ .join(" ");
49
+ }
50
+ return "";
51
+ }
52
+
53
+ /** Extract messages as string array for prefix hash (cache poison Layer 1). */
54
+ function extractMessageTexts(event: { message?: { content?: unknown } }): string[] {
55
+ const c = event?.message?.content;
56
+ if (typeof c === "string") return [c];
57
+ if (Array.isArray(c)) {
58
+ return c.map((p) => (p && typeof p === "object" && "text" in p ? String((p as { text?: string }).text ?? "") : ""));
59
+ }
60
+ return [];
61
+ }
62
+
63
+ interface TurnEvent {
64
+ turnIndex: number;
65
+ message?: { role?: string; content?: unknown; stopReason?: string; usage?: { cacheRead?: number } };
66
+ }
67
+
68
+ export interface HealthMitigationSignal {
69
+ /** Force a compaction to flush degraded context (composite < 0.4). */
70
+ forceCompact: boolean;
71
+ /** Inject a prefix break to bypass corrupted KV cache (cachePoison < 0.3). */
72
+ breakPrefix: boolean;
73
+ /** The composite health score that triggered mitigation (if any). */
74
+ composite: number;
75
+ }
76
+
77
+ /** Handle turn_end: compute + persist context health. Non-fatal.
78
+ * Returns mitigation signals for the caller to act on (agent-handlers has ctx). */
79
+ export function handleTurnEndHealth(
80
+ event: TurnEvent,
81
+ runtime: MegaRuntime,
82
+ config: MegaConfig,
83
+ ): HealthMitigationSignal {
84
+ const noSignal: HealthMitigationSignal = { forceCompact: false, breakPrefix: false, composite: 1 };
85
+ if (!config.contextHealth) return noSignal;
86
+ try {
87
+ const text = extractAssistantText(event);
88
+ const embedder = defaultEmbedder();
89
+ const emb = text.length > 0 ? embedder.embed(text) : [];
90
+
91
+ // Output quality sub-score
92
+ let outputQuality = 1.0;
93
+ let repetitionRatio = 0;
94
+ let coherenceScore = 1.0;
95
+ if (config.contextHealthOutputQuality && text.length > 0) {
96
+ const oq = computeOutputQuality(text, embedder);
97
+ outputQuality = oq.score;
98
+ repetitionRatio = oq.repetitionRatio;
99
+ coherenceScore = oq.coherenceScore;
100
+ }
101
+
102
+ // Drift sub-score
103
+ let drift = 1.0;
104
+ if (config.contextHealthDrift) {
105
+ const topic = computeTopicDrift(emb, runtime.recentTurnEmbeddings);
106
+ const error = computeErrorEscalation(runtime.recentErrorCategories);
107
+ const prefix = computePrefixInstability(0, 5);
108
+ drift = computeDriftScore(topic, error, prefix);
109
+ }
110
+
111
+ // Error rate sub-score (1 = no errors)
112
+ const errorScore = computeErrorEscalation(runtime.recentErrorCategories);
113
+
114
+ // Cache health (from perf-handler)
115
+ const cacheHealth = runtime.rt._lastCacheHealthScore ?? 1.0;
116
+
117
+ // Cache poison sub-score
118
+ let cachePoison = 1.0;
119
+ if (config.contextHealthCachePoison) {
120
+ const msgTexts = extractMessageTexts(event);
121
+ const currentHash = computePrefixHash(msgTexts);
122
+ const cacheRead = event.message?.usage?.cacheRead ?? 0;
123
+ const l1 = checkPrefixHash(currentHash, runtime.lastPrefixHash, cacheRead);
124
+ const result = evaluateCachePoison({
125
+ currentHash,
126
+ storedHash: runtime.lastPrefixHash,
127
+ cacheRead,
128
+ qualityByCacheHit: [],
129
+ qualityByCacheMiss: [],
130
+ errorRateCacheHit: 0,
131
+ errorRateCacheMiss: 0,
132
+ sampleCount: 0,
133
+ });
134
+ cachePoison = result.score;
135
+ if (l1.poisoned) {
136
+ try {
137
+ recordCachePoisonEvent(runtime.currentStateDir, {
138
+ ts: Date.now(),
139
+ turnIndex: event.turnIndex,
140
+ sessionId: runtime.rt.sessionId,
141
+ layer: 1,
142
+ detail: l1.detail,
143
+ severity: "warn",
144
+ });
145
+ } catch { /* non-fatal */ }
146
+ }
147
+ runtime.lastPrefixHash = currentHash;
148
+ }
149
+
150
+ // Composite
151
+ const sub: ContextHealthSubScores = {
152
+ drift,
153
+ outputQuality,
154
+ errorRate: errorScore,
155
+ cacheHealth,
156
+ cachePoison,
157
+ };
158
+ const composite = computeHealthScore(sub);
159
+
160
+ // Persist
161
+ recordContextHealth(runtime.currentStateDir, {
162
+ ts: Date.now(),
163
+ turnIndex: event.turnIndex,
164
+ sessionId: runtime.rt.sessionId,
165
+ driftScore: drift,
166
+ outputQuality,
167
+ errorScore,
168
+ cacheHealth,
169
+ cachePoison,
170
+ composite,
171
+ modelId: runtime.currentModel?.modelId,
172
+ repetitionRatio,
173
+ coherenceScore,
174
+ prefixHash: runtime.lastPrefixHash ?? undefined,
175
+ });
176
+
177
+ // Emit dashboard event
178
+ runtime.dashboard.event("context_health", {
179
+ composite,
180
+ drift,
181
+ outputQuality,
182
+ errorRate: errorScore,
183
+ cacheHealth,
184
+ cachePoison,
185
+ turnIndex: event.turnIndex,
186
+ });
187
+
188
+ // Mitigation: check the runtime-toggleable flag (meta table), not just
189
+ // the env var. The dashboard Maintenance tab toggles this at runtime.
190
+ const mitigate = config.contextHealthMitigate ||
191
+ getHealthMitigate(runtime.currentStateDir);
192
+
193
+ // Update ring buffers
194
+ runtime.recentTurnEmbeddings.push(emb);
195
+ if (runtime.recentTurnEmbeddings.length > RING_MAX) {
196
+ runtime.recentTurnEmbeddings.shift();
197
+ }
198
+ runtime.recentErrorCategories.push(runtime.lastErrorCategory);
199
+ if (runtime.recentErrorCategories.length > RING_MAX) {
200
+ runtime.recentErrorCategories.shift();
201
+ }
202
+
203
+ // Return mitigation signals so agent-handlers.ts (which has ctx) can act.
204
+ if (mitigate) {
205
+ return {
206
+ forceCompact: composite < 0.4,
207
+ breakPrefix: cachePoison < 0.3,
208
+ composite,
209
+ };
210
+ }
211
+ return { forceCompact: false, breakPrefix: false, composite };
212
+ } catch (e) {
213
+ runtime.logger?.error("context_health_failed", {
214
+ error: String(e instanceof Error ? e.message : e),
215
+ });
216
+ return noSignal;
217
+ }
218
+ }
@@ -263,6 +263,13 @@ export class MegaRuntime {
263
263
  diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
264
264
  diagCtxThrown = 0; // live-trim try threw (caught)
265
265
 
266
+ // Context health instrumentation (v0.12): rolling ring buffers for
267
+ // drift detection + cache poison Layer 1 hash baseline.
268
+ recentTurnEmbeddings: number[][] = [];
269
+ recentErrorCategories: (string | null)[] = [];
270
+ lastPrefixHash: string | null = null;
271
+ lastErrorCategory: string | null = null;
272
+
266
273
  /**
267
274
  * S26 capture instrumentation: the "model_snapshots empty → $0.00 cost card"
268
275
  * bug was invisible because captureModel swallowed the DB write in a silent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.11.13",
3
+ "version": "0.12.1",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
@@ -0,0 +1,243 @@
1
+ /**
2
+ * contextHealth/cachePoison.ts — tri-layer KV cache poison validator (v0.9.1).
3
+ *
4
+ * Three validation layers for detecting corrupted KV cache state. Each layer
5
+ * triggers independently; the composite score maps the number of triggered
6
+ * layers to a 0-1 health metric (1 = healthy, 0 = poisoned).
7
+ *
8
+ * Pure functions — zero I/O, zero SQLite, zero network. All inputs are
9
+ * primitive values or arrays passed in; this module makes no external calls
10
+ * and carries no state between invocations.
11
+ *
12
+ * Guardrails: no `any` (PREVENT-011), no un-null-checked JSON.parse
13
+ * (PREVENT-001), zero network (PREVENT-PI-004 trivially satisfied — this
14
+ * module never calls fetch or opens sockets).
15
+ */
16
+
17
+ /** FNV-1a 32-bit offset basis (must match TrigramEmbedder._embedRaw). */
18
+ const FNV_OFFSET_BASIS = 0x811c9dc5;
19
+ /** FNV-1a 32-bit prime (must match TrigramEmbedder._embedRaw). */
20
+ const FNV_PRIME = 0x01000193;
21
+ /** Max input characters for prefix hash (4 KB of UTF-16). */
22
+ const PREFIX_HASH_CAP = 4096;
23
+
24
+ // ─── Layer 1 — Hash validation ──────────────────────────────────────────────
25
+
26
+ /**
27
+ * Compute a stable 32-bit FNV-1a hash over the first 4 KB of the concatenated
28
+ * message text. Returns a lower-case hex string.
29
+ *
30
+ * The algorithm is identical to `TrigramEmbedder._embedRaw`'s internal hash
31
+ * (offset basis 0x811c9dc5, prime 0x01000193), so any change visible to the
32
+ * embedder is also visible here.
33
+ */
34
+ export function computePrefixHash(messages: string[]): string {
35
+ let combined = "";
36
+ for (let i = 0; i < messages.length; i++) {
37
+ combined += messages[i];
38
+ if (combined.length >= PREFIX_HASH_CAP) break;
39
+ }
40
+ const input = combined.length > PREFIX_HASH_CAP
41
+ ? combined.slice(0, PREFIX_HASH_CAP)
42
+ : combined;
43
+
44
+ let h = FNV_OFFSET_BASIS;
45
+ for (let i = 0; i < input.length; i++) {
46
+ h ^= input.charCodeAt(i);
47
+ h = Math.imul(h, FNV_PRIME);
48
+ }
49
+ return (h >>> 0).toString(16).padStart(8, "0");
50
+ }
51
+
52
+ /**
53
+ * Layer 1: detect when the KV cache prefix has been silently mutated.
54
+ *
55
+ * Trigger conditions:
56
+ * - cacheRead > 0 (cache was actually used)
57
+ * - storedHash !== null (we have a baseline from a prior turn)
58
+ * - currentHash !== storedHash (prefix text has drifted)
59
+ *
60
+ * Not triggered when: cache was never read, or no baseline exists (first turn).
61
+ */
62
+ export function checkPrefixHash(
63
+ currentHash: string,
64
+ storedHash: string | null,
65
+ cacheRead: number,
66
+ ): { poisoned: boolean; detail: string } {
67
+ if (cacheRead === 0) {
68
+ return { poisoned: false, detail: "no cache read" };
69
+ }
70
+ if (storedHash === null) {
71
+ return { poisoned: false, detail: "no stored hash baseline" };
72
+ }
73
+ if (currentHash !== storedHash) {
74
+ return {
75
+ poisoned: true,
76
+ detail:
77
+ `prefix hash mismatch: cached prefix changed without cache invalidation (current=${currentHash} stored=${storedHash})`,
78
+ };
79
+ }
80
+ return { poisoned: false, detail: "prefix hash matches baseline" };
81
+ }
82
+
83
+ // ─── Layer 2 — Semantic validation ──────────────────────────────────────────
84
+
85
+ /** Arithmetic mean of a non-empty number array. */
86
+ function mean(values: number[]): number {
87
+ let sum = 0;
88
+ for (const v of values) sum += v;
89
+ return sum / values.length;
90
+ }
91
+
92
+ /**
93
+ * Layer 2: detect when cache-hit outputs are semantically worse than cache-miss
94
+ * outputs.
95
+ *
96
+ * Trigger conditions:
97
+ * - both groups have at least 3 samples (statistical floor)
98
+ * - cache-hit mean quality is more than 0.15 below cache-miss mean quality
99
+ *
100
+ * Not triggered when: insufficient data in either group.
101
+ */
102
+ export function compareOutputQualityByCacheHit(
103
+ qualityByCacheHit: number[],
104
+ qualityByCacheMiss: number[],
105
+ ): { poisoned: boolean; detail: string } {
106
+ const hitN = qualityByCacheHit.length;
107
+ const missN = qualityByCacheMiss.length;
108
+
109
+ if (hitN < 3 || missN < 3) {
110
+ return {
111
+ poisoned: false,
112
+ detail: `insufficient quality samples: hit=${hitN} miss=${missN} (need >= 3 each)`,
113
+ };
114
+ }
115
+
116
+ const hitMean = mean(qualityByCacheHit);
117
+ const missMean = mean(qualityByCacheMiss);
118
+ const threshold = missMean - 0.15;
119
+
120
+ if (hitMean < threshold) {
121
+ return {
122
+ poisoned: true,
123
+ detail:
124
+ `cache-hit quality degraded: hitMean=${hitMean.toFixed(3)} missMean=${missMean.toFixed(3)} (threshold=${threshold.toFixed(3)})`,
125
+ };
126
+ }
127
+ return {
128
+ poisoned: false,
129
+ detail: `quality comparable: hitMean=${hitMean.toFixed(3)} missMean=${missMean.toFixed(3)}`,
130
+ };
131
+ }
132
+
133
+ // ─── Layer 3 — Behavioral validation ────────────────────────────────────────
134
+
135
+ /**
136
+ * Layer 3: detect when errors cluster on cache-hit turns (indicating the cache
137
+ * is returning corrupt state rather than fresh computation catching them).
138
+ *
139
+ * Trigger conditions (all three must hold):
140
+ * - sampleCount >= 5
141
+ * - errorRateCacheHit > errorRateCacheMiss * 2 (hit error rate is >2x miss rate)
142
+ * - errorRateCacheHit > 0.1 (absolute hit error rate is non-trivial)
143
+ *
144
+ * Not triggered when: insufficient observations.
145
+ */
146
+ export function correlateErrorsWithCacheHits(
147
+ errorRateCacheHit: number,
148
+ errorRateCacheMiss: number,
149
+ sampleCount: number,
150
+ ): { poisoned: boolean; detail: string } {
151
+ if (sampleCount < 5) {
152
+ return {
153
+ poisoned: false,
154
+ detail: `insufficient behavioral samples: n=${sampleCount} (need >= 5)`,
155
+ };
156
+ }
157
+ if (
158
+ errorRateCacheHit > errorRateCacheMiss * 2 &&
159
+ errorRateCacheHit > 0.1
160
+ ) {
161
+ return {
162
+ poisoned: true,
163
+ detail:
164
+ `error rate inflated on cache hits: hit=${errorRateCacheHit.toFixed(3)} miss=${errorRateCacheMiss.toFixed(3)} (2x breach + absolute floor)`,
165
+ };
166
+ }
167
+ return {
168
+ poisoned: false,
169
+ detail:
170
+ `error rates normal: hit=${errorRateCacheHit.toFixed(3)} miss=${errorRateCacheMiss.toFixed(3)}`,
171
+ };
172
+ }
173
+
174
+ // ─── Composite score ─────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Compute a 0-1 composite cache poison score from layer triggers.
178
+ *
179
+ * | triggered layers | score |
180
+ * |-----------------|-------|
181
+ * | 0 | 1.0 |
182
+ * | 1 | 0.3 |
183
+ * | 2 | 0.15 |
184
+ * | 3 | 0.0 |
185
+ *
186
+ * Higher = healthier. Scores below ~0.3 warrant immediate cache invalidation.
187
+ */
188
+ export function computeCachePoisonScore(
189
+ l1: boolean,
190
+ l2: boolean,
191
+ l3: boolean,
192
+ ): number {
193
+ const triggers = (l1 ? 1 : 0) + (l2 ? 1 : 0) + (l3 ? 1 : 0);
194
+ switch (triggers) {
195
+ case 0: return 1.0;
196
+ case 1: return 0.3;
197
+ case 2: return 0.15;
198
+ case 3: return 0.0;
199
+ default: return 0.0; // should never reach
200
+ }
201
+ }
202
+
203
+ /** Result shape returned by `evaluateCachePoison`. */
204
+ export interface CachePoisonResult {
205
+ score: number;
206
+ layer1: { poisoned: boolean; detail: string };
207
+ layer2: { poisoned: boolean; detail: string };
208
+ layer3: { poisoned: boolean; detail: string };
209
+ }
210
+
211
+ /**
212
+ * Run all three validation layers and return a composite result.
213
+ *
214
+ * All arguments are primitive values; this function is pure and stateless.
215
+ */
216
+ export function evaluateCachePoison(args: {
217
+ currentHash: string;
218
+ storedHash: string | null;
219
+ cacheRead: number;
220
+ qualityByCacheHit: number[];
221
+ qualityByCacheMiss: number[];
222
+ errorRateCacheHit: number;
223
+ errorRateCacheMiss: number;
224
+ sampleCount: number;
225
+ }): CachePoisonResult {
226
+ const l1 = checkPrefixHash(args.currentHash, args.storedHash, args.cacheRead);
227
+ const l2 = compareOutputQualityByCacheHit(
228
+ args.qualityByCacheHit,
229
+ args.qualityByCacheMiss,
230
+ );
231
+ const l3 = correlateErrorsWithCacheHits(
232
+ args.errorRateCacheHit,
233
+ args.errorRateCacheMiss,
234
+ args.sampleCount,
235
+ );
236
+
237
+ return {
238
+ score: computeCachePoisonScore(l1.poisoned, l2.poisoned, l3.poisoned),
239
+ layer1: l1,
240
+ layer2: l2,
241
+ layer3: l3,
242
+ };
243
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * drift.ts — rolling-window context drift detectors.
3
+ *
4
+ * Pure functions; no I/O. All vectors are number[] (the Embedder output type).
5
+ * Cosine similarity is used throughout (PREVENT-PI-004 safe — no network).
6
+ */
7
+ import type { Vector } from "../embedder.js";
8
+ import { cosineSimilarity } from "../embedder.js";
9
+
10
+ // ─── Topic drift ────────────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Compute topic drift as cosine similarity between the current embedding and
14
+ * the centroid of recent embeddings.
15
+ *
16
+ * Returns 0–1: 1 = no drift (current topic matches the recent window exactly),
17
+ * 0 = completely unrelated. Empty recentEmbs returns 1.0 (baseline, no prior).
18
+ *
19
+ * Centroid: element-wise arithmetic mean of recentEmbs.
20
+ * cosineSimilarity normalizes internally; centroid need not be unit-normalized.
21
+ */
22
+ export function computeTopicDrift(currentEmb: Vector, recentEmbs: Vector[]): number {
23
+ if (recentEmbs.length === 0) return 1.0;
24
+
25
+ const dim = currentEmb.length;
26
+ // Element-wise mean of recent embeddings.
27
+ const centroid = new Array<number>(dim).fill(0);
28
+ for (const emb of recentEmbs) {
29
+ if (emb.length !== dim) continue; // defensive: skip mismatched-dim rows
30
+ for (let i = 0; i < dim; i++) {
31
+ centroid[i] += emb[i];
32
+ }
33
+ }
34
+ for (let i = 0; i < dim; i++) {
35
+ centroid[i] /= recentEmbs.length;
36
+ }
37
+
38
+ return cosineSimilarity(currentEmb, centroid);
39
+ }
40
+
41
+ // ─── Error escalation ───────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Compute error-rate score from recent error categories.
45
+ *
46
+ * `recentErrorCategories` is a list of error-class strings, or null for
47
+ * non-error turns. Returns 1.0 when the list is empty (no data → assume healthy).
48
+ * The score is 1 - (non-null / total), so an entirely-error list scores 0.
49
+ */
50
+ export function computeErrorEscalation(recentErrorCategories: (string | null)[]): number {
51
+ if (recentErrorCategories.length === 0) return 1.0;
52
+ let errors = 0;
53
+ for (const cat of recentErrorCategories) {
54
+ if (cat !== null) errors++;
55
+ }
56
+ return 1 - errors / recentErrorCategories.length;
57
+ }
58
+
59
+ // ─── Prefix instability ─────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Compute prefix-stability score using the cache-health formula from
63
+ * perf-handler.ts (tryComputeCacheHealth → stabilityScore).
64
+ *
65
+ * The stability formula from perf-handler.ts:
66
+ * instability = min(1, prefixBreakCount / (windowMinutes / 5 * 2))
67
+ * stabilityScore = max(0, 1 - breakSamples.length / maxExpectedBreaks)
68
+ *
69
+ * Here we expose a direct formula:
70
+ * instability = min(1, prefixBreakCount / (windowMinutes / 5 * 2))
71
+ * score = 1 - instability
72
+ * = max(0, 1 - min(1, prefixBreakCount / (windowMinutes / 5 * 2)))
73
+ *
74
+ * Returns 0–1: 1 = perfectly stable (no prefix breaks), 0 = maximally unstable.
75
+ * The window is expressed in minutes so the caller controls the lookback.
76
+ */
77
+ export function computePrefixInstability(
78
+ prefixBreakCount: number,
79
+ windowMinutes: number,
80
+ ): number {
81
+ if (prefixBreakCount <= 0) return 1.0;
82
+ if (windowMinutes <= 0) return 1.0; // defensive: avoid div-by-zero
83
+ const maxExpectedBreaks = (windowMinutes / 5) * 2;
84
+ const instability = Math.min(1, prefixBreakCount / maxExpectedBreaks);
85
+ return Math.max(0, 1 - instability);
86
+ }
87
+
88
+ // ─── Composite drift score ───────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Weighted composite drift score from three sub-components.
92
+ *
93
+ * Weights are domain-informed:
94
+ * topic drift (40%) — the strongest signal of context-switching
95
+ * error rate (35%) — errors correlate strongly with confusion
96
+ * prefix stability (25%) — cache instability is secondary to content quality
97
+ *
98
+ * Returns 0–1: 1 = no drift / perfectly healthy, 0 = severe drift.
99
+ */
100
+ export function computeDriftScore(
101
+ topic: number,
102
+ error: number,
103
+ prefix: number,
104
+ ): number {
105
+ const raw = topic * 0.4 + error * 0.35 + prefix * 0.25;
106
+ return Math.max(0, Math.min(1, raw));
107
+ }