Skip to content

Ratify RULE G and RULE N; the CLI on cw; RULE G's compile half - #4

Merged
thorwhalen merged 3 commits into
mainfrom
ratify-and-cli
Sep 2, 2026
Merged

thorwhalen merged 3 commits into
mainfrom
ratify-and-cli

Conversation

@thorwhalen

@thorwhalen thorwhalen commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

628 tests on 3.12, 545 on 3.10. Both owner questions ratified, the CLI lands on cw, and RULE G's compile half is built.

Ratified 2026-09-02

  • RULE G — burns stays separate and gains a looks-backed ffmpeg backend. looks owns the compile side; burns samples its BurnsPath and runs the argv. Dependency points burns → looks, legal because looks is stdlib-only; the reverse stays forbidden.
  • RULE N — looks owns normalisation, exactly as already shipped in Look.target.

looks.motion — RULE G's compile half

Keyframes in, an ffmpeg fragment out. No easing, no sampling, no camera decisions, and no burns import — a window is anything with .x/.y/.w/.h in burns' normalised convention, so the structural type is the seam.

$ looks motion 0:0,0,1,1 2:0.25,0.25,0.5,0.5 --output 1920x1080 --fps 30
zoompan=d=1:s=1920x1080:fps=30:z='1/(1+(-0.5)*min(...))':x='iw*(...)':y='ih*(...)'
$ looks motion 0:0,0,1,1 2:0.25,0.25,0.5,0.5
this path zooms, so it compiles to `zoompan`, which needs output and fps. …
$ echo $?
1

A recorded fleet fact, overturned by measurement

muvid._crop_filter's docstring — propagated by two of this package's own research notes into a warning aimed at the burns owner — says "Not zoompan: its expression vocabulary has no t at all, and it duplicates frames on video input."

Both premises are true. The conclusion does not follow:

claim measured on ffmpeg 8.1
no t true — and in_time/it both work. ffmpeg -h filter=zoompan lists no variables at all, which is likely how the fact came to be half-complete
duplicates frames true at the default d (1800 out for 20 in), exactly 1:1 at d=1

Meanwhile the filter the notes recommended instead cannot do the job: t is not in scope for crop's w/h and the filter refuses to configure — reproduced in three invocation forms. That is stronger than this repo's own earlier note, which recorded it as "evaluated once … produced 60 frames of unchanged size". Nothing renders. A Ken Burns path is a zoom by definition, so it has exactly one compilable form.

muvid's own code is fine — it compiles a pan, the case where crop is right and simpler. Filed as reeleehq/muvid#68; the burns adapter is reeleehq/burns#12.

Three more traps, each a silent wrong answer

Which is why each is a refusal rather than a doc note:

  • x/y are in original input pixels, not zoomed — the two readings score 60.5 dB and 6.3 dB against a crop+scale reference. The first implementation here had it wrong, and it configured cleanly.
  • fps silently retimes — fps=25 on a 10 fps source keeps all 20 frames and turns a 2.0 s clip into 0.8 s, so a frame-count check passes. Required, never defaulted.
  • zoom is clamped at 10 — z=10 scores 54.4 dB, z=12 scores 13.2: not a worse rendering of the window asked for, a correct rendering of a different one.

The tests ask ffmpeg, not the strings

String equality passes for a fragment ffmpeg refuses and for one that renders the wrong picture — both happened here. So each fragment is built a second way by hand and compared with ffmpeg's own psnr: a static window is bit-identical to the crop it names, a zoom is >45 dB from its crop+scale equivalent, and the two filters' clocks agree frame for frame. The threshold isn't a knob — correct pairings score 54–60 dB, nearest wrong ones 6–13.

Full transcript: docs/research/00f_motion_filters_evidence.md.

The CLI is cw

MIT, dependencies = [], and — verified in a fresh interpreter — import cw pulls in nothing beyond stdlib. So looks neither imports the LGPL package nor hand-rolls argparse.

