@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/package.json +33 -0
- package/src/adapter.ts +42 -0
- package/src/adapters/motion-dom.ts +96 -0
- package/src/adapters/native.ts +95 -0
- package/src/authoring.ts +78 -0
- package/src/comparator.ts +129 -0
- package/src/config.ts +22 -0
- package/src/declarations.ts +21 -0
- package/src/index.ts +144 -0
- package/src/oracle/attestation.ts +100 -0
- package/src/oracle/constants.ts +24 -0
- package/src/oracle/controls.ts +202 -0
- package/src/oracle/errors.ts +12 -0
- package/src/oracle/exportTrace.ts +134 -0
- package/src/oracle/index.ts +133 -0
- package/src/oracle/judge.ts +1374 -0
- package/src/oracle/presenter.ts +372 -0
- package/src/oracle/runRecord.ts +307 -0
- package/src/oracle/scenarios.ts +115 -0
- package/src/oracle/scripts/gesture.ts +218 -0
- package/src/oracle/serialize.ts +91 -0
- package/src/oracle/sweep.ts +155 -0
- package/src/oracle/types.ts +76 -0
- package/src/oracle/velocity.ts +44 -0
- package/src/parity.ts +136 -0
- package/src/runner.ts +168 -0
- package/src/scenario.ts +179 -0
- package/src/scenarios/appstore-choreography.ts +105 -0
- package/src/scenarios/component.ts +516 -0
- package/src/scenarios/driver.ts +322 -0
- package/src/scenarios/gesture.ts +363 -0
- package/src/scenarios/layout-identity.ts +264 -0
- package/src/scenarios/layout.ts +258 -0
- package/src/scenarios/presence.ts +302 -0
- package/src/scenarios/spring.ts +180 -0
- package/src/scenarios/value-types.ts +107 -0
- 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
|
+
}
|