feat(marks): one primitive for operator judgment, so the loop stops running through chat

Five mechanisms existed to get one question next to one artifact. Three of
them were the same thing wearing different clothes, and the third of the three
had no code at all: the operator picked winners out of a 270-image set and
told the session in conversation. `sindra-finalists` is 86 items, every one
captioned, with the selection encoded in the booth's NAME.

A MARK is operator judgment attached to a target — the booth, or one item in
it, addressed by the `rel` U1 established as item identity. Three shapes:

  pick — one of N options a session declared in advance   (was: an ask)
  note — free text the operator volunteered               (had nothing)
  flag — this one                                         (had nothing)

One file per booth, one read path, one place openness is computed, one slot
beside the artifact. The storage shape is the operator's call (2026-09-21) and
follows from U4: "does this booth still owe an answer?" gets asked per booth
per sweep tick and per card per index render, so it has to be one read and not
a walk of a booth holding 270 files. Marks are also not links.md — that is an
O_APPEND content-hash log because 17 handles write it concurrently, whereas a
booth's marks see one session and one operator, so locking the common path
costs nothing.

The 2026-09-09 pick semantics are preserved by NOT rewriting them: partial
answers legal, a blank question lands in `unanswered`, `complete` false until
every question has a pick, the only refusal a submission carrying nothing.
`write_answer` split into the pure `build_answer` plus the storage that went
away with the sidecar; `normalize_ask` untouched.

Three findings worth naming, because each was caught by a gate rather than by
reading the diff again:

  * The seam review found `inline.place` indexes asks by SUBSCRIPT — the only
    consumer in the service that does — so a frozen dataclass breaks it, and
    `inline.py` had been missing from the contract's scope entirely.
  * A retargeted test found a regression in the legacy importer: a malformed
    sidecar that renders "broken" today would have silently vanished on
    migration. It now imports carrying its reason.
  * A partially-answered pick counted as CLOSED on the index while the panel
    beside it rendered it "partial" — the two disagreed about one booth. Open
    is the reading U4 needs, and it is declared rather than smuggled in.

`GET /b/<n>/marks.json` is new and load-bearing: sessions on other hosts polled
`<stem>.answer.json` over HTTP, so removing the sidecar without it would have
taken that capability away. `/b/<n>/asks` 308s to `/marks`. Legacy sidecars are
imported, never deleted — four are live and unanswered.

Also records the operator's deterministic-order directive as a cross-cutting v1
invariant, in ROADMAP.md with the per-collection rule table and as CLAUDE.md
invariant 6. The Booth's job is comparison; an order that moves between renders
does not crash, it misfiles the judgment.

