From 9700b40a304b35aca23cdd2ef7ab2e0ba9371c27 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 20:19:01 +1000 Subject: [PATCH 1/2] Wire ratingHint into the failure threshold: the ratings are real (A's ruling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sail.js _checkFailure now fails a corner on load > hw.rating * anchor.ratingHint (read live from the anchor — dress() mutates in place). Consequence asserted, not the formula: on site_02's carport line (uniform rated shackles, funnel on, 12 s calm settle) a cb corner blows before any q corner — at DEFAULT tension (t~29 s into the storm, honest posts hold) and at max. In-suite control pins the attribution: the same rig with every hint forced to 1 holds 4/4, so the break is the hint, not the load. Mutation-checked: unwiring the line fails both. rigging.js weak-link (D's #7): summary.weakest reduces on rating * ratingHint via _effRating, not bare hw.rating — uniform hardware now flags the genuinely worst steel instead of the first click. Mutation-checked. site_audit prices the anchor, not just the steel: tierFor(peak, hint) needs h.rating * hint >= peak, both front-ends carry ratingHint through (browser off world.anchors, node dump extended with the live-dumped hints), rows print the hint. sweep.selftest asserts hints reprice without touching peaks and land over-ceiling corners in unholdable. Mutation-checked. Selftest 319/0/0 (315 + 4). Co-Authored-By: Claude Fable 5 --- tools/site_audit/audit.html | 7 +- tools/site_audit/audit.mjs | 27 ++++--- tools/site_audit/sweep.js | 28 ++++++- tools/site_audit/sweep.selftest.js | 32 ++++++++ web/world/js/rigging.js | 20 ++++- web/world/js/rigging.selftest.js | 26 +++++++ web/world/js/sail.js | 19 ++++- web/world/js/sail.selftest.js | 113 +++++++++++++++++++++++++++++ 8 files changed, 254 insertions(+), 18 deletions(-) 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() { From 2b1c8cf15e4e20729cadb394d76d98b16016e688 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 20:32:33 +1000 Subject: [PATCH 2/2] Re-audit both yards with hints live; fix the dump's 5 mm lie; THREADS tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audit.mjs's hand-typed backyard dump goes full-precision: the 2-dp rounding moved t2 ~5 mm against t1's shelter cone and swung its ice-night peak 4.8 -> 9.3 kN — the node and browser front-ends disagreed by one whole winnable line until the positions matched to the float. Reconciled by measurement (node on full floats reproduces the browser exactly). Re-audit verdict (THREADS, full tables): every night still passes, no retune proposed — E's numbers stand. backyard: hints move exactly one number (night 2 honest $30 -> $40; fascia appears in zero bed-covering quads so its 0.35 is audit-invisible). site_02: 64/66 -> 23/66 affordable, every cheap carport line dead, cheapest now $40 honest steel, cheapest >=70% line $45 through a $30-rated carport post — the trap is priced, not fenced off. Selftest 319/0/0. Co-Authored-By: Claude Fable 5 --- THREADS.md | 92 ++++++++++++++++++++++++++++++++++++++ tools/site_audit/audit.mjs | 28 +++++++++--- 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/THREADS.md b/THREADS.md index a17fec3..76e242f 100644 --- a/THREADS.md +++ b/THREADS.md @@ -3966,3 +3966,95 @@ anchors are your GLB), but the tooling is now waiting, not TODO. · **gate 3 — John plays the week. FIVE sprints standing, and for the first time the excuse is gone: the week is playable end to end.** §BROKE_BELOW watch: if the bank runs away, A's declared first cut is the $20 clean bonus, not feeFor/gardenBonusMax. + +[B] 2026-07-17 — ⚡ **THE RATINGS ARE WIRED. A's ruling, implemented, mutation-checked, and the trap + now fires at DEFAULT tension.** SPRINT12 gate 2 item 1, plus D's #7. Selftest 319/0/0 (315 + 4). + + `sail.js _checkFailure` now fails a corner on `load > c.hw.rating * (c.anchor.ratingHint ?? 1)` — + read LIVE from the anchor each check, not captured at attach, because dress() adopts E's hints by + MUTATING anchors in place and a rig must see the dressed number. Asserted as CONSEQUENCE, per the + sprint: D's exact carport line (cb1 cb2 q2 q3), uniform RATED shackles, 12 s calm settle (funnel + on — the gap doesn't switch off for prep), then the whole early buster: + · **default tension 1.0: cb1 blows at t≈29 s into the storm; both honest posts hold** (q2/q3 + peak 1.8/2.5 kN under a bare 6.5 kN shackle). D — your measured fork is dead: the anchor + decides now, not the dial. Your #6 follows: a cb corner IS likelier to blow than a q. + · max tension 1.4: cb1 goes at t≈0.5 s — **a drum-tight sail rips the beam off in PREP, on the + calm day.** Not a bug; 6.5 kN × 0.22 = 1.43 kN and the standing load at 1.4 exceeds it. The + carport now punishes greed before the storm even arrives. D: worth feeling on your replay. + · in-suite control: same rig, every hint forced to 1, holds 4/4 — so the break IS the hint, not + the load. Mutation-checked the honest way: revert the sail.js line and both tests go red + (nothing blows at either tension). + **Weak-link tie-break (D's #7): `summary.weakest` now reduces on rating × ratingHint** via + `_effRating`, so uniform hardware flags the genuinely worst steel, not the first click — and a + rated shackle on the beam (6.5 × 0.22 = 1.43 kN) correctly loses the label to a carabiner on an + honest post (1.2 kN). Ties still keep first pick: equal products really are interchangeable. + Mutation-checked (old reduce flags q1 when cb1 is clicked last; test clicks honest steel first). + + ⚠️ **A, C — three consumers of `hw.rating` in YOUR files now under-read the failure point** (not + touching them, file ownership): hud.js:352/359 draws the tension bar off `load / hw.rating`, so a + fascia corner shows 35% margin it doesn't have and blows at "0.4/1.2kN"; main.js:166 picks the + verdict's "weakest link that went first" among lost corners by bare rating; skyfx.js:875 scales + an effect off `c.hw.rating`. All three want `hw.rating * (anchor.ratingHint ?? 1)` — the corner + carries `c.anchor`, so it's one expression. Happy to hand any of you the sail-side read if you + want it as a method instead. + +[B] 2026-07-17 — 🔬 **TWO HARNESSES DISAGREED BY ONE WINNABLE LINE, AND THE VARIABLE WAS FIVE + MILLIMETRES.** Found re-auditing, worth its own entry because it's the repo's oldest lesson at a + new scale. The node front-end's hand-typed backyard dump (2 decimals) and the browser's dressed + `world.anchors` (full float) disagreed on the ICE night: dump said 1 affordable line, browser + said 2. Same sweep, same storm, same hints. The 2-dp rounding moves t2 ~5 mm, which sits it + differently against t1's shelter cone, and its peak on the 58% quad swings **4.8 → 9.3 kN** — + NONE-able vs $30-able. Reconciled by measurement: node re-run on the full-precision positions + reproduces the browser exactly (ice 2 winners, wild 4, same lines, same prices). The dump now + carries full floats with a warning comment. **C — this bites your gate-2 harness directly: pull + anchor positions from `world.anchors` (or my dump/sail.selftest fixture, now full-precision), + never from a rounded table, or our envelopes will disagree by design near shelter edges.** + +[B] 2026-07-17 — 📊 **RE-AUDIT, BOTH YARDS, ALL FIVE NIGHTS, FUNNEL ON, HINTS LIVE. Verdict: every + night still PASSES, the escalation survives, and the corner block's $20 carport decoy is dead — + which is the design working, not breaking. NO RETUNE NEEDED: E's numbers stand.** Browser + front-end off dressed `world.anchors`; node front-end agrees exactly post-reconcile (above). + + | n | site | storm | fee | winners | cheap line | honest rig (≥70% cover) | net* | + |---|--------------|--------------|----:|------------:|-----------------|------------------------------|-----:| + | 1 | backyard_01 | gentle | $42 | 12/12 | $20 @58% | $20 +$15 = **$35** (@71%) | +$62 | + | 2 | backyard_01 | southerly | $57 | 12/12 | $20 @29% | $40 +$15 = **$55** (@75%) | +$67 | + | 3 | **corner blk** | early buster | $57 | **23/66** | **$40 @63%** | $45 +$15 = **$60** (@75%) | +$65 | + | 4 | backyard_01 | wild night | $73 | 4/12 | $65 @25% | $120 +$15 = $135 ⚠ over $80 | +$43 | + | 5 | backyard_01 | ice night | $71 | 2/12 | $75 @58% | $120 +$15 = $135 ⚠ over $80 | +$41 | + *net = fee + garden(hp100) + half hardware back − (rig + spare), same formula as my sprint-11 + table, clean bonus excluded for comparability. Deltas vs sprint 11: n2 −$5, n3 −$7, rest equal. + + **What the hints actually moved, attributed by strip-runs (same sweep, hints forced to 1):** + · **backyard_01 — one number.** Night 2's honest line: $30 → $40 (t2b's 0.88 uptiers one + corner $5→$15... then ring assignment shuffles a $15 to p4). Nights 1, 4, 5: byte-identical + verdicts. **The fascia's 0.35 changes NOTHING in the audit — h1..h3 appear in zero + bed-covering quads in the 18–45 m² band.** The house is simply out of position to shade the + bed. So the fascia trap today is a UI-reachable storm failure (a carabiner on h2 now lets go + at 0.42 kN), not an audit-visible economy. E, A: your gate-3 gutter work is what makes that + failure COST something; the audit lens won't see it either way. Flagging so nobody expects + the backyard's audit table to move when the gutter lands. + · **site_02 — the trap grew teeth and the yard stayed winnable.** Hints off: 64/66 affordable, + cheapest $20 @25% THROUGH the carport (q3+q4+cb1+cp2 on carabiners) — sprint-11's numbers + exactly, good continuity. Hints on: **23/66 affordable, 42 lines unholdable, and every + cheap carport line is gone** — cb demands peak/0.22, so the shop's ceiling can't hold a + beam corner that pulls >1.43 kN effective. Cheapest is now honest steel + tree ($40 @63%), + and the cheapest ≥70% line ($45 @75%) runs through **cp1 at $30 rated** — you can still buy + the carport post, it just costs what lying steel costs. 16 of 23 winners still touch + carport anchors: the trap is priced, not fenced off. DESIGN's sentence is now true in the + sim: four free-looking anchors, and tying real load to them either prices you into rated + steel or takes the roof off. + + **Retune verdict, formally: none proposed.** The criteria were "unwinnable or defanged" — + neither occurred. Every night has an affordable line; night 3's honest rig ($60 all-in against a + $57 fee, +$65 net) sits $5 above night 2's, which preserves "looks like night 2 and isn't" + without pricing the block up; nights 4–5 still only rig honestly off a banked wallet, which is + the week's spine doing its job. E's 0.22/0.30/0.35/1.0 (and the branch taper 1.0/0.88/0.76 + nobody talks about, which is doing quiet good work uptiering high-branch corners) all stay. + + **C — your gate-2 handshake, so our harnesses can meet:** my failure envelope is measured + through `auditSweep` (12 s calm settle w/ venturi, resetPeaks, full storm, porosity 0.30, + tension 1.0, static sway) off full-precision dressed anchors; the two consequence tests in + sail.selftest.js carry the exact site_02 fixture + venturi verbatim. If your storm-side numbers + (speedAt × time) disagree with any peak in the tables above, the mm entry above is suspect #1 + and the settle window is #2 — find the variable in THREADS before anyone touches a constant. diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs index 8f27b99..a82a91b 100644 --- a/tools/site_audit/audit.mjs +++ b/tools/site_audit/audit.mjs @@ -73,13 +73,27 @@ const BACKYARD_01 = { // 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], + // + // FULL PRECISION, not 2-decimal — measured, the hard way (SPRINT12): the + // old 2-dp dump moved t2 by ~5 mm, which sat it differently against t1's + // shelter cone and swung its ice-night peak 4.8 → 9.3 kN — the two + // front-ends disagreed by one whole winnable line (ice: 1 winner vs the + // browser's true 2) until the positions matched to the float. Near a + // shelter edge the sweep is mm-sensitive; round nothing. + ['h1', 'house', -3, 2.4797002522727953, -9.949999988079071, 0.35], + ['h2', 'house', 0, 2.4797002522727953, -9.949999988079071, 0.35], + ['h3', 'house', 3, 2.4797002522727953, -9.949999988079071, 0.35], + ['t1', 'tree', -9.960037052631378, 3.4546064703375015, 0.5399932861328125, 1.0], + ['t2', 'tree', 7.833247900009155, 2.9268529771469947, -0.8460308313369751, 1.0], + ['t1b', 'tree', -9.941157281398773, 3.9636089174657974, 2.7770196199417114, 0.88], + ['t1c', 'tree', -10.381556510925293, 5.045027431717506, 2.97845321893692, 0.76], + ['t2b', 'tree', 8.141596481204033, 3.698920047154319, -0.9573200941085815, 0.88], + // pure world.js — cross-checked against live code on every run (0.01 m + // tolerance). Posts are site-JSON anchors: DEFAULT_RATING_HINT = 1. + ['p1', 'post', -4.852518667660811, 3.9450703766405963, 5.930856149363214, 1.0], + ['p2', 'post', 4.308797385647288, 3.958677942376306, 6.463196078470932, 1.0], + ['p3', 'post', 0, 3.954237880742151, 7.556692403840262, 1.0], + ['p4', 'post', -3.721247340646687, 4.000586139378515, -1.3954677527425075, 1.0], ].map(([id, type, x, y, z, ratingHint]) => ({ id, type, ratingHint, pos: { x, y, z } })), };