@unrulysystems/native-motion-conformance 0.1.0-alpha.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/package.json +33 -0
  5. package/src/adapter.ts +42 -0
  6. package/src/adapters/motion-dom.ts +96 -0
  7. package/src/adapters/native.ts +95 -0
  8. package/src/authoring.ts +78 -0
  9. package/src/comparator.ts +129 -0
  10. package/src/config.ts +22 -0
  11. package/src/declarations.ts +21 -0
  12. package/src/index.ts +144 -0
  13. package/src/oracle/attestation.ts +100 -0
  14. package/src/oracle/constants.ts +24 -0
  15. package/src/oracle/controls.ts +202 -0
  16. package/src/oracle/errors.ts +12 -0
  17. package/src/oracle/exportTrace.ts +134 -0
  18. package/src/oracle/index.ts +133 -0
  19. package/src/oracle/judge.ts +1374 -0
  20. package/src/oracle/presenter.ts +372 -0
  21. package/src/oracle/runRecord.ts +307 -0
  22. package/src/oracle/scenarios.ts +115 -0
  23. package/src/oracle/scripts/gesture.ts +218 -0
  24. package/src/oracle/serialize.ts +91 -0
  25. package/src/oracle/sweep.ts +155 -0
  26. package/src/oracle/types.ts +76 -0
  27. package/src/oracle/velocity.ts +44 -0
  28. package/src/parity.ts +136 -0
  29. package/src/runner.ts +168 -0
  30. package/src/scenario.ts +179 -0
  31. package/src/scenarios/appstore-choreography.ts +105 -0
  32. package/src/scenarios/component.ts +516 -0
  33. package/src/scenarios/driver.ts +322 -0
  34. package/src/scenarios/gesture.ts +363 -0
  35. package/src/scenarios/layout-identity.ts +264 -0
  36. package/src/scenarios/layout.ts +258 -0
  37. package/src/scenarios/presence.ts +302 -0
  38. package/src/scenarios/spring.ts +180 -0
  39. package/src/scenarios/value-types.ts +107 -0
  40. package/src/suite.ts +44 -0
