bayes for days
sds plan protocol.md
22 kB


id: protocol title: The observation carries what a bot needs to reason status: open dependsOn: [] exitCriterion: > Every feature in the basis can be computed from what the wire carries, without the bot reaching into MegaMek. #

protocol #

The observation is the interface. What is not in it does not exist for any bot. Adding a field is deliberate, with a reason in the commit message.

Fixed already: WeaponType.getDamage() returns -2 for cluster weapons, so the bot valued every missile below nothing. There is now no plain damage field. The armour getters have the same shape of trap - ARMOR_NA, ARMOR_DOOMED and ARMOR_DESTROYED are -1, -2 and -3 - and the bridge converts them to zero.

The corpus tests the arithmetic and never the arguments #

Three bugs in one week where the differential passed and real play failed. They are one bug.

  • MASC. The observation sent getRunMP(), which folds in a boost this bot never declares. A Stalking Spider with walk 5 was offered a run of 10 where the rules give 8.
  • Deployment elevation. Units were deployed at a hard-coded elevation 0. A Mek in depth-1 water belongs at -1, and from an elevation isElevationValid refuses, MegaMek refuses every path out of the hex for the rest of the match.
  • Carried MP. MovePath.getMpUsed starts at Entity.mpUsed rather than at zero, so the allowance the bot was handed was larger than the one MegaMek enforced, by whatever the unit had already spent.

The movement corpus could not see any of the three, and the reason is the same each time: it is handed the inputs and asked about the arithmetic. A prone case is --prone-case x,y,facing,budget. The budget is an argument to the fixture and a derived quantity in a match, so the fixture can prove "given allowance B we agree with MegaMek about what B reaches" and can never prove "B is the allowance". Every one of the three bugs was in B.

The tell is in the fixture itself. SdsPathfind.search calls unit.fixElevation(), unit.setDone(false), unit.setHullDown(false) and, until this epic, left mpUsed at whatever a freshly loaded scenario has. Each of those lines normalises a piece of entity state that a live movement phase does not normalise - the second bug is literally the harness repairing the state the host creates. A line in a fixture that cleans up entity state is a class of bug that fixture can no longer see, and there are four of them in eleven lines.

The same split runs through the Rust side. pathfind_differential.rs builds its Walker as a struct literal, so it tests search, which was correct, and never Walker::running(&unit), which was not: mpUsed had been on the wire for months, documented, and read by the standing plan's key, and nothing ever read it for the budget. A field can be carried, parsed, and dropped, and no test here was shaped to notice.

What to do about it, cheapest first.

Not yet needed: the observation is sent per decision, ~450KB a round at 8v8. Sending it once per phase would remove most of that. Measured at 291us against ~10ms of thinking, so it is tidiness, not performance.

A refused move is answered again, not overruled #

The host refused a move, stood the unit still, and told the bot nothing. The bot holds no state across a question, so the next ask was the identical question - same round, same settled set, same positions, because the unit had not moved - and the plan cache returned the identical answer. One unit sent TURN_RIGHT, FORWARDS x3 for thirty-seven consecutive rounds. A Bishamon on a Mek suite was refused in 36 of 40 rounds and never moved at all, cycling ten variants of a blocked step list.

The shape is jmm's: log the refusal loudly, mark that move rejected, ask the bot again, repeat until the budget is gone.

  • The observation grows rejected: the step lists this decision has already had turned down, per unit. It is the whole of the bot's memory that anything was refused.
  • SdsClient.askMovement loops. A legal path returns; a refused one is appended to rejected and the same question goes back.
  • unit::propose drops any proposal whose step list is on that list, after the sweep has measured the menu rather than before it.
  • The plan cache is keyed on the number of rejections as well, or the re-ask would be answered out of the cache without rebuilding anything.

This preserves "every order has exactly one possible author" rather than bending it. Every proposal is still the bot's, still made on its own ranking, now made knowing what has been refused. The host contributes the refusal and nothing else. The two obvious alternatives both break it: a client walking a ranked list is choosing between the bot's options, and a client picking a hex is Princess by another name.

Three things that are load-bearing #

  • The budget is a count, and the clock behind it is real. jmm: "we're trying to be deterministic, but there will always be a 'match clock'." Determinism is the aim rather than an absolute - MOVE_ATTEMPTS is a fixed four so a match replays the same way on a loaded machine as an idle one - but the turn is spending real time whatever this file says, and the way that is handled is not to hide it. Every refusal is logged on its own line, so a decision that burned its budget leaves a trail of exactly what it tried instead of a silent stall.
  • A pass ends the decision at once and is never retried. candidates::generate offers nothing for a machine the rules will not move, and says so by passing. Retrying that would spin the budget on a unit that is correctly going nowhere - all 64 defaulted movement decisions on one vehicle bench were an immobilised unit. "This move was refused" and "this machine does not move" are different silences and the loop must not confuse them.
  • Identity is the route, not the destination. Two routes to one hex are not interchangeable - pathfind_routes.jsonl exists because a reachable set can be right while a route into it is refused - so rejecting the hex would throw away a legal way in along with the illegal one.

