feat(u7): filename groups — the last v1 unit, and a table that did not reproduce

Completes U7 with its fourth component: a jump-to-group rail derived from
filename prefixes, replacing the subfolder sections ROADMAP named. The scope
departure was ratified by the operator 2026-09-22; this commit deletes
test_no_group_rail_is_shipped_yet, the guard that held it back, in the same
change that builds what it guarded against.

All seven v1 capabilities are now landed. The 1.0 cut is a decision, not a
dependency, and it is the operator's — no version bump here, because a commit
is not a release.

THE RULE CHANGED AT IMPLEMENTATION, ON MEASURED GROUNDS. The contract specified
`strip ONE trailing run of digits`; run against the live set that yields 24
groups for sindra-bakeoff's 40 images and 27 for sindra's 30 — a rail with a row
per tile — because it keys on the END of the stem, where the instance number
lives. The contract's own table claimed 5 and 1 for those two booths and neither
reproduces; the numbers are reachable only by two OTHER heuristics, so the table
that justified the design was assembled from more than one rule. Its own worked
example contradicts it in plain sight.

The shipped rule keys on the first separator-delimited segment, where the family
lives, destemming only when the stem has no separator at all — so `ac01` -> `ac`
while `v30-seed8302` and `v35-seed8302` stay apart. Re-measured across all 17
live booths; the table is in the contract.

INV-3 GAINED ITS SECOND DEGENERACY. The contract guarded one group for
everything (sc-iso-spread: DSC0001-DSC0006). The live set's actual failure is
the opposite — pewpew-ui-brief yields 23 groups for 34 items, dfa-concepts 13
for 20 — and the contract as written would have shipped a rail that is a second
copy of the grid. The rail now renders only when grouping is informative: two or
more groups, and the middle group holding more than one item. That predicate
gets all 17 booths right.

Grouping is a VIEW. The grid stays sorted(rel) and the zoom ring stays that
order filtered to images; the group fixture interleaves across subdirectories
precisely so a (group, rel) re-sort goes red. Groups are derived from the
RENDERED list, not the full gallery, so no anchor points at a filtered-out tile.

booth/items.py       _group_of + Item.group, derived in the resolver (INV-1)
booth/app.py         _groups() builds the rail rows; build_gallery carries it
booth/templates/     the rail-groups nav and its CSS
tests/               +16 tests; 639 green

