id: observability title: Read back what the bot believed and chose status: open dependsOn: [beliefs] exitCriterion: > A finished match can be stepped through turn by turn, showing what the bot believed and what it rejected. #
observability #
The rationale field already carries each force's stance, what changed its mind,
and every proposal it rejected. Nothing reads it.
"The lance chose badly" and "no unit offered anything better" look identical in a record that keeps only the winner.
SdsClient now appends every decision - the question, the answer, what the host
did with it, and the rationale - to <match-tag>-<seat>.decisions.jsonl in the
run directory. sds view renders one as a self-contained HTML page.
sds labels #
Correlates every label against every other over a corpus. Every label decision
this project has made came out of this measurement, and until now it lived in a
scratch file in /tmp - which meant the method was reproducible by whoever had
that file and nobody else.
Reads result documents only and never opens a decision log, so it is seconds rather than the half hour a decision-log pass costs.
It prints three things: labels that are never usable on this corpus, pairs at |r| >= 0.90, and every label ranked by the largest correlation it has against any other, with a verdict - under 0.4 "a new direction", under 0.8 "partly new", above that "mostly a rename". The bands are the ones the write-ups already used; having the tool say them keeps a reader and the numbers agreeing.
One corpus does not settle a correlation. bv_lost ~ damage_taken measured
0.50 on one 96-match corpus and 0.95 on the next. The command's own help says
so, because that lesson cost a wrong conclusion in a report.
Takes a stored corpus name or a run directory:
sds labels maximalist-r4
sds labels runs/20260822T214253Z-bench
corpus save was dropping the only copy of the refusal reasons #
CORPUS_GLOBS left the per-match .log behind, with the reason written next to
it: it "is the bulk of a run directory and nothing reads it twice". Measured on
the 120-match 4v4 run in flight, the decision logs are 7.3G and every .log
in the run together is 4.0M - 0.05% of what was already being kept, not the
bulk. The second half is wrong too: those files were read three times this week,
for MegaMek's refusal reasons, for the victory condition, and to count how often
the force explored.
.log and .html are now saved. A corpus exists so the run directory can be
deleted, so anything a later question might need has to be inside it - and a
saved corpus that cannot answer "why did it refuse the shot" is missing the
question most often asked of it.
Not retroactive: the corpora already saved have no .log, and the runs they
came from are gone.
sds progress - the standings of a run that has not finished #
bench prints its table once, at the end, and a suite run is hours. The
question asked long before then is "is this vector even in the fight", and it
was being answered by one-off scripts reading the per-match JSON by hand - three
times in one night, once against the wrong denominator, and never picking up the
answered/defaulted columns that say whether the bot played the match it is
credited with.
sds progress <run-dir> renders the same Summary the final table is built
from, over whatever matches have finished. It skips the files that are not match
results and a result caught mid-write.
The interval it prints is the honest width for the games played. A partial read is not a reason to stop a run early: stopping one because the number looked good is how a benchmark turns into a story about when somebody stopped looking. The command's own help says so.
Every baseline from a two-seat run recorded zero wins #
from_run asked summary.sides.get("sds") for the bot's side. A benchmark
alternates which seat the bot plays, so Summary is keyed by role and the sides
are called sds North and sds South; there is no side named sds. The lookup
returned None, the wins field took its else 0 branch, and so did both BV
columns.
Every baseline recorded from a two-seat run has wins: 0. sds baselines has
been printing overnight-1v1, overnight-2v2, overnight-4v4 and
overnight-8v8 at 0.0% for as long as they have existed, and it was read
past several times tonight as if it were a property of those old runs.
It matters more than a wrong number in a listing: baseline.py is the machinery
that is supposed to refuse misleading comparisons, and compare against one of
these would have measured a candidate against zero and called any result a huge
improvement.
Fixed by summing over sides by kind rather than looking one up by name.
fitted-r7-suite is rebuilt from its run directory - the run writes
baseline.json whether or not --save-baseline was passed, which is what made
the repair possible without replaying 120 matches - and now reads 54 of 110
decided, 49.1%.
The four overnight-* baselines cannot be repaired: their run directories are
gone. They should be treated as unrecorded rather than as zero.
And a comparison against one is refused rather than reported #
Fixing from_run stops new baselines being zeroed; it does nothing about the
four already stored, whose run directories are gone. compare now refuses them.
The signature is exact rather than a guess about strength: wins, bv_left and
bv_left_opponent all zero over a run with decided games. A bot that genuinely
lost every game still leaves BV on the board in some match, so it is a result
and stays comparable; an opponent that finished 150 games with none of its BV
remaining in any of them did not happen.
Refusing is the conservative direction here. The failure it prevents is a candidate measured against zero, where every vector is a large improvement and the report says so in confident language with an interval attached.
sds correlate - the win rate broken down by what was in the match #
A win rate says the bot is better or worse. It cannot say at what, and at-what is the question that turns a benchmark into a direction to work in.
sds correlate <run> <suite> joins each result to the .meta.json the tiered
generator writes beside every scenario, and reports the win rate within groups:
battle value tier, era, mean gunnery, mean piloting, map, heaviest machine,
tonnage spread, Clan machines present, machines that can jump, mean weapon
count, role, weight class, and each individual design.
Three things it is careful about:
- A match whose scenario it cannot find is reported, not dropped. Silently shrinking a group is how a join lies.
- A group under a dozen matches gets no interval. Those are summed into one line instead. A three-match group at 100% is a story about noise, and printing a confidence interval next to it invites the story.
- It says "mirrored" in the output. Both sides field the same machines with the same crews, so a group the bot wins more than half of is one it plays better than Princess does - not one that is stronger. Read the other way the whole report is backwards, so the report says which way round it is.
Breadth and per-machine resolution are different suites #
The 418-scenario tiered suite fields 1029 distinct designs across 1672 slots.
That is the right shape for "does the bot generalise, or is it good at our
scenarios" - and it is the wrong shape for "which machines does it fly badly",
because every design appears once or twice and sds correlate suppresses every
per-design group as too thin to read. The table exists and is empty, which is
the honest outcome and not a useful one.
--designs-per-era N draws from a fixed sample instead of the whole pool. At 25
designs a 20-scenario suite already repeats each machine a median of 3 times;
scaled to a few hundred scenarios that is tens of matches per design, which is
readable.
The two are not better and worse. A narrow suite that says the bot cannot fly a Hunchback has learned something a broad suite cannot, and has learned nothing about the nine hundred machines it never fielded. Pick the question per run, and say which one the run asked.
Three questions in one night that the record could not answer #
Each was answerable only by inference, a second run, or reading the source; each is a field the record already had the inputs for.
- Why did this unit propose nothing? A pure-vehicle bench defaulted 21.3%
of its movement decisions, against 0.23%-1.06% on the Mek corpora, and it was
raised as a defect. It is not one: joined against the match document's
per-round snapshot, all 64 were a unit MegaMek had already marked
immobile. Reaching that took an inference first - 17 units defaulted, all 17
never answered again, and all 64 decisions had an empty menu - because nothing in
decisions.jsonlcarriedimmobile,elevationValidorstuck.SdsClient.recordwrites all three per unit now, andUnit::immobilitynames which one so the turn label says it outright. - What was the to-hit, and what arc was the shot in? The vehicles agent hit the same wall twice on the firing side.
The shape is one shape: the observation carries the field, the bot reads it,
and the record drops it. Everything in Observation.unit is available at
record time; what gets written is a chosen summary, and the choice was made
before anyone had the question. So the test is not "is this field interesting
today" but "if this decision looked wrong, could I say why from this file
alone?"
The cost of being wrong is asymmetric and that is the argument. A field nobody
reads is a few bytes a decision against a rationale already measured in hundreds
of kilobytes. A field nobody wrote is a re-run - and a re-run on a suite that
has since changed cannot be compared with the run that raised the question
anyway, which is the trap plan/harness.md is about.
A summary is a proxy, and the proxy rule applies to it. positions looked
complete - id, name, side, hex, facing, done, destroyed - which is exactly why
nobody checked it for the field they needed.
The sweep, and what it cost #
Rather than wait for a fourth wall, Observation.unit was walked against what
record writes: 45 fields emitted, 11 kept, 34 dropped. The cost of keeping
them settles most of the argument before it starts - measured on a real decision
log, positions is 283 bytes a decision against rationale's 1,531,561, a
factor of 5,400. The whole ground-truth record was 0.002% of the file.
Nobody traded anything away; the summary was written before anyone had the
questions.
The 34 sort into four groups, and only one of them was a real gap:
- Recoverable per round from the match
.json(armor,internal,bv,heat,prone,shutDown,crippled). Theentitieskeys are entity ids and join topositions[].id, so this is a join rather than a loss - at a coarser grain and across two files. Left dropped, join written down here - and writing it down was not bookkeeping: that join is what proved all 64 vehicle defaults were immobile units, a question that had otherwise been settled only by inference. Recoverable is not the same as available; the difference is whether anyone knows the join is there. - Static for the match (
weight,maxElevationChange,heatCapacity, the*Maxpair,ownerId,team,twists,role,explodableAmmo,bvOriginal,canUnstickByJumping). Wrong grain to repeat per decision and not worth a header for. Left dropped, except the two below. - Per decision and recoverable nowhere. The real gap, and now kept:
walkMp,runMp,jumpMp,jumpMpTerrain,mpUsed,canStand. - Large structures (
locations,weapons). A real cost rather than a rounding error, so they get their own decision when a question needs them rather than being swept in now.
movementMode and gunnery are kept too, each for a named question rather than
for completeness: plan/harness.md asks for a rate split by the mover's own
type, and this file could not do it - the only alternative was matching on the
unit's name - and gunnery is the direct input to every to-hit figure.
Written into positions rather than a per-match header, which is a
deliberate departure: record already holds that "a corpus is easier to read
and harder to get wrong when one decision is one flat document", and the only
thing a header buys is bytes that the 5,400x figure says are not worth buying. A
widened entry is 313 bytes against 128, and the whole block is 0.08% of the
rationale beside it.
The failure mode this had to be guarded against #
JsonNode.path does not fail on a name that is not there. It returns a missing
node, and asInt() on that is 0, asBoolean() false, asText() the
empty string. A typo in a field name would not throw and would not log - it
would write a plausible figure into the only copy of the ground truth, which is
the 0.00-for-"no answer" defect this repository has already paid for.
tests/test_decision_record.py reads both files and asserts every name record
takes off the observation is one Observation.unit puts on it. A one-letter typo
fails it.