Tag the Photo — audit, and the rework it led to

29 July 2026 · work-queue item 3A · read against docs/handoff-2026-07-29.md · updated after approval: the rework is built, and two figures below were wrong

Two corrections to the first version of this document, both in the direction of worse. Everything else below stood up, and §2's findings were all real.

Handoff state, verified

Every claim in the handoff's header checks out. Build is clean and the suite passes 105 tests, 0 failures on the iPhone 16 simulator (iOS 18.5). Nothing is committed in either repo, as stated.

Verdict on Tag the Photo: it runs, its tests pass, and it is now wrong.
Nothing crashes and nothing is blank. But roughly 84% of runs show the same lineup twice, and about 29% of fields assert a fault that is not a fault. The cause is exactly what you suspected: TagRun reads entry.positions and nothing else, and the corrections made positions identical between base and receive while moving the actual content into spots. The mode did not break — the ground moved and the mode did not notice.

1 · What changed underneath it

Confirmed directly against Data/rotations.json: positions is now identical between base and receive for all six rotations. That is the correction working as designed. The consequence for Tag is that its puzzle pool quietly collapsed.

PoolCountWhy
Entries after the conflicted filter1718 minus wb-r1-defense
Distinct [Zone: Role] maps4see below
— serve-phase maps3R1≡R4, R2≡R5, R3≡R6 — the two outsides are not individuated, so N and N+3 are the same map
— defense maps1one authored shape for every rotation, which is deliberate and true
Distinct pictures once spots are drawn73 role maps × {base, receive} + 1 defense

The last row is the one that matters, and it is the whole argument for §4: base and receive share a zone map by law but draw as different pictures, so rendering the spots is what takes the pool from four things to look at up to seven. The run is six long, so before this it could not avoid repeating itself and now it just barely can.

The one conflicted row filtered out is wb-r1-defense, the RS front-left slip in your open question 1. That filter still works — TagRunTests covers it.

2 · Findings

HighA six-field run could not avoid repeating itself

Because TagRun.build breaks entry.positions and reads nothing else, every entry sharing a role map is the same puzzle with a different caption — and only four role maps exist. Six fields drawn from four maps repeat by arithmetic, every single run.

On top of that, usable.randomElement samples with replacement, so the same entry could be drawn twice outright; the id suffix -\(out.count) made that invisible to testSameSeedProducesTheSameRun, which compared only id strings.

To Stella this reads as the app repeating itself, which is the one thing a six-field run cannot afford — and worse, it presents one picture under two different rotation numbers, teaching that she has misremembered something when she has not.

HighDefense fields assert a rule that is not a rule

Five of the seventeen pool entries (29%) are defense. TagRun never consults Phase.isBeforeContact or Overlap.applies(to:) — the two pieces of code that exist specifically to say the alignment rule has expired by then. So a defense field can be captioned with Phase.explainer:

"Ball's live, the rules let go, you cover the floor"

…directly above a census reading "2 × libero out of the back row". Per docs/rules-intake.md, the libero is always considered a back-row player — that governs her blocking and attacking, not where she may stand. On defense she may stand anywhere on her side. The field contradicts its own caption and teaches a fiction, which is the specific failure the export invariants were built to prevent everywhere else.

MediumTag ignores spots, so it draws base-shaped pictures for every phase

TagCourt is a bespoke 3×2 grid of rounded rectangles, written before the zone/spot/box split. It never touches entry.spots. Meanwhile:

Tag uses neither. Receive and defense both author six spots each (net-left, deep-cross, deep-line…) and Tag renders all of them as a rigid grid. So the receive lesson is invisible in this mode, and the defensive shape is drawn as the very thing the caption says it isn't.

MediumThe mode cannot be lost, and its score type is dead

cleared increments only inside advance(), which is only reachable when remaining == 0. Wrong taps are shown for 0.5s and discarded. So the summary always reads "cleared 6 of 6", whatever she did. TagScore is declared in TagRun.swift and referenced nowhere in Sources/ or Tests/.

Discarding wrong taps is a deliberate choice and the comment gives a good reason — persisting them makes "tag nothing" the safe play. Worth separating that from the summary, though: the reason not to punish a wrong tap in the moment is not a reason for the end screen to congratulate her identically every time.

LowTwo smaller things

3 · One correction to the handoff, and it matters for 3A

The handoff describes Tag as "who's in the wrong spot?" (overlap detection). It is not overlap detection. Overlap.faults compares coordinates, and with everyone at zone centres it returns [] for every possible assignment of six players to six zones — it structurally cannot detect a swap. What Tag actually tests is deviation from the authored lineup, which is a good skill and a different one.

So Tag and Overlap.swift are, today, completely unconnected. Worth knowing before you design a widening on the assumption they already meet.

4 · Recommendation, before any authoring

Render Tag on CourtView with locations from Corpus.locations(for:), and retire TagCourt. One change resolves findings 1 and 3 and half of 2:

The one genuinely new piece: nearest-token hit-testing. A stretched token can be drawn outside its own zone rect — the front-right outside dropping off the net stands in box 1 while her zone is 2 — so onTapZone would accuse the wrong girl. The nearestTarget floor-partitioning already in CourtView for spots is the model to copy. Everything else on that view (marks, showNames, showZoneNumbers) is already what Tag wants, including showZoneNumbers = false once anybody has stretched.

