@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,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
|
+
}
|