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,80 @@
1
+ /**
2
+ * vector-cortex/migrations/request-hash-v2.ts — M5 request-hash-v2 migration
3
+ * (COPY + VALIDATE + SWITCH).
4
+ *
5
+ * Delegate-shell: types + constants live in request-hash-v2-types.ts and the
6
+ * operational logic (copy/verify/switch/collision) lives in
7
+ * request-hash-v2-ops.ts. This file re-exports the public surface so callers
8
+ * import from a single module. Split to keep under the 300-line soft limit
9
+ * (soft-as-hard gate).
10
+ *
11
+ * M5 versions the canonical request hash: the predecessor hashed the outbound
12
+ * request under the v1 scheme; v2 folds in the provider profile's ECONOMICS
13
+ * version so a pricing/TTL/exclusion change cannot silently reuse a cache
14
+ * identity minted under different economics. Like M4/M6 it follows the
15
+ * copy/validate/switch contract:
16
+ *
17
+ * - copy: resumable per (profile, request) — an interrupted run resumes
18
+ * without duplicate rows or active-pointer drift.
19
+ * - validate: every v1 row has exactly one v2 row, every v2 digest re-hashes
20
+ * from its own declared fields, the migration is IDENTITY-PRESERVING
21
+ * (a v2 row carries the same `requestDigest` as its v1 source — v2
22
+ * changes how a CACHE KEY is derived, never what the request IS), and
23
+ * — the M5-specific invariant ADDED in VC7C — there are ZERO
24
+ * collisions: no two distinct v1 rows may map to one v2 hash. A
25
+ * collision (`M5_REQUEST_HASH_COLLISION`) means two different
26
+ * conversations would share a cache key, the most dangerous outcome
27
+ * in the subsystem, so it blocks the switch outright.
28
+ * - switch: ATOMICALLY flip the active pointer to v2 via `host.switchToV2()`.
29
+ * VC7B deferred this; VC7C performs it — but ONLY after re-validating
30
+ * against freshly-read host state at switch time.
31
+ *
32
+ * WHY THE COLLISION CHECK RUNS AT SWITCH TIME, NOT VALIDATE TIME. The brief's
33
+ * failure-injection contract is explicit: crash after M5 validation, inject a
34
+ * collision into host state, then resume — and the RESUMED run must detect
35
+ * `M5_REQUEST_HASH_COLLISION`. If the collision were detected only from the result
36
+ * of an earlier `m5Verify` call, the cached result would be replayed and the
37
+ * injected collision would be invisible. So `m5Switch` RE-READS the host (`v1Rows`,
38
+ * `existingV2`, `activeVersion`) and RE-RUNS the collision check against that live
39
+ * state. Validation is a precondition; the switch is the only place that proves the
40
+ * hazard is absent *right now*. This is the same resume-after-crash discipline as
41
+ * M4/M6: a migration that trusts a stale verification is a migration that loses
42
+ * data on restart.
43
+ *
44
+ * M6 INVALIDATION CONSUMPTION. A v2 request hash is only as trustworthy as the
45
+ * generation it was minted under. The switch consumes M6's structured invalidation
46
+ * keys via the REAL API (`invalidationKey` from `../topology/query.js`) rather than
47
+ * inventing one: when a v2 row's economics version maps to an invalidated router
48
+ * generation, that row cannot be promoted to active. We do not re-derive a
49
+ * generation here — topology is the authority on what generation is live — we only
50
+ * refuse to switch a row whose generation is dead. This keeps M5 from resurrecting
51
+ * cache identities tied to a generation the router has already invalidated.
52
+ *
53
+ * PREVENT-002/011/PI-004 honored.
54
+ */
55
+
56
+ export {
57
+ REQUEST_HASH_V2_VERSION,
58
+ REQUEST_HASH_LEGACY_VERSION,
59
+ M5_FAIL,
60
+ M5_IDS,
61
+ M5_NAMED_IDS,
62
+ } from "./request-hash-v2-types.js";
63
+ export type {
64
+ M5MigrationCode,
65
+ RequestHashV1Row,
66
+ RequestHashV2Row,
67
+ M5Host,
68
+ M5ValidateResult,
69
+ } from "./request-hash-v2-types.js";
70
+
71
+ export {
72
+ deriveRequestHashV2,
73
+ deriveRequestHashRow,
74
+ detectCollision,
75
+ isGenerationInvalidated,
76
+ m5Copy,
77
+ m5Verify,
78
+ m5Switch,
79
+ migrateRequestHashV2,
80
+ } from "./request-hash-v2-ops.js";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * vector-cortex/provider/economics-ids.ts — VC7B conformance ID registrations.
3
+ *
4
+ * Extracted from economics.ts to keep that file under the 300-line soft limit
5
+ * (soft-as-hard gate). The ID ranges and named rows are pure data — splitting
6
+ * them out mirrors how vector-cortex-breakers.ts was extracted from
7
+ * vector-cortex.ts. economics.ts re-exports them so no consumer import path
8
+ * changes.
9
+ *
10
+ * Pi-agnostic, dependency-free (PREVENT-PI-004 / PREVENT-011).
11
+ */
12
+
13
+ /**
14
+ * Registered VC7B cache-economics conformance ID range (CACHE-001..015). The
15
+ * acceptance aggregator reads these rows from the v2 manifest and asserts each
16
+ * returns its manifest `ok`/`code`.
17
+ */
18
+ export const CACHE_IDS: readonly string[] = Array.from(
19
+ { length: 15 },
20
+ (_v, i) => `CACHE-${String(i + 1).padStart(3, "0")}`,
21
+ );
22
+
23
+ /**
24
+ * Registered VC7B provider-economics conformance rows (PRO-024..030), continuing
25
+ * VC7A's PRO-016..023. These pin the ECONOMICS half of a provider profile:
26
+ * pricing validity, the exclusion-proof rule, and TTL/min-prefix eligibility.
27
+ */
28
+ export const ECONOMICS_PROVIDER_IDS: readonly string[] = Array.from(
29
+ { length: 7 },
30
+ (_v, i) => `PRO-${String(i + 24).padStart(3, "0")}`,
31
+ );
32
+
33
+ /** Named VC7B conformance assertions (the sprint's headline rows). */
34
+ export const ECONOMICS_NAMED_IDS = [
35
+ "CACHE-COST-001",
36
+ "CACHE-EXCLUDE-002",
37
+ "CACHE-RANDOM-003",
38
+ ] as const;
@@ -0,0 +1,289 @@
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
+
50
+ import type { ProviderProfileExclusion, ProviderProfileV1 } from "./types.js";
51
+
52
+ /**
53
+ * Cache economics attached to a provider profile.
54
+ *
55
+ * Prices are integer MICRO-UNITS PER TOKEN (1e-6 currency units), so a provider
56
+ * charging $3.00 per million input tokens has `basePrice: 3`. Integers keep the
57
+ * money path exact; see the file header for why floats are refused.
58
+ */
59
+ export interface ProviderEconomicsV1 {
60
+ readonly schema: "provider-economics-v1";
61
+ /** The `ProviderProfileV1.id` these economics belong to. */
62
+ readonly profileId: string;
63
+ /** Profile version — economics are versioned WITH the profile they price. */
64
+ readonly profileVersion: string;
65
+ /** Uncached price per token, integer micro-units. The savings baseline. */
66
+ readonly basePrice: number;
67
+ /** Cache-READ price per token, integer micro-units. Normally < basePrice. */
68
+ readonly readPrice: number;
69
+ /** Cache-WRITE price per token, integer micro-units. Normally > basePrice. */
70
+ readonly writePrice: number;
71
+ /** Cache entry lifetime in ms. A prefix older than this cannot be read back. */
72
+ readonly ttlMs: number;
73
+ /** Minimum cacheable prefix in tokens; a shorter prefix is never cached. */
74
+ readonly minPrefix: number;
75
+ /**
76
+ * Conformance fixture ID proving this profile's exclusion set is safe, or
77
+ * `null` when the profile declares NO exclusions (nothing to prove). A profile
78
+ * WITH exclusions and a null/blank id is rejected — see `validateEconomics`.
79
+ */
80
+ readonly exclusionFixtureId: string | null;
81
+ }
82
+
83
+ /** Observed (or shadow) cache traffic for one economics computation. */
84
+ export interface CacheUsageV1 {
85
+ /** Tokens in the cached prefix. Must be a non-negative safe integer. */
86
+ readonly cachedTokens: number;
87
+ /** Number of cache WRITES (each pays the write premium). */
88
+ readonly writeCount: number;
89
+ /** Number of cache HITS (each pays the discounted read price). */
90
+ readonly hitCount: number;
91
+ }
92
+
93
+ /**
94
+ * Whether a figure may be used as causal evidence.
95
+ *
96
+ * `measured` — from a RANDOMIZED live assignment; admissible in causal
97
+ * intervals.
98
+ * `estimate` — shadow, projected, or non-randomized; reportable but NEVER
99
+ * admissible in a causal interval.
100
+ */
101
+ export type EconomicsEvidence = "measured" | "estimate";
102
+
103
+ /** VC7B economics failure codes. */
104
+ export type EconomicsFailureCode =
105
+ /** A profile declares exclusions but names no proving fixture. */
106
+ | "ECON_EXCLUSION_UNPROVEN"
107
+ /** A price / TTL / prefix is negative, fractional, or not finite. */
108
+ | "ECON_PRICE_INVALID"
109
+ /** A usage count is negative, fractional, or not finite. */
110
+ | "ECON_USAGE_INVALID"
111
+ /** The computed result overflowed the exact-integer safe range. */
112
+ | "ECON_OVERFLOW";
113
+
114
+ /** The computed economics of a cache decision. All money in micro-units. */
115
+ export interface EconomicsResultV1 {
116
+ readonly profileId: string;
117
+ /** What the same traffic would have cost with no cache at all. */
118
+ readonly baselineCost: number;
119
+ /** What it actually cost: write premium on writes, discount on hits. */
120
+ readonly actualCost: number;
121
+ /** `baselineCost - actualCost`. NEGATIVE means the cache lost money. */
122
+ readonly netSavings: number;
123
+ /** Tokens billed at the discounted read price (the cache's token benefit). */
124
+ readonly tokenSavings: number;
125
+ /** `netSavings / baselineCost`, or 0 when the baseline is 0. Always finite. */
126
+ readonly savingsRatio: number;
127
+ /** Hits needed to break even on one write, or null when never profitable. */
128
+ readonly breakEvenHits: number | null;
129
+ /** Whether this figure may enter a causal interval. */
130
+ readonly evidence: EconomicsEvidence;
131
+ }
132
+
133
+ /** The verdict of an economics computation. */
134
+ export type EconomicsResult =
135
+ | { readonly ok: true; readonly result: EconomicsResultV1 }
136
+ | { readonly ok: false; readonly codes: readonly EconomicsFailureCode[] };
137
+
138
+ /** A non-negative safe integer — the only shape money and counts may take. */
139
+ function isCount(n: number): boolean {
140
+ return Number.isSafeInteger(n) && n >= 0;
141
+ }
142
+
143
+ /**
144
+ * Validate a profile's economics, enforcing the exclusion-proof rule.
145
+ *
146
+ * An exclusion claims "this field cannot affect provider cache identity". That
147
+ * claim is only as good as the fixture that proves it, so a profile carrying
148
+ * exclusions MUST name a `exclusionFixtureId`, and every individual exclusion
149
+ * must carry its own `fixtureId` too (VC5B already models that field; VC7B makes
150
+ * a blank one fatal instead of decorative). Returns deduplicated codes in a
151
+ * deterministic order.
152
+ */
153
+ export function validateEconomics(
154
+ econ: ProviderEconomicsV1,
155
+ exclusions: readonly ProviderProfileExclusion[],
156
+ ): readonly EconomicsFailureCode[] {
157
+ const codes = new Set<EconomicsFailureCode>();
158
+
159
+ for (const n of [econ.basePrice, econ.readPrice, econ.writePrice, econ.ttlMs, econ.minPrefix]) {
160
+ if (!isCount(n)) codes.add("ECON_PRICE_INVALID");
161
+ }
162
+
163
+ // The headline rule: exclusions without a proving fixture are never trusted.
164
+ if (exclusions.length > 0) {
165
+ const id = econ.exclusionFixtureId;
166
+ if (id === null || id.trim() === "") codes.add("ECON_EXCLUSION_UNPROVEN");
167
+ for (const ex of exclusions) {
168
+ if (ex.fixtureId.trim() === "") codes.add("ECON_EXCLUSION_UNPROVEN");
169
+ }
170
+ }
171
+
172
+ const order: EconomicsFailureCode[] = ["ECON_EXCLUSION_UNPROVEN", "ECON_PRICE_INVALID"];
173
+ return order.filter((c) => codes.has(c));
174
+ }
175
+
176
+ /**
177
+ * Validate a profile + its economics together. Convenience over
178
+ * `validateEconomics` for callers holding a whole `ProviderProfileV1`: the
179
+ * exclusion list is read from the profile itself, so the two can never disagree.
180
+ */
181
+ export function validateProfileEconomics(
182
+ profile: ProviderProfileV1,
183
+ econ: ProviderEconomicsV1,
184
+ ): readonly EconomicsFailureCode[] {
185
+ return validateEconomics(econ, profile.excludedJsonPointers);
186
+ }
187
+
188
+ /**
189
+ * Hits required for ONE write to break even.
190
+ *
191
+ * One write costs `(writePrice - basePrice)` extra per token; each subsequent
192
+ * hit saves `(basePrice - readPrice)` per token. So break-even is the ceiling of
193
+ * premium/discount. Returns `null` when the cache can NEVER pay for itself
194
+ * (a read that costs at least as much as an uncached token) — reporting a huge
195
+ * number there would imply "just get more hits", which is false.
196
+ * Returns 0 when writing is already free or cheaper than not caching.
197
+ */
198
+ export function breakEvenHits(econ: ProviderEconomicsV1): number | null {
199
+ const premium = econ.writePrice - econ.basePrice;
200
+ const discount = econ.basePrice - econ.readPrice;
201
+ if (premium <= 0) return 0;
202
+ if (discount <= 0) return null;
203
+ return Math.ceil(premium / discount);
204
+ }
205
+
206
+ /**
207
+ * Compute net cache savings for observed (or shadow) usage.
208
+ *
209
+ * Pure integer arithmetic in micro-units. The only float is `savingsRatio`, and
210
+ * it is zero-guarded so a zero baseline yields 0 rather than NaN/Infinity — the
211
+ * sprint invariant is that every reported aggregate is FINITE.
212
+ *
213
+ * `evidence` is supplied by the caller and simply carried through: this function
214
+ * cannot know whether its inputs came from a randomized arm, and guessing would
215
+ * be exactly the mislabeling the causal rules forbid.
216
+ */
217
+ export function computeEconomics(
218
+ econ: ProviderEconomicsV1,
219
+ usage: CacheUsageV1,
220
+ evidence: EconomicsEvidence,
221
+ ): EconomicsResult {
222
+ const codes: EconomicsFailureCode[] = [];
223
+ for (const n of [econ.basePrice, econ.readPrice, econ.writePrice, econ.ttlMs, econ.minPrefix]) {
224
+ if (!isCount(n)) {
225
+ codes.push("ECON_PRICE_INVALID");
226
+ break;
227
+ }
228
+ }
229
+ for (const n of [usage.cachedTokens, usage.writeCount, usage.hitCount]) {
230
+ if (!isCount(n)) {
231
+ codes.push("ECON_USAGE_INVALID");
232
+ break;
233
+ }
234
+ }
235
+ if (codes.length > 0) return { ok: false, codes };
236
+
237
+ const { cachedTokens, writeCount, hitCount } = usage;
238
+ const baselineCost = cachedTokens * econ.basePrice * (writeCount + hitCount);
239
+ const actualCost = cachedTokens * (econ.writePrice * writeCount + econ.readPrice * hitCount);
240
+
241
+ // Exactness is the whole point of the integer model: if any product leaves the
242
+ // safe-integer range the result is silently wrong, so we fail instead.
243
+ if (!Number.isSafeInteger(baselineCost) || !Number.isSafeInteger(actualCost)) {
244
+ return { ok: false, codes: ["ECON_OVERFLOW"] };
245
+ }
246
+
247
+ const netSavings = baselineCost - actualCost;
248
+ // Tokens served at the discounted read price — the cache's token-level benefit,
249
+ // independent of price (a hit re-reads the whole prefix).
250
+ const tokenSavings = cachedTokens * hitCount;
251
+ const savingsRatio = baselineCost === 0 ? 0 : netSavings / baselineCost;
252
+
253
+ return {
254
+ ok: true,
255
+ result: {
256
+ profileId: econ.profileId,
257
+ baselineCost,
258
+ actualCost,
259
+ netSavings,
260
+ tokenSavings,
261
+ savingsRatio,
262
+ breakEvenHits: breakEvenHits(econ),
263
+ evidence,
264
+ },
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Whether a prefix is eligible to be cached at all: it must meet the profile's
270
+ * minimum prefix, and it must still be within TTL. A prefix below `minPrefix` is
271
+ * not "a small win", it is not cacheable by the provider at all.
272
+ */
273
+ export function isCacheEligible(
274
+ econ: ProviderEconomicsV1,
275
+ prefixTokens: number,
276
+ ageMs: number,
277
+ ): boolean {
278
+ if (!isCount(prefixTokens) || !isCount(ageMs)) return false;
279
+ return prefixTokens >= econ.minPrefix && ageMs < econ.ttlMs;
280
+ }
281
+
282
+ // Conformance ID ranges + named rows extracted to economics-ids.ts to keep this
283
+ // file under the 300-line soft limit (soft-as-hard gate). Re-exported here so no
284
+ // consumer import path changes.
285
+ export {
286
+ CACHE_IDS,
287
+ ECONOMICS_PROVIDER_IDS,
288
+ ECONOMICS_NAMED_IDS,
289
+ } from "./economics-ids.js";
@@ -0,0 +1,248 @@
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
+
41
+ import { createHash } from "node:crypto";
42
+
43
+ /**
44
+ * Experiment arms.
45
+ *
46
+ * A — control: the predecessor path, no VC7B crystal compiler.
47
+ * B — treatment: provider-safe compiled crystal boundaries.
48
+ * C — holdout: cache bypassed entirely, the uncached baseline that prices what
49
+ * the cache is being compared AGAINST.
50
+ */
51
+ export type ExperimentArm = "A" | "B" | "C";
52
+
53
+ /** The registered arms, in canonical bucket order. */
54
+ export const EXPERIMENT_ARMS: readonly ExperimentArm[] = ["A", "B", "C"] as const;
55
+
56
+ /** Bucket space: 10k buckets = 0.01% resolution (matches VC5C rollout). */
57
+ export const EXPERIMENT_BUCKETS = 10_000;
58
+
59
+ /**
60
+ * How a session came to be in its arm. Only `randomized` is causal evidence.
61
+ *
62
+ * `randomized` — derived from the stable hash over every eligible session.
63
+ * `forced` — an operator/test override; self-selected, never causal.
64
+ * `shadow` — computed for comparison but not served; never causal.
65
+ */
66
+ export type AssignmentSource = "randomized" | "forced" | "shadow";
67
+
68
+ /** A session's experiment assignment (VC7B contract type). */
69
+ export interface CacheExperimentV1 {
70
+ readonly schema: "cache-experiment-v1";
71
+ /** Names the experiment; folded into the hash so arms differ per experiment. */
72
+ readonly experimentId: string;
73
+ /** The session this assignment belongs to. */
74
+ readonly sessionId: string;
75
+ /** The assigned arm. */
76
+ readonly arm: ExperimentArm;
77
+ /** The session's stable bucket in `[0, EXPERIMENT_BUCKETS)`. */
78
+ readonly bucket: number;
79
+ /** How the arm was chosen — gates causal admissibility. */
80
+ readonly source: AssignmentSource;
81
+ /** INJECTED assignment timestamp (epoch ms). Never read from a clock here. */
82
+ readonly assignedAt: number;
83
+ }
84
+
85
+ /** VC7B experiment failure codes. */
86
+ export type ExperimentFailureCode =
87
+ /** A blank experiment id or session id — an unnamed experiment is unanalyzable. */
88
+ | "EXP_ID_INVALID"
89
+ /** An arm split that does not sum to the full bucket space. */
90
+ | "EXP_SPLIT_INVALID"
91
+ /** A forced arm that is not one of the registered arms. */
92
+ | "EXP_ARM_UNKNOWN";
93
+
94
+ /** The verdict of an assignment. */
95
+ export type ExperimentResult =
96
+ | { readonly ok: true; readonly assignment: CacheExperimentV1 }
97
+ | { readonly ok: false; readonly codes: readonly ExperimentFailureCode[] };
98
+
99
+ /**
100
+ * Traffic split across arms, in buckets. Must sum to exactly
101
+ * `EXPERIMENT_BUCKETS`: a split that sums to less would leave sessions
102
+ * unassigned, and one that sums to more would make the last arm unreachable —
103
+ * both silently bias the comparison, so both are rejected.
104
+ */
105
+ export interface ExperimentSplit {
106
+ readonly A: number;
107
+ readonly B: number;
108
+ readonly C: number;
109
+ }
110
+
111
+ /** Even three-way split (the default): 3334 / 3333 / 3333 = 10000. */
112
+ export const EVEN_SPLIT: ExperimentSplit = { A: 3334, B: 3333, C: 3333 };
113
+
114
+ /**
115
+ * Length-prefixed field framing, identical in spirit to the crystal key encoder:
116
+ * `<byteLength>:<value>` makes the concatenation injective, so experiment
117
+ * `"a"` + session `"b:c"` cannot collide with experiment `"a:b"` + session `"c"`.
118
+ * Without it, two different (experiment, session) pairs could share a bucket and
119
+ * a session could appear to switch arms when an experiment is renamed.
120
+ */
121
+ function field(value: string): string {
122
+ return `${Buffer.byteLength(value, "utf8")}:${value}`;
123
+ }
124
+
125
+ /**
126
+ * The stable bucket for a session: the low 32 bits of
127
+ * `sha256(experimentId, sessionId)`, modulo the bucket space.
128
+ *
129
+ * Deterministic and storage-free — this is what makes a lost assignment journal
130
+ * a non-event. Reading 4 bytes (rather than the whole digest) keeps the value in
131
+ * exact integer range; SHA-256's avalanche means any 32 bits are equidistributed.
132
+ */
133
+ export function experimentBucket(experimentId: string, sessionId: string): number {
134
+ const h = createHash("sha256")
135
+ .update(field(experimentId) + field(sessionId), "utf8")
136
+ .digest();
137
+ return h.readUInt32BE(0) % EXPERIMENT_BUCKETS;
138
+ }
139
+
140
+ /** Validate that a split covers the bucket space exactly once. */
141
+ export function validateSplit(split: ExperimentSplit): boolean {
142
+ for (const n of [split.A, split.B, split.C]) {
143
+ if (!Number.isSafeInteger(n) || n < 0) return false;
144
+ }
145
+ return split.A + split.B + split.C === EXPERIMENT_BUCKETS;
146
+ }
147
+
148
+ /**
149
+ * Map a bucket to an arm under a split. Arms occupy contiguous bucket ranges in
150
+ * the fixed order A, B, C, so the mapping is stable: widening arm C's share can
151
+ * never reshuffle a session already sitting in arm A.
152
+ */
153
+ export function armForBucket(bucket: number, split: ExperimentSplit): ExperimentArm {
154
+ if (bucket < split.A) return "A";
155
+ if (bucket < split.A + split.B) return "B";
156
+ return "C";
157
+ }
158
+
159
+ /**
160
+ * Assign a session to an experiment arm.
161
+ *
162
+ * The default path is `randomized`: bucket = stable hash, arm = bucket's range.
163
+ * Passing `forced` overrides the arm and downgrades the source (and therefore
164
+ * causal admissibility) — the bucket is still computed and reported so the
165
+ * override remains auditable against what the session WOULD have got.
166
+ *
167
+ * `assignedAt` is injected by the caller. Assignment is a pure function of its
168
+ * arguments, so the same session always yields the same arm no matter when, or
169
+ * how many times, it is asked.
170
+ */
171
+ export function assignExperiment(input: {
172
+ readonly experimentId: string;
173
+ readonly sessionId: string;
174
+ readonly assignedAt: number;
175
+ readonly split?: ExperimentSplit;
176
+ readonly forced?: ExperimentArm;
177
+ readonly shadow?: boolean;
178
+ }): ExperimentResult {
179
+ const codes: ExperimentFailureCode[] = [];
180
+ if (input.experimentId.trim() === "" || input.sessionId.trim() === "") {
181
+ codes.push("EXP_ID_INVALID");
182
+ }
183
+ const split = input.split ?? EVEN_SPLIT;
184
+ if (!validateSplit(split)) codes.push("EXP_SPLIT_INVALID");
185
+ if (input.forced !== undefined && !EXPERIMENT_ARMS.includes(input.forced)) {
186
+ codes.push("EXP_ARM_UNKNOWN");
187
+ }
188
+ if (codes.length > 0) return { ok: false, codes };
189
+
190
+ const bucket = experimentBucket(input.experimentId, input.sessionId);
191
+ const natural = armForBucket(bucket, split);
192
+ const arm = input.forced ?? natural;
193
+ // Precedence: a forced arm is self-selected, so it is never causal even when
194
+ // it happens to agree with the natural arm. Shadow is likewise non-causal.
195
+ const source: AssignmentSource =
196
+ input.forced !== undefined ? "forced" : input.shadow === true ? "shadow" : "randomized";
197
+
198
+ return {
199
+ ok: true,
200
+ assignment: {
201
+ schema: "cache-experiment-v1",
202
+ experimentId: input.experimentId,
203
+ sessionId: input.sessionId,
204
+ arm,
205
+ bucket,
206
+ source,
207
+ assignedAt: input.assignedAt,
208
+ },
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Whether an assignment's telemetry may enter a causal interval.
214
+ *
215
+ * ONLY `randomized`. This single predicate is what keeps forced and shadow rows
216
+ * out of the causal aggregate; every consumer computing an interval must filter
217
+ * through it rather than re-deriving the rule.
218
+ */
219
+ export function isCausallyAdmissible(a: CacheExperimentV1): boolean {
220
+ return a.source === "randomized";
221
+ }
222
+
223
+ /**
224
+ * Keep only causally admissible assignments. The complement is not discarded by
225
+ * the caller — it is reported as `estimate` — but it never reaches an interval.
226
+ */
227
+ export function causalOnly(
228
+ assignments: readonly CacheExperimentV1[],
229
+ ): readonly CacheExperimentV1[] {
230
+ return assignments.filter(isCausallyAdmissible);
231
+ }
232
+
233
+ /**
234
+ * Whether every assignment for one session agrees on the arm (CACHE-RANDOM-003).
235
+ * A session that appears in two arms would contaminate both sides of the
236
+ * comparison, so this is asserted directly rather than assumed.
237
+ */
238
+ export function sessionArmsConsistent(
239
+ assignments: readonly CacheExperimentV1[],
240
+ ): boolean {
241
+ const bySession = new Map<string, ExperimentArm>();
242
+ for (const a of assignments) {
243
+ const seen = bySession.get(a.sessionId);
244
+ if (seen !== undefined && seen !== a.arm) return false;
245
+ bySession.set(a.sessionId, a.arm);
246
+ }
247
+ return true;
248
+ }