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 named feature basis: normalised, documented in 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 named 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::Holdscoresdealt_now - projected_damage(enemy) * 0.5, andasks(Close)prices the incoming-fire axis at-0.10with 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;
Screenis+/-1andWithdrawis+/-2to+/-4. Each wins 0.2% of decisions - not rarely chosen, arithmetically unable to win. - Units vote in proportion to their firepower.
combinesums raw appraisals, so a unit projecting 40 points outvotes one projecting 1 by forty to one. - Two quantities answer one question.
projected_damageandexpected_damageboth mean "what we would land", computed differently, used by the two systems respectively.
The work #
-
So it is one feature rather than two holes: a bounded distance from this hex to a coordinate supplied with the decision, which `Reposition` points at its destination and `Screen` points at the interposing hex between the friend it covers and the enemy nearest that friend. It unblocks `Seize` and `Deny` in `plan/objectives.md` at the same time, since a map objective is exactly a caller-named hex, and it is the smallest thing that closes four gaps. **The parameter travels in the observation.** `Posture` carries an `objective: Option<Coord>`, so every feature is still a function of the observation alone and a recorded decision holds the hex it was judged against - re-scoring it later cannot reach for a coordinate nobody wrote down, which is the replay property `sds tactics` depends on. The absent case is a **missing column, not a zero one**. `measure` takes a `positional::Objective`, which cannot be built without a hex, so the feature is simply not measured when no order named one. A candidate that was never asked about an objective has not answered badly about one, and the fit sees no row rather than a row of zeroes. **Nothing sets it yet**, so it is inert in this build and a corpus recorded today carries no column for it. It is the scoring half of four gaps; the ordering half belongs to `plan/tactics.md` and `plan/objectives.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.