--- 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. - [x] **Label pipeline**: `Phi` per candidate and the chosen index are on every movement and firing line of the decision log, and `sds train` consumes them. See the training row in [PROTOCOL.md](../docs/PROTOCOL.md) - [x] **The label itself**: eight one-sided labels, each a discounted sum or a ratio of two, from the decision's round to the end of the match. None of them a differential; `--label`, `--gamma` and `--credit` are the knobs. See "The label" below for the corpus numbers and for what the differential did - [x] Per-unit and per-force label tiers. `--tier unit|force|team`, defaulting to `team`. The eight one-sided labels have a form at every tier; the two differentials have none below the team and refuse. No corpus can carry a sub-team tier yet - see "Labels should be hierarchical" below for what the round tick has to record first - [x] Fitting script: linear regression, writing a new `M`. `sds train`, in `sds/train.py`. Least squares on the difference between the chosen candidate and the ones it beat, refusing any `local` feature, writing `weights/fitted.json` - [x] Load a fitted `M` back: `sds-bot --weights ` reads the `weights` object of that file. Missing, malformed, or naming a feature that does not exist is fatal and says which; the flag rides along in the harness's `--bot` command string - [ ] Train at zero noise. Weights fitted with noise active learn to be noise-robust, which silently changes the policy - [ ] Difficulty parameters in an outer loop, fitted to a target win rate - [x] Keep a hand-authored `M` as a permanent baseline. `Weights::hand_authored` is what the bot plays with when no `--weights` is given, one commented line per weight, and `weights/hand-authored.json` is the same set as a file a bench can be pointed at. If a fitted set only wins by a few points, ship this one - it is editable and explainable - [ ] Self-play only near parity. Two bad bots teach each other to beat a bad bot - [x] **Imitation, to bootstrap**: reconstruct the opposing seat's movement from two consecutive observations and record it as a training row whose `chosen` is what they did. `sds bench --imitate` writes `-.imitation.jsonl`; `sds train --imitation` fits it. See `crates/sds-bot/src/imitate.rs`, and the section below for what it costs - [ ] Re-measure any imitation-fitted `M` against the bot it was cloned from. Until that number exists, an imitation fit has proved nothing - [x] **A corpus is an asset, not scratch.** `sds corpus save ` copies a run out of `runs/` into `corpora//data/` beside the main checkout, and writes a committed manifest beside it. `sds corpus list` is one line each. See "Keeping a corpus" below - [x] Refuse a stale fit. `sds train` reads the manifest before it reads the logs, and stops on a different epoch, a `--feature` the corpus lacks, or data the manifest does not describe Compute is not the constraint: ~0.1 core-hours a match, so 20,000 matches is ~$24 of Graviton spot. The constraints are a bot worth learning from and a harness that does not discard a quarter of its games. ## Keeping a corpus A 360-match corpus - 21937 decisions, about eight hours of machine time - was destroyed by one `git worktree remove --force`. `runs/` is gitignored and inside the worktree, and the only other reference to it was a directory of symlinks in a scratchpad, so there was nothing left that even named what had been lost. A corpus is now stored rather than left where it was written. - The store is `corpora/` beside the **main** checkout, found through `git rev-parse --git-common-dir` so it resolves to the same place from any worktree. Removing a worktree cannot reach it. - Files are copied, with `shutil.copy2`, which follows symlinks. A store of links into a directory somebody else may delete is the failure this exists to prevent, not a cheaper version of it. - `corpora//data/` is gitignored and `corpora//manifest.json` is committed, so a corpus that is gone is still described: commit, epoch, suite fingerprint, bot command, weights file, exploration rate, counts, and the sorted list of every feature name in it. That last field is what the manifest is for. The same overnight corpus was fitted against a newer feature set than the one that recorded it, and nothing objected - only `thin_columns` caught a downstream symptom, hours later. `sds train` now reads the manifest before the logs, on the rule `sds/baseline.py` already applies to a benchmark: what changes the meaning of a number travels with the number, and a mismatch is an error rather than a footnote. | mismatch | what happens | |---|---| | a different epoch | refused, naming each epoch change in between | | a `--feature` the corpus does not have | refused, naming which of the ones asked for are absent | | data the manifest does not describe | refused; the two were not written together, so nothing in the manifest is reliable | | a feature the baseline `M` uses that the corpus lacks | warned, before the report and again after it | `--stale-ok` fits a refused corpus anyway and prints what is wrong. The last row is a warning rather than a refusal on purpose. A decision only logs the features its own phase computes, so every corpus recorded today is missing the nine positional ones - refusing would fire on every fit anybody runs, and a guard that fires every time is a guard that gets `--stale-ok` without being read. It is still worth saying loudly: a fitted vector with no weight for `exposure` plays a different bot than the hand-authored one it is measured against. ### The rule was written down, and then walked into anyway `20260827T030350Z-bench` - the asymmetric corpus, 330 matches and 7568 movement decisions - was **destroyed on 2026-08-29** by `git worktree remove --force` on `.claude/worktrees/asym`, after `claude/asymmetric-forces` merged. Removing a worktree when its branch lands is what `CLAUDE.md` says to do; the corpus was in that worktree's gitignored `runs/` and had never been copied into the store. There is no other copy on this machine. That is the same failure as the 360-match corpus above, on a machine where the lesson was already written down, with the tool to prevent it already built. The tool was not the problem: `sds corpus save` works. Remembering to run it is the problem, and it has now been forgotten twice. The surviving half was in the same position. `20260826T021818Z-bench` - the mirrored corpus, 300 matches, 8214 decisions, every first figure in [TACTICS.md](../docs/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](harness.md#a-corpus-is-kept-compressed), 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. - [ ] An integrity check that runs on read, or on a schedule: a stored corpus whose manifest claims decisions it no longer holds should say so before somebody fits it. Save-time verification cannot see a later deletion **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. - [ ] Zero `honest-movement-r8`'s `decisions` and `matches`, or delete it. jmm's call, and it decides the treatment of `eval-r7-suite`, `princess-opponent-r7`, `retired-basis-r5` and `subset-menu-r6` with it ### 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: - [ ] `sds train --features-from FILE`, reading one name per line. A file is a thing that can be *empty*, which is a state a list of flags cannot represent - so an empty file becomes an error instead of a silence, and "fit everything" stays what it is today: the absence of the flag. Same distinction as an absence that says so versus a zero, which is most of this epic ## Nineteen instances of one mistake Written as one section rather than left spread across four epics, because they were found separately over one night and are the same mistake nineteen times: **a proxy was consulted where a direct check was available and cheap.** Each proxy is true in the common case, which is why none of them looked wrong. Each fails in the case somebody eventually hits. ### Read this one first Two watchers - one to cut a benchmark at a deadline and save its corpus, one to start the fit afterwards - polled `pgrep -f 'sds.cli bench'` to decide whether the benchmark was still running. **That pattern never matches the benchmark.** Its command line is `sds.cli --timeout-ms 60000 bench`, with the flag between the two words. What it *did* match was the launcher shell of each watcher, because the shell command that started them contained the literal string the pattern was looking for. So both waited correctly for two hours, for entirely the wrong reason, and nothing in their behaviour distinguished that from working. Had the unrelated shell exited first, `deadline-stop` would have logged "bench finished on its own", never cut the run and never saved the corpus; `chain-refit` would then have fit against a corpus still being written and reported the result. It has every property this section is about at once: a proxy that looked exactly like the check, green-looking behaviour that proved nothing, a failure that would have produced **a real-looking number about nothing**, and a direct check available the whole time. It is also the only one caught while still live, so it is a near-miss rather than a post-mortem. **The fix is not a better pattern. It is not using a pattern.** A `pgrep` pattern is a *description* of a process; a pid is a *name* for one. Reading a recorded pid and asking `kill -0` is not a more careful version of the same idea, it is a different idea, and no amount of care makes a description into a name. Two things go with it. A watcher now **logs the pid it is watching** on start - one that cannot say what it is waiting on cannot be checked by anybody, including its author. And where a pid genuinely is not available, `pgrep -x -f` requires the whole command line to equal the pattern, which is verifiable from a detached script so the checking shell is not in its own match set. | the proxy | stood for | the direct check | how it failed | |---|---|---|---| | `--corpus` was passed | this run is worth keeping | does the run have decision logs? | two corpora recorded, neither saved, nothing said so | | `copied > 0` | the training data arrived | are the source's decision logs in the store? | `honest-movement-r8`: 5944 decisions asserted, zero logs | | `head_commit()`, `current_basis()` | what recorded this corpus | read the run's own stamp | a rescue stamped three-day-old data with tonight's basis | | the branch has merged | nothing valuable is in this worktree | `ls runs/` | the asymmetric corpus, 330 matches, gone | | this scratch dir has fewer files | it is the stale one | `lsof`, or which process holds it | destroyed a running measurement | | `sweep` returned | it measured something | is `read` over the noise threshold? | a precision table of `0.000` with standard errors of `0.0000` | | `--label`'s default | the recipe the lineage fits with | read the `fit` block of the vector being compared against | a vector four to nine times larger, readable as "the damage model moved everything" | | `-j 2` | I am not taking the machine | `ps`, once | a test binary at 710% CPU while four agents shared the box | | `value_destroyed == 0` | the target's armour is intact | read what `value_destroyed` sums | an ablation that answered about the wrong half of a column | | a corpus's *name* | the corpus I mean | the commit and basis in the manifest about to be written | one corpus stamped with another's provenance | | `pgrep -f 'sds.cli bench'` | the benchmark is still running | the pid, and `kill -0` | above | | a field appears in a `put` call | the wire always carries it | read the guard around the `put` | two fields required that are Mek-only and prone-only | | `prone` re-derived from the entity | the `prone` the node was written with | read it off the node | two different moments compared as one | | a fault *label* | which check raised it | one label per check | `canStand` listed twice, both raising the same name | | `canMakeAntiMekAttacks()` | the unit has the weapons for it | read the mounted weapons | a capability answering a different question | | a corpus *name* passed where a path was wanted | the corpus's location | resolve the name through the store, or take a path | the generator wrote nothing, silently | | no `--feature` flags reaching `train` | the caller meant "fit everything" | check the generated list is non-empty before using it | a ten-minute fit that was the same fit twice | | a hardcoded `(absent)` in a print format | the value was absent | print what was measured | read my own placeholder back as evidence | | `pgrep -f 'fitted-r13-no-crits'` | that fit is still running | `pgrep` from a script, or check the log's last line | reported a finished job as running for twenty minutes | Two of the six are operational rather than code, and they are not a different lesson: the worktree removal and the scratch deletion each had a one-command direct check available and used a plausible signal instead. **Four of the fifteen are the battle-armor agent's**, found while sweeping 1188 designs through the observation. The sweep failed all 1188 three times running, and every time the **checker** was what was wrong rather than the wire. Their third is a shape none of the others has: `canStand` appeared twice in the required list and both checks raised the same fault name, so fixing one changed the count by nothing - which read as evidence the wire was at fault. **A diagnostic that cannot distinguish its own causes sends you looking in the wrong place**, which is worse than one that is merely silent. Their two general formulations are the sharpest anyone produced, and both are theirs: > A proxy over a fixture only fails when somebody changes the fixture, so a > long-green proxy measures incuriosity rather than correctness. That is why every instance here was old and none was caught by CI. Green for months is not evidence; it is the absence of anyone having looked. > What caught mine was not review but widening the fixture for an unrelated > reason. True of two of theirs and of several here. If it generalises, the practical defence is less about care at the moment of writing and more about **periodically changing what a test is pointed at**. It sits beside the reading rule below rather than replacing it: that one is about how you read a result you are looking at, this one is about how you find the ones you never read at all. **The nineteenth is the eleventh, made a third time, an hour after it was written up as the one to read first.** A fit was reported as "still running at twenty-plus minutes" against a ten-minute baseline. It had finished eleven minutes earlier; `pgrep -f` was matching the harness shell whose command line contained the string being searched for. The direct checks each took seconds - read the log's last line, or run `pgrep` from a script that does not contain the literal - and either would have said so immediately. What is worth keeping is not the repetition but what flagged it: **the anomaly pointed the wrong way.** Fitting fifty columns cannot be slower than fifty-one. An unexplained figure that runs opposite to the mechanism is the same signal as an answer that is too clean, and it is the one that has caught the most tonight. **The last three came from one attempt to measure one column**, and they are worth reading together because they compound rather than merely accumulate. A script was handed a corpus *name* and looked for it as a *path*. It failed and wrote an empty file. The shell expanded that empty file to nothing, so `sds train` received no `--feature` flags at all - which legitimately means "fit everything". The ablation was therefore the same fit twice, and it ran for ten minutes and produced a well-formed answer. That answer was `r-squared +0.00000000, agreement +0.00000000`. It **confirmed the prior** - that the column is a re-expression - so it was not interrogated. What broke it open was that it was *too clean*: a genuinely refitted design matrix should perturb something, and not one weight in fifty had moved. That is the rule three sections below, applied to my own output twenty minutes after writing it down. The third is the nastiest and has no upstream at all. An inspection script printed `expected_criticals +0.0122 -> (absent)`, and `(absent)` was a **literal in the format string**, not a check. Every other proxy here is a description standing in for a measurement; this one is a *conclusion printed without being computed*. It survives review perfectly, because the output says exactly what a correct output would say. **And the name-as-path mistake was made hours after fixing the same mistake elsewhere**, in a file written afterwards, by someone who had just written the entry about it. That is stated plainly rather than softened because it is the strongest evidence for the claim below: this is not a thing careless people do. **Three of them were made while fixing the others**, which is worth stating plainly rather than filing as bad luck. The scratch-directory deletion happened while chasing a corpus defect; the corpus mis-stamping happened while writing the tool that records a corpus's scope; the watcher pattern was written to protect a run from exactly the class of accident it would have caused; and the name-as-path bug was re-made in a new script hours after being fixed in another one, by the person who had written the entry about it. Working on this class of bug does not confer immunity to it - it is not something careless people do, it is something everybody does, including while actively looking for it. The battle-armor agent's four sharpen that rather than merely adding to it: all four were in a **checker**, and the checker failed 1188 designs three times running while the thing it was checking was correct. A tool written to find this class of mistake is not exempt from it, and when it makes one the mistake arrives wearing the authority of a diagnostic. **Why the wrong check survives long enough to matter.** The `value_destroyed` one is the clearest case and the mechanism generalises: > I answered a different question than the one I was asked, and did not notice > because the answer came out convenient. A check that confirms what you already believe gets less scrutiny than one that contradicts it, and that asymmetry is invisible from the inside. Nothing in the output looked wrong - the number was real, it was simply about something else. The defence is not more care. It is **preferring the check that could embarrass you, and being suspicious in proportion to how well the answer fits**. **A test that mutates what it tests on is not a dry run**, whatever it is called. `fix-manifest-scope.py` was run against a real corpus "to prove it worked", and proved it by working - on the wrong one. There was no read-only mode because none was asked for. The consolation is that the machinery added for the third defect caught the tenth. A manifest's record of the commit and basis that produced it is what refused to let another corpus's claim be written onto it: ``` tactics-hub-mirrored/manifest.json was recorded at commit '1d6bb72-dirty' on basis '4a963bc90ee9'; this scope is about 03e72bf / ab0cad038be8. Refusing to annotate a corpus it is not about. ``` **What separates the dangerous ones from the merely wrong.** Four announce themselves eventually - an absent corpus is noticed, a table of zeroes looks odd. The provenance one does not: it makes `sds train`'s staleness check **pass**, so the fit runs and the output looks like every other fit. A proxy guarding a guard is worth more scrutiny than a proxy guarding a result. The rule that falls out, and the one worth applying to the next thing written here: **if the direct check is one command, it is not too expensive.** Every proxy above was chosen for convenience, not for cost. ## The fit streams `sds train` was killed by the OOM reaper on a 573-match, 28 GB corpus, and took a benchmark that was running beside it with it. The reader held every decision, each with every candidate's feature vector, so peak memory was the corpus. Least squares needs `XᵀX` and `Xᵀy` and nothing else, and both are sums over rows. So nothing has to be held: read a log, form that decision's `chosen - rejected` rows, add them into the two moment matrices, drop them, move on. `Normals` in `sds/train.py` is that accumulator and `scan_run` is the pass. Peak memory is one decision plus a matrix that is one row and column per feature - about 110 MB on the corpus that killed the old reader, and the same on 24 matches as on 573. Three things had to survive it. - **Every diagnostic stays exact.** `dead_columns` and `thin_columns` are per-column counts of non-zero entries, which is a running total like anything else. Uncentred R² is `Σ w y² - 2 bᵀ Xᵀ W y + bᵀ Xᵀ W X b`, which the same two matrices answer. Nothing was turned into a sample. - **Which columns may be fitted is not known until the last log is read** - a feature that is `local` in any decision is excluded from every one - so columns are discovered as they arrive and dropped afterwards. Dropping one is taking a submatrix: `gram[i][j]` is a sum that never knew the other columns existed, so the numbers are the same to the last bit as a full design matrix in the same row order would have given. - **Agreement needs a second pass.** It is the share of decisions the fitted weights would have picked the same way, and the weights do not exist until the first pass is over. `fit_scan` streams the logs again rather than keeping them: memory stays flat and the number stays exact. A sampled diagnostic that reads like a measured one is worse than none. Determinism is unchanged and comes from a different place than it used to. `Corpus.rows()` sorts because a directory listing is not a promise; the stream gets the same guarantee from `sorted` over the filenames and file order within each, so two fits of one directory give the same numbers. It is not the same order, and floating-point addition is not associative, so the last bits differ from the in-memory fit - on `corrected-basis-explore` the two agree to about twelve significant figures and the printed report is byte-identical. `TestDeterminism.test_row_order_does_not_move_the_answer` is the standing check that row order does not move the answer at all. `sds corpus save` scans the same way, for the same reason. ## The label `sds train --label` picks one of sixteen. Two are differentials and are kept only for comparison; fourteen are one-sided and are what a fit should use. | label | one-sided | what it is | |---|---|---| | `final_bv_differential` | no | `(ours_left - theirs_left) / ours_start`, given to every decision in the match | | `bv_delta_next_3_rounds` | no | the same differential, moved over the three rounds after the decision | | `bv_loss_inflicted` | yes | discounted enemy BV removed, from the decision's round to the end | | `bv_lost` | yes | discounted BV of ours removed, same window | | `damage_inflicted` | yes | discounted enemy armour and structure removed | | `damage_taken` | yes | discounted armour and structure of ours removed | | `kills_secured` | yes | discounted enemy units removed, as a share of what they started with | | `bv_trade_ratio` | yes | enemy BV removed against ours lost | | `damage_trade_ratio` | yes | damage inflicted against damage taken | | `damage_efficiency` | yes | damage inflicted against what our own guns could have landed | | `tempo` | yes | enemy BV removed, per round it took to remove | | `mission_kills_inflicted` | yes | enemy units removed from the fight, killed or crippled | | `dispersion` | yes | how spread out our force stood, as a share of the board | | `rear_share_dealt` | yes | the share of the damage we dealt that landed on rear armour | | `rear_share_taken` | yes | the share of the damage we took that landed on ours | | `shutdowns_suffered` | yes | our machines heat switched off, per machine we brought | **A differential is symmetric under mutual disengagement.** If neither side trades it does not move, so declining to fight scores exactly what fighting well scores. That is not a subtle defect. An eight-hour run of 17 iterations, 680 matches and 21937 decisions fitted `overkill +0.21`, `p_kill -0.07` and `target_breach -0.08` - that killing things is bad - and every one of the 17 vectors lost every decided game, 0 wins in 179, with Princess finishing on 96.6-99.4% of its battle value. A bot that never engages is a faithful maximiser of that label. R-squared was flat at ~0.11 from 40 matches through 360, so sample size was never the constraint, and off-policy exploration was already running at ~23% of decisions. A one-sided label counts one side's losses and nothing else. A round where neither side traded scores zero in every one of them. ### Why this many Not a fishing expedition. `plan/tactics.md` names a strategy only when it has an objective to fit it against, and `M`'s rank is bounded by the number of independent labels - so the label list is the ceiling on how many distinct behaviours this bot can ever have. Each pair below is a distinction a single label cannot make. - **Damage against battle value.** BV is recalculated from what still works, so chipping armour barely moves it while blowing a gun off drops it hard. A unit that trades badly in damage and well in BV is doing something specific. - **Inflicted against taken.** Avoidance needs `damage_taken` for the same reason engagement needs `damage_inflicted`: without it, `exposure` never sees a signal that is actually about being shot at. - **Trading against realising.** `damage_efficiency` is *not* an exchange rate. It is damage landed over what our own guns could have landed, and it catches failures no exchange rate can see: firing at extreme range where nothing hits, having no line of sight, sitting shut down from heat, walking a turn instead of shooting. All of those waste potential without moving a trade ratio. - **Kills against damage.** `kills_secured` needs no new instrumentation - the per-round `units` count already gives it - and it is the sharpest test of whether the label was ever the problem. ### Six more axes, and the two that were not built Ten labels is not ten behaviours. Correlated over a real corpus they collapse to about five independent directions: `bv_loss_inflicted` sits at +0.97 with `damage_inflicted` and +0.94 with `kills_secured`, and the two trade ratios at +0.96 with each other. Adding an eleventh of the same shape buys nothing. Each of these was picked against a feature, or a family of features, that had no outcome to be right or wrong about. - **`tempo`** - nothing measured *how fast*. A win in round 5 and a win in round 15 scored the same, and 112 of 555 overnight matches hit the round cap. Enemy BV removed after the decision, divided by the rounds it took: undiscounted, because `gamma` already prefers a sooner payoff but cannot tell a five-round match from the first five rounds of a fifteen-round one. **It does not pay a unit for dying fast.** A fast death shortens the match too, and a "shorter is better" label would reward it - so the numerator is one-sided. Get wiped out in round three having taken nothing off them and the label reads zero, which is the worst score it can give. - **`mission_kills_inflicted`** - `p_mission_kill` was a feature with no label. A count of enemy machines no longer in the fight, killed *or* crippled, as a share of what they brought. `crippled` is MegaMek's own `Entity.isCrippled` rather than a rule re-derived here. It overlaps `kills_secured` by construction and the overlap is the point: legging a Mek removes it for a fraction of what a kill costs, and only a label that counts both can prefer the cheaper one. - **`dispersion`** - a force-shape outcome, pairing with `cohesion` and `concentration`. Mean distance between two of our machines over the board's span, averaged over the rest of the match rather than summed, so it is not partly a measure of how long the match ran. The only label with **no expected sign in either direction**: whether spreading out is good is the question, not something already known, so the sign check is skipped rather than run against a guess. - **`rear_share_dealt` / `rear_share_taken`** - `rear_arc_gain` and `rear_arc_exposure` were features with no outcome. Rear armour over all the damage in the same window, both directions. **A share, not an absolute.** Written first as absolutes they measured the wrong thing: rear points landed correlated +0.85 with `damage_inflicted` and rear points taken +0.86 with `damage_taken`, because an absolute mostly says "did we land anything at all" rather than "did we get behind them". Dividing by the damage over the same window decorrelates them almost completely - both drop to a maximum |r| of 0.19 against any other label. Undefined where nothing was hit, and **None rather than zero** there: a window in which nobody shot anybody has no rear share, and calling it zero would fill the column with rows claiming a failure to flank. That costs about a third of the rows and is worth it. The honest limit stays: MegaMek records where a shot landed and never which way it came from, so this is damage that arrived *on rear armour*, a subset of damage that arrived from the rear arc - a rear shot into a leg is invisible here. - **`shutdowns_suffered`** - a count of the times heat actually switched one of our machines off, on the *rise*, so one shutdown lasting three rounds is one event. The most independent label in the set at a maximum |r| of 0.24 against anything else - and also the sparsest: 3 shutdown unit-rounds in the first eight matches of the 96-match batch. **It does not rescue the heat features, and no label can.** It was added in the belief that `heat_shutdown_risk` (0.2% non-zero) and `heat_ammo_explosion_risk` (0.0%) are dropped for want of something to correlate against. They are not. `thin_columns` drops a column for being nearly all zeros *in the difference rows*, which is a property of the feature and the corpus and has nothing to do with the label - the same four heat columns dropped at the same percentages fitting `tempo` as fitting `bv_loss_inflicted`. What those features need is a corpus in which the bot gets hot, which is a matter of play and of exploration, not of labelling. `shutdowns_suffered` is still worth having as an outcome; it is not the fix that was hoped for. **A label cannot un-drop a column.** Stated once here because the mistake above is an easy one to repeat: a near-constant feature is dropped by `thin_columns` before any label is consulted. Adding an outcome that is *about* a feature does nothing for a feature that never varies. **Not built: the heat roll itself.** "How many heat effect rolls did we suffer" is not answerable from a result document. MegaMek states a shutdown plainly and does not record the roll behind it anywhere a host can read after the fact, and deriving "a roll was required here" from a heat level means re-implementing the heat effects table in this repository - a rule, and rules stay MegaMek's. `shutdowns_suffered` counts the effect rather than the roll, and is named for what it counts. **Not built: ammunition explosions from heat.** An ammunition bin goes off from a heat roll and from a critical hit, and the result document cannot tell the two apart. A label calling both "heat" would give `heat_ammo_explosion_risk` a column to correlate against that is mostly not about heat, which is worse than leaving it dropped. That feature stays unlearnable until the protocol carries the cause. **Not built: self-inflicted loss.** A fall from 20+ points of damage is already inside `damage_taken`, so the residual is failed stand attempts - and a label built on that teaches "stay prone", which is wrong. **Not built: physical attacks.** There is no code for them yet. ### Two of the original ten are one label with two names Measured over the same corpus, and not fixed here - noted so the next person to touch the label list starts from it rather than rediscovering it. - `bv_trade_ratio` ~ `damage_trade_ratio` at **+0.96**. - `bv_lost` ~ `damage_taken` at **+0.91**. The BV-against-damage distinction argued for above is real when it is about what was *broken* - BV drops hard when a gun goes and barely moves when armour chips. It does not survive being wrapped in a trade ratio or summed over a whole match, where both members of each pair end up measuring the same thing. Re-measured on `labels-entities-r2`, a second corpus of the same size: the trade ratio pair held at **+0.95**, and `bv_lost` ~ `damage_taken` fell to **+0.50**. Only the first is a duplicate. The second was this corpus and not this pair, and a correlation seen once is a property of the run it was seen in until a second run agrees with it. ### bv_per_damage: the distinction the trade ratios lost `bv_per_damage_dealt` is enemy battle value removed over the damage we dealt to remove it, and it is the question the two trade ratios collapse: a point that takes a gun or a pilot off is not a point that strips armour, and a ratio of sums cannot tell them apart. Max |r| **0.33** on `corrected-basis-explore` and **0.34** on `labels-entities-r2` - an independent direction on both. Undefined at zero damage, and None rather than zero there: a window in which we landed nothing says nothing about how expensive the things we hit were. Clamped at 25. The rate has no ceiling - a round where one point set off an ammunition bin and killed a machine reads in the hundreds - and the tail is where the arithmetic goes wrong rather than where the information is. Measured: median 4.1, p90 11.5, p95 29.5, tail to 275; the clamp clips 5.3% of rows. ### The five below the team tier `tempo`, `mission_kills_inflicted`, `dispersion` and the two rear labels have no `unit` or `force` form and refuse when one is asked for. `credits` carries battle value, damage and units; none of those say which points landed on rear armour, or who crippled whom. A force's shape is worse than unavailable - it is a property of the set, and one machine's share of it is not a quantity. The five that read `entities` also **refuse a match that does not carry it** rather than falling back to the match-wide differential. The fallback is right for a corpus recorded before per-round logs existed; it is wrong here, because it would fit a whole corpus against a different label than the one asked for and say so in one line of the report. ### The two ratios, and the denominator problem `damage_trade_ratio` and `bv_trade_ratio` are `inflicted / taken` in intent. Held that way they are unbounded and undefined where nothing was taken - a unit that dealt damage and took none has not achieved an infinite ratio - and an infinity or a NaN reaching `least_squares` produces a silently poisoned weight with nothing to warn on. They are stored as the share `inflicted / (inflicted + taken)`, which is `r / (1 + r)` in the raw ratio: a monotone transform of exactly the quantity wanted, in 0..1, 0.5 at parity, 1.0 when we took nothing and 0.0 when we dealt nothing. Clipping was the alternative and was not taken - it throws away the ordering above the cap, which is where the good trades are. A stretch where neither side did anything reads 0.0 rather than 0.5: nothing was earned. `fit` refuses a non-finite label outright as a backstop, since nothing else between a label and the solve would notice. Both are the ratio *of the two discounted sums*, not the discounted sum of per-round ratios. A round where one point was traded for one point is not the same evidence as a round where a hundred were, and averaging ratios would say it was. ### `damage_efficiency`, and what its denominator counts The denominator is the whole difficulty, so it is stated rather than implied. `SdsHost.potentialOf` logs, per side per round, the sum over every live unit of each weapon's average damage at its own **medium** bracket, counting only weapons `canFire()` allows. What that counts. A unit that dies stops contributing, so a wiped-out side is not charged for output it no longer has. A shut-down unit contributes nothing, so overheating into a shutdown reads as wasted potential - which is the point. A weapon blown off, or out of ammunition, drops out. What it does not count. It is not the range the weapon was actually fired at: the medium bracket is a fixed, neutral choice, the same one `TargetView::threat_of` uses, taken because the host aggregates a round rather than an attack declaration. A side that spent the match at long range therefore reads as inefficient, which is intended - it is a real failure to realise the output it paid for - but it is not distinguishable here from a side that closed and missed. Physical attacks are ignored, and so is whether a target existed at all: a round with no enemy in line of sight is charged full potential. The quotient is clamped to 0..1. Falls, physicals and ammunition explosions all put damage on the enemy that no weapon of ours accounts for, so the raw figure can exceed one. A side with no guns left has no efficiency rather than a perfect one, and the label is undefined there. ### The refit Refitting the same 360-match corpus, changing nothing but the label. **Judge these on `p_kill` and `target_breach`.** Those are the only two signs `EXPECTED_SIGNS` still claims, and they are the ones behaviour corroborates: both negative is the "do not finish anything off" pattern, and the seventeen vectors fitted that way left Princess on 96.6-99.4% of its battle value in every decided game. The other three that used to be counted were bad priors - `heat_incurred` and `ammo_spent` are proxies for shooting and shooting wins matches - or, in `overkill`'s case, eight points of noise. | label | R² | `p_kill` | `target_breach` | contradictions | |---|---|---|---|---| | `final_bv_differential` | 0.188 | **-0.305** | **-0.294** | 3 | | `bv_delta_next_3_rounds` | 0.110 | **-0.070** | **-0.079** | 2 | | `bv_loss_inflicted`, `--credit flat` | 0.214 | +0.154 | +0.081 | 0 | | **`bv_loss_inflicted`, `--credit ratio`** | 0.196 | **+0.225** | **+0.122** | **0** | | `bv_trade_ratio` | **0.250** | +0.277 | +0.064 | 0 | | `kills_secured`, `--credit flat` | 0.164 | +0.120 | +0.069 | 0 | | `kills_secured`, `--credit ratio` | 0.152 | +0.183 | +0.109 | 0 | | `bv_lost` | 0.309 | +0.328 | +0.211 | 3 | Every one-sided label puts both signs the right way round. Agreement with the played policy goes from 40.9% under the old label to 88-91% under all of them. `bv_loss_inflicted --credit ratio` is the default: `bv_trade_ratio` fits better and is the one to try next, but it mixes two signals and the simpler label is the right thing to change to first. `bv_lost` is the interesting failure. Its expected signs are inverted - a bigger number is a worse outcome - and it still contradicts three, because engaging predicts taking damage: a unit that deals more is a unit that was in range to be shot at. That is a confound in the label, not in the features, and it is the argument for fitting avoidance against `damage_taken` with `exposure` in the vector rather than against `bv_lost` alone. `overkill` is dropped as near-constant in every fit above. That is the correct outcome and not a regression - see below. The four labels that read armour, potential or a ratio of them - `damage_inflicted`, `damage_taken`, `damage_trade_ratio`, `damage_efficiency` - cannot be refitted on that corpus at all. They need armour, structure and potential per side per round, which the host only starts writing with this change; on an older result they return nothing and the fit falls back to the match-wide differential and says so in its report. ### `overkill` was a dead column, and the guard missed it The estimator is fixed elsewhere - `DamagePmf::waste_above` against the mission-kill threshold, rather than `max(0, E[X] - c)`. What belongs here is what it did to the fits above. Under the old form `overkill` read zero on 8835 of the corpus's 8843 candidates. **The `+0.2146` weight reported overnight was fitted from the other eight.** It was never a finding about overkill; it was noise on a near-constant column, sitting in a report next to weights that meant something. `dead_columns` did not catch it, because the column was not constant. `thin_columns` is the companion guard: a column non-zero on under 1% of rows is dropped and named *with the share*, so "this feature is not computed" and "this situation is rare" are distinguishable by reading the report rather than the corpus. It fires on `overkill` in every fit in the table above, which is the correct outcome for a corpus recorded before the estimator was fixed - those logged values are the broken quantity, and only a fresh corpus can say what the feature is worth. ### The discount `--gamma`, default 0.93, replacing a fixed 3-round window that was shorter than a kill takes to pay off - a unit crippled in round 4 dies in round 8, and the decision that crippled it was labelled with three rounds of nothing. Grounded in the corpus. Over 516 decided matches the mean length is 13.2 rounds and the median 11, with p25 8, p75 16 and p90 25; the 222 undecided ones all sit at the 41-round limit. gamma 0.93 has an effective horizon of 14.3 rounds and still puts 39% of its weight at round 13, so it spans a typical decided match without paying out over the undecided tail, where nothing is being decided and a longer horizon only adds noise. ### Early kills compound An early kill does not pay out once. It moves the BV ratio, and every later round is fought at that better ratio - the side with more guns left trades better - so it raises the *rate* of every later payoff. A plain discounted sum cannot see that: it pays the kill its own damage and nothing for the compounding. `--credit ratio`, the default, is the cheap approximation: each round's payoff is scaled by enemy BV over ours at the top of that round, clamped to 4x either way. Damage done while outmatched counts for more, because that is the damage that changes the ratio the rest of the match is fought at; damage done when already three units up counts for less, because the match was already decided. The clamp is not decoration - unbounded, the ratio explodes exactly where the per-round steps are largest and least informative, and the last two rounds of every blowout would outweigh the corpus. This is a hypothesis and is switchable. `--credit flat` is the plain discounted sum, and the table above is the comparison rather than an assumption. On that corpus both clear the sign check once `overkill` is out of it, and `ratio` puts `p_kill` and `target_breach` further from zero - +0.225 and +0.122 against +0.154 and +0.081 - which is why it is the default. That is a thin argument and the flag is there to settle it against a bench. ### What is still open Every number above is a refit on a corpus recorded by a bot that had no BV feature and no one-sided label. It says the label is no longer arguing against the features' own descriptions. It does not say the bot wins. That needs a fresh corpus with `target_original_bv`, `target_current_bv` and `target_tonnage` in it, a fit, and a bench. Every vector in `weights/` other than the hand-authored one is now formally stale for a second reason: `target_tonnage` changed normalisation and `target_skill` became `target_gunnery` and `target_piloting`, so the basis fingerprint moved. They are kept as the record of what was fitted, not as something to load - `Weights::from_document` refuses `target_skill` outright. Every label here is still **team-wide**. The per-unit and per-force tiers below are the next sharpening and need no new matches - in an 8v8 one unit's choice currently carries seven other units' luck. The `taken` labels also have a confound worth naming before anyone fits against one alone: engaging predicts being engaged, so `bv_lost` correlates with dealing damage as much as with avoiding it. ## Does a label point at winning at all `sds labels` asks whether two labels measure the same thing. It cannot ask the prior question, and for most of this epic nothing did: **does a label point at winning at all.** `sds labelrank` is that measurement. Each comparison is paired *inside* one match. Both seats played the same scenario, on the same map, with the same forces, under the same seed, and exactly one of them won, so the scenario is held fixed and what is left is the label. Across matches nothing is held fixed and the comparison would mostly measure which scenarios are winnable. Two rules the measurement itself taught us, both now enforced by the tool: - **Read every round, not one.** Several labels are zero at round 1 by construction. `finishing` is the share of our damage that landed on machines already hurt, and at round 1 nobody is hurt yet: it is 0 for both seats in every match, 0 nonzero readings out of 80, and rises to a mean of 0.73 by round 5. Ranked at round 1 alone it reports 50% and reads exactly like a broken label. It is not one. - **A tie is not a miss.** `shutdowns_suffered` is 0-0 in 90% of comparisons because most matches have no shutdown. Excluded from the accuracy and reported beside it, that is a thin label; counted as misses it would look like a wrong one. ### The top group does not separate Over two corpora, with every round read: | label | 58-match bench | `honest-movement-r8` | | --- | --- | --- | | `inflicted_bv_per_round` (what we fit) | 80.4% | 75.9% | | `damage_trade_ratio` | 80.1% | 80.2% | | `bv_trade_ratio` | 80.1% | 76.6% | | `bv_loss_inflicted` | 66.6% | 67.9% | | `finishing` | 50.2% | - | | `damage_focus` | 42.9% | - | The three at the top are within each other's intervals on both corpora, and they swap order between them. **Ranking cannot choose between them**, and a switch of the fitted label has to be argued on a fit and a bench rather than on this table. What the table does settle is the bottom: `finishing` is at chance across ~1200 comparisons, and `damage_focus` is *below* it - concentrating damage correlates with losing these matches. Both are worth a look before either is fitted against again. The suffered-side labels reading 19-33% is arithmetic, not a defect: what one seat inflicts is what the other suffers, so `suffered_bv_per_round` at 19.6% is `inflicted_bv_per_round`'s 80.4% seen from the other chair. ### The label that could not tell the sides apart `behaviour_distance_engagement` scored **0 decided against 964 ties** - it returned an identical number for the winner and the loser of every match in every corpus. It averaged the distance over every (ours, theirs) pair, and that pair set is the same set seen from either seat. Its own docstring claimed "a force that shot from the far edge reads high and one that closed reads low"; both forces read the same. It now reads outward from our own machines: each of ours takes its distance to the nearest of theirs. That is asymmetric, and it ranks the winner at 53.6% and 54.6% on the two corpora - weak, but a signal rather than a constant, and the ties fall from 100% to 2% and 13%. Four corpora were recorded with the broken form. Nothing that was fitted against it is invalid - a column constant across seats still varied across matches and rounds - but no reading of it as "did we close" was ever true. - [x] `sds labelrank`: does a label rank the winning seat above the losing one - [x] `behaviour_distance_engagement` reads from one side - [ ] Fit against `bv_trade_ratio` and bench it against `inflicted_bv_per_round`. Ranking cannot separate them; the argument for the ratio is that it is two-sided, which is the standing warning against the one-sided label - seventeen vectors fitted against "what we took off them" stopped engaging. A share is not a differential and does not carry the differential's pathology: mutual disengagement scores 0, the minimum, not a tie. The hazard to bench for is the opposite one - a share is also maximised by landing one hit and then hiding - [ ] `finishing` is at chance and `damage_focus` is below it. Decide whether each is measuring what its name says before either is fitted against - [ ] Rank labels per era and per tier, not pooled. A label that ranks the winner in a 4v4 clan-invasion match and not in a 2v2 succession-wars one is two labels ## 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. - [x] `sds labeldoc --write` renders the page and its 27 figures - [x] A figure that cannot tell the two seats apart fails the suite ### 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. - [ ] Decide whether an empty window is a `RATIOS` zero or a `QUOTIENTS` None, before any fit is run against a ratio label ## Imitation, and what it inherits The label above is one number per match. Imitation is one label per *decision*: the enemy moved, and the hex it moved to is the answer. That is thousands of rows a night instead of a few hundred, with no credit assignment problem at all, which is why it is worth doing even though what it teaches is somebody else's opinion. ### How it works No Princess code runs, and nothing is asked of the opponent. The observation already carries every visible unit's position, facing and `done` flag, so two consecutive observations of one movement phase bracket a move: 1. a unit that was `done: false` and is now `done: true` took its turn in between, and where it is now is where it chose to be; 2. our own candidate menu is generated for it from its state in the **previous** observation, with the observation mirrored so its side reads as friendly; 3. the observed hex goes into that menu as one more candidate, before anything is measured - `damage_lead` is min-maxed across a decision's own candidates, so a candidate added afterwards would not share the row's spread; 4. every candidate is measured with the same features the bot measures its own with, and the observed one is recorded as `chosen`. Princess's own candidate set is not needed and could not be reproduced: it enumerates every legal path and this bot caps at ~20 curated ones. What is needed is only what it chose. The row is the same schema the bot's own decisions use, so `difference_rows` and `fit` need no branch. `policy` is what tells the two apart, and the rows live in a different file so a corpus is not silently part self-play and part clone. Moves that cannot be reconstructed are dropped and counted, never guessed at: a unit not in the previous observation, a unit destroyed, a unit whose turn the pair of observations does not bracket, and a displacement further than its own movement points can explain. ### The label The row is labelled by the outcome of the **player who made the move**, not the bot that watched it - `label_of` is already per-seat, and the imitation row carries the observed player's id for exactly this. So a match Princess won is a positive label on its moves and a match it lost is a negative one, which is "move the way Princess moves when Princess wins" and its anti-imitation half in the same expression. It also degrades correctly as this bot improves: once Princess starts losing, imitating it stops being rewarded. The alternative - a constant +1 on the winner's moves only - is a special case of this with the magnitude thrown away, and there is no reason to throw it away. Worth being precise about what the fit then is. The design matrix is `phi_chosen - phi_rejected` and there is no intercept, so with a label that is constant within a match the objective is asking for a **fixed margin**: it wants `w . (phi_chosen - phi_rejected)` to equal the label on every comparison. That is a ranking objective, and it is the right shape. It is not a hinge, though - it penalises a margin that is too *large* as well as one that is too small, so a candidate our features already rank far above the rest still contributes error. That is a defect worth knowing about before reading much into a coefficient. ### The cost, which is the point of writing this down `docs/PRINCESS.md` is the argument for this repository existing. `BasicPathRanker.rankPath` takes the **maximum** damage a hex can deal and the **sum** of the damage it can take, so Princess cannot see the value of a crossfire and is systematically pessimistic about advancing. **Cloning its moves clones that blindness.** The same goes for `herdingMod`, which is the conga line. This does not break "MegaMek's rules yes, MegaMek's bot no": no Princess code is linked, imported or invoked, and every order the bot gives still has exactly one possible author. But that invariant's own wording is that inheriting a tactical opinion **silently** is the thing the design exists to avoid - and this deliberately inherits one. So it is recorded here, in `imitate.rs`, and in the fit's own report, which names how many of its rows are imitation and says what they were cloned from. Imitation is a **bootstrap to competence, not the goal.** Weights fitted this way must be re-measured against the bot they were cloned from before they are believed, and the long-term path is outcome-based training that exceeds it. A future reader must not be able to conclude this bot is independent of Princess when part of it was copied from Princess. ## Labels should be hierarchical A decision today is labelled with its whole side's outcome, so in an 8v8 one unit's choice carries seven other units' luck. Credit belongs at the level the decision was made at, and this project already has those levels: units propose, lances and companies decide. Three tiers, nestable the way `Forces` is: - **per unit** - what this machine dealt and took. The tightest signal available and roughly an 8x sharpening in an 8v8, for no new matches. - **per force** - a lance's damage and losses, for the decisions a force makes rather than a unit. Nested: a company's label is over its lances. - **per team** - what we have now, and still the right label for anything that is genuinely a side-wide outcome. A fit can now ask: `--tier unit|force|team`, `team` by default, so nothing changes unless it is asked for. Which labels have a form at which tier, and what each needs: | label | team | force | unit | needs | |---|---|---|---|---| | `bv_lost` | yes | yes | yes | `entities` | | `damage_taken` | yes | yes | yes | `entities` | | `bv_loss_inflicted` | yes | yes | yes | `credits` | | `damage_inflicted` | yes | yes | yes | `credits` | | `kills_secured` | yes | yes | yes | `credits` | | `damage_trade_ratio` | yes | yes | yes | both | | `bv_trade_ratio` | yes | yes | yes | both | | `damage_efficiency` | yes | yes | yes | both | | `final_bv_differential` | yes | **no** | **no** | - | | `bv_delta_next_3_rounds` | yes | **no** | **no** | - | The two differentials are one number over two whole sides. One machine's share of a differential is not a quantity, so those refuse at construction rather than handing back the team's number: a diluted label that reads as a sharpened one is worse than none. The other eight split in two, and the split is the finding rather than an implementation detail: - **What a machine lost is a level it owns.** `bv_lost` and `damage_taken` are the fall in that machine's own BV, armour and structure, which is the same arithmetic the side totals already do one layer up. The round tick just has to record the levels per unit rather than summed. - **What a machine dealt cannot be inferred at all.** A snapshot of levels says which enemy lost armour. It never says who took it off. `bv_loss_inflicted`, `damage_inflicted` and `kills_secured` need the host to attribute a round's damage to the attacker, which is a new record and not a finer slice of an existing one. Neither field exists today, so **every sub-team tier refuses on every corpus on disk**, naming the field it wants. Both are specified in [protocol](protocol.md). Until they land the tier machinery is exercised by `tests/synthetic.py`, where one machine of two does every point of the work: at `--tier team` the two decisions carry the same label and at `--tier unit` they carry opposite ones. ## Four one-sided labels, not two Done, and now eight rather than four. The full list and the reasoning are in "The label" above; the grid this section asked for is: | | damage | battle value | |---|---|---| | **inflicted** | `damage_inflicted` | `bv_loss_inflicted` | | **taken** | `damage_taken` | `bv_lost` | `kills_secured`, `damage_trade_ratio`, `bv_trade_ratio` and `damage_efficiency` are the other four. All of them one-sided on purpose: a differential is symmetric under mutual disengagement, which is how a fit came to prefer a bot that will not engage. ## Heat and ammo features have to be calculated properly Both are worth having and both are easy to get wrong. `shots_left` divided by weapon count is not the answer: - **Several weapons draw from one bin.** Two SRM 6s sharing a ton do not each have a ton. - **A destroyed weapon leaves more for the rest.** Endurance goes *up* when a launcher dies, and a feature that does not know this will read a crippled unit as short of ammo. - **A launcher can have several bins of different munitions.** Standard and Inferno are not interchangeable, and the useful number is per munition. - Heat has the same shape: what matters is generated against dissipated, not generated alone. An alpha strike that can be dissipated is free; one that cannot means shutting down next turn, and `heat_incurred` reads the same either way. Read MegaMek for the bin-to-weapon mapping rather than inferring it. ## A name is not a meaning The manifest records the sorted list of every feature name in a corpus, and refuses a fit whose names do not line up. That catches a feature added or removed. It does not catch the case this change is. `p_kill` kept its name and changed its quantity. It used to ask whether the whole volley cleared the centre torso's health; it now asks whether the centre torso or the head is actually destroyed, and the two differ by a factor of between 3.5 and over a thousand depending on the shape of the volley. `p_mission_kill` and `overkill` moved the same way. A manifest comparing name lists sees `p_kill` on both sides and says the corpus is fine. **Every weight fitted against the old `p_kill` is meaningless**, including the label comparison's `p_kill +0.225` at 91% agreement. Nothing in a corpus recorded before this change says which `p_kill` wrote it, and nothing would object. The basis fingerprint hashes each feature's name, normalisation and one-sentence description, which is a better question than the name list and still not the right one. Tested against this change, it reported the basis had moved - but only because six features were added. Its `REWORDED` list was empty: `p_kill`'s sentence was byte-identical across a change that moved its answer by three orders of magnitude, because "enough damage in one place to destroy the target outright" describes the old computation and the new one equally well. Had this change touched `p_kill` alone and added nothing, the fingerprint would have called the basis unchanged. The descriptions of `p_kill`, `p_mission_kill` and `overkill` now name the quantity rather than the intent, which closes this instance. It does not close the hole: a description is a proxy for a meaning, and a proxy that a person can forget to update is one they will. ## It happened again, and this time nothing was added to cover it `p_kill` above was caught because six features arrived in the same change and moved the fingerprint anyway. The second instance had no such luck. `features::firing::ceiling` used to sum guns alone. It now counts the physical the numerator was already counting, which changes `expected_damage` for **every Mek in the game** - a fist is on the wire for every machine with a working limb, not just one in contact. The description did not change, because "how much of what this unit could put out this volley represents" describes both. So: | corpus | recorded at | `ceiling` counts | reported basis | |---|---|---|---| | `tactics-hub-mirrored` | `1d6bb72` | guns | `4a963bc90ee9` | | `advance-arm-engage` | `8dcc6b8` | guns and a fist | `4a963bc90ee9` | **The guard that exists to stop somebody comparing incomparable corpora says these are comparable.** Both were used as the two readings behind `tactic::MEASURED`, and `Advance` weights `expected_damage` at 6.0 - harder than any other tactic in the vocabulary, so it is the one most exposed to exactly this column. The local note is on `BERSERKER` in `tactic.rs`; the general statement is here, because the next person to fit anything needs it and will not read a tactic constant to find it. What makes this worse than an ordinary silent defect: the fingerprint is a **proxy for "same basis"** and it looks exactly like the check. Everything else in this repository that consulted a proxy was caught by something downstream disagreeing. Nothing is downstream of this one - it is the thing that would have done the disagreeing. Three ways to close it, in the order they cost: - **A hand-bumped `BASIS` constant**, the open item below. Cheapest, and it fails the way `sds/epoch.py` fails: it is only as good as the person remembering. It would not have caught tonight's change either, because the person who wrote the ceiling fix is the person who would have had to bump it, and they did not know the corpora would be compared. - **Hash the computation rather than its sentence.** A digest over each feature's `measure` body - from the source, at build time - moves whenever the arithmetic does and never when it does not. It is the only option that needs nobody to remember anything. It costs a build step and it is noisy: a refactor that changes nothing about the answer still moves the hash, which argues for recording it *beside* the description hash rather than instead of it, so a reader can tell "reworded" from "recomputed". - **Record the build alongside the fingerprint and compare both.** The manifest already stores `commit`; the corpora above differ by it and nothing looks. A reader that refused to compare two corpora from different commits would have caught this tonight with no new machinery at all - and would refuse far too often to be usable as a hard error, so it belongs as a warning that names the two commits. - [ ] Hash a `BASIS` constant in `sds-core` that a person bumps when they change what a feature computes, alongside the description. The same shape as `sds/epoch.py`, and for the same reason: enumerating what might have changed does not work, so one number is declared instead. The epoch itself is the wrong home - it versions what a match is, and the bot's own scoring is not that. - [ ] Make a vague description fail rather than pass. A sentence that still fits after the computation changed was too vague to be load-bearing, and that is a property worth a review rule if not a test ## 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](../sds/epoch.py) on purpose: the epoch versions what a *match* is - rules, victory conditions, what the bots experience. This versions what the bot's *scoring* means. A change can move one without the other, and collapsing them would make both vaguer. ## Starting weights for the first big run `heat_incurred` starts at **0.0**, not the hand set's +6.0. jmm's call, made after a 4v4 diagnostic showed the bot carrying more heat than Princess (17.42 gained a unit-round against 15.80, peak 23 against 17, rising to 32.5 by round 14) and hitting far worse for it (38.7% against 53.3%). The +6.0 was fitted by hand from a six-arm experiment in which most matches hit the round cap, so it is a weak prior and training should not be anchored to it. The other hand weights stand unless something similar argues against them. ## Exploration is movement-only, and firing is most of the feature set **Done, 2026-08-22.** The estimate below was wrong in one specific way, kept because the reasoning around it still holds: `Decision` already carried a `policy` field, added when movement exploration landed, so the "corpus format change" that made this look expensive was a single additive enum variant. Perturbing at the firing call site took about fifteen minutes. Measured on a 4v4: 13 of 45 firing decisions explored at epsilon 0.3. One wrinkle left: movement records exploration on `Chosen.policy` inside the force rationale, firing on the top-level `Decision.policy`. Both are readable, but a fit has to know which is which. Worth unifying. `explored()` lives in `reconcile.rs` and perturbs which proposal a unit takes in the joint movement plan. The firing path is `Decision::score` in `crates/sds-bot/src/main.rs` (~line 1300) and is pure argmax - no exploration, no temperature. **That leaves the larger half of the basis unlearnable.** A corpus with no off-policy firing rows carries no information about `expected_damage`, `p_kill`, `p_mission_kill`, `overkill`, `weapon_concentration`, `p_breach`, `ammo_spent`, the four heat-chart families, or any `target::` term. Every one of those is scored only in a firing decision. A fit against such a corpus does policy *evaluation* for firing - "what distinguished the shots you took" - which is the exact failure this module's own docstring says exploration exists to fix. **Why it is not a two-line change.** `Decision::score` is shared: movement uses it at ~line 959 to record the force's row, firing at ~line 1300 to choose. Put exploration inside it and movement double-explores, since `reconcile` has already perturbed that decision. So it has to go at the firing call site, which needs: - `Bot` to hold the `Explore` config. It does not today - the config is constructed in `main` and handed to `ForceThinker`. - a perturbation after `Decision::score`, over `row.candidates[].value`. `Explore::choose` already takes values rather than a count, so it fits. - a `policy` field on the firing training row. **This is a corpus format change**, and it is the reason not to do it in a hurry: `Chosen` carries `policy` for movement and the firing row has no equivalent, so a fit cannot currently tell an explored shot from a chosen one. **Do it with the subset menu, not before it.** See `plan/candidates.md`, "The firing menu selects by prefix". Exploring a prefix ladder draws from a menu whose shape is already wrong - the near-ties are prefixes of one ordering rather than genuinely different volleys - so the rows would be less informative than the same work over a subset menu. Both changes live in the same call path and want doing together. **Two properties to preserve, both learned the hard way.** - Rank turn order on the *on-policy* value. Movement already does this: `Order` carries `value` (what was taken) and `on_policy_value` (what the weights wanted), because ranking on the perturbed value made one draw perturb both the move and the running order, and a fit could not tell which mattered. Firing has no equivalent ordering decision today, but if one appears, the same rule holds. - **Epsilon is an upper bound, not a target.** `choose` correctly declines when no alternative has meaningful softmax mass - a menu whose argmax is well clear of everything else. Measured: 0.3 asked, 0.206 realised on a 4v4. The shortfall grows as temperature falls, so the two knobs interact and want tuning together rather than setting epsilon alone. ### damage_focus: whether we concentrated fire A normalised Herfindahl index over how the damage in a window was spread across the machines they brought. Zero is damage spread evenly over all of them, one is all of it into a single machine. Focus fire is the most-cited tactic in the game and nothing here measured it; it is also the outcome `concentration` and `weapon_concentration` would need in order to mean anything, both of which are currently dropped as near-constant. Max |r| **0.30**, against `damage_inflicted`. **The floor is the whole design.** Without one the label reads 1.0 for any window holding a single hit - not focus fire but an absence of evidence - and that artefact shows up as correlation with how much damage there was at all. Measured on `labels-entities-r2`: no floor -0.43 over 2034 rows, 10 points -0.40, 30 points -0.32 over 1434, 60 points -0.27 over 899. Thirty is about one solid hit, and past it the rows bought stop paying for the confounding removed. Normalised against what they *brought* rather than what we hit. Dividing by the count we touched would hide the case the label exists to see: we only ever engaged one of their four. Unsigned, with `dispersion`. Whether concentrating fire beats spreading it is the question, and `EXPECTED_SIGNS` is for things already known. ### alpha_strike, and the denominator that did not work What a side fired in a round, in expected damage at a fixed medium bracket, against what its surviving guns could still have fired. This is firing discipline: did we pull the trigger on everything, or hold back. `fired` is read off `Mounted.isUsedThisRound`, the game's own record of the declaration, rather than inferred from heat or from damage arriving. It is sampled as a high-water mark across the round: the flag is set as an attack is declared and cleared by MegaMek's end-of-round pass, and the round tick is written at the top of the *next* round, by which time every flag reads false. It can exceed one before clamping. The numerator is the peak during a round and the denominator is read after it, so a machine that fired a gun and then lost it divides what it fired by what survived firing it. Measured at 1.68 on one round of a 2v2. **`output_realised` was built beside it and removed.** The argument for it was sound: against `alpha_strike` alone, a machine down to its last working gun that fires it reads as a full alpha strike, which is "nothing left" and not "fired everything". So a second denominator - what the standing machines were *built* to fire, guns blown off included - should separate the two. It measured **+0.98** against `alpha_strike` over 120 matches. The mechanism is real and visible in single rounds; it just does not vary across a corpus, because machines are destroyed far more often than individual guns are blown off and both denominators exclude destroyed machines. A third form was tested - denominator fixed at what the side deployed with, dead machines included - and reached 0.79, better but still mostly the same label, and the attrition it adds is already carried by `bv_lost`. The `designed` field stays on the round tick. It costs one number per side per round and it is the only record of what a force was built to put out. ### Five more labels - **`finishing`** - the share of our damage that landed on machines already hurt when the window opened. Finishing a wounded machine and opening a fresh one cost the same shot and are not worth the same, and nothing else separates them: `damage_inflicted` counts the points either way and `damage_focus` sees concentration without seeing what was concentrated on. "Hurt" is measured against the machine's own first reading, because the tick carries what a machine has and never what it was built with. An armour-is-zero test was tried first and read zero for every window of a twelve-round 4v4 - it asks "nearly dead" rather than "worth finishing". Saturates at 1.0 late in a match, when everything on the board is damaged; that is true rather than broken, but it makes the label least discriminating exactly where matches are decided. - **`focus_suffered`** - the concentration index over *their* damage into *our* machines. `dispersion` measures the shape we chose; this measures what that shape cost, and the bot has levers on it. - **`heat_carried`** - how hot we ran, per machine, averaged over the rest of the match. The heat *features* are unfittable because candidates barely vary in heat; this at least gives the heat question an outcome, and unlike `shutdowns_suffered` (5.4% non-zero) it is dense. - **`prone_time`** - the share of our machine-rounds spent on the ground. Falling is a to-hit penalty, a lost turn and a piloting roll to stand, and the only thing that saw any of it was `p_psr_threshold` on the feature side. - **`crippled_suffered`** - our machines put out of the fight, the mirror of `mission_kills_inflicted`. Both level labels needed `SUBSTITUTE_SCALE` entries. Heat and prone start at zero, so their own first tick is not a scale - it is the reading "nobody is hot yet", and dividing by it returns None for every match. They are scaled by the machines we brought, which makes both read per machine. ### The tempo family: eight built, six removed the same day Rate is a different question from total - a side that removed the same battle value in four rounds and in fourteen did not play the same match - so the family was built as (inflicted, suffered) x (bv, damage, kills, mission kills). Measured over `maximalist-r4`, 120 matches: | pair | max \|r\| against the field | |---|---| | `inflicted_bv_per_round` ~ `inflicted_damage_per_round` | **0.95** | | `suffered_bv_per_round` ~ `suffered_damage_per_round` | **0.95** | | `inflicted_kills_per_round`, `suffered_kills_per_round` | 0.84 | | `inflicted_mission_kills_per_round`, `suffered_mission_kills_per_round` | 0.76 | Rate is orthogonal to *what* is counted only in principle. In a real corpus the counts move together, so the rate of one is the rate of the others. The two battle-value forms were kept because battle value is the outcome measure; the other six were removed. This is what "measure rather than assume" costs and buys: an afternoon of implementation for six labels that were deleted, and a label list that is six columns shorter than it would otherwise be. **Two more added beside them, both marginal and both kept for now.** `suffered_kills` reads 0.84 against `bv_lost` and `trade_units` reads 0.85 against `kills_secured` - both in the band where a label is mostly a rename. The argument for `trade_units` is not statistical: unit count drives the initiative sequence, so wiping out a light to win the count is a real play, and no other label can express it. That is a domain argument and it should be tested against a fit rather than settled by a correlation. ### behaviour_distance_engagement: did we ever close Mean distance from each of our machines to each of theirs, as a share of the board, averaged over the rest of the match. Low is a force that closed; high is one that shot from the far edge. Max |r| **0.24** against the other twenty-eight labels - joint third most independent in the whole set, behind only the two rear shares. `dispersion` is its closest neighbour at 0.24, which is the right neighbour to have and still a long way from being the same label: `dispersion` is the shape of our own force and says nothing about whether we ever engaged anybody. Cross-side, so it cannot be a `DERIVED_TOTALS` reducer - those see one side's machines at a time and this needs both. Averaged rather than summed, for the reason `AVERAGED` gives. Unsigned, with `dispersion`: whether closing beats standing off is the question being asked and not something already known. A trace over one match reads 0.39, 0.38, 0.35, 0.32, 0.31, 0.31, 0.30, 0.29, 0.28, 0.26, 0.24, 0.20, 0.16, 0.15, 0.15 - a monotone close. That shape is the sanity check: a label about converging forces should look like converging forces. None rather than zero when one side has nobody on the board. There is no gap to measure, which is not a gap of zero. ### Fleeing is invisible in a self-play corpus `mission_kills_inflicted` counts machines killed or crippled, and should count machines that fled: a unit that leaves the board is out of the fight whatever its armour says. Princess will attempt to withdraw. Measured before building it: **zero fled units across 121 matches** in `maximalist-r4`, and zero trapped. The reason is that every training corpus here is self-play, and this bot has no withdraw behaviour at all - so the case the label is missing cannot occur in the data the label is measured on. That is worth stating as a general caution rather than a footnote about one label. **A self-play corpus can only contain behaviours this bot has.** Anything Princess does and we do not - withdrawing, and whatever else - is absent from every corpus we fit against, and shows up for the first time in evaluation, which is the worst place to meet it. Not built, for two reasons. `entities` does not carry `offBoard`, so counting it per round needs a protocol change; and the change would be untestable against any corpus that exists. **Withdrawal is rare here, not impossible.** Princess can flee. It seldom gets to: these matches tend to end with a weak machine pursued and killed rather than withdrawn, so the behaviour is reachable in the opponent's policy and rarely reached in the states our play produces. Measured: zero fled and zero trapped across 121 self-play matches and the first 22 against Princess. Two consequences worth keeping apart. The label gap is a **correctness** question - a machine that leaves the board is out of the fight and should count as one, whether that happens twice a season or twice a match. And it is **not** a data problem a different corpus fixes: no corpus this project can cheaply build will carry many examples, so the change has to be made on the rules rather than validated against a frequency. Two earlier versions of this passage were wrong in opposite directions - first that self-play hides a behaviour Princess would show, then that nothing in these scenarios withdraws at all. Neither survived contact with jmm, who plays the game. ### damage_efficiency is not a trade Renamed to `inflicted_percent_of_maximum`. It divides damage dealt by what our own guns could have landed, so both halves are ours - there is no trade in it. The `trade_` family is for a quantity of theirs against a quantity of ours. ## The first winning record against Princess `weights/fitted-tempo.json`, fitted against `inflicted_bv_per_round` on `retired-basis-r5`, played on the subset firing menu: | run | sds | princess | decided | rate | |---|---|---|---|---| | first, 24 games | 12 | 9 | 21 | 57.1% | | confirmation, 64 games | 30 | 25 | 55 | 54.5% | | combined | 42 | 34 | 76 | **55.3%** | 96.9% of 2013 decisions answered and none failed. Undecided that were not the round limit: 0.0%, against the 5% criterion - the first run failed that bar at 8.3% and the confirmation passes it. Battle value retained 22.3% against 21.1%. 481 shots at 65.1% against Princess's 394 at 56.6%. **Not settled, and the harness prints the reason.** About 385 decided games separate a 5-point win-rate difference from noise, and 55% against 50% is five points. The interval on 54.5% over 55 decided games runs roughly 40% to 68% and contains 50%. Two runs agreeing is worth more than one, and neither is proof. ### Why this vector and not the other The two fitted vectors are not scaled copies of each other - cosine similarity 0.85, and an argmax sees only direction so a pure scale difference would change nothing. Where they disagree most is the line-of-sight pair: | feature | `fitted-r5`, scaled (6-11) | `fitted-tempo` (12-9) | |---|---|---| | `los_in` | +0.002 | **-0.079** | | `los_out` | +0.002 | **+0.081** | `fitted-tempo` learned *see them without being seen*, and it is the fourth largest weight in that vector. Fitted against `bv_loss_inflicted` the same pair came out the puzzling way round and was written up as unexplained; fitted against the rate label it comes out the way a player would write it. That is the argument for rate labels beyond this one result: `inflicted_bv_per_round` spreads credit over the rounds a decision influenced instead of concentrating it on the round the damage landed, and positional features are exactly the ones whose payoff arrives late. ### Four labels could be measured but never fitted `_round_label` looked up `LABEL_SERIES[spec.kind]` for everything that was not one of the two differentials. Four labels have no entry there, because they are not a sum over a named series: `damage_focus` and `focus_suffered` are Herfindahl indices, `finishing` is a share of a damage map, and `behaviour_distance_engagement` is a cross-side distance. `label_value` answers all four perfectly well; the fit path raised `KeyError` on the first decision. So they could be computed, correlated and written up - and three of them were, including one described as the most independent label in the set - while being impossible to actually fit a vector against. The gap only shows when somebody asks for the fit. The fix routes them to `label_value`, which already knew how. The test asserts that *every* label without a series entry answers, rather than naming the four: a fifth such label is exactly the case that would slip through otherwise. It was checked against the reverted fix to confirm it fails when the guard is gone. ### Refitting on the menu the bot actually plays `fitted-r5` was fitted on a prefix-menu corpus and then dropped into the subset menu, where it lost 6-11. That is distribution shift: it learned to rank about eight candidates drawn from prefixes and was asked to rank 172 drawn from every subset, most of which it had never seen. `fitted-r6` is the same label - `inflicted_bv_per_round` - fitted on a corpus generated *by* the subset menu: | | prefix corpus | subset corpus | |---|---|---| | candidate comparisons | 1.34M | **2.64M** | | uncentred R² | 0.315 | **0.366** | | agrees with the played choice | 62.0% | **66.8%** | The comparison count roughly doubles, which is the subset menu paying for itself in evidence: the same 120 matches carry twice the decisions to learn from. **The line-of-sight asymmetry survives the change of corpus.** `los_out` +0.019 and `los_in` -0.012 - the same signs as `fitted-tempo`, smaller in magnitude. Three fits now: both against the rate label put the pair the way a player would write it, and the one against `bv_loss_inflicted` put it backwards. Two data points for and one against is not a law, but it is the third time the rate label has produced the more sensible vector. ### The refit wins, and the ranking says why Every vector on the subset firing menu, same suite: | vector | trained on | label | record | rate | |---|---|---|---|---| | `hand-authored` | never fitted | - | 6 - 12 | 33% | | `fitted-r5` | prefix corpus | `bv_loss_inflicted` | 6 - 11 | 35% | | `fitted-tempo` | prefix corpus | `inflicted_bv_per_round` | 42 - 34 | 55% | | **`fitted-r6`** | **subset corpus** | `inflicted_bv_per_round` | **23 - 12** | **66%** | 95.1% of 1173 decisions answered, none failed, one defaulted. Undecided that were not the round limit: 2.5% against the 5% criterion. Battle value retained 23.3% against Princess's 18.6%. **Two things move the result and they are separable.** Holding the corpus fixed, the rate label beats the discounted sum: 35% to 55%. Holding the label fixed, fitting on the menu the bot actually plays beats fitting on the old one: 55% to 66%. The vector trained on the wrong menu lost outright, which is what distribution shift looks like when it is measured rather than argued about. **Still thirty-five decided games.** The interval on 66% at that sample runs roughly 48% to 81%; it clears 50% but not comfortably, and the harness asks for about 97 decided games to separate a ten-point difference. A 96-game confirmation is running. **And we are winning while throwing away one movement action in twenty.** The illegal rate is 4.2% here, matching the 4.3% measured on the corpus, and every refusal costs that machine its whole turn. Six hypotheses have been eliminated and the cause is still open - which makes it the largest known unclaimed improvement on the board. ## sds beats Princess `weights/fitted-r6.json` - fitted against `inflicted_bv_per_round` on a corpus generated by the subset firing menu - over two runs on the 2v2 and 4v4 suite: | run | sds | princess | decided | rate | |---|---|---|---|---| | first, 40 games | 23 | 12 | 35 | 65.7% | | confirmation, 96 games | 46 | 33 | 79 | 58.2% | | **combined** | **69** | **45** | **114** | **60.5%** | **95% interval 51.6% to 69.5%, which excludes 50%.** The harness asks for about 97 decided games to separate a ten-point difference and there are 114; the difference is 10.5 points. Quality on the confirmation run: 96.5% of 3842 decisions answered, none failed, 18 defaulted. Undecided that were not the round limit 4.2%, against the 5% criterion. Battle value retained 22.0% against 20.2%. 2592 shots at 63.5% against Princess's 2313 at 55.7% - firing 112% as often and hitting eight points more accurately. ### What it took, in the order the effects were isolated | change | effect | |---|---| | retire the exposure family | forced pass 58.9% to 28.2%, and thirteen unfittable columns became four | | firing menu selects by subset, not prefix | 8.2 candidates to 117; the small guns get a volley of their own | | fit against a rate label, not a discounted sum | 35% to 55% at fixed corpus | | fit on the menu the bot actually plays | 55% to 66% at fixed label | Each was measured against the one before it rather than changed together, which is why the four can be listed separately at all. ### What this does not say The suite is 2v2 and 4v4 only. 1v1 and 8v8 are untested, and 8v8 is where firing has timed out before and where the subset menu's cost is unmeasured. Both runs used the same 144 scenarios with different seeds. The interval is a normal approximation, which is reasonable at this sample and is still an approximation. And the bot is still refusing 4.2% of its own movement actions, which is a defect it is winning in spite of rather than because of. ### The credit knob does nothing for a rate label `--credit ratio` scales each round's payoff by how outmatched the side was, so a hit landed while losing counts for more than the same hit landed while already ahead. It is the default and it is the right default for a discounted sum. It is **ignored** for a rate label, and the fit report says so in one line: `credit 'ratio' ignored (a rate is already per round)`. A rate divides by the rounds it took, which is where the scaling would have gone. Recorded because it was tried as a weight iteration and was a no-op: refitting `inflicted_bv_per_round` with `--credit ratio` produces the vector that already exists. The lever exists for the sum labels and there is nothing to tune here. Levers that are *not* no-ops for a rate label, for whoever iterates next: `gamma` shapes the window a decision is credited with, and `ridge` changes the direction of the vector and not just its scale - which matters, because the flattest vector fitted this session was also the one that survived a change of candidate distribution best. ### The Princess result is a 2v2 and 4v4 result Stated plainly because the number will outlive the context: 69-45 over 114 decided games was measured entirely on 2v2 and 4v4 scenarios, and `fitted-r6` was fitted on a corpus of the same two sizes. At 8v8 the same build times out on 8.9% of its decisions, against roughly 0.5% at the measured sizes. The win rate there is not yet known - the run is small and still going - but the decision quality is already worse, and a bot that defaults one decision in eleven is not the bot the 60.5% describes. Fixing the timeout is a precondition for claiming anything at 8v8, not a follow-up to it. ### 8v8: 12-2, while timing out one decision in thirteen `fitted-r6` on 18 games of 8v8, a size it was never trained on and whose corpus does not exist: | | | |---|---| | record | 12 - 2, 14 decided, **85.7%** | | decisions answered | 90.7% | | **defaulted** | **9.2%**, all timeouts | | illegal | 0.1% | **Read this carefully in both directions.** Fourteen decided games puts the interval around 58% to 96%, so the honest claim is "not worse at 8v8", not "better at 8v8". And the bot is freezing on roughly one decision in eleven, each taking the harness default of stand still and hold fire - Princess is playing a full game against an opponent that periodically stops. A higher win rate *while* handicapped is not evidence the handicap helps. The plausible readings are that 8v8 suits this vector for reasons unrelated to the timeout, that the scenario mix differs, or that fourteen games is noise. What it does say is that the ceiling is above what was measured: fixing the timeout can only add decisions the bot currently forfeits. ## The corpus recorded the move the weights wanted, not the move that was played `--explore 0.3` does explore movement - the force reports 26 explored orders across 48 reconciliations in a 4v4, about 17% - and `reconcile_exploring`, `Explore::choose` and `Chosen::policy` were all correct. What was wrong is one step later. `record_training` rebuilt each row with `Decision::score`, which is a plain argmax over the menu, and never read `decision.chosen`. So on an explored movement the corpus wrote down the candidate the weights preferred while the game went on to play a different one - and the labels, which are computed from what happened next, went on measuring the outcome of the move that was actually made. About one movement row in six in every corpus this session paired one candidate's features with another candidate's result. The method's own doc comment argued for the recomputation: "if the row and the order ever disagree, the corpus is describing a bot nobody played." The reasoning was right and the implementation inverted it. Recomputing the *values* is what that argument wants, so a number can always be re-derived from the features beside it. Recomputing the *choice* is what produced the bot nobody played. Fixed here. `Decision::score_taken` takes the played index and its policy; `record_training` reads both out of `decision.chosen`, and skips a stalled unit rather than recording an argmax move it was refused. An out-of-range index falls back to the argmax and records `argmax` rather than quietly selecting a neighbour. **What this does not excuse.** Every fit before this - including `fitted-r6` at 60.5% - trained on movement rows where roughly a sixth carried mismatched features and labels. The weights are not invalid, but the exploration that was supposed to be teaching them something new was teaching them noise, and the measured gains came from the other changes. **A false alarm this also settles.** Every fit report carries "the chosen candidate was the highest logged `value` on only 88.5% of decisions". Of 898 decisions all 103 disagreements are firing rows labelled `explore`, which is exploration working. The warning fires on correctly-labelled off-policy rows and reads as though the corpus were untrustworthy; movement, which *was* untrustworthy, showed 100% agreement precisely because the bug made it agree. ### The fit report's argmax warning was firing on exploration working `report` warned "the chosen candidate was the highest logged `value` on only 88.5% of decisions... nothing fitted from it means what it says" on every fit this session. `_logged_argmax` already excluded imitation rows on the grounds that "chosen is not the argmax" is the definition of such a row rather than a fault in it. Explored rows are the same case and were counted as disagreements. Re-fitting `subset-menu-r6` with the exclusion in place, the warning does not appear at all: 1172 of 10328 decisions (11.3%) are off-policy on purpose and they accounted for every disagreement. The corpus was never dishonest. The report now states the explored share as a fact rather than a fault, and the warning that remains says what it actually means: imitation and explored rows are already excluded, so a disagreement left over is one nothing accounts for. ### A sign flip now names what it is collinear with The suspect-sign paragraph ended "explain it before benchmarking the vector" and left the reader to go and find the explanation by hand. The Gram matrix the fit already accumulates is exactly that explanation: `gram[i][j]` over the geometric mean of the diagonals is the cosine between two columns as the solve sees them, uncentred, which is the right centring for a no-intercept difference fit. It costs nothing - the matrix is built either way. `fitted-r7`'s three flips, with the partners now printed beside them: | flipped | came out | most collinear with | |---|---|---| | `overkill` | +0.0105 | `p_kill` +0.70, `p_psr_threshold` +0.25 | | `p_mission_kill` | -0.0057 | `value_destroyed` +0.66, `p_kill` +0.30 | | `target_breach` | -0.0131 | `heat_incurred` +0.61, `p_breach` +0.51 | Each one has a stronger same-direction partner measuring an overlapping thing: overkill is a consequence of a shot that was going to kill, a mission kill is most of a kill by value, and a breach comes with the heat that caused it. What is left for the suspect after the partner takes the credit is a residual, and a residual's sign is not evidence about the feature. None reaches 0.9, so this is an explanation rather than a dismissal: at 0.6-0.7 the columns are distinguishable and the fit is entitled to split them. The report says so rather than implying the pair is degenerate. ### The heat family is one direction, and one column takes all of it `EXPECTED_SIGNS` covered eight firing features and nothing heat-side, so a heat weight could come out any sign and the report would not say a word. Four of them have a sign that is not a matter of opinion - `heat_mp_penalty`, `heat_to_hit_penalty`, `heat_shutdown_risk` and `heat_ammo_explosion_risk` are each a share of, or a chance of, something that happens to the machine taking the shot, and a label that rewards fighting well cannot want more of any of them. `heat_incurred` is deliberately left out: heat is the price of firing more guns, and a fit is entitled to pay it. With the four added, `fitted-r7` gains two suspects it had been hiding, and the partner column says what happened: | flipped | came out | most collinear with | |---|---|---| | `heat_mp_penalty` | +0.0317 | `heat_to_hit_penalty` +0.72, `heat_shutdown_risk` +0.44 | | `heat_shutdown_risk` | +0.0198 | `heat_to_hit_penalty` +0.69, `heat_mp_penalty` +0.44 | `heat_to_hit_penalty` came out -0.1017, the second largest weight in the vector. The three are one direction at about +0.7 with each other, so the solve puts the whole "heat is bad" signal on the one column and hands the other two the residual, which is positive. Neither positive weight is a claim that shutting down is good. That is a structural point about the basis rather than about this fit: three columns measuring the same underlying quantity through different consequences will always do this, and adding a fourth consequence of heat would not help. Either the family collapses to one measure, or the report has to read them as a group. Not decided here. ### The label collapse is a property of the corpus, not of the labels `train.py` carried "`bv_loss_inflicted` correlates +0.97 with `damage_inflicted`, +0.94 with `kills_secured`" as a settled fact, and it was used here to argue that a six-label sweep was really a three-label sweep. Re-measured over `princess-opponent-r7` (1245 rows), the same two pairs are **+0.47** and **+0.44**. | pair | comment said | r7 says | |---|---|---| | `bv_loss_inflicted` / `damage_inflicted` | +0.97 | +0.47 | | `bv_loss_inflicted` / `kills_secured` | +0.94 | +0.44 | | `damage_inflicted` / `kills_secured` | - | +0.85 | Only the last pair is still near-duplicate. The older figures came from mirror corpora, where both sides fight the same way and BV, damage and kills move together; against Princess they come apart. Max |r| against every other label now reads 0.56 for `bv_loss_inflicted`, 0.62 for `inflicted_bv_per_round`, 0.75 for `inflicted_percent_of_maximum`, 0.78 for `mission_kills_inflicted` - four usable directions where the comment implied one. The comment now says which corpus its numbers came from and that `sds labels ` 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 `, and the run it is compared against carries `--against `, which writes `comparison.md` with an interval on the difference. A win rate quoted from a log tail is not a result; it is a number whose conditions were not recorded. Two runs on the same suite, each with a saved baseline, is the cheapest thing that can settle r6 against r7, and neither of the runs already made can be repaired into one. ### Every vector fitted this session comes from a corpus with the movement bug `princess-opponent-r7` was recorded on `labels2-suite` against Princess - the same 144 scenarios the evaluation runs, so the distribution is right, and the manifest says so. What is wrong with it is the recording: it was generated before `record_training` was fixed, so about one movement row in six pairs the argmax candidate's features with the played candidate's outcome. All six vectors in the label sweep inherit that, `fitted-r7` included. Choosing between them is choosing between six fits of the same defective data, and the label that wins is partly the label least sensitive to a sixth of its movement rows being mislabelled. So the next corpus matters more than the next label. The binary that records it correctly is built and tested; what it has never done is record a corpus. The order from here: 1. `fitted-r6` on the suite with `--save-baseline` - the first machine-checkable baseline this project has for it, and the thing every later comparison needs. 2. A fresh corpus against Princess on the same suite with the fixed binary, at `--explore 0.3`. First corpus with honest movement rows. 3. Fit it, and compare the vector against the r6 baseline with `--against`. `weights/fitted-r7-damage.json` is staged from the sweep in case a cheap second point is wanted before then, but it is a fit of the same defective corpus and should be read that way. ## fitted-r6 beats Princess, and 120 games cannot rank two vectors The 60.5% that stood all session as an un-baselined number from a log tail has been re-measured on the same suite against the same opponent, with a baseline recorded this time: | run | record | rate | 95% CI | |---|---|---|---| | earlier (log tail) | 69-45 of 114 | 60.5% | 51.4%-69.0% | | baselined re-run | 66-45 of 111 | 59.5% | 50.2%-68.1% | | **pooled** | **135-90 of 225** | **60.0%** | **53.5%-66.2%** | Either run alone clears 50% only barely - the re-run's lower bound is 50.2%. Pooled over 225 decided games the lower bound is 53.5%, and that is the claim worth making: sds beats Princess on this suite, by about ten points, and it has now been shown twice. `fitted-r7` is 54-56 of 110, 49.1%, CI 39.9%-58.3%. It does not clear 50%. **What the comparison actually says.** `compare` puts the r7-minus-r6 difference at -10.4% with a 95% interval of -23.0% to +2.7%. The interval spans zero. A 120-game run cannot tell these two vectors apart even at a ten-point observed gap, and separating an effect that size takes about 358 decided games per side. That is a constraint on the whole method, not a fact about r7. Every A-versus-B this session has been run at about 110 decided games, which is enough to say "this vector beats Princess" and not enough to say "this vector beats that one". Ranking two candidates needs roughly three times the games, or two candidates far enough apart that a 15-point gap is plausible - `games_needed` puts that at 43 decided games. Fitting six labels and benchmarking the winner at 120 games was never going to resolve anything; the sweep's value is in ruling vectors out, not ordering them. ## The honest corpus, measured mid-flight 231 movement rows from the first twelve matches of `honest-movement-r8`: | what the row is | rows | share | |---|---|---| | argmax policy, unit got its top candidate | 111 | 48.1% | | argmax policy, unit took something else | 64 | 27.7% | | explore policy, genuinely off-policy | 56 | 24.2% | **The fix works.** Every corpus before this one recorded 0 explored movement rows; this one records 24.2%, and all 56 of them took a candidate that was not the argmax of their own logged values. The rows describe moves the game played. **And something that was always happening became visible.** 64 rows carry `policy: argmax` and still did not take their best candidate. That is not exploration, it is contention: a unit's preferred hex gets claimed by another unit that reconciled first, and `reconcile` reports it as "moved off their own pick" - nonzero in the live logs, which is the corroboration. The old `record_training` recomputed the argmax, so this was invisible too. A corpus recorded before tonight does not merely mislabel the explored rows; it also writes down the hex each unit *wanted* in place of the one it *got*, wherever two units wanted the same ground. **The open question this raises.** The fit reads `chosen` as the candidate preferred over the others. On these 64 rows it was the candidate left after somebody else took the ground, and the label still measures what followed. That is a third case beside `argmax` and `explore`, the force already counts it, and the training row has nowhere to put it. Whether a displaced row should be fitted, weighted down, or marked and dropped is a real question and is not settled here - but only 48% of movement rows are a unit getting what it asked for, so it is not a rare corner. ### What actually differs between r6 and r7 `fitted-r6` records its label as `final_bv_differential+inflicted_bv_per_round`, which reads like a deliberate composite and is not one: `final_bv_differential` is the fallback for a match the requested label cannot be computed for, and the string records that it fired. Measured, it fired for **5 of 2602 label rows, 0.2%**. r6 is `inflicted_bv_per_round` for all practical purposes, and any argument that its strength comes from a differential label is wrong. Both corpora are the same suite against Princess. The real difference is which bot recorded them: | | corpus | recorded by | decisions | |---|---|---|---| | `fitted-r6` | `subset-menu-r6` | the default weights | 10328 | | `fitted-r7` | `princess-opponent-r7` | `fitted-r6` | 5110 | So the weaker generator produced the better vector, and twice the decisions. A plausible reading is that a stronger bot's play is more concentrated - it makes fewer different mistakes, so its corpus carries less information about what the alternatives were worth - and `princess-opponent-r7` being half the size at the same match count is consistent with that. It is a hypothesis, and the two vectors are not separable at 120 games, so it stays one. What it does argue against is the assumption that generated the r7 corpus in the first place: that recording against the opponent you are evaluated on, with the best weights you have, is obviously better. It was not obviously better, and it was not measurably worse either. ### The argmax warning found displacement and called it a corrupt log The first fit of `honest-movement-r8` printed "**On-policy decisions took the highest logged `value` only 79.7% of the time** ... nothing fitted from it means what it says". It was the third false alarm from that one warning tonight, and the arithmetic is exact: 5944 decisions, 1281 explored, so 4663 on policy; 948 of those reported as displaced; 948/4663 = 20.3%, and 100% - 20.3% = 79.7%. Every disagreement was a displaced row, and it could not have been anything else: for an on-policy row, "chosen is not the argmax" *is* the definition of displaced. The check and the counter measure the same rows, so leaving both in reported displacement twice - once as a measurement and once in the language of a corrupt corpus. Displaced rows are now excluded from the argmax check, as imitation and explored rows already were. What remains would be a disagreement with no account at all, which is what the warning has always claimed to be about. The pattern is worth naming, because it has now happened three times from the same six lines. The check was written when a training row could only ever agree with the argmax, so any disagreement really was a broken log. Each time the bot gained a legitimate reason to play something other than its top-ranked candidate - imitation, exploration, now contention - the warning kept firing and kept saying the corpus was untrustworthy. A guard that does not learn about new legitimate cases becomes a generator of false alarms, and a false alarm that appears on every fit is worse than no guard: it trained me to read past it, and I quoted it as a caveat for a whole session without chasing it. ## Two runs over one suite are paired data, and were being read as independent Every run in this session covers the same 144-scenario suite with the same seeds, so any two of them see the same 120 fights. `compare` treats them as independent samples, which prices scenario difficulty as noise. It is not noise. r6 against r7, the same 120 games read both ways: | | result | |---|---| | unpaired (`compare`) | -10.4%, interval -23.0% to +2.7% | | paired (McNemar) | 29 against 17 discordant, chi2 2.630, **p = 0.105** | 108 pairs were decided in both runs and **62 of them went the same way for both vectors**. More than half the sample says only that some scenarios are winnable and some are not; the comparison lives entirely in the 46 discordant pairs. Throwing the pairing away spends most of the games measuring the suite. Neither test reaches significance, and the conclusion about r6 and r7 is unchanged - but the paired one gets several times closer on identical data, and the earlier estimate that ranking two vectors needs about 358 decided games per side was made with the weaker test. Paired, the requirement is much smaller. `sds paired ` 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. - [x] Warn when a loaded weight vector is lopsided, naming the feature. 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. - [x] **19.6% of rows are displaced decisions read as preferences.** `--drop-displaced` leaves them out. Measured below - [ ] **Try a label that is not one-sided.** `inflicted_bv_per_round` has printed its own warning on every fit: it counts only what was taken off the enemy, so mutual disengagement scores the same as a good trade, and seventeen vectors fitted against one stopped engaging. No other label has been tried on this basis - [ ] **`level_tmm`, `level_terrain` and `cover_quality` have no expected sign.** More defence is never worse - the same argument that signed the six `defense_*plus` rungs. `level_tmm` flipped to -0.0287 in `fitted-r11` and nothing noticed ## 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. - [ ] Decide whether an empty window is a `RATIOS` zero or a `QUOTIENTS` None. It blocks any fit against a ratio label, and it is why the switch to `bv_trade_ratio` has not happened **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. - [ ] Read `_finishing` and `_damage_focus` against a worked case before either is fitted against again. `overkill` was a dead column that a guard missed; this is the same shape of question **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. - [ ] Fit at `unit` and at `force` and compare. **Informative only** - keep the learning restricted to the team tier until a tiered fit has been benched and shown better. Cheap: no new matches, one refit each ## 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. - [ ] Bench `fitted-r12-nodisp` against `fitted-r11`. The diagnostics cannot rank these two and the sign warnings say do not spend a benchmark on a vector whose signs are unexplained - so explain the heat group first, or bench with it excluded ## Fourteen columns with no weight, and what a fit will say about them A census over 1,112,871 candidate values found fourteen columns at zero in both `weights/hand-authored.json` and `weights/princess-bench.json`. Ablating a zero is a no-op, so nothing was known about any of them. Twelve of them turn out to have been fitted already, 13 to 15 times each, across `fitted-r5` through `fitted-r12-nodisp`; `grafted-r10`, which recorded both large corpora, plays eleven. **Only `expected_criticals` and `target_described` have never carried a weight in any file in `weights/`.** Full working in `comparison-price-the-unpriced.md`. What matters here: - **Nine of the fourteen have no price.** On the two current-basis corpora pooled - 120 matches, 1,437,161 comparisons - their bootstrap intervals over resampled matches all cover zero. `cover_distance`, `edge_distance`, `enemy_centroid`, `enemy_nearest`, `overlook`, `p_breach`, `range_spread`, `target_original_bv`, `target_tonnage`. - **`heat_incurred` is +0.1667, 95% CI +0.1301 to +0.2210**, beside `expected_damage` +0.2509 in the same solve. The record left this one for a fit and the fit pays for heat, modestly. It is not the +6.0 the six-arm experiment tried, and it is not zero. - **`expected_criticals` is -0.1783, CI -0.2996 to -0.0767**, against `EXPECTED_SIGNS` +1. Reported, not flipped. It is the most collinear partner `value_destroyed` has, at +0.77 over the difference rows. - **`target_described` is -0.0740, CI -0.1190 to -0.0348**, its first weight anywhere. An indicator, so this is the constant offset its own documentation says a weight on it buys. - **Pricing them buys nothing measurable.** `weights/fitted-r13.json` and `weights/fitted-r13-control.json` differ only in whether the thirteen fittable ones carry a weight: held-out on 71 matches the fit never saw, best-scaled R² 0.28127 against 0.28114, difference +0.00012 with a bootstrap 95% CI of -0.00266 to +0.00299. In play over the suite, `fitted-r13` took 36 of 67 decided against `hand-authored`'s 38 of 65, a change of -4.7% with an interval of -21.0% to +11.9%. Both arms answered about 99% of their decisions and neither issued an illegal order. ### The blocker is the corpus, and it is a hard one Ten stored corpora are at basis `46a42e2765de`. **Every one of them was recorded at exploration 0.0.** Every corpus that carries exploration is a basis behind - `sds train crits-corrected` at this commit is refused outright. So the current-basis numbers above come from a zero-exploration corpus. They are usable for *these* columns only because the vector that recorded it, `princess-bench.json`, prices all fourteen at exactly 0.0: a column the policy weighted at zero steered no choice, so its variation there is exogenous. For any column that vector did use, the same corpus can only confirm what it was generated from. - [ ] Record a self-play corpus at head with `--explore`, and re-take every figure in this section on it. Until then the two never-priced columns have one measurement each and it is from a corpus nobody would choose