diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 1e1fc0a..e55f8a2 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -36,6 +36,7 @@ import movement_harness/virtual_bodies as mvb import movement_harness/bullet_shadows import targeting/enemy_tracker import targeting/target_selector +import env_report const botJsonPath = currentSourcePath().parentDir / "ModularBot.json" const DebugVBullets = false @@ -1261,4 +1262,23 @@ when isMainModule: # Must run AFTER the gun constructors (Tsetlin/TMSelector call randomize() # unconditionally) so the explicit seed override is the value that survives. seedSelectorRng() + # Boot-time environment report (once per process, stdout). Prints what env + # this process actually inherited, the resolved effective value of every + # documented knob, and the build identity behind `grep '^\[env\]'`. + printEnvReport(EnvReportContext( + movementName: MovementName, + vBulletAdmitOnly: VBulletAdmitOnly, + recordWorldState: RecordWorldState, + radarForceSpin: RadarForceSpin, + radarScanLog: RadarScanLog, + radarScanLogPath: RadarScanLogPath, + trackerProbe: TrackerProbe, + trackerProbePath: TrackerProbePath, + powerLog: PowerLog, + gunStatsPath: GunStatsPath, + shotLogPath: ShotLogPath, + disabledGuns: DisabledGuns, + tmHorizon: bot.tmHorizon, + patternMatcher: bot.patternMatcher, + )) start(bot, botJsonPath) diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim new file mode 100644 index 0000000..8739332 --- /dev/null +++ b/ModularBot_garage/src/env_report.nim @@ -0,0 +1,334 @@ +## Boot-time environment report for ModularBot. +## +## The user runs the bot behind the SERVER/GUI, so the bot inherits the +## SERVER's environment — not the environment of whatever terminal they +## exported a variable in. This report answers, once per process at boot and +## in stdout, two questions: +## +## A. What TR_*/GUN_* variables did THIS process actually receive? (plus the +## process identity — pid, parent pid, parent command line — so "I exported +## it in the wrong terminal" is obvious at a glance). +## B. For every documented knob, what value will the bot actually use and did +## it come from the env or from the shipped default? +## +## Every line starts with `[env]` so the whole interface is: +## +## grep '^\[env\]' /tmp/modularbot_stdout.log +## +## It runs once (idempotent guard) and can be suppressed with TR_ENV_REPORT=0. +## It deliberately does NOT dump the whole environment: only TR_*/GUN_* are +## printed, because unrelated variables may hold secrets or machine paths. + +import std/[os, strutils, algorithm, times, sets] +import gun_harness/virtual_bullets +import gun_harness/selector +import movements/ram_decision +import movements/the_floor_is_lava_ring +import guns/tm_horizon +import guns/pattern_matcher + +const EnvReportEnableEnvVar* = "TR_ENV_REPORT" + +# ── resolved values that live in ModularBot.nim (not importable here) ───────── + +type + EnvReportContext* = object + ## Everything the report needs that ModularBot owns. Values are the + ## RESOLVED process-start values, not re-reads, so the report cannot + ## diverge from what the bot will actually use. + movementName*: string + vBulletAdmitOnly*: bool + recordWorldState*: bool + radarForceSpin*: bool + radarScanLog*: bool + radarScanLogPath*: string + trackerProbe*: bool + trackerProbePath*: string + powerLog*: bool + gunStatsPath*: string + shotLogPath*: string + disabledGuns*: HashSet[int] + tmHorizon*: TmHorizonGun + patternMatcher*: PatternMatcherGun + +# ── helpers ────────────────────────────────────────────────────────────────── + +proc onOff(b: bool): string {.inline.} = (if b: "on" else: "off") + +proc metricName(m: BulletMetric): string = + case m + of bmPoint: "point" + of bmPath: "path" + +proc selectorModeName(m: SelectorMode): string = + case m + of smAbsolute: "absolute" + of smRelative: "relative" + +proc rankName(r: RankStat): string = + ## The word `GUN_SELECTOR_RANK` accepts on the command line, not the enum. + case r + of rsMean: "mean" + of rsWilson: "wilson" + of rsUCB: "ucb" + of rsThompson: "thompson" + of rsShrunk: "shrunk" + +proc tieBreakName(t: TieBreakMode): string = + case t + of tbOff: "off" + of tbPoint: "point" + of tbPointCommit: "pointCommit" + +proc sourceOf(name: string): string = + ## Parsed-value knobs go through `getEnv(name, "")`; an empty value means the + ## shipped default, so a non-empty value is the only thing that counts as + ## "from the env". + if getEnv(name, "").len > 0: "env" else: "default" + +proc sourceOfPresence(name: string): string = + ## Flags read with `existsEnv` are "from the env" whenever they are present, + ## even if their value is "0" (presence is what the code tests). + if existsEnv(name): "env" else: "default" + +proc disabledGunsString(guns: HashSet[int]): string = + if guns.len == 0: return "none" + var ids: seq[int] + for id in guns: ids.add id + ids.sort() + for i, id in ids: + if i > 0: result.add "," + result.add $id + +proc emit(name, value, source: string) = + echo "[env] ", name, " = ", value, " (source: ", source, ")" + +proc readProcFile(path: string): string = + ## Best-effort /proc read; never raises, returns "" when unavailable. + try: readFile(path) + except CatchableError: "" + +proc ppidFromStatus(): int = + for line in readProcFile("/proc/self/status").splitLines(): + if line.startsWith("PPid:"): + let parts = line.splitWhitespace() + if parts.len >= 2: + try: return parseInt(parts[1]) + except ValueError: return -1 + -1 + +proc cmdlineOf(pid: int): string = + ## NUL-separated argv from /proc//cmdline, flattened for display. + if pid < 0: return "(unavailable)" + let raw = readProcFile("/proc/" & $pid & "/cmdline") + if raw.len == 0: return "(unavailable)" + result = raw.replace('\0', ' ').strip() + if result.len == 0: result = "(available but empty)" + +proc selfCmdline(): string = + let raw = readProcFile("/proc/self/cmdline") + if raw.len == 0: + return getAppFilename() + result = raw.replace('\0', ' ').strip() + if result.len == 0: result = getAppFilename() + +# ── section A: the raw process environment + process identity ──────────────── + +proc printRawEnvironment() = + echo "[env] --- A. raw process environment (what THIS process really has) ---" + + var rawVars: seq[(string, string)] + var total = 0 + for key, val in envPairs(): + inc total + if key.startsWith("TR_") or key.startsWith("GUN_"): + rawVars.add (key, val) + rawVars.sort(proc(a, b: (string, string)): int = cmp(a[0], b[0])) + + for (key, val) in rawVars: + echo "[env] ", key, "=", val + + echo "[env] raw: ", rawVars.len, " TR_*/GUN_* of ", total, + " total environment variables" + + if rawVars.len == 0: + echo "[env] WARNING: this process has NO TR_*/GUN_* variables - the env you " & + "exported did NOT reach the bot. See docs/env_reference.md." + + # Process identity. The parent is the process that SPAWNED the bot: while + # running under the GUI it is the SERVER/GUI, never the user's interactive + # shell — which is exactly why exporting in an unrelated terminal does nothing. + let pid = getCurrentProcessId() + let ppid = ppidFromStatus() + echo "[env] pid=", pid, " ppid=", ppid + echo "[env] cwd=", getCurrentDir() + echo "[env] self_cmd=", selfCmdline() + echo "[env] parent_cmd=", cmdlineOf(ppid) + +# ── section B: effective values ────────────────────────────────────────────── + +proc printEffectiveValues(ctx: EnvReportContext) = + echo "[env] --- B. effective values (resolved at boot) ---" + + # ── movement / process-level ModularBot knobs ───────────────────────────── + # Only `tfil_ring` selects the ring engine; every other value runs `tfil`, so + # report the RESOLVED engine rather than the raw string. + let effectiveMovement = if ctx.movementName == "tfil_ring": "tfil_ring" else: "tfil" + emit("TR_MOVEMENT", effectiveMovement, sourceOf("TR_MOVEMENT")) + emit("TR_MOVEMENT_LOG", onOff(MovementLog), sourceOfPresence("TR_MOVEMENT_LOG")) + emit("TR_VBULLET_ADMIT_ONLY", onOff(ctx.vBulletAdmitOnly), + sourceOf("TR_VBULLET_ADMIT_ONLY")) + emit("TR_POWER_LOG", onOff(ctx.powerLog), sourceOfPresence("TR_POWER_LOG")) + emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState), + sourceOfPresence("TR_RECORD_WORLDSTATE")) + emit("TR_RADAR_FORCE_SPIN", onOff(ctx.radarForceSpin), + sourceOfPresence("TR_RADAR_FORCE_SPIN")) + emit("TR_RADAR_SCANLOG", onOff(ctx.radarScanLog), + sourceOfPresence("TR_RADAR_SCANLOG")) + emit("TR_RADAR_SCAN_LOG_PATH", ctx.radarScanLogPath, + sourceOf("TR_RADAR_SCAN_LOG_PATH")) + emit("TR_TRACKER_PROBE", onOff(ctx.trackerProbe), + sourceOfPresence("TR_TRACKER_PROBE")) + emit("TR_TRACKER_PROBE_PATH", ctx.trackerProbePath, + sourceOf("TR_TRACKER_PROBE_PATH")) + emit("GUN_STATS_PATH", ctx.gunStatsPath, sourceOf("GUN_STATS_PATH")) + emit("GUN_SHOTLOG_PATH", ctx.shotLogPath, sourceOf("GUN_SHOTLOG_PATH")) + let seed = getEnv("GUN_SELECTOR_SEED", "") + if seed.len > 0: + emit("GUN_SELECTOR_SEED", seed, "env") + else: + emit("GUN_SELECTOR_SEED", "(unset -> time+pid)", "default") + + # ── rack ────────────────────────────────────────────────────────────────── + emit("GUN_RACK_DISABLE", disabledGunsString(ctx.disabledGuns), + sourceOf("GUN_RACK_DISABLE")) + for i in 0..` | `PATTERN=both`, all others `off` | membership: `both` \| `1v1` \| `melee` \| `off` | | `GUN_RACK_DISABLE` | empty | comma-separated gun **ids** to remove entirely (older mechanism; the gun never even spawns virtual bullets) | | `GUN_STATS_PATH` | `/tmp/gun_stats.jsonl` | where the per-round per-gun stats are written | +| `GUN_SHOTLOG_PATH` | `/tmp/shot_log.jsonl` | where the per-shot JSONL (Task A) is written | `` names: `HEADON LINEAR TSETLIN CIRCULAR GUESSFACTOR PATTERN WALLBOUNCE ACCEL STOPSHOT DISPLACE AVGLEAD DECAYGF KNN TMSELECT TMPATTERN TMHORIZON`. @@ -56,8 +146,14 @@ If **every** gun is `off`, the rack falls back to admitting all of them. | `GUN_SELECTOR_MODE` | `relative` | tie-band model: `relative` (scale-aware) or `absolute` (legacy fixed bars) | | `GUN_SELECTOR_WINDOW` | `100` | rolling window (ticks) for the fitness rates | | `GUN_SELECTOR_TIE` | `0.20` | tie band width: tied if `rate >= bestRate*(1-this)` | +| `GUN_SELECTOR_FLOOR` | `0.25` | relative floor: fire HeadOn only if best rate < this × its peak | +| `GUN_SELECTOR_MINOBS` | `50` | min observations before a gun×bin may compete (clamped to ≥1) | +| `GUN_SELECTOR_POOL` | `on` | pool the gun's power bins when computing its rate | +| `GUN_SELECTOR_RANK` | `mean` | window statistic: `mean` \| `wilson` \| `ucb` \| `thompson` \| `shrunk` | +| `GUN_SELECTOR_SHRINK` | `20.0` | empirical-Bayes shrink strength (`shrunk` rank only) | | `GUN_SELECTOR_DWELL` | `10` | ticks the incumbent gun is held before it may be displaced | | `GUN_SELECTOR_MARGIN` | `0.05` | a challenger must beat the incumbent by this fraction to displace it | +| `GUN_SELECTOR_SEED` | unset | integer seed for the tie-break RNG; unset = time+pid per process | | `GUN_SELECTOR_TIEBREAK` | `off` | `off` (shipped) / `point` / `pointCommit`. Measured **negative** on real hit rate; opt-in only | | `GUN_SELECTOR_POINT_TIE` | `0.5` | margin used by the `point` tie-break | @@ -130,7 +226,7 @@ only path that converts. | `TR_TMHORIZON_BIG_MULT` | `1.5` | multiplier when the predicted correction is BIG | | `TR_TMHORIZON_NSTATES` | `64` | **automata inertia** — the "mood". Lower = adapts faster, higher = more stubborn (2…4096) | | `TR_TMHORIZON_RESET_ON_TARGET` | on | wipe the model when the target changes (no-op in 1v1) | -| `TR_TMHORIZON_WINDOW` | `0` | sliding window: retrain on only the most recent N samples. `0` = keep everything | +| `TR_TMHORIZON_WINDOW` | `0` | sliding window: retrain on only the most recent N samples. `0` = keep everything. **ON is MEASURED HARMFUL live: 26.5% vs 49.0% round wins, p=0.036 — keep it at `0`.** | | `TR_TMHORIZON_RESET_DROP` | `0` | change detection: if rolling accuracy falls this many points below its own peak, treat it as "the enemy changed" and re-learn | | `TR_TMHORIZON_ACCURVE` | off | `1` = log the rolling accuracy periodically | | `TR_TMHORIZON_RETRAIN_EVERY` | `50` | samples between windowed retrains | @@ -156,6 +252,7 @@ bearing-only, so a purely radial offset is invisible. Measured byte-identical on | variable | default | what it does | |---|---|---| | `TR_VBULLET_ADMIT_ONLY` | `1` | only admitted guns spawn virtual bullets. **Turning it on gave +68% tick rate** (87→146 ticks/s); `0` restores the old behaviour | +| `TR_ENV_REPORT` | `1` | print the boot-time `[env]` report to stdout; `0` suppresses it | | `TR_RECORD_WORLDSTATE` | off | append every tick's WorldState to `/tmp/worldstate_record.jsonl` for offline replay | | `TR_RADAR_FORCE_SPIN` | off | force the old stateless full-spin melee radar (for A/B on one binary) | | `TR_RADAR_SCANLOG` | off | append per-round radar scan/coverage JSON | @@ -181,6 +278,7 @@ bearing-only, so a purely radial offset is invisible. Measured byte-identical on | `-d:TMH_STALE_MAX=N` | 8 | a sample is dropped if the enemy was not seen this recently | | `-d:TM_S_DEF=x` | `"3.0"` | `guns/tm_pattern.nim` specificity (`s`). **Higher = LONGER clauses**, measured. `s=1.0` is degenerate | | `-d:TM_NSTATES=N` | 64 | `guns/tm_pattern.nim` | +| `-d:TM_NCLAUSES=N` | 40 | `guns/tm_pattern.nim` — clauses per class | ## Adding / finding knobs