@crewhaus/canary-controller 0.4.0 → 0.5.0

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.
@@ -0,0 +1,11 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /**
3
+ * Canary/experiment configuration error. Lives in its own module so both the
4
+ * controller (`index.ts`) and the E50 experiment surface (`experiment.ts`)
5
+ * can throw the SAME class without importing each other — the public export
6
+ * remains `@crewhaus/canary-controller`'s `CanaryError`, unchanged.
7
+ */
8
+ export declare class CanaryError extends CrewhausError {
9
+ readonly name = "CanaryError";
10
+ constructor(message: string, cause?: unknown);
11
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,13 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /**
3
+ * Canary/experiment configuration error. Lives in its own module so both the
4
+ * controller (`index.ts`) and the E50 experiment surface (`experiment.ts`)
5
+ * can throw the SAME class without importing each other — the public export
6
+ * remains `@crewhaus/canary-controller`'s `CanaryError`, unchanged.
7
+ */
8
+ export class CanaryError extends CrewhausError {
9
+ name = "CanaryError";
10
+ constructor(message, cause) {
11
+ super("config", message, cause);
12
+ }
13
+ }
@@ -0,0 +1,159 @@
1
+ /** Default location of the experiment ledgers, relative to cwd. */
2
+ export declare const DEFAULT_EXPERIMENTS_DIR: string;
3
+ /** Suffix of an experiment's append-only outcome ledger. */
4
+ export declare const EXPERIMENT_LEDGER_SUFFIX = ".jsonl";
5
+ /** Suffix of an experiment's variant-assignment manifest. */
6
+ export declare const EXPERIMENT_ASSIGNMENT_SUFFIX = ".assignment.json";
7
+ export type ExperimentVariant = {
8
+ /** The spec-registry version this variant serves. */
9
+ readonly version: string;
10
+ /** Integer 1..100 — percent of the request-key space this variant owns. */
11
+ readonly weight: number;
12
+ };
13
+ export type ExperimentConfig = {
14
+ readonly name: string;
15
+ /** Two or more variants whose weights are positive integers summing to 100. */
16
+ readonly variants: ReadonlyArray<ExperimentVariant>;
17
+ /**
18
+ * Optional stable salt mixed into the hash before bucketing (a tenant id,
19
+ * or a rotation token when an operator deliberately wants a fresh split).
20
+ * Absent ⇒ the empty salt, matching `CanaryController.route()` without a
21
+ * tenant.
22
+ */
23
+ readonly salt?: string;
24
+ };
25
+ export type ExperimentSelection = {
26
+ readonly version: string;
27
+ /** Hash bucket value, 0-99 — the same space `route()` buckets into. */
28
+ readonly bucket: number;
29
+ /** 0-based index of the selected variant in `config.variants`. */
30
+ readonly index: number;
31
+ };
32
+ /**
33
+ * The hash bucket a request key lands in: `sha256(salt|requestKey)`, first
34
+ * 4 bytes as a uint32, mod 100. Shared with `CanaryController.route()` so
35
+ * the two-version canary and the N-variant experiment can never disagree
36
+ * about which side of the split a given key is on.
37
+ */
38
+ export declare function requestBucket(salt: string | undefined, requestKey: string): number;
39
+ /**
40
+ * Validate an {@link ExperimentConfig}. Weights are integers because the
41
+ * bucket space is exactly 100 wide: fractional weights would silently round,
42
+ * and a rounded split is not the split the operator declared.
43
+ */
44
+ export declare function validateExperimentConfig(config: ExperimentConfig): void;
45
+ /**
46
+ * Deterministically select the variant serving `requestKey`. Pure: the same
47
+ * (config, key) pair always yields the same version, in any process, with no
48
+ * shared state — which is what makes the assignment sticky for a user across
49
+ * requests and reproducible for an operator debugging one.
50
+ */
51
+ export declare function selectExperimentVariant(config: ExperimentConfig, requestKey: string): ExperimentSelection;
52
+ export type ExperimentAssignment = ExperimentConfig & {
53
+ /** ISO timestamp of the last write. */
54
+ readonly updatedAt: string;
55
+ /** Optional environment the variants are pinned for (informational). */
56
+ readonly env?: string;
57
+ /**
58
+ * Free-text note recording HOW the assignment is expected to be consumed.
59
+ * Written by `deploy canary --traffic-split` so a file found on disk months
60
+ * later still states its own boundary.
61
+ */
62
+ readonly note?: string;
63
+ };
64
+ /** Filesystem-safe form of an experiment name (path traversal floor). */
65
+ export declare function experimentFileName(name: string): string;
66
+ export declare function writeExperimentAssignment(assignment: ExperimentAssignment, dir?: string): string;
67
+ /**
68
+ * Retire an experiment's variant assignment. Returns true when a file was
69
+ * actually removed.
70
+ *
71
+ * A split is a TEMPORARY state: once the ramp concludes — promotion (100%
72
+ * candidate) or rollback (100% baseline) — the env pin is single-version and
73
+ * any surviving `[{v1,50},{v2,50}]` on disk would keep a compliant serving
74
+ * integration sending half its keys to a version nobody is running. There is
75
+ * no representable "100% one version" assignment ({@link
76
+ * validateExperimentConfig} requires ≥2 variants summing to 100, deliberately
77
+ * — a one-variant split is not a split), so a concluded experiment REMOVES
78
+ * the file and lets the env pin do the talking. `crewhaus experiment assign`
79
+ * then fails loudly instead of routing on a stale split.
80
+ */
81
+ export declare function removeExperimentAssignment(name: string, dir?: string): boolean;
82
+ export declare function readExperimentAssignment(name: string, dir?: string): ExperimentAssignment | undefined;
83
+ export type ExperimentOutcomeRecord = {
84
+ /** ISO timestamp of the observation. */
85
+ readonly ts: string;
86
+ readonly experiment: string;
87
+ /** The variant version this outcome is attributed to. */
88
+ readonly version: string;
89
+ readonly outcome: "success" | "failure";
90
+ /** The stable request key that selected the version, when known. */
91
+ readonly requestKey?: string;
92
+ /** A normalized 0..1 quality score (eval/judge score), when known. */
93
+ readonly score?: number;
94
+ /** A human rating on its own scale (1-5, thumbs as 0/1, …), when known. */
95
+ readonly rating?: number;
96
+ /** Where the observation came from — `eval`, `serving`, `cli`, … */
97
+ readonly source?: string;
98
+ };
99
+ export declare function appendExperimentOutcomes(records: ReadonlyArray<ExperimentOutcomeRecord>, dir?: string): number;
100
+ export declare function appendExperimentOutcome(record: ExperimentOutcomeRecord, dir?: string): void;
101
+ /**
102
+ * Read an experiment's ledger. Torn/partial lines are SKIPPED rather than
103
+ * throwing: a crashed writer must degrade the tally by one observation, not
104
+ * make the whole experiment unreadable.
105
+ */
106
+ export declare function readExperimentOutcomes(name: string, dir?: string): ReadonlyArray<ExperimentOutcomeRecord>;
107
+ /** Every experiment name with a ledger under `dir`, sorted. */
108
+ export declare function listExperiments(dir?: string): ReadonlyArray<string>;
109
+ export type VariantTally = {
110
+ readonly version: string;
111
+ /** Observations recorded for this version. */
112
+ readonly n: number;
113
+ readonly successes: number;
114
+ readonly failures: number;
115
+ /** successes / n; 0 when n is 0. */
116
+ readonly successRate: number;
117
+ /** Mean of the recorded 0..1 scores. Absent when none carried a score. */
118
+ readonly meanScore?: number;
119
+ /** How many observations carried a score. */
120
+ readonly scoredN: number;
121
+ /** Mean of the recorded human ratings. Absent when none carried a rating. */
122
+ readonly meanRating?: number;
123
+ /** How many observations carried a rating. */
124
+ readonly ratedN: number;
125
+ /**
126
+ * Observation count per `source` (`eval` / `serving` / `cli` / …; records
127
+ * without a source fold into `"unknown"`). A reader must be able to tell an
128
+ * n built from offline eval re-runs from one built from live serving
129
+ * outcomes — they are not the same evidence.
130
+ */
131
+ readonly sources: Readonly<Record<string, number>>;
132
+ };
133
+ /**
134
+ * Collapse REPEATED MEASUREMENTS of the same unit so a tally cannot mistake
135
+ * them for independent observations.
136
+ *
137
+ * The motivating case is `deploy canary --traffic-split`: an eval observation
138
+ * is keyed by DATASET SAMPLE, and a ramp (or a re-invoked ramp under the same
139
+ * experiment name) grades the same fixed sample against the same version more
140
+ * than once. Four ramp steps over an 8-sample dataset would otherwise report
141
+ * n=32 per version and shrink a Wilson half-width ~2×, naming a "winner" on
142
+ * evidence that is inconclusive at the real sample size.
143
+ *
144
+ * Scope is deliberately narrow: only records with `source: "eval"` AND a
145
+ * `requestKey` collapse, last-write-wins per (version, requestKey). Serving
146
+ * records pass through untouched — there a request key is commonly a STICKY
147
+ * user/session id, so repeats are genuinely separate requests and collapsing
148
+ * them would destroy real data.
149
+ */
150
+ export declare function dedupeExperimentOutcomes(records: ReadonlyArray<ExperimentOutcomeRecord>): {
151
+ readonly records: ReadonlyArray<ExperimentOutcomeRecord>;
152
+ /** How many records were dropped as repeat measurements. */
153
+ readonly collapsed: number;
154
+ };
155
+ /**
156
+ * Fold outcome records per version, in first-appearance order (so the first
157
+ * version written — the control/baseline in a canary — leads the table).
158
+ */
159
+ export declare function tallyExperimentOutcomes(records: ReadonlyArray<ExperimentOutcomeRecord>): ReadonlyArray<VariantTally>;
@@ -0,0 +1,359 @@
1
+ /**
2
+ * E50 — N-variant online experiments: deterministic per-request version
3
+ * selection plus per-version outcome accounting.
4
+ *
5
+ * HONEST BOUNDARY (read this before claiming "traffic splitting"). This
6
+ * module ships the two halves of an A/B experiment that CrewHaus can
7
+ * actually reach today:
8
+ *
9
+ * 1. SELECTION — {@link selectExperimentVariant} maps a stable request key
10
+ * to exactly one variant version, deterministically (sha256 of
11
+ * `salt|requestKey`, mod 100, walked over the declared weights). Same
12
+ * key ⇒ same version, forever, in every process, with no shared state.
13
+ * This is the same hash `CanaryController.route()` uses, generalized
14
+ * from two versions to N.
15
+ * 2. ACCOUNTING — {@link appendExperimentOutcome} records one outcome per
16
+ * (experiment, version) observation into an append-only JSONL, and
17
+ * {@link tallyExperimentOutcomes} folds it per version.
18
+ *
19
+ * What this module DOES NOT do: it does not intercept live requests. Nothing
20
+ * in CrewHaus's serving surfaces (gateway-server's `RunHandler`, the managed
21
+ * daemon, the channel bots) consults an assignment today — a genuine
22
+ * request-level split needs a consumer at each shape's serving boundary, and
23
+ * `target: cli` has no live request stream at all. So an operator gets the
24
+ * decision function and the ledger; wiring them into a serving boundary is
25
+ * an explicit integration, not something a flag silently turns on. Every
26
+ * user-facing string in this feature says exactly that.
27
+ *
28
+ * Storage is an append-only JSONL per experiment under
29
+ * `.crewhaus/experiments/`, mirroring the alert-watchdog's session-metrics
30
+ * history: readers tolerate torn/partial lines (a crashed writer must not
31
+ * make the ledger unreadable), and validation is hand-rolled rather than zod
32
+ * so this package stays a dependency-light leaf.
33
+ */
34
+ import { createHash } from "node:crypto";
35
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
36
+ import { join } from "node:path";
37
+ import { CanaryError } from "./errors";
38
+ /** Default location of the experiment ledgers, relative to cwd. */
39
+ export const DEFAULT_EXPERIMENTS_DIR = join(".crewhaus", "experiments");
40
+ /** Suffix of an experiment's append-only outcome ledger. */
41
+ export const EXPERIMENT_LEDGER_SUFFIX = ".jsonl";
42
+ /** Suffix of an experiment's variant-assignment manifest. */
43
+ export const EXPERIMENT_ASSIGNMENT_SUFFIX = ".assignment.json";
44
+ /**
45
+ * The hash bucket a request key lands in: `sha256(salt|requestKey)`, first
46
+ * 4 bytes as a uint32, mod 100. Shared with `CanaryController.route()` so
47
+ * the two-version canary and the N-variant experiment can never disagree
48
+ * about which side of the split a given key is on.
49
+ */
50
+ export function requestBucket(salt, requestKey) {
51
+ const seed = `${salt ?? ""}|${requestKey}`;
52
+ const hash = createHash("sha256").update(seed).digest();
53
+ const v = ((hash[0] ?? 0) << 24) | ((hash[1] ?? 0) << 16) | ((hash[2] ?? 0) << 8) | (hash[3] ?? 0);
54
+ return Math.abs(v) % 100;
55
+ }
56
+ /**
57
+ * Validate an {@link ExperimentConfig}. Weights are integers because the
58
+ * bucket space is exactly 100 wide: fractional weights would silently round,
59
+ * and a rounded split is not the split the operator declared.
60
+ */
61
+ export function validateExperimentConfig(config) {
62
+ if (config.name.trim() === "") {
63
+ throw new CanaryError("experiment name must be a non-empty string");
64
+ }
65
+ if (config.variants.length < 2) {
66
+ throw new CanaryError(`experiment "${config.name}" needs at least 2 variants; got ${config.variants.length}`);
67
+ }
68
+ const seen = new Set();
69
+ let total = 0;
70
+ for (const v of config.variants) {
71
+ if (v.version.trim() === "") {
72
+ throw new CanaryError(`experiment "${config.name}" has a variant with an empty version`);
73
+ }
74
+ if (seen.has(v.version)) {
75
+ throw new CanaryError(`experiment "${config.name}" lists version "${v.version}" more than once`);
76
+ }
77
+ seen.add(v.version);
78
+ if (!Number.isInteger(v.weight) || v.weight < 1 || v.weight > 100) {
79
+ throw new CanaryError(`experiment "${config.name}" variant "${v.version}" weight must be an integer in 1..100; got ${v.weight}`);
80
+ }
81
+ total += v.weight;
82
+ }
83
+ if (total !== 100) {
84
+ throw new CanaryError(`experiment "${config.name}" weights must sum to exactly 100; got ${total}`);
85
+ }
86
+ }
87
+ /**
88
+ * Deterministically select the variant serving `requestKey`. Pure: the same
89
+ * (config, key) pair always yields the same version, in any process, with no
90
+ * shared state — which is what makes the assignment sticky for a user across
91
+ * requests and reproducible for an operator debugging one.
92
+ */
93
+ export function selectExperimentVariant(config, requestKey) {
94
+ validateExperimentConfig(config);
95
+ const bucket = requestBucket(config.salt, requestKey);
96
+ let cursor = 0;
97
+ for (let i = 0; i < config.variants.length; i += 1) {
98
+ const variant = config.variants[i];
99
+ if (variant === undefined)
100
+ continue;
101
+ cursor += variant.weight;
102
+ if (bucket < cursor) {
103
+ return { version: variant.version, bucket, index: i };
104
+ }
105
+ }
106
+ // Unreachable while the weights sum to 100 (validated above); the last
107
+ // variant is the honest fallback rather than a throw on a hot path.
108
+ const last = config.variants[config.variants.length - 1];
109
+ return { version: last.version, bucket, index: config.variants.length - 1 };
110
+ }
111
+ /** Filesystem-safe form of an experiment name (path traversal floor). */
112
+ export function experimentFileName(name) {
113
+ const safe = name.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_");
114
+ // A name that sanitizes to punctuation only ("///", "..") would collide
115
+ // with every other such name and read as a traversal attempt — refuse it
116
+ // rather than silently writing to `___.jsonl`.
117
+ if (!/[A-Za-z0-9]/.test(safe)) {
118
+ throw new CanaryError(`experiment name "${name}" has no filesystem-safe characters`);
119
+ }
120
+ return safe;
121
+ }
122
+ export function writeExperimentAssignment(assignment, dir = DEFAULT_EXPERIMENTS_DIR) {
123
+ validateExperimentConfig(assignment);
124
+ mkdirSync(dir, { recursive: true });
125
+ const path = join(dir, `${experimentFileName(assignment.name)}${EXPERIMENT_ASSIGNMENT_SUFFIX}`);
126
+ // Small single-writer manifest — a plain write is fine here; the ledger
127
+ // beside it is the append-only surface that must survive a torn write.
128
+ writeFileSync(path, `${JSON.stringify(assignment, null, 2)}\n`, "utf-8");
129
+ return path;
130
+ }
131
+ /**
132
+ * Retire an experiment's variant assignment. Returns true when a file was
133
+ * actually removed.
134
+ *
135
+ * A split is a TEMPORARY state: once the ramp concludes — promotion (100%
136
+ * candidate) or rollback (100% baseline) — the env pin is single-version and
137
+ * any surviving `[{v1,50},{v2,50}]` on disk would keep a compliant serving
138
+ * integration sending half its keys to a version nobody is running. There is
139
+ * no representable "100% one version" assignment ({@link
140
+ * validateExperimentConfig} requires ≥2 variants summing to 100, deliberately
141
+ * — a one-variant split is not a split), so a concluded experiment REMOVES
142
+ * the file and lets the env pin do the talking. `crewhaus experiment assign`
143
+ * then fails loudly instead of routing on a stale split.
144
+ */
145
+ export function removeExperimentAssignment(name, dir = DEFAULT_EXPERIMENTS_DIR) {
146
+ const path = join(dir, `${experimentFileName(name)}${EXPERIMENT_ASSIGNMENT_SUFFIX}`);
147
+ if (!existsSync(path))
148
+ return false;
149
+ rmSync(path, { force: true });
150
+ return true;
151
+ }
152
+ export function readExperimentAssignment(name, dir = DEFAULT_EXPERIMENTS_DIR) {
153
+ const path = join(dir, `${experimentFileName(name)}${EXPERIMENT_ASSIGNMENT_SUFFIX}`);
154
+ if (!existsSync(path))
155
+ return undefined;
156
+ let parsed;
157
+ try {
158
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ if (typeof parsed !== "object" || parsed === null)
164
+ return undefined;
165
+ const rec = parsed;
166
+ const variants = rec["variants"];
167
+ if (typeof rec["name"] !== "string" || !Array.isArray(variants))
168
+ return undefined;
169
+ const cleaned = [];
170
+ for (const v of variants) {
171
+ if (typeof v !== "object" || v === null)
172
+ return undefined;
173
+ const entry = v;
174
+ if (typeof entry["version"] !== "string" || typeof entry["weight"] !== "number") {
175
+ return undefined;
176
+ }
177
+ cleaned.push({ version: entry["version"], weight: entry["weight"] });
178
+ }
179
+ return {
180
+ name: rec["name"],
181
+ variants: cleaned,
182
+ updatedAt: typeof rec["updatedAt"] === "string" ? rec["updatedAt"] : "",
183
+ ...(typeof rec["salt"] === "string" ? { salt: rec["salt"] } : {}),
184
+ ...(typeof rec["env"] === "string" ? { env: rec["env"] } : {}),
185
+ ...(typeof rec["note"] === "string" ? { note: rec["note"] } : {}),
186
+ };
187
+ }
188
+ function ledgerPath(name, dir) {
189
+ return join(dir, `${experimentFileName(name)}${EXPERIMENT_LEDGER_SUFFIX}`);
190
+ }
191
+ export function appendExperimentOutcomes(records, dir = DEFAULT_EXPERIMENTS_DIR) {
192
+ if (records.length === 0)
193
+ return 0;
194
+ mkdirSync(dir, { recursive: true });
195
+ // Group by experiment so a mixed batch still lands one append per file.
196
+ const byExperiment = new Map();
197
+ for (const rec of records) {
198
+ const lines = byExperiment.get(rec.experiment) ?? [];
199
+ lines.push(JSON.stringify(rec));
200
+ byExperiment.set(rec.experiment, lines);
201
+ }
202
+ for (const [name, lines] of byExperiment) {
203
+ appendFileSync(ledgerPath(name, dir), `${lines.join("\n")}\n`, "utf-8");
204
+ }
205
+ return records.length;
206
+ }
207
+ export function appendExperimentOutcome(record, dir = DEFAULT_EXPERIMENTS_DIR) {
208
+ appendExperimentOutcomes([record], dir);
209
+ }
210
+ /**
211
+ * Read an experiment's ledger. Torn/partial lines are SKIPPED rather than
212
+ * throwing: a crashed writer must degrade the tally by one observation, not
213
+ * make the whole experiment unreadable.
214
+ */
215
+ export function readExperimentOutcomes(name, dir = DEFAULT_EXPERIMENTS_DIR) {
216
+ const path = ledgerPath(name, dir);
217
+ if (!existsSync(path))
218
+ return [];
219
+ let text;
220
+ try {
221
+ text = readFileSync(path, "utf-8");
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ const out = [];
227
+ for (const line of text.split("\n")) {
228
+ if (line.trim() === "")
229
+ continue;
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(line);
233
+ }
234
+ catch {
235
+ continue;
236
+ }
237
+ if (typeof parsed !== "object" || parsed === null)
238
+ continue;
239
+ const rec = parsed;
240
+ const version = rec["version"];
241
+ const outcome = rec["outcome"];
242
+ if (typeof version !== "string" || (outcome !== "success" && outcome !== "failure"))
243
+ continue;
244
+ out.push({
245
+ ts: typeof rec["ts"] === "string" ? rec["ts"] : "",
246
+ experiment: typeof rec["experiment"] === "string" ? rec["experiment"] : name,
247
+ version,
248
+ outcome,
249
+ ...(typeof rec["requestKey"] === "string" ? { requestKey: rec["requestKey"] } : {}),
250
+ ...(typeof rec["score"] === "number" && Number.isFinite(rec["score"])
251
+ ? { score: rec["score"] }
252
+ : {}),
253
+ ...(typeof rec["rating"] === "number" && Number.isFinite(rec["rating"])
254
+ ? { rating: rec["rating"] }
255
+ : {}),
256
+ ...(typeof rec["source"] === "string" ? { source: rec["source"] } : {}),
257
+ });
258
+ }
259
+ return out;
260
+ }
261
+ /** Every experiment name with a ledger under `dir`, sorted. */
262
+ export function listExperiments(dir = DEFAULT_EXPERIMENTS_DIR) {
263
+ if (!existsSync(dir))
264
+ return [];
265
+ let entries;
266
+ try {
267
+ entries = readdirSync(dir);
268
+ }
269
+ catch {
270
+ return [];
271
+ }
272
+ return entries
273
+ .filter((f) => f.endsWith(EXPERIMENT_LEDGER_SUFFIX))
274
+ .map((f) => f.slice(0, -EXPERIMENT_LEDGER_SUFFIX.length))
275
+ .sort();
276
+ }
277
+ /**
278
+ * Collapse REPEATED MEASUREMENTS of the same unit so a tally cannot mistake
279
+ * them for independent observations.
280
+ *
281
+ * The motivating case is `deploy canary --traffic-split`: an eval observation
282
+ * is keyed by DATASET SAMPLE, and a ramp (or a re-invoked ramp under the same
283
+ * experiment name) grades the same fixed sample against the same version more
284
+ * than once. Four ramp steps over an 8-sample dataset would otherwise report
285
+ * n=32 per version and shrink a Wilson half-width ~2×, naming a "winner" on
286
+ * evidence that is inconclusive at the real sample size.
287
+ *
288
+ * Scope is deliberately narrow: only records with `source: "eval"` AND a
289
+ * `requestKey` collapse, last-write-wins per (version, requestKey). Serving
290
+ * records pass through untouched — there a request key is commonly a STICKY
291
+ * user/session id, so repeats are genuinely separate requests and collapsing
292
+ * them would destroy real data.
293
+ */
294
+ export function dedupeExperimentOutcomes(records) {
295
+ const lastIndex = new Map();
296
+ for (let i = 0; i < records.length; i += 1) {
297
+ const rec = records[i];
298
+ if (rec.source !== "eval" || rec.requestKey === undefined)
299
+ continue;
300
+ lastIndex.set(`${rec.version}${rec.requestKey}`, i);
301
+ }
302
+ const kept = [];
303
+ let collapsed = 0;
304
+ for (let i = 0; i < records.length; i += 1) {
305
+ const rec = records[i];
306
+ if (rec.source === "eval" && rec.requestKey !== undefined) {
307
+ if (lastIndex.get(`${rec.version}${rec.requestKey}`) !== i) {
308
+ collapsed += 1;
309
+ continue;
310
+ }
311
+ }
312
+ kept.push(rec);
313
+ }
314
+ return { records: kept, collapsed };
315
+ }
316
+ /**
317
+ * Fold outcome records per version, in first-appearance order (so the first
318
+ * version written — the control/baseline in a canary — leads the table).
319
+ */
320
+ export function tallyExperimentOutcomes(records) {
321
+ const order = [];
322
+ const acc = new Map();
323
+ for (const rec of records) {
324
+ let entry = acc.get(rec.version);
325
+ if (entry === undefined) {
326
+ entry = { n: 0, successes: 0, scoreSum: 0, scoredN: 0, ratingSum: 0, ratedN: 0, sources: {} };
327
+ acc.set(rec.version, entry);
328
+ order.push(rec.version);
329
+ }
330
+ entry.n += 1;
331
+ const source = rec.source ?? "unknown";
332
+ entry.sources[source] = (entry.sources[source] ?? 0) + 1;
333
+ if (rec.outcome === "success")
334
+ entry.successes += 1;
335
+ if (rec.score !== undefined) {
336
+ entry.scoreSum += rec.score;
337
+ entry.scoredN += 1;
338
+ }
339
+ if (rec.rating !== undefined) {
340
+ entry.ratingSum += rec.rating;
341
+ entry.ratedN += 1;
342
+ }
343
+ }
344
+ return order.map((version) => {
345
+ const e = acc.get(version);
346
+ return {
347
+ version,
348
+ n: e.n,
349
+ successes: e.successes,
350
+ failures: e.n - e.successes,
351
+ successRate: e.n > 0 ? e.successes / e.n : 0,
352
+ scoredN: e.scoredN,
353
+ ratedN: e.ratedN,
354
+ sources: e.sources,
355
+ ...(e.scoredN > 0 ? { meanScore: e.scoreSum / e.scoredN } : {}),
356
+ ...(e.ratedN > 0 ? { meanRating: e.ratingSum / e.ratedN } : {}),
357
+ };
358
+ });
359
+ }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,37 @@
1
+ /**
2
+ * Section 28 — `canary-controller`. Shifts a configurable percentage of
3
+ * incoming requests to a new version of a spec. Hash routing on
4
+ * `(tenantId, requestId-hash mod 100 < trafficPercent)` so a given user
5
+ * stays on the same side of the canary across requests.
6
+ *
7
+ * After `evalIntervalMs` elapses, runs an eval-spec against both versions
8
+ * in parallel and gates promotion on `regression-runner` (Section 29 —
9
+ * `gate(baseline, candidate)` over two `EvalRunSummary` results).
10
+ *
11
+ * The real gate is wired by the caller (item 29 — `crewhaus deploy canary`):
12
+ * canary-controller stays dependency-inverted (it does NOT import
13
+ * `regression-runner`/`eval-runner`, keeping this a leaf package). Callers
14
+ * build a `RegressionGate` with {@link makeRegressionGate}, injecting the
15
+ * per-step "eval both versions, then compare" closure. {@link PASSING_GATE}
16
+ * is retained ONLY as an explicit always-pass stub for tests and manual
17
+ * dry-runs — it is no longer the wired production default.
18
+ *
19
+ * Without an eval gate (manual mode), the controller waits for an
20
+ * explicit `crewhaus deploy promote` call.
21
+ *
22
+ * E50 — the N-variant generalization (deterministic per-request version
23
+ * selection + per-version outcome accounting) lives in `./experiment`, which
24
+ * shares this module's hash so a two-version canary and an N-variant
25
+ * experiment can never disagree about which side of the split a key is on.
26
+ * Read that module's HONEST BOUNDARY note before describing any of this as
27
+ * live traffic splitting: nothing here intercepts a request.
28
+ */
1
29
  import type { AuditLog } from "@crewhaus/audit-log";
2
30
  import type { DeploymentController } from "@crewhaus/deployment-controller";
3
- import { CrewhausError } from "@crewhaus/errors";
4
31
  import type { RegistryAdapter } from "@crewhaus/spec-registry";
5
- export declare class CanaryError extends CrewhausError {
6
- readonly name = "CanaryError";
7
- constructor(message: string, cause?: unknown);
8
- }
32
+ import { CanaryError } from "./errors";
33
+ export { CanaryError };
34
+ export { DEFAULT_EXPERIMENTS_DIR, EXPERIMENT_ASSIGNMENT_SUFFIX, EXPERIMENT_LEDGER_SUFFIX, appendExperimentOutcome, appendExperimentOutcomes, dedupeExperimentOutcomes, experimentFileName, listExperiments, readExperimentAssignment, readExperimentOutcomes, removeExperimentAssignment, requestBucket, selectExperimentVariant, tallyExperimentOutcomes, validateExperimentConfig, writeExperimentAssignment, type ExperimentAssignment, type ExperimentConfig, type ExperimentOutcomeRecord, type ExperimentSelection, type ExperimentVariant, type VariantTally, } from "./experiment";
9
35
  export type CanaryRoutingDecision = {
10
36
  readonly version: string;
11
37
  /** Whether this request is on the canary side. */
package/dist/index.js CHANGED
@@ -1,32 +1,7 @@
1
- /**
2
- * Section 28 `canary-controller`. Shifts a configurable percentage of
3
- * incoming requests to a new version of a spec. Hash routing on
4
- * `(tenantId, requestId-hash mod 100 < trafficPercent)` so a given user
5
- * stays on the same side of the canary across requests.
6
- *
7
- * After `evalIntervalMs` elapses, runs an eval-spec against both versions
8
- * in parallel and gates promotion on `regression-runner` (Section 29 —
9
- * `gate(baseline, candidate)` over two `EvalRunSummary` results).
10
- *
11
- * The real gate is wired by the caller (item 29 — `crewhaus deploy canary`):
12
- * canary-controller stays dependency-inverted (it does NOT import
13
- * `regression-runner`/`eval-runner`, keeping this a leaf package). Callers
14
- * build a `RegressionGate` with {@link makeRegressionGate}, injecting the
15
- * per-step "eval both versions, then compare" closure. {@link PASSING_GATE}
16
- * is retained ONLY as an explicit always-pass stub for tests and manual
17
- * dry-runs — it is no longer the wired production default.
18
- *
19
- * Without an eval gate (manual mode), the controller waits for an
20
- * explicit `crewhaus deploy promote` call.
21
- */
22
- import { createHash } from "node:crypto";
23
- import { CrewhausError } from "@crewhaus/errors";
24
- export class CanaryError extends CrewhausError {
25
- name = "CanaryError";
26
- constructor(message, cause) {
27
- super("config", message, cause);
28
- }
29
- }
1
+ import { CanaryError } from "./errors";
2
+ import { requestBucket } from "./experiment";
3
+ export { CanaryError };
4
+ export { DEFAULT_EXPERIMENTS_DIR, EXPERIMENT_ASSIGNMENT_SUFFIX, EXPERIMENT_LEDGER_SUFFIX, appendExperimentOutcome, appendExperimentOutcomes, dedupeExperimentOutcomes, experimentFileName, listExperiments, readExperimentAssignment, readExperimentOutcomes, removeExperimentAssignment, requestBucket, selectExperimentVariant, tallyExperimentOutcomes, validateExperimentConfig, writeExperimentAssignment, } from "./experiment";
30
5
  /**
31
6
  * EXPLICIT always-pass stub. NOT the wired production default anymore
32
7
  * (item 29): the CLI builds a real gate via {@link makeRegressionGate} that
@@ -63,7 +38,7 @@ export function createCanaryController(opts) {
63
38
  config.trafficPercent > 100) {
64
39
  throw new CanaryError(`trafficPercent must be in 0..100; got ${config.trafficPercent}`);
65
40
  }
66
- const bucket = computeBucket(config.tenantId, requestId);
41
+ const bucket = requestBucket(config.tenantId, requestId);
67
42
  const isCanary = bucket < config.trafficPercent;
68
43
  return {
69
44
  version: isCanary ? config.toVersion : config.fromVersion,
@@ -137,10 +112,3 @@ export function createCanaryController(opts) {
137
112
  },
138
113
  };
139
114
  }
140
- function computeBucket(tenantId, requestId) {
141
- const seed = `${tenantId ?? ""}|${requestId}`;
142
- const hash = createHash("sha256").update(seed).digest();
143
- // Take first 4 bytes as uint32, mod 100.
144
- const v = ((hash[0] ?? 0) << 24) | ((hash[1] ?? 0) << 16) | ((hash[2] ?? 0) << 8) | (hash[3] ?? 0);
145
- return Math.abs(v) % 100;
146
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/canary-controller",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Percent-of-traffic rollout with eval-gated promotion. Hash-routes requests across two pinned versions and auto-rolls-back on regression.",
6
6
  "main": "dist/index.js",
@@ -15,10 +15,10 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/audit-log": "0.4.0",
19
- "@crewhaus/deployment-controller": "0.4.0",
20
- "@crewhaus/errors": "0.4.0",
21
- "@crewhaus/spec-registry": "0.4.0"
18
+ "@crewhaus/audit-log": "0.5.0",
19
+ "@crewhaus/deployment-controller": "0.5.0",
20
+ "@crewhaus/errors": "0.5.0",
21
+ "@crewhaus/spec-registry": "0.5.0"
22
22
  },
23
23
  "license": "Apache-2.0",
24
24
  "author": {