Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3126deca00 | ||
|
|
bf351a26d1 | ||
|
|
2f85692e95 | ||
|
|
6938d21085 | ||
|
|
8bf5343049 | ||
|
|
a306e2dc6d | ||
|
|
b50f41bb36 | ||
|
|
e15ee2c4ab | ||
|
|
400e254da6 | ||
|
|
1b394dde18 | ||
|
|
e702be4e1a | ||
|
|
c5ac49356f |
+37
-21
@@ -1,7 +1,12 @@
|
||||
# The Booth — roadmap
|
||||
|
||||
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
||||
Current version: `0.6.0` (U1 through U6 landed; extracted from eshpfi 2026-09-21).
|
||||
Current version: `1.0.0b1` (**U1 through U7 landed — every v1 capability is
|
||||
in**; extracted from eshpfi 2026-09-21). **The v1 target is MET and staged as a
|
||||
beta** (operator, 2026-09-22): feature-complete, external testing, no new
|
||||
features — the remaining work is bugs. `1.0.0` final is cut when the beta
|
||||
survives; per the canonical policy an rc would be cut from the same commit,
|
||||
but a beta may still take fixes.
|
||||
|
||||
## v1 target
|
||||
|
||||
@@ -16,26 +21,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 at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | 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 +93,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,12 +111,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.
|
||||
|
||||
Where it is still to be decided, and must be before the unit ships: **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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
+41
-2
@@ -1,3 +1,42 @@
|
||||
"""The Booth — ephemeral media drop board. See booth.app for the server."""
|
||||
"""The Booth — ephemeral media drop board. See booth.app for the server.
|
||||
|
||||
__version__ = "0.1.0"
|
||||
⚠ THIS FILE IS EFFECTIVELY STDLIB-ONLY and nothing used to say so. `scripts/booth`
|
||||
imports `booth.links` / `booth.marks` / `booth.manifest` under the SYSTEM python3
|
||||
with no venv, and importing any of them executes this module first — so a single
|
||||
third-party import here breaks `booth ask` on every fleet host exactly as one in
|
||||
those three would. `test_stdlib_only` now covers `__init__` for that reason.
|
||||
"""
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
_PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
|
||||
|
||||
def _declared_version() -> str:
|
||||
"""The version of the code actually running, read from `pyproject.toml`.
|
||||
|
||||
⚠ NOT `importlib.metadata`, and the reason is this repo's own shape: there
|
||||
is no build step and no install step — `booth.service` runs uvicorn with
|
||||
WorkingDirectory set to the repo, so the running code IS this tree.
|
||||
Installed metadata describes a DIFFERENT artifact and was found saying
|
||||
`0.3.0` (a vestigial dist-info, three releases stale, with no package
|
||||
directory behind it) while the tree was at `1.0.0b1`. A confidently wrong
|
||||
number that varies by environment is worse than the hardcoded `0.1.0` this
|
||||
replaced, which at least failed the same way everywhere.
|
||||
|
||||
Falls back to installed metadata for the case this repo does not have but a
|
||||
consumer might: packaged as a wheel, where pyproject does not ship.
|
||||
"""
|
||||
try:
|
||||
return tomllib.loads(_PYPROJECT.read_text())["project"]["version"]
|
||||
except (OSError, KeyError, tomllib.TOMLDecodeError):
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version("booth")
|
||||
except Exception:
|
||||
return "0.0.0+unknown"
|
||||
|
||||
|
||||
__version__ = _declared_version()
|
||||
|
||||
+108
-6
@@ -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,
|
||||
@@ -859,7 +861,7 @@ def create_app(
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307)
|
||||
|
||||
@app.get("/b/{name}/", response_class=HTMLResponse)
|
||||
def booth_view(request: Request, name: str, download: int = 0):
|
||||
def booth_view(request: Request, name: str, download: int = 0, filter: str = "all"):
|
||||
booth = resolve_booth(name)
|
||||
# U4: viewing is activity. ABOVE both early returns — the zip download
|
||||
# and the verbatim-index.html branch are looks at this booth too, and a
|
||||
@@ -902,6 +904,7 @@ def create_app(
|
||||
held_marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||||
hold = hold_reason(held_marks, read_err)
|
||||
marks = held_marks if read_err is None else marks_for(booth)
|
||||
rail, shown = _rail(gallery, marks, filter)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"booth.html",
|
||||
@@ -912,7 +915,16 @@ def create_app(
|
||||
# The page could not previously tell keep from release, so it
|
||||
# offered neither and you had to go back to the index.
|
||||
"kept": is_kept(booth),
|
||||
"items": gallery,
|
||||
# THE GRID RENDERS `shown`; everything else reads `gallery`.
|
||||
# Filtering is a VIEW: `shown` is `gallery` with non-matching
|
||||
# items removed and NOTHING re-sorted, so "the third one" means
|
||||
# the same thing with a filter on as with it off. Sorting by
|
||||
# anything filter-derived would look right and silently misfile
|
||||
# the operator's judgment — CLAUDE.md invariant 6.
|
||||
"items": shown,
|
||||
"all_items": gallery,
|
||||
"rail": rail,
|
||||
"filter": rail["active"],
|
||||
# A booth carrying links.md is the standing link board: render
|
||||
# its rows as real UI (link, provenance, pin, per-row + bulk
|
||||
# remove) instead of a markdown blob you can only edit by hand.
|
||||
@@ -951,7 +963,9 @@ def create_app(
|
||||
"marks": marks,
|
||||
"marks_open": len(open_marks(marks)),
|
||||
# Per-item marks, keyed by rel, so a tile reads its own judgment
|
||||
# without every tile re-filtering the whole list.
|
||||
# without every tile re-filtering the whole list. Keyed off the
|
||||
# FULL gallery, not the filtered one, so a tile hidden by the
|
||||
# current filter still has its marks if the filter changes.
|
||||
"item_marks": {
|
||||
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
|
||||
},
|
||||
@@ -969,6 +983,84 @@ def create_app(
|
||||
},
|
||||
)
|
||||
|
||||
FILTERS = ("all", "flagged", "annotated", "unanswered")
|
||||
|
||||
def _rail(gallery: list[dict], marks, requested: str) -> tuple[dict, list[dict]]:
|
||||
"""Per-filter counts, and the items the grid should render.
|
||||
|
||||
`requested` ARRIVES FROM A URL, which is operator-editable and
|
||||
link-shared, so an unknown value falls back to `all` rather than
|
||||
indexing a dict by it. A filter nobody can mistype into a 500.
|
||||
|
||||
`unanswered` means HAS AN OPEN PICK — the U4 hold predicate, which
|
||||
already exists and already has a home. The other reading ("has no mark
|
||||
at all") is a genuinely different question and is an open question on
|
||||
the U7 contract, not something to guess at here.
|
||||
"""
|
||||
active = requested if requested in FILTERS else "all"
|
||||
open_ids = {m.id for m in open_marks(marks)}
|
||||
buckets: dict[str, list[dict]] = {f: [] for f in FILTERS}
|
||||
for it in gallery:
|
||||
mine = marks_for_target(marks, it["name"])
|
||||
buckets["all"].append(it)
|
||||
if any(m.shape == "flag" and m.flagged for m in mine):
|
||||
buckets["flagged"].append(it)
|
||||
if any(m.shape == "note" for m in mine):
|
||||
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, 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.
|
||||
|
||||
@@ -1204,10 +1296,20 @@ def create_app(
|
||||
return _pick_fragments(name, mark)
|
||||
except Exception as exc: # noqa: BLE001 - deliberate
|
||||
broken = replace(mark, error=f"this question could not be rendered: {exc}")
|
||||
try:
|
||||
whole = str(_frag.whole(broken, ask_form_id(mark.id),
|
||||
quote(name, safe="")))
|
||||
except Exception: # noqa: BLE001 - deliberate
|
||||
# THE HANDLER MUST SURVIVE THE FAILURE IT IS HANDLING. The
|
||||
# fallback re-rendered through the SAME macro module that had
|
||||
# just raised, so when `whole` itself was the broken thing this
|
||||
# guard re-raised and took the report anyway — a guard that
|
||||
# only works when the failure is somewhere else. Found while
|
||||
# building a falsifier for the guard: the falsifier tripped it.
|
||||
# Plain text, escaped by the caller, no macro involved.
|
||||
whole = ""
|
||||
return {"id": mark.id, "error": broken.error,
|
||||
"whole": str(_frag.whole(broken, ask_form_id(mark.id),
|
||||
quote(name, safe=""))),
|
||||
"submit": "", "questions": []}
|
||||
"whole": whole, "submit": "", "questions": []}
|
||||
|
||||
@app.get("/b/{name}/embed.json")
|
||||
def booth_embed_json(name: str):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -488,6 +488,40 @@ def _hydrate(entry: dict) -> Mark:
|
||||
norm = normalize_ask(decl, mid)
|
||||
except AskError as exc:
|
||||
return Mark(**base, declaration=decl, answer=answer, error=str(exc))
|
||||
# THE ANSWER'S SHAPE IS VALIDATED HERE, at the ONE boundary every
|
||||
# surface crosses — not at the three render sites that happen to draw
|
||||
# it today, and not defensively in the template, which would hide that
|
||||
# anything is wrong.
|
||||
#
|
||||
# `{"answer": {"answers": [], "notes": ""}}` is well-formed JSON with a
|
||||
# wrong-shaped value. It passed `_entry_type_error`, passed the
|
||||
# `isinstance(answer, dict)` check above, and `marks_for` and
|
||||
# `hold_read` both reported the mark HEALTHY with no read error — and
|
||||
# then `_ask_inline.html` did `a.answer.answers.get(q.key)`, Jinja asked
|
||||
# a LIST for `.get`, and the gallery page and the marks page returned
|
||||
# 500. Measured at 42ea67f, so it predates U3; U3 guarded only its own
|
||||
# surface with `_safe_fragments` and left these two by scope.
|
||||
#
|
||||
# This is the v0.2.2 lesson finished rather than half-done. That outage
|
||||
# was a file that could not be PARSED and the reader was made lenient;
|
||||
# this one parses perfectly and breaks one layer further in, at render,
|
||||
# where no leniency exists. `read_error` was answering a narrower
|
||||
# question than every caller assumed.
|
||||
#
|
||||
# ONLY the multi case is checked, because only the multi case indexes:
|
||||
# a single-question pick's answer IS the record, with no `answers` key
|
||||
# to get wrong. Requiring one unconditionally would break every single
|
||||
# pick, which is the direction a too-eager guard fails in.
|
||||
if norm["multi"] and isinstance(answer, dict) and \
|
||||
not isinstance(answer.get("answers"), dict):
|
||||
return Mark(**base, declaration=decl, answer=None,
|
||||
prompt=norm["prompt"], title=norm["title"],
|
||||
multi=norm["multi"], questions=norm["questions"],
|
||||
options=norm.get("options", []),
|
||||
notes_enabled=norm["notes"], notes_label=norm["notes_label"],
|
||||
error="this pick's answer is stored in a shape the page "
|
||||
"cannot render; the answer was dropped and the "
|
||||
"question is unanswered")
|
||||
return Mark(
|
||||
**base,
|
||||
declaration=decl,
|
||||
|
||||
@@ -520,6 +520,25 @@
|
||||
/* A board row whose booth has been swept. Marked, never auto-removed. */
|
||||
.board-row.board-dead{opacity:.45}
|
||||
.board-dead-tag{font-size:.9em;color:#f2b8b5;opacity:.9}
|
||||
/* U7 — the rail, and the grid cursor. */
|
||||
.rail{position:sticky;top:0;z-index:5;display:flex;gap:.5rem;align-items:baseline;
|
||||
padding:.4rem .6rem;margin:.6rem 0;background:var(--bg,#111);
|
||||
border-bottom:1px solid var(--line,#2a2a2a);flex-wrap:wrap}
|
||||
.rail-total{font-weight:600}
|
||||
.rail-f{font-size:.85em;padding:.1rem .45rem;border-radius:3px;text-decoration:none;
|
||||
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>
|
||||
<body>
|
||||
|
||||
@@ -243,7 +243,40 @@
|
||||
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
|
||||
links.md is rendered as the board above and filtered out), so a plain else
|
||||
would emit an empty <div class="gallery"> under the board. #}
|
||||
<div class="gallery">
|
||||
{# THE RAIL. Totals and per-filter counts, as LINKS with a query parameter —
|
||||
resolved server-side, so the whole thing works with JavaScript off. The
|
||||
gallery is the surface the operator actually reviews on and U3 already
|
||||
cost the verbatim path its no-JS operation; this one does not repeat that.
|
||||
|
||||
ORDER: the declaration order of FILTERS in app.py. A rail is an ordered
|
||||
collection and invariant 6 binds to it like any other.
|
||||
|
||||
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 %}
|
||||
<a class="rail-f{% if f.key == filter %} on{% endif %}"
|
||||
data-filter="{{ f.key }}"
|
||||
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 %}
|
||||
{% if it.doc and it.rendered is not none %}
|
||||
{# Docs render INLINE, collapsible, and closable — not a link to a
|
||||
@@ -329,6 +362,56 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if items %}
|
||||
<script id="gridkeys">
|
||||
/* GRID KEYBOARD — U7. Additive by construction: every action it reaches is a
|
||||
control that already exists on the tile and already works with a mouse, so
|
||||
the page is complete without this file. It is bound ONLY when there is a
|
||||
grid ({% raw %}{% if items %}{% endraw %} above): binding it on the standing
|
||||
link board would swallow `f` and flag nothing.
|
||||
|
||||
Focus moves in RENDER ORDER, which is the item order filtered by the current
|
||||
filter and never re-sorted — so `→` walks the grid in the same sequence the
|
||||
operator reads it, and the same sequence the zoom ring uses. */
|
||||
(function () {
|
||||
var grid = document.getElementById('grid');
|
||||
if (!grid) return;
|
||||
var tiles = function () { return [].slice.call(grid.querySelectorAll('figure.item')); };
|
||||
var at = -1;
|
||||
function focus(i) {
|
||||
var t = tiles();
|
||||
if (!t.length) return;
|
||||
at = Math.max(0, Math.min(i, t.length - 1));
|
||||
t.forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
|
||||
t[at].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function current() { var t = tiles(); return at >= 0 && at < t.length ? t[at] : null; }
|
||||
function click(sel) {
|
||||
var el = current(); if (!el) return;
|
||||
var b = el.querySelector(sel); if (b) b.click();
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
/* Never steal a key the operator is typing into a note or a URL bar. */
|
||||
var tag = (e.target.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowRight': focus(at + 1); e.preventDefault(); break;
|
||||
case 'ArrowLeft': focus(at <= 0 ? 0 : at - 1); e.preventDefault(); break;
|
||||
case 'f': click('.flagbtn, [name="target"]'); e.preventDefault(); break;
|
||||
case 'n': var el = current();
|
||||
if (el) { var f = el.querySelector('input[type=text], textarea');
|
||||
if (f) { f.focus(); e.preventDefault(); } }
|
||||
break;
|
||||
case 'Enter': click('a[href^="view"]'); break;
|
||||
case 'Escape':
|
||||
tiles().forEach(function (x) { x.classList.remove('is-cursor'); });
|
||||
at = -1; break;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
|
||||
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
|
||||
|
||||
@@ -436,7 +436,7 @@ arms flagged the staleness themselves.
|
||||
| **BH-2** | **A submit anchor inside the author's own `<form>` loses ours** — the HTML parser drops a nested form outright. Every control's `form=` then points at nothing, and the code recorded the pick as submitted so the tail added no fallback. The operator fills it in and the button does nothing. | 1 | **Genuine add.** A submit anchor counts as submitted only if the form actually survived (`hasForm`); otherwise the tail supplies one at body level, where no form encloses it. |
|
||||
| **BH-3** | **A broken pick's diagnostic never rendered from a submit-only anchor.** An errored pick's `submit` is empty; mounting that and marking it placed made the tail skip it, so the "broken ask" box vanished from the one surface built to show it. | 3 of 4 | **Genuine add.** A submit anchor for an errored pick is left alone, exactly as an anchor naming no mark is, and the tail mounts the diagnostic. |
|
||||
| **BH-4** | **An author's own element can hijack the chip.** `<section id="bk-ask-winner-background">` satisfies any id-prefix rule — the hyphen boundary from CR-7 included. | 4 of 4 | **Genuine add, and it supersedes CR-7's fix.** The chip now searches only the elements THIS SCRIPT MOUNTED, which is the identity the deleted `bk-ask-<id>-top` anchor used to guarantee, and takes the earliest of those by `compareDocumentPosition`. |
|
||||
| **BH-5** | **No error boundary around fragment rendering.** A `.marks.json` that is well-formed JSON with a wrong-shaped `answer` hydrates with no error and then raises in the macro. | 1, `needs-repro` | **Genuine add — reproduced before building for it.** `_safe_fragments` returns a per-mark error record, the same leniency `_hydrate_safe` applies one layer down. ⚠ **The gallery and marks pages still 500 on it, and that is PRE-EXISTING** — measured at `42ea67f`. Out of scope here and recorded rather than quietly widened: `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`. |
|
||||
| **BH-5** | **No error boundary around fragment rendering.** A `.marks.json` that is well-formed JSON with a wrong-shaped `answer` hydrates with no error and then raises in the macro. | 1, `needs-repro` | **Genuine add — reproduced before building for it.** `_safe_fragments` returns a per-mark error record, the same leniency `_hydrate_safe` applies one layer down. ⚠ **The gallery and marks pages still 500 on it, and that is PRE-EXISTING** — measured at `42ea67f`. Out of scope here and recorded rather than quietly widened: `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`. **CLOSED 2026-09-22**, after U6, at the hydration boundary rather than by a third copy of this guard — so `_safe_fragments` no longer has a reachable natural trigger and is now a pure backstop, falsified synthetically. Hardening the falsifier found that this guard's own fallback re-rendered through the macro module that had just raised, so it re-raised whenever `whole` was the broken thing; fixed in the same pass. |
|
||||
| **BH-6** | Prototype pollution in the placement maps (`toString` as a mark id, `constructor` as a question key). | 1 | **Already fixed this round** as CR-13, from the code-review panel. Two panels, two lenses, the same defect independently — the strongest signal of the evening that the lenses are not redundant. |
|
||||
| **BH-7** | Bare-substring declaration suppresses the chrome. | 4 of 4 | **Already fixed** as CR-3, before the reply landed. |
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
contract_version: "0.1-PROPOSED"
|
||||
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:
|
||||
- "booth.items.booth_items (INV-1: one resolver for item facts. `Item` gains ONE field, `group`, derived here and nowhere else. No route body derives it, exactly as no route body derives `section`, `caption` or `blurred`.)"
|
||||
- "booth.items.Item.section (ALREADY EXISTS from U1 and STAYS. This unit does not delete it and does not render a rail from it — those are different questions. A booth that does have subdirectories keeps its section values; nothing regresses.)"
|
||||
- "booth.app.build_gallery (the thin adapter over `booth_items`; it shapes items for the template and is where `group` reaches the page)"
|
||||
- "booth.app.image_chain (the zoom prev/next ring. UNCHANGED, and named here because it was CHECKED: the ring is the item order filtered to images, and grouping must not reorder it -- a filter that changed what `next` means would misfile the operator's judgment, which is CLAUDE.md invariant 6's whole reason for existing.)"
|
||||
language: "python + jinja + a little javascript"
|
||||
complexity: "medium"
|
||||
estimated_loc: 300
|
||||
confidence: 0.6
|
||||
used_by:
|
||||
- "booth.app.booth_view (the gallery page gains a rail and a filter state; the grid gains keyboard focus)"
|
||||
touches:
|
||||
- "booth/items.py (the `group` field and its derivation)"
|
||||
- "booth/app.py (build_gallery carries `group`; booth_view passes group counts)"
|
||||
- "booth/templates/booth.html (the rail, the filter controls, the grid's focus affordances)"
|
||||
- "booth/templates/base.html (rail + focus CSS)"
|
||||
- "booth/static/embed.js (NOT TOUCHED — named because it was checked; the verbatim path has no grid)"
|
||||
- "tests/test_items.py (group derivation)"
|
||||
- "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 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."
|
||||
open_questions:
|
||||
- "WHETHER THE GROUP HEURISTIC SHOULD BE OVERRIDABLE. A booth could carry a `.groups` dotfile naming its own grouping, the way `.blurred` names blur. Not designed here: no live booth wants it, the heuristic is right on four of five, and adding an override before anyone has been failed by the default is speculative. Parked, not solved."
|
||||
- "RESOLVED 2026-09-22 — `unanswered` means `has an open pick`, the U4 hold predicate, which is what shipped. The `has no mark at all` reading is a genuinely different question and is PARKED for v1.1 rather than pending."
|
||||
---
|
||||
|
||||
# U7 — navigation at the size the booths actually are
|
||||
|
||||
**LANDED — all four components.**
|
||||
|
||||
| component | ROADMAP says | state |
|
||||
|---|---|---|
|
||||
| sticky rail | ratified | **landed** — totals + per-filter counts, links not scripts |
|
||||
| filters | ratified | **landed** — all / flagged / annotated / unanswered |
|
||||
| grid keyboard | ratified | **landed** — `←/→ f n Enter Esc`, bound only when a grid exists |
|
||||
| **sections → filename groups** | **departs from it** | **landed** — ratified by the operator 2026-09-22. `test_no_group_rail_is_shipped_yet`, the guard that held it back, was deleted in the same commit that built it. |
|
||||
|
||||
`unanswered` means **has an open pick** — the U4 hold predicate. **Settled by
|
||||
the operator 2026-09-22**; the "has no mark at all" reading is a different
|
||||
question and is parked, not pending.
|
||||
|
||||
## The defect, re-measured rather than inherited
|
||||
|
||||
ROADMAP sizes this unit for 270 items. **The largest gallery is now 81 items
|
||||
and 40 images.** The four booths it was written against were swept on
|
||||
2026-09-22 and the set churned again during that session. The defect is real
|
||||
and the sizing is not:
|
||||
|
||||
| | ROADMAP's premise | measured 2026-09-22 |
|
||||
|---|---|---|
|
||||
| largest gallery | 270 images, one flat wall | **`sindra-bakeoff`, 40 images** |
|
||||
| galleries with subdirectories | "sections come from subfolders, which already exist" | **0 of 11** |
|
||||
| booths with subdirectories at all | — | 2, and **both are reports** |
|
||||
| grouping signal that does exist | — | **the filename prefix** |
|
||||
|
||||
## Sections are dead. The prefix is not.
|
||||
|
||||
⚠ **THE TABLE BELOW IS THE RE-MEASUREMENT, AND IT DISAGREES WITH THE ONE THIS
|
||||
CONTRACT WAS WRITTEN ON.** The original claimed the rule `strip ONE trailing
|
||||
run of digits` produced **5** groups on `sindra-bakeoff` and **1** on `sindra`.
|
||||
Neither reproduces: that rule gives **24** and **27**. The original table's own
|
||||
worked example says so out loud — it notes `00-sheet-c1-market-noon.png` has no
|
||||
trailing digit run and therefore groups as its whole stem, which makes eight of
|
||||
bakeoff's forty images eight singleton groups. **The numbers 5 and 1 are
|
||||
reproducible only by two OTHER rules** (first-two-segments gives exactly 5 on
|
||||
bakeoff; first-segment gives exactly 1 on sindra), so the table that justified
|
||||
this design was assembled from more than one heuristic. Caught by implementing
|
||||
the stated rule and running it against the live set rather than trusting the
|
||||
table beside it.
|
||||
|
||||
**The shipped rule** — first separator-delimited segment, destemmed only when
|
||||
the stem has no separator — measured against all 17 live booths, 2026-09-22.
|
||||
`G` is groups, `med` the middle group's size, `sing` the singleton groups:
|
||||
|
||||
| booth | items | G | med | sing | rail? |
|
||||
|---|---|---|---|---|---|
|
||||
| `sindra-corpus-v1` | 66 | 11 | 5 | 4 | **yes** — `ac 12 · bu 10 · cu 12 · fb 12 · … · wu 8` |
|
||||
| `sindra-sfw-pool` | 59 | 6 | 11 | 0 | **yes** |
|
||||
| `sindra-nude-pool` | 42 | 9 | 4 | 1 | **yes** |
|
||||
| `sindra-bakeoff` | 41 | 4 | 12 | 1 | **yes** — `00 · README · m · r`, the three real families |
|
||||
| `sindra` | 31 | 2 | 15 | 1 | **yes** |
|
||||
| `muse-clothed-repro` | 7 | 3 | 2 | 1 | **yes** — `v30`/`v35`, the axis that booth is about |
|
||||
| `pewpew-ui-brief` | 34 | 23 | 1 | 19 | no — **degeneracy (b)** |
|
||||
| `dfa-concepts` | 20 | 13 | 1 | 8 | no — **degeneracy (b)** |
|
||||
| `cr123a-to-d-sleeve` | 7 | 6 | 1 | 5 | no — degeneracy (b) |
|
||||
| `sc-iso-spread` | 6 | 1 | 6 | 0 | no — **degeneracy (a)**, `DSC0001`–`DSC0006` |
|
||||
| `music3-songs`, `krea2-lora-portability` | 3 | 1 | 3 | 0 | no — degeneracy (a) |
|
||||
| `miranda-is`, `sindra-voice-1` | 47 / 10 | 10 / 6 | 2 / 2 | 3 / 2 | **no grid at all** — both carry `index.html` and take the verbatim path |
|
||||
|
||||
**Why the rule changed.** `strip ONE trailing run of digits` keys on the END of
|
||||
the stem, which is where the *instance number* lives — so it separates
|
||||
`m-c1-market-noon-9401` from `m-c2-rain-street-9403`, which are the same family.
|
||||
The shipped rule keys on the START, which is where the *family* lives. The
|
||||
competing heuristics measured and rejected: split-on-second-hyphen (59 groups
|
||||
from 59 files), and destemming the first segment unconditionally (merges `v30`
|
||||
with `v35`).
|
||||
|
||||
**The honest cost.** Destemming a flat stem is what makes `ac01.png` → `ac`
|
||||
work, and it is exactly what would merge `v30` with `v35` if applied to a
|
||||
segmented name. The rule therefore has a conditional in it, which is one more
|
||||
thing than "take the first segment" — paid because `sindra-corpus-v1`, the
|
||||
largest gallery, is entirely flat names.
|
||||
|
||||
## What ships
|
||||
|
||||
1. **`Item.group`** — derived once, in the resolver, beside `section`.
|
||||
2. **A sticky rail** — total, per-group counts, per-filter counts, jump-to-group
|
||||
anchors. **Absent entirely when there is one group or fewer.**
|
||||
3. **Filters** — all / flagged / annotated / unanswered, as server-resolved
|
||||
query parameters so they work with JS off.
|
||||
4. **Grid keyboard** — `←/→` move focus, `f` flags, `n` opens a note, `Enter`
|
||||
zooms, `Esc` clears focus. Additive; the page is complete without it.
|
||||
|
||||
## Signatures
|
||||
|
||||
```python
|
||||
def _group_of(rel: str) -> str | None:
|
||||
"""The grouping key for an item, or None when it has none.
|
||||
|
||||
THE RULE, in one line: the first separator-delimited segment of the
|
||||
basename's stem -- with a trailing digit run stripped only when the stem has
|
||||
no separator at all.
|
||||
|
||||
00-sheet-c1-market-noon.png -> 00
|
||||
m-c1-market-noon-9401.png -> m
|
||||
flag-rear.png -> flag
|
||||
ac01.png -> ac (no separator: the digits ARE it)
|
||||
DSC0001.jpg -> DSC
|
||||
v30-seed8302.png -> v30 (separator present, so v30 != v35)
|
||||
01.png -> None (nothing before the digits)
|
||||
|
||||
Derived HERE and nowhere else (INV-1).
|
||||
"""
|
||||
```
|
||||
|
||||
~~**SUPERSEDED — the rule this contract was written with.**~~ *"take the stem of
|
||||
the basename, strip ONE trailing run of digits and any single separator before
|
||||
it. `ac01.png` → `ac`; `00-sheet-c1-market-noon.png` → `00-sheet-c1-market-noon`
|
||||
(no trailing digit run, so the whole stem); `flag-rear.png` → `flag-rear`."*
|
||||
Kept struck rather than deleted: it is the rule the measurement table above was
|
||||
supposed to describe, and the mismatch between the two is the thing worth
|
||||
remembering. It keys on the end of the stem, where the instance number lives,
|
||||
and so splits families rather than gathering them.
|
||||
|
||||
## Ordering — the rule, because invariant 6 binds
|
||||
|
||||
| collection | rule |
|
||||
|---|---|
|
||||
| items | **unchanged** — `sorted(rel)` (U1 INV-3) |
|
||||
| the zoom ring | **unchanged** — item order filtered to images |
|
||||
| **groups among themselves** | **the position of each group's FIRST member in the RENDERED sequence** — which is `sorted(rel)` narrowed by the filter and never re-sorted. So the rail reads in the same direction the grid does, and adding a file never reshuffles the rail unless it lands first in its group. Implemented by walking `shown` once into an insertion-ordered `dict`: the walk IS the rule, so there is no second sort to drift from it. |
|
||||
| items within a group | **unchanged** — they are a filtered view of `sorted(rel)`, never re-sorted |
|
||||
| the filtered grid | **unchanged** — `sorted(rel)` with non-matching items hidden |
|
||||
|
||||
This closes ROADMAP's outstanding U7 order question. Compare pairing is not
|
||||
this unit's problem — compare mode is parked to v1.1 with the pairing rule.
|
||||
|
||||
## Invariants
|
||||
|
||||
**INV-1 — one resolver derives the group.** `_group_of` is called only from
|
||||
`booth_items`. *Falsifiable:* the defeating change is a route or template
|
||||
computing a prefix inline. The test asserts no call to `_group_of` survives
|
||||
inside `create_app` — the same assertion U1 makes for `classify` and
|
||||
`render_doc`, which is why it is the shape used here.
|
||||
|
||||
**INV-2 — grouping and filtering never reorder.** *Falsifiable:* the defeating
|
||||
change is sorting by `(group, rel)` to make the grid render contiguously, which
|
||||
looks right and silently changes what "the third one" means. The test renders a
|
||||
booth whose groups interleave in `sorted(rel)` order and asserts the rendered
|
||||
item sequence is **byte-identical** with grouping on and off, and that
|
||||
`image_chain` is unchanged under every filter.
|
||||
|
||||
**INV-3 — a rail that cannot navigate does not render, in EITHER direction of
|
||||
degeneracy.** The rail is absent unless grouping is informative: **two or more
|
||||
groups, and the middle group holding more than one item.**
|
||||
|
||||
- **(a) one group for everything.** Live specimen `sc-iso-spread`:
|
||||
`DSC0001.jpg`–`DSC0006.jpg`, one group, six images. A rail with a single row
|
||||
cannot navigate.
|
||||
- **(b) one group per item.** Live specimens `pewpew-ui-brief` (23 groups for
|
||||
34 items) 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
|
||||
directly and asserts the server returned the filtered grid, with no JS executed.
|
||||
|
||||
**INV-5 — the keyboard never fires on a booth with no grid.** *Falsifiable:*
|
||||
the defeating change is binding the handler unconditionally, so `f` on the
|
||||
standing link board flags nothing and swallows the keystroke. The test asserts
|
||||
the handler is not bound when `items` is empty.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Sections as a rail.** Measured worthless; `Item.section` is untouched.
|
||||
- **Compare mode.** Parked to v1.1 with its pairing rule.
|
||||
- **Virtualized loading.** Parked; measure first.
|
||||
- **A `.groups` override file.** See open questions.
|
||||
- **Anything on the verbatim path.** It has no grid.
|
||||
@@ -0,0 +1,66 @@
|
||||
# PENDING — fleet note to the 17 consuming handles
|
||||
|
||||
**Status: DRAFTED, NOT SENT.** Blocked by the auto-mode classifier on
|
||||
2026-09-22 because it is a multi-recipient send, which CLAUDE.md gates on
|
||||
explicit operator approval. The operator's blanket "accept all recs" was
|
||||
read as ratifying the note's CONTENT, not as the specific broadcast
|
||||
approval that rule requires — and the classifier agreed. Not worked around.
|
||||
|
||||
**To send it:** the operator says go, or adds a Bash permission rule for
|
||||
`postbox send`. Recipients (17, from the live board's provenance):
|
||||
|
||||
hamr-dev tts-dev nh3-dev shutter-dev infra-ops comfy-dev ldp-dev
|
||||
design-dev pewpew-dev peedlar-dev brokkr-smithy-dev draupnir
|
||||
bifrost-dev yt-voice-clipper-dev svos-dev jackdaw-dev brokkr-scan-dev
|
||||
|
||||
Subject: `booth: \`booth link\` now refuses a booth URL — use \`booth new --why\` instead`
|
||||
|
||||
---
|
||||
|
||||
ONE CHANGE THAT AFFECTS YOU, and it is a refusal you would otherwise hit
|
||||
without knowing why.
|
||||
|
||||
`booth link` now REFUSES a booth URL.
|
||||
|
||||
$ booth link http://10.100.10.50:8090/b/my-run/ "the renders"
|
||||
booth link: that is a booth, and a booth announces itself now.
|
||||
booth new my-run --why "the renders"
|
||||
the index at http://10.100.10.50:8090/ is the feed.
|
||||
exit 2
|
||||
|
||||
WHY. A booth announces itself now — `booth new` and `booth add` write a
|
||||
`.booth.json` carrying your handle and a one-line `--why`, and the index
|
||||
renders it. Posting the URL to the board on top of that creates a row that
|
||||
rots the moment the booth is swept. Measured on the live board: 178 of its 221
|
||||
rows were booth URLs and 156 of those already pointed at nothing.
|
||||
|
||||
WHAT TO DO INSTEAD. Nothing extra — just use `--why`:
|
||||
|
||||
booth new my-run --why "8 renders, pick the two that hold at 4K"
|
||||
booth add my-run out/*.png --why "..."
|
||||
|
||||
The operator sees it on the index with your handle beside it.
|
||||
|
||||
WHAT IS UNCHANGED. `booth link` is NOT deprecated and keeps working for
|
||||
everything else — repos, model cards, docs, recipes, any durable reference.
|
||||
Roughly 14 of the board's 35 distinct non-booth links are exactly that and the
|
||||
board is still their home. Only the booth-URL shape is refused.
|
||||
|
||||
ALSO NEW, and optional: `booth bench add <url> <name>` registers a RUNNING
|
||||
SERVICE — your current bench, the thing that gets promoted to Homepage.
|
||||
Identity is the URL, so re-posting UPDATES the row instead of adding a fifth
|
||||
(`talk` was on the board five times). `booth bench ls` lists them.
|
||||
|
||||
a BOOTH is work to review. Announces itself, swept after 24h.
|
||||
a BENCH is a running thing. Registered, durable, upserted by URL.
|
||||
a LINK is a reference bookmark. The board, unchanged.
|
||||
|
||||
ONE MORE, since it is easy to miss: `booth link` also refuses a URL carrying
|
||||
credentials (`user:pass@host`). The board renders on an unauthenticated LAN
|
||||
surface.
|
||||
|
||||
Shipped in booth v0.6.0/v0.6.1, deployed and live. No action needed from you
|
||||
unless you have a script that posts booth URLs to the board — that will now
|
||||
exit 2 rather than silently adding a dead row.
|
||||
|
||||
-- booth-dev
|
||||
@@ -0,0 +1,45 @@
|
||||
# An approved directive misrouted because pane_find addresses by a rolling title
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**An operator-approved directive (D-0011, sent by Miranda, telling booth-dev to
|
||||
begin U7) landed on the infra-ops handle instead.** Worth keeping for the
|
||||
mechanism, not the incident: the incident resolved cleanly and the mechanism
|
||||
did not.
|
||||
|
||||
## What happened, and why nothing broke
|
||||
|
||||
`pane_find` matched `terminal_2` **by its ROLLING PANE TITLE**, and that pane is
|
||||
the eshpfi-management seat rather than booth-dev. Miranda confirmed all of this
|
||||
directly when asked.
|
||||
|
||||
infra-ops caught it and **deliberately did not relay the content as an
|
||||
instruction** — their reasoning, which is exactly right: a directive arriving as
|
||||
"infra-ops says Miranda says Vuong says" is two hops from the source, and a peer
|
||||
passing operator authority along is the thing the rules warn about. They sent a
|
||||
routing report instead, quoting only the two lines that identified the target.
|
||||
|
||||
This session then **did not act on it**, and asked Miranda directly rather than
|
||||
taking a peer's word for the operator's. She confirmed it was genuine and
|
||||
**superseded pending the sections ruling**. Both loops closed in two messages.
|
||||
|
||||
## The part that is still true tomorrow
|
||||
|
||||
**A pane title that changes as work moves through the pane is not a stable
|
||||
address.** It put an approved directive on the wrong seat, and:
|
||||
|
||||
- **the failure is silent from the sender's side.** Miranda had no signal it
|
||||
went astray until infra-ops spoke up. A directive that misroutes to a quiet
|
||||
or busy seat simply evaporates.
|
||||
- it landed somewhere that caught it. That was luck, not design.
|
||||
|
||||
Reported to infra-ops as an ops matter (`01M35JJ9034E64HMA8X9C21R2N`), with the
|
||||
mechanism named and no fix proposed — not this repo's call. **Not tracked
|
||||
anywhere by booth-dev**; recorded here only so the next session does not
|
||||
re-derive it if a directive goes missing again.
|
||||
|
||||
## The rule this confirms
|
||||
|
||||
The CLAUDE.md Miranda exception is for Miranda relaying **directly**. A
|
||||
second-hand report of a Miranda relay is one hop too far, and infra-ops said so
|
||||
before this session had to. Going to the source cost two messages and settled it.
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
# A wrong-shaped answer 500s the gallery and the marks page — PRE-EXISTING, NOT U3
|
||||
# A wrong-shaped answer 500s the gallery and the marks page — CLOSED 2026-09-22
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
@@ -72,3 +72,49 @@ whose closing comment points back here.
|
||||
Related: [[2026-09-21-marks-write-wiped-judgment]],
|
||||
[[2026-09-22-lenient-reader-blast-radius]],
|
||||
[[2026-09-22-u3-declared-embed-seam-landed]].
|
||||
|
||||
|
||||
---
|
||||
|
||||
## CLOSED — 2026-09-22, after U6, at option (1)
|
||||
|
||||
Fixed in `_hydrate`, the option this entry argued for: **one predicate, one
|
||||
place, every surface inherits it.** The operator was asked three times where the
|
||||
guard belonged and did not answer; the placement was taken under the stated
|
||||
assumption, and it is cheap to move if he disagrees — the whole fix is one
|
||||
condition in one function.
|
||||
|
||||
**Only the MULTI case is checked**, because only the multi case indexes: a
|
||||
single-question pick's answer IS the record, with no `answers` key to get wrong.
|
||||
Requiring one unconditionally would break every single pick — the direction a
|
||||
too-eager guard fails in, and it has its own test.
|
||||
|
||||
Measured before and after, on the gallery booth (no `index.html`):
|
||||
|
||||
before /b/g/ 500 /b/g/marks 500 / 200 /healthz 200
|
||||
after /b/g/ 200 /b/g/marks 200 / 200 /healthz 200
|
||||
and the error is VISIBLE on the page, and the booth's
|
||||
OTHER, healthy pick still renders
|
||||
|
||||
**Two things fell out of it that are worth more than the fix.**
|
||||
|
||||
1. **`_safe_fragments` lost its natural trigger.** Probed every wrong answer
|
||||
shape reachable from a `.marks.json`: `answers` as a list, a string or null
|
||||
all become hydration errors now, and a wrong-typed VALUE inside `answers`
|
||||
renders without raising because Jinja absorbs attribute access on a
|
||||
non-mapping. So U3's guard is now a pure backstop with **no reachable
|
||||
natural input**. Its test was rewritten to a synthetic trigger that says so —
|
||||
patching the shared macro module through `app.state.templates` — rather than
|
||||
left asserting a path nothing reaches. An untested guard and a guard tested
|
||||
by an unreachable input are the same thing.
|
||||
|
||||
2. **The guard's own handler could not survive the failure it was handling.**
|
||||
Building that falsifier tripped it: `_safe_fragments` caught a raising
|
||||
`_pick_fragments` and then rebuilt the broken-ask box **through the same
|
||||
macro module that had just raised**, so when `whole` itself was broken the
|
||||
handler re-raised and took the whole report. Fixed, with its own test. Found
|
||||
by accident, which is the usual way.
|
||||
|
||||
Both new falsifiers were **verified RED against their defeating change** rather
|
||||
than assumed — the discipline from [[2026-09-22-vacuous-falsifiers]], applied to
|
||||
the fix for the entry that names it.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# The operator ruled on all five open items at once
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**"accept all recs, or make good ones, write it to handoff so I can clear."** A
|
||||
blanket ratification. Four of the five executed; one was stopped by the
|
||||
permission layer and is recorded rather than worked around.
|
||||
|
||||
| # | item | ruling | state |
|
||||
|---|---|---|---|
|
||||
| 1 | Drop subfolder sections for filename-prefix groups | **APPROVED** | **not yet built** — the next session's first job |
|
||||
| 2 | What `unanswered` filters on | **open pick** (the shipped reading) | settled; the other reading parked to v1.1 |
|
||||
| 3 | Push `main` | **PUSH** | **DONE** — 16 commits + `v0.6.0` + `v0.6.1` now on `origin` |
|
||||
| 4 | The 17-handle althing note | send it | **BLOCKED** — see below |
|
||||
| 5 | Marks guard placement | **stays at `_hydrate`** | already there; nothing to do |
|
||||
|
||||
## Two things the blanket ruling did NOT cover, and why
|
||||
|
||||
**The broadcast was blocked by the auto-mode classifier, and that was right.**
|
||||
CLAUDE.md gates any multi-recipient althing send on *explicit* operator
|
||||
approval — "ask, then send, never send and report" — because the cost is
|
||||
multiplied by the recipient count and paid out of budgets the sender never
|
||||
sees. A blanket "accept all recs" ratifies the note's **content**; it is not the
|
||||
specific, informed broadcast approval that rule asks for. The classifier agreed
|
||||
and **it was not worked around**. Draft, rationale and the 17-name recipient
|
||||
list live at `docs/pending/fleet-note-booth-link-refusal.md` so they survive a
|
||||
context clear; it needs his explicit go or a `postbox send` permission rule.
|
||||
|
||||
**"No seeding yet" survives the blanket ruling**, because it was a SPECIFIC
|
||||
prior instruction rather than a recommendation of this session's. A blanket
|
||||
acceptance of recommendations does not overwrite a direct instruction pointing
|
||||
the other way. `.benches.json` still does not exist in `~/booth-data`.
|
||||
|
||||
## The push, recorded because it is a first
|
||||
|
||||
`main` was **16 commits ahead** with two release tags unpushed and the whole of
|
||||
U6 single-copy on one box. Pushed with `--follow-tags`, then the two tags
|
||||
explicitly — `--follow-tags` pushed neither, because both tags are LIGHTWEIGHT
|
||||
per the SemVer policy and that flag only carries annotated ones. Worth knowing:
|
||||
**a lightweight release tag needs its own `git push origin <tag>`.**
|
||||
|
||||
## The trap this leaves behind, and it is a real one
|
||||
|
||||
`tests/test_navigation.py::test_no_group_rail_is_shipped_yet` was written to
|
||||
**stop an unapproved group rail from arriving by accident**. The rail is now
|
||||
approved, so that test has inverted: it will block the correct work and read
|
||||
like a genuine invariant while doing it. **Whoever builds the group rail must
|
||||
delete it in the same commit.** A guard that outlives its reason is worse than
|
||||
no guard, because the next reader trusts it.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Three cold panels on one unit, and what each lens could only see alone
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
U6 ran all three `/heid*` gates plus two in-session passes. **Every one of the
|
||||
five found something the others structurally could not**, which is the
|
||||
strongest evidence this repo has for running them all rather than picking one.
|
||||
|
||||
## The scoreboard
|
||||
|
||||
| gate | when | found |
|
||||
|---|---|---|
|
||||
| **seam review** (in-session, sibling-aware) | before code | **3 real contract defects** — a claim about a sibling test that was false, `resolve_booth` named as a per-row predicate when it RAISES 404, and silence on percent-encoding |
|
||||
| **adversarial self-pass** (in-session) | during | **4 defects** — a FIFO hang, `unquote` leaking control characters, a fail-closed-by-accident guard, a stranded scratch file |
|
||||
| **`/heid-contract-review`** (4 arms) | parallel | **the import/apply selection gap, 4-of-4** — plus per-field cap semantics, and two passages of the document contradicting each other |
|
||||
| **`/heid-code-review`** (4 arms) | parallel | **3 surface-drift findings 4-of-4**, an IPv6 identity bug, and **a falsifier that could not fail** |
|
||||
| **`/heid-bug-hunt`** (4 arms) | parallel | a `<div>` inside a `<span>`, a symlink disagreement, an append outside its lock |
|
||||
|
||||
## The three findings worth remembering
|
||||
|
||||
**1. The highest-value finding was a MISSING FEATURE, and the paraphrase lens
|
||||
found it.** `bench import --apply` registered every candidate while the same
|
||||
contract said ~14 of 35 were bookmarks that must stay on the board. The dry-run
|
||||
report existed *because* the decision is not mechanizable — and then `--apply`
|
||||
ignored it. A code-vs-contract lens cannot see this: the code matched the
|
||||
contract. Only reading the contract *as prose*, for what it promises a human,
|
||||
surfaces "these two sentences cannot both be satisfied."
|
||||
|
||||
**2. A falsifier that could not fail, again.** INV-4's tie-break test went
|
||||
through the registry, and `_write_all` serializes with `sort_keys=True` — so
|
||||
both insertion orders came back off disk already id-sorted, and removing the
|
||||
tie-break left the test green. Same class as the five vacuous U4 falsifiers.
|
||||
**We ran a vacuity pass and still shipped one**; a cold reader caught it. See
|
||||
[[2026-09-22-vacuous-falsifiers]].
|
||||
|
||||
**3. The single sharpest line came from a cross-module memory no new-module
|
||||
review could have.** Three bug-hunt arms independently noted that **this repo
|
||||
had already paid for the `RecursionError` class in `marks.py`, with a test
|
||||
documenting it — and the new module re-introduced the unguarded parse.** No
|
||||
amount of reading `benches.py` in isolation surfaces that.
|
||||
|
||||
## Complementarity, measured in both directions on one diff
|
||||
|
||||
The bug-hunt panel found **three live defects the in-session pass missed** — all
|
||||
three invisible to any test (a layout nesting, a symlink disagreement, a
|
||||
lock-ordering race). The in-session pass had **already closed three of that
|
||||
panel's four convergent findings** before the reply landed. Neither substitutes
|
||||
for the other, and this round is the cleanest specimen of it so far.
|
||||
|
||||
**One finding was declined**, with reasoning recorded in the contract: on a host
|
||||
where `booth.links` cannot be imported, `booth link` now refuses every URL
|
||||
rather than only booth ones. A guard that fails open is not a guard, and that
|
||||
state is a broken install where most of the CLI is equally broken.
|
||||
@@ -58,8 +58,10 @@ SR-4 and SR-5 were **verified rather than assumed**: both `list_booths` and
|
||||
a dot, so the registry is safe from the sweeper by two guards, not one. Had
|
||||
either been absent the design would have eaten its own registry on tick one.
|
||||
|
||||
## Still open at the time of writing
|
||||
## How it closed
|
||||
|
||||
Both cold gates are IN FLIGHT — contract review `01M35BWCJ806MT75NA630Y4WFH`,
|
||||
code review `01M35CK8YKEKMV7T15JXEF6A8N`. The bug-hunt has not run. **Committed
|
||||
but NOT tagged**, per the v0.2.0 lesson: if a gate is outstanding, the tag waits.
|
||||
All three cold gates came back and were folded in full, with exactly one finding
|
||||
declined. Released as `v0.6.0` — see [[2026-09-22-u6-benches-released]] and
|
||||
[[2026-09-22-three-cold-panels-on-one-unit]]. The tag waited for the gates, per
|
||||
the v0.2.0 lesson, and that sequencing was right: the panels produced ten code
|
||||
fixes after this entry was first written.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# U6 released as v0.6.0 — benches, and the number that was two defects
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The sixth of seven v1 units. Only U7 remains.** 444 → 607 tests. Tagged
|
||||
`v0.6.0` (minor, operator-approved). **NOT PUSHED** — push is his call.
|
||||
|
||||
## What shipped
|
||||
|
||||
- **`booth/benches.py`** — stdlib-only AND sibling-free. `Bench`,
|
||||
`normalize_bench_url` (the identity), a lenient `read_benches` on the render
|
||||
path and a strict `_load_strict` on the write path, `mkstemp` + `fsync` +
|
||||
`os.replace` under an flock, and `order_benches` with a stated total order
|
||||
`(state rank, name casefolded, id)`.
|
||||
- **`links.booth_target`** — ONE predicate for "is this a booth URL",
|
||||
host-agnostic, path-shaped, percent-decoding, control-character-rejecting,
|
||||
never raising. Three callers: the CLI refusal, the board's dead marker,
|
||||
`bench import`.
|
||||
- **`booth link` refuses** a booth URL (naming `booth new --why`) and a
|
||||
credentialed one, writing nothing in either case.
|
||||
- **The board marks dead rows** — 161 of 221 live. Removal stays the operator's
|
||||
two clicks through the bulk control that already existed. Nothing deletes.
|
||||
- **`booth bench add|ls|state|rm|import`**. `--apply` REQUIRES the ids.
|
||||
|
||||
## The decision that shaped the unit, and it was measured
|
||||
|
||||
**The design doc's headline "69% rot" was two defects wearing one number**, and
|
||||
splitting them is what made the unit the right size — see
|
||||
[[2026-09-22-one-number-was-two-defects]]. 178 of 221 rows are booth
|
||||
announcements (156 already dead) whose *cause* U5 had already closed; only 8 are
|
||||
the bench re-post the registry fixes. A unit scoped off the unsplit number would
|
||||
have built the registry, declared victory, and left 178 rows rotting.
|
||||
|
||||
**Identity is the FULL normalized URL, not the origin**, and that was measured
|
||||
rather than chosen: origin identity merges eight distinct gitea repositories
|
||||
into one row, three unrelated HuggingFace model cards into one, and the two LRPG
|
||||
surfaces on `10.100.10.50:8321` — *the design doc's own example of two real
|
||||
benches* — into one. It destroys more than it deduplicates.
|
||||
|
||||
**`booth link` is NOT deprecated**, against the design doc's plan. Roughly 14 of
|
||||
the 35 distinct non-booth targets are reference bookmarks (repos, model cards,
|
||||
docs) for which the board is the right and only home. Deprecating it would have
|
||||
evicted a third of its live content. The IA doc is corrected.
|
||||
|
||||
## The gates
|
||||
|
||||
All four closed, and every one paid — see
|
||||
[[2026-09-22-three-cold-panels-on-one-unit]]. Contract review
|
||||
`01M35BWCJ806MT75NA630Y4WFH`, code review `01M35CK8YKEKMV7T15JXEF6A8N`, bug hunt
|
||||
`01M35CRRK2RTVWWF1BN09AFQG3`, one consolidated reply sent to heid at
|
||||
`01M35FY8QZTB9E5VR4WXDSBGEV`.
|
||||
|
||||
## Live evidence, unplanned
|
||||
|
||||
The sweeper ran mid-session: **23 booths → 19**, and dead board rows went
|
||||
**156 → 161 in about fifteen minutes**. The defect compounding in real time
|
||||
while the fix was being built — which is the argument for U6-before-U7 playing
|
||||
out on its own.
|
||||
@@ -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".
|
||||
@@ -0,0 +1,70 @@
|
||||
# U7 re-measured before scoping — sections are dead, filename prefixes are not
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Pre-work, not the unit.** The standing instruction is "re-count the booths
|
||||
before scoping U7". Done, on the live set (19 booths). No U7 code, no U7
|
||||
contract — this exists so the scope call is a thirty-second read.
|
||||
|
||||
## The set as it actually is
|
||||
|
||||
| booth | items | images | subdirs | shape |
|
||||
|---|---|---|---|---|
|
||||
| `miranda-is` | 92 | 0 | 0 | report |
|
||||
| `sindra-bakeoff` | 81 | 40 | **0** | gallery |
|
||||
| `sindra-corpus-v1` | 66 | 66 | **0** | gallery |
|
||||
| `sindra` | 61 | 30 | **0** | gallery |
|
||||
| `sindra-sfw-pool` | 59 | 59 | **0** | gallery |
|
||||
| `sindra-nude-pool` | 42 | 42 | **0** | gallery |
|
||||
| `pewpew-ui-brief` | 34 | 1 | 7 | **report** |
|
||||
| `dfa-concepts` | 21 | 14 | 1 | **report** |
|
||||
| …11 more | ≤19 | | 0 | |
|
||||
|
||||
## Finding 1 — sections are worth ZERO, and this is now measured twice
|
||||
|
||||
**Not one gallery booth has a subdirectory.** Zero of eleven. The only two
|
||||
booths with subfolders are both **reports**, the job where grid navigation
|
||||
matters least, and `pewpew-ui-brief`'s seven subdirs hold one image.
|
||||
|
||||
The IA doc calls sections "most of the navigation fix". On this set they are
|
||||
none of it. Cutting `Item.section` rendering from U7 costs nothing measurable.
|
||||
(`Item.section` already exists from U1 and stays — this is about whether U7
|
||||
builds a section RAIL, not about deleting a field.)
|
||||
|
||||
## Finding 2 — the grouping signal is in the FILENAME, and it pays
|
||||
|
||||
Tested two heuristics against every large gallery. Strip a trailing digit-run
|
||||
from the stem and group on what remains:
|
||||
|
||||
| booth | images | groups | verdict |
|
||||
|---|---|---|---|
|
||||
| `sindra-corpus-v1` | 66 | **16** | useful |
|
||||
| `sindra-nude-pool` | 42 | **12** | useful |
|
||||
| `sindra-sfw-pool` | 59 | **10** | useful |
|
||||
| `sindra-bakeoff` | 40 | **5** | useful |
|
||||
| `sindra` | 30 | **1** | **degenerates** |
|
||||
|
||||
Specimens: `00-sheet-c1-market-noon.png`, `ac01.png`, `a01.png`,
|
||||
`flag-rear.png`. The competing heuristic — split on the second hyphen — is
|
||||
useless everywhere (59 "groups" from 59 files).
|
||||
|
||||
So a prefix heuristic pays on **4 of 5** large galleries and collapses to one
|
||||
group on the fifth. **That is a filter/grouping affordance, not a section
|
||||
rail**, and it must degrade gracefully to "one group" rather than render a
|
||||
useless single-section rail.
|
||||
|
||||
## What this implies for the scope, stated as a recommendation not a decision
|
||||
|
||||
U7 as written is four things: sections, a sticky rail, filters, grid keyboard.
|
||||
The measurement says **drop sections, keep the other three**, and consider
|
||||
prefix-grouping as the thing sections were supposed to be — with a stated
|
||||
degenerate case.
|
||||
|
||||
⚠ The sizing case has also changed: the unit was scoped against 270-item
|
||||
booths and **the largest gallery is now 81 items / 40 images**. Everything
|
||||
about virtualization stays parked ([[2026-09-21-ia-and-v1-gate-landed]] names
|
||||
it); at 66 images a lazy grid is fine and measuring it first is the rule.
|
||||
|
||||
**The booth set churned again during this session** — `sindra-sfw-pool` (59
|
||||
images) appeared and the `pancake-*` set went. Re-count again before writing
|
||||
the contract; do not trust this table either.
|
||||
@@ -0,0 +1,56 @@
|
||||
# U7 is three-quarters built and blocked on one word
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The last v1 unit, decomposed by what the operator has already ratified versus
|
||||
what he has not.** ROADMAP's U7 row names four components. Three were already
|
||||
approved there and are **built, tested and deployed** (`a306e2d`). The fourth is
|
||||
a scope departure and is **deliberately not built**.
|
||||
|
||||
| component | ROADMAP | state |
|
||||
|---|---|---|
|
||||
| sticky rail | ratified | **landed** — totals + per-filter counts |
|
||||
| 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** | **NOT BUILT** |
|
||||
|
||||
`tests/test_navigation.py::test_no_group_rail_is_shipped_yet` fails the moment
|
||||
somebody builds the group rail anyway, so the departure cannot arrive by
|
||||
accident while the ruling is outstanding.
|
||||
|
||||
## The question, and why it is his
|
||||
|
||||
**Drop subfolder sections for filename-prefix groups — yes or no?**
|
||||
|
||||
Measured (see [[2026-09-22-u7-remeasured-before-scoping]]): **zero of eleven
|
||||
gallery booths have a subdirectory**, so sections buy nothing; stripping a
|
||||
trailing digit-run from the stem yields **5–16 sensible groups on four of the
|
||||
five large galleries** and degenerates to one group on the fifth. The
|
||||
replacement is better on the evidence — but swapping a ratified component for
|
||||
an unratified one is scope direction, not implementation.
|
||||
|
||||
Contract at `docs/contracts/u7_navigation.contract.md`, status
|
||||
`PARTIALLY LANDED`, with the departure named as the operator's call.
|
||||
|
||||
## Decisions taken under stated assumption, both cheap to reverse
|
||||
|
||||
- **`unanswered` means HAS AN OPEN PICK** — the U4 hold predicate, which already
|
||||
exists. The other reading ("has no mark at all") is a genuinely different
|
||||
question and stays an open question on the contract.
|
||||
- **Filters are LINKS, not scripts**, resolved server-side, so the gallery keeps
|
||||
working with JavaScript off. U3 cost the verbatim path its no-JS operation and
|
||||
said so plainly; the gallery is the surface the operator actually reviews on,
|
||||
and this unit does not repeat it there.
|
||||
|
||||
## The vacuous falsifier, written an hour after the entry about them
|
||||
|
||||
`test_filtering_never_reorders` compared each filtered view against the
|
||||
**unfiltered response** — so a mutation reversing the order reversed both sides
|
||||
and it **stayed green under the exact change it forbade.** Caught only by
|
||||
running the mutation rather than trusting the assertion.
|
||||
|
||||
Rewritten against an independent truth: U1 INV-3 says the order IS `sorted(rel)`,
|
||||
so each view must be sorted, full stop, with no reference to another response.
|
||||
Re-verified RED. **Every new falsifier in this session was mutation-checked
|
||||
after this**, and that is the practice to keep — see
|
||||
[[2026-09-22-vacuous-falsifiers]].
|
||||
+73
-100
@@ -19,109 +19,82 @@ loop it turned out to actually be.
|
||||
|
||||
_As of 2026-09-22:_
|
||||
|
||||
- **v1 is gated on seven units** in `ROADMAP.md`, dependency-ordered
|
||||
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
|
||||
- **U1, U2, U3, U4 and U5 are landed — the whole middle tier is closed.** U1
|
||||
`ce598b3`; U2 `c7f9437` → `v0.2.0`, `5e41108` → `v0.2.1`, `026a1fc` →
|
||||
`v0.2.2`; U5 `c015a91` + `95beede` → `v0.3.0`; U4 `c3a97c1` → `v0.4.0`.
|
||||
**U3 landed 2026-09-22 and released as `v0.5.0`** — 444 tests green
|
||||
(410 → 444), deployed and verified live, 23/23 booth pages 200, and each of
|
||||
the four verbatim booths served at exactly +46 bytes, which is
|
||||
`len(EMBED_SCRIPT_TAG)` — one append, nothing else. `87e2c53` is the unit,
|
||||
`5c20e2f` the panel fixes, `7996fbd` the release. **PUSHED AND DEPLOYED**
|
||||
2026-09-22 on the operator's word — and it was **the first push of this
|
||||
repo's history**: `main` was 26 commits ahead of `origin/main`
|
||||
(`ce598b3..7996fbd`), so `v0.2.0` through `v0.5.0` all reached
|
||||
`git@gitea.phasefinal.com:vh/booth.git` in the same motion. `main` tracks
|
||||
`origin/main` clean now; a future session can assume a remote exists, which
|
||||
no earlier one could.
|
||||
- **U4 released as `v0.4.0`** (operator approved the minor on 2026-09-22).
|
||||
`c3a97c1` is the unit; the release commit carries the pre-existing fixes the
|
||||
bug-hunt panel surfaced in touched files. The tag waited for the last gate to
|
||||
close, per the `v0.2.0` lesson — see Tried and abandoned.
|
||||
- ⚠ **The 17 consuming handles are NOT being told** that `keep` no longer means
|
||||
"waiting on an answer" — operator decision, 2026-09-22, no broadcast. This is
|
||||
deliberate and it CHANGES HOW THE 2026-10-06 RE-COUNT READS: the hold rides
|
||||
for free, but not-pressing-`keep` has to be learned, so a flat `.forever` rate
|
||||
does not falsify anything. Read its entry before measuring.
|
||||
- **U6 LANDED 2026-09-22 — ONE UNIT LEFT TO v1.** Benches: a registry keyed by
|
||||
normalized URL, `booth link` refusing a booth URL, dead rows marked on the
|
||||
board, and a non-destructive `bench import`. 444 → 555 tests, deployed and
|
||||
verified live (23/23 booths 200, 156 of 221 rows marked dead — matching an
|
||||
independent pre-implementation count exactly). ⚠ **COMMITTED BUT NOT TAGGED
|
||||
AND NOT RELEASED**: both cold gates were still in flight at commit time
|
||||
(contract review `01M35BWCJ806MT75NA630Y4WFH`, code review
|
||||
`01M35CK8YKEKMV7T15JXEF6A8N`) and the bug-hunt had not run. Per the v0.2.0
|
||||
lesson, the tag waits for the gates. → `persistent-memory.d/2026-09-22-u6-benches-landed.md`
|
||||
- ⚠ **U6 WAS CHOSEN WITHOUT THE OPERATOR ANSWERING.** He set an autonomous goal
|
||||
("hydrate and land next unit stated in handoff") and the handoff named no
|
||||
unit. The session recommended U6 on measured grounds (its defect compounds —
|
||||
145 → 156 dead rows in a day — while U7's is dormant, and U6 had no
|
||||
unresolved design questions) and proceeded rather than blocking. **The scope
|
||||
call is still his to reverse**; nothing is pushed and nothing is tagged.
|
||||
- **U7 IS THE LAST UNIT, and its premise degraded again.** ⚠ Read
|
||||
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md` AND
|
||||
re-count first. On 2026-09-22 the four large booths U7 was sized against
|
||||
(`pancake-v3-full`/`pancake-v4-full` at 270 items, `sindra20-engines`,
|
||||
`sindra-finalists`) had ALL been swept. Largest live booth is `miranda-is` at
|
||||
**92 items, flat**. Two of 23 booths have subfolders and **both are reports**.
|
||||
Sections buy close to nothing; the rail, filters and grid keyboard are the
|
||||
unit.
|
||||
- **U3's tier was MINOR and the operator approved it** (2026-09-22). The
|
||||
argument that settled it, recorded because the tie-break rule says patch: a
|
||||
capability arrived AND one left — the verbatim path gained a declared public
|
||||
API (`<script src="/_booth/embed.js" defer>`) and lost no-JavaScript
|
||||
operation. That asymmetry is what made it not a tie.
|
||||
- **ALL FOUR U3 GATES ARE CLOSED.** In-session seam review (5 findings, SR-2 a
|
||||
real payload-shape bug); `/heid-contract-review`
|
||||
(`01M351WKV666D681SSRNY7D7X6`, 12 findings, 10 adopted, 2 already settled by
|
||||
the seam review while it was in flight, 1 declined);
|
||||
`/heid-code-review` (`01M352RXV1ZET566KV73C7TSB8`, 3 more vacuous falsifiers
|
||||
+ the prototype-pollution bug); `/heid-bug-hunt`
|
||||
(`01M352TPCSN52G6NGJ07T5WSGY`, 5 net-new, incl. the byte-exactness break).
|
||||
**Seven of the adopted findings were CODE fixes, not wording** — the cold
|
||||
gates were not ceremony on this unit. The **seam review ran in-session and is
|
||||
folded in** — five findings as a table at the end of the U3 contract, and SR-2
|
||||
was a real payload-shape bug the cold panel structurally could not see. U4's three and U5's three are all closed
|
||||
(`01M34VX0SH23Y3VC92E7GM4S70`, `01M34WAFJC3RTERFYBBZJN1SVG`,
|
||||
`01M34Y2R0RAJRSN36Q8K4KAB36`; `01M340PNVRS21HPASZT38PXQPN`,
|
||||
`01M341E9XAPZEFBSPK9HPGAM0S`, `01M343SXX27Z47C3STXXRC7M42`).
|
||||
- ⚠ **ONE DEFECT IS OPEN, FOUND BUT DELIBERATELY NOT FIXED, AND IT HAS NO
|
||||
TRACKING SURFACE YET.** A `.marks.json` that is well-formed JSON with a
|
||||
wrong-shaped `answer` 500s **the gallery page and the marks page** —
|
||||
reproduced, and measured at `42ea67f` so it PREDATES U3. U3 guarded its own
|
||||
surface (`_safe_fragments`) and left those two alone rather than widening the
|
||||
unit; the gallery is named out of scope in the U3 contract. **The operator was
|
||||
asked where the guard belongs and has not answered** — `_hydrate` (session
|
||||
recommendation: one predicate, one place, every surface inherits it), per
|
||||
render site, or the template. No issue filed. Read
|
||||
`persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
|
||||
before touching marks rendering anywhere.
|
||||
- **Two dated predictions are pending and must not be forgotten.** U5's adoption
|
||||
re-measure on **2026-09-29** (two counts, see its entry — already at 3 of 24
|
||||
announced and 2 with a `why`, all from peers told nothing), and the `.forever`
|
||||
re-count **on or after 2026-10-06**, a fortnight after U4 landed, which is
|
||||
U4's success criterion. ⚠ Only 4 booths carry marks at all, so the hold's live
|
||||
blast radius is small and the prediction rests on both halves of U4 — see its
|
||||
entry for what a null result would and would not mean.
|
||||
- **FOUR methodology proposals sit with the operator, all UNTRACKED BY OPERATOR
|
||||
CHOICE** (no issue, no ticket — they are `/heid*` skill changes, not this
|
||||
repo's work, and are recorded here only so they are not lost). Three are from
|
||||
the U5 round: reshaping the paraphrase gate toward a drift-check for
|
||||
narrative-heavy contracts, a standing "green-tests-prove-nothing" direction
|
||||
for the code-review gate, and regin's table-vs-signature consistency pass.
|
||||
The fourth is new and is the one with evidence behind it: a **contract-time
|
||||
VACUITY PASS** — for each invariant, name a change that defeats it and check
|
||||
the test goes red. Regin and Kimi proposed it independently on the U4
|
||||
paraphrase round; the code-review panel then showed five of seven U4
|
||||
falsifiers were vacuous, and heid rates that the strongest single data point
|
||||
for it so far. See `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.
|
||||
- The booth set churns hard: 26 → 24 → 25 across the last two sessions as the
|
||||
sweeper ran. Re-count rather than trusting any number written here.
|
||||
- **U6 SHIPPED (`v0.6.0`) with a late fix (`v0.6.1`). PUSHED.** `main` and both
|
||||
tags are on `origin` as of 2026-09-22 — the tree is no longer single-copy.
|
||||
→ `persistent-memory.d/2026-09-22-u6-benches-released.md`
|
||||
- **THE OPERATOR RULED ON EVERYTHING OUTSTANDING (2026-09-22, "accept all
|
||||
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 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."*
|
||||
Two clauses, both binding: **(a)** no althing announcement of any kind ships
|
||||
from booth-dev until the v1 arc is COMPLETE — not per-unit, not at the 1.0
|
||||
tag, not "just the peers who consume it"; **(b)** when the arc IS done, the
|
||||
announcement is HIS to send, not a thing to ask permission for. This is
|
||||
STRICTER than `~/.claude/CLAUDE.md`'s broadcast gate, which merely requires
|
||||
approval — here the send is not the agent's to make at all, so *asking* is
|
||||
also out of scope. Do not offer, draft-and-await, or surface it as a pending
|
||||
decision; it is settled and not a standing question.
|
||||
**The 17-handle `booth link` note is consequently REASSIGNED, not blocked.**
|
||||
The full draft + recipient list stays at
|
||||
`docs/pending/fleet-note-booth-link-refusal.md` as MATERIAL FOR HIM. It is no
|
||||
longer an open loop, no longer awaiting approval, and no longer a thing to
|
||||
raise. Same for anything U4's `keep`-semantics change would have warranted
|
||||
telling peers.
|
||||
- ⚠ **NOT SEEDED, and this survives the blanket ruling.** "No seeding yet" was a
|
||||
SPECIFIC prior instruction, not a recommendation of mine, so "accept all recs"
|
||||
does not override it. `.benches.json` does not exist in `~/booth-data`.
|
||||
- **A U7 directive (D-0011) misrouted to infra-ops and is SUPERSEDED.** Miranda
|
||||
confirmed directly. Nothing to act on.
|
||||
→ `persistent-memory.d/2026-09-22-a-directive-misrouted-by-pane-title.md`
|
||||
- ⚠ **THE 17 CONSUMING HANDLES WERE NEVER TOLD that `keep` stopped meaning
|
||||
"waiting on an answer"** — and the note above does not tell them either; it is
|
||||
about `booth link`. **This CHANGES HOW THE 2026-10-06 RE-COUNT READS**: a flat
|
||||
`.forever` rate does NOT falsify the diagnosis.
|
||||
- **Two dated predictions pending, not to be run early.** U5's adoption
|
||||
re-measure **2026-09-29**; the `.forever` re-count **on or after 2026-10-06**.
|
||||
- **FIVE `/heid*` methodology proposals sit with the operator**, untracked by
|
||||
his choice.
|
||||
- The booth set churns hard: 26 → 24 → 25 → 23 → **19**. Re-count rather than
|
||||
trusting any number here.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
- `[2026-09-22]` **U6 landed — three surfaces, three jobs, one predicate** — the seam review caught three real contract defects incl. a per-row `resolve_booth` that would have 404'd the board; NOT TAGGED, gates in flight → `persistent-memory.d/2026-09-22-u6-benches-landed.md`
|
||||
- `[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`
|
||||
- `[2026-09-22]` **An approved directive misrouted because pane_find addresses by a rolling pane title** — resolved; the MECHANISM is the durable part, reported to infra-ops, untracked by booth-dev → `persistent-memory.d/2026-09-22-a-directive-misrouted-by-pane-title.md`
|
||||
- `[2026-09-22]` **U7 re-measured before scoping — sections are dead, filename prefixes are not** — PRE-WORK ONLY, no unit started; read before writing U7's contract → `persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md`
|
||||
- `[2026-09-22]` **The last open defect closed, and building its falsifier found another** — the wrong-shaped answer fixed at `_hydrate`; `_safe_fragments` lost its natural trigger and its handler could not survive the failure it handled → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
|
||||
- `[2026-09-22]` **U6 released as `v0.6.0` — benches, and the number that was two defects** — six of seven v1 units landed, NOT PUSHED → `persistent-memory.d/2026-09-22-u6-benches-released.md`
|
||||
- `[2026-09-22]` **Three cold panels on one unit, and what each lens could only see alone** — READ BEFORE DECIDING TO SKIP A GATE; all five passes found something the others structurally could not → `persistent-memory.d/2026-09-22-three-cold-panels-on-one-unit.md`
|
||||
- `[2026-09-22]` **U6 landed — three surfaces, three jobs, one predicate** — the seam review caught three real contract defects incl. a per-row `resolve_booth` that would have 404'd the board → `persistent-memory.d/2026-09-22-u6-benches-landed.md`
|
||||
- `[2026-09-22]` **The 69% link-board rot was two defects wearing one number** — READ BEFORE SCOPING ANY LINK-BOARD WORK; U5 closed the larger half and full-URL-vs-origin identity is a measured call → `persistent-memory.d/2026-09-22-one-number-was-two-defects.md`
|
||||
- `[2026-09-22]` **U3 landed — the page declares the seam, the Booth mounts into it** — ten regexes against author HTML replaced by a substring test and a `+` → `persistent-memory.d/2026-09-22-u3-declared-embed-seam-landed.md`
|
||||
- `[2026-09-22]` **A wrong-shaped answer 500s the gallery and the marks page** — PRE-EXISTING (measured at `42ea67f`), NOT U3; the v0.2.2 lesson is only half-implemented → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.6.0"
|
||||
version = "1.0.0b1"
|
||||
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+95
-6
@@ -307,18 +307,107 @@ def test_a_wrongly_shaped_answer_costs_its_pick_not_the_report(client):
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200
|
||||
by = {m["id"]: m for m in r.json()["marks"]}
|
||||
assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"]
|
||||
# THE PROMISE, NOT THE LAYER. This used to pin the string
|
||||
# `_safe_fragments` produces ("could not be rendered"), which made the test
|
||||
# an assertion about WHICH guard fired. As of the `_hydrate` answer-shape
|
||||
# check, this input is caught one layer earlier and never reaches
|
||||
# `_pick_fragments` at all — the endpoint's promise is unchanged and the
|
||||
# error is better (it names what is wrong with the stored answer instead of
|
||||
# reporting a render failure), so the assertion moved to the promise.
|
||||
# `_safe_fragments` is still the backstop and is still falsified, by
|
||||
# `test_safe_fragments_still_catches_what_hydration_cannot` below.
|
||||
assert by["batch"]["error"], "a wrong-shaped answer reported no error"
|
||||
assert "broken ask" in by["batch"]["whole"]
|
||||
# and the booth's other pick is untouched — one bad entry costs one entry
|
||||
assert by["healthy"]["error"] is None
|
||||
assert "Which render wins?" in by["healthy"]["whole"]
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
|
||||
# ⚠ THE GALLERY AND MARKS PAGES STILL 500 ON THIS ENTRY, and that is NOT
|
||||
# U3's doing — measured at 42ea67f, the commit before this unit. They render
|
||||
# the same macro without this guard. Out of scope here (the gallery is named
|
||||
# out of scope in the contract) and recorded rather than quietly widened:
|
||||
# see persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
|
||||
# ⚠ THE GALLERY AND MARKS PAGES USED TO 500 ON THIS ENTRY, and that was NOT
|
||||
# U3's doing — measured at 42ea67f, the commit before that unit. CLOSED
|
||||
# 2026-09-22 at the hydration boundary rather than by a third copy of this
|
||||
# guard: see tests/test_marks.py
|
||||
# ::test_a_wrong_shaped_answer_is_an_error_at_hydration_not_a_500 and
|
||||
# persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
|
||||
|
||||
|
||||
def test_safe_fragments_still_catches_what_hydration_cannot(client):
|
||||
"""U3's `_safe_fragments` guard, kept falsifiable after `_hydrate` took its
|
||||
natural trigger away.
|
||||
|
||||
The answer-shape check in `_hydrate` now catches every wrong answer shape
|
||||
reachable from a `.marks.json` — probed 2026-09-22: `answers` as a list, a
|
||||
string or null all become hydration errors, and a wrong-typed VALUE inside
|
||||
`answers` renders without raising, because Jinja absorbs attribute access
|
||||
on a non-mapping. **No natural input reaches `_safe_fragments` by this
|
||||
route any more**, and a test that kept pretending one did would assert
|
||||
nothing — which is the failure this suite has now paid for twice.
|
||||
|
||||
So the trigger is synthetic and says so: the shared `_ask_inline` macro
|
||||
module is made to raise. `_pick_fragments` resolves `whole` off that object
|
||||
per call, and `create_app` stashes the environment on `app.state`, so this
|
||||
reaches the very object the closure captured. What it pins is the guard
|
||||
itself — one raising pick costs that pick, never the report.
|
||||
|
||||
Defeating change: removing the try/except in `_safe_fragments`, under which
|
||||
this returns 500.
|
||||
"""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(b, "batch", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
|
||||
frag = c.app.state.templates.env.get_template("_ask_inline.html").module
|
||||
real_submit = frag.submit
|
||||
|
||||
def explode(*a, **k):
|
||||
raise RuntimeError("synthetic render failure")
|
||||
|
||||
object.__setattr__(frag, "submit", explode)
|
||||
try:
|
||||
assert frag.submit is explode, "the patch did not take; this test is vacuous"
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200, "a raising fragment renderer took the whole report"
|
||||
by = {m["id"]: m for m in r.json()["marks"]}
|
||||
assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"]
|
||||
assert by["batch"]["whole"], "the fallback rendered nothing at all"
|
||||
finally:
|
||||
object.__setattr__(frag, "submit", real_submit)
|
||||
|
||||
# and the guard is not sticky — with the macro restored, the pick is fine
|
||||
assert c.get("/b/b/embed.json").json()["marks"][0]["error"] is None
|
||||
|
||||
|
||||
def test_the_handler_survives_the_failure_it_is_handling(client):
|
||||
"""`_safe_fragments` caught a raising `_pick_fragments` and then rebuilt the
|
||||
broken-ask box THROUGH THE SAME MACRO MODULE that had just raised. So when
|
||||
`whole` itself was the broken thing, the handler re-raised and took the
|
||||
whole report — a guard that only worked when the failure was somewhere
|
||||
else.
|
||||
|
||||
Found by accident: the first draft of the falsifier above patched `whole`,
|
||||
and the guard failed rather than caught. Defeating change: removing the
|
||||
inner try/except, under which this returns 500."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(b, "batch", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
|
||||
frag = c.app.state.templates.env.get_template("_ask_inline.html").module
|
||||
real_whole = frag.whole
|
||||
|
||||
def explode(*a, **k):
|
||||
raise RuntimeError("even the fallback macro is broken")
|
||||
|
||||
object.__setattr__(frag, "whole", explode)
|
||||
try:
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200, "the handler re-raised through the broken macro"
|
||||
assert r.json()["marks"][0]["error"]
|
||||
finally:
|
||||
object.__setattr__(frag, "whole", real_whole)
|
||||
|
||||
|
||||
def test_no_regex_touches_author_html():
|
||||
|
||||
@@ -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}
|
||||
|
||||
+85
-1
@@ -7,6 +7,8 @@ See docs/contracts/u2_marks.contract.md.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
@@ -276,7 +278,7 @@ def test_as_dict_round_trips_through_json(tmp_path):
|
||||
# ---- the stdlib-only invariant (INV-5) --------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches"])
|
||||
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches", "__init__"])
|
||||
def test_stdlib_only(module):
|
||||
"""INV-5. scripts/booth imports these under the system python3 with NO venv,
|
||||
through a `python3 -c` heredoc that no AST extractor can see — so nothing
|
||||
@@ -1374,3 +1376,85 @@ def test_a_clock_restore_that_fails_does_not_take_the_route_down(tmp_path):
|
||||
|
||||
assert (booth / MARKS_LOCK).exists()
|
||||
assert [m.target for m in marks_for(booth)] == ["a.png"]
|
||||
|
||||
|
||||
# ---- the wrong-shaped answer, closed at the hydration boundary --------------
|
||||
|
||||
|
||||
def test_a_wrong_shaped_answer_is_an_error_at_hydration_not_a_500(tmp_path):
|
||||
"""A `.marks.json` that is well-formed JSON with a wrong-shaped `answer`
|
||||
passed every reader and then raised in the TEMPLATE: `_hydrate` checked only
|
||||
that `answer` was a dict, never that `answer["answers"]` was one, so
|
||||
`marks_for` and `hold_read` both reported the mark healthy with no read
|
||||
error — and `_ask_inline.html` asked a list for `.get`.
|
||||
|
||||
Measured at `42ea67f`, so it predates U3. U3 guarded its own surface with
|
||||
`_safe_fragments` and left the gallery and marks pages alone by scope. This
|
||||
closes it at the boundary the rest of the module already argues for: ONE
|
||||
predicate, ONE place, every surface inherits it.
|
||||
|
||||
Defeating change: restoring the bare `isinstance(answer, dict)` check —
|
||||
under which `error` is None here and both pages 500.
|
||||
"""
|
||||
declare_pick(tmp_path, "batch", {"title": "T", "questions": [
|
||||
{"key": "r1", "prompt": "A?", "options": ["x", "y"]},
|
||||
{"key": "r2", "prompt": "B?", "options": ["x", "y"]}]})
|
||||
raw = json.loads((tmp_path / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
if e["id"] == "batch":
|
||||
e["answer"] = {"answers": [], "notes": ""}
|
||||
(tmp_path / ".marks.json").write_text(json.dumps(raw))
|
||||
|
||||
mark = {m.id: m for m in marks_for(tmp_path)}["batch"]
|
||||
assert mark.error, "a wrong-shaped answer hydrated as healthy"
|
||||
assert "answer" in mark.error
|
||||
# AND the mark is not silently emptied — the declaration survives, so the
|
||||
# operator can still see WHICH question broke rather than a bare error.
|
||||
assert mark.declaration is not None
|
||||
|
||||
|
||||
def test_a_healthy_multi_answer_still_hydrates(tmp_path):
|
||||
"""The other direction, so the guard cannot be satisfied by rejecting
|
||||
everything. Defeating change: requiring `answers` unconditionally, which
|
||||
would break every single-question pick."""
|
||||
declare_pick(tmp_path, "multi", {"title": "T", "questions": [
|
||||
{"key": "r1", "prompt": "A?", "options": ["x", "y"]}]})
|
||||
declare_pick(tmp_path, "single", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
raw = json.loads((tmp_path / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
if e["id"] == "multi":
|
||||
e["answer"] = {"answers": {"r1": {"choice": "x", "notes": ""}}, "notes": ""}
|
||||
if e["id"] == "single":
|
||||
e["answer"] = {"choice": "x", "notes": ""}
|
||||
(tmp_path / ".marks.json").write_text(json.dumps(raw))
|
||||
by = {m.id: m for m in marks_for(tmp_path)}
|
||||
assert by["multi"].error is None, by["multi"].error
|
||||
assert by["single"].error is None, by["single"].error
|
||||
|
||||
|
||||
def test_the_package_version_carries_no_literal_of_its_own():
|
||||
"""`booth.__version__` said `0.1.0` through six releases while pyproject
|
||||
said `0.6.1` — a second copy of one fact, drifting silently, found only
|
||||
while cutting 1.0.
|
||||
|
||||
THE ASSERTION IS THE ABSENCE OF A LITERAL, not agreement with pyproject:
|
||||
`__version__` is now READ from pyproject, so comparing the two would be
|
||||
circular and would prove only that the read works. The defeating change is
|
||||
hardcoding a number back into this module, and that is what this catches.
|
||||
"""
|
||||
src = (pathlib.Path(__file__).parent.parent / "booth" / "__init__.py").read_text()
|
||||
literals = re.findall(r'__version__\s*=\s*["\']([^"\']+)["\']', src)
|
||||
assert not literals, f"booth/__init__.py hardcodes a version again: {literals}"
|
||||
|
||||
|
||||
def test_the_package_version_is_the_one_the_tree_declares():
|
||||
"""And it resolves, from the tree, to what pyproject says — NOT to whatever
|
||||
a stale dist-info in some venv happens to record. Found saying `0.3.0` from
|
||||
installed metadata while the tree was at `1.0.0b1`."""
|
||||
import booth
|
||||
|
||||
declared = tomllib.loads(
|
||||
(pathlib.Path(__file__).parent.parent / "pyproject.toml").read_text()
|
||||
)["project"]["version"]
|
||||
assert booth.__version__ == declared
|
||||
assert booth.__version__ != "0.0.0+unknown", "the pyproject read fell through"
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"""U7 — the rail, the filters, the grid keyboard, and the groups.
|
||||
|
||||
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
|
||||
all") is a real and different question and is the contract's open question.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
|
||||
from booth.app import create_app # noqa: E402
|
||||
from booth.marks import declare_pick, set_flag, write_note # noqa: E402
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gallery(tmp_path):
|
||||
"""A booth with one of each: flagged, annotated, open pick, and plain."""
|
||||
b = tmp_path / "g"
|
||||
b.mkdir()
|
||||
for n in ("a.png", "b.png", "c.png", "d.png"):
|
||||
(b / n).write_bytes(PNG)
|
||||
set_flag(b, "a.png", True)
|
||||
write_note(b, "b.png", "a remark")
|
||||
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}, target="c.png")
|
||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||
return TestClient(app), b
|
||||
|
||||
|
||||
def _tiles(body: str) -> list[str]:
|
||||
"""The rels the grid actually rendered, in render order."""
|
||||
import re
|
||||
# `data-item` already exists on every tile (both the doc and media
|
||||
# variants). Reusing it rather than adding a parallel `data-rel` is the
|
||||
# same one-fact-one-place discipline INV-1 states for item facts.
|
||||
return re.findall(r'data-item="([^"]+)"', body)
|
||||
|
||||
|
||||
def test_the_rail_counts_every_filter(gallery):
|
||||
c, _ = gallery
|
||||
body = c.get("/b/g/").text
|
||||
assert 'class="rail"' in body
|
||||
for token in ("all", "flagged", "annotated", "unanswered"):
|
||||
assert f'data-filter="{token}"' in body, token
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flt,expected", [
|
||||
("all", ["a.png", "b.png", "c.png", "d.png"]),
|
||||
("flagged", ["a.png"]),
|
||||
("annotated", ["b.png"]),
|
||||
("unanswered", ["c.png"]),
|
||||
])
|
||||
def test_a_filter_narrows_the_grid_server_side(gallery, flt, expected):
|
||||
"""INV-4: a filter is a LINK, not a script. Fetched directly, with no JS
|
||||
executed, the server must return the narrowed grid.
|
||||
|
||||
Defeating change: binding filters to a click handler and returning the full
|
||||
grid for every URL — under which this test gets four tiles every time."""
|
||||
c, _ = gallery
|
||||
assert _tiles(c.get(f"/b/g/?filter={flt}").text) == expected
|
||||
|
||||
|
||||
def test_filtering_never_reorders(gallery):
|
||||
"""INV-2, the load-bearing one. Grouping and filtering are VIEWS.
|
||||
|
||||
The defeating change is sorting the grid by anything derived from the
|
||||
filter — which looks right and silently changes what "the third one" means,
|
||||
the misfiled-judgment failure CLAUDE.md invariant 6 exists to prevent.
|
||||
|
||||
Asserted as a SUBSEQUENCE rather than a set: order is the property, so a
|
||||
filter that returned the right tiles in the wrong sequence must go red."""
|
||||
c, _ = gallery
|
||||
# ⚠ THE BASELINE IS COMPUTED INDEPENDENTLY, and that is the whole test.
|
||||
# The first version of this compared each filtered view against the
|
||||
# UNFILTERED RESPONSE — and a mutation that reversed the order reversed
|
||||
# both sides, so it stayed green under the exact change it forbade. Caught
|
||||
# by running the mutation rather than trusting the assertion, which is the
|
||||
# discipline in persistent-memory.d/2026-09-22-vacuous-falsifiers.md and
|
||||
# which this test failed first time out.
|
||||
#
|
||||
# The independent truth is U1 INV-3: the item order IS `sorted(rel)`. So
|
||||
# each filtered view must be sorted, full stop, with no reference to any
|
||||
# other response.
|
||||
for flt in ("all", "flagged", "annotated", "unanswered"):
|
||||
got = _tiles(c.get(f"/b/g/?filter={flt}").text)
|
||||
assert got == sorted(got), f"{flt} rendered out of sorted(rel) order: {got}"
|
||||
# and every filtered view is a SUBSEQUENCE of the true order, not a reshuffle
|
||||
every = sorted(["a.png", "b.png", "c.png", "d.png"])
|
||||
for flt in ("all", "flagged", "annotated", "unanswered"):
|
||||
got = _tiles(c.get(f"/b/g/?filter={flt}").text)
|
||||
assert got == [r for r in every if r in got], flt
|
||||
|
||||
|
||||
def test_an_unknown_filter_falls_back_to_all_and_does_not_500(gallery):
|
||||
"""A filter arrives from a URL, which is operator-editable and link-shared.
|
||||
Defeating change: indexing a dict by the raw parameter."""
|
||||
c, _ = gallery
|
||||
for junk in ("nonsense", "", "../../etc", "flagged;drop"):
|
||||
r = c.get(f"/b/g/?filter={junk}")
|
||||
assert r.status_code == 200, junk
|
||||
assert len(_tiles(r.text)) == 4, junk
|
||||
|
||||
|
||||
def test_the_zoom_ring_is_identical_under_every_filter(gallery):
|
||||
"""The ring is the item order filtered to images and must not notice the
|
||||
grid's filter — otherwise `next` means something different depending on how
|
||||
the operator arrived, and a flag lands on the wrong artifact.
|
||||
|
||||
Defeating change: building the ring from the filtered list."""
|
||||
c, _ = gallery
|
||||
rings = set()
|
||||
for flt in ("all", "flagged", "annotated", "unanswered"):
|
||||
c.get(f"/b/g/?filter={flt}")
|
||||
body = c.get("/b/g/b.png?view=1").text
|
||||
import re
|
||||
rings.add(tuple(re.findall(r'href="([^"]*\.png[^"]*)"', body)))
|
||||
assert len(rings) == 1, f"the ring changed with the filter: {rings}"
|
||||
|
||||
|
||||
def test_the_rail_is_absent_on_a_booth_with_no_grid(tmp_path):
|
||||
"""INV-5's sibling: a rail over nothing is chrome. The standing link board
|
||||
has no items, so it must not render one."""
|
||||
b = tmp_path / "links"
|
||||
b.mkdir()
|
||||
(b / "links.md").write_text("- [r](https://x.test/) <sub>· a · 2026-09-01 00:00</sub>\n")
|
||||
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||
assert 'class="rail"' not in c.get("/b/links/").text
|
||||
|
||||
|
||||
def test_the_keyboard_is_not_bound_when_there_is_no_grid(tmp_path):
|
||||
"""INV-5. Defeating change: binding the handler unconditionally, so `f` on
|
||||
the standing link board swallows the keystroke and flags nothing."""
|
||||
b = tmp_path / "links"
|
||||
b.mkdir()
|
||||
(b / "links.md").write_text("- [r](https://x.test/) <sub>· a · 2026-09-01 00:00</sub>\n")
|
||||
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||
assert "gridkeys" not in c.get("/b/links/").text
|
||||
|
||||
|
||||
def test_the_keyboard_is_bound_when_there_is_one(gallery):
|
||||
c, _ = gallery
|
||||
assert "gridkeys" in c.get("/b/g/").text
|
||||
|
||||
|
||||
|
||||
|
||||
# --- 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 'class="rail-groups"' not in body
|
||||
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