@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @unrulysystems/native-motion-conformance
|
|
2
|
+
|
|
3
|
+
## 0.1.0-alpha.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Initial alpha prerelease: the M3-era public surface (layout, presence, drag, gestures, variants,
|
|
8
|
+
orchestration, value types) with the cross-engine conformance suite published alongside the
|
|
9
|
+
runtime packages.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies
|
|
14
|
+
- @unrulysystems/native-motion-core@0.1.0-alpha.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Unruly Systems LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @unrulysystems/native-motion-conformance
|
|
2
|
+
|
|
3
|
+
The cross-engine conformance harness (SPEC-CONFORMANCE, private). One engine-agnostic scenario
|
|
4
|
+
format, authored once, executed against two engines — the native core and the pinned web oracle
|
|
5
|
+
(`motion-dom@12.42.2`, see [SEAMS.md](./SEAMS.md)) — then differentially compared within
|
|
6
|
+
ratified tolerance bands. Its job is to make "the loop can never widen a band or silently skip
|
|
7
|
+
a check" mechanical.
|
|
8
|
+
|
|
9
|
+
## The accounting model (the load-bearing idea)
|
|
10
|
+
|
|
11
|
+
Every scenario run returns one of three verdicts — `pass` / `fail` / `unassertable` — and the
|
|
12
|
+
RUNNER, never the scenario author, decides. `aggregateSuite` (`src/suite.ts`) folds all
|
|
13
|
+
per-(scenario, engine) results fail-closed:
|
|
14
|
+
|
|
15
|
+
- any `fail` → suite fails; an empty result set → suite fails;
|
|
16
|
+
- `unassertable` is a ROUTED obligation, not a skip. Cross-engine-parity rows discharge in one of
|
|
17
|
+
two ways (SPEC-CONFORMANCE §3, ratified Option A — R7 M5):
|
|
18
|
+
- **Scalar-comparable → owned in-suite.** When the comparison reduces to a scalar, a real second
|
|
19
|
+
engine is injected and its SAME-ID `pass` is ADDED via the parity seam (`src/parity.ts`), never
|
|
20
|
+
edited in, so the row flips `unassertable` → owned without touching any scenario.
|
|
21
|
+
`gesture.cross-engine-parity` works this way (native core vs an injected motion/react web engine).
|
|
22
|
+
- **External-evidence-only → stays `unassertable`, discharged elsewhere.** When parity needs a real
|
|
23
|
+
DOM/layout engine, real host components, or composite aesthetic judgment, no in-process engine can
|
|
24
|
+
assert it, so the deterministic suite keeps the row `unassertable` and it is asserted at a NAMED
|
|
25
|
+
external altitude — declared either as a frozen counterpart record (`LAYOUT_PARITY_COUNTERPART` /
|
|
26
|
+
`LAYOUT_IDENTITY_PARITY_COUNTERPART` → the Playwright Chromium gate) or, for
|
|
27
|
+
`CMP-R7-cross-engine-parity`, an owner-naming reason pointing at its executed legs
|
|
28
|
+
(`variants.e2e.ts` + `PROOF.md §R7`), or for `CMP-R8-cross-engine-parity`, its executed Chromium
|
|
29
|
+
web leg (`keyframes.e2e.ts`, 20/20) and executed M5 `PROOF.md §R8` device keyframes card
|
|
30
|
+
(Android real-touch verdicts + measured arcs; iOS AUTO-RUN 13/13 + measured peaks), or for
|
|
31
|
+
`CMP-R9-cross-engine-parity`, its executed Chromium legs
|
|
32
|
+
(`examples.smoke.e2e.ts` + `text-layout.e2e.ts` + `image-gallery.e2e.ts`, 22/22) and
|
|
33
|
+
executed M5 `PROOF.md §R9` device element cards (Android gallery 17/17 including stable
|
|
34
|
+
image-gallery post-fix; iOS AUTO-RUN COMPLETE 17/17 with wallpaper loaded).
|
|
35
|
+
`appstore-choreography.cross-engine-parity` is likewise external (SPRING goldens +
|
|
36
|
+
Chromium). These never flip to an in-suite `pass`; `routed > 0 → FAIL` still binds.
|
|
37
|
+
|
|
38
|
+
## Surface map
|
|
39
|
+
|
|
40
|
+
| Area | Role | Key files |
|
|
41
|
+
| --------------- | ----------------------------------------------------------------------------------- | ------------------------------- |
|
|
42
|
+
| scenario format | Pure-data timelines + invariants + declared capabilities | `scenario.ts`, `scenarios/*.ts` |
|
|
43
|
+
| adapters | Per-engine determinism-fixed wrappers (native core; motion-dom under manual timing) | `adapter.ts`, `adapters/` |
|
|
44
|
+
| runner | Drives one scenario on one engine → three-valued verdict | `runner.ts` |
|
|
45
|
+
| comparator | Pure cross-engine differential | `comparator.ts` |
|
|
46
|
+
| suite | Fail-closed aggregation | `suite.ts` |
|
|
47
|
+
| config | Ratified tolerance bands — never loop-tunable | `config.ts` |
|
|
48
|
+
| authoring | Registry-backed capability gate at author time | `authoring.ts` |
|
|
49
|
+
| parity | The additive cross-engine ownership seam | `parity.ts` |
|
|
50
|
+
|
|
51
|
+
Reader note: `runSpringScenario`/`runValueTypeScenario` mark per-engine readings `pass: true`
|
|
52
|
+
by construction — for those kinds the real verdict is the cross-engine DIFFERENTIAL, not the
|
|
53
|
+
single-engine run (`runner.ts` explains this inline).
|
|
54
|
+
|
|
55
|
+
## Verification
|
|
56
|
+
|
|
57
|
+
113 tests / 17 files: 10 cross-engine conformance suites (L2's `layout-identity` and L4's
|
|
58
|
+
`appstore-choreography` are the ninth and tenth), 5 harness unit suites (including the
|
|
59
|
+
fail-closed accounting proofs), 1 wiring smoke, and `motion-seams.canary.test.ts` — canaries
|
|
60
|
+
that pin the exact motion internals the harness drives, so a `motion` upgrade fails loudly
|
|
61
|
+
BEFORE it can skew a verdict. `scripts/gen-*-goldens.mjs` (not CI gates) regenerate core's
|
|
62
|
+
golden files from the pinned oracle on an intentional bump.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd packages/conformance && bunx vitest run
|
|
66
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unrulysystems/native-motion-conformance",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"files": [
|
|
6
|
+
"src",
|
|
7
|
+
"CHANGELOG.md",
|
|
8
|
+
"!src/**/*.test.*"
|
|
9
|
+
],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts",
|
|
13
|
+
"./adapter": "./src/adapter.ts",
|
|
14
|
+
"./adapters/motion-dom": "./src/adapters/motion-dom.ts",
|
|
15
|
+
"./adapters/native": "./src/adapters/native.ts",
|
|
16
|
+
"./comparator": "./src/comparator.ts",
|
|
17
|
+
"./config": "./src/config.ts",
|
|
18
|
+
"./declarations": "./src/declarations.ts",
|
|
19
|
+
"./runner": "./src/runner.ts",
|
|
20
|
+
"./scenarios/*": "./src/scenarios/*.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@unrulysystems/native-motion-core": "0.1.0-alpha.0",
|
|
27
|
+
"motion-dom": "12.42.2",
|
|
28
|
+
"motion-utils": "12.39.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"vitest": "^4.1.9"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The engine adapter seam (REQ-CONFORM-013). A thin, canary-guarded wrapper that drives ONE engine
|
|
2
|
+
// along a scenario timeline. Each engine gets exactly one determinism mechanism, fixed here and never
|
|
3
|
+
// crossed: the native core uses the injectable `ManualClock`; motion-dom uses
|
|
4
|
+
// `MotionGlobalConfig.useManualTiming` + `time.set`. The runner talks only to this interface, so it
|
|
5
|
+
// stays engine-agnostic.
|
|
6
|
+
|
|
7
|
+
import type { Capability, MotionScalar, SpringKind, SpringTrajectorySpec } from './scenario'
|
|
8
|
+
|
|
9
|
+
// A single value under an engine's control, with the engine's clock exposed as `setTime` so the
|
|
10
|
+
// runner can anchor time before each input/assertion. `getVelocity` is always numeric.
|
|
11
|
+
export interface EngineValue {
|
|
12
|
+
// Anchor the engine's clock to absolute time `tMs` (monotonic, non-decreasing within a scenario).
|
|
13
|
+
setTime(tMs: number): void
|
|
14
|
+
set(v: MotionScalar): void
|
|
15
|
+
jump(v: MotionScalar): void
|
|
16
|
+
get(): MotionScalar
|
|
17
|
+
getVelocity(): number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// One sampled point of a spring/timing trajectory: the engine's value and velocity at time `t`.
|
|
21
|
+
export interface TrajectoryPoint {
|
|
22
|
+
readonly t: number
|
|
23
|
+
readonly value: number
|
|
24
|
+
readonly velocity: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EngineAdapter {
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly capabilities: readonly Capability[]
|
|
30
|
+
createValue(initial: MotionScalar): EngineValue
|
|
31
|
+
// Present iff the engine provides the `spring-trajectory` capability: build a generator from `spec`
|
|
32
|
+
// seeded with [from, velocity] and sample value+velocity at each `tGrid` point (SPEC-SPRING §5).
|
|
33
|
+
sampleTrajectory?(
|
|
34
|
+
kind: SpringKind,
|
|
35
|
+
spec: SpringTrajectorySpec,
|
|
36
|
+
tGrid: readonly number[],
|
|
37
|
+
): readonly TrajectoryPoint[]
|
|
38
|
+
// Present iff the engine provides the `value-type-mix` capability: parse two raw property values, mix
|
|
39
|
+
// at `progress` ∈ [0,1], and project the committed value (SPEC-VALUE-TYPES §5). May throw on an
|
|
40
|
+
// intentional loud failure (unit/shape mismatch) — the runner surfaces that to the divergence test.
|
|
41
|
+
mixValueType?(from: string, to: string, progress: number): MotionScalar
|
|
42
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// motion-dom engine adapter: drives the pinned web reference engine (motion-dom@12.42.2). Determinism
|
|
2
|
+
// for state-level VALUE scenarios has two parts (REQ-CONFORM-013b): (1) PRIMARY — the runner reads
|
|
3
|
+
// synchronously right after each `time.set(t)`, so Motion's `time.now()` returns exactly `t` (the set
|
|
4
|
+
// value persists until a microtask clears it, and the runner never yields mid-scenario); (2) DEFENSE
|
|
5
|
+
// — `useManualTiming = true` so that IF `now` is ever cleared, `time.now()` falls back to the
|
|
6
|
+
// controlled `frameData.timestamp` rather than `performance.now()` (wall time), which would
|
|
7
|
+
// false-stale every velocity. This is Motion's production JS path for velocity/handoffs, so it is
|
|
8
|
+
// state-level authoritative. (Frame stepping — where `useManualTiming` becomes load-bearing on its
|
|
9
|
+
// own — arrives with the animation/spring scenarios, not this state-level slice.)
|
|
10
|
+
|
|
11
|
+
import { motionValue, spring, mix, time } from 'motion-dom'
|
|
12
|
+
import { cubicBezier, easingDefinitionToFunction, MotionGlobalConfig } from 'motion-utils'
|
|
13
|
+
import type { EngineAdapter, EngineValue, TrajectoryPoint } from '../adapter'
|
|
14
|
+
import type { MotionScalar, SpringKind, SpringTrajectorySpec } from '../scenario'
|
|
15
|
+
|
|
16
|
+
// Timing defaults must match the native timing generator (REQ-TIMING-002): ease [0.25,0.1,0.35,1],
|
|
17
|
+
// duration 300ms. Velocity is Motion's 5ms finite-diff of the value; the spring path uses Motion's
|
|
18
|
+
// ANALYTIC gen.velocity(t) — both are the native solver's parity targets from the M1/M3 goldens.
|
|
19
|
+
const DEFAULT_TIMING_EASE = [0.25, 0.1, 0.35, 1] as const
|
|
20
|
+
const DEFAULT_TIMING_DURATION_MS = 300
|
|
21
|
+
const VELOCITY_SAMPLE_MS = 5
|
|
22
|
+
const clamp01 = (v: number): number => Math.max(0, Math.min(1, v))
|
|
23
|
+
|
|
24
|
+
// Set the defense ONCE at module init; never toggled (the `motion-seams.canary` proves it is on).
|
|
25
|
+
MotionGlobalConfig.useManualTiming = true
|
|
26
|
+
|
|
27
|
+
export function motionDomAdapter(): EngineAdapter {
|
|
28
|
+
return {
|
|
29
|
+
id: 'motion-dom',
|
|
30
|
+
capabilities: ['numeric-velocity', 'spring-trajectory', 'value-type-mix'],
|
|
31
|
+
// Motion's 2-arg `mix(from, to)` returns a mixer that dispatches on value family (number/color/
|
|
32
|
+
// complex); call it at `progress` for the committed value (SPEC-VALUE-TYPES §5). Motion never throws
|
|
33
|
+
// here — a unit/shape mismatch warns+snaps, and named colors (no DOM) snap to the target.
|
|
34
|
+
mixValueType(from: string, to: string, progress: number): MotionScalar {
|
|
35
|
+
return mix(from, to)(progress) as MotionScalar
|
|
36
|
+
},
|
|
37
|
+
sampleTrajectory(
|
|
38
|
+
kind: SpringKind,
|
|
39
|
+
spec: SpringTrajectorySpec,
|
|
40
|
+
tGrid: readonly number[],
|
|
41
|
+
): readonly TrajectoryPoint[] {
|
|
42
|
+
if (kind === 'timing') {
|
|
43
|
+
const duration = spec.duration ?? DEFAULT_TIMING_DURATION_MS
|
|
44
|
+
const ease = spec.ease ?? DEFAULT_TIMING_EASE
|
|
45
|
+
// Family-2: named curves (circ*/back*/anticipate/ease*) via motion-utils lookup;
|
|
46
|
+
// cubic tuples still go through cubicBezier (REQ-TIMING-001).
|
|
47
|
+
const easeFn =
|
|
48
|
+
typeof ease === 'string'
|
|
49
|
+
? easingDefinitionToFunction(ease)
|
|
50
|
+
: cubicBezier(ease[0], ease[1], ease[2], ease[3])
|
|
51
|
+
const value = (t: number): number => mix(spec.from, spec.to, easeFn(clamp01(t / duration)))
|
|
52
|
+
return tGrid.map((t) => {
|
|
53
|
+
const prevT = Math.max(t - VELOCITY_SAMPLE_MS, 0)
|
|
54
|
+
const dt = t - prevT
|
|
55
|
+
const velocity = dt ? ((value(t) - value(prevT)) / dt) * 1000 : 0
|
|
56
|
+
return { t, value: value(t), velocity }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
const gen = spring({
|
|
60
|
+
keyframes: [spec.from, spec.to],
|
|
61
|
+
velocity: spec.velocity ?? 0,
|
|
62
|
+
...(spec.stiffness !== undefined ? { stiffness: spec.stiffness } : {}),
|
|
63
|
+
...(spec.damping !== undefined ? { damping: spec.damping } : {}),
|
|
64
|
+
...(spec.mass !== undefined ? { mass: spec.mass } : {}),
|
|
65
|
+
...(spec.duration !== undefined ? { duration: spec.duration } : {}),
|
|
66
|
+
...(spec.bounce !== undefined ? { bounce: spec.bounce } : {}),
|
|
67
|
+
...(spec.visualDuration !== undefined ? { visualDuration: spec.visualDuration } : {}),
|
|
68
|
+
})
|
|
69
|
+
// gen.velocity is Motion's ANALYTIC generator velocity (px/s); typed optional, always present
|
|
70
|
+
// for a physics spring.
|
|
71
|
+
return tGrid.map((t) => ({ t, value: gen.next(t).value, velocity: gen.velocity?.(t) ?? 0 }))
|
|
72
|
+
},
|
|
73
|
+
createValue(initial: MotionScalar): EngineValue {
|
|
74
|
+
const value = motionValue(initial)
|
|
75
|
+
return {
|
|
76
|
+
// `time.set` schedules a `queueMicrotask(clearTime)`; the runner reads synchronously right
|
|
77
|
+
// after, before the microtask can clear the anchored timestamp (see M1B packet gotchas).
|
|
78
|
+
setTime(tMs: number): void {
|
|
79
|
+
time.set(tMs)
|
|
80
|
+
},
|
|
81
|
+
set(v: MotionScalar): void {
|
|
82
|
+
value.set(v)
|
|
83
|
+
},
|
|
84
|
+
jump(v: MotionScalar): void {
|
|
85
|
+
value.jump(v)
|
|
86
|
+
},
|
|
87
|
+
get(): MotionScalar {
|
|
88
|
+
return value.get()
|
|
89
|
+
},
|
|
90
|
+
getVelocity(): number {
|
|
91
|
+
return value.getVelocity()
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Native engine adapter: drives @unrulysystems/native-motion-core (the engine under test) through
|
|
2
|
+
// its PUBLIC surface only — no reach into `MotionValueImpl` internals. Determinism mechanism: the
|
|
3
|
+
// injectable `ManualClock` (REQ-CONFORM-013a). Each value gets its own graph + clock so scenarios are
|
|
4
|
+
// isolated and time advances independently.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createMotionGraph,
|
|
8
|
+
ManualClock,
|
|
9
|
+
ManualScheduler,
|
|
10
|
+
mix,
|
|
11
|
+
parseValue,
|
|
12
|
+
resolveSpringGenerator,
|
|
13
|
+
timingGenerator,
|
|
14
|
+
transformValue,
|
|
15
|
+
} from '@unrulysystems/native-motion-core'
|
|
16
|
+
import type { SpringConfig, TimingConfig } from '@unrulysystems/native-motion-core'
|
|
17
|
+
import type { EngineAdapter, EngineValue, TrajectoryPoint } from '../adapter'
|
|
18
|
+
import type { MotionScalar, SpringKind, SpringTrajectorySpec } from '../scenario'
|
|
19
|
+
|
|
20
|
+
export function nativeAdapter(): EngineAdapter {
|
|
21
|
+
return {
|
|
22
|
+
id: 'native',
|
|
23
|
+
// The native core also owns a graph settle ledger (createMotionGraph.whenSettled/isSettled),
|
|
24
|
+
// which motion-dom's value has no analogue for — declared so settle scenarios route to native.
|
|
25
|
+
// `spring-trajectory`: the core's analytic solver/timing generators (SPEC-SPRING §5).
|
|
26
|
+
// `value-type-mix`: the core value-type seam parseValue→mix→transformValue (SPEC-VALUE-TYPES §5).
|
|
27
|
+
capabilities: ['numeric-velocity', 'settle-ledger', 'spring-trajectory', 'value-type-mix'],
|
|
28
|
+
// Parse both raw values, mix at `progress`, project the committed value. Throws on an intentional
|
|
29
|
+
// loud failure (per-slot unit mismatch, complex-shape mismatch) — surfaced to the divergence test.
|
|
30
|
+
mixValueType(from: string, to: string, progress: number): MotionScalar {
|
|
31
|
+
return transformValue(mix(parseValue(from), parseValue(to), progress))
|
|
32
|
+
},
|
|
33
|
+
sampleTrajectory(
|
|
34
|
+
kind: SpringKind,
|
|
35
|
+
spec: SpringTrajectorySpec,
|
|
36
|
+
tGrid: readonly number[],
|
|
37
|
+
): readonly TrajectoryPoint[] {
|
|
38
|
+
const seed = { from: spec.from, velocity: spec.velocity ?? 0 }
|
|
39
|
+
const factory =
|
|
40
|
+
kind === 'timing'
|
|
41
|
+
? timingGenerator(spec.to, timingConfigOf(spec))
|
|
42
|
+
: resolveSpringGenerator(spec.to, springConfigOf(spec))
|
|
43
|
+
const gen = factory(seed)
|
|
44
|
+
return tGrid.map((t) => {
|
|
45
|
+
const s = gen.sample(t)
|
|
46
|
+
return { t, value: s.value, velocity: s.velocity }
|
|
47
|
+
})
|
|
48
|
+
},
|
|
49
|
+
createValue(initial: MotionScalar): EngineValue {
|
|
50
|
+
const clock = new ManualClock()
|
|
51
|
+
const scheduler = new ManualScheduler(clock)
|
|
52
|
+
const graph = createMotionGraph({ clock, scheduler })
|
|
53
|
+
const value = graph.motionValue<MotionScalar>(initial)
|
|
54
|
+
return {
|
|
55
|
+
// Absolute-time anchor: the injectable clock only moves on advance, so step to `tMs`.
|
|
56
|
+
setTime(tMs: number): void {
|
|
57
|
+
clock.advance(tMs - clock.now())
|
|
58
|
+
},
|
|
59
|
+
set(v: MotionScalar): void {
|
|
60
|
+
value.set(v)
|
|
61
|
+
},
|
|
62
|
+
jump(v: MotionScalar): void {
|
|
63
|
+
value.jump(v)
|
|
64
|
+
},
|
|
65
|
+
get(): MotionScalar {
|
|
66
|
+
return value.get()
|
|
67
|
+
},
|
|
68
|
+
getVelocity(): number {
|
|
69
|
+
return value.getVelocity()
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build a core SpringConfig / TimingConfig from a scenario spec, omitting undefined keys so
|
|
77
|
+
// resolution precedence (REQ-SPRING-006) sees only the keys the scenario actually set
|
|
78
|
+
// (`exactOptionalPropertyTypes` also forbids passing explicit `undefined`).
|
|
79
|
+
function springConfigOf(spec: SpringTrajectorySpec): SpringConfig {
|
|
80
|
+
const c: SpringConfig = {}
|
|
81
|
+
if (spec.stiffness !== undefined) c.stiffness = spec.stiffness
|
|
82
|
+
if (spec.damping !== undefined) c.damping = spec.damping
|
|
83
|
+
if (spec.mass !== undefined) c.mass = spec.mass
|
|
84
|
+
if (spec.duration !== undefined) c.duration = spec.duration
|
|
85
|
+
if (spec.bounce !== undefined) c.bounce = spec.bounce
|
|
86
|
+
if (spec.visualDuration !== undefined) c.visualDuration = spec.visualDuration
|
|
87
|
+
return c
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function timingConfigOf(spec: SpringTrajectorySpec): TimingConfig {
|
|
91
|
+
const c: TimingConfig = {}
|
|
92
|
+
if (spec.duration !== undefined) c.duration = spec.duration
|
|
93
|
+
if (spec.ease !== undefined) c.ease = spec.ease
|
|
94
|
+
return c
|
|
95
|
+
}
|
package/src/authoring.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPEC-UNIVERSAL-SUBSET §5.5 — the conformance authoring gate (REQ-SUBSET-018). The scenario author
|
|
2
|
+
// consumes the frozen universal-subset registry as its capability whitelist: every capability a scenario
|
|
3
|
+
// references must be a registry member (else it is rejected as unknown), and — when a host is given —
|
|
4
|
+
// must be declared by that host (else rejected as off-host). Rejection is a THROWN error at author time:
|
|
5
|
+
// a scenario over an unknown or off-host capability is not silently skipped and does not count toward
|
|
6
|
+
// coverage (fail-closed). Each referenced capability's web assertion is routed to the altitude the
|
|
7
|
+
// registry prescribes via webEnginePath. Kept out of `scenario.ts` (which is pure engine-free data) so
|
|
8
|
+
// that file stays import-free; this module is the registry-consuming author.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type HostCapabilities,
|
|
12
|
+
UNIVERSAL_SUBSET,
|
|
13
|
+
resolveCapability,
|
|
14
|
+
} from '@unrulysystems/native-motion-core'
|
|
15
|
+
|
|
16
|
+
// The web assertion altitude a scenario's property is checked at (REQ-SUBSET-015/-018): an accelerated
|
|
17
|
+
// property is scrubbed as a real-browser WAAPI trajectory plus a jsdom WAAPI-contract/end-state check; a
|
|
18
|
+
// JS-path property is state-asserted in jsdom. A shipping:'deferred' member is neither — it is
|
|
19
|
+
// EXPECTED-LOUD (REQ-SUBSET-012): the scenario asserts it FAILS LOUD on both engines until its rung, never
|
|
20
|
+
// that it animates (coverage). The registry's `shipping` field gates first, then `webEnginePath`.
|
|
21
|
+
export type WebAltitude = 'real-browser-trajectory+jsdom-contract' | 'jsdom-state' | 'expected-loud'
|
|
22
|
+
|
|
23
|
+
// The animation targets a scenario declares. Keys are capability keys; values are irrelevant to the
|
|
24
|
+
// membership gate (the gate validates KEYS, the value types are VALUE-TYPES' concern).
|
|
25
|
+
export interface AuthoredTargets {
|
|
26
|
+
readonly initial?: Readonly<Record<string, unknown>>
|
|
27
|
+
readonly animate?: Readonly<Record<string, unknown>>
|
|
28
|
+
readonly exit?: Readonly<Record<string, unknown>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AuthoredScenario {
|
|
32
|
+
readonly keys: readonly string[]
|
|
33
|
+
readonly webAltitudes: ReadonlyMap<string, WebAltitude>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Route a capability's web assertion by its registry web-engine path. Throws for an unknown key so a
|
|
37
|
+
// mis-typed capability cannot be silently routed to a default altitude.
|
|
38
|
+
export function webAltitudeFor(key: string): WebAltitude {
|
|
39
|
+
const entry = UNIVERSAL_SUBSET.get(key)
|
|
40
|
+
if (entry === undefined) {
|
|
41
|
+
throw new Error(`unknown capability '${key}': cannot route a web assertion altitude for it.`)
|
|
42
|
+
}
|
|
43
|
+
// Shipping gates FIRST (REQ-SUBSET-012, r11 major 33): a deferred member is expected-loud, never a
|
|
44
|
+
// coverage altitude, regardless of its JS/accelerated path — a coverage altitude would assert it
|
|
45
|
+
// animates, but a deferred surface fails loud on both engines until its rung.
|
|
46
|
+
if (entry.shipping === 'deferred') return 'expected-loud'
|
|
47
|
+
return entry.webEnginePath === 'accelerated'
|
|
48
|
+
? 'real-browser-trajectory+jsdom-contract'
|
|
49
|
+
: 'jsdom-state'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Author-time capability gate. Collects every key across initial/animate/exit, rejects any that is not a
|
|
53
|
+
// registry member (unknown) or — with a host — not declared by the host (off-host), and returns the
|
|
54
|
+
// referenced keys with their routed altitudes. Fails closed on the FIRST offending key.
|
|
55
|
+
export function authorScenario(
|
|
56
|
+
targets: AuthoredTargets,
|
|
57
|
+
host?: HostCapabilities,
|
|
58
|
+
): AuthoredScenario {
|
|
59
|
+
const keys = new Set<string>()
|
|
60
|
+
for (const target of [targets.initial, targets.animate, targets.exit]) {
|
|
61
|
+
if (target === undefined) continue
|
|
62
|
+
for (const key of Object.keys(target)) keys.add(key)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const webAltitudes = new Map<string, WebAltitude>()
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
if (host !== undefined) {
|
|
68
|
+
// resolveCapability enforces BOTH unknown (registry membership) and off-host (host declaration),
|
|
69
|
+
// with the descriptive REQ-SUBSET-014 message naming the key + supporting hosts.
|
|
70
|
+
resolveCapability(host, key)
|
|
71
|
+
} else if (!UNIVERSAL_SUBSET.has(key)) {
|
|
72
|
+
throw new Error(`unknown capability '${key}': not in the universal-subset registry.`)
|
|
73
|
+
}
|
|
74
|
+
webAltitudes.set(key, webAltitudeFor(key))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { keys: [...keys], webAltitudes }
|
|
78
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The independent cross-engine comparator (REQ-CONFORM-014). A PURE function over two engines'
|
|
2
|
+
// reading series for the SAME scenario: neither engine grades itself; this differentiates the two
|
|
3
|
+
// independent makers' outputs within the ratified tolerance band. Both results must come from the
|
|
4
|
+
// same scenario (same timeline → same reading order), so readings are zipped by index.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
EPS_ANGLE,
|
|
8
|
+
EPS_COLOR_CHANNEL,
|
|
9
|
+
EPS_LENGTH,
|
|
10
|
+
EPS_UNITLESS,
|
|
11
|
+
parseValue,
|
|
12
|
+
type TypedValue,
|
|
13
|
+
} from '@unrulysystems/native-motion-core'
|
|
14
|
+
import { scalarWithin, type ScenarioResult } from './runner'
|
|
15
|
+
import type { MotionScalar, ToleranceBand } from './scenario'
|
|
16
|
+
|
|
17
|
+
export interface CrossEngineMismatch {
|
|
18
|
+
readonly at: number
|
|
19
|
+
readonly kind: 'velocity' | 'value'
|
|
20
|
+
readonly a: MotionScalar
|
|
21
|
+
readonly b: MotionScalar
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CrossEngineResult {
|
|
25
|
+
readonly id: string
|
|
26
|
+
readonly agree: boolean
|
|
27
|
+
readonly mismatches: readonly CrossEngineMismatch[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function compareEngines(
|
|
31
|
+
a: ScenarioResult,
|
|
32
|
+
b: ScenarioResult,
|
|
33
|
+
band: ToleranceBand,
|
|
34
|
+
): CrossEngineResult {
|
|
35
|
+
const mismatches: CrossEngineMismatch[] = []
|
|
36
|
+
const n = Math.min(a.readings.length, b.readings.length)
|
|
37
|
+
for (let i = 0; i < n; i++) {
|
|
38
|
+
const ra = a.readings[i]
|
|
39
|
+
const rb = b.readings[i]
|
|
40
|
+
if (ra === undefined || rb === undefined) continue
|
|
41
|
+
const tol = ra.kind === 'velocity' ? band.velocity : band.value
|
|
42
|
+
if (ra.kind !== rb.kind || !scalarWithin(ra.actual, rb.actual, tol)) {
|
|
43
|
+
mismatches.push({ at: ra.at, kind: ra.kind, a: ra.actual, b: rb.actual })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Fail-closed: differing reading counts are a structural disagreement even if the common prefix
|
|
47
|
+
// matches (the engines ran different assertion sets).
|
|
48
|
+
const agree = mismatches.length === 0 && a.readings.length === b.readings.length
|
|
49
|
+
return { id: a.id, agree, mismatches }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The per-type tolerance for a numeric slot's unit (SPEC-VALUE-TYPES §5): lengths within EPS_LENGTH,
|
|
53
|
+
// angles within EPS_ANGLE, unitless/percent within EPS_UNITLESS — the ratified floors, never widened.
|
|
54
|
+
function epsForUnit(unit: string): number {
|
|
55
|
+
if (unit === 'px' || unit === 'vw' || unit === 'vh') return EPS_LENGTH
|
|
56
|
+
if (unit === 'deg' || unit === 'rad') return EPS_ANGLE
|
|
57
|
+
return EPS_UNITLESS
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Flatten a typed value into its scalar channels, each with its own per-type tolerance. A color yields
|
|
61
|
+
// its four channels at EPS_COLOR_CHANNEL; a complex yields each slot's channels.
|
|
62
|
+
// auto/calc (REQ-VALUETYPE-013) are measure-resolved to px BEFORE mix — unresolved they have no
|
|
63
|
+
// comparable scalar channels. Empty channel lists force a structural mismatch (fail-closed), never
|
|
64
|
+
// a silent partial parse of interior %/px tokens.
|
|
65
|
+
function channelsOf(tv: TypedValue): readonly { readonly value: number; readonly eps: number }[] {
|
|
66
|
+
switch (tv.kind) {
|
|
67
|
+
case 'numeric':
|
|
68
|
+
return [{ value: tv.value.value, eps: epsForUnit(tv.value.unit) }]
|
|
69
|
+
case 'color':
|
|
70
|
+
return [
|
|
71
|
+
{ value: tv.value.red, eps: EPS_COLOR_CHANNEL },
|
|
72
|
+
{ value: tv.value.green, eps: EPS_COLOR_CHANNEL },
|
|
73
|
+
{ value: tv.value.blue, eps: EPS_COLOR_CHANNEL },
|
|
74
|
+
{ value: tv.value.alpha, eps: EPS_COLOR_CHANNEL },
|
|
75
|
+
]
|
|
76
|
+
case 'complex':
|
|
77
|
+
return tv.value.slots.flatMap((s) =>
|
|
78
|
+
s.kind === 'color'
|
|
79
|
+
? [
|
|
80
|
+
{ value: s.value.red, eps: EPS_COLOR_CHANNEL },
|
|
81
|
+
{ value: s.value.green, eps: EPS_COLOR_CHANNEL },
|
|
82
|
+
{ value: s.value.blue, eps: EPS_COLOR_CHANNEL },
|
|
83
|
+
{ value: s.value.alpha, eps: EPS_COLOR_CHANNEL },
|
|
84
|
+
]
|
|
85
|
+
: [{ value: s.value, eps: epsForUnit(s.unit) }],
|
|
86
|
+
)
|
|
87
|
+
case 'auto':
|
|
88
|
+
case 'calc':
|
|
89
|
+
// Discrete keywords have no scalar channels either (T23 B) — the empty list rides the same
|
|
90
|
+
// fail-closed arm below: a discrete reading never agrees by equal-zero length.
|
|
91
|
+
case 'discrete':
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Cross-engine value-type comparison (SPEC-VALUE-TYPES §5, REQ-CONFORM-014). Zips two engines' recomposed
|
|
97
|
+
// value series by index, DECOMPOSES each committed string via the core parser, and checks every typed
|
|
98
|
+
// channel within its per-type epsilon (EPS_COLOR_CHANNEL / EPS_LENGTH / …) — never byte-equality, never a
|
|
99
|
+
// widened band. Neither engine grades itself: parseValue is a neutral structural decomposition applied
|
|
100
|
+
// symmetrically to both outputs. A differing channel count (e.g. a snap to a different family) is a
|
|
101
|
+
// structural mismatch.
|
|
102
|
+
export function compareValueTypeSeries(a: ScenarioResult, b: ScenarioResult): CrossEngineResult {
|
|
103
|
+
const mismatches: CrossEngineMismatch[] = []
|
|
104
|
+
const n = Math.min(a.readings.length, b.readings.length)
|
|
105
|
+
for (let i = 0; i < n; i++) {
|
|
106
|
+
const ra = a.readings[i]
|
|
107
|
+
const rb = b.readings[i]
|
|
108
|
+
if (ra === undefined || rb === undefined) continue
|
|
109
|
+
const ca = channelsOf(parseValue(String(ra.actual)))
|
|
110
|
+
const cb = channelsOf(parseValue(String(rb.actual)))
|
|
111
|
+
// Unresolved auto/calc yield empty channel lists — never agree by equal-zero length
|
|
112
|
+
// (fail-closed: measure-resolve to px before cross-engine compare).
|
|
113
|
+
if (ca.length === 0 || cb.length === 0 || ca.length !== cb.length) {
|
|
114
|
+
mismatches.push({ at: ra.at, kind: 'value', a: ra.actual, b: rb.actual })
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
for (let k = 0; k < ca.length; k++) {
|
|
118
|
+
const xa = ca[k]
|
|
119
|
+
const xb = cb[k]
|
|
120
|
+
if (xa === undefined || xb === undefined) continue
|
|
121
|
+
if (Math.abs(xa.value - xb.value) > Math.max(xa.eps, xb.eps)) {
|
|
122
|
+
mismatches.push({ at: ra.at, kind: 'value', a: ra.actual, b: rb.actual })
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const agree = mismatches.length === 0 && a.readings.length === b.readings.length
|
|
128
|
+
return { id: a.id, agree, mismatches }
|
|
129
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The ratified cross-engine tolerance band (REQ-CONFORM-014). Not loop-tunable: the loop may never
|
|
2
|
+
// widen these to force a green — a real divergence is a `fail` recorded as a decision, then the core
|
|
3
|
+
// is adjusted. `velocity` is bound to the core's pinned `EPS_VELOCITY` (its at-rest velocity epsilon)
|
|
4
|
+
// so the band tracks a single source of truth rather than a duplicated magic number; `value` is exact
|
|
5
|
+
// for state-level scalars (no interpolation at this altitude).
|
|
6
|
+
|
|
7
|
+
import { constants } from '@unrulysystems/native-motion-core'
|
|
8
|
+
import type { ToleranceBand } from './scenario'
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_BAND: ToleranceBand = {
|
|
11
|
+
velocity: constants.EPS_VELOCITY,
|
|
12
|
+
value: 0,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Spring/timing cross-engine band (SPEC-SPRING §5): the ratified physics-parity tolerance —
|
|
16
|
+
// restDelta (0.5) on value, restSpeed (2, the default rest speed from REQ-SPRING-003) on velocity.
|
|
17
|
+
// `value` ties to the core's `EPS_TRAJ`; the velocity floor is stated here because the core does not
|
|
18
|
+
// export its internal rest thresholds. Not loop-tunable — a real divergence is a fail, never a widen.
|
|
19
|
+
export const SPRING_BAND: ToleranceBand = {
|
|
20
|
+
value: constants.EPS_TRAJ,
|
|
21
|
+
velocity: 2,
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The doctrine-census declarations the conformance differentials cite by id.
|
|
2
|
+
//
|
|
3
|
+
// This is one half of a two-package pair, and it lives in a MODULE rather than in the test file
|
|
4
|
+
// that uses it so the other half can import the actual values. The differential
|
|
5
|
+
// (`mix-polymorphic.conformance.test.ts`) builds every non-`match` row's `owner` string from these
|
|
6
|
+
// ids; `packages/example-harness/src/parity/doctrine.test.ts` asserts every id here resolves to a
|
|
7
|
+
// LIVE census declaration. Neither half can live in the other package — example-harness depends on
|
|
8
|
+
// conformance, never the reverse.
|
|
9
|
+
//
|
|
10
|
+
// The direction that matters is the one the harness cannot already see. A census id renamed in
|
|
11
|
+
// `census.ts` already fails `check:parity-doctrine` through `REQUIRED_SEMANTIC_CENSUS_IDS`; what
|
|
12
|
+
// nothing caught before is a CITATION here going stale, leaving a differential row attributing its
|
|
13
|
+
// debt to a declaration that no longer exists. Asserting these values against the live census is
|
|
14
|
+
// that check. Asserting them against the owner strings built from them is NOT — that is a
|
|
15
|
+
// tautology, and it passed a deliberately dead id (H1 round-3 review MAJOR).
|
|
16
|
+
export const CENSUS_DECLARATIONS = {
|
|
17
|
+
seam: 'motion.value-seam-strictness',
|
|
18
|
+
color: 'motion.color-syntax-capability',
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
export type CensusDeclarationKey = keyof typeof CENSUS_DECLARATIONS
|