@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,115 @@
1
+ // Reviewed, provenance-clean judge-visible scenario text. The presenter accepts only these slice
2
+ // identifiers; callers never supply prose that could disclose a source path or alter the prompt.
3
+
4
+ import { SUBJECTIVE_SLICE_IDS, type OracleTrace, type SubjectiveSliceId } from './types'
5
+
6
+ export const ORACLE_TRACE_SPANS = ['ends-mid-active', 'ends-at-release', 'settles'] as const
7
+ export type OracleTraceSpan = (typeof ORACLE_TRACE_SPANS)[number]
8
+
9
+ /** Table-linted words that would promise unseen post-boundary motion in a truncated trace. */
10
+ export const ORACLE_NON_SETTLE_CLAIM_WORDS = [
11
+ 'settle',
12
+ 'settling',
13
+ 'continue',
14
+ 'continuation',
15
+ ] as const
16
+
17
+ const NON_SETTLE_CLAIM_PATTERN = new RegExp(
18
+ `\\b(?:${ORACLE_NON_SETTLE_CLAIM_WORDS.join('|')})\\b`,
19
+ 'i',
20
+ )
21
+ const TRAILING_SENTENCE_PUNCTUATION = /[.!?]$/
22
+ const SETTLED_TAIL_VALUE_EPSILON = 0.001
23
+ const SETTLED_TAIL_VELOCITY_EPSILON = 0.001
24
+
25
+ export interface OracleScenarioConstant {
26
+ readonly scenarioName: string
27
+ readonly expectedInteraction: string
28
+ readonly criticalMoment: string
29
+ /** The frozen trace's real terminal state, independently derived in the scenario-table test. */
30
+ readonly traceSpan: OracleTraceSpan
31
+ }
32
+
33
+ export const ORACLE_SCENARIOS = {
34
+ 'gesture.grab-during-animation': {
35
+ scenarioName: 'Sheet grab during motion',
36
+ expectedInteraction: 'Grab the moving sheet and re-grab it during motion',
37
+ criticalMoment: 'the mid-motion re-grab boundary',
38
+ traceSpan: 'ends-mid-active',
39
+ },
40
+ 'gesture.velocity-handoff': {
41
+ scenarioName: 'Sheet velocity handoff',
42
+ expectedInteraction: 'Fling the sheet through the release handoff',
43
+ criticalMoment: 'the release handoff',
44
+ traceSpan: 'ends-at-release',
45
+ },
46
+ 'gesture.release-seam': {
47
+ scenarioName: 'Sheet release seam',
48
+ expectedInteraction: 'Release the sheet at the continuity seam',
49
+ criticalMoment: 'the release boundary',
50
+ traceSpan: 'ends-at-release',
51
+ },
52
+ 'gesture.settle-accounting': {
53
+ scenarioName: 'Sheet settle after release',
54
+ expectedInteraction: 'Release the sheet and observe its settle to the target',
55
+ criticalMoment: 'the final approach to the target',
56
+ traceSpan: 'settles',
57
+ },
58
+ 'gesture.hold-then-release': {
59
+ scenarioName: 'Sheet hold then release',
60
+ expectedInteraction: 'Hold the sheet still, then release it to settle',
61
+ criticalMoment: 'the transition from the hold to release',
62
+ traceSpan: 'settles',
63
+ },
64
+ 'sheet.open-to-snap': {
65
+ scenarioName: 'Sheet opens to snap',
66
+ expectedInteraction: 'Observe the sheet spring to its open snap point',
67
+ criticalMoment: 'the start and final settle of the spring',
68
+ traceSpan: 'settles',
69
+ },
70
+ } as const satisfies Readonly<Record<SubjectiveSliceId, OracleScenarioConstant>>
71
+
72
+ /**
73
+ * Derives the trace terminal state from its frozen event sequence and observable settled tail.
74
+ * No slice identifier participates, so a script and its reviewed prose cannot silently drift apart.
75
+ */
76
+ export function deriveOracleTraceSpan(trace: OracleTrace): OracleTraceSpan {
77
+ const final = trace.rows.at(-1)
78
+ const penultimate = trace.rows.at(-2)
79
+ if (
80
+ final !== undefined &&
81
+ penultimate !== undefined &&
82
+ Math.abs(final.value - penultimate.value) <= SETTLED_TAIL_VALUE_EPSILON &&
83
+ Math.abs(final.velocity) <= SETTLED_TAIL_VELOCITY_EPSILON &&
84
+ Math.abs(penultimate.velocity) <= SETTLED_TAIL_VELOCITY_EPSILON
85
+ ) {
86
+ return 'settles'
87
+ }
88
+
89
+ return trace.eventTimes.at(-1)?.kind === 'release' ? 'ends-at-release' : 'ends-mid-active'
90
+ }
91
+
92
+ /** Keeps prompt fills single-punctuated and forbids truncated traces from promising a settle. */
93
+ export function assertOracleScenarioTableText(): void {
94
+ for (const sliceId of SUBJECTIVE_SLICE_IDS) {
95
+ const scenario = ORACLE_SCENARIOS[sliceId]
96
+ if (TRAILING_SENTENCE_PUNCTUATION.test(scenario.expectedInteraction)) {
97
+ throw new Error(`${sliceId}: expectedInteraction owns no trailing punctuation`)
98
+ }
99
+ if (
100
+ scenario.traceSpan !== 'settles' &&
101
+ [scenario.expectedInteraction, scenario.criticalMoment].some((text) =>
102
+ NON_SETTLE_CLAIM_PATTERN.test(text),
103
+ )
104
+ ) {
105
+ throw new Error(`${sliceId}: non-settling trace description promises unseen motion`)
106
+ }
107
+ }
108
+ }
109
+
110
+ // Closed reviewed constants are rejected at module initialization before a prompt can be assembled.
111
+ assertOracleScenarioTableText()
112
+
113
+ export function scenarioForSlice(sliceId: SubjectiveSliceId): OracleScenarioConstant {
114
+ return ORACLE_SCENARIOS[sliceId]
115
+ }
@@ -0,0 +1,218 @@
1
+ // Gesture subjective-slice scripts — replay the authored inputs from
2
+ // packages/conformance/src/scenarios/gesture.ts exactly. The fixed grid only controls sampling;
3
+ // it never authorizes synthetic inputs or completion after a source scenario has ended.
4
+
5
+ import {
6
+ ManualClock,
7
+ ManualScheduler,
8
+ createGestureSession,
9
+ createMotionGraph,
10
+ type GestureSession,
11
+ } from '@unrulysystems/native-motion-core'
12
+ import { OracleTraceError } from '../errors'
13
+ import { runFrameSweep, type SweepApi } from '../sweep'
14
+ import type { SubjectiveSliceId, TraceEvent, TraceRow, VelocityProvenance } from '../types'
15
+ import { finiteDiffVelocity } from '../velocity'
16
+
17
+ type SessionOpts = Omit<Parameters<typeof createGestureSession>[0], 'graph'>
18
+ type GestureSliceId = Extract<SubjectiveSliceId, `gesture.${string}`>
19
+ type GestureSample = Parameters<GestureSession['active']>[0]
20
+
21
+ /** The source-scenario inputs consumed by the exporter, retained solely for replay verification. */
22
+ export type ReplayedGestureInput =
23
+ | { readonly kind: 'begin'; readonly activationTranslation: number }
24
+ | { readonly kind: 'active' | 'end'; readonly sample: GestureSample }
25
+ | { readonly kind: 'frames'; readonly count: number }
26
+
27
+ interface GestureScript {
28
+ readonly opts: SessionOpts
29
+ readonly drive: (replay: GestureReplay) => void
30
+ }
31
+
32
+ /** Test seam for observing the engine/generator; production samples the real session directly. */
33
+ export interface GestureTraceOptions {
34
+ readonly sampleEngineVelocity?: (session: GestureSession) => number
35
+ }
36
+
37
+ /** Applies authored inputs while keeping the exact source sequence observable to the contract test. */
38
+ class GestureReplay {
39
+ readonly #inputs: ReplayedGestureInput[] = []
40
+
41
+ constructor(
42
+ private readonly session: GestureSession,
43
+ private readonly scheduler: ManualScheduler,
44
+ private readonly api: SweepApi,
45
+ ) {}
46
+
47
+ get inputs(): readonly ReplayedGestureInput[] {
48
+ return this.#inputs
49
+ }
50
+
51
+ begin(activationTranslation: number, mark: 'begin' | 'interrupt'): void {
52
+ this.#inputs.push({ kind: 'begin', activationTranslation })
53
+ if (mark === 'interrupt') {
54
+ // Re-grab stops the generator. Record its last live engine velocity on the pre-seam side;
55
+ // the first gesture-active row then finite-differences ACROSS the seam against that
56
+ // adjacent exported value (A7), so real value motion is observed, never reset to a
57
+ // fabricated 0 (review 5in4om m3d124b45a58c).
58
+ this.api.sampleBeforeBoundary()
59
+ this.api.mark(mark)
60
+ this.session.begin(activationTranslation)
61
+ return
62
+ }
63
+ this.api.mark(mark)
64
+ this.session.begin(activationTranslation)
65
+ this.api.sample()
66
+ }
67
+
68
+ active(sample: GestureSample, mark?: 'active-start'): void {
69
+ this.#inputs.push({ kind: 'active', sample })
70
+ if (mark !== undefined) this.api.mark(mark)
71
+ this.session.active(sample)
72
+ this.api.sample()
73
+ }
74
+
75
+ end(sample: GestureSample): void {
76
+ this.#inputs.push({ kind: 'end', sample })
77
+ this.api.mark('release')
78
+ this.session.end(sample)
79
+ // The first generator sample is at t=0. Pump it without advancing wall time so the release
80
+ // row observes the engine's seeded velocity rather than a recognizer input or a later decay.
81
+ this.scheduler.frame(0)
82
+ this.api.sample()
83
+ }
84
+
85
+ advance(): void {
86
+ this.api.advance()
87
+ }
88
+
89
+ frameIndex(): number {
90
+ return this.api.frameIndex()
91
+ }
92
+
93
+ frames(count: number): void {
94
+ this.#inputs.push({ kind: 'frames', count })
95
+ for (let i = 0; i < count; i++) {
96
+ this.api.advance()
97
+ this.api.sample()
98
+ }
99
+ }
100
+ }
101
+
102
+ const SCRIPTS: Record<GestureSliceId, GestureScript> = {
103
+ // scenarios/gesture.ts:67 — source ends during the re-grab; do not manufacture a release.
104
+ 'gesture.grab-during-animation': {
105
+ opts: { snap: { points: [0, 300, 600] } },
106
+ drive(replay) {
107
+ replay.begin(0, 'begin')
108
+ replay.advance()
109
+ replay.active({ t: 16, translation: 200, velocity: 2000 }, 'active-start')
110
+ replay.advance()
111
+ replay.end({ t: 32, translation: 200, velocity: 2000 })
112
+ replay.frames(3)
113
+ replay.begin(1000, 'interrupt')
114
+ replay.advance()
115
+ replay.active({ t: 64, translation: 1050, velocity: 0 })
116
+ },
117
+ },
118
+ // scenarios/gesture.ts:87 — one authored post-release frame proves the generator direction.
119
+ 'gesture.velocity-handoff': {
120
+ opts: { initial: 0, snap: { points: [0, 1600, 3200] } },
121
+ drive(replay) {
122
+ replay.begin(0, 'begin')
123
+ replay.advance()
124
+ replay.active({ t: 16, translation: 0, velocity: 2000 }, 'active-start')
125
+ replay.advance()
126
+ replay.end({ t: 32, translation: 0, velocity: 2000 })
127
+ replay.frames(1)
128
+ },
129
+ },
130
+ // scenarios/gesture.ts:103 — the source judges the release boundary itself, with no tail.
131
+ 'gesture.release-seam': {
132
+ opts: { initial: 0, snap: { points: [0, 1600] } },
133
+ drive(replay) {
134
+ replay.begin(0, 'begin')
135
+ replay.advance()
136
+ replay.active({ t: 16, translation: 30, velocity: 1800 }, 'active-start')
137
+ replay.advance()
138
+ replay.active({ t: 32, translation: 60, velocity: 1800 })
139
+ replay.advance()
140
+ replay.end({ t: 48, translation: 60, velocity: 1800 })
141
+ },
142
+ },
143
+ // scenarios/gesture.ts:187 — retain its authored settle-accounting frame span exactly.
144
+ 'gesture.settle-accounting': {
145
+ opts: { initial: 0, snap: { points: [0, 300] } },
146
+ drive(replay) {
147
+ replay.begin(0, 'begin')
148
+ replay.advance()
149
+ replay.active({ t: 16, translation: 100, velocity: 500 }, 'active-start')
150
+ replay.advance()
151
+ replay.end({ t: 32, translation: 100, velocity: 500 })
152
+ replay.frames(400)
153
+ },
154
+ },
155
+ // scenarios/gesture.ts:223 — no invented active samples while the finger is held still.
156
+ 'gesture.hold-then-release': {
157
+ opts: { initial: 0, snap: { points: [0, 300, 600] } },
158
+ drive(replay) {
159
+ replay.begin(0, 'begin')
160
+ replay.advance()
161
+ replay.active({ t: 16, translation: 250, velocity: 500 }, 'active-start')
162
+ while (replay.frameIndex() < 12) replay.advance()
163
+ replay.end({ t: 200, translation: 250, velocity: 0 })
164
+ replay.frames(300)
165
+ },
166
+ },
167
+ }
168
+
169
+ export function exportGestureTrace(
170
+ sliceId: GestureSliceId,
171
+ options: GestureTraceOptions = {},
172
+ ): {
173
+ rows: TraceRow[]
174
+ eventTimes: TraceEvent[]
175
+ velocityProvenance: VelocityProvenance
176
+ replayedInputs: readonly ReplayedGestureInput[]
177
+ } {
178
+ const script = SCRIPTS[sliceId]
179
+ if (script === undefined) throw new OracleTraceError(sliceId, 'no gesture script registered')
180
+
181
+ const clock = new ManualClock(0)
182
+ const scheduler = new ManualScheduler(clock)
183
+ const graph = createMotionGraph({ clock, scheduler })
184
+ const session = createGestureSession({ graph, ...script.opts })
185
+ const sampleEngineVelocity = options.sampleEngineVelocity ?? ((s) => s.velocity())
186
+ let replay: GestureReplay | undefined
187
+
188
+ const { rows, eventTimes } = runFrameSweep(
189
+ sliceId,
190
+ scheduler,
191
+ {
192
+ sample: (t, previous) => {
193
+ const value = session.value()
194
+ // A recognizer sample is an input, never an observation. While the finger is down the
195
+ // exported value is the only observable series, so derive its velocity from adjacent rows.
196
+ const velocity =
197
+ session.state() === 'active'
198
+ ? finiteDiffVelocity(previous, { t, value }, sliceId)
199
+ : sampleEngineVelocity(session)
200
+ return { value, velocity }
201
+ },
202
+ isSettled: () => session.isSettled(),
203
+ },
204
+ (api) => {
205
+ replay = new GestureReplay(session, scheduler, api)
206
+ script.drive(replay)
207
+ },
208
+ )
209
+
210
+ if (replay === undefined) throw new OracleTraceError(sliceId, 'script did not initialize replay')
211
+ // Gesture traces include finite-difference rows while active; animation rows remain direct
212
+ // engine samples. `derived` makes that presentation-only active-segment derivation explicit.
213
+ return { rows, eventTimes, velocityProvenance: 'derived', replayedInputs: replay.inputs }
214
+ }
215
+
216
+ export function isGestureSliceId(id: string): id is GestureSliceId {
217
+ return Object.hasOwn(SCRIPTS, id)
218
+ }
@@ -0,0 +1,91 @@
1
+ // Canonical byte-stable serialization for oracle traces. Stable top-level and nested key order
2
+ // so two independent export runs compare equal as strings (determinism gate).
3
+
4
+ import { gridTime, type OracleTrace, type TraceEvent, type TraceRow } from './types'
5
+ import { OracleTraceError } from './errors'
6
+
7
+ function assertFinite(
8
+ sliceId: string,
9
+ field: string,
10
+ value: number,
11
+ index: number,
12
+ collection = 'row',
13
+ ): void {
14
+ if (!Number.isFinite(value)) {
15
+ throw new OracleTraceError(
16
+ sliceId,
17
+ `non-finite ${field} at ${collection} ${index}: ${String(value)}`,
18
+ )
19
+ }
20
+ }
21
+
22
+ function validateRows(trace: OracleTrace): void {
23
+ if (trace.rows.length === 0) {
24
+ throw new OracleTraceError(trace.scenarioId, 'trace has no grid rows')
25
+ }
26
+ for (let index = 0; index < trace.rows.length; index += 1) {
27
+ if (!(index in trace.rows)) {
28
+ throw new OracleTraceError(trace.scenarioId, `sparse grid row at index ${index}`)
29
+ }
30
+ const row = trace.rows[index]
31
+ if (row === null || typeof row !== 'object') {
32
+ throw new OracleTraceError(trace.scenarioId, `invalid grid row at index ${index}`)
33
+ }
34
+ assertFinite(trace.scenarioId, 'timestamp', row.t, index)
35
+ assertFinite(trace.scenarioId, 'value', row.value, index)
36
+ assertFinite(trace.scenarioId, 'velocity', row.velocity, index)
37
+ const expected = gridTime(index)
38
+ if (row.t !== expected) {
39
+ throw new OracleTraceError(
40
+ trace.scenarioId,
41
+ `fixed-60fps grid timestamp ${row.t} does not equal ${expected} at row ${index}`,
42
+ )
43
+ }
44
+ }
45
+ }
46
+
47
+ function validateEventTimes(trace: OracleTrace): void {
48
+ for (let index = 0; index < trace.eventTimes.length; index += 1) {
49
+ if (!(index in trace.eventTimes)) {
50
+ throw new OracleTraceError(trace.scenarioId, `sparse event mark at index ${index}`)
51
+ }
52
+ const event = trace.eventTimes[index]
53
+ if (event === null || typeof event !== 'object') {
54
+ throw new OracleTraceError(trace.scenarioId, `invalid event mark at index ${index}`)
55
+ }
56
+ assertFinite(trace.scenarioId, 'eventTimes.t', event.t, index, 'event')
57
+ if (!trace.rows.some((row) => row.t === event.t)) {
58
+ throw new OracleTraceError(
59
+ trace.scenarioId,
60
+ `event mark at ${event.t} is outside the fixed-60fps grid`,
61
+ )
62
+ }
63
+ }
64
+ }
65
+
66
+ function serializeRow(row: TraceRow): string {
67
+ // Fixed key order: t, value, velocity
68
+ return `{"t":${JSON.stringify(row.t)},"value":${JSON.stringify(row.value)},"velocity":${JSON.stringify(row.velocity)}}`
69
+ }
70
+
71
+ function serializeEvent(ev: TraceEvent): string {
72
+ return `{"t":${JSON.stringify(ev.t)},"kind":${JSON.stringify(ev.kind)}}`
73
+ }
74
+
75
+ /** Canonical JSON: fixed key order at every level. Not `JSON.stringify` of a plain object. */
76
+ export function serializeTrace(trace: OracleTrace): string {
77
+ validateRows(trace)
78
+ validateEventTimes(trace)
79
+ const rows = `[${trace.rows.map(serializeRow).join(',')}]`
80
+ const events = `[${trace.eventTimes.map(serializeEvent).join(',')}]`
81
+ return (
82
+ `{` +
83
+ `"scenarioId":${JSON.stringify(trace.scenarioId)},` +
84
+ `"engine":${JSON.stringify(trace.engine)},` +
85
+ `"grid":${JSON.stringify(trace.grid)},` +
86
+ `"rows":${rows},` +
87
+ `"eventTimes":${events},` +
88
+ `"velocityProvenance":${JSON.stringify(trace.velocityProvenance)}` +
89
+ `}`
90
+ )
91
+ }
@@ -0,0 +1,155 @@
1
+ // Fixed-60fps frame sweep under ManualClock + ManualScheduler. Bounded budget; fails loud.
2
+ // Protocol: apply inputs at the current grid time → sample once → advance to the next frame.
3
+
4
+ import type { ManualScheduler } from '@unrulysystems/native-motion-core'
5
+ import { OracleTraceError } from './errors'
6
+ import { ORACLE_FRAME_MS, gridTime, type TraceEvent, type TraceRow } from './types'
7
+
8
+ /** Max frames for any subjective-slice sweep (~33s at 60fps) — fail loud past this. */
9
+ export const ORACLE_MAX_FRAMES = 2000
10
+
11
+ /** Frames to keep after settle becomes true (short tail for the judge). */
12
+ export const ORACLE_SETTLE_TAIL_FRAMES = 6
13
+
14
+ export interface SweepSample {
15
+ readonly value: number
16
+ readonly velocity: number
17
+ }
18
+
19
+ /**
20
+ * Run a scripted interaction that records events via `mark`, samples via `sample`, and
21
+ * advances via `advance`. After the script, call `settleSweep` to frame through settle + tail.
22
+ */
23
+ export function runFrameSweep(
24
+ sliceId: string,
25
+ scheduler: ManualScheduler,
26
+ driver: {
27
+ sample: (t: number, previous: TraceRow | undefined) => SweepSample
28
+ isSettled: () => boolean
29
+ },
30
+ script: (api: SweepApi) => void,
31
+ ): { rows: TraceRow[]; eventTimes: TraceEvent[] } {
32
+ const rows: TraceRow[] = []
33
+ const eventTimes: TraceEvent[] = []
34
+ let frameIndex = 0
35
+ let settledAtFrame: number | undefined
36
+ /** True once a row has been written for the current frameIndex (one sample per grid tick). */
37
+ let sampledThisFrame = false
38
+
39
+ const record = (): void => {
40
+ // A7: gesture-active velocity is the finite difference of ADJACENT exported values, spanning
41
+ // every seam — a re-grab whose value continues reads its real motion, and a genuine value
42
+ // jump stays VISIBLE (observe-never-echo) rather than papered over by a fabricated 0.
43
+ const previous = rows.at(sampledThisFrame ? -2 : -1)
44
+ if (sampledThisFrame) {
45
+ // Overwrite the same-frame sample after late inputs on that tick.
46
+ const s = driver.sample(gridTime(frameIndex), previous)
47
+ rows[rows.length - 1] = { t: gridTime(frameIndex), value: s.value, velocity: s.velocity }
48
+ return
49
+ }
50
+ const s = driver.sample(gridTime(frameIndex), previous)
51
+ rows.push({ t: gridTime(frameIndex), value: s.value, velocity: s.velocity })
52
+ sampledThisFrame = true
53
+ }
54
+
55
+ const noteSettle = (): void => {
56
+ if (driver.isSettled()) {
57
+ if (settledAtFrame === undefined) settledAtFrame = frameIndex
58
+ } else {
59
+ settledAtFrame = undefined
60
+ }
61
+ }
62
+
63
+ const api: SweepApi = {
64
+ frameIndex: () => frameIndex,
65
+ now: () => gridTime(frameIndex),
66
+ mark(kind) {
67
+ eventTimes.push({ t: gridTime(frameIndex), kind })
68
+ },
69
+ sample() {
70
+ record()
71
+ noteSettle()
72
+ },
73
+ sampleBeforeBoundary() {
74
+ // A later input on this tick must not overwrite the last running-engine observation.
75
+ if (!sampledThisFrame) {
76
+ record()
77
+ noteSettle()
78
+ }
79
+ },
80
+ advance() {
81
+ if (frameIndex >= ORACLE_MAX_FRAMES) {
82
+ throw new OracleTraceError(
83
+ sliceId,
84
+ `sweep exceeded frame budget (${ORACLE_MAX_FRAMES}) before settle`,
85
+ )
86
+ }
87
+ if (!sampledThisFrame) {
88
+ // Ensure every frame the script leaves has a sample before advancing.
89
+ record()
90
+ noteSettle()
91
+ }
92
+ scheduler.frame(ORACLE_FRAME_MS)
93
+ frameIndex += 1
94
+ sampledThisFrame = false
95
+ },
96
+ settleSweep() {
97
+ // Ensure the post-script frame is sampled before the settle loop.
98
+ if (!sampledThisFrame) {
99
+ record()
100
+ noteSettle()
101
+ }
102
+ while (true) {
103
+ if (
104
+ settledAtFrame !== undefined &&
105
+ frameIndex - settledAtFrame >= ORACLE_SETTLE_TAIL_FRAMES
106
+ ) {
107
+ break
108
+ }
109
+ if (frameIndex >= ORACLE_MAX_FRAMES) {
110
+ throw new OracleTraceError(
111
+ sliceId,
112
+ `sweep exceeded frame budget (${ORACLE_MAX_FRAMES}) before settle`,
113
+ )
114
+ }
115
+ // Advance and sample the new frame.
116
+ scheduler.frame(ORACLE_FRAME_MS)
117
+ frameIndex += 1
118
+ sampledThisFrame = false
119
+ record()
120
+ noteSettle()
121
+ }
122
+ },
123
+ }
124
+
125
+ script(api)
126
+
127
+ if (rows.length === 0) {
128
+ throw new OracleTraceError(sliceId, 'empty sweep — script produced no rows')
129
+ }
130
+ if (eventTimes.length === 0) {
131
+ throw new OracleTraceError(
132
+ sliceId,
133
+ 'empty eventTimes — script marked no interaction boundaries',
134
+ )
135
+ }
136
+
137
+ return { rows, eventTimes }
138
+ }
139
+
140
+ export interface SweepApi {
141
+ /** Current 0-based frame index. */
142
+ frameIndex: () => number
143
+ /** Absolute grid time (ms) at the current frame. */
144
+ now: () => number
145
+ /** Record an interaction boundary at the current grid time. */
146
+ mark: (kind: TraceEvent['kind']) => void
147
+ /** Sample value+velocity at the current grid time (once per frame; re-sample overwrites). */
148
+ sample: () => void
149
+ /** Ensure the current frame holds its pre-boundary observation without overwriting it later. */
150
+ sampleBeforeBoundary: () => void
151
+ /** Advance one 60fps frame (auto-samples the left frame if the script forgot). */
152
+ advance: () => void
153
+ /** Frame-sweep until settled + tail (fail loud on budget). */
154
+ settleSweep: () => void
155
+ }
@@ -0,0 +1,76 @@
1
+ // Oracle M1 trajectory trace schema (REQ-ORACLE-004, M2.7 phase 1).
2
+ // Unit-honest public units only: t in ms, value in the animated axis units at px scale,
3
+ // velocity in value-units per second. No internal-unit leaks.
4
+
5
+ /** Rhymes with `TrajectoryPoint` in adapter.ts — one dense sample on the fixed grid. */
6
+ export interface TraceRow {
7
+ readonly t: number
8
+ readonly value: number
9
+ readonly velocity: number
10
+ }
11
+
12
+ /** Interaction boundary mark for the §5 prompt's `{{event_times}}` slot. */
13
+ export type TraceEventKind = 'begin' | 'active-start' | 'animation-start' | 'release' | 'interrupt'
14
+
15
+ export interface TraceEvent {
16
+ readonly t: number
17
+ readonly kind: TraceEventKind
18
+ }
19
+
20
+ /** Engine that produced the trace (presenter strips this before the judge sees it). */
21
+ export const ORACLE_ENGINES = ['native-core', 'motion-dom'] as const
22
+ export type OracleEngine = (typeof ORACLE_ENGINES)[number]
23
+
24
+ export function isOracleEngine(engine: string): engine is OracleEngine {
25
+ return (ORACLE_ENGINES as readonly string[]).includes(engine)
26
+ }
27
+
28
+ /**
29
+ * Velocity column provenance (adjudication A7).
30
+ * - `engine`: every row is sampled from the engine/generator.
31
+ * - `derived`: gesture-active rows are finite-difference observations of the exported value
32
+ * series; release, interruption, and settle rows remain sampled from the engine/generator.
33
+ */
34
+ export type VelocityProvenance = 'engine' | 'derived'
35
+
36
+ export interface OracleTrace {
37
+ readonly scenarioId: string
38
+ readonly engine: OracleEngine
39
+ readonly grid: 'fixed-60fps'
40
+ readonly rows: readonly TraceRow[]
41
+ readonly eventTimes: readonly TraceEvent[]
42
+ readonly velocityProvenance: VelocityProvenance
43
+ }
44
+
45
+ /**
46
+ * An exported trace is the only valid calibration-control source. The phantom symbol prevents a
47
+ * planted control from being structurally reused as input to another injector.
48
+ */
49
+ declare const oracleRealTraceBrand: unique symbol
50
+ export type OracleRealTrace = OracleTrace & {
51
+ readonly [oracleRealTraceBrand]: 'OracleRealTrace'
52
+ }
53
+
54
+ /** The M1 trajectory slices: five native gesture replays plus the dual-engine SHEET-01 spring. */
55
+ export const SUBJECTIVE_SLICE_IDS = [
56
+ 'gesture.grab-during-animation',
57
+ 'gesture.velocity-handoff',
58
+ 'gesture.release-seam',
59
+ 'gesture.settle-accounting',
60
+ 'gesture.hold-then-release',
61
+ 'sheet.open-to-snap',
62
+ ] as const
63
+
64
+ export type SubjectiveSliceId = (typeof SUBJECTIVE_SLICE_IDS)[number]
65
+
66
+ export function isSubjectiveSliceId(id: string): id is SubjectiveSliceId {
67
+ return (SUBJECTIVE_SLICE_IDS as readonly string[]).includes(id)
68
+ }
69
+
70
+ /** Fixed 60 fps grid step (ms). Matches Motion's 60 fps golden grid (1000/60). */
71
+ export const ORACLE_FRAME_MS = 1000 / 60
72
+
73
+ /** Absolute grid time for frame index `i` (stable 6-decimal rounding). */
74
+ export function gridTime(frameIndex: number): number {
75
+ return Number((frameIndex * ORACLE_FRAME_MS).toFixed(6))
76
+ }