Say which rung answered, when the ladder had to relax one - #99
Merged
Conversation
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
…mdb2-90 # Conflicts: # sw.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #90.
What changed
The engine has computed
relaxedandrungsince the ladder shipped andjs/app.jsread neither. Every rung belowaskedproduces rows that meet the need, andif (meets) strip = ''prints nothing whenever the rows meet the need, so all four relaxations were silent.js/engine.jsexportsRUNGS: the ladder's names in the order it walks them.query()now builds its rung list fromRUNGSinstead 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.jsgetsrungPhrase(rung, { needed, maxWalk }): one plain sentence per relaxed rung, in print and in speech. It lives there rather than injs/app.jsbecausejs/app.jstouches the DOM at import and cannot be loaded under node, and becausejs/state.jsalready holds the row phrasing for exactly that reason.js/app.jsstoresstate.rung/state.relaxedoff a ladder run inanswer(),paintListgives the rung the one strip the list gets, and#sayleads with the same sentence so this is not a visual-only disclosure.sw.jsshell 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. Soany-typedoes not mean no ordinary classroom is free; it means fewer than three were, and the ladder preferred three labs to two classrooms. Same forfurther: it means fewer than three near you, not none.Replaying the shipped app's own ranking (
rank()thenshape()) besidequery()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:further/anywhere/longestover a list with rows: rooms a reader counts as free and long enough inside the walk boundany-type: ordinary classrooms on screen that were free and long enoughSo "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).
longestnever 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
#saysentence 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:Proved in both directions by temporarily adding a
rooftoprung: added toRUNGS+ 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 ofjs/app.jsfor the wiring and the precedence (the issue's owngrep -n "relaxed\|rung" js/app.jsreturning 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.mjsgets a frame of a relaxed answer — not done.shoot.mjsdrives 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 onaskedin all 250 probes there, so the frame needs a second pinned origin. Concrete cases that do produce one, if you want to shoot them: from39.9915, -83.0230— Mon 2026-09-14 08:00 at a 2h ask givesany-type, 10:50 at 30 min givesopens-at, 17:50 at 30 min givesfurther, each over four or five rows.js/app.jsbuilds its list fromrank()+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 fromquery(). 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.rank()1.02 ms,query()1.01 ms, so an answer goes from about one millisecond to two.sw.js'sSHELL_CACHEstamp is left at its current SHA; the script's own docs say the committing job runsscripts/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