cw could not read Sequence[str] (argh recognises list and nothing else), so looks check scale eq lut3d bound one token — quietly, since the parser builds and --help looks right. Fixed upstream in i2mint/cw#36: modern_decode now reads the collections.abc interfaces, with str/bytes excluded by construction (matching on typing.get_origin, which is None for both) rather than by special case — they are registered Sequences, so an issubclass test would have made every string parameter variadic.

Two of this package's own guards caught me

  • test_every_runtime_extra_has_a_ledger_row refused the new [cli] extra: "an extra is a conveyance decision, not a convenience list." cw has a ledger row now, with both observations dated.
  • The pin over §5.7's table refused a silently-grown ledger, so the row is in the decisions document too.

Reading the declaration on both legs

CI went red on 3.10: tomllib is 3.11+. The obvious fix — skip — is what the other guard already did, and finding that is what made this a real fix: the guard that refuses an undeclared conveyance had been reporting "skipped" on half of CI since it was written. A skip reads as a pass.

looks/tests/_pyproject.py reads the file on every version. It is deliberately not a TOML parser — a hand-rolled reader is normally a liability, agreeing with the real one until the day it quietly doesn't. What removes that is test_pyproject_reader.py: on every interpreter that has tomllib (one of CI's two legs, every run) the reader is checked against it over the real file, every table and every key, in both directions. Never trusted, only checked — against the same bytes the 3.10 leg reads.

importlib.metadata was the tempting alternative and is wrong: measured, it reports Provides-Extra: ['dev', 'docs'] — no cli — because an editable install's dist-info is a snapshot of install time. A licence guard reading that would have said the extra does not exist.

Verified on a real 3.10 interpreter: both guards now run there.

Also

__version__ resolves on first access (PEP 562). Eager resolution cost 50 ms of import time — importlib.metadata pulls in email.parser — for a value most callers never read. 54.5 ms → 38.6 ms, pinned by a test asserting email.parser is absent after import looks.

https://claude.ai/code/session_01CnXLihXKG9jfvygHvgKVpX

**Both owner questions ratified 2026-09-02.** RULE G: `burns` stays separate and
gains a `looks`-backed ffmpeg backend — so `looks` owns the COMPILE side
(keyframes to a filter fragment) and `burns` samples its BurnsPath and runs the
argv. The dependency points burns -> looks, legal because looks is stdlib-only;
the reverse stays forbidden. RULE N: yes, `looks` owns normalisation, exactly as
already shipped in `Look.target`.

**The CLI is `cw`** ($PP/i/cw), which was the right answer and which I had
missed: MIT, `dependencies = []`, and — verified in a fresh interpreter —
`import cw` pulls in NOTHING beyond stdlib; the argcomplete/i2 imports an AST
walk finds are all inside function bodies. It is the fleet's own argh
replacement, so `looks` neither imports the LGPL package nor hand-rolls
argparse. Dispatched under `cw.MODERN` rather than its argh-compatible default.

Eight commands, and `check` is the headline:

    $ looks check scale eq lut3d
    eq
    1 of 3 need a GPL ffmpeg build. LGPL-clean substitutions: curves / lutyuv /
    colorlevels / colorbalance / exposure for eq; ...
    $ echo $?
    1

Non-zero on purpose, so a build script can gate on it — a refusal that exits 0
is a warning wearing a refusal's clothes. A typo exits non-zero too, with a
message rather than a traceback: an unrecognised filter name is a refusal, not
a missing answer.

**`cw` could not read the annotations this package is written with.** argh
recognises `list` and nothing else, so `filters: Sequence[str]` bound ONE token
and reported the rest as unrecognised — quietly, since the parser builds and
--help looks right. Contributed the fix upstream as i2mint/cw#36: `modern_decode`
now reads Sequence / Iterable / Collection / Set / tuple / set / frozenset,
subscripted or bare, while `argh_decode` stays argh's if-chain untouched. `str`
and `bytes` are excluded BY CONSTRUCTION (matching on `typing.get_origin`, which
is None for both) rather than by a special case — both are registered Sequences,
so an issubclass test would have read every string parameter as variadic.

**Two of the package's own guards caught me while doing this**, which is the
best evidence they work:

