Skip to content

chantier: expression parity — close the seven walls between a generated scenario and hand-written motion code - #340

Merged
LeadcodeDev merged 15 commits into
mainfrom
chantier/expression-parity
Sep 26, 2026
Merged

LeadcodeDev merged 15 commits into
mainfrom
chantier/expression-parity

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes #327, #328, #329, #330, #331, #332, #333, #334, #335, #336, #338. Refs #326.

Two motion reels were generated by an LLM with no access to Rustmotion — one on Canvas 2D, one on an SVG tree driven by a GSAP timeline and a Web Audio score. Both were better than anything this engine had produced. Rebuilding the first as a scenario, at the maximum the engine then allowed, measured the gap in numbers rather than impressions.

Most of that measurement was in Rustmotion's favour. It renders 15 s at 1920×1080 in 7.5 s, against roughly eight minutes for the SVG reel through headless Chromium and 600 PNG captures. Its geometry validator caught the one real defect in the rebuild — the exact class of defect the SVG reel's author states he had no way to detect. None of that was the problem.

Seven things could not be expressed at all, and this branch closes all seven.

Wall Measured cost before State after
A cut cannot land on a beat transitions subtracted from the timeline; declared 15.0 s rendered 13.5 s, last cut 1.15 s early at in beats, transitions as overlap, declared 15.0 s renders 15.0 s
No sound AudioTrack needed a src; none of the 11 examples had audio a score of voices synthesised offline on the cuts' own clock
No reference between nodes eight lines from a logo to orbiting badges: abandoned node("id", "prop") across four families, resolved in topological order
A glyph is not addressable a ? detaching from a typed sentence: abandoned glyph positions exposed; the sentence's end is readable
No measurement inside a text run caret head, letter advance, gradient span text metrics and glyph rects surfaced
No arithmetic 316 lines of Python to emit the JSON the same scenario is one file, 0 lines of Python
No global shake 155 sampled camera keyframes six {at, amplitude} pairs

This is too large to review in one sitting, and that is worth saying plainly. 20 000 insertions across 166 files. Reviewing it commit by commit is realistic: each of the fourteen carries its own reasoning, the alternative it rejected, and what it could not verify. The split is by subsystem rather than by workstream, because thirteen agents worked in parallel and files like lib.rs carry four of them at once.

What the decomposition did not predict, and what it cost

Five of five blocked deliverables across the chantier were the same thing: wiring. The partition was drawn by mechanism, and every mechanism's wiring lands on one shared surface — the per-frame render path. Fencing it off to stop three agents colliding is what made five of them stop at the fence.

Three independent workstreams then hit a second shared trap. cli/commands/validation.rs carries its own load pipeline, separate from loader.rs, and include.rs carries a third. Static folding, the variable guard and the synthesised audio were each wired once, passed their own tests, and did nothing through the actual CLI until wired a second time. A fourth and fifth pipeline turned up the same way.

A feature is not done in this codebase when loader.rs knows about it. That is the most reusable thing this branch learned, and it will cost the next feature too unless it is written down.

Three findings this branch does not fix

#157 and #337 are the same component, and are plausibly two symptoms of one measurement or placement fault in gradient_text.

Known limits, named rather than buried

A node() reference resolves one hop; a chain C→B→A does not. A referencing node with no id of its own is invisible to the dependency graph, at validate and at render alike. resolve_node_references is wired for slide views, not world views. {"type":"grid"} with neither display nor grid-template-columns loses its dedicated error, since the container merge erases which spelling was written — no example uses it. Expressions have two dialects: = expr on a numeric property, ${expr} inside a string, because a path is text with syntax around it and a number is not.

Verification

