pi-mega-compact 0.20.13 → 0.20.15
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/config/vector-cortex-early.js +89 -0
- package/dist/config/vector-cortex-flag.js +25 -0
- package/dist/config/vector-cortex.js +43 -83
- package/dist/config.js +1 -1
- package/dist/extensions/dashboard-server/api-contracts/vector-cortex-economics.js +20 -0
- package/dist/extensions/dashboard-server/route-dispatch.js +7 -0
- package/dist/extensions/dashboard-server/routes-rag-settings-vector-cortex.js +2 -0
- package/dist/extensions/dashboard-server/routes-vector-cortex-economics.js +59 -0
- package/dist/src/config/vector-cortex-early.js +89 -0
- package/dist/src/config/vector-cortex-flag.js +25 -0
- package/dist/src/config/vector-cortex.js +43 -83
- package/dist/src/config.js +1 -1
- package/dist/src/vector-cortex/cache/_economics-fixture.js +41 -0
- package/dist/src/vector-cortex/cache/compiler.js +201 -0
- package/dist/src/vector-cortex/cache/economics-emit.js +85 -0
- package/dist/src/vector-cortex/migrations/request-hash-v2-ops.js +143 -0
- package/dist/src/vector-cortex/migrations/request-hash-v2-types.js +30 -0
- package/dist/src/vector-cortex/migrations/request-hash-v2.js +56 -0
- package/dist/src/vector-cortex/provider/economics-ids.js +29 -0
- package/dist/src/vector-cortex/provider/economics.js +176 -0
- package/dist/src/vector-cortex/provider/experiments.js +166 -0
- package/dist/vector-cortex/cache/_economics-fixture.js +41 -0
- package/dist/vector-cortex/cache/compiler.js +201 -0
- package/dist/vector-cortex/cache/economics-emit.js +85 -0
- package/dist/vector-cortex/migrations/request-hash-v2-ops.js +143 -0
- package/dist/vector-cortex/migrations/request-hash-v2-types.js +30 -0
- package/dist/vector-cortex/migrations/request-hash-v2.js +56 -0
- package/dist/vector-cortex/provider/economics-ids.js +29 -0
- package/dist/vector-cortex/provider/economics.js +176 -0
- package/dist/vector-cortex/provider/experiments.js +166 -0
- package/extensions/dashboard-client/dist/assets/{AreaChart-CB-U7ViX.js → AreaChart-CV8WqMmt.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AreaChart-CB-U7ViX.js.map → AreaChart-CV8WqMmt.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{BarChart-DnLw0fxA.js → BarChart-M-HEBq9l.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{BarChart-DnLw0fxA.js.map → BarChart-M-HEBq9l.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CjyTVDiP.js → CacheTab-DJ_yPAhP.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CjyTVDiP.js.map → CacheTab-DJ_yPAhP.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-Bz3QEWam.js → EventsTab-DNVlKzt6.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-Bz3QEWam.js.map → EventsTab-DNVlKzt6.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{HealthTab-CUylEvEY.js → HealthTab-B5yjuInb.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{HealthTab-CUylEvEY.js.map → HealthTab-B5yjuInb.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-Cz_hj_E7.js → MaintenanceTab-DoQedESF.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-Cz_hj_E7.js.map → MaintenanceTab-DoQedESF.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-Dh8brIR1.js → MemoryMapTab-CuL5CzdH.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-Dh8brIR1.js.map → MemoryMapTab-CuL5CzdH.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CkqSXc6e.js → MetricsTab-CZSTaCpK.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CkqSXc6e.js.map → MetricsTab-CZSTaCpK.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-DGbJcx6d.js → OverviewTab-vkZlXRW6.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-DGbJcx6d.js.map → OverviewTab-vkZlXRW6.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-BawS3nfF.js → ReposTab-DgPD96rF.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-BawS3nfF.js.map → ReposTab-DgPD96rF.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CUVnmYks.js → SessionsTab-XPbWh7XN.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CUVnmYks.js.map → SessionsTab-XPbWh7XN.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SetupTab-cxTuyZNk.js → SetupTab-Dz74XAtR.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SetupTab-cxTuyZNk.js.map → SetupTab-Dz74XAtR.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Cv5SENkE.js → TimeSavedCard-FynCpJdx.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Cv5SENkE.js.map → TimeSavedCard-FynCpJdx.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-yl6BBG_F.js → TurnsTab-C7m3GDy0.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-yl6BBG_F.js.map → TurnsTab-C7m3GDy0.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-jRd_Iztk.js +2 -0
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-jRd_Iztk.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{WikiTab-DKv-0xCQ.js → WikiTab-C6viLa0u.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{WikiTab-DKv-0xCQ.js.map → WikiTab-C6viLa0u.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{button-CVPrO4UU.js → button-mBEgbBVU.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{button-CVPrO4UU.js.map → button-mBEgbBVU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{card-BFLnQJEo.js → card-CalUQAKG.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{card-BFLnQJEo.js.map → card-CalUQAKG.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-B-IUoLd1.js → generateCategoricalChart-CMmkuwj5.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-B-IUoLd1.js.map → generateCategoricalChart-CMmkuwj5.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-Do749WlW.js → index-Dz8GPBOE.js} +3 -3
- package/extensions/dashboard-client/dist/assets/{index-Do749WlW.js.map → index-Dz8GPBOE.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{switch-3uFnZmtq.js → switch-8QKO6CTU.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{switch-3uFnZmtq.js.map → switch-8QKO6CTU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{toggle-C1rYLeXe.js → toggle-9eCkWI4q.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{toggle-C1rYLeXe.js.map → toggle-9eCkWI4q.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-D1qzLzgR.js → useSSE-s6IqOKoD.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-D1qzLzgR.js.map → useSSE-s6IqOKoD.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +1 -1
- package/extensions/dashboard-client/src/api/vector-cortex.ts +18 -0
- package/extensions/dashboard-client/src/tabs/VectorCortexEconomicsCard.tsx +41 -0
- package/extensions/dashboard-client/src/tabs/VectorCortexTab.tsx +24 -114
- package/extensions/dashboard-client/src/tabs/useVectorCortexPoll.ts +123 -0
- package/extensions/dashboard-client/src/types/vector-cortex-vc7.ts +63 -0
- package/extensions/dashboard-client/src/types/vector-cortex.ts +5 -19
- package/extensions/dashboard-server/api-contracts/vector-cortex-economics.ts +47 -0
- package/extensions/dashboard-server/route-dispatch.ts +6 -0
- package/extensions/dashboard-server/routes-rag-settings-vector-cortex.ts +12 -0
- package/extensions/dashboard-server/routes-vector-cortex-economics.ts +68 -0
- package/package.json +1 -1
- package/src/config/vector-cortex-early.ts +99 -0
- package/src/config/vector-cortex-flag.ts +24 -0
- package/src/config/vector-cortex.ts +55 -89
- package/src/config.ts +2 -0
- package/src/vector-cortex/cache/_economics-fixture.ts +128 -0
- package/src/vector-cortex/cache/compiler.ts +280 -0
- package/src/vector-cortex/cache/economics-emit.ts +116 -0
- package/src/vector-cortex/migrations/request-hash-v2-ops.ts +195 -0
- package/src/vector-cortex/migrations/request-hash-v2-types.ts +87 -0
- package/src/vector-cortex/migrations/request-hash-v2.ts +80 -0
- package/src/vector-cortex/provider/economics-ids.ts +38 -0
- package/src/vector-cortex/provider/economics.ts +289 -0
- package/src/vector-cortex/provider/experiments.ts +248 -0
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-94fqVjat.js +0 -2
- package/extensions/dashboard-client/dist/assets/VectorCortexTab-94fqVjat.js.map +0 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vector-cortex/cache/compiler.ts — VC7B provider-safe crystal boundary compiler.
|
|
3
|
+
*
|
|
4
|
+
* A provider caches a PREFIX of the request. So where you cut the prompt into
|
|
5
|
+
* cacheable segments determines whether anything is reusable at all: cut too
|
|
6
|
+
* finely and every segment falls under the provider's `minPrefix` and nothing is
|
|
7
|
+
* cacheable; cut in the wrong place and a segment that changes every turn sits
|
|
8
|
+
* in front of one that never changes, invalidating everything behind it. This
|
|
9
|
+
* file turns validated source ranges plus a profile's limits into boundaries
|
|
10
|
+
* that the provider can actually cache.
|
|
11
|
+
*
|
|
12
|
+
* THE ONE INVARIANT THAT OUTRANKS EVERYTHING: THE COMPILER NEVER CHANGES
|
|
13
|
+
* REQUEST IDENTITY. It selects where boundaries FALL; it never reorders, never
|
|
14
|
+
* merges across a session, never drops a range, never rewrites bytes. The
|
|
15
|
+
* concatenation of the compiled boundaries covers exactly the same ranges, in
|
|
16
|
+
* exactly the same canonical order, as the input — so the VC5B canonical request
|
|
17
|
+
* digest and the VC7A crystal key are byte-identical before and after
|
|
18
|
+
* compilation. `boundariesPreserveIdentity()` states this as an executable
|
|
19
|
+
* check, and the acceptance suite runs it on every compiled row. If compilation
|
|
20
|
+
* could alter identity it would be a cache-poisoning engine rather than an
|
|
21
|
+
* optimizer: two different conversations could compile to one cache key.
|
|
22
|
+
*
|
|
23
|
+
* WHY MERGE FORWARD, AND ONLY WITHIN A SESSION. Ranges below `minPrefix` are not
|
|
24
|
+
* independently cacheable, so the compiler merges each undersized range into the
|
|
25
|
+
* one that FOLLOWS it in canonical order — forward, so the merged segment keeps
|
|
26
|
+
* the earlier range's start and the prefix relationship is preserved. Merging is
|
|
27
|
+
* confined to a single session because ranges from different sessions cover
|
|
28
|
+
* disjoint byte streams; a cross-session segment would claim a contiguity that
|
|
29
|
+
* does not exist. A trailing undersized run with nothing to merge into is
|
|
30
|
+
* emitted as its own boundary marked `cacheable: false` — reported honestly as
|
|
31
|
+
* uncacheable rather than padded, dropped, or silently attached backwards.
|
|
32
|
+
*
|
|
33
|
+
* BOUNDED. `maxSegments` caps the output so a caller-shaped range list cannot be
|
|
34
|
+
* turned into an unbounded segmentation (`COMP_SEGMENT_LIMIT`).
|
|
35
|
+
*
|
|
36
|
+
* PURE. No clock, no storage, no console, no network (PREVENT-PI-004 /
|
|
37
|
+
* PREVENT-011). Runs identically with `MEGACOMPACT_VC7B` on or off — the flag
|
|
38
|
+
* gates only the reporter/dashboard seam in `./economics-emit.ts`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { createHash } from "node:crypto";
|
|
42
|
+
|
|
43
|
+
import { compareSpans, sortSpans, validateRanges } from "./crystal.js";
|
|
44
|
+
import type { CrystalKeyV1, DagSpan } from "./types.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One compiled, provider-safe cache boundary: a contiguous run of covered ranges
|
|
48
|
+
* within a SINGLE session that the provider may treat as one cacheable segment.
|
|
49
|
+
*/
|
|
50
|
+
export interface CrystalBoundaryV1 {
|
|
51
|
+
readonly schema: "crystal-boundary-v1";
|
|
52
|
+
/** The session these ranges belong to (a boundary never spans sessions). */
|
|
53
|
+
readonly sessionId: string;
|
|
54
|
+
/** The covered ranges in this segment, in canonical order. */
|
|
55
|
+
readonly ranges: readonly DagSpan[];
|
|
56
|
+
/** Total covered bytes in the segment. */
|
|
57
|
+
readonly byteCount: number;
|
|
58
|
+
/** Approximate token count used against `minPrefix` (see `tokensForBytes`). */
|
|
59
|
+
readonly tokenCount: number;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the provider can actually cache this segment: it meets `minPrefix`.
|
|
62
|
+
* A `false` segment is still emitted and still rendered — it is simply priced
|
|
63
|
+
* as uncached rather than pretended to be a hit.
|
|
64
|
+
*/
|
|
65
|
+
readonly cacheable: boolean;
|
|
66
|
+
/** `sha256:` digest over the segment's ranges — a segment-level content pin. */
|
|
67
|
+
readonly digest: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Profile-derived limits the compiler must respect. */
|
|
71
|
+
export interface CompilerLimits {
|
|
72
|
+
/** Minimum cacheable prefix in tokens (`ProviderEconomicsV1.minPrefix`). */
|
|
73
|
+
readonly minPrefix: number;
|
|
74
|
+
/** Maximum number of compiled segments. Bounds the output. */
|
|
75
|
+
readonly maxSegments: number;
|
|
76
|
+
/** Bytes per token used for the token estimate. Must be >= 1. */
|
|
77
|
+
readonly bytesPerToken: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Conventional default limits (4 bytes/token is the usual English estimate). */
|
|
81
|
+
export const DEFAULT_COMPILER_LIMITS: CompilerLimits = {
|
|
82
|
+
minPrefix: 1024,
|
|
83
|
+
maxSegments: 64,
|
|
84
|
+
bytesPerToken: 4,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** VC7B compiler failure codes. */
|
|
88
|
+
export type CompilerFailureCode =
|
|
89
|
+
/** The input ranges failed VC7A validation (empty/malformed/overlapping). */
|
|
90
|
+
| "COMP_RANGE_INVALID"
|
|
91
|
+
/** Compilation would exceed `maxSegments`. */
|
|
92
|
+
| "COMP_SEGMENT_LIMIT"
|
|
93
|
+
/** A limit is negative, fractional, or otherwise unusable. */
|
|
94
|
+
| "COMP_LIMIT_INVALID"
|
|
95
|
+
/** The compiled boundaries did not re-cover the input exactly (never expected). */
|
|
96
|
+
| "COMP_IDENTITY_DRIFT";
|
|
97
|
+
|
|
98
|
+
/** The verdict of a compilation. */
|
|
99
|
+
export type CompileResult =
|
|
100
|
+
| {
|
|
101
|
+
readonly ok: true;
|
|
102
|
+
readonly boundaries: readonly CrystalBoundaryV1[];
|
|
103
|
+
/** Segments the provider can actually cache (`cacheable: true`). */
|
|
104
|
+
readonly cacheableCount: number;
|
|
105
|
+
/** Covered tokens sitting in cacheable segments. */
|
|
106
|
+
readonly cacheableTokens: number;
|
|
107
|
+
}
|
|
108
|
+
| { readonly ok: false; readonly codes: readonly CompilerFailureCode[] };
|
|
109
|
+
|
|
110
|
+
/** Covered bytes of one range. */
|
|
111
|
+
function spanBytes(s: DagSpan): number {
|
|
112
|
+
return s.endByte - s.startByte;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Byte→token estimate. Deliberately a floor: under-estimating tokens can only
|
|
117
|
+
* make the compiler MORE conservative about calling a segment cacheable, which
|
|
118
|
+
* fails safe (a wrongly-cacheable segment would be priced as a hit it never got).
|
|
119
|
+
*/
|
|
120
|
+
export function tokensForBytes(bytes: number, bytesPerToken: number): number {
|
|
121
|
+
return Math.floor(bytes / bytesPerToken);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `sha256:` digest over a segment's ranges — length-prefixed, so injective. */
|
|
125
|
+
function boundaryDigest(ranges: readonly DagSpan[]): string {
|
|
126
|
+
const h = createHash("sha256");
|
|
127
|
+
for (const s of ranges) {
|
|
128
|
+
const parts = [
|
|
129
|
+
s.sessionId,
|
|
130
|
+
s.startSeq.toString(),
|
|
131
|
+
s.endSeq.toString(),
|
|
132
|
+
String(s.startByte),
|
|
133
|
+
String(s.endByte),
|
|
134
|
+
s.digest,
|
|
135
|
+
];
|
|
136
|
+
for (const p of parts) h.update(`${Buffer.byteLength(p, "utf8")}:${p}`, "utf8");
|
|
137
|
+
}
|
|
138
|
+
return `sha256:${h.digest("hex")}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Build one boundary from a contiguous same-session run of ranges. */
|
|
142
|
+
function makeBoundary(
|
|
143
|
+
ranges: readonly DagSpan[],
|
|
144
|
+
limits: CompilerLimits,
|
|
145
|
+
): CrystalBoundaryV1 {
|
|
146
|
+
const byteCount = ranges.reduce((n, s) => n + spanBytes(s), 0);
|
|
147
|
+
const tokenCount = tokensForBytes(byteCount, limits.bytesPerToken);
|
|
148
|
+
const first = ranges[0];
|
|
149
|
+
return {
|
|
150
|
+
schema: "crystal-boundary-v1",
|
|
151
|
+
sessionId: first === undefined ? "" : first.sessionId,
|
|
152
|
+
ranges,
|
|
153
|
+
byteCount,
|
|
154
|
+
tokenCount,
|
|
155
|
+
cacheable: tokenCount >= limits.minPrefix,
|
|
156
|
+
digest: boundaryDigest(ranges),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validLimits(l: CompilerLimits): boolean {
|
|
161
|
+
return (
|
|
162
|
+
Number.isSafeInteger(l.minPrefix) &&
|
|
163
|
+
l.minPrefix >= 0 &&
|
|
164
|
+
Number.isSafeInteger(l.maxSegments) &&
|
|
165
|
+
l.maxSegments > 0 &&
|
|
166
|
+
Number.isSafeInteger(l.bytesPerToken) &&
|
|
167
|
+
l.bytesPerToken >= 1
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Compile validated ranges into provider-safe cache boundaries.
|
|
173
|
+
*
|
|
174
|
+
* Ranges are first put in canonical order (the SAME `sortSpans` order the VC7A
|
|
175
|
+
* key encoder uses — sharing the comparator is what keeps the two subsystems
|
|
176
|
+
* from disagreeing about what "canonical" means). They are then grouped by
|
|
177
|
+
* session, and within each session an undersized run is merged FORWARD until it
|
|
178
|
+
* meets `minPrefix`.
|
|
179
|
+
*
|
|
180
|
+
* Identity is verified before returning: the flattened output must equal the
|
|
181
|
+
* canonical input exactly. That check should never fire — it is a guard against
|
|
182
|
+
* a future edit to the merge loop quietly reordering or dropping a range.
|
|
183
|
+
*/
|
|
184
|
+
export function compileCrystalBoundaries(
|
|
185
|
+
ranges: readonly DagSpan[],
|
|
186
|
+
limits: CompilerLimits = DEFAULT_COMPILER_LIMITS,
|
|
187
|
+
): CompileResult {
|
|
188
|
+
if (!validLimits(limits)) return { ok: false, codes: ["COMP_LIMIT_INVALID"] };
|
|
189
|
+
|
|
190
|
+
// Reuse VC7A's validator verbatim: the compiler must never accept a range set
|
|
191
|
+
// the crystal key would reject, or the two would disagree about what is legal.
|
|
192
|
+
const rangeCodes = validateRanges(ranges);
|
|
193
|
+
if (rangeCodes.length > 0) return { ok: false, codes: ["COMP_RANGE_INVALID"] };
|
|
194
|
+
|
|
195
|
+
const sorted = sortSpans(ranges);
|
|
196
|
+
const boundaries: CrystalBoundaryV1[] = [];
|
|
197
|
+
|
|
198
|
+
let run: DagSpan[] = [];
|
|
199
|
+
const flushRun = (): void => {
|
|
200
|
+
if (run.length > 0) {
|
|
201
|
+
boundaries.push(makeBoundary(run, limits));
|
|
202
|
+
run = [];
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
for (const span of sorted) {
|
|
207
|
+
const head = run[0];
|
|
208
|
+
// A session change always closes the run: a boundary never spans sessions,
|
|
209
|
+
// because two sessions' byte streams are not contiguous with each other.
|
|
210
|
+
if (head !== undefined && head.sessionId !== span.sessionId) flushRun();
|
|
211
|
+
run.push(span);
|
|
212
|
+
const bytes = run.reduce((n, s) => n + spanBytes(s), 0);
|
|
213
|
+
// Close as soon as the run is independently cacheable; anything smaller keeps
|
|
214
|
+
// absorbing the next range (merge-forward).
|
|
215
|
+
if (tokensForBytes(bytes, limits.bytesPerToken) >= limits.minPrefix) flushRun();
|
|
216
|
+
}
|
|
217
|
+
// A trailing undersized run has nothing left to merge into: emit it honestly
|
|
218
|
+
// as a non-cacheable boundary rather than padding or dropping it.
|
|
219
|
+
flushRun();
|
|
220
|
+
|
|
221
|
+
if (boundaries.length > limits.maxSegments) {
|
|
222
|
+
return { ok: false, codes: ["COMP_SEGMENT_LIMIT"] };
|
|
223
|
+
}
|
|
224
|
+
if (!boundariesPreserveIdentity(sorted, boundaries)) {
|
|
225
|
+
return { ok: false, codes: ["COMP_IDENTITY_DRIFT"] };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let cacheableCount = 0;
|
|
229
|
+
let cacheableTokens = 0;
|
|
230
|
+
for (const b of boundaries) {
|
|
231
|
+
if (b.cacheable) {
|
|
232
|
+
cacheableCount += 1;
|
|
233
|
+
cacheableTokens += b.tokenCount;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, boundaries, cacheableCount, cacheableTokens };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The executable form of the sprint's headline invariant: flattening the
|
|
241
|
+
* compiled boundaries must reproduce the canonical input ranges EXACTLY — same
|
|
242
|
+
* ranges, same order, same pinned digests.
|
|
243
|
+
*
|
|
244
|
+
* Comparison is field-by-field on identity (session, seq bounds, byte bounds,
|
|
245
|
+
* digest), never by count or by digest alone: a compiler bug that swapped two
|
|
246
|
+
* equal-length ranges would keep the count and the byte total identical while
|
|
247
|
+
* changing what the request means.
|
|
248
|
+
*/
|
|
249
|
+
export function boundariesPreserveIdentity(
|
|
250
|
+
input: readonly DagSpan[],
|
|
251
|
+
boundaries: readonly CrystalBoundaryV1[],
|
|
252
|
+
): boolean {
|
|
253
|
+
const flat: DagSpan[] = [];
|
|
254
|
+
for (const b of boundaries) flat.push(...b.ranges);
|
|
255
|
+
const canonical = sortSpans(input);
|
|
256
|
+
if (flat.length !== canonical.length) return false;
|
|
257
|
+
for (let i = 0; i < flat.length; i += 1) {
|
|
258
|
+
const a = flat[i];
|
|
259
|
+
const b = canonical[i];
|
|
260
|
+
if (a === undefined || b === undefined) return false;
|
|
261
|
+
if (compareSpans(a, b) !== 0) return false;
|
|
262
|
+
// compareSpans intentionally ignores the pinned digest (it orders by source
|
|
263
|
+
// position). Identity includes the covered bytes, so check it explicitly.
|
|
264
|
+
if (a.digest !== b.digest) return false;
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Compile the ranges of an existing crystal key. Returns the boundaries plus the
|
|
271
|
+
* UNCHANGED key: compilation is an optimization of how the render is segmented,
|
|
272
|
+
* never a re-keying. Returning the same key object makes that explicit at the
|
|
273
|
+
* call site — a caller cannot accidentally pick up a "compiled key" that differs.
|
|
274
|
+
*/
|
|
275
|
+
export function compileForKey(
|
|
276
|
+
key: CrystalKeyV1,
|
|
277
|
+
limits: CompilerLimits = DEFAULT_COMPILER_LIMITS,
|
|
278
|
+
): { readonly key: CrystalKeyV1; readonly compiled: CompileResult } {
|
|
279
|
+
return { key, compiled: compileCrystalBoundaries(key.sourceRanges, limits) };
|
|
280
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vector-cortex/cache/economics-emit.ts — VC7B event reporter seam.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `./crystal-emit.ts`: a thin `safe()` wrapper around an optional
|
|
5
|
+
* injected `emit` (unit tests pass `undefined` and stay pure), and the two event
|
|
6
|
+
* names the sprint spec requires verbatim:
|
|
7
|
+
* - `vector_cortex_cache_experiment_assigned` — a session entered an arm.
|
|
8
|
+
* - `vector_cortex_cache_economics_estimated` — net savings were computed.
|
|
9
|
+
*
|
|
10
|
+
* FLAG SEMANTICS. `computeEconomics`, `compileCrystalBoundaries` and
|
|
11
|
+
* `assignExperiment` are PURE and run REGARDLESS of `MEGACOMPACT_VC7B`. The flag
|
|
12
|
+
* gates ONLY this reporting + dashboard seam: with the flag off a session still
|
|
13
|
+
* hashes to the SAME arm, the compiler still produces the SAME boundaries, and
|
|
14
|
+
* the same net savings are still computed — we just do not announce them under
|
|
15
|
+
* the VC7B event namespace, and the dashboard reports `enabled:false` + mode C.
|
|
16
|
+
* That is what makes flag-off byte-identical to VC7A: the arithmetic is never
|
|
17
|
+
* skipped, only the emission.
|
|
18
|
+
*
|
|
19
|
+
* PAYLOAD DISCIPLINE. These events carry the ARM, the BUCKET, integer money
|
|
20
|
+
* AGGREGATES, and the evidence label — never a session id, never covered ranges,
|
|
21
|
+
* never frozen bytes, never a request or covered digest. A session id here would
|
|
22
|
+
* re-identify a user's conversation in a log file, and the bucket already carries
|
|
23
|
+
* everything needed to audit the split (SECURITY_PRIVACY — the exact ledger is
|
|
24
|
+
* not diagnostic data). The `evidence` label travels WITH every economics event
|
|
25
|
+
* so a downstream aggregator can never mistake a shadow estimate for a measured,
|
|
26
|
+
* randomized result.
|
|
27
|
+
*
|
|
28
|
+
* No console, no storage, no network (PREVENT-PI-004 / PREVENT-011). Every line
|
|
29
|
+
* is a structured JSON event with `ts` + `event`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { VC7B_ENABLED } from "../../config/vector-cortex.js";
|
|
33
|
+
import type { EconomicsEvidence } from "../provider/economics.js";
|
|
34
|
+
import type { AssignmentSource, ExperimentArm } from "../provider/experiments.js";
|
|
35
|
+
|
|
36
|
+
/** The two structured events the VC7B reporter emits. */
|
|
37
|
+
export type EconomicsEventName =
|
|
38
|
+
| "vector_cortex_cache_experiment_assigned"
|
|
39
|
+
| "vector_cortex_cache_economics_estimated";
|
|
40
|
+
|
|
41
|
+
/** Optional emit fn injected by the runtime; tests pass `undefined`. */
|
|
42
|
+
export type EconomicsEmit = (name: string, payload: unknown) => void;
|
|
43
|
+
|
|
44
|
+
/** Run `fn` only when an emit exists; a reporting failure is never fatal. */
|
|
45
|
+
function safe(emit: EconomicsEmit | undefined, fn: (emit: EconomicsEmit) => void): void {
|
|
46
|
+
if (emit === undefined) return;
|
|
47
|
+
try {
|
|
48
|
+
fn(emit);
|
|
49
|
+
} catch {
|
|
50
|
+
// Non-fatal: a reporting failure must never break the agent loop.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The event names VC7B emits, exported for the dashboard seam and tests. */
|
|
55
|
+
export const ECONOMICS_EVENT_NAMES: readonly EconomicsEventName[] = [
|
|
56
|
+
"vector_cortex_cache_experiment_assigned",
|
|
57
|
+
"vector_cortex_cache_economics_estimated",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Report a session's experiment assignment.
|
|
62
|
+
*
|
|
63
|
+
* Arm + bucket + source only. The bucket is sufficient to audit that the split
|
|
64
|
+
* is being honored; the SESSION ID is deliberately absent so the event log
|
|
65
|
+
* cannot be used to reconstruct who was in which arm.
|
|
66
|
+
*/
|
|
67
|
+
export function reportCacheExperimentAssigned(
|
|
68
|
+
emit: EconomicsEmit | undefined,
|
|
69
|
+
payload: {
|
|
70
|
+
readonly experimentId: string;
|
|
71
|
+
readonly arm: ExperimentArm;
|
|
72
|
+
readonly bucket: number;
|
|
73
|
+
readonly source: AssignmentSource;
|
|
74
|
+
},
|
|
75
|
+
): void {
|
|
76
|
+
if (!VC7B_ENABLED()) return;
|
|
77
|
+
safe(emit, (e) =>
|
|
78
|
+
e("vector_cortex_cache_experiment_assigned", {
|
|
79
|
+
ts: undefined,
|
|
80
|
+
event: "vector_cortex_cache_experiment_assigned",
|
|
81
|
+
experimentId: payload.experimentId,
|
|
82
|
+
arm: payload.arm,
|
|
83
|
+
bucket: payload.bucket,
|
|
84
|
+
source: payload.source,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Report computed cache economics.
|
|
91
|
+
*
|
|
92
|
+
* Integer micro-unit aggregates plus the evidence label. `netSavings` may be
|
|
93
|
+
* NEGATIVE and is reported as such — a cache that lost money is the outcome this
|
|
94
|
+
* telemetry exists to surface, so it is never clamped on the way out.
|
|
95
|
+
*/
|
|
96
|
+
export function reportCacheEconomicsEstimated(
|
|
97
|
+
emit: EconomicsEmit | undefined,
|
|
98
|
+
payload: {
|
|
99
|
+
readonly profileId: string;
|
|
100
|
+
readonly netSavings: number;
|
|
101
|
+
readonly tokenSavings: number;
|
|
102
|
+
readonly evidence: EconomicsEvidence;
|
|
103
|
+
},
|
|
104
|
+
): void {
|
|
105
|
+
if (!VC7B_ENABLED()) return;
|
|
106
|
+
safe(emit, (e) =>
|
|
107
|
+
e("vector_cortex_cache_economics_estimated", {
|
|
108
|
+
ts: undefined,
|
|
109
|
+
event: "vector_cortex_cache_economics_estimated",
|
|
110
|
+
profileId: payload.profileId,
|
|
111
|
+
netSavings: payload.netSavings,
|
|
112
|
+
tokenSavings: payload.tokenSavings,
|
|
113
|
+
evidence: payload.evidence,
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* request-hash-v2-ops.ts — M5 migration operational logic (copy/verify/switch).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from request-hash-v2.ts to keep the parent file under the 300-line
|
|
5
|
+
* soft limit (soft-as-hard gate). Pure logic over an injected M5Host;
|
|
6
|
+
* deterministic + testable; no console. PREVENT-002/011/PI-004 honored.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { invalidationKey } from "../topology/query.js";
|
|
10
|
+
import type {
|
|
11
|
+
M5Host,
|
|
12
|
+
M5MigrationCode,
|
|
13
|
+
M5ValidateResult,
|
|
14
|
+
RequestHashV1Row,
|
|
15
|
+
RequestHashV2Row,
|
|
16
|
+
} from "./request-hash-v2-types.js";
|
|
17
|
+
import {
|
|
18
|
+
M5_FAIL,
|
|
19
|
+
REQUEST_HASH_LEGACY_VERSION,
|
|
20
|
+
} from "./request-hash-v2-types.js";
|
|
21
|
+
|
|
22
|
+
/** Length-prefixed framing so the folded fields cannot alias. */
|
|
23
|
+
function field(value: string): string {
|
|
24
|
+
return `${Buffer.byteLength(value, "utf8")}:${value}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const identity = (profileId: string, requestDigest: string): string =>
|
|
28
|
+
`${profileId}::${requestDigest}`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The authoritative v2 hash: SHA-256 over the version tag, profile, request
|
|
32
|
+
* digest, and economics version. Length-prefixed for injectivity, exactly as the
|
|
33
|
+
* crystal key encoder frames its fields.
|
|
34
|
+
*/
|
|
35
|
+
export function deriveRequestHashV2(
|
|
36
|
+
profileId: string,
|
|
37
|
+
requestDigest: string,
|
|
38
|
+
economicsVersion: string,
|
|
39
|
+
): string {
|
|
40
|
+
return createHash("sha256")
|
|
41
|
+
.update(
|
|
42
|
+
field("request-hash-v2") +
|
|
43
|
+
field(profileId) +
|
|
44
|
+
field(requestDigest) +
|
|
45
|
+
field(economicsVersion),
|
|
46
|
+
"utf8",
|
|
47
|
+
)
|
|
48
|
+
.digest("hex");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Derive the v2 row for one v1 row. */
|
|
52
|
+
export function deriveRequestHashRow(
|
|
53
|
+
host: M5Host,
|
|
54
|
+
v1: RequestHashV1Row,
|
|
55
|
+
): RequestHashV2Row {
|
|
56
|
+
const economicsVersion = host.economicsVersionOf(v1.profileId);
|
|
57
|
+
return {
|
|
58
|
+
profileId: v1.profileId,
|
|
59
|
+
// IDENTITY-PRESERVING: carried through verbatim, never re-derived.
|
|
60
|
+
requestDigest: v1.requestDigest,
|
|
61
|
+
economicsVersion,
|
|
62
|
+
hash: deriveRequestHashV2(v1.profileId, v1.requestDigest, economicsVersion),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Economics versions encode the generation they were minted under. We treat the
|
|
68
|
+
* trailing numeric segment as the generation; if it is not numeric the row is
|
|
69
|
+
* considered tied to generation 0 (always live unless explicitly invalidated).
|
|
70
|
+
*/
|
|
71
|
+
function generationFromEconomics(economicsVersion: string): bigint {
|
|
72
|
+
const match = /(\d+)$/.exec(economicsVersion);
|
|
73
|
+
return match ? BigInt(match[1]) : 0n;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function detectCollision(host: M5Host): boolean {
|
|
77
|
+
const byHash = new Map<string, string>();
|
|
78
|
+
for (const v1 of host.v1Rows()) {
|
|
79
|
+
const row = deriveRequestHashRow(host, v1);
|
|
80
|
+
const prior = byHash.get(row.hash);
|
|
81
|
+
if (
|
|
82
|
+
prior !== undefined &&
|
|
83
|
+
prior !== identity(v1.profileId, v1.requestDigest)
|
|
84
|
+
) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
byHash.set(row.hash, identity(v1.profileId, v1.requestDigest));
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isGenerationInvalidated(
|
|
93
|
+
host: M5Host,
|
|
94
|
+
row: RequestHashV2Row,
|
|
95
|
+
): boolean {
|
|
96
|
+
const session = host.sessionOf(row.profileId);
|
|
97
|
+
const live = host.liveGenerationOf(session);
|
|
98
|
+
const keyForLive = invalidationKey(session, live);
|
|
99
|
+
const keyForRow = invalidationKey(
|
|
100
|
+
session,
|
|
101
|
+
generationFromEconomics(row.economicsVersion),
|
|
102
|
+
);
|
|
103
|
+
return keyForLive !== keyForRow;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function m5Copy(host: M5Host): {
|
|
107
|
+
readonly written: readonly RequestHashV2Row[];
|
|
108
|
+
readonly all: readonly RequestHashV2Row[];
|
|
109
|
+
} {
|
|
110
|
+
const existing = new Map(
|
|
111
|
+
host.existingV2().map((r) => [identity(r.profileId, r.requestDigest), r]),
|
|
112
|
+
);
|
|
113
|
+
const wanted: RequestHashV2Row[] = [];
|
|
114
|
+
for (const v1 of host.v1Rows()) {
|
|
115
|
+
const fresh = deriveRequestHashRow(host, v1);
|
|
116
|
+
if (isGenerationInvalidated(host, fresh)) continue;
|
|
117
|
+
const stored = existing.get(identity(v1.profileId, v1.requestDigest));
|
|
118
|
+
if (
|
|
119
|
+
stored !== undefined &&
|
|
120
|
+
stored.hash === fresh.hash &&
|
|
121
|
+
stored.economicsVersion === fresh.economicsVersion
|
|
122
|
+
) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
wanted.push(fresh);
|
|
126
|
+
}
|
|
127
|
+
if (wanted.length > 0) host.putV2(wanted);
|
|
128
|
+
return { written: wanted, all: [...host.existingV2()] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function dedupe(codes: readonly M5MigrationCode[]): M5MigrationCode[] {
|
|
132
|
+
const out: M5MigrationCode[] = [];
|
|
133
|
+
for (const c of codes) if (!out.includes(c)) out.push(c);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function m5Verify(host: M5Host): M5ValidateResult {
|
|
138
|
+
const codes: M5MigrationCode[] = [];
|
|
139
|
+
const stored = host.existingV2();
|
|
140
|
+
const v1Rows = host.v1Rows();
|
|
141
|
+
|
|
142
|
+
const counts = new Map<string, number>();
|
|
143
|
+
for (const r of stored) {
|
|
144
|
+
const id = identity(r.profileId, r.requestDigest);
|
|
145
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const expected = new Set<string>();
|
|
149
|
+
for (const v1 of v1Rows) {
|
|
150
|
+
const id = identity(v1.profileId, v1.requestDigest);
|
|
151
|
+
expected.add(id);
|
|
152
|
+
const n = counts.get(id) ?? 0;
|
|
153
|
+
if (n === 0) codes.push(M5_FAIL.COPY_PARTIAL);
|
|
154
|
+
if (n > 1) codes.push(M5_FAIL.COUNT_MISMATCH);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const v1Digests = new Set(
|
|
158
|
+
v1Rows.map((r) => identity(r.profileId, r.requestDigest)),
|
|
159
|
+
);
|
|
160
|
+
for (const r of stored) {
|
|
161
|
+
const fresh = deriveRequestHashV2(
|
|
162
|
+
r.profileId,
|
|
163
|
+
r.requestDigest,
|
|
164
|
+
r.economicsVersion,
|
|
165
|
+
);
|
|
166
|
+
if (r.hash !== fresh) codes.push(M5_FAIL.DIGEST_MISMATCH);
|
|
167
|
+
const id = identity(r.profileId, r.requestDigest);
|
|
168
|
+
if (!v1Digests.has(id)) {
|
|
169
|
+
codes.push(
|
|
170
|
+
expected.size > 0 ? M5_FAIL.IDENTITY_DRIFT : M5_FAIL.COUNT_MISMATCH,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (detectCollision(host)) codes.push(M5_FAIL.REQUEST_HASH_COLLISION);
|
|
176
|
+
|
|
177
|
+
return { ok: codes.length === 0, codes: dedupe(codes) };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function m5Switch(host: M5Host): M5ValidateResult {
|
|
181
|
+
if (host.activeVersion() !== REQUEST_HASH_LEGACY_VERSION) {
|
|
182
|
+
return { ok: false, codes: [M5_FAIL.NOT_ON_LEGACY] };
|
|
183
|
+
}
|
|
184
|
+
const verify = m5Verify(host);
|
|
185
|
+
if (!verify.ok) return verify;
|
|
186
|
+
if (detectCollision(host))
|
|
187
|
+
return { ok: false, codes: [M5_FAIL.REQUEST_HASH_COLLISION] };
|
|
188
|
+
host.switchToV2();
|
|
189
|
+
return { ok: true, codes: [] };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function migrateRequestHashV2(host: M5Host): M5ValidateResult {
|
|
193
|
+
m5Copy(host);
|
|
194
|
+
return m5Switch(host);
|
|
195
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* request-hash-v2-types.ts — M5 request-hash-v2 migration types.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from request-hash-v2.ts to keep the parent file under the 300-line
|
|
5
|
+
* soft limit (soft-as-hard gate). Pure type + constant definitions; no logic.
|
|
6
|
+
* PREVENT-002/011/PI-004 honored.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The active-version value the v2 pointer is switched to. */
|
|
10
|
+
export const REQUEST_HASH_V2_VERSION = 2;
|
|
11
|
+
/** The legacy (predecessor) active version. */
|
|
12
|
+
export const REQUEST_HASH_LEGACY_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
/** M5 failure codes. */
|
|
15
|
+
export const M5_FAIL = {
|
|
16
|
+
COPY_PARTIAL: "M5_COPY_PARTIAL",
|
|
17
|
+
COUNT_MISMATCH: "M5_COUNT_MISMATCH",
|
|
18
|
+
DIGEST_MISMATCH: "M5_DIGEST_MISMATCH",
|
|
19
|
+
IDENTITY_DRIFT: "M5_IDENTITY_DRIFT",
|
|
20
|
+
/** Two distinct v1 rows map to the same v2 hash — blocks the switch. (VC7C) */
|
|
21
|
+
REQUEST_HASH_COLLISION: "M5_REQUEST_HASH_COLLISION",
|
|
22
|
+
/** The active pointer is not on v1; switching would be a no-op or a regression. */
|
|
23
|
+
NOT_ON_LEGACY: "M5_NOT_ON_LEGACY",
|
|
24
|
+
} as const;
|
|
25
|
+
export type M5MigrationCode = (typeof M5_FAIL)[keyof typeof M5_FAIL];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Registered M5 conformance IDs (M5-001..020). The acceptance test reads these
|
|
29
|
+
* rows from the v2 `migrations/` domain and asserts each returns its manifest
|
|
30
|
+
* bytes or exactly its listed failure code. Mirrors M6_IDS / M4_IDS.
|
|
31
|
+
*/
|
|
32
|
+
export const M5_IDS: readonly string[] = Array.from(
|
|
33
|
+
{ length: 20 },
|
|
34
|
+
(_v, i) => `M5-${String(i + 1).padStart(3, "0")}`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** Named M5 rows surfaced by the conformance corpus (mirrors M6_NAMED_IDS). */
|
|
38
|
+
export const M5_NAMED_IDS = ["M5-COLLIDE-002"] as const;
|
|
39
|
+
|
|
40
|
+
/** A predecessor (v1) request-hash row. */
|
|
41
|
+
export interface RequestHashV1Row {
|
|
42
|
+
readonly profileId: string;
|
|
43
|
+
/** BARE lowercase hex canonical request digest (VC5B convention). */
|
|
44
|
+
readonly requestDigest: string;
|
|
45
|
+
/** The v1 cache-identity hash derived from the request. */
|
|
46
|
+
readonly hash: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A v2 request-hash row. Identity is `(profileId, requestDigest)` — unchanged
|
|
51
|
+
* from v1, which is the point: only `hash` is re-derived, now folding in the
|
|
52
|
+
* economics version.
|
|
53
|
+
*/
|
|
54
|
+
export interface RequestHashV2Row {
|
|
55
|
+
readonly profileId: string;
|
|
56
|
+
readonly requestDigest: string;
|
|
57
|
+
/** Economics version folded into the v2 hash (absent in v1). */
|
|
58
|
+
readonly economicsVersion: string;
|
|
59
|
+
/** The v2 cache-identity hash. */
|
|
60
|
+
readonly hash: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The host the migration reads from / writes to (capability-shaped). */
|
|
64
|
+
export interface M5Host {
|
|
65
|
+
/** Every predecessor row being migrated. */
|
|
66
|
+
readonly v1Rows: () => readonly RequestHashV1Row[];
|
|
67
|
+
/** The economics version to fold in, per profile. */
|
|
68
|
+
readonly economicsVersionOf: (profileId: string) => string;
|
|
69
|
+
/** The session a profile belongs to, for M6 generation invalidation lookup. */
|
|
70
|
+
readonly sessionOf: (profileId: string) => string;
|
|
71
|
+
/** The currently live M6 generation for a session (for invalidation checks). */
|
|
72
|
+
readonly liveGenerationOf: (session: string) => bigint;
|
|
73
|
+
/** Persisted v2 rows already written (for resume). */
|
|
74
|
+
readonly existingV2: () => readonly RequestHashV2Row[];
|
|
75
|
+
/** Idempotent write of v2 rows (never mutates v1 rows). */
|
|
76
|
+
readonly putV2: (rows: readonly RequestHashV2Row[]) => void;
|
|
77
|
+
/** The currently ACTIVE version (1 = legacy; only v1 → v2 switching). */
|
|
78
|
+
readonly activeVersion: () => number;
|
|
79
|
+
/** Atomically flip the active pointer to v2. Mirrors M6Host.switchToV2. */
|
|
80
|
+
readonly switchToV2: () => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** An M5 validation result. */
|
|
84
|
+
export interface M5ValidateResult {
|
|
85
|
+
readonly ok: boolean;
|
|
86
|
+
readonly codes: readonly M5MigrationCode[];
|
|
87
|
+
}
|