HardYards/tools/site_audit/scorecard.js
type-two 1bcfcdad67 Lane B S17 gates 0.3 + 2: the card names its cloth, the session speaks for the client
GATE 0.3 — THE CHARTER, SAID OUT LOUD (D's soaker finding, A consulted on
wording and their every-card rule adopted). The audit always flew shade cloth
and never said so; on the soaker that silence read 'WINNABLE over an all-DEAD
garden — the night is broken'. Now: AUDIT.POROSITY is the one charter knob
(sweep.js + gardenfly.js both read it), scorecard.fabricCharter() names the
fabric and whether tonight's stones pass its weave (same hailBlockFor the sim
charges), and the SCORE IT header prints 'fabric · shade cloth (sweep
charter)' on EVERY card — plus, on leaky-storm nights, A's wording: the
fabric is the bet, and the audit does not place it.

GATE 2 — CLIENT CONSTRAINTS: A's shapes (seam contract, built against as
posted), this lane's teeth.
· rigging.js: validateNightConstraint (checked enum, both ends of the seam),
  setConstraints (survives reset like _startBudget; [] is the only clear).
  The house ban refuses the pick with the CLIENT'S WORDS as the ticker
  reason; the cap refuses at SPEND time on every path (rig / cycle / set /
  spares / fabric), exact at the boundary, refunds always free — and commit
  is belt-and-braces: a caller who walked around the doors gets a THROW.
  The cap covers the whole invoice, spares included — the client queries the
  invoice, not the hardware line.
· sweep.js: applyNightConstraints, ONE function both drivers share — bans
  shrink the candidate list (a card that recommends a forbidden line is the
  card lying), the cap re-prices affordable/clean without touching a load.
· scoreSite carries constraints/budget/constrainedOut; the card prints the
  client's terms next to funnel and fabric, and tells 'no geometry' from
  'client forbids all of it'.
· spent getter now measures against _startBudget (the re-banked session was
  measuring spend against the default bank).

Selftest 485/0/0 in the browser (474 + exactly the 11 added: 1 sail, 4
session, 2 charter, 4 audit-constraints). Wiring line for A filed in THREADS:
session.setConstraints(week.job?.constraints ?? []) at the night boundary.
2026-07-21 02:45:49 +10:00