- `test_every_runtime_extra_has_a_ledger_row` refused the new `[cli]` extra:
  "an extra is a conveyance decision, not a convenience list." So `cw` has a
  ledger row now, with both observations dated — the metadata read AND the
  fresh-interpreter run that shows the empty dependency list is accurate rather
  than optimistic, which is not the usual case.
- The pin over §5.7's table refused a silently-grown ledger, so the row is in
  the decisions document too.

The zero-dependency test needed rework rather than an exemption. Forbidding any
third-party NAME in the source was too strong the moment an optional extra
arrived. It is now two tests that together say something stronger: importing
`looks` must pull in nothing third-party (asked of the interpreter, not the
AST), and any third-party name in the source must be a DECLARED extra imported
lazily. Adding a dependency therefore forces a pyproject edit, which the ledger
coverage test then forces into the ledger.

Also: `__version__` is now resolved on first access (PEP 562). Reading it
eagerly cost **50 ms of import time** — `importlib.metadata` pulls in
`email.parser` — for a value most callers never look at. 54.5 ms eager, 38.6 ms
lazy, with the honest-version property intact. Pinned by a test that asserts
`email.parser` is absent after `import looks`.

Claude-Session: https://claude.ai/code/session_01CnXLihXKG9jfvygHvgKVpX
…t tomllib

CI went red on Python 3.10: `tomllib` is 3.11+, and the new zero-dependency
scan imports it. The obvious fix — skip on 3.10 — is what the OTHER guard
already did, and finding that is what makes this a real fix rather than a
one-line patch:

    test_every_runtime_extra_has_a_ledger_row:
        except ImportError:  # Python 3.10
            pytest.skip("tomllib needs Python 3.11+")

So the guard that refuses an undeclared conveyance — the one that caught `cw`
yesterday — has been reporting **skipped** on half of CI since it was written.
A skip reads as a pass. Two guards, two different failures, one cause: a
licence guard cannot read the declaration on an interpreter this package
claims to support.

`looks/tests/_pyproject.py` reads the file on every version. It is a small
reader for the shapes this one file uses — table headers, strings, arrays of
strings — and it is deliberately NOT a TOML parser. A hand-rolled reader is
normally a liability: it agrees with the real parser until the day it quietly
does not, and everything built on it goes on reporting green.

What removes that liability is `test_pyproject_reader.py`. On every
interpreter that HAS `tomllib` — which is one of CI's two legs, every run —
the reader is run against it over the REAL pyproject, comparing every table
and every key, in both directions (a missed table would make a guard read an
empty declaration and pass vacuously; an invented one would make it read
fiction). The fallback is never trusted, only checked, against the same bytes
the 3.10 leg will read.

Three shapes in the live file break a naive reader, and each has a test:
`publish_marker = "[skip ci]"` is a bracket that must not open an array;
`"looks[cli]"` is one that must not either; and a comment two lines above an
array says "CI's publish job", whose apostrophe would open a string that never
closes. Comments are stripped BEFORE strings are masked, which is the ordering
that handles all three.

`importlib.metadata` was the tempting alternative and is wrong here: measured
on this machine it reports `Provides-Extra: ['dev', 'docs']` — no `cli` — because
an editable install's dist-info is a snapshot of install time and nothing
refreshes it. A licence guard reading that would have said the extra does not
exist.

Verified on a real 3.10 interpreter, not by reasoning about it: both guards now
RUN there (8 passed, 3 skipped — the 3 being the cross-checks, which belong to
the leg that has a parser). Full suite 503 passed / 80 skipped on 3.10 and 580
passed / 3 skipped on 3.12, same 583 collected on both; every one of the 77
extra skips accounted for (60 geometry cross-checks against `mixing`, 13 CLI
tests wanting `cw`, 3 meta-checks, 1 imageio-ffmpeg).

Claude-Session: https://claude.ai/code/session_01CnXLihXKG9jfvygHvgKVpX
…easurement

RULE G was ratified this session: `burns` keeps authored geometry, `looks` owns
compiling it. This is the `looks` half — keyframes in, an ffmpeg fragment out.
It does not ease, does not sample, does not decide where a camera goes, and
never imports `burns`; a window is anything with .x/.y/.w/.h in burns'
normalised convention, so the structural type is the seam.

