diff --git a/docs/HIERARCHY.md b/docs/HIERARCHY.md index 0065380..269f2ef 100644 --- a/docs/HIERARCHY.md +++ b/docs/HIERARCHY.md @@ -1,31 +1,174 @@ # The hierarchy -Princess decides one unit at a time. It picks a unit, scores every legal path -for it, takes the best, and moves on to the next unit knowing nothing about what -that one will do. Eight locally-best moves are not a plan, and the visible -symptom is the conga line: a lance in single file, each machine having made a -defensible choice. +Princess decides one unit at a time. It picks a unit, scores every legal path for +it, takes the best, and moves on to the next knowing nothing about what that one +will do. Eight locally-best moves are not a plan, and the visible symptom is the +conga line: a lance in single file, each machine having made a defensible choice. + +SDS splits the thinking across the force structure MegaMek already keeps. This +page is the vocabulary — what the layers are, what changes at each, and which of +them are decisions rather than labels. + +## The layers + +```mermaid +graph TD + S[Scenario
the rules of winning] --> O[Objective
what we are trying to do now] + O --> C[Side
the coordinator] + C -->|Tactic| F1[Force] + C -->|Tactic| F2[Force] + C -->|Tactic| F3[Force] + F1 -->|TaskOverride, rare| U1[unit → Task] + F1 --> U2[unit → Task] + F2 --> U3[unit → Task] + F3 --> U4[unit → Task] +``` + +**Scenario** — the map, the forces, and the rule that decides a win. Fixed for +the match. -SDS splits the thinking across the force tree that MegaMek already keeps. - -## The cycle - -Once per round, at the first movement question: - -1. **Every unit thinks, at once.** Each is asked what it *could* do and what - each option would be worth. It proposes; it does not decide. -2. **Each force waits for all of its units.** This barrier is the whole point. - A force that acted on the first three of four answers would be making - Princess's mistake one unit later. -3. **The force appraises, commits, and chooses.** It sums its units' readings of - the situation, moves its stance if the case is strong enough, and picks each - unit's proposal by what the stance it is *actually holding* is worth. -4. **Orders are held and played** as each unit's turn arrives. - -Above a lance, a company does the same thing with its lances' readings, and its -stance tilts theirs — it makes holding worth more to the lance on the threatened -flank without telling that lance which hex to stand in. That is what keeps three -levels from collapsing into one level with extra steps. +**Objective** — what we are currently trying to do: win on battle value, or +preserve what is left. It is the *utility function*, not a constraint list. Under +one objective a withdrawal is terrible; under the other it is the plan. Changing +it changes what "good" means everywhere below. + +**Force** — a group of units with a commander. One side holds a handful of them. + +**Tactic** — what one force is doing: `Engage`, `Flank`, `Entrench` and so on. +One per force, chosen at the start of a turn, changed mid-turn only by a forcing +event. A tactic biases movement, firing and physical scoring; it does not pick +hexes. + +**Task** — what one unit actually does this turn: a hex, a facing, a volley. +Every unit always has one. This is the enormous space, settled by ranking +candidates on the feature basis. + +**TaskOverride** — a force constraining one of its units: *you are the spotter*, +*you carry that squad*. Rare, and an exception by design. A constraint narrows +the task set; it does not choose within it, so a unit told to be a taxi still has +several hexes to pick from. + +## The structure is flat, deliberately + +There is no recursive force tree. A side holds forces, a force holds units, and +that is the whole depth. + +Company-on-company is the practical ceiling for a played game — a command lance +covering three others is sixteen units. Depth beyond that buys nothing and costs +a great deal: a recursive design needs a general contract at every level, and a +bug in it is a bug at all depths at once. Two fixed hops can be read as +straight-line code. + +Flattening also makes cross-attachment ordinary rather than special. An infantry +company that belongs to the command lance but fights alongside the artillery is +normal task organisation, and a strict tree cannot express it without edges +layered on top of containment. With one coordinator it is just a relation. + +Note that a **Command Lance is not a command layer.** It is a formation type with +composition requirements that also fights. The commander is a *unit inside it*, +not a level above it. + +## Timescales + +The layers are separated by how often they change, and that is a design +constraint rather than an observation: **what a layer costs should be inversely +proportional to how often it changes.** + +| layer | changes | +|---|---| +| Scenario | never, barring a GM | +| Objective | perhaps once a match, and usually only when losing | +| Tactic | every few rounds; every round at most | +| Task | every round, for every unit | + +So Tasks get cheap scoring over a huge space, Tactics can afford a transition +model with hysteresis over a small one, and the Objective can afford real +deliberation because it is decided once. Anything expensive at Task frequency is +misplaced, and so is anything trivial at Objective frequency. + +## Roles and Formations are labels, not decisions + +Both are classifications derived from what a force brought, and **neither should +drive scoring.** + +A **unit Role** is MegaMek's own field — Brawler, Sniper, Missile Boat, Scout, +Skirmisher, Striker, Juggernaut, Ambusher. The rules derive it from three +questions: *how fast is the unit, how tough is it, and at what range does it +prefer to fight?* + +A **Formation** is the same idea for a force: Battle Lance, Fire Lance, Recon +Lance, and their variants, each defined by composition requirements over weight +class, movement, armour, damage at a range, and the roles present. + +Both are lossy. A BGS-7S is labelled Missile Boat and carries MMLs that want a +medium fight. A Dasher C is a Missile Boat whose LRMs have no minimum range and +which wants to ferry armour and hunt backs. Formations misclassify the same way — +a Hunter Lance qualifies at exactly 50% Ambushers while being mostly logistics. + +The rule that follows: + +> **Continuous capability drives decisions. Discrete labels are portrayal.** + +A unit's own range profile, speed and durability go into the feature basis. The +word "Missile Boat" goes on the screen, so a person can read what the bot is +doing. Formation likewise gives a force a name and a starting hint for its +tactic, and stays out of the scorer — where a threshold at 50% would make a force +at 49% behave discontinuously differently for no reason in the world. + +## Commanders + +Each force has one, and in a campaign it is a specific pilot in a specific +machine — which can be killed. That makes a commander a mortal object rather than +a configuration value, and it is where a force's personality attaches. + +Two consequences worth stating: killing the enemy commander becomes a tactic with +a modelled payoff rather than flavour, and protecting ours becomes a reason to +screen. MegaMek carries `commander` on the entity already; we do not yet read it. + +## The tactics + +Ten, each weighting a different family of features. Whether an eleventh is +genuinely new is measured rather than argued: re-score a recorded corpus under +each tactic and look for a pair that *never* diverges. Neighbouring tactics +agreeing often is expected - some hexes are simply better - and only a pair that +agrees everywhere is one tactic wearing two names. + +| tactic | target | what it optimises | +|---|---|---| +| `Engage` | — | the exchange ratio at our band | +| `Advance` | — or enemy | accepts a worse exchange now to close | +| `Harass` | — | freedom of action; the opposite of committing | +| `Flank` | — or enemy | arc, not range | +| `Screen` | own force | interposition between a friend and their enemies | +| `Entrench` | — | positional durability: cover, elevation, seeing without being seen | +| `Break` | — | losing line of sight — a tempo instrument, not a defensive one | +| `Reposition` | location | an exogenous destination, overriding local scoring | +| `Regroup` | — | cohesion | +| `Retreat` | — | leaving the map | + +`Engage` is the default and should reproduce roughly what the bot does with no +tactic layer at all, so everything else is measured against it. + +Some tactics imply a *distribution* of work inside a force rather than the same +order to everybody — a lance cannot all flank; most of it holds attention while +one unit takes the arc. A tactic that broadcasts one instruction to every unit +cannot express a plan whose point is that units do different things. + +Not yet expressible, and waiting on the epics that unblock them: `Bombard` and +`Spot` (artillery), `Taxi` and `Ride` (battle armor), `Seize` and `Deny` (map +objectives and terrain effects), and closing to melee. + +## What is built, and what is not + +Built: two-level planning, the barrier where a force waits for all its units, +menu reconciliation, and node transports so a level can run on another machine. + +Not built: the tactic layer described above. What exists today is a **stance** — +a weight over seven hand-scored intents, chosen by summing per-unit appraisals +whose arithmetic is hand-written rather than fitted. It is a parallel scoring +system to the feature basis, it prices defence at roughly half of offence by +hard-coded constant, and two of its seven intents are arithmetically unable to +win. See `plan/scoring.md` and `plan/tactics.md`. ## Where a node runs is an address @@ -37,101 +180,4 @@ line protocol on all three, so moving a thinker is a config change: That indirection is not decoration. A planner backed by a language model wants a key, a bigger machine and a much longer deadline than a unit thinker that runs a thousand times a second next to the state — and both have to hang off the same -tree or the tree is a fiction. - -## Strategy is a blend, not a choice - -A lance does not pick "flank" from a list. It holds a **stance**: a weight over -intents that sums to one. - - close · hold · flank · break_los · focus · withdraw · screen - -So "mostly hold at range, a little flanking" is expressible, and is the normal -case. Composition falls out for free — blending two stances is a weighted sum, -where blending two enum values is a coin toss. - -## Commitment is one number - -`grit` is how hard a force is to talk out of what it is already doing. At 0 it -re-decides every round and twitches; at 1 it dies executing its opening plan. - -It controls two things deliberately: the margin a rival stance must beat to take -over, and how fast the stance drifts once it does. Both are facets of -stubbornness, and splitting them into two knobs would invite tuning them against -each other. - -The margin is scaled against what is at stake rather than absolute, so it means -the same thing in a skirmish and a massacre. - -This is a fun mechanism before it is a strength mechanism. A bot that flip-flops -reads as broken even when each individual choice is defensible; a bot that holds -a plan reads as having one. - -## Roles are hints - -Every MegaMek design carries a role — sniper, brawler, scout. It says what the -machine was built for, and it is the right place to start and the wrong place to -finish. - -The case that forces this: **a sniper outranged by everything on the field.** Its -role says stay back and shoot. From back there it will not land a shot all game. -A bot that obeys the label loses while following its instructions correctly. - -So a role contributes a *preference* over intents, never a rule. Every role's -preference is a blend across at least two intents, which is what makes it -possible to outweigh — there is a test asserting exactly that, because a role -that ever became a single intent would silently regain its veto. - -When a unit notices the conflict it says so, and the sentence travels up: - - sniper outranged: reach 12 against 21, nothing lands from here - -The lance sums that with everything else and decides. The sniper does not decide -to close, and it does not suffer in silence either. - -## Everything rejected is kept - -A force's decision records every proposal it was offered, not just the ones it -took. "The lance chose badly" and "no unit offered anything better" look -identical in a record that keeps only the winner, and telling those two apart is -most of what debugging a hierarchy is. - -It travels back to the host in the `rationale` field of the action, which the -host ignores and writes to the match log. - -The decision also records what the menu was *priced against*. `threats` names -each enemy, where it stood, and every hex it could have reached before we -arrive - the set `damage_taken` is a collapse over - and every proposal carries -`feared`, the one member of that set which contributed the most incoming damage -to it. `feared` is an argmax over the arithmetic and not a prediction: nothing -in this bot weights one reachable hex above another, so there is no such thing -here as "where we think the enemy will go". - -## Scoring is being replaced - -A proposal is still scored by `Proposal::serves`: the unit says which intents -its option serves and the force multiplies that by its stance. It is a -self-report, so a unit that is wrong about itself is never contradicted. - -`sds_core::features` is the replacement. A feature is a named number computed -from the observation, and it declares how it is normalised — `bounded`, `rank` -or `local` — in the type system, so an unnormalised feature does not compile and -a `local` one cannot be given a learned weight. Firing, target, latch, -force-level and positional features exist and are tested. The positional group -runs off `sds_core::los` through one survey per movement candidate, which is -what lets a hex be scored on what could shoot it and not only on what it could -shoot. - -## What is not done yet - -- Deployment is scored but not commanded. A deploy hex is priced on the best - move it opens next turn — the same search, envelope, sweep and weights a move - is priced with — but each machine chooses alone, so the lance still does not - choose a shape and two units can want the same ground. -- Movement *order* is the harness's, not the bot's. Which unit moves first is a - real tactical decision. -- Terrain is barely used. Proposals reason about range bands and ignore cover, - which is most of what `break_los` would need to mean anything. -- The damage model during movement is range bands, not to-hit numbers. Real - numbers do not exist for a hex the unit is not standing in yet, but a better - estimate than this one does. +structure or the structure is a fiction. diff --git a/plan/README.md b/plan/README.md index 0a8c783..6fa9d57 100644 --- a/plan/README.md +++ b/plan/README.md @@ -71,7 +71,7 @@ one. **We plan at the lance and fight for an objective.** Not eight machines each solving its own problem. `plan/hierarchy.md` has the planning half - units -propose, forces decide - and `plan/goals.md` has the objective half, where "get +propose, forces decide - and `plan/objectives.md` has the objective half, where "get four units off the east edge" makes a favourable trade bad. Both are open. **We are reproducible against ourselves.** A scenario and a seed replay to the @@ -124,7 +124,7 @@ collection of opinions, and until a control comes out near 50/50 with stalls nea zero, no number in this repository means anything. After that the spine is [protocol](protocol.md) -> [los](los.md) / -[ev](ev.md) -> [features](features.md) -> [strategies](strategies.md), because +[ev](ev.md) -> [features](features.md) -> [tactics](tactics.md), because each one is what the next needs to be computable. [training](training.md) is last for a reason: it fits `M`, and `M` does not exist until `features` and `strategies` do. @@ -141,11 +141,12 @@ for a reason: it fits `M`, and `M` does not exist until `features` and | [ev](ev.md) | Damage as a distribution, not a mean | open | protocol | | [los](los.md) | Line of sight computed here, checked against MegaMek | open | - | | [features](features.md) | A normalised, named feature basis | open | protocol, los, ev | -| [strategies](strategies.md) | Strategy as a blend of features, held with grit | blocked | features | +| [scoring](scoring.md) | One basis, not two | open | features | +| [tactics](tactics.md) | A force is doing one nameable thing, and can be told to do another | blocked | scoring, hierarchy | | [beliefs](beliefs.md) | What the bot thinks is happening | blocked | features | -| [goals](goals.md) | What a force is trying to achieve | blocked | strategies, protocol | -| [doctrine](doctrine.md) | Composable lenses - culture, commander, personality, experience | blocked | strategies | -| [capability](capability.md) | Forces and doctrines map to each other | blocked | strategies | +| [objectives](objectives.md) | What winning means, and what to do when it stops being available | blocked | tactics | +| [doctrine](doctrine.md) | Composable lenses - culture, commander, personality, experience | blocked | tactics | +| [capability](capability.md) | Forces and doctrines map to each other | blocked | tactics | | [difficulty](difficulty.md) | Fallible, never absurd | blocked | beliefs, doctrine | | [training](training.md) | Weights fitted from recorded play | blocked | features, harness | | [observability](observability.md) | Read back what the bot believed and chose | open | beliefs | diff --git a/plan/candidates.md b/plan/candidates.md index ac81500..c5d5397 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -49,7 +49,7 @@ wrong at both ends and has gone. So the scoring work - line of sight, hit locations, damage distributions, per-location value - is being used to rank five options out of scores. **No weight vector can select a hex that was never proposed.** This epic is the -ceiling on everything [features](features.md) and [strategies](strategies.md) +ceiling on everything [features](features.md) and [tactics](tactics.md) can achieve. It is also the uncomfortable half of [PRINCESS.md](../docs/PRINCESS.md): we @@ -85,7 +85,7 @@ aggregations are **not the same operator** - see below. Over `M`, an enemy's possible positions: uncertainty. Collapse with the power mean, whose exponent spans minimax (`p -> -inf`, they find our worst case) through expectation (`p = 1`, they move without regard to us). Same operator -[strategies](strategies.md) already names for composition, and it is +[tactics](tactics.md) already names for composition, and it is characterful rather than arbitrary - a cautious commander runs a lower `p`, so it belongs to [doctrine](doctrine.md). @@ -313,7 +313,7 @@ The frontier fixes it without an exchange rate. A stand dealing 25 while taking survives only where it is genuinely the safest thing available, and a position that fights is on the frontier whenever one exists. No objective term, no distance-to-enemy term, no scalar: a rate between the two columns is doctrine's, -it belongs to [strategies](strategies.md), and it is what `StandScore::margin` +it belongs to [tactics](tactics.md), and it is what `StandScore::margin` was deleted for. **One damage column per enemy, not a maximum over enemies.** Dominance on "best @@ -805,7 +805,7 @@ eight units and the enemy is credited with eight times its firepower. It is a conditional, not a share: the enemy allocates fire, it does not multiply it, so a force's real exposure is an allocation problem whose worst case is concentration on one unit. Nothing here may add these numbers up, and the fix is -force-level - see [hierarchy](hierarchy.md) and [strategies](strategies.md). +force-level - see [hierarchy](hierarchy.md) and [tactics](tactics.md). **The force now does it.** `features::incoming` shares each enemy's fire out over our units by how much each would appeal as a target, and writes the result @@ -933,7 +933,7 @@ There is no bridge from a `Stance` to a `Weights` anywhere in the tree. Choosing along a frontier needs one, and `examples/stands.rs` carries a stand-in so the demonstration can exist - in the example on purpose, because what a stance emphasises is a tactical opinion nobody has argued over yet. Writing that -bridge for real belongs to [strategies](strategies.md). +bridge for real belongs to [tactics](tactics.md). The firing group does not fully survive the aggregation either. `expected_damage`, `p_kill`, `p_mission_kill` and `value_destroyed` come off the diff --git a/plan/capability.md b/plan/capability.md index 28e1252..5093507 100644 --- a/plan/capability.md +++ b/plan/capability.md @@ -2,7 +2,7 @@ id: capability title: Forces and doctrines map to each other status: blocked -dependsOn: [strategies] +dependsOn: [tactics] exitCriterion: > Selecting a lance for a doctrine and inferring a doctrine from that lance round-trips. diff --git a/plan/doctrine.md b/plan/doctrine.md index 8e3404c..250bea8 100644 --- a/plan/doctrine.md +++ b/plan/doctrine.md @@ -2,7 +2,7 @@ id: doctrine title: Composable lenses - culture, commander, personality, experience status: blocked -dependsOn: [strategies] +dependsOn: [tactics] exitCriterion: > A Clan force plays zellbrigen, breaks it under pressure, and a human can author a new opponent by writing rows and masks rather than training. @@ -35,3 +35,30 @@ Verified on sarna: zellbrigen does not formally forbid melee, forbids interfering in another's duel, and makes breaking LOS or withdrawing out of range *dezgra*. So it forbids focus fire - the highest-value force behaviour - which is what makes Clan forces a different opponent rather than a stronger one. + +## Personality attaches to a commander, not to a role + +Settled while designing the tactic layer, and it changes where the lenses hang. + +A **unit Role** is too lossy to group by: a BGS-7S is labelled Missile Boat and +carries MMLs that want a medium fight, and a Dasher C is one whose LRMs have no +minimum range and which wants to ferry armour and hunt backs. Grouping weights by +the role *string* would fit the label's noise. Use the continuous capability the +role is a lossy summary of. + +A **Formation** is the same problem one level up, and belongs to portrayal for +the same reason - see [HIERARCHY.md](../docs/HIERARCHY.md). + +What is left is the **commander**, and every force has one. That gives the lens +stack a home: a side's doctrine multiplies a force commander's personality +multiplies the unit's capability-driven weights. Commutative, attributable, and a +few parameters per level rather than a vector. + +- [ ] Decide **modifier or vector** by the sign-flip test: if a personality only + scales terms it is a multiplicative lens, and if it flips the sign of a + feature no lens can express it and the group needs its own weights. Testable + on the existing corpus with partial pooling, which reports how far a group + moves off the global vector - ridge at 1e-3 is already complete pooling, so + this is one penalty term rather than a new solver +- [ ] Personality is embodied, so it can be killed. Succession needs an answer; + the rules designate a key lieutenant per additional Command Lance diff --git a/plan/goals.md b/plan/goals.md deleted file mode 100644 index 1725793..0000000 --- a/plan/goals.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -id: goals -title: What a force is trying to achieve -status: blocked -dependsOn: [strategies, protocol] -exitCriterion: > - A scenario with an extraction objective is played toward that objective rather - than toward destroying the enemy. ---- - -# goals - -A goal changes the objective, not the policy, which is why it cannot be another -strategy. "Get four units off the east edge" makes trading BV bad even when the -trade is favourable, and the map edge is not in any combat feature. - -Goals decompose by **assignment** (1st Lance screens while 2nd exits); strategies -decompose by **blending**. Different operations. - -MegaMek already carries this: MMS V2 has per-player `victory:` and composable -triggers - `UnitPositionTrigger` is literally an extraction goal. - -- [ ] Read goals from the scenario rather than inventing a vocabulary -- [ ] Goal-conditioned features (`distance_to_exit`) in one union basis with - zeros, so `M` stays rectangular -- [ ] Per-unit goals: this one is the VIP, these are the screen -- [ ] Default goal: defeat the enemy as a cooperating team diff --git a/plan/hierarchy.md b/plan/hierarchy.md index 5101b41..99d2ef9 100644 --- a/plan/hierarchy.md +++ b/plan/hierarchy.md @@ -4,8 +4,9 @@ title: Units propose, forces decide status: open dependsOn: [] exitCriterion: > - A company of two lances plans in one cycle per round, every force waits on all - its children, and the rejected proposals are in the record. + A side of several forces plans in one cycle per round, every force waits on all + its units, the rejected proposals are in the record, and a scenario that + declares forces produces a `force_path` the bot actually reads. --- # hierarchy @@ -240,3 +241,34 @@ range, terrain, arc, and the movement modifier the stand's own gait buys. Costs 2.2 ms for 8 units x 200 stands x 8 enemies in release, measured on a loaded machine, against the 96.8% of decision time movement already spends. + +## The structure is flat, and that is a decision + +There is no recursive force tree: a side holds forces, a force holds units, and +that is the depth. Company-on-company is the practical ceiling for a played game +- sixteen units - and depth beyond it buys nothing while costing a general +contract at every level. Two fixed hops read as straight-line code. + +It also makes cross-attachment ordinary. An infantry company that belongs to the +command lance but fights alongside the artillery is normal task organisation, and +a strict tree cannot express it without edges layered over containment. One +coordinator makes it a relation. + +A **Command Lance is not a command layer** - it is a formation type with +composition requirements that also fights, and the commander is a unit inside it. + +See [HIERARCHY.md](../docs/HIERARCHY.md) for the full vocabulary. + +- [ ] **`force_path` is never populated.** The wire has carried it since the + protocol was written - "outermost first: company, then lance" - and it is + empty in every scenario we generate, so the hierarchy has exactly one level + in use. Everything about coordination is untestable until a scenario + declares forces and MegaMek reports them +- [ ] `from_above` exists on `ForceThinker::command` and carries nothing, for the + same reason +- [ ] The coordinator assigns **relations** between forces - who screens whom, + who spots for whom - and each force chooses its own tactic within them. + Relational assignment is the only part that must be joint +- [ ] `commander` is on MegaMek's entity and not on our wire. It is where a + force's personality attaches, and in a campaign it is a mortal pilot rather + than a configuration value diff --git a/plan/objectives.md b/plan/objectives.md new file mode 100644 index 0000000..1ebcd40 --- /dev/null +++ b/plan/objectives.md @@ -0,0 +1,56 @@ +--- +id: objectives +title: What winning means, and what to do when it stops being available +status: blocked +dependsOn: [tactics] +exitCriterion: > + A losing force switches to preserving what it has, does so on a stated + threshold rather than a guess, and does not oscillate across that threshold. +--- + +# objectives + +An objective is what the side is currently trying to do. It is the **utility +function**, not a constraint list: under `WinOnBattleValue` a withdrawal is +terrible, and under `PreserveByRouting` an advance is. The same tactics are legal +either way; what changes is what they are worth. + +That distinction is load-bearing. A `TaskOverride` removes options from a set +after they are scored. An objective changes the scoring. Collapsing the two +loses the difference between "forbidden" and "bad". + +Absent objectives, `Engage` is the whole game and half the tactic vocabulary is +unreachable - which is the state today. + +## Two to start + +- `WinOnBattleValue` - the current victory rule. Makes `Retreat` unreachable, + `Reposition` nearly so, and `Screen` unavailable to a side with only one force. +- `PreserveByRouting` - reached when the match is lost. Admits `Retreat`, + `Reposition`, `Screen` above one force, and `Break`, which is how a + disengagement survives: losing line of sight is what buys the turn in which + withdrawing is not fatal. + +`Break` sits in both sets, which is right - breaking contact serves winning and +losing equally. + +- [ ] The transition is a **threshold**, not a judgement: remaining battle value, + or a count of crippled units. A threshold can be stated, tested and shown + to a player; a learned judgement cannot yet +- [ ] Verify it does not oscillate. Flip at 30% and check the behaviour at 31% + is stable. This is the cheapest available test of the hysteresis in + `plan/tactics.md`, and losing is easy to produce on demand +- [ ] An objective may also **bias** rather than only constrain, including by + which side of the scenario we are on - an attacker and a defender have + different utility, not different legal moves + +## Scenario-derived objectives, later + +MegaMek already carries this. MMS V2 has per-player `victory:` conditions and +composable triggers, and `UnitPositionTrigger` is literally an extraction +objective. None of our maps declare one yet, so the objective layer cannot be +exercised beyond the two above. + +- [ ] Read objectives from the scenario rather than inventing a vocabulary +- [ ] Objectives decompose by **assignment** - first lance screens while second + exits - which is the coordinator's relational assignment, not a blend diff --git a/plan/order.txt b/plan/order.txt index 2998c47..0875271 100644 --- a/plan/order.txt +++ b/plan/order.txt @@ -12,9 +12,10 @@ hierarchy ev los features -strategies +scoring +tactics beliefs -goals +objectives doctrine capability difficulty diff --git a/plan/protocol.md b/plan/protocol.md index 1c45aeb..59689a1 100644 --- a/plan/protocol.md +++ b/plan/protocol.md @@ -14,12 +14,12 @@ 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. - [x] **Per-location armour and structure** - blocks breach state, so it blocks - punch-versus-sandblast allocation and [cripple](strategies.md). On the + punch-versus-sandblast allocation and [cripple](tactics.md). On the wire as `locations`, with rear armour on the three locations that have it - [x] **Per-location contents** - armour says how hard a location is to break, not what breaking it costs the target. A weapon count and their damage, live ammunition and whether CASE contains it, engine, gyro and cockpit - slots, and an actuator count. One shared addition: [cripple](strategies.md) + slots, and an actuator count. One shared addition: [cripple](tactics.md) needs it for arc-aligned targeting, and a mission kill needs it before "all its guns are gone" can mean anything. Deliberately a summary and not a critical slot listing @@ -94,7 +94,7 @@ Adding a field is deliberate, with a reason in the commit message. change LOS. The board is sent once and never updated, so [los](los.md) would drift correctly-implemented and wrong - [ ] **Scenario objectives** - MMS V2 carries per-player `victory:` conditions - and composable triggers. Blocks [goals](goals.md) + and composable triggers. Blocks [objectives](objectives.md) - [ ] **Minefields** - the bot is blind to them - [ ] **Buildings as objects** - a building spans hexes and has an identity, and the board message sends only per-hex terrain. Every terrain attribute diff --git a/plan/scenarios.md b/plan/scenarios.md index 843fa63..d891b67 100644 --- a/plan/scenarios.md +++ b/plan/scenarios.md @@ -34,7 +34,7 @@ Forces come from helm, which already indexes year, BV, role and tonnage. the check has nothing left to reject. Epoch 3 - [x] Asymmetric scenarios: `sds scenarios --asymmetric` draws the two sides independently. See below -- [ ] Objective scenarios, which [goals](goals.md) needs +- [ ] Objective scenarios, which [objectives](objectives.md) needs City maps are excluded: the bot has no notion of buildings, so a city fight measures its blindness. diff --git a/plan/scoring.md b/plan/scoring.md new file mode 100644 index 0000000..e9ae99c --- /dev/null +++ b/plan/scoring.md @@ -0,0 +1,84 @@ +--- +id: scoring +title: One basis, not two +status: open +dependsOn: [features] +exitCriterion: > + Every score the bot computes comes from the named feature basis. `Intent`, + `Stance`, `Appraisal` and the six-axis `asks` table are deleted, and no weight + the bot uses was written by hand. +--- + +# scoring + +The bot has **two scoring systems** and only one of them is trained. + +The first is the 51-feature basis: named, normalised, documented in +[FEATURES.md](../docs/FEATURES.md), fitted from recorded play. It scores +candidate stands and volleys. + +The second is a parallel arithmetic nobody fitted. `unit.rs` scores seven intents +from hand-written expressions over `projected_damage`; `surface.rs` maps each +intent to a damage weight and six bespoke axes - `take, flank, blind, range, +band, heat` - and *those* rank the candidates. The 51 features are not consulted. + +This epic deletes the second one. + +## What the hand-written arithmetic gets wrong + +Each of these is measured, and each was found from a different direction: + +- **Defence is discounted by a hard-coded half.** `Intent::Hold` scores + `dealt_now - projected_damage(enemy) * 0.5`, and `asks(Close)` prices the + incoming-fire axis at `-0.10` with the comment "the incoming barely priced". + The fitted weights independently land at 1.69:1 favouring closing, and the bot + takes a quarter of its shots at one hex. +- **Intents are denominated inconsistently.** Five are in damage points and read + in the tens; `Screen` is `+/-1` and `Withdraw` is `+/-2` to `+/-4`. Each wins + 0.2% of decisions - not rarely chosen, arithmetically unable to win. +- **Units vote in proportion to their firepower.** `combine` sums raw appraisals, + so a unit projecting 40 points outvotes one projecting 1 by forty to one. +- **Two quantities answer one question.** `projected_damage` and + `expected_damage` both mean "what we would land", computed differently, used by + the two systems respectively. + +## The work + +- [ ] Express a tactic as a weighting over the **named basis**, not over six + bespoke axes. That makes tactics fittable rather than authored, and it is + what lets a personality be a weight vector rather than a table somebody + typed +- [ ] Delete `Intent`, `Stance`, `Appraisal`, `Commitment` and `asks`. The + transition model in `plan/tactics.md` replaces what `Commitment` did +- [ ] Retire `projected_damage` in favour of `expected_damage`, or say why two + are needed +- [ ] **`Screen` has no feature to score itself by.** Nothing in the basis + measures standing between two things; `cover_distance` and `friend_support` + are the nearest and neither is it. This is why the old `Intent::Screen` + decayed into a health check +- [ ] Commensurable denominators for the two halves of an exchange. + `expected_damage` is a share of our maximum output and `incoming_damage` a + share of our remaining health, so their difference is not an exchange ratio + - see `plan/matchup.md` + +## Orphaned feature clusters + +Clustering all 51 against the ten tactics leaves four families that no tactic is +about, and they are all **resource and method** rather than geometry: + +| cluster | features | note | +|---|---|---| +| heat | 5 | a *set point implied by* a tactic, not a tactic. `Break` cools, `Entrench` tolerates | +| ammunition | 1 | same shape as heat | +| damage kind | `p_kill`, `p_mission_kill`, `p_psr_threshold`, `p_breach` | legging a fast unit and killing a slow one are the same points to opposite purpose | +| target choice | the `target_*` family | finish the wounded, kill the dangerous, kill the cheap | + +The covered clusters are all geometry - range, arc, cover, visibility, cohesion, +position. That the bias runs this way probably reflects how the basis grew rather +than what matters, and it is the same finding as "offence is priced exactly and +defence is not priced at all" seen from the feature side. + +- [ ] Decide whether damage-kind and target-choice want a **firing solution + selector** at the force - one joint choice given final movements - rather + than a pre-declared tactic. Fire allocation has complete information after + movement, so declaring it in advance can only lose diff --git a/plan/strategies.md b/plan/strategies.md deleted file mode 100644 index 531a551..0000000 --- a/plan/strategies.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -id: strategies -title: Strategy as a blend of features, held with grit -status: blocked -dependsOn: [features] -exitCriterion: > - A force's stance is a simplex over named strategies, blending composes, and - an SVD of M shows the vocabulary is not redundant. ---- - -# strategies - -`w = S^T M`. `S` is what the force is doing, `M` maps strategies to feature -weights, and scoring is `Phi w`. Blending two strategies gives a weighting that -is genuinely half of each, which an enum cannot do. - -Commitment is one parameter. `grit` sets both the margin a rival must beat and -the drift rate once it moves - facets of stubbornness, and splitting them invites -tuning them against each other. Already built and tested against `Intent`; it -carries over unchanged. - -- [ ] Rows of `M`, **three to start**: `direct_engagement`, `cripple`, - `fighting_withdrawal`. One per objective we can actually fit -- [ ] Port `Commitment`/`Grit` from the intent simplex to the strategy simplex -- [ ] Force-level assignment: greedy per-unit argmax, then swaps that improve the - force-level features. Crossfire is not expressible per unit -- [ ] **SVD check on `M`.** If effective rank collapses, the named strategies are - theatre - that is Princess's disease measured rather than read -- [ ] Composition operator as a hyperparameter: the power mean spans min, - geometric and arithmetic in one `p` - -## As many strategies as there are objectives, and no more - -A row of `M` is a weight vector. A weight vector comes from a fit. A fit needs a -label. So if ten rows are fitted from two labels, `M` has effective rank two and -eight of the names are decoration - blending `flanking` with `gun_line` would -produce something that is not between them, because they are not independent -directions. - -This epic already carries the test that catches it: "**SVD check on `M`.** If -effective rank collapses, the named strategies are theatre." Declaring ten rows -today would fail that check, and would fail it because of the label count rather -than anything about the strategies. - -So the rule: **a strategy is named when it has an objective to be fitted -against.** Three to start, matching the labels being built: - -| strategy | objective | what it should look like | -|---|---|---| -| `direct_engagement` | damage inflicted | efficient bracketing - get to the range band where our weapons work and trade at the best rate | -| `cripple` | BV loss inflicted | take what makes them dangerous: legs, guns, the locations already open | -| `fighting_withdrawal` | damage taken, negated | keep what is left and make them pay for the ground | - -These three are behaviourally distinct rather than three names for aggression, -which is the point. `direct_engagement` and `cripple` diverge exactly where -damage and battle value diverge: chipping armour is damage and barely moves BV, -while a destroyed gun is little damage and a large BV drop. - -`cripple` is the one that needs features we do not have. Hit location is rolled, -not chosen, so "go for the legs" is expressed through kicks, through preferring -targets whose locations are already breached, and through called shots where -the rules allow them. That makes `cripple` wait on the melee features, and on -`target_breach` being crossed with location rather than taken whole. - -### Waiting on a label - -`destroy_weakest` (kills secured), `gun_line`, `flanking`, `crossfire`, -`screen`, `overrun`. Each is a real thing a force does; none has a number to be -fitted against yet. `crossfire` additionally cannot be expressed per unit, so it -needs a force-level label. - -### Waiting on more than a label - -`bait` is not blocked on an objective, it is blocked on [beliefs](beliefs.md). -Baiting means acting on what the opponent will do in response, and nothing in -the bot models an opponent's future choice. Naming it before that exists would -be naming a behaviour we cannot compute. diff --git a/plan/tactics.md b/plan/tactics.md new file mode 100644 index 0000000..d915250 --- /dev/null +++ b/plan/tactics.md @@ -0,0 +1,127 @@ +--- +id: tactics +title: A force is doing one nameable thing, and can be told to do another +status: blocked +dependsOn: [scoring, hierarchy] +exitCriterion: > + Every force carries one of the named tactics each round, a human can read why + it changed, and a run where the tactic layer is disabled loses to one where it + is enabled by an interval that excludes zero. +--- + +# tactics + +A tactic is what one force is doing: `Engage`, `Flank`, `Entrench`. One per +force, chosen at the start of a turn, changed mid-turn only by a forcing event. +It biases movement, firing and physical scoring; it never picks a hex. + +See [HIERARCHY.md](../docs/HIERARCHY.md) for the layer it sits in and the list. + +**What it replaces.** Today a force holds a *stance* - a weight over seven +intents scored by hand-written arithmetic on `projected_damage`, summed across +units without normalisation. Three defects follow and all three are measured: +`Intent::Hold` discounts incoming damage by a hard-coded 0.5; `Screen` and +`Withdraw` are denominated in units two orders of magnitude smaller than the +intents they compete with, so they win 0.2% of the time each and are effectively +dead; and one stance broadcasts one instruction to every unit, which cannot +express a plan whose point is that units do different things. + +## The vocabulary + +Ten tactics, listed in [HIERARCHY.md](../docs/HIERARCHY.md). + +**How to tell whether an eleventh is a new direction or an old one renamed.** +Not "they picked the same hex" - two tactics coinciding on one round means +nothing, because some hexes are simply better and similar tactics *should* agree +often. The question is whether they **always** agree. + +That is measurable offline and needs no new matches. A decision log carries every +candidate with its `phi`, so a tactic expressed as a weighting over the basis can +be re-scored against a recorded corpus: for each decision, what would this tactic +have chosen? Then correlate the choices pairwise across tactics. + +Same shape as `sds labels`, and it should carry the same discipline: report max +agreement against every other tactic, band it - under about 0.4 is a new +direction, over about 0.8 is mostly a rename - and *run it against two corpora +before retiring anything*, because an agreement measured on one corpus is a +property of that corpus until a second one confirms it. + +Expect real correlation between neighbours. `Advance` and `Engage` share most of +their situations; `Break` and `Retreat` both move away. The signal is a pair that +never diverges anywhere. + +- [ ] `sds tactics `: re-score every logged decision under each tactic + and report the pairwise agreement matrix +- [ ] Candidates already cut on reasoning rather than measurement, to re-test + once the tool exists: `Delay` needs a time value we do not model, + `Standoff` looked like `Engage` when we outrange and `Retreat` when we do + not, and `Destroy` looked like `Advance` at an enemy until an objective + makes a kill worth more than its battle value + +- [ ] The `Tactic` trait, and an enum wrapper so a force can hold a + heterogeneous list and compare it uniformly +- [ ] Target polymorphism at the type level: `Screen` cannot be constructed + against an enemy, `Advance` cannot be constructed against a friend. Separate + traits per target arity behind one interface, so it is a compile error + rather than a runtime check +- [ ] Names may not collide with a unit Role or a Formation. `Skirmish` is out - + `Skirmisher` is a Role. So are `Probe`, `Sweep`, `Recon` and `Support` +- [ ] The blocked tactics present as commented-out variants, so the shape is + visible before the epics that unblock them land + +## Choosing one + +Targeted tactics choose their target **with** the tactic and are ranked with it - +four things worth screening are four candidates, not one tactic and a later +lookup. Untargeted ones may resolve a target later: `Bombard` hits whatever is +spotted. + +That keeps the search small. A force facing eight enemies has on the order of a +hundred options, which is an order of magnitude *less* than one unit's firing +decision - measured over the corpus at a median of 47 candidates, p90 263, max +2052. Tactic selection is not where the time goes. + +What must not happen is enumerating every force's tactic jointly. The coordinator +assigns **relations** - who screens whom, who fixes while another flanks - and +each force then chooses its own tactic within those. Four forces give six pairs; +enumerating four independent choices of a hundred gives 10^8. + +- [ ] Per-force choice within a coordinator-assigned constraint +- [ ] Relational assignment at the coordinator, for the pairs only +- [ ] Some tactics imply a distribution of work *inside* a force rather than one + order to everybody. A lance cannot all flank: most of it holds attention + while one unit takes the arc. Decide whether that is a pattern of + `TaskOverride`s or something the tactic expresses directly - this is open + +## Changing one + +A small state space with a cost to moving, which is what a stance's `grit` was +reaching for and what a transition model does properly. + +- [ ] Legal and illegal transitions. `Retreat -> Advance` without a forcing event + is the flap that must be expensive: a force that routs, recovers nerve and + charges back into the guns it fled +- [ ] **Elective** transitions pay the switching cost. **Forced** ones do not, and + have three callers: a target that no longer exists, a scenario condition, + and a GM. Hysteresis that applied to a forced transition would strand a + force whose target just died +- [ ] Transition propensities are a small fittable table - a few features per + transition rather than a weight vector per tactic - which is learnable from + hundreds of matches rather than tens of thousands +- [ ] A tactic can be **self-committing**: a force that manoeuvred into a + hull-down indirect-fire position and lost its spotter is worse off than the + switching cost alone says, because the position it chose has no fallback. + Price that separately from the flip cost + +## Preconditions + +Some tactics do nothing unless something else is arranged. `Bombard` without a +spotter is the clear case - and the fallback to direct fire is often unavailable +*because* the tactic put the force somewhere unseen. + +- [ ] Preconditions in the trait, checked at assignment and monitored during + execution. Failure is a forced transition, which shares a path with target + loss +- [ ] Distinguish **hard** preconditions, which must be guaranteed, from **soft** + ones, which are only value and belong in scoring. `Flank` without something + fixing them still works, just worse diff --git a/plan/training.md b/plan/training.md index da1f34d..64204a0 100644 --- a/plan/training.md +++ b/plan/training.md @@ -197,7 +197,7 @@ neither side traded scores zero in every one of them. ### Why this many -Not a fishing expedition. `plan/strategies.md` names a strategy only when it has +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 diff --git a/weights/fitted-r11.json b/weights/fitted-r11.json new file mode 100644 index 0000000..9cf005e --- /dev/null +++ b/weights/fitted-r11.json @@ -0,0 +1,79 @@ +{ + "basis": "4a963bc90ee9", + "commit": "1d6bb72-dirty", + "epoch": 4, + "fit": { + "agreement": 0.505795574288725, + "credit": "ratio", + "decisions": 18980, + "excluded_dead": [], + "excluded_local": [ + "damage_lead" + ], + "excluded_thin": { + "defense_6plus": 0.0029152468049227706 + }, + "framing": "difference", + "gamma": 0.93, + "imitation_rows": 0, + "label": "inflicted_bv_per_round", + "matches": 300, + "r_squared": 0.25177197083940683, + "ridge": 0.001, + "rows": 4989629, + "tier": "team" + }, + "recorded": "2026-08-26T12:19:23Z", + "version": 1, + "weights": { + "ammo_spent": -0.005583938916848326, + "arc_spread": -0.041490291613012126, + "cohesion": -0.009868670870972785, + "concentration": 0.02022127772291372, + "cover_distance": 0.0017774731024245956, + "cover_quality": 0.003373572880928168, + "defense_1plus": 0.0050835906547254785, + "defense_2plus": 0.008607256110706996, + "defense_3plus": 0.011688985864039785, + "defense_4plus": 0.009114284761196571, + "defense_5plus": 0.002916497094067679, + "deploy_ground": 0.19515634709145324, + "deploy_outlook": 0.042561089331107645, + "edge_distance": 0.006407268220688474, + "elevation_gain": 0.009478807745659878, + "enemy_centroid": -0.03757791173545799, + "enemy_nearest": 0.008169262324644886, + "expected_damage": 0.0868457633280001, + "friend_support": -0.0017598950514153594, + "heat_ammo_explosion_risk": 0.009666918204454963, + "heat_incurred": -0.0010975732895659937, + "heat_mp_penalty": 0.014695829065605773, + "heat_shutdown_risk": -0.002301747354170842, + "heat_to_hit_penalty": -0.04414237859180607, + "incoming_damage": -0.033359258713535324, + "level_terrain": 0.0020237858343682587, + "level_tmm": -0.02868158004198141, + "los_in": -0.03664959542283217, + "los_out": 0.03762021772135282, + "overkill": -0.05117732934229372, + "overlook": 0.0049983993437264845, + "p_breach": -0.0028073621032410757, + "p_kill": 0.051868493725396765, + "p_mission_kill": 0.01375460875467852, + "p_psr_threshold": 0.026822935429581307, + "percent_options": 0.0024515726845411186, + "range_band_fit": 0.009862662171576714, + "range_spread": 0.023525725766304923, + "rear_arc_exposure": -0.003428660264005039, + "rear_arc_gain": -0.0008191071591288034, + "submerged": -0.00865594771519558, + "target_breach": 0.00834439224425374, + "target_current_bv": 0.042813401891670544, + "target_gunnery": 0.019485511402631727, + "target_health": -0.03830512354548032, + "target_original_bv": 0.003992044711032523, + "target_piloting": -0.0016147482852539314, + "target_tonnage": -0.013093796687275725, + "value_destroyed": 0.01627238956736042 + } +} diff --git a/weights/fitted-r12-nodisp.json b/weights/fitted-r12-nodisp.json new file mode 100644 index 0000000..7b18d02 --- /dev/null +++ b/weights/fitted-r12-nodisp.json @@ -0,0 +1,79 @@ +{ + "basis": "4a963bc90ee9", + "commit": "44a2fb1-dirty", + "epoch": 4, + "fit": { + "agreement": 0.44325605900948367, + "credit": "ratio", + "decisions": 15259, + "excluded_dead": [], + "excluded_local": [ + "damage_lead" + ], + "excluded_thin": { + "defense_6plus": 0.001895964796321773 + }, + "framing": "difference", + "gamma": 0.93, + "imitation_rows": 0, + "label": "inflicted_bv_per_round", + "matches": 300, + "r_squared": 0.23093241010648735, + "ridge": 0.001, + "rows": 3623485, + "tier": "team" + }, + "recorded": "2026-08-27T16:15:21Z", + "version": 1, + "weights": { + "ammo_spent": -0.0040440916108910865, + "arc_spread": -0.042187416472592006, + "cohesion": -0.01650755161506821, + "concentration": 0.019974712453658432, + "cover_distance": 0.0011736029696675463, + "cover_quality": 0.009434758161851636, + "defense_1plus": 0.023363419384688573, + "defense_2plus": 0.023414112091617752, + "defense_3plus": 0.0235496591587149, + "defense_4plus": 0.020789287808407175, + "defense_5plus": 0.017656144070919522, + "deploy_ground": 0.1929808397950484, + "deploy_outlook": 0.04200026896495738, + "edge_distance": 0.004811751704553361, + "elevation_gain": 0.0017929428505074332, + "enemy_centroid": -0.008058343095501592, + "enemy_nearest": -0.007966114971400996, + "expected_damage": 0.06362481673739745, + "friend_support": -0.0021540862220886005, + "heat_ammo_explosion_risk": 0.015941394457049785, + "heat_incurred": 0.010725158379188792, + "heat_mp_penalty": 0.019405980157209044, + "heat_shutdown_risk": 0.004994582290725599, + "heat_to_hit_penalty": -0.0656866691643483, + "incoming_damage": -0.07199198543012904, + "level_terrain": -0.03953732240882364, + "level_tmm": -0.08972856058036631, + "los_in": -0.03649871483661407, + "los_out": 0.03168369651119106, + "overkill": -0.04439591088755461, + "overlook": 0.004533115858180404, + "p_breach": -0.0006556012245436022, + "p_kill": 0.04663591851545215, + "p_mission_kill": 0.00472071565708572, + "p_psr_threshold": 0.03294790256926541, + "percent_options": 0.002678445975289285, + "range_band_fit": 0.015110783087060538, + "range_spread": 0.01827705972896492, + "rear_arc_exposure": 0.006089577978090319, + "rear_arc_gain": -0.0015940991529187177, + "submerged": -0.007257306204500381, + "target_breach": 0.010599414295630352, + "target_current_bv": 0.03934925565011759, + "target_gunnery": 0.018812938995083548, + "target_health": -0.03611410119183127, + "target_original_bv": 0.004277682136240265, + "target_piloting": -0.0020798525730568013, + "target_tonnage": -0.010642799643238048, + "value_destroyed": 0.0027998064280556787 + } +}