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,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
|
+
}
|