5 · Two things I need from you

1 · What happens to defense fields in Tag? This gates the widening, and I don't think it's mine to pick. I lean (b), and against my own instinct for the tidier answer — because half the faults 3A wants to add (net touch, illegal back-row attack, libero blocking) are live-ball faults that can only occur after contact. That is the defense phase. Dropping it now would amputate the exact phase the widening needs most. This is the same question as 3A's own open one about depicting an action-fault, arriving from the other side.
2 · Item D — answered, and now unblocked. Annotation #10 arrived in full on the second try. Captured with annotations 08 and 09 in docs/whiteboard-intake-2026-07-29.md: Check advances straight to the Action Screen on success; Show Me replaces Check rather than sitting beside it; Play it out leaves the row entirely and becomes a Whiteboard mode that animates a whole rotation set. Nothing built — the control row is untouched. One thing to confirm: the built app labels the third button Reset, not Clear.

7 · What was then built

Approved on the recommendation and on keeping defense with the census reframed. 115 tests pass, build clean, and the mode has been opened and read on screen — which the first version of this document listed as its own weakness.

Tag now draws on CourtView; TagCourt is gone

Placements plus locations from Corpus.locations(for:), so base draws as the grid it is, receive draws stretched, and defense draws as the spread shape it was authored as. Zone numbers are painted at base only. Two new properties on CourtView: tokenMarks (a result ring on the girl, not a tint on her box) and onTapToken (nearest-girl hit-testing).

One field per distinct picture

Grouped on TagRun.picture(of:locations:) — roles and drawn positions and phase — then drawn without replacement. TagField.picture carries the key so the invariant is testable, and a test asserts the corpus still holds seven pictures so that a future authoring change cannot silently shorten every run.

A second kind of breakage, so the law finally bites

Since a swap cannot produce an overlap fault, the generator can now also displace one girl across a neighbour she is constrained against — built from Overlap.neighbours rather than a hand-written list, and verified against Overlap.faults before it ships: if the law does not see exactly the faults the census claims, the field is discarded rather than shown. Only before contact, because after it there is nothing to check. This is also what makes an odd census reachable — swaps append in pairs, so the old wanted = 2 + random(0...1) could only ever deliver 2 or 4.

Defense no longer claims an expired rule

And the fix turned out to need to be wider than "defense", per §8 below.

The summary can now be had badly

It reads "N of 6 clean" plus a wrong-tag count. Wrong taps are still forgotten on the court — persisting them makes "tag nothing" the safe play — but the end screen has noticed. TagScore is wired up rather than deleted. The empty-fields case says so instead of rendering a blank screen.

8 · Three things only found by building and opening it

1 · My own census committed the zone/box conflation this app exists to undo. On R4 receive the front-right zone is drawn at y = 0.51 — the back half of the court. So swapping the libero into that zone draws her behind the attack line while the census reads "libero out of the back row", pointing at a row the picture does not contain. Fixed by splitting on what the picture shows rather than on phase: base alone may use row language, because there a zone is a box and the numbers are painted. Receive and defense name what is visible instead — "swapped net for deep", "swapped side to side" — read off the drawn coordinates. A test now enforces that only base names a row.
2 · "Same seed, same run" was quietly false. Overlap.neighbours returns a Set, and I shuffled it directly — so the displaced girl's destination depended on Set iteration order while consuming an identical number of RNG draws. Only the last field of a run revealed it. Sorted before shuffled.
3 · A fault has to be taggable, not merely present. Crossing a neighbour by a fixed step landed the displaced girl directly on top of a teammate — the authored spots put the libero about 34pt from the setter on purpose — producing a pile of two faces that cannot be accused of anything. Displacement now requires clearance from everybody and retries otherwise. Relatedly, names are off in Tag: a named chip is about 70pt tall, so at those spacings names turned two girls into one unreadable smear exactly where the lesson lives. Face plus role badge is enough, since the census asks for a libero and a middle and never for a name.

9 · Still not done

10 · Final review of this audit

What it is: a read of TagRun.swift, TagView.swift and TagRunTests.swift against the current Data/rotations.json, plus a verified build and test run. Findings 1 and the pool table are arithmetic on the actual data, not estimates.

The lesson I'd keep from this one: the two things I got wrong in the first version were both places where I reasoned about the data instead of querying it, and the three worst bugs in §8 were only found by building the thing and looking at it. The audit was right about what was broken and wrong about how badly, in the same direction both times.

What is not committed: nothing. Both repos are still dirty, as the handoff left them. The corpus was not touched at all — every change is in Sources/, Tests/ and docs/.

Confidence that Tag is now correct as far as it goes: 90. High on the generator — it refuses to ship a fault the law does not confirm, and the invariants are pinned by tests rather than by my reading. Lower on the tagging feel, which no test can reach: the gesture, the 0.5s wrong-tag flash, and whether a census of "2 × swapped side to side" actually converts into a closing checklist for a 13-year-old are all unverified by anything but my judgement.

Confidence that this is the right shape for the widening: 72, and deliberately lower than before. Knowing the pool is seven pictures deep changes the picture: the widening is no longer a nice extension, it is what the mode needs to have enough material to exist. How an action-fault gets depicted is still undecided and still yours.