The snap is per group, and the slot interval has to reach it

Pulls the preserve-snap forward to step 2: it is the half with no UI and nothing
proposing today, and it needs nothing from the plate side but a fold that can
land last.

Makes the rule exact, because the direction is easy to get backwards and the
draft was vague about it. Slot k reads the latest preserved frame in
(d(k-1), d(k)], else d(k). The closure at native 13 in a 12-from-30 output is
recovered by slot 6, whose default is 15, reading 13 — NOT by slot 5 reaching
forward from 12, which would show it 17ms before the mouth shut and break the
invariant cadence_test already asserts.

Records where it goes, which is the part that was understated as "about thirty
lines". The slot interval exists only at clip.cljs:261 and group identity exists
only at symbol.cljs:399, so the interval has to be threaded down: two internal
signatures, not a drop-in. The rule's seat is the `(js/Math.floor lf)` default
`base-channel-frame` hands `pose/source-frame`, which leaves an explicit hand cut
beating a snap, as manual precedence requires.

And records the shortcut not to take: snapping at clip.cljs:261 needs no
threading and is wrong, because one native frame per output frame means the whole
picture reads 13 instead of 15 — a 67ms stale head to fix the mouth, fighting the
trace selection's own opinion about which head frame to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-01 10:21:35 -04:00
parent cc42155ffa
commit f7e16e5ef4

View file

