@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,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
|
+
}
|
package/src/scenario.ts
ADDED
|
@@ -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
|
+
}
|