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.
16 KiB
contract_version, status, module, purpose, depends_on, language, complexity, estimated_loc, confidence, used_by, touches, assumptions, open_questions
| contract_version | status | module | purpose | depends_on | language | complexity | estimated_loc | confidence | used_by | touches | assumptions | open_questions | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0.1-PROPOSED | 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. | booth.items + booth.app (gallery navigation) | 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. |
|
python + jinja + a little javascript | medium | 300 | 0.6 |
|
|
|
|
U7 — navigation at the size the booths actually are
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 | 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
question and is parked, not pending.
The defect, re-measured rather than inherited
ROADMAP sizes this unit for 270 items. The largest gallery is now 81 items and 40 images. The four booths it was written against were swept on 2026-09-22 and the set churned again during that session. The defect is real and the sizing is not:
| ROADMAP's premise | measured 2026-09-22 | |
|---|---|---|
| largest gallery | 270 images, one flat wall | sindra-bakeoff, 40 images |
| galleries with subdirectories | "sections come from subfolders, which already exist" | 0 of 11 |
| booths with subdirectories at all | — | 2, and both are reports |
| grouping signal that does exist | — | the filename prefix |
Sections are dead. The prefix is not.
⚠ 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.
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:
| 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
Item.group— derived once, in the resolver, besidesection.- A sticky rail — total, per-group counts, per-filter counts, jump-to-group anchors. Absent entirely when there is one group or fewer.
- Filters — all / flagged / annotated / unanswered, as server-resolved query parameters so they work with JS off.
- Grid keyboard —
←/→move focus,fflags,nopens a note,Enterzooms,Escclears focus. Additive; the page is complete without it.
Signatures
def _group_of(rel: str) -> str | None:
"""The grouping key for an item, or None when it has none.
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.
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).
"""
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 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 |
This closes ROADMAP's outstanding U7 order question. Compare pairing is not this unit's problem — compare mode is parked to v1.1 with the pairing rule.
Invariants
INV-1 — one resolver derives the group. _group_of is called only from
booth_items. Falsifiable: the defeating change is a route or template
computing a prefix inline. The test asserts no call to _group_of survives
inside create_app — the same assertion U1 makes for classify and
render_doc, which is why it is the shape used here.
INV-2 — grouping and filtering never reorder. Falsifiable: the defeating
change is sorting by (group, rel) to make the grid render contiguously, which
looks right and silently changes what "the third one" means. The test renders a
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 — 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) anddfa-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 directly and asserts the server returned the filtered grid, with no JS executed.
INV-5 — the keyboard never fires on a booth with no grid. Falsifiable:
the defeating change is binding the handler unconditionally, so f on the
standing link board flags nothing and swallows the keystroke. The test asserts
the handler is not bound when items is empty.
Out of scope
- Sections as a rail. Measured worthless;
Item.sectionis untouched. - Compare mode. Parked to v1.1 with its pairing rule.
- Virtualized loading. Parked; measure first.
- A
.groupsoverride file. See open questions. - Anything on the verbatim path. It has no grid.