@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,44 @@
1
+ // Presentation-only velocity helpers (adjudication A7). Finite-difference derivation never
2
+ // feeds back into any engine.
3
+
4
+ import type { TraceRow } from './types'
5
+ import { ORACLE_FRAME_MS } from './types'
6
+ import { OracleTraceError } from './errors'
7
+
8
+ /**
9
+ * Finite-difference velocity (value-units/s) from consecutive samples.
10
+ * First sample uses 0 (no prior). dt is wall-clock ms between samples.
11
+ */
12
+ export function finiteDiffVelocity(
13
+ prev: { t: number; value: number } | undefined,
14
+ cur: { t: number; value: number },
15
+ sliceId = 'derived-velocity',
16
+ ): number {
17
+ if (prev === undefined) return 0
18
+ const dt = cur.t - prev.t
19
+ if (dt <= 0) {
20
+ throw new OracleTraceError(
21
+ sliceId,
22
+ `non-monotone grid timestamps ${prev.t} -> ${cur.t} while deriving velocity`,
23
+ )
24
+ }
25
+ return ((cur.value - prev.value) / dt) * 1000
26
+ }
27
+
28
+ /** Rebuild a rows series with derived velocity from the value column (presentation only). */
29
+ export function deriveVelocitySeries(
30
+ rows: readonly { readonly t: number; readonly value: number }[],
31
+ sliceId = 'derived-velocity',
32
+ ): readonly TraceRow[] {
33
+ let prev: { t: number; value: number } | undefined
34
+ return rows.map((r) => {
35
+ const velocity = finiteDiffVelocity(prev, r, sliceId)
36
+ prev = { t: r.t, value: r.value }
37
+ return { t: r.t, value: r.value, velocity }
38
+ })
39
+ }
40
+
41
+ /** Default dt for a single frame step when only one sample exists. */
42
+ export function frameDtMs(): number {
43
+ return ORACLE_FRAME_MS
44
+ }
package/src/parity.ts ADDED
@@ -0,0 +1,136 @@
1
+ // Cross-engine parity seam (REQ-CONFORM-002) — ADDITIVE. The M1 fake-host suites record
2
+ // `*.cross-engine-parity` unassertable because no second engine existed. Per the two-class contract
3
+ // (SPEC-CONFORMANCE §3, ratified Option A) this module discharges them TWO ways: (1) SCALAR-COMPARABLE
4
+ // rows are OWNED in-suite — a real second engine produces a ScenarioResult under the SAME scenario id
5
+ // and the aggregator's ownership rule (`unassertable` + a `pass` owner → covered) flips it without
6
+ // touching any scenario (e.g. gesture, via `runGestureParityScenario`). (2) EXTERNAL-EVIDENCE-ONLY rows
7
+ // stay `unassertable` and carry a frozen COUNTERPART record naming the altitude that asserts them
8
+ // (`LAYOUT_PARITY_COUNTERPART` / `LAYOUT_IDENTITY_PARITY_COUNTERPART` → the Playwright Chromium gate) —
9
+ // never an in-suite pass. The web engine is injected as a narrow interface so this package stays free of
10
+ // react/jsdom/motion-react dependencies (REQ-WEB-017: the engine never grades itself).
11
+
12
+ import {
13
+ ManualClock,
14
+ ManualScheduler,
15
+ constants,
16
+ createGestureSession,
17
+ createMotionGraph,
18
+ } from '@unrulysystems/native-motion-core'
19
+ import type { Reading, ScenarioResult } from './runner'
20
+
21
+ export const GESTURE_PARITY_SCENARIO_ID = 'gesture.cross-engine-parity'
22
+ export const LAYOUT_PARITY_SCENARIO_ID = 'layout.cross-engine-parity'
23
+
24
+ // One release case: the finger's story (origin, where it let go, how fast) plus the snap set.
25
+ // Same semantics on both engines: value = origin + translation at release; the platform velocity
26
+ // enters the projection UNSCALED (§G).
27
+ export interface GestureParityCase {
28
+ readonly origin: number
29
+ readonly releaseTranslation: number
30
+ readonly releaseVelocity: number
31
+ readonly snapPoints: readonly number[]
32
+ }
33
+
34
+ // The injected second engine: given a release case, run the WEB release pipeline (inertia +
35
+ // modifyTarget nearest-snap) to settle and report the landing value.
36
+ export interface GestureWebEngine {
37
+ readonly id: string
38
+ releaseEndpoint(c: GestureParityCase): number
39
+ }
40
+
41
+ // Both engines project with Motion's `power · velocity` model (core pins PROJECTION_POWER from
42
+ // the same motion-dom@12.42.2 source), so each case has ONE correct landing snap. The rows cover
43
+ // a still release, a fling past the nearest point, and a negative-direction fling.
44
+ export const GESTURE_PARITY_CASES: readonly GestureParityCase[] = [
45
+ { origin: 0, releaseTranslation: 250, releaseVelocity: 0, snapPoints: [0, 300, 600] },
46
+ { origin: 0, releaseTranslation: 250, releaseVelocity: 500, snapPoints: [0, 300, 600] },
47
+ { origin: 0, releaseTranslation: 100, releaseVelocity: -400, snapPoints: [0, 300, 600] },
48
+ // FLAG 5a contention parity (REQ-DRIVER-026): a mid-drag `animate` drifts the LIVE value PAST the
49
+ // [0,240] snap midpoint (120) before a zero-velocity release. Both engines project from the DRIFTED
50
+ // origin (145) — reference project-then-snap, web motion-dom `inertia` + `modifyTarget` — to the FAR
51
+ // snap `240`. The finger-frozen origin (110, the pre-fix skip) would select the NEAR snap `0`; the
52
+ // contending drift past the midpoint is exactly what flips it. The DRIFT itself is Motion's own
53
+ // shared-value behavior (pinned oracle, `MotionValue.set` does not stop the animation); this row is
54
+ // the cross-engine proof of the release-PROJECTION from the drifted value.
55
+ { origin: 0, releaseTranslation: 145, releaseVelocity: 0, snapPoints: [0, 240] },
56
+ ]
57
+
58
+ // Endpoint tolerance: the reference settles within core's trajectory epsilon of the snap point
59
+ // (EPS_TRAJ) and Motion's inertia stops within its restDelta (0.5) — additive worst case. Not
60
+ // loop-tunable (REQ-CONFORM-014); a bigger gap is a real divergence, recorded, never widened over.
61
+ const MOTION_INERTIA_REST_DELTA = 0.5
62
+ export const GESTURE_ENDPOINT_BAND = constants.EPS_TRAJ + MOTION_INERTIA_REST_DELTA
63
+
64
+ // The reference engine's landing value: core gesture session — begin, one active sample, release
65
+ // with the platform velocity, then frames to settle (the release spring runs to true settle).
66
+ export function referenceReleaseEndpoint(c: GestureParityCase): number {
67
+ const clock = new ManualClock(0)
68
+ const scheduler = new ManualScheduler(clock)
69
+ const graph = createMotionGraph({ clock, scheduler })
70
+ const session = createGestureSession({
71
+ graph,
72
+ initial: c.origin,
73
+ snap: { points: [...c.snapPoints] },
74
+ })
75
+ session.begin(0)
76
+ session.active({ t: 16, translation: c.releaseTranslation, velocity: c.releaseVelocity })
77
+ session.end({ t: 32, translation: c.releaseTranslation, velocity: c.releaseVelocity })
78
+ scheduler.frames(600, 16.67) // ~10s of frames — far past settle for these springs
79
+ return session.value()
80
+ }
81
+
82
+ // Run the parity scenario with the injected web engine. Fail-closed: every case must land within
83
+ // the band on BOTH engines' agreed value; any gap fails with the exact numbers in the detail.
84
+ export function runGestureParityScenario(web: GestureWebEngine): ScenarioResult {
85
+ const readings: Reading[] = GESTURE_PARITY_CASES.map((c, i) => {
86
+ const expected = referenceReleaseEndpoint(c)
87
+ const actual = web.releaseEndpoint(c)
88
+ return {
89
+ at: i,
90
+ kind: 'value',
91
+ expected,
92
+ actual,
93
+ pass: Math.abs(actual - expected) <= GESTURE_ENDPOINT_BAND,
94
+ }
95
+ })
96
+ const failures = readings.filter((r) => !r.pass)
97
+ return {
98
+ id: GESTURE_PARITY_SCENARIO_ID,
99
+ engine: web.id,
100
+ verdict: failures.length === 0 ? 'pass' : 'fail',
101
+ readings,
102
+ ...(failures.length > 0
103
+ ? {
104
+ detail: failures
105
+ .map(
106
+ (f) =>
107
+ `case ${f.at}: web landed at ${String(f.actual)}, reference at ` +
108
+ `${String(f.expected)} (band ${GESTURE_ENDPOINT_BAND})`,
109
+ )
110
+ .join('; '),
111
+ }
112
+ : {}),
113
+ }
114
+ }
115
+
116
+ // Layout parity cannot be asserted under jsdom (no layout engine — onLayout rects would be
117
+ // fiction). The declared counterpart is the Altitude-5 Playwright Chromium run (REQ-CONFORM-002
118
+ // fail-closed accounting: unassertable-here must carry the altitude where it IS assertable).
119
+ export const LAYOUT_PARITY_COUNTERPART = Object.freeze({
120
+ scenarioId: LAYOUT_PARITY_SCENARIO_ID,
121
+ altitude: 'alt5-playwright-chromium',
122
+ reason: 'jsdom has no layout engine; FLIP geometry is asserted in the real-browser gate',
123
+ })
124
+
125
+ // layoutId crossfade parity cannot execute on the fake host (no second engine there). The
126
+ // declared counterpart is the Altitude-5 Playwright Chromium run
127
+ // (`packages/native-motion-web/e2e/layout-identity-parity.e2e.ts`): real motion/react keeps two
128
+ // components alive through one layoutId exchange and the spec asserts the L2 registry's laws —
129
+ // both-travel crossfade, shared-visual-rect coincidence, exact endpoints (REQ-CONFORM-011).
130
+ export const LAYOUT_IDENTITY_PARITY_COUNTERPART = Object.freeze({
131
+ scenarioId: 'layout-identity.cross-engine-parity',
132
+ altitude: 'alt5-playwright-chromium',
133
+ reason:
134
+ 'the fake host has no second engine; the crossfade laws are asserted against real ' +
135
+ 'motion/react in the real-browser gate',
136
+ })
package/src/runner.ts ADDED
@@ -0,0 +1,168 @@
1
+ // The scenario runner: drives a scenario's timeline on one engine and computes the verdict. The
2
+ // runner — never the scenario author — decides pass/fail/unassertable (REQ-CONFORM-011). Accounting
3
+ // is three-valued and fail-closed: a required capability the engine lacks is `unassertable` (a routed
4
+ // obligation, not a silent pass); any failed invariant is `fail`.
5
+
6
+ import type { EngineAdapter } from './adapter'
7
+ import type {
8
+ MotionScalar,
9
+ Scenario,
10
+ SpringScenario,
11
+ ToleranceBand,
12
+ ValueTypeScenario,
13
+ } from './scenario'
14
+
15
+ export type Verdict = 'pass' | 'fail' | 'unassertable'
16
+
17
+ // One invariant evaluated against one engine: what the scenario expected, what the engine produced,
18
+ // and whether it fell within tolerance. Retained on the result so the cross-engine comparator can
19
+ // differentiate the two engines' series without re-running them.
20
+ export interface Reading {
21
+ readonly at: number
22
+ readonly kind: 'velocity' | 'value'
23
+ readonly expected: MotionScalar
24
+ readonly actual: MotionScalar
25
+ readonly pass: boolean
26
+ }
27
+
28
+ export interface ScenarioResult {
29
+ readonly id: string
30
+ readonly engine: string
31
+ readonly verdict: Verdict
32
+ readonly readings: readonly Reading[]
33
+ readonly detail?: string
34
+ }
35
+
36
+ export function runScenario(
37
+ scenario: Scenario,
38
+ adapter: EngineAdapter,
39
+ band: ToleranceBand,
40
+ ): ScenarioResult {
41
+ // Capability gate (REQ-CONFORM-012): an engine missing a required capability cannot assert this
42
+ // scenario. Report `unassertable` — a routed obligation, never a silent skip or pass.
43
+ const missing = scenario.capabilities.filter((c) => !adapter.capabilities.includes(c))
44
+ if (missing.length > 0) {
45
+ return {
46
+ id: scenario.id,
47
+ engine: adapter.id,
48
+ verdict: 'unassertable',
49
+ readings: [],
50
+ detail: `missing capabilities: ${missing.join(', ')}`,
51
+ }
52
+ }
53
+
54
+ const value = adapter.createValue(scenario.initial)
55
+ const readings: Reading[] = []
56
+ // Stable sort by `at`: steps sharing a timestamp keep authored order (input before the assertion
57
+ // that reads it). Each step anchors the engine's clock first, synchronously (motion-dom's
58
+ // `time.set` microtask-clear cannot interleave within one step).
59
+ const steps = [...scenario.timeline].sort((a, b) => a.at - b.at)
60
+ for (const step of steps) {
61
+ value.setTime(step.at)
62
+ if ('set' in step) {
63
+ value.set(step.set)
64
+ } else if ('jump' in step) {
65
+ value.jump(step.jump)
66
+ } else if ('assertVelocity' in step) {
67
+ const actual = value.getVelocity()
68
+ readings.push({
69
+ at: step.at,
70
+ kind: 'velocity',
71
+ expected: step.assertVelocity,
72
+ actual,
73
+ pass: Math.abs(actual - step.assertVelocity) <= band.velocity,
74
+ })
75
+ } else {
76
+ const actual = value.get()
77
+ readings.push({
78
+ at: step.at,
79
+ kind: 'value',
80
+ expected: step.assertValue,
81
+ actual,
82
+ pass: scalarWithin(actual, step.assertValue, band.value),
83
+ })
84
+ }
85
+ }
86
+
87
+ // Fail-closed: a scenario that asserted nothing cannot be a pass.
88
+ const verdict: Verdict = readings.length > 0 && readings.every((r) => r.pass) ? 'pass' : 'fail'
89
+ return { id: scenario.id, engine: adapter.id, verdict, readings }
90
+ }
91
+
92
+ // Run a spring/timing TRAJECTORY scenario on one engine (SPEC-SPRING §5). The engine builds a generator
93
+ // and samples value+velocity on the t-grid; there is no author-supplied expected, so `expected` mirrors
94
+ // `actual` and each per-engine reading passes — the real verdict is the DIFFERENTIAL cross-engine
95
+ // comparison (`compareEngines`) over these readings. An engine lacking `spring-trajectory` is
96
+ // `unassertable` (routed, never a silent pass); an empty grid is fail-closed.
97
+ export function runSpringScenario(
98
+ scenario: SpringScenario,
99
+ adapter: EngineAdapter,
100
+ _band: ToleranceBand,
101
+ ): ScenarioResult {
102
+ const missing = scenario.capabilities.filter((c) => !adapter.capabilities.includes(c))
103
+ if (missing.length > 0 || adapter.sampleTrajectory === undefined) {
104
+ return {
105
+ id: scenario.id,
106
+ engine: adapter.id,
107
+ verdict: 'unassertable',
108
+ readings: [],
109
+ detail: `missing capabilities: ${missing.join(', ') || 'spring-trajectory'}`,
110
+ }
111
+ }
112
+
113
+ const points = adapter.sampleTrajectory(scenario.kind, scenario.spec, scenario.tGrid)
114
+ const readings: Reading[] = []
115
+ for (const p of points) {
116
+ // value THEN velocity per point, identical order on every engine so the comparator zips kinds.
117
+ readings.push({ at: p.t, kind: 'value', expected: p.value, actual: p.value, pass: true })
118
+ readings.push({
119
+ at: p.t,
120
+ kind: 'velocity',
121
+ expected: p.velocity,
122
+ actual: p.velocity,
123
+ pass: true,
124
+ })
125
+ }
126
+ const verdict: Verdict = readings.length > 0 ? 'pass' : 'fail'
127
+ return { id: scenario.id, engine: adapter.id, verdict, readings }
128
+ }
129
+
130
+ // Run a value-type MIX scenario on one engine (SPEC-VALUE-TYPES §5). The engine mixes `from`→`to` at
131
+ // each progress fraction and projects the committed value; as with the spring runner there is no
132
+ // author-supplied expected, so `expected` mirrors `actual` and each per-engine reading passes — the real
133
+ // verdict is the DIFFERENTIAL cross-engine comparison (`compareValueTypeSeries`). An engine lacking
134
+ // `value-type-mix` is `unassertable` (routed, never a silent pass); an empty grid is fail-closed.
135
+ // A `'throw'` divergence scenario is NOT run through here (native would throw) — the divergence test
136
+ // asserts the throw directly.
137
+ export function runValueTypeScenario(
138
+ scenario: ValueTypeScenario,
139
+ adapter: EngineAdapter,
140
+ ): ScenarioResult {
141
+ const missing = scenario.capabilities.filter((c) => !adapter.capabilities.includes(c))
142
+ if (missing.length > 0 || adapter.mixValueType === undefined) {
143
+ return {
144
+ id: scenario.id,
145
+ engine: adapter.id,
146
+ verdict: 'unassertable',
147
+ readings: [],
148
+ detail: `missing capabilities: ${missing.join(', ') || 'value-type-mix'}`,
149
+ }
150
+ }
151
+
152
+ const mixValueType = adapter.mixValueType
153
+ const readings: Reading[] = scenario.grid.map((p) => {
154
+ const actual = mixValueType(scenario.from, scenario.to, p)
155
+ // `at` carries the progress fraction (this altitude has no wall-clock); expected mirrors actual so
156
+ // the per-engine reading passes — the cross-engine comparator owns the verdict.
157
+ return { at: p, kind: 'value', expected: actual, actual, pass: true }
158
+ })
159
+ const verdict: Verdict = readings.length > 0 ? 'pass' : 'fail'
160
+ return { id: scenario.id, engine: adapter.id, verdict, readings }
161
+ }
162
+
163
+ // Scalar equality within a tolerance: numeric within `tol`, otherwise strict equality (non-numeric
164
+ // scalars have no band). Shared with the cross-engine comparator's per-reading check.
165
+ export function scalarWithin(x: MotionScalar, y: MotionScalar, tol: number): boolean {
166
+ if (typeof x === 'number' && typeof y === 'number') return Math.abs(x - y) <= tol
167
+ return x === y
168
+ }
@@ -0,0 +1,179 @@
1
+ // The engine-agnostic scenario format (REQ-CONFORM-010). A scenario is authored ONCE — a timeline of
2
+ // timestamped inputs interleaved with invariant assertions — and executed against every engine. The
3
+ // author declares inputs and invariants; the runner (not the author) computes the verdict
4
+ // (REQ-CONFORM-011). This file is pure data + types: no engine imports, no I/O.
5
+
6
+ // State-level motion values are scalars. Numbers track velocity; non-numeric values must report a
7
+ // velocity of 0 on every engine (REQ-VALUE-010) — the non-numeric parity case is a first-class
8
+ // scenario, so the scalar type is a union rather than `number`.
9
+ export type MotionScalar = number | string
10
+
11
+ // Capabilities an engine PROVIDES; a scenario REQUIRES a subset (REQ-CONFORM-012). The runner runs a
12
+ // scenario on an engine only if the engine provides every required capability; otherwise it reports
13
+ // `unassertable` and routes the obligation to an engine that can (REQ-CONFORM-011). The provisions:
14
+ // - `numeric-velocity` — tracks numeric velocity from timed samples (both state-level engines).
15
+ // - `settle-ledger` — exposes graph settle (native core only; motion-dom's value has no settle).
16
+ // - `geometry` — measures rendered geometry (trajectory altitude; NEITHER state-level
17
+ // engine — a scenario needing it is unassertable here and owned by the M2
18
+ // device/browser harness).
19
+ // M1b's state-level value scenarios need only `numeric-velocity`; the others exist so the three-valued
20
+ // routing (unassertable when a provision is absent) is provable, not theoretical.
21
+ // - `spring-trajectory` — builds an analytic spring/timing generator from a config and samples
22
+ // value+velocity on a t-grid (native core AND motion-dom both provide it;
23
+ // it is the SPEC-SPRING §5 cross-engine altitude).
24
+ // - `value-type-mix` — parses two raw property values, mixes at a progress fraction, and projects
25
+ // the committed value (native core's value-type seam AND motion-dom's `mix`
26
+ // both provide it; the SPEC-VALUE-TYPES §5 cross-engine altitude).
27
+ export type Capability =
28
+ | 'numeric-velocity'
29
+ | 'settle-ledger'
30
+ | 'geometry'
31
+ | 'spring-trajectory'
32
+ | 'value-type-mix'
33
+
34
+ // One timeline entry: either an input applied to the value at time `at`, or an invariant asserted
35
+ // against the value at time `at`. A discriminated union keyed by the present property.
36
+ export type TimelineStep =
37
+ | { readonly at: number; readonly set: MotionScalar }
38
+ | { readonly at: number; readonly jump: MotionScalar }
39
+ | { readonly at: number; readonly assertVelocity: number }
40
+ | { readonly at: number; readonly assertValue: MotionScalar }
41
+
42
+ export interface Scenario {
43
+ readonly id: string
44
+ readonly requirements: readonly string[]
45
+ readonly capabilities: readonly Capability[]
46
+ readonly initial: MotionScalar
47
+ readonly timeline: readonly TimelineStep[]
48
+ }
49
+
50
+ // A spring/timing TRAJECTORY scenario (SPEC-SPRING §5). Unlike a value scenario, there are no
51
+ // author-supplied expected values: the engine builds a generator from `spec` seeded with [from,
52
+ // velocity] and samples value+velocity at each `tGrid` point; the verdict is the DIFFERENTIAL
53
+ // cross-engine comparison (native vs motion-dom) within the ratified spring band. `pendingDecisionD`
54
+ // marks the two known oracle divergences (#1207 overdamped tail, visualDuration-only) where the pinned
55
+ // motion-dom is buggy (pre-plan-030/031) and native is correct — cross-engine parity is fail-closed
56
+ // until the pin is reconciled (SPEC-SPRING §8), never papered over.
57
+ export type SpringKind = 'spring' | 'timing'
58
+
59
+ export interface SpringTrajectorySpec {
60
+ readonly from: number
61
+ readonly to: number
62
+ readonly velocity?: number // incoming public velocity, px/s
63
+ // spring physics OR duration family (resolution precedence is the engine's, REQ-SPRING-006)
64
+ readonly stiffness?: number
65
+ readonly damping?: number
66
+ readonly mass?: number
67
+ readonly duration?: number // ms
68
+ readonly bounce?: number
69
+ readonly visualDuration?: number // seconds
70
+ // timing only: cubic-bezier control points or a Motion named easing (REQ-TIMING-001 family-2)
71
+ readonly ease?:
72
+ | readonly [number, number, number, number]
73
+ | 'linear'
74
+ | 'easeIn'
75
+ | 'easeOut'
76
+ | 'easeInOut'
77
+ | 'circIn'
78
+ | 'circOut'
79
+ | 'circInOut'
80
+ | 'backIn'
81
+ | 'backOut'
82
+ | 'backInOut'
83
+ | 'anticipate'
84
+ }
85
+
86
+ export interface SpringScenario {
87
+ readonly id: string
88
+ readonly requirements: readonly string[]
89
+ readonly capabilities: readonly Capability[] // ['spring-trajectory']
90
+ readonly kind: SpringKind
91
+ readonly spec: SpringTrajectorySpec
92
+ readonly tGrid: readonly number[]
93
+ readonly pendingDecisionD?: boolean
94
+ }
95
+
96
+ // A value-type MIX scenario (SPEC-VALUE-TYPES §5). The engine parses `from`/`to`, mixes at each
97
+ // progress fraction in `grid`, and projects the committed value; the verdict is the DIFFERENTIAL
98
+ // cross-engine comparison (native seam vs motion-dom `mix`) with each typed channel checked within its
99
+ // per-type epsilon (EPS_COLOR_CHANNEL / EPS_LENGTH / …), NOT byte-equality. `divergence` marks the
100
+ // intentional, recorded gaps: `'throw'` — native fails loud on a unit/shape mismatch where motion
101
+ // warns+snaps (REQ-VALUETYPE-004/-007/-008); `'snap'` — native mixes a family motion cannot without a
102
+ // host (named colors, REQ-VALUETYPE-005), so the two engines MUST diverge (a documented capability
103
+ // tripwire, flagged as the §8 named-color-completeness vetoable — never papered over).
104
+ export interface ValueTypeScenario {
105
+ readonly id: string
106
+ readonly requirements: readonly string[]
107
+ readonly capabilities: readonly Capability[] // ['value-type-mix']
108
+ readonly property: string // 'backgroundColor' | 'borderRadius' — informational grouping
109
+ readonly from: string
110
+ readonly to: string
111
+ readonly grid: readonly number[] // progress fractions in [0,1]
112
+ readonly divergence?: 'throw' | 'snap'
113
+ }
114
+
115
+ // The per-property cross-engine tolerance band (REQ-CONFORM-014). Ratified, not loop-tunable: a real
116
+ // divergence is a `fail` recorded as a decision, never a widened band. `velocity` ties to the core's
117
+ // pinned `EPS_VELOCITY`; `value` is exact for state-level scalars (see `config.ts`).
118
+ export interface ToleranceBand {
119
+ readonly velocity: number
120
+ readonly value: number
121
+ }
122
+
123
+ // The core-owned state-level scenarios (M1B-BUILD-PACKET table). Authored engine-agnostically; run
124
+ // against both the native core and pinned motion-dom, then differentially compared. Two steps that
125
+ // share an `at` execute in array order (the runner's sort is stable): inputs precede the assertions
126
+ // that read them.
127
+ export const CORE_SCENARIOS: readonly Scenario[] = [
128
+ {
129
+ // Velocity from the last two timed samples: (8-0)*1000/16 = 500.
130
+ id: 'CORE-V1',
131
+ requirements: ['REQ-VALUE-010'],
132
+ capabilities: ['numeric-velocity'],
133
+ initial: 0,
134
+ timeline: [
135
+ { at: 0, set: 0 },
136
+ { at: 16, set: 8 },
137
+ { at: 16, assertVelocity: 500 },
138
+ ],
139
+ },
140
+ {
141
+ // Staleness: at t=60 the last update (t=16) is 44ms old, past the 30ms window → velocity 0.
142
+ id: 'CORE-V2',
143
+ requirements: ['REQ-VALUE-011'],
144
+ capabilities: ['numeric-velocity'],
145
+ initial: 0,
146
+ timeline: [
147
+ { at: 0, set: 0 },
148
+ { at: 16, set: 8 },
149
+ { at: 60, assertVelocity: 0 },
150
+ ],
151
+ },
152
+ {
153
+ // Teleport resets velocity: jump commits+notifies, ends any active animation (pinned
154
+ // order, REQ-VALUE-010 r16), and reads velocity 0 — no successor seeding.
155
+ id: 'CORE-V3',
156
+ requirements: ['REQ-VALUE-012'],
157
+ capabilities: ['numeric-velocity'],
158
+ initial: 0,
159
+ timeline: [
160
+ { at: 0, set: 0 },
161
+ { at: 16, set: 8 },
162
+ { at: 20, jump: 20 },
163
+ { at: 20, assertValue: 20 },
164
+ { at: 20, assertVelocity: 0 },
165
+ ],
166
+ },
167
+ {
168
+ // Non-numeric: velocity is undefined for strings → 0 on every engine. Requires no capability.
169
+ id: 'CORE-V4',
170
+ requirements: ['REQ-VALUE-010'],
171
+ capabilities: [],
172
+ initial: 'a',
173
+ timeline: [
174
+ { at: 0, set: 'a' },
175
+ { at: 16, set: 'b' },
176
+ { at: 16, assertVelocity: 0 },
177
+ ],
178
+ },
179
+ ]
@@ -0,0 +1,105 @@
1
+ // L4 choreography ordering (specs/SPEC-LAYOUT.md): the trajectory-altitude
2
+ // facts of the App Store card that ARE assertable engine-agnostically, ADDITIVE to the suite.
3
+ // The golden contract's item 3 — "corner radius completes AHEAD of the rect" — is a property of
4
+ // the DRAFT feel constants themselves: the radius channel {900, 60} must settle strictly before
5
+ // the slowest rect channel {550, 45} over the demo-scale deltas, under core's own generators
6
+ // (the native flight's exact math; any engine honoring the constants inherits the ordering).
7
+ // Full composite-flight cross-engine parity is deliberately NOT asserted here: spring-solver
8
+ // parity is already pinned at SPRING altitude against the motion oracle, FLIP geometry at the
9
+ // Chromium layout gate, and the composite aesthetic is the HUMAN-attended golden-frame bar
10
+ // (REQ-ORACLE-005) — recorded unassertable-with-reason below, never silently skipped.
11
+
12
+ import { resolveSpringGenerator } from '@unrulysystems/native-motion-core'
13
+ import type { ScenarioResult } from '../runner'
14
+
15
+ interface Outcome {
16
+ readonly verdict: 'pass' | 'fail' | 'unassertable'
17
+ readonly detail: string
18
+ }
19
+ const pass = (detail: string): Outcome => ({ verdict: 'pass', detail })
20
+ const fail = (detail: string): Outcome => ({ verdict: 'fail', detail })
21
+ const unassertable = (detail: string): Outcome => ({ verdict: 'unassertable', detail })
22
+
23
+ // The L4 draft constants (ratified at the packet review) and demo-scale deltas: radius 24 → 0;
24
+ // the rect's largest channel travels ~200 px (thumb → hero on both engines' demo geometry).
25
+ const RECT_SPRING = { stiffness: 550, damping: 45 }
26
+ const RADIUS_SPRING = { stiffness: 900, damping: 60 }
27
+ const DT = 16.67
28
+ const REST_EPSILON = 0.5 // px-scale rest window, the layout epsilon family
29
+
30
+ function settleFrames(
31
+ from: number,
32
+ to: number,
33
+ spring: { stiffness: number; damping: number },
34
+ ): number {
35
+ const generator = resolveSpringGenerator(to, spring)({ from, velocity: 0 })
36
+ let elapsed = 0
37
+ for (let frame = 1; frame <= 2000; frame += 1) {
38
+ elapsed += DT
39
+ const sample = generator.sample(elapsed)
40
+ if (sample.done) return frame
41
+ if (Math.abs(sample.value - to) < REST_EPSILON && frame > 1) {
42
+ // Conservative: treat first entry into the rest window as visual completion.
43
+ return frame
44
+ }
45
+ }
46
+ return Number.POSITIVE_INFINITY
47
+ }
48
+
49
+ export interface AppstoreChoreographyScenario {
50
+ readonly id: string
51
+ readonly requirements: readonly string[]
52
+ readonly assert: () => Outcome
53
+ }
54
+
55
+ export const APPSTORE_CHOREOGRAPHY_SCENARIOS: readonly AppstoreChoreographyScenario[] = [
56
+ {
57
+ id: 'appstore-choreography.radius-leads-rect',
58
+ requirements: ['REQ-LAYOUT-014', 'REQ-ORACLE-005'],
59
+ assert() {
60
+ const radiusFrames = settleFrames(24, 0, RADIUS_SPRING)
61
+ const rectFrames = settleFrames(0, 200, RECT_SPRING)
62
+ if (!(radiusFrames < rectFrames)) {
63
+ return fail(
64
+ `radius {900,60} settled in ${radiusFrames} frames but the rect {550,45} in ` +
65
+ `${rectFrames} — the golden contract needs the radius to complete AHEAD of the rect`,
66
+ )
67
+ }
68
+ return pass(
69
+ `radius completes ahead: ${radiusFrames} frames vs rect ${rectFrames} at the draft constants`,
70
+ )
71
+ },
72
+ },
73
+ {
74
+ id: 'appstore-choreography.cross-engine-parity',
75
+ requirements: ['REQ-CONFORM-002', 'REQ-ORACLE-005'],
76
+ assert() {
77
+ // Every axis of the composite has a named EXECUTING counterpart (REQ-CONFORM-011):
78
+ // spring feel → the SPRING goldens vs the pinned motion oracle; FLIP geometry →
79
+ // layout.cross-engine-parity in the Chromium gate; the layoutId crossfade →
80
+ // layout-identity-parity.e2e.ts (LAYOUT_IDENTITY_PARITY_COUNTERPART); the composite
81
+ // AESTHETIC → the human-attended golden-frame bar, which REQ-ORACLE-005 defines as the
82
+ // authoritative altitude for visual judgment. A deterministic composite gate would be
83
+ // the pixel-diff proxy the golden README forbids.
84
+ return unassertable(
85
+ 'each axis executes at its own altitude: SPRING goldens (oracle), ' +
86
+ 'layout.cross-engine-parity (Chromium), layout-identity-parity.e2e.ts (Chromium, ' +
87
+ 'LAYOUT_IDENTITY_PARITY_COUNTERPART), and the composite aesthetic at the ' +
88
+ 'REQ-ORACLE-005 human-attended golden-frame altitude',
89
+ )
90
+ },
91
+ },
92
+ ]
93
+
94
+ export function runAppstoreChoreographyScenario(
95
+ scenario: AppstoreChoreographyScenario,
96
+ ): ScenarioResult {
97
+ const outcome = scenario.assert()
98
+ return {
99
+ id: scenario.id,
100
+ engine: 'fake-host',
101
+ verdict: outcome.verdict,
102
+ readings: [],
103
+ detail: outcome.detail,
104
+ }
105
+ }