Written by node tools/matrix.js --report --bsdata <checkout>. A report for the tier work; nothing here reaches the site. Datasheets at BSData 374f505 (build/units.json); the spec is design/matrix.md, the maths design/scoring.md's "The matrix". Derived numbers only: no rules text, no panel grades (a panel is read for its unit names only, and locked and reference panels not at all).
What the matrix is
Every weapon profile a pool unit carries (its dice, the name thrown away) against every defender profile a pool datasheet presents (its statline, Feel No Pain, own defensive effects, Stealth and the keywords an Anti or a condition reads), one attack into a fresh defender, by ev.js's own closed form: unsaved wounds u, damage with each wound capped at a model's wounds r, wounds removed in the long run with every killing blow's excess lost e, and the variance v. Precomputed for the contexts that change many cells (cover, half range for Melta, the charge for Lance); every other context (a detachment, a leader, the unit's own and its army's rules) is a modset key, its cells computed when the jobs ask. The pool is the tier test's: every rankings.json row that isn't Legends or Crucible.
Counts
- Pool: 1,161 datasheets. Attacker profiles: 2,121 dice keys (1,304 ranged, 817 melee), 2,164 with range kept. Defender profiles: 681 on the full key (608 first groups on statline, Feel No Pain and keywords); 75 datasheets with mixed groups.
- Contexts per unit (detachments with each way to answer their choices × alone or joined × sizes × ways in, after the dedupe): median 24, 90th percentile 168, max 3,600; distinct dice contexts alone median 8, max 450. Distinct modsets: 4,508 attacking, 157 defending; 41,928 (profile, modset) rows for the cells computed on demand. Round two counted 2,174 attacking and 107 defending modsets: a vouched condition is now its own key (below), so effects that differ only by their condition no longer share one.
- The cached build (
node tools/matrix.js --build --bsdata <checkout>, folder755b80942720): layers base 2,121 × 681, cover 1,088 × 681, half 56 × 681, half-cover 54 × 681, charge 32 × 681; the unit table 1,161 × 681; 57.0 MB in all. - Timing (this PC, Node 24.21.0): load 0.6 s, contexts 1.6 s, layer:base 6.8 s, layer:cover 3.4 s, layer:half 0.3 s, layer:half-cover 0.2 s, layer:charge 0.1 s, units 25.5 s; 38.5 s in all.
Step 6: an incremental rebuild
Cells are addressed by content: an attacker's dice key and a defender's key, and a unit-table row by its whole datasheet record at its size, a column by its target key and points per wound. A build looks for the newest folder with the same cell hash (the engine files, the effects files and the layers; not the BSData commit, not rankings.json, not the transports read) and copies every cell whose row and column it holds; only keys it lacks are computed. build.json in the folder says which rows and columns were new and how many cells were computed or copied; every other file is byte-identical to a build from scratch.
The test, run here on a copy of the catalogues (the pinned checkout untouched): in Leagues of Votann.json Heavy plasma axe's S set to 13 and Sagitaur's T set to 13 (3 profiles), build_data.js on the copy, then the old data built from scratch, the new data built incrementally from it, and the new data from scratch (all five layers; the unit table's rows for Leagues of Votann and Necrons):
| Part | New rows | New columns | Their armies | Cells computed | Cells copied |
|---|---|---|---|---|---|
| layer base | 1 | 1 | Leagues of Votann | 2,801 | 1,441,600 |
| layer cover | 0 | 1 | Leagues of Votann | 1,088 | 739,840 |
| layer half | 0 | 1 | Leagues of Votann | 56 | 38,080 |
| layer half-cover | 0 | 1 | Leagues of Votann | 54 | 36,720 |
| layer charge | 0 | 1 | Leagues of Votann | 32 | 21,760 |
| unit table | Cthonian Beserks, Sagitaur | 1 | Leagues of Votann | 1,434 | 48,960 |
Every new row and column belongs to the edited army; the incremental folder equals the from-scratch one file for file (11 files, timings and build.json aside): identical. Time: the donor 13.9 s, the incremental build 1.8 s, from scratch 13.9 s (the incremental time is mostly the contexts and reading the donor; the base layer's 2,801 new cells are about 10 ms). tests/unit/matrix.test.js runs the same with two layers.
Step 7: the check
Engine parity
10,000 random (attacker, defender) pairs of the pool, base context: the cell's r against ev.js profileVs damage ÷ attacks on the same profile at a random size: largest difference 1.8e-15 (bar 1e-9). Read back from the build's float32 file: 10,000 of them, largest relative difference 5.2e-8 (float32 holds about 7 figures; bar 1e-6). Round one's test checks the same against each datasheet's own record (its keywords in full, its own Feel No Pain and effects): 1.8e-15.
Today's Offense
Each pool unit's wounds removed per 100 pts into the five stand-ins (tools/targets.js, added as five defender columns), the better of shooting and melee, summed from the cells with the squad the unit pages score into that target (rankings.json lt, T-360: each target's best squad, the average pick or the default wargear) and ev.js's weapon picking (bestProfiles, pickLines), against rankings.json's off. 1,161 units.
| Target | ρ | Median | difference | Median signed | Beyond 15% | Pass | |
|---|---|---|---|---|---|---|---|
| GEQ | 0.9999 | 0.02% | 0.00% | 0 | yes | ||
| MEQ | 0.9988 | 0.16% | 0.01% | 0 | yes | ||
| TEQ | 0.9968 | 1.57% | 0.63% | 2 | yes | ||
| Light vehicle | 0.9995 | 0.04% | 0.01% | 0 | yes | ||
| Heavy vehicle | 0.9998 | 0.02% | 0.01% | 0 | yes |
Bars: ρ ≥ 0.995 and median ≤ 3% per target. Beside it: ev.js's own number for the page's squad against rankings.json (ρ 1.0000, 1.0000, 1.0000, 1.0000, 1.0000; median 0.01%, 0.01%, 0.01%, 0.01%, 0.01%: the file is current, so every gap below is the matrix's), and the default wargear summed from the cells against rankings.json (ρ 0.839, 0.815, 0.820, 0.748, 0.742): 623 of 1,161 units are scored on a squad other than their default, which is why the spec's "default loadout" had to become the page's squad.
Reading a volley from the cells
A cell is per attack and size-free; a unit's volley into a target of m models with W wounds needs two corrections that killDP makes and a sum of cells can't: the model in progress (e charges every killing blow's excess, but the model taking damage when the volley ends has had none: on average about half its wounds are in play, VOLLEY.FRESH = 0.5, the whole volley while it is shorter) and the end of the unit (nothing past m × W is removed; with the volley about normal, mean μ and variance Σ A × v, the expected cut is E[min(X, mW)]). Both use only the stored fields. The readings tried, each target's ρ / median / units beyond 15%:
| Reading | GEQ | MEQ | TEQ | Light vehicle | Heavy vehicle |
|---|---|---|---|---|---|
| e, cut at the unit's wounds (no taper) | 0.9988 / 0.04% / 0 | 0.9961 / 0.89% / 22 | 0.9899 / 4.76% / 62 | 0.9925 / 0.16% / 29 | 0.9956 / 1.86% / 20 |
| e with the taper | 0.9999 / 0.02% / 0 | 0.9988 / 0.16% / 0 | 0.9921 / 4.17% / 56 | 0.9951 / 0.10% / 17 | 0.9965 / 1.24% / 7 |
| fresh 0.5 with the taper (used) | 0.9999 / 0.02% / 0 | 0.9988 / 0.16% / 0 | 0.9968 / 1.57% / 2 | 0.9995 / 0.04% / 0 | 0.9998 / 0.02% / 0 |
| fresh 0.75 with the taper | 0.9999 / 0.02% / 0 | 0.9988 / 0.18% / 0 | 0.9929 / 2.41% / 54 | 0.9998 / 0.04% / 0 | 0.9999 / 0.02% / 0 |
| fresh 1 with the taper | 0.9999 / 0.02% / 0 | 0.9987 / 0.18% / 0 | 0.9895 / 3.69% / 175 | 0.9998 / 0.04% / 0 | 0.9999 / 0.02% / 0 |
The plain cut fails Terminators (ρ 0.990, median 4.8%) and the light vehicle; the taper alone mends the saturated chaff and MEQ rows; the fresh model mends the vehicles and most of Terminators. 0.5 is the renewal argument's mean (the model in progress is on average half spent), not a fit; 0.75 and 1 are shown because they were tried, and both do worse on Terminators.
Outliers beyond 15%
2 (unit, target) pairs, 2 units; 0 of them graded by a panel. Causes: mixed damage 2.
| Graded by | Unit | Army | Target | Matrix | ev.js | rankings.json | Difference | Cause |
|---|---|---|---|---|---|---|---|---|
| – | Corvus Blackstar | Deathwatch | teq | 1.872 | 1.608 | 1.608 | 16.4% | mixed damage |
| – | Corvus Blackstar | Agents of the Imperium | teq | 1.872 | 1.608 | 1.608 | 16.4% | mixed damage |
Mixed damage (the Corvus Blackstar into Terminators): its D1 guns leave wounds on models that its D3 missiles then overkill. Each weapon's cell is exact alone; the waste comes from their order within the volley, which killDP plays and a sum of per-weapon cells can't. The allocation layer fires weapons in turn against a target's wounds left, so it shares the gap; it is small and named.
The fix from round two: vouched conditions
Round two counted a condition the player vouches for (when.manual: near an objective, below half strength, a Battle-shocked target…) as met all game. It is now a flag on the modset (vouch: [class, id]), and the cell read for that modset mixes the branches with and without it by the share of the game it holds: the tier test's round 5 shares (scoring.md, "Detachment context"): an objective 50%, Synapse range 75%, below half strength 35%, below Starting Strength the same (never for a unit of one model), a Battle-shocked target 20%; any other condition stays met all game, as the tier test reads it. Conditions with different words are independent; effects on one condition are met together.
What it touches. Of the 1,161 pool units, 969 have a vouched condition on an attacking effect in some context, 532 of them one with a share below 100%; 690 have one on a defending effect (counted, not measured here). Measure: each unit's pooled expected damage (wounds removed per 100 pts into the five stand-ins, the better half per target, its default wargear, the volley read as above) in its best context, a Hammer stand-in until the field exists:
| Units | Never met | Shares | Met all game (round two) | Lift at shares | Lift at 100% | Removed by the fix |
|---|---|---|---|---|---|---|
| all 969 touched | 17,032 | 19,866 | 20,441 | +2,834 (16.6%) | +3,409 (20.0%) | 574 (2.8% of the total) |
| the 532 with a share below 100% | 10,186 | 11,896 | 12,471 | +1,711 (16.8%) | +2,285 (22.4%) | 574 (4.6%) |
237 units move by more than 1% between the old reading and the shares. Conditions met by class (unit × condition): other 4,301, objective 408, shocked 272, belowHalf 220, belowStart 82, synapse 64, belowStartTarget 21, notBelowHalf 2. The largest gaps (pooled damage, never met / shares / always met):
| Unit | Army | Never | Shares | Always |
|---|---|---|---|---|
| Flayed Ones | Necrons | 42.2 | 57.6 | 68.1 |
| Dogmata | Adepta Sororitas | 14.5 | 16.4 | 24.7 |
| Nemesis Claw | Chaos Space Marines | 19.2 | 20.5 | 28.6 |
| Krootox Rampagers | T'au Empire | 30.8 | 35.1 | 42.9 |
| Kroot Hounds | T'au Empire | 13.0 | 16.4 | 23.9 |
| Celestian Sacresants | Adepta Sororitas | 42.2 | 42.2 | 49.3 |
| Mortifiers | Adepta Sororitas | 25.8 | 26.9 | 34.0 |
| Zephyrim Squad | Adepta Sororitas | 28.3 | 32.0 | 38.8 |
| Traitor Enforcer | Chaos Space Marines | 28.6 | 28.6 | 35.1 |
| Kroot Carnivores | T'au Empire | 19.0 | 22.5 | 29.0 |
Each column is the best context under that reading, so a unit can change context between columns: where Never and Shares are equal, its best context at the shares holds no vouched condition (another detachment wins), and only the old reading made the conditional one best.
Still met all game: the other class is most of the vouched conditions. The commonest, by units: “disembarked this turn” 256; “on the turn it charged” 245; “target within 12"” 197; “set up this turn” 190; “Plasma weapons only” 187; “Saga completed” 187; “left a Drop Pod this turn” 187; “target auspex scanned” 187; “unit within terrain” 187; “vs Oath target, the turn it's used” 187; “if it remained stationary” 183; “weapon already Heavy, stationary” 181; “arrived from Reserves this turn” 88; “a Space Wolves character unit (any unit once the Saga is done)” 80; “if Carmine Wrath is one of the two picks” 80. Many are ones the reference battle can price (the turn it charged or arrived, staying still, a target within 12"): a card for T-569's layer, not this round.
Keywords not read in full (T-582)
34 keywords, each kept in its profile's dice key as x:<keyword> so nothing merges before it is read; ranked by the corpus lists holding a unit they touch (list points a floor):
| Keyword | Read | Profiles | Units | Lists | List points |
|---|---|---|---|---|---|
| LETHAL HITS: NON-MONSTER/VEHICLE | loose | 26 | 39 | 141 | 17,575 |
| CLEAVE 2 | unread | 9 | 11 | 64 | 10,610 |
| SUSTAINED HITS D3 | loose | 13 | 14 | 41 | 8,730 |
| CLEAVE 1 | unread | 13 | 13 | 41 | 5,975 |
| BLAST 1 | loose | 14 | 17 | 51 | 5,015 |
| CONVERSION | unread | 5 | 7 | 25 | 4,830 |
| ANTI-MONSTER/VEHICLE 4+ | unread | 10 | 10 | 34 | 4,475 |
| RAPID FIRE D6+3 | unread | 2 | 4 | 13 | 4,425 |
| BLAST 2 | loose | 6 | 13 | 41 | 4,140 |
| TWIN LINKED | unread | 5 | 11 | 46 | 3,975 |
| - | unread | 5 | 4 | 20 | 2,050 |
| ANTI-MONSTER/VEHICLE 3+ | unread | 3 | 3 | 13 | 1,980 |
| HARPOONED | unread | 1 | 1 | 5 | 1,250 |
| REVERBERATING SUMMONS | unread | 1 | 2 | 4 | 1,060 |
| CLEAVE 2+ | unread | 1 | 1 | 7 | 980 |
Each one read changes ev.js's numbers and so the live Offense: T-582 carries them with that rule.
For the supervisor
Hand-written; --report keeps this section when it regenerates the rest.
Decided alone this round.
- The check compares the unit pages' own squads, not the default wargear. design/matrix.md section 5 says "the unit's default loadout"; 623 of 1,161 units are scored on another squad into at least one target (rankings.json
lt, T-360), and against the default the check sits at ρ 0.74 to 0.84. Summed with the page's squad it measures the matrix, not the loadout solver. The spec's wording should follow (section 5, test 2). - A volley is read from the cells with two corrections:
VOLLEY.FRESH= 0.5 (the model in progress) and the normal taper at the unit's wounds, both from the stored u, r, e, v. The plain sum cut at the wounds fails Terminators (ρ 0.990, median 4.8%) and the light vehicle (ρ 0.9925). 0.5 was chosen from the renewal argument; 0.75 and 1 were also run and are in the table above. It is a read-time rule, so it belongs to T-569's allocation as much as here; section 4.2 of the spec should name it. - The vouched-condition classes follow scoring.md's wording, not tiertest.js's regular expressions: "within half range" is
otherhere (tiertest.js's /half/ reads it as below half strength, 35%), and "isn't below half strength" is 65%, not 35%. A condition on the target's Starting Strength takes 35% whatever the attacker's size. Effects on the same words are one condition; different words are independent. - A defending effect conditioned on the attacker's keywords is not met in a cell until the attacker's unit is known (
branches,attKw). - Incremental builds use a cell hash that leaves out the BSData commit, rankings.json and the transports read; the unit table's rows are keyed on the whole datasheet record, so any change to a datasheet recomputes its row.
Doubts.
- The vouched fix leaves most of the vouched conditions at 100% (
other, 4,301 unit-conditions against 1,069 with a share). Several are things the engine could check today (“Plasma weapons only” by the weapon's name) and many the reference battle could price (the turn it charged, arrived or disembarked; a target within 12"). Worth a card under T-569. - The context counts grew (attacking modsets 2,174 → 4,508; max contexts per unit 2,992 → 3,600) because a vouched condition now keys its own modset; the on-demand cells grow with them (41,928 profile-modset rows), still small at about 4 µs a cell.
swapSix(a Miracle or Fate die) sits in some modsets butcellreads it throughprofileVs, which ignores it (unitVsUnit plays it). Not new this round; the allocation layer should play it as the unit table does.- The incremental test edits a copy of one catalogue that nothing imports. A change to a library catalogue (Aeldari, Astra Militarum, Tyranids, Titans) touches every army importing it, which is right but will compute more.
Paste-ready for design/decisions.md:
3 Oct: the matrix, round three: incremental builds, the check, and vouched conditions at their shares (T-568)
- Built: tools/matrix.js steps 6 and 7. Cells addressed by content (attacker dice key, defender key; unit-table rows by datasheet record, columns by target key and points per wound); an older build with the same cell hash donates every cell it holds.
--check: engine parity and today's Offense;--reportwrites reports/tiers/2026-10-03-matrix.md. scoring.md section 7b.- Fixed from round two: a condition the player vouches for was met all game. It is now a flag on the modset, met for the tier test's round 5 shares (objective 50%, Synapse 75%, below half and below Starting Strength 35%, Battle-shocked 20%; other conditions still all game). Pooled over the 969 units it touches, the lift over never-met falls from 3,409 to 2,834 wounds per 100 pts (574 of 20,441 removed; 237 units move by more than 1%).
- Numbers: parity 1.8e-15 (float32 read-back 5.2e-8). Today's Offense from the cells, with the unit pages' squads: ρ GEQ 0.9999, MEQ 0.9988, TEQ 0.9968, light vehicle 0.9995, heavy vehicle 0.9998; medians 0.02% to 1.57%; 2 pairs beyond 15% (the Corvus Blackstar into Terminators, mixed damage), no panel-graded unit among them. One army's edited catalogue: 1 new row and 1 new column, 2,801 of 1.44 M base cells computed, 1.8 s against 13.9 s from scratch, the result byte-identical.
- Learnt: a size-free cell needs two read-time corrections to reproduce a finite volley: the model in progress (
VOLLEY.FRESH= 0.5) and the unit's last wound (a normal taper with the stored variance). The plain long-run sum fails Terminators. The spec's "default loadout" in the check had to become the page's squad: half the pool is scored on another.- Kept: round two's keys, layers and unit table; the check's bars as the spec set them.
- Next: T-569 steps 3 to 7 read the matrix; most vouched conditions (
other) wait for the reference battle to price them.
Paste-ready for design/tier-engine.md section 7 (to replace "the matrix (T-568)" in Not built and add to Built):
The matrix is built (T-568, 3 Oct): every weapon profile against every defender profile, cached by content so a rules update recomputes only what changed, and checked against today's numbers: summed from the matrix, each unit's damage into the five stand-ins agrees with the unit pages to a median of 1.6% or better per target (ρ 0.997 to 0.9999). Nothing on the site reads it yet; T-569's field and allocation will.