yam-flow 2.5.0 → 2.6.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,249 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export type BenchmarkDirection = 'higher' | 'lower';
4
+ export type PromotionDecision = 'keep' | 'revert' | 'insufficient-evidence' | 'blocked';
5
+
6
+ export interface PromotionSample {
7
+ seed: string;
8
+ candidate: number;
9
+ baseline: number;
10
+ }
11
+
12
+ export interface BoundedPromotionInput {
13
+ label?: string;
14
+ candidate_digest?: string;
15
+ baseline_digest?: string;
16
+ samples?: PromotionSample[];
17
+ min_samples?: number;
18
+ min_mean_delta?: number;
19
+ min_win_rate?: number;
20
+ target?: BenchmarkDirection;
21
+ unit?: string;
22
+ evidence_source?: string;
23
+ }
24
+
25
+ export interface BoundedPromotionReport {
26
+ schema: 'yam.bounded-promotion-receipt.v1';
27
+ generated_at: string;
28
+ label: string;
29
+ candidate_digest: string;
30
+ baseline_digest: string;
31
+ target: BenchmarkDirection;
32
+ unit: string;
33
+ policy: {
34
+ min_samples: number;
35
+ min_mean_delta: number;
36
+ min_win_rate: number;
37
+ };
38
+ samples: Array<PromotionSample & { directional_delta: number; winner: 'candidate' | 'baseline' | 'tie' }>;
39
+ sample_count: number;
40
+ candidate_mean: number | null;
41
+ baseline_mean: number | null;
42
+ directional_mean_delta: number | null;
43
+ win_rate: number | null;
44
+ decision: PromotionDecision;
45
+ failures: string[];
46
+ blockers: string[];
47
+ evidence_source: string;
48
+ evidence_digest: string;
49
+ measurement_truth: 'operator_asserted';
50
+ arithmetic_truth_status: 'verified' | 'blocked';
51
+ truth_status: 'partial' | 'blocked';
52
+ next_action: string;
53
+ }
54
+
55
+ const DIGEST_PATTERN = /^(?:sha256:)?[a-f0-9]{64}$/i;
56
+ const SEED_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
57
+ const MAX_SAMPLES = 256;
58
+
59
+ export function parsePromotionSample(value: string): PromotionSample {
60
+ const parts = String(value || '').split(':');
61
+ if (parts.length !== 3) throw new Error('sample must use seed:candidate:baseline');
62
+ const [seed, candidateText, baselineText] = parts;
63
+ const candidate = Number(candidateText);
64
+ const baseline = Number(baselineText);
65
+ if (!SEED_PATTERN.test(seed)) throw new Error('sample seed must use 1-64 letters, numbers, dot, underscore, or dash');
66
+ if (!Number.isFinite(candidate) || !Number.isFinite(baseline)) throw new Error(`sample ${seed} must contain finite candidate and baseline numbers`);
67
+ return { seed, candidate, baseline };
68
+ }
69
+
70
+ export function buildBoundedPromotionReport(input: BoundedPromotionInput = {}): BoundedPromotionReport {
71
+ const requestedTarget = String(input.target || 'higher');
72
+ const target: BenchmarkDirection = requestedTarget === 'lower' ? 'lower' : 'higher';
73
+ const minSamples = integerInRange(input.min_samples, 2, MAX_SAMPLES, 2, 'min_samples');
74
+ const minMeanDelta = finiteAtLeast(input.min_mean_delta, 0, 0, 'min_mean_delta');
75
+ const minWinRate = finiteRange(input.min_win_rate, 0, 1, 0.5, 'min_win_rate');
76
+ const candidateDigest = normalizeDigest(input.candidate_digest);
77
+ const baselineDigest = normalizeDigest(input.baseline_digest);
78
+ const unit = boundedText(input.unit || '', 64);
79
+ const evidenceSource = boundedText(input.evidence_source || '', 512);
80
+ const rawSamples = Array.isArray(input.samples) ? input.samples : [];
81
+ const validationFailures: string[] = [];
82
+
83
+ if (!['higher', 'lower'].includes(requestedTarget)) validationFailures.push('target_invalid: target must be higher or lower');
84
+ if (!candidateDigest) validationFailures.push('candidate_digest_invalid: record a SHA-256 candidate digest');
85
+ if (!baselineDigest) validationFailures.push('baseline_digest_invalid: record a SHA-256 baseline digest');
86
+ if (candidateDigest && baselineDigest && candidateDigest === baselineDigest) {
87
+ validationFailures.push('candidate_matches_baseline: compare two distinct configuration digests');
88
+ }
89
+ if (!unit) validationFailures.push('unit_missing: record what each paired score measures');
90
+ if (!evidenceSource) validationFailures.push('evidence_source_missing: record where the paired measurements came from');
91
+ if (rawSamples.length > MAX_SAMPLES) validationFailures.push(`sample_limit_exceeded: at most ${MAX_SAMPLES} paired samples are accepted`);
92
+
93
+ const seenSeeds = new Set<string>();
94
+ const samples: BoundedPromotionReport['samples'] = [];
95
+ for (const row of rawSamples.slice(0, MAX_SAMPLES)) {
96
+ const seed = String(row?.seed || '');
97
+ const candidate = Number(row?.candidate);
98
+ const baseline = Number(row?.baseline);
99
+ if (!SEED_PATTERN.test(seed)) {
100
+ validationFailures.push('sample_seed_invalid: every seed must use 1-64 letters, numbers, dot, underscore, or dash');
101
+ continue;
102
+ }
103
+ if (seenSeeds.has(seed)) {
104
+ validationFailures.push(`duplicate_seed: ${seed}`);
105
+ continue;
106
+ }
107
+ seenSeeds.add(seed);
108
+ if (!Number.isFinite(candidate) || !Number.isFinite(baseline)) {
109
+ validationFailures.push(`sample_not_finite: ${seed}`);
110
+ continue;
111
+ }
112
+ const directionalDelta = target === 'higher' ? candidate - baseline : baseline - candidate;
113
+ if (!Number.isFinite(directionalDelta)) {
114
+ validationFailures.push(`sample_delta_not_finite: ${seed}`);
115
+ continue;
116
+ }
117
+ samples.push({
118
+ seed,
119
+ candidate,
120
+ baseline,
121
+ directional_delta: directionalDelta,
122
+ winner: directionalDelta > 0 ? 'candidate' : directionalDelta < 0 ? 'baseline' : 'tie'
123
+ });
124
+ }
125
+ samples.sort((left, right) => left.seed < right.seed ? -1 : left.seed > right.seed ? 1 : 0);
126
+
127
+ const candidateMean = samples.length ? mean(samples.map((sample) => sample.candidate)) : null;
128
+ const baselineMean = samples.length ? mean(samples.map((sample) => sample.baseline)) : null;
129
+ const directionalMeanDelta = samples.length ? mean(samples.map((sample) => sample.directional_delta)) : null;
130
+ const winRate = samples.length ? samples.filter((sample) => sample.winner === 'candidate').length / samples.length : null;
131
+ const failures = [...validationFailures];
132
+ if (!validationFailures.length) {
133
+ if (samples.length < minSamples) failures.push(`minimum_samples_not_met: observed ${samples.length}, required ${minSamples}`);
134
+ if (directionalMeanDelta !== null && directionalMeanDelta < minMeanDelta) {
135
+ failures.push(`minimum_mean_delta_not_met: observed ${directionalMeanDelta}, required ${minMeanDelta}`);
136
+ }
137
+ if (winRate !== null && winRate < minWinRate) failures.push(`minimum_win_rate_not_met: observed ${winRate}, required ${minWinRate}`);
138
+ }
139
+
140
+ const decision: PromotionDecision = validationFailures.length
141
+ ? 'blocked'
142
+ : samples.length < minSamples
143
+ ? 'insufficient-evidence'
144
+ : failures.length
145
+ ? 'revert'
146
+ : 'keep';
147
+ const canonicalEvidence = {
148
+ candidate_digest: candidateDigest,
149
+ baseline_digest: baselineDigest,
150
+ target,
151
+ unit,
152
+ evidence_source: evidenceSource,
153
+ policy: { min_samples: minSamples, min_mean_delta: minMeanDelta, min_win_rate: minWinRate },
154
+ samples: samples.map(({ seed, candidate, baseline }) => ({ seed, candidate, baseline }))
155
+ };
156
+
157
+ return {
158
+ schema: 'yam.bounded-promotion-receipt.v1',
159
+ generated_at: new Date().toISOString(),
160
+ label: boundedText(input.label || 'unnamed', 160),
161
+ candidate_digest: candidateDigest,
162
+ baseline_digest: baselineDigest,
163
+ target,
164
+ unit,
165
+ policy: {
166
+ min_samples: minSamples,
167
+ min_mean_delta: minMeanDelta,
168
+ min_win_rate: minWinRate
169
+ },
170
+ samples,
171
+ sample_count: samples.length,
172
+ candidate_mean: candidateMean,
173
+ baseline_mean: baselineMean,
174
+ directional_mean_delta: directionalMeanDelta,
175
+ win_rate: winRate,
176
+ decision,
177
+ failures,
178
+ blockers: decision === 'keep' ? [] : failures,
179
+ evidence_source: evidenceSource,
180
+ evidence_digest: `sha256:${createHash('sha256').update(stableJson(canonicalEvidence)).digest('hex')}`,
181
+ measurement_truth: 'operator_asserted',
182
+ arithmetic_truth_status: validationFailures.length ? 'blocked' : 'verified',
183
+ truth_status: validationFailures.length ? 'blocked' : 'partial',
184
+ next_action: promotionNextAction(decision)
185
+ };
186
+ }
187
+
188
+ function normalizeDigest(value: unknown) {
189
+ const text = String(value || '').trim().toLowerCase();
190
+ if (!DIGEST_PATTERN.test(text)) return '';
191
+ return text.startsWith('sha256:') ? text : `sha256:${text}`;
192
+ }
193
+
194
+ function integerInRange(value: unknown, min: number, max: number, fallback: number, label: string) {
195
+ if (value === undefined || value === null || value === '') return fallback;
196
+ const number = Number(value);
197
+ if (!Number.isInteger(number) || number < min || number > max) throw new Error(`${label} must be an integer from ${min} to ${max}`);
198
+ return number;
199
+ }
200
+
201
+ function finiteAtLeast(value: unknown, min: number, fallback: number, label: string) {
202
+ if (value === undefined || value === null || value === '') return fallback;
203
+ const number = Number(value);
204
+ if (!Number.isFinite(number) || number < min) throw new Error(`${label} must be a finite number at least ${min}`);
205
+ return number;
206
+ }
207
+
208
+ function finiteRange(value: unknown, min: number, max: number, fallback: number, label: string) {
209
+ if (value === undefined || value === null || value === '') return fallback;
210
+ const number = Number(value);
211
+ if (!Number.isFinite(number) || number < min || number > max) throw new Error(`${label} must be between ${min} and ${max}`);
212
+ return number;
213
+ }
214
+
215
+ function mean(values: number[]) {
216
+ const scale = Math.max(...values.map((value) => Math.abs(value)));
217
+ if (scale === 0) return 0;
218
+ let sum = 0;
219
+ let compensation = 0;
220
+ for (const value of values) {
221
+ const normalized = value / scale;
222
+ const next = sum + normalized;
223
+ compensation += Math.abs(sum) >= Math.abs(normalized)
224
+ ? (sum - next) + normalized
225
+ : (normalized - next) + sum;
226
+ sum = next;
227
+ }
228
+ return ((sum + compensation) / values.length) * scale;
229
+ }
230
+
231
+ function boundedText(value: unknown, max: number) {
232
+ return String(value || '').replace(/[\r\n\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, max);
233
+ }
234
+
235
+ function stableJson(value: unknown): string {
236
+ if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(',')}]`;
237
+ if (value && typeof value === 'object') {
238
+ const record = value as Record<string, unknown>;
239
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`;
240
+ }
241
+ return JSON.stringify(value);
242
+ }
243
+
244
+ function promotionNextAction(decision: PromotionDecision) {
245
+ if (decision === 'keep') return 'keep the candidate only with the recorded evidence source and smallest relevant regression check';
246
+ if (decision === 'revert') return 'revert or revise the candidate because one or more promotion thresholds failed';
247
+ if (decision === 'insufficient-evidence') return 'record more paired samples with the same explicit policy before deciding';
248
+ return 'repair the malformed or ambiguous promotion evidence before making a keep/revert decision';
249
+ }
@@ -0,0 +1,59 @@
1
+ export const HOOK_STDIN_MAX_BYTES = 1024 * 1024;
2
+ export const GENERAL_STDIN_MAX_BYTES = 4 * 1024 * 1024;
3
+
4
+ export type BoundedInputErrorCode = 'input_too_large' | 'invalid_json';
5
+
6
+ export class BoundedInputError extends Error {
7
+ readonly code: BoundedInputErrorCode;
8
+ readonly maxBytes?: number;
9
+
10
+ constructor(code: BoundedInputErrorCode, options: { maxBytes?: number } = {}) {
11
+ const detail = code === 'input_too_large' && options.maxBytes
12
+ ? `stdin input exceeds the ${options.maxBytes}-byte limit`
13
+ : 'stdin input is not a valid JSON object';
14
+ super(`[${code}] ${detail}`);
15
+ this.name = 'BoundedInputError';
16
+ this.code = code;
17
+ this.maxBytes = options.maxBytes;
18
+ }
19
+ }
20
+
21
+ export async function readBoundedStdinText(
22
+ input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>,
23
+ maxBytes: number
24
+ ): Promise<string> {
25
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
26
+ throw new Error('stdin byte limit must be a positive safe integer');
27
+ }
28
+
29
+ const chunks: Buffer[] = [];
30
+ let totalBytes = 0;
31
+ for await (const chunk of input) {
32
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
33
+ totalBytes += bytes.byteLength;
34
+ if (totalBytes > maxBytes) {
35
+ throw new BoundedInputError('input_too_large', { maxBytes });
36
+ }
37
+ chunks.push(bytes);
38
+ }
39
+ return Buffer.concat(chunks, totalBytes).toString('utf8');
40
+ }
41
+
42
+ export async function readBoundedStdinJson(
43
+ input: NodeJS.ReadableStream & AsyncIterable<string | Buffer>,
44
+ maxBytes: number
45
+ ): Promise<Record<string, unknown>> {
46
+ const text = (await readBoundedStdinText(input, maxBytes)).trim();
47
+ if (!text) return {};
48
+
49
+ try {
50
+ const value = JSON.parse(text);
51
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
52
+ throw new BoundedInputError('invalid_json');
53
+ }
54
+ return value;
55
+ } catch (error) {
56
+ if (error instanceof BoundedInputError) throw error;
57
+ throw new BoundedInputError('invalid_json');
58
+ }
59
+ }