THE RANKING WAS NEVER WRONG. "suburban house building" already returned 21
distinct houses at equal score and "pine tree" six distinct pines. The API had
no way to say "give me five DIFFERENT ones", so callers took hit #1 and placed
it five times — with 4,753 models installed, a scene used about six.
find_many(query, VarietyParams{count, spread, seed, filters}) never returns the
same model twice. Spread::Mixed spreads across variant families before
repeating a shape, which handles both real cases with one rule: five houses
come back as building-type-p/r/s/t/u, eight trees as pine/oak/palm/fat/cone/
detailed. palette(query, seed) returns a matched set from ONE pack.
Three things only visible by looking at output, not by reasoning:
- VARIETY MUST STAY ON-TOPIC. Round-robin across families returned one house
then two driveways and two fences (city-kit-suburban themes all of them
"house"). A relevance band was the obvious fix and was WRONG: an exact
one-word hit ("tree") outscores a compound sibling ("tree_blocks") merely for
being shorter, so banding cut real variety while keeping the drift. What
separates them is whether the family NAMES the thing asked for — applied only
when it leaves something, since functional queries name no shared noun
- VARIETY MUST NOT BECOME INCOHERENCE. Maximal spread gave five houses from
five packs — the junk-drawer failure reached from the opposite direction. The
dominant pack is exhausted before crossing; a test asserts a street uses
exactly one pack
- RE-SKINS AREN'T KINDS. tree_blocks/_dark/_fall is one tree in three palettes;
counting them as three kinds returned the same silhouette six times. Colour
and season tokens are stripped from the family key
Palette grouping needed a coarser key of its own: family_of produced 167 groups
of one id each — a listing, not a palette. Bucketing on tile role or first
meaningful token gives 23 usable groups.
Selection is seeded, so multiplayer replicates a scene as (query, seed) and a
re-run looks identical.
Also fixed: "boulder" returned tower-defense-kit/weapon-ammo-boulder — catapult
ammunition — because that filename says the word while landscape rocks reached
it only via a synonym. A confidently wrong top hit matters more than a miss
here, because a composer places it several times.
The perf test now takes MIN-of-N instead of an average: it shares a machine
with 23 other tests, and the same query measured 2.1 ms alone and 44 ms under
the full parallel suite — a 20x swing with no code change. The fastest run is
the one that actually got the CPU. Same protocol the box3d benchmarks use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
THE BUG: GLB_MAGIC was 0x4655_4C67, which spells "gLUF" — not "glTF"
(0x4654_6C67). The magic check therefore rejected EVERY REAL GLB, probe()
returned defaults, and the entire 4,442-model library indexed with
rigged:false, animated:false, size:None. Size filters silently matched
nothing; no model was ever detected as rigged. Any claim made from that
metadata — including "Kenney has essentially no rigging" — was measuring a
no-op, not the catalogue.
It stayed invisible because THE TEST FIXTURE WROTE THE SAME WRONG MAGIC, so
the test and the bug agreed with each other. Fixing the constant broke that
test, which is exactly how a fixture should behave once it stops encoding the
defect. A second bug sat behind it: bounds() searched for "max" only AFTER
"min", but Kenney's exporter writes max first, so bounds would have failed
even with the magic fixed. Both fixed, both with regression tests.
Consequence: the previously-reported 120 ms index build was timing a no-op.
Real probing is ~1.8 s for 5,309 models, now cut to the declared JSON chunk
and parallelised across <=8 threads (std-only, order preserved,
deterministic). The proper fix is caching probes by path+mtime — NOT done, and
the perf bound is now 12 s with a comment saying why rather than a tight
number the test cannot control under contention.
KIT INVENTORY — 23 kits, 2,064 tiles, grouped so a query returns a coherent
visually-matching set instead of one tile from each of five kits. Tile size is
the MEDIAN horizontal extent (kits ship occasional double-width pieces, and a
mean lands between grid pitches — a value no tile uses). Highlights:
city-kit-roads 72 tiles @1.00, coaster-kit 183 @4.00, tower-defense-kit 160
@1.00, marble-kit 162 @1.20, platformer-kit 153 @1.00, modular-buildings 108
@1.00, racing-kit 112 @1.05. The most useful single fact: modular-dungeon,
-cave and -space kits have IDENTICAL role histograms — one layout algorithm
drives all three and the kit choice is pure theming.
Honest failure: city-kit-commercial (41) and city-kit-industrial (25) yield
ZERO roles — their files are building-a..building-z, whole buildings with no
role vocabulary. Grid-placeable but not composable; arguably not kits.
Adjacency ships as DATA (ROLE_ADJACENCY) for the composition layer and is
deliberately coarse: Kenney filenames say what a piece IS, never which edges
are open, so anything finer would be invented. Also added: role/kit/clips/
joints on entries, kits()/kit_tiles() grouping, a find_kit agent tool (<2 KB
so the AI can discover a coherent set before composing), and composition-intent
vocabulary.
Inert per the Kenney-only scope cut: clip extraction, Quaternius source
support, .gltf support — tested and harmless. 64 fetched Quaternius models
were deleted after verifying they parse (46 joints/13 clips).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
decodable is no longer gated on format — acb315614 took the in-house Vorbis
decoder to sample-exact on every shipped file, mono and stereo. The
`undecodable` reporting path stays for a future format we might index before
we can play it; the test now asserts the CURRENT catalogue is clean rather
than asserting ogg is broken.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
52 packs, 4442 GLB models, 136 MB on disk — fetched sequentially with resume
(a hash-valid pack is skipped, so an interrupted run costs nothing) via
kenney.nl's content-hashed URLs with per-zip sha256. MIRROR.toml records
every pack's canonical URL, sha256, size and file count, so a mirror is
reproducible; --mirror=/ARCADE_ASSET_MIRROR redirects the base URL and fetch()
verifies the digest identically whatever host served the bytes — a mirror we
control is never trusted more than upstream. --packs= keeps a fresh clone from
being forced to pull everything.
Aliases restructured to survive the scale: per-pack theme rows (55) so every
model in a pack inherits its setting, filename-token parsing with variant-
marker stripping as the workhorse, and ~240 hand-curated query-time synonyms —
the layer whose curation compounds across the whole catalogue. 82-query suite
reports misses instead of being tuned green; the list is down to 2, both
defensible (a floor IS somewhere to stand; a bell IS a metal clang).
Three ranking bugs root-caused, not patched:
- No stemming, so "smashing" never reached the alias "smash" and "glass
smashing" returned glass PIPES. Added a conservative stemmer probed at
synonym strength (only ever adds matches), which refuses to mangle
glass/grass/class and routes "trees" to "tree", not "tre"
- An overreaching alias: `spaceship` sat on four spaceEngine SOUND families.
An engine hum is not a spaceship. Removed; "spaceship engine" still resolves
- Kind confusion on ties: spacecraft models tied with spaceTrash sounds and
lost the alphabetical tie-break. Added kind-aware tie-breaking driven by
query intent — deliberately a TIE-BREAK, not a score bonus, so it cannot
drag a weak model above a strong sound (laser gun / explosion / coins scores
verified unchanged)
Repo-policy violation fixed: all three asset .gitignore files were deny-lists
covering only .glb/.png/.jpg, leaving 302 .gltf files from 3d-road-tiles fully
committable. Converted to allow-lists — 4,744 asset files are now unstageable
by accident.
Scale at 4,999 entries: build 120 ms, search ~0.2 ms, 2.1 MB heap, and the
prompt summary still 479 chars — flat as the catalogue grows, which is what
keeps it affordable in every AI turn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
75 models (5 KenneyNL starter kits, .glb, pinned commits + sha256, 2.8MB)
and 556 sounds (7 packs via kenney.nl content-hashed URLs, each zip
sha256-verified, 13MB). Nothing large enters git: dirs are gitignored, only
CREDITS.toml and .gitignore files are tracked.
The index is the point — an AI cannot use a library it can't name:
- id is kenney/racing/vehicle-truck-yellow, anchored to where the file
LIVES, not its category, so retuning the category tree never invalidates
a saved game
- Filename tokens are the floor; the value is two hand-curated alias tables
(76 model rows, 116 audio FAMILY rows — Kenney's footstep_wood_000..004
collapse to one family, so 556 files stay maintainable) spanning
synonyms, kid vocabulary and misspellings (vehical, hosue, motercycle),
function over identity ("something to hide behind"), colour/size/
material, and theme, plus ~190 query-time synonym expansions
- AssetKind model/sound/music so a 30-second track can't be returned as a
hit sound; GLB probe reads skins -> rigged, animations -> animated
- FIND_MODEL tool descriptor (provider-neutral plain data) + compact
results; library_summary() is 469 chars for 632 entries and provably
doesn't grow with the catalogue; resolve_or_explain() rejects
hallucinated ids with near-misses; local_spawn() gives the local
librarian a best match plus a confidence blending strength with margin
HONEST GAP: every Kenney audio pack is Ogg Vorbis only — no WAV exists
upstream — and this tree has no vorbis decoder. Sounds are indexed and
searchable but NOT playable: entries carry decodable:false, the agent JSON
emits playable:false so a game cannot fire a silent sound, and
--transcode converts via ffmpeg when present. A real decoder is the fix.
Three bugs found by testing, all fixed: sci-fi-sounds.zip ships a directory
with no owner-write bit so that pack alone silently extracted 0 of 73 files;
prepositions matched phrase aliases ("...at the roadworks" hit "something to
shoot at"), so function words must be dropped, not down-weighted; and exact
names lost to incidental aliases (a coin SOUND outranked the coin MODEL).
Miss list left visible at 2/52 rather than tuned away — both are defensible
answers against over-narrow expectations.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>