Lane A round 4: harden openLate contract (hours>=22 ⟺ openLate == the video), confirm F2

v1-tag round. Two confirmations + one hardening fix; qa.sh --strict GREEN (4/4).

A2 — openLate: F's night gate `hours[1] >= 22` matched 4–15 shops/seed (regular videos
jitter base-21 → 22), not the single landmark the shell assumes. Fixed in plan.js: regular
shops close by LATE_HOUR-1 (21:00); only the deterministically chosen openLate landmark
reaches ≥22, pick is now video-first. So `hours[1] >= 22` and the `openLate` field are the
same singleton, always the video rental (verified 15 seeds). Makes generation match
web/index.html:159-161 + soak.py:68 ("at night only the openLate shop is open"). selfcheck
asserts exactly-one / field⟺threshold / is-video. ALL GREEN 1301/1301.

A1 — Lane F's F2 permittedMax storeys scoping: confirmed 0 warnings across 7 seeds + a
false-negative test (injected video@2 in a scratch checker → caught every seed).

GOLDEN.hash 0x098eec2b → 0x3fa36874: intended A2 output change (a few videos close 21 not
22 for seed 20261990), NOT drift; F2 is tooling-only. Same lots/shops/types/names/skins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 18:26:30 +10:00
parent 9416d0ae08
commit 5b8c806271
4 changed files with 73 additions and 11 deletions

View File

