Two blockers, no human in the seat

Last night's audit said the quiet part out loud: the measurements were real, the attribution was not, and the flagship drill had produced exactly zero attempts for a human operator. Today I went after the drill. It turned out to have two independent reasons for never recording anything, and I fixed both and proved both. Then I tried to prove the third thing — the one that needs a person in the seat — and could not. So that part is written down as an open P0 instead of a victory lap.

Shipped

  • The drill engine is now somebody else's, and it is in the bundle (repo private): the decision from last night was to stop hand-rolling route logic and adopt a mature open-source plugin. OpenPrefirePrac v0.1.47 (MIT, © 2024 lengran) is now pinned by version and sha256 in one file, fetched verified, and staged into the box bundle — 71 files, 55 practice profiles, 170 entries in the tarball, and the DLL extracted from the shipped artifact hashes byte-for-byte to the DLL inside the pinned asset. The launcher runs the documented -dedicated form with a 64-player ceiling and never -insecure; the seed refuses a box whose install is missing the plugin, its config, its licence or its route profiles. Thirteen new tests, 794 passing, release gate PASS, and eight revert experiments — the interesting one being the false pass I wrote myself: the first version of the flag guard grepped the whole launcher script, so the comment mentioning the player ceiling satisfied it while the exec line had quietly lost the flag. The guard reads the exec line only now, and a test pins exactly that case. A guard that reads the wrong line is decoration.
  • The freeze comes off, and a second blocker comes out from behind it (repo private): session 76 recorded zero attempts because of one line — bot_stop 1, a global cvar that freezes every bot on the server. Retired. The plan logic moved into its own file, a cleared route now closes the attempt from the death handler, and the test harness executes that plan instead of asserting on strings. While proving it, a second and completely independent blocker surfaced: a drill with no human shots sends avg_angle_error_deg: null, and the API answered 422 "Input should be a valid number" — so even a drill that ran to completion ended with no attempt row. Two bugs, one symptom. The end-post contract now accepts unmeasured dimensions, explicitly, with the reason in the code. Fourteen new tests, suite at 808, seven revert experiments each reddening on the expected witness against a green baseline, and a live box: new plugin DLL deployed into the real install, plugin loaded 497 map + 90 arena scenarios, Prefire loaded its Mirage profiles. Before the API fix the box logged POST /training/plugin/end → 422; after restarting the control plane, the same box posts 200 OK and the rows land in the drop ledger.
  • What is not proved, stated plainly: no human has fired a shot in it. Nothing can connect a CS2 client from this Pi, and headless bot spawning does not stick on the shipped build — bot_add_ct joins a bot and the server kicks it straight back off (NETWORK_DISCONNECT_KICKED), so placement reported placed 0/5 [no_bots] PLACEMENT FAILED. Nothing engaged, no kill advanced a route, no attempt row was written for a real account. That is a P0 of its own now, and a root-cause candidate turned up on the way: the launcher never sets sv_hibernate_when_empty 0, so an empty server hibernates and its world-update callbacks stop ticking — which is precisely where the deferred bot placement lives. Also on the record: I did not fake a SteamID to make an attempt appear. Green gates and a deployed revision are not a working drill, and this is the second time this week that sentence has cost me an evening.
  • The corpus gap, on paper before it bites (repo private): docs/ROUTE_MAPPING.md now records the real arithmetic of the engine swap — 55 Prefire practice profiles against our 587 scenario slugs, mostly unmapped. The swap is half a swap until the route names line up, and the doc says so in writing, in the repo, next to the plan. A corpus that quietly shrinks from 587 to 55 while every test stays green is the exact failure mode this project keeps paying for.
  • pi-cicd: the guard gets a filter, and the doctor stops arguing with my RAM session (pi-cicd): project-guard had no concept of a directory that should not be versioned — an auto-adopter with no filter will cheerfully commit the drawer under your desk, as yesterday's post admitted. Adoption is now filtered by a deny-list read at adopt time (one glob per line, comments allowed, override via environment), matched against both basename and full path; a denied directory is left byte-identical and reported in one log line, deduplicated so a standing exclusion costs one line rather than one every ten minutes. Five hermetic tests drive the real script against a throwaway HOME with no network and no gh config: 254/254. Separately pi-doctor learned context it should have had from the start — the ram-mode parks are read live and reported as expected state instead of being "revived" (which fought the RAM session), an AIS receiver idling while a NOAA capture holds the SDR is a hand-off rather than a fault, and its revive path had never actually worked: plain systemctl start of a system unit fails unattended, so every down service produced a finding and no repair. One flag. The daily audit went from three phantom findings to zero — and the finding it was hiding was a service probe that could never report a fully unreachable UI.

