bayes for days
sds plan training.md
140 kB


id: training title: Weights fitted from recorded play status: blocked dependsOn: [features, harness] exitCriterion: > A fitted M beats the hand-authored M by an interval that excludes zero, on a suite run the hand-authored M was not tuned against. #

training #

Only M is fitted. Features stay hand-written and named; that is the whole containment strategy.

Label granularity decides feasibility. Win/loss gives one sample per match and needs ~10^5-10^6 matches - infeasible. BV traded over the next N rounds gives ~240 per side per match, so 300-500 matches is a comfortable fit for ~240 parameters. That is one overnight run.

Compute is not the constraint: ~0.1 core-hours a match, so 20,000 matches is ~$24 of Graviton spot. The constraints are a bot worth learning from and a harness that does not discard a quarter of its games.

Keeping a corpus #

A 360-match corpus - 21937 decisions, about eight hours of machine time - was destroyed by one git worktree remove --force. runs/ is gitignored and inside the worktree, and the only other reference to it was a directory of symlinks in a scratchpad, so there was nothing left that even named what had been lost.

A corpus is now stored rather than left where it was written.

  • The store is corpora/ beside the main checkout, found through git rev-parse --git-common-dir so it resolves to the same place from any worktree. Removing a worktree cannot reach it.
  • Files are copied, with shutil.copy2, which follows symlinks. A store of links into a directory somebody else may delete is the failure this exists to prevent, not a cheaper version of it.
  • corpora/<name>/data/ is gitignored and corpora/<name>/manifest.json is committed, so a corpus that is gone is still described: commit, epoch, suite fingerprint, bot command, weights file, exploration rate, counts, and the sorted list of every feature name in it.

That last field is what the manifest is for. The same overnight corpus was fitted against a newer feature set than the one that recorded it, and nothing objected - only thin_columns caught a downstream symptom, hours later.

sds train now reads the manifest before the logs, on the rule sds/baseline.py already applies to a benchmark: what changes the meaning of a number travels with the number, and a mismatch is an error rather than a footnote.

mismatch what happens
a different epoch refused, naming each epoch change in between
a --feature the corpus does not have refused, naming which of the ones asked for are absent
data the manifest does not describe refused; the two were not written together, so nothing in the manifest is reliable
a feature the baseline M uses that the corpus lacks warned, before the report and again after it

--stale-ok fits a refused corpus anyway and prints what is wrong.

The last row is a warning rather than a refusal on purpose. A decision only logs the features its own phase computes, so every corpus recorded today is missing the nine positional ones - refusing would fire on every fit anybody runs, and a guard that fires every time is a guard that gets --stale-ok without being read. It is still worth saying loudly: a fitted vector with no weight for exposure plays a different bot than the hand-authored one it is measured against.

The rule was written down, and then walked into anyway #

20260827T030350Z-bench - the asymmetric corpus, 330 matches and 7568 movement decisions - was destroyed on 2026-08-29 by git worktree remove --force on .claude/worktrees/asym, after claude/asymmetric-forces merged. Removing a worktree when its branch lands is what CLAUDE.md says to do; the corpus was in that worktree's gitignored runs/ and had never been copied into the store. There is no other copy on this machine.

That is the same failure as the 360-match corpus above, on a machine where the lesson was already written down, with the tool to prevent it already built. The tool was not the problem: sds corpus save works. Remembering to run it is the problem, and it has now been forgotten twice.

The surviving half was in the same position. 20260826T021818Z-bench - the mirrored corpus, 300 matches, 8214 decisions, every first figure in TACTICS.md - was still in .claude/worktrees/corpus-run/runs/. It is now corpora/tactics-hub-mirrored.

Four of the seven older corpora have also lost their data and survive as a manifest only: eval-r7-suite, princess-opponent-r7, retired-basis-r5, subset-menu-r6. That is the manifest working as designed - a corpus that is gone is still described - rather than a new failure.

The fix is that saving is no longer a flag. sds bench now saves any run that recorded decisions, naming it after the run's own timestamp when --corpus was not given; --corpus only chooses the name. See corpus.save_run, and TestARecordingRunIsSavedWithoutBeingAsked for the behaviour a change would have to break.

A manifest asserting data that is not there #

honest-movement-r8 is in the store with a manifest claiming 5944 decisions and holding zero decision logs - 242 results, 120 host logs, 120 HTML reports, and nothing to train on.

A first version of this section blamed CORPUS_GLOBS, and was wrong. The claim was that the corpus predated the glob learning .decisions.jsonl.gz, so scan counted compressed logs the copy loop could not see. The dates refute it: the suffix landed on 2026-08-26 in 174c89e, and this corpus was saved on 2026-08-23 - three days earlier, when compression was not yet a practice at all. Its logs were plain .decisions.jsonl, which the glob already covered. They copied.

The real explanation was already written down in harness, unread: five stored corpora have no decision logs because they were deleted for space. corpus save copied them; something later removed them. That is a different defect in a different place, and inferring a cause that fit rather than checking the two dates is the same mistake this file is a list of.

It matters for where the fix goes. The verification added here would not have caught this: it compares the source against the store at save time, and this failure happened afterwards. What would catch it is an integrity check at read time, or on a schedule - nothing currently notices a corpus whose data was removed after it was stored.

The save-time check is still worth having, for the failure it does cover: the count and the thing counted diverging while the corpus is being written. copied was non-zero because results and reports copy whether or not any training data does. save now compares the decision logs in the source against those that landed, refuses on any difference, and removes the half-written corpus rather than leaving it. TestTheManifestCannotDescribeDataThatIsNotThere is that case.

Either way this corpus is worse than the four that are simply gone. Those describe themselves honestly as manifest-only; this one satisfies every check anybody thinks to run and yields agreement 0.000 everywhere, or a fit on nothing, to whoever reaches for it next.

For jmm, because it sets the precedent for the other four. Not actioned here: editing somebody's recorded asset is their call, and whichever way this goes is how the manifest-only corpora get treated from now on.

The evidence, in corpora/honest-movement-r8/:

manifest.json claims 5944 decisions, 120 matches, epoch 4, commit 135405d
data/ holds 242 .json, 120 .log, 120 .html
data/ decision logs zero

Recommendation: correct the counts to zero, do not delete. Deleting trades a lie for an absence and loses the one thing about it that is still true - the provenance block: commit, epoch, basis, bot command, weights file, exploration rate. That block is the only surviving description of the corpus fitted-r8 was fitted from, and fitted-r8 is still in weights/ and still benchable. Zeroing the counts stops the assertion without destroying the record, and the store already carries four manifest-only entries that read honestly; this would join them rather than becoming an eighth absence.

The save stamped the saver's provenance, not the run's #

Found by reading the manifest the rescue had just written, and it is the most dangerous of the three found tonight because it defeats the guard rather than tripping it.

stamp writes a manifest into a run directory as the run starts, carrying the commit, epoch and basis the matches were played under. save then overwrote all three with head_commit(), EPOCH and current_basis() - the saving checkout's. That is correct exactly when the run and the save are the same checkout, which is true of sds bench and false of every rescue.

tactics-hub-mirrored was saved from a worktree carrying the corrected cluster grouping, and came out claiming to be something it is not:

stamped by the save the run's own truth
basis ab0cad038be8 4a963bc90ee9
commit 03e72bf-dirty 1d6bb72-dirty
recorded 2026-08-29T04:52:37Z 2026-08-26T02:18:18Z

Why that is worse than a wrong label. sds train refuses a corpus whose basis differs from the current build - the check that exists because "a fit against a corpus recorded with an older feature set produced a weight vector nobody could tell from a good one". A corpus mis-stamped with the current basis passes that check. The fit would have run, silently, scoring phi recorded under one basis as though it were another.

save now takes commit, epoch, basis, recorded, image and scenario from the run's own stamp when it has one, computes only the counts itself, and says loudly on stderr when a run carries no stamp that its provenance is being taken from the checkout. TestProvenanceComesFromTheRunNotTheSaver is the case. The stored manifest has been corrected in place and its notes say so.

What re-recording the asymmetric corpus would cost #

Costed so it can be decided rather than guessed at; not started, because it is a budget call rather than a technical one.

The mirrored corpus's own 300 matches are the measurement: mean 109s, median 92s, p95 232s, 9.0 core-hours of match compute for 300 4v4 matches against Princess at 0.35 exploration.

matches 330, to match what was lost
match compute ~9.9 core-hours
wall-clock at --jobs 2 ~5 hours on a quiet machine
wall-clock at --jobs 2 under contention ~7-8 hours; a loaded machine measured 1.5x slower
disk ~4 GB
the suite minutes - sds scenarios --asymmetric is deterministic given its seed

It would not have restored the figures. The suite seed and flags that drew the original are not recorded anywhere: they would have been in the manifest, and the manifest was never written. A re-run would have been a second asymmetric corpus rather than a reproduction, so what it bought was a live second opinion going forward, not a recovery.

Answered a different way, and better. The costing above was never spent. ASYMMETRIC is gone from the vocabulary entirely, replaced by BERSERKER - advance-arm-engage, 24 matches of self-play on twelve forces that all classify Berserker/Close. That is a different population rather than a reconstruction of the lost one, which is the property a second corpus needs, and it costs 24 matches rather than 330.

The general lesson is worth more than the arithmetic: a destroyed measurement is not always worth reconstructing. What a second corpus is for is disagreeing with the first, and any population that can do that will serve. The figures in the lost one stay unrepeatable, and nothing now cites them.

--feature cannot tell "all of them" from "my list failed" #

Filed accurately rather than as the defect it first looked like. --feature is action="append", so argparse never receives an empty list - it receives no flag, which legitimately means "fit every learnable column". A caller whose generation step failed and one who genuinely wants the whole basis arrive identically, and train cannot distinguish them because by the time it looks, the difference has already been erased by the shell.

That is what turned a silent failure into a ten-minute fit that looked like an answer. The generator wrote an empty file, $(cat ...) expanded to nothing, and the fit ran on all fifty columns while claiming to be an ablation of forty-nine.

The fix belongs in the caller and is there now: ablate-features.py resolves a corpus name through the store or takes a path, refuses a feature that is not learnable, and refuses if fewer than two would remain. It emits nothing only when it has already exited non-zero.

But the tool could remove the ambiguity rather than relying on every caller:

Nineteen instances of one mistake #

Written as one section rather than left spread across four epics, because they were found separately over one night and are the same mistake nineteen times: a proxy was consulted where a direct check was available and cheap.

Each proxy is true in the common case, which is why none of them looked wrong. Each fails in the case somebody eventually hits.

Read this one first #

Two watchers - one to cut a benchmark at a deadline and save its corpus, one to start the fit afterwards - polled pgrep -f 'sds.cli bench' to decide whether the benchmark was still running.

That pattern never matches the benchmark. Its command line is sds.cli --timeout-ms 60000 bench, with the flag between the two words. What it did match was the launcher shell of each watcher, because the shell command that started them contained the literal string the pattern was looking for.

So both waited correctly for two hours, for entirely the wrong reason, and nothing in their behaviour distinguished that from working. Had the unrelated shell exited first, deadline-stop would have logged "bench finished on its own", never cut the run and never saved the corpus; chain-refit would then have fit against a corpus still being written and reported the result.

It has every property this section is about at once: a proxy that looked exactly like the check, green-looking behaviour that proved nothing, a failure that would have produced a real-looking number about nothing, and a direct check available the whole time. It is also the only one caught while still live, so it is a near-miss rather than a post-mortem.

The fix is not a better pattern. It is not using a pattern. A pgrep pattern is a description of a process; a pid is a name for one. Reading a recorded pid and asking kill -0 is not a more careful version of the same idea, it is a different idea, and no amount of care makes a description into a name.

Two things go with it. A watcher now logs the pid it is watching on start - one that cannot say what it is waiting on cannot be checked by anybody, including its author. And where a pid genuinely is not available, pgrep -x -f requires the whole command line to equal the pattern, which is verifiable from a detached script so the checking shell is not in its own match set.

the proxy stood for the direct check how it failed
--corpus was passed this run is worth keeping does the run have decision logs? two corpora recorded, neither saved, nothing said so
copied > 0 the training data arrived are the source's decision logs in the store? honest-movement-r8: 5944 decisions asserted, zero logs
head_commit(), current_basis() what recorded this corpus read the run's own stamp a rescue stamped three-day-old data with tonight's basis
the branch has merged nothing valuable is in this worktree ls runs/ the asymmetric corpus, 330 matches, gone
this scratch dir has fewer files it is the stale one lsof, or which process holds it destroyed a running measurement
sweep returned it measured something is read over the noise threshold? a precision table of 0.000 with standard errors of 0.0000
--label's default the recipe the lineage fits with read the fit block of the vector being compared against a vector four to nine times larger, readable as "the damage model moved everything"
-j 2 I am not taking the machine ps, once a test binary at 710% CPU while four agents shared the box
value_destroyed == 0 the target's armour is intact read what value_destroyed sums an ablation that answered about the wrong half of a column
a corpus's name the corpus I mean the commit and basis in the manifest about to be written one corpus stamped with another's provenance
pgrep -f 'sds.cli bench' the benchmark is still running the pid, and kill -0 above
a field appears in a put call the wire always carries it read the guard around the put two fields required that are Mek-only and prone-only
prone re-derived from the entity the prone the node was written with read it off the node two different moments compared as one
a fault label which check raised it one label per check canStand listed twice, both raising the same name
canMakeAntiMekAttacks() the unit has the weapons for it read the mounted weapons a capability answering a different question
a corpus name passed where a path was wanted the corpus's location resolve the name through the store, or take a path the generator wrote nothing, silently
no --feature flags reaching train the caller meant "fit everything" check the generated list is non-empty before using it a ten-minute fit that was the same fit twice
a hardcoded (absent) in a print format the value was absent print what was measured read my own placeholder back as evidence
pgrep -f 'fitted-r13-no-crits' that fit is still running pgrep from a script, or check the log's last line reported a finished job as running for twenty minutes

