HardYards/tools/site_audit/garden_probe.html
type-two e1e14018bf Lane B S13: THE AUDIT REWRITE — the audit predicts the garden, on C's corrected picture
The scoring quantity is the FLOWN garden state now, not static cover%.
New gardenfly.js: windForSite (C's shared builder — venturi + shelters, one
copy in the repo) → real attach (throws on D's skipped-attach trap) → skyfx
exposure → garden.js, per candidate line; cover% demoted to a labelled
geometry diagnostic. audit.html flies every affordable line + bare bed and
judges the site's pinned separation block on the block's own storm; audit.mjs
(node) keeps fast winnability, gains p5 in its verified dump, and points at
the browser for garden truth.

On C's correction, three structural adoptions:
- windForSite everywhere (three harnesses independently mis-built site wind;
  no fourth copy);
- LIVE anchors via the re-pointable wind proxy — the audit's own frozen-sway
  remap was C's landmine 2 wearing my file's name (audit.html:69);
- the MARGIN rule (AUDIT.MARGIN = 0.15, one copy): every row prices twice —
  $hw holds vs $cleanHw with >=15% headroom — and only clean lines are
  winners; verdict code 'marginal-only' when the budget can only buy the
  knife edge (D's 39.8-TATTERED wild night, C's dead 91.9 headline).

Also game-true flight: the phantom 12s calm settle is GONE from the sweep
(commit->attach->storm is one keypress; the settle skewed every storm sample
12 s off the authored curve — SailRig samples wind at its INTERNAL clock).
That skew is not academic: it is a.test's separation-flight harness too, and
gardenfly.selftest now pins BOTH chains (game-true 63.8 tattered vs skewed
68.4 'full' on the pinned p5 line — the disagreement is flagged to A in
THREADS, not overruled here). The pinned recipe also sweeps regardless of the
18-45 band (it is 45.9 m² — the band predates p5; flagged to A).

Selftest 356/0/0 on the scratch merge (b+a+c@45bdc2d). Mutation-checked:
MARGIN=0 reddens the margin test (node); dropping siteDef from gardenfly's
windForSite call reddens the venturi test (browser, exact test named).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:50:15 +10:00

165 lines
8.1 KiB
HTML

<!doctype html>
<!--
garden_probe — does cover% predict the garden? [Lane B, SPRINT13 gate 1.2]
The QA pass found one variable sighted three times: the audit's "cover" called
a $20 rig bad and the sim paid it +$97. This page is the measurement behind
that — it flies the SIM's own garden loop (skyfx's gardenExposure /
gardenHailExposure, stepped exactly the way main.js:931-941 steps it) for a
set of rig lines, and prints predicted garden HP beside the audit's cover%.
SPRINT13: the garden model is A's js/garden.js now (the export this page's
first version asked for), so the probe flies the SAME createGarden main.js
flies — the temporary constant copy died the day the export landed, per the
fake → ask → landed → delete-the-fake protocol.
Browser-only by necessity: skyfx needs `document` (verified — node throws
"document is not defined"), so garden prediction can never live in audit.mjs.
tools/site_audit/garden_probe.html?site=site_02_corner_block&storm=storm_03b_earlybuster
-->
<meta charset="utf-8">
<title>garden_probe</title>
<style>
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
.sub { color:#888; margin-bottom:14px; white-space:pre-wrap; }
table { border-collapse:collapse; margin:8px 0; }
td, th { padding:2px 12px 2px 0; white-space:nowrap; text-align:left; }
th { color:#9ab; border-bottom:1px solid #333; }
.ok { color:#6c6; } .bad { color:#e66; } .warn { color:#dc6; }
.note { color:#888; margin-top:14px; white-space:pre-wrap; }
</style>
<h1>garden_probe — does cover% predict the garden?</h1>
<div class="sub" id="sub">loading…</div>
<div id="out"></div>
<div class="note" id="note"></div>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { HARDWARE } from '../../web/world/js/contracts.js';
import { auditSweep } from './sweep.js';
import { flyGarden } from './gardenfly.js';
const q = new URLSearchParams(location.search);
const siteName = q.get('site') || 'site_02_corner_block';
const stormName = q.get('storm') || 'storm_03b_earlybuster';
const el = (id) => document.getElementById(id);
const loadJSON = async (p) => (await fetch(p)).json();
async function run() {
// The yard on a re-pointable wind proxy (C's bench pattern) so world.anchors
// fly THEMSELVES — a static sway remap froze the gum tree and under-read
// every tree corner (C's landmine 2; q4 1.02 frozen vs 1.24 live).
const site = await loadSite(siteName);
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() {},
};
const use = (w) => { currentWind = w; };
const world = createWorld(scene, { wind: windProxy, site });
await world.dress();
const bed = world.gardenBed;
const anchors = world.anchors;
const venturi = site.wind?.venturi ?? [];
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
// The audit's own sweep, for its (diagnostic) cover% numbers.
const { rows } = auditSweep({ anchors, bed, stormDef, siteDef: site, use });
/** Fly one line through gardenfly — THE audit scoring chain, no local copy. */
const gardenFor = (ids, hwTier, { bare = false } = {}) =>
flyGarden({ anchors, bed, stormDef, siteDef: site, use,
ids: bare ? null : ids, hw: hwTier });
el('sub').textContent =
`${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n` +
`venturi: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) gain ${v.gain}`).join(' · ') : 'none'} · ` +
`flying gardenfly.js — the sim's own chain (windForSite → attach → skyfx → garden.js), live anchors`;
// The bare bed is the control: what the garden does with NO sail at all.
const bareRes = gardenFor(null, null, { bare: true });
// Probe a spread of lines: the cheapest (the decoy), the best-covering, and a
// few between — sorted by the audit's cover% so any correlation is visible.
const holdable = rows.filter((r) => !r.unholdable.length);
const byCover = [...holdable].sort((a, b) => a.cover - b.cover);
const pick = [];
const want = 8;
for (let i = 0; i < want; i++) pick.push(byCover[Math.floor(i * (byCover.length - 1) / (want - 1))]);
const seen = new Set();
const probes = pick.filter((r) => r && !seen.has(r.ids.join(',')) && seen.add(r.ids.join(',')));
const tbl = document.createElement('table');
const head = tbl.insertRow();
for (const h of ['line', 'audit cover% (static)', 'hw $', 'garden (flown)', 'by hail', 'by rain', 'corners lost', 'vs bare bed'])
{ const th = document.createElement('th'); th.textContent = h; head.appendChild(th); }
const results = [];
for (const r of probes) {
const g = gardenFor(r.ids, HARDWARE[2]); // rated shackles: hold it, isolate GEOMETRY
results.push({ ids: r.ids.join(','), cover: +(r.cover * 100).toFixed(0), hw: r.hw, ...g });
const tr = tbl.insertRow();
const cells = [r.ids.join(','), `${(r.cover * 100).toFixed(0)}%`, `$${r.hw}`,
`${g.hp.toFixed(1)} ${g.state.toUpperCase()}${g.marginal.length ? `${g.marginal.join(',')}` : ''}`,
g.byHail.toFixed(1), g.byRain.toFixed(1), String(g.cornersLost),
`${(g.hp - bareRes.hp >= 0 ? '+' : '')}${(g.hp - bareRes.hp).toFixed(1)}`];
cells.forEach((c, i) => { const td = tr.insertCell(); td.textContent = c;
if (i === 7) td.className = (g.hp - bareRes.hp) > 15 ? 'ok' : (g.hp - bareRes.hp) > 5 ? 'warn' : 'bad'; });
}
const tr = tbl.insertRow();
['BARE BED (no sail)', '—', '$0', `${bareRes.hp.toFixed(1)} ${bareRes.state.toUpperCase()}`,
bareRes.byHail.toFixed(1), bareRes.byRain.toFixed(1), '—', '—']
.forEach((c) => { const td = tr.insertCell(); td.textContent = c; td.className = 'warn'; });
el('out').appendChild(tbl);
// Correlation between the audit's cover% and the sim's garden HP. If the audit
// is telling the truth this is near 1; the QA pass says it is not.
const xs = results.map((r) => r.cover), ys = results.map((r) => r.hp);
const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length;
const mx = mean(xs), my = mean(ys);
const cov = xs.reduce((s, x, i) => s + (x - mx) * (ys[i] - my), 0);
const sx = Math.sqrt(xs.reduce((s, x) => s + (x - mx) ** 2, 0));
const sy = Math.sqrt(ys.reduce((s, y) => s + (y - my) ** 2, 0));
const r2 = (sx && sy) ? (cov / (sx * sy)) : NaN;
const spread = Math.max(...ys) - Math.min(...ys);
const bestGain = Math.max(...ys) - bareRes.hp;
el('note').textContent =
`cover% → garden HP correlation: r = ${r2.toFixed(3)}\n` +
`garden HP spread across every holdable line: ${spread.toFixed(1)} HP\n` +
`best line vs bare bed: ${bestGain >= 0 ? '+' : ''}${bestGain.toFixed(1)} HP ` +
`(a rig that costs money and holds every corner buys THIS much garden)\n` +
`bare bed finished at ${bareRes.hp.toFixed(1)} HP — hail ${bareRes.byHail.toFixed(1)}, rain ${bareRes.byRain.toFixed(1)}`;
window.__probe = { site: siteName, storm: stormName, bare: bareRes, results, r: r2, spread, bestGain };
document.title = `garden_probe — r=${r2.toFixed(2)}`;
}
run().catch((e) => {
el('note').className = 'bad';
el('note').textContent = `garden_probe crashed: ${e.message}\n${e.stack || ''}`;
window.__probe = { error: e.message };
document.title = 'garden_probe — ERROR';
});
</script>