Tactical Reroll
⋯

The attacker × defender matrix (T-568, rounds one to three)

From reports/tiers/2026-10-03-matrix.md , rendered when the site is built.

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

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

PartNew rowsNew columnsTheir armiesCells computedCells copied
layer base11Leagues of Votann2,8011,441,600
layer cover01Leagues of Votann1,088739,840
layer half01Leagues of Votann5638,080
layer half-cover01Leagues of Votann5436,720
layer charge01Leagues of Votann3221,760
unit tableCthonian Beserks, Sagitaur1Leagues of Votann1,43448,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ρMediandifferenceMedian signedBeyond 15%Pass
GEQ0.99990.02%0.00%0yes
MEQ0.99880.16%0.01%0yes
TEQ0.99681.57%0.63%2yes
Light vehicle0.99950.04%0.01%0yes
Heavy vehicle0.99980.02%0.01%0yes

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

ReadingGEQMEQTEQLight vehicleHeavy vehicle
e, cut at the unit's wounds (no taper)0.9988 / 0.04% / 00.9961 / 0.89% / 220.9899 / 4.76% / 620.9925 / 0.16% / 290.9956 / 1.86% / 20
e with the taper0.9999 / 0.02% / 00.9988 / 0.16% / 00.9921 / 4.17% / 560.9951 / 0.10% / 170.9965 / 1.24% / 7
fresh 0.5 with the taper (used)0.9999 / 0.02% / 00.9988 / 0.16% / 00.9968 / 1.57% / 20.9995 / 0.04% / 00.9998 / 0.02% / 0
fresh 0.75 with the taper0.9999 / 0.02% / 00.9988 / 0.18% / 00.9929 / 2.41% / 540.9998 / 0.04% / 00.9999 / 0.02% / 0
fresh 1 with the taper0.9999 / 0.02% / 00.9987 / 0.18% / 00.9895 / 3.69% / 1750.9998 / 0.04% / 00.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 byUnitArmyTargetMatrixev.jsrankings.jsonDifferenceCause
–Corvus BlackstarDeathwatchteq1.8721.6081.60816.4%mixed damage
–Corvus BlackstarAgents of the Imperiumteq1.8721.6081.60816.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:

UnitsNever metSharesMet all game (round two)Lift at sharesLift at 100%Removed by the fix
all 969 touched17,03219,86620,441+2,834 (16.6%)+3,409 (20.0%)574 (2.8% of the total)
the 532 with a share below 100%10,18611,89612,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):

UnitArmyNeverSharesAlways
Flayed OnesNecrons42.257.668.1
DogmataAdepta Sororitas14.516.424.7
Nemesis ClawChaos Space Marines19.220.528.6
Krootox RampagersT'au Empire30.835.142.9
Kroot HoundsT'au Empire13.016.423.9
Celestian SacresantsAdepta Sororitas42.242.249.3
MortifiersAdepta Sororitas25.826.934.0
Zephyrim SquadAdepta Sororitas28.332.038.8
Traitor EnforcerChaos Space Marines28.628.635.1
Kroot CarnivoresT'au Empire19.022.529.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):

KeywordReadProfilesUnitsListsList points
LETHAL HITS: NON-MONSTER/VEHICLEloose263914117,575
CLEAVE 2unread9116410,610
SUSTAINED HITS D3loose1314418,730
CLEAVE 1unread1313415,975
BLAST 1loose1417515,015
CONVERSIONunread57254,830
ANTI-MONSTER/VEHICLE 4+unread1010344,475
RAPID FIRE D6+3unread24134,425
BLAST 2loose613414,140
TWIN LINKEDunread511463,975
-unread54202,050
ANTI-MONSTER/VEHICLE 3+unread33131,980
HARPOONEDunread1151,250
REVERBERATING SUMMONSunread1241,060
CLEAVE 2+unread117980

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.

Doubts.

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; --report writes 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.