## Shared helper: build the 13 ModularBot guns as type-erased offline range ## drivers, in ModularBot's gun-id order, with the same readiness gate the live ## loop uses (Tsetlin only spawns once its 10-frame window is full). import std/random import gun_harness/offline_range import guns/head_on import guns/linear import guns/circular import guns/tsetlin import guns/guess_factor import guns/pattern_matcher import guns/wall_bounce import guns/accel_predictor import guns/stop_shot import guns/displacement import guns/averaged_lead import guns/decay_gf import guns/knn_gun import guns/tm_selector proc buildAllGunDrivers*(seed = -1, enableTmSelector = false): seq[GunDriver] = ## seed >= 0 re-seeds the global RNG after constructing the stochastic guns ## (Tsetlin and the TM selector both call randomize() in their constructors), ## so their learning is reproducible for offline runs. ## ## Order matches ModularBot's gun ids exactly (TMSelect appended at 13). ## ## `enableTmSelector` must MIRROR the live rack. The shipped ModularBot has ## `EnableTmSelector = false`, so the live loop never spawns gun-13 virtual ## bullets. The replay's shared VirtualTracker ring is ORDER-SENSITIVE: extra ## gun-13 spawns shift the ring head and permute the per-tick resolution ORDER ## of every other gun, which scrambles the `obs` insertion order of the ## learning guns (KNN, DecayGF) and shifts their predictions. That is exactly ## the defect the acceptance test was fixed for (commit 4cd5618): with gun 13 ## spawning offline, the live and offline KNN traces diverged; with its ready ## gate closed they became byte-identical. ## ## The DEFAULT is therefore `false` (mirror the shipped rack). Only a caller ## that is deliberately measuring TMSelect as a gun should pass `true`; the ## default must never silently inject a gun the shipped bot does not spawn, ## because the corruption lands on the OTHER guns' numbers. var tsetlin = initTsetlinGun() var tmSelector = initTmSelectorGun() if seed >= 0: randomize(seed) result = @[ makeDriver("HeadOn", HeadOnGun()), makeDriver("Linear", LinearGun()), makeDriver("Tsetlin", tsetlin), makeDriver("Circular", CircularGun()), makeDriver("GuessFactor", initGFGun()), makeDriver("Pattern", PatternMatcherGun()), makeDriver("WallBounce", initWallBounceGun()), makeDriver("Accel", initAccelGun()), makeDriver("StopShot", initStopShotGun()), makeDriver("Displace", initDisplacementGun()), makeDriver("AvgLead", initAveragedLeadGun()), makeDriver("DecayGF", initDecayGFGun()), makeDriver("KNN", initKNNGun()), makeDriver("TMSelect", tmSelector), ] if not enableTmSelector: ## Same effect as the live `if EnableTmSelector` gate: never spawn gun 13. ## Keep the slot so gun ids / report indices are unchanged. result[13].readyCb = proc(): bool = false proc makeTsetlinDriver*(seed = -1): tuple[driver: GunDriver, gun: ref TsetlinGun] = ## Same as makeDriver("Tsetlin", ...) but keeps a handle to the concrete gun, ## so a test can read its clause-sparsity / correction instrumentation after a ## replay. `makeDriver` heap-boxes a copy internally and drops the handle. let g = new(TsetlinGun) g[] = initTsetlinGun() if seed >= 0: randomize(seed) result.gun = g result.driver = GunDriver( name: "Tsetlin", predictCb: proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed), resultCb: proc(e: FeedbackEvent) = g[].onResult(e), readyCb: proc(): bool = g[].isWarmedUp(), ) proc makeTmSelectorDriver*(seed = -1): tuple[driver: GunDriver, gun: ref TmSelectorGun] = ## Same as makeDriver("TMSelect", ...) but keeps a handle to the concrete gun ## so a test can inspect its votes / clause interpretability after a replay. let g = new(TmSelectorGun) g[] = initTmSelectorGun() if seed >= 0: randomize(seed) result.gun = g result.driver = GunDriver( name: "TMSelect", predictCb: proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed), resultCb: proc(e: FeedbackEvent) = g[].onResult(e), readyCb: proc(): bool = g[].isWarmedUp(), )