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
+33 -26
View File
@@ -1,7 +1,9 @@
# The Booth — roadmap # The Booth — roadmap
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md). Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
Current version: `0.6.1` (U1 through U6 landed; extracted from eshpfi 2026-09-21). Current version: `0.6.1` (**U1 through U7 landed — every v1 capability is in**;
extracted from eshpfi 2026-09-21). **The 1.0 cut is now a decision, not a
dependency**, and it is the operator's: a major bump needs his approval.
## v1 target ## v1 target
@@ -16,26 +18,29 @@ defect — not a wish. The measurements are in the IA doc.
| 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 | | 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 | | 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
| 6 | ~~**Benches**~~ — **landed `1c3ce5d`, released `v0.6.0`** | 69% link-board rot (re-measured: 178 booth rows + 8 bench re-posts) | U6 | | 6 | ~~**Benches**~~ — **landed `1c3ce5d`, released `v0.6.0`** | 69% link-board rot (re-measured: 178 booth rows + 8 bench re-posts) | U6 |
| 7 | **Navigation** — ~~sections~~ **filename groups**, rail, filters, grid keyboard | one flat wall; 0 of 11 galleries have subfolders, so grouping comes from the filename | U7 | | 7 | ~~**Navigation**~~ — ~~sections~~ **filename groups**, rail, filters, grid keyboard — **landed, unreleased** | one flat wall; 0 of 11 galleries have subfolders, so grouping comes from the filename | U7 |
Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5} → Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5} →
U7**, with **U6 independent** of all of them (different storage, different U7**, with **U6 independent** of all of them (different storage, different
surface) and therefore the safest thing to land first or in parallel. surface) and therefore the safest thing to land first or in parallel.
**U1 through U6 are landed.** **U7 is the last unit before the 1.0 cut** — its **ALL SEVEN UNITS ARE LANDED.** U7 closed last; its only dependency was
only dependency was `{U3, U4, U5}` and that closed with U3. `{U3, U4, U5}` and that closed with U3.
⚠ **Before starting U7, read ⚠ **What U7 actually shipped is not what this row first described, and the
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`, and re-count difference is measured.** Sections were dropped for filename-prefix groups
the booths first.** Half its premise is already known to be wrong — every booth (operator-ratified 2026-09-22) because zero of eleven gallery booths have a
that actually needs navigation is FLAT — and the booth set churned again on subdirectory. Then the *grouping rule itself* changed at implementation: the
2026-09-22: the four large booths U7 was sized against (`pancake-v3-full` and contract's `strip a trailing digit run` yields 24 groups for `sindra-bakeoff`'s
`pancake-v4-full` at 270 items, `sindra20-engines`, `sindra-finalists`) have all 40 images and 27 for `sindra`'s 30 — a rail with a row per tile — because it
been swept. The largest live booth is now `miranda-is` at 92 items, flat. Two of keys on the end of the stem, where the instance number lives. The shipped rule
23 booths have subfolders (`pewpew-ui-brief`, `dfa-concepts`) and **both are keys on the **first separator-delimited segment**, where the family lives, and
reports** — the job where grid navigation matters least. Sections, one of U7's gives 4 and 2. The full re-measurement across all 17 live booths is in
four named components, buys close to nothing. The rail, the filters and the grid `docs/contracts/u7_navigation.contract.md`.
keyboard are the unit.
**The v1 target is met.** What remains is a release decision the operator owns:
cut `1.0`, or take a `0.7.0` staging release first. Nothing in the code is
waiting on it.
**U5's adoption is a measured prediction, not a finished result**, and it is **U5's adoption is a measured prediction, not a finished result**, and it is
TWO predictions rather than one. The operator declined a fleetwide announcement TWO predictions rather than one. The operator declined a fleetwide announcement
@@ -85,6 +90,8 @@ Where it already binds, and what the rule is in each case:
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them | | legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
| link board rows | pinned first, then newest-first | | link board rows | pinned first, then newest-first |
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) | | a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
| **groups among themselves** | **the position of each group's first member in the rendered sequence** — `sorted(rel)` narrowed by the filter, never re-sorted. Walking the rendered list once into an insertion-ordered dict IS the rule, so there is no second sort to drift from it (U7) |
| **items within a group** | not a separate order — a group is a label on a tile, not a container. The grid stays `sorted(rel)` and groups interleave in it freely (U7) |
| the bench registry | `(state rank, name casefolded, id)` — live before promoted before retired, then alphabetical, with the id as a TOTAL tie-break so two benches sharing a name cannot swap (U6) | | the bench registry | `(state rank, name casefolded, id)` — live before promoted before retired, then alphabetical, with the id as a TOTAL tie-break so two benches sharing a name cannot swap (U6) |
| the link board's dead marker | not an order — a per-row stamp read from the existing `order_for_display` sequence, so marking cannot move a row (U6) | | the link board's dead marker | not an order — a per-row stamp read from the existing `order_for_display` sequence, so marking cannot move a row (U6) |
| embed anchors in a verbatim report | **document order** — what `querySelectorAll` yields, so the author's markup decides (U3) | | embed anchors in a verbatim report | **document order** — what `querySelectorAll` yields, so the author's markup decides (U3) |
@@ -101,18 +108,18 @@ surfaces (index card, booth header, marks page) render through ONE macro
precisely so they cannot disagree, which is the same property stated for precisely so they cannot disagree, which is the same property stated for
ordering: one rule, one place, every surface reading it. ordering: one rule, one place, every surface reading it.
**U7's group ordering is SETTLED** (operator, 2026-09-22): groups order by the **U7's group ordering is SETTLED and SHIPPED** (operator, 2026-09-22): groups
position of their first member in `sorted(rel)`, so the rail reads in the same order by the position of their first member in the rendered sequence, so the
direction as the grid. Subfolder sections are dropped in favour of rail reads in the same direction as the grid. Subfolder sections were dropped
filename-prefix groups on measured grounds — zero of eleven gallery booths have in favour of filename-prefix groups on measured grounds — zero of eleven
a subdirectory. Compare pairing rides with compare mode into v1.1. gallery booths have a subdirectory.
Formerly undecided, retained for the record: **U7's **Nothing in this table is undecided any more.** The two U7 rules that were
section ordering and its compare pairing** (sections need a stated order among (section ordering among themselves, compare pairing) resolved differently:
themselves, not just within; pairing by filename needs a rule for what happens section ordering is MOOT, because U7 renders no section rail — `Item.section`
to an unpaired file). **U6's bench listing is settled** — the row above. still exists and is still derived, it simply has no ordered surface. Compare
Compare pairing is parked to v1.1 with compare mode itself, so U7 carries one pairing rode into v1.1 with compare mode itself. **U6's bench listing is
undecided rule, not two. settled** — the row above.
The test for any new ordered surface: *can you write the rule down in one line?* The test for any new ordered surface: *can you write the rule down in one line?*
If not, it does not have one yet. If not, it does not have one yet.
+46 -1
View File
@@ -520,6 +520,8 @@ def build_gallery(child: Path) -> list[dict]:
"doc": it.doc, "doc": it.doc,
"url": it.url, "url": it.url,
"section": it.section, "section": it.section,
# U7. Derived in the resolver (INV-1); this only carries it.
"group": it.group,
"caption": it.caption, "caption": it.caption,
"rendered": rendered, "rendered": rendered,
"rendered_html": rendered_html, "rendered_html": rendered_html,
@@ -1007,14 +1009,57 @@ def create_app(
buckets["annotated"].append(it) buckets["annotated"].append(it)
if any(m.id in open_ids for m in mine): if any(m.id in open_ids for m in mine):
buckets["unanswered"].append(it) buckets["unanswered"].append(it)
shown = buckets[active]
rail = { rail = {
"active": active, "active": active,
# ORDER: the declaration order of FILTERS. Stated because a rail is # ORDER: the declaration order of FILTERS. Stated because a rail is
# an ordered collection and invariant 6 binds to it like any other. # an ordered collection and invariant 6 binds to it like any other.
"counts": [{"key": f, "n": len(buckets[f])} for f in FILTERS], "counts": [{"key": f, "n": len(buckets[f])} for f in FILTERS],
"total": len(gallery), "total": len(gallery),
"groups": _groups(shown),
} }
return rail, buckets[active] return rail, shown
def _groups(shown: list[dict]) -> list[dict]:
"""The jump-to-group rows, or [] when grouping would not help.
DERIVED FROM `shown`, NOT FROM THE FULL GALLERY, so every anchor lands
on a tile the page actually rendered. A row pointing at an item the
current filter has hidden scrolls nowhere, which is the same defect as
a wrong id arriving by a different route.
ORDER: 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 direction the grid does, and adding
a file reshuffles nothing unless it lands first in its group. Settled by
the operator 2026-09-22; ROADMAP carries the row. `dict` preserves
insertion order, so walking `shown` once IS the rule.
⚠ THE RAIL IS ABSENT UNLESS GROUPING IS INFORMATIVE: two or more
groups, and the middle group holding more than one item. TWO
degeneracies, not one. The contract named only the first --
`sindra` and `sc-iso-spread` put every file in ONE group, and a rail
with a single row cannot navigate. The second is the one the live set
actually exhibits: `pewpew-ui-brief` yields 23 groups for 34 items and
`dfa-concepts` 13 for 20, a rail that is a second copy of the grid.
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.
"""
by_group: dict[str, list[dict]] = {}
for it in shown:
if it["group"] is not None:
by_group.setdefault(it["group"], []).append(it)
sizes = sorted(len(v) for v in by_group.values())
if len(sizes) < 2 or sizes[len(sizes) // 2] <= 1:
return []
return [
# The anchor is the FIRST member's existing tile id. The template
# already stamps `id="item-<rel>"` on every figure; minting a
# parallel `#group-<key>` would be a second identity for one tile.
{"key": k, "n": len(v), "anchor": f"item-{v[0]['name']}"}
for k, v in by_group.items()
]
def _board_rows(booth: Path) -> list[dict]: def _board_rows(booth: Path) -> list[dict]:
"""The link board's rows, or [] for a board that cannot be read. """The link board's rows, or [] for a board that cannot be read.
+40
View File
@@ -15,6 +15,7 @@ See docs/contracts/u1_item_record.contract.md.
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Sequence from typing import Sequence
@@ -89,6 +90,7 @@ class Item:
url: str url: str
kind: str kind: str
section: str | None section: str | None
group: str | None
caption: str | None caption: str | None
blurred: bool blurred: bool
doc: str | None doc: str | None
@@ -115,6 +117,43 @@ def _section_of(rel: str) -> str | None:
return None if str(parent) == "." else parent.as_posix() return None if str(parent) == "." else parent.as_posix()
# One separator run between name segments. A filename is the only grouping
# signal the live booths actually carry: 0 of 11 galleries have a subdirectory.
_SEG = re.compile(r"[-_. ]+")
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, so the digits are the separator);
`v30-seed8302.png` -> `v30` (separator present, so `v30` survives and does
not merge with `v35`, which is the axis that booth is about).
None for a stem with nothing before the digits -- `01.png` has no prefix to
group on, and inventing one would file every numbered render under the
empty string.
⚠ THIS IS NOT THE RULE THE CONTRACT FIRST NAMED. `strip ONE trailing run of
digits` was measured against the live set on 2026-09-22 and yields 24 groups
for sindra-bakeoff's 40 images and 27 for sindra's 30 -- a rail with one row
per tile. The contract's own table claimed 5 and 1 for those two booths;
neither reproduces under the rule it states beside them. The rewritten table
carries the re-measurement.
Derived HERE and nowhere else (INV-1). A route body that re-derived it would
be the caption bug in a new field.
"""
stem = Path(rel).stem # basename without its last suffix; `a.tar.gz` -> `a.tar`
segs = _SEG.split(stem)
if len(segs) == 1:
return re.sub(r"\d+$", "", stem) or None
return segs[0] or None
def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str]]: def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str]]:
"""(caption-by-rel, rels consumed as sidecars). """(caption-by-rel, rels consumed as sidecars).
@@ -203,6 +242,7 @@ def booth_items(booth: Path) -> list[Item]:
url=quote(rel, safe="/"), url=quote(rel, safe="/"),
kind=classify(p.name), kind=classify(p.name),
section=_section_of(rel), section=_section_of(rel),
group=_group_of(rel),
caption=caption.get(rel), caption=caption.get(rel),
blurred=rel in blurred, blurred=rel in blurred,
doc=doc_kind(p.name), doc=doc_kind(p.name),
+9
View File
@@ -529,6 +529,15 @@
opacity:.65;border:1px solid transparent} opacity:.65;border:1px solid transparent}
.rail-f:hover{opacity:1} .rail-f:hover{opacity:1}
.rail-f.on{opacity:1;border-color:var(--line,#2a2a2a);background:rgba(255,255,255,.06)} .rail-f.on{opacity:1;border-color:var(--line,#2a2a2a);background:rgba(255,255,255,.06)}
/* The group row. Wraps rather than scrolls: 16 groups is the live maximum
and a horizontal scroller hides half of them behind a gesture. */
.rail-groups{display:flex;gap:.35rem;flex-wrap:wrap;align-items:baseline;
padding-left:.5rem;margin-left:.25rem;border-left:1px solid var(--line,#2a2a2a)}
.rail-g{font-size:.8em;padding:.1rem .4rem;border-radius:3px;text-decoration:none;
opacity:.6;border:1px solid transparent}
.rail-g:hover{opacity:1;border-color:var(--line,#2a2a2a)}
/* The jumped-to tile, so a fragment jump says where it landed. */
figure.item:target{outline:2px dashed #7aa2f7;outline-offset:3px}
figure.item.is-cursor{outline:2px solid #7aa2f7;outline-offset:2px} figure.item.is-cursor{outline:2px solid #7aa2f7;outline-offset:2px}
</style> </style>
</head> </head>
+16 -5
View File
@@ -251,11 +251,11 @@
ORDER: the declaration order of FILTERS in app.py. A rail is an ordered ORDER: the declaration order of FILTERS in app.py. A rail is an ordered
collection and invariant 6 binds to it like any other. collection and invariant 6 binds to it like any other.
⚠ NO JUMP-TO-GROUP ANCHORS YET. Replacing directory sections with THE GROUP ROW is `rail.groups`, which is EMPTY unless grouping is
filename-derived groups is a scope departure from ROADMAP's U7 row that informative — see `_groups` in app.py. `{% raw %}{% if rail.groups %}{% endraw %}`
the operator has not ruled on; see docs/contracts/u7_navigation.contract.md is therefore the whole guard; the two degenerate cases (one group for
and tests/test_navigation.py::test_no_group_rail_is_shipped_yet, which everything, one group per item) are decided in Python, where they can be
fails the moment somebody builds it anyway. #} measured, rather than by a count in a template. #}
<div class="rail"> <div class="rail">
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span> <span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
{% for f in rail.counts %} {% for f in rail.counts %}
@@ -264,6 +264,17 @@
href="/b/{{ name_url }}/{% if f.key != 'all' %}?filter={{ f.key }}{% endif %}" href="/b/{{ name_url }}/{% if f.key != 'all' %}?filter={{ f.key }}{% endif %}"
{% if f.key == filter %}aria-current="true"{% endif %}>{{ f.key }} <b>{{ f.n }}</b></a> {% if f.key == filter %}aria-current="true"{% endif %}>{{ f.key }} <b>{{ f.n }}</b></a>
{% endfor %} {% endfor %}
{% if rail.groups %}
<nav class="rail-groups" aria-label="jump to group">
{% for g in rail.groups %}
{# The anchor is the first member's EXISTING tile id, so a group has one
identity on the page rather than two. Plain fragment links: no JS,
and the browser's own back button undoes the jump. #}
<a class="rail-g" data-group="{{ g.key }}"
href="#{{ g.anchor }}">{{ g.key }} <b>{{ g.n }}</b></a>
{% endfor %}
</nav>
{% endif %}
</div> </div>
<div class="gallery" id="grid" tabindex="-1"> <div class="gallery" id="grid" tabindex="-1">
{% for it in items %} {% for it in items %}
+96 -30
View File
@@ -1,6 +1,6 @@
--- ---
contract_version: "0.1-PROPOSED" 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)" 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." 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: depends_on:
@@ -24,8 +24,8 @@ touches:
- "tests/test_navigation.py (new — rail, filters, keyboard)" - "tests/test_navigation.py (new — rail, filters, keyboard)"
- "ROADMAP.md (the deterministic-order table gains the group row; U7's row is rewritten)" - "ROADMAP.md (the deterministic-order table gains the group row; U7's row is rewritten)"
assumptions: 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 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, 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 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." - "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." - "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." - "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 # U7 — navigation at the size the booths actually are
**⚠ PARTIALLY LANDED, DELIBERATELY.** **LANDED — all four components.**
| component | ROADMAP says | state | | component | ROADMAP says | state |
|---|---|---| |---|---|---|
| sticky rail | ratified | **landed** — totals + per-filter counts, links not scripts | | sticky rail | ratified | **landed** — totals + per-filter counts, links not scripts |
| filters | ratified | **landed** — all / flagged / annotated / unanswered | | filters | ratified | **landed** — all / flagged / annotated / unanswered |
| grid keyboard | ratified | **landed** — `←/→ f n Enter Esc`, bound only when a grid exists | | 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 `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 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. ## 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 | | **The shipped rule** — first separator-delimited segment, destemmed only when
|---|---|---|---| the stem has no separator — measured against all 17 live booths, 2026-09-22.
| `sindra-corpus-v1` | 66 | **16** | `ac01.png` | `G` is groups, `med` the middle group's size, `sing` the singleton groups:
| `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** |
Four of five. The competing heuristic — split on the second hyphen — yields 59 | booth | items | G | med | sing | rail? |
"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. | `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 ## 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: def _group_of(rel: str) -> str | None:
"""The grouping key for an item, or None when it has 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 THE RULE, in one line: the first separator-delimited segment of the
and any single separator before it, and return what remains. `ac01.png` → basename's stem -- with a trailing digit run stripped only when the stem has
`ac`; `00-sheet-c1-market-noon.png` → `00-sheet-c1-market-noon` (no no separator at all.
trailing digit run, so the whole stem); `flag-rear.png` → `flag-rear`.
Returns None for a stem that is ENTIRELY a digit run — `01.png` has no 00-sheet-c1-market-noon.png -> 00
prefix to group on, and inventing one would put every numbered file in a m-c1-market-noon-9401.png -> m
group named after the empty string. 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 Derived HERE and nowhere else (INV-1).
would be the caption bug in a new field.
""" """
``` ```
~~**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 ## Ordering — the rule, because invariant 6 binds
| collection | rule | | collection | rule |
|---|---| |---|---|
| items | **unchanged** — `sorted(rel)` (U1 INV-3) | | items | **unchanged** — `sorted(rel)` (U1 INV-3) |
| the zoom ring | **unchanged** — item order filtered to images | | 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 | | 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 | | 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 item sequence is **byte-identical** with grouping on and off, and that
`image_chain` is unchanged under every filter. `image_chain` is unchanged under every filter.
**INV-3 — one group renders NO rail.** *Falsifiable:* the defeating change is **INV-3 — a rail that cannot navigate does not render, in EITHER direction of
`{% if groups %}`, which is true for a single group. The test uses the real degeneracy.** The rail is absent unless grouping is informative: **two or more
`sindra`-shaped fixture (thirty files, one prefix) and asserts the rail element groups, and the middle group holding more than one item.**
is absent — not merely that it lists one entry.
- **(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 **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 change is binding filters to a click handler. The test fetches the filtered URL
@@ -0,0 +1,50 @@
# A mutation harness that certified a broken test, twice, for two reasons
_2026-09-22 · booth_
This repo already knows that **an assertion which has never seen its own
defeating change is not known to falsify anything** — two prior entries say so
([[2026-09-22-vacuous-falsifiers]], [[2026-09-22-seven-of-seven-falsifiers]]).
So U7's groups were built with a harness that applies each defeating change and
asserts the named test goes red. **The harness itself had two defects, and both
produce the same lie: a falsifier certified without being run.**
## Defect 1 — no green baseline
A test that is **already red** reports RED for every mutation thrown at it. The
escaping test had an arithmetic slip (counted `<` against `<a`/`<nav`/`</` and
forgot the two `<b>` elements), so it was failing for a reason unrelated to
escaping — and the harness cheerfully reported `RED ✓ the rail markup is emitted
with |safe`. **Run the test unmutated first; a non-zero baseline is a harness
failure, not a proven falsifier.**
## Defect 2 — the bytecode cache, which is the subtle one
`if len(sizes) < 2` → `if len(sizes) < 1` is **byte-identical in size**. CPython
validates a `.pyc` against the source's `(mtime, size)` at **one-second
granularity** — so a mutation that lands in the same second as the revert before
it is invisible, the cached bytecode is reused, and **the harness runs the
unmutated code and reports the falsifier proven.**
The tell was non-determinism with no cause: INV-3a certified RED on one run and
GREEN on the next with neither the test nor the code changing, and reproduced by
hand every time. Fix: delete `__pycache__` and set `PYTHONDONTWRITEBYTECODE=1`
in the subprocess environment before every run.
⚠ **This bites any same-size source mutation**, which is most interesting ones:
comparison flips, off-by-one constants, `and`↔`or`, `<`↔`>`. A mutation harness
without cache defeat is biased toward exactly the mutations most worth running.
## Result
12 falsifiers, 12 proved, stable across consecutive runs. Two of them only
after these fixes — and one of the twelve (`test_group_order_is_the_position_of
_the_first_member`) was genuinely vacuous on the first pass: its `w, x, y`
fixture's positional order **happened to be alphabetical**, so it stayed green
under the alphabetical-sort mutation it forbade. Rebuilt so all three plausible
rules (position, alphabetical, count) disagree.
**The harness lives in the session scratchpad and dies with the session.**
Whether it becomes `scripts/` is an open question for the operator — this repo
has now been bitten by vacuous falsifiers three times, and prose in a memory
file is not an instrument.
@@ -0,0 +1,77 @@
# U7 landed — and the number that justified it did not reproduce
_2026-09-22 · booth_
**The last v1 unit is in.** The three ratified components landed at `a306e2d`;
the fourth — filename-prefix groups replacing subfolder sections — landed here,
with `test_no_group_rail_is_shipped_yet` deleted in the same commit that built
what it guarded against. **All seven v1 capabilities are now landed.**
## The part worth remembering: the contract's own measurement was wrong
The contract stated a rule and, beside it, a table of what that rule produced.
**They are not the same computation.** Implementing the stated rule and running
it against the live set:
| booth | contract claimed | stated rule actually gives |
|---|---|---|
| `sindra-corpus-v1` | 16 | 16 ✓ |
| `sindra-sfw-pool` | 10 | 10 ✓ |
| `sindra-nude-pool` | 12 | 12 ✓ |
| **`sindra-bakeoff`** | **5** | **24** |
| **`sindra`** | **1 (degenerate)** | **27** |
Three of five matched, which is what made it survive review. The two that did
not were **the two load-bearing rows**: bakeoff was the "this pays" evidence and
sindra was the degenerate case INV-3 was written for.
**The contract contradicts itself in plain sight and nobody caught it.** Its own
worked example says `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, so 5 was never reachable. And the numbers ARE
reproducible, just not by one rule: **first-two-segments gives exactly 5 on
bakeoff; first-segment gives exactly 1 on sindra.** The table was assembled from
two different heuristics and written up as one.
⚠ **A cold contract-review panel cannot catch this, and did not.** The panel
reads the artifact; the artifact is internally plausible. Only running the
stated rule against the live data falsifies it. **A measurement inside a
contract is not reviewed by reviewing the contract** — it is reviewed by
re-running it, and that is now a thing to do before implementing any contract
whose scope rests on a number.
## The degeneracy it guarded was the wrong one
INV-3 guarded **one group for everything** ("a rail with one entry cannot
navigate"). The live set's actual failure is the opposite: **one group per
item** — `pewpew-ui-brief` 23 groups for 34 items, `dfa-concepts` 13 for 20. The
contract as written would have shipped a 23-row rail that is a second copy of
the grid. INV-3 now guards both, with a live specimen each:
- **(a)** `sc-iso-spread` — `DSC0001.jpg`–`DSC0006.jpg`, one group of six.
- **(b)** `pewpew-ui-brief` — 23 groups, 19 of them singletons.
The shipped predicate, one line: **two or more groups, and the middle group
holding more than one item.** It gets all 17 booths right.
## The shipped rule, and why it differs
`strip ONE trailing run of digits` keys on the END of the stem, which is where
the *instance number* lives — so it splits `m-c1-market-noon-9401` from
`m-c2-rain-street-9403`, which are the same family. 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`, but `v30-seed8302` and
`v35-seed8302` stay apart — that split is the axis `muse-clothed-repro` is
about).
Live result: `sindra-corpus-v1` renders `ac 12 · bu 10 · cu 12 · fb 12 · … ·
wu 8` over 66 images. `sindra-bakeoff` renders `00 · README · m · r`, which are
its three real families.
## Also true, and easy to trip on
**`miranda-is` and `sindra-voice-1` group beautifully and get no rail** — both
carry `index.html`, so they take the verbatim path and have no grid at all. A
measurement taken with `booth_items` alone predicts a rail for them; the route
does not. Measure the RENDERED surface, not the resolver, when the question is
"what will the operator see".
+24 -10
View File
@@ -26,16 +26,28 @@ _As of 2026-09-22:_
recs").** Four of five settled and executed; one blocked by the permission recs").** Four of five settled and executed; one blocked by the permission
layer. Nothing is waiting on him. → layer. Nothing is waiting on him. →
`persistent-memory.d/2026-09-22-operator-ruled-on-the-open-five.md` `persistent-memory.d/2026-09-22-operator-ruled-on-the-open-five.md`
- **U7 IS THE ONLY WORK LEFT, AND IT IS NOW UNBLOCKED.** The three ratified - ✅ **U7 IS LANDED — ALL SEVEN v1 UNITS ARE IN.** The fourth component
components (rail, filters, grid keyboard) are landed at `a306e2d`. The fourth (filename-prefix groups) is built; `test_no_group_rail_is_shipped_yet` was
— **filename-prefix groups replacing subfolder sections — is APPROVED** and deleted in the same commit, as required.
NOT yet built. That is the next session's first job. → `persistent-memory.d/2026-09-22-u7-landed-and-a-table-that-did-not-reproduce.md`
→ `persistent-memory.d/2026-09-22-u7-three-quarters-and-one-ruling.md` - 🔶 **THE 1.0 CUT IS NOW A DECISION, NOT A DEPENDENCY, AND IT IS HIS.** The v1
- ⚠ **`tests/test_navigation.py::test_no_group_rail_is_shipped_yet` IS NOW A target is met. A major bump needs explicit operator approval; nothing in the
STALE GUARD.** It exists to stop an UNAPPROVED group rail; the rail is code is waiting on it. The open fork: cut `1.0`, or stage a `0.7.0` first.
approved, so whoever builds it must DELETE that test in the same commit. It **Not bumped — the work is committed as commits, which are not releases.**
will otherwise read as a real invariant and block correct work. - ⚠ **U7'S CONTRACT CARRIED A MEASUREMENT THAT DID NOT REPRODUCE**, and it was
- **623 tests green. Tree clean and pushed. Deployed; 19/19 booths 200.** the number the scope departure rested on. The stated rule gives 24 and 27
groups where the table claimed 5 and 1; the table was assembled from two
different heuristics. **A cold contract-review panel cannot catch this** — the
artifact is internally plausible. Re-run any measurement a contract's scope
rests on before implementing it. Same detail file.
- ⚠ **A MUTATION HARNESS NEEDS A GREEN BASELINE AND CACHE DEFEAT**, or it
certifies falsifiers without running them. Both defects bit in one session.
→ `persistent-memory.d/2026-09-22-a-mutation-harness-that-certified-a-broken-test.md`
- **639 tests green; 12/12 new falsifiers mutation-proved. Deployed; 21/21
booths 200.** ⚠ The set churned again mid-session (19 → 21).
- 🔶 **A bug-hunt panel is IN FLIGHT** — heid thread `01M368G2Y0JMTJ2T7M3JMTXV5Z`,
dispatched 2026-09-22 21:31 PDT over the U7-groups diff. If its reply has not
been consumed, drain `/althing:inbox` and triage before treating U7 as closed.
- 🛑 **STANDING RULING — NO ANNOUNCEMENTS OUT OF THIS REPO, AND THE OPERATOR - 🛑 **STANDING RULING — NO ANNOUNCEMENTS OUT OF THIS REPO, AND THE OPERATOR
SENDS THE EVENTUAL ONE HIMSELF** (operator, 2026-09-22). Verbatim: *"no SENDS THE EVENTUAL ONE HIMSELF** (operator, 2026-09-22). Verbatim: *"no
announcements until the entire arc is done, and even then i'll do it myself."* announcements until the entire arc is done, and even then i'll do it myself."*
@@ -72,6 +84,8 @@ _As of 2026-09-22:_
## Recent decisions ## Recent decisions
- `[2026-09-22]` ✅ **U7 landed — and the number that justified it did not reproduce** — all seven v1 units are in; READ BEFORE TRUSTING A MEASUREMENT INSIDE A CONTRACT, and before assuming a degeneracy guard covers the degeneracy you actually have → `persistent-memory.d/2026-09-22-u7-landed-and-a-table-that-did-not-reproduce.md`
- `[2026-09-22]` ⚠ **A mutation harness certified a broken test, twice, for two reasons** — no green baseline, and the pyc cache silently reverting same-size mutations; READ BEFORE WRITING ONE → `persistent-memory.d/2026-09-22-a-mutation-harness-that-certified-a-broken-test.md`
- `[2026-09-22]` 🛑 **STANDING: no announcements out of this repo until the arc is done, and he sends that one himself** — verbatim *"no announcements until the entire arc is done, and even then i'll do it myself."* Stricter than the house broadcast gate: the send is not the agent's to make, so **asking is also out of scope**. The drafted 17-handle note is REASSIGNED to him, not blocked — see the in-flight row above; do not raise it again. - `[2026-09-22]` 🛑 **STANDING: no announcements out of this repo until the arc is done, and he sends that one himself** — verbatim *"no announcements until the entire arc is done, and even then i'll do it myself."* Stricter than the house broadcast gate: the send is not the agent's to make, so **asking is also out of scope**. The drafted 17-handle note is REASSIGNED to him, not blocked — see the in-flight row above; do not raise it again.
- `[2026-09-22]` **The operator ruled on all five open items at once** — four executed incl. the first push; the broadcast was blocked by the permission layer and is drafted at `docs/pending/` → `persistent-memory.d/2026-09-22-operator-ruled-on-the-open-five.md` - `[2026-09-22]` **The operator ruled on all five open items at once** — four executed incl. the first push; the broadcast was blocked by the permission layer and is drafted at `docs/pending/` → `persistent-memory.d/2026-09-22-operator-ruled-on-the-open-five.md`
- `[2026-09-22]` **U7 is three-quarters built and blocked on one word** — the ratified three landed; the sections-vs-groups departure is NOT built and is the operator's call, tracked at `docs/contracts/u7_navigation.contract.md` → `persistent-memory.d/2026-09-22-u7-three-quarters-and-one-ruling.md` - `[2026-09-22]` **U7 is three-quarters built and blocked on one word** — the ratified three landed; the sections-vs-groups departure is NOT built and is the operator's call, tracked at `docs/contracts/u7_navigation.contract.md` → `persistent-memory.d/2026-09-22-u7-three-quarters-and-one-ruling.md`
+58
View File
@@ -258,3 +258,61 @@ def test_list_booths_counts_match_the_resolver(tmp_path):
got = list_booths(tmp_path, ttl_seconds=86400)[0] got = list_booths(tmp_path, ttl_seconds=86400)[0]
assert got["count"] == len(booth_items(b)) == 2 assert got["count"] == len(booth_items(b)) == 2
# --- U7: the group, derived here and nowhere else -------------------------
#
# ⚠ THE RULE IS NOT THE ONE THE CONTRACT FIRST STATED, and the change is
# measured rather than preferred. The contract's `strip ONE trailing run of
# digits` yields 24 groups for sindra-bakeoff's 40 images and 27 for sindra's
# 30 — a rail with one row per tile, which is a second copy of the grid rather
# than a way through it. Measured against all 17 live booths on 2026-09-22;
# the numbers are in the contract's rewritten table.
def test_group_of_takes_the_first_segment(tmp_path):
from booth.items import _group_of
assert _group_of("00-sheet-c1-market-noon.png") == "00"
assert _group_of("m-c1-market-noon-9401.png") == "m"
assert _group_of("flag-rear.png") == "flag"
assert _group_of("v30-seed8302-HELD.png") == "v30"
def test_group_of_destems_only_a_flat_name(tmp_path):
"""`ac01.png` has no separator, so the digits ARE the separator and the
group is `ac`. `v30-seed8302` HAS one, so `v30` survives intact — stripping
there would merge v30 with v35, which is the axis that booth is about."""
from booth.items import _group_of
assert _group_of("ac01.png") == "ac"
assert _group_of("DSC0001.jpg") == "DSC"
assert _group_of("v30-seed8302.png") == "v30"
assert _group_of("v35-seed8302.png") == "v35"
def test_group_of_is_none_when_there_is_no_prefix(tmp_path):
"""A stem that is entirely digits has nothing to group on. Inventing one
would file every numbered render under the empty string."""
from booth.items import _group_of
assert _group_of("01.png") is None
assert _group_of("0042.jpg") is None
assert _group_of("-leading.png") is None
def test_group_is_derived_from_the_basename_not_the_path(tmp_path):
"""A booth WITH subdirectories still groups on the filename. Sections and
groups are different questions; `Item.section` still carries the path."""
from booth.items import _group_of
assert _group_of("sub/dir/ac01.png") == "ac"
def test_booth_items_carries_the_group(tmp_path):
b = tmp_path / "g"
_touch(b / "ac01.png")
_touch(b / "ac02.png")
_touch(b / "99.png")
got = {it.rel: it.group for it in booth_items(b)}
assert got == {"ac01.png": "ac", "ac02.png": "ac", "99.png": None}
+248 -14
View File
@@ -1,11 +1,11 @@
"""U7 slice 1 — the rail, the filters, the grid keyboard. """U7 — the rail, the filters, the grid keyboard, and the groups.
⚠ SCOPE. This covers ONLY the three components ROADMAP's U7 row already All four components. The fourth — replacing directory sections with
ratifies: a sticky rail, filters, and grid keyboard. The fourth — replacing filename-derived groups — was a scope DEPARTURE from ROADMAP's U7 row and was
directory sections with filename-derived groups — is a scope DEPARTURE proposed ratified by the operator on 2026-09-22; `test_no_group_rail_is_shipped_yet`,
in `docs/contracts/u7_navigation.contract.md` and is deliberately NOT built the guard that held it back while the ruling was outstanding, was deleted in
here, because it is the operator's call and he has not made it. The rail the commit that built it. A guard that outlives its reason is worse than no
therefore carries totals and filter counts and no jump-to-group anchors yet. guard, because the next reader trusts it.
`unanswered` is taken to mean HAS AN OPEN PICK — the U4 hold predicate, which `unanswered` is taken to mean HAS AN OPEN PICK — the U4 hold predicate, which
already exists and already has a home. The alternative reading ("has no mark at already exists and already has a home. The alternative reading ("has no mark at
@@ -158,13 +158,247 @@ def test_the_keyboard_is_bound_when_there_is_one(gallery):
assert "gridkeys" in c.get("/b/g/").text assert "gridkeys" in c.get("/b/g/").text
def test_no_group_rail_is_shipped_yet(gallery):
"""⚠ SCOPE GUARD, and it is deliberate. Replacing directory sections with
filename-derived groups is a scope DEPARTURE from ROADMAP's U7 row that the # --- U7 slice 2: the groups ----------------------------------------------
operator has not ruled on. This test fails the moment somebody builds it
anyway, so the departure cannot arrive by accident while he is away."""
@pytest.fixture
def grouped(tmp_path):
"""Two groups whose members INTERLEAVE in `sorted(rel)`.
`a/x1.png, a/y1.png, b/x2.png, b/y2.png` is the sorted order; group `x` is
at positions 0 and 2, group `y` at 1 and 3. That interleaving is the whole
point of the fixture — a grid re-sorted by `(group, rel)` to make groups
render contiguously would pass every set-based assertion and fail these.
"""
b = tmp_path / "g"
for rel in ("a/x1.png", "a/y1.png", "b/x2.png", "b/y2.png"):
p = b / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(PNG)
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), b
def _groups(body: str) -> list[str]:
"""The group keys the rail listed, in render order."""
import re
return re.findall(r'data-group="([^"]+)"', body)
def test_the_rail_lists_groups_when_grouping_is_informative(grouped):
c, _ = grouped
body = c.get("/b/g/").text
assert 'class="rail-groups"' in body
assert _groups(body) == ["x", "y"]
def test_group_order_is_the_position_of_the_first_member(tmp_path):
"""The settled rule (ROADMAP, operator 2026-09-22): groups order by where
each group's FIRST member falls in the rendered sequence.
⚠ THIS FIXTURE IS BUILT SO THE THREE PLAUSIBLE RULES ALL DISAGREE. The
first version used `w, x, y` — whose positional order happens to BE
alphabetical, so it stayed green under the very change it forbade. Caught
by running the mutation, not by reading the assertion; the same trap
persistent-memory.d/2026-09-22-vacuous-falsifiers.md names and the same one
`test_filtering_never_reorders` fell into an hour after it was written.
sorted(rel): a/z1 a/z2 b/a1 b/a2 b/a3 c/m1 c/m2
by position: z (0), a (2), m (5) <- the rule
alphabetical: a, m, z <- wrong, and differs
by count: a(3), z(2), m(2) <- wrong, and differs
"""
b = tmp_path / "g"
for rel in ("a/z1.png", "a/z2.png", "b/a1.png", "b/a2.png", "b/a3.png",
"c/m1.png", "c/m2.png"):
q = b / rel
q.parent.mkdir(parents=True, exist_ok=True)
q.write_bytes(PNG)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
assert _groups(c.get("/b/g/").text) == ["z", "a", "m"]
def test_grouping_never_reorders_the_grid(grouped):
"""INV-2, the load-bearing one.
The defeating change is sorting the grid by `(group, rel)` so groups render
contiguously — which looks right, passes any set comparison, and silently
changes what "the third one" means. This fixture interleaves precisely so
that change goes red.
The baseline is INDEPENDENT (U1 INV-3: the order IS `sorted(rel)`), not a
second response — the vacuous-falsifier trap this suite already fell into
once."""
c, _ = grouped
tiles = _tiles(c.get("/b/g/").text)
assert tiles == ["a/x1.png", "a/y1.png", "b/x2.png", "b/y2.png"]
assert tiles == sorted(tiles)
def test_every_group_anchor_lands_on_a_rendered_tile(grouped):
"""A jump-to-group link that scrolls nowhere is worse than no link. Every
anchor must name an id the page actually carries.
Defeating change: anchoring to the group KEY (`#group-x`) while the tiles
carry `id="item-<rel>"` — which renders, looks right, and does nothing."""
import re
c, _ = grouped
body = c.get("/b/g/").text
hrefs = re.findall(r'class="rail-g"[^>]*href="#([^"]+)"', body)
assert hrefs, "the rail rendered no group anchors"
for h in hrefs:
assert f'id="{h}"' in body, f"anchor #{h} names no element on the page"
def test_no_group_rail_when_every_item_is_its_own_group(gallery):
"""INV-3's real failure mode, and it is NOT the one the contract feared.
`a.png b.png c.png d.png` yields four groups of one — a rail that is a
second copy of the grid. Measured live: `pewpew-ui-brief` gives 23 groups
for 34 items, `dfa-concepts` 13 for 20. The contract only guarded the
opposite degeneracy (one group for everything), which is why this test
exists.
Defeating change: `{% if rail.groups %}`, true for four singletons."""
c, _ = gallery c, _ = gallery
body = c.get("/b/g/").text body = c.get("/b/g/").text
assert "data-group" not in body
assert 'class="rail-groups"' not in body assert 'class="rail-groups"' not in body
assert 'class="rail"' in body, "the approved rail must still be here" assert 'class="rail"' in body, "the filter rail must still be here"
def test_no_group_rail_when_there_is_only_one_group(tmp_path):
"""INV-3 as the contract states it, with the live specimen: `sc-iso-spread`
is `DSC0001.jpg` through `DSC0006.jpg` — one group, six images.
Defeating change: `{% if rail.groups %}`, true for a single group."""
b = tmp_path / "flat"
b.mkdir()
for i in range(1, 7):
(b / f"DSC{i:04d}.jpg").write_bytes(PNG)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
body = c.get("/b/flat/").text
assert 'class="rail-groups"' not in body
assert 'class="rail"' in body
def test_groups_describe_the_filtered_grid(tmp_path):
"""The rail describes what is ON SCREEN. An anchor to a group the filter
has hidden would scroll nowhere — the same defect as a wrong id, arriving
by a different route.
Three groups of two; the flag covers `x` and `y` entirely and `z` not at
all. Under `?filter=flagged` the rail must list x and y and MUST NOT list
z, whose two tiles are not on the page.
Defeating change: deriving groups from the full gallery rather than from
the rendered list — under which `z` appears and its anchor goes nowhere."""
b = tmp_path / "g"
b.mkdir()
for n in ("x1.png", "x2.png", "y1.png", "y2.png", "z1.png", "z2.png"):
(b / n).write_bytes(PNG)
for n in ("x1.png", "x2.png", "y1.png", "y2.png"):
set_flag(b, n, True)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
assert _groups(c.get("/b/g/").text) == ["x", "y", "z"]
body = c.get("/b/g/?filter=flagged").text
assert _groups(body) == ["x", "y"]
assert _tiles(body) == ["x1.png", "x2.png", "y1.png", "y2.png"]
def test_a_filtered_view_too_small_to_group_drops_the_group_row(grouped):
"""The informativeness rule binds to the RENDERED list, not to the booth.
One flagged tile is one group of one, which cannot navigate — so the group
row goes away even though the unfiltered booth has a perfectly good one.
The filter rail stays, because that is how the operator gets back."""
c, b = grouped
set_flag(b, "a/x1.png", True)
assert 'class="rail-groups"' in c.get("/b/g/").text
body = c.get("/b/g/?filter=flagged").text
assert 'class="rail-groups"' not in body
assert 'class="rail"' in body
def test_the_zoom_ring_ignores_grouping(grouped):
"""The ring is `sorted(rel)` filtered to images and must not notice groups
any more than it notices filters.
THE FIXTURE IS THE FALSIFIER. From `a/x1.png`, sorted order says next is
`a/y1.png` — a DIFFERENT group. A ring rebuilt per group would say
`b/x2.png`, the next member of group `x`, and `→` would start walking a
sequence the operator never saw on the page. That is invariant 6's
misfiled-judgment failure exactly: the flag lands on the wrong artifact."""
import re
c, _ = grouped
body = c.get("/b/g/view?f=a/x1.png").text
nxt = re.findall(r'class="vnav vnext" href="\?f=([^"&]+)"', body)
assert nxt == ["a/y1.png"], f"the ring followed the group, not sorted(rel): {nxt}"
# and the zoom page has no group chrome at all — it is one artifact, not a wall
assert "data-group" not in body
def test_no_route_body_derives_a_group(gallery):
"""INV-1, the same assertion U1 makes for `classify` and `render_doc`.
Defeating change: a route or template computing a prefix inline — the
caption bug in a new field."""
import inspect
import booth.app as app_mod
src = inspect.getsource(app_mod.create_app)
assert "_group_of" not in src, "create_app must read Item.group, not derive it"
def test_a_hostile_filename_cannot_break_out_of_the_rail(tmp_path):
"""Group keys and anchors are AGENT-AUTHORED — they are filenames, and a
session makes a booth by making a folder with no validation anywhere in the
path. CLAUDE.md names autoescape as load-bearing for exactly this.
Defeating change: building the rail markup with `|safe`, or assembling the
href by string concatenation outside Jinja. Both render, both look right,
and both put attacker-controlled bytes into an attribute."""
b = tmp_path / "g"
b.mkdir()
for n in ('q"x1.png', 'q"x2.png', "s<script>1.png", "s<script>2.png"):
(b / n).write_bytes(PNG)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
import re
r = c.get("/b/g/")
assert r.status_code == 200
body = r.text
# THE RAIL ITSELF, isolated — asserting over the whole page would pass on a
# booth where the escaping happened somewhere else.
nav = re.search(r'<nav class="rail-groups".*?</nav>', body, re.S)
assert nav, "the rail rendered no group row"
nav = nav.group(0)
# No tag the template did not write, and no attribute the filename closed.
# Asserted as the SET of element names rather than by counting `<`, which
# the first version got wrong by forgetting the `<b>` counts — an arithmetic
# slip that made the test red for a reason unrelated to escaping.
tags = set(re.findall(r"</?([a-zA-Z][a-zA-Z0-9]*)", nav))
assert tags == {"nav", "a", "b"}, f"the rail grew an element: {tags}"
assert 'data-group="q"' not in nav, "the quote closed the attribute"
assert "&lt;script&gt;" in nav and "<script" not in nav
assert "&#34;" in nav or "&quot;" in nav, "the quote was not escaped"
def test_a_group_key_is_never_the_empty_string(tmp_path):
"""`_group_of` returns None rather than "" for a stem with nothing before
the digits. A "" key would render a nameless rail row that files every
numbered render under it — the failure the None is there to prevent.
Defeating change: `return segs[0]` without the `or None`."""
b = tmp_path / "g"
b.mkdir()
for n in ("01.png", "02.png", "03.png", "ac1.png", "ac2.png"):
(b / n).write_bytes(PNG)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
body = c.get("/b/g/").text
assert 'data-group=""' not in body
assert "" not in _groups(body)