The repo's first multi-opponent gun measurement. Adds tools/ab/gauntlet_run.sh
(per-opponent A/B over the legacy roster, subject = frozen ModularBot),
tools/ab/gauntlet_analyze.py (paired per-opponent deltas, cross-opponent sign
test, style split, MDE) and the arm/opponent fixtures.
Result: BitBrain does NOT generalize beyond DrussGT. 32 opponents x 2 arms x
3 runs x 5 rounds = 192 battles / 960 rounds, 0 failed, 0 retries: damage/run
214.5 (pattern) vs 210.9 (bb), sign-flip p=0.53; round wins 237/480 vs 239/480,
p=0.91. Sign test: bb better on 13/32 opponents (damage). The DrussGT-only
penalty does not carry. The owner's 'killer vs regular movers' sub-claim is not
supported: regular bucket +1.3 dmg/run vs dodgers -0.2 (MW p=0.85), and the
measured movement predictability does not correlate with the delta.
Adds common_libs/tests/measure_melee_bitbrain_ab.nim (+ .sh driver, .py analyzer,
committed per-run fixtures) and docs/melee_bitbrain_ab.md.
Experiment: 4-bot Free-For-All (ModularBot + WaveSurfer + PatternMover +
RandomMover), 4 arms x 16 runs x 7 rounds, frozen ModularBot from git archive
HEAD (commit 0f5cfe3, binary 11bba27), shipped tfil movement in every run.
Arms differ only in the gun rack: pattern (shipped), bb_round, bb_ret, bb_learn.
Result: NOT DETECTABLE. Score (server round score = damage + survival bonus)
differs by -63..+33 pts (perm p=0.16-0.71) against an MDE of 151 (~5.1%).
Every arm finishes rank 1. Round wins hint BitBrain's way (112/112 and 111/112
vs 109/112) but p=0.225 (MW 0.080), half the 0.40-win MDE.
Liveness proven: rack boot lines flip (rack active melee = PATTERN / BITBRAIN),
every run faced 3 distinct targets and ~66-69 target changes, and the bb arms
logged one [bb-reset] reason=target_change per switch. The melee premise was
exercised; the fast adaptation bought no measurable score edge at this sample.
common_libs/movements/wave_surfer.nim was written in an early session and
never wired to the bot. This connects it exactly like tfil/strafe and fixes
the defects a full read found:
1. the dodge direction was INVERTED: the perpendicular was built from the
bot->enemy bearing while GF lives in the enemy->bot frame, so the bot
moved toward MORE danger. Now built from the wave's origin->bot bearing:
+90 provably increases GF.
2. the danger histogram was never reset (resetRound cleared waves only), so
it was a battle-long static average. Now reset to the uniform prior each
round.
3. fire detection tracked only the current target's energy via one scalar;
now per-enemy (seq[(id,energy)]) so melee target switches cannot invent
or hide waves.
4. the wall penalty projected a point from the wave origin, not from the
bot, making the wall test meaningless. Now projects the bot->candidate
direction.
Wave speed uses the actual firepower (the one-tick energy drop IS the
firepower, so speed = 20 - 3*drop is exact). Adds TR_SURF_* knobs and
registers them in the env report. Shipped TR_MOVEMENT=tfil default untouched
(test_tfil_commit_env: 30/30 pass; test_env_report: pass).
4 arms x 15 runs x 7 rounds (60 battles, 0 failed) vs real DrussGT on the
shipped TFIL default, frozen at ed25ce2. bb_id (gain 1.0 identity) is
statistically indistinguishable from shipped Pattern -> plumbing validity
check passes. No BitBrain arm beats TMHorizon or Pattern: bb_learn (the config
the owner likely ran) is the worst arm (276 dmg/run, 39/105 wins), the only
comparison at alpha=0.05 is Pattern beating it on damage. Learned gains
(>=1.0, gated >=300px) over-lead and lose 1.61pp of hit rate at 300-450px.
MDE 29.5 dmg/run, 1.235 wins/run; a 6-4-sized effect needs ~39 runs/arm.
Task j112. Two changes to the TR_MOVEMENT=strafe engine, both OFF the shipped
tfil path; the binary default is still tfil.
CURVED WINGS: the candidate set was the straight 1-D line through the bot, which
a bounded segment always terminates at a wall. It is now an adaptive parabola
with the vertex on the bot:
point(y) = bot + yhat*y + xhat*kappa(y)*y^2
xhat is the unit vector away from the nearest wall(s) (summed inward normals, so
a corner yields the diagonal). kappa grows as the wall approaches and saturates
at TR_STRAFE_KAPPA; every wing point is clamped inside TR_STRAFE_WALL_SAFE, so
the wing FLATTENS and runs parallel to the wall instead of touching it. In open
space kappa == 0 and the wing is exactly the old straight line. The wing chord
at the reach tilts the heading band toward the interior by atan(kappa*reach)
(capped by TR_STRAFE_WING_MAX); the body still only turns slowly to follow that
tangent, never to face the target, and reversals are still setForward sign flips.
GUARANTEED ESCAPE: with every candidate over threshold the old fallback minimised
pathMaxHeat, whose gradient points AT the wall (the shortest path has the least
wall exposure), so the least-hot tile was the adjacent one and led further along
the wall. Near a wall the picker now ranks by the DESTINATION (farthest from the
wall, then coolest tile) and commands the sign whose velocity has a positive
component along the wall-away normal. That sign is re-asserted EVERY tick, so
speed*heading . away >= 0 while escape is active: the clearance cannot fall.
mode=escape reaches the [strafe] log. A mild wall-margin bias
(TR_STRAFE_WALL_BIAS) prefers higher-clearance tiles when near a wall.
GUI/log: the curved wings are drawn as an orange polyline (candidates follow the
curve), a white ray + ESCAPE label marks the escape, and the [strafe] line now
carries wall=<dist> kappa=<..> mode=<pick|fallback|escape|radial>.
Gates (offline, kinematic replay of the DrussGT fixtures; see
common_libs/tests/measure_strafe_wings.nim, plus the reused j108/j111 gates):
wall occupancy (within 54 px) falls 25.6->4.3 / 18.3->4.1 / 23.8->4.3 / 27.8->4.7
percent and the longest continuous wall run 191->31 / 49->25 / 208->47 / 246->27
ticks; corner-region occupancy 4.7->0.0 percent with the longest corner run
71->7. The escape sweep (3520 start x heading x enemy runs, 110k escape ticks)
shows ZERO per-tick guarantee violations and a worst corner run of 21 ticks.
Open-space parity is bit-identical (kappa == 0), reversals are still sign flips
(0 non-sign commands), and mean turn/speed are unchanged (OFF 4.42 deg/tick,
31.3 percent no-turn vs ON 4.45 / 30.7; reversal-interval entropy 5.309 -> 5.311
bits). The fixtures are OPEN-LOOP, so these are veto-capable checks, not a live
win claim.
Task j111. Two changes to the TR_MOVEMENT=strafe engine, both OFF the shipped
tfil path; the binary default is still tfil.
RANGE CONTROL (a hypothesis under test, no default changed elsewhere):
the body is still pinned ~perpendicular to the threat, but the line is tilted
by the range error: lineAngle = threat + 90 + appliedTilt, with the tilt zero
inside +/-TR_STRAFE_RANGE_TOL around TR_STRAFE_RANGE (200 px, chosen because it
is exactly TR_POWER_FAR_DIST) and clamped to +/-TR_STRAFE_TILT_MAX. A tilt alone
cannot change range (the picker chooses both ends at random), so the picker also
PREFERS the end that reduces |distance - target| with a probability that grows
with |tilt|; both ends stay possible. The tilt sign is aligned to the ENEMY
bearing, since is the bullet direction (roughly its opposite) when a
bullet is in flight. Knobs: TR_STRAFE_RANGE (200), TR_STRAFE_RANGE_TOL (25),
TR_STRAFE_TILT_MAX (15), TR_STRAFE_TILT_GAIN (0.10), all registered in
env_report.nim (emit + knownEnvNames). NOT claimed to be better: j107 measured
that drifting 25-30 px closer made damage/run and wins WORSE.
CORNER STALL (a real defect): a line whose in-arena candidate set was empty set
targetValid=false and kept driving on the last sign, so the bot could oscillate
inside a corner tile forever. Three defenses: (1) a deterministic corner guard
projects the outward component off the line whenever BOTH ends are outside, so
the line becomes wall-parallel and a candidate always exists; (2) a degenerate
line (<=1 candidate) falls back to a radial search for the coolest in-arena
tile and commits the sign; (3) a commanded move with no displacement for
StuckFlipTicks (5) ticks flips the sign. Both warnings now reach the [strafe]
log.
GUI/log: the tilt is drawn as the existing strafe line (it is lineForward), plus
a green/red ray toward the enemy (length = |distance-target|) and white text
d=.. tgt=.. tilt=..; a red disc marks a stuck tick. The existing overlays and
the j110 heat grid are unchanged.
Gates (offline, kinematic replay of the DrussGT fixtures; see
common_libs/tests/measure_strafe_range_stall.nim): on the j110 field all four
corners that were 100% confined inside 72 px / 22.6 px max before now escape
(<=2.6% confined, 209-741 px); the achieved |distance-200| falls on 3 of 4
fixtures (mean -16% to -30%); mean |turnRate| and the 8.00 px/tick speed are
essentially unchanged (no-turn property survives). Reversal-interval entropy
falls 5.85 -> 5.31 bits (still above TFIL's 5.09): the range bias costs some
reversal randomness while closing.
Three defects the owner hit as "no heat tiles anymore" under TR_MOVEMENT=strafe.
1. The strafe overlay drew ONLY the tiles on its strafe line, so the computed
heat field was essentially invisible. It now draws the WHOLE field exactly as
TFIL does (every non-zero tile, yellow->orange->red ramp by field max, integer
value label) behind the same debugGraphics flag, with TR_STRAFE_HEAT_GRID=0 to
hide it. The strafe overlays draw on top, unchanged.
2. STRAFE carried the SHIPPED bullet constants (core 10 / aura 5), so a bullet's
own heat sat exactly ON PathDangerThreshold (10.0) and a bullet was never
dangerous on its own in this mover; it only ever bit through its corridor.
Defaults are now the retune's 20/10, exposed as TR_STRAFE_BULLET_CORE /
TR_STRAFE_BULLET_AURA.
3. The ring mover's header documented CorridorHeat 5.0 / WallHotness 10.0 while
the code has always been 10.0 / 15.0. A job read the comment and handed out
sub-threshold heat values, which emptied the field. The comment now states the
real values and their actual behaviour; no code values changed.
Also sets strafe's heat defaults to the retune shape (bullet 20/10, corridor 10,
wall 15/5, pillar 0), documented with the reason.
Gate A re-run (j110, offline DrussGT fixture, measure_strafe_gates.nim):
corrected DEFAULT : 24.6% of picks with ZERO safe tile, mean 11.17 safe
j108 shipped field: 63.4% / 3.70 (reproduced exactly)
j108 ring retune : 8.1% / 18.41 (reproduced exactly)
bullet isolated : 11.4% / 17.07
The corrected default beats the shipped field but is WORSE than j108's retune
row: the bullet retune alone costs 8.1 -> 11.4, the corridor/wall retune accounts
for the rest. That is the deliberate price of making a bullet dangerous.
Guards green: test_env_report 24 PASS, test_tfil_commit_env 30 PASS (shipped TFIL
default untouched, byte-for-byte), test_tfil_ring_weights 24 PASS. The three new
knobs are registered in the boot env report so the tree-scan guard stays clean.
New engine movements/strafe.nim, selected by TR_MOVEMENT=strafe (default stays
tfil, byte-identical — test_tfil_commit_env.nim's 30 checks still pass).
Design (the owner's):
- AXIS = incoming bullet's direction when a bullet is in flight, else the
perpendicular of the enemy bearing. The body heading is kept inside a band
(TR_STRAFE_BAND, default 20 deg) around the perpendicular LINE; it turns only
when outside the band, and never turns to face a movement target.
- Candidate tiles on the perpendicular line through our position, both forward
and backward, within TR_STRAFE_REACH px, with a perpendicular jitter of
+/- TR_STRAFE_SPREAD tiles. A tile is acceptable when its path max heat is
<= PathDangerThreshold, the SAME safety rule TFIL uses.
- Move by SIGN only: setForward(+/-MaxSpeed>). Dwell is re-picked after a random
number of ticks in [TR_STRAFE_DWELL_MIN, TR_STRAFE_DWELL_MAX], on arrival, or
on a serious threat spike.
- Heat machinery is REUSED from the shipped mover, not re-implemented: the
exported heatDecay()/bulletMagScale() (j105 time-indexed model) and the
PillarHotness/PillarRadiance globals (j106 pillar-free default). The heat
shape is overridable via TR_STRAFE_CORRIDOR_HEAT/WALL_HOTNESS/WALL_RADIANCE
(defaults = the shipped TFIL field).
- GUI overlay: strafe line, threat axis, candidate tiles (safe/unsafe), chosen
target, sign-coloured movement ray, and the heading band.
Gates (offline, recorded DrussGT fixture, 20026 ticks):
- A TILE AVAILABILITY: shipped heat field -> a safe tile exists on only 36.6%
of picks (63.4% fall back to the least-hot tile); the ring retune
(corridor 5, wall 10/5) raises it to 91.9%.
- B PREDICTABILITY: reversal-interval entropy 5.84 bits vs TFIL 5.09; direction
entropy 1.00 both; long-lag autocorrelation ~0 for both (no periodic
component). Fewer reversals (710 vs 1453) and more full-speed ticks.
measurements: common_libs/tests/measure_strafe_gates.nim
Also registers TR_STRAFE_* in the boot env report (ModularBot_garage/src/
env_report.nim) and wires the engine into ModularBot.nim (hold -> strafe,
ram trigger -> rammer).
Runs the pre-registered A/B for the two movement changes in HEAD: the
time-indexed bullet heat (TR_TFIL_HEAT_TIME, fca8993) and the removal of the
invented virtual centre pillar (d0750ab). One frozen binary from HEAD vs real
DrussGT: 6 arms x 10 runs x 7 rounds = 60 battles, 420 rounds, 0 failed.
Judged on damage/run and ROUND WINS only (hit rate and hits-taken are context):
hit rate would have inverted the verdict again - tau3 has the best pooled hit
rate of all arms (11.56%) and the fewest round wins (20/70).
RESULT (vs the reconstructed pre-change mover "old"):
heat-time HURTS. tau3/tau5/tau9 lose 1.3-1.7 wins/run (p=0.0010-0.0125) and
deal 22-38 less damage/run (p=0.004-0.047); tau15 is a wash on wins (p=0.64)
and 22 damage/run lower (p=0.046). Nothing improves either metric.
pillar removal does nothing measurable. old vs pillaoff: +5.7 damage/run
(p=0.71), +0.5 wins/run (35 vs 30, p=0.43), 30.8 MORE damage taken/run
without the pillar (p=0.040). The mechanism check proves the knob works
(centre-box occupancy 0.09% -> 2.37%, p<0.0001; range 469 -> 443 px,
p=0.0002), so this is a real behaviour change that buys nothing. At n=10 the
pillar contrast is inside the MDE (33 damage/run, 1.2 wins/run), so this is
not a proven regression.
Flags that the shipped default (pillar removed) should be reverted to the
TR_TFIL_PILLAR_ON behaviour; heat-time stays off.
Adds tools/ab/arms_heat_pillar.txt and tools/ab/ab_mechanism.py (per-tick
mechanism check: central-box occupancy, range distribution, live enemy-bullet
proximity) plus the captured summary/report fixtures.
Adds a per-sample intrinsic-confidence field (GunPrediction.confidence,
threaded through FeedbackEvent/VirtualBullet, populated by Pattern, DecayGF,
KNN, GuessFactor, Tsetlin, TMHorizon) and an offline recorder + analyzer that
reproduce the paper's Figure 2 per gun and its Eq-8 composite.
Measured on 3 held-out tr-bridge DrussGT battles (33k ticks, ~133k samples/gun):
- FAITHFUL: DecayGF (rho +0.133), KNN (+0.090), Pattern (+0.064, weak).
- GuessFactor is ANTI-faithful (rho -0.067); Tsetlin c_max is useless (0.001).
- No pair of guns specialises complementarily: the same gun dominates both
high-confidence slices in every pair.
- Eq-8 alpha-normalised confidence-weighted composite: 18.41% vs Pattern
20.45% (McNemar p=3.1e-126). Faithful-only variant 18.68%, still loses.
Shuffle control passes weakly (composite > shuffle, p=4e-14) so ~0.7pp of
competence is real but ~2pp short. Offline veto: design is dead.
See docs/tmcomposites_gate.md.
The default mover painted a 30/10 radiance blob on the arena centre even
though the arena has NO physical pillar there, creating a 4x4 tile
(144x144 px) exclusion zone over open centre floor. Set
PillarHotness/PillarRadiance to 0/0 in the shipped default (matching the
ring variant) and add TR_TFIL_PILLAR_ON=1 to restore the old 30/10 field
for A/B without a rebuild; registered in env_report.
Because the shipped default legitimately changed, the default-path parity
golden (fixtures/tfil_commit_default.golden) was regenerated from the NEW
default, with an explicit 'deliberate default change' note in the test so
a future failure is treated as a real regression.
Also register the three env reads job j102 added in common_libs/bitbrain
(TR_BITBRAIN_MODE / _DECAY_EVERY / _DECAY_SHIFT), which the env-report
guard was failing on.
Verification: test_env_report all green; test_tfil_commit_env 30/30.
Make danger a function of time-to-arrival instead of flat distance. Bullet
core/aura/corridor heat becomes magnitude(power) * decay(dt), dt = along/speed:
* decay(dt) = exp(-dt/tau) is a function of TIME; a fixed tau projects a
pixel reach of speed*tau, so fast/weak bullets get a longer slope and slow
ones a shorter one — derived from speed = 20 - 3*power, not hand-tuned.
tau = TR_TFIL_HEAT_TAU.
* magnitude(power) scales the near-end heat with power from DAMAGE
(calcBulletDamage = 4p, linear in p; SCORE_PER_BULLET_DAMAGE = 1.0). Hit
probability is FLAT across power (docs/env_reference.md), so risk does not
justify power scaling — the cost of the hit does. Floored at 1.0 so a weak
bullet's near end is never less dangerous than the flat model.
Gain = TR_TFIL_HEAT_POWER_GAIN.
Every source is already f(dt), so the time-indexed planner (evaluate a cell at
the tick the bot would ARRIVE, i.e. heatDecay(dt - arrivalDelay)) is a one-line
change. It is intentionally NOT implemented here.
Default path is byte-identical: with TR_TFIL_HEAT_TIME unset both factors are
exactly 1.0 (IEEE x*1.0 is exact), and the committed golden replay in
common_libs/tests/test_tfil_commit_env.nim (20,026 ticks) still passes
byte-for-byte against the pre-change mover. The debug corridor outline is also
drawn only to the model's reach when enabled, so the GUI shows the shortening.
Offline field measurement (common_libs/tests/measure_tfil_heat_time.nim,
46,054 fixture ticks, tau=9/gain=1): corridor reach drops from 443px
wall-to-wall to 143px mean (32% retained); fraction of tiles > 10 goes
0.61 -> 0.57; largest contiguous safe region 118 -> 140 tiles; mean
distance-to-nearest-safe-tile 49 -> 42px. Saturation stays high because wall
radiance + pillar alone are 44% of tiles over threshold and are untouched.
Registers the three knobs in env_report (report + known-name set).
Re-runs the SBC coincidence premise as a cheap veto test on the 70-battle
live-vs-DrussGT corpus (/tmp/tfil_ab2). Defines the wave-relative state
(lat/vlat/toa/room/turn, 5/7.9/10 bits at Q=2/3/4), quantises the miss offset
at the bullet's arrival into 7 bins, and sweeps window length K in
{1,4,8,16,32,48} with an interpolated suffix-backoff model under a BY-BATTLE
70/30 split (3 seeds).
Result: NO. On the pre-fire frame the window is worse than the single
fire-tick state at every K/Q/A (e.g. K=8 costs +0.35..+0.46 bits). On the
during-flight frame the entire apparent gain is the later decision tick, not
the window; the single state alone drops 2.70 -> 1.31 bits as K goes 1 -> 32.
The shuffle-order control confirms recency matters but the windows do not:
by Q=4/K=8 they average ~1 observation and never recur. The single state
survives as a strong predictor (log-loss 2.346 vs 2.698 majority; bin
accuracy 0.409 vs 0.235).
Gate only: no gun, no live-win claim.
Adds an smCounted storage mode alongside the default smBitset. Each
(i,j,class) cell becomes a saturating uint8 counter; learn increments it and
a global fractional decay (c -= c shr decayShift every decayEvery learns)
makes forgetting possible. infer sums raw counters; new inferProb sums the
per-cell posterior P(class|cell) (scale-free, recommended readout).
Bitset path is the default and byte-for-byte unchanged: test_bitbrain 56/56
(was 32), and test_bitbrain_mnist reproduces 97.210% corrected / 96.540%
bug-compatible exactly.
Counted mode configurable at runtime (TR_BITBRAIN_MODE / TR_BITBRAIN_DECAY_*)
and compile time (-d:bitbrainDecay*). Measured: forgetting (86.2% vs 48.9% on
a permuted-label stream), probabilities (rare-class balanced 0.998 vs 0.500),
and the stationary cost (counted hurts MNIST; see docs/bitbrain_counted_sbc.md).
Harness: common_libs/tests/measure_counted_sbc.nim
Task A of campaign phase 2: the lead-gain candidate set is now pure env, so the
live arms need no recompile.
- common_libs/guns/bitbrain_gun.nim: BB_GAINS_ENV (TR_BITBRAIN_GAINS); the
candidate list is parsed once at gun construction into a dynamic seq, so the
hit counts/hit rates are sized to it. Unset/unparsable -> the shipped
BB_CAND set [0,0.25,0.5,0.75,1.0] (byte-identical behaviour). Exactly ONE
candidate degenerates to a FIXED gain applied from the first shot (learning
bypassed), still gated to the long bands. parseGains clamps to [0,8],
de-dupes and sorts so the argmax tie rule is unchanged. The [bb] line now
prints the APPLIED gain AND the resulting angular shift, so a run's
correction is auditable from stdout.
- ModularBot_garage/src/env_report.nim: emit TR_BITBRAIN_GAINS (resolved
candidate set) and add BB_GAINS_ENV to the known-name list.
- tools/ab/arms_leadgain.txt: the 6-arm phase-2 sweep definition.
Task A — the gain region Phase 0 never covered (gain < 1). Extend the
prediction-quality ruler with gain 0.25/0.50/0.75 arms and a fixed causal
per-band arm. Full 70-run result: the hitProxy-argmax curve is
[1.00, 1.00, 1.00, 0.00, 0.00] — Pattern below 300 px, HeadOn above —
worth +0.13 pp at 300-450 and +2.16 pp at 450+ (0.0767 -> 0.0984). Lead
correlation is identical for every g>0 (Pearson is scale-invariant), so a
shrinking gain adds no lead information; and the least-squares optimum
[1,1,1,1,0.25,0.25] diverges from the hitProxy optimum because Pattern's
lead errors are bimodal.
Task B — rebuild guns/bitbrain_gun.nim as a lead-gain corrector:
aim = LOS + gain*(patternAim - LOS), gain learned online per range band by
ranking candidate gains on the hit-probability proxy (the observed lead
label via tmhObservedAt), gated to range >= 300 px. ADE+SBC output removed.
Offline (70 runs): Pattern below 300 px, +0.37 pp at 300-450, +1.90 pp at
450+ (hitProxy 0.0957 vs 0.0767), matching the fixed rule to within 0.27 pp
at 450+ and exceeding it at 300-450. ~0.0007 ms/tick marginal (old gun
~0.114 ms/tick). Default off; rack membership, env report and guard tests
(bitbrain 32, registration 13, rack 48, tm_pattern 20, env_report) unchanged
and green.
Ledger: docs/bitbrain_campaign.md Phase 1, including the three on-file
negatives and the causal-shippability note. No live claim.
2 arms x 15 runs x 7 rounds, one frozen binary from HEAD a82c864, real DrussGT,
server-side events sidecar. Shipped rack is onlyPattern, so control=Pattern-only
and headon=HeadOn-only (TR_RACK_PATTERN=off TR_RACK_HEADON=both).
arm dmg/run dmgtk/run round wins shots/run
control 279 211 48/105 785
headon 14 228 0/105 580
Round wins and dmg/run both separate at p<0.0001 (MC permutation, se 0.0000),
~7x the damage MDE (35.8). Per range band (pooled, 15 runs):
300-450: Pattern 12.3% (4590 shots) vs HeadOn 0.6% (3701) p<0.0001, MDE 2.0pp
450+ : Pattern 9.2% (6671) vs HeadOn 0.4% (4177) p<0.0001, MDE 1.1pp
HeadOn loses EVERY long-range band by 20-23x, so the whole-battle loss is not a
close-range artefact.
The offline ruler (prediction_quality_results.txt) predicted the opposite: HeadOn
meanAbs 14.61 vs Pattern 17.53 at 300-450 and 12.33 vs 16.19 at 450+, hitProxy
.105/.104 and .098/.077 (+27%). That is an open-loop replay of a FIXED enemy
track, so it cannot see that a different bullet makes the surfer dodge
differently; live, the static gun does not lead at all.
TR_PATTERN_RAD_SCALE arms were skipped: applyRadial scales aim DISTANCE along an
unchanged bearing, so it cannot express 'less lead' (bearing is what firing uses).
HeadOn confirmed to ignore bulletSpeed (head_on.nim:9), liveness OK 15/15.
Adds the range-band analyzer tools/ab/ab_range_bands.py (reuses the lead-capture
Run alignment) and the captured fixtures. Does not touch bitbrain_gun.nim /
bitbrain_campaign.md (job-100).
New harness (common_libs/gun_harness/prediction_quality.nim +
common_libs/tests/run_prediction_quality.nim): per-gun single-tick aim error in
degrees against the true continuous interception point on the recorded
live-vs-real-DrussGT corpus (/tmp/tfil_ab2/out, 70 runs, 899607 ticks), per
range band, with the hit-probability proxy mean(|err|<=atan(18/range)).
Validated: recorded hits separate from misses 13.34x px (reference 11.59x),
perfect-oracle max |err| = 0, correct ordering on synthetic ground truth, two
full runs byte-identical. Fixed a wrap180 bug (Nim float mod keeps the dividend
sign) that inflated the negative error tail.
Bar (mean|err| deg [hitProxy] at 450+): Pattern 16.19 [0.077], naive-linear
22.86 [0.054], TMHorizon 16.20 [0.076], BitBrain 16.20 [0.077], static HeadOn
12.33 [0.098], oracle 0 [1.0]. Lead-gain sweep on Pattern is a dead end (1.0
wins every band). Naive-linear applies ~1.8x Pattern's lead but carries no more
lead information (corr 0.178 vs 0.165) and is strictly worse. Ledger:
docs/bitbrain_campaign.md. All verdicts remain live-only.
4 arms x 7 runs vs real DrussGT. mix alternates the two guns 476 times/7 runs
(liveness OK) but our bullets are no more varied (power sd / aim-offset sd flat)
and DrussGT's dodge quality is unchanged (miss/tick mix-pat +0.03, p=0.66; MDE
3.8%). mix wins 24/49 = the 49% baseline; the user's 6/10 has P=0.353 at 49%.
New tools/ab/ab_dodge_analyze.py splits the validated per-shot dodge instrument
by arm and adds gun-switch/power/bearing liveness; fixtures committed.
Measure, for every shot ModularBot fires at the real DrussGT, the lead we
actually applied vs the lead the enemy's motion required, from the recorded
live battles (/tmp/tfil_ab2, 70 battles / 490 rounds / 54926 shots, plus a
35-battle powtest replication of a different binary).
- requiredLead from an AIM-INDEPENDENT interception solve (bullet speed vs
enemy truth), appliedLead from the server-recorded bullet bearing.
- capture = applied/required, guarded at 2px lateral lead (1.6% excluded);
headline metric is the robust proportional slope.
- validation: hits 11.6px mean miss / 80.8% inside 18px, misses 134px,
11.6x separation; 496/496 death + 70/70 owner attributions correct.
Direct answer: capture falls with RANGE (capSlp 0.401 -> 0.135, and
|err|/tolerance 1.27 -> 7.54) but is FLAT across fired POWER within a band
(450+, enemy alive: 0.154 / 0.127 / 0.127). The sub-0.5 long-range shots
(1.18% hit) are finishKill endgame shots at a near-dead DrussGT, not a
lead-capture failure. A naive linear predictor captures 0.29-0.60; we reach
46-67% of that, so the under-lead is real but capture=1.0 is unattainable
against a dodger (oracle required lead).
Wire the verified common_libs/bitbrain ADE+SBC library into ModularBot as a
fine-grained angular corrector on top of Pattern's prediction, the shape the
offline gate test measured (argmax readout over N correction classes).
- common_libs/guns/bitbrain_gun.nim: new gun. Input = the existing TMHorizon
53 bits (tmhBaseBits + tmhLits); output = argmax class centre over
+-TR_BITBRAIN_RANGE, applied by rotating the Pattern point around the shooter
exactly as tmhApplyShift does. Label = the +h-tick fact from TmHorizonGun's
own observation ring (never across a round). Prequential (defer + resolve).
AD layer synthesised online for our binary inputs (center=0): heuristic
cold-start thresholds + running-histogram ~1% percentile init + the library's
adaptThresholds. Memory modes perRound (default, measured best) / retained /
decay (periodic partial SBC wipe). Lazy network build + local RNG, so the
default path builds nothing and consumes no global randomness.
- tm_horizon.nim: export tmhUpdateHistory and add tmhObservedAt (label seam).
- selector.nim: register BITBRAIN at rack id 16, default rmOff, in the SAME
commit as the id and the wiring (the aed579b admission bug is not repeated).
- ModularBot.nim: id 16 wired through predict/spawn/onResult/resets/colors,
arrays grown 16->17, spawn gated on rack admission, per-round/per-battle/
target reset hooks.
- env_report.nim: report every TR_BITBRAIN_* knob + add names to the known set.
- tests: update the rack length literals; new test_bitbrain_registration
(default-parity: off, lazy, global-RNG clean).
Guard counts unchanged: rack 48, tm_pattern_registration 20, vbullet_admit 12,
env_report 25, and the rest of the suite green.
AUDIT (docs/offline_harness_trust.md, new):
- Re-ran acceptance_offline_vs_online myself TWICE: 12/12 deterministic guns
exact both times (264 ticks/enemyId=1, 244 ticks/enemyId=2), death boundary
included. The offline range reproduces the live bot's own per-gun virtual
telemetry exactly.
- Re-verified the (fireTick, powerBin) wave-pairing fix: exact-key lookup,
collisions counted not silently mislabelled; test_wave_pairing 17/17 PASS.
- The offline score is the live TELEMETRY (last-100 virtual hit rate) but NOT
the live BATTLE score (damage/round wins). Two-level answer, documented.
- bmPoint scores up to one tick-step (~17px) PAST its documented aim distance,
while the tie-break probe scores exactly the aim point. Real, low-impact,
deliberately NOT fixed (point metric is non-default, measured negative, and
the committed point baselines would silently change).
- bmPoint/bmPath, perfect-info captures, conditional-on-selection live rates,
and hit-rate-as-objective-for-movement all catalogued as non-apples comparisons.
FIX (unambiguous, fail-before/pass-after):
- common_libs/tests/range_guns.nim: buildAllGunDrivers defaulted to
enableTmSelector=true, so run_range / analyze_selector / test_power_selection /
measure_power_policy spawned gun 13 (TMSelect) - a gun the shipped bot NEVER
spawns. The shared VirtualTracker ring is order-sensitive, so those 4
spawns/tick permuted the learning guns' resolution order (the exact confound
4cd5618 fixed for the acceptance test, left broken for every default caller).
Default is now false (mirror the shipped rack). Impact on
tr_drussgt_vs_modularbot: Tsetlin 18.8->18.5%, KNN 7.5->7.2%, TMSelect 15.2->0.
- New guard common_libs/tests/test_range_rack_parity.nim (3 checks); proven to
FAIL before and PASS after by stash-reverting the fix.
CALIBRATION (offline prediction vs live outcome, 9 usable arms):
- Direction agreement 3/9 = 33%. Split by domain: open-loop (single-tick
prediction / metric / threshold) 3/3; closed-loop (adaptation / range /
movement / selection) 0/6. Small, non-random, hand-assembled set - no
correlation coefficient is claimed.
- The four motivating "offline wins" re-attributed: ring mover was NEVER
offline (it is a live server-side hit rate, mislabelled "offline" in
env_reference.md:342 and commit 7f6ccfb); TMHorizon window/NSTATES and the TM
gun are the H3 classifier-accuracy harness (not hit rate); TFIL is the H2
open-loop movement replay, whose mechanism prediction was right and whose
outcome prediction was wrong.
- Open-loop hypothesis tested: TR_RACK_* knobs leave the offline range output
BYTE-IDENTICAL (the replay never calls the selector), and the range has no
driver for guns 14/15 (TMPATTERN/TMHORIZON). BUG vs LIMIT separated.
VERDICT: trust the harness for single-tick prediction quality only; never for
anything running through the closed loop. MEASURED vs INFERRED labelled.
Green counts unchanged: test_gun_harness 39, test_vbullet_metric 11,
test_power_selection 3, test_power_policy 58, test_adaptive_radar 41,
test_tfil_ring_weights 24, test_ram_decision 40, test_rack_membership 48,
test_selector_tiebreak 19, test_tm_pattern_registration 20,
test_vbullet_admit_gate 12, test_tm_horizon 104, test_tm_diag 48,
test_tm_automata_diag 55, test_tm_clause_shape 66, test_env_report 25,
test_tfil_commit_env 30 (as-is). New: test_range_rack_parity 3.
Answers the user's hypothesis that DrussGT dodges low-power shots better.
Measured on 70 live battles / 490 rounds / 54939 real shots vs real DrussGT
(/tmp/tfil_ab2) and replicated on 35 more battles / 24280 shots (/tmp/powtest).
Power is not randomly assigned - our policy caps it by RANGE
(TR_POWER_FAR_DIST=200 -> 1.0) and by OUR OWN ENERGY (the slope), so inside a
range band power is almost a deterministic function of our energy and a naive
low-vs-high comparison is secretly a losing-vs-healthy comparison. Everything
is stratified by range band and backed by a within-band shuffled-label null
(arrival re-derived, so the null keeps the kinematic channel), a round-cluster
bootstrap, and a within-shot CONTROL window 40 ticks later when the bullet is
long gone.
RESULT: no behavioural response. In band 450+ the raw miss distance at arrival
is +8.25 px [+5.39,+11.25] for HIGH power - but per flight tick it is 4.52 vs
4.51 px/tick (delta -0.01 [-0.12,+0.10]), i.e. entirely the 2.13-tick longer
flight window of the slower bullet. Fixed-12-tick lateral displacement is flat
(55.63 vs 55.47, -0.15 [-1.15,+0.84]) and turn rate / speed are flat. The whole
difference is already present 5 ticks after the trigger pull (+4.2 px) and is
just as large in the bullet-free control window (+5.6 px), so it is a property
of the low-energy situation, not of the shot. Hit rate is flat (0.10 vs 0.09).
Corpus/attribution notes: e*=DrussGT (subject), s*=ModularBot, per
TrBattleCapture.java; the Tank-Royale owner id is NOT stable across runs and is
recovered per battle from fire geometry + the energy decrement, cross-checked on
496/496 death events. Geometry validated on the server's own hits (mean miss
11.6 px, 80.6% inside the 18 px radius).
Implement the BitBrain (Address Decoder Element + Sparse Binary Coincidence)
classifier as a generic, deterministic Nim library under common_libs/bitbrain/,
written from the published algorithm (Front. Neuroinform. 17:1125844), not from
the GPL-3.0 reference C.
- ade.nim: signed thresholded random projection (scale 64 / centre 127 defaults
reproduce the reference), multi-width ADs, optional deterministic homeostatic
threshold adaptation. Hebbian longevity and Metropolis-Hastings sampling are
described but not implemented.
- sbc.nim: packed class-bit coincidence memory; idempotent learn, counting
inference.
- bitbrain.nim: container over several ADs and SBCs, online learn/infer, argmax
readout, memory accounting.
- tests: 32 unit checks (idempotence, planted rule + monotone online curve,
shuffled-label chance control, unseen input, homeostasis, memory).
- tests/test_bitbrain_mnist.nim: loads the reference pretrained ADs/thresholds
and MNIST from /tmp, reproduces the reference exactly - 97.210% corrected and
96.540% bug-compatible - confirming the port.
No gun/wiring integration yet; inputs and outputs to be agreed separately.
Runs the five-arm commitment A/B that 19bf461 only implemented. One frozen
binary (git archive 19bf461, sha256 46e7ce19...) vs the real DrussGT through
tools/robocode_shim/run_bridge_battle.sh: 7 runs x 7 rounds per arm against
real DrussGT in the pre-registered block, plus an independent replication
block (runs 8-14) - 70 battles, 490 rounds, 5 arms in parallel.
A control (shipped) B TR_TFIL_TILE_REPLAN=off
C B + TR_TFIL_NO_REV=1 D TR_TFIL_TILE_REPLAN=enemy
E B + TR_TFIL_COMMIT_TICKS=30 (TR_TFIL_COMMIT_LOG=1 on every arm)
RESULT: no arm improves damage/run or round wins vs the shipped mover. Pooled
(14 runs/arm, exact two-sided permutation test over all C(28,14) relabellings):
arm damage/run (delta, p) round wins (delta, p)
A 274.44 2.79 (39/98)
B 274.07 (-0.37, p=0.97) 3.07 (+0.29, p=0.66)
C 257.34 (-17.10, p=0.16) 2.43 (-0.36, p=0.47)
D 286.61 (+12.17, p=0.36) 3.29 (+0.50, p=0.35)
E 265.29 (-9.15, p=0.56) 2.93 (+0.14, p=0.89)
The replication is what settles it: block 1 alone showed D at +20.99 damage
(p=0.26); block 2 put D at +3.36. Nothing replicates.
The PREMISE fails. Honouring the commitment does not reduce reversals - it
raises the reversal-pick rate from 33.9% (control) to 50.9% (B) / 57.0% (E);
the no-reversal arm C only claws part of it back (42.4%). The post-reversal
speed dip (~4.9 -> ~4.0 px/tick at +1..+2 calls) is identical in every arm, so
it is a property of turning around, not of the tile replan, and arm A - the
arm carrying the bug - has the LOWEST mean abs(speed) of all five.
Both premise premises are also caught by the metric the report is careful
about: arm C takes significantly FEWER hits (87.2 vs 96.5/run, p=0.0022) while
dealing the least damage and winning the fewest rounds - a movement change
alters hits taken, shots fired and round length at once, which is exactly why
damage and round wins are the primary metrics and hit rate is not.
Treatment verified per arm from the per-tick TR_TFIL_COMMIT_LOG: the pick
interval moves off ~5 calls only where the arm says it should (A 5.05 with
96.6% tile_self replans; B 14.77 with 0%; C 14.79; D 5.29 with 95.7%
tile_enemy; E 26.62), matching the offline fixture replay (5.07 / 14.65 /
14.65 / 4.94 / 27.57). Every arm ran.
Round wins are the final RESULTS firstPlaces, cross-checked against each
round's BotDeathEvent (35/35 and 35/35 runs agree). Damage is the server's
BulletHitBotEvent damage, attributed by numeric owner/victim id and verified
against the capture's own event counters.
measure_tfil_commit_ab.nim reads the raw run artifacts (JSONL states, events
sidecar, capture stdout, commit log) and prints the whole deliverable:
arm table, per-run values, the exact per-run permutation test, the exact
round-level hypergeometric test (flagged anti-conservative - rounds cluster
within a run), the treatment diagnostics, and the direct answer. It reproduces
either report offline from the committed summary:
nim c -r --path:common_libs common_libs/tests/measure_tfil_commit_ab.nim \
--from-summary common_libs/tests/fixtures/tfil_commit_ab_results_runs14.json
The raw ~200 MB of battle artifacts are not committed; the committed summary
holds every per-run value plus the arm-level diagnostics.
The tile-change replan cancels the 15-tick movement commitment whenever OUR
tile changes. With GridSize=36 and speed up to 8 px/tick that is every ~5
ticks, so the commitment is cancelled by the motion it commands (measured:
96.9% of picks were tile-change replans, 33.8% of picks reversed direction).
Adds four env knobs, every default reproducing the shipped mover
byte-for-byte:
TR_TFIL_TILE_REPLAN self (default) | off | enemy
TR_TFIL_COMMIT_TICKS 15 (default)
TR_TFIL_NO_REV 0 (default)
TR_TFIL_COMMIT_LOG off (default, JSONL per-tick diagnostics)
- `off` honours the commitment; the danger replan stays the safety valve.
- `enemy` keys the cancel to the TARGET's tile displacement (the intent the
original comment claimed).
- `TR_TFIL_NO_REV` down-weights (never filters) tiles >90 deg from the travel
direction; the pool can never be emptied.
Default-path parity is guarded by test_tfil_commit_env.nim, which replays
tools/fixtures/tr_drussgt_vs_modularbot.jsonl and diffs every move command
against a golden generated from the pre-change build (git archive f842ac0).
env_report known-name list updated for the four new names.
The boot report printed the raw env and the resolved values, but it never
told the user when a name was WRONG - which is the failure mode that cost
real time: `TMH_NSTATES` was exported as an env var although it is a
compile-time `{.intdefine.}` (`guns/tm_horizon.nim:102`), so the export was
a silent no-op. Add the two warning paths, both boot-only and stdout:
* unknown TR_*/GUN_* names are named explicitly. The known set is built
from the modules' exported env-name constants; the remaining inline
reads are listed once, and common_libs/tests/test_env_report.nim scans
the tree and fails if a name read anywhere is missing.
* any `{.intdefine.}`/`{.strdefine.}`/`{.booldefine.}` symbol present as
an env var is flagged, with the real runtime equivalent when one exists
(`TMH_NSTATES` -> `TR_TMHORIZON_NSTATES`) and an explicit "does not
exist - this knob is compile-time only" when it does not. The map is
derived by grepping the tree; the same test re-greps and fails on drift.
Warnings are emitted only when the env is dirty, so a correct run keeps the
documented A/B/build shape. TR_ENV_REPORT=0 still suppresses everything.
test_env_report.nim: 25 checks (known set, pure helpers, tree literal scan,
tree define scan). All 15 existing guard tests keep their exact counts.
re-plan 3x more often
Uncommitted working-tree change in `the_floor_is_lava_ring.nim`, confirmed by the
user as theirs. Committing it so it stops appearing in every job's `git status`.
WHAT IT DOES - a coherent "be far more afraid of bullets" strategy:
- BULLETS 2x hotter: BulletCore 10 -> 20, BulletAura 5 -> 10.
- Walls weaker and thinner: WallRadiance 10 -> 5; the `WallHotness` default
10 -> 15 (so the heat falls off over ~3 tiles instead of 1, with a higher peak).
- CENTRE PILLAR DISABLED: PillarHotness 30 -> 0, PillarRadiance 10 -> 0, so the
bot may now use the middle of the arena instead of treating it as a no-go zone.
- MUCH more reactive: CommitTicks 15 -> 5 and MinCommitTicks 5 -> 0, i.e. the
dodge target is re-chosen 3x more often and a replan is allowed immediately.
- `CorridorHeat` default 5 -> 10.
This applies ONLY to the ring mover, which is NOT the shipped movement (the default
is `tfil`), so the shipped bot is unaffected.
TWO FACTS RECORDED, not objections - it is the user's call:
1. `CorridorHeat = 10` now EQUALS `PathDangerThreshold = 10`. The earlier measured
taming set it to 5 precisely so a single corridor could no longer poison a path
on its own (a corridor at 10 puts every tile along it at/over the threshold).
That is part of what lifted the "band-weightable" fraction from 10.8% to 26.5%.
At 10 the range weighting gets less to work with.
2. UNMEASURED: this retune has not been A/B'd. The ring mover it modifies was
itself measured as a glass cannon (best offline hit rate, but round wins
16/49 -> 6/49, p=0.012). The two changes push in the same direction - hotter
bullets and more frequent replanning mean MORE dodging and LESS time spent
closing to the 100-200px band where our hit rate peaks (27% vs 5% at 450px).
Worth an A/B before drawing conclusions, but it is an experiment, not a
shipped change.
The user's first-hand diagnosis: "once the pattern is learnt enough we hit DrussGT,
but as soon as it adapts we are not fast enough to re-adapt again." The gun kept
EVERY sample for the whole battle (which the user explicitly asked for), so stale
evidence weighed the same as new evidence - accumulation without forgetting.
TASK 1 - THE CURVE, MEASURED FIRST (prequential side accuracy, 15 rounds x 4
horizons, deciles of the eval stream):
arm early% late% decay
frozen (early only) 64.9 61.4 -3.5
accum (shipped) 76.4 75.3 -1.1
window N=150 84.7 84.6 -0.0
resetdrop 5pp 83.2 84.3 +1.0
**The decay IS real but modest and LOCALIZED IN THE LAST ~30% of the round**
(last-third vs middle-third: frozen -7.7pp, accum -3.7, window -1.3, resetdrop -1.3).
TASK 3 - WHICH FIX HELPS (late-half accuracy, shuffled control in parens):
accum (shipped) 75.3
**window N=150 84.6 (51)** +9.3pp
**resetdrop 5pp 84.3 (51)** +9.0pp
rehearse-all (retrain, NO forgetting) 79.5
- **Sliding window: +9.3pp late (within-round), +9.7 (cross-round), +10.3 (shield).**
The shuffled control stays ~50-51%, so it learns the ENEMY, not noise.
- **Forgetting is the essential ingredient**: the same periodic retrain WITHOUT
forgetting reaches only ~79.5%, so roughly half the gain is the retraining
mechanics and half is the forgetting.
- Change detection ties the window on late accuracy and gives the best decay, but
at a 5pp threshold it fired 300-500 times in the offline stream - noisy.
- INERTIA IS REDUNDANT WITH THE WINDOW: lower inertia helps the keep-everything
model a lot (accum late 75.3 -> 83.3 at N=8) but leaves the window FLAT
(84.6-84.7 at every N). Low inertia and forgetting are SUBSTITUTES, and
forgetting is the robust one - `TR_TMHORIZON_NSTATES=8` is NOT the primary fix.
VERDICT: SHIPPING CANDIDATE = `TR_TMHORIZON_WINDOW=150`, kept at default 0 until a
live A/B confirms.
HONEST CAVEATS THAT SET EXPECTATIONS:
- The fixtures are OPEN-LOOP (DrussGT does not react to our bullets), so a true
mid-round adaptation is NOT present; the dominant measured effect is the LEVEL
gap, not the decay magnitude. The user's "it adapts" magnitude is still INFERRED.
- The harness uses a STRAIGHT-LINE base while the live gun uses Pattern's
prediction, and it ignores the h-tick label delay, so its absolute side accuracy
(75-85%) is INFLATED: the same gun measured ~52% = chance live against DrussGT.
So +9.3pp is a real ARM DELTA, not a promise that the gun now clears the ~80%
accuracy wall that hits need. A live A/B must decide.
Adds `measure_tm_readapt.nim` (prequential harness with the mandatory shuffled
control, within-round + cross-round protocols, inertia sweep) and its captured
results. test_tm_horizon 79 -> 104 (five new groups). test_tm_diag 48,
test_tm_automata_diag 55, test_tm_clause_shape 66, test_rack_membership 48 pass.
ModularBot compiles.
.gitignore: switched from a broad `measure_*` pattern to EXPLICIT binary names.
The broad rule was too blunt - it also excluded the `.txt` results file, which made
`git add` refuse the whole commit twice. Sources stay tracked; binaries do not.
The user's request: "when our bot is low OR enemy is low, it is useless to use high
power instead low fast bullets have more chances to finish the enemy. Let's do a
math slope: starting from some health down, the power goes down with it."
1. ENERGY SLOPE (`TR_POWER_ENERGY_*`), replacing the old hard step at 50 energy:
cap = ENERGY_MAX at/above ENERGY_HI, ENERGY_MIN at/below ENERGY_LO, LINEAR in
power between, clamped. Defaults HI=80 LO=20 MIN=0.5 MAX=3.0, so no cap >=80,
0.5 at <=20, and e.g. E=65 -> 2.375, E=50 -> 1.75, E=35 -> 1.125.
Rationale: bullet speed is 20-3p, so lower power = FASTER bullet (less lead
error, higher hit chance), fires more often (10+2p) and drains slower (p/shot).
E[dE] = p(3P-1) => break-even hit probability is 1/3 INDEPENDENT of power, and
our measured rates are 5-27%, far below it.
2. FINISHING CAP (`TR_POWER_FINISH_KILL`, default ON): cap power at the SMALLEST
bullet that still removes the enemy's remaining energy -
`E<=4 -> p=E/4` (min 0.1), `4<E<=16 -> p=(E+2)/6`, `E>16 -> no cap`.
Rationale, and it makes the user's instinct stronger than a heuristic: server
1.3.1 caps the damage SCORE at the energy ACTUALLY REMOVED, so overkill is
WASTED damage AND ~6x the energy for ZERO extra score. Damage is 4p (p<=1) /
6p-2 (p>1).
Both are min-composed with the existing far/below-average caps, may only LOWER
power (exhaustively tested), and are exempt while ramming.
`TR_POWER_POLICY=0` still returns the uncapped control exactly.
MEASURED ENERGY SAVING (offline replay of the DrussGT fixtures, 28,797 ticks):
arm shots energy meanP E/1k ticks vs cliff
control(uncapped) 1913 4646 2.43 161.4 -90.2%
cliff (today) 2363 2443 1.03 84.8 0.0%
slope 2404 2178 0.91 75.6 ** 10.9% LESS **
slope+finish 2404 2167 0.90 75.3 ** 11.3% LESS **
So the slope spends ~11% less energy than the cliff AND fires slightly MORE shots
(2404 vs 2363) - both directions at once.
HONEST NOTE on the finishing rule's reach here: ticks where the enemy is low
(0 < E <= 16) are only 2252/28797 = 7.8% of these fixtures, so finishing adds just
~11 energy of saving against DrussGT. It matters in CLOSER fights, not this one.
Verification: test_power_policy 58 (was 26) in BOTH the default and TR_POWER_POLICY=0
control arms - slope at E=100/80/65/50/35/20/5, powerToKill across E=0.1..100, the
inverse-cover property for E<=16, monotonicity, ram exemption, and an exhaustive
sweep proving power <= preference. Guards: test_gun_harness 39, test_vbullet_metric
11, test_power_selection 3, test_adaptive_radar 41, test_tfil_ring_weights 24,
test_ram_decision 40, test_rack_membership 48, test_selector_tiebreak 19,
test_tm_pattern_registration 20, test_vbullet_admit_gate 12. acceptance
12/12 PASS. ModularBot compiles release.
Adds `common_libs/tests/measure_power_policy.nim` (the energy/histogram tool) and
updates docs/env_reference.md for the new `energySlope|finishKill` log reasons.
NOT MEASURED: the battle/hit-rate effect. The offline figures use the fixture
shooter's energy as a proxy, open-loop; the RELATIVE saving is the meaningful part.
MY ERROR. `aed579b` committed `ModularBot.nim` (which wires the new gun as id 15)
and `guns/tm_horizon.nim`, but I staged only three files and left job-72's RACK
REGISTRATION behind in the working tree. Consequence at HEAD:
- `common_libs/gun_harness/selector.nim` still had the 15-entry rack table
(ids 0..14) with no TMHORIZON name, so
- `TR_RACK_TMHORIZON` was never read (a silent no-op), and
- id 15 is PAST the table, and `rackAdmitted` documents "an id past the
membership table is admitted" -> **TMHORIZON was admitted unconditionally**.
So the committed default rack was `Pattern + TMHORIZON`, not the intended
`Pattern only`, and the gun could not be switched off by env at HEAD. This was
caught by a live A/B job that had to work around it with `GUN_RACK_DISABLE=15`.
FIX (this commit): the rack table goes to 16 entries with `TMHORIZON` appended at
id 15 defaulting to `rmOff`, plus the two tests whose length literals follow it
(`test_rack_membership` 15->16, `test_tm_pattern_registration` 15->16).
`DefaultRackMembership` is again Pattern-only with every other gun `off`.
NOTE ON SCOPE: `selector.nim` in the working tree also contains a CONCURRENT job's
power-policy threading (an `enemyEnergy` parameter). That work is still in flight
and is deliberately NOT included here - only the three rack hunks were staged.
The remainder stays unstaged for its own commit.
The lesson, recorded because it has bitten twice tonight in different forms: a
change is not committed until its registration/table counterpart is, and
`git add` of a hand-picked file list is exactly how a half-change ships.
The user's requirement: "every battle i means from round 1 to round end-battle, so
retain all learning until the enemy change." What was built wiped the Tsetlin
machines EVERY ROUND, in two places (`onRoundStarted` and the gun's own
tick-regression self-reset), so in a 7-round battle each round started cold,
trained ~360 samples and threw them away - discarding most of its one chance to
do what was asked: overfit the current enemy over the whole battle.
THE FIX - two kinds of state, two triggers:
- **`resetRoundState` (per ROUND)**: the observation ring, pending/deferred
labels, the bullet proxy, motion history, per-tick caches, `roundStartTrained`.
These MUST clear every round, because bots teleport back to the starting corners
between rounds - an old position would build a garbage label. (That exact class
of bug shipped 36-58% wrong labels in the old gun.)
- **`resetLearning` (per BATTLE / per ENEMY)**: both Tsetlin machines, `trained`,
`sideCorrect/sideTotal`, all histograms, the magnitude median, `pendingDropped`,
`observedTargetId`. These now SURVIVE round boundaries.
Triggers for the machine wipe: `onGameStarted` (primary) plus a redundant
`roundNumber <= 1` fallback in `onRoundStarted`; and a TARGET CHANGE
(`targetChanged`, knob `TR_TMHORIZON_RESET_ON_TARGET` default on - a no-op in 1v1,
fires on melee target switches; first acquisition never wipes). The
tick-regression self-reset now clears ONLY per-round state.
Still NO cross-battle persistence: grep for file I/O in the gun finds none.
PROOF IT WORKS (live 2-round battle, `TR_RACK_PATTERN=off TR_RACK_TMHORIZON=both`):
[tmh-reset] reason=game_start trained_was=0
[tmh-reset] reason=round1 trained_was=0
...exactly TWO reset lines in the whole battle, both at battle start, and NONE
at the round-2 boundary. And the per-round summaries:
[tmh-round] trained=1249 thisRound=1249 ... sideAcc=711/1177 (60.4%)
[tmh-round] trained=2226 thisRound=977 ... sideAcc=1361/2154 (63.2%)
`trained` CLIMBED 1249 -> 2226 across the boundary, and side accuracy rose
60.4% -> 63.2% in round 2 (one battle - suggestive, not proof).
Unit tests: `test_tm_horizon` 79 (was 54), including "trained SURVIVES the
boundary", "clause states SURVIVE", "trained climbs round1->round2", "game-start
wipes and all clauses end Exclude", "different enemy wipes / same enemy does not /
knob-off does not", and crucially "a label CANNOT be built across a round
boundary" (ringValidCount==0, ringHas(oldTick)==false, pendingCount==0) - the
single most dangerous interaction of this change.
Guards: test_tm_horizon 79, test_rack_membership 48, test_gun_harness 39,
test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 40,
test_selector_tiebreak 19, test_tm_pattern_registration 20,
test_vbullet_admit_gate 12, test_tm_diag 48, test_tm_automata_diag 55,
test_tm_clause_shape 66. acceptance_offline_vs_online 12/12 VERDICT PASS.
Shipped rack unchanged: DefaultRackMembership is still Pattern-only, TMHORIZON off.
Residual (pre-existing, out of scope, stated): the internal base
`PatternMatcherGun` has a rolling move-history buffer that is NOT cleared at round
boundaries - it never was, and the SHIPPED Pattern gun carries history across
rounds too. It cannot affect label correctness (labels come from `g.ring`), only
base-prediction quality in a round's first ticks.
problem gives 60%. Thread closed with a number, not a shrug.
Gate 2 (fd2f7f6) said STOP because every correction lost hits. But the shifts it
applied were the conditional MEDIANS of the error (4-16 deg) while the gate metric
is HITS, and the naive guess is UNBIASED - so shifting an already-centred
distribution can only destroy near-target mass. That suggested the experiment was
MISCALIBRATED rather than the idea being dead. One cheap test settled it.
ARMS (pooled, both primary fixtures, 25 rounds, N~9433/h; deltas in pp vs naive):
h naive TM-med TM-hit TM-x0.25 PERF-SIGN shuffled
15 39.3 29.0(-10.3) 35.1(-4.2) 37.9(-1.5) **47.0 (+7.7)** 35.1(-4.2)
20 28.2 19.2(-9.0) 22.8(-5.4) 25.6(-2.6) **33.4 (+5.1)** 22.0(-6.2)
25 20.9 13.1(-7.8) 15.4(-5.5) 18.8(-2.2) **25.6 (+4.7)** 16.0(-4.9)
30 16.4 10.5(-5.9) 11.4(-5.0) 14.1(-2.3) **18.3 (+1.9)** 11.2(-5.2)
1. **THE FORM CAN BUY HITS.** Perfect side knowledge + the hit-optimal shift gains
**+4.9 pp mean** (+1.9..+7.7), positive in ~19/25 rounds and in BOTH fixtures -
and it improves the median AND p90 residual. So the premise is NOT structurally
impossible.
2. **BUT THE TM'S 60% SIDE ACCURACY LOSES** (-4.2 pp quadrant, -3.3..-5.6 side).
Shrinking the shift toward zero monotonically reduces the loss but never turns
it positive.
3. **BREAK-EVEN IS ~80% SIDE ACCURACY** (synthetic sweep: 0.70 -> -1.6 pp, **0.80
-> 0.0**, 1.00 -> +4.9 pp). The observed side signal tops out at **~60% (TM) /
53-56% (turn-only rule)** against 50% chance, and the headroom study found it is
essentially ONE WEAK FEATURE. **No realistic predictor of this problem clears
the 80% wall.**
WHY IT IS SO SENSITIVE - the hits-vs-shift curve (h15, calibration): shift 0 ->
36.2%, +-2deg -> 27.4/26.8, +-4deg -> 14.2, +-6deg -> 11.7, +-10deg -> 8.3.
Shifting unconditionally is catastrophic. The whole value comes from shifting ONLY
when the side is known, because the signed-error distribution becomes ONE-SIDED
once you condition on the true side. And the hit-optimal PERF-SIGN shift is only
**+-2 to 3.5 deg** - an order of magnitude smaller than the 4-16 deg medians Gate 2
used, which is exactly why Gate 2's calibration could never win.
VERDICT: **MISCALIBRATED, but practically dead at achievable accuracy.**
Recommendation: stop the per-bucket-shift direction, and record the RIGHT reason -
an **accuracy wall at ~80%**, not an impossibility of the form. If ever revived,
the only viable path is a side predictor that materially exceeds 60%; a better
calibration cannot fix it (we already used the hit-optimal one).
METHOD NOTE: the hit-optimal shift is fitted on the calibration slice (the
pipeline's existing out-of-sample offset split) and applied on the later eval
slice - NOT fitted on the TM training slice, to avoid in-sample label leakage.
INTEGRITY: TM-med reproduces Gate 2's committed deltas to the decimal
(-10.3/-9.0/-7.8/-5.9); PS-shift0 and TM-x0.0 are exactly naive; the shuffled
control never improves. Guards all pass (test_tm_diag 48, test_tm_automata_diag 55,
test_tm_clause_shape 66, diag_synthetic, diag_automata_validation, test_gun_harness,
test_vbullet_metric, test_power_selection, test_adaptive_radar,
test_tfil_ring_weights, test_power_policy, test_ram_decision, test_rack_membership,
test_selector_tiebreak, test_tm_pattern_registration, test_vbullet_admit_gate).
Caveats: DrussGT-only; enemy movement is a closed-loop response to our CURRENT
movement; offline observation is perfect while live we see the enemy only on scans
-> all absolute hit fractions are optimistic upper bounds, only deltas are
meaningful.
Offline four-arm pipeline: extracts the 49-bit draft spec + a 4-bit horizon
one-hot (53 bits) from the DrussGT fixtures, derives fact-based quadrant labels
(side vs the naive guess, magnitude vs the TRAIN median), trains the validated
`tm_diag/tm_core.nim` fresh PER ROUND with a fit / calibrate / eval within-round
split, applies each predicted quadrant's out-of-sample conditional-median offset
and measures the residual angular error. 50 clauses, N=64, s=3.0, 5 epochs
(verdict identical at 1 and 10). Estimated hit = |residual| < atan(18px/range).
POOLED (tr_drussgt_vs_modularbot + _shield, N~9.1-9.4k per horizon):
h arm med|err| p90|err| hit%
15 naive 4.08 14.24 39.3
15 TM 4.81 13.53 29.0
15 shuffled 4.45 14.58 34.3
15 turn-only 4.13 14.10 37.1
20 naive 6.81 21.60 28.2
20 TM 7.61 20.85 19.2
25 naive 9.71 29.13 20.9
25 TM 10.33 28.63 13.1
30 naive 12.62 36.12 16.4
30 TM 13.09 35.17 10.5
Delta hits (pp): TM-naive **-10.3 / -9.0 / -7.8 / -5.9**;
**TM-turn -8.1 / -5.1 / -2.0 / -0.9** (h=15/20/25/30).
Median miss: TM is WORSE by +0.5..+0.8 deg. p90: marginally better by 0.5-1.3 deg.
So it pulls in the TAIL but not the typical miss.
Shuffled control: quadrant accuracy 22.9-24.8% ~= 25% chance, and dHit <= 0 at
every horizon -> NO LEAK. (The mild negative is the expected cost of applying a
noisy offset, not leakage.)
THE MECHANISM, and it is the important part: **the learning is REAL - the TM gets
the side right 59.6-61.0% vs 50% (quadrant 34-35% vs 25% chance; turn-only
53-56%) - but it does not translate into hits because the per-quadrant
conditional-median offsets (~4-16 deg for "big") are FAR LARGER than the body
half-angle (~2.6-3.4 deg at typical range).** The naive guess is essentially
UNBIASED (the median signed error is 0.00 deg at every horizon, from the headroom
study), so its error is centred on the target; shifting an already-centred
distribution away from zero DESTROYS near-target mass. That is why the turn-only
arm loses too (-2.2 to -5.8 pp): any constant shift hurts.
VERDICT AS IMPLEMENTED: **STOP. There is no case for building the new TM gun on
this evidence.**
NOT YET DISTINGUISHED, and worth one cheap test before the idea is declared dead:
whether this is a STRUCTURALLY dead application (no shift can help, because the
baseline is unbiased and the correction is coarser than the target) or merely a
MISCALIBRATED one (the offset was fitted to minimise the conditional MEDIAN of
the error, which is NOT the objective - hits are maximised by the shift that
maximises P(|error| < body), typically a SMALLER shift or none at all on a dense
near-zero distribution). That distinction decides whether the whole "TM predicts
the enemy's position" premise is dead or only this instantiation of it.
Caveats: DrussGT-only; the enemy's movement is a closed-loop response to our
CURRENT movement so the numbers are conditional on how we move now; offline
observation is perfect every tick while live we see the enemy only on scans, so
all of this is an UPPER BOUND. The bullet block was proxy-based (no gun heading or
power is recorded in the fixtures) - INFERRED, and stated as such rather than
silently dropped.