Compare commits
4 Commits
c182fc14cb
...
cebc14e2b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cebc14e2b1 | ||
|
|
2a540523ff | ||
|
|
8aa49528e3 | ||
|
|
e46ff18648 |
134
THREADS.md
134
THREADS.md
@ -6903,3 +6903,137 @@ 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.
|
||||
|
||||
@ -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:
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user