Files
SirRoboGarage/common_libs/tests/audit_wave_pairing.nim
T
SirStone 4657fe715e wave pairing: 36-58% of GF/DecayGF/KNN learning samples were MISLABELLED
The audit inferred (from code) that GF/DecayGF/KNN pop the OLDEST wave on
resolution, while under bmPath bullets leave the arena in NON-FIFO order - so an
outcome could be attached to the wrong wave. It also noted that `starved=0` does
NOT rule this out. Both halves are now MEASURED.

MISPAIRING RATE (10 DrussGT fixtures, real VirtualTracker, 344k resolutions/gun):
  gun         bmPath mispair   label err      bmPoint mispair   label err
  GuessFactor     36.48%         19.39%           18.24%          7.62%
  DecayGF         36.85%         19.52%           20.57%          8.64%
  KNN             57.91%         27.63%           29.75%         11.58%
  (starved = 0 everywhere, exactly as the audit predicted)
So ~1 in 5 GF/DecayGF learning samples and ~1 in 4 KNN samples carried a WRONG
guess-factor bin. This is a material corruption of the learning signal.

FIX: the same fireTick-keyed ring scheme `tsetlin.nim`/`tm_selector.nim` already
use - `slot = (fireTick*4 + bin) mod 1024` (period 256 ticks, longer than the
~91-tick max flight), looked up by exact key. Public interfaces unchanged; added
`waveResolved`/`waveMispaired` integrity counters. AFTER: mispaired = 0 and
starved = 0, both metrics, all three guns.

EFFECT ON HIT RATE: SMALL AND NOT SIGNIFICANT. bmPath 4000 samples/gun:
  GuessFactor 23.20% -> 23.02% (-0.18pp, per-run sign-flip p=0.750)
  DecayGF     23.80% -> 24.25% (+0.45pp, p=0.625)
  KNN         18.27% -> 18.80% (+0.53pp, p=0.547)
bmPoint: +0.05 / +0.33 / -0.15pp, p = 1.00 / 0.50 / 0.50. Per-run ranges overlap
almost completely. A bullet-level z-test is anti-conservative (bullets within a
fixture share a trajectory) and its KNN p=1.9e-16 cannot be trusted given ~10
effective independent runs.
PLAIN READING: this is a CORRECTNESS fix, not a measurable hit-rate win. It
removes a 36-58% mislabelling of the learning signal; the point estimates move by
at most ~0.5pp, within run-to-run noise. Stated plainly rather than oversold.

A REGRESSION IT CAUGHT IN ITSELF (and this explains the SIGSEGV another job saw
and correctly attributed to a concurrent knn_gun.nim rewrite): the first
implementation put an inline `array[1024, KNNWave]` (~100KB) inside each gun,
which overflowed the default 8MB stack and made `test_power_selection` SIGSEGV.
Causation was proven by stashing only the three gun files (test passed), then
fixed by making the rings heap-backed `seq`. Verified: `test_power_selection`
3 PASS on the default stack, and zero inline `array[1024]` remain.

Guards: test_wave_pairing 17 (new, pure), 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 28.
ModularBot compiles. Adds audit_wave_pairing.nim and compare_pairing.nim.
2026-09-22 01:33:31 +02:00

150 lines
6.0 KiB
Nim

