4657fe715e
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.
150 lines
6.0 KiB
Nim
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()
|