Boot-time env report + fix the env reference

The bot is spawned by the server/GUI, so it inherits the SERVER's
environment. The user could not tell whether their exports reached the
bot, so print a one-shot greppable report at boot:

  grep '^\[env\]' /tmp/modularbot_stdout.log

Section A prints every TR_*/GUN_* this process actually received, the
count vs the total env size, a loud warning when nothing matched, and
the process identity (pid/ppid, cwd, self command line, and the PARENT
command line) so the spawn trap is obvious. Section B prints the
resolved effective value of every documented knob with its source
(env|default), including clamps and the rack's empty-set fallback.
Build identity (NimVersion, compile date/time, binary path/size/mtime)
pins the exact artifact. Suppress with TR_ENV_REPORT=0.

docs/env_reference.md: add the missing GUN_SHOTLOG_PATH,
GUN_SELECTOR_MINOBS/FLOOR/POOL/RANK/SHRINK/SEED, TR_ENV_REPORT and
-d:TM_NCLAUSES; record the measured TR_TMHORIZON_WINDOW verdict; and
add a prominent 'Did my env vars actually reach the bot?' section with
the boot report, the /proc/PID/environ no-code check, the correct GUI
launch recipe, and how to prove the trap deliberately.
This commit is contained in:
2026-09-23 08:21:25 +02:00
parent 7f6ccfb015
commit 9bf3005850
3 changed files with 453 additions and 1 deletions
+334
View File
@@ -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/<pid>/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..<len(RackGunNames):
let name = "TR_RACK_" & RackGunNames[i]
emit(name, rackMembershipName(ActiveRackMembership[i]), sourceOf(name))
echo "[env] rack active 1v1 = ", rackActive(ActiveRackMembership, rm1v1)
echo "[env] rack active melee = ", rackActive(ActiveRackMembership, rmMelee)
# ── the selector (virtual-bullet fitness) ─────────────────────────────────
emit("GUN_VBULLET_METRIC", metricName(ActiveMetric), sourceOf("GUN_VBULLET_METRIC"))
emit("GUN_SELECTOR_MODE", selectorModeName(ActiveSelectorMode), sourceOf("GUN_SELECTOR_MODE"))
emit("GUN_SELECTOR_WINDOW", $ActiveWindow, sourceOf("GUN_SELECTOR_WINDOW"))
emit("GUN_SELECTOR_MINOBS", $ActiveMinObs, sourceOf("GUN_SELECTOR_MINOBS"))
emit("GUN_SELECTOR_TIE", $ActiveRelTie, sourceOf("GUN_SELECTOR_TIE"))
emit("GUN_SELECTOR_FLOOR", $ActiveFloorFrac, sourceOf("GUN_SELECTOR_FLOOR"))
emit("GUN_SELECTOR_POOL", onOff(ActivePooled), sourceOf("GUN_SELECTOR_POOL"))
emit("GUN_SELECTOR_RANK", rankName(ActiveRank), sourceOf("GUN_SELECTOR_RANK"))
emit("GUN_SELECTOR_SHRINK", $ActiveShrink, sourceOf("GUN_SELECTOR_SHRINK"))
emit("GUN_SELECTOR_DWELL", $ActiveDwellTicks, sourceOf("GUN_SELECTOR_DWELL"))
emit("GUN_SELECTOR_MARGIN", $ActiveSwitchMargin, sourceOf("GUN_SELECTOR_MARGIN"))
emit("GUN_SELECTOR_TIEBREAK", tieBreakName(ActiveTieBreak), sourceOf("GUN_SELECTOR_TIEBREAK"))
emit("GUN_SELECTOR_POINT_TIE", $ActivePointTie, sourceOf("GUN_SELECTOR_POINT_TIE"))
# ── the power policy ──────────────────────────────────────────────────────
emit("TR_POWER_POLICY", onOff(PowerPolicyEnabled), sourceOf("TR_POWER_POLICY"))
emit("TR_POWER_FAR_DIST", $PowerFarDist, sourceOf("TR_POWER_FAR_DIST"))
emit("TR_POWER_FAR_CAP", $PowerFarCap, sourceOf("TR_POWER_FAR_CAP"))
emit("TR_POWER_MID_CAP", $PowerMidCap, sourceOf("TR_POWER_MID_CAP"))
emit("TR_POWER_REF", $PowerRefFixed, sourceOf("TR_POWER_REF"))
emit("TR_POWER_ENERGY_HI", $PowerEnergyHi, sourceOf("TR_POWER_ENERGY_HI"))
emit("TR_POWER_ENERGY_LO", $PowerEnergyLo, sourceOf("TR_POWER_ENERGY_LO"))
emit("TR_POWER_ENERGY_MIN", $PowerEnergyMin, sourceOf("TR_POWER_ENERGY_MIN"))
emit("TR_POWER_ENERGY_MAX", $PowerEnergyMax, sourceOf("TR_POWER_ENERGY_MAX"))
emit("TR_POWER_FINISH_KILL", onOff(PowerFinishKill),
sourceOf("TR_POWER_FINISH_KILL"))
# ── movement (TFIL ring) ──────────────────────────────────────────────────
emit("TR_TFIL_RANGE_LO", $RangeLo, sourceOf("TR_TFIL_RANGE_LO"))
emit("TR_TFIL_RANGE_HI", $RangeHi, sourceOf("TR_TFIL_RANGE_HI"))
emit("TR_TFIL_RANGE_TEMP", $RangeTemp, sourceOf("TR_TFIL_RANGE_TEMP"))
emit("TR_TFIL_RANGE_K", $RangeK, sourceOf("TR_TFIL_RANGE_K"))
emit("TR_TFIL_CORRIDOR_HEAT", $CorridorHeat, sourceOf("TR_TFIL_CORRIDOR_HEAT"))
emit("TR_TFIL_WALL_HOTNESS", $WallHotness, sourceOf("TR_TFIL_WALL_HOTNESS"))
# ── ramming ───────────────────────────────────────────────────────────────
emit("TR_RAM_OPPORTUNITY", onOff(RamOppEnabled), sourceOf("TR_RAM_OPPORTUNITY"))
emit("TR_RAM_OPP_DIST", $RamOppDist, sourceOf("TR_RAM_OPP_DIST"))
emit("TR_RAM_OPP_MARGIN", $RamOppMargin, sourceOf("TR_RAM_OPP_MARGIN"))
emit("TR_RAM_ABORT_DMG", $RamAbortDmg, sourceOf("TR_RAM_ABORT_DMG"))
emit("TR_RAM_PLAN", onOff(RamPlanEnabled), sourceOf("TR_RAM_PLAN"))
emit("TR_RAM_PLAN_DIST", $RamPlanDist, sourceOf("TR_RAM_PLAN_DIST"))
emit("TR_RAM_PLAN_MARGIN", $RamPlanMargin, sourceOf("TR_RAM_PLAN_MARGIN"))
emit("TR_RAM_PLAN_HITRATE", $RamPlanHitRate, sourceOf("TR_RAM_PLAN_HITRATE"))
emit("TR_RAM_LOG", onOff(RamLog), sourceOfPresence("TR_RAM_LOG"))
# ── the horizon TM gun ────────────────────────────────────────────────────
# `resetLearning`/`targetChanged` resolve the lazily-read fields at round
# start; force them here on a COPY so the report reads real resolved values
# without touching the live gun's state.
var tmh = ctx.tmHorizon
discard tmh.targetChanged(-1)
emit("TR_TMHORIZON_SHIFT", $tmh.shiftDeg, sourceOf("TR_TMHORIZON_SHIFT"))
emit("TR_TMHORIZON_BIG_MULT", $tmh.bigMult, sourceOf("TR_TMHORIZON_BIG_MULT"))
emit("TR_TMHORIZON_LOG", onOff(tmh.logEnabled), sourceOfPresence("TR_TMHORIZON_LOG"))
emit("TR_TMHORIZON_RESET_ON_TARGET", onOff(tmh.resetOnTarget),
sourceOf("TR_TMHORIZON_RESET_ON_TARGET"))
emit("TR_TMHORIZON_NSTATES", $tmh.nStates, sourceOf("TR_TMHORIZON_NSTATES"))
emit("TR_TMHORIZON_WINDOW", $tmh.windowN, sourceOf("TR_TMHORIZON_WINDOW"))
emit("TR_TMHORIZON_RESET_DROP", $tmh.resetDrop, sourceOf("TR_TMHORIZON_RESET_DROP"))
emit("TR_TMHORIZON_ACCURVE", onOff(tmh.accurveEnabled),
sourceOfPresence("TR_TMHORIZON_ACCURVE"))
emit("TR_TMHORIZON_RETRAIN_EVERY", $tmh.retrainEvery,
sourceOf("TR_TMHORIZON_RETRAIN_EVERY"))
emit("TR_TMHORIZON_EPOCHS", $tmh.retrainEpochs, sourceOf("TR_TMHORIZON_EPOCHS"))
# ── Pattern radial knobs ──────────────────────────────────────────────────
# These are resolved lazily inside `predict` (which has not run at boot), so
# unless something already forced them we report the raw env value and say so.
if ctx.patternMatcher.radConfigured:
emit("TR_PATTERN_RAD_OFFSET", $ctx.patternMatcher.radOffset, sourceOf("TR_PATTERN_RAD_OFFSET"))
emit("TR_PATTERN_RAD_SCALE", $ctx.patternMatcher.radScale, sourceOf("TR_PATTERN_RAD_SCALE"))
else:
echo "[env] TR_PATTERN_RAD_OFFSET = ",
getEnv("TR_PATTERN_RAD_OFFSET", "0.0"), " (raw - not resolved here)"
echo "[env] TR_PATTERN_RAD_SCALE = ",
getEnv("TR_PATTERN_RAD_SCALE", "1.0"), " (raw - not resolved here)"
# ── the report's own switch ───────────────────────────────────────────────
emit(EnvReportEnableEnvVar, getEnv(EnvReportEnableEnvVar, "1"),
sourceOf(EnvReportEnableEnvVar))
# ── build identity (which binary is the GUI actually running?) ───────────────
proc printBuildIdentity() =
## The user has been bitten by a stale binary. CompileDate/CompileTime are
## available at runtime (even if a reproducible-build toolchain pins them),
## and the binary path + mtime + size pin down the exact artifact.
echo "[env] --- build identity ---"
echo "[env] build nim=", NimVersion,
" compileDate=", CompileDate, " compileTime=", CompileTime
let bin = getAppFilename()
var size = -1
var mtime = "unknown"
if fileExists(bin):
try:
size = getFileSize(bin)
mtime = $getLastModificationTime(bin)
except CatchableError:
discard
echo "[env] build binary=", bin, " size=", size, " mtime=", mtime
# ── the entry point ──────────────────────────────────────────────────────────
var envReportDone = false
proc printEnvReport*(ctx: EnvReportContext) =
## Print the whole report exactly once per process, to stdout. Suppressed
## (with one visible note) by TR_ENV_REPORT=0/false/no/off.
if envReportDone: return
envReportDone = true
let raw = getEnv(EnvReportEnableEnvVar, "").strip().toLowerAscii()
if raw in ["0", "false", "no", "off"]:
echo "[env] report suppressed by ", EnvReportEnableEnvVar, "=", raw,
" (unset or 1 to enable)"
return
echo "[env] === ENVIRONMENT (boot report) ==="
printRawEnvironment()
printEffectiveValues(ctx)
printBuildIdentity()
echo "[env] === END ENVIRONMENT ==="