Two of the six are operational rather than code, and they are not a different lesson: the worktree removal and the scratch deletion each had a one-command direct check available and used a plausible signal instead.

Four of the fifteen are the battle-armor agent's, found while sweeping 1188 designs through the observation. The sweep failed all 1188 three times running, and every time the checker was what was wrong rather than the wire. Their third is a shape none of the others has: canStand appeared twice in the required list and both checks raised the same fault name, so fixing one changed the count by nothing - which read as evidence the wire was at fault. A diagnostic that cannot distinguish its own causes sends you looking in the wrong place, which is worse than one that is merely silent.

Their two general formulations are the sharpest anyone produced, and both are theirs:

A proxy over a fixture only fails when somebody changes the fixture, so a long-green proxy measures incuriosity rather than correctness.

That is why every instance here was old and none was caught by CI. Green for months is not evidence; it is the absence of anyone having looked.

What caught mine was not review but widening the fixture for an unrelated reason.

True of two of theirs and of several here. If it generalises, the practical defence is less about care at the moment of writing and more about periodically changing what a test is pointed at. It sits beside the reading rule below rather than replacing it: that one is about how you read a result you are looking at, this one is about how you find the ones you never read at all.

The nineteenth is the eleventh, made a third time, an hour after it was written up as the one to read first. A fit was reported as "still running at twenty-plus minutes" against a ten-minute baseline. It had finished eleven minutes earlier; pgrep -f was matching the harness shell whose command line contained the string being searched for. The direct checks each took seconds - read the log's last line, or run pgrep from a script that does not contain the literal - and either would have said so immediately.

What is worth keeping is not the repetition but what flagged it: the anomaly pointed the wrong way. Fitting fifty columns cannot be slower than fifty-one. An unexplained figure that runs opposite to the mechanism is the same signal as an answer that is too clean, and it is the one that has caught the most tonight.

The last three came from one attempt to measure one column, and they are worth reading together because they compound rather than merely accumulate.

A script was handed a corpus name and looked for it as a path. It failed and wrote an empty file. The shell expanded that empty file to nothing, so sds train received no --feature flags at all - which legitimately means "fit everything". The ablation was therefore the same fit twice, and it ran for ten minutes and produced a well-formed answer.

That answer was r-squared +0.00000000, agreement +0.00000000. It confirmed the prior - that the column is a re-expression - so it was not interrogated. What broke it open was that it was too clean: a genuinely refitted design matrix should perturb something, and not one weight in fifty had moved. That is the rule three sections below, applied to my own output twenty minutes after writing it down.

The third is the nastiest and has no upstream at all. An inspection script printed expected_criticals +0.0122 -> (absent), and (absent) was a literal in the format string, not a check. Every other proxy here is a description standing in for a measurement; this one is a conclusion printed without being computed. It survives review perfectly, because the output says exactly what a correct output would say.

And the name-as-path mistake was made hours after fixing the same mistake elsewhere, in a file written afterwards, by someone who had just written the entry about it. That is stated plainly rather than softened because it is the strongest evidence for the claim below: this is not a thing careless people do.

Three of them were made while fixing the others, which is worth stating plainly rather than filing as bad luck. The scratch-directory deletion happened while chasing a corpus defect; the corpus mis-stamping happened while writing the tool that records a corpus's scope; the watcher pattern was written to protect a run from exactly the class of accident it would have caused; and the name-as-path bug was re-made in a new script hours after being fixed in another one, by the person who had written the entry about it. Working on this class of bug does not confer immunity to it - it is not something careless people do, it is something everybody does, including while actively looking for it.

The battle-armor agent's four sharpen that rather than merely adding to it: all four were in a checker, and the checker failed 1188 designs three times running while the thing it was checking was correct. A tool written to find this class of mistake is not exempt from it, and when it makes one the mistake arrives wearing the authority of a diagnostic.

Why the wrong check survives long enough to matter. The value_destroyed one is the clearest case and the mechanism generalises:

I answered a different question than the one I was asked, and did not notice because the answer came out convenient.

A check that confirms what you already believe gets less scrutiny than one that contradicts it, and that asymmetry is invisible from the inside. Nothing in the output looked wrong - the number was real, it was simply about something else. The defence is not more care. It is preferring the check that could embarrass you, and being suspicious in proportion to how well the answer fits.

A test that mutates what it tests on is not a dry run, whatever it is called. fix-manifest-scope.py was run against a real corpus "to prove it worked", and proved it by working - on the wrong one. There was no read-only mode because none was asked for.

The consolation is that the machinery added for the third defect caught the tenth. A manifest's record of the commit and basis that produced it is what refused to let another corpus's claim be written onto it:

tactics-hub-mirrored/manifest.json was recorded at commit '1d6bb72-dirty' on
basis '4a963bc90ee9'; this scope is about 03e72bf / ab0cad038be8.
Refusing to annotate a corpus it is not about.

What separates the dangerous ones from the merely wrong. Four announce themselves eventually - an absent corpus is noticed, a table of zeroes looks odd. The provenance one does not: it makes sds train's staleness check pass, so the fit runs and the output looks like every other fit. A proxy guarding a guard is worth more scrutiny than a proxy guarding a result.

The rule that falls out, and the one worth applying to the next thing written here: if the direct check is one command, it is not too expensive. Every proxy above was chosen for convenience, not for cost.

The fit streams #

sds train was killed by the OOM reaper on a 573-match, 28 GB corpus, and took a benchmark that was running beside it with it. The reader held every decision, each with every candidate's feature vector, so peak memory was the corpus.

Least squares needs XᵀX and Xᵀy and nothing else, and both are sums over rows. So nothing has to be held: read a log, form that decision's chosen - rejected rows, add them into the two moment matrices, drop them, move on. Normals in sds/train.py is that accumulator and scan_run is the pass. Peak memory is one decision plus a matrix that is one row and column per feature - about 110 MB on the corpus that killed the old reader, and the same on 24 matches as on 573.

Three things had to survive it.

  • Every diagnostic stays exact. dead_columns and thin_columns are per-column counts of non-zero entries, which is a running total like anything else. Uncentred R² is Σ w y² - 2 bᵀ Xᵀ W y + bᵀ Xᵀ W X b, which the same two matrices answer. Nothing was turned into a sample.
  • Which columns may be fitted is not known until the last log is read - a feature that is local in any decision is excluded from every one - so columns are discovered as they arrive and dropped afterwards. Dropping one is taking a submatrix: gram[i][j] is a sum that never knew the other columns existed, so the numbers are the same to the last bit as a full design matrix in the same row order would have given.
  • Agreement needs a second pass. It is the share of decisions the fitted weights would have picked the same way, and the weights do not exist until the first pass is over. fit_scan streams the logs again rather than keeping them: memory stays flat and the number stays exact. A sampled diagnostic that reads like a measured one is worse than none.

Determinism is unchanged and comes from a different place than it used to. Corpus.rows() sorts because a directory listing is not a promise; the stream gets the same guarantee from sorted over the filenames and file order within each, so two fits of one directory give the same numbers. It is not the same order, and floating-point addition is not associative, so the last bits differ from the in-memory fit - on corrected-basis-explore the two agree to about twelve significant figures and the printed report is byte-identical. TestDeterminism.test_row_order_does_not_move_the_answer is the standing check that row order does not move the answer at all.

sds corpus save scans the same way, for the same reason.

The label #

sds train --label picks one of sixteen. Two are differentials and are kept only for comparison; fourteen are one-sided and are what a fit should use.

label one-sided what it is
final_bv_differential no (ours_left - theirs_left) / ours_start, given to every decision in the match
bv_delta_next_3_rounds no the same differential, moved over the three rounds after the decision
bv_loss_inflicted yes discounted enemy BV removed, from the decision's round to the end
bv_lost yes discounted BV of ours removed, same window
damage_inflicted yes discounted enemy armour and structure removed
damage_taken yes discounted armour and structure of ours removed
kills_secured yes discounted enemy units removed, as a share of what they started with
bv_trade_ratio yes enemy BV removed against ours lost
damage_trade_ratio yes damage inflicted against damage taken
damage_efficiency yes damage inflicted against what our own guns could have landed
tempo yes enemy BV removed, per round it took to remove
mission_kills_inflicted yes enemy units removed from the fight, killed or crippled
dispersion yes how spread out our force stood, as a share of the board
rear_share_dealt yes the share of the damage we dealt that landed on rear armour
rear_share_taken yes the share of the damage we took that landed on ours
shutdowns_suffered yes our machines heat switched off, per machine we brought

A differential is symmetric under mutual disengagement. If neither side trades it does not move, so declining to fight scores exactly what fighting well scores. That is not a subtle defect. An eight-hour run of 17 iterations, 680 matches and 21937 decisions fitted overkill +0.21, p_kill -0.07 and target_breach -0.08 - that killing things is bad - and every one of the 17 vectors lost every decided game, 0 wins in 179, with Princess finishing on 96.6-99.4% of its battle value. A bot that never engages is a faithful maximiser of that label. R-squared was flat at ~0.11 from 40 matches through 360, so sample size was never the constraint, and off-policy exploration was already running at ~23% of decisions.

A one-sided label counts one side's losses and nothing else. A round where neither side traded scores zero in every one of them.

Why this many #

Not a fishing expedition. plan/tactics.md names a strategy only when it has an objective to fit it against, and M's rank is bounded by the number of independent labels - so the label list is the ceiling on how many distinct behaviours this bot can ever have. Each pair below is a distinction a single label cannot make.

  • Damage against battle value. BV is recalculated from what still works, so chipping armour barely moves it while blowing a gun off drops it hard. A unit that trades badly in damage and well in BV is doing something specific.
  • Inflicted against taken. Avoidance needs damage_taken for the same reason engagement needs damage_inflicted: without it, exposure never sees a signal that is actually about being shot at.
  • Trading against realising. damage_efficiency is not an exchange rate. It is damage landed over what our own guns could have landed, and it catches failures no exchange rate can see: firing at extreme range where nothing hits, having no line of sight, sitting shut down from heat, walking a turn instead of shooting. All of those waste potential without moving a trade ratio.
  • Kills against damage. kills_secured needs no new instrumentation - the per-round units count already gives it - and it is the sharpest test of whether the label was ever the problem.

Six more axes, and the two that were not built #

Ten labels is not ten behaviours. Correlated over a real corpus they collapse to about five independent directions: bv_loss_inflicted sits at +0.97 with damage_inflicted and +0.94 with kills_secured, and the two trade ratios at +0.96 with each other. Adding an eleventh of the same shape buys nothing. Each of these was picked against a feature, or a family of features, that had no outcome to be right or wrong about.

  • tempo - nothing measured how fast. A win in round 5 and a win in round 15 scored the same, and 112 of 555 overnight matches hit the round cap. Enemy BV removed after the decision, divided by the rounds it took: undiscounted, because gamma already prefers a sooner payoff but cannot tell a five-round match from the first five rounds of a fifteen-round one. It does not pay a unit for dying fast. A fast death shortens the match too, and a "shorter is better" label would reward it - so the numerator is one-sided. Get wiped out in round three having taken nothing off them and the label reads zero, which is the worst score it can give.

  • mission_kills_inflicted - p_mission_kill was a feature with no label. A count of enemy machines no longer in the fight, killed or crippled, as a share of what they brought. crippled is MegaMek's own Entity.isCrippled rather than a rule re-derived here. It overlaps kills_secured by construction and the overlap is the point: legging a Mek removes it for a fraction of what a kill costs, and only a label that counts both can prefer the cheaper one.

  • dispersion - a force-shape outcome, pairing with cohesion and concentration. Mean distance between two of our machines over the board's span, averaged over the rest of the match rather than summed, so it is not partly a measure of how long the match ran. The only label with no expected sign in either direction: whether spreading out is good is the question, not something already known, so the sign check is skipped rather than run against a guess.

  • rear_share_dealt / rear_share_taken - rear_arc_gain and rear_arc_exposure were features with no outcome. Rear armour over all the damage in the same window, both directions. A share, not an absolute. Written first as absolutes they measured the wrong thing: rear points landed correlated +0.85 with damage_inflicted and rear points taken +0.86 with damage_taken, because an absolute mostly says "did we land anything at all" rather than "did we get behind them". Dividing by the damage over the same window decorrelates them almost completely - both drop to a maximum |r| of 0.19 against any other label. Undefined where nothing was hit, and None rather than zero there: a window in which nobody shot anybody has no rear share, and calling it zero would fill the column with rows claiming a failure to flank. That costs about a third of the rows and is worth it. The honest limit stays: MegaMek records where a shot landed and never which way it came from, so this is damage that arrived on rear armour, a subset of damage that arrived from the rear arc - a rear shot into a leg is invisible here.

  • shutdowns_suffered - a count of the times heat actually switched one of our machines off, on the rise, so one shutdown lasting three rounds is one event. The most independent label in the set at a maximum |r| of 0.24 against anything else - and also the sparsest: 3 shutdown unit-rounds in the first eight matches of the 96-match batch.

    It does not rescue the heat features, and no label can. It was added in the belief that heat_shutdown_risk (0.2% non-zero) and heat_ammo_explosion_risk (0.0%) are dropped for want of something to correlate against. They are not. thin_columns drops a column for being nearly all zeros in the difference rows, which is a property of the feature and the corpus and has nothing to do with the label - the same four heat columns dropped at the same percentages fitting tempo as fitting bv_loss_inflicted. What those features need is a corpus in which the bot gets hot, which is a matter of play and of exploration, not of labelling. shutdowns_suffered is still worth having as an outcome; it is not the fix that was hoped for.

