Environment Bugs
Part II β Debugging Deterministic Software
Green here, red there, same code, same data
Every contract from Chapter 8 is in place. The suite passes on the engineer’s laptop. It fails in CI β same commit hash, same fixture file, same command. The failure is the old ghost from Chapter 4:
# laptop (passes) # CI (fails)
$ python jobs/run_invoices.py $ python jobs/run_invoices.py
report.csv written, 312 rows Traceback (most recent call last):
File "billing/totals.py", line 88, in summarize
row["refund_id"] = order["refund_id"]
KeyError: 'refund_id'
git rev-parse matches. sha256sum fixtures/incident_input.csv matches. The engineer re-reads totals.py, re-inspects state, re-runs the boundary table β all clean, because the code is clean and the data is clean. Chapters 5β8 have nothing left to convict. That is precisely the signal: when identical code plus identical data still diverges, the divergence lives underneath both.
OBSERVATION: same code hash + same input hash β passes on laptop, raises
KeyErrorin CI. HYPOTHESIS H1 (code/data defect): a latent code or data break that laptop runs mask (stale cache, dirty tree, uncommitted file). HYPOTHESIS H2 (environment divergence): interpreter, dependency, OS, config, or secret differs between the two runtimes. INFERENCE: none yet β the symptom is identical to Chapter 5’s, but Chapters 5β8 probes all pass here; only a substrate swap separates H1 from H2.
This chapter’s question: when code and data are pinned identical and outcomes still differ, what ordered probe convicts the environment?
Why environment bugs outlast their welcome
Three habits make them the longest-lived defects in deterministic software:
- “Nothing changed.” The engineer means no code changed. But
pip installresolvedpandas 2.xin CI while the laptop runs pinned1.5; the CSV reader’s dtype inference changed between those versions, and therefundsjoin column parses asNaNinstead of strings on one side. Chapter 4 named this H3. Nothing in the diff changed; everything around the diff did. - Dirty-tree alibis. The laptop passes because of an uncommitted local edit, a
.envfile absent in CI, aPYTHONPATHentry pointing at last month’sbilling/package, or a warm__pycache__/ wheel cache. H1 (masking) is the reason the environment probe must start from a clean, locked rebuild β otherwise every “environment” conviction is really an uncommitted-code confession. - Config blindness. Secrets, env vars (
REFUND_SOURCE=legacyvs.v2), OS line endings, timezone defaults, locale collation, file-path case sensitivity (Billing/vs.billing/on macOS vs. Linux), GPU-vs-CPU numeric paths. None appears ingit diff. All appear in a parity checklist β if you write one before you need it.
The mental model is Chapter 4’s stack bottom, now with teeth: the environment is every input your program consumes that is not the code text and not the named data file. If it can differ between two runs, it is environment until proven otherwise. The probe discipline from Chapter 4 applies at full strength: hold code and data constant, swap the substrate, observe.
The method: the locked-container probe
Order matters β cheapest exoneration first, and H1 (dirty tree) before H2 (true drift), because a dirty tree makes every later probe lie:
flowchart TD
A{"tree clean both sides? (status / stash / rev-parse)"}
A -->|no| H1["H1: dirty-tree / masking β clean and re-run before any env claim"]
A -->|yes| B["freeze inputs: sha256 every fixture and config"]
B --> C["pin randomness and time: seed, PYTHONHASHSEED=0, TZ=UTC"]
C --> D["locked rebuild from the lockfile, caches disabled"]
D --> E{"sides still differ?"}
E -->|no| H1b["H1: the pass was a cache / stale-env artifact"]
E -->|yes| F["bisect the freeze and env diff β one entry per run until the outcome flips"]
F --> H2["H2: the flipped entry is the drift β pin it (lockfile / image / loader guard)"]
- Prove the tree clean.
git status --porcelainempty,git stash listempty,git rev-parse HEADidentical on both sides. Recordpip freeze(oruv pip freeze/ lockfile hash) on both sides. If the laptop tree is dirty, H1 is still live β clean it and re-run before any environment claim. - Freeze the inputs.
sha256sumon every fixture and config file consumed by the repro, both sides. Chapter 3’s freezing rule extends below the code: hash the.env, theconfig/*.yaml, the fixture directory. - Swap the substrate, one variable per run. The discriminating experiment is the locked rebuild: run the pinned repro (same code hash, same input hash) inside a fresh container/image built from the lockfile β no caches, no local site-packages. Prediction if H1 (masking/latent code-data): the locked container fails everywhere (laptop’s pass was the artifact). Prediction if H2 (environment): the locked container passes or fails identically on both sides β the side-to-side difference disappears once the substrate is identical, and the
pip freezediff names the suspect package. - Bisect the diff. If H2 is supported, the
pip freeze/ lockfile diff plusenvdiff is the suspect list. Change one entry per run (downgrade one package, set one var) until the outcome flips. The flipped entry is convicted; everything else is context.
# parity probe transcript (commands, not results β reader executes)
$ git rev-parse HEAD && git status --porcelain # must be clean + identical
$ sha256sum fixtures/incident_input.csv config/*.yaml
$ pip freeze > evidence/freeze-laptop.txt # vs. evidence/freeze-ci.txt
$ diff evidence/freeze-laptop.txt evidence/freeze-ci.txt
$ docker build --no-cache -t repro:locked . && docker run --rm repro:locked python jobs/run_invoices.py
# Prediction H1: locked run fails (laptop pass was dirty/cache).
# Prediction H2: locked runs agree with each other; freeze diff names pandas 1.5 vs 2.x.
OBSERVATION (constructed illustration, not a measured run): laptop tree clean, input hashes match, freeze diff shows
pandas==1.5.3vs.pandas==2.1.0; both locked-container runs agree (fail identically once the CSV parse path is pinned to 2.x semantics). UPDATED BELIEF: H2 supported β dependency drift in CSV dtype inference; the laptop’s pass was a stale1.5environment, not evidence of code correctness. H1 suspended (tree was clean), but the cache audit stays in the checklist for next time. INFERENCE: the fix is a lockfile pin plus a parse-explicitdtype=/keep_default_na=Falsecontract at the loader (a Chapter 8 guard at the data-entry handoff) β not a code-logic change.
Randomness and time deserve one line each: unseeded random/numpy/PYTHONHASHSEED differences mimic environment divergence perfectly β pin seed + PYTHONHASHSEED=0 before claiming drift. Clock/timezone differences (TZ, DST boundaries, datetime.now() in fixtures) do the same β pin TZ=UTC and freeze time in the repro. Both go on the checklist below as first exclusion rows.
Luo and colleagues catalogued the causes of non-deterministic test failures across 201 flakiness-fixing commits and found eleven recurring categories, dominated by async waits, concurrency, and test-order dependency, with unseeded randomness, time, network, floating-point, and platform dependence close behind (Luo et al., 2014). That taxonomy sharpens this chapter’s H1/H2 split. Some categories β platform dependence, filesystem semantics β are genuine substrate (H2). Others β async, concurrency, order dependency, unseeded RNG β are latent non-determinism in the code that a different substrate merely exposes (H1 in disguise). If the locked-container rebuild still disagrees run-to-run on the same machine, you are looking at one of the H1-flavored categories, not drift.
Research lineage: config and drift are where the outages live
Misconfiguration is a first-class failure class, not a footnote. Yin and colleagues studied 546 real misconfigurations β 309 from a commercial storage product deployed at thousands of sites, 237 from CentOS, MySQL, Apache, and OpenLDAP β and found that 70β85% were parameter mistakes, of which roughly 40β50% set a value that plainly violated a documented format or rule, and 12β30% were inconsistencies between two parameters that individually looked fine (Yin et al., 2011). Two lessons for this chapter. First, “config blindness” is not a minor habit β configuration is a leading root cause of production incidents. Second, a large fraction of config errors are mechanically checkable, which means the fix is a Chapter 8 contract at the config-entry boundary: validate REFUND_SOURCE in {"legacy", "v2"} at startup, and the next bad value fails loudly instead of silently selecting the wrong code path.
Containers are the parity mechanism, with caveats. Building the repro from a lockfile in a --no-cache image is the practical form of reproducible-environment research (Boettiger’s account of Docker for reproducible work is the canonical statement), but a container pins the userland, not the kernel, the CPU, or the GPU (Boettiger, 2015). Numeric divergence from CPU-vs-GPU dispatch or floating-point associativity survives an identical image. Frameworks expose determinism switches for this β torch.use_deterministic_algorithms(True), the cuDNN benchmark flag, CUBLAS_WORKSPACE_CONFIG β but they cost throughput, they do not make results match across different GPU models or driver versions, and some operations have no deterministic implementation at all. So “identical locked container” is a weaker guarantee for numerical code than for the deterministic pipelines of Part II.
And the ground shifts under all of this in Part III. The environment-parity discipline assumes that once the substrate is identical the outcome is identical. Machine-learning systems break that assumption even inside one locked container β Chapter 12 makes reproducibility a designed property rather than an assumed one, Chapter 14 handles device/precision divergence at the tensor boundary, and Chapter 21 treats sampling nondeterminism (including server-batch effects that persist at temperature 0) as a debugging object in its own right. The reproducibility literature in AI β few papers ship runnable code, fewer reproduce independently β is the organizational symptom. Part II’s promise β pin everything, get the same answer β is the last time this book can make it cleanly.
Lab 9: isolate env vs. code with one locked run
PROPOSED, not executed: no author-measured results are reported. The evidence this chapter requires is the reader’s own parity record.
Setup. Take any passes-here/fails-there repro (use the pandas-drift pair above, or inject one: install two dependency versions, set a divergent env var like REFUND_SOURCE, or run with vs. without a local .env). You need two runtimes (laptop vs. CI, two venvs, or venv vs. container) with identical code and data hashes.
Task.
- Write H1 (dirty-tree/masking: one side’s outcome is the artifact) and H2 (substrate drift: runtimes genuinely differ) with distinct locked-run predictions before rebuilding.
- Complete the parity checklist (paper form below) on both sides: code hash, tree cleanliness, input hashes, freeze files,
envdiff, seed/TZ pins. - Run the locked rebuild (
--no-cache, from lockfile). Independent variable: runtime substrate (side A vs. side B vs. locked). Controlled variables: code hash, input hashes, command line, seeds. - Record OBSERVATION per runtime (pass/crash verbatim + traceback line if any) and UPDATED BELIEF. If H2 wins, bisect the freeze/env diff one entry per run until the flip.
| Runtime | Code hash | Input hash | Freeze ref | OBSERVATION | UPDATED BELIEF |
|---|---|---|---|---|---|
| side A | ___ | ___ | ___ | ___ | ___ |
| side B | ___ | ___ | ___ | ___ | ___ |
locked (--no-cache) |
___ | ___ | ___ | ___ | convicts H1 (fails everywhere) or H2 (sides converge; diff names suspect) |
Success criterion. A completed parity record plus the convicted entry (package == version, env var, or dirty-tree confession) and the lockfile/config change that makes both sides agree. A rebuilt image without the before/after freeze diff is not completing the lab β the diff is the diagnosis.
Companion tool: Environment Parity Checklist
What it accepts: the pinned repro reference (code hash + input hashes), the two pip freeze / lockfile artifacts, the env diff, seed/TZ pins, and the three-row runtime table.
What it performs: it enforces exclusion order β dirty-tree and cache checks before any drift claim, seed/TZ pins before package bisection β and it refuses an H2 verdict while any checklist row is still UNKNOWN. It records the convicted entry with its flip demonstration (the one-entry change that moved the outcome).
What it can establish: which substrate entry explains the side-to-side divergence under the pinned repro β or that no substrate entry does (both locked runs still disagree β the “identical” inputs were not identical; re-audit hashes and caches).
What it cannot establish: code/data correctness (a converged environment can still carry a Chapter 5β8 defect β parity is necessary, not sufficient), intent truth (no spec line β UNKNOWN), or future stability (today’s lockfile rots tomorrow without CI enforcement).
How its output changes your next action: a convicted entry routes to pinning it (lockfile, Dockerfile, dtype= guard, .env.example) with the parity record as the regression artifact; a dirty-tree confession routes to clean-tree discipline (pre-commit hooks, git status in CI); persistent disagreement routes back up the stack to Chapters 5β8 with the environment exonerated in writing.
Paper form, sufficient for this chapter:
Repro: code ___ | inputs ___ | cmd ___
Tree clean (both sides)? Y/N Caches disabled (--no-cache / fresh venv)? Y/N
Seeds pinned (seed=___, PYTHONHASHSEED=___)? Y/N TZ=___?
Freeze diff suspect(s): ___
Locked run A: ___ Locked run B: ___ (agree? Y/N)
CONVICTED ENTRY: ___ FLIP DEMO (one-entry change β outcome): ___
PIN APPLIED: lockfile / image / dtype-guard / env-example (circle)
Where a software implementation does not yet exist in the reader’s stack, this checklist is the tool. The parity discipline precedes any automation.
Reusable procedure: every “works on my machine” gets this
- Pin code + data (hashes both sides) before touching anything.
- Confess or exonerate the tree (
status,stash, caches,.env,PYTHONPATH). - Pin randomness and time (seed,
PYTHONHASHSEED,TZ=UTC). - Diff the substrate (freeze files,
env, OS/image tags). - Locked rebuild,
--no-cache, from the lockfile; compare three outcomes. - Bisect one entry per run to the flip; pin the conviction (lockfile + loader guard + CI check).
Failure modes
- Diff-first debugging. Reading code for an hour before checking
git statusandpip freeze. The cheapest probes are below the code β run them first. - Cache-forged passes. A warm Docker layer, a stale venv, or
__pycache__masking the break on one side.--no-cache/ fresh-venv is not hygiene theater; it is the experiment’s control. - Unpinned floating deps.
pandas>=1.5resolving differently per month per machine. Floating specifiers convert every install into a potential H2. - Env-var oral tradition.
REFUND_SOURCEset in one engineer’s shell profile, absent everywhere else. Undocumented vars are unversioned code β promote them to.env.example+ CI assertions. - Agreement-as-parity. “Both sides run Python 3.11, so environments match.” Minor versions, patch releases, build flags, and transitive deps all diverge beneath the headline version. The freeze file is the environment; the headline is a rumor.
- Single-run exoneration. One locked pass declaring the environment clean forever. Lockfiles rot; today’s parity proves today’s run. CI rebuilds from the lockfile on every change β that is the Chapter 8 tripwire for this layer.
Limits, per contract: one parity record convicts one entry under one repro; it does not certify code/data correctness, does not freeze the future, and does not replace human verification where money, safety, or production traffic is at stake. UNKNOWN where any checklist row is unmeasured.
References
- Qingzhou Luo, Farah Hariri, Lamyaa Eloussi, and Darko Marinov. An Empirical Analysis of Flaky Tests. Proceedings of the 22nd ACM SIGSOFT International Symposium on Foundations of Software Engineering (FSE), 2014, pp. 643β653. https://doi.org/10.1145/2635868.2635920
- Zuoning Yin, Xiao Ma, Jing Zheng, Yuanyuan Zhou, Lakshmi N. Bairavasundaram, and Shankar Pasupathy. An Empirical Study on Configuration Errors in Commercial and Open Source Systems. Proceedings of the 23rd ACM Symposium on Operating Systems Principles (SOSP), 2011, pp. 159β172. https://doi.org/10.1145/2043556.2043572
- Carl Boettiger. An Introduction to Docker for Reproducible Research. ACM SIGOPS Operating Systems Review 49(1), 2015, pp. 71β79. https://doi.org/10.1145/2723872.2723882
Debugging Checklist
- Code + input hashes verified identical on both sides before any theory?
- Tree clean, caches disabled,
.env/PYTHONPATHaudited (H1 exonerated or confessed)? - Seed,
PYTHONHASHSEED,TZpinned? - Freeze + env diffs recorded with named suspects?
- Config values validated at the entry boundary (Ch 8 contract), not just read?
- Locked
--no-cacherun compared across runtimes; H1/H2 predictions pre-written? - Same-machine repeat of the locked run checked (rules out async/order/RNG non-determinism)?
- Conviction demonstrated by a one-entry flip; pin applied (lockfile/image/guard/env-example)?
- No headline-version agreement or single-run outcome treated as parity?
What This Chapter Established
- Environment as the stack bottom: every non-code, non-data input is substrate until proven otherwise, with dirty-tree/masking (H1) excluded before drift (H2) is claimed.
- The locked-container probe with exclusion order (tree β hashes β seeds/time β freeze/env diff β
--no-cacherebuild β one-entry bisection), demonstrated on the pandas 1.5-vs-2.x CSV-inference drift β constructed illustration, no measured runs claimed. - Empirical grounding: non-determinism has a taxonomy (Luo et al. β async, concurrency, order, RNG, time, platform), and its categories split along the H1/H2 line; misconfiguration is a leading outage cause and largely mechanically checkable (Yin et al.), so config gets a Ch 8 boundary contract; containers pin userland but not kernel/CPU/GPU (Boettiger).
- Lab 9 as a proposed parity record the reader executes; the Environment Parity Checklist contract (accepts/performs/can-establish/cannot-establish/next-action).
- What was NOT proved: code/data correctness under a converged environment, future stability without CI enforcement, or any parity claim resting on headline versions or warm caches.
- Part II’s closing map: Chapter 5 reads the traceback, Chapter 6 inspects live state, Chapter 7 hunts boundaries, Chapter 8 pins handoffs with contracts, this chapter pins the substrate β deterministic debugging, end to end.
Next
Part II assumed a program you can re-run: same code, same data, same container, same outcome β or a substrate diff that explains the difference. Part III breaks that assumption on purpose. The notebook on your screen is not the program that ran: cells executed out of order, hidden state carried between runs, and “Restart & Run All” tells a different story than the session you debugged. The next chapter opens that gap β the notebook is not the program you see.