One refusal answers for many, and that is measured rather than assumed #

jmm's constraint: "if we have 1 rejected candidate we might actually have 100 backed up behind that." A loop that rediscovers a blocked neighbourhood one candidate at a time spends its budget learning what the first refusal already said. So the refusal has to carry more than "this route".

What a refusal actually tells you, read off 249 of them across three Mek corpora rather than from the field names:

  • prohibited is true once in 249, so "that hex is barred to this mover" is almost never the reason and cannot be the generalisation.
  • class is MOVE_ILLEGAL and endPos is true on every single one. The top-level fields do not discriminate at all.
  • The discriminating fact is in the per-step trace, and it is a strong one: 80% of refusals fail on their very first step.

That last one is the lever, and it generalises exactly rather than heuristically. MoveStep.compileMove walks a path in order and compiles each step from the one before, so a step's legality is a function of the prefix that reaches it: every route sharing an illegal prefix is illegal for the same reason. Excluding the prefix can therefore only drop routes MegaMek would also refuse - it can never cost a legal alternative, which is the property that makes it safe to do at all.

Replaying 229 refusals with each rule shows what it buys:

exclusion rule later refusals it pre-empts
the whole route 33%
the refused prefix 62%

So Rejected carries refusedAt beside the step list, and a refusal with no step-level verdict - an unknown step name, refused before MegaMek saw it - degrades to excluding that route alone.

And the honest bound: it is 62%, not 100%. A budget of four attempts does not make a unit whose whole neighbourhood is illegal into one that moves; it makes it a unit that tried four genuinely different things and said so. The remaining 38% is candidate generation offering routes that fail for unrelated reasons, and no amount of retrying fixes that - which is what the per-rejection log lines are for.

What it reports #

Each refusal is logged on its own line with the unit, the round, the attempt and MegaMek's own diagnosis, and each is recorded individually in the decision's rejections. A count would summarise away the step list, which is the only part that says which rule was missed - and this repository has just spent a night on records that summarised away the thing somebody needed.

What a match's behaviour is made of #

jmm asked for a rejected count and a never answered count beside answered/decisions, "to get a full idea of what happened with behavior during a match". Two of the three were already here under other names; the third was hidden inside a number that could not tell two things apart.

  • rejected counts individual rejections, not decisions that had one. A decision may be refused four times, and the reason the loop exists is that one rejected candidate can have many behind it - so this can exceed decisions and is deliberately outside the outcome partition. It was refusals; the name is jmm's word and the semantics were already right.
  • neverAnswered is a decision that ended with no accepted move, derived as unanswered + illegal + failed. Derived rather than counted so it cannot drift from its parts.
  • passed is not one of them. A bot that declines because the rules will not move the machine has answered: candidates::generate offers nothing for an immobilised unit, and on one vehicle bench all 64 such decisions were exactly that. Counting it as never-answered would report the design working as a failure.

defaulted could not make that distinction, and that is what this change is really about: a reply that never came and a bot that deliberately passed both landed in it. It is now the sum of passed and unanswered, kept so every existing reader means what it always meant, with the halves beside it.

exhausted is gone. It was incremented on the same line as illegal and was always equal to it within a phase - a duplicate counter, written by the same change that added the one it duplicates, which is the defect the review had just caught in a different form.

answered + passed + neverAnswered == decisions, and test_the_outcomes_account_for_every_decision reads the client's source and fails on any tally.<x>++ the partition does not know about. That is the guard that matters: an outcome added later without a counter would otherwise vanish into the gap rather than announce itself, which is precisely how two counters here once read zero forever and were believed.

Two counting faults the loop introduced and the review caught. Neither would have thrown; both would have produced a plausible number.

  • chosenActor tallies chose, differed and substituted, and the loop calls it on every answer. A decision refused three times would have credited four choices against a decisions of one, and chose / decisions - the turn-order statistic - would have read past 100%. Counted on the first answer only: the retries are the host's doing, not the bot's pick.
  • The counter below.

One counter nearly shipped dead. rejected and its neighbour were added to the tally and to runstats.py's COUNTS and left out of the JSON the seat writes. Nothing failed: runstats reads counters out of a Counter, so a name the client never writes reads 0 for every match forever - the column would have said "re-asking never happens" and been believed. tests/test_decision_record.py now asserts every counter the stats expect is one the client writes, which is the same guard the record's own fields got and for the same reason.