@ -197,19 +197,34 @@ the swap untouched.
## The performance selection: a preserve-snap on the grid
Not built. About thirty lines, plus a prepare step.
Not built. The rule is ten lines; getting the two halves of it into the same place
is the work. An earlier draft of this section said "about thirty lines" and that
was understated — see *Where it goes* below.
A grid slot already picks a native frame — `cadence/frame` for the output rate, or
the exposure fold for a deliberate hold at full rate. The change is: when a slot's
pick has a *preserved* frame within the span of native frames that slot is
responsible for, read the preserved frame instead.
the exposure fold for a deliberate hold at full rate. Write `d(k)` for the native
frame slot `k` defaults to. Slot `k` is the first slot to cover everything in
`(d(k-1), d(k)]`, and the frames strictly inside that interval are the ones the
grid shows to nobody. So:
**Snap backward only, never forward.** `cadence/frame`'s contract is the latest
native frame at or before the slot's time, and `cadence_test` asserts
`selected <= f*native/grid` over every grid and native pair. Snapping forward
would show a closure before the mouth shut, which is a lead — a different control,
applied for a different reason, after this one. A backward snap shows the closure
up to a frame or two late, which is the same lateness a hold already has.
> **Slot `k` reads the latest preserved frame in `(d(k-1), d(k)]`, and `d(k)` when
> there is none.**
That is the whole mechanism. It recovers a dropped frame out of the slot's own gap,
it can never read a frame another slot already showed, and it cannot reach past
`d(k)`.
**Snap backward only, never forward**, and note which direction that actually is,
because it is easy to get backwards. The closure at native 13 in a 12-from-30
output is recovered by slot **6** — whose default is 15 — reading 13. It is *not*
recovered by slot 5, whose default is 12, reaching forward to 13: slot 5's instant
is 5/12s = 0.4167s and native 13's is 13/30s = 0.4333s, so that would show the
closure 17ms before the mouth shut. `cadence/frame`'s contract is the latest native
frame at or before the slot's time and `cadence_test` asserts
`selected <= f*native/grid` over every grid and native pair, so reaching forward
breaks a tested invariant as well as the no-lead rule. Reading 13 at slot 6 shows
the closure two native frames late, which is the same lateness every hold already
has.
**The marks come from a cut that is already stored.** `flow/freeze` computes both
closures and keys them as `[:vis]`, with the thresholding and hysteresis already
@ -226,11 +241,40 @@ closure and get no marks, which is correct: there is no extreme brow position
worth protecting.
**Precompute the mark set when the resolver is built**, the way `pose/prepare` and
`trace/prepare` already do. A `[:vis]` channel is keys, not dense, so reading it
per node per frame would be cheap — but the snap also needs the marks as a set
with a span lookup, and building that per frame is the one thing
[animation-model.md](animation-model.md) and the render-path note above both
forbid.
`trace/prepare` already do (`symbol.cljs:420`, `:470`, `:536`). A `[:vis]` channel
is keys, not dense, so reading it per node per frame would be cheap — but the snap
also needs the marks sorted for a backward lookup, and building that per frame is
the one thing [animation-model.md](animation-model.md) and the render-path note
above both forbid.
### Where it goes, and why it is not a one-liner
The rule needs two things that currently live at opposite ends of the resolver:
- **the slot interval** `(d(k-1), d(k)]`, which needs the output slot index and the
fps ratio. Both exist at `clip.cljs:261`, the single place the grid becomes a
native frame: `(cadence/frame f (or (:grid-fps opts) (:fps clip)) (fps clip sid))`.
- **the marks**, which are per pose group, and so belong where group identity
exists: `symbol/base-channel-frame` (`symbol.cljs:399`), whose `:pose-sampled?`
branch already calls `pose/source-frame` with `(js/Math.floor lf)` as the default
pose. **That default is the snap's seat.** Replacing it leaves an explicit hand
cut winning over a snap, which is correct — manual precedence is absolute — and
costs no new plumbing on the pose side, because per-group choices are already
threaded and already prepared.
By the time control reaches `base-channel-frame` there is only `lf`, a native local
frame that placement and retime have already been through, so the slot interval
cannot be recovered there. It has to be threaded down from `clip.cljs:261`
alongside the frame. That is the actual work of this step: two namespaces' internal
signatures, not a drop-in.
**Do not take the shortcut of snapping at `clip.cljs:261` itself.** It is right
there, it needs no threading, and it is wrong: one native frame per output frame
means the *whole picture* reads 13 instead of 15, so the head goes two frames stale
for one output frame to fix the mouth. At 12fps that is a 67ms hitch on a moving
head, and it fights the trace selection, which has its own opinion about which head
frame to show. The snap is per group because the thing being recovered is one
group's closure.
### Why not an aperture signal
@ -368,29 +412,36 @@ Render it in two sections:
1. **`domain/select` with the greedy walk, and its tests.** **Done**, in commit
`02069e8`. Pure, no store, no clip. Note that the extrema part of it is not
wanted by anything below — see *What is revertible*.
2. **The plate signal reader.** `trace/head-signal`: make `trace/measured-local`
2. **The preserve-snap**, pulled forward ahead of the plate side because it is the
half with no UI and nothing proposing today, and because it needs nothing from
the plate side except a fold that can land last. Three pieces: the mark set from
the stored `[:vis]` cuts, built in a prepare step beside `pose/prepare`; the
slot interval threaded from `clip.cljs:261`; the rule itself, seated in
`base-channel-frame`'s default pose. The test that matters is the same synthetic
case `select_test` uses — a one-frame closure at native 13 that a 12-from-30
grid drops — asserted end to end this time: the resolver shows a shut mouth on
exactly one output frame, and the head's frame does not move while it happens.
Write it first.
3. **The plate signal reader.** `trace/head-signal`: make `trace/measured-local`
public, map it over the frames, four corners through `node/apply-pt!`. Test
that it returns a vector of the right length, that a motionless take proposes
`[0]`, and that a stretch where the face was not found becomes `nil` rather
than a pose, a zero or a gap in the vector. Add the held-reconstruction
invariant from *The upgrade path* here, since this is the first place a real
signal exists to assert it over.
3. **Storage for the plate selection.** `:policy`/`:keep`/`:drop` beside
4. **Storage for the plate selection.** `:policy`/`:keep`/`:drop` beside
`:trace :frames`. Extend `leaf/leaves` and the key whitelists in the same
commit — a field without a leaf saves silently and comes back missing, which
is the one bug persistence must not be able to have. Round-trip test.
4. **Re-suggest preserves hand decisions.** Propose at one tolerance, pin a frame,
5. **Re-suggest preserves hand decisions.** Propose at one tolerance, pin a frame,
drop a frame, propose at another, assert both survive. Settle here whether the
hand's `:keep` is also fed to `propose` as `:protect`: under the layering as
written it is not, so a pinned frame does not re-anchor the walk even though it
is on screen, which contradicts the anchor rule above. It is the one live
caller for `propose`'s `:protect` frames.
5. **The preserve-snap.** The mark set from the stored `[:vis]` cuts, built in a
prepare step; the backward-only snap in the grid pick; plate frames folded into
the same mark set. Independent of steps 2–4 and cheaper than any of them, so it
can be pulled forward if the performance side is wanted sooner — the only thing
it needs from the plate side is the fold, which can land last.
6. **The shared UI component**, then its two mountings.
6. **Fold the plate frames into the mark set**, which is the nesting rule and is
one line once both sides exist.
7. **The shared UI component**, then its two mountings.
## What is revertible, and where it is