A label cannot un-drop a column. Stated once here because the mistake above is an easy one to repeat: a near-constant feature is dropped by thin_columns before any label is consulted. Adding an outcome that is about a feature does nothing for a feature that never varies.

Not built: the heat roll itself. "How many heat effect rolls did we suffer" is not answerable from a result document. MegaMek states a shutdown plainly and does not record the roll behind it anywhere a host can read after the fact, and deriving "a roll was required here" from a heat level means re-implementing the heat effects table in this repository - a rule, and rules stay MegaMek's. shutdowns_suffered counts the effect rather than the roll, and is named for what it counts.

Not built: ammunition explosions from heat. An ammunition bin goes off from a heat roll and from a critical hit, and the result document cannot tell the two apart. A label calling both "heat" would give heat_ammo_explosion_risk a column to correlate against that is mostly not about heat, which is worse than leaving it dropped. That feature stays unlearnable until the protocol carries the cause.

Not built: self-inflicted loss. A fall from 20+ points of damage is already inside damage_taken, so the residual is failed stand attempts - and a label built on that teaches "stay prone", which is wrong.

Not built: physical attacks. There is no code for them yet.

Two of the original ten are one label with two names #

Measured over the same corpus, and not fixed here - noted so the next person to touch the label list starts from it rather than rediscovering it.

  • bv_trade_ratio ~ damage_trade_ratio at +0.96.
  • bv_lost ~ damage_taken at +0.91.

The BV-against-damage distinction argued for above is real when it is about what was broken - BV drops hard when a gun goes and barely moves when armour chips. It does not survive being wrapped in a trade ratio or summed over a whole match, where both members of each pair end up measuring the same thing.

Re-measured on labels-entities-r2, a second corpus of the same size: the trade ratio pair held at +0.95, and bv_lost ~ damage_taken fell to +0.50. Only the first is a duplicate. The second was this corpus and not this pair, and a correlation seen once is a property of the run it was seen in until a second run agrees with it.

bv_per_damage: the distinction the trade ratios lost #

bv_per_damage_dealt is enemy battle value removed over the damage we dealt to remove it, and it is the question the two trade ratios collapse: a point that takes a gun or a pilot off is not a point that strips armour, and a ratio of sums cannot tell them apart. Max |r| 0.33 on corrected-basis-explore and 0.34 on labels-entities-r2 - an independent direction on both.

Undefined at zero damage, and None rather than zero there: a window in which we landed nothing says nothing about how expensive the things we hit were.

Clamped at 25. The rate has no ceiling - a round where one point set off an ammunition bin and killed a machine reads in the hundreds - and the tail is where the arithmetic goes wrong rather than where the information is. Measured: median 4.1, p90 11.5, p95 29.5, tail to 275; the clamp clips 5.3% of rows.

The five below the team tier #

tempo, mission_kills_inflicted, dispersion and the two rear labels have no unit or force form and refuse when one is asked for. credits carries battle value, damage and units; none of those say which points landed on rear armour, or who crippled whom. A force's shape is worse than unavailable - it is a property of the set, and one machine's share of it is not a quantity.

The five that read entities also refuse a match that does not carry it rather than falling back to the match-wide differential. The fallback is right for a corpus recorded before per-round logs existed; it is wrong here, because it would fit a whole corpus against a different label than the one asked for and say so in one line of the report.

The two ratios, and the denominator problem #

damage_trade_ratio and bv_trade_ratio are inflicted / taken in intent. Held that way they are unbounded and undefined where nothing was taken - a unit that dealt damage and took none has not achieved an infinite ratio - and an infinity or a NaN reaching least_squares produces a silently poisoned weight with nothing to warn on.

They are stored as the share inflicted / (inflicted + taken), which is r / (1 + r) in the raw ratio: a monotone transform of exactly the quantity wanted, in 0..1, 0.5 at parity, 1.0 when we took nothing and 0.0 when we dealt nothing. Clipping was the alternative and was not taken - it throws away the ordering above the cap, which is where the good trades are. A stretch where neither side did anything reads 0.0 rather than 0.5: nothing was earned. fit refuses a non-finite label outright as a backstop, since nothing else between a label and the solve would notice.

Both are the ratio of the two discounted sums, not the discounted sum of per-round ratios. A round where one point was traded for one point is not the same evidence as a round where a hundred were, and averaging ratios would say it was.

damage_efficiency, and what its denominator counts #

The denominator is the whole difficulty, so it is stated rather than implied. SdsHost.potentialOf logs, per side per round, the sum over every live unit of each weapon's average damage at its own medium bracket, counting only weapons canFire() allows.

What that counts. A unit that dies stops contributing, so a wiped-out side is not charged for output it no longer has. A shut-down unit contributes nothing, so overheating into a shutdown reads as wasted potential - which is the point. A weapon blown off, or out of ammunition, drops out.

What it does not count. It is not the range the weapon was actually fired at: the medium bracket is a fixed, neutral choice, the same one TargetView::threat_of uses, taken because the host aggregates a round rather than an attack declaration. A side that spent the match at long range therefore reads as inefficient, which is intended - it is a real failure to realise the output it paid for - but it is not distinguishable here from a side that closed and missed. Physical attacks are ignored, and so is whether a target existed at all: a round with no enemy in line of sight is charged full potential.

The quotient is clamped to 0..1. Falls, physicals and ammunition explosions all put damage on the enemy that no weapon of ours accounts for, so the raw figure can exceed one. A side with no guns left has no efficiency rather than a perfect one, and the label is undefined there.

The refit #

Refitting the same 360-match corpus, changing nothing but the label.

Judge these on p_kill and target_breach. Those are the only two signs EXPECTED_SIGNS still claims, and they are the ones behaviour corroborates: both negative is the "do not finish anything off" pattern, and the seventeen vectors fitted that way left Princess on 96.6-99.4% of its battle value in every decided game. The other three that used to be counted were bad priors - heat_incurred and ammo_spent are proxies for shooting and shooting wins matches - or, in overkill's case, eight points of noise.

label R² p_kill target_breach contradictions
final_bv_differential 0.188 -0.305 -0.294 3
bv_delta_next_3_rounds 0.110 -0.070 -0.079 2
bv_loss_inflicted, --credit flat 0.214 +0.154 +0.081 0
bv_loss_inflicted, --credit ratio 0.196 +0.225 +0.122 0
bv_trade_ratio 0.250 +0.277 +0.064 0
kills_secured, --credit flat 0.164 +0.120 +0.069 0
kills_secured, --credit ratio 0.152 +0.183 +0.109 0
bv_lost 0.309 +0.328 +0.211 3

Every one-sided label puts both signs the right way round. Agreement with the played policy goes from 40.9% under the old label to 88-91% under all of them. bv_loss_inflicted --credit ratio is the default: bv_trade_ratio fits better and is the one to try next, but it mixes two signals and the simpler label is the right thing to change to first.

bv_lost is the interesting failure. Its expected signs are inverted - a bigger number is a worse outcome - and it still contradicts three, because engaging predicts taking damage: a unit that deals more is a unit that was in range to be shot at. That is a confound in the label, not in the features, and it is the argument for fitting avoidance against damage_taken with exposure in the vector rather than against bv_lost alone.

overkill is dropped as near-constant in every fit above. That is the correct outcome and not a regression - see below.

The four labels that read armour, potential or a ratio of them - damage_inflicted, damage_taken, damage_trade_ratio, damage_efficiency - cannot be refitted on that corpus at all. They need armour, structure and potential per side per round, which the host only starts writing with this change; on an older result they return nothing and the fit falls back to the match-wide differential and says so in its report.

overkill was a dead column, and the guard missed it #

The estimator is fixed elsewhere - DamagePmf::waste_above against the mission-kill threshold, rather than max(0, E[X] - c). What belongs here is what it did to the fits above.

Under the old form overkill read zero on 8835 of the corpus's 8843 candidates. The +0.2146 weight reported overnight was fitted from the other eight. It was never a finding about overkill; it was noise on a near-constant column, sitting in a report next to weights that meant something.

dead_columns did not catch it, because the column was not constant. thin_columns is the companion guard: a column non-zero on under 1% of rows is dropped and named with the share, so "this feature is not computed" and "this situation is rare" are distinguishable by reading the report rather than the corpus. It fires on overkill in every fit in the table above, which is the correct outcome for a corpus recorded before the estimator was fixed - those logged values are the broken quantity, and only a fresh corpus can say what the feature is worth.

The discount #

--gamma, default 0.93, replacing a fixed 3-round window that was shorter than a kill takes to pay off - a unit crippled in round 4 dies in round 8, and the decision that crippled it was labelled with three rounds of nothing.

Grounded in the corpus. Over 516 decided matches the mean length is 13.2 rounds and the median 11, with p25 8, p75 16 and p90 25; the 222 undecided ones all sit at the 41-round limit. gamma 0.93 has an effective horizon of 14.3 rounds and still puts 39% of its weight at round 13, so it spans a typical decided match without paying out over the undecided tail, where nothing is being decided and a longer horizon only adds noise.

Early kills compound #

An early kill does not pay out once. It moves the BV ratio, and every later round is fought at that better ratio - the side with more guns left trades better - so it raises the rate of every later payoff. A plain discounted sum cannot see that: it pays the kill its own damage and nothing for the compounding.

--credit ratio, the default, is the cheap approximation: each round's payoff is scaled by enemy BV over ours at the top of that round, clamped to 4x either way. Damage done while outmatched counts for more, because that is the damage that changes the ratio the rest of the match is fought at; damage done when already three units up counts for less, because the match was already decided. The clamp is not decoration - unbounded, the ratio explodes exactly where the per-round steps are largest and least informative, and the last two rounds of every blowout would outweigh the corpus.

This is a hypothesis and is switchable. --credit flat is the plain discounted sum, and the table above is the comparison rather than an assumption. On that corpus both clear the sign check once overkill is out of it, and ratio puts p_kill and target_breach further from zero - +0.225 and +0.122 against +0.154 and +0.081 - which is why it is the default. That is a thin argument and the flag is there to settle it against a bench.

What is still open #

Every number above is a refit on a corpus recorded by a bot that had no BV feature and no one-sided label. It says the label is no longer arguing against the features' own descriptions. It does not say the bot wins. That needs a fresh corpus with target_original_bv, target_current_bv and target_tonnage in it, a fit, and a bench.

Every vector in weights/ other than the hand-authored one is now formally stale for a second reason: target_tonnage changed normalisation and target_skill became target_gunnery and target_piloting, so the basis fingerprint moved. They are kept as the record of what was fitted, not as something to load - Weights::from_document refuses target_skill outright.

Every label here is still team-wide. The per-unit and per-force tiers below are the next sharpening and need no new matches - in an 8v8 one unit's choice currently carries seven other units' luck. The taken labels also have a confound worth naming before anyone fits against one alone: engaging predicts being engaged, so bv_lost correlates with dealing damage as much as with avoiding it.

Does a label point at winning at all #

sds labels asks whether two labels measure the same thing. It cannot ask the prior question, and for most of this epic nothing did: does a label point at winning at all. sds labelrank is that measurement.

Each comparison is paired inside one match. Both seats played the same scenario, on the same map, with the same forces, under the same seed, and exactly one of them won, so the scenario is held fixed and what is left is the label. Across matches nothing is held fixed and the comparison would mostly measure which scenarios are winnable.

