bayes for days
sds plan contract.md
14 kB
Markdown
at main


id: contract title: The wire is a contract, and every rule has one home status: open dependsOn: [protocol, puzzles] exitCriterion: > Rust matches on no MegaMek identifier outside the wire module, every rule in sds-core is a rulebook table, a named engine deviation or a named policy, a new per-unit or per-weapon fact lands by editing one Rust type and one Java emitter, and the recorded goldens reproduce the same decisions throughout. #

contract #

docs/PROTOCOL.md says the protocol is the product and the JVM is scaffolding that can be replaced. That holds where facts cross the wire: the estimators read numbers, the hard questions are asked of the jar rather than derived, and MegaMek is named 446 times in Rust comments and seven times in Rust code. It does not hold in three places, and the same three places are where the merge conflicts come from.

The wire speaks MegaMek's identifiers and Rust matches on them. Move steps are MoveStepType names (FORWARDS, START_JUMP, GET_UP, LOAD), 48 literals across six files, and the pathfinder emits them as the reply grammar. Movement modes, phases, UnitRole names (about sixty uses of BRAWLER and its siblings), hit-table names, location abbreviations (about two hundred literals in ten files), handler class names and weapon display names in brackets::Excluded::of are all MegaMek's spelling, read as strings.

Every fact about a unit lands in the same four places. A field on the 63-field wire::Unit (wire.rs:606-1042), a put in the 826-line Observation.unit(), a branch in the bot, and an edit to a test fixture literal in the same file as the tests. Counted over the sixty in-flight branches on 2026-09-05: Observation.java is touched by 47, wire.rs by 46, main.rs by 42, unit.rs by 41. Unit kind is a string plus sentinel booleans dispatched at sixteen sites that disagree with each other (pathfind.rs:305 forgets TRIPOD; wire.rs:1217 includes it).

Rules, engine deviations and bot policy sit in the same files. los.rs is ported from LosEffects "rather than from the rules", arc.rs from ComputeArc, pathfind.rs prices what MovePath.getMpUsed would say, and hitloc.rs reproduces a MegaMek vehicle hit-table defect on purpose. Those are defensible: a bot that plays the rulebook loses to the engine's bugs. But none of them is marked as a deviation, so a port to another engine could not tell what to remove. And a bot choice such as Rotary at three shots, or the list of what is not priced, lives inside an estimator rather than beside the other choices.

The rule #

Three sentences, to go in CLAUDE.md once the plan is under way:

  1. Rust reads properties, never identifiers. Every enumeration on the wire is defined in PROTOCOL.md as ours, Java maps MegaMek onto it, and Rust matches on an enum. A test forbids the MegaMek name set outside wire/.
  2. A rules table in Rust cites the rulebook. An engine deviation is named as one, in one list, with the conformance line that proves it.
  3. A fallback for a missing field lives only under a protocol version that predates the field. Under the current version the field is required.

The plan is the order in which the code comes to obey them, with each phase landable on its own and each verified the same way: the recorded goldens reproduce, the conformance dump is unchanged, and clippy and the tests are green.

Phase 0: the instruments #

Nothing moves until there is something that says a move changed no decision.

  • An observation corpus. --dump-observations <dir> on SdsClient, which holds the ObjectNode at ask(); one recorded 2v2, gzipped, under crates/sds-core/tests/corpus/. It is the contract a wasm build is tested against too (browser).
  • Goldens from it. The puzzles runner in its golden form: every recorded decision replayed through the offline proposer and the force command with the vector that recorded it, asserting the recorded chosen. A phase that changes no decision leaves every golden green; a phase that changes one says so in its commit and re-records.
  • Conformance per weapon family. SdsConformance already re-asks the settled questions; extend it to dump the derived facts for one weapon of every family MegaMek ships, so a Java refactor is checked byte for byte.
  • A ratchet on identifiers. A test over sds-core source that fails on any MegaMek identifier from a listed set outside wire/, seeded with an allowlist of today's sites. The allowlist only shrinks.

Phase 1: files, pure moves #