**Which filter is not a matter of taste, and the fleet's recorded answer was
wrong.** `muvid._crop_filter`'s docstring — propagated by two of this package's
own research notes into a warning aimed at the `burns` owner — says *"Not
zoompan: its expression vocabulary has no t at all, and it duplicates frames on
video input."* Measured on ffmpeg 8.1 today, both premises are true and the
conclusion does not follow:

- `t` really is undefined in zoompan. But `in_time` and `it` are not, and
  `ffmpeg -h filter=zoompan` prints no variable list at all — which is very
  likely how the recorded fact came to be half-complete.
- It really does duplicate frames. At the DEFAULT d: 1800 out for 20 in. At
  `d=1` it is exactly 20 for 20.

Meanwhile the filter the notes recommended instead cannot do the job at all.
`crop`'s w/h do not merely evaluate once — `t` is not in scope and the filter
REFUSES TO CONFIGURE (`Error when evaluating the expression`), reproduced in
three invocation forms. That is stronger than this repo's own earlier note,
which recorded it as "evaluated once ... produced 60 frames of unchanged size";
nothing renders. So a Ken Burns path, which is a zoom by definition, has exactly
one compilable form. muvid's own code is fine — it compiles a *pan*, the case
where crop is right and simpler. What was wrong was generalising from "wrong for
my case" to "wrong filter".

**Three more zoompan traps, none of them in the notes, each a silent wrong
answer rather than an error — which is why each is now a refusal:**

- its x/y are in ORIGINAL input pixels, not zoomed ones. The two readings score
  60.5 dB and 6.3 dB against a crop+scale reference for the same window. The
  first implementation here had it wrong, and it configured cleanly.
- its `fps` silently retimes. At fps=25 on a 10 fps source the frame count is
  unchanged and a 2.0 s clip becomes 0.8 s — so a frame-count check, the obvious
  check, passes. Required here, never defaulted.
- its `zoom` is clamped at 10. z=10 scores 54.4 dB against the reference and
  z=12 scores 13.2 — not a worse rendering of the window asked for, a correct
  rendering of a different one. Windows below 1/10 of the frame are refused.

Also refused: a window that leaves the frame (ffmpeg would clamp to a different
framing, silently), keyframes that do not advance in time, a window that is not
the source's shape (zoompan's zoom is one scalar, so honouring one axis would
distort the other), and a zoom with no delivery size or rate.

The ramp is a sum of clamped terms rather than nested `if()`: exact at every
knot, linear between, and flat outside the path at both ends — so a clip that
outlives its path parks on the last window instead of drifting off it. A
segment that does not move emits no term, so a still axis is a constant that
ffmpeg reads once rather than an expression it evaluates per frame.

**The tests ask ffmpeg, not the strings.** String equality would pass for a
fragment ffmpeg refuses and equally for one that renders the wrong picture —
both of which happened here. So each fragment is built a second way by hand and
the two are compared with ffmpeg's own `psnr`: a static window is bit-identical
to the crop it names, a zoom is >45 dB from its crop+scale equivalent, and the
two filters' clocks agree frame for frame on the same pan. The threshold is not
a tuning knob — correct pairings score 54-60 dB and the nearest wrong ones score
6-13.

Full transcript: docs/research/00f_motion_filters_evidence.md. The warning to
hand the burns owner is withdrawn and replaced: the fast path is viable on
video, on four specific terms, and all four are compiled away so an adapter
remembers none of them.

628 tests on 3.12, 545 on 3.10.

Claude-Session: https://claude.ai/code/session_01CnXLihXKG9jfvygHvgKVpX
@thorwhalen thorwhalen changed the title Ratify RULE G and RULE N; the CLI on cw, not argh Ratify RULE G and RULE N; the CLI on cw; RULE G's compile half Sep 2, 2026
@thorwhalen
thorwhalen merged commit 87bc91c into main Sep 2, 2026
12 checks passed
@thorwhalen
thorwhalen deleted the ratify-and-cli branch September 2, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant