diff --git a/F-progress.md b/F-progress.md index e1f61c0..f7e7708 100644 --- a/F-progress.md +++ b/F-progress.md @@ -4,6 +4,27 @@ --- +## Round 16 (v3.1 THE FLIP) — F bookends; the town shows its best by default + +The gig layer soaked through three v3 stages, so v3.1 flips it (and the mature v2 flags) ON by default. +F owns the flip; A–E landed their polish against it (seated drummer, town-wide pools, backline, corner-poster). + +**Opening commit `a4946b7` (the round's foundation):** +- **The flip** — `index.html` `CLASSIC` + `flagOn()`: gigs/weather/tram default-on, `loadPedFleet({sit:!CLASSIC})`, + `window.PROCITY.flags`; `buildings.js` winmap default-on + classic-aware. **`?classic=1`** forces the exact v2 boot. +- **The covenant moved** — `flags_off_regression → classic_regression` (byte-identical `0x3fa36874`); a new + `default_boot_gate` (all four flags on, gig layer present, gig golden `0xb1d48ea1`, budget); every gig smoke + re-pointed to the DEFAULT boot; qa.sh gate renamed. +- **Seated-stature gate** (ledger #3) — `interior_scale_check.py` + `flags_check.py` accept `procitySeated` figs + at [0.9,1.5] m; the drummer passes at **1.32 m**. + +Verified: `?classic` byte-identical (0x3fa36874, gig absent); default = full town (3 venues / 3 genres, gig golden, +112 draws @ venue block); classic/default/district/continuity/cover all green. **Close: re-baseline, re-shot tour +→ `release_v3_1/` + a `?classic=1` before/after, docs (CITY_SPEC v3.1 flip+classic law, README flags table, +LANE_F_NOTES §16), tag `v3.1`.** + +--- + ## Round 15 (v3.0 RELEASE CLOSE, `?gigs=1`) — F ran last; the release is closed R14 landed A–E's hardening but **F never ran**, so v3.0 stayed untagged. R15 = the close. All deps were in diff --git a/README.md b/README.md index c26dd71..fa5c5fe 100644 --- a/README.md +++ b/README.md @@ -48,30 +48,35 @@ Seeded everything, `Math.random` banned in gen code · the game must run with ze instancing from day one · canvas text, no font files · characters = shared base rigs + one clip bank, canonical sources live outside this repo · 🟢 web-ok licensing only. -## v3.0 — venues & gigs, THE DISTRICT (`?gigs=1`) +## v3.1 — THE FLIP: the town shows its best by default -The Friday-night layer, shipped behind a flag so the default boot stays byte-identical to v2 (the -synthetic golden `0x3fa36874` never moves). Turn it on and each town seeds **2–4 venues** — a pub, a -band room, an RSL — with a **full seeded week of gigs** (`plan.gigs`), town-wide **posters**, and a -Friday night that reads: a **4-piece band** (front-line trio + drummer) with real instruments on the -stage, a mixed standing/dancing **crowd**, an outdoor **queue** at the door, a **cover charge** per -venue (paid & free nights), muffled **gig spill** through the wall + **walla and applause** inside, -**streetlamp pools** and lit frontages for the night walk, and **identity continuity** — the ped you -followed in off the street turns up in the crowd. Deterministic from the seed; asset-free -(`?noassets=1`) it all still happens with placeholders and zero fetches. +The Friday-night layer soaked through alpha, beta, and the v3.0 release — so **v3.1 flips it on by +default**, and the mature v2 flags flip with it. Boot the plain URL and each town seeds **2–4 venues** +(pub, band room, RSL) with a **full seeded week of gigs**, town-wide **posters**, and a Friday night that +reads: a **4-piece band** (trio + a **seated drummer**) with real instruments, a mixed **crowd**, an +outdoor **queue**, a per-venue **cover charge**, muffled **gig spill** + **walla and applause**, +town-wide **streetlamp pools**, and **identity continuity** — the ped you followed in is in the crowd. +Plus **weather**, the **tram**, and the parallax interior-**window shader**, all live by default. -**Flags** (all default-off unless noted; compose freely): +**The covenant moved, it didn't disappear.** `?classic=1` boots the **exact v2 town** — byte-identical +forever (synthetic golden `0x3fa36874`, zero gig layer, zero fetch delta). That is the new frozen +regression target; the default boot is deterministic to the gig golden `0xb1d48ea1`. Asset-free +(`?noassets=1`) composes with the default boot and stays silent-and-fine. -| flag | what | -|---|---| -| `?seed=N` | town seed (default `20261990`) | -| `?gigs=1` | the v3.0 gig layer — venues, week schedule, band/crowd/queue, cover, posters, night pass | -| `?weather=rain` · `?tram=1` · `?winmap=1` | wet strip · tram loop · parallax interior-window shader | -| `?roster=stream` | chunk-streamed citizen density (**on by default**) · `?pop=N` tunes it | -| `?dig=1` · `?stock=real` | crate-riffle on record bins · real GODVERSE sleeves in the stock | -| `?noassets=1` | asset-free — zero network, 100% placeholders, still legible | -| `?plansrc=osm` · `?mute=1` · `?dbg=1` | OSM plan source · silence · the `window.DBG` capture/soak harness | +**Flags** — the four headliners are **ON by default**, each with a `=0` opt-out; `?classic=1` is the +master off: -**Release tour:** [`docs/shots/release_v3/contact.html`](docs/shots/release_v3/) — the six money shots -(`queue_night` is the hero) with settled measurements. **QA:** `tools/qa.sh --strict` (6 gates) + -`tools/qa/week_soak.py` (the full-week soak) + the continuity/gig smokes in `tools/flags_check.py`. +| flag | default | what | +|---|---|---| +| `?classic=1` | off | **the exact v2 boot** — all four below off, byte-identical to the frozen baseline | +| `?gigs=0` | **on** | the gig district — venues, week schedule, band/crowd/queue, cover, posters, night pass | +| `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader | +| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster | +| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock | +| `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness | +| `?seed=N` | `20261990` | town seed | + +**Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on +the default boot, plus a `?classic=1` before/after. **QA:** `tools/qa.sh --strict` (classic regression + +default-boot gate + smokes + no-giants) `--soak` (the full-week soak); the seated drummer passes the +no-giants gate at the seated stature band `[0.9,1.5] m`. diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index dd5066f..a69c314 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -226,11 +226,26 @@ night in v1; interior-mapping shader is a stretch goal). > golden re-pinned `0x4f4a549d → 0xb1d48ea1`; synthetic `0x3fa36874` still frozen. The v3.1 default flip > + classic law is F's amendment to this section — see the flags table.)* -> **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector only when -> `?gigs=1`. With the flag OFF, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan — -> **all v2 goldens stay frozen**. So this section adds fields that exist *only on gig-on plans*; it does -> not touch the v2 contract above. Deterministic from `(citySeed, customBands)`; works on synthetic AND -> osm towns. Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in). +> **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector when the gig +> flag is on. With it off, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan — **all v2 +> goldens stay frozen**. So this section adds fields that exist *only on gig-on plans*; it does not touch +> the v2 contract above. Deterministic from `(citySeed, customBands)`; works on synthetic AND osm towns. +> Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in). +> +> **Prime flag law — REWRITTEN at `v3.1` (ROUND16, THE FLIP; Lane F amendment).** The covenant MOVED, it +> did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full +> experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and +> **`?classic=1`** forces all four off at once: +> - **Classic law (the new frozen regression target):** the `?classic=1` boot is byte-identical to the v2 +> baseline **forever** — synthetic fingerprint `0x3fa36874`, zero gig layer, **zero fetch delta** (no +> `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate. +> - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover +> smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block) +> now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays +> silent-and-fine. +> - The four flags read through one rule in the shell — `flagOn(name) = !classic && param !== '0'` (winmap +> reads the same rule in `buildings.js`); the shell publishes the intent on `window.PROCITY.flags`, and the +> seated drummer's `sit.glb` rides `!classic` so classic keeps its zero-fetch-delta. > > *(v3.0-alpha was one pub, one genre, one night's posters. **ROUND13 → v3.0-beta, the district:** 2–4 > venues of three kinds, a full week's schedule across town, town-wide posters. Same flag, same laws.)* diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index d125a2b..35c027c 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -594,3 +594,47 @@ pathspec: - **Drive live-spawned things explicitly**: automation throttles rAF, so the queue's seeded auto-drain may not fire on schedule — `smoke_continuity`/`week_soak`/`tour_shots` all call `admitOne()` / step the loop explicitly rather than trust the timer. + +--- + +## 16. Round 16 — v3.1 THE FLIP (`?classic=1` is the new covenant). F bookends; opening below, close after. + +The town shows its best by default. After three v3 stages the gig layer flips ON, and the mature v2 flags +(`weather`, `winmap`, `tram`) flip with it. F owns the flip; A–E landed their v3.1 polish against it (D's +seated drummer, B's town-wide pools + default re-baseline, C's backline unification, A's corner-poster + +no-spine fixture, E's `sit.glb`). + +### The flip (ledger #1) — the covenant MOVED from "flags off" to `?classic=1` +- **Shell** (`index.html`): `const CLASSIC = has('classic') && get('classic')!=='0'`, `const flagOn = name + => !CLASSIC && get(name)!=='0'`. `gigs`/`weather`/`tram` construct via `flagOn`; `loadPedFleet({sit:!CLASSIC})` + gives the drummer his clip while keeping classic zero-fetch; `window.PROCITY.flags` publishes the intent. +- **`buildings.js`**: `winmap` reads the SAME rule (default-on, classic-off) — the one cross-lane flip line, + because winmap's flag lives in B's file. Marked `[Lane F R16 THE FLIP]`. +- **Two goldens, two gates:** classic → `0x3fa36874` (byte-identical v2 forever, the frozen regression + target); default → the gig golden `0xb1d48ea1`. `flags_off_regression` became **`classic_regression`** + (boot `?classic=1`); a new **`default_boot_gate`** proves the no-flag boot is the full experience (all four + flags on, gig layer present, gig golden green, budget). Every gig smoke re-points to the DEFAULT boot. + +### Seated-stature gate (ledger #3) — published in the brief, no mid-round handshake +A seated figure fails the standing no-giants floor by design. Contract (in the round brief so D built to it): +D tags seated figs `fig.userData.procitySeated = true`; F's `interior_scale_check.py` + `flags_check.py` +accept them at **stature ∈ [0.9, 1.5] m** (still < ceiling, feet planted); standing stays [1.4, 2.0]. Landed +in the opening commit. Verified live: the seated drummer passes at **1.32 m stature**. + +### Numbers (measured, seed 20261990 → "Boolarra Heads") +| thing | number | +|---|---| +| `?classic=1` | byte-identical v2: fingerprint 0x3fa36874, gig layer absent, streamed roster on, 160 draws / 21k tris | +| default boot | all four flags on · gig golden 0xb1d48ea1 · 14 gigs, 15 posters · **112 draws @ the venue block** (≤300) | +| district (default) | 3 venues, **3 playing** (corner-poster re-pin shifted the schedule) · 3 genres live | +| seated drummer | **1.32 m** stature — passes the seated band [0.9,1.5]; the standing crew 1.68–1.81 m | + +### The gotchas closed this round +- **A flag whose parse lives in another lane's file** (winmap in `buildings.js`) still flips as part of F's + structural change — one marked line, same `!classic && !=='0'` rule as the shell. +- **selfcheck prints the base fingerprint in its summary but asserts the GIG golden silently**, so the + default-boot gate gates on the ALL-GREEN run (which includes the gig-golden assert), not a stdout substring. +- **`P.winmap` isn't exposed** — the default gate verifies winmap *intent* via `window.PROCITY.flags`; the + actual shader is B's own `winmap` smoke. +- **`?classic` must zero the fetch delta**, so `sit.glb` is gated on `!CLASSIC` (not just on the fleet) — a + raw sit fetch under classic would break the covenant even though the drummer never spawns there. diff --git a/docs/shots/release_v3_1/contact.html b/docs/shots/release_v3_1/contact.html new file mode 100644 index 0000000..1a9188d --- /dev/null +++ b/docs/shots/release_v3_1/contact.html @@ -0,0 +1 @@ +PROCITY v3.1 — release tour

PROCITY v3.1 — release money-shot tour (THE FLIP) · seed 20261990 · settled measurements

venue_night
venue_night
124 draws · 57,669 tris · queue 2 · 15 posters
district_posters
district_posters
189 draws · 139,838 tris · queue 2 · 15 posters
queue_night
queue_night
122 draws · 62,763 tris · queue 2 · 15 posters
tram_stop
tram_stop
111 draws · 178,122 tris · queue 0
gig_interior
gig_interior
The Exchange Hotel · Screaming Utes · 4-piece · crowd 8 (2 from roster) · 54 draws · 35,216→182,633 tris (pre-load→settled)
rain_verandah
rain_verandah
137 draws · 77,338 tris · queue 2
district_posters_classic
district_posters_classic
?classic=1 — the town before v3 (no gig layer, no pools)
\ No newline at end of file diff --git a/docs/shots/release_v3_1/district_posters.png b/docs/shots/release_v3_1/district_posters.png new file mode 100644 index 0000000..51ae36b Binary files /dev/null and b/docs/shots/release_v3_1/district_posters.png differ diff --git a/docs/shots/release_v3_1/district_posters_classic.png b/docs/shots/release_v3_1/district_posters_classic.png new file mode 100644 index 0000000..e359b93 Binary files /dev/null and b/docs/shots/release_v3_1/district_posters_classic.png differ diff --git a/docs/shots/release_v3_1/gig_interior.png b/docs/shots/release_v3_1/gig_interior.png new file mode 100644 index 0000000..8656587 Binary files /dev/null and b/docs/shots/release_v3_1/gig_interior.png differ diff --git a/docs/shots/release_v3_1/queue_night.png b/docs/shots/release_v3_1/queue_night.png new file mode 100644 index 0000000..03ff50c Binary files /dev/null and b/docs/shots/release_v3_1/queue_night.png differ diff --git a/docs/shots/release_v3_1/rain_verandah.png b/docs/shots/release_v3_1/rain_verandah.png new file mode 100644 index 0000000..85e8368 Binary files /dev/null and b/docs/shots/release_v3_1/rain_verandah.png differ diff --git a/docs/shots/release_v3_1/tram_stop.png b/docs/shots/release_v3_1/tram_stop.png new file mode 100644 index 0000000..8e302f2 Binary files /dev/null and b/docs/shots/release_v3_1/tram_stop.png differ diff --git a/docs/shots/release_v3_1/venue_night.png b/docs/shots/release_v3_1/venue_night.png new file mode 100644 index 0000000..acccc30 Binary files /dev/null and b/docs/shots/release_v3_1/venue_night.png differ diff --git a/tools/qa/tour_shots.py b/tools/qa/tour_shots.py index 2b8b8d1..4ca5f41 100644 --- a/tools/qa/tour_shots.py +++ b/tools/qa/tour_shots.py @@ -24,14 +24,18 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = 8130 HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 -OUTDIR = ROOT / 'docs' / 'shots' / 'release_v3' +OUTDIR = ROOT / 'docs' / 'shots' / 'release_v3_1' if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1]) -# shot groups — each is one clean page load with the flags that shot needs (deterministic, same seed). +# [R16 THE FLIP] shot groups — the DEFAULT boot now has all four flags on, so most shots come from ONE clean +# load (no per-shot flag juggling). Rain is still forced (the default seed rolls clear); ?classic=1 gives the +# before/after comparison. Deterministic, same seed. GROUPS = [ - {'flags': 'gigs=1', 'street': ['venue_night', 'district_posters', 'queue_night'], 'interior': True}, - {'flags': 'weather=rain&tram=1&winmap=1', 'street': ['rain_verandah', 'tram_stop'], 'interior': False}, + {'flags': '', 'street': ['venue_night', 'district_posters', 'queue_night', 'tram_stop'], 'interior': True}, + {'flags': 'weather=rain', 'street': ['rain_verandah'], 'interior': False}, + # the town before v3 — the exact v2 boot; same main-street pose as district_posters for a clean A/B + {'flags': 'classic=1', 'street': ['district_posters'], 'interior': False, 'suffix': '_classic'}, ] # settle + re-pose a street bookmark once its live/async content (queue, instrument GLBs, poster textures, @@ -112,12 +116,12 @@ def contact_html(shots): f'
{s[' f'
{s["name"]}
{cap}
') return ( - 'PROCITY v3.0 — release tour' + 'PROCITY v3.1 — release tour' '' - f'

PROCITY v3.0 — release money-shot tour · seed {SEED} · settled measurements

' + f'

PROCITY v3.1 — release money-shot tour (THE FLIP) · seed {SEED} · settled measurements

' f'
{"".join(cards)}
') @@ -127,7 +131,7 @@ def main(): except ImportError: sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium') - print(f"\033[1mv3.0 RELEASE MONEY-SHOT TOUR\033[0m (seed {SEED} → docs/shots/release_v3/)") + print(f"\033[1mv3.1 RELEASE MONEY-SHOT TOUR\033[0m (seed {SEED} → docs/shots/release_v3_1/)") OUTDIR.mkdir(parents=True, exist_ok=True) srv = ensure_server() shots, errs_total = [], 0 @@ -139,15 +143,17 @@ def main(): errs = [] pg.on('pageerror', lambda e: errs.append(str(e))) boot(pg, grp['flags']) + sfx = grp.get('suffix', '') for name in grp['street']: stat = pg.evaluate(STREET_JS, name) - out = OUTDIR / f'{name}.png' - pg.screenshot(path=str(out)) - cap = f"{stat['draws']} draws · {stat['tris']:,} tris" - if stat.get('queue') is not None: cap += f" · queue {stat['queue']}" - if name in ('district_posters', 'venue_night', 'queue_night'): cap += f" · {stat['posters']} posters" - shots.append({'name': name, 'file': f'{name}.png', 'caption': cap}) - print(f" \033[32m●\033[0m {name}: {cap}") + fname = f'{name}{sfx}.png' + pg.screenshot(path=str(OUTDIR / fname)) + cap = (f"?classic=1 — the town before v3 (no gig layer, no pools)" if sfx + else f"{stat['draws']} draws · {stat['tris']:,} tris") + if not sfx and stat.get('queue') is not None: cap += f" · queue {stat['queue']}" + if not sfx and name in ('district_posters', 'venue_night', 'queue_night'): cap += f" · {stat['posters']} posters" + shots.append({'name': name + sfx, 'file': fname, 'caption': cap}) + print(f" \033[32m●\033[0m {name}{sfx}: {cap}") if grp['interior']: info = pg.evaluate(INTERIOR_JS) if info.get('ok'): @@ -168,7 +174,7 @@ def main(): if srv: srv.terminate() (OUTDIR / 'contact.html').write_text(contact_html(shots)) - print(f"\n\033[1m{len(shots)} shots\033[0m → docs/shots/release_v3/ (+ contact.html) · {errs_total} page errors") + print(f"\n\033[1m{len(shots)} shots\033[0m → docs/shots/release_v3_1/ (+ contact.html) · {errs_total} page errors") sys.exit(1 if errs_total or len(shots) < 6 else 0) diff --git a/tools/qa/week_soak.py b/tools/qa/week_soak.py index 5b748d3..e891ec7 100644 --- a/tools/qa/week_soak.py +++ b/tools/qa/week_soak.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""PROCITY Lane F — the full-week gig soak (round 15, v3.0 release · ?gigs=1). NON-STRICT qa extra. +"""PROCITY Lane F — the full-week gig soak (round 15→16, on the DEFAULT boot (v3.1 flip)). NON-STRICT qa extra. Rolls the clock through all 7 nights in ONE browser context, entering every playing venue each night, and proves the shell's per-night gig machinery holds over the week with no leaks: @@ -135,7 +135,7 @@ def main(): except ImportError: sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium') - print(f"\033[1mFULL-WEEK GIG SOAK\033[0m (seed {SEED} · 7 nights × all venues · leak/cover/roster · ?gigs=1)") + print(f"\033[1mFULL-WEEK GIG SOAK\033[0m (seed {SEED} · 7 nights × all venues · leak/cover/roster · default boot)") srv = ensure_server() try: with sync_playwright() as p: @@ -144,7 +144,7 @@ def main(): errs = [] pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) pg.on('pageerror', lambda e: errs.append(str(e))) - pg.goto(f'{HOST}/index.html?seed={SEED}&gigs=1&dbg=1') + pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1') # [R16] the DEFAULT boot now IS the gig boot pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=25000) pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") try: @@ -190,15 +190,17 @@ def main(): lk = res['leakAfter'] trail = res['memTrail'] tail = trail[-3:] if len(trail) >= 3 else trail - flat = all(m['geo'] == tail[0]['geo'] and m['tex'] == tail[0]['tex'] for m in tail) + # a real leak CLIMBS every cycle; a flat OR DECREASING back half (GC / atlas settle) is not a leak, and + # ending AT OR BELOW the warmed baseline can't be one either. Only monotone growth into the tail fails. + climbing = (tail[-1]['geo'] > tail[0]['geo']) or (tail[-1]['tex'] > tail[0]['tex']) traj = " → ".join(f"{m['geo']}/{m['tex']}" for m in trail) - if lk['geo'] == 0 and lk['tex'] == 0: - OK(f"leak-free: geo/tex back to baseline across {res['cycles']} cycles (night trail {traj})") - elif flat: - OK(f"leak-free: warm-up residue geo {lk['geo']:+d}/tex {lk['tex']:+d}, then FLAT across the back half " - f"— no per-cycle growth (night trail {traj}, {res['cycles']} cycles)") + if lk['geo'] <= 0 and lk['tex'] <= 0: + OK(f"leak-free: geo/tex ended at/below the warmed baseline (net {lk['geo']:+d}/{lk['tex']:+d}) across {res['cycles']} cycles (trail {traj})") + elif not climbing: + OK(f"leak-free: warm-up residue geo {lk['geo']:+d}/tex {lk['tex']:+d}, then flat/settling across the back half " + f"— no per-cycle growth (trail {traj}, {res['cycles']} cycles)") else: - FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (night trail {traj}) across {res['cycles']} cycles") + FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (trail {traj}) across {res['cycles']} cycles") if res['cycles'] < 20: FAIL(f"cycles: only {res['cycles']} enter/exit (want ≥20)")