Research 027 — Gen-1 recipe index (committed station-recipe snapshot)
Status: complete
Step 1.5 output, between the spec draft and the approval gate. All three [NEEDS VERIFICATION] markers in spec.md have a verdict below; none refuted.
Verified against: the resolver at the worktree base 2f2495b (origin/main; the pre-parallelization sequential RecipeGraphService — topology is identical either way, only timing differs), and the live GW2 API v2 on 2026-08-20 (build 205780 per GET /v2/build). File:line citations are into this worktree. The closure numbers were produced by a throwaway spike that drove the real resolver via GET /api/recipe-graph/:itemId over the 21 Gen-1 roots (30684–30704); the spike is discarded, its findings recorded here.
V1 — Is the Gen-1 closure bounded and fully covered by the index?
Question. spec.md R1/R2/SC1 assume the set of items reachable from the 21 Gen-1 roots is small enough to commit and that an index built from it covers every id the resolver touches, so a re-resolve issues zero station fetches. For that to hold the reachable set must be bounded and closed under the resolve walk.
Verdict. Confirmed. The closure is 103 distinct ids across all 21 roots — tiny, trivially committable. Coverage is exact by construction: the index is defined as the closure, and the walk only calls getRecipes for ids it reaches, all of which are in the closure. Zero-fetch-when-covered is already an asserted behaviour of the resolver, not a new hope (see Evidence).
Evidence.
- Spike (real resolver, 21 roots): 103 distinct node ids in the union; per-root counts range 23–44 (
30698The Bifrost = 44, the other twenty = 23,30689Eternity = 28). The union being 103, not 21×23, shows heavy cross-tree sharing (gifts, Ecto, clovers appear in many trees, resolved once). getRecipesis called once per distinct id (deduped through the client'ssearchCache), so a cold Gen-1 ranking issues ≈103searchRecipes+ 9recipesreads — exactly what the index removes.- Zero-fetch-when-covered is proven today by
recipe-graph.service.warm-cache.test.ts:78("SC7: the second resolve issues 0 new GW2 fetches"), assertingwarmCallCount === coldCallCount(:92). The client's no-TTLsearchCache/staticCacheis an in-memory index of the closure; the committed file is the same index made persistent, so the same zero-fetch result holds on a cold process.
Caveat. 103 is today's live topology (build 205780). A game patch can add or drop ids; that is the staleness the manual-regen decision (R6) accepts. The number is a snapshot, not a constant.
V2 — Is StationDataService.getRecipes the only per-node station read?
Question. R4 replaces getRecipes and claims that removes all per-node station calls for indexed ids — i.e. no other upstream read happens per node. If some other call fired per node, the index would not deliver zero station fetches.
Verdict. Confirmed. The only per-node upstream read in the walk is station.getRecipes, which is searchRecipes + recipes. Curated lookups are local; item metadata is a single batched call after the walk; pricing is once, outside the walk.
Evidence.
recipe-graph.service.ts:127—expandawaitsbuildRecipes(id)per node, recursing per item edge (:145).recipe-graph.service.ts:156–172—buildRecipes::159this.curated.getRecipe(id)is a synchronous, local package lookup (noawait, no network);:172await this.station.getRecipes(id)is the only awaited upstream read per node.station-data.service.ts:27/:29—getRecipes=gw2.searchRecipes({ output })then, only when ids exist,gw2.recipes(ids). These are the two per-node network reads.recipe-graph.service.ts:95— enrichment runsitemData.metadata(ids)once, after the walk (item-data.service.ts:27→gw2.items(ids), one batched call), not per node.- Independently corroborated by
recipe-graph.service.warm-cache.test.ts: its fakefetchonly serves/recipes/searchand/items?ids=and throws on anything else (e.g./commerce/prices), and a fullresolve()passes — soresolve()'s entire upstream surface issearchRecipes+recipes+items, of which only the first two are per-node and both live ingetRecipes.
V3 — Is the resolver deterministic, so serialization is byte-stable?
Question. R6/SC5 require the generator to emit a byte-identical file on re-run against unchanged upstream data, so a patch produces a legible diff. That needs the resolved topology to be deterministic and the serialization order-independent.
Verdict. Confirmed. The closure is deterministic across runs, and the walk has no nondeterministic input; byte-stability then follows from serializing with sorted keys.
Evidence.
- Spike ran the full 21-root closure twice; the id-set and the station-id-set were identical across runs (
run1 === run2true for both). grepforMath.random/Date.now/new Dateacrossrecipe-graph.service.tsandstation-data.service.tsreturns none — the walk is a pure function of the API responses, so fixed upstream data yields a fixed topology.- The only residual nondeterminism is object/key order (insertion follows walk order). Removed by sorting keys at serialization (a generator design choice, R6), which the fixed id-set makes sufficient for a byte-identical file.
F4 — Only 9 of 103 closure ids carry a station recipe
The spike found 9 ids with a station recipe; the other 94 are leaves or forge-only, recorded as []. So the committed index is 9 non-empty entries plus 94 empty markers, and 94 of 103 per-node searchRecipes calls the resolver makes today return empty — pure waste.
Why it matters. This quantifies the spec's premise (the user's "only ~10 of 44 are craftable") across the whole Gen-1 set, and it is the direct justification for R2's "present-with-[], not absent" rule: if the 94 leaves were omitted rather than recorded as [], every one would be an index miss falling through to live search — reproducing the exact waste this spec removes. Recording them is what turns 103 station reads into 0.
F5 — The Bifrost is the closure's outlier
30698 (The Bifrost) reaches 44 distinct nodes; the other twenty roots reach 23 (Eternity 28). The union closure (103) is therefore dominated by Bifrost's unique subtree plus the shared gifts. No action — noted so a future reader isn't surprised that one root is ~2× the others.
F6 — Forge recipes are absent from the GW2 API
Verified this session: GET /v2/recipes/search?output=<id> returns [] for Sunrise (30703) and Gift of Fortune (19626) — both Mystic Forge assemblies — while Bolt of Damask (46741, a discipline craft) returns [7309]. ArenaNet never published legendary/gift forge recipes to the API; they exist only in the hand-curated @gw2priory/legendary-recipes package.
Why it matters. This is the premise of the revised spec's two-source model (R1): forge is authored (cannot be fetched), station is fetched. It is why the load-time merge exists — a generated station file can never contain the forge recipes, so they stay in the authored package and are merged at boot. It also confirms that station.getRecipes(legendaryId) legitimately returns [] (recorded as a leaf-stop, R2), not a missing lookup.
F7 — Discipline names are stable identifiers, not localized text
Verified this session: disciplines on /v2/recipes/7319 are byte-identical across ?lang=en/fr/de (["Leatherworker","Armorsmith","Tailor",…]), whereas item names localize (19721 → "Glob of Ectoplasm" / "Boule d'ectoplasme").
Why it matters. The committed station file stores disciplines and type as-is (R3) without an i18n concern — they are enum keys, not display copy. The index stores no localizable text (it holds ids, counts, and these enums); names come from the separate metadata step, which can pass ?lang=. i18n is a render-layer concern, keyed off these stable identifiers.
F8 — The generated index, and forge-first supersedes 007's R4 union (implementation finding)
The generator (GW2 build 205780, 2026-08-20) produced 76 non-forge entries — real station recipes for the discipline-crafted intermediates plus reached leaves as []; forge-covered ids (the 21 legendaries + gifts) are absent (authored package). Consistent with V1's ~103-id closure (76 non-forge + ~27 forge-covered). Non-empty entries include LegendaryComponent precursor recipes (e.g. 29166–29169), stored raw per R3 and filtered at runtime in toOptions. The committed file is byte-stable across a re-run (verified).
Implementation surfaced one deliberate behaviour change: the forge-first merge supersedes 007's R4 "an id with both a curated and a station recipe keeps both sources". recipesFor checks forge first and does not read station live for a forge-covered id (the generator likewise skips forge-covered ids). No Gen-1 id has both (V3), so there is no production behaviour change, and equivalence (R5) holds for all 21 roots — the coverage test resolves every root with zero station reads. The synthetic 007 R4 test was updated to exercise the union via a populated index.
Why it matters. R2/R4/R5/SC1: the committed index fully covers the Gen-1 closure, and this records the one 007 semantic the migration changed.
Refuted claims
None. All three markers confirmed; the revised two-source/merge design rests on F6/F7, both verified.
Graduation
Candidate for docs/architecture/ at step 6 (so the next spec doesn't re-discover it):
- The Gen-1 legendary closure is 103 ids, 9 with station recipes (build
205780, 2026-08-20), and the resolver's entire per-node upstream surface isStationDataService.getRecipes(searchRecipes+recipes) — a factdocs/architecture/gw2-api.mdordomain.mdshould carry.