Skip to content

Say which rung answered, when the ladder had to relax one - #99

Merged
EnesYilmazcode merged 3 commits into
mainfrom
claude/open-issues-eymdb2-90
Sep 2, 2026
Merged

Say which rung answered, when the ladder had to relax one#99
EnesYilmazcode merged 3 commits into
mainfrom
claude/open-issues-eymdb2-90

Conversation

@EnesYilmazcode

Copy link
Copy Markdown
Owner

Closes #90.

What changed

The engine has computed relaxed and rung since the ladder shipped and js/app.js read neither. Every rung below asked produces rows that meet the need, and if (meets) strip = '' prints nothing whenever the rows meet the need, so all four relaxations were silent.

  • js/engine.js exports RUNGS: the ladder's names in the order it walks them. query() now builds its rung list from RUNGS instead of keeping a second copy inline, so the list the screen is tested against and the list the ladder walks are one list.
  • js/state.js gets rungPhrase(rung, { needed, maxWalk }): one plain sentence per relaxed rung, in print and in speech. It lives there rather than in js/app.js because js/app.js touches the DOM at import and cannot be loaded under node, and because js/state.js already holds the row phrasing for exactly that reason.
  • js/app.js stores state.rung / state.relaxed off a ladder run in answer(), paintList gives the rung the one strip the list gets, and #say leads with the same sentence so this is not a visual-only disclosure.
  • sw.js shell gzip figure re-measured, because the shell grew.

The wording is not the issue's wording, and here is why

The issue proposes "Nothing ordinary is free. These are computer labs and meeting rooms." I measured that sentence and it is false every time it would print.

A rung wins by finding LADDER_QUORUM = three rooms, and the ladder overwrites a held rung whenever a later one reaches quorum. So any-type does not mean no ordinary classroom is free; it means fewer than three were, and the ladder preferred three labs to two classrooms. Same for further: it means fewer than three near you, not none.

Replaying the shipped app's own ranking (rank() then shape()) beside query() over the committed index — 99 origins on a grid around the Oval, Mon–Fri 2026-09-14 to 18, hourly 08:00–20:00, at a 30 and a 60 minute ask, 12,870 answers:

ladder relaxed 9,486 of 12,870
relaxed and the list still had rows 510
further/anywhere/longest over a list with rows: rooms a reader counts as free and long enough inside the walk bound 0, 1 or 2 — never three (85, 7, 16 of 108)
any-type: ordinary classrooms on screen that were free and long enough 1 or 2 — never zero and never three (15 and 151 of 166)

So "Nothing ordinary is free" would have been contradicted by the rows underneath it in all 166 cases. Every sentence therefore counts to the quorum: "Fewer than three ordinary classrooms near you are free for 1h00. Vacant reached into computer labs and departmental rooms." The room words are measured too — over those 166 answers every free, long-enough, non-classroom row on screen carried a secondary type (2P computer teaching lab 130, 5K departmental seminar room 120, of 272).

longest never fired once in the 12,870, so its sentence is the only one with no measurement behind it, and it says the least for that reason.

Strip precedence

Rung first, and only one strip. Both are true at once on 131 of the 510 lists that had rows (25.7%), so printing both would stack a second paragraph over the list a quarter of the time. The row counts describe the rows; the rung describes which question the whole list is answering. The unknown-hours caveat is deliberately untouched — it is not a strip, and a list of rooms nobody publishes hours for has to keep saying so whichever rung found them.

The #say sentence is announced only when the list has rows, because that is the only screen the strip is on: the empty screen already names the same fact in its own words, and a live region reading out a sentence that is nowhere on the page is worse than one that says nothing.

Tests

Eight new tests in scripts/test/engine.test.mjs. The one that matters:

every rung the ladder can return has a sentence, and a new rung fails until it does