@ -1,10 +1,30 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (1283/1283); round-2 (B/E/F requests) and
round-3 (F finding #5 verified stale + golden hash confirmed) closed** (see bottom). `qa.sh --strict`
GREEN. Awaiting Fable review.*
*Status: **all deliverables landed; self-check all-green (1301/1301); rounds 24 closed** (see bottom).
`qa.sh --strict` GREEN. Awaiting Fable review.*
*Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)*
## Round 4 (2026-07-14) — v1-tag round: 2 confirmations, 1 hardening fix
**A1 — storeys checker (post-F2): CONFIRMED.** `tools/qa/consistency_check.mjs` `permittedMax` scoping
(Lane F's F2, matching my R3 spec) gives **0 storeys warnings** across 7 seeds; `qa.sh --strict` GREEN
(4/4). False-negative test: injected a real `video@2` into a scratch copy of the checker's input →
caught on every seed. No benign-anchor false positives, no real-drift false negatives.
**A2 — openLate contract: AUDITED + HARDENED.** F's night gate `hours[1] >= 22` was matching 415
shops/seed (regular videos jittered from base-21 to 22), not the single landmark it assumes. Fixed in
plan.js so `hours[1] >= 22``openLate` (exactly one, always the video rental): regular shops close
by 21:00; only the deterministically-chosen video landmark reaches ≥22 (`LATE_HOUR`). This also makes
the generator finally match what `web/index.html:159-161` and `soak.py:68` already assumed
("at night only the openLate shop is open"). Added selfcheck assertions; verified across 15 seeds.
**⚠ GOLDEN.hash: `0x098eec2b``0x3fa36874`** — intended output change from the A2 fix (not drift;
F2 is tooling-only and didn't touch generation). Same lots/shops/types/names/skins; only a few videos'
close hour moved 22→21. Details for Fable in `LANE_A_NOTES.md` (round 4).
**Self-check:** `✓ ALL GREEN — 1301/1301` (was 1283; +18 are the new openLate exactly-one / field⟺threshold
/ is-video assertions). Seed 20261990 → "Boolarra Heads": 681 lots, 493 shops, unchanged.
> **⚠ Fable — commit-label mixup (no action needed on the code):** my round-2 changes are committed
> and correct, but a shared-working-tree race landed them under Lane C's message —
> commit **`4235060` "Lane C: keeper stand pose…" actually contains only my 5 Lane A files** (a

View File

@ -2,6 +2,36 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 4 (2026-07-14)
### → Lane F (A1): storeys checker (F2) confirmed — 0 warnings, no false negatives
`tools/qa/consistency_check.mjs` `permittedMax` scoping (your F2) is **correct**. Verified this round:
- `node tools/qa/consistency_check.mjs` across 7 seeds → **0 storeys warnings**; `qa.sh --strict` GREEN (4/4).
- **False-negative test**: injected a real `video@2` (registry `[1,1]`) into a scratch copy of the
checker's plan input (NOT plan.js) across 5 seeds → the storeys gate still fired every time. So the
scoping suppresses benign 3-storey corner anchors without hiding real single-storey-type drift. ✓
### → Fable / Lane F (A2): openLate contract HARDENED — `hours[1] ≥ 22` now ⟺ `openLate` (and it IS the video)
Your night gate `plan.shops.some(s => s.hours[1] >= 22)` was matching **415 shops/seed**, not one:
regular video rentals (base close 21) jittered up to 22. That contradicted the shell's own contract
(`web/index.html:159-161`: "exactly one openLate landmark, always the video rental… at night only the
openLate shop is open") and `soak.py:68`. Rather than ask you to change the gate, I made reality match it:
- **plan.js**: regular shops now close by `LATE_HOUR-1` (21:00); only the deterministically-chosen
`openLate` landmark reaches ≥22. The openLate pick is now **video-first** (milk-bar / any-non-stall
only if a town somehow has no video — never happens in practice).
- **Result (verified 15 seeds)**: exactly ONE shop has `hours[1] ≥ 22`, it is exactly the `openLate`
shop, and it is always the video rental. `{s: s.hours[1] >= 22}` and `{s: s.openLate}` are now the
same singleton — **gate on either**, they agree.
- selfcheck.js asserts all of this now (exactly-one, field⟺threshold, is-video).
**⚠ GOLDEN.hash changed: `0x098eec2b``0x3fa36874`.** This is the *intended* consequence of the A2
plan.js fix (a handful of video shops now close at 21 instead of 22 for seed 20261990). It is **not**
drift, and **F2 (your tooling change) did not touch generation** — that half of A1 holds. selfcheck.js
carries the new golden and is GREEN. Nothing else in a plan changed (same lots/shops/types/names/skins).
## Round 3 (2026-07-14)
### → Lane F: the `qa.sh` "storeys outside registry range" warnings are BENIGN (finding #5 closed)

View File

@ -15,6 +15,10 @@ import { shopName, townName } from './names.js';
const r2 = v => Math.round(v * 100) / 100; // position/size precision (2dp metres)
const r4 = v => Math.round(v * 10000) / 10000; // angle precision (radians)
const clampHour = h => Math.max(0, Math.min(23, h | 0));
// The town has exactly ONE late-night landmark. Regular shops close by LATE_HOUR-1; only the
// deterministically-chosen `openLate` shop reaches ≥ LATE_HOUR — so `hours[1] >= LATE_HOUR`
// and the `openLate` field are equivalent, and both identify that single shop (Lane F night gate).
const LATE_HOUR = 22;
// World-space corners of a lot's rotated footprint. Convention matches selfcheck + Lane B:
// a lot faces its street via `ry`; corners are [±w/2, ±d/2] rotated by ry about the lot centre.
@ -79,6 +83,7 @@ export function generatePlan(citySeed) {
if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1));
if (close <= open) close = clampHour(open + 2);
close = Math.min(close, LATE_HOUR - 1); // regular shops close by 21:00 (only the openLate landmark is later)
// (the town's one guaranteed late-night shop is chosen deterministically after the whole
// shop set is final — see the `openLate` pass at the end of generatePlan.)
const { name, sign } = shopName(seed, type);
@ -356,16 +361,18 @@ export function generatePlan(citySeed) {
// The brief calls for one weirdo trading late. Make it a deterministic, explicitly-flagged
// landmark (not a per-shop dice, which gave 0..many and often none past 22:00) so Lane B can
// light it after dark and Lane F can gate on a known field instead of a magic hours threshold.
// Prefer a naturally-late type (video rental / milk bar); market stalls (morning-only) are never it.
// Runs AFTER corner de-confliction so the chosen shop can't then be spliced out.
// The video rental is the archetypal 90s late-night shop, so pick a video first; fall back to a
// milk bar, then any non-stall, only if a town somehow has none. Market stalls (morning-only)
// are never it. Runs AFTER corner de-confliction so the chosen shop can't then be spliced out.
if (shops.length) {
const lateCandidates = shops.filter(s => s.type === 'video' || s.type === 'milkbar');
const pool = lateCandidates.length ? lateCandidates : shops.filter(s => s.type !== 'stall');
const videos = shops.filter(s => s.type === 'video');
const milkbars = shops.filter(s => s.type === 'milkbar');
const pool = videos.length ? videos : (milkbars.length ? milkbars : shops.filter(s => s.type !== 'stall'));
if (pool.length) {
const lr = rng(citySeed, 'openlate', 0);
const chosen = pick(lr, pool);
chosen.openLate = true;
chosen.hours = [chosen.hours[0], 22 + ((lr() * 2) | 0)]; // closes 22:00 or 23:00
chosen.hours = [chosen.hours[0], LATE_HOUR + ((lr() * 2) | 0)]; // 22:00 or 23:00 — the town's only shop ≥ LATE_HOUR
}
}

View File

@ -40,7 +40,7 @@ for (const s of SEEDS) {
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
section('golden fingerprint (output-drift guard)');
const GOLDEN = { seed: 20261990, hash: 0x098eec2b };
const GOLDEN = { seed: 20261990, hash: 0x3fa36874 };
{
const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0;
ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`);
@ -183,10 +183,15 @@ for (const s of SEEDS) {
const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length;
ok(milkbars >= 2 && milkbars <= 4, `seed ${s}: 24 corner milk bars (${milkbars})`);
// exactly one deterministic late-night landmark (Lane F's night gate keys off `openLate`, not a threshold)
// exactly one deterministic late-night landmark, and the `openLate` field ⟺ `hours[1] >= 22`
// (Lane F's night gate may key off EITHER — they identify the same single shop).
const late = plan.shops.filter(sh => sh.openLate);
const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22);
ok(late.length === 1, `seed ${s}: exactly one open-late shop (${late.length})`);
ok(late.every(sh => sh.hours[1] >= 22 && sh.type !== 'stall'), `seed ${s}: open-late shop closes ≥22:00 and isn't a market stall`);
ok(ge22.length === 1, `seed ${s}: exactly one shop closes ≥22:00 (${ge22.length})`);
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `seed ${s}: openLate field ⟺ hours[1]≥22 (same shop)`);
ok(late.length === 1 && late[0].hours[1] >= 22 && late[0].type !== 'stall', `seed ${s}: open-late shop closes ≥22:00 and isn't a market stall`);
ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `seed ${s}: the open-late shop is the video rental`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}