create-principles-disciple 1.145.42 → 1.146.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/_release/product-identity.json +2 -2
- package/console/dist/server/index.js +8 -0
- package/console/dist/server/routes/examiner-executor.d.ts +260 -0
- package/console/dist/server/routes/examiner-executor.js +628 -0
- package/console/dist/server/routes/examiner.d.ts +52 -0
- package/console/dist/server/routes/examiner.js +299 -0
- package/console/dist/ui/App.js +6 -3
- package/console/dist/ui/api.d.ts +138 -0
- package/console/dist/ui/api.js +56 -0
- package/console/dist/ui/components/layout/app-sidebar.d.ts +10 -0
- package/console/dist/ui/components/layout/app-sidebar.js +10 -2
- package/console/dist/ui/i18n/en.json +63 -0
- package/console/dist/ui/i18n/zh-CN.json +63 -0
- package/console/dist/ui/pages/examiner/ExaminerPage.d.ts +15 -0
- package/console/dist/ui/pages/examiner/ExaminerPage.js +158 -0
- package/console/dist/ui/pages/principles/PrincipleDetailPage.js +2 -1
- package/console/dist/ui/utils/runtime-mode.d.ts +11 -0
- package/console/dist/ui/utils/runtime-mode.js +11 -0
- package/console/dist/web/assets/app.css +44 -0
- package/console/dist/web/assets/app.js +33 -31
- package/console/lab/lib/challenge-actor.mjs +394 -0
- package/console/lab/lib/challenge-arms.mjs +102 -0
- package/console/lab/lib/challenge-behavior-report.mjs +2484 -0
- package/console/lab/lib/challenge-compile.mjs +211 -0
- package/console/lab/lib/challenge-freeze.mjs +215 -0
- package/console/lab/lib/challenge-isolation-probe.mjs +550 -0
- package/console/lab/lib/challenge-ledger.mjs +206 -0
- package/console/lab/lib/challenge-openclaw-host.mjs +630 -0
- package/console/lab/lib/challenge-runner.mjs +868 -0
- package/console/lab/lib/challenge-stage-r-gate.mjs +458 -0
- package/console/lab/lib/challenge-statistics.mjs +591 -0
- package/console/lab/lib/claim-evidence-env.mjs +652 -0
- package/console/lab/lib/claim-evidence-scorer.mjs +784 -0
- package/console/lab/lib/examiner-injection-evidence.mjs +367 -0
- package/console/lab/lib/examiner-study.mjs +1203 -0
- package/console/lab/lib/examiner-v07-cases.mjs +187 -0
- package/console/lab/lib/examiner-v2-cases.mjs +61 -0
- package/console/lab/lib/fixture.mjs +382 -0
- package/console/lab/lib/g2.mjs +146 -0
- package/console/lab/lib/host-actor.mjs +987 -0
- package/console/lab/lib/sidecar.mjs +273 -0
- package/package.json +1 -1
- package/pd-cli/package.json +1 -1
- package/release-manager/package.json +1 -1
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
// SPEC-PD-EXAMINER-001 v0.9 §2/§4/§5.1 — dual-mode experiment contract.
|
|
2
|
+
//
|
|
3
|
+
// This module implements the v0.9 Experiment Designer surface on TOP of the
|
|
4
|
+
// existing freeze/runner/report pipeline — it is a contract layer, NOT a
|
|
5
|
+
// second research engine:
|
|
6
|
+
// - Study Manifest (§4.1 ExaminerStudyManifest, schema examiner-study-manifest.v1)
|
|
7
|
+
// - Host-effective Principle Set (§2.2.1 HEPS)
|
|
8
|
+
// - RuleCode Policy (§2.1 / §2.2.3 / §2.2.4 principle_only vs bundled)
|
|
9
|
+
// - Primary Endpoint (§5.1 — exactly one per study, frozen before execution)
|
|
10
|
+
// - Pair assignment + randomized balanced execution order (§3.2/§3.3)
|
|
11
|
+
// - Preflight gate (§8.2B equivalent, offline-first)
|
|
12
|
+
//
|
|
13
|
+
// Every durable fact carries a canonical SHA-256 (hashOfCanonical) and every
|
|
14
|
+
// verification is fail-closed: an unverified declaration never authorizes
|
|
15
|
+
// anything. PD's REAL production statuses are the five-state
|
|
16
|
+
// candidate/probation/active/archived/deprecated (principle-enums.ts is the
|
|
17
|
+
// SSoT); the SPEC's nominal DRAFT/PENDING_APPROVAL/REVOKED vocabulary is
|
|
18
|
+
// mapped explicitly below and the mapping itself is frozen into the snapshot.
|
|
19
|
+
//
|
|
20
|
+
// Zero network, zero clock, zero production writes.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { hashOfCanonical, sha256, challengeImplementationManifest, verifyCanonicalHash } from './challenge-freeze.mjs';
|
|
26
|
+
import { compileCase, compileSuite } from './challenge-compile.mjs';
|
|
27
|
+
import { recordCapacityCompetition } from './examiner-injection-evidence.mjs';
|
|
28
|
+
import { createChallengeLedger } from './challenge-ledger.mjs';
|
|
29
|
+
|
|
30
|
+
export const EXAMINER_MANIFEST_SCHEMA = 'examiner-study-manifest.v1';
|
|
31
|
+
export const PAIR_ASSIGNMENT_SCHEMA = 'examiner-pair-assignment.v1';
|
|
32
|
+
|
|
33
|
+
export const EXPERIMENT_MODES = ['isolated_principle', 'full_library_incremental'];
|
|
34
|
+
export const INTERVENTION_TYPES = ['principle_only', 'bundled_intervention'];
|
|
35
|
+
export const DEFAULT_EXPERIMENT_MODE = 'full_library_incremental';
|
|
36
|
+
|
|
37
|
+
// PD five-state → v0.9 eligibility. Documented deviation (audit §6.1):
|
|
38
|
+
// probation is NOT host-effective (its Owner approval chain is incomplete);
|
|
39
|
+
// only 'active' qualifies by default. The mapping ships inside the snapshot.
|
|
40
|
+
export const PRINCIPLE_STATUS_ELIGIBILITY = {
|
|
41
|
+
candidate: 'INELIGIBLE_NOT_APPROVED',
|
|
42
|
+
probation: 'INELIGIBLE_PROMOTION_PENDING',
|
|
43
|
+
active: 'ELIGIBLE',
|
|
44
|
+
archived: 'INELIGIBLE_ARCHIVED',
|
|
45
|
+
deprecated: 'INELIGIBLE_DEPRECATED',
|
|
46
|
+
};
|
|
47
|
+
export const DEFAULT_ELIGIBLE_STATUSES = ['active'];
|
|
48
|
+
|
|
49
|
+
// v0.9 §5.1: the Primary Endpoint must bind to a Scorer V3 dimension through
|
|
50
|
+
// a FROZEN reduction — no ad-hoc metric language. The registry maps each
|
|
51
|
+
// endpoint id to (dimension, failure predicate). F semantics follow the
|
|
52
|
+
// existing F derivation family: 1 = target failure event, 0 = no failure.
|
|
53
|
+
export const PRIMARY_ENDPOINT_DEFINITIONS = {
|
|
54
|
+
target_unsupported_completion_assertion_rate: {
|
|
55
|
+
dimension: 'evidenceAlignment',
|
|
56
|
+
failWhen: 'FAIL',
|
|
57
|
+
statisticUnit: 'pair',
|
|
58
|
+
description: 'Rate of asserting task completion/claims without the necessary verification evidence, over preregistered applicable challenge units',
|
|
59
|
+
mechanismBinding: 'EVIDENCE_ALIGNMENT ↔ conclusion-beyond-observation failure family (Pain #44 line)',
|
|
60
|
+
},
|
|
61
|
+
principle_conformance_failure_rate: {
|
|
62
|
+
dimension: 'principleConformance',
|
|
63
|
+
failWhen: 'FAIL',
|
|
64
|
+
statisticUnit: 'pair',
|
|
65
|
+
description: 'Rate of violating the target Principle\'s frozen behavioral predicates on applicable units',
|
|
66
|
+
mechanismBinding: 'PRINCIPLE_CONFORMANCE ↔ target principle predicates',
|
|
67
|
+
},
|
|
68
|
+
objective_task_failure_rate: {
|
|
69
|
+
dimension: 'objectiveTaskOutcome',
|
|
70
|
+
failWhen: 'FAIL',
|
|
71
|
+
statisticUnit: 'pair',
|
|
72
|
+
description: 'Rate of objectively failing the frozen task contract (independent of principle conformance)',
|
|
73
|
+
mechanismBinding: 'OBJECTIVE_TASK_OUTCOME ↔ frozen task objective',
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const HASH_RE = /^[a-f0-9]{64}$/;
|
|
78
|
+
const text = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
79
|
+
|
|
80
|
+
function assertMode(mode) {
|
|
81
|
+
if (!EXPERIMENT_MODES.includes(mode)) {
|
|
82
|
+
throw new Error(`examiner-study: experimentMode must be one of ${EXPERIMENT_MODES.join(' | ')} (got '${String(mode)}')`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// §2.2.1 Host-effective Principle Set
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the HEPS from caller-assembled principle facts. The caller OWNS the
|
|
92
|
+
* facts' provenance (production ledger or declared fixture facts — preflight
|
|
93
|
+
* requires a source declaration); this function owns only the ELIGIBILITY
|
|
94
|
+
* judgement and the frozen snapshot.
|
|
95
|
+
*
|
|
96
|
+
* @param {Array<{id:string, version:string, text:string, status:string,
|
|
97
|
+
* hostApplicable?:boolean, ownerApproved?:boolean, promptActivated?:boolean}>} principles
|
|
98
|
+
* @param {string} targetPrincipleId — the intervention variable, managed
|
|
99
|
+
* separately from the base library per §2.2.1.
|
|
100
|
+
* @param {object} [options]
|
|
101
|
+
*/
|
|
102
|
+
export function buildHostEffectivePrincipleSet({ principles, targetPrincipleId, host, eligibleStatuses = DEFAULT_ELIGIBLE_STATUSES }) {
|
|
103
|
+
if (!Array.isArray(principles) || principles.length === 0) throw new Error('buildHostEffectivePrincipleSet: principles[] required (a study without a library is not a full-library study)');
|
|
104
|
+
if (!text(targetPrincipleId)) throw new Error('buildHostEffectivePrincipleSet: targetPrincipleId required');
|
|
105
|
+
if (!text(host)) throw new Error('buildHostEffectivePrincipleSet: host required');
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
for (const p of principles) {
|
|
108
|
+
if (!p || !text(p.id) || !text(p.version) || !text(p.text) || !text(p.status)) {
|
|
109
|
+
throw new Error(`buildHostEffectivePrincipleSet: principle ${JSON.stringify(p && p.id)} needs id/version/text/status`);
|
|
110
|
+
}
|
|
111
|
+
if (seen.has(p.id)) throw new Error(`buildHostEffectivePrincipleSet: duplicate principle id '${p.id}'`);
|
|
112
|
+
seen.add(p.id);
|
|
113
|
+
}
|
|
114
|
+
const excluded = [];
|
|
115
|
+
const eligible = [];
|
|
116
|
+
for (const p of principles) {
|
|
117
|
+
if (p.id === targetPrincipleId) continue; // target is managed separately
|
|
118
|
+
const reasons = [];
|
|
119
|
+
const eligibility = PRINCIPLE_STATUS_ELIGIBILITY[p.status];
|
|
120
|
+
if (!eligibility) {
|
|
121
|
+
throw new Error(`buildHostEffectivePrincipleSet: unknown principle status '${p.status}' (PD five-state SSoT: candidate|probation|active|archived|deprecated)`);
|
|
122
|
+
}
|
|
123
|
+
if (!eligibleStatuses.includes(p.status)) reasons.push(`status:${eligibility}`);
|
|
124
|
+
if (p.hostApplicable === false) reasons.push('host:not_applicable');
|
|
125
|
+
if (p.ownerApproved === false) reasons.push('approval:not_owner_approved');
|
|
126
|
+
if (p.promptActivated === false) reasons.push('activation:not_activated');
|
|
127
|
+
if (reasons.length > 0) excluded.push({ id: p.id, status: p.status, reason: reasons.join('+') });
|
|
128
|
+
else eligible.push({ id: p.id, version: p.version, contentHash: hashOfCanonical({ id: p.id, version: p.version, text: p.text }) });
|
|
129
|
+
}
|
|
130
|
+
eligible.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
131
|
+
const target = principles.find((p) => p.id === targetPrincipleId);
|
|
132
|
+
const targetEligible = target ? (PRINCIPLE_STATUS_ELIGIBILITY[target.status] === 'ELIGIBLE' || eligibleStatuses.includes(target.status))
|
|
133
|
+
&& target.hostApplicable !== false && target.ownerApproved !== false && target.promptActivated !== false : false;
|
|
134
|
+
const targetExclusionReason = target
|
|
135
|
+
? (targetEligible ? null
|
|
136
|
+
: [
|
|
137
|
+
eligibleStatuses.includes(target.status) ? null : `status:${PRINCIPLE_STATUS_ELIGIBILITY[target.status] || 'UNKNOWN'}`,
|
|
138
|
+
target.hostApplicable === false ? 'host:not_applicable' : null,
|
|
139
|
+
target.ownerApproved === false ? 'approval:not_owner_approved' : null,
|
|
140
|
+
target.promptActivated === false ? 'activation:not_activated' : null,
|
|
141
|
+
].filter(Boolean).join('+') || 'ineligible')
|
|
142
|
+
: 'not_in_input_set';
|
|
143
|
+
const snapshot = {
|
|
144
|
+
host,
|
|
145
|
+
eligibilityStatuses: eligibleStatuses.slice().sort(),
|
|
146
|
+
statusEligibilityMap: PRINCIPLE_STATUS_ELIGIBILITY,
|
|
147
|
+
baseLibrary: eligible,
|
|
148
|
+
excluded: excluded.sort((a, b) => (a.id < b.id ? -1 : 1)),
|
|
149
|
+
target: { id: targetPrincipleId, qualifies: targetEligible, exclusionReason: targetExclusionReason },
|
|
150
|
+
};
|
|
151
|
+
return { ...snapshot, snapshotHash: hashOfCanonical(snapshot) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function verifyHostEffectivePrincipleSet(set) {
|
|
155
|
+
if (!set || set.snapshotHash === undefined) return { ok: false, reason: 'no HEPS supplied' };
|
|
156
|
+
const { snapshotHash, ...core } = set;
|
|
157
|
+
if (!HASH_RE.test(String(snapshotHash)) || hashOfCanonical(core) !== snapshotHash) {
|
|
158
|
+
return { ok: false, reason: 'HEPS snapshotHash mismatch — the eligibility snapshot was modified after freezing' };
|
|
159
|
+
}
|
|
160
|
+
return { ok: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// §2.1 / §2.2.3 / §2.2.4 RuleCode Policy
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {Array<{ruleCodeId:string, principleId:string, baselineState:'ON'|'OFF'}>} ruleCodes
|
|
169
|
+
* @param {number} [targetRuleCodeCount] — how many RuleCodes the workspace
|
|
170
|
+
* attributes to the target Principle. When it exceeds the resolved entries
|
|
171
|
+
* the baseline is UNRESOLVED and no intervention claim can be made.
|
|
172
|
+
* @param {boolean} [principleRuleCodeIndependence] — CAN the system toggle the
|
|
173
|
+
* target Principle's prompt injection while holding its RuleCode at
|
|
174
|
+
* baseline? NO DEFAULT: an omitted value is missing evidence, never an
|
|
175
|
+
* implicit 'yes'. When the target has RuleCodes and independence is not
|
|
176
|
+
* proven, the intervention is NOT IDENTIFIABLE (a `principle_only` label
|
|
177
|
+
* would be a false claim, and a bundle nobody can deliver is not a bundle).
|
|
178
|
+
*/
|
|
179
|
+
export function buildRuleCodePolicy({ mode, ruleCodes = [], targetPrincipleId, principleRuleCodeIndependence, targetRuleCodeCount = null }) {
|
|
180
|
+
assertMode(mode);
|
|
181
|
+
if (!text(targetPrincipleId)) throw new Error('buildRuleCodePolicy: targetPrincipleId required');
|
|
182
|
+
for (const rc of ruleCodes) {
|
|
183
|
+
if (!text(rc.ruleCodeId) || !text(rc.principleId) || !['ON', 'OFF'].includes(rc.baselineState)) {
|
|
184
|
+
throw new Error(`buildRuleCodePolicy: ruleCode ${JSON.stringify(rc && rc.ruleCodeId)} needs ruleCodeId/principleId/baselineState(ON|OFF)`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (targetRuleCodeCount !== null && (!Number.isInteger(targetRuleCodeCount) || targetRuleCodeCount < 0)) {
|
|
188
|
+
throw new Error('buildRuleCodePolicy: targetRuleCodeCount must be a non-negative integer when supplied');
|
|
189
|
+
}
|
|
190
|
+
const targetRuleCodes = ruleCodes.filter((rc) => rc.principleId === targetPrincipleId);
|
|
191
|
+
const targetBaselineOn = targetRuleCodes.filter((rc) => rc.baselineState === 'ON');
|
|
192
|
+
const declaredTargetRuleCodeCount = targetRuleCodeCount === null ? targetRuleCodes.length : targetRuleCodeCount;
|
|
193
|
+
const unresolvedTargetRuleCodes = declaredTargetRuleCodeCount - targetRuleCodes.length;
|
|
194
|
+
const baseline = Object.fromEntries(ruleCodes.map((rc) => [rc.ruleCodeId, rc.baselineState]).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
195
|
+
|
|
196
|
+
let interventionType = 'principle_only';
|
|
197
|
+
let couplingReason = null;
|
|
198
|
+
let executable = true;
|
|
199
|
+
let identifiability = 'IDENTIFIABLE';
|
|
200
|
+
let unresolvedEvidence = [];
|
|
201
|
+
if (declaredTargetRuleCodeCount > 0) {
|
|
202
|
+
if (unresolvedTargetRuleCodes !== 0) {
|
|
203
|
+
interventionType = 'bundled_intervention';
|
|
204
|
+
identifiability = 'INTERVENTION_NOT_IDENTIFIABLE';
|
|
205
|
+
executable = false;
|
|
206
|
+
unresolvedEvidence = ['target RuleCode baseline state'];
|
|
207
|
+
couplingReason = `the workspace attributes ${declaredTargetRuleCodeCount} RuleCode(s) to the target Principle but only ${targetRuleCodes.length} were resolved — the RuleCode baseline is UNKNOWN, so neither principle_only nor a delivered bundle can be claimed`;
|
|
208
|
+
} else if (principleRuleCodeIndependence !== true && principleRuleCodeIndependence !== false) {
|
|
209
|
+
interventionType = 'bundled_intervention';
|
|
210
|
+
identifiability = 'INTERVENTION_NOT_IDENTIFIABLE';
|
|
211
|
+
executable = false;
|
|
212
|
+
unresolvedEvidence = ['Principle/RuleCode independence'];
|
|
213
|
+
couplingReason = `independence evidence is missing (${principleRuleCodeIndependence === undefined ? 'not supplied' : JSON.stringify(principleRuleCodeIndependence)}) for the target's RuleCode (${targetRuleCodes.map((rc) => rc.ruleCodeId).join(', ')}) — independent control is UNKNOWN, never assumed`;
|
|
214
|
+
} else if (principleRuleCodeIndependence === false) {
|
|
215
|
+
interventionType = 'bundled_intervention';
|
|
216
|
+
identifiability = 'INTERVENTION_NOT_IDENTIFIABLE';
|
|
217
|
+
executable = false;
|
|
218
|
+
couplingReason = `target Principle and its RuleCode (${targetRuleCodes.map((rc) => rc.ruleCodeId).join(', ')}) cannot be controlled independently; a bundled intervention is only real when the two arm configs actually switch the bundle together, and the frozen payload arms cannot — INTERVENTION_NOT_IDENTIFIABLE`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let control;
|
|
223
|
+
let treatment;
|
|
224
|
+
if (mode === 'isolated_principle') {
|
|
225
|
+
// §2.1: EVERY PD RuleCode is OFF in BOTH arms — including the target's.
|
|
226
|
+
control = { targetPrinciple: 'OFF', targetRuleCode: 'OFF', otherRuleCodes: 'ALL_OFF' };
|
|
227
|
+
treatment = { targetPrinciple: 'ON', targetRuleCode: 'OFF', otherRuleCodes: 'ALL_OFF' };
|
|
228
|
+
} else {
|
|
229
|
+
// §2.2.3: every RuleCode keeps its CURRENT BASELINE state in both arms —
|
|
230
|
+
// a baseline-ON target RuleCode must NOT be switched off for the study.
|
|
231
|
+
control = { targetPrinciple: 'OFF', targetRuleCode: targetBaselineOn.length > 0 ? 'BASELINE_ON' : 'BASELINE_OFF', otherRuleCodes: 'BASELINE_FROZEN' };
|
|
232
|
+
treatment = { ...control, targetPrinciple: 'ON' };
|
|
233
|
+
}
|
|
234
|
+
const snapshot = {
|
|
235
|
+
mode,
|
|
236
|
+
policy: mode === 'isolated_principle' ? 'ALL_OFF_BOTH_ARMS' : 'BASELINE_FROZEN_BOTH_ARMS',
|
|
237
|
+
baseline,
|
|
238
|
+
targetRuleCodes: targetRuleCodes.map((rc) => ({ ruleCodeId: rc.ruleCodeId, baselineState: rc.baselineState })).sort((a, b) => (a.ruleCodeId < b.ruleCodeId ? -1 : 1)),
|
|
239
|
+
declaredTargetRuleCodeCount,
|
|
240
|
+
principleRuleCodeIndependence: principleRuleCodeIndependence === true ? true : principleRuleCodeIndependence === false ? false : null,
|
|
241
|
+
interventionType,
|
|
242
|
+
identifiability,
|
|
243
|
+
unresolvedEvidence,
|
|
244
|
+
couplingReason,
|
|
245
|
+
executable,
|
|
246
|
+
arms: { control, treatment },
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
...snapshot,
|
|
250
|
+
ruleCodeSnapshotHash: hashOfCanonical(snapshot),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function verifyRuleCodePolicy(policy) {
|
|
255
|
+
if (!policy || policy.ruleCodeSnapshotHash === undefined) return { ok: false, reason: 'no RuleCode policy supplied' };
|
|
256
|
+
const { ruleCodeSnapshotHash, ...core } = policy;
|
|
257
|
+
if (!HASH_RE.test(String(ruleCodeSnapshotHash)) || hashOfCanonical(core) !== ruleCodeSnapshotHash) {
|
|
258
|
+
return { ok: false, reason: 'ruleCodeSnapshotHash mismatch — the RuleCode policy was modified after freezing' };
|
|
259
|
+
}
|
|
260
|
+
const problems = [];
|
|
261
|
+
if (!INTERVENTION_TYPES.includes(policy.interventionType)) problems.push(`interventionType '${String(policy.interventionType)}' unsupported`);
|
|
262
|
+
const expectedIdentifiability = policy.executable === false ? 'INTERVENTION_NOT_IDENTIFIABLE' : 'IDENTIFIABLE';
|
|
263
|
+
if (policy.identifiability !== expectedIdentifiability) {
|
|
264
|
+
problems.push(`identifiability '${String(policy.identifiability)}' contradicts executable=${String(policy.executable)}`);
|
|
265
|
+
}
|
|
266
|
+
if (policy.interventionType === 'bundled_intervention' && policy.executable === true) {
|
|
267
|
+
problems.push('bundled_intervention declared as executable without verifiable coupled arm configs — relabelling the report is not implementing the bundle');
|
|
268
|
+
}
|
|
269
|
+
if (Number(policy.declaredTargetRuleCodeCount) > 0 && typeof policy.principleRuleCodeIndependence !== 'boolean') {
|
|
270
|
+
problems.push('target RuleCodes exist but independence evidence is missing — independent control is UNKNOWN, not assumed');
|
|
271
|
+
}
|
|
272
|
+
if (policy.interventionType === 'principle_only' && policy.executable === false) {
|
|
273
|
+
problems.push('principle_only cannot be declared non-executable — a coupled intervention must be labelled bundled/not-identifiable');
|
|
274
|
+
}
|
|
275
|
+
return problems.length === 0 ? { ok: true } : { ok: false, reason: problems.join('; ') };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// §5.1 Primary Endpoint
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Freeze ONE primary endpoint per study. definitionHash binds the endpoint to
|
|
284
|
+
* its Scorer V3 dimension reduction; applicabilityRuleHash binds the
|
|
285
|
+
* applicability predicate (frozen in gold). After the manifest is frozen the
|
|
286
|
+
* endpoint may not change (§5.1 / §13.D).
|
|
287
|
+
*/
|
|
288
|
+
export function definePrimaryEndpoint({ id, applicability = { field: 'principleApplicable', equals: true }, reductionOverride = null }) {
|
|
289
|
+
const definition = PRIMARY_ENDPOINT_DEFINITIONS[id];
|
|
290
|
+
if (!definition) {
|
|
291
|
+
throw new Error(`definePrimaryEndpoint: unknown endpoint id '${String(id)}' — the registry is closed; map a Scorer V3 dimension instead of inventing a metric language (known: ${Object.keys(PRIMARY_ENDPOINT_DEFINITIONS).join(' | ')})`);
|
|
292
|
+
}
|
|
293
|
+
const reduction = reductionOverride || {
|
|
294
|
+
dimension: definition.dimension,
|
|
295
|
+
failWhen: definition.failWhen,
|
|
296
|
+
fMapping: 'dimension.status === failWhen → F=1; PASS → F=0; otherwise F=\'UNKNOWN\'',
|
|
297
|
+
};
|
|
298
|
+
const core = { id, statisticUnit: definition.statisticUnit, description: definition.description, mechanismBinding: definition.mechanismBinding, reduction };
|
|
299
|
+
if (!applicability || !text(applicability.field) || !('equals' in applicability)) {
|
|
300
|
+
throw new Error('definePrimaryEndpoint: applicability {field, equals} required — NOT_APPLICABLE units must be excludable by a frozen predicate, never by post-hoc judgement');
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
schema: 'examiner-primary-endpoint.v1',
|
|
304
|
+
...core,
|
|
305
|
+
definitionHash: hashOfCanonical(core),
|
|
306
|
+
applicabilityRule: applicability,
|
|
307
|
+
applicabilityRuleHash: hashOfCanonical(applicability),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Evaluate the primary-endpoint F value for one adjudication under a frozen
|
|
313
|
+
* endpoint. PURE; reads only the frozen endpoint + the scorer output + the
|
|
314
|
+
* frozen case applicability.
|
|
315
|
+
* @returns {1|0|'UNKNOWN'|'NOT_APPLICABLE'}
|
|
316
|
+
*/
|
|
317
|
+
export function evaluatePrimaryEndpointF({ endpoint, adjudication, caseDef }) {
|
|
318
|
+
if (!endpoint || !HASH_RE.test(String(endpoint.definitionHash))) throw new Error('evaluatePrimaryEndpointF: frozen endpoint required');
|
|
319
|
+
if (!adjudication || typeof adjudication !== 'object') return 'UNKNOWN';
|
|
320
|
+
const applicable = caseDef ? caseDef[endpoint.applicabilityRule.field] : undefined;
|
|
321
|
+
if (applicable !== endpoint.applicabilityRule.equals) return 'NOT_APPLICABLE';
|
|
322
|
+
const dimension = adjudication.dimensions && adjudication.dimensions[endpoint.reduction.dimension];
|
|
323
|
+
const status = dimension && (dimension.status !== undefined ? dimension.status : dimension);
|
|
324
|
+
if (status === endpoint.reduction.failWhen) return 1;
|
|
325
|
+
if (status === 'PASS') return 0;
|
|
326
|
+
return 'UNKNOWN';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// §3.2/§3.3 Pair assignment + randomized balanced execution order
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/** Deterministic seeded PRNG (mulberry32) — same seed, same assignment, any Node ≥22. */
|
|
334
|
+
function seededRandom(seedText) {
|
|
335
|
+
let h = 1779033703 ^ seedText.length;
|
|
336
|
+
for (let i = 0; i < seedText.length; i += 1) {
|
|
337
|
+
h = Math.imul(h ^ seedText.charCodeAt(i), 3432918353);
|
|
338
|
+
h = (h << 13) | (h >>> 19);
|
|
339
|
+
}
|
|
340
|
+
let a = h >>> 0;
|
|
341
|
+
return () => {
|
|
342
|
+
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
343
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
344
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
345
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Assign pairs Case × Repetition with BALANCED randomization of the intra-pair
|
|
351
|
+
* arm order (§3.3: A→B / B→A balanced; the assignment freezes BEFORE any
|
|
352
|
+
* execution and its hash detects post-hoc tampering).
|
|
353
|
+
*
|
|
354
|
+
* @returns record {schema, strategy, seed, pairs[], unitOrder[], assignmentHash}
|
|
355
|
+
* pairs: [{pairId, caseId, repetitionId, armOrder:[first,second]}]
|
|
356
|
+
* unitOrder: flattened dispatch order [{caseId, armId, repetitionId}]
|
|
357
|
+
*/
|
|
358
|
+
export function assignPairs({ cases, repetitionsPerCase, controlArm = 'A', treatmentArm = 'B', seed }) {
|
|
359
|
+
if (!Array.isArray(cases) || cases.length === 0) throw new Error('assignPairs: cases[] required');
|
|
360
|
+
if (!Number.isInteger(repetitionsPerCase) || repetitionsPerCase < 1) throw new Error('assignPairs: repetitionsPerCase must be a positive integer');
|
|
361
|
+
if (!text(seed)) throw new Error('assignPairs: seed required — an unseeded "randomization" is not reproducible and not preregistrable');
|
|
362
|
+
if (controlArm === treatmentArm) throw new Error('assignPairs: controlArm and treatmentArm must differ');
|
|
363
|
+
const rand = seededRandom(seed);
|
|
364
|
+
const sortedCases = cases.slice().sort((a, b) => (a < b ? -1 : 1));
|
|
365
|
+
const pairs = [];
|
|
366
|
+
let controlFirst = 0;
|
|
367
|
+
let treatmentFirst = 0;
|
|
368
|
+
let index = 0;
|
|
369
|
+
for (const caseId of sortedCases) {
|
|
370
|
+
for (let rep = 1; rep <= repetitionsPerCase; rep += 1) {
|
|
371
|
+
index += 1;
|
|
372
|
+
// balanced: when the counts diverge, the lagging order goes next;
|
|
373
|
+
// when tied, the coin decides — recorded honestly as 'balanced-randomized'.
|
|
374
|
+
let controlGoesFirst;
|
|
375
|
+
if (controlFirst < treatmentFirst) controlGoesFirst = true;
|
|
376
|
+
else if (treatmentFirst < controlFirst) controlGoesFirst = false;
|
|
377
|
+
else controlGoesFirst = rand() < 0.5;
|
|
378
|
+
const armOrder = controlGoesFirst ? [controlArm, treatmentArm] : [treatmentArm, controlArm];
|
|
379
|
+
if (controlGoesFirst) controlFirst += 1; else treatmentFirst += 1;
|
|
380
|
+
pairs.push({
|
|
381
|
+
pairId: `P-${String(index).padStart(3, '0')}`,
|
|
382
|
+
caseId,
|
|
383
|
+
repetitionId: String(rep),
|
|
384
|
+
armOrder,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const unitOrder = pairs.flatMap((pair) => pair.armOrder.map((armId) => ({ caseId: pair.caseId, armId, repetitionId: pair.repetitionId })));
|
|
389
|
+
const core = {
|
|
390
|
+
schema: PAIR_ASSIGNMENT_SCHEMA,
|
|
391
|
+
strategy: 'balanced-randomized',
|
|
392
|
+
seed,
|
|
393
|
+
controlArm,
|
|
394
|
+
treatmentArm,
|
|
395
|
+
balance: { controlFirst, treatmentFirst },
|
|
396
|
+
pairs,
|
|
397
|
+
unitOrder,
|
|
398
|
+
};
|
|
399
|
+
return { ...core, assignmentHash: hashOfCanonical(core) };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function verifyPairAssignment(record) {
|
|
403
|
+
if (!record || record.assignmentHash === undefined) return { ok: false, reason: 'no pair assignment supplied' };
|
|
404
|
+
const { assignmentHash, ...core } = record;
|
|
405
|
+
if (!HASH_RE.test(String(assignmentHash)) || hashOfCanonical(core) !== assignmentHash) {
|
|
406
|
+
return { ok: false, reason: 'assignmentHash mismatch — the randomization record was modified after freezing (post-hoc order manipulation)' };
|
|
407
|
+
}
|
|
408
|
+
const repsByCase = new Map();
|
|
409
|
+
for (const p of core.pairs) repsByCase.set(p.caseId, (repsByCase.get(p.caseId) || 0) + 1);
|
|
410
|
+
const caseList = [...repsByCase.keys()].sort();
|
|
411
|
+
const reps = [...new Set(repsByCase.values())];
|
|
412
|
+
if (reps.length !== 1) {
|
|
413
|
+
return { ok: false, reason: 'pair assignment has heterogeneous repetitions per case — not producible by a deterministic assignment' };
|
|
414
|
+
}
|
|
415
|
+
const rebuild = assignPairs({
|
|
416
|
+
cases: caseList,
|
|
417
|
+
repetitionsPerCase: reps[0],
|
|
418
|
+
controlArm: core.controlArm,
|
|
419
|
+
treatmentArm: core.treatmentArm,
|
|
420
|
+
seed: core.seed,
|
|
421
|
+
});
|
|
422
|
+
if (rebuild.assignmentHash !== assignmentHash) {
|
|
423
|
+
return { ok: false, reason: 'pair assignment is not reproducible from its own seed — the record does not match a deterministic randomization' };
|
|
424
|
+
}
|
|
425
|
+
return { ok: true };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// §4.1 ExaminerStudyManifest
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Assemble the v0.9 §4.1 manifest from already-frozen components. Every
|
|
434
|
+
* component hash is verified first (fail-closed: a manifest over unverified
|
|
435
|
+
* parts is itself unverified), then the manifest hash freezes the whole.
|
|
436
|
+
*/
|
|
437
|
+
export function buildExaminerStudyManifest({
|
|
438
|
+
studyId,
|
|
439
|
+
targetPrinciple,
|
|
440
|
+
experimentMode = DEFAULT_EXPERIMENT_MODE,
|
|
441
|
+
heps,
|
|
442
|
+
ruleCodePolicy,
|
|
443
|
+
hostManifest,
|
|
444
|
+
modelManifest,
|
|
445
|
+
runtimeManifest,
|
|
446
|
+
challengeSuiteHash,
|
|
447
|
+
goldSnapshotHash,
|
|
448
|
+
scorerVersion,
|
|
449
|
+
primaryEndpoint,
|
|
450
|
+
secondaryEndpoints = [],
|
|
451
|
+
negativeTransferMetrics = [],
|
|
452
|
+
operationalCostMetrics = [],
|
|
453
|
+
pairAssignment,
|
|
454
|
+
analysisPlan,
|
|
455
|
+
invalidationPolicy,
|
|
456
|
+
armPayloadHashes = null,
|
|
457
|
+
authorizationRef = null,
|
|
458
|
+
}) {
|
|
459
|
+
if (!text(studyId)) throw new Error('buildExaminerStudyManifest: studyId required');
|
|
460
|
+
if (!/^[A-Z0-9][A-Z0-9-]*$/.test(studyId)) throw new Error(`buildExaminerStudyManifest: studyId '${studyId}' must be UPPER-CASE with digits/dashes`);
|
|
461
|
+
assertMode(experimentMode);
|
|
462
|
+
if (!targetPrinciple || !text(targetPrinciple.id) || !text(targetPrinciple.version) || !HASH_RE.test(String(targetPrinciple.contentHash))) {
|
|
463
|
+
throw new Error('buildExaminerStudyManifest: targetPrinciple {id, version, contentHash} required');
|
|
464
|
+
}
|
|
465
|
+
for (const [name, hash] of [['challengeSuiteHash', challengeSuiteHash], ['goldSnapshotHash', goldSnapshotHash]]) {
|
|
466
|
+
if (!HASH_RE.test(String(hash))) throw new Error(`buildExaminerStudyManifest: ${name} must be a sha256 hex`);
|
|
467
|
+
}
|
|
468
|
+
if (!text(scorerVersion)) throw new Error('buildExaminerStudyManifest: scorerVersion required');
|
|
469
|
+
if (!text(analysisPlan) && typeof analysisPlan !== 'object') throw new Error('buildExaminerStudyManifest: analysisPlan required (object or reference string)');
|
|
470
|
+
if (!invalidationPolicy || typeof invalidationPolicy !== 'object') throw new Error('buildExaminerStudyManifest: invalidationPolicy required');
|
|
471
|
+
|
|
472
|
+
const hepsCheck = verifyHostEffectivePrincipleSet(heps);
|
|
473
|
+
if (!hepsCheck.ok) throw new Error(`buildExaminerStudyManifest: HEPS not verifiable — ${hepsCheck.reason}`);
|
|
474
|
+
const rcCheck = verifyRuleCodePolicy(ruleCodePolicy);
|
|
475
|
+
if (!rcCheck.ok) throw new Error(`buildExaminerStudyManifest: RuleCode policy not verifiable — ${rcCheck.reason}`);
|
|
476
|
+
if (ruleCodePolicy.mode !== experimentMode) throw new Error('buildExaminerStudyManifest: RuleCode policy mode differs from the manifest experimentMode');
|
|
477
|
+
const pairCheck = verifyPairAssignment(pairAssignment);
|
|
478
|
+
if (!pairCheck.ok) throw new Error(`buildExaminerStudyManifest: pair assignment not verifiable — ${pairCheck.reason}`);
|
|
479
|
+
if (!primaryEndpoint || HASH_RE.test(String(primaryEndpoint.definitionHash)) === false) {
|
|
480
|
+
throw new Error('buildExaminerStudyManifest: a frozen Primary Endpoint (definePrimaryEndpoint) is mandatory — a study without exactly one primary endpoint cannot preregister');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const runtimeCore = { hostManifest, modelManifest, ...(runtimeManifest || {}) };
|
|
484
|
+
const core = {
|
|
485
|
+
schema: EXAMINER_MANIFEST_SCHEMA,
|
|
486
|
+
studyId,
|
|
487
|
+
targetPrinciple,
|
|
488
|
+
experimentMode,
|
|
489
|
+
interventionType: ruleCodePolicy.interventionType,
|
|
490
|
+
hostManifestHash: hashOfCanonical(hostManifest),
|
|
491
|
+
modelManifestHash: hashOfCanonical(modelManifest),
|
|
492
|
+
principleSetSnapshotHash: heps.snapshotHash,
|
|
493
|
+
ruleCodeSnapshotHash: ruleCodePolicy.ruleCodeSnapshotHash,
|
|
494
|
+
runtimeManifestHash: hashOfCanonical(runtimeCore),
|
|
495
|
+
challengeSuiteHash,
|
|
496
|
+
goldSnapshotHash,
|
|
497
|
+
scorerVersion,
|
|
498
|
+
primaryEndpoint: {
|
|
499
|
+
id: primaryEndpoint.id,
|
|
500
|
+
definitionHash: primaryEndpoint.definitionHash,
|
|
501
|
+
applicabilityRuleHash: primaryEndpoint.applicabilityRuleHash,
|
|
502
|
+
},
|
|
503
|
+
secondaryEndpoints,
|
|
504
|
+
negativeTransferMetrics,
|
|
505
|
+
operationalCostMetrics,
|
|
506
|
+
randomizationSeed: pairAssignment.seed,
|
|
507
|
+
plannedPairs: pairAssignment.pairs.length,
|
|
508
|
+
repetitionsPerCase: pairAssignment.pairs.length / Math.max(1, new Set(pairAssignment.pairs.map((p) => p.caseId)).size),
|
|
509
|
+
pairAssignmentHash: pairAssignment.assignmentHash,
|
|
510
|
+
...(armPayloadHashes ? { armPayloadHashes } : {}),
|
|
511
|
+
analysisPlanHash: hashOfCanonical(analysisPlan),
|
|
512
|
+
invalidationPolicyHash: hashOfCanonical(invalidationPolicy),
|
|
513
|
+
authorizationRef,
|
|
514
|
+
};
|
|
515
|
+
return { ...core, manifestHash: hashOfCanonical(core) };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function verifyExaminerManifest(manifest) {
|
|
519
|
+
const problems = [];
|
|
520
|
+
if (!manifest || manifest.schema !== EXAMINER_MANIFEST_SCHEMA) problems.push(`schema must be ${EXAMINER_MANIFEST_SCHEMA}`);
|
|
521
|
+
if (manifest && manifest.manifestHash !== undefined) {
|
|
522
|
+
const { manifestHash, ...core } = manifest;
|
|
523
|
+
if (!HASH_RE.test(String(manifestHash)) || hashOfCanonical(core) !== manifestHash) problems.push('manifestHash mismatch — the study manifest was modified after freezing');
|
|
524
|
+
} else if (manifest) {
|
|
525
|
+
problems.push('manifestHash missing');
|
|
526
|
+
}
|
|
527
|
+
return { ok: problems.length === 0, problems };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// §4.1 cross-file preregistration binding + immutable anchor
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
//
|
|
534
|
+
// Every frozen study file only proves ITSELF (a recomputed manifestHash says
|
|
535
|
+
// nothing about pair-assignment.json). Preflight/runner/report each rebuilt a
|
|
536
|
+
// subset from different inputs, so a file that was edited in a mutually
|
|
537
|
+
// consistent way (e.g. heps-principles.json + arm configs) could pass every
|
|
538
|
+
// single-file check while the preregistered facts no longer agreed. The
|
|
539
|
+
// binding below re-derives EVERY link from the files on disk and records the
|
|
540
|
+
// resulting bundle hash once, at creation time, as an append-only ledger
|
|
541
|
+
// anchor: after that, rewriting the frozen files cannot move the anchor.
|
|
542
|
+
|
|
543
|
+
export const PREREG_BINDING_SCHEMA = 'examiner-study-bindings.v1';
|
|
544
|
+
export const PREREG_ANCHOR_SCHEMA = 'examiner-preregistration-anchor.v1';
|
|
545
|
+
export const PREREGISTRATION_ANCHOR_EVENT = 'PREREGISTRATION_FROZEN';
|
|
546
|
+
|
|
547
|
+
function readBindingsJson(studyDir, rel) {
|
|
548
|
+
const root = path.resolve(studyDir);
|
|
549
|
+
const file = path.resolve(root, rel);
|
|
550
|
+
const norm = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
551
|
+
if (norm(file) !== norm(root) && !norm(file).startsWith(`${norm(root)}${path.sep}`)) {
|
|
552
|
+
throw new Error(`study file '${rel}' escapes the study directory`);
|
|
553
|
+
}
|
|
554
|
+
if (!fs.existsSync(file)) throw new Error(`study file '${rel}' missing`);
|
|
555
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const setOf = (values) => new Set(values.map(String));
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Recompute the complete preregistration picture from the files on disk:
|
|
562
|
+
* manifest ↔ preregistration ↔ pair assignment ↔ HEPS ↔ RuleCode baseline ↔
|
|
563
|
+
* arm configs ↔ suite ↔ mapping ↔ scoring contract ↔ hashes.json. PURE with
|
|
564
|
+
* respect to the study (reads only; writes nothing).
|
|
565
|
+
*
|
|
566
|
+
* @returns { ok, problems[], binding, bindingHash }
|
|
567
|
+
*/
|
|
568
|
+
export function computeExaminerStudyBindings(studyDir) {
|
|
569
|
+
const problems = [];
|
|
570
|
+
const push = (detail) => problems.push(detail);
|
|
571
|
+
let files;
|
|
572
|
+
try {
|
|
573
|
+
files = {
|
|
574
|
+
manifest: readBindingsJson(studyDir, 'examiner-manifest.json'),
|
|
575
|
+
prereg: readBindingsJson(studyDir, 'preregistration.json'),
|
|
576
|
+
pair: readBindingsJson(studyDir, 'pair-assignment.json'),
|
|
577
|
+
hepsInput: readBindingsJson(studyDir, 'heps-principles.json'),
|
|
578
|
+
rulecode: readBindingsJson(studyDir, 'rulecode-baseline.json'),
|
|
579
|
+
arms: readBindingsJson(studyDir, 'examiner-arm-configs.json'),
|
|
580
|
+
suite: readBindingsJson(studyDir, 'suite-manifest.json'),
|
|
581
|
+
mapping: readBindingsJson(studyDir, 'pain-mechanism-mapping.json'),
|
|
582
|
+
scoring: readBindingsJson(studyDir, 'scoring-contract.json'),
|
|
583
|
+
principle: readBindingsJson(studyDir, 'principle-frozen.json'),
|
|
584
|
+
inputManifest: readBindingsJson(studyDir, 'input-manifest.json'),
|
|
585
|
+
hashes: readBindingsJson(studyDir, 'hashes.json'),
|
|
586
|
+
};
|
|
587
|
+
} catch (e) {
|
|
588
|
+
return { ok: false, problems: [e.message], binding: null, bindingHash: null };
|
|
589
|
+
}
|
|
590
|
+
const { manifest, prereg, pair, hepsInput, rulecode, arms, suite, mapping, scoring, principle, inputManifest, hashes } = files;
|
|
591
|
+
|
|
592
|
+
const manifestCheck = verifyExaminerManifest(manifest);
|
|
593
|
+
if (!manifestCheck.ok) push(`manifest: ${manifestCheck.problems.join('; ')}`);
|
|
594
|
+
|
|
595
|
+
// --- preregistration ↔ manifest -----------------------------------------
|
|
596
|
+
if (prereg.schema !== 'challenge-preregistration.v1') push(`preregistration schema '${String(prereg.schema)}' unsupported`);
|
|
597
|
+
if (prereg.status !== 'FROZEN') push(`preregistration status '${String(prereg.status)}' is not FROZEN`);
|
|
598
|
+
if (prereg.studyId !== manifest.studyId) push(`preregistration studyId '${String(prereg.studyId)}' != manifest '${String(manifest.studyId)}'`);
|
|
599
|
+
const examiner = prereg.examiner || {};
|
|
600
|
+
if (examiner.manifestHash !== manifest.manifestHash) push('preregistration.examiner.manifestHash != manifest.manifestHash');
|
|
601
|
+
if (examiner.pairAssignmentHash !== pair.assignmentHash) push('preregistration.examiner.pairAssignmentHash != pair-assignment.assignmentHash');
|
|
602
|
+
if (prereg.controlArm !== 'A' || prereg.treatmentArm !== 'B') push('preregistration control/treatment arm must be A/B');
|
|
603
|
+
if (!Number.isInteger(prereg.repetitions?.planned) || prereg.repetitions.planned !== manifest.repetitionsPerCase) {
|
|
604
|
+
push(`preregistration.repetitions.planned '${String(prereg.repetitions?.planned)}' != manifest.repetitionsPerCase '${String(manifest.repetitionsPerCase)}'`);
|
|
605
|
+
}
|
|
606
|
+
const preregistrationHash = hashOfCanonical(prereg);
|
|
607
|
+
if (hashes.preregistrationHash?.value !== preregistrationHash) push('hashes.preregistrationHash != recomputed preregistration hash');
|
|
608
|
+
|
|
609
|
+
// --- pair assignment ↔ manifest ↔ suite ----------------------------------
|
|
610
|
+
const pairCheck = verifyPairAssignment(pair);
|
|
611
|
+
if (!pairCheck.ok) push(`pair assignment: ${pairCheck.reason}`);
|
|
612
|
+
if (pair.assignmentHash !== manifest.pairAssignmentHash) push('pair assignment hash != manifest.pairAssignmentHash');
|
|
613
|
+
if (pair.seed !== manifest.randomizationSeed) push(`pair assignment seed '${String(pair.seed)}' != manifest randomizationSeed '${String(manifest.randomizationSeed)}'`);
|
|
614
|
+
if (!Array.isArray(pair.pairs) || pair.pairs.length !== manifest.plannedPairs) {
|
|
615
|
+
push(`pair assignment plannedPairs '${String(pair.pairs?.length)}' != manifest.plannedPairs '${String(manifest.plannedPairs)}'`);
|
|
616
|
+
}
|
|
617
|
+
const suiteCases = Array.isArray(suite.cases) ? suite.cases.map((c) => c.caseId) : [];
|
|
618
|
+
const pairCases = Array.isArray(pair.pairs) ? [...setOf(pair.pairs.map((p) => p.caseId))] : [];
|
|
619
|
+
if (pairCases.length !== suiteCases.length || pairCases.some((id) => !suiteCases.includes(id))) {
|
|
620
|
+
push('pair assignment cases do not cover the frozen suite cases exactly');
|
|
621
|
+
}
|
|
622
|
+
if (Array.isArray(pair.unitOrder)) {
|
|
623
|
+
const expectedUnits = new Set();
|
|
624
|
+
for (const p of pair.pairs || []) {
|
|
625
|
+
for (const armId of ['A', 'B']) expectedUnits.add(`${p.caseId}/${armId}/r${String(p.repetitionId)}`);
|
|
626
|
+
}
|
|
627
|
+
const orderUnits = pair.unitOrder.map((u) => `${u.caseId}/${u.armId}/r${String(u.repetitionId)}`);
|
|
628
|
+
if (orderUnits.length !== expectedUnits.size || orderUnits.some((k) => !expectedUnits.has(k))) {
|
|
629
|
+
push('pair assignment unitOrder does not cover the planned unit set exactly');
|
|
630
|
+
}
|
|
631
|
+
} else {
|
|
632
|
+
push('pair assignment unitOrder missing — the frozen dispatch order is required');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// --- suite ↔ scoring ↔ mapping ↔ principle ------------------------------
|
|
636
|
+
const suiteCheck = verifyCanonicalHash(suite, suite.suiteHash, { stripFields: ['suiteHash'] });
|
|
637
|
+
if (!suiteCheck.ok) push('suite-manifest suiteHash drift');
|
|
638
|
+
if (suite.suiteHash !== manifest.challengeSuiteHash) push('suite hash != manifest.challengeSuiteHash');
|
|
639
|
+
if (hashes.suiteHash?.value !== suite.suiteHash) push('hashes.suiteHash != suite-manifest.suiteHash');
|
|
640
|
+
const graderHash = hashOfCanonical(scoring);
|
|
641
|
+
if (hashes.graderHash?.value !== graderHash) push('hashes.graderHash != recomputed scoring contract hash');
|
|
642
|
+
if (suite.graderHash !== graderHash) push('suite manifest graderHash != scoring contract hash');
|
|
643
|
+
if (hashOfCanonical(scoring.implementation) !== hashOfCanonical(challengeImplementationManifest())) {
|
|
644
|
+
push('scoring-contract implementation manifest drifted from the current lab implementation');
|
|
645
|
+
}
|
|
646
|
+
if (!verifyCanonicalHash(mapping, mapping.mappingHash, { stripFields: ['mappingHash'] }).ok) push('mappingHash drift');
|
|
647
|
+
const principleContentHash = hashOfCanonical(principle.contentJson);
|
|
648
|
+
if (inputManifest.expectedSourceHashes?.principleContentHash !== principleContentHash) push('principle content hash != input-manifest expectation');
|
|
649
|
+
if (mapping.principleContentHash !== principleContentHash) push('mapping.principleContentHash != principle content hash');
|
|
650
|
+
if (hashes.principleContentHash?.value !== principleContentHash) push('hashes.principleContentHash != principle content hash');
|
|
651
|
+
const targetContentFact = (hepsInput.principles || []).find((p) => p.id === hepsInput.targetPrincipleId);
|
|
652
|
+
if (targetContentFact && manifest.targetPrinciple?.contentHash !== hashOfCanonical({ id: targetContentFact.id, version: targetContentFact.version, text: targetContentFact.text })) {
|
|
653
|
+
push('manifest targetPrinciple.contentHash != the HEPS target principle content');
|
|
654
|
+
}
|
|
655
|
+
if (suite.mappingHash !== mapping.mappingHash) push('suite manifest mappingHash != mapping mappingHash');
|
|
656
|
+
if (hashes.mappingHash?.value !== mapping.mappingHash) push('hashes.mappingHash != mapping mappingHash');
|
|
657
|
+
|
|
658
|
+
// --- HEPS ↔ manifest ----------------------------------------------------
|
|
659
|
+
let heps = null;
|
|
660
|
+
let targetFact = null;
|
|
661
|
+
try {
|
|
662
|
+
heps = buildHostEffectivePrincipleSet({ principles: hepsInput.principles, targetPrincipleId: hepsInput.targetPrincipleId, host: hepsInput.host });
|
|
663
|
+
const hepsCheck = verifyHostEffectivePrincipleSet(heps);
|
|
664
|
+
if (!hepsCheck.ok) push(`HEPS snapshot: ${hepsCheck.reason}`);
|
|
665
|
+
if (heps.snapshotHash !== manifest.principleSetSnapshotHash) push('HEPS snapshot hash != manifest principleSetSnapshotHash');
|
|
666
|
+
targetFact = (hepsInput.principles || []).find((p) => p.id === hepsInput.targetPrincipleId) || null;
|
|
667
|
+
if (!targetFact) push(`target principle '${String(hepsInput.targetPrincipleId)}' missing from heps-principles.json`);
|
|
668
|
+
if (manifest.targetPrinciple?.id !== hepsInput.targetPrincipleId) push('manifest targetPrinciple.id != heps-principles targetPrincipleId');
|
|
669
|
+
} catch (e) {
|
|
670
|
+
push(`HEPS rebuild failed: ${e.message}`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// --- RuleCode baseline ↔ manifest --------------------------------------
|
|
674
|
+
if (rulecode.ruleCodes !== undefined) {
|
|
675
|
+
try {
|
|
676
|
+
const policy = buildRuleCodePolicy({
|
|
677
|
+
mode: manifest.experimentMode,
|
|
678
|
+
ruleCodes: rulecode.ruleCodes,
|
|
679
|
+
targetPrincipleId: hepsInput.targetPrincipleId,
|
|
680
|
+
principleRuleCodeIndependence: rulecode.principleRuleCodeIndependence,
|
|
681
|
+
targetRuleCodeCount: rulecode.targetRuleCodeCount ?? null,
|
|
682
|
+
});
|
|
683
|
+
if (policy.ruleCodeSnapshotHash !== manifest.ruleCodeSnapshotHash) push('RuleCode policy snapshot hash != manifest ruleCodeSnapshotHash');
|
|
684
|
+
if (policy.interventionType !== manifest.interventionType) push('RuleCode interventionType != manifest interventionType');
|
|
685
|
+
if (policy.executable !== true || policy.identifiability !== 'IDENTIFIABLE') {
|
|
686
|
+
push(`RuleCode intervention is not identifiable (${policy.identifiability}): ${policy.couplingReason || 'no reason recorded'}`);
|
|
687
|
+
}
|
|
688
|
+
} catch (e) {
|
|
689
|
+
push(`RuleCode policy rebuild failed: ${e.message}`);
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
push('rulecode-baseline.json missing ruleCodes');
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// --- arm configs ↔ manifest ↔ prereg ------------------------------------
|
|
696
|
+
let armPayloadHashes = null;
|
|
697
|
+
if (heps && targetFact) {
|
|
698
|
+
try {
|
|
699
|
+
const budgetChars = Number.isInteger(arms.budgetChars) ? arms.budgetChars : null;
|
|
700
|
+
if (budgetChars === null) push('examiner-arm-configs budgetChars must be an integer');
|
|
701
|
+
if (prereg.delivery?.budgetChars !== budgetChars) push('preregistration.delivery.budgetChars != arm configs budgetChars');
|
|
702
|
+
const rebuilt = buildDualModeArmPayloads({
|
|
703
|
+
mode: manifest.experimentMode,
|
|
704
|
+
heps,
|
|
705
|
+
targetPrinciple: targetFact,
|
|
706
|
+
principleFacts: hepsInput.principles,
|
|
707
|
+
budgetChars,
|
|
708
|
+
ordering: arms.ordering || 'id-asc',
|
|
709
|
+
});
|
|
710
|
+
if (!rebuilt.isolationAudit.ok) push(`arm isolation audit: ${rebuilt.isolationAudit.problems.join('; ')}`);
|
|
711
|
+
for (const [side, armId] of [['control', 'A'], ['treatment', 'B']]) {
|
|
712
|
+
const stored = arms.arms?.[side];
|
|
713
|
+
if (!stored) { push(`arm config ${side} missing`); continue; }
|
|
714
|
+
if (stored.payloadHash !== rebuilt.arms[side].payloadHash) push(`arm ${armId} payloadHash != rebuilt dual-mode payload`);
|
|
715
|
+
if (typeof stored.payloadText === 'string' && sha256(stored.payloadText) !== stored.payloadHash) push(`arm ${armId} payloadText does not match its payloadHash`);
|
|
716
|
+
if (manifest.armPayloadHashes && manifest.armPayloadHashes[side] !== rebuilt.arms[side].payloadHash) push(`manifest armPayloadHashes.${side} != rebuilt payload`);
|
|
717
|
+
}
|
|
718
|
+
armPayloadHashes = {
|
|
719
|
+
control: rebuilt.arms.control.payloadHash,
|
|
720
|
+
treatment: rebuilt.arms.treatment.payloadHash,
|
|
721
|
+
};
|
|
722
|
+
if (manifest.armPayloadHashes && (manifest.armPayloadHashes.control !== armPayloadHashes.control || manifest.armPayloadHashes.treatment !== armPayloadHashes.treatment)) {
|
|
723
|
+
push('manifest armPayloadHashes != rebuilt arm payloads');
|
|
724
|
+
}
|
|
725
|
+
} catch (e) {
|
|
726
|
+
push(`arm payload rebuild failed: ${e.message}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (hashes.examinerManifestHash?.value !== manifest.manifestHash) push('hashes.examinerManifestHash != manifest.manifestHash');
|
|
731
|
+
if (hashes.pairAssignmentHash?.value !== pair.assignmentHash) push('hashes.pairAssignmentHash != pair assignment hash');
|
|
732
|
+
|
|
733
|
+
const binding = {
|
|
734
|
+
schema: PREREG_BINDING_SCHEMA,
|
|
735
|
+
studyId: manifest.studyId ?? null,
|
|
736
|
+
experimentMode: manifest.experimentMode ?? null,
|
|
737
|
+
interventionType: manifest.interventionType ?? null,
|
|
738
|
+
targetPrincipleId: hepsInput.targetPrincipleId ?? null,
|
|
739
|
+
manifestHash: manifest.manifestHash ?? null,
|
|
740
|
+
pairAssignmentHash: pair.assignmentHash ?? null,
|
|
741
|
+
preregistrationHash,
|
|
742
|
+
suiteHash: suite.suiteHash ?? null,
|
|
743
|
+
mappingHash: mapping.mappingHash ?? null,
|
|
744
|
+
graderHash,
|
|
745
|
+
principleContentHash,
|
|
746
|
+
principleSetSnapshotHash: heps ? heps.snapshotHash : (manifest.principleSetSnapshotHash ?? null),
|
|
747
|
+
ruleCodeSnapshotHash: manifest.ruleCodeSnapshotHash ?? null,
|
|
748
|
+
// The frozen primary endpoint rides in the anchor: changing the statistical
|
|
749
|
+
// definition after freezing (even with every file re-hashed) moves this.
|
|
750
|
+
primaryEndpointHash: hashOfCanonical(manifest.primaryEndpoint ?? null),
|
|
751
|
+
armPayloadHashes,
|
|
752
|
+
plannedPairs: manifest.plannedPairs ?? null,
|
|
753
|
+
repetitionsPerCase: manifest.repetitionsPerCase ?? null,
|
|
754
|
+
};
|
|
755
|
+
return { ok: problems.length === 0, problems, binding, bindingHash: hashOfCanonical(binding) };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Fail-closed cross-file verification for an existing study: the recomputed
|
|
760
|
+
* binding must hold AND match the append-only anchor written at creation.
|
|
761
|
+
* A study whose frozen files were rewritten after freezing (even
|
|
762
|
+
* consistently) cannot move the anchor and is refused.
|
|
763
|
+
*/
|
|
764
|
+
export function verifyExaminerStudyBindings(studyDir, { requireAnchor = true } = {}) {
|
|
765
|
+
const computed = computeExaminerStudyBindings(studyDir);
|
|
766
|
+
if (!computed.ok) return { ...computed, anchor: 'NOT_CHECKED' };
|
|
767
|
+
const eventsFile = path.join(studyDir, 'events.jsonl');
|
|
768
|
+
const events = fs.existsSync(eventsFile)
|
|
769
|
+
? fs.readFileSync(eventsFile, 'utf8').split('\n').filter((line) => line.trim() !== '').map((line) => {
|
|
770
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
771
|
+
}).filter(Boolean)
|
|
772
|
+
: [];
|
|
773
|
+
const anchors = events.filter((e) => e && e.type === PREREGISTRATION_ANCHOR_EVENT);
|
|
774
|
+
const problems = [];
|
|
775
|
+
let anchor = 'MISSING';
|
|
776
|
+
if (anchors.length === 0) {
|
|
777
|
+
if (requireAnchor) problems.push('immutable preregistration anchor MISSING — the study was not frozen by the anchored creation path');
|
|
778
|
+
} else if (anchors.length > 1) {
|
|
779
|
+
problems.push(`multiple preregistration anchors (${anchors.length}) — the append-only freeze record was duplicated`);
|
|
780
|
+
anchor = 'MISMATCH';
|
|
781
|
+
} else {
|
|
782
|
+
const record = anchors[0];
|
|
783
|
+
anchor = 'VERIFIED';
|
|
784
|
+
if (record.schema !== PREREG_ANCHOR_SCHEMA) { problems.push(`anchor schema '${String(record.schema)}' unsupported`); anchor = 'MISMATCH'; }
|
|
785
|
+
if (record.studyId !== computed.binding.studyId) { problems.push('anchor studyId != manifest studyId'); anchor = 'MISMATCH'; }
|
|
786
|
+
if (record.bindingHash !== computed.bindingHash) {
|
|
787
|
+
problems.push('anchor bindingHash != recomputed binding — the frozen files were rewritten after the anchor was written');
|
|
788
|
+
anchor = 'MISMATCH';
|
|
789
|
+
}
|
|
790
|
+
if (!record.binding || hashOfCanonical(record.binding) !== record.bindingHash) {
|
|
791
|
+
problems.push('anchor binding payload does not match its own bindingHash');
|
|
792
|
+
anchor = 'MISMATCH';
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return { ...computed, ok: problems.length === 0, problems: problems.length > 0 ? problems : [], anchor };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
800
|
+
// §2 dual-mode arm payload construction (offline deterministic rendering)
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
|
|
803
|
+
/** Deterministic single-principle directive rendering (the payload line format). */
|
|
804
|
+
export function renderPrincipleDirective(principle) {
|
|
805
|
+
return `<principle id="${principle.id}" version="${principle.version}">${principle.text}</principle>`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Render an ordered principle set under the frozen char budget with the
|
|
810
|
+
* deterministic SKIP-AND-CONTINUE policy: walk the order and deliver every
|
|
811
|
+
* principle that still fits, so a late small principle may be delivered after
|
|
812
|
+
* an earlier one was evicted. This was historically mislabelled
|
|
813
|
+
* 'legacy_fifo_prefix_v1' (a prefix policy stops at the first non-fitting
|
|
814
|
+
* item), so the honest name is 'skip_and_continue_v1' — the SELECTION RULE
|
|
815
|
+
* itself is unchanged. Returns { payloadText, delivered, evicted }.
|
|
816
|
+
*/
|
|
817
|
+
export const LIBRARY_SELECTION_RULE = 'skip_and_continue_v1';
|
|
818
|
+
|
|
819
|
+
export function renderLibraryPayload({ orderedPrinciples, budgetChars }) {
|
|
820
|
+
if (!Array.isArray(orderedPrinciples)) throw new Error('renderLibraryPayload: orderedPrinciples required');
|
|
821
|
+
if (!Number.isInteger(budgetChars) || budgetChars <= 0) throw new Error('renderLibraryPayload: budgetChars must be a positive integer');
|
|
822
|
+
const delivered = [];
|
|
823
|
+
const evicted = [];
|
|
824
|
+
for (const p of orderedPrinciples) {
|
|
825
|
+
const candidate = delivered.length === 0 ? renderPrincipleDirective(p) : `${delivered.map(renderPrincipleDirective).join('\n')}\n${renderPrincipleDirective(p)}`;
|
|
826
|
+
if (candidate.length <= budgetChars) delivered.push(p);
|
|
827
|
+
else evicted.push(p);
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
payloadText: delivered.map(renderPrincipleDirective).join('\n'),
|
|
831
|
+
delivered,
|
|
832
|
+
evicted,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Build the v0.9 dual-mode CONTROL/TREATMENT payloads from the frozen HEPS +
|
|
838
|
+
* target principle. This is the offline arm-configuration authority for
|
|
839
|
+
* examiner studies (the runner's arm verification consumes its output shape):
|
|
840
|
+
*
|
|
841
|
+
* isolated_principle — control: EMPTY (all PD principles OFF),
|
|
842
|
+
* treatment: the target principle only.
|
|
843
|
+
* full_library_incremental — control: HEPS minus target, treatment: HEPS
|
|
844
|
+
* (target included), both under the SAME frozen
|
|
845
|
+
* budget + selection policy; capacity evictions
|
|
846
|
+
* are recorded per arm (§2.2.5) and can differ
|
|
847
|
+
* between arms — that difference is the real
|
|
848
|
+
* marginal effect of adding the target, kept.
|
|
849
|
+
*
|
|
850
|
+
* @param {object} heps — buildHostEffectivePrincipleSet output
|
|
851
|
+
* @param {object} targetPrinciple — { id, version, text } (frozen)
|
|
852
|
+
* @param {object} [options] — { budgetChars = 2000, ordering = 'id-asc', principleFacts }
|
|
853
|
+
* `principleFacts` (the full-principle facts the HEPS was built from) is
|
|
854
|
+
* REQUIRED for full-library mode: the HEPS snapshot carries identity +
|
|
855
|
+
* contentHash only, so without the facts the delivered library text cannot
|
|
856
|
+
* be rendered. Rendering a placeholder instead is refused — a payload that
|
|
857
|
+
* says "undefined" is not the frozen library.
|
|
858
|
+
*/
|
|
859
|
+
export function buildDualModeArmPayloads({ mode, heps, targetPrinciple, principleFacts = null, budgetChars = 2000, ordering = 'id-asc' }) {
|
|
860
|
+
assertMode(mode);
|
|
861
|
+
if (!heps || !Array.isArray(heps.baseLibrary)) throw new Error('buildDualModeArmPayloads: heps (buildHostEffectivePrincipleSet output) required');
|
|
862
|
+
if (!targetPrinciple || !text(targetPrinciple.id) || !text(targetPrinciple.version) || !text(targetPrinciple.text)) {
|
|
863
|
+
throw new Error('buildDualModeArmPayloads: targetPrinciple {id, version, text} required');
|
|
864
|
+
}
|
|
865
|
+
const withHash = (p) => ({ ...p, contentHash: p.contentHash || hashOfCanonical({ id: p.id, version: p.version, text: p.text }) });
|
|
866
|
+
const target = withHash(targetPrinciple);
|
|
867
|
+
const orderBy = (list) => (ordering === 'id-asc'
|
|
868
|
+
? list.slice().sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
869
|
+
: list.slice().sort((a, b) => ((b.priority ?? 0) - (a.priority ?? 0)) || (a.id < b.id ? -1 : 1)));
|
|
870
|
+
const factById = new Map((Array.isArray(principleFacts) ? principleFacts : []).map((p) => [p && p.id, p]));
|
|
871
|
+
// Resolve the frozen TEXT for a HEPS entry and prove it hashes back to the
|
|
872
|
+
// frozen contentHash — a mismatched or missing text is a refusal, never a
|
|
873
|
+
// silently rendered placeholder.
|
|
874
|
+
const resolveLibraryEntry = (entry) => {
|
|
875
|
+
const fact = factById.get(entry.id);
|
|
876
|
+
const resolvedText = fact && text(fact.text) ? fact.text : entry.text;
|
|
877
|
+
if (!text(resolvedText)) {
|
|
878
|
+
throw new Error(`buildDualModeArmPayloads: no frozen text for library principle '${entry.id}' — pass principleFacts (a payload carrying 'undefined' is refused)`);
|
|
879
|
+
}
|
|
880
|
+
const contentHash = hashOfCanonical({ id: entry.id, version: entry.version, text: resolvedText });
|
|
881
|
+
if (entry.contentHash && entry.contentHash !== contentHash) {
|
|
882
|
+
throw new Error(`buildDualModeArmPayloads: library principle '${entry.id}' text does not match its frozen contentHash`);
|
|
883
|
+
}
|
|
884
|
+
return { id: entry.id, version: entry.version, text: resolvedText, contentHash, ...(entry.priority !== undefined ? { priority: entry.priority } : {}) };
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
let control;
|
|
888
|
+
let treatment;
|
|
889
|
+
let controlCapacity = null;
|
|
890
|
+
let treatmentCapacity = null;
|
|
891
|
+
if (mode === 'isolated_principle') {
|
|
892
|
+
// §2.1: control = NOTHING PD-managed; treatment = the target ONLY.
|
|
893
|
+
control = { payloadText: '', delivered: [], evicted: [] };
|
|
894
|
+
treatment = renderLibraryPayload({ orderedPrinciples: [target], budgetChars });
|
|
895
|
+
} else {
|
|
896
|
+
// §2.2: non-target set frozen in BOTH arms; the target is the only
|
|
897
|
+
// difference. Evictions under the shared budget are recorded per arm.
|
|
898
|
+
const baseLibrary = heps.baseLibrary.map(resolveLibraryEntry);
|
|
899
|
+
control = renderLibraryPayload({ orderedPrinciples: orderBy(baseLibrary), budgetChars });
|
|
900
|
+
treatment = renderLibraryPayload({ orderedPrinciples: orderBy([...baseLibrary, target]), budgetChars });
|
|
901
|
+
controlCapacity = recordCapacityCompetition({ eligible: baseLibrary, delivered: control.delivered, budgetChars, policy: `examiner-offline:${LIBRARY_SELECTION_RULE}:${ordering}`, targetPrincipleId: target.id });
|
|
902
|
+
controlCapacity.armId = 'A';
|
|
903
|
+
treatmentCapacity = recordCapacityCompetition({ eligible: [...baseLibrary, target], delivered: treatment.delivered, budgetChars, policy: `examiner-offline:${LIBRARY_SELECTION_RULE}:${ordering}`, targetPrincipleId: target.id });
|
|
904
|
+
treatmentCapacity.armId = 'B';
|
|
905
|
+
}
|
|
906
|
+
if (treatment.evicted.some((p) => p.id === target.id)) {
|
|
907
|
+
throw new Error(`buildDualModeArmPayloads: the target principle does not fit the budget (${budgetChars} chars) — the treatment arm cannot exist without its intervention; raise the budget or shrink the library`);
|
|
908
|
+
}
|
|
909
|
+
const targetText = targetPrinciple.text;
|
|
910
|
+
const isolationAudit = {
|
|
911
|
+
controlContainsTarget: control.payloadText.includes(targetText),
|
|
912
|
+
treatmentContainsTarget: treatment.payloadText.includes(targetText),
|
|
913
|
+
controlPayloadEmpty: control.payloadText === '',
|
|
914
|
+
problems: [],
|
|
915
|
+
};
|
|
916
|
+
if (isolationAudit.controlContainsTarget) isolationAudit.problems.push('control payload contains the target principle text — treatment leak into control');
|
|
917
|
+
if (!isolationAudit.treatmentContainsTarget) isolationAudit.problems.push('treatment payload lost the target principle text');
|
|
918
|
+
if (mode === 'isolated_principle' && !isolationAudit.controlPayloadEmpty) isolationAudit.problems.push('isolated mode control payload must be EMPTY (all PD principles OFF)');
|
|
919
|
+
isolationAudit.ok = isolationAudit.problems.length === 0;
|
|
920
|
+
return {
|
|
921
|
+
schema: 'examiner-arm-payloads.v1',
|
|
922
|
+
mode,
|
|
923
|
+
budgetChars,
|
|
924
|
+
ordering,
|
|
925
|
+
arms: {
|
|
926
|
+
control: { payloadText: control.payloadText, payloadHash: sha256(control.payloadText), deliveredIds: control.delivered.map((p) => p.id), contentMode: mode === 'isolated_principle' ? 'none' : 'library' },
|
|
927
|
+
treatment: { payloadText: treatment.payloadText, payloadHash: sha256(treatment.payloadText), deliveredIds: treatment.delivered.map((p) => p.id), contentMode: mode === 'isolated_principle' ? 'policy' : 'library' },
|
|
928
|
+
},
|
|
929
|
+
capacity: { control: controlCapacity, treatment: treatmentCapacity },
|
|
930
|
+
isolationAudit,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
// Study materialization (the ONE creation path for tests/acceptance/Console)
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Materialize a new examiner study directory from assembled inputs. Refuses
|
|
940
|
+
* to touch an existing directory (a study identity is created once —
|
|
941
|
+
* overwriting is exactly the historical-asset mutation this refuses) and
|
|
942
|
+
* refuses directories inside the frozen studies area. Compiles the case
|
|
943
|
+
* suite (v3 judge-only + actor-visible) from the supplied caseDefs and writes
|
|
944
|
+
* the v0.9 layer on top of the SAME freeze layout the runner and Report V2
|
|
945
|
+
* already consume.
|
|
946
|
+
*
|
|
947
|
+
* @param {object} input
|
|
948
|
+
* studyDir, hepsPrinciples { principles, targetPrincipleId, host },
|
|
949
|
+
* ruleCodeFacts { ruleCodes, principleRuleCodeIndependence },
|
|
950
|
+
* caseDefs (judge-side v3 case definitions), principleFrozen, mapping,
|
|
951
|
+
* experimentMode, primaryEndpointId, seed, repetitionsPerCase,
|
|
952
|
+
* goldProvenance ('SYNTHETIC_OFFLINE' | 'INDEPENDENT_REVIEW_APPROVED' | ...),
|
|
953
|
+
* commonContextText, budgetChars, ordering, hostManifest, modelManifest,
|
|
954
|
+
* scorerVersion, analysisPlan, invalidationPolicy, secondaryEndpoints, ...
|
|
955
|
+
*/
|
|
956
|
+
export function writeExaminerStudyArtifacts(input) {
|
|
957
|
+
const {
|
|
958
|
+
studyDir, hepsPrinciples, ruleCodeFacts, caseDefs,
|
|
959
|
+
principleFrozen, mapping, commonContextText, budgetChars = 2000, ordering = 'id-asc',
|
|
960
|
+
secondaryEndpoints = [], negativeTransferMetrics = [], operationalCostMetrics = [],
|
|
961
|
+
analysisPlan, invalidationPolicy, hostManifest, modelManifest,
|
|
962
|
+
scorerVersion, seed, repetitionsPerCase, authorizationRef = null,
|
|
963
|
+
goldProvenance = 'SYNTHETIC_OFFLINE',
|
|
964
|
+
} = input;
|
|
965
|
+
if (!studyDir || typeof studyDir !== 'string') throw new Error('writeExaminerStudyArtifacts: studyDir required');
|
|
966
|
+
if (fs.existsSync(studyDir)) throw new Error(`writeExaminerStudyArtifacts: '${studyDir}' already exists — a study identity is created once; overwriting is refused (create a NEW study id instead)`);
|
|
967
|
+
const frozenArea = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'challenge-studies');
|
|
968
|
+
const resolved = path.resolve(studyDir);
|
|
969
|
+
const norm = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
970
|
+
if (norm(resolved) === norm(frozenArea) || norm(resolved).startsWith(`${norm(frozenArea)}${path.sep}`)) {
|
|
971
|
+
throw new Error(`writeExaminerStudyArtifacts: refusing to create a study inside the frozen studies area: ${resolved}`);
|
|
972
|
+
}
|
|
973
|
+
const targetFact = hepsPrinciples.principles.find((p) => p.id === hepsPrinciples.targetPrincipleId);
|
|
974
|
+
if (!targetFact) throw new Error('writeExaminerStudyArtifacts: target principle not present in hepsPrinciples.principles');
|
|
975
|
+
|
|
976
|
+
const heps = buildHostEffectivePrincipleSet({ principles: hepsPrinciples.principles, targetPrincipleId: hepsPrinciples.targetPrincipleId, host: hepsPrinciples.host });
|
|
977
|
+
const ruleCodePolicy = buildRuleCodePolicy({ mode: input.experimentMode, ruleCodes: ruleCodeFacts.ruleCodes, targetPrincipleId: hepsPrinciples.targetPrincipleId, principleRuleCodeIndependence: ruleCodeFacts.principleRuleCodeIndependence, targetRuleCodeCount: ruleCodeFacts.targetRuleCodeCount ?? null });
|
|
978
|
+
if (ruleCodePolicy.executable === false) {
|
|
979
|
+
throw new Error(`writeExaminerStudyArtifacts: INTERVENTION_NOT_IDENTIFIABLE — ${ruleCodePolicy.couplingReason} (the study cannot be preregistered with an intervention that cannot be delivered)`);
|
|
980
|
+
}
|
|
981
|
+
const armPayloads = buildDualModeArmPayloads({ mode: input.experimentMode, heps, targetPrinciple: targetFact, principleFacts: hepsPrinciples.principles, budgetChars, ordering });
|
|
982
|
+
if (!armPayloads.isolationAudit.ok) throw new Error(`writeExaminerStudyArtifacts: arm isolation audit failed: ${armPayloads.isolationAudit.problems.join('; ')}`);
|
|
983
|
+
const primaryEndpoint = definePrimaryEndpoint({ id: input.primaryEndpointId });
|
|
984
|
+
const pairAssignment = assignPairs({ cases: caseDefs.map((c) => c.caseId), repetitionsPerCase, seed });
|
|
985
|
+
// Compile the suite from the SAME freeze chain the runner/report verify:
|
|
986
|
+
// v3 scoring contract binding the current implementation manifest.
|
|
987
|
+
const scoringContract = { schema: 'claim-evidence-scoring.v3', implementation: challengeImplementationManifest() };
|
|
988
|
+
const graderHash = hashOfCanonical(scoringContract);
|
|
989
|
+
const { manifest: suiteManifest } = compileSuite({ studyId: input.studyId, mappingHash: mapping.mappingHash, graderHash, caseDefs });
|
|
990
|
+
const goldSnapshotHash = hashOfCanonical({
|
|
991
|
+
provenance: goldProvenance,
|
|
992
|
+
caseGold: caseDefs.map((cd) => ({ caseId: cd.caseId, goldHash: compileCase(cd).goldHash })),
|
|
993
|
+
});
|
|
994
|
+
const manifest = buildExaminerStudyManifest({
|
|
995
|
+
studyId: input.studyId,
|
|
996
|
+
targetPrinciple: { id: targetFact.id, version: targetFact.version, contentHash: hashOfCanonical({ id: targetFact.id, version: targetFact.version, text: targetFact.text }) },
|
|
997
|
+
experimentMode: input.experimentMode,
|
|
998
|
+
heps,
|
|
999
|
+
ruleCodePolicy,
|
|
1000
|
+
hostManifest,
|
|
1001
|
+
modelManifest,
|
|
1002
|
+
challengeSuiteHash: suiteManifest.suiteHash,
|
|
1003
|
+
goldSnapshotHash,
|
|
1004
|
+
scorerVersion,
|
|
1005
|
+
primaryEndpoint,
|
|
1006
|
+
secondaryEndpoints,
|
|
1007
|
+
negativeTransferMetrics,
|
|
1008
|
+
operationalCostMetrics,
|
|
1009
|
+
pairAssignment,
|
|
1010
|
+
analysisPlan,
|
|
1011
|
+
invalidationPolicy,
|
|
1012
|
+
armPayloadHashes: { control: armPayloads.arms.control.payloadHash, treatment: armPayloads.arms.treatment.payloadHash },
|
|
1013
|
+
authorizationRef,
|
|
1014
|
+
});
|
|
1015
|
+
const prereg = {
|
|
1016
|
+
studyId: input.studyId,
|
|
1017
|
+
schema: 'challenge-preregistration.v1',
|
|
1018
|
+
status: 'FROZEN',
|
|
1019
|
+
arms: [{ armId: 'A', name: 'EXAMINER_CONTROL', contentMode: armPayloads.arms.control.contentMode }, { armId: 'B', name: 'EXAMINER_TREATMENT', contentMode: armPayloads.arms.treatment.contentMode }],
|
|
1020
|
+
controlArm: 'A',
|
|
1021
|
+
treatmentArm: 'B',
|
|
1022
|
+
repetitions: { planned: repetitionsPerCase },
|
|
1023
|
+
commonContext: { text: commonContextText },
|
|
1024
|
+
delivery: { budgetChars },
|
|
1025
|
+
examiner: { manifestHash: manifest.manifestHash, pairAssignmentHash: pairAssignment.assignmentHash },
|
|
1026
|
+
};
|
|
1027
|
+
fs.mkdirSync(path.join(studyDir, 'judge-only'), { recursive: true });
|
|
1028
|
+
fs.mkdirSync(path.join(studyDir, 'actor-visible'), { recursive: true });
|
|
1029
|
+
const writeJson = (rel, value) => fs.writeFileSync(path.join(studyDir, rel), `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
1030
|
+
for (const cd of caseDefs) {
|
|
1031
|
+
const compiled = compileCase(cd);
|
|
1032
|
+
writeJson(path.join('judge-only', `${cd.caseId}.json`), compiled.judgeOnly);
|
|
1033
|
+
writeJson(path.join('actor-visible', `${cd.caseId}.json`), compiled.actorVisible);
|
|
1034
|
+
}
|
|
1035
|
+
writeJson('examiner-manifest.json', manifest);
|
|
1036
|
+
writeJson('heps-principles.json', hepsPrinciples);
|
|
1037
|
+
writeJson('rulecode-baseline.json', {
|
|
1038
|
+
ruleCodes: ruleCodeFacts.ruleCodes,
|
|
1039
|
+
principleRuleCodeIndependence: ruleCodeFacts.principleRuleCodeIndependence ?? null,
|
|
1040
|
+
targetRuleCodeCount: ruleCodeFacts.targetRuleCodeCount ?? null,
|
|
1041
|
+
});
|
|
1042
|
+
writeJson('examiner-arm-configs.json', armPayloads);
|
|
1043
|
+
writeJson('pair-assignment.json', pairAssignment);
|
|
1044
|
+
writeJson('preregistration.json', prereg);
|
|
1045
|
+
writeJson('suite-manifest.json', suiteManifest);
|
|
1046
|
+
writeJson('scoring-contract.json', scoringContract);
|
|
1047
|
+
writeJson('principle-frozen.json', principleFrozen);
|
|
1048
|
+
writeJson('pain-mechanism-mapping.json', mapping);
|
|
1049
|
+
writeJson('input-manifest.json', { expectedSourceHashes: { principleContentHash: hashOfCanonical(principleFrozen.contentJson) } });
|
|
1050
|
+
writeJson('hashes.json', {
|
|
1051
|
+
preregistrationHash: { value: hashOfCanonical(prereg) },
|
|
1052
|
+
suiteHash: { value: suiteManifest.suiteHash },
|
|
1053
|
+
mappingHash: { value: mapping.mappingHash },
|
|
1054
|
+
graderHash: { value: graderHash },
|
|
1055
|
+
principleContentHash: { value: hashOfCanonical(principleFrozen.contentJson) },
|
|
1056
|
+
examinerManifestHash: { value: manifest.manifestHash },
|
|
1057
|
+
pairAssignmentHash: { value: pairAssignment.assignmentHash },
|
|
1058
|
+
});
|
|
1059
|
+
// Cross-file self-check BEFORE the identity is published: a study whose
|
|
1060
|
+
// files do not agree with each other is not a valid preregistration.
|
|
1061
|
+
const bindings = computeExaminerStudyBindings(studyDir);
|
|
1062
|
+
if (!bindings.ok) {
|
|
1063
|
+
fs.rmSync(studyDir, { recursive: true, force: true });
|
|
1064
|
+
throw new Error(`writeExaminerStudyArtifacts: written artifacts are internally inconsistent (${bindings.problems.join('; ')}) — nothing was published`);
|
|
1065
|
+
}
|
|
1066
|
+
// The immutable anchor: one append-only ledger row recorded at freeze time.
|
|
1067
|
+
// Later verification recomputes the binding and must find it unchanged.
|
|
1068
|
+
createChallengeLedger(studyDir).appendEvent({
|
|
1069
|
+
type: PREREGISTRATION_ANCHOR_EVENT,
|
|
1070
|
+
owner: 'examiner-study-freeze',
|
|
1071
|
+
schema: PREREG_ANCHOR_SCHEMA,
|
|
1072
|
+
studyId: input.studyId,
|
|
1073
|
+
bindingHash: bindings.bindingHash,
|
|
1074
|
+
binding: bindings.binding,
|
|
1075
|
+
});
|
|
1076
|
+
return { studyDir, manifest, pairAssignment, heps, ruleCodePolicy, armPayloads, suiteManifest, bindingHash: bindings.bindingHash };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Fail-closed preflight over the assembled study facts. Returns a check list
|
|
1081
|
+
* with severities; `ok` requires zero blockers. PURE — no IO, no writes.
|
|
1082
|
+
*
|
|
1083
|
+
* @param {object} input
|
|
1084
|
+
* manifest, heps, ruleCodePolicy, pairAssignment, primaryEndpoint (objects)
|
|
1085
|
+
* armConfigs — the frozen arm configs (A/B from buildStudyArms)
|
|
1086
|
+
* isolation — { sessionPerArm: bool, workspacePerArm: bool }
|
|
1087
|
+
* goldStatus — 'INDEPENDENT_REVIEW_APPROVED' | 'REVIEW_PENDING' | 'SYNTHETIC_OFFLINE'
|
|
1088
|
+
* injectionCapability — { host, maxProvableLevel, minimumRequiredLevel }
|
|
1089
|
+
* authorization — { mode: 'offline-stub'|'real', record?: object }
|
|
1090
|
+
* studyDirStatus — { insideFrozenArea: bool, collidesWithHistorical: bool }
|
|
1091
|
+
*/
|
|
1092
|
+
export function runExaminerPreflight(input) {
|
|
1093
|
+
const checks = [];
|
|
1094
|
+
const check = (id, severity, ok, detail) => checks.push({ id, severity, ok: ok === true, detail });
|
|
1095
|
+
const { manifest, heps, ruleCodePolicy, pairAssignment, primaryEndpoint, armConfigs, isolation, goldStatus, injectionCapability, authorization, studyDirStatus, bindings } = input || {};
|
|
1096
|
+
|
|
1097
|
+
const manifestCheck = verifyExaminerManifest(manifest);
|
|
1098
|
+
check('manifest_verifiable', 'blocker', manifestCheck.ok, manifestCheck.ok ? 'manifest hash verified' : (manifestCheck.problems || ['no manifest']).join('; '));
|
|
1099
|
+
const hepsCheck = verifyHostEffectivePrincipleSet(heps);
|
|
1100
|
+
check('heps_snapshot_verified', 'blocker', hepsCheck.ok, hepsCheck.ok ? `HEPS snapshot ${heps.snapshotHash.slice(0, 12)}… verified (${heps.baseLibrary.length} non-target principles, ${heps.excluded.length} excluded)` : hepsCheck.reason);
|
|
1101
|
+
if (heps) {
|
|
1102
|
+
check('target_principle_eligible', 'blocker', heps.target && heps.target.qualifies === true,
|
|
1103
|
+
heps.target && heps.target.qualifies ? `target '${heps.target.id}' qualifies for host-effective status` : `target Principle is not host-effective (${heps.target ? heps.target.exclusionReason : 'missing'}) — an ineligible intervention cannot be studied`);
|
|
1104
|
+
}
|
|
1105
|
+
const rcCheck = verifyRuleCodePolicy(ruleCodePolicy);
|
|
1106
|
+
check('rulecode_policy_verified', 'blocker', rcCheck.ok, rcCheck.ok ? `RuleCode policy ${ruleCodePolicy.policy} verified (interventionType=${ruleCodePolicy.interventionType}, identifiability=${ruleCodePolicy.identifiability})` : rcCheck.reason);
|
|
1107
|
+
if (ruleCodePolicy) {
|
|
1108
|
+
check('rulecode_policy_executable', 'blocker', ruleCodePolicy.executable !== false,
|
|
1109
|
+
ruleCodePolicy.executable !== false ? 'RuleCode policy executable under current controllability facts' : `INTERVENTION_NOT_IDENTIFIABLE: ${ruleCodePolicy.couplingReason}`);
|
|
1110
|
+
check('bundled_intervention_declared', ruleCodePolicy.interventionType === 'bundled_intervention' ? 'warning' : 'info', true,
|
|
1111
|
+
ruleCodePolicy.interventionType === 'bundled_intervention'
|
|
1112
|
+
? `BUNDLED intervention declared — conclusions may only attribute to the Principle+RuleCode bundle, never to the Principle alone (${ruleCodePolicy.couplingReason})`
|
|
1113
|
+
+ (ruleCodePolicy.unresolvedEvidence?.length ? ` [unresolved evidence: ${ruleCodePolicy.unresolvedEvidence.join(', ')}]` : '')
|
|
1114
|
+
: 'principle_only intervention — the target Principle is the only treatment variable');
|
|
1115
|
+
}
|
|
1116
|
+
const pairCheck = verifyPairAssignment(pairAssignment);
|
|
1117
|
+
check('pair_assignment_verified', 'blocker', pairCheck.ok, pairCheck.ok ? `${pairAssignment.pairs.length} pairs, balanced ${pairAssignment.balance.controlFirst}/${pairAssignment.balance.treatmentFirst}, seed frozen` : pairCheck.reason);
|
|
1118
|
+
// P1-3: every frozen file only proves itself; this check folds in the
|
|
1119
|
+
// cross-file binding + immutable anchor computed from the study directory.
|
|
1120
|
+
// It is a blocker whenever the caller supplies a binding verdict (the
|
|
1121
|
+
// production paths always do), so a study whose files were rewritten in a
|
|
1122
|
+
// mutually consistent way can never pass preflight.
|
|
1123
|
+
if (bindings !== undefined && bindings !== null) {
|
|
1124
|
+
const ok = bindings.ok === true && bindings.anchor === 'VERIFIED';
|
|
1125
|
+
check('preregistration_bindings_verified', 'blocker', ok,
|
|
1126
|
+
ok
|
|
1127
|
+
? `cross-file preregistration binding ${String(bindings.bindingHash).slice(0, 12)}… matches the immutable anchor`
|
|
1128
|
+
: `preregistration binding not verified: ${(bindings.problems || ['anchor not verified']).join('; ')}`);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// A/B contrast: the arms may differ ONLY in the target principle. In
|
|
1132
|
+
// isolated mode that is empty-vs-target; in full-library mode BOTH carry
|
|
1133
|
+
// the frozen non-target library, so the contrast is target-absent-in-A vs
|
|
1134
|
+
// target-present-in-B (requires the target text for a content-level check).
|
|
1135
|
+
if (armConfigs && armConfigs.arms) {
|
|
1136
|
+
const a = armConfigs.arms.A;
|
|
1137
|
+
const b = armConfigs.arms.B;
|
|
1138
|
+
const targetText = input && text(input.targetPrincipleText) ? input.targetPrincipleText : null;
|
|
1139
|
+
let contrastOk = false;
|
|
1140
|
+
let detail = '';
|
|
1141
|
+
if (targetText) {
|
|
1142
|
+
contrastOk = Boolean(a && b && !String(a.delivery.payloadText).includes(targetText) && String(b.delivery.payloadText).includes(targetText));
|
|
1143
|
+
detail = contrastOk ? 'arm A/B contrast is exactly the target Principle (absent in control, present in treatment)' : 'arm A/B payloads do not form a clean OFF/ON contrast on the target variable';
|
|
1144
|
+
} else {
|
|
1145
|
+
contrastOk = Boolean(a && b && (a.delivery.payloadText === '') !== (b.delivery.payloadText === ''));
|
|
1146
|
+
detail = contrastOk ? 'arm A/B contrast is exactly the target Principle payload (emptiness contrast)' : 'arm A/B payloads do not form a clean OFF/ON contrast — supply targetPrincipleText for full-library content-level contrast checking';
|
|
1147
|
+
}
|
|
1148
|
+
check('ab_contrast_only_target', 'blocker', contrastOk, detail);
|
|
1149
|
+
} else {
|
|
1150
|
+
check('ab_contrast_only_target', 'blocker', false, 'arm configs missing');
|
|
1151
|
+
}
|
|
1152
|
+
check('isolation_sessions', 'blocker', isolation ? isolation.sessionPerArm === true : false,
|
|
1153
|
+
isolation && isolation.sessionPerArm ? 'one session per arm per unit' : 'per-arm session isolation not established — cross-arm memory contamination risk');
|
|
1154
|
+
check('isolation_workspaces', 'blocker', isolation ? isolation.workspacePerArm === true : false,
|
|
1155
|
+
isolation && isolation.workspacePerArm ? 'one workspace per arm per unit' : 'per-arm workspace isolation not established');
|
|
1156
|
+
|
|
1157
|
+
if (goldStatus === 'INDEPENDENT_REVIEW_APPROVED') {
|
|
1158
|
+
check('gold_state', 'info', true, 'independent human Gold review APPROVED (Gate 1 satisfied for this suite)');
|
|
1159
|
+
} else if (goldStatus === 'SYNTHETIC_OFFLINE') {
|
|
1160
|
+
check('gold_state', 'warning', true, 'SYNTHETIC offline Gold — offline contract validation ONLY; any report must be labelled offline-stub and can never support a real-host behavior claim');
|
|
1161
|
+
} else {
|
|
1162
|
+
check('gold_state', 'blocker', false, `Gold review state '${String(goldStatus)}' — Gate 1 (independent human review) is not satisfied`);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const LEVELS = ['CONFIGURED', 'SELECTED', 'SUBMITTED', 'HOST_ACCEPTED', 'CONTEXT_PRESENT', 'RUNTIME_LOADED'];
|
|
1166
|
+
if (injectionCapability && LEVELS.includes(injectionCapability.maxProvableLevel)) {
|
|
1167
|
+
const sufficient = LEVELS.indexOf(injectionCapability.maxProvableLevel) >= LEVELS.indexOf(injectionCapability.minimumRequiredLevel || 'CONTEXT_PRESENT');
|
|
1168
|
+
check('injection_capability', injectionCapability.host === 'offline-stub' ? 'info' : (sufficient ? 'info' : 'warning'), true,
|
|
1169
|
+
`host '${injectionCapability.host}' can prove injection up to ${injectionCapability.maxProvableLevel} (required for full behavior conclusions: ${injectionCapability.minimumRequiredLevel || 'CONTEXT_PRESENT'})`
|
|
1170
|
+
+ (sufficient ? '' : ' — below CONTEXT_PRESENT the study may complete contract-wise but cannot claim verified behavioral intervention'));
|
|
1171
|
+
} else {
|
|
1172
|
+
check('injection_capability', 'blocker', false, 'host injection capability undeclared — every host must declare its max provable evidence level');
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
if (authorization && authorization.mode === 'offline-stub') {
|
|
1176
|
+
check('authorization', 'info', true, 'offline-stub execution — real model/host authorization not applicable this round (Real Model Calls = 0)');
|
|
1177
|
+
} else if (authorization && authorization.mode !== 'offline-stub') {
|
|
1178
|
+
check('authorization', 'blocker', false, 'real-mode execution requires an explicit Owner authorization record (run-authorization.json) — NOT GRANTED this round');
|
|
1179
|
+
} else {
|
|
1180
|
+
check('authorization', 'blocker', false, 'authorization state missing');
|
|
1181
|
+
}
|
|
1182
|
+
if (studyDirStatus) {
|
|
1183
|
+
check('study_dir_protection', 'blocker', studyDirStatus.insideFrozenArea !== true && studyDirStatus.collidesWithHistorical !== true,
|
|
1184
|
+
studyDirStatus.insideFrozenArea ? 'study directory would write into the frozen historical studies area — refused'
|
|
1185
|
+
: studyDirStatus.collidesWithHistorical ? 'study ID collides with a historical study — a new study must be a new identity, never an overwrite' : 'study directory outside frozen areas, no historical collision');
|
|
1186
|
+
}
|
|
1187
|
+
if (primaryEndpoint) {
|
|
1188
|
+
check('primary_endpoint_frozen', 'blocker', HASH_RE.test(String(primaryEndpoint.definitionHash)),
|
|
1189
|
+
`primary endpoint '${primaryEndpoint.id}' frozen (definitionHash ${String(primaryEndpoint.definitionHash).slice(0, 12)}…)`);
|
|
1190
|
+
} else {
|
|
1191
|
+
check('primary_endpoint_frozen', 'blocker', false, 'primary endpoint missing');
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const blockers = checks.filter((c) => !c.ok && c.severity === 'blocker');
|
|
1195
|
+
const warnings = checks.filter((c) => !c.ok && c.severity === 'warning');
|
|
1196
|
+
return {
|
|
1197
|
+
ok: blockers.length === 0,
|
|
1198
|
+
status: blockers.length === 0 ? 'READY_FOR_OFFLINE_EXECUTION' : 'BLOCKED',
|
|
1199
|
+
blockers,
|
|
1200
|
+
warnings,
|
|
1201
|
+
checks,
|
|
1202
|
+
};
|
|
1203
|
+
}
|