## Task 1 + Task 3 measurement: wave-pairing audit and before/after hit rates for
## the three learned GF guns (GuessFactor / DecayGF / KNN).
##
## Replays the committed DrussGT fixtures through the REAL VirtualTracker, exactly
## as common_libs/gun_harness/offline_range.replayFixture does, but keeps handles
## to the concrete guns so it can read their pairing-audit counters and dump the
## raw per-bullet hit booleans for a later bullet-level permutation test.
##
## Run:
## nim c -r --path:common_libs common_libs/tests/audit_wave_pairing.nim <tag> [metric]
## tag = label written into /tmp/wavepair_<tag>_<metric>.txt
## metric = path (default, shipped) | point | both
import std/[os, math, strformat, tables, algorithm, random]
import gun_harness/gun_interface
import gun_harness/virtual_bullets as vb
import gun_harness/offline_range
import guns/guess_factor
import guns/decay_gf
import guns/knn_gun
const fixturesDir = currentSourcePath().parentDir.parentDir.parentDir / "tools" / "fixtures"
const GunNames = ["GuessFactor", "DecayGF", "KNN"]
type
Ref[G] = ref object
g: G
proc mkRef[G](v: G): Ref[G] = Ref[G](g: v)
proc driver[G](name: string, r: Ref[G]): GunDriver =
result.name = name
result.predictCb = proc(s: WorldState, sp: float): GunPrediction = r.g.predict(s, sp)
result.resultCb = proc(e: FeedbackEvent) = r.g.onResult(e)
result.readyCb = nil
proc collectOne(fx: Fixture, drivers: seq[GunDriver], metric: BulletMetric,
perGunHits: ref seq[seq[bool]]): seq[GunReport] =
let tid = fx.enemyId
var tracker = vb.initTracker(drivers.len, metric)
for si in 0..<fx.states.len:
let state = fx.states[si]
for gi in 0..<drivers.len:
var preds: array[len(PowerBins), GunPrediction]
for i in 0..<len(PowerBins):
preds[i] = drivers[gi].predictCb(state, bulletSpeed(PowerBins[i]))
tracker.spawnBullets(gi, preds, state, tid)
let act = fx.states[si]
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
var lst = act.tick
if si < fx.lastSeen.len and fx.lastSeen[si] >= 0: lst = fx.lastSeen[si]
enemyPositions[tid] = (x: act.enemyX, y: act.enemyY, lastSeenTick: lst, alive: true)
let dref = drivers
tracker.tickBullets(state, enemyPositions,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) =
dref[gunId].resultCb(e)
perGunHits[gunId].add e.hit)
let fit = tracker.fitnessFor(tid)
for gi in 0..<drivers.len:
var r = GunReport(name: drivers[gi].name)
for binIdx in 0..<len(PowerBins):
let fw = fit[gi].bins[binIdx]
let n = min(fw.count, WindowSize)
for k in 0..<n:
if fw.hits[k]: inc r.hits
r.shots += n
result.add r
proc rate(h, n: int): float = (if n == 0: 0.0 else: h.float / n.float * 100.0)
proc runMetric(mname: string, metric: BulletMetric, tag: string) =
echo "################################################################"
echo "### METRIC = ", mname, " (", metric, ")"
echo "################################################################"
var pooled = [0, 0, 0]
var pooledN = [0, 0, 0]
var perGunHits: seq[seq[bool]]
perGunHits.setLen(3)
var perFixtureRate: array[3, seq[float]]
var files: seq[string]
for f in walkFiles(fixturesDir / "tr_drussgt_*.jsonl"): files.add f
for f in walkFiles(fixturesDir / "drussgt_*.jsonl"): files.add f
files.sort()
# Totals accumulated from FRESH guns per fixture (no cross-fixture learning).
var totResolved, totMispaired, totStarved, totPushes: array[3, int]
for path in files:
var fx = loadFixture(path)
if fx.states.len < 50: continue
let gfRef = mkRef(initGFGun())
let decRef = mkRef(initDecayGFGun())
let knnRef = mkRef(initKNNGun())
let drivers = @[driver("GuessFactor", gfRef), driver("DecayGF", decRef),
driver("KNN", knnRef)]
let thisHits = new(seq[seq[bool]])
thisHits[].setLen(3)
let reps = collectOne(fx, drivers, metric, thisHits)
var line = fmt"{extractFilename(path):<42}"
for gi in 0..<3:
let r = reps[gi]
pooled[gi] += r.hits; pooledN[gi] += r.shots
perFixtureRate[gi].add rate(r.hits, r.shots)
for h in thisHits[gi]: perGunHits[gi].add h
line.add fmt" {GunNames[gi]}={r.hits:>4}/{r.shots:<4}"
echo line
totResolved[0] += gfRef.g.waveResolved; totMispaired[0] += gfRef.g.waveMispaired
totMispaired[0] += gfRef.g.waveMispaired; totStarved[0] += gfRef.g.waveStarved
totPushes[0] += gfRef.g.wavePushes
totResolved[1] += decRef.g.waveResolved; totMispaired[1] += decRef.g.waveMispaired
totStarved[1] += decRef.g.waveStarved
totPushes[1] += decRef.g.wavePushes
totResolved[2] += knnRef.g.waveResolved; totMispaired[2] += knnRef.g.waveMispaired
totStarved[2] += knnRef.g.waveStarved
totPushes[2] += knnRef.g.wavePushes
echo ""
echo "── pooled hit rate (", mname, ") ──"
for gi in 0..<3:
echo fmt"{GunNames[gi]:<12} {pooled[gi]:>5}/{pooledN[gi]:<6} {rate(pooled[gi], pooledN[gi]):>6.2f}% per-fixture min/max {min(perFixtureRate[gi]):.1f}/{max(perFixtureRate[gi]):.1f}"
echo ""
echo "── pairing audit (", mname, ") ──"
echo "gun resolved mispaired mispair% starved pushes"
for gi, name in GunNames:
echo fmt"{name:<12} {totResolved[gi]:>8} {totMispaired[gi]:>10} {rate(totMispaired[gi], totResolved[gi]):>8.2f} {totStarved[gi]:>7} {totPushes[gi]:>6}"
# Dump per-gun hit booleans for the cross-build permutation test.
for gi in 0..<3:
let path = fmt"/tmp/wavepair_{tag}_{mname}_{GunNames[gi]}.txt"
var f = open(path, fmWrite)
defer: f.close()
for h in perGunHits[gi]:
f.writeLine(if h: "1" else: "0")
echo fmt"dumped {perGunHits[gi].len} outcomes -> {path}"
proc main() =
let tag = if paramCount() >= 1: paramStr(1) else: "run"
let metricArg = if paramCount() >= 2: paramStr(2) else: "path"
case metricArg
of "point": runMetric("point", bmPoint, tag)
of "both": (runMetric("path", bmPath, tag); runMetric("point", bmPoint, tag))
else: runMetric("path", bmPath, tag)
echo "\ndone."
when isMainModule:
randomize(1)
main()