From ad8db1b709b766324c6a7ec9f57857e6634ec845 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 15:53:43 +1000 Subject: [PATCH 1/7] =?UTF-8?q?Lane=20B=20S14:=20THREADS=20=E2=80=94=20two?= =?UTF-8?q?=20seam=20asks=20for=20A=20(module=20loading;=20why=20I=20need?= =?UTF-8?q?=20no=20wind=20seam)=20+=20gate-2.3=20probe=20proposal=20for=20?= =?UTF-8?q?C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- THREADS.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/THREADS.md b/THREADS.md index 50c7c01..9a5ae9e 100644 --- a/THREADS.md +++ b/THREADS.md @@ -5852,3 +5852,60 @@ anchors are your GLB), but the tooling is now waiting, not TODO. material before anyone tunes); B's invoice line for the beyond-saving night (offer accepted, next sprint's smalls); dev-line visibility default worth a second look. **Gate 4 remains open: John plays the week β€” SEVEN sprints standing, and the game has never looked more like itself.** + +[B] 2026-07-18 β€” πŸ”Œ **TWO SEAM ASKS FOR A, posted before I write the card so you can land them while I + build.** Read A's contract in full; it holds for everything I need except these. Neither is a + complaint β€” both are the "ask here, don't route around it" case A explicitly invited. + + **ASK 1 β€” the page has no way to LOAD a lane module.** `editor.html`'s boot script imports + `editor.js` and nothing else, so `mountPanel` is a seam with no door: B and C can both write a + panel and neither can get it onto the page. My module is + `web/world/js/editor_score.js` (mine, lands on lane/b, self-registers on import β€” it reads + `globalThis.EDITOR`, mounts order 40, and touches nothing of yours). The whole ask is ONE LINE in + editor.html's boot block, after `createEditor(...)` resolves: + `await import('./js/editor_score.js'); // B β€” SCORE IT (order 40)` + C will want the twin. Suggest you add both lines in one commit β€” a lane-module block with a + comment saying new lanes append here β€” so gate 2's two panels don't become two merge conflicts in + your file. If you'd rather invert it (`createEditor({ modules: [...] })`, or the boot script + importing a `lanes.js` manifest that A owns), any of those work for me; you pick, I'll match it. + Until it lands I develop on a scratch merge with the line added locally, and **my work cannot run + standalone on lane/b β€” it imports A's editor page by design.** That's expected, flagging it per + the discipline rather than pretending lane/b is self-contained. + + **ASK 2 β€” none, and here's why, so nobody wonders whether I routed around one.** I do NOT need a + wind seam on `EDITOR.world`. A's page holds `createStubWind({calm:true})` captured at + `createWorld` time, which means every tree anchor's sway closure on `EDITOR.world` samples the + CALM STUB forever. Scoring off those anchors would re-arm C's landmine 2 β€” the frozen tree that + read q4 at 1.02 against a live 1.24 β€” and it would do it invisibly, which is the worst version. + So the score does not touch `EDITOR.world` at all. It builds its OWN scoring world from + `EDITOR.siteClone()` on a re-pointable wind proxy, which is byte-for-byte what `audit.html` has + done since Sprint 10 (`use(wind)` re-points the proxy per flight so live sway closures sample the + storm actually flying). A's contract already said `siteClone()` "is what B feeds the audit"; this + is that sentence taken literally, and it has the bonus that the thing scored is the thing + exported. **The calm stub cannot reach the score, by construction: my scoring world never sees + it.** Every m/s in the card still comes from `windForSite()` via sweep.js/gardenfly.js β€” no + fourth harness, no private wind. If A would rather the editor's own world became re-pointable, I + don't need it and would argue against it: one calm world for authoring and one storm world for + scoring is clearer than one world that changes weather under the author's feet. + +[B] 2026-07-18 β€” 🎯 **GATE 2.3 β€” C, here is my half of the pin; take it or move it, I'll match you.** + SPRINT14.3 wants "one probe point, one storm second, exact match". Proposing concretely so we can + converge in one round-trip instead of three: + + | knob | proposal | why this one | + |---|---|---| + | site | `backyard_01` | the pinned yard; has a `separation` block and a funnel, so the venturi is live and a funnel-off regression shows up | + | storm | `storm_02_wildnight` | the storm every S13 number was argued over β€” if the pin ever moves, it moves against numbers people remember | + | probe | `(1, 0, 2)` β€” the garden bed's NW corner, ground level | it is bed geometry, not a magic number: `gardenBed {x:1,z:2,w:6,d:4}`. Wind over the BED is the wind the whole score is about | + | second | `t = 30.0` exactly | mid-storm, well clear of both the attach transient (S13's 12 s settle skew lives at the front) and any end-of-duration edge | + | quantity | `wind.speedAt({x:1,y:0,z:2}, 30.0)` | scalar, no vector-component sign argument, and it's the same call C's overlay samples | + | match | **exact** β€” `===` on the float, not a tolerance | two chains that agree to 1e-9 agree; a tolerance is where a funnel-off bug hides (it moved the S13 headline 91.5 β†’ 39.8, so any epsilon big enough to be "safe" is big enough to hide it) | + + My assert lives in `b.test.js` and builds both sides through `windForSite()` β€” the editor's + scoring wind (built from `siteClone()`) and the game's played wind (built from `loadSite()`) β€” + and asserts the same number at that probe/second, plus a mutation-check that dropping the + venturi makes it go red. **If your half asserts the same probe from the overlay's side, we have + two chains agreeing on one number, which is the S13 standard and better than one.** Shout if you + want a different probe (a point inside the funnel throat would test the venturi harder β€” I chose + the bed because it is what the score is FOR, but I'd take either); I'll re-pin to whatever you + name rather than have two pins that disagree. From 6825794d987fe4cfd1db27e2218fa246bfc8e091 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 15:56:50 +1000 Subject: [PATCH 2/7] =?UTF-8?q?Lane=20B=20S14=20gate=202.1:=20SCORE=20IT?= =?UTF-8?q?=20=E2=80=94=20shared=20scorecard=20engine=20+=20editor=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scorecard.js: the audit run-logic that audit.html had grown moves into one shared engine both front-ends import (build the scoring world, sweep, fly the winners, pick the best line, judge the separation block). Takes a site OBJECT, not a path β€” loadSite always returned an object, so the fetch was never the audit's business. Builds its OWN dressed world on a re-pointable wind proxy so the editor's calm stub can never reach a score; every m/s still comes from windForSite() via sweep.js/gardenfly.js. editor_score.js: front-end only, zero audit logic. Mounts at A's reserved order 40, renders the card with A's contract classes, says the funnel state first and loudly, and marks itself stale when the yard changes underneath it. --- tools/site_audit/scorecard.js | 233 ++++++++++++++++++++++++ web/world/js/editor_score.js | 322 ++++++++++++++++++++++++++++++++++ 2 files changed, 555 insertions(+) create mode 100644 tools/site_audit/scorecard.js create mode 100644 web/world/js/editor_score.js diff --git a/tools/site_audit/scorecard.js b/tools/site_audit/scorecard.js new file mode 100644 index 0000000..f3fa50d --- /dev/null +++ b/tools/site_audit/scorecard.js @@ -0,0 +1,233 @@ +/** + * scorecard.js β€” score a site OBJECT, in-memory, through the real chain. + * [Lane B, SPRINT14 gate 2.1] + * + * Sprint 10 gave the audit two front-ends (audit.mjs, audit.html) and ONE + * engine (sweep.js), on the theory that a tool built to catch reimplemented- + * formula drift must not carry two copies of its own math. Sprint 14 adds a + * THIRD front-end β€” A's editor, where the yard being scored has never been a + * file β€” so the run logic that audit.html had grown (build the world, fly the + * winners, pick the best line, judge the separation block) moves HERE, where + * both pages import it. audit.html keeps its rendering and loses its logic. + * + * ── The one thing this module exists to get right ────────────────────────── + * + * The audit's input has always been a site JSON fetched off disk. The editor's + * yard is an object that has never been written down. That is the whole change, + * and it is smaller than it looks: `loadSite()` returns a parsed object, so + * every engine below already took an object β€” the fetch was the front-end's + * business, never the audit's. `scoreSite({ site })` takes the object; where it + * came from is not this module's problem. A's `EDITOR.siteClone()` hands over + * the canonically-ordered clone that the export writes, so an editor score is a + * score of the bytes you would ship, not of an editor-private object. + * + * ── Why this builds its OWN world, and why that is not a private harness ──── + * + * A's editor renders on `createStubWind({ calm: true })`, captured at + * `createWorld` time and deliberately calm: gate 1 is geometry, and a yard you + * can only place a post in during a gale is not authorable. That stub must + * never reach a score β€” and it would, silently, if this module read + * `EDITOR.world.anchors`, because a tree anchor's `sway` closure samples the + * wind its world was built with, forever. That is C's landmine 2 wearing a new + * hat: the frozen gum tree read q4 at 1.02 against a live 1.24, and a tree rig + * eats the tree's sway as dynamic load. So the scoring world is a SEPARATE + * world, built here from the site object on a re-pointable wind proxy, exactly + * as audit.html has done since Sprint 10 β€” `use(wind)` re-points the proxy per + * flight so live sway closures sample the storm actually flying. + * + * This is not a fourth wind harness. Every m/s still comes from `windForSite()` + * (C's shared builder) inside sweep.js and gardenfly.js; this module never + * builds a wind, it only owns the proxy the flights re-point. The stub cannot + * reach the score because the scoring world has never seen it. + * + * ── What it does NOT do ──────────────────────────────────────────────────── + * + * No I/O. Storm defs arrive parsed, because a storm is content and the caller + * knows where content lives (the editor is on a page with an importmap; the + * node front-end has a filesystem). This module fetches nothing so it can be + * driven from a selftest without a network. + * + * Browser-only: `dress()` needs GLTFLoader and gardenfly needs `document`. + * audit.mjs stays node-side and blind, and still points here for garden truth. + */ + +import * as THREE from '../../web/world/vendor/three.module.js'; +import { createWorld } from '../../web/world/js/world.js'; +import { AUDIT, auditSweep } from './sweep.js'; +import { flyGarden, flySeparation } from './gardenfly.js'; + +/** How many affordable lines get flown. Flights are seconds each; the card is + * on-demand and slow is fine, but an unbounded sweep on a yard with fifteen + * anchors is a hang, not a score. */ +export const FLY_CAP = 12; + +/** + * Build a world for SCORING from a site object β€” dressed, on a re-pointable + * wind proxy. Never the editor's own world (see the header). + * + * The proxy starts on a calm placeholder purely so `createWorld` and `dress()` + * have something to call during construction; no score is ever taken against + * it, because every flight calls `use()` first. It is not the editor's stub and + * it is not reachable from one. + * + * @param {object} site parsed/cloned site object (loadSite's shape) + * @returns {Promise<{world, anchors, bed, use, dressed, dressError}>} + */ +export async function buildScoringWorld(site) { + const scene = new THREE.Scene(); + + let currentWind = { + sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), + speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, + gustTelegraph: () => null, eventsBetween: () => [], + }; + const windProxy = { + sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()), + speedAt: (p, t) => currentWind.speedAt(p, t), + rainAt: (t) => currentWind.rainAt(t), + rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0), + gustTelegraph: (t) => currentWind.gustTelegraph(t), + eventsBetween: (a, b) => currentWind.eventsBetween(a, b), + setSheltersFromTrees() {}, + }; + /** C's bench pattern: re-point the yard's wind at the storm being flown, so + * live tree-sway closures move with it. Handed to every sweep/flight below. */ + const use = (w) => { currentWind = w; }; + + const world = createWorld(scene, { wind: windProxy, site }); + + let dressed = false, dressError = null; + if (world.dress) { + try { await world.dress(); dressed = true; } + catch (err) { dressError = err?.message ?? String(err); } + } + + return { world, anchors: world.anchors, bed: world.gardenBed, use, dressed, dressError }; +} + +/** + * The full gauntlet against one site object. + * + * @param {object} o + * @param {object} o.site site object (EDITOR.siteClone() / loadSite()) + * @param {object} o.stormDef the storm to sweep and fly + * @param {string} [o.stormName] label only + * @param {object} [o.sepStormDef] storm for the site's pinned separation block; + * omit and separation is judged on stormDef if + * the keys match, else reported unjudged + * @param {number} [o.flyCap] + * @param {object} [o.prebuilt] a buildScoringWorld() result to reuse + * @returns {Promise} pure data β€” no DOM, no strings-as-verdicts + */ +export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null }) { + const built = prebuilt ?? await buildScoringWorld(site); + const { anchors, bed, use, dressed, dressError } = built; + + // The funnel state is REPORTED, never assumed. Sprint 11 shipped an audit + // whose headline was wrong because the venturi lives in the SITE def and a + // storm def's `wind.venturi` LOOKS right and never fires; the fix was + // windForSite, and the discipline that came with it is that any front-end + // printing a score must also print whether the funnel was on. Three + // harnesses got this wrong; the card says it out loud so a fourth can't. + const venturi = site.wind?.venturi ?? []; + + const { cands, rows, verdict, winners, marginalWinners } = + auditSweep({ anchors, bed, stormDef, siteDef: site, use }); + + // The bare bed β€” the control every garden number is read against. + const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use }); + + // Fly every line the budget can buy. Marginal lines fly too, deliberately: + // they are the trap the margin rule exists to name, and a card that hid them + // would be the 91.9-FULL illusion with better CSS. + const flown = new Map(); + for (const r of [...winners, ...marginalWinners].slice(0, flyCap)) { + // clean winners fly the CLEAN tiers (what the audit recommends buying); + // marginal winners fly the knife-edge tiers (the trap, priced as sold). + // Aligned by anchorId, never input order β€” attach reorders picks into ring + // order and a tier quoted against input order arms the wrong corner. + const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier])); + flown.set(r.ids.join(','), flyGarden({ + anchors, bed, stormDef, siteDef: site, use, + ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)), + })); + } + const skipped = Math.max(0, winners.length + marginalWinners.length - flyCap); + + // Best GARDEN line the budget buys β€” cheapest on ties, and never a marginal + // one. A marginal flight gets reported, not sold. + const rowBy = (key) => rows.find((w) => w.ids.join(',') === key); + const pickBest = (entries) => entries.reduce((best, [key, g]) => { + if (!best) return { key, g }; + if (g.hp > best.g.hp + 0.05) return { key, g }; + if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g }; + return best; + }, null); + const bestGarden = pickBest([...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean)); + const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean)); + + // The site's pinned separation target (A's gate-1.4 ruling), on the block's + // OWN storm β€” the target is site data, not a per-run choice. + let separation = null, sepStormName = null, sepUnjudged = null; + if (site.separation) { + sepStormName = site.separation.stormKey; + const sepStorm = sepStormDef ?? (sepStormName && sepStormName === stormName ? stormDef : null); + if (sepStorm) { + separation = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use }); + } else { + // Judging a pinned target on the wrong storm is worse than not judging it. + sepUnjudged = `pinned against ${sepStormName}, which the caller did not supply`; + } + } + + return { + site: site.id ?? site.name ?? '(unnamed)', + storm: stormName, dressed, dressError, + venturi, funnelOn: venturi.length > 0, + anchorCount: anchors.length, bed, + cands: cands.length, rows, winners, marginalWinners, verdict, + flown, skipped, flyCap, + bare, + bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null, + bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null, + separation, sepStormName, sepUnjudged, + MARGIN: AUDIT.MARGIN, + }; +} + +/** + * The cheapest line that is HONEST β€” clean at the shop's clean price, and + * proven to beat a bare bed in flight. Sprint 13's lesson priced into one + * helper: "cheapest that holds" sold a $20 rig the sim paid βˆ’$97 for, so + * cheapness is only a virtue among lines that actually saved something. + * + * @returns {{row, garden, total}|null} + */ +export function cheapestHonest(score) { + const cands = score.winners + .map((r) => ({ row: r, garden: score.flown.get(r.ids.join(',')) ?? null })) + .filter((c) => c.garden && !c.garden.marginal.length && c.garden.hp > score.bare.hp); + if (!cands.length) return null; + cands.sort((a, b) => a.row.cleanHw - b.row.cleanHw); + const best = cands[0]; + return { ...best, total: best.row.cleanHw + AUDIT.SPARE_COST }; +} + +/** + * Every corner, across every flown line, sitting inside the margin band β€” + * the 15% rule's flag list, deduped by anchor and worst-first. + * + * MANUAL.md records the rule as POLICY while the cause is unfound: a bench + * under-reads the real UI's peaks by ~5–15%, so a corner that holds on paper + * with 4% headroom is D's 39.8 TATTERED in play. Marginal is not PASS. + */ +export function marginFlags(score) { + const worst = new Map(); + for (const r of score.rows) { + for (const c of r.marginal) { + const prev = worst.get(c.id); + if (!prev || c.headroom < prev.headroom) worst.set(c.id, { id: c.id, headroom: c.headroom, peak: c.peak, hint: c.hint, line: r.ids.join(',') }); + } + } + return [...worst.values()].sort((a, b) => a.headroom - b.headroom); +} diff --git a/web/world/js/editor_score.js b/web/world/js/editor_score.js new file mode 100644 index 0000000..267a121 --- /dev/null +++ b/web/world/js/editor_score.js @@ -0,0 +1,322 @@ +/** + * editor_score.js β€” the SCORE IT panel. [Lane B, SPRINT14 gate 2.1] + * + * One button in A's editor runs the whole audit gauntlet against the yard + * currently on the glass, in-page, on demand, and renders the answer as a card: + * winnable lines at $80 with the funnel state SAID OUT LOUD, the cheapest + * honest line, held-vs-bare garden separation, and the 15%-rule margin flags. + * + * ── This file's entire job is to be a front-end ───────────────────────────── + * + * It contains no audit logic and no wind. The gauntlet is `scorecard.js`, which + * is the same engine `audit.html` runs; the wind inside it is `windForSite()`, + * which is C's shared builder and the only door site wind comes through. If you + * find yourself about to compute a load, a coverage or a m/s in this file, that + * is the fourth harness arriving and Sprint 13 says it will be wrong in a new + * way. Put it in the engine where both front-ends get it. + * + * ── What it deliberately does NOT read ────────────────────────────────────── + * + * `EDITOR.world`. Not once. A's page renders on a calm stub wind (correctly β€” + * gate 1 is geometry), and a tree anchor's sway closure samples the wind its + * world was built with forever, so scoring off the editor's anchors would + * freeze every tree at calm and under-read every tree corner exactly the way + * C's landmine 2 did (q4: 1.02 frozen vs 1.24 live). The score builds its own + * dressed world from `EDITOR.siteClone()` β€” which is A's contract taken + * literally ("that is what B feeds the audit"), and has the bonus that the + * yard scored is the yard exported, byte for byte. + * + * ── Registration ──────────────────────────────────────────────────────────── + * + * Self-registering on import: no exports anyone must call, no edit to + * editor.html beyond the one import line that loads it (A's ask-1 seam, + * THREADS 2026-07-18). Mounts at reserved order 40 via `EDITOR.mountPanel`, + * fills `body`, and styles with A's contract classes only. + * + * Because it imports A's page, this module cannot run standalone on lane/b. + * That is by design and is flagged in THREADS rather than worked around. + */ + +import { loadStorm } from './weather.js'; +import { START_BUDGET } from './contracts.js'; +import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags } from '../../../tools/site_audit/scorecard.js'; + +/** The storms a yard gets judged against. `storm_02_wildnight` leads because + * it is the storm every Sprint-13 number was argued over β€” score against the + * one people remember. */ +const STORMS = [ + 'storm_02_wildnight', + 'storm_01_gentle', + 'storm_02b_icenight', + 'storm_03_southerly', + 'storm_03b_earlybuster', +]; + +const el = (tag, cls, text) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; +}; + +/** A `.ed-card-row`: dim key on the left, value on the right. */ +function kv(parent, key, value, cls) { + const row = el('div', 'ed-card-row'); + row.append(el('span', 'ed-kv', key)); + row.append(el('span', cls || null, value)); + parent.append(row); + return row; +} + +function card(parent, head, headCls) { + const c = el('div', 'ed-card'); + const h = el('div', `ed-card-head${headCls ? ' ' + headCls : ''}`, head); + c.append(h); + parent.append(c); + return c; +} + +const pct = (x) => `${(x * 100).toFixed(0)}%`; +const money = (n) => `$${n}`; + +function boot() { + const EDITOR = globalThis.EDITOR; + if (!EDITOR) { + console.error('[score] EDITOR missing β€” editor_score.js must be imported AFTER createEditor() resolves.'); + return; + } + + const { body } = EDITOR.mountPanel({ id: 'score', title: 'SCORE IT', order: 40 }); + body.replaceChildren(); + + // --- controls ------------------------------------------------------------ + const rowStorm = el('div', 'ed-row'); + rowStorm.append(el('span', 'ed-label', 'storm')); + const sel = el('select', 'ed-sel'); + for (const s of STORMS) sel.append(new Option(s.replace(/^storm_/, ''), s)); + rowStorm.append(sel); + body.append(rowStorm); + + const rowBtn = el('div', 'ed-row'); + const btn = el('button', 'ed-btn primary', 'SCORE IT'); + rowBtn.append(btn); + body.append(rowBtn); + + const out = el('div'); + body.append(out); + + const note = el('div', 'ed-note', + 'Scores a fresh dressed world built from the EXPORT clone β€” not the editor\'s ' + + 'view, whose wind is a calm stub. Every number flies windForSite() and the real ' + + 'commitβ†’attach chain. Takes a few seconds; that is the point.'); + body.append(note); + + // A score describes the yard as it was WHEN SCORED. The moment anything + // moves, the card is a claim about a yard that no longer exists β€” so it says + // so rather than quietly ageing on screen. (Same instinct as A clearing the + // overlay on rebuild: one place remembers staleness, nobody else has to.) + let stale = false; + const markStale = () => { + if (!out.firstChild || stale) return; + stale = true; + const warn = el('div', 'ed-card-head ed-warn', '⚠ YARD CHANGED SINCE THIS SCORE β€” re-run'); + out.prepend(warn); + }; + EDITOR.on('change', markStale); + EDITOR.on('siteload', markStale); + + async function run() { + stale = false; + btn.disabled = true; + btn.textContent = 'scoring…'; + out.replaceChildren(el('div', 'ed-note', 'building a dressed world, sweeping every quad, flying the winners…')); + // yield so the button repaints before we block the thread for seconds + await new Promise((r) => setTimeout(r, 0)); + + const t0 = performance.now(); + try { + const site = EDITOR.siteClone(); + const stormName = sel.value; + const stormDef = await loadStorm(stormName); + + // The separation block names its OWN storm β€” judging A's pinned target on + // whatever storm the dropdown happens to show would be a different claim + // wearing the target's name. + const sepKey = site.separation?.stormKey ?? null; + const sepStormDef = sepKey ? (sepKey === stormName ? stormDef : await loadStorm(sepKey)) : null; + + const built = await buildScoringWorld(site); + const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built }); + render(out, score, performance.now() - t0); + globalThis.__editorScore = score; // for the selftest + console spelunking + } catch (err) { + out.replaceChildren(); + const c = card(out, 'SCORE FAILED', 'ed-err'); + c.append(el('div', 'ed-note', String(err?.stack ?? err))); + console.error('[score]', err); + } finally { + btn.disabled = false; + btn.textContent = 'SCORE IT'; + } + } + + btn.addEventListener('click', run); +} + +/** Render a scorecard result. Pure DOM from pure data β€” no scoring here. */ +function render(out, s, ms) { + out.replaceChildren(); + + // ── the header card: WHAT WAS SCORED, and the funnel state, loudly ──────── + // Sprint 11 shipped a headline built with the funnel off, and the number + // looked entirely reasonable. Three harnesses made that mistake because + // nothing printed the funnel state next to the score. This card does, first, + // before any result β€” a score whose weather you can't see is a rumour. + const head = card(out, s.funnelOn ? 'FUNNEL ON' : 'NO FUNNEL', s.funnelOn ? 'ed-warn' : null); + kv(head, 'yard', s.site); + kv(head, 'storm', s.storm ?? 'β€”'); + kv(head, 'venturi', s.funnelOn + ? s.venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' Β· ') + : 'none declared in site.wind'); + kv(head, 'anchors', `${s.anchorCount} ${s.dressed ? 'dressed βœ“' : '⚠ UNDRESSED'}`, + s.dressed ? 'ed-ok' : 'ed-err'); + if (!s.dressed) { + head.append(el('div', 'ed-note ed-err', + `dress() failed (${s.dressError ?? 'unknown'}) β€” these are GRAYBOX positions and every ` + + 'ratingHint silently became 1.0. That is the missing-importmap gotcha; the numbers below ' + + 'are not what ships. Fix the page before believing this card.')); + } + kv(head, 'bed', `${s.bed.w}Γ—${s.bed.d} m at (${s.bed.x}, ${s.bed.z})`); + kv(head, 'took', `${(ms / 1000).toFixed(1)} s`); + + // ── 1. winnability at $80 ──────────────────────────────────────────────── + const V = s.verdict; + const vcls = V.code === 'pass' ? 'ed-ok' : V.code === 'marginal-only' ? 'ed-warn' : 'ed-err'; + const vhead = { pass: 'βœ“ WINNABLE', 'marginal-only': '⚠ MARGINAL ONLY', + unaffordable: 'βœ— UNAFFORDABLE', 'no-cover': 'βœ— NO LINE COVERS THE BED' }[V.code] ?? V.code; + const cw = card(out, `${vhead} β€” at ${money(START_BUDGET)}`, vcls); + kv(cw, 'quads in band', `${s.cands} candidate${s.cands === 1 ? '' : 's'}`); + kv(cw, 'clean lines', String(s.winners.length), s.winners.length ? 'ed-ok' : 'ed-err'); + kv(cw, 'marginal lines', String(s.marginalWinners.length), s.marginalWinners.length ? 'ed-warn' : null); + + if (V.code === 'no-cover') { + cw.append(el('div', 'ed-note ed-err', + 'No quad in the audit band shades the bed at all. This yard cannot be rigged: it needs an ' + + 'anchor near the bed before it ships. (The band is an availability FLOOR, not a ceiling β€” ' + + 'a yard failing here is failing on geometry, not on the heuristic.)')); + } else if (V.code === 'marginal-only') { + const b = V.best; + cw.append(el('div', 'ed-note ed-warn', + `Every affordable line carries a corner inside the ${pct(s.MARGIN)} break-in-game band. ` + + `Best is ${b.ids.join(',')} at ${money(b.hw)} β€” on this bench it holds; in the real game it is ` + + 'the wild night’s 39.8 TATTERED. Clean would cost ' + + `${b.cleanHw != null ? money(b.cleanHw) : 'steel over the shop ceiling'}.`)); + } else if (!V.ok) { + const b = V.best; + cw.append(el('div', 'ed-note ed-err', + `No affordable holding line. Cheapest is ${b.ids.join(',')} at ${money(b.hw)} on a ` + + `${money(START_BUDGET)} budget` + + (b.unholdable.length + ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop’s ceiling at any price.` + : '.'))); + } + + // ── 2. the cheapest HONEST line ────────────────────────────────────────── + // Not the cheapest that holds: Sprint 13 proved that quantity sells rigs the + // sim then charges you for. Cheapest among lines that are clean AND beat a + // bare bed in flight. + const ch = cheapestHonest(s); + const cc = card(out, 'CHEAPEST HONEST LINE', ch ? 'ed-ok' : 'ed-err'); + if (ch) { + kv(cc, 'line', ch.row.ids.join(',') + (ch.row.pinned ? ' πŸ“Œ pinned' : '')); + kv(cc, 'hardware', money(ch.row.cleanHw) + (ch.row.hw < ch.row.cleanHw ? ` (holds at ${money(ch.row.hw)})` : '')); + kv(cc, '+ spare', money(ch.total), ch.total <= START_BUDGET ? 'ed-ok' : 'ed-warn'); + kv(cc, 'area', `${ch.row.area.toFixed(0)} mΒ²`); + kv(cc, 'garden', `${ch.garden.hp} ${ch.garden.state.toUpperCase()}`, + ch.garden.state === 'full' ? 'ed-ok' : ch.garden.state === 'tattered' ? 'ed-warn' : 'ed-err'); + kv(cc, 'worth', `${ch.garden.hp - s.bare.hp >= 0 ? '+' : ''}${(ch.garden.hp - s.bare.hp).toFixed(1)} HP over bare`); + if (ch.total > START_BUDGET) { + cc.append(el('div', 'ed-note ed-warn', + `Buyable, but there is no room left for a ${money(ch.total - ch.row.cleanHw)} spare β€” ` + + 'a night with no spare is a repair the player cannot make.')); + } + } else { + cc.append(el('div', 'ed-note ed-err', + 'No line is BOTH clean and better than leaving the bed bare. Either nothing clean is ' + + 'buyable, or the clean lines that are buyable do not actually save the garden β€” which is ' + + 'the wild night’s intended reading, and a bug on any other night.')); + } + + // ── 3. the garden: held vs bare (gardenfly) ────────────────────────────── + const cg = card(out, 'GARDEN β€” HELD vs BARE (flown)'); + kv(cg, 'bare bed', `${s.bare.hp} ${s.bare.state.toUpperCase()}`, + s.bare.state === 'dead' ? 'ed-err' : s.bare.state === 'tattered' ? 'ed-warn' : 'ed-ok'); + if (s.bestGarden) { + kv(cg, 'best clean', `${s.bestGarden.ids} β†’ ${s.bestGarden.hp} ${s.bestGarden.state.toUpperCase()}`, + s.bestGarden.state === 'full' ? 'ed-ok' : 'ed-warn'); + kv(cg, 'separation', `${s.bestGarden.hp - s.bare.hp >= 0 ? '+' : ''}${(s.bestGarden.hp - s.bare.hp).toFixed(1)} HP`, + s.bestGarden.hp - s.bare.hp > 0 ? 'ed-ok' : 'ed-err'); + kv(cg, 'by hail / rain', `${s.bestGarden.byHail} / ${s.bestGarden.byRain}`); + } else { + kv(cg, 'best clean', 'none buyable', 'ed-err'); + } + if (s.bestMarginal && (!s.bestGarden || s.bestMarginal.hp > s.bestGarden.hp)) { + cg.append(el('div', 'ed-note ed-warn', + `⚠ The best-reading line ${s.bestMarginal.ids} is MARGINAL: ` + + `${s.bestMarginal.hp} ${s.bestMarginal.state.toUpperCase()} on paper, inside the ` + + `${pct(s.MARGIN)} band. Treat it as the trap, not the answer β€” it is the shape of result ` + + 'that shipped a 91.9 FULL the player experienced as 39.8 TATTERED.')); + } + if (s.skipped) { + cg.append(el('div', 'ed-note', `${s.skipped} affordable line(s) beyond the ${s.flyCap}-flight cap were not flown.`)); + } + + // ── 4. the pinned separation target ────────────────────────────────────── + const sep = s.separation; + if (sep) { + const cs = card(out, `SEPARATION TARGET β€” ${sep.ok ? 'MEETS' : 'FAILS'}`, sep.ok ? 'ed-ok' : 'ed-err'); + kv(cs, 'storm', s.sepStormName); + kv(cs, 'held', `${sep.held.hp} ${sep.held.state.toUpperCase()} (${money(sep.held.cost)}, ${sep.held.cornersLost}/4 lost)`, + sep.heldOk ? 'ed-ok' : 'ed-err'); + kv(cs, 'bare', String(sep.bare.hp), sep.bareOk ? 'ed-ok' : 'ed-err'); + kv(cs, 'held wins?', sep.heldWin ? 'yes' : 'no', sep.heldWin ? 'ed-ok' : 'ed-err'); + if (sep.ok && !sep.heldClean) { + cs.append(el('div', 'ed-note ed-warn', + '⚠ Meets the target, but the pinned line is KNIFE-EDGED: ' + + sep.held.marginal.map((id) => { + const p = sep.held.peaks.find((x) => x.id === id); + return `${id} ${pct(p.headroom)} headroom (${p.peakN}/${p.effN} N)`; + }).join(', ') + + `, inside the ${pct(s.MARGIN)} band. The pin is A’s ruling and this card does not ` + + 'overrule it β€” but nobody has PLAYED this line.')); + } + } else if (s.sepUnjudged) { + const cs = card(out, 'SEPARATION TARGET β€” NOT JUDGED', 'ed-warn'); + cs.append(el('div', 'ed-note', s.sepUnjudged)); + } else { + const cs = card(out, 'SEPARATION TARGET β€” NONE PINNED', 'ed-warn'); + cs.append(el('div', 'ed-note', + 'This yard declares no `separation` block, so there is no held-vs-bare target to judge it ' + + 'against. A shipping site should pin one: it is the difference between "a sail helps here" ' + + 'and "a sail is the point of this night".')); + } + + // ── 5. margin flags β€” the 15% rule ─────────────────────────────────────── + const flags = marginFlags(s); + const cm = card(out, `MARGIN FLAGS β€” the ${pct(s.MARGIN)} rule`, flags.length ? 'ed-warn' : 'ed-ok'); + if (!flags.length) { + kv(cm, 'knife-edge corners', 'none', 'ed-ok'); + } else { + for (const f of flags) { + const row = kv(cm, `${f.id}${f.hint !== 1 ? ` Γ—${f.hint}` : ''}`, + `${pct(f.headroom)} headroom β€” ${(f.peak / 1000).toFixed(1)} kN on ${f.line}`, 'ed-warn'); + row.title = `worst case across every swept line; first seen on ${f.line}`; + } + } + cm.append(el('div', 'ed-note', + `A corner within ${pct(s.MARGIN)} of its effective rating breaks in the real game β€” benches ` + + 'under-read the UI’s peaks by 5–15% and the cause is still unfound, so MANUAL.md ' + + 'carries this as policy. Marginal is not PASS.')); +} + +boot(); From af0d64ffe38df101a875f7420412e1e2253531ca Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 16:03:59 +1000 Subject: [PATCH 3/7] =?UTF-8?q?Lane=20B=20S14:=20audit.html=20onto=20the?= =?UTF-8?q?=20shared=20scorecard=20engine=20=E2=80=94=20one=20copy=20of=20?= =?UTF-8?q?the=20run=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page keeps its rendering and loses its logic. A third front-end (A's editor) was about to make sweep-fly-pick-judge exist twice, which is the exact failure sweep.js was extracted to prevent, one level up. Behaviour-preserving, and measured that way rather than reasoned: both shipped sites were pinned BEFORE the refactor and reproduce to the digit after β€” backyard_01/wildnight 16 cands, 3 clean, 2 marginal, bare 35.7 tattered, best p1,p2,p3,p4 60.5, sep MEETS held 63.8; site_02/earlybuster 66 cands, 20 clean, 1 marginal, bare 82.6 full, best tr1,tr1b,q1,q4 91.3 full. Caught in the act by that check: __audit.cands went undefined because scoreSite returns the COUNT where the old local was the LIST. A refactor that only got read would have shipped it. --- tools/site_audit/audit.html | 125 ++++++++++++------------------------ 1 file changed, 40 insertions(+), 85 deletions(-) diff --git a/tools/site_audit/audit.html b/tools/site_audit/audit.html index db84ea1..f1c0d98 100644 --- a/tools/site_audit/audit.html +++ b/tools/site_audit/audit.html @@ -25,6 +25,19 @@ tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03b_earlybuster Served from the repo root (server.py), so the relative imports resolve. + + SPRINT14: this page's RUN LOGIC moved to scorecard.js and it kept only its + rendering. A third front-end arrived (A's editor, gate 2.1, where the yard + being scored has never been a file), and the sweep-fly-pick-judge sequence + this page had grown was about to exist in two places β€” which is the exact + failure sweep.js was extracted to prevent, one level up. `scoreSite()` takes + a site OBJECT; fetching one is this page's business and nobody else's. + + Verified by measurement, not by reading: the refactor was pinned against + both shipped sites BEFORE it happened and reproduced them to the digit β€” + backyard_01/wildnight (16 cands, 3 clean, 2 marginal, bare 35.7 tattered, + best p1,p2,p3,p4 60.5, sep MEETS held 63.8) and site_02/earlybuster (66 + cands, 20 clean, 1 marginal, bare 82.6 full, best tr1,tr1b,q1,q4 91.3). --> site_audit @@ -51,11 +64,10 @@ } }