370 lines
18 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 { FABRIC } from '../../web/world/js/rigging.js';
import { hailBlockFor } from '../../web/world/js/weather.core.js';
import { AUDIT, auditSweepAsync, yieldToEventLoop } from './sweep.js';
import { flyGarden, flySeparation } from './gardenfly.js';
/**
* SPRINT17 gate 0.3 — the score's fabric charter, SAID OUT LOUD. [B, with D's
* soaker finding as the brief and A consulted on the card wording.]
*
* Every audit number is flown on knitted shade cloth (AUDIT.POROSITY — one
* knob, sweep.js and gardenfly.js both read it). Sound charter, but the card
* never NAMED it, and on the one night where fabric is the whole answer the
* silence lied by omission: the soaker's card said "✓ WINNABLE at $80" over a
* garden table where every cloth line reads DEAD, and a reader without the
* charter in their head concludes the night is broken. The fix is the same
* discipline as the funnel state: any front-end printing a score also prints
* what fabric it flew — and on a storm whose stones pass the weave (C's 2 mm
* ruling, the same hailBlockFor the sim charges), it says the F key is not in
* the card.
*
* Pure data from pure inputs, browser-free, so the selftest can pin it against
* real storm defs without a flight.
*
* @param {object|null} stormDef parsed storm def (hail.size in storm units)
* @returns {{ fabric: {id,name,porosity}, leaks: boolean }}
* `fabric` — the charter entry (matched out of rigging's FABRIC by the
* porosity actually flown, so a charter retune renames the card by itself);
* `leaks` — true when tonight's stones pass this weave, i.e. the un-swept
* membrane would stop stones the swept cloth lets through.
*/
export function fabricCharter(stormDef) {
const f = FABRIC.find((x) => x.porosity === AUDIT.POROSITY)
?? { id: 'custom', name: `porosity ${AUDIT.POROSITY}`, porosity: AUDIT.POROSITY };
const hailSize = stormDef?.hail?.size ?? 0;
return {
fabric: { id: f.id, name: f.name, porosity: f.porosity },
leaks: hailSize > 0 && hailBlockFor(hailSize, AUDIT.POROSITY) < 1,
};
}
/** 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
* @param {function} [o.onProgress] called ({ phase, done, total, label }) as
* work completes — phases 'sweep' (per candidate flight),
* 'fly' (per garden flight, bare first), 'separation'.
* SPRINT15 gate 2.1: the 7582 s blocking run made the
* editor batch; the caller renders this so the page tells
* the truth about progress instead of looking crashed.
* @param {number} [o.yieldEvery] work units per event-loop yield (default
* 1). MUST NOT change any number — the scorecard selftest
* perturbs it and demands identical results.
* @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,
onProgress = null, yieldEvery = 1, constraints = [] }) {
const built = prebuilt ?? await buildScoringWorld(site);
const { world, 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 ?? [];
// What a lost corner BILLS, resolved through the world's own pricing path.
//
// The card's headline is "winnable at $80", and that sentence is incomplete
// if letting a corner go also costs $180 of carport. Priced here rather than
// in the front-end for the reason everything else is: `world.collateralFor()`
// is the one resolver, it reads site JSON first and the GLB extra second
// (SPRINT14 — A's structKey fix, after id-equality made the first
// editor-made carport free), and a second copy of that lookup in a panel is
// how a yard's trap silently prices to nothing.
//
// `null` from collateralFor means UNPRICED, never free — the house's fascia
// anchors say `collateral:"gutter"` and a gutter had no price for two
// sprints. The card must say "not scored" there, because "free" is the lie
// that makes a dangerous yard look safe.
const collateral = {};
for (const a of anchors) {
if (!a.collateral) continue;
const priced = world?.collateralFor?.(a.collateral) ?? null;
collateral[a.id] = priced
? { key: a.collateral, cost: priced.cost, label: priced.label }
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
}
// gate 2 (SPRINT17): a constrained NIGHT scores under its client's terms —
// bans shrink the candidate list before a single flight, the cap decides
// affordability. A card that recommends a forbidden line is the card lying.
const { cands, rows, verdict, winners, marginalWinners, budget, constrainedOut } =
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery, constraints });
// Garden flights: the bare-bed control first, then every line the budget can
// buy. The flight list is known up front so the progress tick has an honest
// denominator (bare + capped lines + the separation pair if pinned).
const toFly = [...winners, ...marginalWinners].slice(0, flyCap);
const flyTotal = 1 + toFly.length;
let flyDone = 0;
const tickFly = async (label) => {
flyDone += 1;
onProgress?.({ phase: 'fly', done: flyDone, total: flyTotal, label });
if (flyDone % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
};
// The bare bed — the control every garden number is read against.
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
await tickFly('bare bed');
// 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 toFly) {
// 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)),
}));
await tickFly(r.ids.join(','));
}
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) {
// Two flights (held + bare) on the block's own storm — announced before
// they run, because they are the one chunk left with no tick inside it.
onProgress?.({ phase: 'separation', done: 0, total: 1, label: sepStormName });
await yieldToEventLoop();
separation = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
onProgress?.({ phase: 'separation', done: 1, total: 1, label: sepStormName });
} 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`;
}
}
// The charter the whole card is denominated in (gate 0.3): reported next to
// the funnel state, computed from the storm actually scored — never assumed.
const charter = fabricCharter(stormDef);
return {
site: site.id ?? site.name ?? '(unnamed)',
storm: stormName, dressed, dressError,
venturi, funnelOn: venturi.length > 0,
fabric: charter.fabric, fabricLeaks: charter.leaks,
// gate 2: the terms this card was scored under, echoed so every front-end
// can print them — a constrained score that doesn't SAY so is the soaker
// silence again, one gate over. `budget` is what decided affordable/clean.
constraints, budget, constrainedOut,
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,
// SPRINT16 gate 2.3 [B]: a site that ATTEMPTED a pin and honestly refused
// one records why in its JSON; the card prints it so NONE PINNED reads as
// "measured and refused, here's the fight" rather than "nobody tried".
separationFinding: site._separation_finding ?? null,
collateral,
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(','),
// what this corner bills if it lets go — the flag and the bill belong
// on the same line, because "knife-edge" and "knife-edge AND $180"
// are different decisions for the person authoring the yard
collateral: score.collateral[c.id] ?? null });
}
}
}
return [...worst.values()].sort((a, b) => a.headroom - b.headroom);
}
/**
* Every priced thing in the yard a lost corner could wreck, deduped by
* structure — main.js bills per STRUCTURE, not per corner ("two beams letting
* go is one carport gone, not $360"), so exposure has to dedupe the same way
* or the card invents money.
*
* @returns {{priced: Array, unpriced: Array, total: number}}
*/
export function collateralExposure(score) {
const byKey = new Map();
for (const [anchorId, c] of Object.entries(score.collateral)) {
const e = byKey.get(c.key) ?? { ...c, anchors: [] };
e.anchors.push(anchorId);
byKey.set(c.key, e);
}
const all = [...byKey.values()];
const priced = all.filter((e) => !e.unpriced);
return {
priced,
unpriced: all.filter((e) => e.unpriced),
total: priced.reduce((s, e) => s + e.cost, 0),
};
}