Merge remote-tracking branch 'origin/lane/b'

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-17 21:13:57 +10:00
commit 28ef4ef138
9 changed files with 359 additions and 18 deletions

View File

@ -4279,3 +4279,94 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
peak. The peak-timing comparison in my prediction 3 applies to your resetPeaks peak-load peak. The peak-timing comparison in my prediction 3 applies to your resetPeaks peak-load
tables on lines that HOLD — when you post the full re-audit of both yards, that's the number tables on lines that HOLD — when you post the full re-audit of both yards, that's the number
to put beside my tPeak column. If THOSE disagree, then we hunt the variable. to put beside my tPeak column. If THOSE disagree, then we hunt the variable.
[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 1845 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 45 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.

View File

@ -67,9 +67,12 @@ async function run() {
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } } 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. // 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 anchors = world.anchors.map((a) => {
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z }; 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`); 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'; } else { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; }
const cs = tr.insertCell(); cs.className = 'corner'; const cs = tr.insertCell(); cs.className = 'corner';
cs.innerHTML = r.tiers.map((c) => cs.innerHTML = r.tiers.map((c) =>
`${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : '<span class="none">NONE</span>'}`).join(' '); `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : '<span class="none">NONE</span>'}`).join(' ');
} }
el('out').appendChild(tbl); el('out').appendChild(tbl);

View File

@ -67,14 +67,34 @@ const BACKYARD_01 = {
bed: { x: 1, z: 2, w: 6, d: 4 }, bed: { x: 1, z: 2, w: 6, d: 4 },
venturi: [], // backyard_01.json ships "venturi": [] — no funnel. Stated, not assumed. venturi: [], // backyard_01.json ships "venturi": [] — no funnel. Stated, not assumed.
anchors: [ anchors: [
// dress()-only: adopted from E's GLBs. Unverifiable headless. // dress()-only: adopted from E's GLBs. Unverifiable headless. The 6th column
['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95], // is ratingHint, dumped from the live dressed world.anchors (SPRINT12) —
['t1', 'tree', -9.96, 3.45, 0.54], ['t2', 'tree', 7.83, 2.93, -0.85], // the fascia's 0.35 and the branches' 1.0/0.88/0.76 are E's baked numbers,
['t1b', 'tree', -9.94, 3.96, 2.78], ['t1c', 'tree', -10.38, 5.05, 2.98], ['t2b', 'tree', 8.14, 3.70, -0.96], // and the sweep prices hardware against rating × hint now that sail.js
// pure world.js — cross-checked against live code on every run. // fails on it. Hand-kept like the positions; the browser front-end is the
['p1', 'post', -4.85, 3.95, 5.93], ['p2', 'post', 4.31, 3.96, 6.46], ['p3', 'post', 0.00, 3.95, 7.56], // authority if these ever drift.
['p4', 'post', -3.72, 4.00, -1.40], //
].map(([id, type, x, y, z]) => ({ id, type, pos: { x, y, z } })), // 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 } })),
}; };
/** Anchors dress() never touches — so live world.js IS authoritative for these. */ /** Anchors dress() never touches — so live world.js IS authoritative for these. */
@ -160,6 +180,9 @@ async function loadSite(path) {
// This is what a dressed export (or a future createWorld dump) would hand us. // This is what a dressed export (or a future createWorld dump) would hand us.
const anchors = (j.anchors || []).map((a) => ({ const anchors = (j.anchors || []).map((a) => ({
id: a.id, type: a.type || 'post', 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 }, 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, // A resolved export still owes us the site's funnel — the venturi is site data,
@ -205,7 +228,7 @@ async function main() {
console.log(`${cands.length} quad(s) in band shading the bed:\n`); console.log(`${cands.length} quad(s) in band shading the bed:\n`);
for (const r of rows) { 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}`; 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}`); 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}`);
} }

View File

@ -35,13 +35,28 @@ export function areaOf(q) {
return Math.abs(a / 2); 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. * Sweep every bed-covering quad and price the cheapest holding line.
* @param {object} o * @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.bed garden bed rect { x, z, w, d }
* @param {object} o.stormDef the storm JSON to fly * @param {object} o.stormDef the storm JSON to fly
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle) * @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 rig.resetPeaks(); // ← the storm starts HERE
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT); 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 unholdable = tiers.filter((c) => !c.tier);
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0); 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, rows.push({ ...cnd, tiers, unholdable, hw, total: hw + AUDIT.SPARE_COST,

View File

@ -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; export const SWEEP_TESTS = TESTS;

View File

@ -222,6 +222,16 @@ export class RiggingSession {
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension); 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. */ /** Everything the HUD needs to draw the prep panel, in one read. */
get summary() { get summary() {
return { 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 }, fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
canStart: this.canStart, canStart: this.canStart,
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })), 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 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, : null,
}; };
} }

View File

@ -211,6 +211,32 @@ test('summary names the weak link for the HUD', () => {
return `weak link flagged: ${sum.weakest}, $${sum.budget} left`; 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', () => { test('reset() returns a used session to a fresh prep phase', () => {
const s = session(); const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);

View File

@ -863,12 +863,27 @@ export class SailRig {
return n ? Math.sqrt(sum / n) / SIM_DT : 0; 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) { _checkFailure(dt) {
for (let k = 0; k < 4; k++) { for (let k = 0; k < 4; k++) {
const c = this.corners[k]; const c = this.corners[k];
if (c.broken) continue; 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); else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
if (c.overload > OVERLOAD_SECS) { if (c.overload > OVERLOAD_SECS) {
c.broken = true; c.broken = true;

View File

@ -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)'; 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 const SAIL_TESTS = TESTS;
export function runSailSelftest() { export function runSailSelftest() {