Two rules the measurement itself taught us, both now enforced by the tool:

  • Read every round, not one. Several labels are zero at round 1 by construction. finishing is the share of our damage that landed on machines already hurt, and at round 1 nobody is hurt yet: it is 0 for both seats in every match, 0 nonzero readings out of 80, and rises to a mean of 0.73 by round 5. Ranked at round 1 alone it reports 50% and reads exactly like a broken label. It is not one.
  • A tie is not a miss. shutdowns_suffered is 0-0 in 90% of comparisons because most matches have no shutdown. Excluded from the accuracy and reported beside it, that is a thin label; counted as misses it would look like a wrong one.

The top group does not separate #

Over two corpora, with every round read:

label 58-match bench honest-movement-r8
inflicted_bv_per_round (what we fit) 80.4% 75.9%
damage_trade_ratio 80.1% 80.2%
bv_trade_ratio 80.1% 76.6%
bv_loss_inflicted 66.6% 67.9%
finishing 50.2% -
damage_focus 42.9% -

The three at the top are within each other's intervals on both corpora, and they swap order between them. Ranking cannot choose between them, and a switch of the fitted label has to be argued on a fit and a bench rather than on this table. What the table does settle is the bottom: finishing is at chance across ~1200 comparisons, and damage_focus is below it - concentrating damage correlates with losing these matches. Both are worth a look before either is fitted against again.

The suffered-side labels reading 19-33% is arithmetic, not a defect: what one seat inflicts is what the other suffers, so suffered_bv_per_round at 19.6% is inflicted_bv_per_round's 80.4% seen from the other chair.

The label that could not tell the sides apart #

behaviour_distance_engagement scored 0 decided against 964 ties - it returned an identical number for the winner and the loser of every match in every corpus. It averaged the distance over every (ours, theirs) pair, and that pair set is the same set seen from either seat. Its own docstring claimed "a force that shot from the far edge reads high and one that closed reads low"; both forces read the same.

It now reads outward from our own machines: each of ours takes its distance to the nearest of theirs. That is asymmetric, and it ranks the winner at 53.6% and 54.6% on the two corpora - weak, but a signal rather than a constant, and the ties fall from 100% to 2% and 13%.

Four corpora were recorded with the broken form. Nothing that was fitted against it is invalid - a column constant across seats still varied across matches and rounds - but no reading of it as "did we close" was ever true.

Labels, worked through #

docs/LABELS.md is to labels what docs/FEATURES.md is to features, and the case for it is stronger. A feature is a number about one candidate at one instant and a reader can check it against the board. A label is a window: it looks from the decision's round to the end of the match, discounts what it finds, and collapses it to one number. None of that is in the name, and two labels a word apart can have completely different shapes over the same match.

Every figure plots one label over the rounds of one hand-built 2v2, both seats on one pair of axes. Two lines rather than one is the whole point - the question a label exists to answer is which of these two sides was doing better, and behaviour_distance_engagement drew the same line twice through four corpora without anything noticing. tests/test_labeldoc.py now fails a figure that cannot separate the seats, alongside the staleness and says-something checks vignettes.rs already makes for features.

An empty window is not a quiet one #

Drawing the ratios turned up a tension between two rules this file states in different places, and it is a live question rather than a defect.

RATIOS says a round pair where neither side did anything reads 0.0 rather than 0.5, because "the one-sided rule is that a quiet stretch scores zero". QUOTIENTS, forty lines away, says a window with nothing in it is None and never zero, because calling it zero "would fill the column with rows saying we failed to get behind them about windows where nobody shot anybody".

The final round of a match is where the two collide. Its window is empty - there are no later ticks at all - rather than quiet. Measured over the 58-match bench, bv_trade_ratio and damage_trade_ratio read 0.0 there in 600 of 600 seat-matches, for the winner as well as the loser, and 7.3% of all decisions are taken in that round. Under a ratio label those rows say "maximally out-traded" about a round in which the question was not asked.

Nothing was changed here. The 0.0 is deliberate and documented, and this is the owner's call, not a bug to quietly fix. It does bear directly on the pending switch: inflicted_bv_per_round reading 0.0 at the last round is truthful, since nothing was inflicted after it, and the same 0.0 under a share is not.

Imitation, and what it inherits #

The label above is one number per match. Imitation is one label per decision: the enemy moved, and the hex it moved to is the answer. That is thousands of rows a night instead of a few hundred, with no credit assignment problem at all, which is why it is worth doing even though what it teaches is somebody else's opinion.

How it works #

No Princess code runs, and nothing is asked of the opponent. The observation already carries every visible unit's position, facing and done flag, so two consecutive observations of one movement phase bracket a move:

  1. a unit that was done: false and is now done: true took its turn in between, and where it is now is where it chose to be;
  2. our own candidate menu is generated for it from its state in the previous observation, with the observation mirrored so its side reads as friendly;
  3. the observed hex goes into that menu as one more candidate, before anything is measured - damage_lead is min-maxed across a decision's own candidates, so a candidate added afterwards would not share the row's spread;
  4. every candidate is measured with the same features the bot measures its own with, and the observed one is recorded as chosen.

Princess's own candidate set is not needed and could not be reproduced: it enumerates every legal path and this bot caps at ~20 curated ones. What is needed is only what it chose.

The row is the same schema the bot's own decisions use, so difference_rows and fit need no branch. policy is what tells the two apart, and the rows live in a different file so a corpus is not silently part self-play and part clone.

Moves that cannot be reconstructed are dropped and counted, never guessed at: a unit not in the previous observation, a unit destroyed, a unit whose turn the pair of observations does not bracket, and a displacement further than its own movement points can explain.

The label #

The row is labelled by the outcome of the player who made the move, not the bot that watched it - label_of is already per-seat, and the imitation row carries the observed player's id for exactly this. So a match Princess won is a positive label on its moves and a match it lost is a negative one, which is "move the way Princess moves when Princess wins" and its anti-imitation half in the same expression. It also degrades correctly as this bot improves: once Princess starts losing, imitating it stops being rewarded.

The alternative - a constant +1 on the winner's moves only - is a special case of this with the magnitude thrown away, and there is no reason to throw it away.

Worth being precise about what the fit then is. The design matrix is phi_chosen - phi_rejected and there is no intercept, so with a label that is constant within a match the objective is asking for a fixed margin: it wants w . (phi_chosen - phi_rejected) to equal the label on every comparison. That is a ranking objective, and it is the right shape. It is not a hinge, though - it penalises a margin that is too large as well as one that is too small, so a candidate our features already rank far above the rest still contributes error. That is a defect worth knowing about before reading much into a coefficient.

The cost, which is the point of writing this down #

docs/PRINCESS.md is the argument for this repository existing. BasicPathRanker.rankPath takes the maximum damage a hex can deal and the sum of the damage it can take, so Princess cannot see the value of a crossfire and is systematically pessimistic about advancing. Cloning its moves clones that blindness. The same goes for herdingMod, which is the conga line.

This does not break "MegaMek's rules yes, MegaMek's bot no": no Princess code is linked, imported or invoked, and every order the bot gives still has exactly one possible author. But that invariant's own wording is that inheriting a tactical opinion silently is the thing the design exists to avoid - and this deliberately inherits one. So it is recorded here, in imitate.rs, and in the fit's own report, which names how many of its rows are imitation and says what they were cloned from.

Imitation is a bootstrap to competence, not the goal. Weights fitted this way must be re-measured against the bot they were cloned from before they are believed, and the long-term path is outcome-based training that exceeds it. A future reader must not be able to conclude this bot is independent of Princess when part of it was copied from Princess.

Labels should be hierarchical #

A decision today is labelled with its whole side's outcome, so in an 8v8 one unit's choice carries seven other units' luck. Credit belongs at the level the decision was made at, and this project already has those levels: units propose, lances and companies decide.

Three tiers, nestable the way Forces is:

  • per unit - what this machine dealt and took. The tightest signal available and roughly an 8x sharpening in an 8v8, for no new matches.
  • per force - a lance's damage and losses, for the decisions a force makes rather than a unit. Nested: a company's label is over its lances.
  • per team - what we have now, and still the right label for anything that is genuinely a side-wide outcome.

A fit can now ask: --tier unit|force|team, team by default, so nothing changes unless it is asked for.

Which labels have a form at which tier, and what each needs:

label team force unit needs
bv_lost yes yes yes entities
damage_taken yes yes yes entities
bv_loss_inflicted yes yes yes credits
damage_inflicted yes yes yes credits
kills_secured yes yes yes credits
damage_trade_ratio yes yes yes both
bv_trade_ratio yes yes yes both
damage_efficiency yes yes yes both
final_bv_differential yes no no -
bv_delta_next_3_rounds yes no no -

The two differentials are one number over two whole sides. One machine's share of a differential is not a quantity, so those refuse at construction rather than handing back the team's number: a diluted label that reads as a sharpened one is worse than none.

The other eight split in two, and the split is the finding rather than an implementation detail:

  • What a machine lost is a level it owns. bv_lost and damage_taken are the fall in that machine's own BV, armour and structure, which is the same arithmetic the side totals already do one layer up. The round tick just has to record the levels per unit rather than summed.
  • What a machine dealt cannot be inferred at all. A snapshot of levels says which enemy lost armour. It never says who took it off. bv_loss_inflicted, damage_inflicted and kills_secured need the host to attribute a round's damage to the attacker, which is a new record and not a finer slice of an existing one.

Neither field exists today, so every sub-team tier refuses on every corpus on disk, naming the field it wants. Both are specified in protocol. Until they land the tier machinery is exercised by tests/synthetic.py, where one machine of two does every point of the work: at --tier team the two decisions carry the same label and at --tier unit they carry opposite ones.

Four one-sided labels, not two #

Done, and now eight rather than four. The full list and the reasoning are in "The label" above; the grid this section asked for is:

damage battle value
inflicted damage_inflicted bv_loss_inflicted
taken damage_taken bv_lost

kills_secured, damage_trade_ratio, bv_trade_ratio and damage_efficiency are the other four. All of them one-sided on purpose: a differential is symmetric under mutual disengagement, which is how a fit came to prefer a bot that will not engage.

Heat and ammo features have to be calculated properly #

Both are worth having and both are easy to get wrong. shots_left divided by weapon count is not the answer:

  • Several weapons draw from one bin. Two SRM 6s sharing a ton do not each have a ton.
  • A destroyed weapon leaves more for the rest. Endurance goes up when a launcher dies, and a feature that does not know this will read a crippled unit as short of ammo.
  • A launcher can have several bins of different munitions. Standard and Inferno are not interchangeable, and the useful number is per munition.
  • Heat has the same shape: what matters is generated against dissipated, not generated alone. An alpha strike that can be dissipated is free; one that cannot means shutting down next turn, and heat_incurred reads the same either way.

Read MegaMek for the bin-to-weapon mapping rather than inferring it.

A name is not a meaning #

The manifest records the sorted list of every feature name in a corpus, and refuses a fit whose names do not line up. That catches a feature added or removed. It does not catch the case this change is.

p_kill kept its name and changed its quantity. It used to ask whether the whole volley cleared the centre torso's health; it now asks whether the centre torso or the head is actually destroyed, and the two differ by a factor of between 3.5 and over a thousand depending on the shape of the volley. p_mission_kill and overkill moved the same way. A manifest comparing name lists sees p_kill on both sides and says the corpus is fine.

Every weight fitted against the old p_kill is meaningless, including the label comparison's p_kill +0.225 at 91% agreement. Nothing in a corpus recorded before this change says which p_kill wrote it, and nothing would object.

The basis fingerprint hashes each feature's name, normalisation and one-sentence description, which is a better question than the name list and still not the right one. Tested against this change, it reported the basis had moved - but only because six features were added. Its REWORDED list was empty: p_kill's sentence was byte-identical across a change that moved its answer by three orders of magnitude, because "enough damage in one place to destroy the target outright" describes the old computation and the new one equally well. Had this change touched p_kill alone and added nothing, the fingerprint would have called the basis unchanged.

The descriptions of p_kill, p_mission_kill and overkill now name the quantity rather than the intent, which closes this instance. It does not close the hole: a description is a proxy for a meaning, and a proxy that a person can forget to update is one they will.

It happened again, and this time nothing was added to cover it #

p_kill above was caught because six features arrived in the same change and moved the fingerprint anyway. The second instance had no such luck.

features::firing::ceiling used to sum guns alone. It now counts the physical the numerator was already counting, which changes expected_damage for every Mek in the game - a fist is on the wire for every machine with a working limb, not just one in contact. The description did not change, because "how much of what this unit could put out this volley represents" describes both.

So:

corpus recorded at ceiling counts reported basis
tactics-hub-mirrored 1d6bb72 guns 4a963bc90ee9
advance-arm-engage 8dcc6b8 guns and a fist 4a963bc90ee9

The guard that exists to stop somebody comparing incomparable corpora says these are comparable. Both were used as the two readings behind tactic::MEASURED, and Advance weights expected_damage at 6.0 - harder than any other tactic in the vocabulary, so it is the one most exposed to exactly this column. The local note is on BERSERKER in tactic.rs; the general statement is here, because the next person to fit anything needs it and will not read a tactic constant to find it.

What makes this worse than an ordinary silent defect: the fingerprint is a proxy for "same basis" and it looks exactly like the check. Everything else in this repository that consulted a proxy was caught by something downstream disagreeing. Nothing is downstream of this one - it is the thing that would have done the disagreeing.

Three ways to close it, in the order they cost:

  • A hand-bumped BASIS constant, the open item below. Cheapest, and it fails the way sds/epoch.py fails: it is only as good as the person remembering. It would not have caught tonight's change either, because the person who wrote the ceiling fix is the person who would have had to bump it, and they did not know the corpora would be compared.

  • Hash the computation rather than its sentence. A digest over each feature's measure body - from the source, at build time - moves whenever the arithmetic does and never when it does not. It is the only option that needs nobody to remember anything. It costs a build step and it is noisy: a refactor that changes nothing about the answer still moves the hash, which argues for recording it beside the description hash rather than instead of it, so a reader can tell "reworded" from "recomputed".

  • Record the build alongside the fingerprint and compare both. The manifest already stores commit; the corpora above differ by it and nothing looks. A reader that refused to compare two corpora from different commits would have caught this tonight with no new machinery at all - and would refuse far too often to be usable as a hard error, so it belongs as a warning that names the two commits.

Leaving the map: not yet, and why #

Fighting to destruction is wrong in the long run - a scenario can be won on battle value preserved, so a crippled machine walking off the field is worth real points, and the bot has no way to value that today.

It is deliberately not being added yet. A withdrawal term introduced before the engagement terms are trustworthy has an obvious degenerate optimum: land one good blow and run everything off the map. That scores well on every label above and is not the game. Withdrawal waits until a fitted M beats the hand-authored one on a fight it is supposed to win.

The manifest cannot see a feature that changed its mind #

A corpus manifest records the sorted list of feature names it was recorded against, which catches a feature added, removed or renamed. It cannot catch the case that has already happened once:

p_kill asked P(total damage >= centre torso health). That ignored that damage is distributed across hit locations, and it was overconfident by between 3.5x and 1144x depending on the volley's shape - worst for many small packets, because a threshold needing k packets on one location falls off like share^k rather than like share. Its replacement asks P(a lethal location is destroyed). Same name, same normalisation, a different quantity, and every weight ever fitted against the old one is void.

A name list sees none of that. So the manifest also records a basis fingerprint: a hash over each feature's name, normalisation and one-sentence description. A feature whose meaning changed needs its sentence changed too, and a test already fails the build when a description is missing or is not one sentence, so the sentence is a usable proxy for the meaning.

sds train refuses a corpus recorded against a different basis, with --stale-ok to override.

What it still does not catch: a computation changed while its sentence was left alone. Nothing short of a hand-bumped constant beside the code can, and that belongs in sds-core rather than in the Python. Left open rather than pretended away.

Not in epoch on purpose: the epoch versions what a match is - rules, victory conditions, what the bots experience. This versions what the bot's scoring means. A change can move one without the other, and collapsing them would make both vaguer.

Starting weights for the first big run #

heat_incurred starts at 0.0, not the hand set's +6.0. jmm's call, made after a 4v4 diagnostic showed the bot carrying more heat than Princess (17.42 gained a unit-round against 15.80, peak 23 against 17, rising to 32.5 by round 14) and hitting far worse for it (38.7% against 53.3%). The +6.0 was fitted by hand from a six-arm experiment in which most matches hit the round cap, so it is a weak prior and training should not be anchored to it.

The other hand weights stand unless something similar argues against them.

Exploration is movement-only, and firing is most of the feature set #

Done, 2026-08-22. The estimate below was wrong in one specific way, kept because the reasoning around it still holds: Decision already carried a policy field, added when movement exploration landed, so the "corpus format change" that made this look expensive was a single additive enum variant. Perturbing at the firing call site took about fifteen minutes. Measured on a 4v4: 13 of 45 firing decisions explored at epsilon 0.3.

One wrinkle left: movement records exploration on Chosen.policy inside the force rationale, firing on the top-level Decision.policy. Both are readable, but a fit has to know which is which. Worth unifying.

explored() lives in reconcile.rs and perturbs which proposal a unit takes in the joint movement plan. The firing path is Decision::score in crates/sds-bot/src/main.rs (~line 1300) and is pure argmax - no exploration, no temperature.

That leaves the larger half of the basis unlearnable. A corpus with no off-policy firing rows carries no information about expected_damage, p_kill, p_mission_kill, overkill, weapon_concentration, p_breach, ammo_spent, the four heat-chart families, or any target:: term. Every one of those is scored only in a firing decision. A fit against such a corpus does policy evaluation for firing - "what distinguished the shots you took" - which is the exact failure this module's own docstring says exploration exists to fix.

Why it is not a two-line change. Decision::score is shared: movement uses it at ~line 959 to record the force's row, firing at ~line 1300 to choose. Put exploration inside it and movement double-explores, since reconcile has already perturbed that decision. So it has to go at the firing call site, which needs:

  • Bot to hold the Explore config. It does not today - the config is constructed in main and handed to ForceThinker.
  • a perturbation after Decision::score, over row.candidates[].value. Explore::choose already takes values rather than a count, so it fits.
  • a policy field on the firing training row. This is a corpus format change, and it is the reason not to do it in a hurry: Chosen carries policy for movement and the firing row has no equivalent, so a fit cannot currently tell an explored shot from a chosen one.

Do it with the subset menu, not before it. See plan/candidates.md, "The firing menu selects by prefix". Exploring a prefix ladder draws from a menu whose shape is already wrong - the near-ties are prefixes of one ordering rather than genuinely different volleys - so the rows would be less informative than the same work over a subset menu. Both changes live in the same call path and want doing together.

Two properties to preserve, both learned the hard way.

  • Rank turn order on the on-policy value. Movement already does this: Order carries value (what was taken) and on_policy_value (what the weights wanted), because ranking on the perturbed value made one draw perturb both the move and the running order, and a fit could not tell which mattered. Firing has no equivalent ordering decision today, but if one appears, the same rule holds.
  • Epsilon is an upper bound, not a target. choose correctly declines when no alternative has meaningful softmax mass - a menu whose argmax is well clear of everything else. Measured: 0.3 asked, 0.206 realised on a 4v4. The shortfall grows as temperature falls, so the two knobs interact and want tuning together rather than setting epsilon alone.

damage_focus: whether we concentrated fire #

A normalised Herfindahl index over how the damage in a window was spread across the machines they brought. Zero is damage spread evenly over all of them, one is all of it into a single machine. Focus fire is the most-cited tactic in the game and nothing here measured it; it is also the outcome concentration and weapon_concentration would need in order to mean anything, both of which are currently dropped as near-constant.

Max |r| 0.30, against damage_inflicted.

The floor is the whole design. Without one the label reads 1.0 for any window holding a single hit - not focus fire but an absence of evidence - and that artefact shows up as correlation with how much damage there was at all. Measured on labels-entities-r2: no floor -0.43 over 2034 rows, 10 points -0.40, 30 points -0.32 over 1434, 60 points -0.27 over 899. Thirty is about one solid hit, and past it the rows bought stop paying for the confounding removed.

Normalised against what they brought rather than what we hit. Dividing by the count we touched would hide the case the label exists to see: we only ever engaged one of their four.

Unsigned, with dispersion. Whether concentrating fire beats spreading it is the question, and EXPECTED_SIGNS is for things already known.

alpha_strike, and the denominator that did not work #

What a side fired in a round, in expected damage at a fixed medium bracket, against what its surviving guns could still have fired. This is firing discipline: did we pull the trigger on everything, or hold back.

fired is read off Mounted.isUsedThisRound, the game's own record of the declaration, rather than inferred from heat or from damage arriving. It is sampled as a high-water mark across the round: the flag is set as an attack is declared and cleared by MegaMek's end-of-round pass, and the round tick is written at the top of the next round, by which time every flag reads false.

It can exceed one before clamping. The numerator is the peak during a round and the denominator is read after it, so a machine that fired a gun and then lost it divides what it fired by what survived firing it. Measured at 1.68 on one round of a 2v2.

output_realised was built beside it and removed. The argument for it was sound: against alpha_strike alone, a machine down to its last working gun that fires it reads as a full alpha strike, which is "nothing left" and not "fired everything". So a second denominator - what the standing machines were built to fire, guns blown off included - should separate the two.

It measured +0.98 against alpha_strike over 120 matches. The mechanism is real and visible in single rounds; it just does not vary across a corpus, because machines are destroyed far more often than individual guns are blown off and both denominators exclude destroyed machines. A third form was tested - denominator fixed at what the side deployed with, dead machines included - and reached 0.79, better but still mostly the same label, and the attrition it adds is already carried by bv_lost.

The designed field stays on the round tick. It costs one number per side per round and it is the only record of what a force was built to put out.

Five more labels #

  • finishing - the share of our damage that landed on machines already hurt when the window opened. Finishing a wounded machine and opening a fresh one cost the same shot and are not worth the same, and nothing else separates them: damage_inflicted counts the points either way and damage_focus sees concentration without seeing what was concentrated on. "Hurt" is measured against the machine's own first reading, because the tick carries what a machine has and never what it was built with. An armour-is-zero test was tried first and read zero for every window of a twelve-round 4v4 - it asks "nearly dead" rather than "worth finishing". Saturates at 1.0 late in a match, when everything on the board is damaged; that is true rather than broken, but it makes the label least discriminating exactly where matches are decided.
  • focus_suffered - the concentration index over their damage into our machines. dispersion measures the shape we chose; this measures what that shape cost, and the bot has levers on it.
  • heat_carried - how hot we ran, per machine, averaged over the rest of the match. The heat features are unfittable because candidates barely vary in heat; this at least gives the heat question an outcome, and unlike shutdowns_suffered (5.4% non-zero) it is dense.
  • prone_time - the share of our machine-rounds spent on the ground. Falling is a to-hit penalty, a lost turn and a piloting roll to stand, and the only thing that saw any of it was p_psr_threshold on the feature side.
  • crippled_suffered - our machines put out of the fight, the mirror of mission_kills_inflicted.

Both level labels needed SUBSTITUTE_SCALE entries. Heat and prone start at zero, so their own first tick is not a scale - it is the reading "nobody is hot yet", and dividing by it returns None for every match. They are scaled by the machines we brought, which makes both read per machine.

The tempo family: eight built, six removed the same day #

Rate is a different question from total - a side that removed the same battle value in four rounds and in fourteen did not play the same match - so the family was built as (inflicted, suffered) x (bv, damage, kills, mission kills).

Measured over maximalist-r4, 120 matches:

pair max |r| against the field
inflicted_bv_per_round ~ inflicted_damage_per_round 0.95
suffered_bv_per_round ~ suffered_damage_per_round 0.95
inflicted_kills_per_round, suffered_kills_per_round 0.84
inflicted_mission_kills_per_round, suffered_mission_kills_per_round 0.76

Rate is orthogonal to what is counted only in principle. In a real corpus the counts move together, so the rate of one is the rate of the others. The two battle-value forms were kept because battle value is the outcome measure; the other six were removed.

This is what "measure rather than assume" costs and buys: an afternoon of implementation for six labels that were deleted, and a label list that is six columns shorter than it would otherwise be.

Two more added beside them, both marginal and both kept for now. suffered_kills reads 0.84 against bv_lost and trade_units reads 0.85 against kills_secured - both in the band where a label is mostly a rename. The argument for trade_units is not statistical: unit count drives the initiative sequence, so wiping out a light to win the count is a real play, and no other label can express it. That is a domain argument and it should be tested against a fit rather than settled by a correlation.

behaviour_distance_engagement: did we ever close #

Mean distance from each of our machines to each of theirs, as a share of the board, averaged over the rest of the match. Low is a force that closed; high is one that shot from the far edge.

Max |r| 0.24 against the other twenty-eight labels - joint third most independent in the whole set, behind only the two rear shares. dispersion is its closest neighbour at 0.24, which is the right neighbour to have and still a long way from being the same label: dispersion is the shape of our own force and says nothing about whether we ever engaged anybody.

Cross-side, so it cannot be a DERIVED_TOTALS reducer - those see one side's machines at a time and this needs both.

Averaged rather than summed, for the reason AVERAGED gives. Unsigned, with dispersion: whether closing beats standing off is the question being asked and not something already known.

A trace over one match reads 0.39, 0.38, 0.35, 0.32, 0.31, 0.31, 0.30, 0.29, 0.28, 0.26, 0.24, 0.20, 0.16, 0.15, 0.15 - a monotone close. That shape is the sanity check: a label about converging forces should look like converging forces.

None rather than zero when one side has nobody on the board. There is no gap to measure, which is not a gap of zero.

Fleeing is invisible in a self-play corpus #

mission_kills_inflicted counts machines killed or crippled, and should count machines that fled: a unit that leaves the board is out of the fight whatever its armour says. Princess will attempt to withdraw.

Measured before building it: zero fled units across 121 matches in maximalist-r4, and zero trapped. The reason is that every training corpus here is self-play, and this bot has no withdraw behaviour at all - so the case the label is missing cannot occur in the data the label is measured on.

That is worth stating as a general caution rather than a footnote about one label. A self-play corpus can only contain behaviours this bot has. Anything Princess does and we do not - withdrawing, and whatever else - is absent from every corpus we fit against, and shows up for the first time in evaluation, which is the worst place to meet it.

Not built, for two reasons. entities does not carry offBoard, so counting it per round needs a protocol change; and the change would be untestable against any corpus that exists.

Withdrawal is rare here, not impossible. Princess can flee. It seldom gets to: these matches tend to end with a weak machine pursued and killed rather than withdrawn, so the behaviour is reachable in the opponent's policy and rarely reached in the states our play produces. Measured: zero fled and zero trapped across 121 self-play matches and the first 22 against Princess.

Two consequences worth keeping apart. The label gap is a correctness question - a machine that leaves the board is out of the fight and should count as one, whether that happens twice a season or twice a match. And it is not a data problem a different corpus fixes: no corpus this project can cheaply build will carry many examples, so the change has to be made on the rules rather than validated against a frequency.

Two earlier versions of this passage were wrong in opposite directions - first that self-play hides a behaviour Princess would show, then that nothing in these scenarios withdraws at all. Neither survived contact with jmm, who plays the game.

damage_efficiency is not a trade #

Renamed to inflicted_percent_of_maximum. It divides damage dealt by what our own guns could have landed, so both halves are ours - there is no trade in it. The trade_ family is for a quantity of theirs against a quantity of ours.

The first winning record against Princess #

weights/fitted-tempo.json, fitted against inflicted_bv_per_round on retired-basis-r5, played on the subset firing menu:

run sds princess decided rate
first, 24 games 12 9 21 57.1%
confirmation, 64 games 30 25 55 54.5%
combined 42 34 76 55.3%

96.9% of 2013 decisions answered and none failed. Undecided that were not the round limit: 0.0%, against the 5% criterion - the first run failed that bar at 8.3% and the confirmation passes it. Battle value retained 22.3% against 21.1%. 481 shots at 65.1% against Princess's 394 at 56.6%.

Not settled, and the harness prints the reason. About 385 decided games separate a 5-point win-rate difference from noise, and 55% against 50% is five points. The interval on 54.5% over 55 decided games runs roughly 40% to 68% and contains 50%. Two runs agreeing is worth more than one, and neither is proof.

Why this vector and not the other #

The two fitted vectors are not scaled copies of each other - cosine similarity 0.85, and an argmax sees only direction so a pure scale difference would change nothing. Where they disagree most is the line-of-sight pair:

feature fitted-r5, scaled (6-11) fitted-tempo (12-9)
los_in +0.002 -0.079
los_out +0.002 +0.081

fitted-tempo learned see them without being seen, and it is the fourth largest weight in that vector. Fitted against bv_loss_inflicted the same pair came out the puzzling way round and was written up as unexplained; fitted against the rate label it comes out the way a player would write it.

That is the argument for rate labels beyond this one result: inflicted_bv_per_round spreads credit over the rounds a decision influenced instead of concentrating it on the round the damage landed, and positional features are exactly the ones whose payoff arrives late.

Four labels could be measured but never fitted #

_round_label looked up LABEL_SERIES[spec.kind] for everything that was not one of the two differentials. Four labels have no entry there, because they are not a sum over a named series: damage_focus and focus_suffered are Herfindahl indices, finishing is a share of a damage map, and behaviour_distance_engagement is a cross-side distance. label_value answers all four perfectly well; the fit path raised KeyError on the first decision.

So they could be computed, correlated and written up - and three of them were, including one described as the most independent label in the set - while being impossible to actually fit a vector against. The gap only shows when somebody asks for the fit.

The fix routes them to label_value, which already knew how. The test asserts that every label without a series entry answers, rather than naming the four: a fifth such label is exactly the case that would slip through otherwise. It was checked against the reverted fix to confirm it fails when the guard is gone.

Refitting on the menu the bot actually plays #

fitted-r5 was fitted on a prefix-menu corpus and then dropped into the subset menu, where it lost 6-11. That is distribution shift: it learned to rank about eight candidates drawn from prefixes and was asked to rank 172 drawn from every subset, most of which it had never seen.

fitted-r6 is the same label - inflicted_bv_per_round - fitted on a corpus generated by the subset menu:

prefix corpus subset corpus
candidate comparisons 1.34M 2.64M
uncentred R² 0.315 0.366
agrees with the played choice 62.0% 66.8%

The comparison count roughly doubles, which is the subset menu paying for itself in evidence: the same 120 matches carry twice the decisions to learn from.

The line-of-sight asymmetry survives the change of corpus. los_out +0.019 and los_in -0.012 - the same signs as fitted-tempo, smaller in magnitude. Three fits now: both against the rate label put the pair the way a player would write it, and the one against bv_loss_inflicted put it backwards. Two data points for and one against is not a law, but it is the third time the rate label has produced the more sensible vector.

The refit wins, and the ranking says why #

Every vector on the subset firing menu, same suite:

vector trained on label record rate
hand-authored never fitted - 6 - 12 33%
fitted-r5 prefix corpus bv_loss_inflicted 6 - 11 35%
fitted-tempo prefix corpus inflicted_bv_per_round 42 - 34 55%
fitted-r6 subset corpus inflicted_bv_per_round 23 - 12 66%

95.1% of 1173 decisions answered, none failed, one defaulted. Undecided that were not the round limit: 2.5% against the 5% criterion. Battle value retained 23.3% against Princess's 18.6%.

Two things move the result and they are separable. Holding the corpus fixed, the rate label beats the discounted sum: 35% to 55%. Holding the label fixed, fitting on the menu the bot actually plays beats fitting on the old one: 55% to 66%. The vector trained on the wrong menu lost outright, which is what distribution shift looks like when it is measured rather than argued about.

Still thirty-five decided games. The interval on 66% at that sample runs roughly 48% to 81%; it clears 50% but not comfortably, and the harness asks for about 97 decided games to separate a ten-point difference. A 96-game confirmation is running.

And we are winning while throwing away one movement action in twenty. The illegal rate is 4.2% here, matching the 4.3% measured on the corpus, and every refusal costs that machine its whole turn. Six hypotheses have been eliminated and the cause is still open - which makes it the largest known unclaimed improvement on the board.

sds beats Princess #

weights/fitted-r6.json - fitted against inflicted_bv_per_round on a corpus generated by the subset firing menu - over two runs on the 2v2 and 4v4 suite:

run sds princess decided rate
first, 40 games 23 12 35 65.7%
confirmation, 96 games 46 33 79 58.2%
combined 69 45 114 60.5%

95% interval 51.6% to 69.5%, which excludes 50%. The harness asks for about 97 decided games to separate a ten-point difference and there are 114; the difference is 10.5 points.

Quality on the confirmation run: 96.5% of 3842 decisions answered, none failed, 18 defaulted. Undecided that were not the round limit 4.2%, against the 5% criterion. Battle value retained 22.0% against 20.2%. 2592 shots at 63.5% against Princess's 2313 at 55.7% - firing 112% as often and hitting eight points more accurately.

What it took, in the order the effects were isolated #

change effect
retire the exposure family forced pass 58.9% to 28.2%, and thirteen unfittable columns became four
firing menu selects by subset, not prefix 8.2 candidates to 117; the small guns get a volley of their own
fit against a rate label, not a discounted sum 35% to 55% at fixed corpus
fit on the menu the bot actually plays 55% to 66% at fixed label

Each was measured against the one before it rather than changed together, which is why the four can be listed separately at all.

What this does not say #

The suite is 2v2 and 4v4 only. 1v1 and 8v8 are untested, and 8v8 is where firing has timed out before and where the subset menu's cost is unmeasured. Both runs used the same 144 scenarios with different seeds. The interval is a normal approximation, which is reasonable at this sample and is still an approximation.

And the bot is still refusing 4.2% of its own movement actions, which is a defect it is winning in spite of rather than because of.

The credit knob does nothing for a rate label #

--credit ratio scales each round's payoff by how outmatched the side was, so a hit landed while losing counts for more than the same hit landed while already ahead. It is the default and it is the right default for a discounted sum.

It is ignored for a rate label, and the fit report says so in one line: credit 'ratio' ignored (a rate is already per round). A rate divides by the rounds it took, which is where the scaling would have gone.

Recorded because it was tried as a weight iteration and was a no-op: refitting inflicted_bv_per_round with --credit ratio produces the vector that already exists. The lever exists for the sum labels and there is nothing to tune here.

Levers that are not no-ops for a rate label, for whoever iterates next: gamma shapes the window a decision is credited with, and ridge changes the direction of the vector and not just its scale - which matters, because the flattest vector fitted this session was also the one that survived a change of candidate distribution best.

The Princess result is a 2v2 and 4v4 result #

Stated plainly because the number will outlive the context: 69-45 over 114 decided games was measured entirely on 2v2 and 4v4 scenarios, and fitted-r6 was fitted on a corpus of the same two sizes.

At 8v8 the same build times out on 8.9% of its decisions, against roughly 0.5% at the measured sizes. The win rate there is not yet known - the run is small and still going - but the decision quality is already worse, and a bot that defaults one decision in eleven is not the bot the 60.5% describes.

Fixing the timeout is a precondition for claiming anything at 8v8, not a follow-up to it.

8v8: 12-2, while timing out one decision in thirteen #

fitted-r6 on 18 games of 8v8, a size it was never trained on and whose corpus does not exist:

record 12 - 2, 14 decided, 85.7%
decisions answered 90.7%
defaulted 9.2%, all timeouts
illegal 0.1%

Read this carefully in both directions. Fourteen decided games puts the interval around 58% to 96%, so the honest claim is "not worse at 8v8", not "better at 8v8". And the bot is freezing on roughly one decision in eleven, each taking the harness default of stand still and hold fire - Princess is playing a full game against an opponent that periodically stops.

A higher win rate while handicapped is not evidence the handicap helps. The plausible readings are that 8v8 suits this vector for reasons unrelated to the timeout, that the scenario mix differs, or that fourteen games is noise. What it does say is that the ceiling is above what was measured: fixing the timeout can only add decisions the bot currently forfeits.

The corpus recorded the move the weights wanted, not the move that was played #

--explore 0.3 does explore movement - the force reports 26 explored orders across 48 reconciliations in a 4v4, about 17% - and reconcile_exploring, Explore::choose and Chosen::policy were all correct. What was wrong is one step later.

record_training rebuilt each row with Decision::score, which is a plain argmax over the menu, and never read decision.chosen. So on an explored movement the corpus wrote down the candidate the weights preferred while the game went on to play a different one - and the labels, which are computed from what happened next, went on measuring the outcome of the move that was actually made. About one movement row in six in every corpus this session paired one candidate's features with another candidate's result.

The method's own doc comment argued for the recomputation: "if the row and the order ever disagree, the corpus is describing a bot nobody played." The reasoning was right and the implementation inverted it. Recomputing the values is what that argument wants, so a number can always be re-derived from the features beside it. Recomputing the choice is what produced the bot nobody played.

Fixed here. Decision::score_taken takes the played index and its policy; record_training reads both out of decision.chosen, and skips a stalled unit rather than recording an argmax move it was refused. An out-of-range index falls back to the argmax and records argmax rather than quietly selecting a neighbour.

What this does not excuse. Every fit before this - including fitted-r6 at 60.5% - trained on movement rows where roughly a sixth carried mismatched features and labels. The weights are not invalid, but the exploration that was supposed to be teaching them something new was teaching them noise, and the measured gains came from the other changes.

A false alarm this also settles. Every fit report carries "the chosen candidate was the highest logged value on only 88.5% of decisions". Of 898 decisions all 103 disagreements are firing rows labelled explore, which is exploration working. The warning fires on correctly-labelled off-policy rows and reads as though the corpus were untrustworthy; movement, which was untrustworthy, showed 100% agreement precisely because the bug made it agree.

The fit report's argmax warning was firing on exploration working #

report warned "the chosen candidate was the highest logged value on only 88.5% of decisions... nothing fitted from it means what it says" on every fit this session. _logged_argmax already excluded imitation rows on the grounds that "chosen is not the argmax" is the definition of such a row rather than a fault in it. Explored rows are the same case and were counted as disagreements.

Re-fitting subset-menu-r6 with the exclusion in place, the warning does not appear at all: 1172 of 10328 decisions (11.3%) are off-policy on purpose and they accounted for every disagreement. The corpus was never dishonest.

The report now states the explored share as a fact rather than a fault, and the warning that remains says what it actually means: imitation and explored rows are already excluded, so a disagreement left over is one nothing accounts for.

A sign flip now names what it is collinear with #

The suspect-sign paragraph ended "explain it before benchmarking the vector" and left the reader to go and find the explanation by hand. The Gram matrix the fit already accumulates is exactly that explanation: gram[i][j] over the geometric mean of the diagonals is the cosine between two columns as the solve sees them, uncentred, which is the right centring for a no-intercept difference fit. It costs nothing - the matrix is built either way.

fitted-r7's three flips, with the partners now printed beside them:

flipped came out most collinear with
overkill +0.0105 p_kill +0.70, p_psr_threshold +0.25
p_mission_kill -0.0057 value_destroyed +0.66, p_kill +0.30
target_breach -0.0131 heat_incurred +0.61, p_breach +0.51

Each one has a stronger same-direction partner measuring an overlapping thing: overkill is a consequence of a shot that was going to kill, a mission kill is most of a kill by value, and a breach comes with the heat that caused it. What is left for the suspect after the partner takes the credit is a residual, and a residual's sign is not evidence about the feature.

None reaches 0.9, so this is an explanation rather than a dismissal: at 0.6-0.7 the columns are distinguishable and the fit is entitled to split them. The report says so rather than implying the pair is degenerate.

The heat family is one direction, and one column takes all of it #

EXPECTED_SIGNS covered eight firing features and nothing heat-side, so a heat weight could come out any sign and the report would not say a word. Four of them have a sign that is not a matter of opinion - heat_mp_penalty, heat_to_hit_penalty, heat_shutdown_risk and heat_ammo_explosion_risk are each a share of, or a chance of, something that happens to the machine taking the shot, and a label that rewards fighting well cannot want more of any of them. heat_incurred is deliberately left out: heat is the price of firing more guns, and a fit is entitled to pay it.

With the four added, fitted-r7 gains two suspects it had been hiding, and the partner column says what happened:

flipped came out most collinear with
heat_mp_penalty +0.0317 heat_to_hit_penalty +0.72, heat_shutdown_risk +0.44
heat_shutdown_risk +0.0198 heat_to_hit_penalty +0.69, heat_mp_penalty +0.44

heat_to_hit_penalty came out -0.1017, the second largest weight in the vector. The three are one direction at about +0.7 with each other, so the solve puts the whole "heat is bad" signal on the one column and hands the other two the residual, which is positive. Neither positive weight is a claim that shutting down is good.

That is a structural point about the basis rather than about this fit: three columns measuring the same underlying quantity through different consequences will always do this, and adding a fourth consequence of heat would not help. Either the family collapses to one measure, or the report has to read them as a group. Not decided here.

The label collapse is a property of the corpus, not of the labels #

train.py carried "bv_loss_inflicted correlates +0.97 with damage_inflicted, +0.94 with kills_secured" as a settled fact, and it was used here to argue that a six-label sweep was really a three-label sweep. Re-measured over princess-opponent-r7 (1245 rows), the same two pairs are +0.47 and +0.44.

pair comment said r7 says
bv_loss_inflicted / damage_inflicted +0.97 +0.47
bv_loss_inflicted / kills_secured +0.94 +0.44
damage_inflicted / kills_secured - +0.85

Only the last pair is still near-duplicate. The older figures came from mirror corpora, where both sides fight the same way and BV, damage and kills move together; against Princess they come apart. Max |r| against every other label now reads 0.56 for bv_loss_inflicted, 0.62 for inflicted_bv_per_round, 0.75 for inflicted_percent_of_maximum, 0.78 for mission_kills_inflicted - four usable directions where the comment implied one.

The comment now says which corpus its numbers came from and that sds labels <corpus> re-measures them. The general point is the one worth keeping: a correlation in a comment is a reading, not a constant, and this one was quoted for long enough to shrink a sweep that did not need shrinking.

Ranking a label sweep by agreement rewards standing still #

Six labels fitted over princess-opponent-r7, which fitted-r6 generated:

label R² agrees with played sign suspects cos(r6) cos(r7)
inflicted_percent_of_maximum 0.474 77.1% 4 +0.852 +0.835
damage_inflicted 0.442 71.4% 4 +0.827 +0.605
inflicted_bv_per_round (= r7) 0.326 74.1% 5 +0.556 +1.000
kills_secured 0.314 71.0% 4 +0.690 +0.759
bv_loss_inflicted 0.283 72.5% 6 +0.770 +0.735

inflicted_percent_of_maximum tops R², agreement and suspects, and it is also the vector closest to the weights that generated the corpus. Those are not independent facts. Agreement is measured against the choices fitted-r6 played, so a fit that stays near fitted-r6 scores well on it by construction, and "agrees with the played choice" is a measure of recovery, not of strength. R² is not comparable across labels at all - different targets, different y variance.

Read with the cosines instead, inflicted_bv_per_round is the interesting one: it moved furthest from the generator (+0.556) and still explains 74.1% of the played choices. That is the vector now being evaluated.

The next one to spend a benchmark on is damage_inflicted, at +0.605 from r7 - far enough to be a real third point rather than a re-run of it, with the second R² and the joint-fewest suspects. inflicted_percent_of_maximum is +0.835 from r7 and would mostly re-measure it.

None of this says anything about play. The head-to-head does.

Comparing two win rates by reading two log tails #

fitted-r7 was benchmarked against fitted-r6's 60.5% by starting a run and comparing the number at the end of it to a number remembered from an earlier one. The earlier one was measured over suite:labels2-suite, 144 scenarios. The new one was started without --suite and ran a single scenario 120 times. Two numbers, the same shape, about different games. Seven hours of machine time, and the interim read was only questioned because 27.8% was too far from 60.5% to be believable - a result near 60% would have been reported as a comparison that did not exist.

sds/baseline.py exists to make this impossible and its docstring says so: "most of it exists to refuse comparisons that would mislead", with scenario in COMPARABLE and a mismatch raising rather than warning. It was not used. There is no stored baseline for fitted-r6, its run directory is gone, and the 60.5% now survives only as text in a log.

The procedure, from here. A run whose number will be compared to anything carries --save-baseline <name>, and the run it is compared against carries --against <name>, which writes comparison.md with an interval on the difference. A win rate quoted from a log tail is not a result; it is a number whose conditions were not recorded. Two runs on the same suite, each with a saved baseline, is the cheapest thing that can settle r6 against r7, and neither of the runs already made can be repaired into one.

Every vector fitted this session comes from a corpus with the movement bug #

princess-opponent-r7 was recorded on labels2-suite against Princess - the same 144 scenarios the evaluation runs, so the distribution is right, and the manifest says so. What is wrong with it is the recording: it was generated before record_training was fixed, so about one movement row in six pairs the argmax candidate's features with the played candidate's outcome.

All six vectors in the label sweep inherit that, fitted-r7 included. Choosing between them is choosing between six fits of the same defective data, and the label that wins is partly the label least sensitive to a sixth of its movement rows being mislabelled.

So the next corpus matters more than the next label. The binary that records it correctly is built and tested; what it has never done is record a corpus. The order from here:

  1. fitted-r6 on the suite with --save-baseline - the first machine-checkable baseline this project has for it, and the thing every later comparison needs.
  2. A fresh corpus against Princess on the same suite with the fixed binary, at --explore 0.3. First corpus with honest movement rows.
  3. Fit it, and compare the vector against the r6 baseline with --against.

weights/fitted-r7-damage.json is staged from the sweep in case a cheap second point is wanted before then, but it is a fit of the same defective corpus and should be read that way.

fitted-r6 beats Princess, and 120 games cannot rank two vectors #

The 60.5% that stood all session as an un-baselined number from a log tail has been re-measured on the same suite against the same opponent, with a baseline recorded this time:

run record rate 95% CI
earlier (log tail) 69-45 of 114 60.5% 51.4%-69.0%
baselined re-run 66-45 of 111 59.5% 50.2%-68.1%
pooled 135-90 of 225 60.0% 53.5%-66.2%

Either run alone clears 50% only barely - the re-run's lower bound is 50.2%. Pooled over 225 decided games the lower bound is 53.5%, and that is the claim worth making: sds beats Princess on this suite, by about ten points, and it has now been shown twice.

fitted-r7 is 54-56 of 110, 49.1%, CI 39.9%-58.3%. It does not clear 50%.

What the comparison actually says. compare puts the r7-minus-r6 difference at -10.4% with a 95% interval of -23.0% to +2.7%. The interval spans zero. A 120-game run cannot tell these two vectors apart even at a ten-point observed gap, and separating an effect that size takes about 358 decided games per side.

That is a constraint on the whole method, not a fact about r7. Every A-versus-B this session has been run at about 110 decided games, which is enough to say "this vector beats Princess" and not enough to say "this vector beats that one". Ranking two candidates needs roughly three times the games, or two candidates far enough apart that a 15-point gap is plausible - games_needed puts that at 43 decided games. Fitting six labels and benchmarking the winner at 120 games was never going to resolve anything; the sweep's value is in ruling vectors out, not ordering them.

The honest corpus, measured mid-flight #

231 movement rows from the first twelve matches of honest-movement-r8:

what the row is rows share
argmax policy, unit got its top candidate 111 48.1%
argmax policy, unit took something else 64 27.7%
explore policy, genuinely off-policy 56 24.2%

The fix works. Every corpus before this one recorded 0 explored movement rows; this one records 24.2%, and all 56 of them took a candidate that was not the argmax of their own logged values. The rows describe moves the game played.

And something that was always happening became visible. 64 rows carry policy: argmax and still did not take their best candidate. That is not exploration, it is contention: a unit's preferred hex gets claimed by another unit that reconciled first, and reconcile reports it as "moved off their own pick" - nonzero in the live logs, which is the corroboration.

The old record_training recomputed the argmax, so this was invisible too. A corpus recorded before tonight does not merely mislabel the explored rows; it also writes down the hex each unit wanted in place of the one it got, wherever two units wanted the same ground.

The open question this raises. The fit reads chosen as the candidate preferred over the others. On these 64 rows it was the candidate left after somebody else took the ground, and the label still measures what followed. That is a third case beside argmax and explore, the force already counts it, and the training row has nowhere to put it. Whether a displaced row should be fitted, weighted down, or marked and dropped is a real question and is not settled here - but only 48% of movement rows are a unit getting what it asked for, so it is not a rare corner.

What actually differs between r6 and r7 #

fitted-r6 records its label as final_bv_differential+inflicted_bv_per_round, which reads like a deliberate composite and is not one: final_bv_differential is the fallback for a match the requested label cannot be computed for, and the string records that it fired. Measured, it fired for 5 of 2602 label rows, 0.2%. r6 is inflicted_bv_per_round for all practical purposes, and any argument that its strength comes from a differential label is wrong.

Both corpora are the same suite against Princess. The real difference is which bot recorded them:

corpus recorded by decisions
fitted-r6 subset-menu-r6 the default weights 10328
fitted-r7 princess-opponent-r7 fitted-r6 5110

So the weaker generator produced the better vector, and twice the decisions. A plausible reading is that a stronger bot's play is more concentrated - it makes fewer different mistakes, so its corpus carries less information about what the alternatives were worth - and princess-opponent-r7 being half the size at the same match count is consistent with that. It is a hypothesis, and the two vectors are not separable at 120 games, so it stays one.

What it does argue against is the assumption that generated the r7 corpus in the first place: that recording against the opponent you are evaluated on, with the best weights you have, is obviously better. It was not obviously better, and it was not measurably worse either.

The argmax warning found displacement and called it a corrupt log #

The first fit of honest-movement-r8 printed "On-policy decisions took the highest logged value only 79.7% of the time ... nothing fitted from it means what it says". It was the third false alarm from that one warning tonight, and the arithmetic is exact: 5944 decisions, 1281 explored, so 4663 on policy; 948 of those reported as displaced; 948/4663 = 20.3%, and 100% - 20.3% = 79.7%.

Every disagreement was a displaced row, and it could not have been anything else: for an on-policy row, "chosen is not the argmax" is the definition of displaced. The check and the counter measure the same rows, so leaving both in reported displacement twice - once as a measurement and once in the language of a corrupt corpus.

Displaced rows are now excluded from the argmax check, as imitation and explored rows already were. What remains would be a disagreement with no account at all, which is what the warning has always claimed to be about.

The pattern is worth naming, because it has now happened three times from the same six lines. The check was written when a training row could only ever agree with the argmax, so any disagreement really was a broken log. Each time the bot gained a legitimate reason to play something other than its top-ranked candidate - imitation, exploration, now contention - the warning kept firing and kept saying the corpus was untrustworthy. A guard that does not learn about new legitimate cases becomes a generator of false alarms, and a false alarm that appears on every fit is worse than no guard: it trained me to read past it, and I quoted it as a caveat for a whole session without chasing it.

Two runs over one suite are paired data, and were being read as independent #

Every run in this session covers the same 144-scenario suite with the same seeds, so any two of them see the same 120 fights. compare treats them as independent samples, which prices scenario difficulty as noise. It is not noise.

r6 against r7, the same 120 games read both ways:

result
unpaired (compare) -10.4%, interval -23.0% to +2.7%
paired (McNemar) 29 against 17 discordant, chi2 2.630, p = 0.105

108 pairs were decided in both runs and 62 of them went the same way for both vectors. More than half the sample says only that some scenarios are winnable and some are not; the comparison lives entirely in the 46 discordant pairs. Throwing the pairing away spends most of the games measuring the suite.

Neither test reaches significance, and the conclusion about r6 and r7 is unchanged - but the paired one gets several times closer on identical data, and the earlier estimate that ranking two vectors needs about 358 decided games per side was made with the weaker test. Paired, the requirement is much smaller.

sds paired <run> <run> does this. It refuses runs that share no scenario and seed, and says plainly when the discordant pairs do not separate the two, rather than reporting a direction that is only the larger half of a coin flip.

And how many pairs it takes #

At the effect r6 and r7 actually showed - 29 of 46 discordant pairs, a 63% split, with the two disagreeing on 42.6% of decided games - a paired comparison separates them at about 133 decided pairs. The run had 108.

split of discordant pairs decided pairs needed
55% 902
63% (observed) 134
70% 57
80% 26

So the earlier conclusion that ranking two vectors is out of reach was an artefact of the weaker test. It is not out of reach; it was 25 games short. paired_games_needed is beside games_needed so the two questions are asked with the right tool, and it returns 0 for a split at or below 50% - because a bot that is not better does not become better with more games, and a sample size is the wrong answer to that question.

The practical consequence: a 150-game run per vector, read paired, settles a difference this size. That is affordable, and it is what the next comparison worth making should be.

The recording fix did not produce a better vector #

fitted-r8 is fitted on the first corpus whose movement rows describe the moves that were played. Read against fitted-r6 on the 81 scenario-and-seed pairs both runs have decided so far:

pairs
both won 31
both lost 25
r6 only 12
r8 only 13

A 48% split of the discordant pairs. McNemar p = 1.000. On identical scenarios with identical seeds the two vectors are indistinguishable, and paired_games_needed returns 0 for a split like that: it is not a sample size problem, there is nothing there to find.

Their unpaired rates look different - 59.5% against 53.0% - and that difference is which matches happened to be decided rather than how the vectors played. The paired read is the one to believe.

What this does and does not say. It does not say the recording fix was wrong. The corpus is honest now in a way it demonstrably was not: 21.6% of its movement rows are explored where every earlier corpus had none, and 15.9% record a unit taking ground it did not pick. Those are facts about the data, not about the fit. What it says is that a fit of that corpus, at this size, with this label and this basis, plays the same as a fit of the dishonest one.

Two readings, and this run cannot separate them. Either the mislabelled sixth was never load-bearing - a difference-framed least squares over a million comparisons may simply absorb it - or the corpus is now honest about a signal the current feature basis cannot express. The second is testable by changing the basis rather than the corpus, and is the more interesting of the two.

honest-default-r9 is still queued and still worth running: it isolates the recording from the generating weights, which honest-movement-r8 confounds.

r8 final #

The run finished at 59-52 of 111 decided, 53.2%, CI 43.9%-62.2%. Paired against fitted-r6 over all 106 pairs both runs decided: 40 both won, 30 both lost, 20 to r6, 16 to r8. McNemar p = 0.617.

The interim read at 81 pairs was 12-13; the full run is 20-16. Both say the same thing and neither is close to separating. compare's unpaired answer is -6.3% with an interval of -19.0% to +6.7%.

So the three fitted vectors stand at:

vector record rate paired against r6
fitted-r6 66-45 of 111 59.5% -
fitted-r7 54-56 of 110 49.1% 29-17, p=0.105
fitted-r8 59-52 of 111 53.2% 20-16, p=0.617

fitted-r6 remains the only vector shown to beat Princess, and it is shown twice, pooling to 135-90 over 225 decided games. Nothing fitted since has improved on it, and nothing fitted since is distinguishable from it either.

A grafted vector played cleanly and lost 37 of 38 #

fitted-r6-plus is fitted-r6 with four features added to the basis and carried in at their hand-authored values - its own notes field says so. The two numbers are in different unit systems and always were: hand-authored.json is documented as the points a feature is worth at full strength and runs 0..8, and a fit against inflicted_bv_per_round lands near 0.02. Grafted unscaled, the four hand values came out ~65x every fitted one.

The bench read perfectly: 100% answered, no defaults, no illegal orders, no timeouts, and 1 win in 38 decided games. The bot fired 51% as often as Princess and both sides' hit rates collapsed, because it was positioning for cover and range bands rather than for damage. Nothing in any report said anything was wrong, and nothing could have: every existing check asks whether a vector is readable, not whether it is shaped like a vector.

The guard is dispersion, not a ceiling #

The obvious guard - warn above some absolute weight - fires on hand-authored.json every time, and that is the set the bot plays when no --weights is given. This epic already has the lesson written down one section up: a guard that fires on the known-good reference trains people to read past it, and is worse than no guard.

Dispersion::dominance is max |w| / mean |w|, a ratio inside one vector, so it reads the same whether the vector runs 0..8 or 0..0.08. A flat vector reads 1.0; a vector where one feature carries k times its even share reads k. Measured over every vector in weights/, as this build reads them:

vector n dominance largest
fitted-tempo 33 3.22 target_health -0.098
fitted-r6 / fitted 36 3.35 expected_damage +0.084
fitted-r5 33 3.44 expected_damage +0.390
fitted-hitloc 24 3.51 elevation_gain +0.948
fitted-r7-damage 36 3.77 expected_damage +0.427
hand-authored 37 3.82 heat_ammo_explosion_risk -8.000
fitted-r8 37 4.40 heat_to_hit_penalty -0.135
fitted-r7 36 4.80 value_destroyed +0.128
r6-no-positional 36 4.84 expected_damage +0.084
fitted-r9 40 7.53 incoming_damage -0.203
fitted-r6-plus 40 14.03 incoming_damage -4.000

fitted-r9 at 7.53 is a real fit that found incoming_damage worth a lot, and has to pass. The threshold is 10, which clears it with room and catches the graft.

Against the mean rather than the median, which is what was measured first. r6-no-positional zeroes 17 of its columns on purpose; that collapses the median and reads 51x, so a median-based guard would refuse a deliberate ablation on its first outing - the same false-alarm failure this section exists to avoid. The mean moves with the zeros instead of collapsing onto them and the same vector reads 4.84. It also carries n: dominance is the share of total absolute weight times the column count, so one threshold covers a 24-column vector and a 40-column one.

The consequence worth knowing is that dominance is bounded by n. A four-column vector cannot reach 10 however lopsided it is. That is correct for what this measures - a mature basis crowded with features, where jmm's reading is that nothing should tower over its neighbours because there is no silver bullet left to find - but the guard says nothing about a short vector and does not pretend to.

It fires from two places, because a vector arrives two ways. sds-bot's load_weights covers every vector that reaches a match, including the hand-edited file that caused this; sds train warns as it writes a fit, which is where somebody decides whether to spend a benchmark. Both are warnings and never refusals - a lopsided vector is the operator's business and one may some day be right. The threshold lives in sds_core::features::Dispersion::LOPSIDED and tests/test_train.py reads it out of the Rust source, so the two copies cannot drift.

target_skill was refusing two checked-in vectors #

Found while testing the above. Splitting target_skill into target_gunnery and target_piloting did not add the old name to RETIRED, so weights/fitted-r9.json and weights/fitted-hitloc.json were refused outright rather than read with the column dropped - --weights on either failed. A split cannot be aliased, since one column became two, so retiring it is the whole fix.

From the first fit on the corrected basis #

fitted-r11 came out of 300 matches at R2 0.252 against fitted-r10's 0.387, and played indistinguishably from the hand-assembled vector it was trained from

  • 65.5% of 58 decided against 75.0% of 36, intervals overlapping. Two explanations were tried and both failed: dropping the thermometer's components moved R2 by 0.001, and the label was reached for before the weights were read.

Three questions the label work left open #

An empty window is not a quiet one. RATIOS returns 0.0 when neither side did anything, on the stated rule that "a quiet stretch scores zero". QUOTIENTS, forty lines away, returns None for a window with nothing in it, because calling it zero "would fill the column with rows saying we failed to get behind them about windows where nobody shot anybody". The last round of a match is where they collide: its window is empty, not quiet. Measured over the 58-match bench, both ratio labels read 0.0 there in 600 of 600 seat-matches - winner and loser alike - and 7.3% of all decisions are taken in that round. Under a ratio label those rows say "maximally out-traded" about a round where the question was never asked. Nothing has been changed; the 0.0 is deliberate and documented, and this is a decision rather than a defect.

Two labels do not point at winning. Over ~1200 paired comparisons on the 58-match bench, finishing ranks the winner at 50.2% - chance - and damage_focus at 42.9%, below it: concentrating damage correlates with losing these matches. Both are stable enough to be a signal rather than noise. Either they do not measure what their names say, or the tactical advice they encode is wrong for this matchup.

The per-unit and per-force tiers have never been fitted. --tier unit|force exists and every one-sided label has a form at every tier. In an 8v8 one unit's choice currently carries seven other units' luck, which is a straightforward attribution error the team tier cannot avoid.

Dropping the displaced rows moves the fit, and not where expected #

--drop-displaced leaves out the decisions where the weights' first choice was taken by another unit before the force reconciled. Over the 300-match corpus that is 3721 of 18980 decisions, 19.6%, and 1.37M of 4.99M difference rows.

kept dropped
decisions 18980 15259
rows 4989629 3623485
r_squared 0.2518 0.2309
agreement 0.506 0.443

The two r_squared figures are not comparable and neither is the agreement. Dropping 27% of the rows changes the denominator both are measured against, so the pair is not a ranking, and the report now says so rather than inviting the reading. What the numbers can say is that the fit moved: the two vectors correlate at r = +0.93 with five sign flips, which is a different vector rather than a nudge.

Where it moved is the interesting part, and it is the opposite of a nuisance correction:

kept dropped
expected_damage +0.0868 +0.0636
incoming_damage -0.0334 -0.0720
level_tmm -0.0287 -0.0897
enemy_centroid -0.0376 -0.0081

Summing the terms that pull us in against those that hold us off, the balance goes from 1.69:1 in favour of closing to 0.40:1 - defence outweighs offence once the displaced rows are gone. That is the same quantity plan/features.md calls "offence is priced exactly and defence is not priced at all", arrived at from a completely different direction, and it says a substantial part of that imbalance was rows where the bot did not get the hex it asked for being read as a preference for the hex it settled for.

Two cautions before this is believed. level_tmm going to -0.0897 is a larger violation of "more defence is never worse", not a smaller one. And the heat group's suspect signs got worse rather than better - heat_shutdown_risk joins heat_ammo_explosion_risk and heat_mp_penalty on the wrong side, all three mutually collinear above 0.7, where the report's own rule says the split of weight between them is arbitrary.

Fourteen columns with no weight, and what a fit will say about them #

A census over 1,112,871 candidate values found fourteen columns at zero in both weights/hand-authored.json and weights/princess-bench.json. Ablating a zero is a no-op, so nothing was known about any of them.

Twelve of them turn out to have been fitted already, 13 to 15 times each, across fitted-r5 through fitted-r12-nodisp; grafted-r10, which recorded both large corpora, plays eleven. Only expected_criticals and target_described have never carried a weight in any file in weights/.

Full working in comparison-price-the-unpriced.md. What matters here:

  • Nine of the fourteen have no price. On the two current-basis corpora pooled - 120 matches, 1,437,161 comparisons - their bootstrap intervals over resampled matches all cover zero. cover_distance, edge_distance, enemy_centroid, enemy_nearest, overlook, p_breach, range_spread, target_original_bv, target_tonnage.
  • heat_incurred is +0.1667, 95% CI +0.1301 to +0.2210, beside expected_damage +0.2509 in the same solve. The record left this one for a fit and the fit pays for heat, modestly. It is not the +6.0 the six-arm experiment tried, and it is not zero.
  • expected_criticals is -0.1783, CI -0.2996 to -0.0767, against EXPECTED_SIGNS +1. Reported, not flipped. It is the most collinear partner value_destroyed has, at +0.77 over the difference rows.
  • target_described is -0.0740, CI -0.1190 to -0.0348, its first weight anywhere. An indicator, so this is the constant offset its own documentation says a weight on it buys.
  • Pricing them buys nothing measurable. weights/fitted-r13.json and weights/fitted-r13-control.json differ only in whether the thirteen fittable ones carry a weight: held-out on 71 matches the fit never saw, best-scaled R² 0.28127 against 0.28114, difference +0.00012 with a bootstrap 95% CI of -0.00266 to +0.00299. In play over the suite, fitted-r13 took 36 of 67 decided against hand-authored's 38 of 65, a change of -4.7% with an interval of -21.0% to +11.9%. Both arms answered about 99% of their decisions and neither issued an illegal order.

The blocker is the corpus, and it is a hard one #

Ten stored corpora are at basis 46a42e2765de. Every one of them was recorded at exploration 0.0. Every corpus that carries exploration is a basis behind - sds train crits-corrected at this commit is refused outright.

So the current-basis numbers above come from a zero-exploration corpus. They are usable for these columns only because the vector that recorded it, princess-bench.json, prices all fourteen at exactly 0.0: a column the policy weighted at zero steered no choice, so its variation there is exogenous. For any column that vector did use, the same corpus can only confirm what it was generated from.