A workbench for crafting, experimenting, researching and analysing Commander decks.
It is built around one idea: a claim about a deck is worth what the experiment behind it
is worth. docs/vision.md is the page everything else is written against.
At the centre is the swap loop, on two engines with different jobs:
- A seeded, PAIRED Monte Carlo goldfish — the decision instrument.
tryplays the current list and a candidate through the same games, seed for seed, and answers with an interval on the difference in about ten seconds. - Forge, the real rules engine, run headless and seeded — a targeted probe since 2026-10-04. It answers what the goldfish cannot: does the AI actually play this card, and what happens against blockers and removal.
Around them sit the things that make an experiment mean something: a deterministic builder, a rules-citation loop for lines that must be proven rather than measured, dated web reconnaissance, deterministic card mining over 34,964 cards, and a frontend that surfaces the results.
Optimised for one player; open-sourced so anyone can stand up their own bench, not so anyone else is supported.
a question → an experiment → a result you can cite
"is this swap better?" try --out A --in B damage@T10 +0.83 [+0.68, +0.98], paired
"does the AI play this card?" forge-cast-check --card X drawn 28, cast 0 — held
"is this line lethal?" /resolve-stack ✓ or refuted, with CR cites
"how fast does it go off?" goldfish mean t4.19, 89% by t6
"what do strong lists run?" deck-recon, /prescribe ranked, cited, skeptic-checked
"what would fix this axis?" deck-audit + card-search candidates that move the number
"is this change worth buying?" net-change, then propose a trade, priced, with a bill
try is the flagship. manamap pilot try <slug> --out "A" --in "B" — about ten
seconds, nothing written: every card in and out with its roles and what the goldfish can
see of it, the pilot's keep list (protected.json), colour sources before and after, and
the net-change rows with a paired interval on the difference. Every goldfish game
takes its own seed and the two lists are aligned slot for slot, so the noise of two
independent samples cancels — a list against itself reads exactly zero. A swap that
survives becomes a branch (--stage NAME), and net-change grades it on one
pre-registered objective plus twelve Holm-corrected rows. Never a comparison of two
marginal intervals: that is the overlap fallacy, and the key that reported it was removed.
The workbench (viz/workbench.html) — the landing page: every deck you own, in
racks by whether it is sleeved, waiting on cardboard, on the bench or history; or as
one fleet table across record, stages, evidence, table and open work — sortable by
recently played, needs game logs, needs analysis, optimisations identified and
waiting on cardboard. Each deck carries a derived next, and three named links: its
Pilot's Operating Handbook, its dossier, and where it sits on the map.
The card atlas — every Magic oracle card (~35,000) embedded by two small neural nets. It opens on one card: hover it, click a relation, and its neighbours join a force-directed graph you grow by clicking. Load one of your own decks and it lights up with its commander ringed. The 34,964-point atlas is one click away, and drifts slowly at altitude, settling as you zoom in to read. Three relations, each precomputed so a click is instant: similar (embedding neighbours), synergy (rule-based complements, each edge labelled with its rule), outclassed by (strictly-better replacements). Boot costs 1.9 MB.
The deck page (viz/deck.html?deck=<slug>) — the workbench surface: what to do next,
where the deck stands, every list it has been, what limits it, the engine, the
experiments and simulation runs with their intervals, prescriptions, the captain's log,
open questions, and the deck's own constellation. It renders info.json — the shape
deck-info composes — rather than re-deriving anything, so it cannot disagree with the
command that owns each figure.
The branch workbench (viz/branch.html?deck=<slug>&branch=<name>) — one candidate 99
and the decision about it: what it would become and what it was accepted on, the verdict,
every measured row with its definition and a plain-language reading, reward / risk / cost,
and the bill. A branch is a deck that does not exist, so it gets its own page rather than
growing into the dossier.
Curate (viz/library.html) — the library across piles, at a size you can read. The
atlas's drawer keeps one card; this cuts forty. A pile rail, a grid with multi-select and
bulk move/remove, a pinned pane, and sort plus colour/type/role filters over the same card
index the atlas boots on — fetched after the first render, so a failed fetch degrades to
the piles, the name box and the three factless sorts rather than to a blank page. A card
the index cannot resolve gets its own chip and sorts last, never as 0.
The embedding-space appendix (viz/spaces.html) — what each embedding space is for and
how they differ, because "similar" means two different things in this repo and the wrong one
returns arbitrary same-colour cards. Linked from the shared nav on every page.
try <slug> --out A --in B— the swap loop: a paired goldfish screen in ~10 s.net-change <slug> --branch B— a candidate 99 graded: one primary, twelve exploratory rows, Holm-corrected, paired per game. A Forge loss beside it is a warning, not a block.goldfish— seeded Monte Carlo resource development, 10,000 games in seconds. Every channel it models is opt-in per deck, so what it can see is a thing you declare and can check.forge-cast-check <slug> --card X— the Forge probe: does the AI play this card?simulate/experiment— Forge games against a named pod, when a question needs a table; every rate with its interval, graded only beside an A/A at the same N.build <slug> --commander "<name>"— the one command: a brief becomes a legal, bracket-gated, measured 99 on the bench in six stages, about ten seconds, no agents. Omit--commanderand it proposes three and halts. The dev batch is the goldfish, not Forge — a twelve-minute Forge batch is ~20 games, whose minimum detectable difference is 42 points. (build-deckis the underlying scoring step and is still callable on its own.)deck-audit— 16 axes, each carrying the verbatimstrategy.mdquote that sets its target.card-search— deterministic mining over the corpus: identity, oracle/name regex, role, cmc, and--ownedagainst your physical boxes.deck-info <slug>— the whole join, and a derived next.deck-branch— a candidate 99 you cannot yet sleeve: stage swaps, measure it against the deck withnet-change, thenproposeit as the next version and wait for the cardboard. The decision is frozen; the blocker is recomputed from your boxes on every read, so a proposal un-blocks itself when a card lands in one.deck-version— every list the deck has been, from git, joined to the games played on it;deck-version <slug> paper <ref>is how you say this one is sleeved.check-in— a paper list you typed up becomesdecklist.txt: it diffs, and refuses a silently-wrong list rather than guessing which side is right.model-coverage— what the model cannot see, before the games: seen / dark (feeds a channel that is off) / invisible.deck-state/deck-delete— archive, retire, supersede, revive; and the one destructive verb, which refuses a deck that was ever sleeved, played or published.regen— rebuild the fleet in dependency order after a model change, parallel across decks but never splitting the games inside one run, so it stays bit-identical.deck-notes add→/debrief→/captains-log→/prescribe— the table, structured, read, then answered./resolve-stack— a board (authored, or lifted from a simulated game) resolved with Comprehensive Rules citations and adversarially checked. The ✓ tier./analyze-engine— the deck's machine as eight stages, solid where a stack proves a line./write-manual+/poh-procedures→build-poh— the notes a person writes, then the handbook.
Under it all is a three-tier evidence contract that never moves: ✓ rules-verified, ◆ data-derived (seeded where randomness is involved), ★ coaching — labelled judgment, never disguised as measurement. A figure travels with its interval, its N and its limits, or it does not travel — enforced in code, not by convention. Every agent returns JSON a validator checks; the Python makes zero LLM calls; the deployed site and your machine run the same code.
Four things this is honest about. Forge's AI pilots every seat including yours, and rates itself "poor to ok in control, pretty bad for combo" — a sentence quoted verbatim in every run record, which makes a control deck's win rate a lower bound on the pilot; that is why Forge is a probe. Five decks carry a captain's log — 19 games, every entry debriefed (Ur-Dragon 7, Edgar 6, Gishath 2, Goblin Storm 2, Heliod 2, as of 2026-10-05) — which is nineteen, not a sample: the log, debrief and prescription surfaces are built and tested, and barely used. Six of fourteen decks are marked as built in paper (Edgar, Gishath, Goblin Storm, Heliod, Sharknado, Ur-Dragon); whether a deck exists as cardboard is an assertion only the pilot can make, so an unlocked deck says it is unlocked rather than being assumed playable — and that one authored flag is what decides whether the whole chain runs for it automatically.
And the goldfish has no blockers and only models what a deck declares. It cannot price
a blocker's worth or removal, so its verdict on board quality is not evidence. Every channel — draw, combat,
Treasure, sacrifice, the copy commander, the spell count — is off until that deck's
goldfish_targets.json switches it on, so a card feeding an off channel measures as exactly
nothing and looks identical to a card that does not help. model-coverage <slug> is the
preflight that names those cards, and it exists because that confusion has cost this project
a whole branch.
The Pilot's Operating Handbook (manuals/p/<slug>.html, from manamap pilot build-poh) — each deck's self-contained printable page: the game plan, the mulligan, the
verified lines argued, the engine, the numbers with their intervals, and the emergency and
normal procedures a person writes. No <script> anywhere, so it rebuilds
byte-identically and is trustworthy offline and in print — which is exactly why live map
embeds go on the dossier instead. The legacy magazine it replaced was deleted on
2026-09-13; what it measured is kept in docs/gotchas-magazine-legacy.md.
Two commands. No install, no pipeline, no API keys. Every data file the frontend needs is committed.
git clone git@github.com:macrae/mana-map.git && cd mana-map
python3 -m http.server 8000Open http://localhost:8000/viz/index.html.
Contributing? CONTRIBUTING.md is two commands and a list of landmines. docs/README.md sorts the documentation into current reference and historical design records, which saves reading 12,000 lines of the latter by accident.
Three things to know:
- Serve from the repo root. The page fetches
../data/*, soviz/anddata/must stay top-level siblings. Openingviz/index.htmlas afile://URL fails on CORS. - The clone carries 250 MB of tracked data (and
git clonetransfers rather more than that — the history is ~254 MB), but discovery boots on 1.9 MB gzipped — a slim card index plus a precomputed neighbour table. The heavy artifacts load only if you ask for what needs them: the 3.0 MB projection when you open the atlas, the 17.9 MB embedding matrix never on the discovery path at all. That the clone is large is deliberate — see Landmines. (Two of the tracked files,combo_details.jsonandcard_roles.json, are for the deck builder and the agents; the browser never fetches them.) - d3 loads from a CDN, so the page needs internet even though the data doesn't.
What you get: two maps (one clustered by color and type, one by what cards do), three
relations on every card — similar via embedding neighbours, synergy via rule-based
complementarity, outclassed by via the obsolescence index (these are different
algorithms, see docs/architecture.md) — a Build mode that lights up a deck or a pool inside the 34K atlas, and obsolescence
badges for cards with strictly-better replacements.
Build shows a deck's footprint in card space — its role histogram, mana curve segmented by
the current overlay, colour load and verified lines — and hands work back out as a
brief.json the deterministic builder reads. It does not score cards: evaluation comes
from the pipeline through the agent loop, so there is exactly one scorer.
The atlas is a launchpad: clicking a relation there carries you into the walk seeded on that card, rather than doing something subtly different because you happened to be in a different mode.
This half is more involved, and honest about why: the agent phases need
Claude Code. The Python in this repo makes zero LLM
calls — it is deterministic infrastructure (fetching, simulating, validating, rendering)
that AI agents drive from the outside, and most of the bench (deck-info, deck-version,
deck-notes, goldfish, simulate, model-coverage, regen, every validator) is pure CLI.
The agent routines are what cost tokens — the doctor and its skeptic, the resolver and its
checker, the engineer and its critic, the strategy researcher, the notes writer, the handbook's
procedures author, the debrief, the captain's log and the cartographer — and an invocation
cache is what makes iterating on them affordable. Twenty-four agent charters live in
.claude/agents/, twenty-four skills in .claude/skills/. docs/agent-cost.md has the
breakdown; two deliberate opt-in exceptions are the only LLM calls reachable from Python
itself: serve.py's ask bridge, which shells out to claude -p as a polled job, and
mm ask, whose SDK is an optional extra the core install does not pull.
make setupThat is the whole step. It checks for Python 3.10 exactly — PyTorch publishes no wheels
for 3.13+, and the pins (sentence-transformers<4, numpy<2) target torch 2.2.2 — then
installs in the one order that works and downloads chromium for the browser tests.
If your 3.10 is not called python3.10 on PATH (a conda or pyenv build, say):
make setup PYTHON310=$(pyenv which python3.10)By hand it is three commands, and the order is not cosmetic: pacmap pulls numba, and installing it afterwards triggers a source build of LLVM.
python3.10 -m venv .venv
.venv/bin/pip install llvmlite==0.41.1 numba==0.58.1 # must come first
.venv/bin/pip install -e ".[dev]"Developed on macOS/arm64. Linux should work apart from MPS device selection; Windows is untested.
.venv/bin/manamap pilot download-rules # the Comprehensive Rules text
.venv/bin/manamap pilot build-rules-db # ~3.9K chunks, chunk ID = rule number
.venv/bin/manamap pilot build-strategy-db # the strategy companion's RAG indexAll three outputs are gitignored and must be built locally. CR_RULES_URL in config.py is pinned to
a specific rules release — update it when Wizards ships a new one.
mkdir -p data/decks/<slug>/{stacks,decisions}
# write data/decks/<slug>/decklist.txt
.venv/bin/manamap pilot fetch-deck <slug>
.venv/bin/manamap pilot validate-deck <slug>Use a Moxfield export. Lines like 1 Zada, Hedron Grinder (SLD) 2406 *F* carry the
set, collector number and foil marker, and fetch-deck resolves those first — so the
deck page shows the actual cards in your deck, with the right art. A name-only list works
but yields default reprints.
fetch-deck short-circuits when the decklist hasn't changed; use --force after oracle
errata.
Nothing scaffolds these. data/decks/goblin-storm/ is the worked reference.
| File | Why it's manual |
|---|---|
decklist.txt |
It's your deck — unless you let the builder write it, see below |
goldfish_targets.json |
Which key-piece sets are worth simulating is a judgment call — and which channels the model may switch on for this deck. /build-deck derives a first version from the plan's declared engines; no agent ever edits it afterwards |
deck_versions.json |
Authored, and the home of two claims only you can make: paper (this exact list is sleeved, which is what puts the deck on the automatic chain) and lifecycle (archived / retired / superseded). Written through deck-version paper and deck-state, never by hand |
issue.json |
Optional now. It carried the magazine's authored identity; with that renderer deleted, only the display name is still read, and most decks have none |
If you have a commander in mind rather than a list, the builder makes one:
# author data/decks/<slug>/brief.json: commander, bracket (1-5), playstyle
.venv/bin/manamap pilot build-deck <slug> --write-decklist # deterministic, no agents
.venv/bin/manamap pilot fetch-deck <slug>That alone produces a legal, tier-conditioned, goldfishable 99. Running the /build-deck
skill on top adds the agent loop, which is what makes it good rather than merely legal —
and the whole path from brief to a finished deck is proven (hapatra was built this way).
Run the skills from Claude Code in the repo root (each is in .claude/skills/).
/publish-deck sequences the lifecycle and manamap pilot deck-info <slug> tells you
where a deck stands and what to do next; start with both.
| Step | Produces | Pure CLI? |
|---|---|---|
/build-deck |
A 99 from a brief: pool → architect ⇄ critic, bracket-gated | no |
manamap pilot bracket-check <slug> |
Computed bracket floor + its evidence | yes |
manamap pilot goldfish <slug> |
Resource curves from 10k seeded games | yes |
manamap pilot deck-map <slug> |
The constellation: local layout + clusters | yes |
/analyze-engine |
The engine: stages, lines, what a stack actually proves | no |
/resolve-stack |
A verified line: resolver → validator → adversarial checker | no |
manamap pilot deck-audit <slug> |
16 cited axes plus engine activation | yes |
manamap pilot model-coverage <slug> |
What the model cannot see, before the games: seen / dark / invisible | yes |
/write-manual |
The pilot's notes: game plan, mulligan, line intros, threats, matchups | no |
/poh-procedures |
The half a person writes: emergency and normal procedures, rules of engagement | no |
manamap pilot build-poh <slug> + build-index |
The Pilot's Operating Handbook (manuals/p/, deterministic, no <script>) |
yes |
Then the loop the bench exists for — all CLI except the two agents:
manamap pilot deck-branch <slug> new/stage/… → net-change --branch |
a candidate 99, measured against the deck | yes |
manamap pilot deck-branch <slug> propose <name> --as v1.0.2 |
accept it, and wait for the cardboard | yes |
manamap pilot deck-version <slug> [tag …] |
commit the list; every version numbered from git | yes |
manamap pilot fetch-opponent "<commander>" / simulate <slug> --vs <pod> --games N |
your table, in Forge, seeded | yes |
manamap pilot check-in <slug> --from <file> |
a paper list becomes decklist.txt; refuses a silently-wrong one |
yes |
manamap pilot deck-version <slug> paper <ref> |
this list is sleeved — the one claim that puts the deck on the automatic chain | yes |
manamap pilot deck-notes <slug> add "…" --result win|loss --cause <code> |
the captain's log; --cause is a closed vocabulary so the dossier can count how games end |
yes |
/debrief <slug> |
the note, structured and routed | no |
/captains-log <slug> |
each night plain, and the read — what the games have taught | no |
/prescribe <slug> "<question>" |
the doctor's answer, priced and skeptic-checked | no |
manamap pilot sim-scenario <slug> <run> --game G --turn T --stack → /resolve-stack |
a simulated board, proven | mixed |
Two pipelines, same pattern: each stage writes artifacts the next one reads, and every stage is independently runnable and testable.
Card map download → extract → preprocess → train ×2 → embed → reduce
→ combos → export → synergy → power-creep → regions → card-roles
→ viz-index → eval-embeddings (the quality gate, step 15)
Build brief.json → build-deck → bracket-check → architect ⇄ critic → decklist
Experiment decklist → fetch-deck → goldfish ─┐
pod → fetch-opponent ───────┼→ simulate / experiment → parse → analysis
└→ sim-scenario → /resolve-stack → ✓
Change decklist → deck-branch new → stage → net-change → propose → merge → a version
└ blocked on cardboard, derived
Fleet a model change → regen (dependency order, parallel across decks)
goldfish → mana-analysis → net-change → diagnose → benchmark → deck-info
Surface artifacts → deck-info --write → info.json ─┐
build-index → index.json ──────────────────┴→ viz/deck.html
manamap run drives the first (15 steps, ~40–60 min, internet at two of them).
manamap pilot <cmd> drives the rest (156 pilot subcommands against 28 top-level ones).
All constants live in src/manamap/config.py; both CLIs are registry-driven with lazy
imports.
manamap serve does three jobs, and only the first is obvious. It serves viz/ and a local
/api the deployed site does not have — and it is also a warm worker: with it running,
every read-only manamap pilot <cmd> routes through /api/cli and skips the cold import,
byte-identically. Measured: query-rules 6.93s → 0.16s, deck-facts 1.44s → 0.14s,
deck-audit 2.26s → 0.59s. It fails open — no server, or any error at all, and the
command runs locally exactly as before. MANAMAP_NO_DAEMON=1 opts out.
One gotcha that costs ten confusing minutes every time: the server holds the old modules
until you restart it. After editing Python, restart serve or set MANAMAP_NO_DAEMON=1,
or you will measure the code you just replaced.
.mcp.json registers manamap.mcp_server, which hands Claude Code seven read-only tools
over the same warm process — deck_state, fleet, search_docs, search_code, stats,
run_command, command_help — so an agent gets structured data instead of parsing prose
(deck-status heliod is 2.9s cold, 0.003s warm, byte-identical). There is no MCP SDK
dependency: the protocol is JSON-RPC over stdio and the subset a tool server needs is ~150
lines, the same reasoning that keeps scipy out of sim/stats.py. It cannot write, and the
gate is serve._cli imported rather than restated, so the two surfaces cannot drift apart.
Code: src/manamap/sim/{forge,parse,experiment,bridge,opponents,validate_sim,engine_casts}.py.
Design, the spike and the verdict: docs/simulation.md.
Since 2026-10-04 Forge answers narrow questions; it does not decide. Overnight pod runs
left the decision loop — a day per answer, an MDE of ~0.14 at 200 games, and an AI that
mis-pilots some decks into floors — so a swap is screened by try on the paired goldfish,
and Forge's first job is forge-cast-check: does the AI play this card at all. What
follows is how the harness works, which is still how every probe works.
Forge was chosen by measurement, not preference. Three things were checked before
committing to it: every log line parses, 4-seat Commander runs headless, and -s makes a
run byte-replayable. Writing a rules engine was shelved for one narrow deterministic case.
A run is seeded. simulate converts each seat's decklist.txt to a Forge .dck
through the repo's own parser — so a deck analysed and a deck simulated can never
disagree about what is in it — then runs N Commander games across J JVMs. The default seed
derives from the configuration, so the default replays; --seed asks for a new sample.
Job i runs seed_base + i, and a same-id re-run is refused without --force.
The record is one tracked JSON. Outcomes, per-game rows, every seat's decklist sha,
Forge and Java versions, wall time, the seeds, and an analysis block with Wilson
intervals for rates and normal intervals for means. validate-sim re-derives that
analysis from the kept logs where they exist and form-checks where they do not, so a
figure in the record is not merely asserted.
Two turn counts, and they are not the same. round is the winner's own turn count
(Forge's Game Outcome: Turn N); global_turn is the game's last Turn: line. In a
4-seat game round 8 is global turn ~32.
Three things the parser gets right on purpose. Tokens are reported two honest ways —
token_resolutions (creation abilities that resolved; blind to X and doubling) and
tokens_observed (distinct ids seen acting) — because Forge names a token on first use,
never on creation. Damage figures see damage only: a drain kill shows in life_by_turn
and eliminated_how, never in a damage total. And commander damage is per defender,
because CR 903.10a asks for 21 from one commander on one player — 60 damage spread over
three seats wins nothing.
Every aggregate carries median, min, max and an interval. A mean over a skewed sample is a true number that describes no game: one measured arm read mean 17.42 with a median of 0, the whole difference being two games out of twelve.
The bridge closes the loop. sim-scenario <slug> <run> --game G --turn T --step S
lifts a board out of a simulated game into a game_state v2 scenario, which
/resolve-stack then proves with rules citations. Life and lands are exact; hand size is
an estimate; every approximation is written into extras.reconstruction_notes. This has
run for real: radagast stack 008 is a board lifted from a simulated game, resolved and
checker-passed in three iterations, and the checker caught two triggers the author missed
that the log confirms.
The AI caveat is not a footnote, and it is not one caveat. Every seat is a Forge AI
including yours, and Forge's own rating — "poor to ok in control, pretty bad for combo" — is
quoted verbatim in every run record's assumptions. Measured: no AI profile flies a hold-up
deck better than Default (Default 3/6, Experimental 2/6, Reckless 2/6 over seeded games), so
Default stays the default. Three sharper consequences, each of which has cost a real
conclusion:
- A result on a deck whose engine the AI never cast is a floor, and the record says so.
One seat cast Wheel of Fortune once and Windfall never in 60 games while discarding
Windfall three times — the log's own statement that the card was held and passed over.
record["engine_casts"]carries per-card cast / activated / discarded for your seat, andsimulateprints "held and never cast" at its tail. Check it before reading any rate. - The AI will not sacrifice for a benefit its evaluator cannot price, so a Forge result
on a sacrifice deck is a floor.
Ashnod's Altaris free and was 0 for 59 castings;Indulgent Aristocratcosts {2}, puts a visible counter on the board, and activates 0.41 per cast. Cost is not the discriminator — a visible board payoff is. Prefer a trigger over an activation. - Sometimes it is not a floor but a different deck. Zada, Hedron Grinder copies a
single-target spell across your board; over 60 games she was cast 100 times and her trigger
fired 21, because the evaluator prices
Brute Forceon Zada exactly as it prices it on anything else. Every card was cast, just never at her. So that deck's three measured win rates are void rather than conservative, and the A/B is not rescued by comparing them. The preflight is one grep for the commander's trigger in the logs; the route to evidence issim-scenario --stackon one of the 21 real boards, proven by citation.
Code: src/manamap/pilot/goldfish*.py. The channel-by-channel reference:
docs/simulation.md.
Forge plays real games slowly; the goldfish plays 10,000 seeded hands of resource development against nobody, in seconds. It answers questions about a curve — how fast does the mana arrive, when is the engine assembled, what does the board look like on turn eight — and it is wrong to ask it anything about a table.
Everything it models is opt-in per deck, declared in that deck's
goldfish_targets.json. Draw, combat, Treasure, sacrifice and deaths, discard, the spell
count and storm, magecraft, and four commander abilities that only one corpus card each
possesses are all separate flags. This is not configurability for its own sake: a channel
that is on for every deck is a channel that has to be right for every deck, and the
alternative — a card silently measuring as zero because nothing reads it — is the single most
expensive failure mode this repo has. Hence two rules, both enforced by tests:
- A flag the model sets is a claim the model must act on.
treasure_doubleronce shipped set-and-never-read; fifteen candidate cards came back byte-identical. - Teach the casting predicate in the same commit as the ability. Every casting loop selects on a channel, so a card matching none of them sits in hand for ten turns while its profile says precisely what it would have done. Found five times in one session, and only recognised as a class on the fourth.
Three limits worth stating up front, because they decide which engine to ask. It has no
blockers, so its verdict on board quality is not evidence — a go-wide refactor it
preferred on damage, kill rate and card advantage lost 31/400 to 50/400 in Forge, because 1/1
tokens do not connect. Lands enter untapped, always, so it cannot rank two lands that make
the same colours; mana-analysis and mana-fit are deterministic for exactly that reason and
are the whole of the evidence for a land swap. And meta.model_version is a sha over the
model's own source, so a model change makes every derived figure in the fleet stale and
says so rather than quietly reporting the old number.
A technical overview. Code: src/manamap/training/{model,train,train_ability}.py,
src/manamap/ingest/{extract,preprocess}.py, src/manamap/analysis/eval_embeddings.py.
Every constant named below lives in config.py.
Two lightweight fusion MLPs (~180K params each) produce the 128-dim embeddings; the text encoder stays frozen. They answer different questions and are not interchangeable. The layout model organises the map by colour and type and feeds the projection only. The function model answers whether two cards do the same job, and is the sole source of similarity — the similar relation, the walk and drill all read it whichever map is on screen.
That split exists because the alternative was measured and was bad: when similarity followed
the displayed map, the colour/type space was using 3.9 of its 128 dimensions and Doubling
Season's nearest neighbours came back as arbitrary green enchantments. manamap eval-embeddings (step 15) scores every space against a hand-authored golden set so a claim
like that is a number rather than an opinion.
34,890 Magic cards, each a short piece of natural language plus a dozen structured attributes, into a metric space where "these two cards do the same job" is a nearest-neighbour query. There is no click-stream and no relevance judgements — supervision has to be manufactured from the cards themselves, which is most of what makes this interesting.
One card becomes nine parallel inputs. Nothing is learned end-to-end from raw text; the sentence encoder is frozen and everything else is a small learned table over an explicit feature.
| block | shape | how it is built |
|---|---|---|
| frozen text | 384 | all-MiniLM-L6-v2 over a synthesised sentence (below) |
| supertype | 1 → 16 | nn.Embedding(10, 16) — Creature, Land, Instant… |
| rarity | 1 → 8 | nn.Embedding(7, 8) |
| colour identity | 1 → 32 / 8 | nn.Embedding(33, ·) over the 32 observed WUBRG subsets |
| layout | 1 → 16 | nn.Embedding(18, 16) — normal, split, transform, adventure… |
| continuous | 2 | normalised CMC; normalised EDHREC rank |
| keywords | 50 | multi-hot over the 50 most frequent keywords |
| mechanical tags | 33 | multi-hot, regex over oracle text (function model only) |
| structured | 15 | power/toughness (3) + mana pips (6) + colour features (6) |
Two details that are load-bearing rather than incidental:
The sentence is synthesised, and the card's name is deliberately excluded.
build_embedding_text emits "{type_line}. Cost {mana_cost}. {P}/{T}. {oracle_text}. Keywords: {…}". The name used to lead the string and was buying similarity off shared
tokens rather than shared function — Rhystic Study matched White Rhystic Study at 0.951,
Sol Ring matched Sisay's Ring. A name is also a large fraction of a short card: Sol
Ring's entire text is eleven words, three of them the name. Dropping it moved held-out
recall@10 from 0.187 to 0.248 and median rank from 159 to 129.
Continuous features use fixed scales, never per-run min-max. EDHREC rank divides by a hardcoded 50,000, power/toughness by 15. A per-run normalisation makes the same card's features differ between pipeline runs, which silently destroys comparability between two runs' embeddings — this was a real bug.
A fusion MLP. Categorical blocks go through embedding tables, the two high-cardinality
multi-hot blocks (keywords, mechanical tags) through Linear + ReLU, and everything is
concatenated into one wide vector fed to a three-layer trunk:
concat[…] → Linear(d_in, 256) → ReLU → Dropout(0.1)
→ Linear(256, 128) → ReLU → Dropout(0.1)
→ Linear(128, d_out)
181,272 trainable parameters for the layout model, 192,672 for the function model. That is small on purpose: the frozen 384-dim MiniLM output is the only component that needed scale, and it is amortised across every run.
The layout model returns F.normalize(x), 128 dims, and that is the whole story.
The function model does something else. Its trunk emits 96 dims and a separate
Linear(384, 32) with no ReLU projects the frozen text into the remaining 32. Each half
is L2-normalised independently, then scaled by √(1−W) and √W with W = 0.3:
learned = F.normalize(x) * sqrt(1 - W)
text = F.normalize(text_proj(text_emb)) * sqrt(W)
return torch.cat([learned, text], dim=1) # already unit-normBecause the squared weights sum to 1, the concatenation is unit-norm and the dot product of two cards is exactly
sim(a, b) = 0.7 · cos_learned(a, b) + 0.3 · cos_text(a, b)
This exists because of a measured failure. The previous function model scored 0.093
recall@10 against 0.187 for the frozen text it was built from — training was subtractive,
and the model had quietly learned to discard the only signal that was working. The split
makes discarding it structurally impossible: the text's contribution is a fixed fraction
set by architecture, not a fraction the optimiser is free to drive to zero. The rectifier is
omitted from text_proj for the same reason — this half exists to preserve the text
geometry, and a ReLU folds half of it away.
Layout model — TripletMarginLoss, margin 0.3. Positives are drawn from the same
(supertype, primary_colour) group with two fallbacks; negatives must differ on both.
This task is nearly trivial, which is the point — its only job is to give PaCMAP something
with legible colour/type structure to project. Its effective dimensionality of 3.9/128 is
not a defect for that job; it is the job.
Function model — symmetric in-batch InfoNCE, τ = 0.05. The dataset yields
(anchor, positive) pairs only; the batch supplies negatives.
scores = (anchors @ positives.T) / temperature
labels = torch.arange(len(scores))
loss = 0.5 * (cross_entropy(scores, labels) + cross_entropy(scores.T, labels))At batch 256 that is 255 negatives per anchor for one forward pass, against the old triplet loss's single mined negative. The replacement was diagnostic, not fashionable: a margin loss stops producing gradient the moment it is satisfied, which for a task this easy was around epoch 3, so nothing pressured the model to preserve structure within a class.
With no labels, the positive-selection rule is effectively the loss function. Three tiers, per anchor:
- Rarest specific role first. A 53-role taxonomy (
ROLE_PATTERNS) gives a specific role to 73.2% of the 31,830 commander-legal cards, at 1.62 specific roles each (card_roles.json'smeta.specific_coverage; total coverage including thethreat:bodyfallback is 89.6%). Roles are sorted by group size ascending — two cards sharingdoubler:tokens(11 cards) say far more about each other than two sharingvalue:etb(5,580), so the positive is spent on the anchor's most specific claim. - ≥2 shared mechanical tags (the old rule, now a fallback). It covered only 46.9% of the corpus, so for most cards it was the random tier wearing a better name.
- Random.
ROLE_BODY_FALLBACK is excluded deliberately: it labels all 19,050 creatures, so "shares
this role" would be barely narrower than "is a creature" and would rebuild the trivial task
this design exists to escape. Mining is scoped to each split's own indices — a validation
positive drawn from the training set is leakage.
No hard-negative mining, stated as a non-change. Random in-batch negatives are safe here: measured on this corpus, a random pair at batch 256 has a 0.004% chance of being a true near-duplicate, about 0.01 false negatives per anchor. That would not survive hard mining, which selects nearest-non-positives by construction while 39% of cards have a text neighbour above 0.75 — it needs a similarity ceiling, and that is a second variable.
Optimiser: Adam, lr 1e-3, batch 256, 10% validation, early stopping on patience 5, seed 42.
Embeddings are L2-normalised at build time, so cosine is a plain dot product and top-k is
argpartition over one matrix-vector product — no index structure, no approximation, 34,890
rows is small.
Serving is the constrained part, because the browser must branch synchronously mid-gesture
and cannot download a 16.8 MB float matrix. neighbours.bin precomputes, per card, 12
similar + 10 synergy + 5 obsoleted-by row ids (NEIGHBOURS_K_* in config.py): uint16
ids, similarities quantised to uint8 — 1.9 MB gzipped for the whole discovery boot,
against 18.4 MB before.
The quantised value is used for edge length only, and ordering is array order. Re-sorting client-side by the lossy value changes the top-10 for roughly two thirds of cards, because the space is a narrow cone — median pairwise cosine 0.714, so 8 bits over the observed range is coarser than the gaps being ranked. It would read as a model regression rather than a precision bug. The header carries a SHA-256 of the embeddings it was built from and a test fails if they diverge, because a stale table parses fine and answers confidently.
The 2D map is a separate artifact: PaCMAP, n_components=2, random_state=42, over the
layout embeddings only. Nothing reads the projection for similarity.
manamap eval-embeddings scores every space against data/eval/similarity_golden.json — 40
hand-authored groups, 12 dev / 28 test. It must stay hand-authored: training mines its
positives from roles and tags, so an eval derived from those would only measure whether
training memorised its own supervision.
Three metrics, deliberately including two geometric ones, because recall alone hid the collapse for months:
- recall@k / median rank against the golden groups.
- effective dimensionality — participation ratio of the PCA spectrum,
(Σλ)²/Σλ². Reads as "how many of the nominal dimensions are actually in use"; equals d for an isotropic d-dimensional cloud and 1 for a line. - neighbour spread — mean cosine gap between the 1st and 50th neighbour. Near zero means the top neighbours are indistinguishable and whichever one is returned first is an artefact of float ordering.
Current shipped artifacts, test split:
| space | dim | eff. dim | spread | r@10 | r@50 | med. rank |
|---|---|---|---|---|---|---|
| layout (colour+type) | 128 | 3.89 | 0.0061 | 0.086 | 0.139 | 1148 |
| function (ability) | 128 | 27.31 | 0.0323 | 0.232 | 0.464 | 76 |
| text baseline (frozen MiniLM) | 384 | 51.39 | 0.1341 | 0.244 | 0.414 | 126 |
Read that table honestly. Against the previous function model — 5.97 effective dims,
0.093 recall@10, median rank 995 — the rebuild is a large win, and the current space is
clearly better at depth: r@50 0.464 vs 0.414, median rank 76 vs 126. But it is still 0.012
behind the frozen text at r@10, and the eval prints a warning saying so on every run. It
is not fixed. tests/test_embedding_quality.py also holds a deliberately still-failing
xfail(strict=True) gate on neighbour spread — 0.0323 against a 0.05 target — whose
threshold was not lowered to match the result.
Two standing rules around this harness:
- Do not tune hyperparameters on it. Sweeping the text weight looked like a win (0.258
r@10) until selecting on
devpicked a different value and the two splits disagreed. At this sample size those differences are noise. - Quote the test split.
devwas consumed while diagnosing.
| To add… | Touch |
|---|---|
| A pipeline step | STEPS in pipeline.py; the module exposes main() |
| A pilot command | PILOT_STEPS + _DECK_COMMANDS + argparse in pilot/registry.py; module exposes main(args) |
| A deck lifecycle phase | STAGES in pilot/deck_status.py, or the next person will not find it |
| A pod seat | manamap pilot fetch-opponent "<commander>", or a decklist.txt under data/opponents/<slug>/ |
| A figure the sim reports | game_facts + aggregate in sim/parse.py, then re-derive every run with --analyze — the record is compared against the logs, so an added key must be backfilled |
| A panel on the deck page | a *Panel(d) function in viz/js/deck-view.js returning '' when its artifact is absent, plus the artifact's filename in deck_manifest.gather_entries if a browser cannot list it |
| A field the deck page reads | deck_info.compose, then deck-info <slug> --write for every deck — info.json is committed and staleness-gated |
| A section of the handbook | poh_spec.SECTIONS and poh.RENDERERS — the spec declares ten sections and the map holds seven, so a section added to one alone never renders |
| A data file the viz reads | The DATA map in viz/js/mana-map.js, plus a .gitignore negation |
| A goldfish channel | a reader in goldfish_profiles.py, the casting predicate in goldfish_turn.py in the same commit, a flag the deck declares, an entry in model_coverage, and a corpus sweep in the commit message. Skip any one and the channel measures as zero on every deck |
| A metric the branch report prints | net_change.METRICS — a test asserts it matches ROWS in both directions, because a figure whose definition a reader has to look up gets guessed at |
| A synergy rule, tag, or threshold | config.py, nowhere else |
| A deckbuilding role | ROLE_PATTERNS in config.py, then re-run manamap card-roles |
| A bracket rule | BRACKETS / COMBO_BRACKET_TAGS / MASS_LAND_DENIAL in config.py |
| Builder tuning | DECK_ROLE_BUDGET / DECK_BUILD_WEIGHTS in config.py |
| An agent cache routine | AGENT_ROUTINES in config.py |
One thing that is someone's, not the project's. data/collection/*.txt lists a
physical card collection — deck-history reads it to tell a swap you already own from
one you would have to buy, and it is the only ownership question left in the repo. The
tracked files are the maintainer's, kept as a worked example. Point
MANAMAP_COLLECTION_DIR at your own boxes, or at nothing: an absent directory means no
ownership claim rather than an error.
Evidence tiers. Every artifact and every figure carries a tier: ✓ rules-verified, ◆ data-derived (seeded where randomness is involved; sampled said out loud where it cannot be), ★ coaching. A validator per artifact enforces that nothing claims a tier it was not granted — costume never earns the badge.
Validation gates — form in code, meaning by agent, publication by verdict. A mechanical
validator checks structure: does every step cite a rule, does that rule exist, is the quote
a verbatim substring. A separate adversarial agent then fetches each full rule and judges
whether it actually supports the claim. Only pass renders. Failed artifacts are kept —
they document open questions.
Determinism. Agents return JSON and never write HTML. That keeps every renderer a pure
function of committed artifacts, byte-identical on rebuild — and asserted from outside the
code that asserts it: CI runs make manuals and then git diff --exit-code, because
otherwise the Makefile is the one thing nothing checks (and it was, until make manuals broke
on CI's first run). It is also why goldfish and Forge runs are seeded, why a dated artifact
carries an authored date rather than a generated one, and why image URLs get their
cache-busters stripped.
make test # UNIT: no tracked data, ~1 min — what you run all day
make regression # REGRESSION: the tracked fleet + corpus, every producer re-run
make integration # INTEGRATION: a real browser, Forge, the pages rebuilt byte-identically
make prepush # before a push: the tiers the diff can break, by area (docs/testing.md). CI runs it all.
make test-report # measure the unit tier: counts, time, coverage (FULL=1 adds regression)Counts and runtimes live in docs/testing.md and nowhere else, with the markers, the
eight skip conditions, the regenerate-and-compare cache and the rules for writing a test.
A fresh clone skips what it cannot build and says which command would enable it, so skips
there are expected; make test-fresh is the one to trust before a PR.
The ones that cost the most; CLAUDE.md has one line per rule and the gotchas pages under
docs/ hold the measurements.
python -m manamap.pipelinestarts the full 40–60 minute run with no arguments and no confirmation, overwriting trained models. Use themanamapCLI.- Never put
data/on Git LFS — GitHub Pages serves pointer files, not content. - Index alignment:
projection[i]≡cards.csv[i]≡embeddings[i], positionally. Never partially regenerate after a card-count change. - An edit to any of the four goldfish files is a model change — its bytes are the
model_versionstamp — so it meansmanamap pilot regen --jobs 8 && make manuals. manamap serveholds the old modules. Restart it after editing Python.- Bump
?v=Non the script and CSS tags inviz/index.htmlafter any frontend edit. - Cache ordering for agents: check → spawn → write → validate → record last.
- Scryfall leaves
mana_costempty on transform and MDFC layouts — readcommon.front_field(card, "mana_cost"), nevercard["mana_cost"]. - Ownership means a BOX, never deck membership (
collection.owned_names()).
GitHub Pages serves the repo directly. There is no root index; the entry points are
/viz/workbench.html (the landing page — start here), /viz/index.html (the card
atlas), /viz/deck.html?deck=<slug> (one deck's dossier),
/viz/branch.html?deck=<slug>&branch=<name> (one candidate 99), /viz/library.html
(Curate), /viz/spaces.html (the embedding-space appendix) and /manuals/p/<slug>.html
(a deck's Pilot's Operating Handbook). Pushing to main deploys.
The version list is committed and does render. deck-version derives it by walking git,
and the commit that changes decklist.txt receives its sha after anything written in the
same commit — so versions.json is written on the following commit rather than the same
one, and the newest row can lag by one until then. That is a lag, not the structural blocker
this section used to describe: the panel renders from the tracked file like every other panel,
and a panel whose artifact is genuinely absent still renders nothing rather than an error.
docs/README.md indexes and sorts all of it — current reference above, historical design
records below, which saves reading 12,000 lines of the latter by accident.
| Doc | Covers |
|---|---|
docs/vision.md |
Start here. Who this is for, what the bench does, what is live / legacy / next |
docs/prd.md |
What is being built: three environments, five epics, the metrics catalog, and four resolved decisions. vision.md says what the bench is; this says where it is going |
CLAUDE.md |
Orientation, environment, gotchas — the densest single page |
PLAN.md |
Current state and what's next |
/publish-deck |
The deck lifecycle: every phase in order, with its gate |
docs/simulation.md |
Both engines: Forge's spike and harness, the parser, the pod, the bridge — and the goldfish channel-by-channel |
docs/pilot.md |
Evidence contract, the bench's commands and artifacts, rules and strategy DBs |
docs/architecture.md |
Models, mechanical tags, synergy rules, power creep, regions |
docs/pipeline.md |
All 15 steps: inputs, outputs, runtimes, when to re-run |
docs/data-artifacts.md |
Every data/ file: producer, size, git status, consumers |
docs/viz.md |
Frontend structure, the window.MM API, Pages layout |
docs/testing.md |
Test layout, skip markers, conventions — and the only stated runtimes |
docs/agent-cost.md |
Where LLM spend lives, per-routine costs, the cache |
The gotcha pages are the expensive part. CLAUDE.md loads into every session; these do
not, and they hold every measurement this project has paid for, verbatim. Read the one that
covers what you are about to touch.
| Page | Read before touching |
|---|---|
docs/gotchas-bench.md |
src/manamap/pilot/, src/manamap/sim/ — much the largest |
docs/gotchas-viz.md |
anything under viz/ |
docs/gotchas-evidence.md |
a validator, a citation, engine.json |
docs/gotchas-analysis.md |
src/manamap/analysis/ — synergy, power creep, roles, regions |
docs/gotchas-magazine-legacy.md |
the deleted magazine renderer, kept because it was measured |
Historical design records, for provenance only — none of it describes live code:
docs/history/manual-v5-spec.md (the compact deck page that replaced the magazine),
docs/history/agent-audit-2026-08-19.md (the audit behind the pivot), and
docs/history/prd-2026-08.md (the superseded PRD that ~27 PRD-v1 §N citations resolve against).
STYLEv3.md, the magazine's constitution, was deleted 2026-08-25; its STYLEv3 §N
comments resolve through git show 23e8cec:STYLEv3.md.
Lint and formatting are intentionally absent. Match the surrounding style; the test suite is the gate. Adding a formatter to 20,000 lines would produce one enormous diff and no information.
CI was on this list, on the grounds that a single-author project does not need it. It is
here now — .github/workflows/test.yml runs the fast suite and checks that every deck page
still rebuilds byte-identically — because a pull request from someone else arrives
unverified otherwise.
Card images and card text are property of Wizards of the Coast. This is unofficial fan content permitted under the Wizards of the Coast Fan Content Policy, not approved or endorsed by Wizards.