No window needed, no semantic change, goldens identical.

  • unit.rs into unit/{projection,caches,menu,report,fixtures,tests}.rs - landed as the unit-split stack.
  • main.rs (4700 lines) into cli.rs (the hand-parsed flags at 2768-2960), weights.rs, firing.rs (firing_plan, ladders, allocations, volleys), bot.rs (Bot, plan, movement, deployment), classify.rs, run.rs. Tests move with their subjects.
  • Observation.unit() into bridge/sds/unit/<Group>.java, one static void into(ObjectNode u, Game g, Player me, Entity e) per field group, called in today's order; the weapon helpers into bridge/sds/weapons/, still as the functions they are.
  • bridge/sds/ into runtime/, probes/ and tools/ under one package; scripts/build.sh:78 compiles with find instead of the glob, and no class name in sds/match.py moves. Probes whose answers are already in a corpus file or an observed.txt leave the default compile.
  • One fixture builder per crate. Twelve fn unit( and six fn board( builders exist; features::fixture already has the builder, behind #[cfg(test)] pub(crate). A fixtures cargo feature makes it reachable from sds-bot's dev-dependencies and sds-core's own tests/.

Phase 2: the wire owns its vocabulary #

Byte-identical JSON, so every corpus still parses; only the Rust type changes.

  • Enums in wire/ with serde spelled as today: Step, Phase, Role, LocationName, HitTable, PhysicalKind, MovementMode. Each documented in PROTOCOL.md as the protocol's own, with the MegaMek name it maps from in the Java emitter and nowhere else.
  • UnitKind (Mek(Biped|Quad|Tripod), Vehicle, Vtol, BattleArmor, Infantry, Unknown), from a unitType key emitted from Entity.getUnitType(), with the derivation from locations and movement mode as the fallback for older observations. can_fall, tracks_heat and hitloc::Body stay as fields beside it; wire.rs:846-865 says why none of the three follows from the chassis.
  • The sixteen dispatch sites become a match on the kind. Resolving the TRIPOD disagreement changes how a tripod's movement is priced: that one gets a control and a re-recorded golden.
  • protocol: u32 on board, a hello the host reads back, and refusal when the ranges do not overlap. The Mek-shaped fallbacks (turn_mp reading 1, attacker_movement reading the Mek penalties, empty terrain reading the ported column, empty mode reading a biped) are gated to protocol 0; under the current version the fields are required and their absence is an error.
  • The three drifts found at fbd7d49 are fixed on the way: Java emits board.kinds and rejected[].attempt that Rust drops; Rust declares stackEnterable that no emitter writes, though plan/battle-armor.md says it is sent.
  • The ratchet's allowlist ends at wire/.

Phase 3: one home per kind of rule #

Moves and renames inside sds-core; goldens identical.

  • sds-core/src/rules/: the cluster hits table, the hit-location tables, the range modifiers, the heat scale thresholds, the piloting cliff. Each a const with the rulebook reference in its doc and the dump test that already exists beside it.
  • sds-core/src/rules/deviations.rs: the list of places where the code follows MegaMek 0.51 rather than the book, each with its conformance line: the vehicle front table hitting one side twice, the LosEffects height off-by-one, the section MegaMek calls a HACK, MovePath.getMpUsed pricing. Nothing is rewritten; the ports stay. What changes is that a reader, or a port to another engine, can find the list.
  • sds-core/src/policy/: what the bot chooses rather than what the rules say. brackets::Excluded moves here as not_priced.rs, keyed by a wire property (a modelled flag per weapon, which plan/protocol.md already asks for) instead of by name; the rate-of-fire assumption (Rotary at three); FIRING_ONLY. Each entry is one sentence a player could argue with.
  • sds-core/src/kinds/{mek,vehicle,battle_armor,infantry}.rs, behind UnitKind: how each takes damage (hitloc::Body, the platoon model), how it moves (the terrain column, jumping, facing cost), what it cannot do (swarming squads, prone, heat). infantry.rs is the first of these already; this gives it siblings and a trait.

Phase 4: weapons #

The exceptions are in Java because that is where the jar can be asked.

  • bridge/sds/weapons/<Family>.java: one WeaponRule per family that needs one (LbxAutocannon, UltraAutocannon, Mml, Atm, Artillery, Flak, Infantry), each applies(WeaponType, AmmoType) and describe(facts). A general derivation runs first; the matching rules override fields in an order written down once in Weapons.java, because ammunition can redefine ranges and an MML's mode changes its rack.
  • The modelled flag on the wire, set by the rules, replaces every name match in Rust.
  • docs/WEAPONS.md, generated: family, Java rule, the wire fields it sets, the Rust readers of each. The page that answers "where does the LB-X live".
  • Verified by the per-family conformance dump from phase 0, byte for byte.

Phase 5: the typed unit, in a window #

The one phase that rewrites every line of wire.rs:606-1042 and Observation.unit(), so it conflicts with every open branch on every hotspot. Done as one mechanical pull request when the wire-touching branches have landed, it conflicts once.

  • Twelve sub-structs under #[serde(flatten)], twelve Java emitters, one to one:
sub-struct fields who has it
Identity id, name, owner_id, team, friendly, role, force_path everyone
Worth bv, bv_original, weight everyone
Pose x, y, elevation, facing, secondary_facing, twists, elevation_valid everyone
Mobility walk_mp, run_mp, jump_mp, jump_mp_terrain, movement_mode, max_elevation_change, terrain, turn_mp, attacker_movement, mp_used, stack_enterable everyone
Heat heat, heat_capacity, tracks_heat, heat_buildup, explodable_ammo Meks; tracks_heat marks the rest
Hull armor, armor_max, internal, internal_max, locations everyone
Crew gunnery, piloting, psr_base everyone
Status prone, can_stand, can_fall, shut_down, immobile, stuck, can_unstick_by_jumping, destroyed, crippled, done, swarm_target_id, swarm_attacker_id everyone
Melee physicals, allowed_physical_attacks Meks
Carriage carried_by, carrying, can_carry carriers, battle armor, infantry
Platoon infantry_damage, mechanized, damage_divisor, dug_in conventional infantry
Arms weapons everyone

Field names are unique by construction, so flattening cannot collide; Reply.action at wire.rs:2473 is the precedent. imitate.rs:502 builds a Unit as a struct literal and is the one constructor that breaks.

  • Delete Appraisal, Intent, Stance, Commitment and asks. They are computed every turn (unit.rs:492-599, force.rs:278-296) and decide nothing: the scorer is allocate(&mut thoughts, &ours, &weights) and candidates.rs:436-438 says surfacing only supplies a label. plan/scoring.md asked for this already; it removes ninety lines from propose_including. Proposal labels change, so the imitation corpus is re-recorded before anything compares against it.
  • Delete Proposal.risk: written as 0.0 at nine sites, read by no Rust code, defaulted by the viewers.
  • Measure Observation parse time before and after: flatten deserialises through a buffered map.

What this plan does not do #

It does not rewrite line of sight, pathfinding, arcs or hit locations to the rulebook. The bot plays MegaMek, so it predicts MegaMek; the plan names the deviations rather than removing them. It does not touch the sweep's cost (latency) or the fit (estimator). And it does not move a per-weapon rule into Rust: a rule that lives after the wire changes what a recorded corpus means, and a Rust table of what an ATM does drifts from the jar while a Java rule that asks the jar does not.