Skip to content

Staging telemetry is on unless --no-staging says otherwise (#563) - #576

Open
anth-volk wants to merge 10 commits into
mainfrom
staging-telemetry-563
Open

Staging telemetry is on unless --no-staging says otherwise (#563)#576
anth-volk wants to merge 10 commits into
mainfrom
staging-telemetry-563

Conversation

@anth-volk

@anth-volk anth-volk commented Jul 29, 2026

Copy link
Copy Markdown

Fixes #563.

Staging telemetry recorded nothing from 2026-07-02 onward. Every national release since carried "staging": null, the staging repo's runs.json stayed frozen at that date, and no gate treated any of it as a problem — the dashboard served three-week-old data with two runs stuck at status: "running".

--no-staging remains a supported way to build and is untouched. The defect is that other things produced the same outcome without anyone choosing it, and the manifest could not tell the cases apart.

The bug

os.environ.get(name, default) falls back to the default only when the variable is absent. Exported empty, it returns "", which is falsy, so an empty POPULACE_STAGING_REPO_ID defeated the on-by-default staging repo and _staging_telemetry took its "no destination" branch. Staging then did nothing for an entire build.

The second half is that this was unreadable afterwards. A deliberate --no-staging and a silently defeated one both wrote "staging": null, which is why several releases shipped this way before anyone noticed, and why it still cannot be said with certainty which of buildm/n/o were choices.

What changed

Commit
366958f Name the staging repo id and run prefix defaults instead of inlining them
524ef8a Treat an empty staging environment override as unset, not as off
a01ad02 --no-staging is the only way a build produces no staging telemetry
4200543 Record in the build manifest why a release has no staging run
7f67e43 Refuse to publish a release whose staging telemetry never arrived
249d90d Normalize the staging path prefix where the class owns it
6f6e606 Mark a staging run failed when the build does not finish
639c3f4 Changelog

Three things beyond the literal fix are worth calling out, because each closes a way the same failure could recur:

The manifest now states what happened. A skip is a positive statement carrying its reason ({"enabled": false, "reason": "--no-staging"}), following the shape warm_start and selection_source already use in the same manifest. A staged run carries its run id, destination, and uploads_succeeded.

That last field matters more than it looks. Uploads are best-effort and self-disable after three consecutive failures, so on a machine without a Hugging Face write token repo_id is already cleared before _staging_telemetry returns. The manifest previously recorded the configured repo id, asserting a destination that had received nothing. Without this field a publish-time gate would pass on exactly the misconfiguration it exists to catch.

Publishing refuses the dishonest case only. A build that meant to stage and delivered nothing is refused (--allow-missing-staging overrides); a declared opt-out publishes silently. Scoping is by presence of the staging key rather than country or dataset role: a builder that stages writes the key on every build, one with no staging path writes none, so the ACS local-area product stays out of the gate with no exception list. A release-id prefix test would not have worked — local-area ids also begin with populace-us-.

A crashed build marks its run failed. StagingTelemetry.fail() had no call sites and the entry point had no handler, which is why two runs read "running" indefinitely. main() is now a thin wrapper; the build body is _main, unmoved and unindented.

Verification

Full suite green across all five libraries (pytest packages, exit 0), ruff check . clean, and all five wheels build with the new symbols present in the packaged artifacts.

The three cases that define the fix, checked against the real parser:

1. POPULACE_STAGING_REPO_ID=""  -> repo='policyengine/populace-us-staging' prefix='runs'
2. --no-staging                 -> telemetry=None  block={'enabled': False, 'reason': '--no-staging'}
3. --staging-repo-id ""         -> SystemExit(2), before any download

The regression test for the bug was also confirmed to fail when the fix is reverted (assert '' == 'policyengine/populace-us-staging'), so it cannot pass vacuously.

Two source-inspection tests read the build body by name and correctly failed on the main_main rename; both now read _main.

Notes for review

Operationally significant. Every release built before 4200543 has staging: null, so re-publishing any of them now requires --allow-missing-staging. That is intended, but it is the change most likely to surprise someone mid-release. 7f67e43 is the natural split point if it should land separately.

The root cause is still unconfirmed for buildm/n/o. experiments/build_j_recert/buildj_sparse.sh:86 passes --no-staging explicitly and builds exactly the buildj release in #563's evidence table, so at least one null block there has a legitimate cause. No checked-in script covers buildm/n/o. This PR handles both causes, and after 4200543 the question stops being answerable retroactively but stops recurring; printenv POPULACE_STAGING_REPO_ID on the build machine settles the history.

Local-area datasets are out of scope. Everything here applies to the national fiscal refresh only. StagingTelemetry has exactly one consumer — tools/build_us_fiscal_refresh_release.py — so the ACS local-area build (tools/build_us_acs_local_release.py) emits no staging telemetry at all, writes no staging key, and is untouched by every change in this PR including the publish gate. Its publishes keep working, verified by test.

That leaves a real gap this PR does not close: a local-area candidate still cannot be reviewed on the staging dashboard before it is published. Giving it telemetry is not a matter of copying the wiring — the two builds are shaped differently. The local-area build has five separately resumable stages (materialize, calibrate, qa, finalize, package) bound by an on-disk run_identity.json, and no release id exists until the last of them, whereas StagingTelemetry assumes one process from start to finish and keys the run on the release id. Its default --stage all does run in one process, so the common path would work as-is; the split-stage recovery path would need the telemetry to resume across invocations, which is a change to the class rather than to the caller.

Not covered. SIGKILL is out of reach of any in-process handler, so an out-of-memory kill still leaves a stale run — the likelier explanation for the two live ones, given this build has been jetsam-killed before. Reaping those needs a staleness rule on the reader side and is left out.

Unrelated finding from the same investigation, filed separately: #575.

anth-volk and others added 10 commits July 28, 2026 21:20
…them

The staging repo id was the only production repo id in the fiscal refresh
build script inlined into an argparse default rather than hoisted to a module
constant, a thousand lines below the block that already holds REPO_ID,
DATASET_FILENAME and CALIBRATION_FILENAME. The run prefix was worse: the same
"runs" literal appeared as the StagingTelemetry dataclass default and again as
the argparse default, free to drift apart.

DEFAULT_STAGING_PREFIX joins the staging module's existing constant block
(STAGING_SCHEMA_VERSION, LATEST_STAGING_POINTER, RUNS_INDEX) and is re-exported
with them, since the dataclass owns that default. STAGING_REPO_ID goes beside
REPO_ID in the build script: it is a US deployment fact with one consumer, and
exporting it from the country-agnostic staging module would add package API
surface for nothing.

The local run directory keeps its own "runs" literal. That segment is local
filesystem layout, not a repo prefix, and does not track --staging-prefix.

test_staging_repo_can_default_from_environment was setting the environment
variable to the same string as the built-in default, so it passed whether the
environment or the default won; it now uses a distinct value. The literal is
asserted in exactly one place, so the constant cannot be retyped unnoticed.

No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
os.environ.get(name, default) falls back to the default only when the variable
is absent. A variable exported as an empty string returns "", which is falsy,
so an empty POPULACE_STAGING_REPO_ID defeated the on-by-default staging repo
and _staging_telemetry bailed at its "no destination" branch. Staging then did
nothing for the whole build and the manifest recorded a null staging block --
indistinguishable from a deliberate --no-staging, which is why it went
unnoticed across several releases.

Both staging environment overrides now route through one _env_default helper
that treats blank and whitespace as unset. The helper is the DRY unit for two
call sites and gives the contract somewhere to live.

The regression test drives the real parser with the variable exported empty;
with the two default= lines reverted it fails on the empty string reaching
through, so it cannot pass vacuously. A direct unit test covers unset, empty,
whitespace and padded values.

--no-staging is untouched and remains the way to build without staging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

Three states collapsed into one silent skip: staging deliberately disabled,
staging unconfigured, and staging configured with a blank repo id. Only the
first is a decision. The other two produced a full build with no telemetry and
a release that never reached the staging dashboard, with nothing said at the
time and nothing recorded afterwards.

Staging with no destination is now an argparse error, so it costs seconds
rather than surfacing hours in -- _staging_telemetry runs after the base H5
download and hash, the ledger feed load, target compilation and the release
directory mkdir, so raising there would burn minutes and leave an orphan
directory behind. _staging_telemetry keeps the same rule as a defensive
ValueError for callers that build the namespace directly, so the function now
has exactly one return None and it is the --no-staging branch.

The local-only mode is untouched and now asserted: --staging-dir with a blank
repo id is a destination, writes run files, and uploads nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The staging block was null both when a build deliberately skipped staging and
when it meant to stage and produced nothing. Those are different facts, and
collapsing them is why an outage sat unnoticed for weeks: every recent release
looked the same, and after the fact nobody could tell which were choices.

The block now follows the shape its siblings in the same manifest already use
-- warm start and selection source both say enabled: False rather than going
absent -- so a skip is a positive statement carrying its reason, and a staged
run carries its run id, destination and delivery count.

uploads_succeeded is the part that makes the block trustworthy. Uploads are
best-effort and self-disable after three consecutive failures, so on a machine
without a write token repo_id is already cleared before the telemetry object
is returned. The manifest previously recorded the configured repo id, which
asserted a destination that had received nothing. It now records what the run
actually did.

_staging_manifest_block is extracted rather than inlined: the manifest builder
takes some thirty arguments, so the shape had no test seam where it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The publish path warned about a missing staging block and published anyway.
A warning at the tail of a two-hour build is easy to miss, which is how five
consecutive releases shipped without ever appearing on the staging dashboard.
The reform-validation guard next to it already refuses with an explicit escape
hatch; this brings staging to the same footing.

What is refused is narrow: a build that recorded staging and delivered nothing.
That includes an empty or null block, and an enabled block whose
uploads_succeeded is zero -- the shape a run takes when it has no Hugging Face
write token and uploads self-disable. A declared enabled: False opt-out
publishes silently, because saying "I skipped staging" is exactly what makes it
distinguishable from failing to stage.

Scoping is by presence of the staging key rather than by country or dataset
role. A builder that stages writes the key on every build; one with no staging
path writes none, so the local-area product and any future build without
staging stay out of the gate with no exception list to maintain. A release-id
prefix test would not have worked -- local-area ids also start with
populace-us-.

Also adds the first test of --allow-incomplete-reform-validation, which had
none: an escape hatch nobody exercises is a wall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixing only the argparse default would have left --staging-prefix "" broken,
along with the environment variable and every programmatic caller. Normalizing
in __post_init__ covers all of them once.

A blank or slash-only prefix put run files at the repo root. The runs index
recorded them faithfully, so nothing looked wrong from the producer side, but
the dashboard resolves runs/<run_id> paths and would have found nothing.

repo_run_prefix loses its empty-prefix fallback with the invariant established
at construction. That branch was unreachable and described root-level runs as
an alternative layout, which is the outcome this change exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StagingTelemetry.fail() had no call sites and the entry point had no handler,
so a build that died left its run reading "running" for good. Two such runs
are sitting in the staging repo now. The dashboard cannot tell a crashed run
from one still working, and nothing ever resolves them.

main() becomes a thin wrapper that reports the failure and re-raises; the
build body is _main, unmoved and unindented. The telemetry handle travels via
a module-level reference rather than being threaded back up through 2,600
lines of call stack for the failure path alone, and it is cleared at the start
of every _staging_telemetry call so a handle cannot outlive its run.

A failure while reporting a failure warns and keeps the original traceback:
the build's own error is the one worth having.

This does not reach SIGKILL, so an out-of-memory kill still leaves a stale
run -- the likelier cause of the two live ones, given the build has been
jetsam-killed before. Reaping those needs a staleness rule on the reader side.

Two source-inspection tests read the build body by name and correctly failed
on the rename; both now read _main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…name

#568 added structural tests that locate the build body by AST, selecting the
function literally named main. This branch made main a thin entry point and
moved the body to _main, so those tests walked the four-line wrapper, found
none of the calls they check, and failed.

Neither change is wrong alone and the two merge cleanly, since they touch
different lines. Only the combination breaks, which is why it surfaced on CI
rather than in either branch.

All three by-name lookups now select _main: the certified-release-dir refusal
ordering, the final-household-weight evidence placement, and its bound cleanup
loop. Searched for every by-name reference rather than only the two CI named,
so the third could not fail on the next run.

Behavioral callers of main() are unaffected -- the wrapper delegates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@anth-volk
anth-volk requested a review from MaxGhenis July 29, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Staging telemetry silently disabled since 2026-07-02: empty POPULACE_STAGING_REPO_ID defeats the on-by-default staging repo

1 participant