@@ -0,0 +1,372 @@
1
+ // The M1 presenter is a closed boundary: slice IDs enter, reviewed constants and exporter output
2
+ // leave. It intentionally has no parser or generic input validator for foreign judge bundles.
3
+
4
+ import {
5
+ injectDiscontinuityControl,
6
+ injectLinearControl,
7
+ eligibleOracleControlKinds,
8
+ type BlindedOracleTrace,
9
+ type OracleControlKind,
10
+ type OracleControlTrace,
11
+ } from './controls'
12
+ import { assertAttestedRealTrace, oracleContentHash } from './attestation'
13
+ import { exportTraceRun, type OracleTraceRun } from './exportTrace'
14
+ import { deriveOracleTraceSpan, scenarioForSlice, type OracleTraceSpan } from './scenarios'
15
+ import { serializeTrace } from './serialize'
16
+ import {
17
+ isSubjectiveSliceId,
18
+ type OracleEngine,
19
+ type OracleTrace,
20
+ type SubjectiveSliceId,
21
+ type TraceEvent,
22
+ type TraceRow,
23
+ } from './types'
24
+
25
+ /** The verbatim §5 M1 prompt. Reviewed scenario constants are its only text fills. */
26
+ export const M1_TRAJECTORY_JUDGE_PROMPT_TEMPLATE = `You are an independent motion-quality judge. You did not produce any of the data below and you are
27
+ not told which system produced which trace. Judge only what the numbers show.
28
+
29
+ Scenario: {{scenario_name}} — {{one_line_description_of_expected_interaction}}.
30
+ You are given {{N}} anonymized traces, labeled A, B, C, ... Each trace is a time series of an
31
+ animated value: rows of (t_ms, value) and (t_ms, velocity), sampled at a fixed step. At least one
32
+ trace may be a deliberately degraded control.
33
+
34
+ Judge each trace on two dimensions:
35
+ 1. Continuity — at every interruption or gesture handoff marked at t={{event_times}}, does value
36
+ stay continuous (no jump) and does velocity carry through (no reset to zero, no sign flip that
37
+ the input did not cause)? Flag any discontinuity with its timestamp.
38
+ 2. Physical plausibility — does the approach to each target look like a settling spring (a smooth,
39
+ decaying approach) rather than a linear ramp, an instant snap, or an oscillation that should not
40
+ be there?
41
+
42
+ Return ONLY one JSON object — no prose before or after it — with exactly these keys and no others:
43
+
44
+ {
45
+ "rankings": {
46
+ "continuity": [every trace label exactly once, best to worst],
47
+ "physicalPlausibility": [every trace label exactly once, best to worst]
48
+ },
49
+ "traces": {
50
+ one entry per trace label: {
51
+ "continuity": { "verdict": "pass" | "borderline" | "fail", "issues": [...] },
52
+ "physicalPlausibility": { "verdict": "pass" | "borderline" | "fail", "issues": [...] }
53
+ }
54
+ }
55
+ }
56
+
57
+ Every issue is { "timestamps": [t_ms values copied exactly from the trace rows], "description":
58
+ "one line of what looked wrong" }. A "pass" verdict must have "issues": []; a "borderline" or
59
+ "fail" verdict must carry at least one issue naming where it looked wrong. Do not guess which
60
+ system is which. The rankings require a strict order: if two traces are indistinguishable, rank
61
+ them adjacently.`
62
+
63
+ export class OraclePresenterError extends Error {
64
+ constructor(message: string) {
65
+ super(`oracle presenter: ${message}`)
66
+ this.name = 'OraclePresenterError'
67
+ }
68
+ }
69
+
70
+ export interface OracleJudgeScenario {
71
+ readonly name: string
72
+ readonly expectedInteraction: string
73
+ }
74
+
75
+ declare const oracleJudgeBundleBrand: unique symbol
76
+
77
+ /** The entire judge-readable data surface, constructible only by `createJudgeBundle`. */
78
+ export type OracleJudgeBundle = Readonly<{
79
+ prompt: string
80
+ scenario: OracleJudgeScenario
81
+ traces: readonly BlindedOracleTrace[]
82
+ readonly [oracleJudgeBundleBrand]: 'OracleJudgeBundle'
83
+ }>
84
+
85
+ export type OracleUnblindingSource =
86
+ | { readonly engine: OracleEngine }
87
+ | { readonly control: OracleControlKind }
88
+
89
+ export interface OracleUnblindingEntry {
90
+ readonly source: OracleUnblindingSource
91
+ readonly traceHash: string
92
+ /** Guard-only metadata: it is never projected into the blinded judge bundle bytes. */
93
+ readonly eventTimes: readonly TraceEvent[]
94
+ readonly traceSpan: OracleTraceSpan
95
+ }
96
+
97
+ /** Separate from `OracleJudgeBundle`; only the later calibration guard may consume this record. */
98
+ export type OracleUnblindingRecord = Readonly<Record<string, OracleUnblindingEntry>>
99
+
100
+ export interface OraclePresentation {
101
+ readonly bundle: OracleJudgeBundle
102
+ readonly unblinding: OracleUnblindingRecord
103
+ /** Run-level calibration can query the kinds without adding provenance to judge-readable bytes. */
104
+ readonly controlKinds: readonly OracleControlKind[]
105
+ readonly manifestHash: string
106
+ /** The slice ids this presentation was built from — carried, so callers never re-claim them. */
107
+ readonly sliceIds: readonly SubjectiveSliceId[]
108
+ /** The run-scoped exporter registry anchoring every real candidate. */
109
+ readonly attestations: OracleTraceRun['attestations']
110
+ }
111
+
112
+ // Chain-of-custody brand: downstream consumers (the run-record builder) admit ONLY presentations
113
+ // this module built. Copies lose membership; deep-freezing below makes in-place mutation throw
114
+ // (review seuljp m18f8b1ed).
115
+ const builtPresentations = new WeakSet<object>()
116
+
117
+ export function assertPresenterBuiltPresentation(presentation: OraclePresentation): void {
118
+ if (!builtPresentations.has(presentation)) {
119
+ throw new OraclePresenterError('presentation was not built by the presenter')
120
+ }
121
+ }
122
+
123
+ type PresenterEntry = {
124
+ readonly trace: OracleTrace | OracleControlTrace
125
+ readonly source: OracleUnblindingSource
126
+ }
127
+
128
+ type PresenterEntries = {
129
+ readonly entries: readonly PresenterEntry[]
130
+ readonly controlKinds: readonly OracleControlKind[]
131
+ }
132
+
133
+ function error(message: string): never {
134
+ throw new OraclePresenterError(message)
135
+ }
136
+
137
+ function assertDenseSliceIds(sliceIds: readonly SubjectiveSliceId[]): SubjectiveSliceId {
138
+ if (!Array.isArray(sliceIds)) error('slice IDs must be a dense array')
139
+ if (sliceIds.length !== 1) {
140
+ error(`a judge bundle requires exactly one scenario slice; received ${sliceIds.length}`)
141
+ }
142
+ if (!(0 in sliceIds)) error('slice IDs contains a sparse entry at index 0')
143
+ const sliceId = sliceIds[0]
144
+ if (typeof sliceId !== 'string' || !isSubjectiveSliceId(sliceId)) {
145
+ error(`unsupported scenario slice '${String(sliceId)}'`)
146
+ }
147
+ return sliceId
148
+ }
149
+
150
+ function sameEvents(a: readonly TraceEvent[], b: readonly TraceEvent[]): boolean {
151
+ return (
152
+ a.length === b.length &&
153
+ a.every((event, index) => event.t === b[index]?.t && event.kind === b[index]?.kind)
154
+ )
155
+ }
156
+
157
+ function sameGrid(a: readonly TraceRow[], b: readonly TraceRow[]): boolean {
158
+ return a.length === b.length && a.every((row, index) => row.t === b[index]?.t)
159
+ }
160
+
161
+ /** Validates dense, exact fixed-60fps source rows before they can enter a blind bundle. */
162
+ function assertRunTrace(trace: OracleTrace, sliceId: SubjectiveSliceId): void {
163
+ if (trace.scenarioId !== sliceId) {
164
+ error(
165
+ `exported trace scenario '${trace.scenarioId}' does not match requested slice '${sliceId}'`,
166
+ )
167
+ }
168
+ // `serializeTrace` owns the numeric and grid contract: dense arrays, exact 60 fps timestamps,
169
+ // and event marks landing on rows. It throws a typed `OracleTraceError` for every malformed row.
170
+ serializeTrace(trace)
171
+ }
172
+
173
+ function injectEligibleControl(
174
+ trace: OracleTraceRun['realTraces'][number],
175
+ kind: OracleControlKind,
176
+ ): OracleControlTrace {
177
+ switch (kind) {
178
+ case 'C-DISC':
179
+ return injectDiscontinuityControl(trace)
180
+ case 'C-LIN':
181
+ return injectLinearControl(trace)
182
+ }
183
+ }
184
+
185
+ function entriesForRun(sliceId: SubjectiveSliceId, run: OracleTraceRun): PresenterEntries {
186
+ const realTraces = run.realTraces
187
+ if (realTraces.length === 0) error('exporter returned no real traces')
188
+ const reference = realTraces[0]
189
+ if (reference === undefined) error('exporter returned no real traces')
190
+
191
+ for (const trace of realTraces) {
192
+ assertAttestedRealTrace(trace, 'judge bundle real candidate')
193
+ assertRunTrace(trace, sliceId)
194
+ if (
195
+ !sameGrid(trace.rows, reference.rows) ||
196
+ !sameEvents(trace.eventTimes, reference.eventTimes)
197
+ ) {
198
+ error('exporter returned incompatible grids or event marks for one scenario')
199
+ }
200
+ }
201
+
202
+ // A5 eligibility is a trace property, not a slice-name policy. Mid-active/release-only traces
203
+ // still carry C-DISC; only traces with the controls module's usable settle segment carry C-LIN.
204
+ const controlKinds = eligibleOracleControlKinds(reference)
205
+ const controls = controlKinds.map((kind) => injectEligibleControl(reference, kind))
206
+ for (const control of controls) {
207
+ assertRunTrace(control, sliceId)
208
+ if (
209
+ !sameGrid(control.rows, reference.rows) ||
210
+ !sameEvents(control.eventTimes, reference.eventTimes)
211
+ ) {
212
+ error(`${control.control.kind} control changed the scenario grid or event marks`)
213
+ }
214
+ }
215
+
216
+ return {
217
+ entries: [
218
+ ...realTraces.map((trace) => ({ trace, source: { engine: trace.engine } }) as const),
219
+ ...controls.map((trace) => ({ trace, source: { control: trace.control.kind } }) as const),
220
+ ],
221
+ controlKinds,
222
+ }
223
+ }
224
+
225
+ function labelAt(index: number): string {
226
+ let current = index
227
+ let label = ''
228
+ do {
229
+ label = String.fromCharCode(65 + (current % 26)) + label
230
+ current = Math.floor(current / 26) - 1
231
+ } while (current >= 0)
232
+ return label
233
+ }
234
+
235
+ /** Deterministic PRNG seeded by the manifest hash; never use wall-clock time or Math.random here. */
236
+ function seededShuffle<T>(values: readonly T[], manifestHash: string): T[] {
237
+ let state = Number.parseInt(manifestHash.slice(0, 8), 16) >>> 0
238
+ const next = (): number => {
239
+ state = (state + 0x6d2b79f5) >>> 0
240
+ let value = state
241
+ value = Math.imul(value ^ (value >>> 15), value | 1)
242
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
243
+ return ((value ^ (value >>> 14)) >>> 0) / 0x1_0000_0000
244
+ }
245
+ const shuffled = [...values]
246
+ for (let index = shuffled.length - 1; index > 0; index -= 1) {
247
+ const swapIndex = Math.floor(next() * (index + 1))
248
+ const current = shuffled[index]
249
+ shuffled[index] = shuffled[swapIndex]!
250
+ shuffled[swapIndex] = current!
251
+ }
252
+ return shuffled
253
+ }
254
+
255
+ function formatEventTimes(events: readonly TraceEvent[]): string {
256
+ return events.map((event) => `${event.t}ms (${event.kind})`).join(', ')
257
+ }
258
+
259
+ function fillPrompt(
260
+ sliceId: SubjectiveSliceId,
261
+ traceCount: number,
262
+ events: readonly TraceEvent[],
263
+ ): string {
264
+ const scenario = scenarioForSlice(sliceId)
265
+ return M1_TRAJECTORY_JUDGE_PROMPT_TEMPLATE.replace('{{scenario_name}}', scenario.scenarioName)
266
+ .replace('{{one_line_description_of_expected_interaction}}', scenario.expectedInteraction)
267
+ .replace('{{N}}', String(traceCount))
268
+ .replace('{{event_times}}', formatEventTimes(events))
269
+ }
270
+
271
+ function blindedTrace(label: string, trace: OracleTrace): BlindedOracleTrace {
272
+ return {
273
+ label,
274
+ grid: trace.grid,
275
+ rows: trace.rows.map((row) => ({ t: row.t, value: row.value, velocity: row.velocity })),
276
+ eventTimes: trace.eventTimes.map((event) => ({ t: event.t, kind: event.kind })),
277
+ }
278
+ }
279
+
280
+ function canonicalManifest(sliceId: SubjectiveSliceId, entries: readonly PresenterEntry[]): string {
281
+ const first = entries[0]?.trace
282
+ if (first === undefined) error('trace set is empty')
283
+ const scenario = scenarioForSlice(sliceId)
284
+ return JSON.stringify({
285
+ promptTemplate: M1_TRAJECTORY_JUDGE_PROMPT_TEMPLATE,
286
+ promptFills: {
287
+ scenarioName: scenario.scenarioName,
288
+ expectedInteraction: scenario.expectedInteraction,
289
+ traceCount: entries.length,
290
+ eventTimes: formatEventTimes(first.eventTimes),
291
+ },
292
+ labelVocabulary: entries.map((_, index) => labelAt(index)),
293
+ entries: entries.map(({ trace, source }) => ({ source, trace: serializeTrace(trace) })),
294
+ })
295
+ }
296
+
297
+ /**
298
+ * Creates one scenario's blind bundle from a closed slice-ID table. The input is deliberately an
299
+ * ID array so phase 4 can pass its selected run slices without gaining any text or trace surface.
300
+ */
301
+ export function createJudgeBundle(sliceIds: readonly SubjectiveSliceId[]): OraclePresentation {
302
+ const sliceId = assertDenseSliceIds(sliceIds)
303
+ const run = exportTraceRun([sliceId])
304
+ const { entries, controlKinds } = entriesForRun(sliceId, run)
305
+ const manifestHash = oracleContentHash(canonicalManifest(sliceId, entries))
306
+ const shuffled = seededShuffle(entries, manifestHash)
307
+ const first = shuffled[0]?.trace
308
+ if (first === undefined) error('trace set is empty')
309
+
310
+ const unblinding: Record<string, OracleUnblindingEntry> = {}
311
+ const traces = shuffled.map((entry, index) => {
312
+ const label = labelAt(index)
313
+ unblinding[label] = {
314
+ source: entry.source,
315
+ traceHash: oracleContentHash(serializeTrace(entry.trace)),
316
+ eventTimes: entry.trace.eventTimes.map((event) => ({ ...event })),
317
+ traceSpan: deriveOracleTraceSpan(entry.trace),
318
+ }
319
+ return blindedTrace(label, entry.trace)
320
+ })
321
+ const scenario = scenarioForSlice(sliceId)
322
+ const bundle = {
323
+ prompt: fillPrompt(sliceId, traces.length, first.eventTimes),
324
+ scenario: { name: scenario.scenarioName, expectedInteraction: scenario.expectedInteraction },
325
+ traces,
326
+ } as unknown as OracleJudgeBundle
327
+
328
+ const presentation: OraclePresentation = {
329
+ bundle,
330
+ unblinding,
331
+ controlKinds,
332
+ manifestHash,
333
+ sliceIds: Object.freeze([...sliceIds]),
334
+ attestations: run.attestations,
335
+ }
336
+ deepFreezePresentation(presentation)
337
+ builtPresentations.add(presentation)
338
+ return presentation
339
+ }
340
+
341
+ // Runtime immutability for the evidence chain: a frozen presentation cannot be doctored in place
342
+ // (strict-mode assignment throws), and a copy loses the WeakSet brand above.
343
+ function deepFreezePresentation(presentation: OraclePresentation): void {
344
+ for (const trace of presentation.bundle.traces) {
345
+ for (const row of trace.rows) Object.freeze(row)
346
+ for (const event of trace.eventTimes) Object.freeze(event)
347
+ Object.freeze(trace.rows)
348
+ Object.freeze(trace.eventTimes)
349
+ Object.freeze(trace)
350
+ }
351
+ Object.freeze(presentation.bundle.traces)
352
+ Object.freeze(presentation.bundle.scenario)
353
+ Object.freeze(presentation.bundle)
354
+ for (const entry of Object.values(presentation.unblinding)) {
355
+ for (const event of entry.eventTimes) Object.freeze(event)
356
+ Object.freeze(entry.eventTimes)
357
+ Object.freeze(entry.source)
358
+ Object.freeze(entry)
359
+ }
360
+ Object.freeze(presentation.unblinding)
361
+ Object.freeze(presentation.controlKinds)
362
+ Object.freeze(presentation)
363
+ }
364
+
365
+ /** Canonical provenance-free bytes for judge dispatch and the immutable run record. */
366
+ export function serializeJudgeBundle(bundle: OracleJudgeBundle): string {
367
+ return JSON.stringify({
368
+ prompt: bundle.prompt,
369
+ scenario: bundle.scenario,
370
+ traces: bundle.traces,
371
+ })
372
+ }
@@ -0,0 +1,307 @@
1
+ // The executed-run record (packet A3/A4). Evidence rides a provenance CHAIN (reviews 0nh3zf +
2
+ // seuljp): presentations must be presenter-built (branded + deep-frozen), verdict outcomes must
3
+ // be judgeBundle-built (branded, carrying the provider config actually used), real-trace hashes
4
+ // are checked against the exporter's attestation registry, raw judge text is re-parsed and
5
+ // compared, and the pure guard is re-run and compared. Nothing is recorded on trust. The module
6
+ // performs no I/O and reads no clock: deterministic bytes plus a dated path are handed to the
7
+ // caller (the phase-5 command) for persistence.
8
+
9
+ import { oracleContentHash } from './attestation'
10
+ import {
11
+ assertJudgeVerdictOutcome,
12
+ decideOracleGuard,
13
+ parseJudgeVerdict,
14
+ type JudgeProviderConfig,
15
+ type JudgeVerdictOutcome,
16
+ type OracleBlindnessAudit,
17
+ type OracleGuardOutcome,
18
+ type OracleJudgeCleanupError,
19
+ type OracleJudgeVerdict,
20
+ } from './judge'
21
+ import type { OracleControlKind } from './controls'
22
+ import {
23
+ assertPresenterBuiltPresentation,
24
+ serializeJudgeBundle,
25
+ type OracleJudgeScenario,
26
+ type OraclePresentation,
27
+ type OracleUnblindingRecord,
28
+ } from './presenter'
29
+ import { SUBJECTIVE_SLICE_IDS, type SubjectiveSliceId } from './types'
30
+
31
+ export const ORACLE_RUNS_DIRECTORY = 'packages/conformance/oracle-runs'
32
+
33
+ export class OracleRunRecordError extends Error {
34
+ constructor(message: string) {
35
+ super(`oracle run record: ${message}`)
36
+ this.name = 'OracleRunRecordError'
37
+ }
38
+ }
39
+
40
+ /** One judged bundle: the two branded pipeline objects. Everything else is DERIVED from them. */
41
+ export interface OracleRunBundleInput {
42
+ readonly presentation: OraclePresentation
43
+ readonly outcome: JudgeVerdictOutcome
44
+ }
45
+
46
+ export interface OracleRunInputs {
47
+ readonly bundles: readonly OracleRunBundleInput[]
48
+ readonly guard: OracleGuardOutcome
49
+ }
50
+
51
+ /** Per-bundle persisted evidence; every field below is derived inside `buildOracleRunRecord`. */
52
+ export interface OracleRunBundleRecord {
53
+ readonly sliceIds: readonly SubjectiveSliceId[]
54
+ readonly scenario: OracleJudgeScenario
55
+ readonly labels: readonly string[]
56
+ readonly traceHashes: readonly { readonly label: string; readonly hash: string }[]
57
+ readonly manifestHash: string
58
+ readonly controlKinds: readonly OracleControlKind[]
59
+ /** The judge-visible prompt (provenance-free by construction), retained for re-audit. */
60
+ readonly prompt: string
61
+ readonly rawJudgeText: string
62
+ /** The FULL raw --json event stream, so the no-tool-use claim is independently re-auditable. */
63
+ readonly rawTranscript: string
64
+ readonly verdict: OracleJudgeVerdict
65
+ /**
66
+ * Post-judgment unblinding (label → source/hash/events/span): the record is the run's human-
67
+ * facing evidence, so a later reviewer can recompute the calibration guard from the artifact.
68
+ */
69
+ readonly unblinding: OracleUnblindingRecord
70
+ /** The per-run transcript audit — a run without a blindness proof must not record. */
71
+ readonly blindness: OracleBlindnessAudit
72
+ readonly cleanupFailed?: OracleJudgeCleanupError
73
+ }
74
+
75
+ export interface OracleRunRecord {
76
+ readonly bundles: readonly OracleRunBundleRecord[]
77
+ readonly guard: OracleGuardOutcome
78
+ /** Derived from the outcomes' carried configs (all bundles must agree), never claimed. */
79
+ readonly provider: Pick<JudgeProviderConfig, 'binary' | 'args' | 'timeoutMs'>
80
+ }
81
+
82
+ export interface OracleRunRecordFile {
83
+ readonly path: string
84
+ readonly content: string
85
+ }
86
+
87
+ // Runtime provenance for the record itself: only builder output may serialize. The brand is a
88
+ // private WeakSet membership (the registry-handle precedent) — JSON round-trips cannot forge it.
89
+ const builtRecords = new WeakSet<OracleRunRecord>()
90
+
91
+ // Key-sorted stringify so structural equality comparisons are insensitive to construction order.
92
+ function stableStringify(value: unknown): string {
93
+ return JSON.stringify(value, (_key, node: unknown) => {
94
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) return node
95
+ const entries = Object.entries(node as Record<string, unknown>)
96
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
97
+ return Object.fromEntries(entries)
98
+ })
99
+ }
100
+
101
+ /**
102
+ * Derive-and-bind over the provenance chain. Throws instead of recording when any link fails:
103
+ * an unbranded presentation or outcome, a real-trace hash absent from the exporter's attestation
104
+ * registry, raw text that does not re-parse to the recorded verdict, mismatched provider configs
105
+ * across bundles, or a guard outcome the recomputation does not reproduce.
106
+ */
107
+ export function buildOracleRunRecord(inputs: OracleRunInputs): OracleRunRecord {
108
+ if (inputs.bundles.length === 0) {
109
+ throw new OracleRunRecordError('a run record requires at least one judged bundle')
110
+ }
111
+
112
+ // Full-run slice closure (review c6w0wg m27): an M1 run covers EVERY required slice exactly
113
+ // once — a partial or duplicated run must never record as an executed oracle run.
114
+ const covered = inputs.bundles.flatMap((entry) => entry.presentation.sliceIds)
115
+ if (new Set(covered).size !== covered.length) {
116
+ throw new OracleRunRecordError('a run must cover each slice exactly once (duplicate slice)')
117
+ }
118
+ const missing = SUBJECTIVE_SLICE_IDS.filter((sliceId) => !covered.includes(sliceId))
119
+ const foreign = covered.filter(
120
+ (sliceId) => !(SUBJECTIVE_SLICE_IDS as readonly string[]).includes(sliceId),
121
+ )
122
+ if (missing.length > 0 || foreign.length > 0) {
123
+ throw new OracleRunRecordError(
124
+ `a run must cover every required slice exactly once (missing: ${missing.join(', ') || 'none'}; foreign: ${foreign.join(', ') || 'none'})`,
125
+ )
126
+ }
127
+
128
+ let provider: OracleRunRecord['provider'] | undefined
129
+ const bundles: OracleRunBundleRecord[] = inputs.bundles.map((entry, index) => {
130
+ const { presentation, outcome } = entry
131
+ try {
132
+ assertPresenterBuiltPresentation(presentation)
133
+ } catch (error) {
134
+ const cause = error instanceof Error ? error.message : String(error)
135
+ throw new OracleRunRecordError(`bundle ${index}: ${cause}`)
136
+ }
137
+ try {
138
+ assertJudgeVerdictOutcome(outcome)
139
+ } catch (error) {
140
+ const cause = error instanceof Error ? error.message : String(error)
141
+ throw new OracleRunRecordError(`bundle ${index}: ${cause}`)
142
+ }
143
+
144
+ // Outcome↔bundle identity (review c6w0wg m25): the outcome must have judged THESE bytes.
145
+ if (outcome.bundleHash !== oracleContentHash(serializeJudgeBundle(presentation.bundle))) {
146
+ throw new OracleRunRecordError(
147
+ `bundle ${index}: outcome was paired with a presentation it did not judge`,
148
+ )
149
+ }
150
+
151
+ // Blindness proof (ruling 2026-07-14): REQ-ORACLE-002's void semantics — a judgment
152
+ // without an audited pure-turn transcript must never record as an executed oracle run.
153
+ if (outcome.blindness === undefined) {
154
+ throw new OracleRunRecordError(
155
+ `bundle ${index}: outcome carries no blindness proof — the judgment cannot be verified path-blind`,
156
+ )
157
+ }
158
+
159
+ // Execution provenance (review kpp2fg m7f2a61c9): only transcripts read off the pipe of a
160
+ // process the REAL CLI provider spawned may record as an executed run. Injected providers
161
+ // are for tests of everything upstream; their outcomes stop here.
162
+ if (outcome.executionProvenance !== 'cli') {
163
+ throw new OracleRunRecordError(
164
+ `bundle ${index}: outcome execution provenance is ${outcome.executionProvenance} — only CLI-executed judgments may record`,
165
+ )
166
+ }
167
+
168
+ // Real candidates must carry hashes the exporter attested in THIS run; a doctored hash has
169
+ // no matching attestation. (Controls are verified by injector recomputation at presentation
170
+ // time; their hashes are presenter-derived and frozen.)
171
+ const attested = new Set(presentation.attestations.traceHashes)
172
+ for (const [label, unblindingEntry] of Object.entries(presentation.unblinding)) {
173
+ if ('engine' in unblindingEntry.source && !attested.has(unblindingEntry.traceHash)) {
174
+ throw new OracleRunRecordError(
175
+ `bundle ${index}: real trace ${label} hash is not attested by this run's exporter`,
176
+ )
177
+ }
178
+ }
179
+
180
+ let reparsed: OracleJudgeVerdict
181
+ try {
182
+ reparsed = parseJudgeVerdict(outcome.rawText, presentation.bundle)
183
+ } catch (error) {
184
+ const cause = error instanceof Error ? error.message : String(error)
185
+ throw new OracleRunRecordError(
186
+ `bundle ${index}: raw judge text does not parse against its bundle: ${cause}`,
187
+ )
188
+ }
189
+ if (stableStringify(reparsed) !== stableStringify(outcome.verdict)) {
190
+ throw new OracleRunRecordError(
191
+ `bundle ${index}: raw judge text does not re-parse to the recorded verdict`,
192
+ )
193
+ }
194
+
195
+ if (provider === undefined) {
196
+ provider = outcome.provider
197
+ } else if (stableStringify(provider) !== stableStringify(outcome.provider)) {
198
+ throw new OracleRunRecordError(
199
+ `bundle ${index}: provider config differs from the run's other bundles`,
200
+ )
201
+ }
202
+
203
+ const entries = Object.entries(presentation.unblinding)
204
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
205
+ return {
206
+ sliceIds: presentation.sliceIds,
207
+ scenario: presentation.bundle.scenario,
208
+ labels: entries.map(([label]) => label),
209
+ traceHashes: entries.map(([label, unblindingEntry]) => ({
210
+ label,
211
+ hash: unblindingEntry.traceHash,
212
+ })),
213
+ manifestHash: presentation.manifestHash,
214
+ controlKinds: presentation.controlKinds,
215
+ prompt: presentation.bundle.prompt,
216
+ rawJudgeText: outcome.rawText,
217
+ rawTranscript: outcome.transcript,
218
+ verdict: outcome.verdict,
219
+ unblinding: presentation.unblinding,
220
+ blindness: outcome.blindness,
221
+ ...(outcome.cleanupFailed === undefined ? {} : { cleanupFailed: outcome.cleanupFailed }),
222
+ }
223
+ })
224
+ if (provider === undefined) {
225
+ throw new OracleRunRecordError('no provider configuration could be derived from the outcomes')
226
+ }
227
+
228
+ const recomputedGuard = decideOracleGuard(
229
+ inputs.bundles.map((entry) => {
230
+ assertJudgeVerdictOutcome(entry.outcome)
231
+ return {
232
+ verdict: entry.outcome.verdict,
233
+ unblinding: entry.presentation.unblinding,
234
+ controlKinds: entry.presentation.controlKinds,
235
+ }
236
+ }),
237
+ )
238
+ if (stableStringify(recomputedGuard) !== stableStringify(inputs.guard)) {
239
+ throw new OracleRunRecordError(
240
+ 'supplied guard outcome does not match the recomputed guard decision',
241
+ )
242
+ }
243
+
244
+ // Store the RECOMPUTED guard, never the caller's object: an alias the caller retains could be
245
+ // mutated after branding (review fva54q m4). Its whole graph is frozen in deepFreezeRecord.
246
+ const record: OracleRunRecord = { bundles, guard: recomputedGuard, provider }
247
+ deepFreezeRecord(record)
248
+ builtRecords.add(record)
249
+ return record
250
+ }
251
+
252
+ // A branded record that could be doctored after building would defeat the brand (review c6w0wg
253
+ // m26): freeze the whole persisted graph. Guard/verdict/provider substructures arrive already
254
+ // frozen from their builders; freeze the record-owned containers here.
255
+ function deepFreezeRecord(record: OracleRunRecord): void {
256
+ for (const bundle of record.bundles) {
257
+ for (const traceHash of bundle.traceHashes) Object.freeze(traceHash)
258
+ Object.freeze(bundle.traceHashes)
259
+ Object.freeze(bundle.labels)
260
+ Object.freeze(bundle.sliceIds)
261
+ Object.freeze(bundle.controlKinds)
262
+ Object.freeze(bundle.scenario)
263
+ Object.freeze(bundle.blindness)
264
+ Object.freeze(bundle)
265
+ }
266
+ Object.freeze(record.bundles)
267
+ // The guard graph: advisory orderings, unclassified findings, and their nested arrays.
268
+ for (const ordering of record.guard.advisory) {
269
+ Object.freeze(ordering.bestToWorst)
270
+ Object.freeze(ordering)
271
+ }
272
+ Object.freeze(record.guard.advisory)
273
+ for (const finding of record.guard.unclassifiedFindings) {
274
+ Object.freeze(finding.timestamps)
275
+ Object.freeze(finding)
276
+ }
277
+ Object.freeze(record.guard.unclassifiedFindings)
278
+ Object.freeze(record.guard)
279
+ Object.freeze(record.provider)
280
+ Object.freeze(record)
281
+ }
282
+
283
+ function timestampForPath(clock: Date): string {
284
+ if (Number.isNaN(clock.valueOf()))
285
+ throw new OracleRunRecordError('clock returned an invalid date')
286
+ // Second precision for EVERY clock value: a live clock virtually always carries nonzero
287
+ // milliseconds, and the record path contract is YYYY-MM-DDTHH-MM-SSZ.json (oracle-runs/README).
288
+ return clock
289
+ .toISOString()
290
+ .replaceAll(':', '-')
291
+ .replace(/\.\d{3}Z$/, 'Z')
292
+ }
293
+
294
+ /** Canonical dated path plus JSON bytes; caller owns the filesystem write and supplies the clock. */
295
+ export function serializeOracleRunRecord(
296
+ record: OracleRunRecord,
297
+ clock: Date,
298
+ ): OracleRunRecordFile {
299
+ if (!builtRecords.has(record)) {
300
+ throw new OracleRunRecordError('record was not produced by buildOracleRunRecord')
301
+ }
302
+ const timestamp = timestampForPath(clock)
303
+ return {
304
+ path: `${ORACLE_RUNS_DIRECTORY}/${timestamp}.json`,
305
+ content: `${JSON.stringify(record, null, 2)}\n`,
306
+ }
307
+ }