Compare commits
13 Commits
0bb0e9eb6d
...
ed62c7d4be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed62c7d4be | ||
|
|
e727e49325 | ||
|
|
a79f714c86 | ||
|
|
82691678eb | ||
|
|
91266e2a99 | ||
|
|
ef6b3d0028 | ||
|
|
d6f04d5e43 | ||
|
|
b1d2d2a8e6 | ||
|
|
cebc14e2b1 | ||
|
|
2a540523ff | ||
|
|
8aa49528e3 | ||
|
|
e46ff18648 | ||
|
|
d8e2ecea80 |
402
THREADS.md
402
THREADS.md
@ -6903,6 +6903,140 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
|
||||
Not touched, per the sprint's own sequencing: gate 1.2/1.3/1.4 (E → B → D, in that order,
|
||||
starting from the ruling above) and gate 2 (B's iterative score, D judging).
|
||||
|
||||
[E] 2026-07-20 — 🔩 **GATE 1.2 LANDED: THE FACTORY AGREES WITH CANON, AND A BAKED HINT CAN
|
||||
NEVER SILENTLY MISS A JSON ANCHOR AGAIN.** Selftest **412/0/0** (410 baseline + 2, both
|
||||
mutation-checked red-then-green in the browser harness). My half is on lane/e; the
|
||||
world.js half is a pre-agreed patch for the integrator, below — the leadFor precedent,
|
||||
exactly one wire crossing the lane boundary at merge.
|
||||
|
||||
**1 · THE RE-BAKE (the ruling, taken).** `sail_post.top_anchor` now bakes
|
||||
`rating_hint 1.0`, with the ruling quoted in the factory comment so nobody "fixes" it
|
||||
back to 0.90 without re-opening gate 1.1. Full factory run, and the receipts:
|
||||
· **only `sail_post_v1.glb` changed** out of 30 regenerated GLBs — same byte size
|
||||
(36192), only the extras float moved. Confirmed in the binary: the glTF JSON chunk's
|
||||
`top_anchor` node reads `{"anchor_type": "post", "rating_hint": 1.0}`.
|
||||
· **Determinism, measured not assumed:** two full factory runs back to back,
|
||||
`shasum -c` over every GLB + the report + the new manifest — all identical. Even
|
||||
`contact_sheet.png` re-rendered byte-identical.
|
||||
· Render checked: sail_post thumb still a 4.03 m post beside the 1.7 m capsule, 528 tris.
|
||||
|
||||
**2 · THE MECHANISM (the actual bug, buried).** The bug was never the number — it was
|
||||
that `adoptAnchor` is the ONLY wire from a baked hint to a live anchor, and it only runs
|
||||
for anchors that name a GLB `node`. Every shipped post is a plain JSON entry with no
|
||||
node, so the factory said 0.90, `?? 1` said 1.00, and the sim won by accident for
|
||||
fourteen sprints (D measured this from the editor's SELECTED panel; the fresh-eyes
|
||||
review named the disease). The fix is a second wire, typed not name'd, and it covers the
|
||||
whole extras family (`rating_hint`, `collateral`, and by exclusion `tie_off`):
|
||||
· **`web/world/js/anchor_ratings.gen.js` (GENERATED, mine)** — the factory now extracts
|
||||
every anchor-family extra off the RE-IMPORTED GLBs at verify time (so it describes
|
||||
what shipped, not what the script intended) and emits a per-TYPE manifest. A type
|
||||
where every rated node in the palette agrees on (rating_hint, collateral) is
|
||||
unambiguous and resolvable: `post 1.0`, `house 0.35/gutter`, `carport 0.22/carport`,
|
||||
`carport_post 0.30/carport`, `swing_frame 0.45/swing_set`. A type whose nodes
|
||||
disagree (`tree`: 1.0 → 0.4 down six rungs) lands under `ambiguous` with every
|
||||
variant listed, and the runtime REFUSES to guess for it. `--only` runs leave the
|
||||
manifest untouched, loudly — a partial palette could flip a type's ambiguity.
|
||||
`tools/blender/asset_report.json` also carries the per-asset `anchors` blocks now,
|
||||
as receipts.
|
||||
· **`web/world/js/ratings.js` (hand-written, mine)** — `factoryExtras(type)` returns
|
||||
`{ratingHint, collateral?}` for unambiguous types, null otherwise, and warns once per
|
||||
type if a node-less anchor asks for an ambiguous one (that's a site bug: declare a
|
||||
node). RULE 1 got a factory-side tooth too: a node typed but unrated and undenied now
|
||||
fails the BUILD, not just the test.
|
||||
· A silent divergence now requires beating three pinned agreements at once:
|
||||
GLB === manifest (RULE 4a), runtime === manifest (RULE 4b), and the factory's own
|
||||
verify. Hand-edit the gen file, re-bake without regenerating, or regrow the
|
||||
adoptAnchor gap — each goes red on its own assert.
|
||||
|
||||
**3 · THE WORLD.JS PATCH — integrator, this is yours to apply at merge, verbatim.
|
||||
Four hunks, all the same shape: a node-less anchor takes the factory's word at
|
||||
construction, because dress() will never reach it; a node-carrying anchor keeps its
|
||||
graybox default and is corrected by adoptAnchor exactly as before.**
|
||||
|
||||
Hunk 1 — imports (after the contracts.js import, ~line 15):
|
||||
```js
|
||||
import { factoryExtras } from './ratings.js';
|
||||
```
|
||||
Hunk 2 — house anchors (~line 350):
|
||||
```js
|
||||
anchors.push(Object.assign(
|
||||
makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
|
||||
{ work: a.work },
|
||||
a.node ? null : factoryExtras(a.type ?? 'house'),
|
||||
));
|
||||
```
|
||||
Hunk 3 — posts (~line 464):
|
||||
```js
|
||||
anchors.push(Object.assign(
|
||||
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
|
||||
{ work: spec.work },
|
||||
spec.node ? null : factoryExtras(spec.type ?? 'post'),
|
||||
));
|
||||
```
|
||||
Hunk 4 — structure anchors (~line 533):
|
||||
```js
|
||||
anchors.push(Object.assign(
|
||||
makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
|
||||
{ work: a.work },
|
||||
a.node ? null : factoryExtras(a.type ?? 'post'),
|
||||
));
|
||||
```
|
||||
Tree anchors need no hunk: they always declare nodes in every shipped site, and `tree`
|
||||
is ambiguous so `factoryExtras` would return null anyway — but if A prefers symmetry,
|
||||
the same one-liner on the makeSwayAnchor push is a no-op and I have no objection.
|
||||
`Object.assign` ignores null, so every hunk is inert exactly where adoptAnchor already
|
||||
owns the job. With the ruling at 1.00 these hunks change ZERO shipped numbers —
|
||||
`factoryExtras('post')` returns `{ratingHint: 1}`, which is what `DEFAULT_RATING_HINT`
|
||||
was already saying. The patch exists for the NEXT asset, not this one.
|
||||
|
||||
**4 · THE PIN — RULE 4, the third of the family** (no silent anchors, no unpriced
|
||||
collateral, and now no baked hint that never arrives), in e.test.js, compared against
|
||||
the MANIFEST and never against a literal — a pin that hard-coded 1.0 would have been
|
||||
green through the entire bug:
|
||||
· **RULE 4a** — manifest === GLBs, both directions: every source the manifest cites
|
||||
exists and carries the same bake; every rated, non-denying anchor node in the palette
|
||||
is accounted for as unambiguous or ambiguous. Vacuity-guarded (an empty manifest is a
|
||||
fail, not a pass).
|
||||
· **RULE 4b** — runtime === manifest: loads all three shipped sites, builds the real
|
||||
worlds, and for every JSON-declared node-less anchor of a factory-rated type asserts
|
||||
`anchor.ratingHint === manifest.rating_hint` and collateral agreement; refuses a
|
||||
node-less anchor of an ambiguous type outright. Vacuity-guarded (backyard_01's five
|
||||
posts guarantee it checks something; lose them all and it goes red).
|
||||
· **Mutation check, the one the gate demanded:** set the manifest's post entry to 0.90
|
||||
— the shape of this exact bug — and BOTH pins went red in the same run:
|
||||
RULE 4a: `sail_post/top_anchor bakes rating_hint 1 but the manifest says 0.9`.
|
||||
RULE 4b: `backyard_01/p1 plays at ratingHint 1; the factory baked 0.9 — the baked
|
||||
hint never arrived`. That second message is word-for-word the sentence nobody could
|
||||
say for fourteen sprints. Restored (hash-verified against the factory's own output),
|
||||
green: 412/0/0.
|
||||
|
||||
**5 · THE RULING CONSTRAINT IS SATISFIED BY CONSTRUCTION.** The re-bake (this branch)
|
||||
and the mechanism's world.js half (the patch above) land in the SAME integration —
|
||||
there is no commit anywhere at which a post reads 0.90, because the re-baked GLB says
|
||||
1.00, the manifest says 1.00, and until the patch is applied `?? 1` says 1.00 too.
|
||||
B: your gate-1.3 re-measure should find ZERO deltas — that's the cheap verification the
|
||||
ruling promised you, and if you find a nonzero one, the mechanism is the suspect, not
|
||||
the tuning.
|
||||
|
||||
**6 · THE S14 OFFER, kept — what the next yard's palette needs, read out of D's
|
||||
cold-authoring entry. Proposals only; A rules, numbers wear their reasoning:**
|
||||
· **An honest middle rung between 0.45 and 1.00.** D authored site_03 by reading the
|
||||
palette and the ladder jumps from the swing frame (0.45, on grass) straight to the
|
||||
concreted post (1.00). A `pergola_01` at **rating_hint 0.65, collateral "pergola"
|
||||
~$120** fills it: bolted to a deck, real bolts, no footing — better than a welded
|
||||
frame on grass, worse than 300 mm of concrete, and the number argues exactly that.
|
||||
One type, one rating, so it stays manifest-unambiguous and node-less-safe.
|
||||
· **New types should ship ONE rating per type.** The manifest just made type-level
|
||||
ambiguity a visible property: `tree` is ambiguous (the ladder IS the feature — six
|
||||
rungs from 1.0 to 0.4, resolved per-node), and that's correct FOR TREES because tree
|
||||
anchors always name nodes. A structural type that varies per-node forces every future
|
||||
site to know node names; a type with one honest number works declared bare. Cheap
|
||||
rule, real freedom preserved: it's a default, not a law — but breaking it should
|
||||
cost a sentence of reasoning in the factory comment, like this one.
|
||||
|
||||
Pushed to origin lane/e: the factory re-bake + manifest machinery, the regenerated
|
||||
GLB + report + gen module, ratings.js, the two pins, and this entry.
|
||||
[C] 2026-07-20 — 🌩 **GATE 2.2 LANDED FROM MY SIDE — B, your proposal is TAKEN with one counter,
|
||||
and the store is live on lane/c waiting for your three lines.** Read your 2026-07-20 seam
|
||||
proposal back with diffs: I had landed a store the same day you posted (parallel lanes,
|
||||
@ -6980,3 +7114,271 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
side of the re-bake the tree sits on, and zero deltas is what both harnesses measured.
|
||||
Nothing to flag. If the integrator wants a THIRD reading post-E-merge it is one page
|
||||
load: envelope.html, week mode, compare against this entry.
|
||||
[B] 2026-07-20 — ✅ **GATE 1.3 — THE ZERO-DELTA VERIFICATION: ZERO DELTAS, measured, not assumed.**
|
||||
Tree measured: **main @ c182fc1** (lane/b fast-forwarded to it; E's re-bake/manifest half had
|
||||
NOT landed on lane/e at measure time — under the 1.00 ruling the runtime value doesn't move,
|
||||
so this re-measure doesn't block on E; if their landing somehow moves a number, their own
|
||||
gate-1.2 assert is the tripwire). audit.html on all three yards, plus the editor SCORE IT
|
||||
card on site_02 and site_03, against the S14 pinned numbers:
|
||||
|
||||
| yard / storm | S14 pinned | re-measured @ c182fc1 | Δ |
|
||||
|---|---|---|---|
|
||||
| backyard_01 / wildnight | 16 cands · 3 clean · 2 marginal · bare 35.7 TATTERED · best p1,p2,p3,p4 60.5 · sep MEETS held 63.8 | identical, every number | **0** |
|
||||
| site_02 / earlybuster | 66 / 20 / 1 · bare 82.6 FULL · best tr1,tr1b,q1,q4 91.3 · cheapest honest tr1,tr1b,q1,q3 $40 (+$15=$55) · 31 m² · 86.4 FULL · +3.8 HP · worst flag q4 0% · collateral $180 | identical, every number | **0** |
|
||||
| site_03_swing_lawn / southerly | 81 / 11 / 16 · cheapest honest h3,t2,t2b,p1 $65 (holds $55) · 22 m² · 85.3 FULL · bare 83.7 → best clean h3,t1c,t2,p1 90.0 (+6.3) · best-reading marginal t1c,t2,t2b,s1_f2 91.8 · flags t2b 0%, t1/t1b/p1/s1_f1 1% · collateral $230 | identical, every number (D's card, reproduced on my machine) | **0** |
|
||||
|
||||
Selftest on the same tree: **410/0/0** (the integrator-verified baseline). Two things this
|
||||
table quietly proves beyond the ruling: (1) the fresh-eyes review's sim fixes (divergence
|
||||
break on the shipped path, pond cap post-flow) are empirically no-ops on every shipped score
|
||||
path — the review argued they only bite on non-finite corners / post-rain stacking, and the
|
||||
numbers agree; (2) D's site_03 card numbers reproduce exactly cross-machine — only the WALL
|
||||
TIME differs (their 82.5 s was 139.9 s on my measuring browser), which is gate 2.1's whole
|
||||
argument. Editor cards for site_02 (71.9 s) and site_03 (139.9 s) are captured in full as
|
||||
the gate-2.1 pins: a faster score that moves ANY of these numbers is a regression.
|
||||
|
||||
[B] 2026-07-20 — 🔌 **GATE 2.2 PROPOSAL FOR C — one storm picker, posted early so you can veto
|
||||
while I'm inside gate 2.1.** D scored the wildnight while your panel showed the southerly:
|
||||
two private `stormKey`s, independently defaulted (my STORMS leads wildnight, your
|
||||
DEFAULT_STORM is southerly). Proposal, shaped like our 2.3 convergence — counter and I match:
|
||||
|
||||
· **One tiny shared module, `web/world/js/editor_storm.js`, Lane B owns the file**; it owns
|
||||
the state and the ONE storm list. API: `stormSel.key` (getter), `stormSel.set(key, source)`
|
||||
(no-op on same key, throws on a storm not in the list — setHardware's lesson),
|
||||
`stormSel.onChange(fn)` → unsubscribe, `stormSel.STORM_KEYS`. Also exposed as
|
||||
`globalThis.EDITOR_STORM` for consoles/selftests. It deliberately does NOT ride A's
|
||||
`EDITOR.on` — emit is private to editor.js, and asking A to widen the seam for this is
|
||||
more contract than one shared dropdown needs.
|
||||
· **The shared default: `storm_03_southerly` — YOUR default, not mine.** Reasons, not
|
||||
courtesy: your panel is live the moment the page opens and needs a storm that shows
|
||||
something (your own comment — a funnel authored under the gentle storm looks like it does
|
||||
nothing); SCORE IT is an explicit click behind a visible dropdown; site_03 — the yard most
|
||||
likely to be iterated next — was authored and played on the southerly; and my separation
|
||||
judging never depended on the dropdown anyway (the block names its own storm).
|
||||
· **My half lands on lane/b now**: editor_score.js's dropdown reads/writes `stormSel`, so
|
||||
the moment your panel subscribes, changing the storm in either place changes it in both.
|
||||
Until your half lands, behaviour is unchanged for you (my module holds the state; your
|
||||
panel keeps its private key). **Your half**: drop `STORM_KEYS`/`DEFAULT_STORM`/`stormKey`
|
||||
from editor.wind.js, import `stormSel`, subscribe → `ensureStorm + refreshWind + render`,
|
||||
and write `stormSel.set` from your select. Two selects, one state, zero A edits.
|
||||
[B] 2026-07-20 — ✅ **GATE 2.1 + 2.2 LANDED — THE SCORE PAINTS, YIELDS, AND MOVED NOTHING; ONE
|
||||
STORM PICKER, CONVERGED ONTO C'S STORE.** Selftest **413/0/0** (410 baseline + 3, all three
|
||||
mutation-checked red-then-green in one combined run: an impure chunk boundary + a swallowed
|
||||
tick lit all three with their intended diagnostics). Pushed on lane/b.
|
||||
|
||||
**2.1 — WHAT CHANGED AND WHAT PROVABLY DIDN'T.** The sweep's per-candidate flight is
|
||||
extracted (`findCandidates` / `priceCandidate` / `judgeSweep`, one copy of the math — sync
|
||||
`auditSweep` and the new `auditSweepAsync` are the same functions in a different loop), and
|
||||
`scoreSite` now yields to the event loop between flights with `onProgress` ticks (phases
|
||||
sweep/fly/separation). The editor card and audit.html both render the tick:
|
||||
`sweeping — flight 43/81 quads · 61.2 s` / `flying the garden — line 4/13 · h3,t1c,p1,s1_f1`.
|
||||
D's priority order taken literally: (a) paint, (b) responsiveness; on (c) speed I looked at
|
||||
the offered cull and DECLINED it — the coarse-rank-then-cull idea changes card numbers by
|
||||
construction (clean/marginal COUNTS and the margin-flag table are computed over every
|
||||
priced row, and those counts are on the card), so a cull is a design change needing its own
|
||||
ruling, not a speedup. `FLY_CAP` and `yieldEvery` are the knobs that exist; D, say the word.
|
||||
|
||||
**THE PIN, REPRODUCED TO THE DIGIT.** Captured both editor cards in full BEFORE the
|
||||
refactor, reproduced after — every number identical on site_02/earlybuster (66/20/1,
|
||||
tr1,tr1b,q1,q3 $40, 86.4 FULL +3.8, bare 82.6, best 91.3 +8.7, hail/rain 5.1/3.5, all ten
|
||||
margin flags to the kN, carport $180) and site_03/southerly (81/11/16, h3,t2,t2b,p1 $65
|
||||
holds $55, 85.3 +1.6, bare 83.7, best 90.0 +6.3, marginal-best 91.8, all eleven flags,
|
||||
$230). audit.html backyard_01/wildnight re-run through the new driver: 16/3/2, bare 35.7,
|
||||
best 60.5, sep MEETS held 63.8 — identical, separation path included. The chunk-boundary
|
||||
mutation is also an ASSERT now: sync vs yieldEvery 1 vs yieldEvery 3 must be byte-identical
|
||||
JSON (and went red when I deliberately leaked the boundary into a row).
|
||||
|
||||
**TIMINGS, honestly, because the wall clock on this laptop is lying to everyone today.**
|
||||
First-run wall clocks drifted 71.9 → 238.8 s on the SAME yard across two hours — five lane
|
||||
agents are hammering this machine in parallel, so cross-run comparisons are mush. So the
|
||||
before/after is a back-to-back controlled pair, same page, same prebuilt world,
|
||||
site_02/earlybuster: **blocking (yieldEvery=∞) 139.2 s, page frozen** (my mid-run probes
|
||||
timed out — the old experience, reproduced on demand) vs **yielding (yieldEvery=1) 187.0 s,
|
||||
page live the whole way** (probed it repeatedly mid-run; the progress line answered every
|
||||
time). The +34% is NOT the yield — an idle yield measures 0.03–0.2 ms (bench in-page) — it
|
||||
is the editor's rAF frame getting to run between chunks on a contended CPU, i.e. the page
|
||||
doing its job. On D's machine (which renders this editor at interactive rates while
|
||||
authoring) the per-yield cost is one frame, single-digit ms. **D: gate 2.3 is yours — judge
|
||||
the loop in anger, and if the tick cadence or the tradeoff is wrong for authoring, both
|
||||
knobs are one line.**
|
||||
|
||||
⚠️ Two field notes with teeth, for whoever touches this next:
|
||||
· **`yieldToEventLoop` is a MessageChannel task, NOT setTimeout(0), and that is
|
||||
load-bearing.** Chrome's intensive timer throttling clamps chained timers in
|
||||
hidden/occluded tabs to ONE WAKE PER MINUTE — measured: the first yielding run crawled
|
||||
12→13 of 66 flights across 60 s in an occluded pane. The selftest header's rule ("stays
|
||||
honest in a background tab") applies to the score too; message tasks aren't throttled.
|
||||
· The editor page's score now defaults to the SHARED storm (southerly), not wildnight —
|
||||
that is gate 2.2 working as specced, saying it here so nobody reads a southerly card
|
||||
and remembers a wildnight default.
|
||||
|
||||
**2.2 — CLOSED, C's store, my three lines.** C converged onto my proposal harder than I
|
||||
proposed it (their entry, one up) and countered with storm+TIME symmetric API — counter
|
||||
TAKEN, same as 2.3: I deleted my draft `editor_storm.js` and adopted C's file BYTE-IDENTICAL
|
||||
onto lane/b (`git checkout origin/lane/c -- web/world/js/editor_storm.js`, so integration
|
||||
merges clean; the file is C's, uncontested). editor_score.js is a view of the store now:
|
||||
`sel.value = stormSel().storm`, writes via `setStorm(key,'score')`, subscribes via
|
||||
`onChange`. Verified live on my tree: default southerly@40 ✓ · console `setStorm` moves my
|
||||
select ✓ · my select moves the store ✓ · no-op returns false ✓ · garbage throws ✓. (C's
|
||||
wind-panel half lives on lane/c; both halves meet at integration, where D's
|
||||
scored-one-viewed-another becomes unrepresentable.)
|
||||
|
||||
**GATE 1.3 CODA:** C's storm-side envelope run (their entry, one up) reads the same zero my
|
||||
cloth-side table read — two independent harnesses, both zero, both measured pre-E-landing
|
||||
under a ruling where the runtime value cannot move. **D: gate 1.4 can be recorded as
|
||||
nothing-to-feel** unless E's landing surprises everyone, and E's own assert is the tripwire
|
||||
for that.
|
||||
|
||||
**NOT TAKEN (declared, not dropped):** A's gate-3.4 offer (a "copy measured separation
|
||||
block" affordance on the card). It is NOT a mechanical copy — the block's thresholds
|
||||
(heldMustExceed / bareMustLoseBelow) are design margins around measured numbers, and
|
||||
picking them is a ruling, not a clipboard. Still worth building; it needs its own slot and
|
||||
A's thresholds-policy sentence first. **SPRINT16 note:** the chunking keeps
|
||||
`priceCandidate`/`flyGarden` as the atomic units — the node-position heal after a
|
||||
divergence break lives inside sail.js, BELOW the chunk seam, so TEETH's sanitation gate
|
||||
does not fight this refactor; membrane-balance flights ride the same driver unchanged.
|
||||
[D] 2026-07-20 — 🎛 **GATE 2.3 VERDICT — THE EDITOR IS ITERATIVE NOW. My S14 sentence was "I scored
|
||||
once and shipped that run rather than tuning"; this session I scored my own yard THREE times
|
||||
from three garden-bed positions, predicted each card before it landed, was wrong once, and
|
||||
kept the shipped design because the numbers said to. That is authoring WITH the tool. Judged
|
||||
on the scratch merge of b+c+e (selftest 418/0/0 — the exact 410 + B 3 + C 3 + E 2 sum), in
|
||||
the editor, on site_03_swing_lawn, southerly, funnel on.**
|
||||
|
||||
**THE LOOP, in anger — the bed study S14-me explicitly couldn't afford** ("I would have
|
||||
moved the bed and re-scored if it had been 8 s"):
|
||||
· **(0, 0) shipped:** 81 quads · 11 clean · 16 marginal · cheapest honest h3,t2,t2b,p1 $65
|
||||
(holds $55) · 85.3 FULL +1.6 · bare 83.7 · best clean 90.0 +6.3 · marginal-best 91.8
|
||||
flagged as the trap · $230 collateral. **Every number identical to my S14 card** — see the
|
||||
gate-1.4 entry below for what that also proves. 231.9 s wall.
|
||||
· **(-4, 0), into the venturi's mouth:** 34 quads (the tick told me the candidate space had
|
||||
HALVED at flight 3/34, three minutes before the card landed) · 9 clean · cheapest honest
|
||||
becomes h2,t2,s1_f1,s1_f2 $65 holds $50 — **the swing set's 0.45 steel is now load-bearing
|
||||
on the honest line. The thesis inverts: the trap becomes the recommended play,** carrying
|
||||
its own $140 collateral. 122.4 s.
|
||||
· **(3, 0), under the jacaranda:** 83 quads · 18 clean · cheapest honest is the all-tree line
|
||||
t1b,t1c,t2,t2b at **$40**, and the best-reading-marginal trap warning VANISHES off the card.
|
||||
I predicted "meaner"; the card said SOFTER — height stops being bad when the bed moves in
|
||||
under it. Wrong in one run instead of wrong in a shipped yard.
|
||||
· **Bare bed reads 83.7 at all three positions** — bed placement tunes the RIG question, not
|
||||
the bare baseline. Worth knowing before SPRINT16 wires this yard as a night.
|
||||
· Reverted to (0, 0) through the panel; **EXPORT is byte-identical to the shipped JSON**
|
||||
(normalized compare, 5398 = 5398) — a three-run tuning session leaves no residue.
|
||||
|
||||
**B's three bets, judged:**
|
||||
· **The tick matters more than raw speed — CONFIRMED, and it's better than the bet.** The
|
||||
phase+flight+line tick ("sweeping — flight 43/81 · 61.2 s", "flying the garden — line
|
||||
11/13 · t1,p1,s1_f1,s1_f2") isn't just proof of life: **the quads-in-band count is the
|
||||
first design number, and it arrives at flight 1.** Not-knowing-if-it-died is dead.
|
||||
· **Responsiveness — CONFIRMED with receipts.** Mid-run at flight 19/81 I scrubbed the storm
|
||||
to 60 s and the venturi readout recomputed live (15.5 m/s · align 31%); mid-run at ~flight
|
||||
11/83 I fired JUMP TO WORST SECOND and the scan returned in **4.4 ms**. The page authors
|
||||
while it scores. Wall times on this machine (running the whole sprint's agents): 231.9 /
|
||||
122.4 / 241.3 s — still batch-SIZED, no longer batch-SHAPED, and the honest unit of
|
||||
authoring cost is now "start a score, keep working" rather than "watch a dead page."
|
||||
Tick cadence is right; leave both knobs where they are.
|
||||
· **The declined cull — RIGHT CALL.** The clean/marginal counts and the flag table carried
|
||||
half this verdict's design information; a coarse-rank cull would have changed exactly the
|
||||
numbers I tuned against.
|
||||
|
||||
**GATE 2.2, the picker — the trap is unrepresentable, verified both directions on the real
|
||||
page:** both selects open on southerly@40 (C's converged default); WIND's select moved →
|
||||
SCORE's followed + store agreed; SCORE's select back → WIND followed. My
|
||||
scored-the-wildnight-while-viewing-the-southerly cannot happen; there is nothing left to
|
||||
hold two opinions.
|
||||
|
||||
**C's JUMP TO WORST SECOND — EARNS ITS BUTTON, twice over.** One click: **27.84 m/s @ 45.5 s
|
||||
at (-4.5, -6.4)** — the house-shed gap, found instantly, deterministic across two invocations,
|
||||
and it wrote the shared second through the store. In S14 I found this by scrubbing and
|
||||
watching. And the receipt it didn't promise: **in the gate-1.4 replay below, h3 let go
|
||||
between simT 47 and the change's hail — the scanner's second is when the yard actually
|
||||
screamed in play.** The readout beats the FIELD card's own fastest (27.71 coarse grid vs
|
||||
27.84 exact throat probe), which is C's probe design doing exactly what their entry said.
|
||||
|
||||
**A's gate-3 edges, exercised in passing:** Enter committed my bed moves on the SELECTED
|
||||
panel — typed -4, Enter, no blur, site data moved (measured after ruling out my own
|
||||
harness's fake Enter, which arrives as key:"" — same tooling-artifact family as my S14
|
||||
wheel-scroll false alarm, filed here so the next agent doesn't file it as A's bug).
|
||||
|
||||
**VERDICT: gate 2.3 CLOSED — the loop changed how I work, and this entry's bed study is the
|
||||
proof it couldn't have produced in S14.** What I'd still want, in order: (a) B's declined-cull
|
||||
stance made canon on the card (a sentence saying counts are census, not sample, so nobody
|
||||
"optimises" them away); (b) the separation-block copy affordance B parked, once A writes the
|
||||
thresholds-policy sentence. Neither blocks anything.
|
||||
[D] 2026-07-20 — 🌅 **GATE 1.4 CLOSED — I PLAYED THE HONEST LINE ON THE SCRATCH MERGE AND NOTHING
|
||||
IS DIFFERENT, to the decimal. Two harnesses said zero; the player's hands agree, and that
|
||||
sentence is the receipt the gate asked for.**
|
||||
|
||||
**The run:** night 2 pointed at the swing lawn exactly as my S14 play scratch did (week.js
|
||||
NIGHTS[1] edit + a boot page using main.js's own `boot({site, bank, splash:false})` door —
|
||||
both scratch-only, reverted before this entry landed, lane/d ships neither). Job sheet read
|
||||
JOB 1042 · the Delaneys · NIGHT 2 OF 5 · SOUTHERLY BUSTER · $122 the night done right — the
|
||||
S14 letterhead verbatim. Rigged the cheapest honest line through the real prep UI, clicks on
|
||||
rings, **h3,t2,t2b,p1, $15 shackles across, $60 spent of $80** — the S14 "$60 at the same
|
||||
money" flight, re-flown with hands.
|
||||
· **p1 committed at eff 3,200 = shackle 3,200 × ratingHint 1.00** — the gate-1.1 ruling
|
||||
alive in the real commit chain on a tree carrying E's re-bake. A leaked 0.90 would have
|
||||
read 2,880 in the panel; it read 3,200.
|
||||
· **The shackle at h3 let go** mid-southerly (broken by simT 47.4, the change's hail window,
|
||||
the second C's scanner names) — the EXACT corner my S14 entry said this line loses at $60.
|
||||
· **Garden finished 85.18 → invoice "garden 85%"** against S14's played **85.2 FULL**. Same
|
||||
number, same corner, same bill: **the gutter −$90**, clean-site struck, gear recovered
|
||||
+$23, **amount due −$32, bank $80 → $48**, "THE GARDEN MADE IT. The shackle at H3 let go
|
||||
and the rest carried it."
|
||||
· The editor-side pin agrees: my baseline gate-2.3 card reproduced the S14 site_03 card to
|
||||
the digit on the SAME scratch — which is the **third, post-E-merge reading C offered the
|
||||
integrator**, from the third harness (the authoring path), and it also read zero. E's
|
||||
world.js hunks were NOT applied (integrator's patch, as filed); under the ruling `?? 1`
|
||||
and `factoryExtras` say the same word, and the play just measured that they do.
|
||||
· Selftest on the scratch before judging: **418/0/0** — the exact per-lane sum
|
||||
(410 + B 3 + C 3 + E 2). Scratch branch deleted after this entry; lane/d carries THREADS
|
||||
only.
|
||||
|
||||
**Harness disclosure, per my own S14 standard:** every rig action, hardware cycle, commit
|
||||
(Enter) and the score runs went through the real UI. Three tool-driven exceptions, declared:
|
||||
camera orbit via `cameraRig.yaw` (the RMB-drag equivalent; my pane can't right-drag), one
|
||||
card scrolled into view before its real click, and — the one with teeth — **the storm was
|
||||
advanced with the game's own exposed fixed-dt `step(FIXED_DT)` after measuring the pane's
|
||||
rAF throttle at 0.6 SIM-SECONDS PER 30 WALL-SECONDS** (occluded-surface throttling, the
|
||||
same disease B's MessageChannel note documents; the game loop is rAF-driven by design and
|
||||
honest about it). Fixed-dt is the sim's native gait — same chain, same determinism, and the
|
||||
85.18 landing on S14's 85.2 is the cross-check that it measured the same game.
|
||||
|
||||
**🔩 E's pergola_01 (0.65, ~$120), the playtester's sentence gate 6 asked for:** yes, build
|
||||
it — authoring site_03 the ladder jumped straight from 0.45-on-grass to 1.00-in-concrete
|
||||
with nothing between, and tonight's replay shows exactly where the missing rung bites: the
|
||||
honest line's weakest slot is a 0.35 fascia that breaks at affordable money, so a 0.65
|
||||
bolted-to-deck option would turn "fascia or nothing" into a real spend-vs-risk decision —
|
||||
the number argues its own case and I'll build against it gladly when SPRINT16 wires the
|
||||
swing lawn.
|
||||
|
||||
[I] 2026-07-19 — **SPRINT 15 INTEGRATION (main).** All four lanes merged; E's world.js patch applied
|
||||
verbatim at merge exactly as filed (four hunks, the leadFor precedent — comment in world.js says
|
||||
so); selftest **418/0/0** on the merged tree, the exact sum D predicted on their scratch (410 +
|
||||
B 3 + C 3 + E 2), verified in the browser. Game boots clean; posts adopt at ratingHint 1.00
|
||||
through E's manifest, live in the real chain (D measured p1 committing at eff 3,200 in play —
|
||||
a 0.90 world would have read 2,880).
|
||||
|
||||
**The sprint closed every gate it opened:**
|
||||
· Gate 1 — ruled 1.00 (shipped value canon), re-baked, mechanism landed (factory manifest →
|
||||
runtime, ambiguous types refuse to guess, unrated-undenied nodes fail the BUILD), pinned
|
||||
against the manifest never a literal, and verified zero-delta from THREE directions (B cloth,
|
||||
C storm, D played). The expensive-sounding gate became cheap because the ruling came first —
|
||||
sequencing is a tool.
|
||||
· Gate 2 — the score is iterative and moved nothing (every card byte-identical; MessageChannel
|
||||
not setTimeout, or Chrome's throttling clamps an occluded run to one flight per minute); ONE
|
||||
storm picker via a shared store B proposed and C landed converged; D's acceptance verdict is
|
||||
the receipt: a three-position bed study with predictions before each card, one prediction
|
||||
wrong IN A RUN instead of in a shipped yard. C's worst-second button predicted the second h3
|
||||
actually broke in play. "Batch, not iterative" is dead.
|
||||
· Gate 3 — landed last integration (honest edges), stands.
|
||||
· Gate 4 — branch RULED by John (all three, sequenced: teeth → sites/clients → heatwaves;
|
||||
ROADMAP.md is the growth canon). The play half stands open as ever.
|
||||
|
||||
Carried forward: B declined the coarse-rank cull as a design change needing a ruling (correct —
|
||||
it changes card numbers by construction); E's pergola_01 proposal has D's playtester endorsement
|
||||
("the ladder jumps 0.45-on-grass straight to 1.00-in-concrete; a 0.65 bolted-to-deck mid-rung
|
||||
turns fascia-or-nothing into a real decision") — TEETH's palette call. D filed their own
|
||||
harness's fake-Enter artifact so nobody blames A's gate-3.1 work.
|
||||
|
||||
**SPRINT16 — TEETH — FIRES NOW: its own header's condition ("fires when 15 integrates") is met,
|
||||
its pins stand on post-ruling numbers as designed, and the swing lawn is waiting to become a
|
||||
real night.**
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"ref_capsule",
|
||||
"ref_capsule_mesh"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -36,6 +37,23 @@
|
||||
"tree_gum_01",
|
||||
"trunk"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "branch_anchor_01",
|
||||
"anchor_type": "tree",
|
||||
"rating_hint": 1.0
|
||||
},
|
||||
{
|
||||
"node": "branch_anchor_02",
|
||||
"anchor_type": "tree",
|
||||
"rating_hint": 0.88
|
||||
},
|
||||
{
|
||||
"node": "branch_anchor_03",
|
||||
"anchor_type": "tree",
|
||||
"rating_hint": 0.76
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -56,6 +74,18 @@
|
||||
"tree_gum_02",
|
||||
"trunk"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "branch_anchor_01",
|
||||
"anchor_type": "tree",
|
||||
"rating_hint": 1.0
|
||||
},
|
||||
{
|
||||
"node": "branch_anchor_02",
|
||||
"anchor_type": "tree",
|
||||
"rating_hint": 0.88
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -79,6 +109,23 @@
|
||||
"tree_jacaranda_01",
|
||||
"trunk"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "branch_anchor_01",
|
||||
"anchor_type": "tree",
|
||||
"rating_hint": 0.95
|
||||
},
|
||||
{
|
||||
"node": "branch_anchor_02",
|
||||
"anchor_type": "tree",
|
||||
"rating_hint": 0.52
|
||||
},
|
||||
{
|
||||
"node": "branch_anchor_03",
|
||||
"anchor_type": "tree",
|
||||
"rating_hint": 0.4
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -94,6 +141,7 @@
|
||||
"fence_post",
|
||||
"post"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -110,6 +158,7 @@
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -128,6 +177,7 @@
|
||||
"hinge_axis",
|
||||
"hinges"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -153,6 +203,30 @@
|
||||
"window_glow",
|
||||
"window_light_anchor"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "fascia_anchor_01",
|
||||
"anchor_type": "house",
|
||||
"rating_hint": 0.35,
|
||||
"collateral": "gutter"
|
||||
},
|
||||
{
|
||||
"node": "fascia_anchor_02",
|
||||
"anchor_type": "house",
|
||||
"rating_hint": 0.35,
|
||||
"collateral": "gutter"
|
||||
},
|
||||
{
|
||||
"node": "fascia_anchor_03",
|
||||
"anchor_type": "house",
|
||||
"rating_hint": 0.35,
|
||||
"collateral": "gutter"
|
||||
},
|
||||
{
|
||||
"node": "window_light_anchor",
|
||||
"tie_off": false
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -178,6 +252,12 @@
|
||||
"window_glow",
|
||||
"window_light_anchor"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "window_light_anchor",
|
||||
"tie_off": false
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -200,6 +280,32 @@
|
||||
"posts",
|
||||
"roof"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "beam_anchor_01",
|
||||
"anchor_type": "carport",
|
||||
"rating_hint": 0.22,
|
||||
"collateral": "carport"
|
||||
},
|
||||
{
|
||||
"node": "beam_anchor_02",
|
||||
"anchor_type": "carport",
|
||||
"rating_hint": 0.22,
|
||||
"collateral": "carport"
|
||||
},
|
||||
{
|
||||
"node": "post_anchor_01",
|
||||
"anchor_type": "carport_post",
|
||||
"rating_hint": 0.3,
|
||||
"collateral": "carport"
|
||||
},
|
||||
{
|
||||
"node": "post_anchor_02",
|
||||
"anchor_type": "carport_post",
|
||||
"rating_hint": 0.3,
|
||||
"collateral": "carport"
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -218,6 +324,7 @@
|
||||
"posts",
|
||||
"roof_down"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -237,6 +344,24 @@
|
||||
"swing_set_01",
|
||||
"swings"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "crossbar",
|
||||
"tie_off": false
|
||||
},
|
||||
{
|
||||
"node": "frame_anchor_01",
|
||||
"anchor_type": "swing_frame",
|
||||
"rating_hint": 0.45,
|
||||
"collateral": "swing_set"
|
||||
},
|
||||
{
|
||||
"node": "frame_anchor_02",
|
||||
"anchor_type": "swing_frame",
|
||||
"rating_hint": 0.45,
|
||||
"collateral": "swing_set"
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -254,6 +379,12 @@
|
||||
"swing_set_01_wrecked",
|
||||
"swings"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "crossbar",
|
||||
"tie_off": false
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -272,6 +403,12 @@
|
||||
"shed_01",
|
||||
"shell"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "door_anchor",
|
||||
"tie_off": false
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -289,6 +426,12 @@
|
||||
"table_frame",
|
||||
"table_top"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "pickup_anchor",
|
||||
"tie_off": false
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -308,6 +451,7 @@
|
||||
"plants_tattered",
|
||||
"soil"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -327,6 +471,13 @@
|
||||
"sail_post",
|
||||
"top_anchor"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "top_anchor",
|
||||
"anchor_type": "post",
|
||||
"rating_hint": 1.0
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -344,6 +495,7 @@
|
||||
"ladder_base",
|
||||
"ladder_top"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -360,6 +512,7 @@
|
||||
"pin",
|
||||
"shackle"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -376,6 +529,7 @@
|
||||
"carabiner",
|
||||
"gate"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -393,6 +547,7 @@
|
||||
"eye_b",
|
||||
"turnbuckle"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -411,6 +566,7 @@
|
||||
"rim",
|
||||
"tramp_01"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -429,6 +585,7 @@
|
||||
"wheelie_bin_01",
|
||||
"wheels"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -446,6 +603,7 @@
|
||||
"mast",
|
||||
"washing_line_01"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -461,6 +619,7 @@
|
||||
"garden_gnome_01",
|
||||
"gnome"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -479,6 +638,7 @@
|
||||
"wheel_front",
|
||||
"wheel_rear"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -497,6 +657,7 @@
|
||||
"shards",
|
||||
"stump"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -512,6 +673,7 @@
|
||||
"hail_stone_01",
|
||||
"stone"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -531,6 +693,12 @@
|
||||
"head",
|
||||
"poke_tip"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "grip_anchor",
|
||||
"tie_off": false
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
@ -548,6 +716,7 @@
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
|
||||
@ -70,6 +70,16 @@ DEBRIS_DIR = os.path.join(MODELS_DIR, "debris")
|
||||
TEXTURES_DIR = os.path.join(MODELS_DIR, "textures")
|
||||
CONTACT_SHEET = os.path.join(SCRIPT_DIR, "contact_sheet.png")
|
||||
REPORT_JSON = os.path.join(SCRIPT_DIR, "asset_report.json")
|
||||
# SPRINT15 gate 1.2 — the ratings manifest the RUNTIME reads. A baked
|
||||
# rating_hint used to reach the sim only through adoptAnchor, which only runs
|
||||
# for anchors that name a GLB `node` — every plain-JSON post defaulted to
|
||||
# `?? 1` no matter what this factory baked, and factory and sim silently
|
||||
# disagreed for fourteen sprints. This module is the missing wire: per
|
||||
# anchor_type, the factory's baked extras, generated HERE so it cannot be
|
||||
# hand-edited into a second source of truth. world.js resolves a node-less
|
||||
# JSON anchor's type through it; e.test.js pins runtime === manifest === GLB.
|
||||
RATINGS_GEN_JS = os.path.join(REPO_ROOT, "web", "world", "js",
|
||||
"anchor_ratings.gen.js")
|
||||
|
||||
# The 3D-STORE library on this box. PLAN3D §2 lists ~/Documents/3D-STORE (that
|
||||
# path is from the M1 Ultra); on the M3 Ultra the library lives here. Checked in
|
||||
@ -1684,7 +1694,16 @@ def build_sail_post(name):
|
||||
|
||||
e = add_empty("top_anchor", (0, 0, H - 0.02), size=0.2)
|
||||
e["anchor_type"] = "post"
|
||||
e["rating_hint"] = 0.9
|
||||
# SPRINT15 gate 1.1 RULING (THREADS [A] 2026-07-18): the honest post is
|
||||
# 1.00 — the shipped value becomes canon. The 0.90 baked here from Sprint 1
|
||||
# never reached the sim: every shipped post is a plain JSON entry with no
|
||||
# `node`, so adoptAnchor never ran and `?? 1` won. Every measured number in
|
||||
# the repo (separation pins, winnable-lines tables, night 5's whisker) was
|
||||
# measured at effective 1.00; 0.90 was design intent that never shipped.
|
||||
# A weaker honest post later is a DESIGN CHANGE proposed with evidence,
|
||||
# wearing its own name — not a bugfix. Do not lower this without
|
||||
# re-opening that ruling.
|
||||
e["rating_hint"] = 1.0
|
||||
above.append(e)
|
||||
for o in above:
|
||||
parent_keep_transform(o, rake)
|
||||
@ -3137,6 +3156,104 @@ def add_label(cam, text):
|
||||
return t
|
||||
|
||||
|
||||
def extract_anchor_meta(objs):
|
||||
"""The anchor-family custom props (`anchor_type`, `rating_hint`, `tie_off`,
|
||||
`collateral`), read off the RE-IMPORTED GLB rather than the build graph —
|
||||
so the report describes what actually shipped through the exporter, not
|
||||
what this script intended. Same provenance rule as the dims/nodes checks.
|
||||
"""
|
||||
out = []
|
||||
for o in objs:
|
||||
keys = set(o.keys())
|
||||
if "anchor_type" not in keys and "tie_off" not in keys:
|
||||
continue
|
||||
m = {"node": o.name}
|
||||
if "anchor_type" in keys:
|
||||
m["anchor_type"] = str(o["anchor_type"])
|
||||
if "rating_hint" in keys:
|
||||
m["rating_hint"] = round(float(o["rating_hint"]), 4)
|
||||
if "tie_off" in keys:
|
||||
m["tie_off"] = bool(o["tie_off"])
|
||||
if "collateral" in keys:
|
||||
m["collateral"] = str(o["collateral"])
|
||||
out.append(m)
|
||||
return sorted(out, key=lambda m: m["node"])
|
||||
|
||||
|
||||
def build_ratings_manifest(report):
|
||||
"""Collapse the per-asset anchor meta into per-TYPE ratings the runtime can
|
||||
resolve. The contract:
|
||||
|
||||
· `tie_off: false` nodes are DENIALS, not anchors — excluded.
|
||||
· a type where every rated node in the palette agrees on
|
||||
(rating_hint, collateral) is UNAMBIGUOUS and lands in `types` — a
|
||||
node-less JSON anchor of that type takes these extras at runtime.
|
||||
· a type whose nodes disagree (tree: 1.0/0.88/0.76 down the trunk) is
|
||||
AMBIGUOUS — listed with every variant so the runtime can refuse to
|
||||
guess. A node-less anchor of an ambiguous type is a site bug; e.test
|
||||
pins that no shipped site declares one.
|
||||
· a node that carries `anchor_type` but no `rating_hint` and no denial
|
||||
violates RULE 1 (no silent anchors) and fails the build loudly here,
|
||||
before the GLB can ship.
|
||||
"""
|
||||
per_type = {}
|
||||
for a in report:
|
||||
for m in a.get("anchors", []):
|
||||
if m.get("tie_off") is False:
|
||||
continue # explicit denial: not an anchor
|
||||
ty = m.get("anchor_type")
|
||||
if ty is None:
|
||||
continue
|
||||
if "rating_hint" not in m:
|
||||
raise RuntimeError(
|
||||
f"{a['name']}/{m['node']} carries anchor_type={ty!r} with "
|
||||
f"no rating_hint and no tie_off denial — RULE 1 (no silent "
|
||||
f"anchors). Rate it or not_a_tie_off() it.")
|
||||
key = (m["rating_hint"], m.get("collateral"))
|
||||
per_type.setdefault(ty, {}).setdefault(key, []).append(
|
||||
f"{a['name']}/{m['node']}")
|
||||
types, ambiguous = {}, {}
|
||||
for ty in sorted(per_type):
|
||||
variants = per_type[ty]
|
||||
if len(variants) == 1:
|
||||
(hint, coll), sources = next(iter(variants.items()))
|
||||
entry = {"rating_hint": hint, "sources": sorted(sources)}
|
||||
if coll is not None:
|
||||
entry["collateral"] = coll
|
||||
types[ty] = entry
|
||||
else:
|
||||
ambiguous[ty] = {"variants": [
|
||||
dict(rating_hint=h, sources=sorted(srcs),
|
||||
**({"collateral": c} if c is not None else {}))
|
||||
for (h, c), srcs in sorted(variants.items(),
|
||||
key=lambda kv: (-kv[0][0],
|
||||
kv[0][1] or ""))
|
||||
]}
|
||||
return {"types": types, "ambiguous": ambiguous}
|
||||
|
||||
|
||||
def write_ratings_module(manifest):
|
||||
body = json.dumps(manifest, indent=2, sort_keys=True)
|
||||
header = (
|
||||
"// GENERATED by tools/blender/build_yard_assets.py — do NOT hand-edit.\n"
|
||||
"// Regenerate: blender -b -P tools/blender/build_yard_assets.py\n"
|
||||
"//\n"
|
||||
"// SPRINT15 gate 1.2 — the wire between the factory and the sim.\n"
|
||||
"// adoptAnchor only carries a baked rating_hint to anchors that name a\n"
|
||||
"// GLB `node`; a plain-JSON anchor (every shipped sail post) defaulted\n"
|
||||
"// to `?? 1` regardless of the bake, and nobody could see the\n"
|
||||
"// disagreement. This module is the factory's bakes, resolvable by\n"
|
||||
"// anchor TYPE at runtime: `types` are unambiguous (every rated node\n"
|
||||
"// of that type agrees), `ambiguous` lists the disagreements so the\n"
|
||||
"// runtime can refuse to guess. ratings.js is the reader; e.test.js\n"
|
||||
"// pins runtime === this module === the GLBs, so none of the three\n"
|
||||
"// can drift from the others silently again.\n"
|
||||
)
|
||||
with open(RATINGS_GEN_JS, "w") as f:
|
||||
f.write(header + "export const FACTORY_ANCHOR_RATINGS = "
|
||||
+ "Object.freeze(" + body + ");\n")
|
||||
|
||||
|
||||
def verify_all(only=None):
|
||||
todo = [a for a in ASSETS if only is None or a["name"] in only]
|
||||
print(f"\n--- VERIFY {len(todo)} GLBs (re-import + assert + render) ---\n")
|
||||
@ -3202,8 +3319,8 @@ def verify_all(only=None):
|
||||
for p in problems:
|
||||
print(f" -> {p}")
|
||||
report.append(dict(name=name, dims=dims, tris=tris,
|
||||
nodes=sorted(names), status=status,
|
||||
problems=problems))
|
||||
nodes=sorted(names), anchors=extract_anchor_meta(objs),
|
||||
status=status, problems=problems))
|
||||
return report, failures, thumbs
|
||||
|
||||
|
||||
@ -3293,6 +3410,16 @@ def main():
|
||||
json.dump(dict(blender=bpy.app.version_string, assets=report,
|
||||
debris=debris), f, indent=2)
|
||||
print(f" report -> {REPORT_JSON}")
|
||||
# The runtime manifest is only honest when built from the WHOLE
|
||||
# palette — an --only run would collapse "every node of this type
|
||||
# agrees" to a sample of one and could flip an ambiguous type to
|
||||
# unambiguous. So partial runs leave it untouched, loudly.
|
||||
if only is None:
|
||||
write_ratings_module(build_ratings_manifest(report))
|
||||
print(f" ratings -> {RATINGS_GEN_JS}")
|
||||
else:
|
||||
print(" ratings -> NOT rewritten (--only run is a partial "
|
||||
"palette; run the full factory to regenerate)")
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
if failures:
|
||||
|
||||
@ -91,7 +91,17 @@ async function run() {
|
||||
? (sepKey === stormName ? stormDef : await loadJSON(`../../web/world/data/storms/${sepKey}.json`))
|
||||
: null;
|
||||
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef });
|
||||
// scoreSite yields between flights now (SPRINT15 gate 2.1), so this page can
|
||||
// say where it is instead of sitting on "loading…" for a minute.
|
||||
const t0 = performance.now();
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, onProgress: (p) => {
|
||||
const secs = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
el('sub').textContent = p.phase === 'sweep'
|
||||
? `${siteName} / ${stormName} — sweeping: flight ${p.done}/${p.total} quads · ${secs} s`
|
||||
: p.phase === 'fly'
|
||||
? `${siteName} / ${stormName} — flying the garden: line ${p.done}/${p.total}${p.label ? ` (${p.label})` : ''} · ${secs} s`
|
||||
: `${siteName} / ${stormName} — judging the separation target (${p.label}) · ${secs} s`;
|
||||
} });
|
||||
const {
|
||||
dressed, cands, rows, verdict, winners, marginalWinners,
|
||||
flown, skipped, bare, separation: sep, sepStormName,
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld } from '../../web/world/js/world.js';
|
||||
import { AUDIT, auditSweep } from './sweep.js';
|
||||
import { AUDIT, auditSweepAsync, yieldToEventLoop } from './sweep.js';
|
||||
import { flyGarden, flySeparation } from './gardenfly.js';
|
||||
|
||||
/** How many affordable lines get flown. Flights are seconds each; the card is
|
||||
@ -117,9 +117,19 @@ export async function buildScoringWorld(site) {
|
||||
* the keys match, else reported unjudged
|
||||
* @param {number} [o.flyCap]
|
||||
* @param {object} [o.prebuilt] a buildScoringWorld() result to reuse
|
||||
* @param {function} [o.onProgress] called ({ phase, done, total, label }) as
|
||||
* work completes — phases 'sweep' (per candidate flight),
|
||||
* 'fly' (per garden flight, bare first), 'separation'.
|
||||
* SPRINT15 gate 2.1: the 75–82 s blocking run made the
|
||||
* editor batch; the caller renders this so the page tells
|
||||
* the truth about progress instead of looking crashed.
|
||||
* @param {number} [o.yieldEvery] work units per event-loop yield (default
|
||||
* 1). MUST NOT change any number — the scorecard selftest
|
||||
* perturbs it and demands identical results.
|
||||
* @returns {Promise<object>} pure data — no DOM, no strings-as-verdicts
|
||||
*/
|
||||
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null }) {
|
||||
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null,
|
||||
onProgress = null, yieldEvery = 1 }) {
|
||||
const built = prebuilt ?? await buildScoringWorld(site);
|
||||
const { world, anchors, bed, use, dressed, dressError } = built;
|
||||
|
||||
@ -155,16 +165,29 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
|
||||
}
|
||||
|
||||
const { cands, rows, verdict, winners, marginalWinners } =
|
||||
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
|
||||
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery });
|
||||
|
||||
// Garden flights: the bare-bed control first, then every line the budget can
|
||||
// buy. The flight list is known up front so the progress tick has an honest
|
||||
// denominator (bare + capped lines + the separation pair if pinned).
|
||||
const toFly = [...winners, ...marginalWinners].slice(0, flyCap);
|
||||
const flyTotal = 1 + toFly.length;
|
||||
let flyDone = 0;
|
||||
const tickFly = async (label) => {
|
||||
flyDone += 1;
|
||||
onProgress?.({ phase: 'fly', done: flyDone, total: flyTotal, label });
|
||||
if (flyDone % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
|
||||
};
|
||||
|
||||
// The bare bed — the control every garden number is read against.
|
||||
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
|
||||
await tickFly('bare bed');
|
||||
|
||||
// Fly every line the budget can buy. Marginal lines fly too, deliberately:
|
||||
// they are the trap the margin rule exists to name, and a card that hid them
|
||||
// would be the 91.9-FULL illusion with better CSS.
|
||||
const flown = new Map();
|
||||
for (const r of [...winners, ...marginalWinners].slice(0, flyCap)) {
|
||||
for (const r of toFly) {
|
||||
// clean winners fly the CLEAN tiers (what the audit recommends buying);
|
||||
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
|
||||
// Aligned by anchorId, never input order — attach reorders picks into ring
|
||||
@ -174,6 +197,7 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
|
||||
anchors, bed, stormDef, siteDef: site, use,
|
||||
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
|
||||
}));
|
||||
await tickFly(r.ids.join(','));
|
||||
}
|
||||
const skipped = Math.max(0, winners.length + marginalWinners.length - flyCap);
|
||||
|
||||
@ -196,7 +220,12 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
|
||||
sepStormName = site.separation.stormKey;
|
||||
const sepStorm = sepStormDef ?? (sepStormName && sepStormName === stormName ? stormDef : null);
|
||||
if (sepStorm) {
|
||||
// Two flights (held + bare) on the block's own storm — announced before
|
||||
// they run, because they are the one chunk left with no tick inside it.
|
||||
onProgress?.({ phase: 'separation', done: 0, total: 1, label: sepStormName });
|
||||
await yieldToEventLoop();
|
||||
separation = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
|
||||
onProgress?.({ phase: 'separation', done: 1, total: 1, label: sepStormName });
|
||||
} else {
|
||||
// Judging a pinned target on the wrong storm is worse than not judging it.
|
||||
sepUnjudged = `pinned against ${sepStormName}, which the caller did not supply`;
|
||||
|
||||
@ -110,6 +110,7 @@ import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.j
|
||||
// with itself by construction.
|
||||
import { createWindRouter } from '../../web/world/js/main.js';
|
||||
import { buildScoringWorld } from './scorecard.js';
|
||||
import { auditSweep, auditSweepAsync } from './sweep.js';
|
||||
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
|
||||
@ -328,6 +329,74 @@ export async function buildScorecardTests() {
|
||||
+ 'Every garden number on this yard just moved — re-baseline the audit and tell A/D.');
|
||||
}]);
|
||||
|
||||
// ── SPRINT15 gate 2.1: the chunked sweep is the SAME sweep ───────────────
|
||||
// scoreSite runs auditSweepAsync now — same flights, but the candidate loop
|
||||
// yields so the editor page paints progress instead of freezing for 75 s.
|
||||
// The contract is that yielding is PURE: any yieldEvery produces numbers
|
||||
// byte-identical to the sync sweep. These asserts are the tripwire for
|
||||
// anyone who later threads state across candidates (a reused rig, a shared
|
||||
// accumulator) — the exact class of bug a chunk boundary would expose.
|
||||
//
|
||||
// Synthetic five-anchor yard (multiple candidates, so chunk boundaries land
|
||||
// MID-list), 8 s storm — cheap on purpose; the real-yard reproduction lives
|
||||
// in THREADS (site_02 + site_03 cards pinned before and after the refactor).
|
||||
const YARD5 = [
|
||||
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
|
||||
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
|
||||
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
|
||||
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
|
||||
{ id: 'a5', type: 'post', pos: { x: 0, y: 3.9, z: 4 } },
|
||||
].map((a) => ({ ...a, sway: () => a.pos }));
|
||||
const YARD5_BED = { x: 0, z: 0, w: 4, d: 4 };
|
||||
const YARD5_STORM = {
|
||||
id: 'chunk_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
|
||||
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
|
||||
};
|
||||
// Strip nothing, hide nothing: the whole result must survive comparison.
|
||||
const sweepJSON = (r) => JSON.stringify({
|
||||
cands: r.cands, rows: r.rows, winners: r.winners,
|
||||
marginalWinners: r.marginalWinners, verdict: r.verdict,
|
||||
});
|
||||
|
||||
const syncResult = auditSweep({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] });
|
||||
const ticks = [];
|
||||
const chunk1 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
|
||||
yieldEvery: 1, onProgress: (p) => ticks.push({ ...p }) });
|
||||
const chunk3 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
|
||||
yieldEvery: 3 });
|
||||
|
||||
tests.push(['gate 2.1: the yielding sweep === the sync sweep, byte for byte', () => {
|
||||
// vacuity guard first — a yard whose sweep found nothing would make the
|
||||
// equality below a comparison of two empty objects
|
||||
assert(syncResult.cands.length >= 3,
|
||||
`gate 2.1: the synthetic yard swept only ${syncResult.cands.length} candidate(s) — `
|
||||
+ 'not enough list for a chunk boundary to land mid-way; the purity assert is decoration. '
|
||||
+ 'Fix the fixture, not the assert.');
|
||||
assert(sweepJSON(chunk1) === sweepJSON(syncResult),
|
||||
'gate 2.1 FAILED: auditSweepAsync(yieldEvery 1) returned different numbers than auditSweep '
|
||||
+ 'on the same yard and storm. Yielding must be WHEN the page breathes, never WHAT gets '
|
||||
+ 'computed — some state is leaking across candidates.');
|
||||
}]);
|
||||
|
||||
tests.push(['gate 2.1: perturbing the chunk boundary moves NOTHING (yieldEvery 3 === yieldEvery 1)', () => {
|
||||
assert(sweepJSON(chunk3) === sweepJSON(chunk1),
|
||||
'gate 2.1 FAILED: changing yieldEvery (1 → 3) changed the sweep\'s numbers. The chunk '
|
||||
+ 'boundary is a paint schedule, not an input — if moving it moves a number, a candidate '
|
||||
+ 'flight is reading something a previous chunk wrote.');
|
||||
}]);
|
||||
|
||||
tests.push(['gate 2.1: progress ticks fire, count monotonically, and reach the total', () => {
|
||||
const sweepTicks = ticks.filter((p) => p.phase === 'sweep');
|
||||
assert(sweepTicks.length === syncResult.cands.length,
|
||||
`gate 2.1: expected one 'sweep' tick per candidate (${syncResult.cands.length}), got `
|
||||
+ `${sweepTicks.length} — a progress line that undercounts is the looks-wired-isn't `
|
||||
+ 'disease on the UI layer.');
|
||||
sweepTicks.forEach((p, i) => {
|
||||
assert(p.done === i + 1 && p.total === syncResult.cands.length,
|
||||
`gate 2.1: tick ${i} read done=${p.done}/total=${p.total}, expected ${i + 1}/${syncResult.cands.length}`);
|
||||
});
|
||||
}]);
|
||||
|
||||
// ── the clone is the same weather as the file ────────────────────────────
|
||||
tests.push(['gate 2.3: the export clone carries the funnel (site_02 venturi survives the round-trip)', () => {
|
||||
const v = fun.editor.site.wind?.venturi ?? [];
|
||||
|
||||
@ -93,6 +93,38 @@ export const tierFor = (peakN, ratingHint = 1) =>
|
||||
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }}
|
||||
*/
|
||||
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null }) {
|
||||
const cands = findCandidates({ anchors, bed, siteDef });
|
||||
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
|
||||
|
||||
// 2. peak corner loads, flown the way the GAME flies them. Every clause here
|
||||
// is a bug some harness shipped:
|
||||
// · windForSite — the one shared wind builder (C's helper): venturi from
|
||||
// the SITE def + tree shelters, byte-for-byte main.js's site-load
|
||||
// wiring. THREE harnesses independently mis-built site wind before it
|
||||
// existed (this tool's Sprint-11 funnel-off audit, C's bench reading
|
||||
// def.wind.venturi off the STORM def, D's first garden harness) — a
|
||||
// fourth copy of the wiring is how there's a fifth bug.
|
||||
// · `use` re-points the caller's world-wind proxy so LIVE tree-sway
|
||||
// closures sample this sweep's storm (frozen sway under-read q4 by
|
||||
// 0.22 kN on the $80 line — C's landmine 2).
|
||||
// · NO calm settle, NO resetPeaks: commit→attach→storm is one keypress
|
||||
// in the real game, so the attach transient flies under storm wind
|
||||
// and its loads count (cheap steel genuinely dies "at the settle" —
|
||||
// that's the storm's opening seconds, not a separate phase).
|
||||
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
|
||||
use?.(wind);
|
||||
|
||||
const rows = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind }));
|
||||
return judgeSweep(cands, rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the sweep, alone: every quad in the rigging band that shades the
|
||||
* bed, plus the site's pinned separation line. Extracted (SPRINT15 gate 2.1)
|
||||
* so an ASYNC driver can enumerate the work before doing it — a progress tick
|
||||
* needs a denominator. Same code auditSweep always ran, one copy.
|
||||
*/
|
||||
export function findCandidates({ anchors, bed, siteDef = null }) {
|
||||
// 1. every quad, in the rigging band, that shades the bed
|
||||
const cands = [];
|
||||
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
|
||||
@ -124,63 +156,59 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [
|
||||
} catch { /* a pin naming unriggable anchors will fail loudly in a.test; nothing to add here */ }
|
||||
}
|
||||
}
|
||||
return cands;
|
||||
}
|
||||
|
||||
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
|
||||
/**
|
||||
* Fly ONE candidate and price its corners. Extracted (SPRINT15 gate 2.1) as
|
||||
* the unit of chunked work: the async driver yields between calls to this so
|
||||
* the page can paint, and because each call builds its own rig and flies the
|
||||
* same wind at the same seconds, chunking cannot change a number — the
|
||||
* scorecard selftest asserts exactly that (chunked === sync, to the byte).
|
||||
*/
|
||||
export function priceCandidate(cnd, { anchors, stormDef, wind }) {
|
||||
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
// 2. peak corner loads, flown the way the GAME flies them. Every clause here
|
||||
// is a bug some harness shipped:
|
||||
// · windForSite — the one shared wind builder (C's helper): venturi from
|
||||
// the SITE def + tree shelters, byte-for-byte main.js's site-load
|
||||
// wiring. THREE harnesses independently mis-built site wind before it
|
||||
// existed (this tool's Sprint-11 funnel-off audit, C's bench reading
|
||||
// def.wind.venturi off the STORM def, D's first garden harness) — a
|
||||
// fourth copy of the wiring is how there's a fifth bug.
|
||||
// · `use` re-points the caller's world-wind proxy so LIVE tree-sway
|
||||
// closures sample this sweep's storm (frozen sway under-read q4 by
|
||||
// 0.22 kN on the $80 line — C's landmine 2).
|
||||
// · NO calm settle, NO resetPeaks: commit→attach→storm is one keypress
|
||||
// in the real game, so the attach transient flies under storm wind
|
||||
// and its loads count (cheap steel genuinely dies "at the settle" —
|
||||
// that's the storm's opening seconds, not a separate phase).
|
||||
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
|
||||
use?.(wind);
|
||||
// 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.
|
||||
//
|
||||
// TWO prices per corner (C's margin rule):
|
||||
// `tier` cheapest hardware that HOLDS the measured peak — what the
|
||||
// old audit sold, and what a player gambling the knife edge
|
||||
// actually buys;
|
||||
// `cleanTier` cheapest hardware that holds it WITH ≥ MARGIN headroom —
|
||||
// the price the margin rule trusts (demand ÷ (1 − MARGIN)).
|
||||
// A corner whose `tier` sits inside the margin band is `marginal`: it
|
||||
// holds on this bench and "breaks in the game" (the residual's working
|
||||
// rule). The row's clean price is what closing that gap costs.
|
||||
const tiers = rig.corners.map((c) => {
|
||||
const hint = c.anchor.ratingHint ?? 1;
|
||||
const tier = tierFor(c.peakLoad, hint);
|
||||
return { id: c.anchorId, peak: c.peakLoad, hint, tier,
|
||||
cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint),
|
||||
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
|
||||
});
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
const cleanHw = tiers.every((c) => c.cleanTier)
|
||||
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
|
||||
return { ...cnd, tiers, unholdable, marginal, hw, cleanHw,
|
||||
total: hw + AUDIT.SPARE_COST,
|
||||
affordable: !unholdable.length && hw <= START_BUDGET,
|
||||
clean: cleanHw != null && cleanHw <= START_BUDGET };
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const cnd of cands) {
|
||||
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
// 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.
|
||||
//
|
||||
// TWO prices per corner (C's margin rule):
|
||||
// `tier` cheapest hardware that HOLDS the measured peak — what the
|
||||
// old audit sold, and what a player gambling the knife edge
|
||||
// actually buys;
|
||||
// `cleanTier` cheapest hardware that holds it WITH ≥ MARGIN headroom —
|
||||
// the price the margin rule trusts (demand ÷ (1 − MARGIN)).
|
||||
// A corner whose `tier` sits inside the margin band is `marginal`: it
|
||||
// holds on this bench and "breaks in the game" (the residual's working
|
||||
// rule). The row's clean price is what closing that gap costs.
|
||||
const tiers = rig.corners.map((c) => {
|
||||
const hint = c.anchor.ratingHint ?? 1;
|
||||
const tier = tierFor(c.peakLoad, hint);
|
||||
return { id: c.anchorId, peak: c.peakLoad, hint, tier,
|
||||
cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint),
|
||||
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
|
||||
});
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
const cleanHw = tiers.every((c) => c.cleanTier)
|
||||
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
|
||||
rows.push({ ...cnd, tiers, unholdable, marginal, hw, cleanHw,
|
||||
total: hw + AUDIT.SPARE_COST,
|
||||
affordable: !unholdable.length && hw <= START_BUDGET,
|
||||
clean: cleanHw != null && cleanHw <= START_BUDGET });
|
||||
}
|
||||
/**
|
||||
* Sort the priced rows and hand down the verdict — the tail of auditSweep,
|
||||
* shared with the async driver. Sorts `rows` in place, exactly as auditSweep
|
||||
* always has (Array.prototype.sort is stable, so chunked and sync drivers
|
||||
* order ties identically).
|
||||
*/
|
||||
export function judgeSweep(cands, rows) {
|
||||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||||
|
||||
// winners are lines the budget can buy at the CLEAN price (≥15% headroom on
|
||||
@ -198,3 +226,61 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [
|
||||
: { ok: false, code: 'unaffordable', best: rows[0] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* auditSweep with a heartbeat — same candidates, same flights, same verdict,
|
||||
* but the candidate loop yields to the event loop every `yieldEvery` flights
|
||||
* so a page driving it repaints instead of freezing. [SPRINT15 gate 2.1]
|
||||
*
|
||||
* D's verdict on the 75–82 s blocking score: "made the editor batch, not
|
||||
* iterative — I scored once and shipped that run rather than tuning", and the
|
||||
* worst part "is not the wait, it's not knowing whether it died". The fix is
|
||||
* NOT a faster sim (the numbers must not move) — it is telling the truth about
|
||||
* progress while the same work happens.
|
||||
*
|
||||
* PURITY IS THE CONTRACT: every number this returns must equal auditSweep's
|
||||
* to the byte, for any yieldEvery ≥ 1. That holds by construction — each
|
||||
* priceCandidate builds its own rig and flies a wind that is a pure function
|
||||
* of (p, t) — and by assert (scorecard.selftest.js runs sync vs yieldEvery 1
|
||||
* vs yieldEvery 3 on the same yard and demands identical JSON). If you add
|
||||
* state that survives across candidates, that assert is the tripwire.
|
||||
*
|
||||
* @param {function} [o.onProgress] called ({ phase:'sweep', done, total })
|
||||
* after every flight — cheap, DOM-free, caller renders it
|
||||
* @param {number} [o.yieldEvery] flights per yield (macrotask); 1 = every flight
|
||||
*/
|
||||
export async function auditSweepAsync({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null,
|
||||
onProgress = null, yieldEvery = 1 }) {
|
||||
const cands = findCandidates({ anchors, bed, siteDef });
|
||||
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
|
||||
|
||||
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
|
||||
use?.(wind);
|
||||
|
||||
const rows = [];
|
||||
for (let i = 0; i < cands.length; i++) {
|
||||
rows.push(priceCandidate(cands[i], { anchors, stormDef, wind }));
|
||||
onProgress?.({ phase: 'sweep', done: i + 1, total: cands.length });
|
||||
if ((i + 1) % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
|
||||
}
|
||||
return judgeSweep(cands, rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* One macrotask boundary — long enough for the browser to paint, nothing else.
|
||||
*
|
||||
* MessageChannel, NOT setTimeout(0), and it is load-bearing: Chrome clamps
|
||||
* chained timers in hidden/occluded tabs (1 s, then 1/minute under intensive
|
||||
* throttling), which turned a 72 s score into one flight per MINUTE the first
|
||||
* time this ran in an occluded pane — measured, 12→13 of 66 across 60 s.
|
||||
* Message tasks are not timer-throttled, and the selftest header's own rule
|
||||
* ("stays honest in a background tab") applies to the score as much as the
|
||||
* suite. Falls back to setTimeout where MessageChannel is missing (node).
|
||||
*/
|
||||
export const yieldToEventLoop = typeof MessageChannel === 'undefined'
|
||||
? () => new Promise((r) => setTimeout(r, 0))
|
||||
: () => new Promise((r) => {
|
||||
const ch = new MessageChannel();
|
||||
ch.port1.onmessage = () => { ch.port1.close(); r(); };
|
||||
ch.port2.postMessage(0);
|
||||
});
|
||||
|
||||
100
web/world/js/anchor_ratings.gen.js
Normal file
100
web/world/js/anchor_ratings.gen.js
Normal file
@ -0,0 +1,100 @@
|
||||
// GENERATED by tools/blender/build_yard_assets.py — do NOT hand-edit.
|
||||
// Regenerate: blender -b -P tools/blender/build_yard_assets.py
|
||||
//
|
||||
// SPRINT15 gate 1.2 — the wire between the factory and the sim.
|
||||
// adoptAnchor only carries a baked rating_hint to anchors that name a
|
||||
// GLB `node`; a plain-JSON anchor (every shipped sail post) defaulted
|
||||
// to `?? 1` regardless of the bake, and nobody could see the
|
||||
// disagreement. This module is the factory's bakes, resolvable by
|
||||
// anchor TYPE at runtime: `types` are unambiguous (every rated node
|
||||
// of that type agrees), `ambiguous` lists the disagreements so the
|
||||
// runtime can refuse to guess. ratings.js is the reader; e.test.js
|
||||
// pins runtime === this module === the GLBs, so none of the three
|
||||
// can drift from the others silently again.
|
||||
export const FACTORY_ANCHOR_RATINGS = Object.freeze({
|
||||
"ambiguous": {
|
||||
"tree": {
|
||||
"variants": [
|
||||
{
|
||||
"rating_hint": 1.0,
|
||||
"sources": [
|
||||
"tree_gum_01/branch_anchor_01",
|
||||
"tree_gum_02/branch_anchor_01"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rating_hint": 0.95,
|
||||
"sources": [
|
||||
"tree_jacaranda_01/branch_anchor_01"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rating_hint": 0.88,
|
||||
"sources": [
|
||||
"tree_gum_01/branch_anchor_02",
|
||||
"tree_gum_02/branch_anchor_02"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rating_hint": 0.76,
|
||||
"sources": [
|
||||
"tree_gum_01/branch_anchor_03"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rating_hint": 0.52,
|
||||
"sources": [
|
||||
"tree_jacaranda_01/branch_anchor_02"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rating_hint": 0.4,
|
||||
"sources": [
|
||||
"tree_jacaranda_01/branch_anchor_03"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"types": {
|
||||
"carport": {
|
||||
"collateral": "carport",
|
||||
"rating_hint": 0.22,
|
||||
"sources": [
|
||||
"carport_01/beam_anchor_01",
|
||||
"carport_01/beam_anchor_02"
|
||||
]
|
||||
},
|
||||
"carport_post": {
|
||||
"collateral": "carport",
|
||||
"rating_hint": 0.3,
|
||||
"sources": [
|
||||
"carport_01/post_anchor_01",
|
||||
"carport_01/post_anchor_02"
|
||||
]
|
||||
},
|
||||
"house": {
|
||||
"collateral": "gutter",
|
||||
"rating_hint": 0.35,
|
||||
"sources": [
|
||||
"house_yardside/fascia_anchor_01",
|
||||
"house_yardside/fascia_anchor_02",
|
||||
"house_yardside/fascia_anchor_03"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"rating_hint": 1.0,
|
||||
"sources": [
|
||||
"sail_post/top_anchor"
|
||||
]
|
||||
},
|
||||
"swing_frame": {
|
||||
"collateral": "swing_set",
|
||||
"rating_hint": 0.45,
|
||||
"sources": [
|
||||
"swing_set_01/frame_anchor_01",
|
||||
"swing_set_01/frame_anchor_02"
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -40,17 +40,12 @@
|
||||
import { loadStorm } from './weather.js';
|
||||
import { START_BUDGET } from './contracts.js';
|
||||
import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags, collateralExposure } from '../../../tools/site_audit/scorecard.js';
|
||||
|
||||
/** The storms a yard gets judged against. `storm_02_wildnight` leads because
|
||||
* it is the storm every Sprint-13 number was argued over — score against the
|
||||
* one people remember. */
|
||||
const STORMS = [
|
||||
'storm_02_wildnight',
|
||||
'storm_01_gentle',
|
||||
'storm_02b_icenight',
|
||||
'storm_03_southerly',
|
||||
'storm_03b_earlybuster',
|
||||
];
|
||||
// SPRINT15 gate 2.2: the storm list and the SELECTION live in editor_storm.js
|
||||
// now — C's converged store (storm + second; this panel ignores the second on
|
||||
// purpose — a score flies the WHOLE storm). D scored the wildnight while
|
||||
// viewing the southerly because this file and editor.wind.js each defaulted a
|
||||
// private stormKey; neither carries one any more.
|
||||
import { stormSel, STORM_KEYS } from './editor_storm.js';
|
||||
|
||||
const el = (tag, cls, text) => {
|
||||
const n = document.createElement(tag);
|
||||
@ -93,7 +88,14 @@ function boot() {
|
||||
const rowStorm = el('div', 'ed-row');
|
||||
rowStorm.append(el('span', 'ed-label', 'storm'));
|
||||
const sel = el('select', 'ed-sel');
|
||||
for (const s of STORMS) sel.append(new Option(s.replace(/^storm_/, ''), s));
|
||||
for (const s of STORM_KEYS) sel.append(new Option(s.replace(/^storm_/, ''), s));
|
||||
// One storm selection for the whole page (gate 2.2): this select is a VIEW of
|
||||
// C's store, not an owner — change it here and the wind panel follows, change
|
||||
// it there and this follows. Scoring one storm while reading another's wind
|
||||
// field is how D got quietly wrong numbers.
|
||||
sel.value = stormSel().storm;
|
||||
sel.addEventListener('change', () => stormSel().setStorm(sel.value, 'score'));
|
||||
stormSel().onChange(({ storm }) => { sel.value = storm; });
|
||||
rowStorm.append(sel);
|
||||
body.append(rowStorm);
|
||||
|
||||
@ -108,7 +110,8 @@ function boot() {
|
||||
const note = el('div', 'ed-note',
|
||||
'Scores a fresh dressed world built from the EXPORT clone — not the editor\'s '
|
||||
+ 'view, whose wind is a calm stub. Every number flies windForSite() and the real '
|
||||
+ 'commit→attach chain. Takes a few seconds; that is the point.');
|
||||
+ 'commit→attach chain. Slow is honest — every candidate flies the full storm — '
|
||||
+ 'but the page stays live and the progress line says where it is.');
|
||||
body.append(note);
|
||||
|
||||
// A score describes the yard as it was WHEN SCORED. The moment anything
|
||||
@ -129,14 +132,30 @@ function boot() {
|
||||
stale = false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'scoring…';
|
||||
out.replaceChildren(el('div', 'ed-note', 'building a dressed world, sweeping every quad, flying the winners…'));
|
||||
// yield so the button repaints before we block the thread for seconds
|
||||
// The progress line (gate 2.1). D's verdict on the old blocking run: the
|
||||
// worst part "is not the wait, it's not knowing whether it died". scoreSite
|
||||
// yields between flights now, so every update here actually PAINTS.
|
||||
const prog = el('div', 'ed-note', 'building a dressed world…');
|
||||
out.replaceChildren(prog);
|
||||
// yield so the button repaints before the first heavy chunk
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const t0 = performance.now();
|
||||
const secs = () => `${((performance.now() - t0) / 1000).toFixed(1)} s`;
|
||||
const onProgress = (p) => {
|
||||
if (p.phase === 'sweep') {
|
||||
prog.textContent = `sweeping — flight ${p.done}/${p.total} quads · ${secs()}`;
|
||||
} else if (p.phase === 'fly') {
|
||||
prog.textContent = `flying the garden — line ${p.done}/${p.total}${p.label ? ` · ${p.label}` : ''} · ${secs()}`;
|
||||
} else if (p.phase === 'separation') {
|
||||
prog.textContent = p.done
|
||||
? `separation target judged · ${secs()}`
|
||||
: `judging the pinned separation target (${p.label}) · ${secs()}`;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const site = EDITOR.siteClone();
|
||||
const stormName = sel.value;
|
||||
const stormName = stormSel().storm;
|
||||
const stormDef = await loadStorm(stormName);
|
||||
|
||||
// The separation block names its OWN storm — judging A's pinned target on
|
||||
@ -146,7 +165,7 @@ function boot() {
|
||||
const sepStormDef = sepKey ? (sepKey === stormName ? stormDef : await loadStorm(sepKey)) : null;
|
||||
|
||||
const built = await buildScoringWorld(site);
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built });
|
||||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built, onProgress });
|
||||
render(out, score, performance.now() - t0);
|
||||
globalThis.__editorScore = score; // for the selftest + console spelunking
|
||||
} catch (err) {
|
||||
|
||||
68
web/world/js/ratings.js
Normal file
68
web/world/js/ratings.js
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* SHADES / HARD YARDS — Lane E owns this file.
|
||||
*
|
||||
* The runtime end of the factory's anchor ratings (SPRINT15 gate 1.2).
|
||||
*
|
||||
* THE BUG THIS CLOSES: `adoptAnchor` is the only place a baked `rating_hint`
|
||||
* ever reached an anchor, and it only runs for anchors that name a GLB `node`.
|
||||
* Every shipped sail post is a plain JSON entry with no `node`, so the
|
||||
* factory's bake never arrived and `?? 1` won — the factory said 0.90, the sim
|
||||
* played 1.00, and nothing anywhere could see the disagreement. Gate 1.1 ruled
|
||||
* the shipped 1.00 canon (every measured number stands on it), and this module
|
||||
* is the mechanism half of that ruling: the same silent divergence is now
|
||||
* impossible for the whole extras family (`rating_hint`, `collateral`, and by
|
||||
* exclusion `tie_off`) on ANY node-less JSON anchor whose type the factory
|
||||
* rates.
|
||||
*
|
||||
* HOW: the factory emits `anchor_ratings.gen.js` from the re-imported GLBs —
|
||||
* per anchor TYPE, the baked extras, but only where every rated node of that
|
||||
* type in the palette agrees (`types`). Types whose nodes disagree (tree:
|
||||
* 1.0 → 0.4 down the trunk) are listed under `ambiguous`, and this module
|
||||
* REFUSES to guess for them — a node-less anchor of an ambiguous type keeps
|
||||
* the default hint and warns loudly, and e.test.js pins that no shipped site
|
||||
* declares one. Three-way agreement is pinned in e.test.js:
|
||||
* GLB === manifest === runtime, so a re-bake that skips regeneration, a
|
||||
* hand-edit to the gen file, or a future adoptAnchor-shaped gap all go red.
|
||||
*
|
||||
* WHO CALLS IT: world.js, at the JSON-anchor creation sites, as
|
||||
* `Object.assign(makeStaticAnchor(...), ..., a.node ? null : factoryExtras(type))`
|
||||
* — node-carrying anchors keep their graybox default and are corrected by
|
||||
* adoptAnchor at dress time, exactly as before; node-less ones take the
|
||||
* factory's word at construction, because dress will never reach them.
|
||||
*/
|
||||
|
||||
import { FACTORY_ANCHOR_RATINGS } from './anchor_ratings.gen.js';
|
||||
|
||||
/** Warn once per type, not once per anchor — a site with four posts of a
|
||||
* future ambiguous type should not print four identical warnings. */
|
||||
const warned = new Set();
|
||||
|
||||
/**
|
||||
* The factory's baked extras for an anchor TYPE, in runtime (camelCase) field
|
||||
* names, ready to `Object.assign` onto an Anchor.
|
||||
*
|
||||
* @param {string} type An ANCHOR_TYPE string (contracts.js).
|
||||
* @returns {{ratingHint: number, collateral?: string}|null}
|
||||
* The extras when the factory rates this type unambiguously; null when it
|
||||
* doesn't rate it at all (caller keeps its default) or rates it ambiguously
|
||||
* (caller keeps its default AND this is almost certainly a site bug — a
|
||||
* node-less anchor of an ambiguous type has no honest rating; declare a
|
||||
* `node` instead. e.test.js refuses it in shipped sites).
|
||||
*/
|
||||
export function factoryExtras(type) {
|
||||
const t = FACTORY_ANCHOR_RATINGS.types[type];
|
||||
if (t) {
|
||||
const out = { ratingHint: t.rating_hint };
|
||||
if (t.collateral !== undefined) out.collateral = t.collateral;
|
||||
return out;
|
||||
}
|
||||
if (FACTORY_ANCHOR_RATINGS.ambiguous[type] && !warned.has(type)) {
|
||||
warned.add(type);
|
||||
console.warn(
|
||||
`[ratings] anchor type "${type}" is factory-AMBIGUOUS (its GLB nodes carry `
|
||||
+ `different rating_hints) — a node-less "${type}" anchor falls back to the `
|
||||
+ `default hint, which is the exact silent-divergence bug gate 1.2 buried. `
|
||||
+ `Declare a \`node\` on the anchor so adoptAnchor can carry the real bake.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -21,6 +21,8 @@
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert } from '../testkit.js';
|
||||
import { ANCHOR_TYPE } from '../contracts.js';
|
||||
import { createWorld, loadSite } from '../world.js';
|
||||
import { FACTORY_ANCHOR_RATINGS } from '../anchor_ratings.gen.js';
|
||||
|
||||
// GLTFLoader is imported DYNAMICALLY, below, and that is deliberate.
|
||||
//
|
||||
@ -799,4 +801,105 @@ export default async function run(t) {
|
||||
assert(g.scene.getObjectByName(state), `${state} missing — Lane A toggles these by name`);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT15 gate 1.2 — RULE 4, the third pin of the S14 family: no silent
|
||||
// anchors, no unpriced collateral, and now NO BAKED HINT THAT NEVER ARRIVES.
|
||||
//
|
||||
// The bug this buries: `adoptAnchor` was the only wire from a baked
|
||||
// `rating_hint` to a live anchor, and it only runs for anchors that name a
|
||||
// GLB `node`. Every shipped sail post is a plain JSON entry with no node, so
|
||||
// the factory said 0.90, `?? 1` said 1.00, and the sim won BY ACCIDENT for
|
||||
// fourteen sprints. Gate 1.1 ruled the shipped 1.00 canon — which means the
|
||||
// values now agree BY CONSTRUCTION, so these pins compare runtime against
|
||||
// the generated MANIFEST (anchor_ratings.gen.js), never against a literal:
|
||||
// a pin that hard-codes 1.0 would have been green through the entire bug.
|
||||
//
|
||||
// Two directions, so nothing can drift silently:
|
||||
// RULE 4a manifest === GLBs (a re-bake that skips regeneration → red)
|
||||
// RULE 4b runtime === manifest (the adoptAnchor-shaped gap itself → red)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
t.test('RULE 4a — the ratings manifest matches the shipped GLBs, both directions', () => {
|
||||
const types = FACTORY_ANCHOR_RATINGS.types, ambig = FACTORY_ANCHOR_RATINGS.ambiguous;
|
||||
assert(Object.keys(types).length > 0,
|
||||
'vacuous: the manifest rates nothing — regenerate anchor_ratings.gen.js');
|
||||
const nodeOf = (src) => {
|
||||
const [asset, node] = src.split('/');
|
||||
return loaded.get(asset)?.scene.getObjectByName(node);
|
||||
};
|
||||
// manifest → GLB: every source it cites exists and carries the same bake.
|
||||
for (const [ty, entry] of Object.entries(types)) {
|
||||
assert(entry.sources.length > 0, `manifest type "${ty}" cites no sources`);
|
||||
for (const src of entry.sources) {
|
||||
const o = nodeOf(src);
|
||||
assert(o, `manifest type "${ty}" cites ${src}, which is not in the palette — stale manifest, regenerate`);
|
||||
assert(o.userData?.rating_hint === entry.rating_hint,
|
||||
`${src} bakes rating_hint ${o.userData?.rating_hint} but the manifest says ${entry.rating_hint} — ` +
|
||||
'the factory and the manifest disagree; regenerate, do not hand-edit');
|
||||
assert((o.userData?.collateral ?? null) === (entry.collateral ?? null),
|
||||
`${src} bakes collateral ${JSON.stringify(o.userData?.collateral)} but the manifest says ` +
|
||||
`${JSON.stringify(entry.collateral)} — regenerate`);
|
||||
}
|
||||
}
|
||||
// GLB → manifest: every rated, non-denying anchor node is accounted for.
|
||||
for (const [asset, o] of allNodes()) {
|
||||
const ty = o.userData?.anchor_type;
|
||||
if (ty === undefined || o.userData?.tie_off === false) continue;
|
||||
assert(types[ty] || ambig[ty],
|
||||
`${asset}/${o.name} is typed "${ty}", which the manifest does not know — regenerate`);
|
||||
if (types[ty]) {
|
||||
assert(o.userData?.rating_hint === types[ty].rating_hint,
|
||||
`${asset}/${o.name} bakes ${o.userData?.rating_hint} but manifest type "${ty}" says ` +
|
||||
`${types[ty].rating_hint} — either regenerate, or this type just became ambiguous ` +
|
||||
'and the manifest must say so');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// RULE 4b needs live worlds; Suite.test() refuses async fns, so the awaits
|
||||
// happen out here and the assert stays synchronous.
|
||||
const siteWorlds = [];
|
||||
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
|
||||
const site = await loadSite(name);
|
||||
siteWorlds.push({ name, site, world: createWorld(new THREE.Scene(), { site }) });
|
||||
}
|
||||
|
||||
t.test('RULE 4b — a node-less JSON anchor of a factory-rated type carries the factory rating at runtime', () => {
|
||||
const types = FACTORY_ANCHOR_RATINGS.types, ambig = FACTORY_ANCHOR_RATINGS.ambiguous;
|
||||
let checked = 0;
|
||||
for (const { name, site, world } of siteWorlds) {
|
||||
// Every anchor a site DECLARES in JSON, with the same type defaults
|
||||
// world.js applies at each creation site.
|
||||
const declared = [
|
||||
...(site.posts ?? []).map((p) => ({ ...p, type: p.type ?? 'post' })),
|
||||
...(site.house?.anchors ?? []).map((a) => ({ ...a, type: a.type ?? 'house' })),
|
||||
...(site.trees ?? []).flatMap((tr) => (tr.anchors ?? []).map((a) => ({ ...a, type: a.type ?? 'tree' }))),
|
||||
...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => ({ ...a, type: a.type ?? 'post' }))),
|
||||
].filter((a) => !a.node); // node-carrying anchors are adoptAnchor's job at dress
|
||||
for (const d of declared) {
|
||||
assert(!ambig[d.type],
|
||||
`${name}/${d.id}: a node-less anchor of AMBIGUOUS type "${d.type}" has no honest ` +
|
||||
'rating — the runtime refuses to guess (ratings.js); declare a `node` instead');
|
||||
const entry = types[d.type];
|
||||
assert(entry,
|
||||
`${name}/${d.id}: type "${d.type}" is not factory-rated at all — bake a rating_hint ` +
|
||||
'in build_yard_assets.py or this anchor plays at the default hint forever');
|
||||
const rt = world.anchor(d.id);
|
||||
assert(rt, `${name}/${d.id}: declared in JSON but absent from world.anchors`);
|
||||
assert(rt.ratingHint === entry.rating_hint,
|
||||
`${name}/${d.id} plays at ratingHint ${rt.ratingHint}; the factory baked ` +
|
||||
`${entry.rating_hint} — the baked hint never arrived. This is the sail_post ` +
|
||||
'0.90/1.00 bug shape: factory and sim disagreeing with no error anywhere');
|
||||
assert((rt.collateral ?? null) === (entry.collateral ?? null),
|
||||
`${name}/${d.id} plays at collateral ${JSON.stringify(rt.collateral)}; the factory ` +
|
||||
`baked ${JSON.stringify(entry.collateral)} — same wire, same bug shape`);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert(checked > 0,
|
||||
'vacuous: no node-less declared anchors found across the shipped sites — this pin checked nothing');
|
||||
});
|
||||
|
||||
for (const { world } of siteWorlds) world.dispose();
|
||||
}
|
||||
|
||||
@ -13,6 +13,13 @@
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { ANCHOR_TYPE, createStubWind } from './contracts.js';
|
||||
// SPRINT15 gate 1.2 — E's patch, applied verbatim by the integrator at merge
|
||||
// (the leadFor precedent): a node-less anchor takes the factory's word at
|
||||
// construction, because dress() will never reach it. Object.assign ignores
|
||||
// null, so each hunk is inert exactly where adoptAnchor already owns the job.
|
||||
// At the ruled 1.00 this changes ZERO shipped numbers; it exists for the NEXT
|
||||
// asset. E's e.test pins runtime === manifest so divergence can't be silent.
|
||||
import { factoryExtras } from './ratings.js';
|
||||
import { validateSiteWind } from './weather.core.js';
|
||||
|
||||
/**
|
||||
@ -350,6 +357,7 @@ export function createWorld(scene, opts = {}) {
|
||||
anchors.push(Object.assign(
|
||||
makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
|
||||
{ work: a.work },
|
||||
a.node ? null : factoryExtras(a.type ?? 'house'),
|
||||
));
|
||||
}
|
||||
|
||||
@ -464,6 +472,7 @@ export function createWorld(scene, opts = {}) {
|
||||
anchors.push(Object.assign(
|
||||
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
|
||||
{ work: spec.work },
|
||||
spec.node ? null : factoryExtras(spec.type ?? 'post'),
|
||||
));
|
||||
}
|
||||
|
||||
@ -532,6 +541,7 @@ export function createWorld(scene, opts = {}) {
|
||||
anchors.push(Object.assign(
|
||||
makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
|
||||
{ work: a.work },
|
||||
a.node ? null : factoryExtras(a.type ?? 'post'),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user