Proved in both directions by temporarily adding a rooftop rung: added to RUNGS + the runner, the coverage test fails; added to the runner only, the "walks the names in RUNGS" test fails. Also included: a probe over the committed index asserting that no rung sentence is ever contradicted by the rows printed under it (420 relaxed answers, 19 over a list with rows), plus tests that the walk bound in the sentence is the bound the rows were cut to, that the spoken form says "1 hour" where the strip says "1h00", and a source scan of js/app.js for the wiring and the precedence (the issue's own grep -n "relaxed\|rung" js/app.js returning nothing is what this replaces).

npm test: 674 tests, 670 pass, 0 fail, 4 skipped (baseline on main: 666 / 662 / 0 / 4).

What I did not do

  • scripts/shoot.mjs gets a frame of a relaxed answer — not done. shoot.mjs drives real Chrome and there is no Chrome or Chromium in this environment, so I could not capture a frame, and I was not willing to add a frame definition I could not run or verify. Its pinned scenario (Thompson Library steps, 2026-09-16 10:20) also never relaxes: from the Oval the ladder came back on asked in all 250 probes there, so the frame needs a second pinned origin. Concrete cases that do produce one, if you want to shoot them: from 39.9915, -83.0230 — Mon 2026-09-14 08:00 at a 2h ask gives any-type, 10:50 at 30 min gives opens-at, 17:50 at 30 min gives further, each over four or five rows.
  • The rung describes the ladder's verdict, not literally the rows. js/app.js builds its list from rank() + shape(), which have no ladder in them, so the strip and the rows are two answers to one question. The quorum-honest wording plus the probe test is what keeps them from contradicting each other, but the clean fix is for the list itself to come from query(). That overlaps The ga flag ships in every room and the ranking never reads it, so 98 departmentally controlled rooms rank as if they were general classrooms #89 and would change which rooms are ranked, so it is not in this PR.
  • Cost: the disclosure runs the ladder as a second sweep. Measured under node on the committed index (425 rooms, median of 200 warm runs): rank() 1.02 ms, query() 1.01 ms, so an answer goes from about one millisecond to two.
  • sw.js's SHELL_CACHE stamp is left at its current SHA; the script's own docs say the committing job runs scripts/stamp-sw.mjs. Only the gzip figure in the header was corrected.

🤖 Generated with Claude Code

https://claude.ai/code/session_017xiX95r6nnUUmhD3KwMnWM


Generated by Claude Code

The engine has computed `relaxed` and `rung` since the ladder shipped and
js/app.js read neither, so an answer built by dropping the room-type filter
was headed by the same words as a list of ordinary classrooms. Every rung
below `asked` produces rows that meet the need, and `if (meets) strip = ''`
prints nothing whenever the rows meet the need, so all four relaxations were
silent.

- js/engine.js exports RUNGS, the ladder's names in the order it walks them,
  and query() now iterates that list rather than keeping a second copy. A rung
  added to the runner and not to RUNGS never runs; one added to RUNGS without
  a sentence fails the test below.
- js/state.js gets rungPhrase(), one sentence per relaxed rung in print and in
  speech. It lives there because js/app.js touches the DOM at import and cannot
  be loaded under node, and because that file already holds the row phrasing
  for the same reason.
- js/app.js stores state.rung and state.relaxed off a ladder run, paintList
  gives the rung the one strip the list gets, and #say leads with the same
  sentence so the disclosure is not visual only.

The wording counts to LADDER_QUORUM rather than to zero, and that is the whole
difficulty. A rung wins by finding THREE rooms, so `further` does not mean
nothing near you is free: it means the ladder preferred three further out to
the two close by. Replaying the shipped rank() and shape() beside query() over
the committed index, 99 origins around the Oval, Mon to Fri 2026-09-14 to 18,
hourly 08:00 to 20:00 at a 30 and a 60 minute ask, 12,870 answers: the ladder
relaxed on 9,486 and the list still had rows on 510, and on those the rooms a
reader could count as free and long enough inside the walk bound were 0, 1 or 2
and never three. "Nothing near you is free" over a list showing two free rooms
is the one way this disclosure can lie.

Eight tests, and one of them is the point of the change: every name in RUNGS
must have a sentence, so the next rung is not silent the way `any-type` was.
Both directions were proved by adding a `rooftop` rung and watching the guards
fail. Another replays the app over the committed index and asserts no sentence
is ever contradicted by the rows printed under it.

674 tests, 670 pass, 0 fail, 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xiX95r6nnUUmhD3KwMnWM
Review found the second clause of three sentences making a claim the rows
underneath refute, and it is structural rather than unlucky.

js/app.js ranks with rank() + shape(). shape() keeps only rows inside
maxWalk, which is 12. `further` and `anywhere` are by definition the rungs
whose answer lies outside that cut, so a room they found can never appear on
the screen their sentence sits on -- not rarely, never. Measured over the
same 12,870-answer replay: 108 of 108 such lists. One of them printed

  "Fewer than three rooms within a 12 minute walk are free for 30 min.
   Vacant looked twice that far."

over five rooms 11 minutes away with 9 usable minutes, while the four rooms
the ladder actually found sat 15 minutes away with 155. And 23 of the 108
replaced silence: main's strip logic printed nothing there, so the change
made those screens worse than the ones it was written to fix.

`opens-at` failed the same way on 12 of 170: "Vacant fell back to rooms that
open later" over lists where every row is free now.

All three keep the first clause, which is about the list, and drop the
second, which was about the search. `anywhere` now quotes maxWalk rather
than maxWalk * 2 for the same reason: 24 is where the ladder looked, 12 is
where the rows were cut, and a reader checking 24 against the walk times in
front of them finds nothing above 12. `further` and `anywhere` come out
identical, and they should -- from the reader's side they are one situation.

The quorum half needed no change: "fewer than three" held in all 25,350
replayed answers, and the any-type sentence is unrefuted in 228 cases.

Three more from the same review:

The ladder ran before paintList(), so a second full sweep of the index sat
between the tap and the first row. Warm that is ~1 ms; cold and unwarmed it
is 10.5 ms here against rank()'s 14.6, and 5-10x that on a mid-range phone,
on the path spike #29 exists to measure. state.rung has exactly one reader
and paintList() is its only caller, so the sweep moves after the rows and
repaints the strip.

The coverage guard's scan read /^ {4}'?([a-z-]+...)/, which captured six
literal names. The `shorter` family is a template literal inside
Object.fromEntries and was never matched, making the branch that handled it
dead code; and [a-z-] rejects digits, so a rung called `roof2` could be
added to the runner, never to RUNGS, and the suite stayed green while it
never ran -- the exact failure the guard exists to catch. It catches it now.

rungPhrase priced a rest-of-day ask with dur(state.needed), which is 12h15
in the morning and a flat "30 min" in the last half hour of the index's day.
#85 puts the ask in words directly above this strip, so merged they would
have read "You asked for the rest of the day" over "free for 12h15" -- two
vocabularies for one ask on adjacent lines, which is what that line exists
to prevent. It takes the words.

675 tests, 671 pass, 0 fail. Shell gzip re-measured through the documented
pipe: 86,235 bytes, exact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xiX95r6nnUUmhD3KwMnWM
@EnesYilmazcode
EnesYilmazcode merged commit 3defdbf into main Sep 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The engine says a relaxed answer "has to admit it" and returns the rung that produced it; the app never reads it

2 participants