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:
+33
-26
@@ -1,7 +1,9 @@
|
||||
# The Booth — roadmap
|
||||
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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} →
|
||||
U7**, with **U6 independent** of all of them (different storage, different
|
||||
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
|
||||
only dependency was `{U3, U4, U5}` and that closed with U3.
|
||||
**ALL SEVEN UNITS ARE LANDED.** U7 closed last; its only dependency was
|
||||
`{U3, U4, U5}` and that closed with U3.
|
||||
|
||||
⚠ **Before starting U7, read
|
||||
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`, and re-count
|
||||
the booths first.** Half its premise is already known to be wrong — every booth
|
||||
that actually needs navigation is FLAT — and the booth set churned again on
|
||||
2026-09-22: the four large booths U7 was sized against (`pancake-v3-full` and
|
||||
`pancake-v4-full` at 270 items, `sindra20-engines`, `sindra-finalists`) have all
|
||||
been swept. The largest live booth is now `miranda-is` at 92 items, flat. Two of
|
||||
23 booths have subfolders (`pewpew-ui-brief`, `dfa-concepts`) and **both are
|
||||
reports** — the job where grid navigation matters least. Sections, one of U7's
|
||||
four named components, buys close to nothing. The rail, the filters and the grid
|
||||
keyboard are the unit.
|
||||
⚠ **What U7 actually shipped is not what this row first described, and the
|
||||
difference is measured.** Sections were dropped for filename-prefix groups
|
||||
(operator-ratified 2026-09-22) because zero of eleven gallery booths have a
|
||||
subdirectory. Then the *grouping rule itself* changed at implementation: the
|
||||
contract's `strip a trailing digit run` 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 shipped rule
|
||||
keys on the **first separator-delimited segment**, where the family lives, and
|
||||
gives 4 and 2. The full re-measurement across all 17 live booths is in
|
||||
`docs/contracts/u7_navigation.contract.md`.
|
||||
|
||||
**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
|
||||
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 |
|
||||
| link board rows | pinned first, then newest-first |
|
||||
| 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 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) |
|
||||
@@ -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
|
||||
ordering: one rule, one place, every surface reading it.
|
||||
|
||||
**U7's group ordering is SETTLED** (operator, 2026-09-22): groups order by the
|
||||
position of their first member in `sorted(rel)`, so the rail reads in the same
|
||||
direction as the grid. Subfolder sections are dropped in favour of
|
||||
filename-prefix groups on measured grounds — zero of eleven gallery booths have
|
||||
a subdirectory. Compare pairing rides with compare mode into v1.1.
|
||||
**U7's group ordering is SETTLED and SHIPPED** (operator, 2026-09-22): groups
|
||||
order by the position of their first member in the rendered sequence, so the
|
||||
rail reads in the same direction as the grid. Subfolder sections were dropped
|
||||
in favour of filename-prefix groups on measured grounds — zero of eleven
|
||||
gallery booths have a subdirectory.
|
||||
|
||||
Formerly undecided, retained for the record: **U7's
|
||||
section ordering and its compare pairing** (sections need a stated order among
|
||||
themselves, not just within; pairing by filename needs a rule for what happens
|
||||
to an unpaired file). **U6's bench listing is settled** — the row above.
|
||||
Compare pairing is parked to v1.1 with compare mode itself, so U7 carries one
|
||||
undecided rule, not two.
|
||||
**Nothing in this table is undecided any more.** The two U7 rules that were
|
||||
(section ordering among themselves, compare pairing) resolved differently:
|
||||
section ordering is MOOT, because U7 renders no section rail — `Item.section`
|
||||
still exists and is still derived, it simply has no ordered surface. Compare
|
||||
pairing rode into v1.1 with compare mode itself. **U6's bench listing is
|
||||
settled** — the row above.
|
||||
|
||||
The test for any new ordered surface: *can you write the rule down in one line?*
|
||||
If not, it does not have one yet.
|
||||
|
||||
+46
-1
@@ -520,6 +520,8 @@ def build_gallery(child: Path) -> list[dict]:
|
||||
"doc": it.doc,
|
||||
"url": it.url,
|
||||
"section": it.section,
|
||||
# U7. Derived in the resolver (INV-1); this only carries it.
|
||||
"group": it.group,
|
||||
"caption": it.caption,
|
||||
"rendered": rendered,
|
||||
"rendered_html": rendered_html,
|
||||
@@ -1007,14 +1009,57 @@ def create_app(
|
||||
buckets["annotated"].append(it)
|
||||
if any(m.id in open_ids for m in mine):
|
||||
buckets["unanswered"].append(it)
|
||||
shown = buckets[active]
|
||||
rail = {
|
||||
"active": active,
|
||||
# ORDER: the declaration order of FILTERS. Stated because a rail is
|
||||
# an ordered collection and invariant 6 binds to it like any other.
|
||||
"counts": [{"key": f, "n": len(buckets[f])} for f in FILTERS],
|
||||
"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]:
|
||||
"""The link board's rows, or [] for a board that cannot be read.
|
||||
|
||||
@@ -15,6 +15,7 @@ See docs/contracts/u1_item_record.contract.md.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
@@ -89,6 +90,7 @@ class Item:
|
||||
url: str
|
||||
kind: str
|
||||
section: str | None
|
||||
group: str | None
|
||||
caption: str | None
|
||||
blurred: bool
|
||||
doc: str | None
|
||||
@@ -115,6 +117,43 @@ def _section_of(rel: str) -> str | None:
|
||||
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]]:
|
||||
"""(caption-by-rel, rels consumed as sidecars).
|
||||
|
||||
@@ -203,6 +242,7 @@ def booth_items(booth: Path) -> list[Item]:
|
||||
url=quote(rel, safe="/"),
|
||||
kind=classify(p.name),
|
||||
section=_section_of(rel),
|
||||
group=_group_of(rel),
|
||||
caption=caption.get(rel),
|
||||
blurred=rel in blurred,
|
||||
doc=doc_kind(p.name),
|
||||
|
||||
@@ -529,6 +529,15 @@
|
||||
opacity:.65;border:1px solid transparent}
|
||||
.rail-f:hover{opacity:1}
|
||||
.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}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -251,11 +251,11 @@
|
||||
ORDER: the declaration order of FILTERS in app.py. A rail is an ordered
|
||||
collection and invariant 6 binds to it like any other.
|
||||
|
||||
⚠ NO JUMP-TO-GROUP ANCHORS YET. Replacing directory sections with
|
||||
filename-derived groups is a scope departure from ROADMAP's U7 row that
|
||||
the operator has not ruled on; see docs/contracts/u7_navigation.contract.md
|
||||
and tests/test_navigation.py::test_no_group_rail_is_shipped_yet, which
|
||||
fails the moment somebody builds it anyway. #}
|
||||
THE GROUP ROW is `rail.groups`, which is EMPTY unless grouping is
|
||||
informative — see `_groups` in app.py. `{% raw %}{% if rail.groups %}{% endraw %}`
|
||||
is therefore the whole guard; the two degenerate cases (one group for
|
||||
everything, one group per item) are decided in Python, where they can be
|
||||
measured, rather than by a count in a template. #}
|
||||
<div class="rail">
|
||||
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
|
||||
{% for f in rail.counts %}
|
||||
@@ -264,6 +264,17 @@
|
||||
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>
|
||||
{% 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 class="gallery" id="grid" tabindex="-1">
|
||||
{% for it in items %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -26,16 +26,28 @@ _As of 2026-09-22:_
|
||||
recs").** Four of five settled and executed; one blocked by the permission
|
||||
layer. Nothing is waiting on him. →
|
||||
`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
|
||||
components (rail, filters, grid keyboard) are landed at `a306e2d`. The fourth
|
||||
— **filename-prefix groups replacing subfolder sections — is APPROVED** and
|
||||
NOT yet built. That is the next session's first job.
|
||||
→ `persistent-memory.d/2026-09-22-u7-three-quarters-and-one-ruling.md`
|
||||
- ⚠ **`tests/test_navigation.py::test_no_group_rail_is_shipped_yet` IS NOW A
|
||||
STALE GUARD.** It exists to stop an UNAPPROVED group rail; the rail is
|
||||
approved, so whoever builds it must DELETE that test in the same commit. It
|
||||
will otherwise read as a real invariant and block correct work.
|
||||
- **623 tests green. Tree clean and pushed. Deployed; 19/19 booths 200.**
|
||||
- ✅ **U7 IS LANDED — ALL SEVEN v1 UNITS ARE IN.** The fourth component
|
||||
(filename-prefix groups) is built; `test_no_group_rail_is_shipped_yet` was
|
||||
deleted in the same commit, as required.
|
||||
→ `persistent-memory.d/2026-09-22-u7-landed-and-a-table-that-did-not-reproduce.md`
|
||||
- 🔶 **THE 1.0 CUT IS NOW A DECISION, NOT A DEPENDENCY, AND IT IS HIS.** The v1
|
||||
target is met. A major bump needs explicit operator approval; nothing in the
|
||||
code is waiting on it. The open fork: cut `1.0`, or stage a `0.7.0` first.
|
||||
**Not bumped — the work is committed as commits, which are not releases.**
|
||||
- ⚠ **U7'S CONTRACT CARRIED A MEASUREMENT THAT DID NOT REPRODUCE**, and it was
|
||||
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
|
||||
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."*
|
||||
@@ -72,6 +84,8 @@ _As of 2026-09-22:_
|
||||
|
||||
## 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]` **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`
|
||||
|
||||
@@ -258,3 +258,61 @@ def test_list_booths_counts_match_the_resolver(tmp_path):
|
||||
|
||||
got = list_booths(tmp_path, ttl_seconds=86400)[0]
|
||||
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
@@ -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
|
||||
ratifies: a sticky rail, filters, and grid keyboard. The fourth — replacing
|
||||
directory sections with filename-derived groups — is a scope DEPARTURE proposed
|
||||
in `docs/contracts/u7_navigation.contract.md` and is deliberately NOT built
|
||||
here, because it is the operator's call and he has not made it. The rail
|
||||
therefore carries totals and filter counts and no jump-to-group anchors yet.
|
||||
All four components. The fourth — replacing directory sections with
|
||||
filename-derived groups — was a scope DEPARTURE from ROADMAP's U7 row and was
|
||||
ratified by the operator on 2026-09-22; `test_no_group_rail_is_shipped_yet`,
|
||||
the guard that held it back while the ruling was outstanding, was deleted in
|
||||
the commit that built it. A guard that outlives its reason is worse than no
|
||||
guard, because the next reader trusts it.
|
||||
|
||||
`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
|
||||
@@ -158,13 +158,247 @@ def test_the_keyboard_is_bound_when_there_is_one(gallery):
|
||||
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
|
||||
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."""
|
||||
|
||||
|
||||
# --- U7 slice 2: the groups ----------------------------------------------
|
||||
|
||||
|
||||
@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
|
||||
body = c.get("/b/g/").text
|
||||
assert "data-group" 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 "<script>" in nav and "<script" not in nav
|
||||
assert """ in nav or """ 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)
|
||||
|
||||
Reference in New Issue
Block a user