Compare commits

...

4 Commits

7 changed files with 704 additions and 3 deletions

View File

@ -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.

View File

@ -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": []
}

View File

@ -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:

View 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
View 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;
}

View File

@ -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.