@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.
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +13 -0
- package/dist/experiment.d.ts +159 -0
- package/dist/experiment.js +359 -0
- package/dist/index.d.ts +31 -5
- package/dist/index.js +5 -37
- package/package.json +5 -5
package/dist/errors.d.ts
ADDED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
19
|
-
"@crewhaus/deployment-controller": "0.
|
|
20
|
-
"@crewhaus/errors": "0.
|
|
21
|
-
"@crewhaus/spec-registry": "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": {
|