242 tests. No version bump — a release tier for this is the operator's call.
This commit is contained in:
vh
2026-09-21 23:38:27 -07:00
parent 9272c9872e
commit c7f9437a64
23 changed files with 2677 additions and 650 deletions
+72 -133
View File
@@ -189,155 +189,89 @@ Deliberately **not** a database. The board is a markdown file — editable with
any editor, greppable, and trivially prunable by hand, which is the whole point
of the Booth's filesystem-is-the-state model.
## Asks — let the operator pick one of N, and read the pick back
## Marks — operator judgment, attached to an artifact
The one **interactive** primitive. A session needs a human decision — which
render wins, which plan, go/no-go — and wants to act on it without a chat
round-trip. Drop a question in a booth; the page renders it as a radio form
with a notes field; the operator's submit writes an **answer sidecar** the
session reads. Filesystem is still the state:
The one **interactive** primitive, and one primitive for what used to be three
jobs:
| shape | who writes it | what it is |
|---|---|---|
| **`pick`** | a session declares the options, the operator chooses | "which render wins?" — this is what `booth ask` poses |
| **`note`** | the operator, in the browser | free text for the session that posted the work |
| **`flag`** | the operator, in the browser | *this one* — selecting winners out of a set |
All three are the operator judging something and the session reading the
judgment back. They live in **one file per booth**, so "does this booth still owe
me an answer?" is a single read:
```
<booth>/<stem>.ask.json the question (a session writes it)
<booth>/<stem>.answer.json the answer (the web UI writes it, atomically)
<booth>/.marks.json every mark in the booth (dotfile: never a tile, never in the zip)
<booth>/.marks.lock the write lock (a session declares, the operator answers)
```
There are **no `note` or `flag` CLI verbs**, on purpose: the CLI is the session's
side of the loop, and a session does not author the operator's judgment. It reads
it.
```bash
# On nh3-dev — pose, then block until answered (default 1h), then act on it:
booth ask r18-ab winner "Which render wins?" "A — baseline" "B — cudaMallocAsync"
booth answer r18-ab winner --wait # prints the answer JSON when it lands
booth answer r18-ab winner # non-blocking: exit 1 while unanswered
booth asks r18-ab # list a booth's asks + state
booth marks r18-ab # every mark in the booth, as JSON
booth marks r18-ab --wait # block while any pick is still open
# Options can carry an id + detail line instead of a bare label — write the
# JSON yourself (booth.asks.write_ask validates the same way):
cat > ~/booth-data/r18-ab/plan.ask.json <<'EOF'
{"title": "optional short label above the question",
"prompt": "Ship which?",
"options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"},
{"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}],
"notes": true, "notes_label": "why / conditions"}
EOF
# Options can carry an id + detail line instead of a bare label. Write the whole
# declaration yourself and it is validated by the same normaliser the page uses:
booth ask r18-ab plan "Ship which?" "Plan A" "Plan B" # or, for detail lines:
python3 -c '
import pathlib, sys; sys.path.insert(0, "/home/lkraven/development/booth")
from booth.marks import declare_pick
declare_pick(pathlib.Path("/home/lkraven/booth-data/r18-ab"), "plan", {
"title": "optional short label above the question",
"prompt": "Ship which?",
"options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"},
{"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}],
"notes": True, "notes_label": "why / conditions"})'
# From another host: rsync the ask in, then poll the sidecar over HTTP:
curl -sf http://10.100.10.50:8090/b/r18-ab/winner.answer.json # 404 until answered
# From another host: rsync your work in, then read the judgment over HTTP.
# ONE request for the whole booth, rather than one per question.
curl -sf http://10.100.10.50:8090/b/r18-ab/marks.json | jq '.open, .marks[].answer'
```
**Several questions, one form.** Give the ask a `questions` list instead of
`prompt`+`options`; the page renders one form with a radio group per question
and a single submit, every question required. Per-question `notes: true` adds
a small text field under that question; the form-level `notes` stays one field
for the whole ask. The answer is keyed by question:
**Several questions, one form.** Give the declaration a `questions` list instead
of `prompt`+`options`; the page renders one form with a radio group per question
and a single submit. Per-question `notes: true` adds a small text field under
that question; the form-level `notes` stays one field for the whole pick. The
answer is keyed by question:
```python
declare_pick(booth, "batch", {
"title": "R18 batch review",
"questions": [
{"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": True},
{"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]},
{"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}],
"notes": True, "notes_label": "anything else"})
# -> answer: {"stem", "title", "answers": {"r1": {"prompt", "choice",
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}},
# "unanswered", "complete", "notes", "answered_at", "answered_by"}
```
**A partial answer is recorded, not refused.** A question left blank is a
deliberate outcome — "none of these", "not yet", "ask me later" — so it lands in
`unanswered`, stays absent from `answers` unless it carried a note, and
`complete` stays false. **A partially-answered pick still counts as open**, which
is what the index badge reports. The one refusal is a submission carrying nothing
at all: no choice anywhere and no notes.
**Migrating a booth that predates marks.** The old two-sidecars-per-question
files (`<stem>.ask.json` / `<stem>.answer.json`) are imported, never deleted:
```bash
cat > ~/booth-data/r18-ab/batch.ask.json <<'EOF'
{"title": "R18 batch review",
"questions": [
{"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": true},
{"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]},
{"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}],
"notes": true, "notes_label": "anything else"}
EOF
# -> batch.answer.json: {"stem", "title", "answers": {"r1": {"prompt", "choice",
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"}
booth marks-import r18-ab # idempotent; the sidecars stay on disk
```
Both shapes also carry **`unanswered`** (the question keys left blank; `[null]`
for a blank single-question ask) and **`complete`** (false until every question
has a pick). A reading session should check `complete` before acting on a
multi-question answer, and treat a key in `unanswered` as "not decided", never
as "declined".
The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
"unanswered", "complete", "answered_at", "answered_by"}` — `choice` is the option id (the label itself
for string options), `choice_index` its 0-based position, `answered_by` the
client address. `POST /b/<name>/answer` is what the form submits — fields `ask` plus
`choice` / `notes` (single) or `choice.<key>` / `notes.<key>` / `notes` (multi);
a missing or bad choice is a 400, an unknown stem a 404.
Rules of the primitive:
- **Radio, one pick per question.** ≥ 2 options, ≤ 40 per question, ≤ 30
questions per ask. No multi-select checkboxes (not yet asked for). Many asks
per booth are fine — each is its own form and its own sidecar; use
`questions` when the picks belong together and should land as one answer.
- **Re-answering overwrites.** The sidecar is the *current* answer, not a log.
The page shows the recorded answer with a collapsed *change answer* form.
- **Blanks are legal — a partial answer is recorded, not refused.** Leaving a
question alone is a real outcome ("none of these", "not listened to yet"), and
refusing the whole submission over one blank threw away the picks that WERE
made. So every answered question is recorded, every blank one lands in
`unanswered`, and `complete` says whether the set is finished. The radios carry
no HTML `required`, so the browser does not block the submit either. A question
left blank but carrying a note keeps the note (`choice: null`). The one refusal
is a submission with **no pick anywhere and no notes** — a 400, because it would
flip an open ask to "answered" while recording no decision, which is worse for
the reading session than leaving it open. A choice that is not in the option
list is still an error: that is a broken form, not a skipped question.
Partially-answered asks show as `◐ partial` with an `n/N` count; re-submitting
fills in the rest.
- **Open asks are flagged** — an amber `? N asks` badge on the index card and in
the booth header — so a waiting question is visible from the front page.
- **A broken ask is shown as broken**, not hidden: if the JSON does not
validate, the page says why, so a session never thinks it posted a question
the operator cannot see.
- Ask/answer files are not gallery items and do not count toward the booth's
item count; they render as the panel above the gallery. Answering bumps the
booth's mtime, so it lives another TTL — the session has 24h to read it.
- Works with JavaScript off (plain form POST). No auth, same as everything here.
### Where the form renders
Two booth shapes, two placements. Either way the ask is never invisible — that
is the guarantee; markup only moves it somewhere better.
**Auto-gallery booth** (no `index.html` of its own): the asks panel renders
above the gallery, styled like the rest of the Booth. Nothing to do.
**A booth serving its own `index.html`**: that page is returned verbatim, so the
Booth substitutes **placeholders in your markup** rather than rendering a panel
above a gallery that does not exist. The question then sits with the artifact it
is about (operator ruling 2026-09-09: *"the asks should be inline with the
artifacts, not on a separate page"*).
**When inline is worth the markup, and when it is not.** The test is whether the
artifact can be held in the head while the form is on screen. Two short images
side by side — no, the appended form is fine. Twenty audio clips, five per voice
across four voices — yes: on a separate page the operator is choosing from
*memory of the audio*, not from the audio, and by the fourth voice that memory is
gone. That is the case this mechanism exists for (framing owed to tts-dev,
2026-09-09, from the `redo-anchors` audition).
```html
<div data-booth-ask="anchors"></div> <!-- the whole ask: every question + submit -->
<div data-booth-ask="anchors:lawson"></div> <!-- just that one question's radios -->
<div data-booth-ask-submit="anchors"></div> <!-- the notes field + submit button -->
<!-- booth:ask anchors:lawson --> <!-- comment form, identical behaviour -->
```
Per-question fragments bind to **one** form via the HTML5 `form=` attribute, so a
four-voice audition puts each radio group under that voice's audio and still
submits every pick in a single POST — which is what a multi-question ask
requires. Fragments ship their own scoped styles, inherit nothing from your page,
and use no JavaScript.
⚠ **Put the placeholder outside any CSS grid or flex container**, or it becomes a
cell in it — measured on `redo-anchors`, where the first attempt rendered as a
224 px sixth grid cell wedged between two audio players. A sibling of the block
it belongs to is right.
The fallbacks, so a page can never strand a question:
| you marked up | what happens |
|---|---|
| nothing | the whole ask is appended at the end of the page |
| some questions, no submit | the rest of the questions **and** a submit block are appended |
| a stem this booth does not have | your markup is left alone, untouched; the real ask is still appended |
An amber `? N open asks` chip floats top-right as a jump link to the first open
ask, and `GET /b/<name>/asks` still renders every ask on a plain page of its own
— useful when you want to hand someone only the question.
## Upload for pickup
The reverse direction — put files in through the web, pick them up by id:
@@ -386,8 +320,13 @@ to a safe basename (no path traversal).
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
| `GET /b/<name>/<file>` | Serve a file out of the booth |
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
| `GET /b/<name>/asks` | The asks panel on its own page — the only place a verbatim-`index.html` booth can show its asks |
| `POST /b/<name>/answer` | Answer an ask (fields `ask` = stem, `choice`/`choice.<key>`, `notes`/`notes.<key>`, `back`); writes `<stem>.answer.json`, 303 back |
| `GET /b/<name>/marks` | The marks panel on its own page — the only place a verbatim-`index.html` booth can show its marks (`/asks` 308s here) |
| `GET /b/<name>/marks.json` | Every mark as JSON, plus `open` — the read path for a session that is not on this host |
| `POST /b/<name>/answer` | Answer a pick (fields `ask` = mark id, `choice`/`choice.<key>`, `notes`/`notes.<key>`, `back`); 303 back |
| `POST /b/<name>/note` | Attach free text to an item (`target`) or to the booth (`target` empty); `text` required |
| `POST /b/<name>/flag` | Flag or unflag one item (`target`, `on`) — an upsert; unflagging removes the mark |
| `POST /b/<name>/unmark` | Withdraw one mark (`mark` = its id) |
| `POST /b/<name>/import-asks` | Import this booth's legacy `*.ask.json` sidecars; idempotent, deletes nothing |
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |