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 throughgit rev-parse --git-common-dirso 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 andcorpora/<name>/manifest.jsonis 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_columnsandthin_columnsare 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
localin 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_scanstreams 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_takenfor the same reason engagement needsdamage_inflicted: without it,exposurenever sees a signal that is actually about being shot at. - Trading against realising.
damage_efficiencyis 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_securedneeds no new instrumentation - the per-roundunitscount 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, becausegammaalready 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_killwas 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.crippledis MegaMek's ownEntity.isCrippledrather than a rule re-derived here. It overlapskills_securedby 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 withcohesionandconcentration. 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_gainandrear_arc_exposurewere 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 withdamage_inflictedand rear points taken +0.86 withdamage_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) andheat_ammo_explosion_risk(0.0%) are dropped for want of something to correlate against. They are not.thin_columnsdrops 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 fittingtempoas fittingbv_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_sufferedis 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_ratioat +0.96.bv_lost~damage_takenat +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.
finishingis 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_sufferedis 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:
- a unit that was
done: falseand is nowdone: truetook its turn in between, and where it is now is where it chose to be; - 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;
- the observed hex goes into that menu as one more candidate, before anything
is measured -
damage_leadis min-maxed across a decision's own candidates, so a candidate added afterwards would not share the row's spread; - 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_lostanddamage_takenare 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_inflictedandkills_securedneed 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_incurredreads 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
BASISconstant, the open item below. Cheapest, and it fails the waysds/epoch.pyfails: 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
measurebody - 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:
Botto hold theExploreconfig. It does not today - the config is constructed inmainand handed toForceThinker.- a perturbation after
Decision::score, overrow.candidates[].value.Explore::choosealready takes values rather than a count, so it fits. - a
policyfield on the firing training row. This is a corpus format change, and it is the reason not to do it in a hurry:Chosencarriespolicyfor 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:
Ordercarriesvalue(what was taken) andon_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.
choosecorrectly 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_inflictedcounts the points either way anddamage_focussees 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.dispersionmeasures 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 unlikeshutdowns_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 wasp_psr_thresholdon the feature side.crippled_suffered- our machines put out of the fight, the mirror ofmission_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:
fitted-r6on the suite with--save-baseline- the first machine-checkable baseline this project has for it, and the thing every later comparison needs.- A fresh corpus against Princess on the same suite with the fixed binary, at
--explore 0.3. First corpus with honest movement rows. - 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_incurredis +0.1667, 95% CI +0.1301 to +0.2210, besideexpected_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_criticalsis -0.1783, CI -0.2996 to -0.0767, againstEXPECTED_SIGNS+1. Reported, not flipped. It is the most collinear partnervalue_destroyedhas, at +0.77 over the difference rows.target_describedis -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.jsonandweights/fitted-r13-control.jsondiffer 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-r13took 36 of 67 decided againsthand-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.