pi-mega-compact 0.20.23 → 0.20.24
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/extensions/dashboard-server/routes-rag-settings-helpers.js +2 -0
- package/dist/extensions/mega-events/agent-handlers/turnEndHandler/cacheStripe.js +15 -4
- package/dist/extensions/mega-events/context-handler/afterCompact.js +23 -4
- package/dist/extensions/mega-events/context-handler/dbMirrorAppend.js +35 -7
- package/dist/extensions/mega-events/context-handler/tailResult.js +45 -12
- package/dist/src/cache-stripe-impl.js +52 -214
- package/dist/src/cache-stripe-score.js +115 -0
- package/extensions/dashboard-server/routes-rag-settings-helpers.ts +12 -0
- package/extensions/mega-events/agent-handlers/turnEndHandler/cacheStripe.ts +13 -4
- package/extensions/mega-events/context-handler/afterCompact.ts +23 -4
- package/extensions/mega-events/context-handler/dbMirrorAppend.ts +39 -7
- package/extensions/mega-events/context-handler/tailResult.ts +56 -15
- package/package.json +1 -1
- package/src/cache-stripe-impl.ts +73 -277
- package/src/cache-stripe-score.ts +170 -0
package/src/cache-stripe-impl.ts
CHANGED
|
@@ -1,270 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cache-stripe-impl.ts — Vector-Aware Cache Striping
|
|
2
|
+
* cache-stripe-impl.ts — Vector-Aware Cache Striping (PLAN_V2 Phase 3).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Owns refreshStripeAssignments: the DB-touching write path that scores each
|
|
5
|
+
* context chunk and UPSERTs its stripe row into cache_stripes. All pure math
|
|
6
|
+
* and scoring types live in cache-stripe-score.ts (extracted via the
|
|
7
|
+
* delegate-shell pattern to keep this file under the 300-line src/ soft
|
|
8
|
+
* limit). cache-stripe.ts re-exports the public surface.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - semanticSimilarity: cosine similarity of the chunk's embedding against the
|
|
13
|
-
* running session embedding (from TrigramEmbedder). High similarity means the
|
|
14
|
-
* chunk is topically relevant to current work.
|
|
15
|
-
* - recency: how recently the chunk appeared (normalized to 0.0-1.0 across all
|
|
16
|
-
* chunks in the epoch). Recent chunks are more likely to benefit from caching.
|
|
17
|
-
* - frequency: how often the chunk's content has been referenced (0.0-1.0,
|
|
18
|
-
* estimated from a simple access counter stored alongside).
|
|
19
|
-
*
|
|
20
|
-
* Reassignment happens at epoch boundaries via refreshStripeAssignments.
|
|
21
|
-
* All SQL is parameterized (PREVENT-002). No pi runtime types are imported,
|
|
22
|
-
* keeping this module pi-agnostic.
|
|
10
|
+
* Runs entirely offline — no network, no LLM (PREVENT-PI-004). All SQL is
|
|
11
|
+
* parameterized (PREVENT-002). No pi runtime types are imported, keeping this
|
|
12
|
+
* module pi-agnostic.
|
|
23
13
|
*/
|
|
24
14
|
|
|
25
15
|
import { randomBytes } from "node:crypto";
|
|
26
16
|
import { openStore, withTx } from "./store/sqlite/utils.js";
|
|
27
17
|
import type { DatabaseSync } from "node:sqlite";
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
/** Epoch identifier this assignment belongs to. */
|
|
49
|
-
epochId: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Input shape for computeStabilityScore. Matches the context_chunks row shape
|
|
53
|
-
* the SQL caller extracts. */
|
|
54
|
-
export interface ChunkInput {
|
|
55
|
-
/** Primary key / rowid from context_chunks. */
|
|
56
|
-
chunkId: string;
|
|
57
|
-
/** The text content of the chunk. */
|
|
58
|
-
content: string;
|
|
59
|
-
/** How many times this chunk has been recalled/referenced (access count). */
|
|
60
|
-
accessCount: number;
|
|
61
|
-
/** Unix-epoch seconds of the most recent access. */
|
|
62
|
-
lastAccessedAt: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
/** Semantic similarity weight in the composite score. */
|
|
68
|
-
const WEIGHT_SEMANTIC = 0.5;
|
|
69
|
-
/** Recency weight in the composite score. */
|
|
70
|
-
const WEIGHT_RECENCY = 0.3;
|
|
71
|
-
/** Frequency weight in the composite score. */
|
|
72
|
-
const WEIGHT_FREQUENCY = 0.2;
|
|
73
|
-
|
|
74
|
-
/** Stripes a chunk lands in based on its stability score. Thresholds define
|
|
75
|
-
* the boundary between adjacent layers. */
|
|
76
|
-
const STRIPE_THRESHOLDS = [
|
|
77
|
-
{ minStability: 0.90, stripe: 0 },
|
|
78
|
-
{ minStability: 0.70, stripe: 1 },
|
|
79
|
-
{ minStability: 0.50, stripe: 2 },
|
|
80
|
-
{ minStability: 0.30, stripe: 3 },
|
|
81
|
-
{ minStability: -Infinity, stripe: 4 },
|
|
82
|
-
] as const;
|
|
83
|
-
|
|
84
|
-
// ─── Embedding helpers (no external dep) ─────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* FNV-1a 32-bit hash for the content-based embedding fallback. The production
|
|
88
|
-
* path uses TrigramEmbedder from embedder.ts but we keep a self-contained hash
|
|
89
|
-
* for the case where no embedder is passed in.
|
|
90
|
-
*/
|
|
91
|
-
function fnv1a(text: string): number {
|
|
92
|
-
let hash = 0x811c9dc5;
|
|
93
|
-
for (let i = 0; i < text.length; i++) {
|
|
94
|
-
hash ^= text.charCodeAt(i);
|
|
95
|
-
hash = Math.imul(hash, 0x01000193);
|
|
96
|
-
}
|
|
97
|
-
return (hash >>> 0) / 0x100000000;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Produce a crude 128-dim pseudorandom embedding from text using hashed n-gram
|
|
102
|
-
* bins. Matches the approach in TrigramEmbedder._embedRaw conceptually. Used
|
|
103
|
-
* only as a fallback / test path; the caller should prefer TrigramEmbedder.
|
|
104
|
-
*/
|
|
105
|
-
function fallbackEmbed(text: string): number[] {
|
|
106
|
-
const dim = 128;
|
|
107
|
-
const vec = new Array<number>(dim).fill(0);
|
|
108
|
-
const norm = text.toLowerCase().replace(/\s+/g, " ");
|
|
109
|
-
if (norm.length === 0) return vec;
|
|
110
|
-
|
|
111
|
-
vec[Math.floor(fnv1a(norm) * dim)] += 1;
|
|
112
|
-
for (const word of norm.split(" ")) {
|
|
113
|
-
if (word.length === 0) continue;
|
|
114
|
-
vec[Math.floor(fnv1a(word) * dim)] += 0.5;
|
|
115
|
-
for (let i = 0; i < Math.max(1, word.length - 1); i++) {
|
|
116
|
-
const trigram = word.slice(i, i + 3);
|
|
117
|
-
if (trigram.length === 3) {
|
|
118
|
-
vec[Math.floor(fnv1a(trigram) * dim)] += 0.25;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return l2Normalize(vec);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function l2Normalize(v: number[]): number[] {
|
|
126
|
-
let sumSq = 0;
|
|
127
|
-
for (let i = 0; i < v.length; i++) sumSq += v[i] * v[i];
|
|
128
|
-
if (sumSq === 0) return v;
|
|
129
|
-
const norm = Math.sqrt(sumSq);
|
|
130
|
-
for (let i = 0; i < v.length; i++) v[i] /= norm;
|
|
131
|
-
return v;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Compute cosine similarity between two vectors of equal length. */
|
|
135
|
-
function cosineSimilarity(a: number[], b: number[]): number {
|
|
136
|
-
if (a.length !== b.length || a.length === 0) return 0;
|
|
137
|
-
let dot = 0;
|
|
138
|
-
let na = 0;
|
|
139
|
-
let nb = 0;
|
|
140
|
-
for (let i = 0; i < a.length; i++) {
|
|
141
|
-
dot += a[i] * b[i];
|
|
142
|
-
na += a[i] * a[i];
|
|
143
|
-
nb += b[i] * b[i];
|
|
144
|
-
}
|
|
145
|
-
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
|
146
|
-
return denom === 0 ? 0 : dot / denom;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Embedder interface — matches the shape of TrigramEmbedder.embed. */
|
|
150
|
-
export interface EmbedderLike {
|
|
151
|
-
embed(text: string): number[];
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ─── Stability Scoring ───────────────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Compute a composite stability score for a single chunk.
|
|
158
|
-
*
|
|
159
|
-
* @param chunk The chunk metadata + content to score.
|
|
160
|
-
* @param allChunks All chunks in this epoch (used to compute relative recency).
|
|
161
|
-
* @param embedder Optional embedder instance. If omitted, uses the
|
|
162
|
-
* self-contained fallback (128-dim hashed n-gram).
|
|
163
|
-
* @param sessionEmbed Pre-computed embedding for the current session (the
|
|
164
|
-
* "query" vector). If omitted, computed on the fly from
|
|
165
|
-
* the chunk content alone, which degrades semantic scoring
|
|
166
|
-
* to a self-similarity baseline.
|
|
167
|
-
* @returns A number in [0.0, 1.0] where 1.0 = most stable.
|
|
168
|
-
*/
|
|
169
|
-
export function computeStabilityScore(
|
|
170
|
-
chunk: ChunkInput,
|
|
171
|
-
allChunks: ChunkInput[],
|
|
172
|
-
embedder?: EmbedderLike,
|
|
173
|
-
sessionEmbed?: number[],
|
|
174
|
-
): number {
|
|
175
|
-
// ── Semantic similarity (0.5 weight) ────────────────────────────────────
|
|
176
|
-
const emb = embedder
|
|
177
|
-
? embedder.embed(chunk.content)
|
|
178
|
-
: fallbackEmbed(chunk.content);
|
|
179
|
-
|
|
180
|
-
// If no session embedding is provided, use the chunk's own embedding as
|
|
181
|
-
// a self-similarity — this produces a baseline score based on content
|
|
182
|
-
// density (chunks with more meaningful content get higher internal
|
|
183
|
-
// similarity). Real deployments should pass the session embedding.
|
|
184
|
-
const sem = cosineSimilarity(
|
|
185
|
-
emb,
|
|
186
|
-
sessionEmbed ?? emb,
|
|
187
|
-
);
|
|
188
|
-
const semanticScore = isNaN(sem) ? 0 : sem;
|
|
189
|
-
|
|
190
|
-
// ── Recency (0.3 weight) ────────────────────────────────────────────────
|
|
191
|
-
// Relative recency: lastAccessedAt of this chunk vs. min/max across epoch.
|
|
192
|
-
// Falls back to 0.5 if there's only one chunk or no timestamp data.
|
|
193
|
-
let recencyScore = 0.5;
|
|
194
|
-
const accessed = allChunks
|
|
195
|
-
.map((c) => c.lastAccessedAt)
|
|
196
|
-
.filter((t) => t > 0);
|
|
197
|
-
if (accessed.length > 1) {
|
|
198
|
-
const minT = Math.min(...accessed);
|
|
199
|
-
const maxT = Math.max(...accessed);
|
|
200
|
-
const range = maxT - minT;
|
|
201
|
-
if (range > 0) {
|
|
202
|
-
recencyScore = (chunk.lastAccessedAt - minT) / range;
|
|
203
|
-
} else {
|
|
204
|
-
recencyScore = 1.0;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// ── Frequency (0.2 weight) ──────────────────────────────────────────────
|
|
209
|
-
// Access count relative to the max across the epoch.
|
|
210
|
-
const counts = allChunks.map((c) => c.accessCount);
|
|
211
|
-
const maxCount = Math.max(...counts, 1);
|
|
212
|
-
const freqScore = maxCount > 0 ? chunk.accessCount / maxCount : 0;
|
|
213
|
-
|
|
214
|
-
// ── Composite ───────────────────────────────────────────────────────────
|
|
215
|
-
const stability =
|
|
216
|
-
WEIGHT_SEMANTIC * semanticScore +
|
|
217
|
-
WEIGHT_RECENCY * recencyScore +
|
|
218
|
-
WEIGHT_FREQUENCY * freqScore;
|
|
219
|
-
|
|
220
|
-
// Clamp to [0.0, 1.0] as a safety net.
|
|
221
|
-
return Math.max(0, Math.min(1, stability));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Determine the cache stripe (layer) for a given stability score.
|
|
226
|
-
*
|
|
227
|
-
* @param stability Composite stability score in [0.0, 1.0].
|
|
228
|
-
* @returns Stripe number 0-4.
|
|
229
|
-
*/
|
|
230
|
-
export function stabilityToStripe(stability: number): number {
|
|
231
|
-
for (const t of STRIPE_THRESHOLDS) {
|
|
232
|
-
if (stability >= t.minStability) return t.stripe;
|
|
233
|
-
}
|
|
234
|
-
return 4;
|
|
235
|
-
}
|
|
18
|
+
import {
|
|
19
|
+
computeStabilityScore,
|
|
20
|
+
stabilityToStripe,
|
|
21
|
+
fallbackEmbed,
|
|
22
|
+
l2Normalize,
|
|
23
|
+
type ChunkInput,
|
|
24
|
+
type EmbedderLike,
|
|
25
|
+
} from "./cache-stripe-score.js";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
computeStabilityScore,
|
|
29
|
+
stabilityToStripe,
|
|
30
|
+
fallbackEmbed,
|
|
31
|
+
l2Normalize,
|
|
32
|
+
} from "./cache-stripe-score.js";
|
|
33
|
+
export type {
|
|
34
|
+
CacheStripe,
|
|
35
|
+
ChunkInput,
|
|
36
|
+
EmbedderLike,
|
|
37
|
+
} from "./cache-stripe-score.js";
|
|
236
38
|
|
|
237
39
|
// ─── Stripe Reassignment ─────────────────────────────────────────────────────
|
|
238
40
|
|
|
239
41
|
/**
|
|
240
|
-
* Refresh stripe assignments for all chunks in
|
|
42
|
+
* Refresh stripe assignments for all chunks in an epoch.
|
|
241
43
|
*
|
|
242
44
|
* Steps:
|
|
243
|
-
* 1.
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* (
|
|
45
|
+
* 1. Resolve target epochId: explicit string → use it; '' → no epoch filter
|
|
46
|
+
* (assign all chunks); undefined → look up the most recently committed
|
|
47
|
+
* checkpoint_epochs row so the stripes we write are visible to the
|
|
48
|
+
* buildCacheOptimizedPrompt reader (`ORDER BY created_at DESC LIMIT 1`).
|
|
49
|
+
* If no epoch exists yet (pre-first-compaction), fall back to a random id
|
|
50
|
+
* — the write succeeds and the rows are simply never read until an epoch
|
|
51
|
+
* lands.
|
|
52
|
+
* 2. Read context_chunks (chunk_id = c.id, NOT rowid — the read path joins
|
|
53
|
+
* on the TEXT id).
|
|
54
|
+
* 3. Score each chunk via computeStabilityScore (mean-pool embeddings for a
|
|
55
|
+
* session-level semantic "query" vector).
|
|
56
|
+
* 4. Atomic UPSERT into cache_stripes.
|
|
253
57
|
*
|
|
254
|
-
* Non-fatal: failures are logged via
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* @param store An open SQLite DatabaseSync handle (or a stateDir string
|
|
258
|
-
* to open lazily). Accepts either to match the caller's
|
|
259
|
-
* convenience. When a string is passed, opens the store for
|
|
260
|
-
* this call only (does not cache the connection).
|
|
261
|
-
* @param epochId The epoch to reassign. If omitted, generates a new epoch
|
|
262
|
-
* ID (random hex). Pass '' to reassign all chunks without
|
|
263
|
-
* filtering by epoch.
|
|
264
|
-
* @param embedder Optional TrigramEmbedder instance. When provided, uses it
|
|
265
|
-
* for semantic similarity; otherwise uses the fallback.
|
|
266
|
-
* @param logFn Optional logging callback (defaults to no-op).
|
|
267
|
-
* @returns The number of chunks that were reassigned.
|
|
58
|
+
* Non-fatal: failures are logged via the provided logger and never thrown.
|
|
59
|
+
* Returns the count of chunks reassigned (0 on error).
|
|
268
60
|
*/
|
|
269
61
|
export function refreshStripeAssignments(
|
|
270
62
|
store: DatabaseSync | string,
|
|
@@ -275,29 +67,40 @@ export function refreshStripeAssignments(
|
|
|
275
67
|
const db: DatabaseSync =
|
|
276
68
|
typeof store === "string" ? openStore(store) : store;
|
|
277
69
|
const log = logFn ?? (() => {});
|
|
278
|
-
|
|
70
|
+
|
|
71
|
+
let actualEpochId: string;
|
|
72
|
+
if (epochId !== undefined) {
|
|
73
|
+
actualEpochId = epochId;
|
|
74
|
+
} else {
|
|
75
|
+
try {
|
|
76
|
+
const latest = db
|
|
77
|
+
.prepare(
|
|
78
|
+
`SELECT epoch_id FROM checkpoint_epochs ORDER BY created_at DESC LIMIT 1`,
|
|
79
|
+
)
|
|
80
|
+
.get() as { epoch_id: string } | undefined;
|
|
81
|
+
actualEpochId = latest?.epoch_id ?? nextEpochId();
|
|
82
|
+
} catch {
|
|
83
|
+
actualEpochId = nextEpochId();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
279
86
|
const now = Math.floor(Date.now() / 1000);
|
|
280
87
|
|
|
281
88
|
try {
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
89
|
+
// cache_stripes has no access_count / last_accessed_at columns (schema:
|
|
90
|
+
// chunk_id/stripe/stability/assigned_at/epoch_id) — querying them throws.
|
|
91
|
+
// Score freshness/frequency to 0 here; stability derives from content +
|
|
92
|
+
// semantic similarity only until an access-tracking column is added.
|
|
286
93
|
const rows = db
|
|
287
94
|
.prepare(
|
|
288
|
-
`SELECT c.
|
|
289
|
-
COALESCE(c.summary, c.normalized_text, c.key_decisions, '') AS content
|
|
290
|
-
COALESCE(s.access_count, 0) AS access_count,
|
|
291
|
-
COALESCE(s.last_accessed_at, 0) AS last_accessed_at
|
|
95
|
+
`SELECT c.id AS chunk_id,
|
|
96
|
+
COALESCE(c.summary, c.normalized_text, c.key_decisions, '') AS content
|
|
292
97
|
FROM context_chunks c
|
|
293
|
-
LEFT JOIN cache_stripes s ON s.chunk_id =
|
|
98
|
+
LEFT JOIN cache_stripes s ON s.chunk_id = c.id
|
|
294
99
|
WHERE (? = '' OR s.epoch_id = ? OR s.epoch_id IS NULL)`,
|
|
295
100
|
)
|
|
296
101
|
.all(actualEpochId, actualEpochId) as Array<{
|
|
297
|
-
chunk_id:
|
|
102
|
+
chunk_id: string;
|
|
298
103
|
content: string;
|
|
299
|
-
access_count: number;
|
|
300
|
-
last_accessed_at: number;
|
|
301
104
|
}>;
|
|
302
105
|
|
|
303
106
|
if (rows.length === 0) {
|
|
@@ -305,16 +108,13 @@ export function refreshStripeAssignments(
|
|
|
305
108
|
return 0;
|
|
306
109
|
}
|
|
307
110
|
|
|
308
|
-
// Build the allChunks array for relative scoring.
|
|
309
111
|
const allChunks: ChunkInput[] = rows.map((r) => ({
|
|
310
|
-
chunkId:
|
|
112
|
+
chunkId: r.chunk_id,
|
|
311
113
|
content: r.content,
|
|
312
|
-
accessCount:
|
|
313
|
-
lastAccessedAt:
|
|
114
|
+
accessCount: 0,
|
|
115
|
+
lastAccessedAt: 0,
|
|
314
116
|
}));
|
|
315
117
|
|
|
316
|
-
// Compute a session embedding (mean of all chunk embeddings) for semantic
|
|
317
|
-
// similarity comparison.
|
|
318
118
|
let sessionEmbed: number[] | undefined;
|
|
319
119
|
try {
|
|
320
120
|
const dim = embedder ? embedder.embed("").length : 128;
|
|
@@ -335,7 +135,6 @@ export function refreshStripeAssignments(
|
|
|
335
135
|
log("cache-stripe: session embedding failed, skipping semantic weight");
|
|
336
136
|
}
|
|
337
137
|
|
|
338
|
-
// 2. Compute stability for each chunk.
|
|
339
138
|
const results: Array<{
|
|
340
139
|
chunkId: string;
|
|
341
140
|
stripe: number;
|
|
@@ -353,7 +152,6 @@ export function refreshStripeAssignments(
|
|
|
353
152
|
results.push({ chunkId: chunk.chunkId, stripe, stability });
|
|
354
153
|
}
|
|
355
154
|
|
|
356
|
-
// 3. UPSERT into cache_stripes using a savepoint for atomicity.
|
|
357
155
|
const upsert = db.prepare(
|
|
358
156
|
`INSERT OR REPLACE INTO cache_stripes(chunk_id, stripe, stability, assigned_at, epoch_id)
|
|
359
157
|
VALUES (?, ?, ?, ?, ?)`,
|
|
@@ -377,9 +175,7 @@ export function refreshStripeAssignments(
|
|
|
377
175
|
}
|
|
378
176
|
}
|
|
379
177
|
|
|
380
|
-
/**
|
|
381
|
-
* Generate a random epoch ID (16 hex chars) for tokenizing stripe cohorts.
|
|
382
|
-
*/
|
|
178
|
+
/** Generate a random epoch id (16 hex chars) for tokenizing stripe cohorts. */
|
|
383
179
|
function nextEpochId(): string {
|
|
384
180
|
return randomBytes(8).toString("hex");
|
|
385
|
-
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache-stripe-score.ts — stability scoring + pure helpers for cache-striping.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from cache-stripe-impl.ts (delegate-shell split) so the DB-touching
|
|
5
|
+
* refreshStripeAssignments lives apart from pure scoring math. No SQL, no pi
|
|
6
|
+
* runtime types (PREVENT-PI-004, PREVENT-002).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** Cache stripe assigned to a single chunk. */
|
|
12
|
+
export interface CacheStripe {
|
|
13
|
+
/** Unique identifier for the chunk (the context_chunks.id — TEXT, e.g. "chkpt_001"). */
|
|
14
|
+
chunkId: string;
|
|
15
|
+
/** Cache stripe / layer number:
|
|
16
|
+
* 0 = permanent (system prompt, never evicted)
|
|
17
|
+
* 1 = epoch (stable across the whole session)
|
|
18
|
+
* 2 = topic (stable within a topic cluster)
|
|
19
|
+
* 3 = thread (current conversation thread)
|
|
20
|
+
* 4 = volatile (tail — appended, not cached)
|
|
21
|
+
*/
|
|
22
|
+
stripe: number;
|
|
23
|
+
/** Composite stability score (0.0-1.0). */
|
|
24
|
+
stability: number;
|
|
25
|
+
/** Unix-epoch seconds when this assignment was computed. */
|
|
26
|
+
assignedAt: number;
|
|
27
|
+
/** Epoch identifier this assignment belongs to. */
|
|
28
|
+
epochId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Input shape for computeStabilityScore. Matches the SQL projection the
|
|
32
|
+
* caller extracts from context_chunks + cache_stripes. */
|
|
33
|
+
export interface ChunkInput {
|
|
34
|
+
/** context_chunks.id (TEXT). */
|
|
35
|
+
chunkId: string;
|
|
36
|
+
/** The text content of the chunk. */
|
|
37
|
+
content: string;
|
|
38
|
+
/** Access count — currently 0 (cache_stripes has no tracking column yet). */
|
|
39
|
+
accessCount: number;
|
|
40
|
+
/** Unix-epoch seconds of last access — currently 0 (same reason). */
|
|
41
|
+
lastAccessedAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Embedder interface — matches the shape of TrigramEmbedder.embed. */
|
|
45
|
+
export interface EmbedderLike {
|
|
46
|
+
embed(text: string): number[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const WEIGHT_SEMANTIC = 0.5;
|
|
52
|
+
const WEIGHT_RECENCY = 0.3;
|
|
53
|
+
const WEIGHT_FREQUENCY = 0.2;
|
|
54
|
+
|
|
55
|
+
const STRIPE_THRESHOLDS = [
|
|
56
|
+
{ minStability: 0.90, stripe: 0 },
|
|
57
|
+
{ minStability: 0.70, stripe: 1 },
|
|
58
|
+
{ minStability: 0.50, stripe: 2 },
|
|
59
|
+
{ minStability: 0.30, stripe: 3 },
|
|
60
|
+
{ minStability: -Infinity, stripe: 4 },
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
// ─── Embedding helpers (no external dep) ─────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** FNV-1a 32-bit hash for the content-based embedding fallback. */
|
|
66
|
+
function fnv1a(text: string): number {
|
|
67
|
+
let hash = 0x811c9dc5;
|
|
68
|
+
for (let i = 0; i < text.length; i++) {
|
|
69
|
+
hash ^= text.charCodeAt(i);
|
|
70
|
+
hash = Math.imul(hash, 0x01000193);
|
|
71
|
+
}
|
|
72
|
+
return (hash >>> 0) / 0x100000000;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Crude 128-dim hashed n-gram embedding fallback. Used when no embedder is
|
|
76
|
+
* injected (e.g. tests); production should pass TrigramEmbedder. */
|
|
77
|
+
export function fallbackEmbed(text: string): number[] {
|
|
78
|
+
const dim = 128;
|
|
79
|
+
const vec = new Array<number>(dim).fill(0);
|
|
80
|
+
const norm = text.toLowerCase().replace(/\s+/g, " ");
|
|
81
|
+
if (norm.length === 0) return vec;
|
|
82
|
+
|
|
83
|
+
vec[Math.floor(fnv1a(norm) * dim)] += 1;
|
|
84
|
+
for (const word of norm.split(" ")) {
|
|
85
|
+
if (word.length === 0) continue;
|
|
86
|
+
vec[Math.floor(fnv1a(word) * dim)] += 0.5;
|
|
87
|
+
for (let i = 0; i < Math.max(1, word.length - 1); i++) {
|
|
88
|
+
const trigram = word.slice(i, i + 3);
|
|
89
|
+
if (trigram.length === 3) {
|
|
90
|
+
vec[Math.floor(fnv1a(trigram) * dim)] += 0.25;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return l2Normalize(vec);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function l2Normalize(v: number[]): number[] {
|
|
98
|
+
let sumSq = 0;
|
|
99
|
+
for (let i = 0; i < v.length; i++) sumSq += v[i] * v[i];
|
|
100
|
+
if (sumSq === 0) return v;
|
|
101
|
+
const norm = Math.sqrt(sumSq);
|
|
102
|
+
for (let i = 0; i < v.length; i++) v[i] /= norm;
|
|
103
|
+
return v;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Compute cosine similarity between two vectors of equal length. */
|
|
107
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
108
|
+
if (a.length !== b.length || a.length === 0) return 0;
|
|
109
|
+
let dot = 0;
|
|
110
|
+
let na = 0;
|
|
111
|
+
let nb = 0;
|
|
112
|
+
for (let i = 0; i < a.length; i++) {
|
|
113
|
+
dot += a[i] * b[i];
|
|
114
|
+
na += a[i] * a[i];
|
|
115
|
+
nb += b[i] * b[i];
|
|
116
|
+
}
|
|
117
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
|
118
|
+
return denom === 0 ? 0 : dot / denom;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Stability Scoring ───────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Composite stability = 0.5*semantic + 0.3*recency + 0.2*frequency.
|
|
125
|
+
* Recency/frequency fall back to neutral scores when the epoch lacks
|
|
126
|
+
* access-tracking data (the current cache_stripes schema).
|
|
127
|
+
*/
|
|
128
|
+
export function computeStabilityScore(
|
|
129
|
+
chunk: ChunkInput,
|
|
130
|
+
allChunks: ChunkInput[],
|
|
131
|
+
embedder?: EmbedderLike,
|
|
132
|
+
sessionEmbed?: number[],
|
|
133
|
+
): number {
|
|
134
|
+
const emb = embedder
|
|
135
|
+
? embedder.embed(chunk.content)
|
|
136
|
+
: fallbackEmbed(chunk.content);
|
|
137
|
+
// No session embedding → self-similarity baseline.
|
|
138
|
+
const sem = cosineSimilarity(emb, sessionEmbed ?? emb);
|
|
139
|
+
const semanticScore = isNaN(sem) ? 0 : sem;
|
|
140
|
+
|
|
141
|
+
let recencyScore = 0.5;
|
|
142
|
+
const accessed = allChunks
|
|
143
|
+
.map((c) => c.lastAccessedAt)
|
|
144
|
+
.filter((t) => t > 0);
|
|
145
|
+
if (accessed.length > 1) {
|
|
146
|
+
const minT = Math.min(...accessed);
|
|
147
|
+
const maxT = Math.max(...accessed);
|
|
148
|
+
const range = maxT - minT;
|
|
149
|
+
recencyScore = range > 0 ? (chunk.lastAccessedAt - minT) / range : 1.0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const counts = allChunks.map((c) => c.accessCount);
|
|
153
|
+
const maxCount = Math.max(...counts, 1);
|
|
154
|
+
const freqScore = maxCount > 0 ? chunk.accessCount / maxCount : 0;
|
|
155
|
+
|
|
156
|
+
const stability =
|
|
157
|
+
WEIGHT_SEMANTIC * semanticScore +
|
|
158
|
+
WEIGHT_RECENCY * recencyScore +
|
|
159
|
+
WEIGHT_FREQUENCY * freqScore;
|
|
160
|
+
|
|
161
|
+
return Math.max(0, Math.min(1, stability));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Map a stability score to its stripe (layer). */
|
|
165
|
+
export function stabilityToStripe(stability: number): number {
|
|
166
|
+
for (const t of STRIPE_THRESHOLDS) {
|
|
167
|
+
if (stability >= t.minStability) return t.stripe;
|
|
168
|
+
}
|
|
169
|
+
return 4;
|
|
170
|
+
}
|