Every new falsifier was proved by running its defeating change (12/12). Three
were vacuous first time out: one fixture's positional order happened to be
alphabetical, one assertion miscounted elements, and the harness itself
certified a broken test twice — no green baseline, and byte-identical mutations
silently defeated by the pyc cache's one-second mtime granularity.
This commit is contained in:
vh
2026-09-22 21:33:54 -07:00
parent 2f85692e95
commit bf351a26d1
11 changed files with 697 additions and 86 deletions
+96 -30
View File
@@ -1,6 +1,6 @@
---
contract_version: "0.1-PROPOSED"
status: "APPROVED 2026-09-22 and PARTIALLY LANDED. The operator ratified the scope departure (drop subfolder sections, add filename-prefix groups) and settled the `unanswered` open question in favour of the shipped reading. The three components ROADMAP already ratifies — rail, filters, grid keyboard — are implemented and deployed (tests/test_navigation.py, 12 tests). The FOURTH, replacing directory sections with filename-derived groups, is NOT built and is the one scope-direction call below. The scope below departs from ROADMAP's U7 row on measured grounds and the operator has not ruled on it. Do not implement, and do not treat this as settled, until he has."
status: "LANDED 2026-09-22, all four components. The operator ratified the scope departure (drop subfolder sections, add filename-prefix groups) and settled the `unanswered` open question in favour of the shipped reading. Rail, filters and grid keyboard landed at a306e2d; the groups landed in the commit carrying this revision, which also DELETED tests/test_navigation.py::test_no_group_rail_is_shipped_yet — the guard that held the departure back while the ruling was outstanding. ⚠ TWO THINGS IN THIS CONTRACT CHANGED AT IMPLEMENTATION, both measured rather than preferred: the grouping RULE (see Signatures) and INV-3, which guarded one degeneracy and needed to guard two. The original text of both is kept below, struck, because the reasoning is the useful part."
module: "booth.items + booth.app (gallery navigation)"
purpose: "The last unit before the 1.0 cut. A gallery booth renders as one flat wall with no way to filter it, no way to move through it from the keyboard, and no grouping — so a review of sixty-odd renders is a scroll-and-squint. ROADMAP names four components: sections, a sticky rail, filters, grid keyboard. THE MEASUREMENT KILLS THE FIRST AND REPLACES IT: not one of the eleven live gallery booths has a subdirectory, so sections buy nothing, while a filename-prefix heuristic yields 5-16 sensible groups on four of the five large galleries. This unit ships the rail, the filters, the grid keyboard, and GROUPS DERIVED FROM FILENAMES rather than from a directory tree that does not exist."
depends_on:
@@ -24,8 +24,8 @@ touches:
- "tests/test_navigation.py (new — rail, filters, keyboard)"
- "ROADMAP.md (the deterministic-order table gains the group row; U7's row is rewritten)"
assumptions:
- "⚠ THE SCOPE DEPARTS FROM ROADMAP AND THE OPERATOR HAS NOT APPROVED IT. ROADMAP's U7 row says `sections, rail, filters, grid keyboard`. This contract drops sections and adds filename groups. The evidence is in `persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md`: zero of eleven gallery booths have a subdirectory, the only two booths that do are reports, and `pewpew-ui-brief`'s seven subdirectories hold one image between them. THIS IS THE ONE THING IN THIS DOCUMENT THAT IS A SCOPE-DIRECTION CALL RATHER THAN AN IMPLEMENTATION ONE, and it is his."
- "THE GROUP HEURISTIC DEGENERATES, AND THAT IS A FIRST-CLASS CASE RATHER THAN AN EDGE. Measured: `sindra` yields ONE group for thirty images because every filename starts `00-`. A single group must render as NO rail rather than as a rail with one entry -- a navigation affordance that cannot navigate is worse than none, because it occupies the space where the real one would be."
- "THE SCOPE DEPARTURE WAS RATIFIED BY THE OPERATOR 2026-09-22. ROADMAP's U7 row said `sections, rail, filters, grid keyboard`; this contract drops sections and adds filename groups. The evidence is in `persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md`: zero of eleven gallery booths have a subdirectory, the only two booths that do are reports, and `pewpew-ui-brief`'s seven subdirectories hold one image between them."
- "THE GROUP HEURISTIC DEGENERATES IN TWO DIRECTIONS, NOT ONE, AND THIS CONTRACT ORIGINALLY SAW ONLY THE FIRST. (a) ONE GROUP FOR EVERYTHING -- live specimen `sc-iso-spread`, `DSC0001.jpg` through `DSC0006.jpg`. (b) ONE GROUP PER ITEM -- live specimens `pewpew-ui-brief` at 23 groups for 34 items and `dfa-concepts` at 13 for 20. Both render as NO rail, because a navigation affordance that cannot navigate is worse than none: it occupies the space where the real one would be. Degeneracy (b) is the one the shipped rule actually meets on the live set, and the contract as first written would have shipped it everywhere."
- "GROUPING IS A VIEW, NEVER A REORDERING. The item order stays `sorted(rel)` (U1 INV-3) and the zoom ring stays that order filtered to images. Grouping and filtering change what is SHOWN and never the sequence -- so `the third one` means the same thing with a filter on as with it off, and a flag lands where the operator thinks it does. This is the whole of CLAUDE.md invariant 6 applied to a surface that did not exist when it was written."
- "THE PAGE WORKS WITH NO JAVASCRIPT. Filters are links with a query parameter, resolved server-side; the rail is anchors. Keyboard is the one genuinely JS-only affordance and it is additive -- the page is fully usable without it. U3 cost the verbatim path its no-JS operation and said so plainly; this unit must not quietly do the same to the gallery, which is the surface the operator actually reviews on."
- "VIRTUALIZATION STAYS PARKED. The largest gallery is 66 images. ROADMAP parks progressive loading with `measure the real booth before optimising it`; at this size a lazy grid is almost certainly fine, and inventing the work is the failure the parking lot exists to prevent."
@@ -36,14 +36,14 @@ open_questions:
# U7 — navigation at the size the booths actually are
**⚠ PARTIALLY LANDED, DELIBERATELY.**
**LANDED — all four components.**
| component | ROADMAP says | state |
|---|---|---|
| sticky rail | ratified | **landed** — totals + per-filter counts, links not scripts |
| filters | ratified | **landed** — all / flagged / annotated / unanswered |
| grid keyboard | ratified | **landed** — `←/→ f n Enter Esc`, bound only when a grid exists |
| **sections → filename groups** | **departs from it** | **APPROVED 2026-09-22, NOT YET BUILT.** The operator ratified the departure. `test_no_group_rail_is_shipped_yet` is now a STALE GUARD and must be deleted by whoever builds the group rail — it exists to stop an unapproved build, and the build is approved. |
| **sections → filename groups** | **departs from it** | **landed** — ratified by the operator 2026-09-22. `test_no_group_rail_is_shipped_yet`, the guard that held it back, was deleted in the same commit that built it. |
`unanswered` means **has an open pick** — the U4 hold predicate. **Settled by
the operator 2026-09-22**; the "has no mark at all" reading is a different
@@ -65,19 +65,51 @@ and the sizing is not:
## Sections are dead. The prefix is not.
Strip a trailing digit-run from each stem and group on what remains:
⚠ **THE TABLE BELOW IS THE RE-MEASUREMENT, AND IT DISAGREES WITH THE ONE THIS
CONTRACT WAS WRITTEN ON.** The original claimed the rule `strip ONE trailing
run of digits` produced **5** groups on `sindra-bakeoff` and **1** on `sindra`.
Neither reproduces: that rule gives **24** and **27**. The original table's own
worked example says so out loud — it notes `00-sheet-c1-market-noon.png` has no
trailing digit run and therefore groups as its whole stem, which makes eight of
bakeoff's forty images eight singleton groups. **The numbers 5 and 1 are
reproducible only by two OTHER rules** (first-two-segments gives exactly 5 on
bakeoff; first-segment gives exactly 1 on sindra), so the table that justified
this design was assembled from more than one heuristic. Caught by implementing
the stated rule and running it against the live set rather than trusting the
table beside it.
| booth | images | groups | |
|---|---|---|---|
| `sindra-corpus-v1` | 66 | **16** | `ac01.png` |
| `sindra-nude-pool` | 42 | **12** | `flag-rear.png` |
| `sindra-sfw-pool` | 59 | **10** | `a01.png` |
| `sindra-bakeoff` | 40 | **5** | `00-sheet-c1-market-noon.png` |
| `sindra` | 30 | **1** | `00-b1-sheet-1.png` — **degenerates** |
**The shipped rule** — first separator-delimited segment, destemmed only when
the stem has no separator — measured against all 17 live booths, 2026-09-22.
`G` is groups, `med` the middle group's size, `sing` the singleton groups:
Four of five. The competing heuristic — split on the second hyphen — yields 59
"groups" from 59 files and is useless everywhere. The degenerate fifth is the
case the design has to carry, not the case that invalidates it.
| booth | items | G | med | sing | rail? |
|---|---|---|---|---|---|
| `sindra-corpus-v1` | 66 | 11 | 5 | 4 | **yes** — `ac 12 · bu 10 · cu 12 · fb 12 · … · wu 8` |
| `sindra-sfw-pool` | 59 | 6 | 11 | 0 | **yes** |
| `sindra-nude-pool` | 42 | 9 | 4 | 1 | **yes** |
| `sindra-bakeoff` | 41 | 4 | 12 | 1 | **yes** — `00 · README · m · r`, the three real families |
| `sindra` | 31 | 2 | 15 | 1 | **yes** |
| `muse-clothed-repro` | 7 | 3 | 2 | 1 | **yes** — `v30`/`v35`, the axis that booth is about |
| `pewpew-ui-brief` | 34 | 23 | 1 | 19 | no — **degeneracy (b)** |
| `dfa-concepts` | 20 | 13 | 1 | 8 | no — **degeneracy (b)** |
| `cr123a-to-d-sleeve` | 7 | 6 | 1 | 5 | no — degeneracy (b) |
| `sc-iso-spread` | 6 | 1 | 6 | 0 | no — **degeneracy (a)**, `DSC0001`–`DSC0006` |
| `music3-songs`, `krea2-lora-portability` | 3 | 1 | 3 | 0 | no — degeneracy (a) |
| `miranda-is`, `sindra-voice-1` | 47 / 10 | 10 / 6 | 2 / 2 | 3 / 2 | **no grid at all** — both carry `index.html` and take the verbatim path |
**Why the rule changed.** `strip ONE trailing run of digits` keys on the END of
the stem, which is where the *instance number* lives — so it separates
`m-c1-market-noon-9401` from `m-c2-rain-street-9403`, which are the same family.
The shipped rule keys on the START, which is where the *family* lives. The
competing heuristics measured and rejected: split-on-second-hyphen (59 groups
from 59 files), and destemming the first segment unconditionally (merges `v30`
with `v35`).
**The honest cost.** Destemming a flat stem is what makes `ac01.png` → `ac`
work, and it is exactly what would merge `v30` with `v35` if applied to a
segmented name. The rule therefore has a conditional in it, which is one more
thing than "take the first segment" — paid because `sindra-corpus-v1`, the
largest gallery, is entirely flat names.
## What ships
@@ -95,27 +127,38 @@ case the design has to carry, not the case that invalidates it.
def _group_of(rel: str) -> str | None:
"""The grouping key for an item, or None when it has none.
THE RULE: take the stem of the basename, strip ONE trailing run of digits
and any single separator before it, and return what remains. `ac01.png` →
`ac`; `00-sheet-c1-market-noon.png` → `00-sheet-c1-market-noon` (no
trailing digit run, so the whole stem); `flag-rear.png` → `flag-rear`.
THE RULE, in one line: the first separator-delimited segment of the
basename's stem -- with a trailing digit run stripped only when the stem has
no separator at all.
Returns None for a stem that is ENTIRELY a digit run — `01.png` has no
prefix to group on, and inventing one would put every numbered file in a
group named after the empty string.
00-sheet-c1-market-noon.png -> 00
m-c1-market-noon-9401.png -> m
flag-rear.png -> flag
ac01.png -> ac (no separator: the digits ARE it)
DSC0001.jpg -> DSC
v30-seed8302.png -> v30 (separator present, so v30 != v35)
01.png -> None (nothing before the digits)
Derived HERE and nowhere else (INV-1). A route body that re-derived it
would be the caption bug in a new field.
Derived HERE and nowhere else (INV-1).
"""
```
~~**SUPERSEDED — the rule this contract was written with.**~~ *"take the stem of
the basename, strip ONE trailing run of digits and any single separator before
it. `ac01.png` → `ac`; `00-sheet-c1-market-noon.png` → `00-sheet-c1-market-noon`
(no trailing digit run, so the whole stem); `flag-rear.png` → `flag-rear`."*
Kept struck rather than deleted: it is the rule the measurement table above was
supposed to describe, and the mismatch between the two is the thing worth
remembering. It keys on the end of the stem, where the instance number lives,
and so splits families rather than gathering them.
## Ordering — the rule, because invariant 6 binds
| collection | rule |
|---|---|
| items | **unchanged** — `sorted(rel)` (U1 INV-3) |
| the zoom ring | **unchanged** — item order filtered to images |
| **groups among themselves** | **the position of each group's FIRST member in `sorted(rel)`** — so the rail reads in the same direction the grid does, and adding a file never reshuffles the rail unless it lands first in its group |
| **groups among themselves** | **the position of each group's FIRST member in the RENDERED sequence** — which is `sorted(rel)` narrowed by the filter and never re-sorted. So the rail reads in the same direction the grid does, and adding a file never reshuffles the rail unless it lands first in its group. Implemented by walking `shown` once into an insertion-ordered `dict`: the walk IS the rule, so there is no second sort to drift from it. |
| items within a group | **unchanged** — they are a filtered view of `sorted(rel)`, never re-sorted |
| the filtered grid | **unchanged** — `sorted(rel)` with non-matching items hidden |
@@ -137,10 +180,33 @@ booth whose groups interleave in `sorted(rel)` order and asserts the rendered
item sequence is **byte-identical** with grouping on and off, and that
`image_chain` is unchanged under every filter.
**INV-3 — one group renders NO rail.** *Falsifiable:* the defeating change is
`{% if groups %}`, which is true for a single group. The test uses the real
`sindra`-shaped fixture (thirty files, one prefix) and asserts the rail element
is absent — not merely that it lists one entry.
**INV-3 — a rail that cannot navigate does not render, in EITHER direction of
degeneracy.** The rail is absent unless grouping is informative: **two or more
groups, and the middle group holding more than one item.**
- **(a) one group for everything.** Live specimen `sc-iso-spread`:
`DSC0001.jpg`–`DSC0006.jpg`, one group, six images. A rail with a single row
cannot navigate.
- **(b) one group per item.** Live specimens `pewpew-ui-brief` (23 groups for
34 items) and `dfa-concepts` (13 for 20). A rail with a row per tile is a
second copy of the grid.
*Falsifiable:* two defeating changes, each with its own test. `{% if
rail.groups %}` in the template is true for a single group and true for N
singletons — so the decision lives in Python, where it can be measured, and the
template guard is the whole of it. Dropping the `>= 2` term reds
`test_no_group_rail_when_there_is_only_one_group`; dropping the median term
reds `test_no_group_rail_when_every_item_is_its_own_group`. Both mutations were
RUN.
~~**SUPERSEDED — INV-3 as first written.**~~ *"one group renders NO rail …the
test uses the real `sindra`-shaped fixture (thirty files, one prefix)."* Two
things wrong with it, and the second is why this is kept: the `sindra` fixture
does not exist (that booth yields 27 groups under the rule stated beside it,
and 2 under the shipped one — `sc-iso-spread` is the real specimen), and it
guarded only degeneracy (a) when (b) is the one the live set actually
exhibits. A contract that had shipped as written would have put a 23-row rail
on `pewpew-ui-brief`.
**INV-4 — a filter is a link, not a script.** *Falsifiable:* the defeating
change is binding filters to a click handler. The test fetches the filtered URL