diff --git a/tools/site_audit/audit.html b/tools/site_audit/audit.html
index b44974a..2ac46fe 100644
--- a/tools/site_audit/audit.html
+++ b/tools/site_audit/audit.html
@@ -67,9 +67,12 @@ async function run() {
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } }
// world.anchors are the REAL dressed positions — freeze sway like balance.test.
+ // ratingHint rides along (SPRINT12): the sweep prices hardware against
+ // rating × hint, and dropping it here would audit a yard where the fascia
+ // and the carport beam are honest steel — the exact lie the wiring killed.
const anchors = world.anchors.map((a) => {
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };
- return { id: a.id, type: a.type, pos, sway: () => pos };
+ return { id: a.id, type: a.type, ratingHint: a.ratingHint, pos, sway: () => pos };
});
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
@@ -105,7 +108,7 @@ async function run() {
else { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; }
const cs = tr.insertCell(); cs.className = 'corner';
cs.innerHTML = r.tiers.map((c) =>
- `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
+ `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
}
el('out').appendChild(tbl);
diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs
index e83d84e..8f27b99 100644
--- a/tools/site_audit/audit.mjs
+++ b/tools/site_audit/audit.mjs
@@ -67,14 +67,20 @@ const BACKYARD_01 = {
bed: { x: 1, z: 2, w: 6, d: 4 },
venturi: [], // backyard_01.json ships "venturi": [] — no funnel. Stated, not assumed.
anchors: [
- // dress()-only: adopted from E's GLBs. Unverifiable headless.
- ['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95],
- ['t1', 'tree', -9.96, 3.45, 0.54], ['t2', 'tree', 7.83, 2.93, -0.85],
- ['t1b', 'tree', -9.94, 3.96, 2.78], ['t1c', 'tree', -10.38, 5.05, 2.98], ['t2b', 'tree', 8.14, 3.70, -0.96],
- // pure world.js — cross-checked against live code on every run.
- ['p1', 'post', -4.85, 3.95, 5.93], ['p2', 'post', 4.31, 3.96, 6.46], ['p3', 'post', 0.00, 3.95, 7.56],
- ['p4', 'post', -3.72, 4.00, -1.40],
- ].map(([id, type, x, y, z]) => ({ id, type, pos: { x, y, z } })),
+ // dress()-only: adopted from E's GLBs. Unverifiable headless. The 6th column
+ // is ratingHint, dumped from the live dressed world.anchors (SPRINT12) —
+ // the fascia's 0.35 and the branches' 1.0/0.88/0.76 are E's baked numbers,
+ // and the sweep prices hardware against rating × hint now that sail.js
+ // fails on it. Hand-kept like the positions; the browser front-end is the
+ // authority if these ever drift.
+ ['h1', 'house', -3.00, 2.48, -9.95, 0.35], ['h2', 'house', 0.00, 2.48, -9.95, 0.35], ['h3', 'house', 3.00, 2.48, -9.95, 0.35],
+ ['t1', 'tree', -9.96, 3.45, 0.54, 1.0], ['t2', 'tree', 7.83, 2.93, -0.85, 1.0],
+ ['t1b', 'tree', -9.94, 3.96, 2.78, 0.88], ['t1c', 'tree', -10.38, 5.05, 2.98, 0.76], ['t2b', 'tree', 8.14, 3.70, -0.96, 0.88],
+ // pure world.js — cross-checked against live code on every run. Posts are
+ // site-JSON anchors: DEFAULT_RATING_HINT = 1, guaranteed by world.js.
+ ['p1', 'post', -4.85, 3.95, 5.93, 1.0], ['p2', 'post', 4.31, 3.96, 6.46, 1.0], ['p3', 'post', 0.00, 3.95, 7.56, 1.0],
+ ['p4', 'post', -3.72, 4.00, -1.40, 1.0],
+ ].map(([id, type, x, y, z, ratingHint]) => ({ id, type, ratingHint, pos: { x, y, z } })),
};
/** Anchors dress() never touches — so live world.js IS authoritative for these. */
@@ -160,6 +166,9 @@ async function loadSite(path) {
// This is what a dressed export (or a future createWorld dump) would hand us.
const anchors = (j.anchors || []).map((a) => ({
id: a.id, type: a.type || 'post',
+ // hint 1 when the export omits it — but a dressed export SHOULD carry it,
+ // or a fascia/carport anchor audits as honest steel (see sweep.js tierFor).
+ ratingHint: a.ratingHint ?? 1,
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
}));
// A resolved export still owes us the site's funnel — the venturi is site data,
@@ -205,7 +214,7 @@ async function main() {
console.log(`${cands.length} quad(s) in band shading the bed:\n`);
for (const r of rows) {
- const cs = r.tiers.map((c) => `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
+ const cs = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `✓ $${r.hw}` : `✗ $${r.hw} > $${START_BUDGET}`;
console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`);
}
diff --git a/tools/site_audit/sweep.js b/tools/site_audit/sweep.js
index a799a37..1da3f2e 100644
--- a/tools/site_audit/sweep.js
+++ b/tools/site_audit/sweep.js
@@ -35,13 +35,28 @@ export function areaOf(q) {
return Math.abs(a / 2);
}
-/** Cheapest hardware that holds a corner at `peakN` newtons, or null if the shop can't. */
-export const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null;
+/**
+ * Cheapest hardware that holds a corner at `peakN` newtons off an anchor with
+ * `ratingHint` hint, or null if the shop can't at any price.
+ *
+ * SPRINT12: sail.js fails a corner on `load > hw.rating * anchor.ratingHint`
+ * (A's ruling — the anchor is a failure point, not just the steel), so the
+ * hardware that HOLDS is the cheapest h with `h.rating * hint >= peak`, i.e.
+ * `h.rating >= peak / hint`. An audit that keeps pricing on bare hw.rating
+ * flies a different game than ships: on the corner block it would sell you a
+ * $5 carabiner for a beam that now lets go at 0.22 of it.
+ */
+export const tierFor = (peakN, ratingHint = 1) =>
+ HARDWARE.find((h) => h.rating * ratingHint >= peakN) || null;
/**
* Sweep every bed-covering quad and price the cheapest holding line.
* @param {object} o
- * @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway }
+ * @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway,
+ * ratingHint } — omit ratingHint and the anchor is
+ * priced at full hardware strength (hint 1), which
+ * is a LIE for any GLB-dressed anchor (fascia 0.35,
+ * carport beam 0.22). Hand this the dressed truth.
* @param {object} o.bed garden bed rect { x, z, w, d }
* @param {object} o.stormDef the storm JSON to fly
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle)
@@ -103,7 +118,12 @@ export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) {
rig.resetPeaks(); // ← the storm starts HERE
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
- const tiers = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad, tier: tierFor(c.peakLoad) }));
+ // Price each corner against its anchor's EFFECTIVE strength. c.anchor is the
+ // resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures.
+ const tiers = rig.corners.map((c) => ({
+ id: c.anchorId, peak: c.peakLoad, hint: c.anchor.ratingHint ?? 1,
+ tier: tierFor(c.peakLoad, c.anchor.ratingHint ?? 1),
+ }));
const unholdable = tiers.filter((c) => !c.tier);
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + AUDIT.SPARE_COST,
diff --git a/tools/site_audit/sweep.selftest.js b/tools/site_audit/sweep.selftest.js
index a61094e..b8523dd 100644
--- a/tools/site_audit/sweep.selftest.js
+++ b/tools/site_audit/sweep.selftest.js
@@ -87,4 +87,36 @@ test('no venturi is a no-op — a site without a funnel is untouched', () => {
}
});
+test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFor', () => {
+ // SPRINT12: sail.js fails a corner on rating × ratingHint, so the sweep must
+ // price hardware against the same product or it audits a nicer yard than
+ // ships — a fascia (0.35) or carport beam (0.22) corner would be sold a $5
+ // carabiner that the sim now snaps. Same synthetic yard, one anchor's hint
+ // dropped to 0.01: its corner's demand (peak / hint) leaves the shop's 6.5 kN
+ // ceiling entirely, so its tier must go to NONE while every OTHER corner and
+ // every PEAK stays byte-identical (the hint is a pricing fact, not physics —
+ // the sweep flies unbreakable audit hardware). Written to fail if the hint
+ // is dropped from sweep.js's tierFor call: the two runs collapse into one.
+ const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, calmDef: CALM, venturi: [] }).rows[0];
+ const honest = sweep(ANCHORS);
+ const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: 0.01, sway: a.sway } : a)));
+
+ for (let i = 0; i < honest.tiers.length; i++) {
+ assert(Math.abs(honest.tiers[i].peak - lied.tiers[i].peak) < 1e-9,
+ `corner ${honest.tiers[i].id}: ratingHint moved a PEAK (${honest.tiers[i].peak} -> ${lied.tiers[i].peak}) — ` +
+ 'the hint is a failure threshold, it must never touch the flown loads');
+ }
+ const a1 = lied.tiers.find((c) => c.id === 'a1');
+ const a1honest = honest.tiers.find((c) => c.id === 'a1');
+ assert(a1honest.tier, 'fixture drift: a1 must be holdable at hint 1 or this test asserts nothing');
+ assert(!a1.tier,
+ `a1 at hint 0.01 (demand ${(a1.peak / 0.01 / 1000).toFixed(1)} kN vs the 6.5 kN ceiling) still got ` +
+ `hardware ($${a1.tier?.cost}) — sweep.js is pricing bare hw.rating, not rating × ratingHint`);
+ const others = lied.tiers.filter((c) => c.id !== 'a1');
+ assert(others.every((c) => c.tier && c.tier.cost === honest.tiers.find((h) => h.id === c.id).tier.cost),
+ 'a hint on a1 repriced a DIFFERENT corner — hints must be per-anchor');
+ assert(lied.unholdable.some((c) => c.id === 'a1'),
+ 'an over-ceiling corner must land in unholdable, or the verdict lies');
+});
+
export const SWEEP_TESTS = TESTS;
diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js
index f8a4247..3d4fde6 100644
--- a/web/world/js/rigging.js
+++ b/web/world/js/rigging.js
@@ -222,6 +222,16 @@ export class RiggingSession {
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
}
+ /**
+ * What a pick's corner actually fails at, in newtons: hardware rating × the
+ * anchor's ratingHint (`?? 1` mirrors sail.js — world.js guarantees the field,
+ * but a session can be handed bare test anchors that never met world.js).
+ */
+ _effRating(p) {
+ const a = this.anchors.find((x) => x.id === p.anchorId);
+ return p.hw.rating * (a?.ratingHint ?? 1);
+ }
+
/** Everything the HUD needs to draw the prep panel, in one read. */
get summary() {
return {
@@ -232,8 +242,16 @@ export class RiggingSession {
fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
canStart: this.canStart,
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
+ // The weak link is the lowest EFFECTIVE rating — hw.rating × the anchor's
+ // ratingHint, the same product sail.js fails a corner on (SPRINT12, A's
+ // ruling). D's #7: this used to reduce on hw.rating alone with strict <,
+ // so four identical carabiners flagged whichever was clicked FIRST — the
+ // label pointed at click order, not steel, and on the corner block it
+ // named the genuinely worst anchor only by luck. Ratings ties still keep
+ // the first pick, honestly: if the products are equal the steel really is
+ // interchangeable and there is no "genuinely worst" to point at.
weakest: this.picks.length
- ? this.picks.reduce((w, p) => (p.hw.rating < w.hw.rating ? p : w)).anchorId
+ ? this.picks.reduce((w, p) => (this._effRating(p) < this._effRating(w) ? p : w)).anchorId
: null,
};
}
diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js
index 3895c52..922b373 100644
--- a/web/world/js/rigging.selftest.js
+++ b/web/world/js/rigging.selftest.js
@@ -211,6 +211,32 @@ test('summary names the weak link for the HUD', () => {
return `weak link flagged: ${sum.weakest}, $${sum.budget} left`;
});
+test("weak link is rating × ratingHint, not click order (D's #7)", () => {
+ // Corner-block shape: uniform hardware, one anchor made of lies. The old
+ // reduce compared bare hw.rating with strict <, so four identical carabiners
+ // flagged whichever was clicked FIRST — on site_02 it pointed at cb1 only
+ // because D happened to click cb1 first. The label must follow the product
+ // sail.js actually fails on: hw.rating × anchor.ratingHint.
+ const anchors = [
+ { id: 'q1', type: 'post', pos: { x: -3, y: 4, z: -3 }, ratingHint: 1 },
+ { id: 'q2', type: 'post', pos: { x: 3, y: 4, z: -3 }, ratingHint: 1 },
+ { id: 'q3', type: 'post', pos: { x: 3, y: 4, z: 3 }, ratingHint: 1 },
+ { id: 'cb1', type: 'carport', pos: { x: -3, y: 2.3, z: 3 }, ratingHint: 0.22 },
+ ].map((a) => ({ ...a, sway: () => a.pos }));
+ const s = new RiggingSession({ anchors });
+ // click the honest steel FIRST — the pre-fix code flags q1 here, forever
+ for (const id of ['q1', 'q2', 'q3', 'cb1']) assert(s.rig(id).ok, `rig ${id} failed`);
+ assert(s.summary.weakest === 'cb1',
+ `four identical carabiners: the weak link is the 0.22 carport beam, got ${s.summary.weakest}`);
+ // And better hardware on the lying anchor can make it genuinely NOT the weak
+ // link: rated 6500 × 0.22 = 1430 N still beats carabiner 1200 × 1.0 — the
+ // label follows the product, not the price tag and not the hint alone.
+ s.setHardware('cb1', RATED);
+ assert(s.summary.weakest === 'q1',
+ `rated@0.22 (1430 N) out-rates carabiner@1.0 (1200 N) — expected q1, got ${s.summary.weakest}`);
+ return 'weak link tracks rating × hint through hardware changes';
+});
+
test('reset() returns a used session to a fresh prep phase', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
diff --git a/web/world/js/sail.js b/web/world/js/sail.js
index f6a745d..d30ec41 100644
--- a/web/world/js/sail.js
+++ b/web/world/js/sail.js
@@ -863,12 +863,27 @@ export class SailRig {
return n ? Math.sqrt(sum / n) / SIM_DT : 0;
}
- /** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
+ /**
+ * Ported from the prototype: 0.4 s sustained over the rating and it lets go.
+ *
+ * SPRINT12 — the threshold is the ANCHOR's, not just the hardware's:
+ * `hw.rating * anchor.ratingHint`. A's ruling (THREADS sprint 11, "THE
+ * RATINGS ARE REAL. WIRE THEM."): DESIGN's fascia/carport lie is an ANCHOR
+ * failing, and before this line every anchor was exactly as strong as the
+ * carabiner you hung off it — E's baked 0.22/0.30/0.35 were read by nothing,
+ * and the lever that decided whether the trap fired was tension, not steel.
+ * Read LIVE from c.anchor each check (not captured at attach): dress() adopts
+ * GLB hints by MUTATING the anchor objects in place, and a prep-time rig must
+ * see the dressed number, not whatever was there when attach() ran.
+ * The `?? 1` is belt-and-braces — world.js guarantees a finite hint on every
+ * anchor (DEFAULT_RATING_HINT, a.test pins it), because `load > rating *
+ * undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor.
+ */
_checkFailure(dt) {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) continue;
- if (c.load > c.hw.rating) c.overload += dt;
+ if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
if (c.overload > OVERLOAD_SECS) {
c.broken = true;
diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js
index dc66050..e520f43 100644
--- a/web/world/js/sail.selftest.js
+++ b/web/world/js/sail.selftest.js
@@ -901,6 +901,119 @@ test('ponding: rain that the router swallows cannot silently pass', () => {
return 'no rain API -> no pond (and Lane A asserts the router keeps it)';
});
+// --- SPRINT12: THE RATINGS ARE REAL (A's ruling, THREADS sprint 11) ---------
+// sail.js fails a corner on load > hw.rating * anchor.ratingHint. This test
+// asserts the CONSEQUENCE, not the formula: on the corner block, at max
+// tension, through the funnel, the carport blows before an honest post — the
+// yard's whole thesis, which D measured to be UNENFORCED before the wiring
+// (at any tension the sim had no reason to prefer cb over q; the lever that
+// decided the trap was tension, not steel).
+
+const STORM_03B = await loadStormDef('storm_03b_earlybuster');
+
+/**
+ * site_02_corner_block, DRESSED — dumped live from world.anchors after dress()
+ * (browser, SPRINT12), same provenance as tools/site_audit's backyard dump.
+ * Node cannot dress (GLTFLoader + fetch), and the graybox positions are a
+ * different yard — see audit.mjs's header for the whole sermon. ratingHint is
+ * E's baked number, adopted by adoptAnchor; q1..q4 are site-JSON posts at
+ * world.js's DEFAULT_RATING_HINT = 1.
+ */
+const SITE2_DRESSED = [
+ ['tr1', 'tree', 6.833, 2.992, 0.154, 1.0], ['tr1b', 'tree', 7.142, 3.764, 0.043, 0.88],
+ ['q1', 'post', -3.953, 3.937, -2.824, 1.0], ['q2', 'post', 3.983, 4.001, -2.276, 1.0],
+ ['q3', 'post', 2.249, 3.991, 4.498, 1.0], ['q4', 'post', -3.334, 4.022, 4.445, 1.0],
+ ['cb1', 'carport', -8.41, 2.289, -3, 0.22], ['cb2', 'carport', -5.59, 2.289, -3, 0.22],
+ ['cp1', 'carport_post', -8.41, 1.679, -0.39, 0.3], ['cp2', 'carport_post', -8.41, 1.679, -5.61, 0.3],
+].map(([id, type, x, y, z, ratingHint]) => {
+ const pos = { x, y, z };
+ return { id, type, ratingHint, pos, sway: () => pos };
+});
+
+/** site_02's funnel, verbatim from the site JSON — the yard's personality. */
+const SITE2_VENTURI = [{ x: -6, z: 0, axis: 2.1, gain: 1.5, radius: 5, sharp: 3 }];
+
+/** realWind() with the SITE's venturi on it, the way main.js sets it at load. */
+function site2Wind(def) {
+ const field = createWindField(def);
+ field.setVenturi(SITE2_VENTURI);
+ const out = { x: 0, y: 0, z: 0 };
+ return {
+ sample(pos, t) { return field.vecAt(pos.x, pos.z, t, out); },
+ speedAt(t) { field.vecAt(-6, 0, t, out); return Math.hypot(out.x, out.z); },
+ gustTelegraph: () => null,
+ rainAt: (t) => field.rainAt(t),
+ rainMmPerHour: (t) => field.rainMmPerHour(t),
+ };
+}
+
+/**
+ * D's exact carport line (cb1 cb2 q2 q3) on UNIFORM rated shackles, settled 12 s
+ * on the calm day (funnel on — the gap doesn't switch off for prep, same as the
+ * audit), then flown through the whole early buster. Uniform hardware ON
+ * PURPOSE: the only asymmetry left is the anchors themselves, so if a cb corner
+ * goes first it can only be ratingHint. Returns breaks with the rig's OWN clock
+ * on them — settle is t 0..12, the storm is t 12..102.
+ */
+const flyCarportLine = (anchors, tension) => {
+ const r = new SailRig({ anchors, gridN: 10, porosity: 0.30 });
+ r.watchDivergence = true;
+ r.attach(['cb1', 'cb2', 'q2', 'q3'], Array(4).fill(HARDWARE[2]), tension);
+ const breaks = [];
+ r.events.on('break', (e) => breaks.push({ id: e.anchorId, t: e.t }));
+ const calm = site2Wind(STORM_01), storm = site2Wind(STORM_03B);
+ for (let i = 0, n = Math.round(12 / SIM_DT); i < n; i++) r.step(SIM_DT, calm, (i * SIM_DT) % 3);
+ const SETTLE_END = r.t;
+ for (let i = 0; i < Math.round(STORM_03B.duration / SIM_DT); i++) r.step(SIM_DT, storm, i * SIM_DT);
+ return { breaks, SETTLE_END };
+};
+
+test('ruling: on site_02 the CARPORT blows before an honest post — at DEFAULT tension', () => {
+ // The half D's cold pass proved missing: pre-wiring, at tension 1.0 the
+ // honest post blew first and the carport cost nothing, so the lever that
+ // decided the trap was tension. Now the same rig, default tension, survives
+ // prep and loses the CARPORT mid-storm while both honest posts hold — the
+ // anchor decides, not the dial. (Measured: cb1 lets go around t≈41 with
+ // q2/q3 peaking ~1.8/2.5 kN, well under a bare 6.5 kN rated shackle.)
+ const { breaks, SETTLE_END } = flyCarportLine(SITE2_DRESSED, 1.0);
+ assert(breaks.length > 0,
+ 'nothing blew at default tension: ratingHint is not reaching _checkFailure — the trap is still tension-gated');
+ const firstCb = breaks.findIndex((b) => b.id.startsWith('cb'));
+ const firstQ = breaks.findIndex((b) => b.id.startsWith('q'));
+ assert(firstCb >= 0, `the carport never blew (breaks: ${breaks.map((b) => b.id).join(',')})`);
+ assert(firstQ === -1 || firstCb < firstQ,
+ `an honest post (${breaks[firstQ]?.id}) blew before the carport — the trap is pointing the wrong way`);
+ assert(breaks[firstCb].t > SETTLE_END,
+ `the beam let go at t=${breaks[firstCb].t.toFixed(1)}s, INSIDE the ${SETTLE_END.toFixed(0)}s prep settle — ` +
+ 'at default tension the trap should fire in the storm, not on the shop floor');
+ return `default tension: ${breaks[firstCb].id} blew at t=${(breaks[firstCb].t - SETTLE_END).toFixed(1)}s into the storm, honest posts held`;
+});
+
+test('ruling: at MAX tension the carport still goes first — and the hint is doing it', () => {
+ // The sprint's literal shape (max tension through the funnel), plus the
+ // control that makes both tests mean "the HINT", not "the load".
+ const { breaks } = flyCarportLine(SITE2_DRESSED, 1.4); // TENSION_MAX
+ const firstCb = breaks.findIndex((b) => b.id.startsWith('cb'));
+ const firstQ = breaks.findIndex((b) => b.id.startsWith('q'));
+ assert(firstCb >= 0, `max tension and the carport still never blew (breaks: ${breaks.map((b) => b.id).join(',')})`);
+ assert(firstQ === -1 || firstCb < firstQ,
+ `an honest post (${breaks[firstQ]?.id}) blew before the carport at max tension`);
+
+ // Identical yard, every hint forced to 1 — bare hardware ratings, the
+ // pre-wiring game — must survive the same night intact: rated shackles at
+ // 6.5 kN hold these loads (worst peak ~3.4 kN); only 6.5 × 0.22 = 1.43 kN
+ // lets go. This is the mutation control IN the suite: unwire sail.js and
+ // both tests above go red because the wired and unhinted runs collapse into
+ // this one. If THIS half ever breaks, the loads moved and the cb-first
+ // asserts stop isolating the wiring — re-measure before touching thresholds.
+ const unhinted = flyCarportLine(
+ SITE2_DRESSED.map((a) => ({ ...a, ratingHint: 1, sway: a.sway })), 1.4).breaks;
+ assert(unhinted.length === 0,
+ `hint-free control lost ${unhinted.map((b) => b.id).join(',')} — bare rated shackles no longer hold this line; ` +
+ 'the cb-first asserts are no longer attributing the break to ratingHint');
+ return `max tension: ${breaks[firstCb].id} first at t=${breaks[firstCb].t.toFixed(1)}s; hint-free control held 4/4`;
+});
+
export const SAIL_TESTS = TESTS;
export function runSailSelftest() {