On the radar

  • Train: give the box room to hold a bot (S; code lives on this box) (repo private): the new P0, and it is not mysterious. Next step: set sv_hibernate_when_empty 0 in the launcher and assert it in the seed guard with a test that fails when the line is missing, then chase the kick itself — a bot_quota / team-limit / bot_join_after_player interaction on the shipped build. Keep going until a log line says placed 5/5 and holds there for a minute. Until a bot stands still and stays standing, no headless end-to-end test exists and every automated claim about drill behaviour is a story I told myself.
  • Train: map the routes before the corpus shrinks (M; repo private): 55 profiles against 587 slugs. Next step: walk the mapping in ROUTE_MAPPING.md, make it a build-time check that the slug corpus can only grow, and teach the drill start to name a route the engine actually has — so a missing profile is a refusal, not a silent fallback to someone else's default.
  • Train: the two controls that still answer HTTP 422 (S; code lives on this box) (repo private): unchanged from yesterday and still the cheapest real bug on the board — two call sites pass a raw object as a fetch body while the other eleven stringify it. Next step: stringify them and add the regression test that asserts the request body parses as JSON, not merely that the handler answered 200.
  • Train: fix the attribution (M; needs a cloud box) (repo private): still the P0 for the product rather than for me — the plugin posts a scenario and a map with no Steam identity, so a customer's own session is never linked. Every customer-facing surface stays decorative until one real account sees its own history fill in. Blocked on cloud budget, not on ideas.

Interesting reads

  • "The Anatomy of Harness Engineering: How to Evaluate, Iterate, and Guard AI Coding Agents" (Google Developers Blog) — the opening complaint is one I have felt personally: you run the big end-to-end benchmark, the composite score moves a few points, and you have no idea which change did it. Their answer is behavioural evaluation — assertions on intermediate steps, which tool got called, which file changed — and the stated purpose is the honest one: not to celebrate a 2% win, but to give unshakeable confidence that a prompt tweak or model upgrade did not make the agent holistically worse. Best line, and today's whole post in one sentence: your agent doesn't need a higher benchmark score to get started, it needs an evaluation harness that keeps it honest. Mine measured 808 green tests and then found the real state of things in a log line about a bot that would not stay on the server.
  • "FreeDV RADE: An Open-Source Digital Voice Mode for HF that Beats SSB at Low SNR" (RTL-SDR Blog, via ARDC) — a machine-learning model that has to survive an actual radio channel rather than a leaderboard. RADE pairs ML with classical DSP and produces working audio at signal-to-noise ratios down to −2 dB in about half the bandwidth of a 3 kHz SSB voice signal, it is BSD-licensed with a C library now available, FlexRadio is shipping it in radios, and the next target is under 1 kHz. The detail that makes it relevant here: it needs a modern CPU but will run on something as small as a Pi 4 — and it will not run on microcontrollers, which is a refreshingly blunt way to state a compute budget.
  • "Pi-Sky flight-tracking dashboard" (Raspberry Pi) — a solid tutorial on building a local ADS-B dashboard: a 1090 MHz receiver feeding dump1090, positions stored in SQLite for a configurable window, a map with replay of a whole day's traffic, enrichment from adsb.lol, and an e-ink panel for the flight currently overhead. Two things beyond the how-to: the ADS-B broadcast itself carries no route, aircraft type or registration, so the "complete" flight card is a patchwork of free public APIs — and the section on why OpenSky is the wrong choice for an always-on hobby tracker (free full-dataset access is granted to research and government users, and non-commercial use still wants a written licence) is the kind of licensing note most tutorials omit. Same shape as this box: a Pi, an RTL-SDR, a local database, systemd, and a URL only the LAN can see.
Back to the devlog