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.
Files changed (103) hide show
  1. package/dist/config/vector-cortex-early.js +89 -0
  2. package/dist/config/vector-cortex-flag.js +25 -0
  3. package/dist/config/vector-cortex.js +43 -83
  4. package/dist/config.js +1 -1
  5. package/dist/extensions/dashboard-server/api-contracts/vector-cortex-economics.js +20 -0
  6. package/dist/extensions/dashboard-server/route-dispatch.js +7 -0
  7. package/dist/extensions/dashboard-server/routes-rag-settings-vector-cortex.js +2 -0
  8. package/dist/extensions/dashboard-server/routes-vector-cortex-economics.js +59 -0
  9. package/dist/src/config/vector-cortex-early.js +89 -0
  10. package/dist/src/config/vector-cortex-flag.js +25 -0
  11. package/dist/src/config/vector-cortex.js +43 -83
  12. package/dist/src/config.js +1 -1
  13. package/dist/src/vector-cortex/cache/_economics-fixture.js +41 -0
  14. package/dist/src/vector-cortex/cache/compiler.js +201 -0
  15. package/dist/src/vector-cortex/cache/economics-emit.js +85 -0
  16. package/dist/src/vector-cortex/migrations/request-hash-v2-ops.js +143 -0
  17. package/dist/src/vector-cortex/migrations/request-hash-v2-types.js +30 -0
  18. package/dist/src/vector-cortex/migrations/request-hash-v2.js +56 -0
  19. package/dist/src/vector-cortex/provider/economics-ids.js +29 -0
  20. package/dist/src/vector-cortex/provider/economics.js +176 -0
  21. package/dist/src/vector-cortex/provider/experiments.js +166 -0
  22. package/dist/vector-cortex/cache/_economics-fixture.js +41 -0
  23. package/dist/vector-cortex/cache/compiler.js +201 -0
  24. package/dist/vector-cortex/cache/economics-emit.js +85 -0
  25. package/dist/vector-cortex/migrations/request-hash-v2-ops.js +143 -0
  26. package/dist/vector-cortex/migrations/request-hash-v2-types.js +30 -0
  27. package/dist/vector-cortex/migrations/request-hash-v2.js +56 -0
  28. package/dist/vector-cortex/provider/economics-ids.js +29 -0
  29. package/dist/vector-cortex/provider/economics.js +176 -0
  30. package/dist/vector-cortex/provider/experiments.js +166 -0
  31. package/extensions/dashboard-client/dist/assets/{AreaChart-CB-U7ViX.js → AreaChart-CV8WqMmt.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{AreaChart-CB-U7ViX.js.map → AreaChart-CV8WqMmt.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{BarChart-DnLw0fxA.js → BarChart-M-HEBq9l.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{BarChart-DnLw0fxA.js.map → BarChart-M-HEBq9l.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{CacheTab-CjyTVDiP.js → CacheTab-DJ_yPAhP.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{CacheTab-CjyTVDiP.js.map → CacheTab-DJ_yPAhP.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{EventsTab-Bz3QEWam.js → EventsTab-DNVlKzt6.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{EventsTab-Bz3QEWam.js.map → EventsTab-DNVlKzt6.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{HealthTab-CUylEvEY.js → HealthTab-B5yjuInb.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/{HealthTab-CUylEvEY.js.map → HealthTab-B5yjuInb.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-Cz_hj_E7.js → MaintenanceTab-DoQedESF.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-Cz_hj_E7.js.map → MaintenanceTab-DoQedESF.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-Dh8brIR1.js → MemoryMapTab-CuL5CzdH.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-Dh8brIR1.js.map → MemoryMapTab-CuL5CzdH.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{MetricsTab-CkqSXc6e.js → MetricsTab-CZSTaCpK.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{MetricsTab-CkqSXc6e.js.map → MetricsTab-CZSTaCpK.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/{OverviewTab-DGbJcx6d.js → OverviewTab-vkZlXRW6.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{OverviewTab-DGbJcx6d.js.map → OverviewTab-vkZlXRW6.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/{ReposTab-BawS3nfF.js → ReposTab-DgPD96rF.js} +2 -2
  50. package/extensions/dashboard-client/dist/assets/{ReposTab-BawS3nfF.js.map → ReposTab-DgPD96rF.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/{SessionsTab-CUVnmYks.js → SessionsTab-XPbWh7XN.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{SessionsTab-CUVnmYks.js.map → SessionsTab-XPbWh7XN.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{SetupTab-cxTuyZNk.js → SetupTab-Dz74XAtR.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{SetupTab-cxTuyZNk.js.map → SetupTab-Dz74XAtR.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Cv5SENkE.js → TimeSavedCard-FynCpJdx.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Cv5SENkE.js.map → TimeSavedCard-FynCpJdx.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/assets/{TurnsTab-yl6BBG_F.js → TurnsTab-C7m3GDy0.js} +2 -2
  58. package/extensions/dashboard-client/dist/assets/{TurnsTab-yl6BBG_F.js.map → TurnsTab-C7m3GDy0.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/VectorCortexTab-jRd_Iztk.js +2 -0
  60. package/extensions/dashboard-client/dist/assets/VectorCortexTab-jRd_Iztk.js.map +1 -0
  61. package/extensions/dashboard-client/dist/assets/{WikiTab-DKv-0xCQ.js → WikiTab-C6viLa0u.js} +2 -2
  62. package/extensions/dashboard-client/dist/assets/{WikiTab-DKv-0xCQ.js.map → WikiTab-C6viLa0u.js.map} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{button-CVPrO4UU.js → button-mBEgbBVU.js} +2 -2
  64. package/extensions/dashboard-client/dist/assets/{button-CVPrO4UU.js.map → button-mBEgbBVU.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/{card-BFLnQJEo.js → card-CalUQAKG.js} +2 -2
  66. package/extensions/dashboard-client/dist/assets/{card-BFLnQJEo.js.map → card-CalUQAKG.js.map} +1 -1
  67. package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-B-IUoLd1.js → generateCategoricalChart-CMmkuwj5.js} +2 -2
  68. package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-B-IUoLd1.js.map → generateCategoricalChart-CMmkuwj5.js.map} +1 -1
  69. package/extensions/dashboard-client/dist/assets/{index-Do749WlW.js → index-Dz8GPBOE.js} +3 -3
  70. package/extensions/dashboard-client/dist/assets/{index-Do749WlW.js.map → index-Dz8GPBOE.js.map} +1 -1
  71. package/extensions/dashboard-client/dist/assets/{switch-3uFnZmtq.js → switch-8QKO6CTU.js} +2 -2
  72. package/extensions/dashboard-client/dist/assets/{switch-3uFnZmtq.js.map → switch-8QKO6CTU.js.map} +1 -1
  73. package/extensions/dashboard-client/dist/assets/{toggle-C1rYLeXe.js → toggle-9eCkWI4q.js} +2 -2
  74. package/extensions/dashboard-client/dist/assets/{toggle-C1rYLeXe.js.map → toggle-9eCkWI4q.js.map} +1 -1
  75. package/extensions/dashboard-client/dist/assets/{useSSE-D1qzLzgR.js → useSSE-s6IqOKoD.js} +2 -2
  76. package/extensions/dashboard-client/dist/assets/{useSSE-D1qzLzgR.js.map → useSSE-s6IqOKoD.js.map} +1 -1
  77. package/extensions/dashboard-client/dist/index.html +1 -1
  78. package/extensions/dashboard-client/src/api/vector-cortex.ts +18 -0
  79. package/extensions/dashboard-client/src/tabs/VectorCortexEconomicsCard.tsx +41 -0
  80. package/extensions/dashboard-client/src/tabs/VectorCortexTab.tsx +24 -114
  81. package/extensions/dashboard-client/src/tabs/useVectorCortexPoll.ts +123 -0
  82. package/extensions/dashboard-client/src/types/vector-cortex-vc7.ts +63 -0
  83. package/extensions/dashboard-client/src/types/vector-cortex.ts +5 -19
  84. package/extensions/dashboard-server/api-contracts/vector-cortex-economics.ts +47 -0
  85. package/extensions/dashboard-server/route-dispatch.ts +6 -0
  86. package/extensions/dashboard-server/routes-rag-settings-vector-cortex.ts +12 -0
  87. package/extensions/dashboard-server/routes-vector-cortex-economics.ts +68 -0
  88. package/package.json +1 -1
  89. package/src/config/vector-cortex-early.ts +99 -0
  90. package/src/config/vector-cortex-flag.ts +24 -0
  91. package/src/config/vector-cortex.ts +55 -89
  92. package/src/config.ts +2 -0
  93. package/src/vector-cortex/cache/_economics-fixture.ts +128 -0
  94. package/src/vector-cortex/cache/compiler.ts +280 -0
  95. package/src/vector-cortex/cache/economics-emit.ts +116 -0
  96. package/src/vector-cortex/migrations/request-hash-v2-ops.ts +195 -0
  97. package/src/vector-cortex/migrations/request-hash-v2-types.ts +87 -0
  98. package/src/vector-cortex/migrations/request-hash-v2.ts +80 -0
  99. package/src/vector-cortex/provider/economics-ids.ts +38 -0
  100. package/src/vector-cortex/provider/economics.ts +289 -0
  101. package/src/vector-cortex/provider/experiments.ts +248 -0
  102. package/extensions/dashboard-client/dist/assets/VectorCortexTab-94fqVjat.js +0 -2
  103. package/extensions/dashboard-client/dist/assets/VectorCortexTab-94fqVjat.js.map +0 -1
@@ -0,0 +1,176 @@
1
+ /**
2
+ * vector-cortex/provider/economics.ts — VC7B cache economics (ProviderProfileV1
3
+ * extension).
4
+ *
5
+ * VC7A proved a render can be FROZEN and reused. VC7B answers the question that
6
+ * immediately follows: is reusing it actually WORTH IT? Provider prompt caching
7
+ * is not free — a cache WRITE typically costs MORE than an uncached token, and a
8
+ * cache READ costs less. So a cache is profitable only when a written prefix is
9
+ * read back enough times, before its TTL expires, to repay the write premium.
10
+ * This file is that arithmetic and nothing else.
11
+ *
12
+ * THE NET-SAVINGS MODEL. For a prefix of `cachedTokens` tokens:
13
+ *
14
+ * baseline (no cache) = cachedTokens * basePrice * (writes + hits)
15
+ * actual (cached) = cachedTokens * (writePrice * writes + readPrice * hits)
16
+ * netSavings = baseline - actual
17
+ *
18
+ * A NEGATIVE `netSavings` is a real, expected, and important outcome: it means
19
+ * the cache LOST money (a prefix written once and never re-read always does, by
20
+ * exactly the write premium). We report it rather than clamping it at zero,
21
+ * because a floor at zero would make every rollout look free and would hide the
22
+ * single failure mode this sprint exists to detect.
23
+ *
24
+ * EXACT ARITHMETIC, NO FLOATS IN THE MONEY PATH. Prices are integer
25
+ * MICRO-UNITS per token (1e-6 of the provider's currency unit), never floating
26
+ * point. `0.1 + 0.2 !== 0.3` is not an acceptable property for a cost ledger, and
27
+ * accumulating float error across millions of tokens produces aggregates that
28
+ * silently disagree with the provider's own bill. All money is integer; only the
29
+ * derived RATIO is a float, and it is finite-guarded.
30
+ *
31
+ * EVERY EXCLUSION NEEDS A FIXTURE (the sprint's headline safety rule). A provider
32
+ * profile may declare that some request field does not affect cache identity —
33
+ * but an UNPROVEN exclusion is how you get silent cache poisoning: the renderer
34
+ * folds a field the provider actually keys on, two different requests collapse to
35
+ * one cache entry, and one conversation is served another's prefix. So an
36
+ * exclusion without a `fixtureId` (or with a blank one) is REJECTED here —
37
+ * `ECON_EXCLUSION_UNPROVEN` — rather than trusted. There is no override.
38
+ *
39
+ * ESTIMATE VS MEASURED. Every result is labeled. Only telemetry from a
40
+ * RANDOMIZED live assignment may be called `measured` and fed into a causal
41
+ * interval; shadow or non-randomized numbers are `estimate` and are excluded
42
+ * from causal aggregates (see `experiments.ts`). Mixing the two would let a
43
+ * self-selected population masquerade as a controlled experiment.
44
+ *
45
+ * PURE. No clock, no storage, no console, no network (PREVENT-PI-004 /
46
+ * PREVENT-011). Runs identically with `MEGACOMPACT_VC7B` on or off — the flag
47
+ * gates only the reporter/dashboard seam in `../cache/economics-emit.ts`.
48
+ */
49
+ /** A non-negative safe integer — the only shape money and counts may take. */
50
+ function isCount(n) {
51
+ return Number.isSafeInteger(n) && n >= 0;
52
+ }
53
+ /**
54
+ * Validate a profile's economics, enforcing the exclusion-proof rule.
55
+ *
56
+ * An exclusion claims "this field cannot affect provider cache identity". That
57
+ * claim is only as good as the fixture that proves it, so a profile carrying
58
+ * exclusions MUST name a `exclusionFixtureId`, and every individual exclusion
59
+ * must carry its own `fixtureId` too (VC5B already models that field; VC7B makes
60
+ * a blank one fatal instead of decorative). Returns deduplicated codes in a
61
+ * deterministic order.
62
+ */
63
+ export function validateEconomics(econ, exclusions) {
64
+ const codes = new Set();
65
+ for (const n of [econ.basePrice, econ.readPrice, econ.writePrice, econ.ttlMs, econ.minPrefix]) {
66
+ if (!isCount(n))
67
+ codes.add("ECON_PRICE_INVALID");
68
+ }
69
+ // The headline rule: exclusions without a proving fixture are never trusted.
70
+ if (exclusions.length > 0) {
71
+ const id = econ.exclusionFixtureId;
72
+ if (id === null || id.trim() === "")
73
+ codes.add("ECON_EXCLUSION_UNPROVEN");
74
+ for (const ex of exclusions) {
75
+ if (ex.fixtureId.trim() === "")
76
+ codes.add("ECON_EXCLUSION_UNPROVEN");
77
+ }
78
+ }
79
+ const order = ["ECON_EXCLUSION_UNPROVEN", "ECON_PRICE_INVALID"];
80
+ return order.filter((c) => codes.has(c));
81
+ }
82
+ /**
83
+ * Validate a profile + its economics together. Convenience over
84
+ * `validateEconomics` for callers holding a whole `ProviderProfileV1`: the
85
+ * exclusion list is read from the profile itself, so the two can never disagree.
86
+ */
87
+ export function validateProfileEconomics(profile, econ) {
88
+ return validateEconomics(econ, profile.excludedJsonPointers);
89
+ }
90
+ /**
91
+ * Hits required for ONE write to break even.
92
+ *
93
+ * One write costs `(writePrice - basePrice)` extra per token; each subsequent
94
+ * hit saves `(basePrice - readPrice)` per token. So break-even is the ceiling of
95
+ * premium/discount. Returns `null` when the cache can NEVER pay for itself
96
+ * (a read that costs at least as much as an uncached token) — reporting a huge
97
+ * number there would imply "just get more hits", which is false.
98
+ * Returns 0 when writing is already free or cheaper than not caching.
99
+ */
100
+ export function breakEvenHits(econ) {
101
+ const premium = econ.writePrice - econ.basePrice;
102
+ const discount = econ.basePrice - econ.readPrice;
103
+ if (premium <= 0)
104
+ return 0;
105
+ if (discount <= 0)
106
+ return null;
107
+ return Math.ceil(premium / discount);
108
+ }
109
+ /**
110
+ * Compute net cache savings for observed (or shadow) usage.
111
+ *
112
+ * Pure integer arithmetic in micro-units. The only float is `savingsRatio`, and
113
+ * it is zero-guarded so a zero baseline yields 0 rather than NaN/Infinity — the
114
+ * sprint invariant is that every reported aggregate is FINITE.
115
+ *
116
+ * `evidence` is supplied by the caller and simply carried through: this function
117
+ * cannot know whether its inputs came from a randomized arm, and guessing would
118
+ * be exactly the mislabeling the causal rules forbid.
119
+ */
120
+ export function computeEconomics(econ, usage, evidence) {
121
+ const codes = [];
122
+ for (const n of [econ.basePrice, econ.readPrice, econ.writePrice, econ.ttlMs, econ.minPrefix]) {
123
+ if (!isCount(n)) {
124
+ codes.push("ECON_PRICE_INVALID");
125
+ break;
126
+ }
127
+ }
128
+ for (const n of [usage.cachedTokens, usage.writeCount, usage.hitCount]) {
129
+ if (!isCount(n)) {
130
+ codes.push("ECON_USAGE_INVALID");
131
+ break;
132
+ }
133
+ }
134
+ if (codes.length > 0)
135
+ return { ok: false, codes };
136
+ const { cachedTokens, writeCount, hitCount } = usage;
137
+ const baselineCost = cachedTokens * econ.basePrice * (writeCount + hitCount);
138
+ const actualCost = cachedTokens * (econ.writePrice * writeCount + econ.readPrice * hitCount);
139
+ // Exactness is the whole point of the integer model: if any product leaves the
140
+ // safe-integer range the result is silently wrong, so we fail instead.
141
+ if (!Number.isSafeInteger(baselineCost) || !Number.isSafeInteger(actualCost)) {
142
+ return { ok: false, codes: ["ECON_OVERFLOW"] };
143
+ }
144
+ const netSavings = baselineCost - actualCost;
145
+ // Tokens served at the discounted read price — the cache's token-level benefit,
146
+ // independent of price (a hit re-reads the whole prefix).
147
+ const tokenSavings = cachedTokens * hitCount;
148
+ const savingsRatio = baselineCost === 0 ? 0 : netSavings / baselineCost;
149
+ return {
150
+ ok: true,
151
+ result: {
152
+ profileId: econ.profileId,
153
+ baselineCost,
154
+ actualCost,
155
+ netSavings,
156
+ tokenSavings,
157
+ savingsRatio,
158
+ breakEvenHits: breakEvenHits(econ),
159
+ evidence,
160
+ },
161
+ };
162
+ }
163
+ /**
164
+ * Whether a prefix is eligible to be cached at all: it must meet the profile's
165
+ * minimum prefix, and it must still be within TTL. A prefix below `minPrefix` is
166
+ * not "a small win", it is not cacheable by the provider at all.
167
+ */
168
+ export function isCacheEligible(econ, prefixTokens, ageMs) {
169
+ if (!isCount(prefixTokens) || !isCount(ageMs))
170
+ return false;
171
+ return prefixTokens >= econ.minPrefix && ageMs < econ.ttlMs;
172
+ }
173
+ // Conformance ID ranges + named rows extracted to economics-ids.ts to keep this
174
+ // file under the 300-line soft limit (soft-as-hard gate). Re-exported here so no
175
+ // consumer import path changes.
176
+ export { CACHE_IDS, ECONOMICS_PROVIDER_IDS, ECONOMICS_NAMED_IDS, } from "./economics-ids.js";
@@ -0,0 +1,166 @@
1
+ /**
2
+ * vector-cortex/provider/experiments.ts — VC7B session-level cache experiments.
3
+ *
4
+ * To claim a cache strategy CAUSED a saving you need a randomized comparison,
5
+ * not a before/after. This file assigns each session to an experiment arm and
6
+ * guarantees the two properties that make the resulting telemetry admissible as
7
+ * causal evidence.
8
+ *
9
+ * 1. STABLE, JOURNAL-FREE ASSIGNMENT. The arm is a pure function of
10
+ * `sha256(experimentId, sessionId)` — NOT a random draw recorded in a
11
+ * journal. So the assignment survives anything: process restart, a lost or
12
+ * corrupted journal, a different host, a replay months later. This is the
13
+ * sprint's unique failure-injection case (lose the journal after the first
14
+ * event, restart, and the same arm must come back), and a hash-derived arm
15
+ * passes it by construction rather than by careful recovery code. A journal
16
+ * can still be kept as an audit trail, but nothing DEPENDS on it.
17
+ *
18
+ * The corollary matters just as much: every event in one session shares one
19
+ * arm (CACHE-RANDOM-003). If a session could drift between arms mid-flight,
20
+ * its rows would appear in both arms of the comparison and the contrast would
21
+ * be measuring nothing.
22
+ *
23
+ * 2. RANDOMIZED ⇒ CAUSAL; EVERYTHING ELSE ⇒ ESTIMATE. Hashing a session id is
24
+ * randomization only when the id itself is unpredictable and every session is
25
+ * eligible. A FORCED arm (operator override), a SHADOW arm (computed but not
26
+ * served), and a non-randomized rollout are all labeled `estimate` and are
27
+ * excluded from causal intervals by `isCausallyAdmissible`. Shadow numbers are
28
+ * genuinely useful — they are just not evidence of an effect.
29
+ *
30
+ * WHY 10_000 BUCKETS. Bucket granularity bounds how finely traffic can be split;
31
+ * 10k gives 0.01% resolution, matching VC5C's rollout bucketing so the two
32
+ * subsystems partition traffic on the same scale rather than on quietly
33
+ * different ones.
34
+ *
35
+ * PURE. No clock, no storage, no console, no network (PREVENT-PI-004 /
36
+ * PREVENT-011). `assignedAt` is an INJECTED timestamp, never `Date.now()` read
37
+ * here, so assignment is fully reproducible in tests and replays. Runs
38
+ * identically with `MEGACOMPACT_VC7B` on or off.
39
+ */
40
+ import { createHash } from "node:crypto";
41
+ /** The registered arms, in canonical bucket order. */
42
+ export const EXPERIMENT_ARMS = ["A", "B", "C"];
43
+ /** Bucket space: 10k buckets = 0.01% resolution (matches VC5C rollout). */
44
+ export const EXPERIMENT_BUCKETS = 10_000;
45
+ /** Even three-way split (the default): 3334 / 3333 / 3333 = 10000. */
46
+ export const EVEN_SPLIT = { A: 3334, B: 3333, C: 3333 };
47
+ /**
48
+ * Length-prefixed field framing, identical in spirit to the crystal key encoder:
49
+ * `<byteLength>:<value>` makes the concatenation injective, so experiment
50
+ * `"a"` + session `"b:c"` cannot collide with experiment `"a:b"` + session `"c"`.
51
+ * Without it, two different (experiment, session) pairs could share a bucket and
52
+ * a session could appear to switch arms when an experiment is renamed.
53
+ */
54
+ function field(value) {
55
+ return `${Buffer.byteLength(value, "utf8")}:${value}`;
56
+ }
57
+ /**
58
+ * The stable bucket for a session: the low 32 bits of
59
+ * `sha256(experimentId, sessionId)`, modulo the bucket space.
60
+ *
61
+ * Deterministic and storage-free — this is what makes a lost assignment journal
62
+ * a non-event. Reading 4 bytes (rather than the whole digest) keeps the value in
63
+ * exact integer range; SHA-256's avalanche means any 32 bits are equidistributed.
64
+ */
65
+ export function experimentBucket(experimentId, sessionId) {
66
+ const h = createHash("sha256")
67
+ .update(field(experimentId) + field(sessionId), "utf8")
68
+ .digest();
69
+ return h.readUInt32BE(0) % EXPERIMENT_BUCKETS;
70
+ }
71
+ /** Validate that a split covers the bucket space exactly once. */
72
+ export function validateSplit(split) {
73
+ for (const n of [split.A, split.B, split.C]) {
74
+ if (!Number.isSafeInteger(n) || n < 0)
75
+ return false;
76
+ }
77
+ return split.A + split.B + split.C === EXPERIMENT_BUCKETS;
78
+ }
79
+ /**
80
+ * Map a bucket to an arm under a split. Arms occupy contiguous bucket ranges in
81
+ * the fixed order A, B, C, so the mapping is stable: widening arm C's share can
82
+ * never reshuffle a session already sitting in arm A.
83
+ */
84
+ export function armForBucket(bucket, split) {
85
+ if (bucket < split.A)
86
+ return "A";
87
+ if (bucket < split.A + split.B)
88
+ return "B";
89
+ return "C";
90
+ }
91
+ /**
92
+ * Assign a session to an experiment arm.
93
+ *
94
+ * The default path is `randomized`: bucket = stable hash, arm = bucket's range.
95
+ * Passing `forced` overrides the arm and downgrades the source (and therefore
96
+ * causal admissibility) — the bucket is still computed and reported so the
97
+ * override remains auditable against what the session WOULD have got.
98
+ *
99
+ * `assignedAt` is injected by the caller. Assignment is a pure function of its
100
+ * arguments, so the same session always yields the same arm no matter when, or
101
+ * how many times, it is asked.
102
+ */
103
+ export function assignExperiment(input) {
104
+ const codes = [];
105
+ if (input.experimentId.trim() === "" || input.sessionId.trim() === "") {
106
+ codes.push("EXP_ID_INVALID");
107
+ }
108
+ const split = input.split ?? EVEN_SPLIT;
109
+ if (!validateSplit(split))
110
+ codes.push("EXP_SPLIT_INVALID");
111
+ if (input.forced !== undefined && !EXPERIMENT_ARMS.includes(input.forced)) {
112
+ codes.push("EXP_ARM_UNKNOWN");
113
+ }
114
+ if (codes.length > 0)
115
+ return { ok: false, codes };
116
+ const bucket = experimentBucket(input.experimentId, input.sessionId);
117
+ const natural = armForBucket(bucket, split);
118
+ const arm = input.forced ?? natural;
119
+ // Precedence: a forced arm is self-selected, so it is never causal even when
120
+ // it happens to agree with the natural arm. Shadow is likewise non-causal.
121
+ const source = input.forced !== undefined ? "forced" : input.shadow === true ? "shadow" : "randomized";
122
+ return {
123
+ ok: true,
124
+ assignment: {
125
+ schema: "cache-experiment-v1",
126
+ experimentId: input.experimentId,
127
+ sessionId: input.sessionId,
128
+ arm,
129
+ bucket,
130
+ source,
131
+ assignedAt: input.assignedAt,
132
+ },
133
+ };
134
+ }
135
+ /**
136
+ * Whether an assignment's telemetry may enter a causal interval.
137
+ *
138
+ * ONLY `randomized`. This single predicate is what keeps forced and shadow rows
139
+ * out of the causal aggregate; every consumer computing an interval must filter
140
+ * through it rather than re-deriving the rule.
141
+ */
142
+ export function isCausallyAdmissible(a) {
143
+ return a.source === "randomized";
144
+ }
145
+ /**
146
+ * Keep only causally admissible assignments. The complement is not discarded by
147
+ * the caller — it is reported as `estimate` — but it never reaches an interval.
148
+ */
149
+ export function causalOnly(assignments) {
150
+ return assignments.filter(isCausallyAdmissible);
151
+ }
152
+ /**
153
+ * Whether every assignment for one session agrees on the arm (CACHE-RANDOM-003).
154
+ * A session that appears in two arms would contaminate both sides of the
155
+ * comparison, so this is asserted directly rather than assumed.
156
+ */
157
+ export function sessionArmsConsistent(assignments) {
158
+ const bySession = new Map();
159
+ for (const a of assignments) {
160
+ const seen = bySession.get(a.sessionId);
161
+ if (seen !== undefined && seen !== a.arm)
162
+ return false;
163
+ bySession.set(a.sessionId, a.arm);
164
+ }
165
+ return true;
166
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * cache/_economics-fixture.ts — conformance fixture I/O for VC7B economics rows.
3
+ *
4
+ * Sibling of `./_crystal-fixture.ts`, same job for a different corpus: turn
5
+ * canonical JSON back into the REAL production types the economics / compiler /
6
+ * experiments modules consume. Fixtures cannot express bigints, so token counts
7
+ * stored as numbers are converted here — if that conversion were lossy the
8
+ * computed digests would diverge and the acceptance rows would fail loudly.
9
+ *
10
+ * No mocks, no stubs, no parallel "test shape": the decoded objects ARE
11
+ * `ProviderEconomicsV1` / `CacheUsageV1` / `CacheExperimentV1` and are fed
12
+ * verbatim into `validateProfileEconomics`, `computeEconomics`, `compileCrystalBoundaries`,
13
+ * and `assignExperiment`.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import assert from "node:assert/strict";
18
+ import { V2, readManifest } from "../heal/_acceptance-fixture.js";
19
+ /** Read one registered cache-economics fixture (asserting it IS registered). */
20
+ export function economicsFixture(id) {
21
+ const m = readManifest();
22
+ const row = m.fixtures.find((f) => f.id === id && f.path.startsWith("cache-economics/"));
23
+ assert.ok(row, `fixture ${id} registered under cache-economics/ in manifest`);
24
+ return JSON.parse(readFileSync(join(V2, row.path), "utf8"));
25
+ }
26
+ /** Flag-pinned wrapper: VC7B gated by MEGACOMPACT_VC7B (defaults ON). */
27
+ export function withVc7bFlag(value, fn) {
28
+ return () => {
29
+ const saved = process.env.MEGACOMPACT_VC7B;
30
+ process.env.MEGACOMPACT_VC7B = value;
31
+ try {
32
+ fn();
33
+ }
34
+ finally {
35
+ if (saved === undefined)
36
+ delete process.env.MEGACOMPACT_VC7B;
37
+ else
38
+ process.env.MEGACOMPACT_VC7B = saved;
39
+ }
40
+ };
41
+ }
@@ -0,0 +1,201 @@
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
+ import { createHash } from "node:crypto";
41
+ import { compareSpans, sortSpans, validateRanges } from "./crystal.js";
42
+ /** Conventional default limits (4 bytes/token is the usual English estimate). */
43
+ export const DEFAULT_COMPILER_LIMITS = {
44
+ minPrefix: 1024,
45
+ maxSegments: 64,
46
+ bytesPerToken: 4,
47
+ };
48
+ /** Covered bytes of one range. */
49
+ function spanBytes(s) {
50
+ return s.endByte - s.startByte;
51
+ }
52
+ /**
53
+ * Byte→token estimate. Deliberately a floor: under-estimating tokens can only
54
+ * make the compiler MORE conservative about calling a segment cacheable, which
55
+ * fails safe (a wrongly-cacheable segment would be priced as a hit it never got).
56
+ */
57
+ export function tokensForBytes(bytes, bytesPerToken) {
58
+ return Math.floor(bytes / bytesPerToken);
59
+ }
60
+ /** `sha256:` digest over a segment's ranges — length-prefixed, so injective. */
61
+ function boundaryDigest(ranges) {
62
+ const h = createHash("sha256");
63
+ for (const s of ranges) {
64
+ const parts = [
65
+ s.sessionId,
66
+ s.startSeq.toString(),
67
+ s.endSeq.toString(),
68
+ String(s.startByte),
69
+ String(s.endByte),
70
+ s.digest,
71
+ ];
72
+ for (const p of parts)
73
+ h.update(`${Buffer.byteLength(p, "utf8")}:${p}`, "utf8");
74
+ }
75
+ return `sha256:${h.digest("hex")}`;
76
+ }
77
+ /** Build one boundary from a contiguous same-session run of ranges. */
78
+ function makeBoundary(ranges, limits) {
79
+ const byteCount = ranges.reduce((n, s) => n + spanBytes(s), 0);
80
+ const tokenCount = tokensForBytes(byteCount, limits.bytesPerToken);
81
+ const first = ranges[0];
82
+ return {
83
+ schema: "crystal-boundary-v1",
84
+ sessionId: first === undefined ? "" : first.sessionId,
85
+ ranges,
86
+ byteCount,
87
+ tokenCount,
88
+ cacheable: tokenCount >= limits.minPrefix,
89
+ digest: boundaryDigest(ranges),
90
+ };
91
+ }
92
+ function validLimits(l) {
93
+ return (Number.isSafeInteger(l.minPrefix) &&
94
+ l.minPrefix >= 0 &&
95
+ Number.isSafeInteger(l.maxSegments) &&
96
+ l.maxSegments > 0 &&
97
+ Number.isSafeInteger(l.bytesPerToken) &&
98
+ l.bytesPerToken >= 1);
99
+ }
100
+ /**
101
+ * Compile validated ranges into provider-safe cache boundaries.
102
+ *
103
+ * Ranges are first put in canonical order (the SAME `sortSpans` order the VC7A
104
+ * key encoder uses — sharing the comparator is what keeps the two subsystems
105
+ * from disagreeing about what "canonical" means). They are then grouped by
106
+ * session, and within each session an undersized run is merged FORWARD until it
107
+ * meets `minPrefix`.
108
+ *
109
+ * Identity is verified before returning: the flattened output must equal the
110
+ * canonical input exactly. That check should never fire — it is a guard against
111
+ * a future edit to the merge loop quietly reordering or dropping a range.
112
+ */
113
+ export function compileCrystalBoundaries(ranges, limits = DEFAULT_COMPILER_LIMITS) {
114
+ if (!validLimits(limits))
115
+ return { ok: false, codes: ["COMP_LIMIT_INVALID"] };
116
+ // Reuse VC7A's validator verbatim: the compiler must never accept a range set
117
+ // the crystal key would reject, or the two would disagree about what is legal.
118
+ const rangeCodes = validateRanges(ranges);
119
+ if (rangeCodes.length > 0)
120
+ return { ok: false, codes: ["COMP_RANGE_INVALID"] };
121
+ const sorted = sortSpans(ranges);
122
+ const boundaries = [];
123
+ let run = [];
124
+ const flushRun = () => {
125
+ if (run.length > 0) {
126
+ boundaries.push(makeBoundary(run, limits));
127
+ run = [];
128
+ }
129
+ };
130
+ for (const span of sorted) {
131
+ const head = run[0];
132
+ // A session change always closes the run: a boundary never spans sessions,
133
+ // because two sessions' byte streams are not contiguous with each other.
134
+ if (head !== undefined && head.sessionId !== span.sessionId)
135
+ flushRun();
136
+ run.push(span);
137
+ const bytes = run.reduce((n, s) => n + spanBytes(s), 0);
138
+ // Close as soon as the run is independently cacheable; anything smaller keeps
139
+ // absorbing the next range (merge-forward).
140
+ if (tokensForBytes(bytes, limits.bytesPerToken) >= limits.minPrefix)
141
+ flushRun();
142
+ }
143
+ // A trailing undersized run has nothing left to merge into: emit it honestly
144
+ // as a non-cacheable boundary rather than padding or dropping it.
145
+ flushRun();
146
+ if (boundaries.length > limits.maxSegments) {
147
+ return { ok: false, codes: ["COMP_SEGMENT_LIMIT"] };
148
+ }
149
+ if (!boundariesPreserveIdentity(sorted, boundaries)) {
150
+ return { ok: false, codes: ["COMP_IDENTITY_DRIFT"] };
151
+ }
152
+ let cacheableCount = 0;
153
+ let cacheableTokens = 0;
154
+ for (const b of boundaries) {
155
+ if (b.cacheable) {
156
+ cacheableCount += 1;
157
+ cacheableTokens += b.tokenCount;
158
+ }
159
+ }
160
+ return { ok: true, boundaries, cacheableCount, cacheableTokens };
161
+ }
162
+ /**
163
+ * The executable form of the sprint's headline invariant: flattening the
164
+ * compiled boundaries must reproduce the canonical input ranges EXACTLY — same
165
+ * ranges, same order, same pinned digests.
166
+ *
167
+ * Comparison is field-by-field on identity (session, seq bounds, byte bounds,
168
+ * digest), never by count or by digest alone: a compiler bug that swapped two
169
+ * equal-length ranges would keep the count and the byte total identical while
170
+ * changing what the request means.
171
+ */
172
+ export function boundariesPreserveIdentity(input, boundaries) {
173
+ const flat = [];
174
+ for (const b of boundaries)
175
+ flat.push(...b.ranges);
176
+ const canonical = sortSpans(input);
177
+ if (flat.length !== canonical.length)
178
+ return false;
179
+ for (let i = 0; i < flat.length; i += 1) {
180
+ const a = flat[i];
181
+ const b = canonical[i];
182
+ if (a === undefined || b === undefined)
183
+ return false;
184
+ if (compareSpans(a, b) !== 0)
185
+ return false;
186
+ // compareSpans intentionally ignores the pinned digest (it orders by source
187
+ // position). Identity includes the covered bytes, so check it explicitly.
188
+ if (a.digest !== b.digest)
189
+ return false;
190
+ }
191
+ return true;
192
+ }
193
+ /**
194
+ * Compile the ranges of an existing crystal key. Returns the boundaries plus the
195
+ * UNCHANGED key: compilation is an optimization of how the render is segmented,
196
+ * never a re-keying. Returning the same key object makes that explicit at the
197
+ * call site — a caller cannot accidentally pick up a "compiled key" that differs.
198
+ */
199
+ export function compileForKey(key, limits = DEFAULT_COMPILER_LIMITS) {
200
+ return { key, compiled: compileCrystalBoundaries(key.sourceRanges, limits) };
201
+ }
@@ -0,0 +1,85 @@
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
+ import { VC7B_ENABLED } from "../../config/vector-cortex.js";
32
+ /** Run `fn` only when an emit exists; a reporting failure is never fatal. */
33
+ function safe(emit, fn) {
34
+ if (emit === undefined)
35
+ return;
36
+ try {
37
+ fn(emit);
38
+ }
39
+ catch {
40
+ // Non-fatal: a reporting failure must never break the agent loop.
41
+ }
42
+ }
43
+ /** The event names VC7B emits, exported for the dashboard seam and tests. */
44
+ export const ECONOMICS_EVENT_NAMES = [
45
+ "vector_cortex_cache_experiment_assigned",
46
+ "vector_cortex_cache_economics_estimated",
47
+ ];
48
+ /**
49
+ * Report a session's experiment assignment.
50
+ *
51
+ * Arm + bucket + source only. The bucket is sufficient to audit that the split
52
+ * is being honored; the SESSION ID is deliberately absent so the event log
53
+ * cannot be used to reconstruct who was in which arm.
54
+ */
55
+ export function reportCacheExperimentAssigned(emit, payload) {
56
+ if (!VC7B_ENABLED())
57
+ return;
58
+ safe(emit, (e) => e("vector_cortex_cache_experiment_assigned", {
59
+ ts: undefined,
60
+ event: "vector_cortex_cache_experiment_assigned",
61
+ experimentId: payload.experimentId,
62
+ arm: payload.arm,
63
+ bucket: payload.bucket,
64
+ source: payload.source,
65
+ }));
66
+ }
67
+ /**
68
+ * Report computed cache economics.
69
+ *
70
+ * Integer micro-unit aggregates plus the evidence label. `netSavings` may be
71
+ * NEGATIVE and is reported as such — a cache that lost money is the outcome this
72
+ * telemetry exists to surface, so it is never clamped on the way out.
73
+ */
74
+ export function reportCacheEconomicsEstimated(emit, payload) {
75
+ if (!VC7B_ENABLED())
76
+ return;
77
+ safe(emit, (e) => e("vector_cortex_cache_economics_estimated", {
78
+ ts: undefined,
79
+ event: "vector_cortex_cache_economics_estimated",
80
+ profileId: payload.profileId,
81
+ netSavings: payload.netSavings,
82
+ tokenSavings: payload.tokenSavings,
83
+ evidence: payload.evidence,
84
+ }));
85
+ }