HardYards/tools/site_audit/scorecard.js
type-two 6825794d98 Lane B S14 gate 2.1: SCORE IT — shared scorecard engine + editor panel
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.
2026-07-18 15:56:50 +10:00

234 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<object>} 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 ~515%, 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);
}