cargo fmt --all -- --check     clean
cargo clippy --workspace --all-targets -- -D warnings     0 errors
cargo test --workspace     1701 passed, 10 ignored
examples     14/15 validate (the 15th is #157, pre-existing)
Cargo.lock     989 crates, down from 1000

Verified by rebuilding and measuring rather than on a report's word: six scenes declaring 15.0 s render 13.5 s under v1 and 15.0 s under v2; a for-each over eight computed cosines places eight labels on an ellipse with no generator script; "opacity": "= $fade" over an animated variable renders black → mid → full across three sampled instants; a 59-event synthesised score muxes at peak −0.147 dB with two renders byte-identical; 272 container instances across the corpus render byte-identical over nineteen sampled frames after the six-into-one merge.

syntect, similar, fancy-regex and plist leave the tree with codeblock. Two of the three manifests declaring syntect never imported it.

`build_frame_tasks` built a flat Vec<FrameTask> where the index was the
frame number, and a transition's frames replaced scene frames instead of
overlapping them. Six scenes declaring 15.0s with five transitions
rendered 13.5s, and every cut after the first drifted: -0.30, -0.60,
-0.90, -1.15s. At 115 BPM the last of those is more than two beats, so a
rhythmic edit was impossible without compensating each duration by hand,
and changing one transition re-timed everything after it.

A scene now declares `at`, in seconds or in beats against the scenario's
own `bpm` and `beat_offset`. A transition is a declared overlap: scene
i-1 keeps rendering through it, frozen on its last frame or still
animating, and the total stops shrinking.

Behind `"timing": "v2"`. Absent, the old accounting is byte-identical and
`render` says so once. `snap: "beat"` rounds every resolved time onto the
grid, which is the cheapest way to make a generated edit rhythmic without
the generator having to think about it.

An unresolvable `at` is a hard error, not a warning: a typo that leaves
validation green and silently changes the edit is the failure class this
whole chantier exists to remove. Grammar is checked at deserialization,
where no bpm is needed; resolution is checked at validation, where it is.
Rebuilding a reference reel as a scenario needed 316 lines of Python to
emit the JSON, because a scenario cannot compute. Every position ended a
frozen literal: unreadable in the studio, uneditable by hand, and severed
from the intent that produced it. Eight badges on a circle, twenty-four
facets shaded by their angle to a light, an element floating on a sine —
each is one line in the reference reels and was impossible here.

A value prefixed with `=` is an expression. The grammar is deliberately
not Turing-complete: no loops, no user functions, no recursion, and a
nesting cap, so evaluation stays bounded and a hostile expression fails at
parse rather than hanging a render. `rand` and `noise` are pure functions
of their arguments with no global state, so two renders of a file match.

Two tiers. An expression whose free variables are all load-time-known is
folded to a literal during resolution and costs nothing per frame; that
tier alone removes the generator script. Everything else compiles once to
a stack machine and evaluates per frame.

The fold had to be wired five times, not once. `loader.rs` has three
entry points, `cli/commands/validation.rs` carries its own pipeline that
`validate` and `render` actually use, and `include.rs` resolves each
included file independently. A feature is not done in this codebase when
`loader.rs` knows about it.

`find_unresolved` now skips `=`-prefixed strings rather than carrying a
list of reserved names: the scan's job is "did a $var fail to resolve",
and an expression is a different sub-language with its own resolver and
its own more precise error. A name list would need updating every time
this layer grows; recognising the syntax cannot drift.
…frame

`Computed<T>` was defined and used by exactly zero fields, so a scenario
writing `"opacity": "= $keyDraw"` died at deserialization with `invalid
type: string, expected f32` before any per-frame machinery ran. Static
folding worked only because it rewrites the JSON to a literal before
`CssStyle` ever deserializes; the dynamic tier had nowhere to land. Three
landed workstreams resolved correct values into a scope no field could
read from.

Retyping every style field to `Computed<f32>` would ripple through every
consumer of every property. Instead a `= ...` string is lifted out of the
JSON before the typed field sees it, parsed once, and re-applied each
frame through `apply_animated_props` — the same override path animations
already use, rather than a second one beside it.

`vars` completes it: a scalar declared once, animated on the absolute
timeline, read by any expression. That is the mechanism the reference
reel's author describes as animating a plain object and mapping it to
attributes every frame, and it is what makes derived motion expressible
at all — a hand-rolled 3D wireframe, a float on a sine, a counter driving
a typewriter.

`Expr::is_static()` cannot see `vars` and never could: it special-cases
four fixed names, so `$keyDraw` looks like a constant and would fold into
an "unknown identifier" error. `dynamic_names` is the mechanism that
stops it, and the loader guard consults it in every fold site.
…glyphs

Eight lines joining a logo to badges orbiting around it were abandoned
when this chantier's gap was measured: a line's endpoint cannot follow a
node whose position is animated. A `?` detaching from the end of a typed
sentence went the same way, because nothing could ask where that sentence
ends.

Most of this is exposure rather than new geometry. Glyph positions were
already computed at paint time and discarded; text measurement already
existed behind an intrinsic. What was missing is a way to name a node and
read it.

A node takes an `id`; an expression reads `node("id", "prop")` across four
families — geometry after layout, the transform resolved this frame, text
measurement, and glyph positions.

The ordering is the whole difficulty. Resolving in tree order rather than
topological order yields values one frame stale, which is invisible in
playback and only shows if someone pauses on the wrong frame. A fresh
ResolvedFrame is built every frame and filled in DepGraph::order(), so
staleness is structural rather than a caller's discipline, and reading a
node before its turn fails loudly instead of returning zero.

Known limit, documented where it bites: a direct reference resolves, a
transitive chain C->B->A does not. Closing it needs per-id re-resolution
walking the graph rather than a second whole-tree build.
Reproducing the reference reel's camera shake took 155 sampled camera
keyframes, generated by a Python loop evaluating a damped sine and pasted
into the JSON. GSAP writes the same motion as `{x: 7, duration: .035,
repeat: 11, yoyo: true}`. `repeat` was a bool with no count, there was no
`yoyo`, and no `steps` easing, so a blinking caret and an oscillating
offset were both spelled out keyframe by keyframe.

`Scene.shake` takes a list of impacts whose times are TimePoints, so they
are written on the same beat grid as the cuts. The model is a decaying
spiral rather than a decaying line — x and y 90 degrees out of phase —
because that reads as a shake and not as a bounce, and `decay` and
`frequency` are knobs an author can reason about. The formula is on the
type, since tuning a number whose meaning is hidden is guesswork.

Additive over `camera`, so a pan and a shake coexist instead of fighting
over one keyframe track. A scene declaring `shake` with no `camera` block
needs a neutral camera to ride on, or the most natural authoring shape —
just an impact, no pan — would silently do nothing.

`repeat: true` deserializes and behaves exactly as before, proven two ways
rather than argued: the old formula kept inline as a reference across
fourteen sample points, and a JSON-level round trip through the single
`loop` key.
`AudioTrack` required a `src`, so every generated scenario was silent —
not one of the eleven example files had any audio at all. A language model
can write a score and cannot hand over a WAV. Both reference reels
synthesise their entire soundtrack from oscillators, noise and filters,
with no audio file anywhere.

Voices, a score and a master bus, rendered offline to an f32 buffer at
48kHz and muxed through the existing path beside file-based tracks rather
than replacing them.

Every time in a score is a TimePoint, so `every: "1b"` means one beat of
the scenario's own grid. That sharing is the point: a score that merely
plays alongside the picture is worth a fraction of one whose impacts and
cuts resolve through the same clock. A cut declared at 8b and a hit
declared at 8b land on the same instant by construction, not by tuning.

The limiter is on by default. The author of one reference reel found his
first mix clipping at 0 dB only by reading ffmpeg output afterwards, which
is the loop this removes.

`every` and `offset` are durations, not instants, so they resolve against
a context with `beat_offset` zeroed — a beat's length is set by bpm, and
folding the grid's anchor into an interval would stretch every repeat
whenever a scenario moved its origin.
Asked how he verified a motion reel he had generated, the author of one
reference reel was direct: he cannot watch it move or hear it. He
captured stills at chosen instants, assembled contact sheets by hand, and
found a misplaced hexagon and a clipped subtitle that way. His audio he
judged only through peak and RMS numbers read afterwards. One blind spot
he names outright: "the transitions, I never saw them play."

This engine had the same one. `geometry.rs` iterated `view.scenes` and
sampled within each scene's own duration, so transition frames — separate
FrameTask variants, and under v2 a window that extends past a scene's own
end — were checked by nobody. The composite of two frame buffers was
unvalidated.

`rustmotion sheet` renders a contact sheet in one command, each cell
stamped with its instant, because an unlabelled grid cannot locate a
defect in time. `--strict-anim` now walks frame tasks rather than scenes.
`--report` carries peak, RMS and true peak, per beat where a bpm is
declared, so a clipping soundtrack is a named violation found at
validation rather than a discovery made later with another tool.

`rustmotion migrate` converts a scenario to v2, compensating each scene's
duration by its own transition so the render is frame-identical, and
verifies that by loading both files through the same scheduler `render`
uses rather than re-deriving the arithmetic a second time.

None of this gives a generator better sight. It gives it numbers in place
of the sense it does not have, which is the only thing that can be given.
`card`, `flex`, `grid`, `div`, `container` and `positioned` were six names
for the same thing. All six had an empty `paint_content` — every
decoration a card was supposed to carry is painted by `paint_pass` from
`CssStyle`, identically for any node in the tree — and five of the six had
the same seven fields, character for character. `container` was already a
serde alias of `div`; the others should have been. `flex` additionally
declared a `FlexSize` struct referenced nowhere in the workspace.

One variant now, six accepted spellings. Nothing breaks: the 272
occurrences across `examples/` keep validating and render byte-identical,
verified over nineteen sampled frames of the two files that lean on them
hardest.

`type` stays required. A typeless object meaning "container" would read as
simpler and would turn a typo — `"tpye": "text"` — into a silently
laid-out empty box instead of a named error. This chantier's subject is
failures that pass validation; it does not add one to save a word.

The default-display heuristic could not survive as a variant match, since
the alias used is erased after deserialization. It now keys on whether
`grid-template-columns` is set, which reproduces the old behaviour for
every valid grid scenario and for all card/flex/div usage. `positioned`
loses an implicit block default it never relied on in practice.
Three thousand lines of engine for three shapes a scenario can now
describe itself. A terminal is a title bar over monospace lines under a
typewriter. A toast is a card that slides in and out on start_at/end_at.
Both were compositions wearing a type name.

Codeblock was the one that looked irreducible: syntax highlighting needs
the grammars of a hundred languages, and that is real. The argument fails
one level up. Whoever writes the scenario is a language model that
already knows those grammars, and can emit a coloured span per token
directly. The grammar belongs in the generator, not in the binary every
user of this crate links.

`syntect` went with it, and it turned out to be declared in three
manifests rather than the two that used it — dead weight in `rustmotion`
and `rustmotion-core` for as long as it had been there. `similar` was the
same story, reached only by codeblock's diff mode. `fancy-regex` and
`plist` followed transitively: eleven crates out of the tree, on a project
whose users link it as a library.

`check_auto_scroll` and `ViolationKind::AutoScrollDisabledOverflow` went
too. No component carries an `auto_scroll` field any more, so the variant
could never be constructed again, and validator machinery that describes a
feature nobody can use is the defect this chantier keeps finding.
Across the eleven files in `examples/`, five component types carried 82%
of roughly 690 instances. Twenty-seven types appeared exactly once in the
whole repository. `dark-premium.json` used four types, `1600-style.json`
nine; `mega-showcase.json` used fifty-four, and its job is to demonstrate
the catalogue. The only file that used the catalogue was the one that
exists to show it.

A component that adds nothing a primitive already does is a name to learn
for no capability gained. Thirty-four of them are `card` plus `text` plus
`shape` plus an animation, and a component defined inside a scenario beats
a global one, because art direction is per video and `components` with
`for-each` already provides exactly that.

Seven of the thirty-four were classified as irreducible algorithms before
this chantier and reclassified after it, because the chantier moved the
line. A five-bar chart with proportional heights and an index-staggered
grow-in is now a `for-each` and one expression. `particle` is `rand(seed,
$i)` and a sine, which is its definition. That call was wrong when it was
made and the evidence for changing it is a rendered frame, not an
argument.

Nothing is removed. Deprecating a struct in Rust also deprecates every
field read on it, so a crate-root allow silences the engine's own dispatch
tables, and three narrow allows cover the CLI's per-type lookups — on a
function or a match arm, never a file, except `text_sizes`, whose entire
purpose is that per-type table.

Each note names the composition that replaces it rather than saying
"deprecated", because a deprecation that does not say what to write
instead just moves the problem to the reader.
The skill presented sixty components across fifty-five rule files, most of
them orientated at UI widgets. A generator handed that catalogue assembles
cards — 233 layout nodes across eight levels of nesting in a sixty-second
example, against 42 nodes for the same subject written as a film.

Thirty-four widgets leave the catalogue; three are gone from the engine
entirely. Half the rule files existed only to document a removed
component's quirks and go with them, and `composition-recipes.md` replaces
them with the recipe each one is superseded by.

A library of the thirty-four was considered and rejected. A library is
still a default that anchors, and a generator fills `stat` rather than
designing one. What ships instead is worked example scenarios: an example
teaches composition, a library teaches filling in blanks.

The container section now teaches one word. The entry document said `div`
is layout and `card` is the same with decoration expected, which was never
true in the code and is now not true in the schema either.

This writes off recent careful work on several components. That is the
cost of the decision, not an argument against it.
Four new scenarios show what replaces the widgets that left the catalogue:
a KPI row, a pill row, animated progress bars and a step flow, each one
component defined once and instantiated through `for-each`.

Four existing ones were rewritten rather than deleted when codeblock,
terminal and notification went, so the corpus proves the recipes work
instead of a rules file promising they do.

Two real defects surfaced only in the rendered frames, not in validation.
A tokeniser regex with no catch-all silently dropped the characters it did
not recognise, so a sentence rendered with its full stop missing. And a
lone `=` token, from tokenising `width="1920"`, was read as an expression
by the loader this chantier added, which is a collision worth knowing
about for anyone emitting code as data.
`apply_post_effects` gained the scene-local instant it renders, which only
`Flash` reads — every other effect is time-invariant and depends on the
frame index alone. The call sites and their tests follow.

Two audio error variants for the synth path, sitting in `error.rs`'s
existing per-feature sectioning.
Module declarations added by hand across thirteen workstreams landed out
of order. CI runs fmt first, so this is the difference between a green
pipeline and a red one on the first push.
@LeadcodeDev LeadcodeDev added the enhancement New feature or request label Sep 26, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 26, 2026
`chunks_exact_to_as_chunks` landed in clippy 1.98. CI pins the stable
channel as of 2026-09-22; this machine was on 1.97.1 from July, so the
lint existed in the pipeline and not in the local check that cleared the
branch.

Three sites: the synth's stereo-pair assertion, the audio report's PCM
decode, and the flash post-effect's pixel walk.

Worth recording rather than fixing silently: a green local clippy on an
older toolchain is not evidence about CI. The version gap is the check,
not the command.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let scenario values be arithmetic expressions

1 participant