HardYards/tools/site_audit/gardenfly.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

185 lines
10 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.

/**
* gardenfly.js — fly a rig line through the REAL scoring chain and return the
* garden verdict the sim would hand the invoice. [Lane B, SPRINT13 gate 1.2]
*
* This module exists because the audit's old ranking quantity — static cover%
* of the taut attach-shape — predicted the wild night's garden BACKWARDS
* (r = 0.42, THREADS [B] gate-1 evidence): it told three people a $20 rig was
* bad while the sim paid it +$97. The quantity that scores is the FLOWN
* vertical (hail) shadow through the storm: hail is 5.0 against rain's 0.25
* (garden.js), stones fall near-vertical, and a sail either blocks the hail
* over the bed or blocks nothing that matters. Only flying the real chain
* measures that, so this is the audit's scoring engine now; cover% is demoted
* to a geometry diagnostic in both front-ends.
*
* THE chain, one copy of it, shared by audit.html and garden_probe.html:
*
* windForSite(stormDef, siteDef, anchors) (C's shared builder —
* venturi + tree shelters, byte-for-byte main.js's site-load wiring)
* → SailRig.attach(ids, hw, tension) (the real attach)
* → rig.step + sky.step(dt, t, {sail: rig}) (main.js's exact call)
* → createGarden(...).step(dt, hailExp, rainExp) (A's garden.js, no copy)
*
* NO calm settle, deliberately: in the real game the rig does not exist before
* commit, and commit→attach→storm is one keypress (the rig.t fix's own words),
* so the attach transient flies under STORM wind and counts. C measured the
* phantom settle's skew and removed it from the bench the same day.
*
* Landmines this file exists to never re-arm (each earned this sprint):
*
* · D's skipped-attach trap (skyfx.js:803): a harness that never runs
* attach() scores EVERY rig as bare — the bare-bed constant lies politely.
* flyGarden throws if the rig it built has no shadow mesh.
* · The venturi lives in the SITE def, not the storm def. THREE harnesses
* shipped that bug (my Sprint-11 audit, C's bench, D's first harness half);
* windForSite is the one door now, and the selftest mutation-checks that
* the funnel reaches this module's wind.
* · The FROZEN TREE (C's landmine 2): remapping anchors to `sway: () => pos`
* freezes the gum tree whose sway is the dynamic load a tr1 rig eats —
* q4 read 1.02 frozen vs 1.24 live. Callers hand this module
* world.anchors THEMSELVES and re-point the world's wind proxy via `use`
* so the sway closures sample the storm that is actually flying.
* · THE MARGIN RULE (C's residual): even corrected, benches under-read the
* real UI's peaks by ~515% on site_02 (cause unfound, in the pool). Until
* it is found, any corner within 15% of its effective rating is scored
* "breaks in the game" — a flight that holds on paper with q4 at 1.24 vs
* 1.2 is D's 39.8 TATTERED in play, not the bench's 91.9 FULL. flyGarden
* reports `marginal` and callers must not print PASS over it.
*
* Per-corner peaks are labelled from `corners[k].anchorId`, never input order —
* C's label-permutation warning (attach reorders picks into ring order).
*
* Browser-only by necessity: skyfx needs `document` (node throws), which is
* why audit.mjs (node) prints winnability and POINTS HERE for garden truth.
*/
import { SailRig } from '../../web/world/js/sail.js';
import { windForSite } from '../../web/world/js/weather.js';
import { createSkyFx } from '../../web/world/js/skyfx.js';
import { createGarden } from '../../web/world/js/garden.js';
import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js';
import { AUDIT } from './sweep.js';
// The margin rule's knob is AUDIT.MARGIN — sweep.js holds the ONE copy and
// both audit engines read it (a local copy here would be the exact drift this
// sprint spent itself killing).
/** Shop hardware by its contracts.js name ("rated shackle") — throws on a
* stranger, because a typo'd tier silently becoming a carabiner is exactly
* the setHardware() lesson from gate 3. */
export function hardwareByName(name) {
const hw = HARDWARE.find((h) => h.name === name);
if (!hw) throw new Error(`unknown hardware "${name}" — the shop sells: ${HARDWARE.map((h) => h.name).join(', ')}`);
return hw;
}
/**
* Fly one line (or a bare bed) and return the garden verdict.
*
* @param {object} o
* @param {Array} o.anchors world.anchors THEMSELVES (live sway), or resolved
* {id, type, pos, sway, ratingHint} for synthetic yards
* @param {object} o.bed garden bed rect {x, z, w, d}
* @param {object} o.stormDef storm JSON to fly
* @param {object} [o.siteDef] site JSON — its wind.venturi is half the yard's
* weather; omit ONLY for a site with no funnel
* @param {function} [o.use] yard's wind-proxy re-pointer (C's bench pattern):
* called with this flight's wind so world.anchors'
* tree-sway closures sample the storm actually flying
* @param {Array} [o.ids] 4 anchor ids; null/omitted = bare bed (the control)
* @param {Array|object} [o.hw] hardware per pick (aligned with ids), or one tier for all 4
* @param {number} [o.tension]
* @param {number} [o.porosity] fabric: 0.30 = shade cloth (the default every
* audit number to date was flown on — unchanged),
* 0 = membrane. SPRINT16 gate 3.1 measures the
* fabric bet through THIS chain, same driver as
* every other garden number, so cloth-vs-membrane
* differ by exactly one variable. [Lane C addition,
* THREADS 2026-07-20 — B: contest at integration if
* you want a different door.]
* @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }}
*/
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = AUDIT.POROSITY }) {
const wind = windForSite(stormDef, siteDef, anchors);
use?.(wind);
let rig = null, cost = 0;
if (ids) {
const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle'));
cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0);
// shade cloth (AUDIT.POROSITY — the sweep charter, one knob since
// SPRINT17 gate 0.3) unless the caller is measuring the fabric bet.
rig = new SailRig({ anchors, gridN: 10, porosity }).attach(ids, hwArr, tension);
// D's skipped-attach trap: a rig without its shadow mesh scores as a bare
// bed and the number LOOKS plausible. Refuse to fly it.
if (!rig.rigged || !rig.pos || !rig.tris || !rig.tris.length) {
throw new Error('flyGarden: rig is not attached (no shadow mesh) — a bypassed attach scores ' +
'every rig as bare (skyfx.js:803, D\'s landmine). Fix the harness, do not trust the number.');
}
}
const sky = createSkyFx({ wind, night: true });
const garden = createGarden({ setPlants() {} }); // THE model — garden.js, main.js's own
const n = Math.round(stormDef.duration / FIXED_DT);
for (let i = 0; i < n; i++) {
const t = i * FIXED_DT;
if (rig) rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig }); // main.js's exact third param
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t));
}
sky.dispose?.();
// labelled from the rig's OWN anchorId — attach reorders picks into ring
// order, and a peak quoted against input order swaps corners (C's warning).
const peaks = rig ? rig.corners.map((c) => {
const eff = c.hw.rating * (c.anchor.ratingHint ?? 1);
return { id: c.anchorId, peakN: Math.round(c.peakLoad), effN: Math.round(eff),
headroom: +(1 - c.peakLoad / eff).toFixed(3) };
}) : [];
return {
hp: +garden.hp.toFixed(1),
state: garden.state, // 'full' | 'tattered' | 'dead' — garden.js's own thresholds
byHail: +garden.damage.hail.toFixed(1),
byRain: +garden.damage.rain.toFixed(1),
cornersLost: rig ? rig.corners.filter((c) => c.broken).length : 4,
peaks,
// C's margin rule: corners that held on paper but sit within MARGIN of
// their effective rating — in the real game these break. A prediction
// carrying names here is NOT a clean hold, whatever the hp says.
marginal: peaks.filter((p) => !rig.corners.find((c) => c.anchorId === p.id).broken && p.headroom < AUDIT.MARGIN)
.map((p) => p.id),
cost,
};
}
/**
* Judge a site against its pinned `separation` block (A's gate-1.4 ruling,
* backyard_01.json): fly the pinned recipe AND a bare bed on the block's own
* storm, and answer the only question the target asks — does the best line the
* budget buys read FULL and WIN, and does a bare bed LOSE the night.
*
* `heldWin` is main.js's win rule (hp >= 50 && corners lost < 2) — EXACTLY the
* rule, so this stays in lockstep with a.test's pin of the same block.
* `heldClean` is the margin rule's opinion, kept SEPARATE from `ok` on
* purpose: the pinned target is A's ruling and this module doesn't get to
* overrule it with a working rule whose cause is still unfound — but a pinned
* line carrying a corner inside the 15% band is a knife edge the front-end
* must SAY (it's a flag for A/D, not a verdict).
*
* @returns {{ held, bare, heldOk, heldWin, heldClean, bareOk, ok }}
*/
export function flySeparation({ anchors, bed, separation, stormDef, siteDef = null, use = null }) {
const ids = separation.line.map((l) => l.anchor);
const hw = separation.line.map((l) => hardwareByName(l.hw));
const tension = separation.tension ?? 1.0;
const held = flyGarden({ anchors, bed, stormDef, siteDef, use, ids, hw, tension });
const bare = flyGarden({ anchors, bed, stormDef, siteDef, use });
const heldOk = held.hp > separation.heldMustExceed;
const heldWin = held.hp >= 50 && held.cornersLost < 2;
const heldClean = held.marginal.length === 0;
const bareOk = bare.hp < separation.bareMustLoseBelow;
return { held, bare, heldOk, heldWin, heldClean, bareOk, ok: heldOk && heldWin && bareOk };
}