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
[Zone: Role] because the two outside hitters are
not individuated, and all five usable defense entries are one authored shape.
I derived 11 by assuming base/receive collapsed per rotation and that everything
else was distinct, instead of measuring it. Measuring it took one query.Overlap.faults "become
live". It would not. A swap exchanges which girl stands at which zone but
leaves the geometry untouched, so Overlap — which compares
coordinates — still returns nothing. Making the law bite needed a second kind of
breakage, described in §7.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.
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.
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.
| Pool | Count | Why |
|---|---|---|
Entries after the conflicted filter | 17 | 18 minus wb-r1-defense |
Distinct [Zone: Role] maps | 4 | see below |
| — serve-phase maps | 3 | R1≡R4, R2≡R5, R3≡R6 — the two outsides are not individuated, so N and N+3 are the same map |
| — defense maps | 1 | one authored shape for every rotation, which is deliberate and true |
| Distinct pictures once spots are drawn | 7 | 3 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.
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.
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.
spots, so it draws base-shaped pictures for every phaseTagCourt is a bespoke 3×2 grid of rounded rectangles, written before the
zone/spot/box split. It never touches entry.spots. Meanwhile:
Corpus.locations(for:) already resolves spots to unit court space, falling
back to zone centres for base.CourtView already accepts locations: [String: CGPoint], with a
comment that reads: "Receive is the case this exists for: the zones are identical to base
and the entire lesson is in these offsets."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.
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.
wanted = 2 + Int.random(in: 0...1) can never produce 3 faults. Swaps append
faults in pairs, so the result is always 2 or 4. Not a bug — the tests correctly assert the
census matches — but the name says something the code cannot do.TagRun.build ever returns an empty array, TagView renders a
blank screen: finished is false when fields.isEmpty, so
neither branch draws anything. Currently unreachable, and silent if it ever isn't.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.
Render Tag on CourtView with locations from
Corpus.locations(for:), and retire TagCourt. One change resolves
findings 1 and 3 and half of 2:
Overlap.faults(positions:locations:) becomes live — a swap along a
stretched row produces a real, visible, machine-checkable overlap fault. Tag finally connects
to the one law instead of running beside it.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.
Overlap and Phase.isBeforeContact. Pool shrinks to 12 entries /
6 distinct lineups.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.
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.
CourtView; TagCourt is gonePlacements 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).
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.
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.
And the fix turned out to need to be wider than "defense", per §8 below.
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.
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.
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.
docs/rules-intake.md — is
not started. That was always downstream 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.