bmad-method-test-architecture-enterprise 3.2.0 → 3.3.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/.claude-plugin/marketplace.json +1 -1
- package/cli/evaluate.js +20 -0
- package/cli/lib/agent-adapters.js +46 -0
- package/cli/lib/evaluate/configuration-experiment.js +3401 -0
- package/cli/lib/evaluate/experiments.js +1299 -0
- package/cli/lib/evaluate/retain-skill-artifacts.js +78 -0
- package/cli/lib/evaluate/test-design-corpus/clean/docs/epics/epic-9-last-sync-indicator.md +47 -0
- package/cli/lib/evaluate/test-design-corpus/ground-truth.json +222 -0
- package/cli/lib/evaluate/test-design-corpus/native-identity.json +4 -0
- package/cli/lib/evaluate/test-design-corpus/seeded/docs/epics/epic-7-offline-order-capture.md +75 -0
- package/cli/lib/evaluate/test-design-replay.js +452 -0
- package/cli/lib/run-agent.js +18 -2
- package/cli/lib/test-design-staged-config.js +60 -0
- package/cli/lib/test-design-staging.js +229 -0
- package/cli/test-design-runner.js +245 -11
- package/package.json +3 -2
- package/skills/bmad-tea-evaluate/SKILL.md +11 -0
- package/skills/bmad-tea-evaluate/references/configuration-experiments.md +37 -0
- package/skills/bmod-tea/bmod.toml +1 -1
- package/skills/bmod-tea/help/help.md +1 -1
|
@@ -0,0 +1,3401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline reader for repeatability and one-factor configuration experiments.
|
|
3
|
+
*
|
|
4
|
+
* WHAT IT DOES
|
|
5
|
+
*
|
|
6
|
+
* It reads a frozen declaration and saved evidence (recorded Test Design runs, or
|
|
7
|
+
* evidence rows another tool wrote) and returns an advisory report: a JSON object,
|
|
8
|
+
* a Markdown rendering, and an evidence index with one row per attempt. It runs no
|
|
9
|
+
* subprocess, calls no model, replaces no grader, and writes nothing unless
|
|
10
|
+
* `writeOutputs` is called with an output directory the operator names.
|
|
11
|
+
*
|
|
12
|
+
* TWO MODES
|
|
13
|
+
*
|
|
14
|
+
* Descriptive repeatability works on whatever artifacts exist: per-case success
|
|
15
|
+
* fractions, metric spread, the native stability signature of each repetition, and a
|
|
16
|
+
* replay of the deterministic scorer over the saved bytes of each document. Replay
|
|
17
|
+
* is a control on the scorer. It is a separate result and never replaces what the
|
|
18
|
+
* native run recorded.
|
|
19
|
+
*
|
|
20
|
+
* The paired contrast compares one baseline with one candidate that differs in
|
|
21
|
+
* exactly one declared setting. It needs a frozen declaration, verified attempts
|
|
22
|
+
* (receipt, saved bytes, native record and a scorer that reproduces the row's
|
|
23
|
+
* numbers), complete pairs in recorded pairing blocks, and all-in cost evidence where
|
|
24
|
+
* the objective is cost. A confirmation also needs the pinned selection-cleared
|
|
25
|
+
* report it follows. Anything
|
|
26
|
+
* missing yields an explicit `blocked`, `new-baseline-required`, `descriptive-only` or
|
|
27
|
+
* `inconclusive` outcome, never an invented value.
|
|
28
|
+
*
|
|
29
|
+
* DECISION ORDER (one place, `decide`)
|
|
30
|
+
*
|
|
31
|
+
* 1. an unusable declaration, or a confirmation without its pinned selection-cleared
|
|
32
|
+
* report, blocks
|
|
33
|
+
* 2. changed task, ground truth, rubric, scorer, thresholds or arm identity
|
|
34
|
+
* requires a new baseline; a second changed setting, or a setting whose requested
|
|
35
|
+
* and resolved or applied identities do not change together, requires a revised design
|
|
36
|
+
* 3. a verified candidate critical miss rejects, whatever the averages or costs say
|
|
37
|
+
* 4. a binding native gate that is red rejects
|
|
38
|
+
* 5. claimed evidence the reader cannot verify, an unverified critical miss, an
|
|
39
|
+
* unrecorded critical count, incomplete pairs, missing pairing blocks, a dispatch
|
|
40
|
+
* order off the declared schedule, unknown or unclassified required cost, unknown
|
|
41
|
+
* latency under a latency gate, non-finite data or an unusable exposure ledger blocks
|
|
42
|
+
* 6. an unsupported design stays descriptive-only
|
|
43
|
+
* 7. too few clusters, a degenerate interval or a bootstrap that fails its
|
|
44
|
+
* reference fixtures is inconclusive
|
|
45
|
+
* 8. every required lower bound must strictly exceed its threshold; equality does
|
|
46
|
+
* not pass. A failed bound is inconclusive. Failing to show noninferiority
|
|
47
|
+
* proves nothing about inferiority.
|
|
48
|
+
*
|
|
49
|
+
* The outcome is an advisory analysis. It carries no native EQ verdict, accepts no
|
|
50
|
+
* baseline and applies no patch. A pass during selection is `selection-cleared`;
|
|
51
|
+
* only a confirmation stage on untouched cases can recommend.
|
|
52
|
+
*
|
|
53
|
+
* STATISTICS
|
|
54
|
+
*
|
|
55
|
+
* The estimator is the equal-weight paired case-cluster percentile bootstrap. Each
|
|
56
|
+
* case is a cluster. Within a cluster each arm's complete planned repetitions are
|
|
57
|
+
* averaged first, then the paired difference (candidate minus baseline) is taken.
|
|
58
|
+
* Each of B resamples draws n whole clusters with replacement, with the same draws
|
|
59
|
+
* for every contrast, and averages them. The one-sided lower bound is the alpha
|
|
60
|
+
* quantile of the B resampled means. Alpha, B, the seed, the PRNG (`mulberry32`) and the
|
|
61
|
+
* quantile convention are declared and frozen. Before any bound can support a
|
|
62
|
+
* decision the bootstrap is run against reference fixtures (`CALIBRATION_FIXTURES`)
|
|
63
|
+
* whose expected values come from an independent implementation.
|
|
64
|
+
*
|
|
65
|
+
* The checkout compatibility wrapper also exposes freeze, digest and analyze CLI
|
|
66
|
+
* operations. Installed usage is tea-evaluate experiment --help.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
'use strict';
|
|
70
|
+
|
|
71
|
+
const crypto = require('node:crypto');
|
|
72
|
+
const fs = require('node:fs');
|
|
73
|
+
const path = require('node:path');
|
|
74
|
+
|
|
75
|
+
const {
|
|
76
|
+
projectTestDesignResult,
|
|
77
|
+
readDesign,
|
|
78
|
+
scoreRun,
|
|
79
|
+
signatureOf,
|
|
80
|
+
loadGroundTruth,
|
|
81
|
+
testDesignDiagnosticProjection,
|
|
82
|
+
numericContributions,
|
|
83
|
+
} = require('./test-design-replay');
|
|
84
|
+
|
|
85
|
+
const SCHEMA_VERSION = '1.0.0';
|
|
86
|
+
const MAX_REPEATS = 1000;
|
|
87
|
+
const MAX_BOOTSTRAP_RESAMPLES = 1_000_000;
|
|
88
|
+
const ANALYSIS_VERSION = '1.0.0';
|
|
89
|
+
const ARMS = Object.freeze(['A', 'B']);
|
|
90
|
+
const STAGES = Object.freeze(['baseline', 'selection', 'confirmation']);
|
|
91
|
+
const STATUSES = Object.freeze(['completed', 'failed', 'canceled', 'invalid', 'timeout']);
|
|
92
|
+
const FACTOR_KINDS = Object.freeze(['model', 'effort', 'prompt']);
|
|
93
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
94
|
+
const TOKEN_CATEGORIES = Object.freeze(['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']);
|
|
95
|
+
const QUANTILES = Object.freeze(['linear', 'nearest-rank']);
|
|
96
|
+
const REQUIRED_TASK_COMPONENTS = Object.freeze(['target', 'tools']);
|
|
97
|
+
const DEFAULT_COST_SCOPE = Object.freeze({ taskExecution: REQUIRED_TASK_COMPONENTS, judge: ['judge'] });
|
|
98
|
+
const SIGNATURE_COMPONENTS = Object.freeze([
|
|
99
|
+
'set',
|
|
100
|
+
'mentions',
|
|
101
|
+
'shape',
|
|
102
|
+
'shapeFailures',
|
|
103
|
+
'links',
|
|
104
|
+
'grounding',
|
|
105
|
+
'ungrounded',
|
|
106
|
+
'ceiling',
|
|
107
|
+
'coverageChecks',
|
|
108
|
+
'orderingChecks',
|
|
109
|
+
'flattenedPriorities',
|
|
110
|
+
'unscoredRiskTables',
|
|
111
|
+
'mutations',
|
|
112
|
+
]);
|
|
113
|
+
const IDENTITY_KEY_CLASS = Object.freeze({
|
|
114
|
+
suiteDigest: 'task',
|
|
115
|
+
caseDigests: 'task',
|
|
116
|
+
fixtureDigest: 'task',
|
|
117
|
+
promptDigest: 'task',
|
|
118
|
+
groundTruthDigest: 'ground-truth',
|
|
119
|
+
rubricDigest: 'rubric',
|
|
120
|
+
scorerDigest: 'scorer',
|
|
121
|
+
thresholdsDigest: 'thresholds',
|
|
122
|
+
});
|
|
123
|
+
const OUTCOMES = Object.freeze({
|
|
124
|
+
blocked: 'blocked',
|
|
125
|
+
newBaseline: 'new-baseline-required',
|
|
126
|
+
revisedDesign: 'revised-design-required',
|
|
127
|
+
rejected: 'rejected',
|
|
128
|
+
descriptiveOnly: 'descriptive-only',
|
|
129
|
+
inconclusive: 'inconclusive',
|
|
130
|
+
aaControl: 'aa-control',
|
|
131
|
+
selectionCleared: 'selection-cleared',
|
|
132
|
+
recommend: 'recommend',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// small helpers
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
140
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
141
|
+
const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
|
|
142
|
+
const isPositiveInteger = (value) => Number.isInteger(value) && value > 0;
|
|
143
|
+
|
|
144
|
+
function canonicalJson(value) {
|
|
145
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`;
|
|
146
|
+
if (isObject(value)) {
|
|
147
|
+
const keys = Object.keys(value)
|
|
148
|
+
.filter((key) => value[key] !== undefined)
|
|
149
|
+
.sort();
|
|
150
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
151
|
+
}
|
|
152
|
+
return JSON.stringify(value) ?? 'null';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sha256Hex(text) {
|
|
156
|
+
return crypto.createHash('sha256').update(text).digest('hex');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function digestOf(value) {
|
|
160
|
+
return `sha256:${sha256Hex(canonicalJson(value))}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function deepFreeze(value) {
|
|
164
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
165
|
+
Object.freeze(value);
|
|
166
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const clone = (value) => (value === undefined ? undefined : structuredClone(value));
|
|
172
|
+
|
|
173
|
+
/** A key as one segment of a leaf path: a backslash or dot inside it is escaped. */
|
|
174
|
+
const escapeKey = (key) => key.replaceAll('\\', '\\\\').replaceAll('.', String.raw`\.`);
|
|
175
|
+
|
|
176
|
+
/** The last key of a leaf path, unescaped. */
|
|
177
|
+
function lastKey(leaf) {
|
|
178
|
+
let key = '';
|
|
179
|
+
for (let index = 0; index < leaf.length; index += 1) {
|
|
180
|
+
if (leaf[index] === '\\') {
|
|
181
|
+
index += 1;
|
|
182
|
+
key += leaf[index] ?? '';
|
|
183
|
+
} else if (leaf[index] === '.') {
|
|
184
|
+
key = '';
|
|
185
|
+
} else {
|
|
186
|
+
key += leaf[index];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return key;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Leaf paths of a nested object. Arrays are leaves. A dot or backslash inside a key is
|
|
194
|
+
* escaped, so a key named `a.b` never collides with the path of `{ a: { b } }`.
|
|
195
|
+
*/
|
|
196
|
+
function flatten(value, prefix = '', out = Object.create(null)) {
|
|
197
|
+
if (isObject(value)) {
|
|
198
|
+
for (const key of Object.keys(value).sort()) {
|
|
199
|
+
const name = escapeKey(key);
|
|
200
|
+
flatten(value[key], prefix ? `${prefix}.${name}` : name, out);
|
|
201
|
+
}
|
|
202
|
+
if (Object.keys(value).length === 0 && prefix) out[prefix] = {};
|
|
203
|
+
} else if (prefix) {
|
|
204
|
+
out[prefix] = value;
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
210
|
+
|
|
211
|
+
/** Sum that stays unknown: any null/undefined/non-finite term makes the sum null. */
|
|
212
|
+
function knownSum(values) {
|
|
213
|
+
let sum = 0;
|
|
214
|
+
for (const value of values) {
|
|
215
|
+
if (!isFiniteNumber(value)) return null;
|
|
216
|
+
sum += value;
|
|
217
|
+
}
|
|
218
|
+
return sum;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// declaration
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/** The text the declaration digest covers: the declaration without its `frozen` block. */
|
|
226
|
+
function declarationDigest(declaration) {
|
|
227
|
+
const { frozen, ...rest } = isObject(declaration) ? declaration : {};
|
|
228
|
+
void frozen;
|
|
229
|
+
return digestOf(rest);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The cases a declaration's stage runs, in declared order. */
|
|
233
|
+
function stageCases(declaration) {
|
|
234
|
+
const memberships = declaration?.scope?.memberships ?? {};
|
|
235
|
+
const list = (key) => (Array.isArray(memberships[key]) ? memberships[key] : []);
|
|
236
|
+
if (declaration?.stage === 'confirmation') return [...list('confirmation')];
|
|
237
|
+
if (declaration?.stage === 'selection') return [...list('development'), ...list('validation')];
|
|
238
|
+
return [...list('development')];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The one setting a contrast changes. `leaves` are the identity leaves it may change;
|
|
243
|
+
* `together` are the leaves that all have to change: a requested model or effort with
|
|
244
|
+
* the one the provider resolved or applied, a prompt region with the digest of the
|
|
245
|
+
* whole prompt. Null when the declaration names no setting its kind allows.
|
|
246
|
+
*/
|
|
247
|
+
function changedSetting(changedFactor) {
|
|
248
|
+
const kind = changedFactor?.kind;
|
|
249
|
+
const setting = changedFactor?.setting;
|
|
250
|
+
if (kind === 'model' && (setting === undefined || setting === 'model')) {
|
|
251
|
+
return { setting: 'model', leaves: ['model.requested', 'model.resolved'], together: ['model.requested', 'model.resolved'] };
|
|
252
|
+
}
|
|
253
|
+
if (kind === 'effort' && setting === 'effort') {
|
|
254
|
+
return { setting, leaves: ['effort.requested', 'effort.applied'], together: ['effort.requested', 'effort.applied'] };
|
|
255
|
+
}
|
|
256
|
+
// The name after `decode.` or `promptRegions.` is one key, escaped like any leaf path.
|
|
257
|
+
const named = (group) => {
|
|
258
|
+
const name = isNonEmptyString(setting) && setting.startsWith(`${group}.`) ? setting.slice(group.length + 1) : '';
|
|
259
|
+
return name.length > 0 && !name.includes('.') ? `${group}.${escapeKey(name)}` : null;
|
|
260
|
+
};
|
|
261
|
+
if (kind === 'effort' && named('decode')) return { setting, leaves: [named('decode')], together: [named('decode')] };
|
|
262
|
+
if (kind === 'prompt' && named('promptRegions')) {
|
|
263
|
+
return { setting, leaves: [named('promptRegions'), 'promptDigest'], together: [named('promptRegions'), 'promptDigest'] };
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function clusterPlan(declaration) {
|
|
269
|
+
const cases = stageCases(declaration);
|
|
270
|
+
const declared = Array.isArray(declaration?.scope?.clusters) ? declaration.scope.clusters : null;
|
|
271
|
+
if (!declared) return cases.map((caseId) => ({ caseId, clusterId: caseId, weight: 1 / cases.length }));
|
|
272
|
+
return declared.filter(isObject).map((entry) => ({ caseId: entry.caseId, clusterId: entry.clusterId, weight: entry.weight }));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Validate a declaration. `problems` make it unusable for any mode; `pairedProblems`
|
|
277
|
+
* only block the paired contrast, so a baseline-only declaration stays readable.
|
|
278
|
+
*
|
|
279
|
+
* @param {object} declaration
|
|
280
|
+
* @returns {{problems: Array, pairedProblems: Array, digest: string, frozen: {declared: boolean, matches: boolean|null}}}
|
|
281
|
+
*/
|
|
282
|
+
function validateDeclaration(declaration) {
|
|
283
|
+
const problems = [];
|
|
284
|
+
const pairedProblems = [];
|
|
285
|
+
const decl = isObject(declaration) ? declaration : {};
|
|
286
|
+
const need = (list, ok, where, message) => {
|
|
287
|
+
if (!ok) list.push({ where, message });
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
need(problems, isObject(declaration), 'declaration', 'must be a JSON object');
|
|
291
|
+
need(problems, decl.schemaVersion === SCHEMA_VERSION, 'schemaVersion', `must be ${SCHEMA_VERSION}`);
|
|
292
|
+
need(problems, isNonEmptyString(decl.experimentId), 'experimentId', 'is required');
|
|
293
|
+
need(problems, STAGES.includes(decl.stage), 'stage', `must be one of ${STAGES.join(', ')}`);
|
|
294
|
+
|
|
295
|
+
const scope = isObject(decl.scope) ? decl.scope : {};
|
|
296
|
+
need(problems, isNonEmptyString(scope.workflow), 'scope.workflow', 'is required');
|
|
297
|
+
need(problems, isNonEmptyString(scope.owner), 'scope.owner', 'is required');
|
|
298
|
+
need(problems, ['fixed-suite', 'population'].includes(scope.claim), 'scope.claim', 'must be fixed-suite or population');
|
|
299
|
+
need(problems, isNonEmptyString(scope.baseline?.id), 'scope.baseline.id', 'is required');
|
|
300
|
+
need(problems, isObject(scope.memberships), 'scope.memberships', 'is required');
|
|
301
|
+
need(problems, isObject(decl.identity?.fixed), 'identity.fixed', 'is required');
|
|
302
|
+
need(problems, ['none', 'rubric'].includes(scope.judge), 'scope.judge', 'must be none (deterministic scorer) or rubric');
|
|
303
|
+
need(
|
|
304
|
+
problems,
|
|
305
|
+
isPositiveInteger(decl.design?.repeats) && decl.design.repeats <= MAX_REPEATS,
|
|
306
|
+
'design.repeats',
|
|
307
|
+
`must be a positive integer of at most ${MAX_REPEATS}`,
|
|
308
|
+
);
|
|
309
|
+
need(
|
|
310
|
+
problems,
|
|
311
|
+
scope.clusters === undefined || (Array.isArray(scope.clusters) && scope.clusters.every((entry) => isObject(entry))),
|
|
312
|
+
'scope.clusters',
|
|
313
|
+
'must be a list of objects',
|
|
314
|
+
);
|
|
315
|
+
need(
|
|
316
|
+
problems,
|
|
317
|
+
decl.gates?.critical === undefined ||
|
|
318
|
+
(isObject(decl.gates.critical) &&
|
|
319
|
+
Array.isArray(decl.gates.critical.rules) &&
|
|
320
|
+
decl.gates.critical.rules.every((rule) => isObject(rule))),
|
|
321
|
+
'gates.critical.rules',
|
|
322
|
+
'must be a list of objects',
|
|
323
|
+
);
|
|
324
|
+
need(problems, isObject(decl.gates?.success?.metrics), 'gates.success.metrics', 'is required');
|
|
325
|
+
need(
|
|
326
|
+
problems,
|
|
327
|
+
Object.values(decl.gates?.success?.metrics ?? {}).every((value) => isFiniteNumber(value)),
|
|
328
|
+
'gates.success.metrics',
|
|
329
|
+
'every minimum must be a finite number',
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const memberships = isObject(scope.memberships) ? scope.memberships : {};
|
|
333
|
+
const seen = new Map();
|
|
334
|
+
for (const key of ['development', 'validation', 'confirmation']) {
|
|
335
|
+
const list = memberships[key];
|
|
336
|
+
need(problems, list === undefined || Array.isArray(list), `scope.memberships.${key}`, 'must be a list of case ids');
|
|
337
|
+
for (const caseId of Array.isArray(list) ? list : []) {
|
|
338
|
+
if (seen.has(caseId)) problems.push({ where: `scope.memberships.${key}`, message: `${caseId} is also in ${seen.get(caseId)}` });
|
|
339
|
+
else seen.set(caseId, key);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
need(problems, stageCases(decl).length > 0, 'scope.memberships', 'the stage has no cases');
|
|
343
|
+
|
|
344
|
+
// -- paired contrast -------------------------------------------------------
|
|
345
|
+
const pairs = pairedProblems;
|
|
346
|
+
need(pairs, ['selection', 'confirmation'].includes(decl.stage), 'stage', 'a paired contrast runs in the selection or confirmation stage');
|
|
347
|
+
need(pairs, isNonEmptyString(scope.candidate?.id), 'scope.candidate.id', 'is required for a paired contrast');
|
|
348
|
+
need(pairs, FACTOR_KINDS.includes(scope.changedFactor?.kind), 'scope.changedFactor.kind', `must be one of ${FACTOR_KINDS.join(', ')}`);
|
|
349
|
+
need(
|
|
350
|
+
pairs,
|
|
351
|
+
!FACTOR_KINDS.includes(scope.changedFactor?.kind) || changedSetting(scope.changedFactor) !== null,
|
|
352
|
+
'scope.changedFactor.setting',
|
|
353
|
+
'must name the one setting that changes: model for a model; effort or decode.<name> for effort; promptRegions.<name> for a prompt',
|
|
354
|
+
);
|
|
355
|
+
need(pairs, ['cost', 'quality'].includes(scope.objective), 'scope.objective', 'must be cost or quality');
|
|
356
|
+
need(pairs, isObject(decl.identity?.arms?.A) && isObject(decl.identity?.arms?.B), 'identity.arms', 'needs an A and a B arm');
|
|
357
|
+
for (const key of ['suiteDigest', 'groundTruthDigest', 'scorerDigest', 'thresholdsDigest', 'rubricDigest']) {
|
|
358
|
+
need(
|
|
359
|
+
pairs,
|
|
360
|
+
decl.identity?.fixed?.[key] !== undefined,
|
|
361
|
+
`identity.fixed.${key}`,
|
|
362
|
+
'is required (use "not-applicable" where nothing applies)',
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
need(
|
|
366
|
+
pairs,
|
|
367
|
+
Object.keys(flatten(decl.identity?.fixed ?? {})).some((leaf) => lastKey(leaf) === 'cacheRegime'),
|
|
368
|
+
'identity.fixed',
|
|
369
|
+
'needs a cacheRegime: both arms run under one recorded cache policy',
|
|
370
|
+
);
|
|
371
|
+
for (const arm of ARMS) {
|
|
372
|
+
for (const key of ['model.requested', 'model.resolved', 'effort.requested', 'effort.applied']) {
|
|
373
|
+
const [group, leaf] = key.split('.');
|
|
374
|
+
need(pairs, decl.identity?.arms?.[arm]?.[group]?.[leaf] !== undefined, `identity.arms.${arm}.${key}`, 'is required');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const plan = Array.isArray(scope.clusters) ? scope.clusters : null;
|
|
379
|
+
need(pairs, plan !== null, 'scope.clusters', 'is required: independent cluster ids and weights per case');
|
|
380
|
+
if (plan) {
|
|
381
|
+
const cases = stageCases(decl);
|
|
382
|
+
const planned = plan.map((entry) => entry?.caseId);
|
|
383
|
+
need(
|
|
384
|
+
pairs,
|
|
385
|
+
cases.every((caseId) => planned.includes(caseId)),
|
|
386
|
+
'scope.clusters',
|
|
387
|
+
'must cover every case of the stage',
|
|
388
|
+
);
|
|
389
|
+
need(
|
|
390
|
+
pairs,
|
|
391
|
+
planned.every((caseId) => cases.includes(caseId)),
|
|
392
|
+
'scope.clusters',
|
|
393
|
+
'names a case outside the stage',
|
|
394
|
+
);
|
|
395
|
+
need(pairs, new Set(planned).size === planned.length, 'scope.clusters', 'names a case twice');
|
|
396
|
+
need(
|
|
397
|
+
pairs,
|
|
398
|
+
plan.every((entry) => isNonEmptyString(entry?.clusterId) && isFiniteNumber(entry?.weight) && entry.weight >= 0),
|
|
399
|
+
'scope.clusters',
|
|
400
|
+
'every entry needs a clusterId and a nonnegative weight',
|
|
401
|
+
);
|
|
402
|
+
const total = plan.reduce((sum, entry) => sum + (isFiniteNumber(entry?.weight) ? entry.weight : 0), 0);
|
|
403
|
+
need(pairs, Math.abs(total - 1) < 1e-9, 'scope.clusters', 'weights must sum to one');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const design = isObject(decl.design) ? decl.design : {};
|
|
407
|
+
need(pairs, ['interleaved', 'randomized'].includes(design.order), 'design.order', 'must be interleaved or randomized');
|
|
408
|
+
need(pairs, isNonEmptyString(design.isolation), 'design.isolation', 'is required');
|
|
409
|
+
need(pairs, isPositiveInteger(design.maxAttempts), 'design.maxAttempts', 'must be a positive integer');
|
|
410
|
+
need(pairs, isNonEmptyString(design.stoppingRule), 'design.stoppingRule', 'is required');
|
|
411
|
+
need(
|
|
412
|
+
pairs,
|
|
413
|
+
['incomplete', 'failure'].includes(design.failureRules?.timeout),
|
|
414
|
+
'design.failureRules.timeout',
|
|
415
|
+
'must be incomplete or failure',
|
|
416
|
+
);
|
|
417
|
+
if (design.failureRules?.timeout === 'failure') {
|
|
418
|
+
need(pairs, isFiniteNumber(design.failureRules.failureMetricValue), 'design.failureRules.failureMetricValue', 'is required');
|
|
419
|
+
need(
|
|
420
|
+
pairs,
|
|
421
|
+
design.failureRules.failureMetricValue === 0,
|
|
422
|
+
'design.failureRules.failureMetricValue',
|
|
423
|
+
'must be 0, the bottom of the [0, 1] scale of a required metric: a timeout scored as a failure scores the worst outcome',
|
|
424
|
+
);
|
|
425
|
+
need(
|
|
426
|
+
pairs,
|
|
427
|
+
Object.keys(decl.gates?.success?.metrics ?? {}).length > 0 &&
|
|
428
|
+
Object.values(decl.gates.success.metrics).every((minimum) => minimum > 0),
|
|
429
|
+
'gates.success.metrics',
|
|
430
|
+
'needs a minimum above 0 on every metric under a failure rule, so a failure never counts as a success',
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
const analysis = isObject(design.analysis) ? design.analysis : {};
|
|
434
|
+
need(pairs, isFiniteNumber(analysis.alpha) && analysis.alpha > 0 && analysis.alpha < 0.5, 'design.analysis.alpha', 'must be in (0, 0.5)');
|
|
435
|
+
need(
|
|
436
|
+
pairs,
|
|
437
|
+
isPositiveInteger(analysis.B) && analysis.B >= 1000 && analysis.B <= MAX_BOOTSTRAP_RESAMPLES,
|
|
438
|
+
'design.analysis.B',
|
|
439
|
+
`must be an integer from 1000 to ${MAX_BOOTSTRAP_RESAMPLES}`,
|
|
440
|
+
);
|
|
441
|
+
need(
|
|
442
|
+
pairs,
|
|
443
|
+
Number.isInteger(analysis.seed) && analysis.seed >= 0 && analysis.seed <= 0xff_ff_ff_ff,
|
|
444
|
+
'design.analysis.seed',
|
|
445
|
+
'must be a uint32',
|
|
446
|
+
);
|
|
447
|
+
need(pairs, analysis.prng === 'mulberry32', 'design.analysis.prng', 'must be mulberry32');
|
|
448
|
+
need(pairs, QUANTILES.includes(analysis.quantile), 'design.analysis.quantile', `must be one of ${QUANTILES.join(', ')}`);
|
|
449
|
+
need(pairs, typeof analysis.simultaneous === 'boolean', 'design.analysis.simultaneous', 'must be true or false');
|
|
450
|
+
|
|
451
|
+
const gates = isObject(decl.gates) ? decl.gates : {};
|
|
452
|
+
const quality = isObject(gates.quality) ? gates.quality : {};
|
|
453
|
+
need(pairs, Object.keys(quality).length > 0, 'gates.quality', 'needs at least one required metric');
|
|
454
|
+
for (const [metric, rule] of Object.entries(quality)) {
|
|
455
|
+
const loss = isFiniteNumber(rule?.maxLoss) && rule.maxLoss >= 0 && rule.maxLoss < 1;
|
|
456
|
+
const gain = isFiniteNumber(rule?.minImprovement) && rule.minImprovement > 0;
|
|
457
|
+
const keys = isObject(rule) ? ['maxLoss', 'minImprovement'].filter((key) => key in rule) : [];
|
|
458
|
+
need(
|
|
459
|
+
pairs,
|
|
460
|
+
keys.length === 1 && (loss || gain),
|
|
461
|
+
`gates.quality.${metric}`,
|
|
462
|
+
'needs exactly one of maxLoss in [0, 1) or minImprovement (> 0)',
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (scope.objective === 'cost') {
|
|
466
|
+
need(
|
|
467
|
+
pairs,
|
|
468
|
+
Object.values(quality).every((rule) => isFiniteNumber(rule?.maxLoss)),
|
|
469
|
+
'gates.quality',
|
|
470
|
+
'a cost objective allows quality loss only',
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
if (scope.objective === 'cost' || gates.saving !== undefined) {
|
|
474
|
+
need(
|
|
475
|
+
pairs,
|
|
476
|
+
isFiniteNumber(gates.saving?.minFraction) && gates.saving.minFraction > 0 && gates.saving.minFraction < 1,
|
|
477
|
+
'gates.saving.minFraction',
|
|
478
|
+
'must be in (0, 1) for a cost objective or a declared saving gate',
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
if (scope.objective === 'quality') {
|
|
482
|
+
need(
|
|
483
|
+
pairs,
|
|
484
|
+
Object.values(quality).some((rule) => isFiniteNumber(rule?.minImprovement)),
|
|
485
|
+
'gates.quality',
|
|
486
|
+
'a quality objective needs a minImprovement metric',
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const floors = isObject(gates.floors) ? gates.floors : {};
|
|
490
|
+
for (const [metric, floor] of Object.entries(floors)) {
|
|
491
|
+
need(pairs, isFiniteNumber(floor) && metric in quality, `gates.floors.${metric}`, 'needs a finite floor on a required quality metric');
|
|
492
|
+
}
|
|
493
|
+
if (gates.latency !== undefined) {
|
|
494
|
+
need(
|
|
495
|
+
pairs,
|
|
496
|
+
isFiniteNumber(gates.latency?.maxIncreaseFraction) && gates.latency.maxIncreaseFraction >= 0,
|
|
497
|
+
'gates.latency.maxIncreaseFraction',
|
|
498
|
+
'must be >= 0',
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
need(
|
|
502
|
+
pairs,
|
|
503
|
+
gates.critical?.tolerance === 0 && Array.isArray(gates.critical?.rules) && gates.critical.rules.length > 0,
|
|
504
|
+
'gates.critical',
|
|
505
|
+
'needs tolerance 0 and at least one rule',
|
|
506
|
+
);
|
|
507
|
+
for (const [index, rule] of (Array.isArray(gates.critical?.rules) ? gates.critical.rules : []).entries()) {
|
|
508
|
+
need(
|
|
509
|
+
pairs,
|
|
510
|
+
isNonEmptyString(rule?.id) && isNonEmptyString(rule?.count),
|
|
511
|
+
`gates.critical.rules[${index}]`,
|
|
512
|
+
'needs an id and a count name',
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
need(
|
|
516
|
+
pairs,
|
|
517
|
+
isObject(decl.resources) && 'authorization' in decl.resources,
|
|
518
|
+
'resources.authorization',
|
|
519
|
+
'is required (null means no live execution)',
|
|
520
|
+
);
|
|
521
|
+
const costScope = decl.resources?.costScope;
|
|
522
|
+
if (costScope !== undefined) {
|
|
523
|
+
const names = (list) => Array.isArray(list) && list.every((name) => isNonEmptyString(name));
|
|
524
|
+
need(
|
|
525
|
+
pairs,
|
|
526
|
+
isObject(costScope) && (costScope.taskExecution === undefined || names(costScope.taskExecution)),
|
|
527
|
+
'resources.costScope.taskExecution',
|
|
528
|
+
'must be a list of component names',
|
|
529
|
+
);
|
|
530
|
+
need(
|
|
531
|
+
pairs,
|
|
532
|
+
REQUIRED_TASK_COMPONENTS.every((name) => (costScope?.taskExecution ?? REQUIRED_TASK_COMPONENTS).includes(name)),
|
|
533
|
+
'resources.costScope.taskExecution',
|
|
534
|
+
`must include ${REQUIRED_TASK_COMPONENTS.join(' and ')}: task execution cost is all-in, retries included`,
|
|
535
|
+
);
|
|
536
|
+
need(
|
|
537
|
+
pairs,
|
|
538
|
+
isObject(costScope) && (costScope.judge === undefined || names(costScope.judge)),
|
|
539
|
+
'resources.costScope.judge',
|
|
540
|
+
'must be a list of component names',
|
|
541
|
+
);
|
|
542
|
+
need(
|
|
543
|
+
pairs,
|
|
544
|
+
!(
|
|
545
|
+
Array.isArray(costScope?.judge) &&
|
|
546
|
+
costScope.judge.some((name) => (costScope?.taskExecution ?? REQUIRED_TASK_COMPONENTS).includes(name))
|
|
547
|
+
),
|
|
548
|
+
'resources.costScope.judge',
|
|
549
|
+
'must name no task execution component',
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
need(pairs, isNonEmptyString(decl.frozen?.digest), 'frozen.digest', 'a paired contrast needs a frozen declaration');
|
|
553
|
+
if (decl.stage === 'confirmation') {
|
|
554
|
+
const pin = decl.exposure?.ledgerHeadAtFreeze;
|
|
555
|
+
need(
|
|
556
|
+
pairs,
|
|
557
|
+
isNonEmptyString(pin?.head) && isPositiveInteger(pin?.length),
|
|
558
|
+
'exposure.ledgerHeadAtFreeze',
|
|
559
|
+
'a confirmation pins the selection ledger it was frozen against',
|
|
560
|
+
);
|
|
561
|
+
need(
|
|
562
|
+
pairs,
|
|
563
|
+
isNonEmptyString(decl.selection?.declarationDigest) && isNonEmptyString(decl.selection?.reportDigest),
|
|
564
|
+
'selection',
|
|
565
|
+
'a confirmation pins the digests of the selection declaration and of its selection-cleared report',
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const digest = declarationDigest(decl);
|
|
570
|
+
const declared = isNonEmptyString(decl.frozen?.digest);
|
|
571
|
+
return { problems, pairedProblems, digest, frozen: { declared, matches: declared ? decl.frozen.digest === digest : null } };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// evidence rows
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
/** Milliseconds of an ISO 8601 timestamp with a zone, or null. */
|
|
579
|
+
function timestampMs(value) {
|
|
580
|
+
if (typeof value !== 'string' || !TIMESTAMP.test(value)) return null;
|
|
581
|
+
const ms = Date.parse(value);
|
|
582
|
+
return Number.isFinite(ms) ? ms : null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** A row's receipt when its shape holds: kind, workspace, ordered ISO times and the artifact hash it claims. */
|
|
586
|
+
function validReceipt(raw) {
|
|
587
|
+
const receipt = raw.receipt;
|
|
588
|
+
if (!isObject(receipt)) return { receipt: null, problem: null };
|
|
589
|
+
if (receipt.kind !== 'fresh-workspace') return { receipt: null, problem: 'receipt kind is not fresh-workspace' };
|
|
590
|
+
if (!isNonEmptyString(receipt.workspaceId)) return { receipt: null, problem: 'receipt has no workspaceId' };
|
|
591
|
+
if (!isNonEmptyString(receipt.startedAt) || !isNonEmptyString(receipt.endedAt))
|
|
592
|
+
return { receipt: null, problem: 'receipt has no start and end time' };
|
|
593
|
+
const started = timestampMs(receipt.startedAt);
|
|
594
|
+
const ended = timestampMs(receipt.endedAt);
|
|
595
|
+
if (started === null || ended === null) return { receipt: null, problem: 'receipt start and end are not ISO 8601 timestamps' };
|
|
596
|
+
if (ended < started) return { receipt: null, problem: 'receipt ends before it starts' };
|
|
597
|
+
const savedNothing =
|
|
598
|
+
raw.status !== 'completed' &&
|
|
599
|
+
(raw.outputSha256 ?? null) === null &&
|
|
600
|
+
(receipt.artifactSha256 ?? null) === null &&
|
|
601
|
+
(raw.outputText ?? null) === null;
|
|
602
|
+
if (!savedNothing && (!isNonEmptyString(receipt.artifactSha256) || receipt.artifactSha256 !== raw.outputSha256)) {
|
|
603
|
+
return { receipt: null, problem: 'receipt artifactSha256 does not match outputSha256' };
|
|
604
|
+
}
|
|
605
|
+
return { receipt: clone(receipt), problem: null };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** A copy of a row's metrics or counts with missing values as null. */
|
|
609
|
+
function numberMap(value) {
|
|
610
|
+
const out = Object.create(null);
|
|
611
|
+
for (const [key, entry] of Object.entries(isObject(value) ? value : {})) out[key] = entry === null || entry === undefined ? null : entry;
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** One raw evidence row into the normalized form the analysis reads. Never throws. */
|
|
616
|
+
function normalizeRow(raw, inputIndex) {
|
|
617
|
+
const problems = [];
|
|
618
|
+
const source = isObject(raw) ? raw : {};
|
|
619
|
+
if (!isObject(raw)) problems.push('row is not an object');
|
|
620
|
+
if (!isNonEmptyString(source.attemptId)) problems.push('no attemptId');
|
|
621
|
+
if (!ARMS.includes(source.arm)) problems.push('arm must be A or B');
|
|
622
|
+
if (!isNonEmptyString(source.caseId)) problems.push('no caseId');
|
|
623
|
+
if (!isPositiveInteger(source.repetition)) problems.push('repetition must be a positive integer');
|
|
624
|
+
if (!STATUSES.includes(source.status)) problems.push(`status must be one of ${STATUSES.join(', ')}`);
|
|
625
|
+
|
|
626
|
+
const { receipt, problem: receiptProblem } = validReceipt(source);
|
|
627
|
+
const metrics = numberMap(source.metrics);
|
|
628
|
+
const counts = numberMap(source.counts);
|
|
629
|
+
const nonFinite = [...Object.entries(metrics), ...Object.entries(counts)].some(([, value]) => value !== null && !isFiniteNumber(value));
|
|
630
|
+
const text = typeof source.outputText === 'string' ? source.outputText : null;
|
|
631
|
+
const hashMismatch = text !== null && isNonEmptyString(source.outputSha256) && `sha256:${sha256Hex(text)}` !== source.outputSha256;
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
inputIndex,
|
|
635
|
+
attemptId: isNonEmptyString(source.attemptId) ? source.attemptId : null,
|
|
636
|
+
runId: source.runId ?? null,
|
|
637
|
+
stage: source.stage ?? null,
|
|
638
|
+
arm: ARMS.includes(source.arm) ? source.arm : null,
|
|
639
|
+
caseId: isNonEmptyString(source.caseId) ? source.caseId : null,
|
|
640
|
+
clusterId: isNonEmptyString(source.clusterId) ? source.clusterId : isNonEmptyString(source.caseId) ? source.caseId : null,
|
|
641
|
+
blockId: source.blockId ?? null,
|
|
642
|
+
buildId: source.buildId ?? null,
|
|
643
|
+
repetition: isPositiveInteger(source.repetition) ? source.repetition : null,
|
|
644
|
+
attemptNumber: isPositiveInteger(source.attempt) ? source.attempt : null,
|
|
645
|
+
status: source.status ?? null,
|
|
646
|
+
error: source.error ?? null,
|
|
647
|
+
retryOf: source.retryOf ?? null,
|
|
648
|
+
receipt,
|
|
649
|
+
receiptProblem: receiptProblem ?? (source.receipt && !receipt ? 'receipt is not an object' : null),
|
|
650
|
+
inputSha256: source.inputSha256 ?? null,
|
|
651
|
+
outputSha256: source.outputSha256 ?? null,
|
|
652
|
+
outputText: text,
|
|
653
|
+
hashMismatch,
|
|
654
|
+
native: clone(source.native ?? null),
|
|
655
|
+
metrics,
|
|
656
|
+
counts,
|
|
657
|
+
notApplicable: Array.isArray(source.notApplicable) ? [...source.notApplicable].sort() : [],
|
|
658
|
+
nonFinite,
|
|
659
|
+
critical: Array.isArray(source.critical) ? [...source.critical] : [],
|
|
660
|
+
usage: isObject(source.usage) ? numberMap(source.usage) : null,
|
|
661
|
+
cost: isObject(source.cost) ? { currency: source.cost.currency ?? null, components: numberMap(source.cost.components) } : null,
|
|
662
|
+
latency: isObject(source.latency) ? numberMap(source.latency) : null,
|
|
663
|
+
observed: clone(source.observed ?? null),
|
|
664
|
+
historicalContext: clone(source.historicalContext ?? null),
|
|
665
|
+
provenance: clone(source.provenance ?? null),
|
|
666
|
+
problems,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Price token usage. Null unless every rate and every token count is present and finite. */
|
|
671
|
+
function priceUsage(usage, pricing) {
|
|
672
|
+
if (!isObject(usage) || !isObject(pricing?.perMillionTokens) || !isNonEmptyString(pricing?.currency)) return null;
|
|
673
|
+
let total = 0;
|
|
674
|
+
for (const category of TOKEN_CATEGORIES) {
|
|
675
|
+
const tokens = usage[category];
|
|
676
|
+
const rate = pricing.perMillionTokens[category];
|
|
677
|
+
if (!isFiniteNumber(tokens) || tokens < 0 || !isFiniteNumber(rate) || rate < 0) return null;
|
|
678
|
+
total += (tokens * rate) / 1e6;
|
|
679
|
+
}
|
|
680
|
+
return { currency: pricing.currency, amount: total };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* The cost components of one attempt. A missing component is unknown, never zero.
|
|
685
|
+
* `target` can be derived from complete usage and a complete price sheet; the
|
|
686
|
+
* derivation is recorded.
|
|
687
|
+
*/
|
|
688
|
+
function attemptCostComponents(row, pricing) {
|
|
689
|
+
const components = { ...row.cost?.components };
|
|
690
|
+
const derived = [];
|
|
691
|
+
const currency = row.cost?.currency ?? null;
|
|
692
|
+
if (!isFiniteNumber(components.target)) {
|
|
693
|
+
const priced = priceUsage(row.usage, pricing);
|
|
694
|
+
if (priced && (currency === null || currency === priced.currency)) {
|
|
695
|
+
components.target = priced.amount;
|
|
696
|
+
derived.push('target');
|
|
697
|
+
return { components, currency: priced.currency, derived };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return { components, currency, derived };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function scopedCost(rows, names, pricing) {
|
|
704
|
+
const parts = [];
|
|
705
|
+
const unknown = [];
|
|
706
|
+
const currencies = new Set();
|
|
707
|
+
for (const row of rows) {
|
|
708
|
+
const { components, currency } = attemptCostComponents(row, pricing);
|
|
709
|
+
for (const name of names) {
|
|
710
|
+
if (isFiniteNumber(components[name]) && components[name] >= 0 && currency !== null) {
|
|
711
|
+
parts.push(components[name]);
|
|
712
|
+
currencies.add(currency);
|
|
713
|
+
} else {
|
|
714
|
+
parts.push(null);
|
|
715
|
+
unknown.push({ attemptId: row.attemptId, component: name });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
const mixed = currencies.size > 1;
|
|
720
|
+
const total = mixed || rows.length === 0 ? null : knownSum(parts);
|
|
721
|
+
return { total, unknown, currency: currencies.size === 1 ? [...currencies][0] : null, mixedCurrency: mixed };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Drop copies. Two rows with one attemptId are one trial; two attempts that claim one
|
|
726
|
+
* fresh workspace are one trial. The later row is excluded and says why. A copy that
|
|
727
|
+
* differs from the first in anything but its attempt id and input position conflicts.
|
|
728
|
+
*/
|
|
729
|
+
function rowContent(row) {
|
|
730
|
+
const { inputIndex, attemptId, problems, ...content } = row;
|
|
731
|
+
void [inputIndex, attemptId, problems];
|
|
732
|
+
return canonicalJson(content);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Kept rows and excluded copies, malformed rows and shared workspaces, each exclusion with its reason. */
|
|
736
|
+
function classifyRows(rows) {
|
|
737
|
+
const byAttempt = new Map();
|
|
738
|
+
const byWorkspace = new Map();
|
|
739
|
+
const kept = [];
|
|
740
|
+
const excluded = [];
|
|
741
|
+
for (const row of rows) {
|
|
742
|
+
if (row.problems.length > 0) {
|
|
743
|
+
excluded.push({ row, reason: 'malformed-row', detail: row.problems.join('; ') });
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (byAttempt.has(row.attemptId)) {
|
|
747
|
+
const first = byAttempt.get(row.attemptId);
|
|
748
|
+
excluded.push({
|
|
749
|
+
row,
|
|
750
|
+
reason: 'duplicate-attempt-id',
|
|
751
|
+
detail: `attemptId already used by ${first.arm}/${first.caseId}`,
|
|
752
|
+
sharedAcrossArms: first.arm !== row.arm,
|
|
753
|
+
conflicting: rowContent(row) !== rowContent(first),
|
|
754
|
+
});
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
byAttempt.set(row.attemptId, row);
|
|
758
|
+
if (row.receipt) {
|
|
759
|
+
const owner = byWorkspace.get(row.receipt.workspaceId);
|
|
760
|
+
if (owner) {
|
|
761
|
+
excluded.push({
|
|
762
|
+
row,
|
|
763
|
+
reason: 'copied-trial',
|
|
764
|
+
detail: `shares fresh workspace ${row.receipt.workspaceId} with ${owner.attemptId}`,
|
|
765
|
+
sharedAcrossArms: owner.arm !== row.arm,
|
|
766
|
+
conflicting: rowContent(row) !== rowContent(owner),
|
|
767
|
+
});
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
byWorkspace.set(row.receipt.workspaceId, row);
|
|
771
|
+
}
|
|
772
|
+
kept.push(row);
|
|
773
|
+
}
|
|
774
|
+
return { kept, excluded };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
// statistics
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
/** mulberry32: a 32-bit seeded generator. Returns floats in [0, 1). */
|
|
782
|
+
function mulberry32(seed) {
|
|
783
|
+
let state = seed >>> 0;
|
|
784
|
+
return () => {
|
|
785
|
+
state = (state + 0x6d_2b_79_f5) >>> 0;
|
|
786
|
+
let t = state;
|
|
787
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
788
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
789
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function quantileOf(sortedAscending, probability, convention) {
|
|
794
|
+
const count = sortedAscending.length;
|
|
795
|
+
if (convention === 'nearest-rank') return sortedAscending[Math.max(0, Math.ceil(probability * count) - 1)];
|
|
796
|
+
const position = (count - 1) * probability;
|
|
797
|
+
const low = Math.floor(position);
|
|
798
|
+
const high = Math.ceil(position);
|
|
799
|
+
return sortedAscending[low] + (position - low) * (sortedAscending[high] - sortedAscending[low]);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Equal-weight paired case-cluster percentile bootstrap.
|
|
804
|
+
*
|
|
805
|
+
* @param {object} input
|
|
806
|
+
* @param {Object<string, number[]>} input.contrasts Per contrast, one finite number per cluster.
|
|
807
|
+
* @param {number} input.B Resample count.
|
|
808
|
+
* @param {number} input.seed uint32 seed of mulberry32.
|
|
809
|
+
* @param {number} input.alpha Level of the one-sided lower bound.
|
|
810
|
+
* @param {'linear'|'nearest-rank'} input.quantile Interpolation convention.
|
|
811
|
+
* @returns {Object<string, {estimate: number, lower: number, clusters: number, degenerate: boolean}>}
|
|
812
|
+
*/
|
|
813
|
+
function pairedClusterBootstrap({ contrasts, B, seed, alpha, quantile }) {
|
|
814
|
+
const names = Object.keys(contrasts);
|
|
815
|
+
const out = {};
|
|
816
|
+
if (names.length === 0) return out;
|
|
817
|
+
const n = contrasts[names[0]].length;
|
|
818
|
+
const random = mulberry32(seed);
|
|
819
|
+
const resampled = Object.fromEntries(names.map((name) => [name, Array.from({ length: B })]));
|
|
820
|
+
for (let b = 0; b < B; b += 1) {
|
|
821
|
+
const sums = Object.fromEntries(names.map((name) => [name, 0]));
|
|
822
|
+
for (let draw = 0; draw < n; draw += 1) {
|
|
823
|
+
const index = Math.floor(random() * n);
|
|
824
|
+
for (const name of names) sums[name] += contrasts[name][index];
|
|
825
|
+
}
|
|
826
|
+
for (const name of names) resampled[name][b] = sums[name] / n;
|
|
827
|
+
}
|
|
828
|
+
for (const name of names) {
|
|
829
|
+
const values = contrasts[name];
|
|
830
|
+
const sorted = [...resampled[name]].sort((left, right) => left - right);
|
|
831
|
+
out[name] = {
|
|
832
|
+
estimate: mean(values),
|
|
833
|
+
lower: quantileOf(sorted, alpha, quantile),
|
|
834
|
+
clusters: n,
|
|
835
|
+
degenerate: Math.min(...values) === Math.max(...values),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Reference fixtures for the bootstrap. The expected values were produced by an
|
|
843
|
+
* independent implementation (Python, written separately from this file) and are
|
|
844
|
+
* held to 1e-12. A bootstrap that does not reproduce them cannot support a decision.
|
|
845
|
+
*/
|
|
846
|
+
const CALIBRATION_FIXTURES = deepFreeze([
|
|
847
|
+
{
|
|
848
|
+
name: 'six clusters, linear quantile',
|
|
849
|
+
contrasts: { quality: [0.02, -0.01, 0.03, 0, 0.04, -0.02], cost: [0.3, 0.25, 0.4, 0.2, 0.35, 0.28] },
|
|
850
|
+
B: 1000,
|
|
851
|
+
seed: 20_261_014,
|
|
852
|
+
alpha: 0.05,
|
|
853
|
+
quantile: 'linear',
|
|
854
|
+
expected: {
|
|
855
|
+
quality: { estimate: 0.01, lower: -0.005 },
|
|
856
|
+
cost: { estimate: 0.296_666_666_666_666_7, lower: 0.255 },
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
name: 'eight clusters, nearest-rank quantile',
|
|
861
|
+
contrasts: { quality: [-0.05, 0.01, 0.02, -0.03, 0.04, 0.06, -0.01, 0] },
|
|
862
|
+
B: 2000,
|
|
863
|
+
seed: 7,
|
|
864
|
+
alpha: 0.1,
|
|
865
|
+
quantile: 'nearest-rank',
|
|
866
|
+
expected: { quality: { estimate: 0.005, lower: -0.01 } },
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
name: 'twelve distinct clusters, linear quantile between two distinct order statistics',
|
|
870
|
+
contrasts: { m: [0.013, -0.027, 0.041, 0.008, -0.012, 0.036, 0.022, -0.005, 0.017, -0.031, 0.029, 0.003] },
|
|
871
|
+
B: 1000,
|
|
872
|
+
seed: 314_159,
|
|
873
|
+
alpha: 0.037,
|
|
874
|
+
quantile: 'linear',
|
|
875
|
+
expected: { m: { estimate: 0.007_833_333_333_333_333, lower: -0.003_851_833_333_333_332_5 } },
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
name: 'twelve distinct clusters, nearest-rank quantile beside a distinct lower neighbour',
|
|
879
|
+
contrasts: { m: [0.013, -0.027, 0.041, 0.008, -0.012, 0.036, 0.022, -0.005, 0.017, -0.031, 0.029, 0.003] },
|
|
880
|
+
B: 1000,
|
|
881
|
+
seed: 314_159,
|
|
882
|
+
alpha: 0.0435,
|
|
883
|
+
quantile: 'nearest-rank',
|
|
884
|
+
expected: { m: { estimate: 0.007_833_333_333_333_333, lower: -0.003_416_666_666_666_667 } },
|
|
885
|
+
},
|
|
886
|
+
]);
|
|
887
|
+
|
|
888
|
+
/** Run a bootstrap implementation against the reference fixtures. */
|
|
889
|
+
function runCalibration(bootstrap = pairedClusterBootstrap) {
|
|
890
|
+
const failures = [];
|
|
891
|
+
for (const fixture of CALIBRATION_FIXTURES) {
|
|
892
|
+
let result;
|
|
893
|
+
try {
|
|
894
|
+
result = bootstrap({
|
|
895
|
+
contrasts: clone(fixture.contrasts),
|
|
896
|
+
B: fixture.B,
|
|
897
|
+
seed: fixture.seed,
|
|
898
|
+
alpha: fixture.alpha,
|
|
899
|
+
quantile: fixture.quantile,
|
|
900
|
+
});
|
|
901
|
+
} catch (error) {
|
|
902
|
+
failures.push({ fixture: fixture.name, problem: `threw ${error.message}` });
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
for (const [name, expected] of Object.entries(fixture.expected)) {
|
|
906
|
+
for (const field of ['estimate', 'lower']) {
|
|
907
|
+
const actual = result?.[name]?.[field];
|
|
908
|
+
if (!isFiniteNumber(actual) || Math.abs(actual - expected[field]) > 1e-12) {
|
|
909
|
+
failures.push({ fixture: fixture.name, problem: `${name}.${field} is ${actual}, expected ${expected[field]}` });
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return { passed: failures.length === 0, fixtures: CALIBRATION_FIXTURES.length, failures };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* One gate: the lower bound has to strictly exceed the threshold, by more than floating-point rounding. Equality does not pass.
|
|
919
|
+
*
|
|
920
|
+
* @param {number} lower
|
|
921
|
+
* @param {number} threshold
|
|
922
|
+
* @returns {boolean}
|
|
923
|
+
*/
|
|
924
|
+
function lowerBoundClears(lower, threshold) {
|
|
925
|
+
if (!isFiniteNumber(lower) || !isFiniteNumber(threshold)) return false;
|
|
926
|
+
// An excess of floating-point rounding size is equality, and equality does not pass.
|
|
927
|
+
const rounding = 1e-12 * Math.max(1, Math.abs(lower), Math.abs(threshold));
|
|
928
|
+
return lower - threshold > rounding;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
// exposure ledger
|
|
933
|
+
// ---------------------------------------------------------------------------
|
|
934
|
+
|
|
935
|
+
const LEDGER_GENESIS = 'sha256:genesis';
|
|
936
|
+
|
|
937
|
+
function ledgerEntryHash(entry, previous) {
|
|
938
|
+
const { hash, ...rest } = entry;
|
|
939
|
+
void hash;
|
|
940
|
+
return `sha256:${sha256Hex(`${previous}\n${canonicalJson(rest)}`)}`;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Build a ledger entry that extends `previous` (the head hash of the ledger so far). */
|
|
944
|
+
function appendExposure(ledger, entry) {
|
|
945
|
+
const previous = ledger.length === 0 ? LEDGER_GENESIS : ledger.at(-1).hash;
|
|
946
|
+
const next = { seq: ledger.length + 1, ...entry };
|
|
947
|
+
return [...ledger, { ...next, hash: ledgerEntryHash(next, previous) }];
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Verify the ledger chain. Removing, reordering or editing an entry changes a hash.
|
|
952
|
+
* `pins` carry heads recorded earlier (the frozen declaration, a prior report); each
|
|
953
|
+
* pinned head must still be in the chain at its recorded length.
|
|
954
|
+
*/
|
|
955
|
+
function verifyLedger(ledger, pins = []) {
|
|
956
|
+
const problems = [];
|
|
957
|
+
let previous = LEDGER_GENESIS;
|
|
958
|
+
for (const [index, entry] of ledger.entries()) {
|
|
959
|
+
if (!isObject(entry)) {
|
|
960
|
+
problems.push(`entry ${index + 1} is not an object`);
|
|
961
|
+
previous = null;
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
if (entry.seq !== index + 1) problems.push(`entry ${index + 1} has seq ${entry.seq}`);
|
|
965
|
+
if (entry.hash !== ledgerEntryHash(entry, previous)) problems.push(`entry ${index + 1} does not extend the chain`);
|
|
966
|
+
previous = entry.hash;
|
|
967
|
+
}
|
|
968
|
+
for (const pin of pins) {
|
|
969
|
+
if (!isObject(pin) || !isNonEmptyString(pin.head) || !isPositiveInteger(pin.length)) {
|
|
970
|
+
problems.push('a pinned ledger head needs a head and a length');
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (ledger.length < pin.length || ledger[pin.length - 1].hash !== pin.head) {
|
|
974
|
+
problems.push(`pinned head at length ${pin.length} is no longer in the ledger`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return { ok: problems.length === 0, problems, head: ledger.length === 0 ? null : ledger.at(-1).hash, length: ledger.length };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** The exposure ledger checked against its pins and against what this stage's cases may have seen. */
|
|
981
|
+
function exposureProblems(declaration, ledger, resume, extraPins = []) {
|
|
982
|
+
if (!Array.isArray(ledger)) return { blockers: ['exposure ledger is missing'], burned: [], ledger: null };
|
|
983
|
+
const pins = [...extraPins];
|
|
984
|
+
if (isObject(declaration?.exposure?.ledgerHeadAtFreeze)) pins.push(declaration.exposure.ledgerHeadAtFreeze);
|
|
985
|
+
if (isObject(resume?.ledger)) pins.push(resume.ledger);
|
|
986
|
+
const verified = verifyLedger(ledger, pins);
|
|
987
|
+
const blockers = verified.problems.map((problem) => `exposure ledger: ${problem}`);
|
|
988
|
+
const candidate = declaration?.scope?.candidate?.id;
|
|
989
|
+
const memberships = declaration?.scope?.memberships ?? {};
|
|
990
|
+
const burned = [];
|
|
991
|
+
const confirmation = declaration?.stage === 'confirmation';
|
|
992
|
+
for (const caseId of stageCases(declaration)) {
|
|
993
|
+
const entries = ledger.filter((entry) => entry.caseId === caseId);
|
|
994
|
+
const recorded = entries.some(
|
|
995
|
+
(entry) =>
|
|
996
|
+
(confirmation ? entry.kind === 'confirmation-exposure' : entry.kind === 'selection-exposure') && entry.candidateId === candidate,
|
|
997
|
+
);
|
|
998
|
+
if (!recorded) blockers.push(`exposure of ${caseId} to ${candidate} is not recorded in the ledger`);
|
|
999
|
+
if (!confirmation) {
|
|
1000
|
+
for (const entry of entries) {
|
|
1001
|
+
if (entry.kind === 'confirmation-exposure') burned.push({ caseId, reason: `was used to confirm ${entry.candidateId}` });
|
|
1002
|
+
}
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
for (const entry of entries) {
|
|
1006
|
+
if (entry.kind === 'selection-exposure') burned.push({ caseId, reason: 'was exposed during selection' });
|
|
1007
|
+
else if (entry.kind === 'confirmation-exposure' && entry.candidateId !== candidate) {
|
|
1008
|
+
burned.push({ caseId, reason: `was already used to confirm ${entry.candidateId}` });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
void memberships;
|
|
1013
|
+
return { blockers, burned, ledger: verified };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** What a confirmation keeps from its selection declaration, as leaf paths. */
|
|
1017
|
+
function frozenAcrossStages(declaration) {
|
|
1018
|
+
const copy = clone(declaration);
|
|
1019
|
+
for (const key of ['stage', 'frozen', 'exposure', 'selection']) delete copy[key];
|
|
1020
|
+
if (isObject(copy.scope)) {
|
|
1021
|
+
delete copy.scope.clusters;
|
|
1022
|
+
if (isObject(copy.scope.memberships)) delete copy.scope.memberships.confirmation;
|
|
1023
|
+
}
|
|
1024
|
+
return flatten(copy);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* A confirmation runs only for a candidate that cleared selection. The confirmation
|
|
1029
|
+
* declaration pins the selection declaration and its report by digest. The reader reads
|
|
1030
|
+
* the selection again from its own evidence, with the same scorer and bootstrap, and
|
|
1031
|
+
* the report it gets has to be the pinned one, with a selection-cleared outcome. A
|
|
1032
|
+
* report that was edited after the fact therefore never matches. The confirmation may
|
|
1033
|
+
* change only its stage, its confirmation cases and their clusters, its ledger pin and
|
|
1034
|
+
* these pins. Candidate, baseline, identities, gates, design, analysis and cost rules
|
|
1035
|
+
* stay as selection froze them. The selection report's ledger head is pinned too.
|
|
1036
|
+
*/
|
|
1037
|
+
function selectionChainProblems(declaration, selection, { replay, bootstrap }) {
|
|
1038
|
+
if (declaration.stage !== 'confirmation') return { problems: [], pin: null };
|
|
1039
|
+
const selected = isObject(selection?.declaration) ? selection.declaration : null;
|
|
1040
|
+
if (!selected) return { problems: ['the frozen selection declaration and its evidence are not supplied'], pin: null };
|
|
1041
|
+
const { report } = readExperimentChecked({ ...selection, selection: null, replay, bootstrap });
|
|
1042
|
+
const problems = [];
|
|
1043
|
+
const selectedDigest = declarationDigest(selected);
|
|
1044
|
+
if (selectedDigest !== declaration.selection.declarationDigest)
|
|
1045
|
+
problems.push('the selection declaration does not match selection.declarationDigest');
|
|
1046
|
+
if (selected.frozen?.digest !== selectedDigest) problems.push('the selection declaration is not frozen at its own digest');
|
|
1047
|
+
if (selected.stage !== 'selection')
|
|
1048
|
+
problems.push(`the pinned declaration is a ${selected.stage} stage; a confirmation follows a selection`);
|
|
1049
|
+
if (digestOf(report) !== declaration.selection.reportDigest) {
|
|
1050
|
+
problems.push('the selection evidence does not reproduce the report pinned in selection.reportDigest');
|
|
1051
|
+
}
|
|
1052
|
+
const outcome = report.contrast?.decision?.outcome ?? 'missing';
|
|
1053
|
+
if (outcome !== OUTCOMES.selectionCleared) problems.push(`the selection outcome is ${outcome}; a confirmation needs selection-cleared`);
|
|
1054
|
+
const before = frozenAcrossStages(selected);
|
|
1055
|
+
const after = frozenAcrossStages(declaration);
|
|
1056
|
+
const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
1057
|
+
.filter((key) => canonicalJson(before[key]) !== canonicalJson(after[key]))
|
|
1058
|
+
.sort();
|
|
1059
|
+
if (changed.length > 0) problems.push(`the confirmation changes what selection froze: ${changed.join(', ')}`);
|
|
1060
|
+
const head = report.contrast?.exposure;
|
|
1061
|
+
const pin = isNonEmptyString(head?.head) && isPositiveInteger(head?.length) ? { head: head.head, length: head.length } : null;
|
|
1062
|
+
if (!pin) problems.push('the selection report records no exposure ledger head');
|
|
1063
|
+
return { problems, pin };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ---------------------------------------------------------------------------
|
|
1067
|
+
// descriptive repeatability
|
|
1068
|
+
// ---------------------------------------------------------------------------
|
|
1069
|
+
|
|
1070
|
+
/** Whether a completed row meets every success minimum with no critical miss and no unknown count. */
|
|
1071
|
+
function rowSuccess(row, declaration) {
|
|
1072
|
+
if (row.status !== 'completed') return false;
|
|
1073
|
+
if (criticalMisses(row, declaration).length > 0 || unknownCriticalCounts(row, declaration).length > 0) return false;
|
|
1074
|
+
const floors = declaration.gates.success.metrics;
|
|
1075
|
+
return Object.entries(floors).every(
|
|
1076
|
+
([metric, minimum]) => row.notApplicable.includes(metric) || (isFiniteNumber(row.metrics[metric]) && row.metrics[metric] >= minimum),
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** The declared zero-tolerance rules that name a count. */
|
|
1081
|
+
function criticalRules(declaration) {
|
|
1082
|
+
const rules = declaration?.gates?.critical?.rules;
|
|
1083
|
+
return Array.isArray(rules) ? rules.filter((rule) => isObject(rule) && isNonEmptyString(rule.count)) : [];
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const isCount = (value) => Number.isInteger(value) && value >= 0;
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Critical rules whose count the row does not record as a nonnegative integer. An
|
|
1090
|
+
* unrecorded count is unknown, never zero, and a row's `notApplicable` tag cannot
|
|
1091
|
+
* excuse a zero-tolerance rule.
|
|
1092
|
+
*/
|
|
1093
|
+
function unknownCriticalCounts(row, declaration) {
|
|
1094
|
+
return criticalRules(declaration)
|
|
1095
|
+
.filter((rule) => !isCount(row.counts[rule.count]))
|
|
1096
|
+
.map((rule) => rule.id)
|
|
1097
|
+
.sort();
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** Critical rules a row's counts show as recorded but not as a nonnegative integer. */
|
|
1101
|
+
function invalidCriticalCounts(row, declaration) {
|
|
1102
|
+
return criticalRules(declaration)
|
|
1103
|
+
.filter((rule) => row.counts[rule.count] !== null && row.counts[rule.count] !== undefined && !isCount(row.counts[rule.count]))
|
|
1104
|
+
.map((rule) => rule.id)
|
|
1105
|
+
.sort();
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** The critical rules a row reports as missed, from its own list and its positive counts. */
|
|
1109
|
+
function criticalMisses(row, declaration) {
|
|
1110
|
+
const hits = new Set(row.critical);
|
|
1111
|
+
for (const rule of criticalRules(declaration)) {
|
|
1112
|
+
if (isCount(row.counts[rule.count]) && row.counts[rule.count] > 0) hits.add(rule.id);
|
|
1113
|
+
}
|
|
1114
|
+
return [...hits].sort();
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function spreadOf(values) {
|
|
1118
|
+
const known = values.filter((value) => isFiniteNumber(value));
|
|
1119
|
+
if (known.length === 0) return { n: 0, unmeasurable: values.length, min: null, max: null, range: null, mean: null };
|
|
1120
|
+
return {
|
|
1121
|
+
n: known.length,
|
|
1122
|
+
unmeasurable: values.length - known.length,
|
|
1123
|
+
min: Math.min(...known),
|
|
1124
|
+
max: Math.max(...known),
|
|
1125
|
+
range: Math.max(...known) - Math.min(...known),
|
|
1126
|
+
mean: mean(known),
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function signatureDifferences(signatures) {
|
|
1131
|
+
const parsed = [];
|
|
1132
|
+
for (const signature of signatures) {
|
|
1133
|
+
try {
|
|
1134
|
+
const value = JSON.parse(signature);
|
|
1135
|
+
if (!Array.isArray(value) || value.length !== SIGNATURE_COMPONENTS.length) return null;
|
|
1136
|
+
parsed.push(value);
|
|
1137
|
+
} catch {
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return SIGNATURE_COMPONENTS.filter((_, index) => new Set(parsed.map((value) => canonicalJson(value[index]))).size > 1);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function identityGroupOf(row) {
|
|
1145
|
+
if (row.historicalContext) {
|
|
1146
|
+
// A recorded invocation/repetition still identifies retries and duplicate completions.
|
|
1147
|
+
// Without that binding, each retained record is its own descriptive observation.
|
|
1148
|
+
return `historical:${digestOf({
|
|
1149
|
+
invocation: row.runId,
|
|
1150
|
+
trial: row.runId === null ? row.attemptId : row.repetition,
|
|
1151
|
+
requested: row.historicalContext,
|
|
1152
|
+
observed: row.observed,
|
|
1153
|
+
})}`;
|
|
1154
|
+
}
|
|
1155
|
+
return row.observed ? digestOf(row.observed) : 'unobserved';
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function describeCases(declaration, rows, plan, nativeSignature) {
|
|
1159
|
+
const cases = [];
|
|
1160
|
+
for (const entry of plan) {
|
|
1161
|
+
const own = rows.filter((row) => row.caseId === entry.caseId);
|
|
1162
|
+
const byRepetition = new Map();
|
|
1163
|
+
const extraCompleted = [];
|
|
1164
|
+
const ordered = own
|
|
1165
|
+
.filter((row) => row.status === 'completed')
|
|
1166
|
+
.sort((left, right) => (left.attemptNumber ?? 0) - (right.attemptNumber ?? 0) || left.inputIndex - right.inputIndex);
|
|
1167
|
+
for (const row of ordered) {
|
|
1168
|
+
const key = `${row.runId ?? ''}\u0000${row.repetition}`;
|
|
1169
|
+
if (byRepetition.has(key)) extraCompleted.push(row.attemptId);
|
|
1170
|
+
else byRepetition.set(key, row);
|
|
1171
|
+
}
|
|
1172
|
+
const completed = [...byRepetition.values()].sort((left, right) => left.inputIndex - right.inputIndex);
|
|
1173
|
+
const successes = completed.filter((row) => rowSuccess(row, declaration));
|
|
1174
|
+
const metricNames = [...new Set(completed.flatMap((row) => Object.keys(row.metrics)))].sort();
|
|
1175
|
+
const signatures = completed.map((row) => nativeSignature(row)).filter((signature) => typeof signature === 'string');
|
|
1176
|
+
const distinct = new Set(signatures);
|
|
1177
|
+
cases.push({
|
|
1178
|
+
caseId: entry.caseId,
|
|
1179
|
+
clusterId: entry.clusterId,
|
|
1180
|
+
planned: declaration.design.repeats,
|
|
1181
|
+
attempts: own.length,
|
|
1182
|
+
completed: completed.length,
|
|
1183
|
+
extraCompleted,
|
|
1184
|
+
distinctOutputs: new Set(completed.map((row) => row.outputSha256)).size,
|
|
1185
|
+
freshReceipted: completed.filter((row) => row.receipt).length,
|
|
1186
|
+
importedHistorical: completed.filter((row) => !row.receipt).length,
|
|
1187
|
+
incomplete: Math.max(0, declaration.design.repeats - completed.length),
|
|
1188
|
+
successes: successes.length,
|
|
1189
|
+
successFraction: completed.length === 0 ? null : successes.length / completed.length,
|
|
1190
|
+
metrics: Object.fromEntries(
|
|
1191
|
+
metricNames.map((name) => [name, spreadOf(completed.filter((row) => name in row.metrics).map((row) => row.metrics[name]))]),
|
|
1192
|
+
),
|
|
1193
|
+
criticalMisses: completed
|
|
1194
|
+
.map((row) => ({ attemptId: row.attemptId, rules: criticalMisses(row, declaration) }))
|
|
1195
|
+
.filter((hit) => hit.rules.length > 0),
|
|
1196
|
+
stability: {
|
|
1197
|
+
signed: signatures.length,
|
|
1198
|
+
distinct: distinct.size,
|
|
1199
|
+
stable: signatures.length >= 2 && signatures.length === completed.length ? distinct.size === 1 : null,
|
|
1200
|
+
differingComponents: signatures.length >= 2 ? signatureDifferences(signatures) : null,
|
|
1201
|
+
},
|
|
1202
|
+
sources: own.map((row) => row.attemptId),
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
return cases;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Replay the deterministic scorer over the saved bytes of each document, twice.
|
|
1210
|
+
* Rows are grouped by output hash, so a replay only ever compares identical bytes.
|
|
1211
|
+
*/
|
|
1212
|
+
function replayControl(rows, replay, nativeSignatureOf) {
|
|
1213
|
+
if (typeof replay !== 'function') return { status: 'not-provided', reason: 'no deterministic scorer was supplied', results: [] };
|
|
1214
|
+
const results = [];
|
|
1215
|
+
for (const row of rows) {
|
|
1216
|
+
if (row.status !== 'completed') continue;
|
|
1217
|
+
if (row.outputText === null) {
|
|
1218
|
+
results.push({ attemptId: row.attemptId, status: 'no-bytes', reason: 'the saved document is not part of the evidence' });
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
if (row.hashMismatch) {
|
|
1222
|
+
results.push({ attemptId: row.attemptId, status: 'refused', reason: 'saved bytes do not match outputSha256' });
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
let first;
|
|
1226
|
+
let second;
|
|
1227
|
+
try {
|
|
1228
|
+
first = replay(row.outputText, row);
|
|
1229
|
+
second = replay(row.outputText, row);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
results.push({ attemptId: row.attemptId, status: 'refused', reason: `the scorer threw: ${error.message}` });
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (first?.unmeasurable || second?.unmeasurable) {
|
|
1235
|
+
results.push({
|
|
1236
|
+
attemptId: row.attemptId,
|
|
1237
|
+
caseId: row.caseId,
|
|
1238
|
+
status: 'unavailable',
|
|
1239
|
+
unmeasurable: first?.unmeasurable ?? second?.unmeasurable,
|
|
1240
|
+
reason: first?.unmeasurable ?? second?.unmeasurable,
|
|
1241
|
+
});
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const deterministic = canonicalJson(first) === canonicalJson(second);
|
|
1245
|
+
const nativeSignature = nativeSignatureOf(row);
|
|
1246
|
+
let nativeAgreement = 'not-comparable';
|
|
1247
|
+
if (typeof first?.signature === 'string' && typeof nativeSignature === 'string') {
|
|
1248
|
+
nativeAgreement = first.signature === nativeSignature ? 'match' : 'differs';
|
|
1249
|
+
}
|
|
1250
|
+
results.push({
|
|
1251
|
+
attemptId: row.attemptId,
|
|
1252
|
+
caseId: row.caseId,
|
|
1253
|
+
repetition: row.repetition,
|
|
1254
|
+
outputSha256: row.outputSha256,
|
|
1255
|
+
status: 'replayed',
|
|
1256
|
+
deterministic,
|
|
1257
|
+
signature: first?.signature ?? null,
|
|
1258
|
+
unmeasurable: first?.unmeasurable ?? null,
|
|
1259
|
+
nativeAgreement,
|
|
1260
|
+
note: 'separate from the native record; a parser correction produces a new replay, never a new native outcome',
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
return { status: 'replayed', results };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Replay saved bytes as one fixed output. Refuses unless every row names the same
|
|
1268
|
+
* artifact hash and every saved text hashes to it.
|
|
1269
|
+
*/
|
|
1270
|
+
function replayFixedOutput(rows, replay) {
|
|
1271
|
+
const hashes = new Set(rows.map((row) => row.outputSha256));
|
|
1272
|
+
if (rows.length === 0 || hashes.size !== 1 || hashes.has(null) || hashes.has(undefined)) {
|
|
1273
|
+
return { ok: false, reason: 'a fixed-output replay needs one identical artifact hash on every row' };
|
|
1274
|
+
}
|
|
1275
|
+
const [expected] = hashes;
|
|
1276
|
+
for (const row of rows) {
|
|
1277
|
+
if (typeof row.outputText !== 'string' || `sha256:${sha256Hex(row.outputText)}` !== expected) {
|
|
1278
|
+
return { ok: false, reason: `the saved bytes of ${row.attemptId} do not hash to ${expected}` };
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
const outputs = rows.map((row) => canonicalJson(replay(row.outputText, row)));
|
|
1282
|
+
return { ok: true, outputSha256: expected, runs: rows.length, identical: new Set(outputs).size === 1 };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function describeJudge(declaration, judgeCalls) {
|
|
1286
|
+
if (declaration.scope?.judge === 'none') {
|
|
1287
|
+
return {
|
|
1288
|
+
status: 'not-applicable',
|
|
1289
|
+
reason: 'the measured path scores with a deterministic parser; there is no rubric judge to disagree',
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
if (!Array.isArray(judgeCalls)) {
|
|
1293
|
+
return { status: 'not-provided', reason: 'a rubric judge is declared but no repeated judgments of identical output were supplied' };
|
|
1294
|
+
}
|
|
1295
|
+
return analyzeJudgeCalls(judgeCalls);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Disagreement of a stochastic judge on identical, frozen output, kept apart from
|
|
1300
|
+
* target variation. Controls labeled good or bad test validity: a judge that is
|
|
1301
|
+
* stable and wrong is still invalid.
|
|
1302
|
+
*
|
|
1303
|
+
* @param {Array<{callId: string, outputSha256: string, inputSha256: string, rubricSha256: string, judgeIdentity: string, verdict: 'pass'|'fail'|null, rationale?: string, label?: 'good'|'bad', receipt?: object}>} calls
|
|
1304
|
+
*/
|
|
1305
|
+
function analyzeJudgeCalls(calls) {
|
|
1306
|
+
const groups = new Map();
|
|
1307
|
+
const malformed = calls.filter((call) => !isObject(call));
|
|
1308
|
+
for (const call of calls.filter((entry) => isObject(entry))) {
|
|
1309
|
+
const key = call.outputSha256;
|
|
1310
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
1311
|
+
groups.get(key).push(call);
|
|
1312
|
+
}
|
|
1313
|
+
const outputs = [];
|
|
1314
|
+
for (const [outputSha256, members] of groups) {
|
|
1315
|
+
const coherent = new Set(members.map((call) => canonicalJson([call.inputSha256, call.rubricSha256, call.judgeIdentity]))).size === 1;
|
|
1316
|
+
const receipted =
|
|
1317
|
+
members.every((call) => isObject(call.receipt) && isNonEmptyString(call.receipt.callId) && call.receipt.callId === call.callId) &&
|
|
1318
|
+
new Set(members.map((call) => call.callId)).size === members.length;
|
|
1319
|
+
const verdicts = members.map((call) => call.verdict ?? null);
|
|
1320
|
+
const known = verdicts.filter((verdict) => verdict !== null);
|
|
1321
|
+
const labels = new Set(members.map((call) => call.label ?? null));
|
|
1322
|
+
const label = labels.size === 1 ? [...labels][0] : null;
|
|
1323
|
+
const disagreement = new Set(known).size > 1;
|
|
1324
|
+
outputs.push({
|
|
1325
|
+
outputSha256,
|
|
1326
|
+
calls: members.length,
|
|
1327
|
+
missing: verdicts.length - known.length,
|
|
1328
|
+
coherentIdentity: coherent,
|
|
1329
|
+
freshReceipts: receipted,
|
|
1330
|
+
verdicts: known,
|
|
1331
|
+
disagreement,
|
|
1332
|
+
label,
|
|
1333
|
+
correct: label === null ? null : known.length > 0 && known.every((verdict) => verdict === (label === 'good' ? 'pass' : 'fail')),
|
|
1334
|
+
sources: members.map((call) => call.callId),
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
const controls = outputs.filter((output) => output.label === 'good' || output.label === 'bad');
|
|
1338
|
+
const controlsPresent = controls.some((output) => output.label === 'good') && controls.some((output) => output.label === 'bad');
|
|
1339
|
+
const usable = malformed.length === 0 && outputs.every((output) => output.coherentIdentity && output.freshReceipts && output.calls >= 2);
|
|
1340
|
+
const valid =
|
|
1341
|
+
usable && controlsPresent && controls.every((output) => output.correct === true && output.missing === 0 && !output.disagreement);
|
|
1342
|
+
return {
|
|
1343
|
+
status: usable ? 'analyzed' : 'blocked',
|
|
1344
|
+
reason: usable
|
|
1345
|
+
? null
|
|
1346
|
+
: 'every call has to be an object, and every output needs at least two receipted judge calls on one input, rubric and judge identity',
|
|
1347
|
+
outputs,
|
|
1348
|
+
disagreementRate: outputs.length === 0 ? null : outputs.filter((output) => output.disagreement).length / outputs.length,
|
|
1349
|
+
missingRate:
|
|
1350
|
+
calls.length === 0 ? null : calls.filter((call) => !isObject(call) || (call.verdict ?? null) === null).length / calls.length,
|
|
1351
|
+
controls: { present: controlsPresent, correct: controls.filter((output) => output.correct === true).length, total: controls.length },
|
|
1352
|
+
valid,
|
|
1353
|
+
validity: valid ? 'valid' : 'invalid: the judge is not shown to be stable and correct on labeled good and bad controls',
|
|
1354
|
+
note: 'computed on identical frozen output, so target variation does not enter',
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function describeRepeatability({ declaration, rows, excluded, replay, judgeCalls, nativeRecords }) {
|
|
1359
|
+
const baselineRows = rows.filter((row) => row.arm === 'A');
|
|
1360
|
+
// Stability is what the native runs signed; a row without a loaded record has no known signature.
|
|
1361
|
+
const nativeSignature = (row) => {
|
|
1362
|
+
const signature = nativeRecordOf(row, nativeRecords)?.signature;
|
|
1363
|
+
return typeof signature === 'string' ? signature : null;
|
|
1364
|
+
};
|
|
1365
|
+
const groups = new Map();
|
|
1366
|
+
for (const row of baselineRows) {
|
|
1367
|
+
const key = identityGroupOf(row);
|
|
1368
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
1369
|
+
groups.get(key).push(row);
|
|
1370
|
+
}
|
|
1371
|
+
const plan = clusterPlan(declaration).filter((entry) => stageCases(declaration).includes(entry.caseId));
|
|
1372
|
+
const identityGroups = [...groups.entries()].map(([identity, members]) => ({
|
|
1373
|
+
identity: members[0].historicalContext ? null : identity,
|
|
1374
|
+
observed: members[0].observed,
|
|
1375
|
+
...(members[0].historicalContext
|
|
1376
|
+
? {
|
|
1377
|
+
configurationComparability: 'unknown',
|
|
1378
|
+
invocationId: members[0].runId,
|
|
1379
|
+
requested: { agent: members[0].historicalContext.agent, model: members[0].historicalContext.requestedModel },
|
|
1380
|
+
}
|
|
1381
|
+
: {}),
|
|
1382
|
+
cases: describeCases(declaration, members, plan, nativeSignature),
|
|
1383
|
+
}));
|
|
1384
|
+
const uncertainHistorical = baselineRows.some((row) => row.historicalContext);
|
|
1385
|
+
return {
|
|
1386
|
+
mixedIdentity: uncertainHistorical ? null : identityGroups.length > 1,
|
|
1387
|
+
...(uncertainHistorical ? { configurationComparability: 'unknown' } : {}),
|
|
1388
|
+
note: uncertainHistorical
|
|
1389
|
+
? 'historical configuration comparability is unknown; observations are separated by recorded invocation/repetition or individual record, and requested runner/model hints establish no applied configuration'
|
|
1390
|
+
: identityGroups.length > 1
|
|
1391
|
+
? 'rows carry more than one observed identity, so statistics are kept per identity and never pooled'
|
|
1392
|
+
: 'total target variability across repetitions; none of it is attributed to a judge',
|
|
1393
|
+
freshIndependentTrials: baselineRows.filter((row) => row.status === 'completed' && row.receipt).length,
|
|
1394
|
+
importedHistoricalTrials: baselineRows.filter((row) => row.status === 'completed' && !row.receipt).length,
|
|
1395
|
+
excludedCopies: excluded.filter((entry) => entry.row.arm === 'A').length,
|
|
1396
|
+
groups: identityGroups,
|
|
1397
|
+
replay: replayControl(baselineRows, replay, nativeSignature),
|
|
1398
|
+
judge: describeJudge(declaration, judgeCalls),
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ---------------------------------------------------------------------------
|
|
1403
|
+
// paired contrast
|
|
1404
|
+
// ---------------------------------------------------------------------------
|
|
1405
|
+
|
|
1406
|
+
const sameNumber = (left, right) =>
|
|
1407
|
+
(left ?? null) === (right ?? null) ||
|
|
1408
|
+
(isFiniteNumber(left) && isFiniteNumber(right) && Math.abs(left - right) <= 1e-12 * Math.max(1, Math.abs(left), Math.abs(right)));
|
|
1409
|
+
|
|
1410
|
+
/** The scorer's reading of a row's saved bytes, or null when there are no checkable bytes or no scorer. */
|
|
1411
|
+
function replayRow(replay, row) {
|
|
1412
|
+
if (typeof replay !== 'function' || row.outputText === null || row.hashMismatch) return null;
|
|
1413
|
+
try {
|
|
1414
|
+
return replay(row.outputText, row) ?? null;
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
return { unmeasurable: `the scorer threw: ${error.message}` };
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Native records the reader accepts, by where they came from. `loadRecordedTestDesignRun`
|
|
1422
|
+
* adds each record it builds from a manifest-checked run; `trustNativeRecords` adds
|
|
1423
|
+
* records a test adapter builds, and says so in the evidence index. An object built any
|
|
1424
|
+
* other way is not a native record, whatever it holds.
|
|
1425
|
+
*/
|
|
1426
|
+
const NATIVE_SOURCES = new WeakMap();
|
|
1427
|
+
|
|
1428
|
+
function brandNativeRecord(record, source) {
|
|
1429
|
+
const frozen = deepFreeze(clone(record));
|
|
1430
|
+
NATIVE_SOURCES.set(frozen, source);
|
|
1431
|
+
return frozen;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Mark records as native records of a trusted test adapter. For synthetic fixtures only:
|
|
1436
|
+
* the evidence index names them `trusted-adapter`, apart from manifest-checked records.
|
|
1437
|
+
*
|
|
1438
|
+
* @param {Object<string, object>} records Records by reference.
|
|
1439
|
+
* @returns {Object<string, object>} The marked records, frozen.
|
|
1440
|
+
*/
|
|
1441
|
+
function trustNativeRecords(records) {
|
|
1442
|
+
return Object.fromEntries(Object.entries(records).map(([ref, record]) => [ref, brandNativeRecord(record, 'trusted-adapter')]));
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** The native record a row references, when it is one the reader accepts. */
|
|
1446
|
+
function nativeRecordOf(row, nativeRecords) {
|
|
1447
|
+
const ref = row.native?.ref;
|
|
1448
|
+
if (!isNonEmptyString(ref) || !isObject(nativeRecords) || !Object.hasOwn(nativeRecords, ref)) return null;
|
|
1449
|
+
const record = nativeRecords[ref];
|
|
1450
|
+
return NATIVE_SOURCES.has(record) && record.ref === ref ? record : null;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/** The record fields a row has to repeat exactly. */
|
|
1454
|
+
const NATIVE_BINDING = Object.freeze([
|
|
1455
|
+
['caseId', (row) => row.caseId],
|
|
1456
|
+
['repetition', (row) => row.repetition],
|
|
1457
|
+
['attempt', (row) => row.attemptNumber],
|
|
1458
|
+
['status', (row) => row.status],
|
|
1459
|
+
['inputSha256', (row) => row.inputSha256],
|
|
1460
|
+
['outputSha256', (row) => row.outputSha256],
|
|
1461
|
+
['receipt', (row) => row.receipt],
|
|
1462
|
+
['observed', (row) => row.observed],
|
|
1463
|
+
['blockId', (row) => row.blockId],
|
|
1464
|
+
['usage', (row) => row.usage],
|
|
1465
|
+
['cost', (row) => row.cost],
|
|
1466
|
+
['latency', (row) => row.latency],
|
|
1467
|
+
]);
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Why a row does not match the native record it names. The record comes from a run
|
|
1471
|
+
* whose manifest was checked against its bytes; the row pins that manifest's digest
|
|
1472
|
+
* and repeats the record's attempt identity, hashes, receipt, observed identity,
|
|
1473
|
+
* pairing block, usage, cost and latency.
|
|
1474
|
+
* A signature or qualification the row states has to be the record's own: the native
|
|
1475
|
+
* gates read the record, never the row.
|
|
1476
|
+
*/
|
|
1477
|
+
function nativeRecordProblems(row, record) {
|
|
1478
|
+
if (!record) return [`the native record ${row.native.ref} is not among the records of a manifest-checked run`];
|
|
1479
|
+
const problems = [];
|
|
1480
|
+
if (!isNonEmptyString(record.manifestSha256) || record.manifestSha256 !== row.native.manifestSha256) {
|
|
1481
|
+
problems.push(`the row does not pin the manifest of its native record ${row.native.ref}`);
|
|
1482
|
+
}
|
|
1483
|
+
for (const [field, valueOf] of NATIVE_BINDING) {
|
|
1484
|
+
if (canonicalJson(valueOf(row) ?? null) !== canonicalJson(record[field] ?? null))
|
|
1485
|
+
problems.push(`${field} differs from the native record`);
|
|
1486
|
+
}
|
|
1487
|
+
for (const field of ['signature', 'qualification']) {
|
|
1488
|
+
if (field in row.native && canonicalJson(row.native[field]) !== canonicalJson(record[field] ?? null)) {
|
|
1489
|
+
problems.push(`native ${field} differs from the native record`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
return problems;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Why an attempt's evidence is only claimed. A receipt and a row's numbers are
|
|
1497
|
+
* claims; the reader checks what it can recompute offline. Every attempt, whatever
|
|
1498
|
+
* its status, has to carry a receipt, name its stage and its input, and match the
|
|
1499
|
+
* native record it references (`nativeRecordProblems`). An attempt that completed or claims an artifact also has to carry the saved
|
|
1500
|
+
* bytes, they have to hash to the output and receipt hashes, and the deterministic
|
|
1501
|
+
* scorer, run again over them, has to reproduce every required metric and every
|
|
1502
|
+
* critical count the row reports. An empty list means verified.
|
|
1503
|
+
*/
|
|
1504
|
+
function verificationProblems(row, declaration, replayed, nativeRecords) {
|
|
1505
|
+
const problems = [];
|
|
1506
|
+
if (!row.receipt) problems.push(row.receiptProblem ?? 'no fresh generation receipt');
|
|
1507
|
+
if (row.stage !== declaration.stage) problems.push('the row does not record its stage');
|
|
1508
|
+
if (!isNonEmptyString(row.inputSha256)) problems.push('no input hash');
|
|
1509
|
+
if (isNonEmptyString(row.native?.ref)) problems.push(...nativeRecordProblems(row, nativeRecordOf(row, nativeRecords)));
|
|
1510
|
+
else problems.push('no native record reference');
|
|
1511
|
+
const claimsArtifact = row.status === 'completed' || row.outputText !== null || row.outputSha256 !== null || row.receipt?.artifactSha256;
|
|
1512
|
+
if (!claimsArtifact) return problems;
|
|
1513
|
+
if (row.outputText === null) {
|
|
1514
|
+
problems.push('the artifact bytes are not part of the evidence');
|
|
1515
|
+
return problems;
|
|
1516
|
+
}
|
|
1517
|
+
if (row.hashMismatch || `sha256:${sha256Hex(row.outputText)}` !== row.outputSha256) {
|
|
1518
|
+
problems.push('the artifact bytes do not hash to outputSha256');
|
|
1519
|
+
return problems;
|
|
1520
|
+
}
|
|
1521
|
+
if (replayed === null) {
|
|
1522
|
+
problems.push('no deterministic scorer was supplied to re-derive the metrics and counts');
|
|
1523
|
+
return problems;
|
|
1524
|
+
}
|
|
1525
|
+
if (!isObject(replayed.metrics) || !isObject(replayed.counts)) {
|
|
1526
|
+
problems.push(`the scorer could not score the artifact${replayed.unmeasurable ? ` (${replayed.unmeasurable})` : ''}`);
|
|
1527
|
+
return problems;
|
|
1528
|
+
}
|
|
1529
|
+
for (const metric of Object.keys(declaration.gates.quality)) {
|
|
1530
|
+
if (!sameNumber(row.metrics[metric], replayed.metrics[metric])) {
|
|
1531
|
+
problems.push(`metric ${metric} is ${row.metrics[metric]}, the scorer gives ${replayed.metrics[metric]}`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
for (const rule of criticalRules(declaration)) {
|
|
1535
|
+
if (!sameNumber(row.counts[rule.count], replayed.counts[rule.count])) {
|
|
1536
|
+
problems.push(`count ${rule.count} is ${row.counts[rule.count]}, the scorer gives ${replayed.counts[rule.count]}`);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return problems;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/** The declared arms against the one changed setting: unknown leaves, differing leaves, and what differs outside it. */
|
|
1543
|
+
function checkIdentity(declaration) {
|
|
1544
|
+
const arms = declaration.identity?.arms ?? {};
|
|
1545
|
+
const flatA = flatten(arms.A ?? {});
|
|
1546
|
+
const flatB = flatten(arms.B ?? {});
|
|
1547
|
+
const fixed = flatten(declaration.identity?.fixed ?? {});
|
|
1548
|
+
const unknown = [];
|
|
1549
|
+
for (const [scopeName, flat] of [
|
|
1550
|
+
['identity.fixed', fixed],
|
|
1551
|
+
['identity.arms.A', flatA],
|
|
1552
|
+
['identity.arms.B', flatB],
|
|
1553
|
+
]) {
|
|
1554
|
+
for (const [leaf, value] of Object.entries(flat)) {
|
|
1555
|
+
if (value === null || value === undefined || value === '') unknown.push(`${scopeName}.${leaf}`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
const paths = [...new Set([...Object.keys(flatA), ...Object.keys(flatB)])].sort();
|
|
1559
|
+
const differing = paths.filter((leaf) => canonicalJson(flatA[leaf]) !== canonicalJson(flatB[leaf]));
|
|
1560
|
+
const kind = declaration.scope?.changedFactor?.kind;
|
|
1561
|
+
const changed = changedSetting(declaration.scope?.changedFactor);
|
|
1562
|
+
const outside = differing.filter((leaf) => !(changed?.leaves ?? []).includes(leaf));
|
|
1563
|
+
const unchanged = (changed?.together ?? []).filter((leaf) => !differing.includes(leaf));
|
|
1564
|
+
return {
|
|
1565
|
+
unknown,
|
|
1566
|
+
differing,
|
|
1567
|
+
outside,
|
|
1568
|
+
kind,
|
|
1569
|
+
setting: changed?.setting ?? null,
|
|
1570
|
+
unchanged,
|
|
1571
|
+
aa: differing.length === 0,
|
|
1572
|
+
bundled: outside.length > 0,
|
|
1573
|
+
factorUnchanged: differing.length > 0 && unchanged.length > 0,
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function classifyIdentityKey(key) {
|
|
1578
|
+
const head = key.split('.')[0];
|
|
1579
|
+
return IDENTITY_KEY_CLASS[head] ?? 'environment';
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function observedIdentityProblems(declaration, rows) {
|
|
1583
|
+
const fixed = flatten(declaration.identity?.fixed ?? {});
|
|
1584
|
+
const arms = declaration.identity?.arms ?? {};
|
|
1585
|
+
const unobserved = [];
|
|
1586
|
+
const drift = new Map();
|
|
1587
|
+
for (const row of rows) {
|
|
1588
|
+
if (!row.observed || !isObject(row.observed.fixed) || !isObject(row.observed.arm)) {
|
|
1589
|
+
unobserved.push(row.attemptId);
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
const seen = flatten(row.observed.fixed);
|
|
1593
|
+
for (const key of new Set([...Object.keys(fixed), ...Object.keys(seen)])) {
|
|
1594
|
+
if (canonicalJson(fixed[key]) !== canonicalJson(seen[key])) drift.set(`fixed.${key}`, classifyIdentityKey(key));
|
|
1595
|
+
}
|
|
1596
|
+
const declaredArm = flatten(arms[row.arm] ?? {});
|
|
1597
|
+
const seenArm = flatten(row.observed.arm);
|
|
1598
|
+
for (const key of new Set([...Object.keys(declaredArm), ...Object.keys(seenArm)])) {
|
|
1599
|
+
if (canonicalJson(declaredArm[key]) !== canonicalJson(seenArm[key])) drift.set(`arm.${row.arm}.${key}`, 'arm-configuration');
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return { unobserved, drift: [...drift.entries()].map(([key, kind]) => ({ key, kind })).sort((a, b) => a.key.localeCompare(b.key)) };
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/** Group kept rows by planned slot and arm, and pick the scoring attempt of each. */
|
|
1606
|
+
function resolveSlots(declaration, rows, plan) {
|
|
1607
|
+
const repeats = declaration.design.repeats;
|
|
1608
|
+
const required = Object.keys(declaration.gates.quality);
|
|
1609
|
+
const failureRule = declaration.design.failureRules.timeout;
|
|
1610
|
+
const failureValue = declaration.design.failureRules.failureMetricValue;
|
|
1611
|
+
const slots = [];
|
|
1612
|
+
const stageRows = rows;
|
|
1613
|
+
const problems = {
|
|
1614
|
+
missing: [],
|
|
1615
|
+
duplicate: [],
|
|
1616
|
+
overBudget: [],
|
|
1617
|
+
missingBlock: [],
|
|
1618
|
+
blockMismatch: [],
|
|
1619
|
+
nonFinite: [],
|
|
1620
|
+
missingMetric: [],
|
|
1621
|
+
unplanned: [],
|
|
1622
|
+
outOfRange: [],
|
|
1623
|
+
attemptGap: [],
|
|
1624
|
+
};
|
|
1625
|
+
for (const row of stageRows) {
|
|
1626
|
+
if (plan.some((entry) => entry.caseId === row.caseId) && row.repetition > repeats) {
|
|
1627
|
+
problems.unplanned.push(`${row.arm}/${row.caseId}/${row.repetition}`);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
for (const entry of plan) {
|
|
1631
|
+
for (let repetition = 1; repetition <= repeats; repetition += 1) {
|
|
1632
|
+
const slot = { caseId: entry.caseId, clusterId: entry.clusterId, repetition, arms: {} };
|
|
1633
|
+
for (const arm of ARMS) {
|
|
1634
|
+
const attempts = stageRows
|
|
1635
|
+
.filter((row) => row.arm === arm && row.caseId === entry.caseId && row.repetition === repetition)
|
|
1636
|
+
.sort((left, right) => (left.attemptNumber ?? 0) - (right.attemptNumber ?? 0) || left.inputIndex - right.inputIndex);
|
|
1637
|
+
const completed = attempts.filter((row) => row.status === 'completed');
|
|
1638
|
+
const timeouts = attempts.filter((row) => row.status === 'timeout');
|
|
1639
|
+
let scoring = null;
|
|
1640
|
+
let failureScored = false;
|
|
1641
|
+
if (completed.length === 1) [scoring] = completed;
|
|
1642
|
+
else if (completed.length > 1) problems.duplicate.push(`${arm}/${entry.caseId}/${repetition}`);
|
|
1643
|
+
else if (timeouts.length > 0 && failureRule === 'failure') {
|
|
1644
|
+
failureScored = true;
|
|
1645
|
+
scoring = timeouts.at(-1);
|
|
1646
|
+
}
|
|
1647
|
+
if (attempts.length > declaration.design.maxAttempts) problems.overBudget.push(`${arm}/${entry.caseId}/${repetition}`);
|
|
1648
|
+
if (attempts.some((row, index) => row.attemptNumber !== index + 1))
|
|
1649
|
+
problems.attemptGap.push(`${arm}/${entry.caseId}/${repetition}`);
|
|
1650
|
+
if (scoring === null && completed.length === 0) problems.missing.push(`${arm}/${entry.caseId}/${repetition}`);
|
|
1651
|
+
let values = null;
|
|
1652
|
+
if (scoring !== null) {
|
|
1653
|
+
values = {};
|
|
1654
|
+
for (const metric of required) {
|
|
1655
|
+
const value = failureScored ? failureValue : scoring.metrics[metric];
|
|
1656
|
+
if (isFiniteNumber(value) && (value < 0 || value > 1))
|
|
1657
|
+
problems.outOfRange.push(`${arm}/${entry.caseId}/${repetition}/${metric}`);
|
|
1658
|
+
else if (isFiniteNumber(value)) values[metric] = value;
|
|
1659
|
+
else if (value === null || value === undefined) {
|
|
1660
|
+
const reason = scoring.notApplicable.includes(metric) ? 'not applicable to this case' : 'unmeasured';
|
|
1661
|
+
problems.missingMetric.push(`${arm}/${entry.caseId}/${repetition}/${metric} (${reason})`);
|
|
1662
|
+
} else problems.nonFinite.push(`${arm}/${entry.caseId}/${repetition}/${metric}`);
|
|
1663
|
+
}
|
|
1664
|
+
if (scoring.nonFinite) problems.nonFinite.push(`${arm}/${entry.caseId}/${repetition}`);
|
|
1665
|
+
}
|
|
1666
|
+
slot.arms[arm] = { attempts, scoring, failureScored, values };
|
|
1667
|
+
}
|
|
1668
|
+
if (ARMS.every((arm) => slot.arms[arm].scoring !== null)) {
|
|
1669
|
+
const blocks = ARMS.map((arm) => slot.arms[arm].scoring.blockId);
|
|
1670
|
+
if (!blocks.every((block) => isNonEmptyString(block))) problems.missingBlock.push(`${entry.caseId}/${repetition}`);
|
|
1671
|
+
else if (blocks[0] !== blocks[1]) problems.blockMismatch.push(`${entry.caseId}/${repetition}`);
|
|
1672
|
+
}
|
|
1673
|
+
slots.push(slot);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return { slots, problems };
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* The recorded dispatch order against the declared schedule, from the receipts' start
|
|
1681
|
+
* times. A pairing block holds one pair: one case and repetition on both arms. Every
|
|
1682
|
+
* attempt of the pair, retries included, belongs to its block and has to record when
|
|
1683
|
+
* it started. Pairing blocks run one after another:
|
|
1684
|
+
* every attempt of a block starts strictly before the first attempt of the next block,
|
|
1685
|
+
* since equal times show no order. Inside a block an interleaved schedule never starts
|
|
1686
|
+
* one arm's scoring attempt twice in a row, and two scoring attempts that start at the
|
|
1687
|
+
* same time show no alternation.
|
|
1688
|
+
*/
|
|
1689
|
+
function scheduleProblems(slots, order) {
|
|
1690
|
+
const byBlock = new Map();
|
|
1691
|
+
const problems = [];
|
|
1692
|
+
for (const slot of slots) {
|
|
1693
|
+
const blockId = slot.arms.A.scoring?.blockId;
|
|
1694
|
+
if (!isNonEmptyString(blockId) || blockId !== slot.arms.B.scoring?.blockId) continue;
|
|
1695
|
+
for (const arm of ARMS) {
|
|
1696
|
+
for (const row of slot.arms[arm].attempts) {
|
|
1697
|
+
if (row.blockId !== blockId) problems.push(`${row.attemptId} is not in the block ${blockId} of its pair`);
|
|
1698
|
+
const start = timestampMs(row.receipt?.startedAt);
|
|
1699
|
+
if (start === null) {
|
|
1700
|
+
problems.push(`${row.attemptId} records no start time`);
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
if (!byBlock.has(blockId)) byBlock.set(blockId, []);
|
|
1704
|
+
byBlock.get(blockId).push({ arm, start, scoring: row === slot.arms[arm].scoring, inputIndex: row.inputIndex });
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
const blockSlots = new Map();
|
|
1709
|
+
for (const slot of slots) {
|
|
1710
|
+
const blockId = slot.arms.A.scoring?.blockId;
|
|
1711
|
+
if (!isNonEmptyString(blockId) || blockId !== slot.arms.B.scoring?.blockId) continue;
|
|
1712
|
+
blockSlots.set(blockId, [...(blockSlots.get(blockId) ?? []), `${slot.caseId}/${slot.repetition}`]);
|
|
1713
|
+
}
|
|
1714
|
+
for (const [blockId, members] of blockSlots) {
|
|
1715
|
+
if (members.length > 1) problems.push(`block ${blockId} holds more than one pair (${members.join(', ')})`);
|
|
1716
|
+
}
|
|
1717
|
+
const blocks = [...byBlock.entries()]
|
|
1718
|
+
.map(([blockId, runs]) => {
|
|
1719
|
+
const sorted = runs.sort((left, right) => left.start - right.start || left.inputIndex - right.inputIndex);
|
|
1720
|
+
return { blockId, runs: sorted, first: sorted[0].start, last: sorted.at(-1).start };
|
|
1721
|
+
})
|
|
1722
|
+
.sort((left, right) => left.first - right.first || left.last - right.last);
|
|
1723
|
+
let latest = null;
|
|
1724
|
+
for (const block of blocks) {
|
|
1725
|
+
if (latest !== null && latest.last >= block.first) {
|
|
1726
|
+
problems.push(`block ${latest.blockId} does not finish starting attempts before block ${block.blockId} begins`);
|
|
1727
|
+
}
|
|
1728
|
+
if (latest === null || block.last > latest.last) latest = block;
|
|
1729
|
+
}
|
|
1730
|
+
if (order === 'interleaved') {
|
|
1731
|
+
for (const block of blocks) {
|
|
1732
|
+
const scoring = block.runs.filter((run) => run.scoring);
|
|
1733
|
+
if (scoring.some((run, index) => index > 0 && (run.arm === scoring[index - 1].arm || run.start === scoring[index - 1].start))) {
|
|
1734
|
+
problems.push(`block ${block.blockId} does not alternate the arms`);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
return problems;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/** Build ids that more than one cluster used. */
|
|
1742
|
+
function sharedBuilds(rows) {
|
|
1743
|
+
const owners = new Map();
|
|
1744
|
+
const shared = new Set();
|
|
1745
|
+
for (const row of rows) {
|
|
1746
|
+
if (!row.buildId) continue;
|
|
1747
|
+
const owner = owners.get(row.buildId);
|
|
1748
|
+
if (owner !== undefined && owner !== row.clusterId) shared.add(row.buildId);
|
|
1749
|
+
owners.set(row.buildId, row.clusterId);
|
|
1750
|
+
}
|
|
1751
|
+
return [...shared].sort();
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/** Cluster-level arm values: cases averaged equally within a cluster, repetitions first. */
|
|
1755
|
+
function clusterValues(declaration, slots, plan, valueOf) {
|
|
1756
|
+
const clusters = [...new Set(plan.map((entry) => entry.clusterId))];
|
|
1757
|
+
const out = [];
|
|
1758
|
+
for (const clusterId of clusters) {
|
|
1759
|
+
const caseIds = plan.filter((entry) => entry.clusterId === clusterId).map((entry) => entry.caseId);
|
|
1760
|
+
const perArm = {};
|
|
1761
|
+
for (const arm of ARMS) {
|
|
1762
|
+
const caseMeans = [];
|
|
1763
|
+
for (const caseId of caseIds) {
|
|
1764
|
+
const values = slots.filter((slot) => slot.caseId === caseId).map((slot) => valueOf(slot.arms[arm], slot));
|
|
1765
|
+
if (values.length === 0 || values.some((value) => !isFiniteNumber(value))) {
|
|
1766
|
+
caseMeans.push(null);
|
|
1767
|
+
} else {
|
|
1768
|
+
caseMeans.push(mean(values));
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
perArm[arm] = caseMeans.includes(null) ? null : mean(caseMeans);
|
|
1772
|
+
}
|
|
1773
|
+
out.push({ clusterId, caseIds, ...perArm });
|
|
1774
|
+
}
|
|
1775
|
+
return out;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* Evaluate every required gate against its bound. Each lower bound has to strictly
|
|
1780
|
+
* exceed its threshold.
|
|
1781
|
+
*/
|
|
1782
|
+
function evaluateGates({ gates, bounds, objective }) {
|
|
1783
|
+
const results = [];
|
|
1784
|
+
for (const [metric, rule] of Object.entries(gates.quality)) {
|
|
1785
|
+
const bound = bounds[`quality:${metric}`];
|
|
1786
|
+
const threshold = isFiniteNumber(rule.maxLoss) ? -rule.maxLoss : rule.minImprovement;
|
|
1787
|
+
results.push({
|
|
1788
|
+
gate: `quality:${metric}`,
|
|
1789
|
+
kind: isFiniteNumber(rule.maxLoss) ? 'noninferiority' : 'improvement',
|
|
1790
|
+
threshold,
|
|
1791
|
+
lower: bound?.lower ?? null,
|
|
1792
|
+
passes: lowerBoundClears(bound?.lower, threshold),
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
for (const metric of Object.keys(gates.floors ?? {})) {
|
|
1796
|
+
const bound = bounds[`floor:${metric}`];
|
|
1797
|
+
results.push({
|
|
1798
|
+
gate: `floor:${metric}`,
|
|
1799
|
+
kind: 'absolute-floor',
|
|
1800
|
+
threshold: 0,
|
|
1801
|
+
lower: bound?.lower ?? null,
|
|
1802
|
+
passes: lowerBoundClears(bound?.lower, 0),
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
if (objective === 'cost' || isObject(gates.saving)) {
|
|
1806
|
+
const bound = bounds.saving;
|
|
1807
|
+
results.push({ gate: 'saving', kind: 'saving', threshold: 0, lower: bound?.lower ?? null, passes: lowerBoundClears(bound?.lower, 0) });
|
|
1808
|
+
}
|
|
1809
|
+
if (isObject(gates.latency)) {
|
|
1810
|
+
const bound = bounds.latency;
|
|
1811
|
+
results.push({
|
|
1812
|
+
gate: 'latency',
|
|
1813
|
+
kind: 'latency',
|
|
1814
|
+
threshold: 0,
|
|
1815
|
+
lower: bound?.lower ?? null,
|
|
1816
|
+
passes: lowerBoundClears(bound?.lower, 0),
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
return results;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function describeArms(declaration, slots, plan, pricing) {
|
|
1823
|
+
const out = {};
|
|
1824
|
+
const metrics = Object.keys(declaration.gates.quality);
|
|
1825
|
+
for (const arm of ARMS) {
|
|
1826
|
+
const armRows = slots.flatMap((slot) => slot.arms[arm].attempts);
|
|
1827
|
+
const quality = {};
|
|
1828
|
+
for (const metric of metrics) {
|
|
1829
|
+
const clusters = clusterValues(declaration, slots, plan, (side) => side.values?.[metric] ?? null);
|
|
1830
|
+
const values = clusters.map((cluster) => cluster[arm]);
|
|
1831
|
+
quality[metric] = values.every((value) => isFiniteNumber(value)) ? mean(values) : null;
|
|
1832
|
+
}
|
|
1833
|
+
const task = scopedCost(armRows, declaration.resources?.costScope?.taskExecution ?? DEFAULT_COST_SCOPE.taskExecution, pricing);
|
|
1834
|
+
out[arm] = {
|
|
1835
|
+
quality,
|
|
1836
|
+
attempts: armRows.length,
|
|
1837
|
+
sources: armRows.map((row) => row.attemptId),
|
|
1838
|
+
completed: armRows.filter((row) => row.status === 'completed').length,
|
|
1839
|
+
errors: armRows.filter((row) => ['failed', 'invalid', 'timeout'].includes(row.status)).length,
|
|
1840
|
+
canceled: armRows.filter((row) => row.status === 'canceled').length,
|
|
1841
|
+
taskExecutionCost: { total: task.total, currency: task.currency, unknown: task.unknown.length },
|
|
1842
|
+
taskLatencyMs: armRows.length === 0 ? null : knownSum(armRows.map((row) => row.latency?.taskMs ?? null)),
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
return out;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/** The paired contrast: verification, identity, pairs, schedule, cost, the bootstrap bounds and the decision. */
|
|
1849
|
+
function analyzeContrast({ declaration, validation, rows, excluded, ledger, resume, bootstrap, replay, selection, nativeRecords }) {
|
|
1850
|
+
const facts = { rows: rows.length };
|
|
1851
|
+
const pricing = declaration.resources?.pricing ?? null;
|
|
1852
|
+
const plan = clusterPlan(declaration);
|
|
1853
|
+
const identity = checkIdentity(declaration);
|
|
1854
|
+
facts.identity = identity;
|
|
1855
|
+
|
|
1856
|
+
const contrastRows = rows.filter((row) => ARMS.includes(row.arm));
|
|
1857
|
+
const observed = observedIdentityProblems(declaration, contrastRows);
|
|
1858
|
+
facts.observedIdentity = observed;
|
|
1859
|
+
|
|
1860
|
+
const chain = selectionChainProblems(declaration, selection, { replay, bootstrap });
|
|
1861
|
+
facts.selectionChain = chain.problems;
|
|
1862
|
+
const exposure = exposureProblems(declaration, ledger, resume, chain.pin ? [chain.pin] : []);
|
|
1863
|
+
facts.exposure = exposure;
|
|
1864
|
+
|
|
1865
|
+
const sharedAcrossArms = excluded.filter((entry) => entry.sharedAcrossArms).map((entry) => entry.row.attemptId);
|
|
1866
|
+
facts.sharedAcrossArms = sharedAcrossArms;
|
|
1867
|
+
// A candidate row left out of this stage has to belong to another paired stage, on
|
|
1868
|
+
// that stage's own cases. Anything else hides candidate evidence from the decision.
|
|
1869
|
+
const otherStage = (row) =>
|
|
1870
|
+
['selection', 'confirmation'].includes(row.stage) &&
|
|
1871
|
+
row.stage !== declaration.stage &&
|
|
1872
|
+
stageCases({ ...declaration, stage: row.stage }).includes(row.caseId);
|
|
1873
|
+
facts.mislabeled = excluded
|
|
1874
|
+
.filter((entry) => ['wrong-stage', 'outside-stage-membership'].includes(entry.reason) && entry.row.arm === 'B')
|
|
1875
|
+
.filter((entry) => !otherStage(entry.row))
|
|
1876
|
+
.map((entry) => entry.row.attemptId);
|
|
1877
|
+
|
|
1878
|
+
const resolved = resolveSlots(declaration, contrastRows, plan);
|
|
1879
|
+
facts.slots = resolved.problems;
|
|
1880
|
+
const { slots } = resolved;
|
|
1881
|
+
facts.schedule = scheduleProblems(slots, declaration.design.order);
|
|
1882
|
+
|
|
1883
|
+
// -- verification: a completed attempt's receipt, bytes and numbers -------------
|
|
1884
|
+
const verification = new Map();
|
|
1885
|
+
for (const row of contrastRows) {
|
|
1886
|
+
verification.set(row.attemptId, verificationProblems(row, declaration, replayRow(replay, row), nativeRecords));
|
|
1887
|
+
}
|
|
1888
|
+
const unverified = contrastRows
|
|
1889
|
+
.filter((row) => verification.get(row.attemptId).length > 0)
|
|
1890
|
+
.map((row) => ({ attemptId: row.attemptId, problems: verification.get(row.attemptId) }));
|
|
1891
|
+
facts.unverified = unverified;
|
|
1892
|
+
|
|
1893
|
+
// -- critical misses: every candidate attempt counts, retries included ----------
|
|
1894
|
+
const candidateRows = contrastRows.filter((row) => row.arm === 'B');
|
|
1895
|
+
const critical = { verified: [], unverified: [] };
|
|
1896
|
+
for (const row of candidateRows) {
|
|
1897
|
+
const rules = criticalMisses(row, declaration);
|
|
1898
|
+
if (rules.length === 0) continue;
|
|
1899
|
+
(verification.get(row.attemptId)?.length === 0 ? critical.verified : critical.unverified).push({
|
|
1900
|
+
attemptId: row.attemptId,
|
|
1901
|
+
caseId: row.caseId,
|
|
1902
|
+
repetition: row.repetition,
|
|
1903
|
+
rules,
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
for (const entry of excluded) {
|
|
1907
|
+
if (!['duplicate-attempt-id', 'copied-trial'].includes(entry.reason) || entry.row.arm !== 'B' || entry.row.status !== 'completed')
|
|
1908
|
+
continue;
|
|
1909
|
+
const rules = criticalMisses(entry.row, declaration);
|
|
1910
|
+
if (rules.length > 0) {
|
|
1911
|
+
critical.unverified.push({ attemptId: entry.row.attemptId, caseId: entry.row.caseId, repetition: entry.row.repetition, rules });
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
critical.unrecorded = candidateRows
|
|
1915
|
+
.map((row) => ({
|
|
1916
|
+
attemptId: row.attemptId,
|
|
1917
|
+
rules: row.status === 'completed' ? unknownCriticalCounts(row, declaration) : invalidCriticalCounts(row, declaration),
|
|
1918
|
+
}))
|
|
1919
|
+
.filter((entry) => entry.rules.length > 0);
|
|
1920
|
+
facts.critical = critical;
|
|
1921
|
+
facts.malformedRows = excluded.filter((entry) => entry.reason === 'malformed-row').length;
|
|
1922
|
+
facts.conflictingCopies = excluded.filter((entry) => entry.conflicting).map((entry) => entry.row.attemptId);
|
|
1923
|
+
|
|
1924
|
+
// -- native binding gates: read from the native record of a verified attempt -------
|
|
1925
|
+
const nativeRed = [];
|
|
1926
|
+
const bound = candidateRows.filter((row) => row.status === 'completed' && verification.get(row.attemptId).length === 0);
|
|
1927
|
+
const recordOf = (row) => nativeRecordOf(row, nativeRecords);
|
|
1928
|
+
const unsigned = [];
|
|
1929
|
+
const unjudged = [];
|
|
1930
|
+
if (declaration.gates?.native?.requireStableSignatures) {
|
|
1931
|
+
for (const entry of plan) {
|
|
1932
|
+
const signed = bound.filter((row) => row.caseId === entry.caseId).map((row) => [row.attemptId, recordOf(row).signature]);
|
|
1933
|
+
for (const [attemptId, signature] of signed) if (!isNonEmptyString(signature)) unsigned.push(attemptId);
|
|
1934
|
+
const signatures = signed.map(([, signature]) => signature).filter((signature) => isNonEmptyString(signature));
|
|
1935
|
+
if (new Set(signatures).size > 1)
|
|
1936
|
+
nativeRed.push({ caseId: entry.caseId, reason: 'native stability signatures differ between repetitions' });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
if (declaration.gates?.native?.requireQualification) {
|
|
1940
|
+
for (const row of bound) {
|
|
1941
|
+
if (recordOf(row).qualification === null || recordOf(row).qualification === undefined) unjudged.push(row.attemptId);
|
|
1942
|
+
else if (recordOf(row).qualification !== 'passed')
|
|
1943
|
+
nativeRed.push({ attemptId: row.attemptId, reason: 'native qualification is not passed' });
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
facts.nativeRed = nativeRed;
|
|
1947
|
+
facts.nativeUnknown = [
|
|
1948
|
+
...unsigned.map((attemptId) => `${attemptId} has no native signature for the stability gate`),
|
|
1949
|
+
...unjudged.map((attemptId) => `${attemptId} has no native qualification verdict for the qualification gate`),
|
|
1950
|
+
];
|
|
1951
|
+
|
|
1952
|
+
// -- design support ---------------------------------------------------------------
|
|
1953
|
+
const unsupported = [];
|
|
1954
|
+
const clusterCount = new Set(plan.map((entry) => entry.clusterId)).size;
|
|
1955
|
+
const weightOf = new Map();
|
|
1956
|
+
for (const entry of plan) weightOf.set(entry.clusterId, (weightOf.get(entry.clusterId) ?? 0) + entry.weight);
|
|
1957
|
+
const clusterWeightsEqual = [...weightOf.values()].every((weight) => Math.abs(weight - 1 / clusterCount) < 1e-9);
|
|
1958
|
+
const caseWeightsEqualWithinClusters = [...weightOf.keys()].every((clusterId) => {
|
|
1959
|
+
const weights = plan.filter((entry) => entry.clusterId === clusterId).map((entry) => entry.weight);
|
|
1960
|
+
return weights.every((weight) => Math.abs(weight - weights[0]) < 1e-9);
|
|
1961
|
+
});
|
|
1962
|
+
if (!clusterWeightsEqual) unsupported.push('unequal or stratified cluster weights');
|
|
1963
|
+
if (!caseWeightsEqualWithinClusters) unsupported.push('unequal case weights inside a cluster');
|
|
1964
|
+
const shared = sharedBuilds(contrastRows);
|
|
1965
|
+
if (shared.length > 0 || declaration.design.sharedBuilds === true) unsupported.push('clusters share a generated build');
|
|
1966
|
+
facts.unsupported = unsupported;
|
|
1967
|
+
|
|
1968
|
+
// -- cost -------------------------------------------------------------------------
|
|
1969
|
+
const costScope = declaration.resources?.costScope ?? DEFAULT_COST_SCOPE;
|
|
1970
|
+
const taskNames = costScope.taskExecution ?? DEFAULT_COST_SCOPE.taskExecution;
|
|
1971
|
+
const slotCost = (side) => {
|
|
1972
|
+
const cost = scopedCost(side.attempts, taskNames, pricing);
|
|
1973
|
+
return cost.total;
|
|
1974
|
+
};
|
|
1975
|
+
const slotLatency = (side) =>
|
|
1976
|
+
side.attempts.length === 0
|
|
1977
|
+
? null
|
|
1978
|
+
: knownSum(side.attempts.map((row) => (isFiniteNumber(row.latency?.taskMs) && row.latency.taskMs >= 0 ? row.latency.taskMs : null)));
|
|
1979
|
+
const needsCost = declaration.scope.objective === 'cost' || isObject(declaration.gates.saving);
|
|
1980
|
+
const judgeNames = declaration.scope.judge === 'rubric' ? (costScope.judge ?? DEFAULT_COST_SCOPE.judge) : [];
|
|
1981
|
+
const classified = new Set([...taskNames, ...judgeNames]);
|
|
1982
|
+
const unclassified = needsCost
|
|
1983
|
+
? contrastRows.flatMap((row) =>
|
|
1984
|
+
Object.keys(row.cost?.components ?? {})
|
|
1985
|
+
.filter((name) => !classified.has(name))
|
|
1986
|
+
.map((name) => `${row.attemptId}/${name}`),
|
|
1987
|
+
)
|
|
1988
|
+
: [];
|
|
1989
|
+
facts.unclassifiedCost = unclassified;
|
|
1990
|
+
facts.outOfRange = contrastRows.flatMap((row) =>
|
|
1991
|
+
Object.keys(declaration.gates.quality)
|
|
1992
|
+
.filter((metric) => isFiniteNumber(row.metrics[metric]) && (row.metrics[metric] < 0 || row.metrics[metric] > 1))
|
|
1993
|
+
.map((metric) => `${row.attemptId}/${metric}`),
|
|
1994
|
+
);
|
|
1995
|
+
facts.nothingCompleted = !contrastRows.some((row) => row.arm === 'B' && row.status === 'completed');
|
|
1996
|
+
facts.latencyUnknown =
|
|
1997
|
+
isObject(declaration.gates.latency) &&
|
|
1998
|
+
clusterValues(declaration, slots, plan, (side) => slotLatency(side)).some((cluster) => cluster.A === null || cluster.B === null);
|
|
1999
|
+
const costClusters = clusterValues(declaration, slots, plan, (side) => slotCost(side));
|
|
2000
|
+
const costUnknown = needsCost && costClusters.some((cluster) => cluster.A === null || cluster.B === null);
|
|
2001
|
+
facts.costUnknown = costUnknown;
|
|
2002
|
+
const currencies = new Set();
|
|
2003
|
+
for (const row of contrastRows) {
|
|
2004
|
+
const { currency } = attemptCostComponents(row, pricing);
|
|
2005
|
+
if (currency) currencies.add(currency);
|
|
2006
|
+
}
|
|
2007
|
+
const mixedCurrency = currencies.size > 1;
|
|
2008
|
+
facts.mixedCurrency = mixedCurrency;
|
|
2009
|
+
|
|
2010
|
+
// -- estimates ---------------------------------------------------------------------
|
|
2011
|
+
const completePairs =
|
|
2012
|
+
resolved.problems.missing.length === 0 && resolved.problems.duplicate.length === 0 && resolved.problems.missingMetric.length === 0;
|
|
2013
|
+
const finite = resolved.problems.nonFinite.length === 0 && contrastRows.every((row) => !row.nonFinite);
|
|
2014
|
+
const arms = describeArms(declaration, slots, plan, pricing);
|
|
2015
|
+
const gates = declaration.gates;
|
|
2016
|
+
const analysis = declaration.design.analysis;
|
|
2017
|
+
const calibration = runCalibration(bootstrap);
|
|
2018
|
+
let estimates = null;
|
|
2019
|
+
let gateResults = [];
|
|
2020
|
+
let diagnostics = [];
|
|
2021
|
+
const canEstimate = completePairs && finite && !costUnknown && !mixedCurrency && unsupported.length === 0 && clusterCount >= 1;
|
|
2022
|
+
if (canEstimate) {
|
|
2023
|
+
const contrasts = {};
|
|
2024
|
+
for (const metric of Object.keys(gates.quality)) {
|
|
2025
|
+
const clusters = clusterValues(declaration, slots, plan, (side) => side.values?.[metric] ?? null);
|
|
2026
|
+
contrasts[`quality:${metric}`] = clusters.map((cluster) => cluster.B - cluster.A);
|
|
2027
|
+
}
|
|
2028
|
+
for (const [metric, floor] of Object.entries(gates.floors ?? {})) {
|
|
2029
|
+
const clusters = clusterValues(declaration, slots, plan, (side) => side.values?.[metric] ?? null);
|
|
2030
|
+
contrasts[`floor:${metric}`] = clusters.map((cluster) => cluster.B - floor);
|
|
2031
|
+
}
|
|
2032
|
+
if (needsCost) {
|
|
2033
|
+
const s = gates.saving?.minFraction ?? 0;
|
|
2034
|
+
contrasts.saving = costClusters.map((cluster) => (1 - s) * cluster.A - cluster.B);
|
|
2035
|
+
}
|
|
2036
|
+
if (isObject(gates.latency)) {
|
|
2037
|
+
const clusters = clusterValues(declaration, slots, plan, (side) => slotLatency(side));
|
|
2038
|
+
if (clusters.some((cluster) => cluster.A === null || cluster.B === null)) {
|
|
2039
|
+
contrasts.latency = null;
|
|
2040
|
+
} else {
|
|
2041
|
+
contrasts.latency = clusters.map((cluster) => (1 + gates.latency.maxIncreaseFraction) * cluster.A - cluster.B);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
const usable = Object.fromEntries(Object.entries(contrasts).filter(([, values]) => values !== null));
|
|
2045
|
+
const all = Object.values(usable).flat();
|
|
2046
|
+
if (all.every((value) => isFiniteNumber(value)) && Object.keys(usable).length > 0) {
|
|
2047
|
+
const bounds = Object.keys(usable).length;
|
|
2048
|
+
const alpha = analysis.simultaneous ? analysis.alpha / bounds : analysis.alpha;
|
|
2049
|
+
estimates = bootstrap({ contrasts: usable, B: analysis.B, seed: analysis.seed, alpha, quantile: analysis.quantile });
|
|
2050
|
+
estimates = { alphaPerBound: alpha, bounds: estimates };
|
|
2051
|
+
gateResults = evaluateGates({ gates, bounds: estimates.bounds, objective: declaration.scope.objective });
|
|
2052
|
+
if (needsCost) {
|
|
2053
|
+
const meanA = mean(costClusters.map((cluster) => cluster.A));
|
|
2054
|
+
facts.baselineMeanCost = meanA;
|
|
2055
|
+
if (!(meanA > 0)) diagnostics.push('baseline mean cost is not positive');
|
|
2056
|
+
}
|
|
2057
|
+
} else {
|
|
2058
|
+
diagnostics.push('a contrast was not finite');
|
|
2059
|
+
}
|
|
2060
|
+
if (contrasts.latency === null) {
|
|
2061
|
+
gateResults.push({ gate: 'latency', kind: 'latency', threshold: 0, lower: null, passes: false });
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
facts.clusterCount = clusterCount;
|
|
2065
|
+
facts.diagnostics = diagnostics;
|
|
2066
|
+
facts.calibration = calibration;
|
|
2067
|
+
const degenerate = estimates
|
|
2068
|
+
? Object.entries(estimates.bounds)
|
|
2069
|
+
.filter(([, bound]) => bound.degenerate)
|
|
2070
|
+
.map(([name]) => name)
|
|
2071
|
+
: [];
|
|
2072
|
+
facts.degenerate = degenerate;
|
|
2073
|
+
|
|
2074
|
+
const decision = decide({
|
|
2075
|
+
declaration,
|
|
2076
|
+
validation,
|
|
2077
|
+
facts,
|
|
2078
|
+
gateResults,
|
|
2079
|
+
estimates,
|
|
2080
|
+
identity,
|
|
2081
|
+
observed,
|
|
2082
|
+
exposure,
|
|
2083
|
+
critical,
|
|
2084
|
+
nativeRed,
|
|
2085
|
+
unverified,
|
|
2086
|
+
resolved,
|
|
2087
|
+
});
|
|
2088
|
+
return {
|
|
2089
|
+
plannedClusters: clusterCount,
|
|
2090
|
+
arms,
|
|
2091
|
+
identity: {
|
|
2092
|
+
oneFactor: {
|
|
2093
|
+
kind: identity.kind,
|
|
2094
|
+
setting: identity.setting,
|
|
2095
|
+
differing: identity.differing,
|
|
2096
|
+
outsideFactor: identity.outside,
|
|
2097
|
+
unchanged: identity.unchanged,
|
|
2098
|
+
unknown: identity.unknown,
|
|
2099
|
+
},
|
|
2100
|
+
observed,
|
|
2101
|
+
},
|
|
2102
|
+
pairs: { slots: slots.length * 2, problems: resolved.problems, unverified, schedule: facts.schedule },
|
|
2103
|
+
selection: { problems: facts.selectionChain },
|
|
2104
|
+
verification: Object.fromEntries(verification),
|
|
2105
|
+
critical,
|
|
2106
|
+
nativeRed,
|
|
2107
|
+
cost: {
|
|
2108
|
+
needed: needsCost,
|
|
2109
|
+
unknown: costUnknown,
|
|
2110
|
+
unclassified,
|
|
2111
|
+
mixedCurrency,
|
|
2112
|
+
taskExecutionScope: taskNames,
|
|
2113
|
+
baselineMeanCost: facts.baselineMeanCost ?? null,
|
|
2114
|
+
},
|
|
2115
|
+
exposure: {
|
|
2116
|
+
blockers: exposure.blockers,
|
|
2117
|
+
burned: exposure.burned,
|
|
2118
|
+
head: exposure.ledger?.head ?? null,
|
|
2119
|
+
length: exposure.ledger?.length ?? 0,
|
|
2120
|
+
},
|
|
2121
|
+
estimator: {
|
|
2122
|
+
method: 'equal-weight paired case-cluster percentile bootstrap',
|
|
2123
|
+
alpha: analysis.alpha,
|
|
2124
|
+
alphaPerBound: estimates?.alphaPerBound ?? null,
|
|
2125
|
+
B: analysis.B,
|
|
2126
|
+
seed: analysis.seed,
|
|
2127
|
+
prng: analysis.prng,
|
|
2128
|
+
quantile: analysis.quantile,
|
|
2129
|
+
simultaneous: analysis.simultaneous,
|
|
2130
|
+
clusters: clusterCount,
|
|
2131
|
+
calibration,
|
|
2132
|
+
bounds: estimates?.bounds ?? null,
|
|
2133
|
+
degenerate,
|
|
2134
|
+
diagnostics,
|
|
2135
|
+
},
|
|
2136
|
+
gates: gateResults,
|
|
2137
|
+
decision,
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
/** The decision, in the order the module header lists; the first rule that applies decides. */
|
|
2142
|
+
function decide({
|
|
2143
|
+
declaration,
|
|
2144
|
+
validation,
|
|
2145
|
+
facts,
|
|
2146
|
+
gateResults,
|
|
2147
|
+
estimates,
|
|
2148
|
+
identity,
|
|
2149
|
+
observed,
|
|
2150
|
+
exposure,
|
|
2151
|
+
critical,
|
|
2152
|
+
nativeRed,
|
|
2153
|
+
unverified,
|
|
2154
|
+
resolved,
|
|
2155
|
+
}) {
|
|
2156
|
+
const stage = declaration.stage;
|
|
2157
|
+
const result = (outcome, reasons) => ({
|
|
2158
|
+
outcome,
|
|
2159
|
+
reasons,
|
|
2160
|
+
advisory: true,
|
|
2161
|
+
nativeVerdict: null,
|
|
2162
|
+
acceptsBaseline: false,
|
|
2163
|
+
appliesPatch: false,
|
|
2164
|
+
adoptionRequires: 'explicit human approval of the exact configuration bytes and destination',
|
|
2165
|
+
});
|
|
2166
|
+
const blocked = [];
|
|
2167
|
+
|
|
2168
|
+
// 1. declaration
|
|
2169
|
+
for (const problem of [...validation.problems, ...validation.pairedProblems])
|
|
2170
|
+
blocked.push(`declaration: ${problem.where} ${problem.message}`);
|
|
2171
|
+
if (validation.frozen.declared && validation.frozen.matches === false)
|
|
2172
|
+
blocked.push('declaration: frozen.digest does not match the declaration bytes');
|
|
2173
|
+
for (const problem of facts.selectionChain) blocked.push(`selection: ${problem}`);
|
|
2174
|
+
if (blocked.length > 0) return result(OUTCOMES.blocked, blocked);
|
|
2175
|
+
|
|
2176
|
+
// 2. identity
|
|
2177
|
+
if (identity.unknown.length > 0)
|
|
2178
|
+
return result(
|
|
2179
|
+
OUTCOMES.blocked,
|
|
2180
|
+
identity.unknown.map((key) => `unknown identity: ${key}`),
|
|
2181
|
+
);
|
|
2182
|
+
if (observed.unobserved.length > 0) {
|
|
2183
|
+
return result(OUTCOMES.blocked, [
|
|
2184
|
+
`identity was not recorded on ${observed.unobserved.length} attempt(s), so the comparison is not attributable`,
|
|
2185
|
+
]);
|
|
2186
|
+
}
|
|
2187
|
+
if (observed.drift.length > 0) {
|
|
2188
|
+
return result(
|
|
2189
|
+
OUTCOMES.newBaseline,
|
|
2190
|
+
observed.drift.map((entry) => `${entry.kind} changed: ${entry.key}`),
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
if (identity.bundled) {
|
|
2194
|
+
return result(OUTCOMES.revisedDesign, [`more than the declared ${identity.setting} setting differs: ${identity.outside.join(', ')}`]);
|
|
2195
|
+
}
|
|
2196
|
+
if (identity.factorUnchanged) {
|
|
2197
|
+
return result(OUTCOMES.revisedDesign, [
|
|
2198
|
+
`the declared ${identity.setting} setting does not change on every identity it has to: ${identity.unchanged.join(', ')} stays the same`,
|
|
2199
|
+
]);
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// 3. critical miss, whatever else is true
|
|
2203
|
+
if (critical.verified.length > 0) {
|
|
2204
|
+
return result(
|
|
2205
|
+
OUTCOMES.rejected,
|
|
2206
|
+
critical.verified.map((hit) => `candidate critical miss (${hit.rules.join(', ')}) on ${hit.caseId} repetition ${hit.repetition}`),
|
|
2207
|
+
);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// 4. binding native gates
|
|
2211
|
+
if (nativeRed.length > 0)
|
|
2212
|
+
return result(
|
|
2213
|
+
OUTCOMES.rejected,
|
|
2214
|
+
nativeRed.map((entry) => `native gate is red: ${entry.reason}`),
|
|
2215
|
+
);
|
|
2216
|
+
|
|
2217
|
+
// 5. blocks
|
|
2218
|
+
const blocks = [];
|
|
2219
|
+
if (critical.unverified.length > 0)
|
|
2220
|
+
blocks.push(
|
|
2221
|
+
'a candidate critical miss is not verified against its receipt, bytes and scorer, so it can be neither dismissed nor confirmed',
|
|
2222
|
+
);
|
|
2223
|
+
if (facts.sharedAcrossArms.length > 0) blocks.push(`rows are shared between the arms: ${facts.sharedAcrossArms.join(', ')}`);
|
|
2224
|
+
if (facts.nativeUnknown.length > 0) blocks.push(`native gate evidence is missing: ${facts.nativeUnknown.join('; ')}`);
|
|
2225
|
+
if (facts.mislabeled.length > 0) {
|
|
2226
|
+
blocks.push(`candidate attempts belong to no stage this declaration plans: ${facts.mislabeled.join(', ')}`);
|
|
2227
|
+
}
|
|
2228
|
+
if (critical.unrecorded.length > 0) {
|
|
2229
|
+
blocks.push(
|
|
2230
|
+
`critical counts are not recorded as nonnegative integers on ${critical.unrecorded.length} candidate attempt(s): ${critical.unrecorded.map((entry) => `${entry.attemptId} (${entry.rules.join(', ')})`).join(', ')}`,
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
if (facts.conflictingCopies.length > 0) {
|
|
2234
|
+
blocks.push(`rows that share an attempt id or a workspace disagree with the first: ${facts.conflictingCopies.join(', ')}`);
|
|
2235
|
+
}
|
|
2236
|
+
if (unverified.length > 0) {
|
|
2237
|
+
blocks.push(
|
|
2238
|
+
`${unverified.length} attempt(s) carry claimed evidence the reader could not verify: ${unverified
|
|
2239
|
+
.map((entry) => `${entry.attemptId} (${entry.problems.join('; ')})`)
|
|
2240
|
+
.join(', ')}`,
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
const { problems } = resolved;
|
|
2244
|
+
if (problems.missing.length > 0) blocks.push(`missing pairs: ${problems.missing.join(', ')}`);
|
|
2245
|
+
if (problems.duplicate.length > 0) blocks.push(`duplicate scoring attempts: ${problems.duplicate.join(', ')}`);
|
|
2246
|
+
if (facts.malformedRows > 0)
|
|
2247
|
+
blocks.push(`${facts.malformedRows} evidence row(s) could not be read, so their charges and results are unknown`);
|
|
2248
|
+
if (problems.unplanned.length > 0) blocks.push(`attempts for repetitions the schedule does not plan: ${problems.unplanned.join(', ')}`);
|
|
2249
|
+
if (problems.overBudget.length > 0) blocks.push(`attempts exceed design.maxAttempts: ${problems.overBudget.join(', ')}`);
|
|
2250
|
+
if (problems.attemptGap.length > 0) {
|
|
2251
|
+
blocks.push(`attempt numbers do not run 1, 2, ... without a gap or a repeat: ${problems.attemptGap.join(', ')}`);
|
|
2252
|
+
}
|
|
2253
|
+
if (problems.outOfRange.length > 0 || facts.outOfRange.length > 0) {
|
|
2254
|
+
blocks.push(`required metric outside [0, 1]: ${[...problems.outOfRange, ...facts.outOfRange].join(', ')}`);
|
|
2255
|
+
}
|
|
2256
|
+
if (facts.nothingCompleted) blocks.push('the candidate completed no attempt, so nothing it produced was measured');
|
|
2257
|
+
if (problems.missingBlock.length > 0) blocks.push(`pairing block not recorded on both arms: ${problems.missingBlock.join(', ')}`);
|
|
2258
|
+
if (problems.blockMismatch.length > 0) blocks.push(`pairing blocks differ: ${problems.blockMismatch.join(', ')}`);
|
|
2259
|
+
if (facts.schedule.length > 0) {
|
|
2260
|
+
blocks.push(
|
|
2261
|
+
`the recorded dispatch order does not follow the declared ${declaration.design.order} schedule: ${facts.schedule.join('; ')}`,
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
if (problems.missingMetric.length > 0) blocks.push(`required metric unmeasured: ${problems.missingMetric.join(', ')}`);
|
|
2265
|
+
if (problems.nonFinite.length > 0 || facts.diagnostics.includes('a contrast was not finite')) blocks.push('non-finite data');
|
|
2266
|
+
if (facts.costUnknown) blocks.push('required cost is unknown for at least one cluster');
|
|
2267
|
+
if (facts.unclassifiedCost.length > 0) {
|
|
2268
|
+
blocks.push(`charged cost components are in neither the task execution nor the judge scope: ${facts.unclassifiedCost.join(', ')}`);
|
|
2269
|
+
}
|
|
2270
|
+
if (facts.latencyUnknown) blocks.push('the latency gate is declared and task latency is unknown for at least one cluster');
|
|
2271
|
+
if (facts.mixedCurrency) blocks.push('attempts carry more than one currency');
|
|
2272
|
+
blocks.push(...exposure.blockers, ...exposure.burned.map((entry) => `case ${entry.caseId} ${entry.reason}`));
|
|
2273
|
+
if (identity.aa) blocks.push('the arms have identical configuration: this is an A/A control');
|
|
2274
|
+
if (blocks.length > 0) {
|
|
2275
|
+
if (identity.aa && blocks.length === 1) return result(OUTCOMES.aaControl, blocks);
|
|
2276
|
+
return result(OUTCOMES.blocked, blocks);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// 6. unsupported designs
|
|
2280
|
+
if (facts.unsupported.length > 0) {
|
|
2281
|
+
return result(
|
|
2282
|
+
OUTCOMES.descriptiveOnly,
|
|
2283
|
+
facts.unsupported.map((reason) => `unsupported design: ${reason}; no interval supports a decision`),
|
|
2284
|
+
);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// 7. validity of the interval
|
|
2288
|
+
const inconclusive = [];
|
|
2289
|
+
if (!facts.calibration.passed) inconclusive.push('the bootstrap does not reproduce its reference fixtures; intervals are descriptive');
|
|
2290
|
+
if (facts.clusterCount < 2) inconclusive.push(`${facts.clusterCount} cluster(s): no uncertainty can be estimated`);
|
|
2291
|
+
if (facts.degenerate.length > 0) {
|
|
2292
|
+
inconclusive.push(
|
|
2293
|
+
`degenerate interval (all cluster contrasts equal) for ${facts.degenerate.join(', ')}: diagnostic only, not proof of zero uncertainty`,
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
if (facts.diagnostics.includes('baseline mean cost is not positive')) inconclusive.push('baseline mean cost is not positive');
|
|
2297
|
+
if (!estimates) inconclusive.push('no estimate could be formed');
|
|
2298
|
+
if (inconclusive.length > 0) return result(OUTCOMES.inconclusive, inconclusive);
|
|
2299
|
+
|
|
2300
|
+
// 8. gates
|
|
2301
|
+
const failed = gateResults.filter((gate) => !gate.passes);
|
|
2302
|
+
if (failed.length > 0) {
|
|
2303
|
+
return result(
|
|
2304
|
+
OUTCOMES.inconclusive,
|
|
2305
|
+
failed.map((gate) => `${gate.gate}: lower bound ${gate.lower} does not exceed ${gate.threshold}`),
|
|
2306
|
+
);
|
|
2307
|
+
}
|
|
2308
|
+
if (stage !== 'confirmation') {
|
|
2309
|
+
return result(OUTCOMES.selectionCleared, [
|
|
2310
|
+
'every required gate clears on selection data; a fresh confirmation on untouched cases is still required',
|
|
2311
|
+
]);
|
|
2312
|
+
}
|
|
2313
|
+
return result(OUTCOMES.recommend, ['every required gate clears on confirmation cases that were never exposed to selection']);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// ---------------------------------------------------------------------------
|
|
2317
|
+
// the reader
|
|
2318
|
+
// ---------------------------------------------------------------------------
|
|
2319
|
+
|
|
2320
|
+
function evidenceIndexRow(row, status, detail, verification, nativeSource) {
|
|
2321
|
+
return {
|
|
2322
|
+
stage: row.stage,
|
|
2323
|
+
arm: row.arm,
|
|
2324
|
+
caseId: row.caseId,
|
|
2325
|
+
clusterId: row.clusterId,
|
|
2326
|
+
blockId: row.blockId,
|
|
2327
|
+
repetition: row.repetition,
|
|
2328
|
+
runId: row.runId,
|
|
2329
|
+
attemptId: row.attemptId,
|
|
2330
|
+
attempt: row.attemptNumber,
|
|
2331
|
+
retryOf: row.retryOf,
|
|
2332
|
+
origin: row.receipt ? 'fresh' : 'imported-historical',
|
|
2333
|
+
receipt: row.receipt,
|
|
2334
|
+
receiptProblem: row.receiptProblem,
|
|
2335
|
+
verification: verification === undefined ? 'not-checked' : verification.length === 0 ? 'verified' : 'claimed',
|
|
2336
|
+
nativeSource: nativeSource ?? null,
|
|
2337
|
+
verificationProblems: verification ?? [],
|
|
2338
|
+
inputSha256: row.inputSha256,
|
|
2339
|
+
outputSha256: row.outputSha256,
|
|
2340
|
+
native: row.native,
|
|
2341
|
+
status: row.status,
|
|
2342
|
+
error: row.error,
|
|
2343
|
+
metrics: row.metrics,
|
|
2344
|
+
counts: row.counts,
|
|
2345
|
+
notApplicable: row.notApplicable,
|
|
2346
|
+
criticalMisses: row.critical,
|
|
2347
|
+
usage: row.usage,
|
|
2348
|
+
cost: row.cost,
|
|
2349
|
+
latency: row.latency,
|
|
2350
|
+
provenance: row.provenance,
|
|
2351
|
+
counted: status,
|
|
2352
|
+
detail: detail ?? null,
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* Read an experiment.
|
|
2358
|
+
*
|
|
2359
|
+
* @param {object} input
|
|
2360
|
+
* @param {object} input.declaration The experiment declaration.
|
|
2361
|
+
* @param {Array<object>} input.attempts Evidence rows.
|
|
2362
|
+
* @param {Array<object>} [input.ledger] The exposure ledger.
|
|
2363
|
+
* @param {object} [input.resume] Heads recorded by an earlier report: `{ ledger: {head, length} }`.
|
|
2364
|
+
* @param {object} [input.selection] For a confirmation: the selection stage's own input
|
|
2365
|
+
* (`declaration`, `attempts`, `ledger`, ...). The reader reads it again and holds the
|
|
2366
|
+
* result to the selection-cleared report the confirmation pins.
|
|
2367
|
+
* @param {Object<string, object>} [input.nativeRecords] Native records by reference, as
|
|
2368
|
+
* `loadRecordedTestDesignRun` returns them from a manifest-checked run directory.
|
|
2369
|
+
* @param {Array<object>} [input.judgeCalls] Repeated judgments of identical output.
|
|
2370
|
+
* @param {(text: string, row: object) => object} [input.replay] Deterministic scorer over saved bytes.
|
|
2371
|
+
* It returns `{ metrics, counts }` for a document; attempts are verified against it.
|
|
2372
|
+
* @param {Function} [input.bootstrap] The bootstrap implementation (calibrated before use).
|
|
2373
|
+
* @returns {{report: object, evidenceIndex: object}}
|
|
2374
|
+
*/
|
|
2375
|
+
function readExperimentChecked({
|
|
2376
|
+
declaration,
|
|
2377
|
+
attempts = [],
|
|
2378
|
+
ledger = null,
|
|
2379
|
+
resume = null,
|
|
2380
|
+
selection = null,
|
|
2381
|
+
nativeRecords = null,
|
|
2382
|
+
judgeCalls = null,
|
|
2383
|
+
replay = null,
|
|
2384
|
+
bootstrap = pairedClusterBootstrap,
|
|
2385
|
+
}) {
|
|
2386
|
+
const validation = validateDeclaration(declaration);
|
|
2387
|
+
const usable = validation.problems.length === 0;
|
|
2388
|
+
const declarationCopy = clone(declaration);
|
|
2389
|
+
const normalized = (Array.isArray(attempts) ? attempts : []).map((raw, index) => normalizeRow(raw, index));
|
|
2390
|
+
const { kept: unique, excluded } = classifyRows(normalized);
|
|
2391
|
+
|
|
2392
|
+
let kept = unique;
|
|
2393
|
+
if (usable) {
|
|
2394
|
+
const cases = stageCases(declarationCopy);
|
|
2395
|
+
kept = [];
|
|
2396
|
+
for (const row of unique) {
|
|
2397
|
+
if (!cases.includes(row.caseId)) {
|
|
2398
|
+
excluded.push({
|
|
2399
|
+
row,
|
|
2400
|
+
reason: 'outside-stage-membership',
|
|
2401
|
+
detail: `${row.caseId} is not a case of the ${declarationCopy.stage} stage`,
|
|
2402
|
+
});
|
|
2403
|
+
} else if (row.stage !== null && row.stage !== declarationCopy.stage) {
|
|
2404
|
+
excluded.push({
|
|
2405
|
+
row,
|
|
2406
|
+
reason: 'wrong-stage',
|
|
2407
|
+
detail: `the row belongs to the ${row.stage} stage, this declaration reads the ${declarationCopy.stage} stage`,
|
|
2408
|
+
});
|
|
2409
|
+
} else {
|
|
2410
|
+
kept.push(row);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
const limitations = [];
|
|
2416
|
+
const report = {
|
|
2417
|
+
kind: 'configuration-experiment-report',
|
|
2418
|
+
schemaVersion: SCHEMA_VERSION,
|
|
2419
|
+
analysisVersion: ANALYSIS_VERSION,
|
|
2420
|
+
advisory: true,
|
|
2421
|
+
nativeVerdict: null,
|
|
2422
|
+
declaration: {
|
|
2423
|
+
experimentId: declarationCopy?.experimentId ?? null,
|
|
2424
|
+
stage: declarationCopy?.stage ?? null,
|
|
2425
|
+
digest: validation.digest,
|
|
2426
|
+
frozen: validation.frozen,
|
|
2427
|
+
problems: validation.problems,
|
|
2428
|
+
},
|
|
2429
|
+
identities: usable ? { fixed: declarationCopy.identity.fixed, arms: declarationCopy.identity.arms ?? null } : null,
|
|
2430
|
+
descriptive: null,
|
|
2431
|
+
contrast: null,
|
|
2432
|
+
cost: null,
|
|
2433
|
+
denominators: null,
|
|
2434
|
+
exclusions: excluded.map((entry) => ({ attemptId: entry.row.attemptId, reason: entry.reason, detail: entry.detail })),
|
|
2435
|
+
sourceLimitations: limitations,
|
|
2436
|
+
};
|
|
2437
|
+
|
|
2438
|
+
if (!usable) {
|
|
2439
|
+
report.contrast = { status: 'blocked', decision: blockedDecision(validation) };
|
|
2440
|
+
return finish({ report, kept, excluded, inputCount: normalized.length });
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
report.descriptive = describeRepeatability({ declaration: declarationCopy, rows: kept, excluded, replay, judgeCalls, nativeRecords });
|
|
2444
|
+
if (report.descriptive.importedHistoricalTrials > 0) {
|
|
2445
|
+
limitations.push(
|
|
2446
|
+
`${report.descriptive.importedHistoricalTrials} baseline attempt(s) are imported historical captures without a fresh generation receipt: they describe what was recorded and are not fresh executions`,
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
if (kept.every((row) => !row.usage && !row.cost)) {
|
|
2450
|
+
limitations.push('no usage or cost telemetry is recorded, so no saving can be measured from this evidence');
|
|
2451
|
+
}
|
|
2452
|
+
const extras = report.descriptive.groups.flatMap((group) => group.cases.flatMap((entry) => entry.extraCompleted));
|
|
2453
|
+
if (extras.length > 0) {
|
|
2454
|
+
limitations.push(
|
|
2455
|
+
`${extras.length} completed attempt(s) repeat a run and repetition already counted and are listed under extraCompleted: ${extras.join(', ')}`,
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
limitations.push('hashes identify bytes; they do not prove that generation happened. Only a fresh-workspace receipt does');
|
|
2459
|
+
|
|
2460
|
+
let analyzedContrast = null;
|
|
2461
|
+
if (isNonEmptyString(declarationCopy.scope.candidate?.id)) {
|
|
2462
|
+
if (validation.pairedProblems.length > 0) {
|
|
2463
|
+
report.contrast = { status: 'blocked', decision: blockedDecision(validation) };
|
|
2464
|
+
} else {
|
|
2465
|
+
analyzedContrast = analyzeContrast({
|
|
2466
|
+
declaration: declarationCopy,
|
|
2467
|
+
validation,
|
|
2468
|
+
rows: kept,
|
|
2469
|
+
excluded,
|
|
2470
|
+
ledger,
|
|
2471
|
+
resume,
|
|
2472
|
+
bootstrap,
|
|
2473
|
+
replay,
|
|
2474
|
+
selection,
|
|
2475
|
+
nativeRecords,
|
|
2476
|
+
});
|
|
2477
|
+
report.contrast = { status: 'analyzed', ...analyzedContrast };
|
|
2478
|
+
}
|
|
2479
|
+
} else {
|
|
2480
|
+
report.contrast = {
|
|
2481
|
+
status: 'not-requested',
|
|
2482
|
+
reason: 'the declaration names no candidate; this is a descriptive repeatability reading',
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
report.cost = costSection(declarationCopy, kept);
|
|
2487
|
+
if (report.cost.experimentTotal.total === null)
|
|
2488
|
+
limitations.push('total experiment cost is unknown: at least one cost component is not recorded');
|
|
2489
|
+
return finish({ report, kept, excluded, inputCount: normalized.length, declaration: declarationCopy, analyzedContrast, nativeRecords });
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/**
|
|
2493
|
+
* Read an experiment. A fault inside the reader is reported as a blocked contrast
|
|
2494
|
+
* naming the fault, never thrown at the caller.
|
|
2495
|
+
*
|
|
2496
|
+
* @param {object} input See `readExperimentChecked`.
|
|
2497
|
+
* @returns {{report: object, evidenceIndex: object}}
|
|
2498
|
+
*/
|
|
2499
|
+
function readExperiment(input) {
|
|
2500
|
+
try {
|
|
2501
|
+
return readExperimentChecked(input);
|
|
2502
|
+
} catch (error) {
|
|
2503
|
+
const validation = {
|
|
2504
|
+
problems: [{ where: 'reader', message: `could not read this input: ${error.message}` }],
|
|
2505
|
+
pairedProblems: [],
|
|
2506
|
+
frozen: {},
|
|
2507
|
+
};
|
|
2508
|
+
const report = {
|
|
2509
|
+
kind: 'configuration-experiment-report',
|
|
2510
|
+
schemaVersion: SCHEMA_VERSION,
|
|
2511
|
+
analysisVersion: ANALYSIS_VERSION,
|
|
2512
|
+
advisory: true,
|
|
2513
|
+
nativeVerdict: null,
|
|
2514
|
+
declaration: {
|
|
2515
|
+
experimentId: null,
|
|
2516
|
+
stage: null,
|
|
2517
|
+
digest: null,
|
|
2518
|
+
frozen: { declared: false, matches: null },
|
|
2519
|
+
problems: validation.problems,
|
|
2520
|
+
},
|
|
2521
|
+
identities: null,
|
|
2522
|
+
descriptive: null,
|
|
2523
|
+
contrast: { status: 'blocked', decision: blockedDecision(validation) },
|
|
2524
|
+
cost: null,
|
|
2525
|
+
denominators: null,
|
|
2526
|
+
exclusions: [],
|
|
2527
|
+
sourceLimitations: ['the reader failed on this input; nothing was analyzed'],
|
|
2528
|
+
evidenceIndex: { rows: 0, digest: digestOf([]) },
|
|
2529
|
+
};
|
|
2530
|
+
return {
|
|
2531
|
+
report,
|
|
2532
|
+
evidenceIndex: { kind: 'configuration-experiment-evidence-index', schemaVersion: SCHEMA_VERSION, declarationDigest: null, rows: [] },
|
|
2533
|
+
};
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
/** Task, judge and experiment cost over every kept attempt, retries and failures included. */
|
|
2538
|
+
function costSection(declaration, rows) {
|
|
2539
|
+
const pricing = declaration.resources?.pricing ?? null;
|
|
2540
|
+
const scope = declaration.resources?.costScope ?? DEFAULT_COST_SCOPE;
|
|
2541
|
+
const taskNames = scope.taskExecution ?? DEFAULT_COST_SCOPE.taskExecution;
|
|
2542
|
+
const judgeNames = declaration.scope?.judge === 'rubric' ? (scope.judge ?? DEFAULT_COST_SCOPE.judge) : [];
|
|
2543
|
+
const everyName = [...new Set([...taskNames, ...judgeNames, ...rows.flatMap((row) => Object.keys(row.cost?.components ?? {}))])];
|
|
2544
|
+
const everything = scopedCost(rows, everyName, pricing);
|
|
2545
|
+
const overhead = declaration.resources?.overhead;
|
|
2546
|
+
const overheadDeclared = Array.isArray(overhead);
|
|
2547
|
+
const overheadTotal = overheadDeclared
|
|
2548
|
+
? knownSum(overhead.map((entry) => (isFiniteNumber(entry?.amount) && entry.amount >= 0 ? entry.amount : null)))
|
|
2549
|
+
: null;
|
|
2550
|
+
let total = null;
|
|
2551
|
+
if (everything.total !== null && overheadTotal !== null) {
|
|
2552
|
+
const overheadCurrencies = new Set(overhead.map((entry) => entry.currency));
|
|
2553
|
+
const currenciesAgree =
|
|
2554
|
+
overheadCurrencies.size <= 1 &&
|
|
2555
|
+
(overheadCurrencies.size === 0 || everything.currency === null || overheadCurrencies.has(everything.currency));
|
|
2556
|
+
total = currenciesAgree ? everything.total + overheadTotal : null;
|
|
2557
|
+
}
|
|
2558
|
+
return {
|
|
2559
|
+
includes: 'every attempt: retries, canceled, failed and invalid work stay in the totals',
|
|
2560
|
+
attempts: rows.length,
|
|
2561
|
+
sources: rows.map((row) => row.attemptId),
|
|
2562
|
+
taskExecution: summarizeCost(scopedCost(rows, taskNames, pricing)),
|
|
2563
|
+
judge:
|
|
2564
|
+
judgeNames.length === 0
|
|
2565
|
+
? { total: 0, currency: null, unknownComponents: 0, mixedCurrency: false, notApplicable: true }
|
|
2566
|
+
: summarizeCost(scopedCost(rows, judgeNames, pricing)),
|
|
2567
|
+
experimentTotal: {
|
|
2568
|
+
...summarizeCost(everything),
|
|
2569
|
+
total,
|
|
2570
|
+
overheadDeclared,
|
|
2571
|
+
overheadTotal,
|
|
2572
|
+
note: 'preflight, calibration, diagnostics and canceled work outside the attempts are declared in resources.overhead; an undeclared overhead leaves the total unknown',
|
|
2573
|
+
},
|
|
2574
|
+
derivedFromUsage: rows.flatMap((row) =>
|
|
2575
|
+
attemptCostComponents(row, pricing).derived.map((name) => ({ attemptId: row.attemptId, component: name })),
|
|
2576
|
+
),
|
|
2577
|
+
note: 'unknown stays unknown: a missing component never counts as zero, and tokens become money only through a complete price sheet over complete usage',
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
function summarizeCost(cost) {
|
|
2582
|
+
return {
|
|
2583
|
+
total: cost.total,
|
|
2584
|
+
currency: cost.currency,
|
|
2585
|
+
unknownComponents: cost.unknown.length,
|
|
2586
|
+
mixedCurrency: cost.mixedCurrency,
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
/** The blocked decision for a declaration that cannot run the contrast. */
|
|
2591
|
+
function blockedDecision(validation) {
|
|
2592
|
+
const reasons = [...validation.problems, ...validation.pairedProblems].map(
|
|
2593
|
+
(problem) => `declaration: ${problem.where} ${problem.message}`,
|
|
2594
|
+
);
|
|
2595
|
+
if (validation.frozen.declared && validation.frozen.matches === false)
|
|
2596
|
+
reasons.push('declaration: frozen.digest does not match the declaration bytes');
|
|
2597
|
+
return {
|
|
2598
|
+
outcome: OUTCOMES.blocked,
|
|
2599
|
+
reasons,
|
|
2600
|
+
advisory: true,
|
|
2601
|
+
nativeVerdict: null,
|
|
2602
|
+
acceptsBaseline: false,
|
|
2603
|
+
appliesPatch: false,
|
|
2604
|
+
adoptionRequires: 'explicit human approval of the exact configuration bytes and destination',
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/** The evidence index, the denominators and their reconciliation, added to the report. */
|
|
2609
|
+
function finish({ report, kept, excluded, inputCount, declaration = null, analyzedContrast = null, nativeRecords = null }) {
|
|
2610
|
+
const countedAs = new Map();
|
|
2611
|
+
if (declaration && analyzedContrast) {
|
|
2612
|
+
const contrastRows = kept.filter((row) => ARMS.includes(row.arm));
|
|
2613
|
+
for (const slot of resolveSlots(declaration, contrastRows, clusterPlan(declaration)).slots) {
|
|
2614
|
+
for (const arm of ARMS) {
|
|
2615
|
+
const side = slot.arms[arm];
|
|
2616
|
+
for (const row of side.attempts) countedAs.set(row.attemptId, side.scoring === row ? 'scored' : 'superseded');
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
const evidenceRows = [
|
|
2621
|
+
...kept.map((row) =>
|
|
2622
|
+
evidenceIndexRow(
|
|
2623
|
+
row,
|
|
2624
|
+
countedAs.get(row.attemptId) ?? (row.status === 'completed' ? 'described' : 'unscored'),
|
|
2625
|
+
null,
|
|
2626
|
+
analyzedContrast?.verification[row.attemptId],
|
|
2627
|
+
NATIVE_SOURCES.get(nativeRecordOf(row, nativeRecords)),
|
|
2628
|
+
),
|
|
2629
|
+
),
|
|
2630
|
+
...excluded.map((entry) => evidenceIndexRow(entry.row, `excluded:${entry.reason}`, entry.detail)),
|
|
2631
|
+
].sort(
|
|
2632
|
+
(left, right) => String(left.attemptId ?? '').localeCompare(String(right.attemptId ?? '')) || left.counted.localeCompare(right.counted),
|
|
2633
|
+
);
|
|
2634
|
+
|
|
2635
|
+
if (declaration) {
|
|
2636
|
+
const plannedSlotsPerArm = clusterPlan(declaration).length * declaration.design.repeats;
|
|
2637
|
+
const arms = {};
|
|
2638
|
+
for (const arm of ARMS) {
|
|
2639
|
+
const armRows = kept.filter((row) => row.arm === arm);
|
|
2640
|
+
arms[arm] = {
|
|
2641
|
+
rows: armRows.length,
|
|
2642
|
+
completed: armRows.filter((row) => row.status === 'completed').length,
|
|
2643
|
+
failed: armRows.filter((row) => row.status === 'failed').length,
|
|
2644
|
+
canceled: armRows.filter((row) => row.status === 'canceled').length,
|
|
2645
|
+
invalid: armRows.filter((row) => row.status === 'invalid').length,
|
|
2646
|
+
timeout: armRows.filter((row) => row.status === 'timeout').length,
|
|
2647
|
+
scoredSlots: armRows.filter((row) => countedAs.get(row.attemptId) === 'scored').length,
|
|
2648
|
+
missingSlots: analyzedContrast
|
|
2649
|
+
? analyzedContrast.pairs.problems.missing.filter((entry) => entry.startsWith(`${arm}/`)).length
|
|
2650
|
+
: null,
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
report.denominators = {
|
|
2654
|
+
plannedSlotsPerArm,
|
|
2655
|
+
arms,
|
|
2656
|
+
keptAttempts: kept.length,
|
|
2657
|
+
excludedAttempts: excluded.length,
|
|
2658
|
+
attemptsInIndex: evidenceRows.length,
|
|
2659
|
+
reconciles:
|
|
2660
|
+
evidenceRows.length === inputCount &&
|
|
2661
|
+
kept.length + excluded.length === inputCount &&
|
|
2662
|
+
(!analyzedContrast ||
|
|
2663
|
+
ARMS.every(
|
|
2664
|
+
(arm) =>
|
|
2665
|
+
arms[arm].scoredSlots +
|
|
2666
|
+
arms[arm].missingSlots +
|
|
2667
|
+
analyzedContrast.pairs.problems.duplicate.filter((entry) => entry.startsWith(`${arm}/`)).length ===
|
|
2668
|
+
plannedSlotsPerArm,
|
|
2669
|
+
)),
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
const evidenceIndex = {
|
|
2673
|
+
kind: 'configuration-experiment-evidence-index',
|
|
2674
|
+
schemaVersion: SCHEMA_VERSION,
|
|
2675
|
+
declarationDigest: report.declaration.digest,
|
|
2676
|
+
rows: evidenceRows,
|
|
2677
|
+
};
|
|
2678
|
+
report.evidenceIndex = { rows: evidenceRows.length, digest: digestOf(evidenceRows) };
|
|
2679
|
+
return { report, evidenceIndex };
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// ---------------------------------------------------------------------------
|
|
2683
|
+
// recorded Test Design runs
|
|
2684
|
+
// ---------------------------------------------------------------------------
|
|
2685
|
+
|
|
2686
|
+
function readJson(file) {
|
|
2687
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function ratio(numerator, denominator) {
|
|
2691
|
+
return isFiniteNumber(numerator) && isFiniteNumber(denominator) && denominator > 0 ? numerator / denominator : null;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
function metricsFromContributions(contributions) {
|
|
2695
|
+
const metrics = {};
|
|
2696
|
+
const counts = {};
|
|
2697
|
+
const notApplicable = [];
|
|
2698
|
+
const names = new Set(
|
|
2699
|
+
Object.keys(contributions)
|
|
2700
|
+
.filter((key) => key.endsWith('.numerator'))
|
|
2701
|
+
.map((key) => key.slice(0, -'.numerator'.length)),
|
|
2702
|
+
);
|
|
2703
|
+
for (const name of names) {
|
|
2704
|
+
if (contributions[`${name}.denominator`] === 0) notApplicable.push(name);
|
|
2705
|
+
else metrics[name] = ratio(contributions[`${name}.numerator`], contributions[`${name}.denominator`]);
|
|
2706
|
+
}
|
|
2707
|
+
for (const [key, value] of Object.entries(contributions)) {
|
|
2708
|
+
if (key.includes('.') || key.startsWith('max') || !isFiniteNumber(value)) continue;
|
|
2709
|
+
counts[key] = value;
|
|
2710
|
+
}
|
|
2711
|
+
return { metrics, counts, notApplicable };
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
/**
|
|
2715
|
+
* Evidence rows and native records for a recorded Test Design run directory
|
|
2716
|
+
* (`manifest.json`, `result.json`, `cases/<case>-run-<n>/`). The manifest's hashes
|
|
2717
|
+
* are checked against the bytes on disk first, and every file the loader reads has
|
|
2718
|
+
* to be listed in it. Each attempt's native record holds what the run itself wrote:
|
|
2719
|
+
* its identity, input and output hashes, signature, the run's qualification (failure
|
|
2720
|
+
* class `none`), and what the runner recorded in `provenance.json`: the observed
|
|
2721
|
+
* identity, generation receipt, pairing block, usage, cost and latency. Without a
|
|
2722
|
+
* recorded identity the record holds what `result.json` shows (suite, prompt, source,
|
|
2723
|
+
* requested model and runner), and without the others they are null, so such an
|
|
2724
|
+
* attempt cannot back a decision. The records are frozen under the digest of the manifest. A run without a recorded receipt is
|
|
2725
|
+
* imported historical evidence: a recorded workspace path is provenance, not a receipt.
|
|
2726
|
+
*
|
|
2727
|
+
* @param {object} options
|
|
2728
|
+
* @param {string} options.dir The recorded run directory.
|
|
2729
|
+
* @param {'A'|'B'} [options.arm]
|
|
2730
|
+
* @param {string} [options.stage]
|
|
2731
|
+
* @param {string} [options.runId]
|
|
2732
|
+
* @returns {{rows: Array<object>, records: Object<string, object>, integrity: {checked: number, problems: string[]}, source: object}}
|
|
2733
|
+
*/
|
|
2734
|
+
function heldEvidenceBytes(dir, relative) {
|
|
2735
|
+
let file = dir;
|
|
2736
|
+
for (const part of relative.split(path.sep)) {
|
|
2737
|
+
file = path.join(file, part);
|
|
2738
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw new Error('evidence path contains a symbolic link');
|
|
2739
|
+
}
|
|
2740
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
2741
|
+
try {
|
|
2742
|
+
const held = fs.fstatSync(fd);
|
|
2743
|
+
const named = fs.lstatSync(file);
|
|
2744
|
+
if (!held.isFile() || held.nlink !== 1 || named.nlink !== 1 || held.ino !== named.ino || held.dev !== named.dev)
|
|
2745
|
+
throw new Error('evidence must be a regular single-link file');
|
|
2746
|
+
return fs.readFileSync(fd);
|
|
2747
|
+
} finally {
|
|
2748
|
+
fs.closeSync(fd);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
function loadRecordedTestDesignRun({ dir, arm = 'A', stage = 'baseline', runId = path.basename(dir) }) {
|
|
2752
|
+
const manifestBytes = heldEvidenceBytes(dir, 'manifest.json');
|
|
2753
|
+
const manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
2754
|
+
const manifestSha256 = `sha256:${sha256Hex(manifestBytes)}`;
|
|
2755
|
+
const problems = [];
|
|
2756
|
+
const listed = new Set();
|
|
2757
|
+
const verifiedBytes = new Map();
|
|
2758
|
+
for (const entry of manifest.files) {
|
|
2759
|
+
const relative = path.normalize(entry.path);
|
|
2760
|
+
if (path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`))
|
|
2761
|
+
throw new Error('manifest path escapes recorded run');
|
|
2762
|
+
let current = dir;
|
|
2763
|
+
for (const part of relative.split(path.sep)) {
|
|
2764
|
+
current = path.join(current, part);
|
|
2765
|
+
if (fs.lstatSync(current).isSymbolicLink()) throw new Error('manifest contains a symbolic link');
|
|
2766
|
+
}
|
|
2767
|
+
if (listed.has(entry.path)) throw new Error('manifest repeats a path');
|
|
2768
|
+
listed.add(entry.path);
|
|
2769
|
+
const bytes = heldEvidenceBytes(dir, relative);
|
|
2770
|
+
verifiedBytes.set(relative, bytes);
|
|
2771
|
+
if (bytes.length !== entry.bytes) problems.push(`${entry.path}: ${bytes.length} bytes, manifest says ${entry.bytes}`);
|
|
2772
|
+
if (sha256Hex(bytes) !== entry.sha256) problems.push(`${entry.path}: sha256 differs from the manifest`);
|
|
2773
|
+
}
|
|
2774
|
+
const read = (relative) => {
|
|
2775
|
+
const normalized = path.normalize(relative);
|
|
2776
|
+
if (path.isAbsolute(normalized) || normalized === '..' || normalized.startsWith(`..${path.sep}`))
|
|
2777
|
+
throw new Error('recorded artifact path escapes the run');
|
|
2778
|
+
let file = dir;
|
|
2779
|
+
for (const part of normalized.split(path.sep)) {
|
|
2780
|
+
file = path.join(file, part);
|
|
2781
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw new Error('recorded artifact path contains a symbolic link');
|
|
2782
|
+
}
|
|
2783
|
+
if (!listed.has(relative)) problems.push(`${relative}: not listed in the manifest`);
|
|
2784
|
+
return verifiedBytes.get(normalized) ?? heldEvidenceBytes(dir, normalized);
|
|
2785
|
+
};
|
|
2786
|
+
const result = JSON.parse(read('result.json').toString('utf8'));
|
|
2787
|
+
const [runner] = result.runners;
|
|
2788
|
+
// The run's own verdict: failure class `none` passed, any other class failed, none recorded unknown.
|
|
2789
|
+
const qualification = typeof result.failureClass === 'string' ? (result.failureClass === 'none' ? 'passed' : 'failed') : null;
|
|
2790
|
+
const rows = [];
|
|
2791
|
+
const records = {};
|
|
2792
|
+
for (const [index, diagnostic] of (runner.diagnostics ?? []).entries()) {
|
|
2793
|
+
const caseDirectory = diagnostic.artifactDirectory ?? `cases/${diagnostic.caseId}-run-${diagnostic.repetition}`;
|
|
2794
|
+
const provenance = JSON.parse(read(`${caseDirectory}/provenance.json`).toString('utf8'));
|
|
2795
|
+
const observation = JSON.parse(read(`${caseDirectory}/observation.json`).toString('utf8'));
|
|
2796
|
+
const prompt = fs.existsSync(path.join(dir, caseDirectory, 'prompt.txt')) ? read(`${caseDirectory}/prompt.txt`) : null;
|
|
2797
|
+
const text = observation.artifacts?.design?.value ?? null;
|
|
2798
|
+
const { metrics, counts, notApplicable } = metricsFromContributions(diagnostic.metricContributions ?? {});
|
|
2799
|
+
const ref = `${path.basename(dir)}/result.json#/runners/0/diagnostics/${index}`;
|
|
2800
|
+
const recorded = {
|
|
2801
|
+
fixed: { suiteDigest: result.suite.fixtureDigest, promptDigest: result.suite.promptDigest, sourceCommit: result.repository.commit },
|
|
2802
|
+
arm: { model: { requested: provenance.requestedModel, resolved: null }, runner: runner.version },
|
|
2803
|
+
};
|
|
2804
|
+
// An identity the runner recorded has to agree with what the run itself shows: the
|
|
2805
|
+
// suite and the requested model always, the prompt, source and runner where it names them.
|
|
2806
|
+
if (isObject(provenance.observed)) {
|
|
2807
|
+
const stated = flatten(provenance.observed);
|
|
2808
|
+
const shown = flatten(recorded);
|
|
2809
|
+
for (const leaf of ['fixed.suiteDigest', 'arm.model.requested', 'fixed.promptDigest', 'fixed.sourceCommit', 'arm.runner']) {
|
|
2810
|
+
const required = leaf === 'fixed.suiteDigest' || leaf === 'arm.model.requested';
|
|
2811
|
+
if ((required || leaf in stated) && canonicalJson(stated[leaf] ?? null) !== canonicalJson(shown[leaf] ?? null)) {
|
|
2812
|
+
problems.push(`${caseDirectory}/provenance.json: observed ${leaf} differs from what the run recorded`);
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
const observed = isObject(provenance.observed) ? clone(provenance.observed) : recorded;
|
|
2817
|
+
const record = {
|
|
2818
|
+
ref,
|
|
2819
|
+
manifestSha256,
|
|
2820
|
+
caseId: diagnostic.caseId,
|
|
2821
|
+
repetition: diagnostic.repetition,
|
|
2822
|
+
attempt: provenance.attempt ?? 1,
|
|
2823
|
+
status:
|
|
2824
|
+
diagnostic.completionState === 'completed'
|
|
2825
|
+
? 'completed'
|
|
2826
|
+
: ['failed', 'canceled', 'timeout'].includes(diagnostic.completionState)
|
|
2827
|
+
? diagnostic.completionState
|
|
2828
|
+
: 'invalid',
|
|
2829
|
+
inputSha256: prompt === null ? null : `sha256:${sha256Hex(prompt)}`,
|
|
2830
|
+
outputSha256: text === null ? null : `sha256:${sha256Hex(text)}`,
|
|
2831
|
+
receipt: isObject(provenance.receipt) ? clone(provenance.receipt) : null,
|
|
2832
|
+
observed,
|
|
2833
|
+
blockId: provenance.blockId ?? null,
|
|
2834
|
+
usage: isObject(provenance.usage) ? numberMap(provenance.usage) : null,
|
|
2835
|
+
cost: isObject(provenance.cost)
|
|
2836
|
+
? { currency: provenance.cost.currency ?? null, components: numberMap(provenance.cost.components) }
|
|
2837
|
+
: null,
|
|
2838
|
+
latency: isObject(provenance.latency) ? numberMap(provenance.latency) : null,
|
|
2839
|
+
signature: diagnostic.signature,
|
|
2840
|
+
qualification,
|
|
2841
|
+
};
|
|
2842
|
+
records[ref] = record;
|
|
2843
|
+
rows.push({
|
|
2844
|
+
attemptId: `${runId}/${diagnostic.caseId}/${diagnostic.repetition}/${provenance.attempt ?? 1}`,
|
|
2845
|
+
runId,
|
|
2846
|
+
stage,
|
|
2847
|
+
arm,
|
|
2848
|
+
caseId: diagnostic.caseId,
|
|
2849
|
+
clusterId: diagnostic.caseId,
|
|
2850
|
+
blockId: record.blockId,
|
|
2851
|
+
repetition: diagnostic.repetition,
|
|
2852
|
+
attempt: record.attempt,
|
|
2853
|
+
status: record.status,
|
|
2854
|
+
error: null,
|
|
2855
|
+
receipt: clone(record.receipt),
|
|
2856
|
+
inputSha256: record.inputSha256,
|
|
2857
|
+
outputSha256: record.outputSha256,
|
|
2858
|
+
outputText: text,
|
|
2859
|
+
native: {
|
|
2860
|
+
ref,
|
|
2861
|
+
manifestSha256,
|
|
2862
|
+
signature: diagnostic.signature,
|
|
2863
|
+
qualification,
|
|
2864
|
+
failureClass: diagnostic.failureClass ?? null,
|
|
2865
|
+
rootCause: diagnostic.rootCause ?? null,
|
|
2866
|
+
reason: diagnostic.reason ?? null,
|
|
2867
|
+
runnerFailureClass: runner.failureClass ?? null,
|
|
2868
|
+
runnerFailures: runner.failures ?? [],
|
|
2869
|
+
},
|
|
2870
|
+
metrics,
|
|
2871
|
+
counts,
|
|
2872
|
+
notApplicable,
|
|
2873
|
+
usage: clone(record.usage),
|
|
2874
|
+
cost: clone(record.cost),
|
|
2875
|
+
latency: clone(record.latency),
|
|
2876
|
+
observed: clone(observed),
|
|
2877
|
+
provenance: {
|
|
2878
|
+
mode: provenance.mode,
|
|
2879
|
+
capturedAt: provenance.capturedAt,
|
|
2880
|
+
originalWorkspace: provenance.originalWorkspace,
|
|
2881
|
+
agent: provenance.agent,
|
|
2882
|
+
},
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
// Records are native records only when the whole run matched its manifest.
|
|
2886
|
+
const checked = problems.length === 0;
|
|
2887
|
+
return {
|
|
2888
|
+
rows,
|
|
2889
|
+
records: Object.fromEntries(
|
|
2890
|
+
Object.entries(records).map(([ref, record]) => [ref, checked ? brandNativeRecord(record, 'manifest-checked') : deepFreeze(record)]),
|
|
2891
|
+
),
|
|
2892
|
+
integrity: { checked: manifest.files.length, problems },
|
|
2893
|
+
source: {
|
|
2894
|
+
dir,
|
|
2895
|
+
repository: result.repository,
|
|
2896
|
+
model: runner.model,
|
|
2897
|
+
runner: runner.version,
|
|
2898
|
+
repetitions: runner.repetitions,
|
|
2899
|
+
usage: runner.usage ?? null,
|
|
2900
|
+
failureClass: runner.failureClass ?? null,
|
|
2901
|
+
failures: runner.failures ?? [],
|
|
2902
|
+
},
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
/** Read a source-captured native attempt. Every consumed byte must be sealed. */
|
|
2907
|
+
function loadSealedTestDesignAttempt({ dir, runId = path.basename(path.dirname(dir)) }) {
|
|
2908
|
+
const manifestBytes = heldEvidenceBytes(dir, 'manifest.json');
|
|
2909
|
+
const manifest = JSON.parse(manifestBytes);
|
|
2910
|
+
const listed = new Set();
|
|
2911
|
+
const verifiedBytes = new Map();
|
|
2912
|
+
for (const entry of manifest.files) {
|
|
2913
|
+
const relative = path.normalize(entry.path);
|
|
2914
|
+
if (path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`) || listed.has(relative))
|
|
2915
|
+
throw new Error('invalid sealed attempt manifest path');
|
|
2916
|
+
let absolute = dir;
|
|
2917
|
+
for (const part of relative.split(path.sep)) {
|
|
2918
|
+
absolute = path.join(absolute, part);
|
|
2919
|
+
if (fs.lstatSync(absolute).isSymbolicLink()) throw new Error('sealed attempt contains a symbolic link');
|
|
2920
|
+
}
|
|
2921
|
+
const bytes = heldEvidenceBytes(dir, relative);
|
|
2922
|
+
if (bytes.length !== entry.bytes || sha256Hex(bytes) !== entry.sha256)
|
|
2923
|
+
throw new Error(`sealed attempt integrity failure: ${entry.path}`);
|
|
2924
|
+
listed.add(relative);
|
|
2925
|
+
verifiedBytes.set(relative, bytes);
|
|
2926
|
+
}
|
|
2927
|
+
const read = (relative) => {
|
|
2928
|
+
if (!listed.has(relative)) throw new Error(`sealed attempt does not list ${relative}`);
|
|
2929
|
+
return verifiedBytes.get(relative);
|
|
2930
|
+
};
|
|
2931
|
+
const provenance = JSON.parse(read('provenance.json'));
|
|
2932
|
+
const observation = JSON.parse(read('observation.json'));
|
|
2933
|
+
const diagnostic = JSON.parse(read('diagnostic.json'));
|
|
2934
|
+
const sourceBytes = read('workspace/source-capture.json');
|
|
2935
|
+
const source = JSON.parse(sourceBytes);
|
|
2936
|
+
let proof;
|
|
2937
|
+
try {
|
|
2938
|
+
proof = (observation.stdout?.kind === 'json' ? observation.stdout.value : JSON.parse(observation.stdout?.value ?? '')).sourceCapture;
|
|
2939
|
+
} catch {
|
|
2940
|
+
/* Missing proof is rejected below. */
|
|
2941
|
+
}
|
|
2942
|
+
if (
|
|
2943
|
+
source.captureIntegrityViolation ||
|
|
2944
|
+
source.status !== provenance.status ||
|
|
2945
|
+
diagnostic.completionState !== source.status ||
|
|
2946
|
+
proof?.status !== 'verified' ||
|
|
2947
|
+
proof.path !== 'source-capture.json' ||
|
|
2948
|
+
proof.sha256 !== `sha256:${sha256Hex(sourceBytes)}`
|
|
2949
|
+
)
|
|
2950
|
+
throw new Error('Rejected or unverified source capture cannot supply native evidence');
|
|
2951
|
+
const prompt = read('prompt.txt');
|
|
2952
|
+
const outputText = observation.artifacts?.design?.value ?? null;
|
|
2953
|
+
const outputSha256 = outputText === null ? null : `sha256:${sha256Hex(outputText)}`;
|
|
2954
|
+
if (
|
|
2955
|
+
source.inputSha256 !== `sha256:${sha256Hex(prompt)}` ||
|
|
2956
|
+
source.workspace !== provenance.originalWorkspace ||
|
|
2957
|
+
(source.endedAt !== null &&
|
|
2958
|
+
(source.workspaceId !== provenance.receipt?.workspaceId ||
|
|
2959
|
+
source.artifactSha256 !== outputSha256 ||
|
|
2960
|
+
source.startedAt !== provenance.receipt?.startedAt ||
|
|
2961
|
+
source.endedAt !== provenance.receipt?.endedAt)) ||
|
|
2962
|
+
(source.endedAt === null && provenance.receipt !== null)
|
|
2963
|
+
)
|
|
2964
|
+
throw new Error('source receipt disagrees with retained provenance or bytes');
|
|
2965
|
+
if (
|
|
2966
|
+
canonicalJson(source.cost) !== canonicalJson(provenance.cost) ||
|
|
2967
|
+
canonicalJson(source.usage) !== canonicalJson(provenance.usage) ||
|
|
2968
|
+
source.observed?.resolvedModel !== provenance.observed?.arm?.model?.resolved ||
|
|
2969
|
+
source.observed?.appliedEffort !== provenance.observed?.arm?.effort?.applied
|
|
2970
|
+
)
|
|
2971
|
+
throw new Error('source telemetry disagrees with retained provenance');
|
|
2972
|
+
const manifestSha256 = `sha256:${sha256Hex(manifestBytes)}`;
|
|
2973
|
+
const ref = `${dir}/diagnostic.json`;
|
|
2974
|
+
const record = {
|
|
2975
|
+
ref,
|
|
2976
|
+
manifestSha256,
|
|
2977
|
+
caseId: provenance.caseId,
|
|
2978
|
+
repetition: provenance.repetition,
|
|
2979
|
+
attempt: provenance.attempt,
|
|
2980
|
+
status: provenance.status,
|
|
2981
|
+
inputSha256: source.inputSha256,
|
|
2982
|
+
outputSha256,
|
|
2983
|
+
receipt: provenance.receipt ?? null,
|
|
2984
|
+
observed: provenance.observed ?? null,
|
|
2985
|
+
blockId: provenance.blockId ?? null,
|
|
2986
|
+
usage: provenance.usage ?? null,
|
|
2987
|
+
cost: provenance.cost ?? null,
|
|
2988
|
+
latency: provenance.latency ?? null,
|
|
2989
|
+
signature: diagnostic.signature,
|
|
2990
|
+
qualification: diagnostic.qualification ?? null,
|
|
2991
|
+
};
|
|
2992
|
+
const row = {
|
|
2993
|
+
...record,
|
|
2994
|
+
attemptId: `${runId}/${provenance.caseId}/${provenance.repetition}/${provenance.arm}/${provenance.attempt}`,
|
|
2995
|
+
runId,
|
|
2996
|
+
stage: provenance.stage,
|
|
2997
|
+
arm: provenance.arm,
|
|
2998
|
+
clusterId: provenance.caseId,
|
|
2999
|
+
outputText,
|
|
3000
|
+
error: source.error ?? null,
|
|
3001
|
+
native: {
|
|
3002
|
+
ref,
|
|
3003
|
+
manifestSha256,
|
|
3004
|
+
signature: diagnostic.signature,
|
|
3005
|
+
qualification: record.qualification,
|
|
3006
|
+
failureClass: diagnostic.failureClass,
|
|
3007
|
+
},
|
|
3008
|
+
...metricsFromContributions(diagnostic.metricContributions ?? {}),
|
|
3009
|
+
provenance,
|
|
3010
|
+
};
|
|
3011
|
+
return {
|
|
3012
|
+
rows: [row],
|
|
3013
|
+
records: { [ref]: brandNativeRecord(record, 'manifest-checked') },
|
|
3014
|
+
integrity: { checked: manifest.files.length, problems: [] },
|
|
3015
|
+
verifiedBytes,
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
/**
|
|
3020
|
+
* The deterministic Test Design scorer as a replay function. Scores saved bytes
|
|
3021
|
+
* against the case's fixture set; the result carries the native signature,
|
|
3022
|
+
* diagnostic projection, metrics and counts. Fixture mutations happen
|
|
3023
|
+
* in the workspace, so the bytes cannot show them: the replay signs them as zero and
|
|
3024
|
+
* reports no `fixtureMutations` count, and a row's claimed count stays unverified.
|
|
3025
|
+
*
|
|
3026
|
+
* @param {{groundTruth: object}} options
|
|
3027
|
+
* @returns {(text: string, row: object) => object}
|
|
3028
|
+
*/
|
|
3029
|
+
function testDesignReplay({ groundTruth }) {
|
|
3030
|
+
const categories = new Set(groundTruth.riskCategories ?? []);
|
|
3031
|
+
return (text, row) => {
|
|
3032
|
+
const set = groundTruth.fixtureSets.find((entry) => entry.id === row.caseId);
|
|
3033
|
+
if (!set) return { unmeasurable: 'unknown-case' };
|
|
3034
|
+
const read = readDesign({ kind: 'text', value: text });
|
|
3035
|
+
if (!read.ok) return { unmeasurable: read.failureClass };
|
|
3036
|
+
const scored = scoreRun(set, read.design, categories);
|
|
3037
|
+
const { metrics, counts, notApplicable } = metricsFromContributions(numericContributions(testDesignDiagnosticProjection(scored, 0)));
|
|
3038
|
+
delete counts.fixtureMutations;
|
|
3039
|
+
return {
|
|
3040
|
+
signature: signatureOf(scored, 0),
|
|
3041
|
+
projection: projectTestDesignResult(text, set, categories),
|
|
3042
|
+
metrics,
|
|
3043
|
+
counts,
|
|
3044
|
+
notApplicable,
|
|
3045
|
+
};
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
// ---------------------------------------------------------------------------
|
|
3050
|
+
// rendering and files
|
|
3051
|
+
// ---------------------------------------------------------------------------
|
|
3052
|
+
|
|
3053
|
+
const pct = (value) => (value === null || value === undefined ? 'unknown' : `${(value * 100).toFixed(1)}%`);
|
|
3054
|
+
const num = (value) => (value === null || value === undefined ? 'unknown' : String(Number(value.toFixed(6))));
|
|
3055
|
+
|
|
3056
|
+
/** A readable rendering of the report. */
|
|
3057
|
+
function renderReport(report) {
|
|
3058
|
+
const lines = [];
|
|
3059
|
+
lines.push(
|
|
3060
|
+
`# Experiment report: ${report.declaration.experimentId ?? 'unnamed'}`,
|
|
3061
|
+
'',
|
|
3062
|
+
`Advisory analysis. It carries no native EQ verdict, accepts no baseline and applies no patch.`,
|
|
3063
|
+
'',
|
|
3064
|
+
`- Stage: ${report.declaration.stage ?? 'unknown'}`,
|
|
3065
|
+
`- Declaration digest: \`${report.declaration.digest}\` (${describeFrozen(report.declaration.frozen)})`,
|
|
3066
|
+
`- Schema ${report.schemaVersion}, analysis ${report.analysisVersion}`,
|
|
3067
|
+
'',
|
|
3068
|
+
);
|
|
3069
|
+
if (report.declaration.problems.length > 0) {
|
|
3070
|
+
lines.push('## Declaration problems', '');
|
|
3071
|
+
for (const problem of report.declaration.problems) lines.push(`- ${problem.where}: ${problem.message}`);
|
|
3072
|
+
lines.push('');
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
const descriptive = report.descriptive;
|
|
3076
|
+
if (descriptive) {
|
|
3077
|
+
lines.push(
|
|
3078
|
+
'## Repeatability (descriptive)',
|
|
3079
|
+
'',
|
|
3080
|
+
`${descriptive.note}.`,
|
|
3081
|
+
'',
|
|
3082
|
+
`- Fresh receipted baseline trials: ${descriptive.freshIndependentTrials}`,
|
|
3083
|
+
`- Imported historical baseline trials: ${descriptive.importedHistoricalTrials}`,
|
|
3084
|
+
`- Copies excluded: ${descriptive.excludedCopies}`,
|
|
3085
|
+
'',
|
|
3086
|
+
);
|
|
3087
|
+
for (const group of descriptive.groups) {
|
|
3088
|
+
lines.push(
|
|
3089
|
+
group.configurationComparability === 'unknown'
|
|
3090
|
+
? `### Historical observation ${group.cases.flatMap((entry) => entry.sources).join(', ')}`
|
|
3091
|
+
: `### Identity ${group.identity === 'unobserved' ? 'not recorded' : group.identity}`,
|
|
3092
|
+
'',
|
|
3093
|
+
...(group.configurationComparability === 'unknown'
|
|
3094
|
+
? [
|
|
3095
|
+
`Configuration comparability: unknown. Recorded invocation: ${group.invocationId ?? 'unknown'}. Requested runner: ${group.requested.agent ?? 'unknown'}; requested model: ${group.requested.model ?? 'unknown'}.`,
|
|
3096
|
+
'',
|
|
3097
|
+
]
|
|
3098
|
+
: []),
|
|
3099
|
+
'| Case | Completed / planned | Success | Native signature | Differing components |',
|
|
3100
|
+
'| --- | --- | --- | --- | --- |',
|
|
3101
|
+
);
|
|
3102
|
+
for (const entry of group.cases) {
|
|
3103
|
+
const stable =
|
|
3104
|
+
entry.stability.stable === null
|
|
3105
|
+
? 'unknown'
|
|
3106
|
+
: entry.stability.stable
|
|
3107
|
+
? 'stable'
|
|
3108
|
+
: `unstable (${entry.stability.distinct} distinct)`;
|
|
3109
|
+
lines.push(
|
|
3110
|
+
`| ${entry.caseId} | ${entry.completed} / ${entry.planned} | ${entry.successes === null ? 'unknown' : `${entry.successes} of ${entry.completed} (${pct(entry.successFraction)})`} | ${stable} | ${(entry.stability.differingComponents ?? []).join(', ') || 'none or unknown'} |`,
|
|
3111
|
+
);
|
|
3112
|
+
}
|
|
3113
|
+
lines.push('', '| Case | Metric | n | Min | Max | Range | Mean |', '| --- | --- | --- | --- | --- | --- | --- |');
|
|
3114
|
+
for (const entry of group.cases) {
|
|
3115
|
+
for (const [name, spread] of Object.entries(entry.metrics)) {
|
|
3116
|
+
lines.push(
|
|
3117
|
+
`| ${entry.caseId} | ${name} | ${spread.n} | ${num(spread.min)} | ${num(spread.max)} | ${num(spread.range)} | ${num(spread.mean)} |`,
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
lines.push('');
|
|
3122
|
+
}
|
|
3123
|
+
const replay = descriptive.replay;
|
|
3124
|
+
lines.push('### Scorer replay on saved bytes', '');
|
|
3125
|
+
if (replay.status === 'replayed') {
|
|
3126
|
+
const replayed = replay.results.filter((entry) => entry.status === 'replayed');
|
|
3127
|
+
const deterministic = replayed.filter((entry) => entry.deterministic).length;
|
|
3128
|
+
lines.push(`- Replayed ${replayed.length} document(s); ${deterministic} replayed identically twice.`);
|
|
3129
|
+
const unavailable = replay.results.filter((entry) => entry.status !== 'replayed');
|
|
3130
|
+
if (unavailable.length > 0)
|
|
3131
|
+
lines.push(
|
|
3132
|
+
`- Replay unavailable for ${unavailable.length} document(s): ${[...new Set(unavailable.map((entry) => entry.reason))].join('; ')}.`,
|
|
3133
|
+
);
|
|
3134
|
+
const tally = (value) => replayed.filter((entry) => entry.nativeAgreement === value).length;
|
|
3135
|
+
lines.push(
|
|
3136
|
+
`- Against the recorded native signature: ${tally('match')} match, ${tally('differs')} differ, ${tally('not-comparable')} not comparable.`,
|
|
3137
|
+
'- A difference is a separate replay result. The native outcome stays as recorded.',
|
|
3138
|
+
);
|
|
3139
|
+
} else {
|
|
3140
|
+
lines.push(`- ${replay.reason}`);
|
|
3141
|
+
}
|
|
3142
|
+
lines.push(
|
|
3143
|
+
'',
|
|
3144
|
+
'### Judge',
|
|
3145
|
+
'',
|
|
3146
|
+
descriptive.judge.status === 'analyzed'
|
|
3147
|
+
? `- Disagreement rate ${pct(descriptive.judge.disagreementRate)}, missing ${pct(descriptive.judge.missingRate)}, ${descriptive.judge.validity}.`
|
|
3148
|
+
: `- ${descriptive.judge.status}: ${descriptive.judge.reason}`,
|
|
3149
|
+
'',
|
|
3150
|
+
);
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
lines.push('## Paired contrast', '');
|
|
3154
|
+
const contrast = report.contrast;
|
|
3155
|
+
if (contrast?.status === 'analyzed') {
|
|
3156
|
+
lines.push(...renderContrast(contrast));
|
|
3157
|
+
} else if (contrast?.status === 'blocked') {
|
|
3158
|
+
lines.push(`Blocked.`, '', ...contrast.decision.reasons.map((reason) => `- ${reason}`), '');
|
|
3159
|
+
} else {
|
|
3160
|
+
lines.push(`${contrast?.reason ?? 'not requested'}`, '');
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
if (report.cost) {
|
|
3164
|
+
lines.push('## Cost', '');
|
|
3165
|
+
const line = (name, cost) =>
|
|
3166
|
+
`- ${name}: ${cost.total === null ? 'unknown' : `${num(cost.total)} ${cost.currency ?? ''}`.trim()}${cost.unknownComponents > 0 ? ` (${cost.unknownComponents} component(s) not recorded)` : ''}`;
|
|
3167
|
+
lines.push(
|
|
3168
|
+
line('Task execution', report.cost.taskExecution),
|
|
3169
|
+
line('Judge', report.cost.judge),
|
|
3170
|
+
line('Experiment total', report.cost.experimentTotal),
|
|
3171
|
+
'',
|
|
3172
|
+
report.cost.note,
|
|
3173
|
+
'',
|
|
3174
|
+
);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
if (report.denominators) {
|
|
3178
|
+
lines.push('## Denominators', '', `- Planned slots per arm: ${report.denominators.plannedSlotsPerArm}`);
|
|
3179
|
+
for (const arm of ARMS) {
|
|
3180
|
+
const counts = report.denominators.arms[arm];
|
|
3181
|
+
lines.push(
|
|
3182
|
+
`- Arm ${arm}: ${counts.rows} rows, ${counts.completed} completed, ${counts.failed} failed, ${counts.canceled} canceled, ${counts.invalid} invalid, ${counts.timeout} timed out`,
|
|
3183
|
+
);
|
|
3184
|
+
}
|
|
3185
|
+
lines.push(
|
|
3186
|
+
`- Excluded rows: ${report.denominators.excludedAttempts}; index rows: ${report.denominators.attemptsInIndex}; reconciles: ${report.denominators.reconciles}`,
|
|
3187
|
+
'',
|
|
3188
|
+
);
|
|
3189
|
+
}
|
|
3190
|
+
if (report.exclusions.length > 0) {
|
|
3191
|
+
lines.push('## Exclusions', '');
|
|
3192
|
+
for (const entry of report.exclusions)
|
|
3193
|
+
lines.push(`- ${entry.attemptId ?? '(no id)'}: ${entry.reason}${entry.detail ? ` (${entry.detail})` : ''}`);
|
|
3194
|
+
lines.push('');
|
|
3195
|
+
}
|
|
3196
|
+
lines.push('## Source limitations', '');
|
|
3197
|
+
for (const limitation of report.sourceLimitations) lines.push(`- ${limitation}`);
|
|
3198
|
+
lines.push('');
|
|
3199
|
+
return `${lines.join('\n')}\n`.replaceAll(/\n{3,}/g, '\n\n');
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
function describeFrozen(frozen) {
|
|
3203
|
+
if (!frozen.declared) return 'not frozen';
|
|
3204
|
+
return frozen.matches ? 'frozen and matching' : 'frozen digest does not match';
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function renderContrast(contrast) {
|
|
3208
|
+
const lines = [];
|
|
3209
|
+
lines.push(`Decision: **${contrast.decision.outcome}**`, '');
|
|
3210
|
+
for (const reason of contrast.decision.reasons) lines.push(`- ${reason}`);
|
|
3211
|
+
lines.push('', `Adoption would require ${contrast.decision.adoptionRequires}.`, '');
|
|
3212
|
+
const arms = contrast.arms;
|
|
3213
|
+
lines.push(
|
|
3214
|
+
'| Arm | Attempts | Completed | Errors | Canceled | Task cost | Task latency (ms) |',
|
|
3215
|
+
'| --- | --- | --- | --- | --- | --- | --- |',
|
|
3216
|
+
);
|
|
3217
|
+
for (const arm of ARMS) {
|
|
3218
|
+
const side = arms[arm];
|
|
3219
|
+
const cost =
|
|
3220
|
+
side.taskExecutionCost.total === null
|
|
3221
|
+
? 'unknown'
|
|
3222
|
+
: `${num(side.taskExecutionCost.total)} ${side.taskExecutionCost.currency ?? ''}`.trim();
|
|
3223
|
+
lines.push(
|
|
3224
|
+
`| ${arm} | ${side.attempts} | ${side.completed} | ${side.errors} | ${side.canceled} | ${cost} | ${num(side.taskLatencyMs)} |`,
|
|
3225
|
+
);
|
|
3226
|
+
}
|
|
3227
|
+
lines.push('');
|
|
3228
|
+
if (contrast.gates.length > 0) {
|
|
3229
|
+
lines.push('| Gate | Threshold | Lower bound | Result |', '| --- | --- | --- | --- |');
|
|
3230
|
+
for (const gate of contrast.gates)
|
|
3231
|
+
lines.push(`| ${gate.gate} | ${num(gate.threshold)} | ${num(gate.lower)} | ${gate.passes ? 'clears' : 'does not clear'} |`);
|
|
3232
|
+
lines.push('');
|
|
3233
|
+
}
|
|
3234
|
+
const { estimator } = contrast;
|
|
3235
|
+
lines.push(
|
|
3236
|
+
`Estimator: ${estimator.method}; alpha ${estimator.alpha}${estimator.simultaneous ? ` split to ${num(estimator.alphaPerBound)} per bound` : ''}, B ${estimator.B}, seed ${estimator.seed}, ${estimator.prng}, ${estimator.quantile} quantile, ${estimator.clusters} cluster(s). Calibration against reference fixtures: ${estimator.calibration.passed ? 'passed' : 'failed'}.`,
|
|
3237
|
+
'',
|
|
3238
|
+
);
|
|
3239
|
+
return lines;
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
/**
|
|
3243
|
+
* Write the three outputs into a new directory. Existing files are never overwritten.
|
|
3244
|
+
*
|
|
3245
|
+
* @param {{report: object, evidenceIndex: object}} result
|
|
3246
|
+
* @param {string} directory
|
|
3247
|
+
* @returns {string[]} The files written.
|
|
3248
|
+
*/
|
|
3249
|
+
function writeOutputs(result, directory) {
|
|
3250
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
3251
|
+
const files = {
|
|
3252
|
+
'experiment-report.json': `${JSON.stringify(result.report, null, 2)}\n`,
|
|
3253
|
+
'experiment-report.md': renderReport(result.report),
|
|
3254
|
+
'evidence-index.json': `${JSON.stringify(result.evidenceIndex, null, 2)}\n`,
|
|
3255
|
+
};
|
|
3256
|
+
for (const name of Object.keys(files)) {
|
|
3257
|
+
if (fs.existsSync(path.join(directory, name)))
|
|
3258
|
+
throw new Error(`${path.join(directory, name)} already exists; outputs are never overwritten`);
|
|
3259
|
+
}
|
|
3260
|
+
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(directory, name), content, { flag: 'wx' });
|
|
3261
|
+
return Object.keys(files).map((name) => path.join(directory, name));
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// ---------------------------------------------------------------------------
|
|
3265
|
+
// command line
|
|
3266
|
+
// ---------------------------------------------------------------------------
|
|
3267
|
+
|
|
3268
|
+
/**
|
|
3269
|
+
* `analyze` reads an evidence file that points at saved material:
|
|
3270
|
+
* `{ declaration, recordedRuns: [{kind: "test-design", dir, arm, stage}], nativeRuns: [dir], attempts, ledger, resume, selection, judgeCalls }`,
|
|
3271
|
+
* where `nativeRuns` are run directories whose native records back the `attempts`, and
|
|
3272
|
+
* a confirmation's `selection` is the path of the selection stage's evidence file.
|
|
3273
|
+
* Relative paths resolve against the evidence file. `digest` prints the digest a
|
|
3274
|
+
* confirmation pins for a selection report.
|
|
3275
|
+
*/
|
|
3276
|
+
/** The `readExperiment` input an evidence file describes; manifest problems go into `integrity`. */
|
|
3277
|
+
function loadEvidence(file, integrity) {
|
|
3278
|
+
const base = path.dirname(file);
|
|
3279
|
+
const evidence = readJson(file);
|
|
3280
|
+
const attempts = [...(evidence.attempts ?? [])];
|
|
3281
|
+
const references = evidence.recordedRuns ?? [];
|
|
3282
|
+
const nativeRecords = {};
|
|
3283
|
+
const addRecords = (records) => {
|
|
3284
|
+
for (const [ref, record] of Object.entries(records)) {
|
|
3285
|
+
if (Object.hasOwn(nativeRecords, ref) && canonicalJson(nativeRecords[ref]) !== canonicalJson(record)) {
|
|
3286
|
+
integrity.push(`${ref}: two native runs hold different records under one reference`);
|
|
3287
|
+
}
|
|
3288
|
+
nativeRecords[ref] = record;
|
|
3289
|
+
}
|
|
3290
|
+
};
|
|
3291
|
+
for (const reference of references) {
|
|
3292
|
+
const loaded = loadRecordedTestDesignRun({ dir: path.resolve(base, reference.dir), arm: reference.arm, stage: reference.stage });
|
|
3293
|
+
attempts.push(...loaded.rows);
|
|
3294
|
+
addRecords(loaded.records);
|
|
3295
|
+
integrity.push(...loaded.integrity.problems);
|
|
3296
|
+
}
|
|
3297
|
+
for (const dir of evidence.nativeRuns ?? []) {
|
|
3298
|
+
const loaded = loadRecordedTestDesignRun({ dir: path.resolve(base, dir) });
|
|
3299
|
+
addRecords(loaded.records);
|
|
3300
|
+
integrity.push(...loaded.integrity.problems);
|
|
3301
|
+
}
|
|
3302
|
+
return {
|
|
3303
|
+
declaration: readJson(path.resolve(base, evidence.declaration)),
|
|
3304
|
+
attempts,
|
|
3305
|
+
nativeRecords,
|
|
3306
|
+
ledger: evidence.ledger ?? null,
|
|
3307
|
+
resume: evidence.resume ?? null,
|
|
3308
|
+
judgeCalls: evidence.judgeCalls ?? null,
|
|
3309
|
+
recordedRuns: references.length,
|
|
3310
|
+
};
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
async function main(argv) {
|
|
3314
|
+
const [command, file, ...rest] = argv;
|
|
3315
|
+
if (command === 'freeze' && file) {
|
|
3316
|
+
const declaration = readJson(file);
|
|
3317
|
+
console.log(declarationDigest(declaration));
|
|
3318
|
+
return 0;
|
|
3319
|
+
}
|
|
3320
|
+
if (command === 'digest' && file) {
|
|
3321
|
+
console.log(digestOf(readJson(file)));
|
|
3322
|
+
return 0;
|
|
3323
|
+
}
|
|
3324
|
+
if (command === 'analyze' && file) {
|
|
3325
|
+
const outIndex = rest.indexOf('--out');
|
|
3326
|
+
const out = outIndex === -1 ? null : rest[outIndex + 1];
|
|
3327
|
+
if (!out) {
|
|
3328
|
+
console.error('analyze needs --out <directory>');
|
|
3329
|
+
return 2;
|
|
3330
|
+
}
|
|
3331
|
+
const integrity = [];
|
|
3332
|
+
const input = loadEvidence(path.resolve(file), integrity);
|
|
3333
|
+
const base = path.dirname(path.resolve(file));
|
|
3334
|
+
const selectionFile = readJson(path.resolve(file)).selection;
|
|
3335
|
+
if (selectionFile !== undefined && selectionFile !== null) {
|
|
3336
|
+
if (!isNonEmptyString(selectionFile)) {
|
|
3337
|
+
console.error('selection names the evidence file of the selection stage');
|
|
3338
|
+
return 2;
|
|
3339
|
+
}
|
|
3340
|
+
input.selection = loadEvidence(path.resolve(base, selectionFile), integrity);
|
|
3341
|
+
}
|
|
3342
|
+
if (integrity.length > 0) {
|
|
3343
|
+
console.error(`recorded evidence does not match its manifest:\n${integrity.join('\n')}`);
|
|
3344
|
+
return 1;
|
|
3345
|
+
}
|
|
3346
|
+
const testDesign = [input, input.selection].some(
|
|
3347
|
+
(entry) => entry?.recordedRuns > 0 || entry?.declaration?.scope?.workflow === 'bmad-tea-test-design',
|
|
3348
|
+
);
|
|
3349
|
+
const replay = testDesign ? testDesignReplay({ groundTruth: await loadGroundTruth() }) : null;
|
|
3350
|
+
const strip = ({ recordedRuns, ...rest }) => (void recordedRuns, rest);
|
|
3351
|
+
const result = readExperiment({ ...strip(input), selection: input.selection ? strip(input.selection) : null, replay });
|
|
3352
|
+
for (const file of writeOutputs(result, path.resolve(out))) console.log(file);
|
|
3353
|
+
return 0;
|
|
3354
|
+
}
|
|
3355
|
+
console.error(
|
|
3356
|
+
'usage: configuration-experiment.js freeze <declaration.json> | digest <experiment-report.json> | analyze <evidence.json> --out <directory>',
|
|
3357
|
+
);
|
|
3358
|
+
return 2;
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
if (require.main === module) {
|
|
3362
|
+
main(process.argv.slice(2)).then(
|
|
3363
|
+
(code) => process.exit(code),
|
|
3364
|
+
(error) => {
|
|
3365
|
+
console.error(error.message);
|
|
3366
|
+
process.exit(1);
|
|
3367
|
+
},
|
|
3368
|
+
);
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
module.exports = {
|
|
3372
|
+
metricsFromContributions,
|
|
3373
|
+
main,
|
|
3374
|
+
SCHEMA_VERSION,
|
|
3375
|
+
ANALYSIS_VERSION,
|
|
3376
|
+
OUTCOMES,
|
|
3377
|
+
SIGNATURE_COMPONENTS,
|
|
3378
|
+
CALIBRATION_FIXTURES,
|
|
3379
|
+
canonicalJson,
|
|
3380
|
+
digestOf,
|
|
3381
|
+
declarationDigest,
|
|
3382
|
+
validateDeclaration,
|
|
3383
|
+
normalizeRow,
|
|
3384
|
+
classifyRows,
|
|
3385
|
+
priceUsage,
|
|
3386
|
+
pairedClusterBootstrap,
|
|
3387
|
+
runCalibration,
|
|
3388
|
+
lowerBoundClears,
|
|
3389
|
+
evaluateGates,
|
|
3390
|
+
appendExposure,
|
|
3391
|
+
verifyLedger,
|
|
3392
|
+
replayFixedOutput,
|
|
3393
|
+
analyzeJudgeCalls,
|
|
3394
|
+
readExperiment,
|
|
3395
|
+
renderReport,
|
|
3396
|
+
writeOutputs,
|
|
3397
|
+
loadRecordedTestDesignRun,
|
|
3398
|
+
loadSealedTestDesignAttempt,
|
|
3399
|
+
trustNativeRecords,
|
|
3400
|
+
testDesignReplay,
|
|
3401
|
+
};
|