Math, Statistics, and Data › Working with Real Data › Day 140
Hands-on lab — Day 140: Section Project: An Exploratory Study
- ← Back to the Day 140 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/math-statistics-and-data/day-140-section-project-an-exploratory-study/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-140-section-project-an-exploratory-study
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy, pandas, matplotlib; print(numpy.__version__, pandas.__version__, matplotlib.__version__)" Run
cd examples && ../.venv/bin/python3 01_question_recorded.py && cd ..
cd examples && ../.venv/bin/python3 02_provenance_complete.py && cd ..
cd examples && ../.venv/bin/python3 03_grain_asserted.py && cd ..
cd examples && ../.venv/bin/python3 04_damage_report.py && cd ..
cd examples && ../.venv/bin/python3 05_confirmation_untouched.py && cd ..
cd examples && ../.venv/bin/python3 06_uncertainty_in_the_prose.py && cd ..
cd examples && ../.venv/bin/python3 07_figures_carry_claims.py && cd ..
cd examples && ../.venv/bin/python3 08_reproducibility.py && cd ..
cd examples && ../.venv/bin/python3 09_whole_harness.py && cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
.venv/bin/pytest starter -q -p no:cacheprovider Test
bash tests/run_tests.sh File tree
examples/01_question_recorded.py examples/02_provenance_complete.py examples/03_grain_asserted.py examples/04_damage_report.py examples/05_confirmation_untouched.py examples/06_uncertainty_in_the_prose.py examples/07_figures_carry_claims.py examples/08_reproducibility.py examples/09_whole_harness.py examples/acceptance.py examples/conftest.py examples/data/observations.csv examples/dataset.py examples/fixtures.py examples/study.py examples/test_reference.py expected-output/01-question-recorded.txt expected-output/02-provenance-complete.txt expected-output/03-grain-asserted.txt expected-output/04-damage-report.txt expected-output/05-confirmation-untouched.txt expected-output/06-uncertainty-in-the-prose.txt expected-output/07-figures-carry-claims.txt expected-output/08-reproducibility.txt expected-output/09-whole-harness.txt expected-output/examples-run.txt expected-output/FIELDS.md expected-output/starter-run.txt expected-output/test-run.txt expected-output/worked-study-damage-report.md expected-output/worked-study-manifest.txt expected-output/worked-study-report.md expected-output/worked-study-research-log.md expected-output/worked-study-verdict.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/00_brief.md starter/acceptance.py starter/conftest.py starter/data/observations.csv starter/dataset.py starter/fixtures.py starter/study.py starter/test_starter.py tests/run_tests.sh troubleshooting.md
Lab README
Day 140 lab — A Study That Holds Together
Lesson
- Lesson title: Section Project: An Exploratory Study
- Day number: 140 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-140-section-project-an-exploratory-study
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-140-section-project-an-exploratory-studywhen the site is running.
Purpose
Course 03 taught a dozen separable skills. A study is what happens when they have to hold each other up — and the thing that breaks in a capstone is never a single skill. It is the seams. A clean dataset with an unstated question. A beautiful chart of a leaked feature. A confident conclusion drawn from an exploration that examined forty things. Every component correct, the study worthless.
This lab has two halves, and the second is the one you keep.
A. A worked miniature study, executed end to end. A small synthetic
dataset ships with the lab. examples/study.py carries it through the whole
arc — question written first, provenance with a verified checksum, ingestion
with an asserted grain, cleaning with a measured damage report, an
exploration/confirmation split, two honest figures, a difference of means with
an interval, and a generated report that names what it cannot do. It is small
enough to read in one sitting on purpose. It is a demonstration, not a
portfolio piece.
B. An acceptance harness you run against your own study.
check_study(path) reads a study directory and returns a verdict: eight
gates, each passing or carrying findings that name exactly what is missing.
That is the deliverable. It is what makes the section project gradeable by
you, before anyone else sees it.
The nine exercises in starter/acceptance.py build the harness. The worked
study is your test subject and your reference.
Learning objectives
By the end you will be able to:
- Carry one small question through the whole arc — question, provenance, ingestion, cleaning, exploration, statistics, visuals, report — and see every handoff between stages as an artefact on disk rather than a habit.
- Write a checker that fails a study whose question file is missing or empty, and names the file.
- Write a checker that fails a source record lacking a URL, retrieval date or checksum, names which one, and recomputes the checksum against the file.
- Assert a row grain at ingestion, record that the assertion failed on arrival, and check the recorded result rather than the intention.
- Tell a damage report from a changelog, and fail a cleaning step documented without a before/after measurement.
- Detect a study whose confirmation set was used during exploration, by reading the research log's ordering — the only place that failure leaves a trace.
- Fail a reported estimate that carries no interval, and name the sentence.
- Fail a figure that carries no question and no claim, and catch a figure file no record mentions.
- Prove a study is reproducible by rebuilding it and comparing bytes, and detect one whose outputs moved after its manifest was written.
- Run all eight gates against a real, complete study, then delete one required element and watch exactly one gate fail — the proof that the harness works on a study and not only on fixtures.
Prerequisites
- Day 119 — the decision framing: would the answer change what anybody
does? The worked study's
QUESTION.mdstates the decision it informs. - Day 134 — provenance: licence, dictionary, checksum, retrieval record.
- Day 135 — ingestion with a stated grain.
- Days 121 and 125 — loading, inspecting and cleaning messy data.
- Day 126 — a reproducible cleaning pipeline and its manifest.
- Day 133 — building an EDA report, and the rule that every figure carries a question and a claim.
- Day 136 — the exploratory process, the research log, and the confirmation set held out before any hypothesis exists.
- Days 117 and 118 — the standard error and the confidence interval; this
lab rebuilds the interval from
math.erfalone, as Day 118 did. - Days 127–132 — choosing the chart, and chart honesty.
- Day 138 — ethics, proxies, and who is missing from the data.
- Days 71–74 — running pytest and reading its skip-versus-fail output.
- Day 43 —
python3 -m venvand installing a package withpip.
Supported operating systems
- macOS — run and captured here (macOS 26.5.2, Apple Silicon, arm64).
- Linux — the same commands apply unchanged. Not run here.
- Windows — use the Windows Subsystem for Linux and follow the Linux
instructions, or Git Bash with
.venv\Scripts\python.exein place of.venv/bin/python3. Not run here.
Hardware requirements
Anything that runs Python 3.11 or later. The dataset is 264 rows and 12 KB. The whole test harness completes in a few seconds; the two figures are the slowest thing in the lab and they are small. No GPU, no network after the one-time install, no more than a few hundred megabytes of disk for the virtual environment.
Required software
- Python 3.11 or later (3.14.0 here).
- The four pinned packages in
requirements/requirements.txt: NumPy 2.5.2, pandas 3.0.5, matplotlib 3.11.1, pytest 9.1.1. bashfor the test harness (3.2.57 here — no bash 4 features are used).
Free and open-source options
Every package in this lab is free and open source, and there is no paid tier
of anything, no account, no key and no signup. requirements/README.md lists
each package, its licence and what this lab uses it for, and says plainly what
is deliberately not installed — seaborn, scipy, statsmodels, scikit-learn
and Jupyter — and that no output from any of them is reproduced anywhere here.
The harness itself has no third-party dependency at all: acceptance.py
imports only hashlib, json, re, dataclasses and pathlib. If you can
run Python, you can run check_study against a study directory.
Installation
From the repository root:
cd labs/sections/math-statistics-and-data/day-140-section-project-an-exploratory-study
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy, pandas, matplotlib; print(numpy.__version__, pandas.__version__, matplotlib.__version__)"
That last command printed 2.5.2 3.0.5 3.11.1 here. The pip install is the
only command in this lab that opens a network connection.
File structure
day-140-section-project-an-exploratory-study/
├── README.md
├── metadata.yml
├── security.md
├── troubleshooting.md
├── requirements/
│ ├── README.md what is installed, why, licences, what is not
│ └── requirements.txt four pinned versions
├── examples/ the reference: worked study + working harness
│ ├── dataset.py the synthetic source, four defects, one real effect
│ ├── data/observations.csv the committed source file (264 rows)
│ ├── study.py the worked study: the whole arc, executed
│ ├── acceptance.py the harness: eight gates and check_study
│ ├── fixtures.py deliberately broken copies, one defect at a time
│ ├── 01_question_recorded.py … 09_whole_harness.py
│ ├── conftest.py import guard (see below)
│ └── test_reference.py 54 tests
├── starter/ your work
│ ├── 00_brief.md read this first
│ ├── dataset.py, study.py, fixtures.py, data/ given, complete
│ ├── acceptance.py nine exercises to fill in
│ ├── conftest.py import guard
│ └── test_starter.py 33 tests: skip until attempted
├── tests/run_tests.sh the full harness, 81 checks
└── expected-output/ captured from real runs, never fabricated
├── FIELDS.md what is exact and what is machine-dependent
├── 01-…-.txt … 09-whole-harness.txt
├── worked-study-report.md, -damage-report.md, -research-log.md
├── worked-study-manifest.txt, -verdict.txt
└── examples-run.txt, starter-run.txt, test-run.txt
Both examples/ and starter/ contain modules called acceptance, study,
dataset and fixtures. Each directory's conftest.py puts its own
directory first on sys.path and drops any already-imported module of those
names from elsewhere, so pytest starter can never silently import the
reference solution and report a pass for work you have not done.
How to run
Read the worked study before you grade anything:
.venv/bin/python3 -c "
import sys; sys.path.insert(0, 'examples')
import fixtures, pathlib
print(fixtures.worked_study(pathlib.Path('/tmp/day140-look')))
"
Then open QUESTION.md, SOURCE.json, INGEST.json, CLEANING.md,
RESEARCH_LOG.md, FIGURES.json, REPORT.md and MANIFEST.json in
/tmp/day140-look/study/, and rm -rf /tmp/day140-look when you are done.
The nine reference scripts, each one gate:
cd examples && ../.venv/bin/python3 01_question_recorded.py && cd ..
cd examples && ../.venv/bin/python3 02_provenance_complete.py && cd ..
cd examples && ../.venv/bin/python3 03_grain_asserted.py && cd ..
cd examples && ../.venv/bin/python3 04_damage_report.py && cd ..
cd examples && ../.venv/bin/python3 05_confirmation_untouched.py && cd ..
cd examples && ../.venv/bin/python3 06_uncertainty_in_the_prose.py && cd ..
cd examples && ../.venv/bin/python3 07_figures_carry_claims.py && cd ..
cd examples && ../.venv/bin/python3 08_reproducibility.py && cd ..
cd examples && ../.venv/bin/python3 09_whole_harness.py && cd ..
The two test suites — run them as two separate commands, never
pytest examples starter, because both directories carry modules of the
same names and the combined form is unreliable:
.venv/bin/pytest examples -q -p no:cacheprovider
.venv/bin/pytest starter -q -p no:cacheprovider
And the whole harness:
bash tests/run_tests.sh
What the commands do
| Command | What it proves |
|---|---|
01_question_recorded.py |
The question gate passes a written question and fails a missing, empty or question-free file, naming QUESTION.md every time. |
02_provenance_complete.py |
Missing fields are named one at a time, and the recorded checksum is recomputed against the file on disk. |
03_grain_asserted.py |
The grain is stated, checked, and honest: it FAILED on arrival with 8 violations, and the record says so. |
04_damage_report.py |
Four cleaning steps, each with a before and after. One step reduced to a changelog entry is named. |
05_confirmation_untouched.py |
A peeked confirmation set is caught from the log's ordering — and the report, figures and interval are byte-identical either way. |
06_uncertainty_in_the_prose.py |
Five forms of interval evidence are accepted; a bare point estimate is rejected and quoted. |
07_figures_carry_claims.py |
A figure with no claim, a figure with no question, a stray file and a dangling record are all caught. |
08_reproducibility.py |
Two builds produce identical bytes, figures included; a study whose output moved is named. |
09_whole_harness.py |
Eight gates pass on the worked study; one deleted field fails exactly one gate; three defects come back as three. |
pytest examples |
54 tests: the dataset, the arithmetic, the arc, and every gate against every fixture. |
pytest starter |
Your running score. Skips are unattempted; failures show your answer next to the real one. |
bash tests/run_tests.sh |
81 checks, including a deliberate self-test that proves the suite can go red. |
Expected output
Everything in expected-output/ was captured from real runs on the authoring
machine on 2026-08-20. expected-output/FIELDS.md says exactly which values
are identical everywhere and which are expected to differ — in this lab that
list is short, because nothing is sampled: the only genuinely
machine-dependent values are the two PNG digests, and no test asserts a PNG
digest against a stored literal.
The verdict on the worked study
(expected-output/worked-study-verdict.txt):
ACCEPTED: <tmp>/study
[PASS] question_recorded
[PASS] provenance_complete
[PASS] grain_asserted
[PASS] damage_report_quantified
[PASS] confirmation_untouched
[PASS] uncertainty_reported
[PASS] figures_documented
[PASS] outputs_reproducible
And the last line of a passing harness run:
81 checks, 0 failure(s).
Validation steps
bash tests/run_tests.shexits 0 and prints81 checks, 0 failure(s).Capture its own exit status directly —bash tests/run_tests.sh; echo $?— never through a pipe, because a pipeline reports the last command's status and has hidden a real failure in this repository before..venv/bin/pytest examples -qreports 54 passed..venv/bin/pytest starter -qreports 1 passed and 32 skipped on an untouched checkout, and 33 passed once every exercise is solved.- Each of the nine scripts exits 0 and ends with a line beginning
OK:. - Compare a script's output with the matching file in
expected-output/; they differ only in the temporary directory path. - Confirm the suite can fail: change
"264"to"265"in thethe delivery carries 264 rowscheck intests/run_tests.sh, run it, see81 checks, 1 failure(s).and a non-zero exit, then change it back. That was done during authoring and is recorded inmetadata.yml.
Tests
tests/run_tests.sh is a bash assert harness. It checks real behaviour and
real values, never file existence alone, and it never asserts on a timing.
Ten sections: the pinned versions actually installed; all nine reference
scripts running with every internal assertion holding; the worked study's
measured numbers; one defect at a time failing exactly the gate it should with
a finding that names something; a peeked study being byte-identical outside
its log; one required element removed from the real study failing one gate by
name; the reference suite; the starter suite skipping rather than failing;
a self-test that solves starter/ in a scratch copy, breaks one gate on
purpose and confirms a red run; and a final sweep for anything left behind.
It exits 0 only if every check passes.
Cleanup
find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf /tmp/day140-look # only if you built the study there to read it
rm -rf .venv # optional: removes the lab virtual environment
git checkout -- starter/ # optional: reset your work
The lab writes nothing outside a temporary directory of its own making. Every
study the scripts and tests build goes into a mktemp -d directory that is
removed on exit, and section 10 of the harness checks that neither
examples/ nor starter/ has gained a file.
Troubleshooting
See troubleshooting.md for the full list. The three you are most likely to
hit:
pytest starterreports passes for exercises you have not written. You ranpytest examples starteras one command. Run them separately.- A gate you wrote returns
Noneand the test skips instead of failing.attempt()treatsNoneas "not attempted". Return aGateResult. - Your figures do not hash identically across two runs. Something is
reading a clock, or you dropped
metadata={"Software": None}fromsavefig.
Security notes
See security.md. In short: no network after the one-time install, no
credentials, no sudo, no ports bound, and the study's source URL points at
example.invalid — a reserved name that can never resolve — because the file
is generated locally and never fetched. The one genuine caution is that
check_study reads whatever directory you hand it: treat a study directory
from someone else as untrusted input, and note that the harness reads files
but never executes anything inside them.
Extension exercises
- A ninth gate: the decision. Day 119 asks whether an answer would change
what anybody does. Add
gate_decision_namedrequiringQUESTION.mdto state a decision the answer informs, and see how many of your own past analyses would fail it. - Make the uncertainty gate stricter. It currently accepts any interval evidence. Make it also require the interval to be wider than a threshold you set, and think hard about why that is a bad idea before you keep it.
- A gate for the leaked feature. The worked study draws its figures from
the exploration half only. Extend
FIGURES.jsonwith asplitfield and fail any figure drawn from the confirmation half. - Port the harness to YAML. Swap
json.loadsfor a YAML parser and confirm all eight gates pass unchanged against a YAML study directory. The gates do not care about the format; that is the point. - Run it on your Week 20 project. The real extension. Point
check_studyat your own study directory and fix what it names — before anyone else reads it.
Navigation
- Course 03 — Math, Statistics, and Data — ends here. Day 141 begins Course 04, Machine Learning.
- Previous lab: Day 139 — Reproducible Notebooks.
- The Week 20 project brief (your own full exploratory study) lives with the week's projects; this lab supplies the harness you grade it with.
Expected output
01-question-recorded.txt
The worked study's question file:
# Question
Do roadside air-quality stations record higher PM2.5 than park stations, and by how much?
Written before the source file was opened. A decision this would
inform: whether the next four stations in the network are sited to
widen roadside coverage or to fill in the park gaps.
gate question_recorded -> PASS
Three ways the same gate fails, and what each finding says:
the file is missing -> QUESTION.md is missing
the file is empty -> QUESTION.md is empty
a topic, not a question -> QUESTION.md records no question sentence (no non-heading line ends in a question mark)
OK: the question gate passes a written question and fails a missing,
empty, or question-free file, naming QUESTION.md every time.
02-provenance-complete.txt
The worked study's source record:
url https://example.invalid/air-quality/observations.csv
retrieved 2026-06-30
licence CC0-1.0 (synthetic data generated for this lab)
path data/observations.csv
checksum_sha256 8323f91e84d399f6d89ce9e4478fbd65ecbe46c105bbafcb705dcdadf1157a4e
dictionary 7 columns described
gate provenance_complete -> PASS
With url, retrieved and checksum_sha256 removed:
SOURCE.json is missing: url
SOURCE.json is missing: retrieved
SOURCE.json is missing: checksum_sha256
One field at a time:
drop url -> SOURCE.json is missing: url
drop retrieved -> SOURCE.json is missing: retrieved
drop checksum_sha256 -> SOURCE.json is missing: checksum_sha256
drop licence -> SOURCE.json is missing: licence
A checksum that no longer matches the file it describes:
SOURCE.json: checksum_sha256 does not match data/observations.csv (recorded 000000000000..., actual 8323f91e84d3...)
OK: the provenance gate names every missing field individually and
recomputes the recorded checksum against the file on disk.
03-grain-asserted.txt
The worked study's ingestion contract:
grain ['reading_id']
grain_statement one row is one reading from one station
grain_violations_on_arrival 8
resolved_by cleaning step 'drop duplicate reading_id rows'
grain_verified True
rows_in / rows_out 264 / 245
gate grain_asserted -> PASS (a grain, stated and checked)
An ingestion with no grain declared at all:
INGEST.json declares no row grain: expected a non-empty 'grain' list of key columns
INGEST.json records no 'grain_verified' result: the grain was never checked against the data
A grain declared but never verified:
INGEST.json declares a grain but records no 'grain_verified' result: the grain was never checked against the data
Re-running the grain check directly, outside the harness:
on arrival: verified=False violations=8
after cleaning: verified=True violations=0
OK: the grain gate passes a stated-and-checked grain, and fails both
a missing grain and one declared without a verification result.
04-damage-report.txt
The worked study's damage report, step by step:
step before after changed
normalise station_type casing 8 2 6
drop duplicate reading_id rows 264 256 8
drop sensor fault sentinel readings 6 0 6
drop rows with no pm25 reading 5 0 5
gate damage_report_quantified -> PASS (four steps, all measured)
With one step documented but not measured:
CLEANING.md: cleaning step 'drop sensor fault sentinel readings' is a changelog entry, not a damage report: no before or after measurement
steps still carrying measurements: 3 of 4
OK: the damage-report gate accepts four measured steps and rejects the
one step reduced to a changelog entry, naming that step.
05-confirmation-untouched.txt
The worked study's research log, in order:
seq split activity
1 exploration distribution of pm25_ug_m3 across all stations
2 exploration pm25_ug_m3 split by station_type
3 exploration pm25_ug_m3 against humidity_pct
4 exploration pm25_ug_m3 by individual station_id
5 none hypothesis declared
6 confirmation test the declared hypothesis once
hypothesis declared at entry 5
confirmation split first used at entry 6
gate confirmation_untouched -> PASS
A study whose log shows the held-out half was opened early:
seq split activity
1 exploration distribution of pm25_ug_m3 across all stations
2 confirmation check whether the gap also shows up in the held-out half
3 exploration pm25_ug_m3 split by station_type
4 none hypothesis declared
5 confirmation test the declared hypothesis once
RESEARCH_LOG.md: the confirmation split was first used at entry 2 (check whether the gap also shows up in the held-out half), before the hypothesis was declared at entry 4 -- the held-out half was part of the exploration
RESEARCH_LOG.md: the confirmation split is used 2 times; a confirmation set tested more than once is an exploration set with a better name
REPORT.md and FIGURES.json are byte-identical in both studies:
the peek is visible in the log and nowhere else.
A log that never uses the confirmation split at all:
RESEARCH_LOG.md records no entry against the confirmation split: the held-out half was never used, so nothing was confirmed
OK: the confirmation gate reads the log's ordering, catches a split
used before the hypothesis existed, and catches one never used.
06-uncertainty-in-the-prose.txt
The worked study's findings section:
On the confirmation half, roadside stations recorded a mean PM2.5 5.50
ug/m3 higher than park stations (95% CI 3.80 to 7.21, n=60 roadside and
n=63 park readings).
The interval excludes zero, so the direction of the difference is the
same across the whole interval.
The estimate is imprecise enough that a true difference anywhere between
3.80 and 7.21 ug/m3 would be consistent with what was seen, which is a
much weaker statement than the point value alone would suggest.
Comparisons examined before this hypothesis was declared: 4.
That number belongs next to the interval, not in a footnote: it is what
tells a reader how much searching preceded the one test.
gate uncertainty_reported -> PASS
The same finding with the interval removed:
REPORT.md: estimate reported without an interval -- "Roadside stations
recorded a mean PM2.5 5.50 ug/m3 higher than park stations."
Interval evidence the gate accepts, tested one form at a time:
accepted The mean difference was 5.50 ug/m3 (95% CI 3.80 to 7.21).
accepted The mean difference was 5.50 ug/m3, plus or minus 1.70 (±1.70).
accepted The mean difference was 5.50 ug/m3, interval [3.80, 7.21].
accepted The mean difference was anywhere between 3.80 and 7.21 ug/m3.
accepted The estimated mean difference was 3.80 to 7.21 ug/m3.
REJECTED The mean difference was 5.50 ug/m3.
OK: the uncertainty gate accepts five forms of interval evidence, and
names the exact sentence when an estimate stands without one.
07-figures-carry-claims.txt
The worked study's figure records:
figures/fig-01-pm25-by-station-type.png
chart: box plot
baseline: y axis starts at zero; PM2.5 is a ratio quantity
question: Do roadside and park readings occupy different ranges?
claim: Roadside readings sit higher, but the boxes overlap: this
is a shift in centre, not two separate populations.
figures/fig-02-pm25-distribution.png
chart: overlapping histogram, common bins
baseline: counts from zero; identical 2 ug/m3 bins for both series
question: What shape is PM2.5 within each station type?
claim: Both distributions are single-peaked and broadly
overlapping, so the difference in means is not driven by a subgroup.
gate figures_documented -> PASS (two figures, both documented)
remove claim -> FIGURES.json: figure figures/fig-01-pm25-by-station-type.png carries no claim
remove question -> FIGURES.json: figure figures/fig-01-pm25-by-station-type.png carries no question
stray file -> figures/fig-99-leftover.png is present but undocumented: no entry in FIGURES.json
dangling record -> FIGURES.json[1]: figures/fig-03-never-rendered.png does not exist
OK: the figures gate passes documented figures and fails an entry with
no question or claim, a stray file, and a record with no file.
08-reproducibility.txt
Two independent builds, Markdown compared byte for byte:
CLEANING.md 3fa71fd335ab7dff identical
QUESTION.md 1d19d1778ccfe303 identical
REPORT.md f4c4448d7bc43293 identical
RESEARCH_LOG.md 208ce790015116f1 identical
And every other generated file, including the figures:
CLEANING.md identical
FIGURES.json identical
INGEST.json identical
QUESTION.md identical
REPORT.md identical
RESEARCH_LOG.md identical
SOURCE.json identical
data/observations.csv identical
figures/fig-01-pm25-by-station-type.png identical
figures/fig-02-pm25-distribution.png identical
MANIFEST.json identical
gate outputs_reproducible -> PASS on a fresh build
A study whose output moved after its manifest was written:
REPORT.md does not match its manifest checksum (manifest f4c4448d7bc4..., actual 4902fcb6b3a6...): the output changed since the manifest was written
A rebuild with a different as-of date, checked against the old manifest:
moved: REPORT.md
moved: SOURCE.json
An output file the manifest never heard of:
scratch-notes.md exists but is not covered by MANIFEST.json
OK: the worked study rebuilds byte-identically, and the harness names
every output that moved, went missing, or was never tracked.
09-whole-harness.txt
ACCEPTED: <tmp>/study
[PASS] question_recorded
[PASS] provenance_complete
[PASS] grain_asserted
[PASS] damage_report_quantified
[PASS] confirmation_untouched
[PASS] uncertainty_reported
[PASS] figures_documented
[PASS] outputs_reproducible
all 8 gates pass on the worked study
The same study with SOURCE.json's checksum_sha256 deleted:
NOT ACCEPTED: <tmp>/one-element-removed
[PASS] question_recorded
[FAIL] provenance_complete
- SOURCE.json is missing: checksum_sha256
[PASS] grain_asserted
[PASS] damage_report_quantified
[PASS] confirmation_untouched
[PASS] uncertainty_reported
[PASS] figures_documented
[PASS] outputs_reproducible
Three elements removed at once -- the verdict is a task list:
- QUESTION.md is missing
- INGEST.json declares no row grain: expected a non-empty 'grain' list of key columns
- INGEST.json records no 'grain_verified' result: the grain was never checked against the data
- FIGURES.json: figure figures/fig-02-pm25-distribution.png carries no claim
an empty directory fails all 8 gates
a missing path raises FileNotFoundError: not a study directory: <tmp>/nowhere
OK: eight gates pass on the worked study, one deleted field fails
exactly one gate by name, and three failures come back as three.
FIELDS.md
# What is exact, what is sampled, and what is machine-dependent
Captured on 2026-08-20 from a real run on the authoring machine (macOS
26.5.2, Apple Silicon, arm64, Python 3.14.0, NumPy 2.5.2, pandas 3.0.5,
matplotlib 3.11.1, pytest 9.1.1), through a lab-local `.venv` created by the
documented setup commands. Every number below came from that run; none is
invented.
## Exact everywhere, on any correct implementation
These are fixed by the seeded generator and by arithmetic, not by sampling.
They are the same on any machine that installs the pinned versions.
- The raw delivery is **264 rows**; **245** survive cleaning; **19** are
removed (7.20% of the delivery).
- The four damage-report measurements: distinct `station_type` values
8 -> 2; rows 264 -> 256; fault-sentinel rows 6 -> 0; blank-PM2.5 rows
5 -> 0.
- **8** rows violate the declared grain on arrival, and **0** after cleaning.
- The exploration/confirmation split is **122 / 123** readings.
- The research log records **4** exploration looks, so the reported
comparison count is **4**.
- The measured difference on the confirmation half is **5.50 ug/m3**, with a
95% interval of **3.80 to 7.21**, on n=60 roadside and n=63 park readings.
The generator plants a true difference of **6.00**, which falls inside that
interval.
- The two-sided z critical value for a 95% interval, computed by bisecting
`phi`: **1.959964** (to six decimal places).
- The worked study writes exactly **11 files**; the harness runs exactly
**8 gates**.
- The final line of a passing `run_tests.sh`: `81 checks, 0 failure(s).`
- `pytest examples` reports **54 passed**; `pytest starter` on an untouched
checkout reports **1 passed, 32 skipped**; a fully solved `starter/`
reports **33 passed**.
## Digests: exact for text, expected to differ for the figures
`worked-study-manifest.txt` lists a SHA-256 for every file the study
generates. They are not all equally portable, and the distinction matters
because gate 8 compares them.
- The **text digests** (`CLEANING.md`, `FIGURES.json`, `INGEST.json`,
`QUESTION.md`, `REPORT.md`, `RESEARCH_LOG.md`, `SOURCE.json`,
`data/observations.csv`) are byte-stable for a given pandas and NumPy
version. `data/observations.csv` hashes to
`8323f91e...1157a4e` here; `SOURCE.json` embeds that hash, so if the CSV
digest ever moves, `SOURCE.json`'s moves with it.
- The **two PNG digests are machine-dependent** and are the one thing in
this lab expected to differ on another computer. Matplotlib rasterises
text with the fonts it finds, and a different font file, FreeType build or
matplotlib version produces different bytes for a visually identical
chart. **Nothing in the harness asserts a PNG digest against a stored
literal.** What is asserted is the property that matters: two builds *on
the same machine* produce identical bytes, checked by rebuilding and
comparing rather than by looking a value up.
## Reproducibility, and exactly what it does and does not claim
`08-reproducibility.txt` shows every generated file identical across two
independent builds, figures included. That was measured, not assumed, and it
holds because `study.py` reads no clock, seeds every draw, wraps its report
with `textwrap.fill` rather than by hand, and saves PNGs with
`metadata={"Software": None}` so the matplotlib-version tag is not written
into the file.
The claim is **same machine, same pinned versions, same seeds, identical
bytes**. It is not a claim that the figures hash the same on your machine as
on the authoring one; see above.
## Paths sanitised in the captured output
The example scripts build their studies in a temporary directory, so their
real output contains a path like
`/var/folders/.../T/tmpXXXXXXXX/study` on macOS or `/tmp/tmpXXXXXXXX/study`
on Linux. Those have been replaced with `<tmp>` in the captured files. One
consequence worth flagging: in `09-whole-harness.txt`, the
`FileNotFoundError` line is truncated to 44 characters by the script before
printing, so on a real run it reads as a truncated real path with a trailing
`...`, not as `<tmp>/nowhere`. Everything else in that file is verbatim.
## Machine-dependent, described but not asserted on
- The `platform` line in section 1 of `run_tests.sh`'s output reads
`macOS-26.5.2-arm64-arm-64bit-Mach-O` here and will read differently on
Linux or Windows/WSL. Nothing in the harness checks its content, only that
the four installed versions match `requirements.txt`.
- Every wall-clock figure (`54 passed in 0.90s` and similar) is hardware
dependent. No test asserts on a timing anywhere in this lab.
- The scratch directory `run_tests.sh` creates with `mktemp -d` has a
different name every run and is removed by an `EXIT` trap.
## Nothing here is sampled
Unusually for this section, there is no tolerance anywhere in this lab. Every
figure is either fixed arithmetic or fixed by a seed, so every assertion is
an equality. The one statistical statement — that the planted 6.00 difference
falls inside the measured 95% interval — is checked as a fact about this
seed's interval, not as a coverage rate over repeated samples.
examples-run.txt
...................................................... [100%]
54 passed in 0.90s
starter-run.txt
.ssssssssssssssssssssssssssssssss [100%]
1 passed, 32 skipped in 0.54s
test-run.txt
Day 140 — Section Project: An Exploratory Study
1. The tools and the versions this lab was written against
python 3.14.0
numpy 2.5.2
pandas 3.0.5
matplotlib 3.11.1
pytest 9.1.1
platform macOS-26.5.2-arm64-arm-64bit-Mach-O
exe python3
ok: installed numpy matches requirements.txt
ok: installed pandas matches requirements.txt
ok: installed matplotlib matches requirements.txt
ok: installed pytest matches requirements.txt
2. Every reference script runs and every assertion inside it holds
ok: 01_question_recorded.py exits 0
ok: 01_question_recorded.py reports OK
ok: 02_provenance_complete.py exits 0
ok: 02_provenance_complete.py reports OK
ok: 03_grain_asserted.py exits 0
ok: 03_grain_asserted.py reports OK
ok: 04_damage_report.py exits 0
ok: 04_damage_report.py reports OK
ok: 05_confirmation_untouched.py exits 0
ok: 05_confirmation_untouched.py reports OK
ok: 06_uncertainty_in_the_prose.py exits 0
ok: 06_uncertainty_in_the_prose.py reports OK
ok: 07_figures_carry_claims.py exits 0
ok: 07_figures_carry_claims.py reports OK
ok: 08_reproducibility.py exits 0
ok: 08_reproducibility.py reports OK
ok: 09_whole_harness.py exits 0
ok: 09_whole_harness.py reports OK
3. The worked study: real numbers, measured now, not quoted
rows_in=264
rows_out=245
grain_violations=8
damage_steps=4
comparison_count=4
difference=5.50
ci_low=3.80
ci_high=7.21
true_difference=6.0
interval_covers_truth=True
file_count=11
gate_count=8
verdict_ok=True
failed_gates=none
markdown_identical=True
everything_identical=True
ok: the worked study builds and is graded
ok: the delivery carries 264 rows
ok: 245 rows survive cleaning
ok: 8 rows violate the grain on arrival
ok: the damage report has four measured steps
ok: the research log records 4 comparisons
ok: the study writes 11 files
ok: the harness runs 8 gates
ok: the worked study is ACCEPTED
ok: no gate fails on the worked study
ok: the planted 6.0 difference falls inside the measured interval
ok: two builds produce identical Markdown
ok: two builds produce identical everything, figures included
4. One defect at a time fails exactly the gate it should
ok: missing-question fails only question_recorded
ok: missing-question: the finding names a file, field, step or sentence
ok: incomplete-source fails only provenance_complete
ok: incomplete-source: the finding names a file, field, step or sentence
ok: stale-checksum fails only provenance_complete
ok: stale-checksum: the finding names a file, field, step or sentence
ok: no-grain fails only grain_asserted
ok: no-grain: the finding names a file, field, step or sentence
ok: unverified-grain fails only grain_asserted
ok: unverified-grain: the finding names a file, field, step or sentence
ok: changelog-not-damage fails only damage_report_quantified
ok: changelog-not-damage: the finding names a file, field, step or sentence
ok: peeked-confirmation fails only confirmation_untouched
ok: peeked-confirmation: the finding names a file, field, step or sentence
ok: no-interval fails only uncertainty_reported
ok: no-interval: the finding names a file, field, step or sentence
ok: unlabelled-figure fails only figures_documented
ok: unlabelled-figure: the finding names a file, field, step or sentence
ok: stray-figure fails only figures_documented
ok: stray-figure: the finding names a file, field, step or sentence
ok: output-moved fails only outputs_reproducible
ok: output-moved: the finding names a file, field, step or sentence
5. A peeked confirmation set is invisible outside the research log
identical_files=6
log_differs=True
caught=True
ok: six study files are byte-identical between the honest and peeked study
ok: only the research log differs
ok: and the harness still catches the peek
6. Removing ONE required element from the real study fails one gate
ok=False
failed=provenance_complete
findings=1
finding=SOURCE.json is missing: checksum_sha256
ok: the study is no longer accepted
ok: exactly one gate fails
ok: exactly one finding is reported
ok: and it names the deleted field
7. The reference pytest suite: real values, real exceptions
...................................................... [100%]
54 passed in 0.86s
ok: pytest examples exits 0
ok: no test in the reference suite failed
ok: the reference suite ran at least 50 tests (ran 54)
8. The starter suite skips unattempted work instead of failing it
.ssssssssssssssssssssssssssssssss [100%]
1 passed, 32 skipped in 0.54s
ok: pytest starter exits 0 on an untouched checkout
ok: the starter suite reports no failures
ok: unwritten exercises are reported as skipped, not passed
ok: auto-discovering both suites does not turn skips into passes
9. The starter suite can actually fail
................................. [100%]
33 passed in 0.57s
ok: a fully solved starter passes every exercise
ok: the solved starter runs all 33 exercises
=========================== short test summary info ============================
FAILED solved/test_starter.py::test_provenance_gate_verifies_the_checksum - A...
1 failed, 32 passed in 0.56s
ok: breaking one gate makes the starter suite exit non-zero (1)
ok: the failing test is named in the output
ok: the failure message explains what went wrong
10. Nothing was left behind
ok: no __pycache__ directory left by the lab's own code
ok: no .pytest_cache directory left under the lab
ok: the lab's own directories contain exactly the files that ship
ok: no study directory was written inside the lab
ok: no lab source opens a network connection
81 checks, 0 failure(s).
worked-study-damage-report.md
# Damage report
What the cleaning *changed*, measured. A step with no before/after
number is a changelog entry, not a damage report.
Rows in: 264. Rows out: 245. Rows removed: 19 (7.20% of the delivery).
### normalise station_type casing
measure: distinct station_type values
before: 8
after: 2
changed: 6
strip and lower-case; no row is dropped by this step
### drop duplicate reading_id rows
measure: rows
before: 264
after: 256
changed: 8
the duplicates are byte-identical redeliveries; first wins
### drop sensor fault sentinel readings
measure: rows carrying the -1.0 fault sentinel
before: 6
after: 0
changed: 6
-1.0 is not a low reading; it is the unit reporting a fault
### drop rows with no pm25 reading
measure: rows with a blank pm25_ug_m3
before: 5
after: 0
changed: 5
blank means the reading never arrived; it is not a zero
worked-study-manifest.txt
# The worked study on disk, with the SHA-256 of every file
3fa71fd335ab7dffda3be164c11a7be07c115fc63187f8d574b4c6532334b1dd CLEANING.md
2c1825cd9686d486ada2b54047e2a6575d6b0bfbeeff2d432d2d0ea01717ac39 FIGURES.json
a54ec005e52c1bff78469badec0594e5216789bce2efeee3a6a47824d56f0b5d INGEST.json
1d19d1778ccfe30301a2cdb4619f7306c8aa24f0bdc0ec2d3a08d2cce0073fd8 QUESTION.md
f4c4448d7bc432936cb1c78e8609d93fe6a16e6999b7726aaaf5935c5d6e32c2 REPORT.md
208ce790015116f1415da73330430bcb8bb8aeebabd3e2410d99f92622bc3044 RESEARCH_LOG.md
ce8520c4f61bb3f6fc63106512a74ad749391d4ec735e571cb4e1a5e3cd76e1a SOURCE.json
8323f91e84d399f6d89ce9e4478fbd65ecbe46c105bbafcb705dcdadf1157a4e data/observations.csv
42d661ed11a0fe3af2b99904afe4158ea48eba00f7af528cfb8c706cc4163fa7 figures/fig-01-pm25-by-station-type.png
53a6e04ee684c45591bc16568246dfe32d9a974776318bf70977d15323501b5d figures/fig-02-pm25-distribution.png
MANIFEST.json itself is never covered by the manifest it writes.
Measured by the study:
rows_in = 264
rows_out = 245
grain_violations = 8
damage_steps = 4
exploration_n = 122
confirmation_n = 123
comparison_count = 4
difference = 5.504706349206348
ci_low = 3.8027064505232397
ci_high = 7.206706247889456
p_value = 2.3121815573290405e-10
n_roadside = 60
n_park = 63
worked-study-report.md
# Roadside and park PM2.5: an exploratory study
As of 2026-06-30. Exploratory. Not causal.
## Question
Do roadside air-quality stations record higher PM2.5 than park stations, and
by how much?
The question was written to QUESTION.md before the source file was opened,
so that the analysis could not quietly become a search for whichever
question the data happened to answer well.
## What the data is
A synthetic network of eight fixed air-quality stations, four sited at
roadside and four in parks, reporting daily PM2.5 through June 2026.
Provenance, licence, dictionary and checksum are recorded in SOURCE.json;
the grain -- one row per reading -- is asserted in INGEST.json, and the
record says plainly that the assertion failed on arrival and what resolved
it.
## What cleaning changed
The delivery carried 264 rows. 245 survived cleaning. The four steps and
their before/after measurements are in CLEANING.md. The largest single loss
is the eight duplicated readings, which is a grain violation rather than a
data-quality problem, and would have biased every mean below had it gone
unnoticed.
## How it was explored
The cleaned frame was split into an exploration half (122 readings) and a
confirmation half (123 readings) before any look was taken. RESEARCH_LOG.md
records every look in order. The exploration half was examined 4 times. The
confirmation half was opened once, after the hypothesis was written down,
and tested once.
## Findings
On the confirmation half, roadside stations recorded a mean PM2.5 5.50 ug/m3
higher than park stations (95% CI 3.80 to 7.21, n=60 roadside and n=63 park
readings).
The interval excludes zero, so the direction of the difference is the same
across the whole interval. The estimate is imprecise enough that a true
difference anywhere between 3.80 and 7.21 ug/m3 would be consistent with
what was seen, which is a much weaker statement than the point value alone
would suggest.
Comparisons examined before this hypothesis was declared: 4. That number
belongs next to the interval, not in a footnote: it is what tells a reader
how much searching preceded the one test.
## Figures
Each figure in FIGURES.json carries the question it was drawn to answer and
the claim it supports. Both are drawn from the exploration half only, so no
figure shows the data the estimate above was measured on.


## Limits
This study is exploratory. It does not establish that roadside siting
*causes* higher PM2.5. Station siting is not randomised: the roadside units
are where they are for reasons -- traffic volume, building density, land
availability -- that are themselves plausible causes of the difference
measured here.
The measured quantity is a proxy. PM2.5 at a fixed station is not what
anyone breathes; exposure depends on where people actually are and for how
long, which this data does not contain.
Who is missing: eight stations is a sample of sites, not of people.
Neighbourhoods without a station contribute nothing, and stations are not
sited at random, so the absence is not random either.
What would establish causation: an intervention -- a road closure, a
traffic-calming scheme, a low-emission zone boundary -- with readings from
the same stations before and after, and control stations outside the
intervention area over the same period. This study names that design; it
does not run it.
## Reproducing this
MANIFEST.json records a SHA-256 for every file this study generated.
Rebuilding the study from the same source file and the same seeds reproduces
every one of them, figures included. Nothing here reads the clock: the as-of
date is a parameter.
worked-study-research-log.md
# Research log
Every look taken, in the order it was taken, including the ones that
found nothing. The number of `exploration` rows below is the
comparison count reported in REPORT.md.
| seq | timestamp | split | activity | outcome |
| --- | --- | --- | --- | --- |
| 1 | 2026-06-30T09:05:00Z | exploration | distribution of pm25_ug_m3 across all stations | right-skewed, no second mode; nothing to explain |
| 2 | 2026-06-30T09:18:00Z | exploration | pm25_ug_m3 split by station_type | roadside sits visibly higher; worth a hypothesis |
| 3 | 2026-06-30T09:31:00Z | exploration | pm25_ug_m3 against humidity_pct | no visible relationship; nothing found |
| 4 | 2026-06-30T09:44:00Z | exploration | pm25_ug_m3 by individual station_id | spread within each type, no single station driving the gap |
| 5 | 2026-06-30T09:52:00Z | none | hypothesis declared | Roadside stations have a higher mean PM2.5 than park stations. |
| 6 | 2026-06-30T10:07:00Z | confirmation | test the declared hypothesis once | see REPORT.md |
worked-study-verdict.txt
ACCEPTED: <tmp>/study
[PASS] question_recorded
[PASS] provenance_complete
[PASS] grain_asserted
[PASS] damage_report_quantified
[PASS] confirmation_untouched
[PASS] uncertainty_reported
[PASS] figures_documented
[PASS] outputs_reproducible
Source files
examples/01_question_recorded.py (2498 bytes)
"""Exercise 1 -- a question recorded before the analysis.
The first seam. A study whose question was written down after the looking is
not a study; it is a search for whichever question the data answers well, and
nothing downstream can tell the difference. The harness cannot prove the
ordering, so it insists on the weaker thing it CAN check: the question exists,
it is not empty, and it is actually a question.
Run: ../.venv/bin/python3 01_question_recorded.py
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import acceptance
import fixtures as fx
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
good = fx.worked_study(root)
verdict = acceptance.check_study(good)
gate = verdict.gate("question_recorded")
print("The worked study's question file:")
print((good / "QUESTION.md").read_text().rstrip())
print()
print(f" gate question_recorded -> {'PASS' if gate.ok else 'FAIL'}")
assert gate.ok, gate.findings
print()
print("Three ways the same gate fails, and what each finding says:")
for label, mutator in (
("the file is missing", fx.break_missing_question),
("the file is empty", fx.break_empty_question),
("a topic, not a question", fx.break_question_without_a_question),
):
broken = fx.variant(good, root / f"q-{label.replace(' ', '-')}", mutator)
result = acceptance.check_study(broken).gate("question_recorded")
assert not result.ok, f"{label} should have failed the gate"
assert len(result.findings) == 1, result.findings
finding = result.findings[0]
assert finding.startswith("QUESTION.md"), finding
print(f" {label:26} -> {finding}")
# The finding always names the file. That is the difference between a
# verdict you can act on and one you have to investigate.
for mutator in (fx.break_missing_question, fx.break_empty_question):
broken = fx.variant(good, root / "named", mutator)
names = acceptance.check_study(broken).gate("question_recorded").findings
assert all("QUESTION.md" in f for f in names), names
print()
print("OK: the question gate passes a written question and fails a missing,")
print(" empty, or question-free file, naming QUESTION.md every time.")
if __name__ == "__main__":
main()
examples/02_provenance_complete.py (3367 bytes)
"""Exercise 2 -- provenance complete: url, retrieval date, checksum, licence.
Day 134's four facts, mechanised. A study whose source record is missing any
one of them cannot be re-obtained by anyone, including its own author six
months later. The gate names which field is missing rather than saying
"provenance incomplete", because the first is a task and the second is an
investigation.
It also does the thing a surprising number of real pipelines skip: it VERIFIES
the checksum against the file the record points at. A checksum nobody checks
is a decoration.
Run: ../.venv/bin/python3 02_provenance_complete.py
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import acceptance
import fixtures as fx
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
good = fx.worked_study(root)
record = json.loads((good / "SOURCE.json").read_text())
print("The worked study's source record:")
for key in ("url", "retrieved", "licence", "path"):
print(f" {key:16} {record[key]}")
print(f" {'checksum_sha256':16} {record['checksum_sha256']}")
print(f" {'dictionary':16} {len(record['dictionary'])} columns described")
print()
gate = acceptance.check_study(good).gate("provenance_complete")
assert gate.ok, gate.findings
print(" gate provenance_complete -> PASS")
# Drop all three at once: the gate must name all three, not the first.
broken = fx.variant(good, root / "no-provenance", fx.break_provenance)
result = acceptance.check_study(broken).gate("provenance_complete")
assert not result.ok
print()
print("With url, retrieved and checksum_sha256 removed:")
for finding in result.findings:
print(f" {finding}")
for key in ("url", "retrieved", "checksum_sha256"):
assert any(f.endswith(key) for f in result.findings), (key, result.findings)
assert len(result.findings) == 3, result.findings
# One field at a time, to prove the gate is not simply reporting all
# four whenever anything is wrong.
print()
print("One field at a time:")
for key in ("url", "retrieved", "checksum_sha256", "licence"):
one = fx.variant(
good,
root / f"missing-{key}",
lambda d, k=key: fx.break_provenance(d, drop=(k,)),
)
findings = acceptance.check_study(one).gate("provenance_complete").findings
assert findings == (f"SOURCE.json is missing: {key}",), findings
print(f" drop {key:16} -> {findings[0]}")
# And the checksum is really recomputed, not merely required.
stale = fx.variant(good, root / "stale-checksum", fx.break_provenance_checksum)
findings = acceptance.check_study(stale).gate("provenance_complete").findings
assert len(findings) == 1 and "does not match" in findings[0], findings
print()
print("A checksum that no longer matches the file it describes:")
print(f" {findings[0]}")
print()
print("OK: the provenance gate names every missing field individually and")
print(" recomputes the recorded checksum against the file on disk.")
if __name__ == "__main__":
main()
examples/03_grain_asserted.py (3971 bytes)
"""Exercise 3 -- the ingestion states a grain, and checks it.
"One row is one ___." Day 135's sentence, and the one every count downstream
silently depends on. The worked study's raw delivery VIOLATES its grain --
eight readings arrive twice, byte-identical -- and the record says so, names
the cleaning step that resolved it, and records the verified result for the
frame the study actually proceeds with.
That honesty is the point. A study that quietly de-duplicates and reports a
clean grain has hidden the most consequential thing that happened to its data.
Run: ../.venv/bin/python3 03_grain_asserted.py
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import acceptance
import fixtures as fx
import study
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
good = fx.worked_study(root)
record = json.loads((good / "INGEST.json").read_text())
print("The worked study's ingestion contract:")
print(f" grain {record['grain']}")
print(f" grain_statement {record['grain_statement']}")
print(f" grain_violations_on_arrival {record['grain_violations_on_arrival']}")
print(f" resolved_by {record['resolved_by']}")
print(f" grain_verified {record['grain_verified']}")
print(f" rows_in / rows_out {record['rows_in']} / {record['rows_out']}")
print()
assert record["grain"] == ["reading_id"]
assert record["grain_violations_on_arrival"] == 8, record
assert record["grain_verified"] is True
assert record["rows_in"] - record["rows_out"] == 19, record
gate = acceptance.check_study(good).gate("grain_asserted")
assert gate.ok, gate.findings
print(" gate grain_asserted -> PASS (a grain, stated and checked)")
# An ingestion that never says what a row is.
silent = fx.variant(good, root / "no-grain", fx.break_grain)
findings = acceptance.check_study(silent).gate("grain_asserted").findings
print()
print("An ingestion with no grain declared at all:")
for finding in findings:
print(f" {finding}")
assert any("declares no row grain" in f for f in findings), findings
# A grain declared and never checked. This is the subtler failure and
# by far the commoner one: the schema says unique, nobody ran the test.
hoped = fx.variant(good, root / "unverified-grain", fx.break_grain_unverified)
findings = acceptance.check_study(hoped).gate("grain_asserted").findings
print()
print("A grain declared but never verified:")
for finding in findings:
print(f" {finding}")
assert len(findings) == 1 and "never checked against the data" in findings[0], findings
# The check itself is real: re-run it against the raw frame and the
# cleaned frame and confirm the two answers differ as recorded.
raw = study.ds.load_source_csv(good / "data" / "observations.csv")
on_arrival = study.ingest(raw)
cleaned, _ = study.clean(on_arrival.frame)
after = study.ingest(cleaned)
print()
print("Re-running the grain check directly, outside the harness:")
print(f" on arrival: verified={on_arrival.grain_verified} "
f"violations={on_arrival.grain_violations}")
print(f" after cleaning: verified={after.grain_verified} "
f"violations={after.grain_violations}")
assert on_arrival.grain_verified is False and on_arrival.grain_violations == 8
assert after.grain_verified is True and after.grain_violations == 0
print()
print("OK: the grain gate passes a stated-and-checked grain, and fails both")
print(" a missing grain and one declared without a verification result.")
if __name__ == "__main__":
main()
examples/04_damage_report.py (3187 bytes)
"""Exercise 4 -- a damage report, not a changelog.
The distinction this exercise exists for:
changelog "dropped the fault-sentinel readings"
damage report "rows carrying the -1.0 fault sentinel: before 6, after 0"
The first tells you what somebody did. The second tells you what it cost, and
only the second lets a reader decide whether the cleaning was proportionate.
Every number the study reports downstream was computed after these steps ran,
so a reader who cannot see the damage cannot audit anything that follows.
Run: ../.venv/bin/python3 04_damage_report.py
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import acceptance
import fixtures as fx
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
good = fx.worked_study(root)
text = (good / "CLEANING.md").read_text()
steps = acceptance.cleaning_steps(text)
print("The worked study's damage report, step by step:")
print(f" {'step':44} {'before':>8} {'after':>8} {'changed':>8}")
for title, values in steps:
print(f" {title:44} {values['before']:8.0f} {values['after']:8.0f} "
f"{values['before'] - values['after']:8.0f}")
print()
assert len(steps) == 4, steps
by_name = dict(steps)
assert by_name["normalise station_type casing"] == {"before": 8.0, "after": 2.0}
assert by_name["drop duplicate reading_id rows"] == {"before": 264.0, "after": 256.0}
assert by_name["drop sensor fault sentinel readings"] == {"before": 6.0, "after": 0.0}
assert by_name["drop rows with no pm25 reading"] == {"before": 5.0, "after": 0.0}
gate = acceptance.check_study(good).gate("damage_report_quantified")
assert gate.ok, gate.findings
print(" gate damage_report_quantified -> PASS (four steps, all measured)")
# Now turn ONE step back into a changelog entry and watch the gate
# name that step specifically.
broken = fx.variant(good, root / "changelog", fx.break_damage_report)
result = acceptance.check_study(broken).gate("damage_report_quantified")
assert not result.ok
assert len(result.findings) == 1, result.findings
print()
print("With one step documented but not measured:")
print(f" {result.findings[0]}")
assert fx.CHANGELOG_STEP in result.findings[0]
assert "changelog entry, not a damage report" in result.findings[0]
# The other three steps still pass: the gate is per-step, so a study
# with one lapse gets one task, not a blanket rejection.
still_measured = acceptance.cleaning_steps((broken / "CLEANING.md").read_text())
measured = [t for t, v in still_measured if "before" in v and "after" in v]
print(f" steps still carrying measurements: {len(measured)} of {len(still_measured)}")
assert len(measured) == 3, measured
print()
print("OK: the damage-report gate accepts four measured steps and rejects the")
print(" one step reduced to a changelog entry, naming that step.")
if __name__ == "__main__":
main()
examples/05_confirmation_untouched.py (4240 bytes)
"""Exercise 5 -- was the confirmation set actually untouched?
This is the hardest seam in the whole arc, because it leaves no trace in the
finished report. A study that peeked at its held-out half during exploration
and a study that did not produce IDENTICAL-looking reports: same interval,
same p-value, same figures. The only difference is the ORDER things happened
in, and the only record of order is the research log.
So the gate reads the log as a sequence and asks one question: does the first
use of the confirmation split come after the entry where the hypothesis was
declared? If it does not, the confirmation half was part of the exploration
and its p-value means nothing.
Run: ../.venv/bin/python3 05_confirmation_untouched.py
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import acceptance
import fixtures as fx
def show(rows) -> None:
print(f" {'seq':>3} {'split':<12} {'activity'}")
for row in rows:
print(f" {row['seq']:>3} {row['split']:<12} {row['activity']}")
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
good = fx.worked_study(root)
rows = acceptance.research_log_rows((good / "RESEARCH_LOG.md").read_text())
print("The worked study's research log, in order:")
show(rows)
print()
splits = [row["split"] for row in rows]
hypothesis_at = splits.index("none")
confirmation_at = splits.index("confirmation")
print(f" hypothesis declared at entry {hypothesis_at + 1}")
print(f" confirmation split first used at entry {confirmation_at + 1}")
assert confirmation_at > hypothesis_at
assert splits.count("confirmation") == 1
assert splits.count("exploration") == 4
gate = acceptance.check_study(good).gate("confirmation_untouched")
assert gate.ok, gate.findings
print(" gate confirmation_untouched -> PASS")
# The same study, same numbers, same figures -- but the log shows the
# held-out half was opened at entry 2, before any hypothesis existed.
peeked = fx.variant(good, root / "peeked", fx.break_confirmation_peeked)
print()
print("A study whose log shows the held-out half was opened early:")
show(acceptance.research_log_rows((peeked / "RESEARCH_LOG.md").read_text()))
result = acceptance.check_study(peeked).gate("confirmation_untouched")
assert not result.ok
print()
for finding in result.findings:
print(f" {finding}")
assert any("before the hypothesis was declared" in f for f in result.findings)
assert any("used 2 times" in f for f in result.findings)
# Note what did NOT change. The report, the figures and the interval
# are byte-identical between the two studies. The peek is invisible
# everywhere except the log.
for name in ("REPORT.md", "FIGURES.json"):
assert (good / name).read_bytes() == (peeked / name).read_bytes(), name
print()
print(" REPORT.md and FIGURES.json are byte-identical in both studies:")
print(" the peek is visible in the log and nowhere else.")
# A log with no confirmation entry at all fails differently, and
# should: nothing was confirmed.
def strip_confirmation(study_dir: Path) -> None:
path = Path(study_dir) / "RESEARCH_LOG.md"
kept = [
line
for line in path.read_text().splitlines()
if "| confirmation |" not in line
]
path.write_text("\n".join(kept) + "\n", encoding="utf-8")
never = fx.variant(good, root / "never-confirmed", strip_confirmation)
findings = acceptance.check_study(never).gate("confirmation_untouched").findings
print()
print("A log that never uses the confirmation split at all:")
print(f" {findings[0]}")
assert "never used" in findings[0], findings
print()
print("OK: the confirmation gate reads the log's ordering, catches a split")
print(" used before the hypothesis existed, and catches one never used.")
if __name__ == "__main__":
main()
examples/06_uncertainty_in_the_prose.py (4822 bytes)
"""Exercise 6 -- uncertainty in the prose, not only in the notebook.
Days 117 and 118 built the interval. This exercise checks it survived the trip
into the report. It almost always does not: the analyst computes a 95% CI,
looks at it, decides the effect is real, and writes "roadside stations are
5.5 ug/m3 higher" -- which is a number wearing the costume of a fact.
The gate is a heuristic and says so out loud. A findings sentence carrying a
number AND an estimate word must also carry interval evidence: a CI, a
plus-or-minus, a bracketed range, a "x to y" or a "between x and y". It scans
the findings section only, because a methods paragraph mentioning a row count
is not a claim, and a checker that flags those trains you to ignore it.
Run: ../.venv/bin/python3 06_uncertainty_in_the_prose.py
"""
from __future__ import annotations
import tempfile
import textwrap
from pathlib import Path
import acceptance
import fixtures as fx
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
good = fx.worked_study(root)
block = acceptance.findings_section((good / "REPORT.md").read_text())
print("The worked study's findings section:")
for sentence in acceptance.sentences_of(block):
print(textwrap.indent(textwrap.fill(sentence, 72), " "))
print()
gate = acceptance.check_study(good).gate("uncertainty_reported")
assert gate.ok, gate.findings
print(" gate uncertainty_reported -> PASS")
# Strip the interval; keep the point estimate.
bare = fx.variant(good, root / "no-interval", fx.break_uncertainty)
result = acceptance.check_study(bare).gate("uncertainty_reported")
assert not result.ok
print()
print("The same finding with the interval removed:")
for finding in result.findings:
print(textwrap.indent(textwrap.fill(finding, 72), " "))
assert len(result.findings) == 1, result.findings
assert "5.50 ug/m3 higher than park stations" in result.findings[0]
# The gate names the SENTENCE, not the file. On a twelve-page report
# that is the difference between a fix and a re-read.
assert "estimate reported without an interval" in result.findings[0]
# Which forms of interval evidence the gate accepts, checked one at a
# time against the same sentence.
print()
print("Interval evidence the gate accepts, tested one form at a time:")
forms = (
"The mean difference was 5.50 ug/m3 (95% CI 3.80 to 7.21).",
"The mean difference was 5.50 ug/m3, plus or minus 1.70 (±1.70).",
"The mean difference was 5.50 ug/m3, interval [3.80, 7.21].",
"The mean difference was anywhere between 3.80 and 7.21 ug/m3.",
"The estimated mean difference was 3.80 to 7.21 ug/m3.",
)
for sentence in forms:
def rewrite(study_dir: Path, s=sentence) -> None:
path = Path(study_dir) / "REPORT.md"
lines = path.read_text().splitlines()
start = lines.index("## Findings")
end = next(i for i in range(start + 1, len(lines))
if lines[i].startswith("## "))
path.write_text(
"\n".join(lines[:start] + ["## Findings", "", s, ""] + lines[end:])
+ "\n",
encoding="utf-8",
)
probe = fx.variant(good, root / "form", rewrite)
ok = acceptance.check_study(probe).gate("uncertainty_reported").ok
print(f" {'accepted' if ok else 'REJECTED':9} {sentence}")
assert ok, sentence
# And a form it correctly rejects.
def rewrite_bare(study_dir: Path) -> None:
path = Path(study_dir) / "REPORT.md"
lines = path.read_text().splitlines()
start = lines.index("## Findings")
end = next(i for i in range(start + 1, len(lines))
if lines[i].startswith("## "))
path.write_text(
"\n".join(
lines[:start]
+ ["## Findings", "", "The mean difference was 5.50 ug/m3.", ""]
+ lines[end:]
)
+ "\n",
encoding="utf-8",
)
probe = fx.variant(good, root / "bare-form", rewrite_bare)
assert not acceptance.check_study(probe).gate("uncertainty_reported").ok
print(" REJECTED The mean difference was 5.50 ug/m3.")
print()
print("OK: the uncertainty gate accepts five forms of interval evidence, and")
print(" names the exact sentence when an estimate stands without one.")
if __name__ == "__main__":
main()
examples/07_figures_carry_claims.py (3691 bytes)
"""Exercise 7 -- every figure carries a question and a claim (Day 133).
Day 133's rule, mechanised. A figure exists to answer a question and to
support a claim. A figure with neither is decoration, and decoration in a
report is where a reader's attention goes to die -- worse, it is where a
reader's TRUST goes, because a chart that says nothing still looks like
evidence.
The gate checks both directions, which matters more than it sounds:
* every documented figure has a file that exists, a question and a claim;
* every figure file on disk is documented.
The second catches the chart that survived three drafts because nobody
remembered what it was for.
Run: ../.venv/bin/python3 07_figures_carry_claims.py
"""
from __future__ import annotations
import json
import tempfile
import textwrap
from pathlib import Path
import acceptance
import fixtures as fx
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
good = fx.worked_study(root)
records = json.loads((good / "FIGURES.json").read_text())
print("The worked study's figure records:")
for record in records:
print(f" {record['file']}")
print(f" chart: {record['chart']}")
print(f" baseline: {record['baseline']}")
print(textwrap.indent(textwrap.fill(f"question: {record['question']}", 68), " "))
print(textwrap.indent(textwrap.fill(f"claim: {record['claim']}", 68), " "))
print()
assert (good / record["file"]).is_file()
gate = acceptance.check_study(good).gate("figures_documented")
assert gate.ok, gate.findings
print(" gate figures_documented -> PASS (two figures, both documented)")
# A figure with no claim.
for key in ("claim", "question"):
broken = fx.variant(
good,
root / f"no-{key}",
lambda d, k=key: fx.break_figure_label(d, index=0, key=k),
)
findings = acceptance.check_study(broken).gate("figures_documented").findings
assert len(findings) == 1, findings
assert findings[0].endswith(f"carries no {key}"), findings
assert "fig-01-pm25-by-station-type.png" in findings[0]
print(f" remove {key:8} -> {findings[0]}")
# A figure file nobody documented.
stray = fx.variant(good, root / "stray-figure", fx.break_figure_undocumented)
findings = acceptance.check_study(stray).gate("figures_documented").findings
assert len(findings) == 1, findings
assert "fig-99-leftover.png" in findings[0] and "undocumented" in findings[0]
print(f" stray file -> {findings[0]}")
# A record pointing at a figure that was never rendered.
def dangling(study_dir: Path) -> None:
path = Path(study_dir) / "FIGURES.json"
payload = json.loads(path.read_text())
payload[1]["file"] = "figures/fig-03-never-rendered.png"
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
(Path(study_dir) / "figures" / "fig-02-pm25-distribution.png").unlink()
missing = fx.variant(good, root / "dangling", dangling)
findings = acceptance.check_study(missing).gate("figures_documented").findings
assert any("does not exist" in f for f in findings), findings
print(f" dangling record -> {findings[0]}")
print()
print("OK: the figures gate passes documented figures and fails an entry with")
print(" no question or claim, a stray file, and a record with no file.")
if __name__ == "__main__":
main()
examples/08_reproducibility.py (5088 bytes)
"""Exercise 8 -- the study regenerates byte for byte, and the harness notices
when it does not.
Two separate claims, and they need separate evidence.
The first is about the worked study: built twice, into two different
directories, it produces identical bytes -- every Markdown file, every JSON
record, and both PNG figures. That is not luck. It is the direct consequence
of four decisions in `study.py`: the as-of date is a parameter rather than a
clock reading, the split is a seeded permutation, the report is wrapped by
`textwrap.fill` rather than by hand, and the figures are saved with their
PNG `Software` metadata suppressed. Remove any one of them and this exercise
fails.
The second is about the harness: given a study whose outputs have moved since
its manifest was written, it says so and names the file.
Run: ../.venv/bin/python3 08_reproducibility.py
"""
from __future__ import annotations
import hashlib
import json
import tempfile
from pathlib import Path
import acceptance
import fixtures as fx
import study
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
first = root / "run-1"
second = root / "run-2"
summary_a = study.build_study(first)
summary_b = study.build_study(second)
assert summary_a == summary_b, "two runs measured different numbers"
markdown = sorted(p.name for p in first.glob("*.md"))
print("Two independent builds, Markdown compared byte for byte:")
for name in markdown:
a, b = digest(first / name), digest(second / name)
print(f" {name:20} {a[:16]} {'identical' if a == b else 'DIFFERENT'}")
assert a == b, name
assert markdown == ["CLEANING.md", "QUESTION.md", "REPORT.md", "RESEARCH_LOG.md"]
print()
print("And every other generated file, including the figures:")
for rel in study.manifest_targets(first) + [study.MANIFEST_NAME]:
a, b = digest(first / rel), digest(second / rel)
status = "identical" if a == b else "DIFFERENT"
print(f" {rel:44} {status}")
assert a == b, rel
gate = acceptance.check_study(first).gate("outputs_reproducible")
assert gate.ok, gate.findings
print()
print(" gate outputs_reproducible -> PASS on a fresh build")
# Now the harness's side of the claim. A study whose report changed
# after the manifest was written -- which is exactly what a pipeline
# that stamps a timestamp into its output looks like from outside.
drifted = fx.variant(
first, root / "drifted", fx.break_reproducibility, rewrite_manifest=False
)
result = acceptance.check_study(drifted).gate("outputs_reproducible")
assert not result.ok
print()
print("A study whose output moved after its manifest was written:")
for finding in result.findings:
print(f" {finding}")
assert len(result.findings) == 1, result.findings
assert "REPORT.md" in result.findings[0]
assert "the output changed since the manifest was written" in result.findings[0]
# Rebuilding with a different as-of date moves several outputs at once,
# and the harness lists every one of them rather than stopping at the
# first -- the verdict is a task list, not an exception.
rebuilt = root / "rebuilt"
study.build_study(rebuilt, as_of="2026-07-15")
stale_manifest = json.loads((first / "MANIFEST.json").read_text())
(rebuilt / "MANIFEST.json").write_text(
json.dumps(stale_manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
result = acceptance.check_study(rebuilt).gate("outputs_reproducible")
moved = [f.split(" does not match")[0] for f in result.findings]
print()
print("A rebuild with a different as-of date, checked against the old manifest:")
for name in moved:
print(f" moved: {name}")
assert sorted(moved) == ["REPORT.md", "SOURCE.json"], moved
# An output nobody put in the manifest is caught too.
def add_untracked(study_dir: Path) -> None:
(Path(study_dir) / "scratch-notes.md").write_text(
"# Scratch\n\nnumbers from the third attempt\n", encoding="utf-8"
)
untracked = fx.variant(
first, root / "untracked", add_untracked, rewrite_manifest=False
)
findings = acceptance.check_study(untracked).gate("outputs_reproducible").findings
assert len(findings) == 1 and "not covered by MANIFEST.json" in findings[0]
print()
print("An output file the manifest never heard of:")
print(f" {findings[0]}")
print()
print("OK: the worked study rebuilds byte-identically, and the harness names")
print(" every output that moved, went missing, or was never tracked.")
if __name__ == "__main__":
main()
examples/09_whole_harness.py (4111 bytes)
"""Exercise 9 -- the whole harness on the worked study, and on a broken one.
A harness that has only ever passed is an untested harness. So this script
does two things.
First it runs all eight gates against the complete worked study and asserts a
clean verdict -- which is the day's claim that the arc holds together, made by
running it rather than by saying it.
Then it removes ONE required element from that same study -- the
`checksum_sha256` field in SOURCE.json, a single line -- and asserts the
harness catches it, names it, and fails exactly one gate. That is the proof
that matters: the harness can fail on a real study, not only on a fixture
built to fail.
Finally it removes three elements at once and confirms the verdict is a task
list rather than a first exception.
Run: ../.venv/bin/python3 09_whole_harness.py
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import acceptance
import fixtures as fx
def remove_checksum(study_dir: Path) -> None:
"""Delete one required element from an otherwise complete study."""
path = Path(study_dir) / "SOURCE.json"
payload = json.loads(path.read_text(encoding="utf-8"))
del payload["checksum_sha256"]
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def remove_three(study_dir: Path) -> None:
fx.break_missing_question(study_dir)
fx.break_grain(study_dir)
fx.break_figure_label(study_dir, index=1, key="claim")
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
good = fx.worked_study(root)
verdict = acceptance.check_study(good)
print(verdict.summary())
print()
assert verdict.ok, verdict.findings
assert len(verdict.gates) == 8
assert tuple(g.name for g in verdict.gates) == acceptance.GATE_NAMES
assert verdict.findings == ()
print(f" all {len(verdict.gates)} gates pass on the worked study")
# One required element removed from a real, complete study.
broken = fx.variant(good, root / "one-element-removed", remove_checksum)
verdict = acceptance.check_study(broken)
print()
print("The same study with SOURCE.json's checksum_sha256 deleted:")
print(verdict.summary())
assert not verdict.ok
assert verdict.failed_gates == ("provenance_complete",), verdict.failed_gates
assert verdict.findings == ("SOURCE.json is missing: checksum_sha256",)
# Three at once: every gate still runs, and the verdict lists all of it.
several = fx.variant(good, root / "three-elements-removed", remove_three)
verdict = acceptance.check_study(several)
print()
print("Three elements removed at once -- the verdict is a task list:")
for finding in verdict.findings:
print(f" - {finding}")
assert set(verdict.failed_gates) == {
"question_recorded",
"grain_asserted",
"figures_documented",
}, verdict.failed_gates
assert len(verdict.findings) == 4, verdict.findings
# A directory that is not a study at all.
empty = root / "empty"
empty.mkdir()
verdict = acceptance.check_study(empty)
assert not verdict.ok
assert len(verdict.failed_gates) == 8, verdict.failed_gates
print()
print(f" an empty directory fails all {len(verdict.failed_gates)} gates")
# And a path that does not exist raises rather than quietly passing.
try:
acceptance.check_study(root / "nowhere")
except FileNotFoundError as exc:
print(f" a missing path raises FileNotFoundError: {str(exc)[:44]}...")
else: # pragma: no cover - only reached if the harness is wrong
raise AssertionError("a missing study directory must raise")
print()
print("OK: eight gates pass on the worked study, one deleted field fails")
print(" exactly one gate by name, and three failures come back as three.")
if __name__ == "__main__":
main()
examples/acceptance.py (27274 bytes)
"""The acceptance harness: `check_study(path)` grades a study directory.
This is the day's deliverable. It reads a directory laid out the way
`study.py` writes one and returns a structured verdict: eight gates, each
either passing or carrying a list of findings that name the file, the field
or the sentence at fault.
The eight gates, and the seam each one guards:
question_recorded a question written down before the looking
provenance_complete url, retrieval date, checksum, licence
grain_asserted "one row is one ___", checked not assumed
damage_report_quantified every cleaning step measured before and after
confirmation_untouched the split's first use comes after the hypothesis
uncertainty_reported every estimate in the findings carries an interval
figures_documented every figure carries a question and a claim
outputs_reproducible every output still matches its manifest checksum
Two design decisions worth stating plainly, because they are the difference
between a harness that helps and one that is decorative.
**It reads the artefacts, not the intent.** No gate asks whether the analysis
was good. `uncertainty_reported` cannot tell whether an interval is correctly
computed; it can tell whether one is there. That is a much smaller claim, and
it is a claim a machine can actually make.
**Its findings name things.** "Provenance incomplete" is useless at 23:00 the
night before a deadline. "SOURCE.json is missing: checksum_sha256" is a task.
The machine-readable files are JSON rather than YAML for one reason: JSON is
in the standard library and YAML is not, so the harness has no dependency
beyond what the study itself needs. The same eight gates apply unchanged to a
YAML study directory if you swap `json.loads` for a YAML parser.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
# ---------------------------------------------------------------------------
# The verdict types
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class GateResult:
"""One gate's outcome. A passing gate carries no findings; a failing gate
carries at least one, and each finding names what is wrong and where."""
name: str
ok: bool
findings: tuple[str, ...] = ()
def __str__(self) -> str: # pragma: no cover - convenience only
mark = "PASS" if self.ok else "FAIL"
if self.ok:
return f"[{mark}] {self.name}"
joined = "\n".join(f" - {f}" for f in self.findings)
return f"[{mark}] {self.name}\n{joined}"
@dataclass(frozen=True)
class StudyVerdict:
"""The whole harness's answer about one study directory."""
path: str
gates: tuple[GateResult, ...] = field(default_factory=tuple)
@property
def ok(self) -> bool:
return all(gate.ok for gate in self.gates)
@property
def failed_gates(self) -> tuple[str, ...]:
return tuple(gate.name for gate in self.gates if not gate.ok)
@property
def findings(self) -> tuple[str, ...]:
return tuple(f for gate in self.gates for f in gate.findings)
def gate(self, name: str) -> GateResult:
for gate in self.gates:
if gate.name == name:
return gate
raise KeyError(f"no such gate: {name!r} (have {[g.name for g in self.gates]})")
def summary(self) -> str:
head = "ACCEPTED" if self.ok else "NOT ACCEPTED"
lines = [f"{head}: {self.path}"]
lines += [str(gate) for gate in self.gates]
return "\n".join(lines)
def _passed(name: str) -> GateResult:
return GateResult(name=name, ok=True, findings=())
def _failed(name: str, findings) -> GateResult:
findings = tuple(findings)
if not findings: # a failing gate with nothing to say is a bug
raise ValueError(f"gate {name!r} failed without a finding")
return GateResult(name=name, ok=False, findings=findings)
# ---------------------------------------------------------------------------
# Small readers, shared by the gates
# ---------------------------------------------------------------------------
def _read_text(study_dir: Path, name: str) -> str | None:
path = Path(study_dir) / name
if not path.is_file():
return None
return path.read_text(encoding="utf-8")
def _read_json(study_dir: Path, name: str):
"""Return (payload, error). Exactly one of the two is None."""
raw = _read_text(study_dir, name)
if raw is None:
return None, f"{name} is missing"
try:
return json.loads(raw), None
except json.JSONDecodeError as exc:
return None, f"{name} is not valid JSON: {exc}"
def sha256_of(path: Path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
# ---------------------------------------------------------------------------
# Gate 1 -- the question was recorded before the analysis
# ---------------------------------------------------------------------------
QUESTION_FILE = "QUESTION.md"
def gate_question_recorded(study_dir: Path) -> GateResult:
"""A study with no written question is a search for whichever question the
data happens to answer well. The gate cannot prove the question came
first; it can insist that it exists, is not empty, and is a question."""
name = "question_recorded"
text = _read_text(study_dir, QUESTION_FILE)
if text is None:
return _failed(name, [f"{QUESTION_FILE} is missing"])
if not text.strip():
return _failed(name, [f"{QUESTION_FILE} is empty"])
body = [
line.strip()
for line in text.splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
if not body:
return _failed(
name, [f"{QUESTION_FILE} contains only headings, no question text"]
)
if not any(line.endswith("?") for line in body):
return _failed(
name,
[
f"{QUESTION_FILE} records no question sentence "
f"(no non-heading line ends in a question mark)"
],
)
return _passed(name)
# ---------------------------------------------------------------------------
# Gate 2 -- provenance is complete (Day 134)
# ---------------------------------------------------------------------------
SOURCE_FILE = "SOURCE.json"
REQUIRED_SOURCE_FIELDS = ("url", "retrieved", "checksum_sha256", "licence")
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
def gate_provenance_complete(study_dir: Path) -> GateResult:
"""Where the data came from, when it was taken, under what licence, and
what it hashed to on arrival. Any one of those missing and the study
cannot be re-obtained by anyone, including its own author in six months."""
name = "provenance_complete"
study_dir = Path(study_dir)
payload, error = _read_json(study_dir, SOURCE_FILE)
if error is not None:
return _failed(name, [error])
if not isinstance(payload, dict):
return _failed(name, [f"{SOURCE_FILE} must be a JSON object"])
findings = []
for key in REQUIRED_SOURCE_FIELDS:
value = payload.get(key)
if value is None or (isinstance(value, str) and not value.strip()):
findings.append(f"{SOURCE_FILE} is missing: {key}")
retrieved = payload.get("retrieved")
if isinstance(retrieved, str) and retrieved.strip():
if not _DATE_RE.match(retrieved.strip()):
findings.append(
f"{SOURCE_FILE}: retrieved is not an ISO date "
f"(got {retrieved.strip()!r})"
)
# If the record names the local copy, the checksum is checkable, so check
# it. A checksum nobody verifies is a decoration.
recorded = payload.get("checksum_sha256")
local = payload.get("path")
if isinstance(local, str) and local.strip() and isinstance(recorded, str):
local_path = study_dir / local.strip()
if not local_path.is_file():
findings.append(f"{SOURCE_FILE}: path {local.strip()} does not exist")
elif sha256_of(local_path) != recorded.strip():
findings.append(
f"{SOURCE_FILE}: checksum_sha256 does not match {local.strip()} "
f"(recorded {recorded.strip()[:12]}..., "
f"actual {sha256_of(local_path)[:12]}...)"
)
if findings:
return _failed(name, findings)
return _passed(name)
# ---------------------------------------------------------------------------
# Gate 3 -- the ingestion asserts a row grain (Day 135)
# ---------------------------------------------------------------------------
INGEST_FILE = "INGEST.json"
def gate_grain_asserted(study_dir: Path) -> GateResult:
""""One row is one ___" is the sentence every downstream count depends on.
The gate wants the sentence written down AND checked -- a declared grain
that was never verified is a hope."""
name = "grain_asserted"
payload, error = _read_json(study_dir, INGEST_FILE)
if error is not None:
return _failed(name, [error])
if not isinstance(payload, dict):
return _failed(name, [f"{INGEST_FILE} must be a JSON object"])
findings = []
grain = payload.get("grain")
has_grain = isinstance(grain, list) and bool(grain)
if not has_grain:
findings.append(
f"{INGEST_FILE} declares no row grain: expected a non-empty "
f"'grain' list of key columns"
)
if "grain_verified" not in payload:
stated = "declares a grain but records" if has_grain else "records"
findings.append(
f"{INGEST_FILE} {stated} no 'grain_verified' result: "
f"the grain was never checked against the data"
)
elif payload.get("grain_verified") is not True:
violations = payload.get("grain_violations", "an unrecorded number of")
findings.append(
f"{INGEST_FILE}: grain_verified is not true "
f"({violations} rows violate the declared grain)"
)
if "rows_in" not in payload:
findings.append(f"{INGEST_FILE} records no 'rows_in' count")
if findings:
return _failed(name, findings)
return _passed(name)
# ---------------------------------------------------------------------------
# Gate 4 -- the cleaning carries a damage report (Days 121, 125)
# ---------------------------------------------------------------------------
CLEANING_FILE = "CLEANING.md"
_STEP_RE = re.compile(r"^###\s+(?P<title>.+?)\s*$")
_MEASURE_RE = re.compile(r"^(?P<key>before|after)\s*:\s*(?P<value>-?[\d.]+)\s*$", re.I)
def cleaning_steps(text: str) -> list[tuple[str, dict[str, float]]]:
"""Split CLEANING.md into (step title, measurements) pairs."""
steps: list[tuple[str, dict[str, float]]] = []
current: str | None = None
values: dict[str, float] = {}
for line in text.splitlines():
header = _STEP_RE.match(line)
if header:
if current is not None:
steps.append((current, values))
current = header.group("title")
values = {}
continue
if current is None:
continue
measure = _MEASURE_RE.match(line.strip())
if measure:
values[measure.group("key").lower()] = float(measure.group("value"))
if current is not None:
steps.append((current, values))
return steps
def gate_damage_report_quantified(study_dir: Path) -> GateResult:
"""A changelog says what you did. A damage report says what it cost. The
gate insists on a before and an after number for every step, and refuses
a step whose before and after are identical -- a step that changed
nothing measurable did not need doing, or measured the wrong thing."""
name = "damage_report_quantified"
text = _read_text(study_dir, CLEANING_FILE)
if text is None:
return _failed(name, [f"{CLEANING_FILE} is missing"])
steps = cleaning_steps(text)
if not steps:
return _failed(
name,
[
f"{CLEANING_FILE} lists no cleaning steps "
f"(expected one '### <step name>' heading per step)"
],
)
findings = []
for title, values in steps:
missing = [k for k in ("before", "after") if k not in values]
if missing:
findings.append(
f"{CLEANING_FILE}: cleaning step '{title}' is a changelog "
f"entry, not a damage report: no "
f"{' or '.join(missing)} measurement"
)
elif values["before"] == values["after"]:
findings.append(
f"{CLEANING_FILE}: cleaning step '{title}' reports "
f"before == after ({values['before']:g}); nothing was measured "
f"to change"
)
if findings:
return _failed(name, findings)
return _passed(name)
# ---------------------------------------------------------------------------
# Gate 5 -- the confirmation set was untouched during exploration (Day 136)
# ---------------------------------------------------------------------------
LOG_FILE = "RESEARCH_LOG.md"
_ROW_RE = re.compile(r"^\|(?P<cells>.+)\|\s*$")
def research_log_rows(text: str) -> list[dict[str, str]]:
"""Read the research log's Markdown table into ordered dicts."""
header: list[str] | None = None
rows: list[dict[str, str]] = []
for line in text.splitlines():
match = _ROW_RE.match(line.strip())
if not match:
continue
cells = [cell.strip() for cell in match.group("cells").split("|")]
if header is None:
header = [cell.lower() for cell in cells]
continue
if all(set(cell) <= {"-", ":"} and cell for cell in cells):
continue # the ---|--- separator row
if len(cells) != len(header):
continue
rows.append(dict(zip(header, cells)))
return rows
def gate_confirmation_untouched(study_dir: Path) -> GateResult:
"""The confirmation half only means anything if it was opened once, after
the hypothesis existed. That is a claim about ORDER, and the research log
is the only record of order the study has -- so the gate reads the log's
own sequence and compares the hypothesis row against the first
confirmation row."""
name = "confirmation_untouched"
text = _read_text(study_dir, LOG_FILE)
if text is None:
return _failed(name, [f"{LOG_FILE} is missing"])
rows = research_log_rows(text)
if not rows:
return _failed(
name,
[
f"{LOG_FILE} contains no log entries "
f"(expected a Markdown table with seq, timestamp, split, "
f"activity and outcome columns)"
],
)
if "split" not in rows[0]:
return _failed(name, [f"{LOG_FILE} has no 'split' column"])
if "activity" not in rows[0]:
return _failed(name, [f"{LOG_FILE} has no 'activity' column"])
findings = []
hypothesis_at = None
confirmation_at = None
for index, row in enumerate(rows):
activity = row.get("activity", "").lower()
split = row.get("split", "").lower()
if hypothesis_at is None and "hypothesis declared" in activity:
hypothesis_at = index
if confirmation_at is None and split == "confirmation":
confirmation_at = index
if hypothesis_at is None:
findings.append(
f"{LOG_FILE} records no 'hypothesis declared' entry, so there is "
f"nothing the confirmation set can be said to come after"
)
if confirmation_at is None:
findings.append(
f"{LOG_FILE} records no entry against the confirmation split: the "
f"held-out half was never used, so nothing was confirmed"
)
if hypothesis_at is not None and confirmation_at is not None:
if confirmation_at <= hypothesis_at:
first = rows[confirmation_at]
findings.append(
f"{LOG_FILE}: the confirmation split was first used at entry "
f"{first.get('seq', confirmation_at + 1)} "
f"({first.get('activity', 'unnamed activity')}), before the "
f"hypothesis was declared at entry "
f"{rows[hypothesis_at].get('seq', hypothesis_at + 1)} -- the "
f"held-out half was part of the exploration"
)
confirmation_uses = sum(
1 for row in rows if row.get("split", "").lower() == "confirmation"
)
if confirmation_uses > 1:
findings.append(
f"{LOG_FILE}: the confirmation split is used {confirmation_uses} "
f"times; a confirmation set tested more than once is an "
f"exploration set with a better name"
)
if findings:
return _failed(name, findings)
return _passed(name)
# ---------------------------------------------------------------------------
# Gate 6 -- the reported estimates carry uncertainty (Days 117, 118)
# ---------------------------------------------------------------------------
REPORT_FILE = "REPORT.md"
FINDINGS_HEADING = "## Findings"
ESTIMATE_WORDS = (
"mean",
"average",
"median",
"difference",
"rate",
"estimate",
"higher",
"lower",
"increase",
"decrease",
"proportion",
)
_NUMBER_RE = re.compile(r"-?\d+(?:\.\d+)?")
_INTERVAL_RES = (
re.compile(r"\bci\b", re.I),
re.compile(r"confidence interval", re.I),
re.compile(r"credible interval", re.I),
re.compile(r"\binterval\b", re.I),
re.compile(r"±"),
re.compile(r"\+/-"),
re.compile(r"-?\d+(?:\.\d+)?\s+to\s+-?\d+(?:\.\d+)?"),
# "anywhere between 3.80 and 7.21" states an interval in words. This
# pattern was added because the harness flagged exactly that sentence in
# its own worked report -- a real false positive, fixed in the checker
# rather than papered over by rewording the report.
re.compile(r"between\s+-?\d+(?:\.\d+)?\s+and\s+-?\d+(?:\.\d+)?", re.I),
re.compile(r"\[\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\]"),
)
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
def findings_section(text: str) -> str | None:
"""The report's findings section only. The gate is deliberately scoped:
a methods paragraph mentioning a row count is not a claim, and flagging
it would train the reader to ignore the harness."""
lines = text.splitlines()
start = None
for index, line in enumerate(lines):
if line.strip().lower() == FINDINGS_HEADING.lower():
start = index + 1
break
if start is None:
return None
end = len(lines)
for index in range(start, len(lines)):
if lines[index].startswith("## "):
end = index
break
return "\n".join(lines[start:end])
def sentences_of(block: str) -> list[str]:
paragraphs = [p for p in block.split("\n\n") if p.strip()]
sentences = []
for paragraph in paragraphs:
flat = " ".join(paragraph.split())
if flat.startswith(("!", "|", "#", "-", "*")):
continue # images, tables, headings and bullets are not prose
sentences.extend(s.strip() for s in _SENTENCE_SPLIT.split(flat) if s.strip())
return sentences
def gate_uncertainty_reported(study_dir: Path) -> GateResult:
"""An estimate with no interval is a number wearing the costume of a
fact. The gate is a heuristic and says so: a findings sentence that
carries a number AND an estimate word must also carry interval evidence
-- a CI, a plus-or-minus, a bracketed range, or a "x to y" pair."""
name = "uncertainty_reported"
text = _read_text(study_dir, REPORT_FILE)
if text is None:
return _failed(name, [f"{REPORT_FILE} is missing"])
block = findings_section(text)
if block is None:
return _failed(
name, [f"{REPORT_FILE} has no '{FINDINGS_HEADING}' section to check"]
)
sentences = sentences_of(block)
if not sentences:
return _failed(
name, [f"{REPORT_FILE}: the findings section contains no prose"]
)
estimatesentences_of = [
sentence
for sentence in sentences
if _NUMBER_RE.search(sentence)
and any(word in sentence.lower() for word in ESTIMATE_WORDS)
]
if not estimatesentences_of:
return _failed(
name,
[
f"{REPORT_FILE}: the findings section reports no numeric "
f"estimate at all, so there is nothing for a reader to act on"
],
)
findings = []
for sentence in estimatesentences_of:
if not any(pattern.search(sentence) for pattern in _INTERVAL_RES):
shown = sentence if len(sentence) <= 160 else sentence[:157] + "..."
findings.append(
f'{REPORT_FILE}: estimate reported without an interval -- "{shown}"'
)
if findings:
return _failed(name, findings)
return _passed(name)
# ---------------------------------------------------------------------------
# Gate 7 -- every figure carries a question and a claim (Day 133)
# ---------------------------------------------------------------------------
FIGURES_FILE = "FIGURES.json"
FIGURES_DIR = "figures"
FIGURE_SUFFIXES = (".png", ".svg", ".jpg", ".jpeg", ".pdf")
def gate_figures_documented(study_dir: Path) -> GateResult:
"""Day 133's rule, mechanised: a figure exists to answer a question and to
support a claim. A figure with neither is decoration, and decoration in a
report is where a reader's attention goes to die."""
name = "figures_documented"
study_dir = Path(study_dir)
payload, error = _read_json(study_dir, FIGURES_FILE)
if error is not None:
return _failed(name, [error])
if not isinstance(payload, list):
return _failed(
name, [f"{FIGURES_FILE} must be a JSON list of figure records"]
)
findings = []
documented = set()
for index, entry in enumerate(payload):
label = f"{FIGURES_FILE}[{index}]"
if not isinstance(entry, dict):
findings.append(f"{label} is not an object")
continue
file_name = entry.get("file")
if not isinstance(file_name, str) or not file_name.strip():
findings.append(f"{label} names no file")
else:
documented.add(file_name.strip())
if not (study_dir / file_name.strip()).is_file():
findings.append(f"{label}: {file_name.strip()} does not exist")
for key in ("question", "claim"):
value = entry.get(key)
if not isinstance(value, str) or not value.strip():
shown = file_name if isinstance(file_name, str) else label
findings.append(
f"{FIGURES_FILE}: figure {shown} carries no {key}"
)
figures_dir = study_dir / FIGURES_DIR
if figures_dir.is_dir():
for path in sorted(figures_dir.rglob("*")):
if not path.is_file() or path.suffix.lower() not in FIGURE_SUFFIXES:
continue
rel = path.relative_to(study_dir).as_posix()
if rel not in documented:
findings.append(
f"{rel} is present but undocumented: no entry in "
f"{FIGURES_FILE}"
)
if findings:
return _failed(name, findings)
return _passed(name)
# ---------------------------------------------------------------------------
# Gate 8 -- the outputs still match their manifest (Day 126)
# ---------------------------------------------------------------------------
MANIFEST_FILE = "MANIFEST.json"
_MANIFEST_EXCLUDE = {MANIFEST_FILE}
def gate_outputs_reproducible(study_dir: Path) -> GateResult:
"""A manifest of SHA-256 digests turns "it reproduces" from a belief into
a check. Rebuild the study, rerun this gate: if any digest moved, the
pipeline is not deterministic and the report is not reproducible, whatever
its methods section claims."""
name = "outputs_reproducible"
study_dir = Path(study_dir)
payload, error = _read_json(study_dir, MANIFEST_FILE)
if error is not None:
return _failed(name, [error])
if not isinstance(payload, dict) or not isinstance(payload.get("files"), dict):
return _failed(
name,
[f"{MANIFEST_FILE} must be an object with a 'files' map of path to digest"],
)
entries: dict[str, str] = payload["files"]
if not entries:
return _failed(name, [f"{MANIFEST_FILE} records no files"])
findings = []
for rel in sorted(entries):
target = study_dir / rel
if not target.is_file():
findings.append(f"{MANIFEST_FILE} lists {rel}, which does not exist")
continue
actual = sha256_of(target)
if actual != entries[rel]:
findings.append(
f"{rel} does not match its manifest checksum "
f"(manifest {entries[rel][:12]}..., actual {actual[:12]}...): "
f"the output changed since the manifest was written"
)
on_disk = set()
for path in sorted(study_dir.rglob("*")):
if path.is_file():
rel = path.relative_to(study_dir).as_posix()
if rel not in _MANIFEST_EXCLUDE:
on_disk.add(rel)
for rel in sorted(on_disk - set(entries)):
findings.append(f"{rel} exists but is not covered by {MANIFEST_FILE}")
for required in (REPORT_FILE,):
if required not in entries:
findings.append(f"{MANIFEST_FILE} does not cover {required}")
if findings:
return _failed(name, findings)
return _passed(name)
# ---------------------------------------------------------------------------
# The harness
# ---------------------------------------------------------------------------
GATES = (
gate_question_recorded,
gate_provenance_complete,
gate_grain_asserted,
gate_damage_report_quantified,
gate_confirmation_untouched,
gate_uncertainty_reported,
gate_figures_documented,
gate_outputs_reproducible,
)
GATE_NAMES = (
"question_recorded",
"provenance_complete",
"grain_asserted",
"damage_report_quantified",
"confirmation_untouched",
"uncertainty_reported",
"figures_documented",
"outputs_reproducible",
)
def check_study(path) -> StudyVerdict:
"""Run every gate against a study directory and return the verdict.
Gates run independently and all of them always run: a study missing its
question file should still be told about its missing checksum, because
the point is a task list, not the first thing that went wrong.
"""
study_dir = Path(path)
if not study_dir.is_dir():
raise FileNotFoundError(f"not a study directory: {study_dir}")
return StudyVerdict(
path=str(study_dir),
gates=tuple(gate(study_dir) for gate in GATES),
)
examples/conftest.py (1076 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain modules called `acceptance`, `study`,
`dataset` and `fixtures`, and pytest imports test files by putting their
directory on `sys.path`. Without this file, running `pytest` across both
directories at once would import whichever copy was seen first and reuse it
for the other -- so the starter tests would silently pass against the
reference solution instead of skipping. That is a wrong answer with a green
tick on it, which is the worst kind.
So: put this directory first on the import path, and drop any already-imported
module of those names that came from somewhere else.
"""
import sys
from pathlib import Path
HERE = str(Path(__file__).parent.resolve())
if HERE in sys.path:
sys.path.remove(HERE)
sys.path.insert(0, HERE)
for name in ("acceptance", "study", "dataset", "fixtures"):
module = sys.modules.get(name)
origin = getattr(module, "__file__", "") or ""
if module is not None and not origin.startswith(HERE):
del sys.modules[name]
examples/data/observations.csv (12265 bytes)
reading_id,station_id,captured_at,station_type,pm25_ug_m3,humidity_pct,temp_c
R0165,ST-06,2026-06-05, park ,-1.0,60.7,19.3
R0113,ST-04,2026-06-17,ROADSIDE,26.1,66.0,26.9
R0187,ST-06,2026-06-27, park ,9.56,51.7,27.3
R0133,ST-05,2026-06-05,PARK,12.73,58.2,25.3
R0103,ST-04,2026-06-07,roadside,16.59,40.5,17.7
R0039,ST-02,2026-06-07, roadside ,22.2,62.0,25.0
R0168,ST-06,2026-06-08,Park,5.53,56.1,21.4
R0095,ST-03,2026-06-31,ROADSIDE,17.51,56.5,25.4
R0241,ST-08,2026-06-17,Park,-1.0,84.5,22.6
R0041,ST-02,2026-06-09,ROADSIDE,16.17,89.0,18.9
R0143,ST-05,2026-06-15,PARK,8.88,45.2,19.7
R0144,ST-05,2026-06-16,park,19.44,83.0,22.0
R0042,ST-02,2026-06-10,roadside,16.21,45.3,16.9
R0214,ST-07,2026-06-22,park,14.45,52.8,19.8
R0126,ST-04,2026-06-30, roadside ,20.79,47.6,26.6
R0108,ST-04,2026-06-12,Roadside,12.05,58.7,16.1
R0019,ST-01,2026-06-19,ROADSIDE,23.87,87.4,18.3
R0156,ST-05,2026-06-28, park ,15.68,82.7,27.3
R0035,ST-02,2026-06-03,ROADSIDE,16.96,80.1,26.0
R0234,ST-08,2026-06-10,Park,13.87,53.7,27.9
R0210,ST-07,2026-06-18,Park,10.48,77.1,18.4
R0044,ST-02,2026-06-12, roadside ,17.62,75.4,26.0
R0140,ST-05,2026-06-12,Park,5.8,40.2,15.8
R0080,ST-03,2026-06-16,ROADSIDE,15.75,81.9,25.2
R0191,ST-06,2026-06-31,park,12.21,50.0,21.7
R0112,ST-04,2026-06-16, roadside ,14.05,55.9,28.6
R0189,ST-06,2026-06-29, park ,14.3,74.3,29.3
R0076,ST-03,2026-06-12,ROADSIDE,16.26,51.7,23.3
R0107,ST-04,2026-06-11, roadside ,27.61,43.6,17.9
R0021,ST-01,2026-06-21,roadside,15.79,57.4,25.0
R0212,ST-07,2026-06-20,Park,8.93,46.1,23.5
R0026,ST-01,2026-06-26,ROADSIDE,27.82,54.0,17.7
R0135,ST-05,2026-06-07, park ,10.05,87.1,16.4
R0252,ST-08,2026-06-28,PARK,13.22,40.9,24.5
R0072,ST-03,2026-06-08, roadside ,14.01,52.6,15.7
R0201,ST-07,2026-06-09, park ,12.41,82.7,16.4
R0131,ST-05,2026-06-03,Park,9.43,73.9,26.0
R0111,ST-04,2026-06-15,Roadside,10.85,85.1,20.5
R0121,ST-04,2026-06-25,ROADSIDE,13.53,64.4,17.9
R0231,ST-08,2026-06-07,Park,11.03,57.4,17.3
R0003,ST-01,2026-06-03, roadside ,13.78,89.0,16.5
R0181,ST-06,2026-06-21,park,11.26,80.7,18.3
R0170,ST-06,2026-06-10,PARK,11.88,63.1,23.5
R0172,ST-06,2026-06-12, park ,8.8,41.8,16.7
R0089,ST-03,2026-06-25,ROADSIDE,22.9,73.2,22.6
R0254,ST-08,2026-06-30, park ,14.26,60.0,17.1
R0104,ST-04,2026-06-08,roadside,7.61,52.4,19.5
R0199,ST-07,2026-06-07,PARK,-1.71,67.6,29.0
R0161,ST-06,2026-06-01,park,11.48,50.0,24.5
R0227,ST-08,2026-06-03,Park,11.47,86.6,20.8
R0178,ST-06,2026-06-18,Park,11.51,89.8,29.1
R0038,ST-02,2026-06-06,roadside,15.27,68.7,16.8
R0215,ST-07,2026-06-23,Park,19.63,74.2,22.3
R0034,ST-02,2026-06-02,roadside,12.82,51.4,20.5
R0186,ST-06,2026-06-26, park ,20.19,72.8,25.0
R0213,ST-07,2026-06-21,park,6.55,42.6,24.1
R0067,ST-03,2026-06-03,ROADSIDE,22.32,85.4,23.3
R0125,ST-04,2026-06-29,Roadside,19.84,55.3,20.4
R0163,ST-06,2026-06-03,PARK,14.66,55.0,29.4
R0088,ST-03,2026-06-24, roadside ,,72.4,15.7
R0096,ST-03,2026-06-32,roadside,17.76,83.7,26.9
R0082,ST-03,2026-06-18, roadside ,11.41,74.5,16.0
R0256,ST-08,2026-06-32,park,19.43,67.2,16.0
R0237,ST-08,2026-06-13,Park,7.81,79.9,28.8
R0074,ST-03,2026-06-10,Roadside,26.51,64.3,29.7
R0223,ST-07,2026-06-31, park ,1.62,59.7,15.0
R0148,ST-05,2026-06-20,Park,4.39,72.4,20.9
R0202,ST-07,2026-06-10, park ,22.3,83.3,29.5
R0027,ST-01,2026-06-27, roadside ,12.74,66.9,20.7
R0226,ST-08,2026-06-02,PARK,17.78,66.5,19.3
R0218,ST-07,2026-06-26,PARK,13.57,48.7,27.2
R0100,ST-04,2026-06-04,Roadside,18.37,80.8,21.1
R0142,ST-05,2026-06-14,park,5.24,87.0,23.5
R0173,ST-06,2026-06-13,Park,12.63,73.3,19.3
R0206,ST-07,2026-06-14,PARK,4.45,89.2,23.7
R0077,ST-03,2026-06-13,roadside,14.93,51.2,17.7
R0009,ST-01,2026-06-09,roadside,21.5,53.1,29.3
R0250,ST-08,2026-06-26, park ,10.08,83.4,27.8
R0247,ST-08,2026-06-23,Park,6.28,62.9,28.9
R0090,ST-03,2026-06-26,Roadside,,68.4,20.4
R0048,ST-02,2026-06-16,ROADSIDE,16.73,69.2,23.2
R0221,ST-07,2026-06-29,park,11.87,40.5,17.8
R0084,ST-03,2026-06-20,roadside,22.79,61.9,23.0
R0031,ST-01,2026-06-31, roadside ,11.37,41.8,22.3
R0079,ST-03,2026-06-15,roadside,23.33,43.8,16.1
R0032,ST-01,2026-06-32, roadside ,20.36,63.6,17.0
R0083,ST-03,2026-06-19,ROADSIDE,21.34,70.5,27.9
R0094,ST-03,2026-06-30,Roadside,17.79,67.8,22.9
R0250,ST-08,2026-06-26, park ,10.08,83.4,27.8
R0030,ST-01,2026-06-30,roadside,13.58,65.0,22.4
R0205,ST-07,2026-06-13,PARK,10.38,75.0,19.2
R0158,ST-05,2026-06-30,Park,8.12,63.3,21.2
R0106,ST-04,2026-06-10,ROADSIDE,18.35,41.4,21.1
R0162,ST-06,2026-06-02,PARK,9.9,61.0,24.8
R0024,ST-01,2026-06-24,roadside,13.39,79.4,23.1
R0081,ST-03,2026-06-17,roadside,25.16,56.8,29.5
R0064,ST-02,2026-06-32,Roadside,-1.0,43.9,18.8
R0057,ST-02,2026-06-25,Roadside,11.7,89.7,22.0
R0018,ST-01,2026-06-18, roadside ,24.8,83.9,25.8
R0004,ST-01,2026-06-04,roadside,20.49,41.6,21.2
R0157,ST-05,2026-06-29,PARK,-0.21,59.2,27.8
R0105,ST-04,2026-06-09,Roadside,23.6,67.5,16.0
R0184,ST-06,2026-06-24,park,13.92,69.1,27.7
R0115,ST-04,2026-06-19,Roadside,14.45,53.4,25.0
R0073,ST-03,2026-06-09, roadside ,18.9,79.8,15.3
R0117,ST-04,2026-06-21,roadside,16.2,51.4,27.3
R0146,ST-05,2026-06-18,PARK,8.65,72.5,28.2
R0016,ST-01,2026-06-16,Roadside,14.75,42.5,22.9
R0238,ST-08,2026-06-14, park ,6.83,67.5,19.3
R0219,ST-07,2026-06-27,park,16.12,86.7,19.0
R0141,ST-05,2026-06-13,park,14.36,47.3,17.7
R0070,ST-03,2026-06-06, roadside ,19.51,61.1,27.0
R0065,ST-03,2026-06-01, roadside ,12.14,76.0,23.7
R0207,ST-07,2026-06-15,Park,13.32,59.9,26.7
R0179,ST-06,2026-06-19,Park,14.9,62.0,16.1
R0134,ST-05,2026-06-06,park,16.34,79.4,21.5
R0056,ST-02,2026-06-24, roadside ,16.08,75.5,16.2
R0195,ST-07,2026-06-03,park,,55.9,29.9
R0006,ST-01,2026-06-06,ROADSIDE,17.21,40.3,26.7
R0249,ST-08,2026-06-25, park ,8.62,46.3,29.7
R0253,ST-08,2026-06-29,park,8.43,61.4,24.2
R0136,ST-05,2026-06-08, park ,0.19,76.4,24.6
R0017,ST-01,2026-06-17,Roadside,-1.0,53.5,29.8
R0203,ST-07,2026-06-11, park ,17.39,74.3,25.1
R0208,ST-07,2026-06-16, park ,16.24,47.1,15.5
R0150,ST-05,2026-06-22,park,13.94,82.6,26.7
R0051,ST-02,2026-06-19,roadside,10.48,51.6,23.4
R0160,ST-05,2026-06-32, park ,14.38,45.8,16.3
R0091,ST-03,2026-06-27,Roadside,21.46,63.1,29.0
R0093,ST-03,2026-06-29, roadside ,15.62,87.7,30.0
R0055,ST-02,2026-06-23,Roadside,14.65,79.0,18.0
R0182,ST-06,2026-06-22,PARK,18.0,60.9,20.3
R0025,ST-01,2026-06-25, roadside ,14.74,43.4,23.0
R0066,ST-03,2026-06-02, roadside ,18.82,70.0,19.5
R0109,ST-04,2026-06-13,Roadside,9.31,86.6,29.3
R0164,ST-06,2026-06-04, park ,10.9,70.7,17.5
R0066,ST-03,2026-06-02, roadside ,18.82,70.0,19.5
R0151,ST-05,2026-06-23,PARK,10.74,72.8,27.6
R0145,ST-05,2026-06-17,PARK,16.48,83.8,24.7
R0040,ST-02,2026-06-08,roadside,15.77,40.8,24.6
R0069,ST-03,2026-06-05,roadside,16.75,82.0,17.4
R0043,ST-02,2026-06-11,ROADSIDE,17.37,71.7,23.2
R0251,ST-08,2026-06-27,park,8.64,68.1,29.3
R0149,ST-05,2026-06-21,Park,13.93,44.7,25.0
R0075,ST-03,2026-06-11,roadside,20.01,57.0,27.6
R0147,ST-05,2026-06-19,park,11.98,58.9,28.9
R0123,ST-04,2026-06-27,ROADSIDE,11.82,41.6,15.3
R0216,ST-07,2026-06-24,Park,14.61,63.8,20.0
R0167,ST-06,2026-06-07,PARK,8.47,43.2,16.8
R0053,ST-02,2026-06-21,ROADSIDE,21.67,71.3,20.4
R0049,ST-02,2026-06-17,Roadside,21.62,78.5,25.3
R0028,ST-01,2026-06-28,ROADSIDE,22.0,88.1,25.7
R0155,ST-05,2026-06-27,Park,16.08,73.1,22.8
R0127,ST-04,2026-06-31,roadside,15.63,61.8,17.0
R0009,ST-01,2026-06-09,roadside,21.5,53.1,29.3
R0122,ST-04,2026-06-26, roadside ,23.12,52.2,20.1
R0139,ST-05,2026-06-11, park ,9.81,88.6,17.5
R0198,ST-07,2026-06-06,Park,13.52,85.6,20.6
R0092,ST-03,2026-06-28,ROADSIDE,16.62,87.8,22.3
R0068,ST-03,2026-06-04,roadside,15.53,79.0,25.4
R0113,ST-04,2026-06-17,ROADSIDE,26.1,66.0,26.9
R0013,ST-01,2026-06-13,Roadside,14.52,86.9,27.3
R0225,ST-08,2026-06-01,Park,16.85,55.9,21.2
R0166,ST-06,2026-06-06, park ,14.61,41.0,19.0
R0114,ST-04,2026-06-18, roadside ,,75.1,21.5
R0062,ST-02,2026-06-30,ROADSIDE,14.2,48.6,27.0
R0230,ST-08,2026-06-06,Park,16.47,87.6,17.2
R0217,ST-07,2026-06-25,park,5.49,40.4,29.3
R0242,ST-08,2026-06-18,PARK,8.74,67.5,19.1
R0177,ST-06,2026-06-17,Park,18.2,76.7,30.0
R0005,ST-01,2026-06-05,ROADSIDE,9.92,44.6,17.2
R0098,ST-04,2026-06-02,roadside,20.28,89.8,20.0
R0008,ST-01,2026-06-08,Roadside,11.19,55.9,26.1
R0001,ST-01,2026-06-01,ROADSIDE,27.0,75.4,18.3
R0159,ST-05,2026-06-31,PARK,,61.3,23.8
R0235,ST-08,2026-06-11, park ,17.38,42.9,21.9
R0138,ST-05,2026-06-10,PARK,17.81,60.0,22.1
R0022,ST-01,2026-06-22, roadside ,9.17,57.7,29.3
R0243,ST-08,2026-06-19, park ,13.73,73.7,24.5
R0107,ST-04,2026-06-11, roadside ,27.61,43.6,17.9
R0085,ST-03,2026-06-21,roadside,20.35,81.7,15.0
R0116,ST-04,2026-06-20, roadside ,14.91,45.9,29.7
R0209,ST-07,2026-06-17,Park,7.25,50.4,24.7
R0086,ST-03,2026-06-22, roadside ,21.56,66.4,21.4
R0185,ST-06,2026-06-25,Park,11.26,57.1,26.9
R0010,ST-01,2026-06-10,ROADSIDE,13.84,83.5,25.2
R0011,ST-01,2026-06-11,Roadside,12.75,44.6,21.2
R0183,ST-06,2026-06-23,Park,15.16,53.5,16.0
R0037,ST-02,2026-06-05, roadside ,22.15,87.0,26.6
R0222,ST-07,2026-06-30,Park,-1.0,55.9,26.7
R0059,ST-02,2026-06-27, roadside ,12.88,81.2,24.7
R0192,ST-06,2026-06-32, park ,7.35,53.4,22.5
R0036,ST-02,2026-06-04,Roadside,27.25,46.7,15.7
R0046,ST-02,2026-06-14,Roadside,8.25,50.9,21.3
R0054,ST-02,2026-06-22, roadside ,13.85,78.8,29.0
R0196,ST-07,2026-06-04,PARK,9.86,41.2,28.5
R0061,ST-02,2026-06-29,roadside,20.94,64.8,22.5
R0188,ST-06,2026-06-28,Park,16.89,48.8,27.8
R0132,ST-05,2026-06-04,PARK,9.33,85.0,27.8
R0101,ST-04,2026-06-05,Roadside,29.03,59.0,19.4
R0045,ST-02,2026-06-13, roadside ,7.48,41.6,25.4
R0012,ST-01,2026-06-12,ROADSIDE,22.7,60.3,18.3
R0023,ST-01,2026-06-23,roadside,17.96,55.6,22.4
R0029,ST-01,2026-06-29,ROADSIDE,19.43,85.9,23.7
R0087,ST-03,2026-06-23,Roadside,22.49,44.1,23.8
R0174,ST-06,2026-06-14,Park,11.15,81.9,23.1
R0002,ST-01,2026-06-02,ROADSIDE,9.66,67.9,22.0
R0050,ST-02,2026-06-18,ROADSIDE,16.04,89.4,22.6
R0102,ST-04,2026-06-06,ROADSIDE,15.63,80.6,17.8
R0052,ST-02,2026-06-20,ROADSIDE,18.21,56.3,29.0
R0013,ST-01,2026-06-13,Roadside,14.52,86.9,27.3
R0255,ST-08,2026-06-31,Park,17.46,57.3,20.1
R0130,ST-05,2026-06-02,park,9.11,48.1,17.8
R0047,ST-02,2026-06-15, roadside ,20.73,87.1,19.8
R0193,ST-07,2026-06-01,Park,9.99,60.8,17.4
R0152,ST-05,2026-06-24, park ,14.64,43.3,29.6
R0229,ST-08,2026-06-05,Park,17.42,69.5,19.8
R0235,ST-08,2026-06-11, park ,17.38,42.9,21.9
R0175,ST-06,2026-06-15,Park,17.29,52.7,24.3
R0124,ST-04,2026-06-28,ROADSIDE,-1.0,81.4,26.8
R0097,ST-04,2026-06-01,roadside,24.29,82.8,21.0
R0197,ST-07,2026-06-05,Park,7.92,55.8,21.8
R0240,ST-08,2026-06-16,park,18.93,64.9,21.8
R0020,ST-01,2026-06-20,roadside,10.85,75.2,19.4
R0137,ST-05,2026-06-09,park,11.32,43.3,16.4
R0233,ST-08,2026-06-09,Park,12.16,42.7,20.2
R0228,ST-08,2026-06-04, park ,7.22,76.5,17.2
R0248,ST-08,2026-06-24,Park,15.01,68.4,20.2
R0058,ST-02,2026-06-26,roadside,14.76,71.9,24.7
R0015,ST-01,2026-06-15,roadside,21.88,76.9,26.7
R0128,ST-04,2026-06-32,Roadside,8.27,67.3,17.6
R0054,ST-02,2026-06-22, roadside ,13.85,78.8,29.0
R0180,ST-06,2026-06-20,Park,10.28,62.9,15.5
R0220,ST-07,2026-06-28, park ,6.19,70.1,15.1
R0110,ST-04,2026-06-14,roadside,13.29,70.7,18.9
R0211,ST-07,2026-06-19,PARK,3.1,73.4,17.6
R0071,ST-03,2026-06-07, roadside ,27.73,64.0,27.7
R0120,ST-04,2026-06-24,ROADSIDE,14.33,67.2,16.9
R0236,ST-08,2026-06-12, park ,10.3,69.5,18.8
R0204,ST-07,2026-06-12,Park,10.89,48.0,17.5
R0154,ST-05,2026-06-26,park,9.14,58.5,16.7
R0007,ST-01,2026-06-07,roadside,24.48,47.5,26.5
R0060,ST-02,2026-06-28,Roadside,19.56,82.3,18.4
R0190,ST-06,2026-06-30, park ,7.85,84.6,24.1
R0118,ST-04,2026-06-22, roadside ,18.75,63.2,21.2
R0129,ST-05,2026-06-01,PARK,20.92,60.8,21.7
R0153,ST-05,2026-06-25, park ,15.19,69.7,29.6
R0176,ST-06,2026-06-16,park,2.91,51.9,23.6
R0232,ST-08,2026-06-08,park,8.77,45.5,18.5
R0119,ST-04,2026-06-23,roadside,22.67,74.5,18.6
R0033,ST-02,2026-06-01, roadside ,17.94,43.5,28.2
R0239,ST-08,2026-06-15, park ,6.49,83.1,28.9
R0194,ST-07,2026-06-02,PARK,11.19,40.3,22.0
R0099,ST-04,2026-06-03,ROADSIDE,18.29,61.9,18.5
R0078,ST-03,2026-06-14,roadside,22.99,87.1,29.7
R0200,ST-07,2026-06-08, park ,9.8,47.8,25.4
R0244,ST-08,2026-06-20,Park,16.12,87.1,24.6
R0171,ST-06,2026-06-11, park ,8.82,76.5,22.6
R0245,ST-08,2026-06-21, park ,14.32,42.3,15.9
R0014,ST-01,2026-06-14,ROADSIDE,21.27,86.5,23.8
R0169,ST-06,2026-06-09,park,12.46,77.6,23.1
R0063,ST-02,2026-06-31,roadside,13.77,87.0,15.6
R0246,ST-08,2026-06-22, park ,6.23,56.5,17.4
R0224,ST-07,2026-06-32,PARK,6.24,65.4,24.3
examples/dataset.py (5209 bytes)
"""The small synthetic dataset this lab's worked study is built on.
The study needs a source that is messy in *specific, nameable* ways, because
the point of the worked study is to show a damage report with real numbers in
it. So the frame is generated from a fixed seed with four deliberate defects:
1. inconsistent casing and stray whitespace in `station_type`;
2. eight duplicated `reading_id` values (the same reading delivered twice);
3. six readings where the sensor emitted its fault sentinel, -1.0;
4. five readings where `pm25_ug_m3` is simply blank.
Underneath the mess there is one real effect: roadside stations record higher
PM2.5 than park stations. The generator plants a true mean difference of
6.0 ug/m3 (roadside 18.0, park 12.0, both with a standard deviation of 5.0),
so the study has something honest to find, and the confirmation half has a
real chance of confirming it.
`observations.csv` in this directory is the saved output of
`generate_frame()`. It is committed so that the study has a genuine file on
disk with a genuine checksum, which is what the provenance gate checks.
`test_reference.py` asserts the committed file still matches the generator
byte for byte, so the two can never drift apart silently.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).parent.resolve()
SOURCE_CSV = HERE / "data" / "observations.csv"
DATASET_SEED = 140
ROADSIDE_STATIONS = ("ST-01", "ST-02", "ST-03", "ST-04")
PARK_STATIONS = ("ST-05", "ST-06", "ST-07", "ST-08")
ROADSIDE_MEAN = 18.0
PARK_MEAN = 12.0
PM25_SD = 5.0
TRUE_DIFFERENCE = ROADSIDE_MEAN - PARK_MEAN
READINGS_PER_STATION = 32
N_DUPLICATED = 8
N_SENTINEL = 6
N_BLANK = 5
FAULT_SENTINEL = -1.0
# The four casing variants the field units actually emit. Exactly one of them
# is the value the study wants; the other three are the same thing wearing a
# different coat, which is what the cleaning step measures.
CASINGS = ("roadside", "Roadside", "ROADSIDE", " roadside ")
PARK_CASINGS = ("park", "Park", "PARK", " park ")
ALPHA = 0.05
def generate_frame(seed: int = DATASET_SEED) -> pd.DataFrame:
"""Build the raw observation frame, defects and all, deterministically."""
rng = np.random.default_rng(seed)
stations = list(ROADSIDE_STATIONS) + list(PARK_STATIONS)
rows = []
for station in stations:
roadside = station in ROADSIDE_STATIONS
mean = ROADSIDE_MEAN if roadside else PARK_MEAN
casings = CASINGS if roadside else PARK_CASINGS
for day in range(READINGS_PER_STATION):
rows.append(
{
"station_id": station,
"captured_at": f"2026-06-{day + 1:02d}",
"station_type": casings[rng.integers(0, len(casings))],
"pm25_ug_m3": round(float(rng.normal(mean, PM25_SD)), 2),
"humidity_pct": round(float(rng.uniform(40.0, 90.0)), 1),
"temp_c": round(float(rng.uniform(15.0, 30.0)), 1),
}
)
frame = pd.DataFrame(rows)
# A stable id assigned in capture order, before any defect is introduced.
frame.insert(0, "reading_id", [f"R{i + 1:04d}" for i in range(len(frame))])
# Defect 3 and 4: the sensor fault sentinel, and outright blanks. Both are
# chosen from disjoint index pools so a single row never carries two.
damaged = rng.choice(frame.index, size=N_SENTINEL + N_BLANK, replace=False)
sentinel_rows = damaged[:N_SENTINEL]
blank_rows = damaged[N_SENTINEL:]
frame.loc[sentinel_rows, "pm25_ug_m3"] = FAULT_SENTINEL
frame.loc[blank_rows, "pm25_ug_m3"] = np.nan
# Defect 2: eight readings delivered twice. The duplicate is byte-identical
# to its original, which is exactly why a naive ingest never notices. The
# rows are drawn from the undamaged pool so that each defect stays
# separable in the damage report -- a duplicated blank would be two
# defects on one row and would blur the before/after counts.
undamaged = frame.index.difference(pd.Index(damaged))
repeated = rng.choice(undamaged, size=N_DUPLICATED, replace=False)
frame = pd.concat([frame, frame.loc[repeated]], ignore_index=True)
# Delivery order is not capture order. Shuffle so nothing downstream can
# accidentally depend on the rows arriving sorted.
order = rng.permutation(len(frame))
frame = frame.iloc[order].reset_index(drop=True)
return frame
def write_source_csv(path: Path | None = None, seed: int = DATASET_SEED) -> Path:
"""Write the generated frame to CSV exactly as the committed file was."""
target = Path(path) if path is not None else SOURCE_CSV
target.parent.mkdir(parents=True, exist_ok=True)
generate_frame(seed).to_csv(target, index=False, lineterminator="\n")
return target
def load_source_csv(path: Path | None = None) -> pd.DataFrame:
"""Read the committed CSV the way an ingest step would: everything as
text first, so nothing is silently coerced before the contract runs."""
source = Path(path) if path is not None else SOURCE_CSV
return pd.read_csv(source, dtype=str, keep_default_na=False)
examples/fixtures.py (8142 bytes)
"""Deliberately broken copies of the worked study, one defect at a time.
A harness that has only ever been run on a good study is an untested harness.
Each function here takes a complete, passing study directory and removes or
corrupts exactly one thing, so that a test can assert which gate fires and
what the finding says.
Every mutator rewrites MANIFEST.json afterwards by default. That is not
cosmetic: without it, deleting QUESTION.md would fail BOTH the question gate
and the reproducibility gate, and a test asserting "one gate fired" would be
asserting something untrue. The one exception is `break_reproducibility`,
whose whole point is to leave the manifest stale.
Nothing here writes outside the directory it is handed.
"""
from __future__ import annotations
import json
import re
import shutil
from pathlib import Path
import study
def worked_study(root: Path, name: str = "study", as_of: str = study.AS_OF) -> Path:
"""Build a complete, passing study directory under `root`."""
dest = Path(root) / name
study.build_study(dest, as_of=as_of)
return dest
def copy_of(source: Path, dest: Path) -> Path:
dest = Path(dest)
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(Path(source), dest)
return dest
def variant(source: Path, dest: Path, mutator, *, rewrite_manifest: bool = True) -> Path:
"""Copy `source` to `dest`, apply one defect, and refresh the manifest so
that exactly the intended gate fails."""
target = copy_of(source, dest)
mutator(target)
if rewrite_manifest:
study.write_manifest(target)
return target
# ---------------------------------------------------------------------------
# The defects
# ---------------------------------------------------------------------------
def break_missing_question(study_dir: Path) -> None:
(Path(study_dir) / "QUESTION.md").unlink()
def break_empty_question(study_dir: Path) -> None:
(Path(study_dir) / "QUESTION.md").write_text(" \n\n", encoding="utf-8")
def break_question_without_a_question(study_dir: Path) -> None:
"""The commonest real version: a heading and a topic, not a question."""
(Path(study_dir) / "QUESTION.md").write_text(
"# Question\n\nAir quality in the city network.\n", encoding="utf-8"
)
def break_provenance(
study_dir: Path, drop: tuple[str, ...] = ("url", "retrieved", "checksum_sha256")
) -> None:
path = Path(study_dir) / "SOURCE.json"
payload = json.loads(path.read_text(encoding="utf-8"))
for key in drop:
payload.pop(key, None)
payload.pop("path", None) # nothing left to verify a checksum against
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def break_provenance_checksum(study_dir: Path) -> None:
"""The record keeps its checksum, but the file it describes has moved on."""
path = Path(study_dir) / "SOURCE.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload["checksum_sha256"] = "0" * 64
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def break_grain(study_dir: Path) -> None:
"""An ingestion that reads the file and never says what a row is."""
path = Path(study_dir) / "INGEST.json"
payload = json.loads(path.read_text(encoding="utf-8"))
for key in ("grain", "grain_statement", "grain_verified", "grain_violations"):
payload.pop(key, None)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def break_grain_unverified(study_dir: Path) -> None:
"""The grain is declared but never checked -- a hope with a schema."""
path = Path(study_dir) / "INGEST.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload.pop("grain_verified", None)
payload.pop("grain_violations", None)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
CHANGELOG_STEP = "drop sensor fault sentinel readings"
def break_damage_report(study_dir: Path, step: str = CHANGELOG_STEP) -> None:
"""Turn one damage-report entry back into a changelog entry: it still says
what was done, it no longer says what it cost."""
path = Path(study_dir) / "CLEANING.md"
lines = path.read_text(encoding="utf-8").splitlines()
out: list[str] = []
inside = False
for line in lines:
if line.startswith("### "):
inside = line[4:].strip() == step
out.append(line)
continue
if inside and re.match(r"^(measure|before|after|changed)\s*:", line.strip(), re.I):
if line.strip().lower().startswith("measure"):
out.append("removed the readings that carried the fault sentinel.")
continue
out.append(line)
path.write_text("\n".join(out) + "\n", encoding="utf-8")
PEEKED_LOG = """# Research log
Every look taken, in the order it was taken.
| seq | timestamp | split | activity | outcome |
| --- | --- | --- | --- | --- |
| 1 | 2026-06-30T09:05:00Z | exploration | distribution of pm25_ug_m3 across all stations | right-skewed, nothing to explain |
| 2 | 2026-06-30T09:18:00Z | confirmation | check whether the gap also shows up in the held-out half | it does, encouraging |
| 3 | 2026-06-30T09:31:00Z | exploration | pm25_ug_m3 split by station_type | roadside sits higher |
| 4 | 2026-06-30T09:52:00Z | none | hypothesis declared | Roadside stations have a higher mean PM2.5 than park stations. |
| 5 | 2026-06-30T10:07:00Z | confirmation | test the declared hypothesis once | see REPORT.md |
"""
def break_confirmation_peeked(study_dir: Path) -> None:
"""The single most common capstone failure, and the hardest to see from
the finished report: the held-out half was looked at during exploration,
so its p-value means nothing. Only the log's ORDER reveals it."""
(Path(study_dir) / "RESEARCH_LOG.md").write_text(PEEKED_LOG, encoding="utf-8")
BARE_FINDINGS = """
Roadside stations recorded a mean PM2.5 5.50 ug/m3 higher than park stations.
The difference is clear and consistent with what the figures show.
Comparisons examined before this hypothesis was declared: 4.
"""
def break_uncertainty(study_dir: Path) -> None:
"""Strip the interval out of the findings and leave the point estimate
standing on its own, which is how most first drafts actually read."""
path = Path(study_dir) / "REPORT.md"
lines = path.read_text(encoding="utf-8").splitlines()
start = next(i for i, line in enumerate(lines) if line.strip() == "## Findings")
end = next(
(i for i in range(start + 1, len(lines)) if lines[i].startswith("## ")),
len(lines),
)
replacement = ["## Findings"] + BARE_FINDINGS.strip("\n").splitlines() + [""]
path.write_text("\n".join(lines[:start] + replacement + lines[end:]) + "\n",
encoding="utf-8")
def break_figure_label(study_dir: Path, index: int = 0, key: str = "claim") -> None:
"""A figure that answers no stated question and supports no stated claim."""
path = Path(study_dir) / "FIGURES.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload[index].pop(key, None)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def break_figure_undocumented(study_dir: Path) -> None:
"""A figure in the folder that no record mentions -- the one that survived
three drafts because nobody remembered what it was for."""
figures = Path(study_dir) / "figures"
source = figures / "fig-01-pm25-by-station-type.png"
shutil.copyfile(source, figures / "fig-99-leftover.png")
def break_reproducibility(study_dir: Path) -> None:
"""The output moved after the manifest was written. Called through
`variant(..., rewrite_manifest=False)`, this is what a non-deterministic
pipeline looks like from the outside: a second run, different bytes."""
path = Path(study_dir) / "REPORT.md"
text = path.read_text(encoding="utf-8")
path.write_text(
text + "\nRegenerated at 2026-07-01T14:22:07Z by run 8814.\n",
encoding="utf-8",
)
examples/study.py (28204 bytes)
"""The worked miniature study: the whole arc, actually performed.
This module carries one small question from "written down before looking" to
"reported with its limits", using the tools Course 03 taught, and writes the
result as a *study directory* -- the artefact `acceptance.py` grades.
The arc, and where each stage came from:
question Day 119 / Day 136 QUESTION.md, written before any look
provenance Day 134 SOURCE.json: licence, dictionary, checksum
ingestion Day 135 INGEST.json: a stated grain, asserted
cleaning Days 121, 125 CLEANING.md: a damage report, measured
exploration Day 136 RESEARCH_LOG.md, confirmation set sealed
statistics Days 117, 118 an interval, and the comparison count
visuals Days 127-132 FIGURES.json: each figure has a claim
pipeline Day 126 MANIFEST.json: checksums of every output
report Day 133 REPORT.md: the argument
ethics Day 138 the limits section, named not implied
Everything here is deterministic. There is no clock reading anywhere: the
`as_of` date is a parameter, the research-log timestamps are fixed strings,
the split is a seeded permutation, and the figures are saved with their PNG
`Software` metadata suppressed. That is not fastidiousness for its own sake --
`09_whole_harness.py` asserts that two independent builds of this study
produce byte-identical Markdown, and every clock reading left in would break
it.
"""
from __future__ import annotations
import hashlib
import json
import math
import shutil
import textwrap
from dataclasses import dataclass
from pathlib import Path
import matplotlib
matplotlib.use("Agg") # headless: no display, no plt.show(), ever
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import pandas as pd # noqa: E402
import dataset as ds # noqa: E402
SPLIT_SEED = 20260630
CONFIDENCE = 0.95
AS_OF = "2026-06-30"
QUESTION = (
"Do roadside air-quality stations record higher PM2.5 than park stations, "
"and by how much?"
)
# The four looks taken on the exploration half, in the order they were taken.
# The count of these IS the comparison count reported in REPORT.md; that is
# the whole reason the log is a data structure rather than a memory.
EXPLORATION_LOOKS = (
("2026-06-30T09:05:00Z", "distribution of pm25_ug_m3 across all stations",
"right-skewed, no second mode; nothing to explain"),
("2026-06-30T09:18:00Z", "pm25_ug_m3 split by station_type",
"roadside sits visibly higher; worth a hypothesis"),
("2026-06-30T09:31:00Z", "pm25_ug_m3 against humidity_pct",
"no visible relationship; nothing found"),
("2026-06-30T09:44:00Z", "pm25_ug_m3 by individual station_id",
"spread within each type, no single station driving the gap"),
)
HYPOTHESIS = (
"Roadside stations have a higher mean PM2.5 than park stations."
)
# ---------------------------------------------------------------------------
# The statistics, built from math.erf alone -- the Day 118 construction
# ---------------------------------------------------------------------------
def phi(z: float) -> float:
"""The standard normal CDF."""
return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0)))
def z_critical_two_sided(alpha: float) -> float:
"""The z whose two-sided tail probability is alpha, by bisection: there
is no closed form for the inverse of `phi`."""
low, high = 0.0, 10.0
target = 1.0 - alpha / 2.0
for _ in range(200):
mid = (low + high) / 2.0
if phi(mid) < target:
low = mid
else:
high = mid
return (low + high) / 2.0
def p_from_z_two_sided(z: float) -> float:
return 2.0 * (1.0 - phi(abs(z)))
@dataclass(frozen=True)
class Estimate:
"""A difference of means with the uncertainty attached to it, because an
estimate without an interval is a number pretending to be a fact."""
difference: float
standard_error: float
low: float
high: float
z: float
p_value: float
n_a: int
n_b: int
def difference_in_means(a, b, confidence: float = CONFIDENCE) -> Estimate:
"""Welch-style difference of means: each group keeps its own variance."""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
diff = float(a.mean() - b.mean())
se = float(math.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b)))
z_star = z_critical_two_sided(1.0 - confidence)
z = diff / se
return Estimate(
difference=diff,
standard_error=se,
low=diff - z_star * se,
high=diff + z_star * se,
z=z,
p_value=p_from_z_two_sided(z),
n_a=int(len(a)),
n_b=int(len(b)),
)
# ---------------------------------------------------------------------------
# Ingestion, with a stated grain (Day 135)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class IngestResult:
frame: pd.DataFrame
rows_in: int
grain: tuple[str, ...]
grain_verified: bool
grain_violations: int
def ingest(raw: pd.DataFrame, grain: tuple[str, ...] = ("reading_id",)) -> IngestResult:
"""Bring the raw text frame in, and assert the grain before anything else
touches it. The grain is the sentence "one row is one ___". Here it is
"one row is one reading", and the raw delivery violates it eight times."""
rows_in = len(raw)
duplicated = int(raw.duplicated(subset=list(grain)).sum())
return IngestResult(
frame=raw.copy(),
rows_in=rows_in,
grain=grain,
grain_verified=duplicated == 0,
grain_violations=duplicated,
)
# ---------------------------------------------------------------------------
# Cleaning, with a damage report (Days 121 and 125)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class DamageStep:
"""One cleaning step, with the measurement that makes it a damage report
rather than a changelog entry: what the quantity was before, and after."""
name: str
measure: str
before: float
after: float
note: str
@property
def changed(self) -> float:
return self.before - self.after
def clean(frame: pd.DataFrame) -> tuple[pd.DataFrame, list[DamageStep]]:
"""Four steps, each measured on the way past. Nothing here is clever; the
discipline is that no step is allowed to happen without a number."""
steps: list[DamageStep] = []
work = frame.copy()
before_types = int(work["station_type"].nunique())
work["station_type"] = work["station_type"].str.strip().str.lower()
steps.append(
DamageStep(
name="normalise station_type casing",
measure="distinct station_type values",
before=before_types,
after=int(work["station_type"].nunique()),
note="strip and lower-case; no row is dropped by this step",
)
)
before_rows = len(work)
work = work.drop_duplicates(subset=["reading_id"], keep="first")
steps.append(
DamageStep(
name="drop duplicate reading_id rows",
measure="rows",
before=before_rows,
after=len(work),
note="the duplicates are byte-identical redeliveries; first wins",
)
)
work["pm25_ug_m3"] = pd.to_numeric(work["pm25_ug_m3"], errors="coerce")
before_sentinel = int((work["pm25_ug_m3"] == ds.FAULT_SENTINEL).sum())
work = work[work["pm25_ug_m3"] != ds.FAULT_SENTINEL]
steps.append(
DamageStep(
name="drop sensor fault sentinel readings",
measure="rows carrying the -1.0 fault sentinel",
before=before_sentinel,
after=int((work["pm25_ug_m3"] == ds.FAULT_SENTINEL).sum()),
note="-1.0 is not a low reading; it is the unit reporting a fault",
)
)
before_missing = int(work["pm25_ug_m3"].isna().sum())
work = work[work["pm25_ug_m3"].notna()]
steps.append(
DamageStep(
name="drop rows with no pm25 reading",
measure="rows with a blank pm25_ug_m3",
before=before_missing,
after=int(work["pm25_ug_m3"].isna().sum()),
note="blank means the reading never arrived; it is not a zero",
)
)
work = work.reset_index(drop=True)
return work, steps
# ---------------------------------------------------------------------------
# The exploration/confirmation split (Day 136)
# ---------------------------------------------------------------------------
def split_exploration_confirmation(
frame: pd.DataFrame, seed: int = SPLIT_SEED
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Halve the cleaned frame with a seeded permutation. This happens BEFORE
any look, and the confirmation half is not opened until a hypothesis
exists -- which is a claim about ordering, and therefore something a
research log can be checked against."""
rng = np.random.default_rng(seed)
order = rng.permutation(len(frame))
cut = len(frame) // 2
exploration = frame.iloc[order[:cut]].reset_index(drop=True)
confirmation = frame.iloc[order[cut:]].reset_index(drop=True)
return exploration, confirmation
# ---------------------------------------------------------------------------
# The figures (Days 127-132)
# ---------------------------------------------------------------------------
_ROADSIDE_COLOUR = "#1d4ed8"
_PARK_COLOUR = "#0f766e"
def _save(fig, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
# metadata={"Software": None} drops the matplotlib-version tag PNG writers
# add by default, which is the one thing that would otherwise make two
# builds of the same figure differ byte for byte.
fig.savefig(path, format="png", dpi=110, bbox_inches="tight",
metadata={"Software": None})
plt.close(fig)
def figure_pm25_by_station_type(exploration: pd.DataFrame, path: Path) -> None:
"""A box plot: the right chart for "are these two distributions
different", because it shows spread and overlap rather than hiding both
behind two bars whose height difference is the only visible fact."""
roadside = exploration.loc[exploration["station_type"] == "roadside", "pm25_ug_m3"]
park = exploration.loc[exploration["station_type"] == "park", "pm25_ug_m3"]
fig, ax = plt.subplots(figsize=(6.0, 3.6))
parts = ax.boxplot(
[roadside.to_numpy(), park.to_numpy()],
tick_labels=[f"roadside (n={len(roadside)})", f"park (n={len(park)})"],
patch_artist=True,
widths=0.5,
)
for patch, colour in zip(parts["boxes"], (_ROADSIDE_COLOUR, _PARK_COLOUR)):
patch.set_facecolor(colour)
patch.set_alpha(0.30)
patch.set_edgecolor(colour)
for median in parts["medians"]:
median.set_color("#1a202c")
# The axis starts at zero: PM2.5 is a ratio quantity, so a truncated
# baseline would exaggerate the gap. Lie factor stays at 1.
ax.set_ylim(0, None)
ax.set_ylabel("PM2.5 (ug/m3)")
ax.set_title("Exploration half: PM2.5 by station type")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
_save(fig, path)
def figure_pm25_distribution(exploration: pd.DataFrame, path: Path) -> None:
"""A histogram: the right chart for "what shape is this quantity", and the
honest answer to whether the gap above is two clean modes (it is not)."""
fig, ax = plt.subplots(figsize=(6.0, 3.6))
bins = np.arange(0.0, 36.0, 2.0)
for label, colour in (("roadside", _ROADSIDE_COLOUR), ("park", _PARK_COLOUR)):
values = exploration.loc[exploration["station_type"] == label, "pm25_ug_m3"]
ax.hist(values.to_numpy(), bins=bins, alpha=0.55, label=label, color=colour)
ax.set_xlabel("PM2.5 (ug/m3)")
ax.set_ylabel("readings")
ax.set_title("Exploration half: overlapping PM2.5 distributions")
ax.legend(frameon=False)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
_save(fig, path)
# ---------------------------------------------------------------------------
# Writing the study directory
# ---------------------------------------------------------------------------
def _write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="\n")
def _write_json(path: Path, payload) -> None:
_write_text(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
def sha256_of(path: Path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def _research_log_markdown(hypothesis_time: str, confirmation_time: str) -> str:
lines = [
"# Research log",
"",
"Every look taken, in the order it was taken, including the ones that",
"found nothing. The number of `exploration` rows below is the",
"comparison count reported in REPORT.md.",
"",
"| seq | timestamp | split | activity | outcome |",
"| --- | --- | --- | --- | --- |",
]
seq = 0
for timestamp, activity, outcome in EXPLORATION_LOOKS:
seq += 1
lines.append(f"| {seq} | {timestamp} | exploration | {activity} | {outcome} |")
seq += 1
lines.append(
f"| {seq} | {hypothesis_time} | none | hypothesis declared | {HYPOTHESIS} |"
)
seq += 1
lines.append(
f"| {seq} | {confirmation_time} | confirmation | "
f"test the declared hypothesis once | see REPORT.md |"
)
lines.append("")
return "\n".join(lines)
def _cleaning_markdown(steps: list[DamageStep], rows_in: int, rows_out: int) -> str:
lines = [
"# Damage report",
"",
"What the cleaning *changed*, measured. A step with no before/after",
"number is a changelog entry, not a damage report.",
"",
f"Rows in: {rows_in}. Rows out: {rows_out}. "
f"Rows removed: {rows_in - rows_out} "
f"({100.0 * (rows_in - rows_out) / rows_in:.2f}% of the delivery).",
"",
]
for step in steps:
lines += [
f"### {step.name}",
"",
f"measure: {step.measure}",
f"before: {step.before:g}",
f"after: {step.after:g}",
f"changed: {step.changed:g}",
"",
step.note,
"",
]
return "\n".join(lines)
def _wrap(text: str, width: int = 76) -> str:
return textwrap.fill(" ".join(text.split()), width=width)
def _report_markdown(
*,
as_of: str,
estimate: Estimate,
comparison_count: int,
rows_in: int,
rows_out: int,
exploration_n: int,
confirmation_n: int,
) -> str:
"""Render the report deterministically.
Paragraphs are wrapped by `textwrap.fill` at a fixed width rather than
hand-wrapped, so the same numbers always produce the same bytes no matter
how the source string happened to be laid out in this file.
"""
blocks: list[tuple[str, str]] = [
("h1", "Roadside and park PM2.5: an exploratory study"),
("p", f"As of {as_of}. Exploratory. Not causal."),
("h2", "Question"),
("p", QUESTION),
("p",
"The question was written to QUESTION.md before the source file was "
"opened, so that the analysis could not quietly become a search for "
"whichever question the data happened to answer well."),
("h2", "What the data is"),
("p",
"A synthetic network of eight fixed air-quality stations, four sited at "
"roadside and four in parks, reporting daily PM2.5 through June 2026. "
"Provenance, licence, dictionary and checksum are recorded in "
"SOURCE.json; the grain -- one row per reading -- is asserted in "
"INGEST.json, and the record says plainly that the assertion failed on "
"arrival and what resolved it."),
("h2", "What cleaning changed"),
("p",
f"The delivery carried {rows_in} rows. {rows_out} survived cleaning. The "
f"four steps and their before/after measurements are in CLEANING.md. The "
f"largest single loss is the eight duplicated readings, which is a grain "
f"violation rather than a data-quality problem, and would have biased "
f"every mean below had it gone unnoticed."),
("h2", "How it was explored"),
("p",
f"The cleaned frame was split into an exploration half ({exploration_n} "
f"readings) and a confirmation half ({confirmation_n} readings) before any "
f"look was taken. RESEARCH_LOG.md records every look in order. The "
f"exploration half was examined {comparison_count} times. The confirmation "
f"half was opened once, after the hypothesis was written down, and tested "
f"once."),
("h2", "Findings"),
("p",
f"On the confirmation half, roadside stations recorded a mean PM2.5 "
f"{estimate.difference:.2f} ug/m3 higher than park stations (95% CI "
f"{estimate.low:.2f} to {estimate.high:.2f}, n={estimate.n_a} roadside and "
f"n={estimate.n_b} park readings)."),
("p",
f"The interval excludes zero, so the direction of the difference is the "
f"same across the whole interval. The estimate is imprecise enough that a "
f"true difference anywhere between {estimate.low:.2f} and "
f"{estimate.high:.2f} ug/m3 would be consistent with what was seen, which "
f"is a much weaker statement than the point value alone would suggest."),
("p",
f"Comparisons examined before this hypothesis was declared: "
f"{comparison_count}. That number belongs next to the interval, not in a "
f"footnote: it is what tells a reader how much searching preceded the one "
f"test."),
("h2", "Figures"),
("p",
"Each figure in FIGURES.json carries the question it was drawn to answer "
"and the claim it supports. Both are drawn from the exploration half only, "
"so no figure shows the data the estimate above was measured on."),
("raw", ""),
("raw", ""),
("h2", "Limits"),
("p",
"This study is exploratory. It does not establish that roadside siting "
"*causes* higher PM2.5. Station siting is not randomised: the roadside "
"units are where they are for reasons -- traffic volume, building density, "
"land availability -- that are themselves plausible causes of the "
"difference measured here."),
("p",
"The measured quantity is a proxy. PM2.5 at a fixed station is not what "
"anyone breathes; exposure depends on where people actually are and for "
"how long, which this data does not contain."),
("p",
"Who is missing: eight stations is a sample of sites, not of people. "
"Neighbourhoods without a station contribute nothing, and stations are not "
"sited at random, so the absence is not random either."),
("p",
"What would establish causation: an intervention -- a road closure, a "
"traffic-calming scheme, a low-emission zone boundary -- with readings "
"from the same stations before and after, and control stations outside the "
"intervention area over the same period. This study names that design; it "
"does not run it."),
("h2", "Reproducing this"),
("p",
"MANIFEST.json records a SHA-256 for every file this study generated. "
"Rebuilding the study from the same source file and the same seeds "
"reproduces every one of them, figures included. Nothing here reads the "
"clock: the as-of date is a parameter."),
]
out: list[str] = []
for kind, text in blocks:
if kind == "h1":
out.append(f"# {text}")
elif kind == "h2":
out.append(f"## {text}")
elif kind == "raw":
out.append(text)
else:
out.append(_wrap(text))
out.append("")
return "\n".join(out)
def build_study(dest: Path, as_of: str = AS_OF, source_csv: Path | None = None) -> dict:
"""Run the whole arc and write a complete study directory at `dest`.
Returns a small summary dict of the numbers the study measured, so callers
can assert on them without re-parsing the Markdown.
"""
dest = Path(dest)
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True)
source = Path(source_csv) if source_csv is not None else ds.SOURCE_CSV
# -- question, written down first -------------------------------------
_write_text(
dest / "QUESTION.md",
"# Question\n"
"\n"
f"{QUESTION}\n"
"\n"
"Written before the source file was opened. A decision this would\n"
"inform: whether the next four stations in the network are sited to\n"
"widen roadside coverage or to fill in the park gaps.\n",
)
# -- provenance (Day 134) ---------------------------------------------
data_dir = dest / "data"
data_dir.mkdir(parents=True, exist_ok=True)
local_copy = data_dir / "observations.csv"
shutil.copyfile(source, local_copy)
_write_json(
dest / "SOURCE.json",
{
"name": "Synthetic city air-quality network, June 2026",
"path": "data/observations.csv",
"url": "https://example.invalid/air-quality/observations.csv",
"retrieved": as_of,
"checksum_sha256": sha256_of(local_copy),
"licence": "CC0-1.0 (synthetic data generated for this lab)",
"dictionary": {
"reading_id": "stable id, one per reading, assigned at capture",
"station_id": "ST-01..ST-08, fixed monitoring sites",
"captured_at": "capture date, ISO 8601, YYYY-MM-DD",
"station_type": "roadside or park; raw casing is inconsistent",
"pm25_ug_m3": "PM2.5 mass concentration; -1.0 is a fault sentinel",
"humidity_pct": "relative humidity, percent",
"temp_c": "air temperature, degrees Celsius",
},
"retrieval_note": (
"This URL is deliberately unresolvable: the file is generated "
"by dataset.py in this lab and never fetched. The record is "
"real in shape and honest about its origin."
),
},
)
# -- ingestion with a stated grain (Day 135) --------------------------
raw = ds.load_source_csv(local_copy)
ingested = ingest(raw)
# -- cleaning with a damage report (Days 121, 125) --------------------
cleaned, steps = clean(ingested.frame)
_write_text(
dest / "CLEANING.md",
_cleaning_markdown(steps, ingested.rows_in, len(cleaned)),
)
# The grain contract is asserted twice, and the record says so. On arrival
# it FAILS -- eight redelivered readings -- and that failure is what the
# second cleaning step exists to resolve. `grain_verified` is the answer
# for the frame the study actually proceeds with, because that is the
# frame every number downstream is counted from.
after_clean = ingest(cleaned, ingested.grain)
_write_json(
dest / "INGEST.json",
{
"source": "data/observations.csv",
"grain": list(ingested.grain),
"grain_statement": "one row is one reading from one station",
"grain_verified": after_clean.grain_verified,
"grain_violations": after_clean.grain_violations,
"grain_violations_on_arrival": ingested.grain_violations,
"resolved_by": "cleaning step 'drop duplicate reading_id rows'",
"rows_in": ingested.rows_in,
"rows_out": len(cleaned),
"columns": list(raw.columns),
"read_as": "all columns read as text; nothing coerced before the contract",
},
)
# -- split, then explore (Day 136) ------------------------------------
exploration, confirmation = split_exploration_confirmation(cleaned)
_write_text(
dest / "RESEARCH_LOG.md",
_research_log_markdown("2026-06-30T09:52:00Z", "2026-06-30T10:07:00Z"),
)
# -- figures (Days 127-132), drawn from the exploration half only -----
fig1 = dest / "figures" / "fig-01-pm25-by-station-type.png"
fig2 = dest / "figures" / "fig-02-pm25-distribution.png"
figure_pm25_by_station_type(exploration, fig1)
figure_pm25_distribution(exploration, fig2)
_write_json(
dest / "FIGURES.json",
[
{
"file": "figures/fig-01-pm25-by-station-type.png",
"question": "Do roadside and park readings occupy different ranges?",
"claim": (
"Roadside readings sit higher, but the boxes overlap: this is "
"a shift in centre, not two separate populations."
),
"chart": "box plot",
"baseline": "y axis starts at zero; PM2.5 is a ratio quantity",
},
{
"file": "figures/fig-02-pm25-distribution.png",
"question": "What shape is PM2.5 within each station type?",
"claim": (
"Both distributions are single-peaked and broadly overlapping, "
"so the difference in means is not driven by a subgroup."
),
"chart": "overlapping histogram, common bins",
"baseline": "counts from zero; identical 2 ug/m3 bins for both series",
},
],
)
# -- the estimate, on the confirmation half, once (Days 117, 118) -----
road = confirmation.loc[confirmation["station_type"] == "roadside", "pm25_ug_m3"]
park = confirmation.loc[confirmation["station_type"] == "park", "pm25_ug_m3"]
estimate = difference_in_means(road, park)
comparison_count = len(EXPLORATION_LOOKS)
_write_text(
dest / "REPORT.md",
_report_markdown(
as_of=as_of,
estimate=estimate,
comparison_count=comparison_count,
rows_in=ingested.rows_in,
rows_out=len(cleaned),
exploration_n=len(exploration),
confirmation_n=len(confirmation),
),
)
# -- the manifest (Day 126), written last ------------------------------
write_manifest(dest)
return {
"rows_in": ingested.rows_in,
"rows_out": len(cleaned),
"grain_violations": ingested.grain_violations,
"damage_steps": len(steps),
"exploration_n": len(exploration),
"confirmation_n": len(confirmation),
"comparison_count": comparison_count,
"difference": estimate.difference,
"ci_low": estimate.low,
"ci_high": estimate.high,
"p_value": estimate.p_value,
"n_roadside": estimate.n_a,
"n_park": estimate.n_b,
}
MANIFEST_NAME = "MANIFEST.json"
def manifest_targets(study_dir: Path) -> list[str]:
"""Every generated file the manifest is responsible for, as sorted
study-relative POSIX paths. The manifest never covers itself."""
study_dir = Path(study_dir)
names = []
for path in sorted(study_dir.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(study_dir).as_posix()
if rel == MANIFEST_NAME:
continue
names.append(rel)
return sorted(names)
def write_manifest(study_dir: Path) -> dict:
study_dir = Path(study_dir)
entries = {rel: sha256_of(study_dir / rel) for rel in manifest_targets(study_dir)}
payload = {"algorithm": "sha256", "files": entries}
_write_json(study_dir / MANIFEST_NAME, payload)
return payload
examples/test_reference.py (17763 bytes)
"""The reference suite: every claim the worked study and the harness make.
Run from the lab directory:
.venv/bin/pytest examples -q
Nothing here asserts on a timing. Everything asserts on a shape, an exact
value the arithmetic fixes, or a measured value against a stated tolerance.
"""
from __future__ import annotations
import hashlib
import json
import pytest
import acceptance
import dataset as ds
import fixtures as fx
import study
# ---------------------------------------------------------------------------
# One worked study, built once for the whole session
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def workspace(tmp_path_factory):
return tmp_path_factory.mktemp("day140")
@pytest.fixture(scope="session")
def good(workspace):
return fx.worked_study(workspace, name="worked")
@pytest.fixture(scope="session")
def summary(workspace):
return study.build_study(workspace / "summary-run")
# ---------------------------------------------------------------------------
# The dataset shipped with the lab
# ---------------------------------------------------------------------------
def test_committed_csv_matches_its_generator(tmp_path):
"""The committed file and the generator can never drift apart silently."""
regenerated = ds.write_source_csv(tmp_path / "observations.csv")
assert regenerated.read_bytes() == ds.SOURCE_CSV.read_bytes()
def test_dataset_carries_its_four_defects():
frame = ds.load_source_csv()
assert len(frame) == 264
assert len(frame) - frame["reading_id"].nunique() == 8
assert (frame["pm25_ug_m3"] == "-1.0").sum() == 6
assert (frame["pm25_ug_m3"] == "").sum() == 5
assert frame["station_type"].nunique() == 8
def test_dataset_plants_a_real_effect(summary):
"""The measured difference is an estimate of a difference the generator
really planted, so the interval has a right answer to contain."""
assert ds.TRUE_DIFFERENCE == 6.0
assert summary["ci_low"] < ds.TRUE_DIFFERENCE < summary["ci_high"]
# ---------------------------------------------------------------------------
# The statistics, checked against a hand computation
# ---------------------------------------------------------------------------
def test_phi_of_zero_is_one_half():
assert study.phi(0.0) == pytest.approx(0.5)
def test_z_critical_for_95_percent():
# The textbook value for a two-sided 95% interval.
assert study.z_critical_two_sided(0.05) == pytest.approx(1.959964, abs=1e-5)
def test_difference_in_means_matches_hand_computation():
import math
import statistics
a = [12.0, 14.0, 11.0, 15.0, 13.0, 12.5, 14.5, 13.5]
b = [9.0, 10.0, 8.5, 11.0, 9.5, 10.5, 8.0, 9.0]
est = study.difference_in_means(a, b)
se = math.sqrt(statistics.variance(a) / len(a) + statistics.variance(b) / len(b))
diff = statistics.mean(a) - statistics.mean(b)
assert est.difference == pytest.approx(diff, abs=1e-12)
assert est.standard_error == pytest.approx(se, abs=1e-12)
assert est.low == pytest.approx(diff - 1.959964 * se, abs=1e-5)
assert est.high == pytest.approx(diff + 1.959964 * se, abs=1e-5)
def test_the_interval_is_symmetric_about_the_estimate(summary):
midpoint = (summary["ci_low"] + summary["ci_high"]) / 2.0
assert midpoint == pytest.approx(summary["difference"], abs=1e-9)
# ---------------------------------------------------------------------------
# The arc, stage by stage
# ---------------------------------------------------------------------------
def test_ingest_reports_the_grain_violation_on_arrival():
raw = ds.load_source_csv()
result = study.ingest(raw)
assert result.grain == ("reading_id",)
assert result.grain_verified is False
assert result.grain_violations == 8
def test_cleaning_resolves_the_grain_and_measures_every_step():
raw = ds.load_source_csv()
cleaned, steps = study.clean(study.ingest(raw).frame)
assert study.ingest(cleaned).grain_verified is True
assert [s.name for s in steps] == [
"normalise station_type casing",
"drop duplicate reading_id rows",
"drop sensor fault sentinel readings",
"drop rows with no pm25 reading",
]
assert [(s.before, s.after) for s in steps] == [
(8, 2), (264, 256), (6, 0), (5, 0),
]
assert len(cleaned) == 245
assert sorted(cleaned["station_type"].unique()) == ["park", "roadside"]
def test_the_split_is_disjoint_and_covers_everything():
raw = ds.load_source_csv()
cleaned, _ = study.clean(study.ingest(raw).frame)
exploration, confirmation = study.split_exploration_confirmation(cleaned)
assert len(exploration) + len(confirmation) == len(cleaned)
ids = set(exploration["reading_id"]) & set(confirmation["reading_id"])
assert ids == set()
def test_the_split_is_the_same_every_time():
raw = ds.load_source_csv()
cleaned, _ = study.clean(study.ingest(raw).frame)
first, _ = study.split_exploration_confirmation(cleaned)
second, _ = study.split_exploration_confirmation(cleaned)
assert list(first["reading_id"]) == list(second["reading_id"])
def test_the_comparison_count_is_the_research_log_length(good, summary):
rows = acceptance.research_log_rows((good / "RESEARCH_LOG.md").read_text())
exploration_rows = [r for r in rows if r["split"] == "exploration"]
assert len(exploration_rows) == summary["comparison_count"] == 4
def test_the_report_states_the_interval_and_the_comparison_count(good, summary):
# The report is wrapped, so flatten the whitespace before matching phrases
# that may straddle a line break.
text = " ".join((good / "REPORT.md").read_text().split())
assert f"{summary['difference']:.2f} ug/m3 higher" in text
assert f"95% CI {summary['ci_low']:.2f} to {summary['ci_high']:.2f}" in text
assert f"declared: {summary['comparison_count']}." in text
def test_the_report_names_what_it_cannot_do(good):
text = (good / "REPORT.md").read_text()
assert "does not establish" in text
assert "proxy" in text
assert "Who is missing" in text
assert "would establish causation" in text
def test_the_study_writes_every_expected_file(good):
names = {p.relative_to(good).as_posix() for p in good.rglob("*") if p.is_file()}
assert names == {
"QUESTION.md",
"SOURCE.json",
"INGEST.json",
"CLEANING.md",
"RESEARCH_LOG.md",
"FIGURES.json",
"REPORT.md",
"MANIFEST.json",
"data/observations.csv",
"figures/fig-01-pm25-by-station-type.png",
"figures/fig-02-pm25-distribution.png",
}
def test_the_figures_are_real_png_files(good):
for record in json.loads((good / "FIGURES.json").read_text()):
data = (good / record["file"]).read_bytes()
assert data[:8] == b"\x89PNG\r\n\x1a\n"
assert len(data) > 5_000
def test_the_manifest_covers_every_generated_file(good):
manifest = json.loads((good / "MANIFEST.json").read_text())
on_disk = {
p.relative_to(good).as_posix()
for p in good.rglob("*")
if p.is_file() and p.name != "MANIFEST.json"
}
assert set(manifest["files"]) == on_disk
for rel, digest in manifest["files"].items():
assert hashlib.sha256((good / rel).read_bytes()).hexdigest() == digest
# ---------------------------------------------------------------------------
# The harness: the worked study passes
# ---------------------------------------------------------------------------
def test_every_gate_passes_on_the_worked_study(good):
verdict = acceptance.check_study(good)
assert verdict.ok, verdict.findings
assert verdict.findings == ()
assert tuple(g.name for g in verdict.gates) == acceptance.GATE_NAMES
def test_gate_names_match_the_gate_functions():
assert len(acceptance.GATES) == len(acceptance.GATE_NAMES) == 8
assert tuple(g.__name__ for g in acceptance.GATES) == tuple(
f"gate_{name}" for name in acceptance.GATE_NAMES
)
def test_a_missing_directory_raises(tmp_path):
with pytest.raises(FileNotFoundError):
acceptance.check_study(tmp_path / "nowhere")
def test_an_empty_directory_fails_every_gate(tmp_path):
empty = tmp_path / "empty"
empty.mkdir()
verdict = acceptance.check_study(empty)
assert verdict.failed_gates == acceptance.GATE_NAMES
def test_a_failing_gate_always_carries_a_finding(tmp_path):
empty = tmp_path / "empty"
empty.mkdir()
for gate in acceptance.check_study(empty).gates:
assert gate.findings, gate.name
# ---------------------------------------------------------------------------
# The harness: one defect at a time, one gate at a time
# ---------------------------------------------------------------------------
DEFECTS = [
("question_recorded", fx.break_missing_question, "QUESTION.md is missing"),
("question_recorded", fx.break_empty_question, "QUESTION.md is empty"),
("question_recorded", fx.break_question_without_a_question,
"records no question sentence"),
("provenance_complete", fx.break_provenance, "is missing: checksum_sha256"),
("provenance_complete", fx.break_provenance_checksum, "does not match"),
("grain_asserted", fx.break_grain, "declares no row grain"),
("grain_asserted", fx.break_grain_unverified, "never checked against the data"),
("damage_report_quantified", fx.break_damage_report,
"changelog entry, not a damage report"),
("confirmation_untouched", fx.break_confirmation_peeked,
"before the hypothesis was declared"),
("uncertainty_reported", fx.break_uncertainty,
"estimate reported without an interval"),
("figures_documented", fx.break_figure_label, "carries no claim"),
("figures_documented", fx.break_figure_undocumented, "undocumented"),
]
@pytest.mark.parametrize(
"gate_name,mutator,expected",
DEFECTS,
ids=[f"{name}-{mutator.__name__}" for name, mutator, _ in DEFECTS],
)
def test_one_defect_fails_exactly_one_gate(good, tmp_path, gate_name, mutator, expected):
broken = fx.variant(good, tmp_path / "broken", mutator)
verdict = acceptance.check_study(broken)
assert verdict.failed_gates == (gate_name,), verdict.findings
gate = verdict.gate(gate_name)
assert any(expected in finding for finding in gate.findings), gate.findings
def test_provenance_names_each_missing_field_individually(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_provenance)
findings = acceptance.check_study(broken).gate("provenance_complete").findings
assert set(findings) == {
"SOURCE.json is missing: url",
"SOURCE.json is missing: retrieved",
"SOURCE.json is missing: checksum_sha256",
}
def test_the_uncertainty_finding_quotes_the_sentence(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_uncertainty)
findings = acceptance.check_study(broken).gate("uncertainty_reported").findings
assert len(findings) == 1
assert "5.50 ug/m3 higher than park stations" in findings[0]
INTERVAL_FORMS = [
"The mean difference was 5.50 ug/m3 (95% CI 3.80 to 7.21).",
"The mean difference was 5.50 ug/m3 (confidence interval 3.80, 7.21).",
"The mean difference was 5.50 ug/m3 ±1.70.",
"The mean difference was 5.50 ug/m3, interval [3.80, 7.21].",
"The mean difference was anywhere between 3.80 and 7.21 ug/m3.",
"The estimated mean difference was 3.80 to 7.21 ug/m3.",
]
@pytest.mark.parametrize("sentence", INTERVAL_FORMS)
def test_interval_evidence_is_recognised(good, tmp_path, sentence):
def rewrite(study_dir):
path = study_dir / "REPORT.md"
lines = path.read_text().splitlines()
start = lines.index("## Findings")
end = next(i for i in range(start + 1, len(lines)) if lines[i].startswith("## "))
body = ["## Findings", "", sentence, ""]
path.write_text("\n".join(lines[:start] + body + lines[end:]) + "\n")
probe = fx.variant(good, tmp_path / "probe", rewrite)
assert acceptance.check_study(probe).gate("uncertainty_reported").ok
def test_a_findings_section_with_no_estimate_fails(good, tmp_path):
def rewrite(study_dir):
path = study_dir / "REPORT.md"
lines = path.read_text().splitlines()
start = lines.index("## Findings")
end = next(i for i in range(start + 1, len(lines)) if lines[i].startswith("## "))
body = ["## Findings", "", "The effect was clear and worth acting on.", ""]
path.write_text("\n".join(lines[:start] + body + lines[end:]) + "\n")
probe = fx.variant(good, tmp_path / "probe", rewrite)
findings = acceptance.check_study(probe).gate("uncertainty_reported").findings
assert any("reports no numeric estimate" in f for f in findings), findings
def test_a_confirmation_set_never_used_is_caught(good, tmp_path):
def strip(study_dir):
path = study_dir / "RESEARCH_LOG.md"
kept = [
line for line in path.read_text().splitlines()
if "| confirmation |" not in line
]
path.write_text("\n".join(kept) + "\n")
probe = fx.variant(good, tmp_path / "probe", strip)
findings = acceptance.check_study(probe).gate("confirmation_untouched").findings
assert any("never used" in f for f in findings), findings
def test_a_peeked_study_looks_identical_everywhere_except_the_log(good, tmp_path):
"""The reason this gate has to read the log: nothing else changes."""
peeked = fx.variant(good, tmp_path / "peeked", fx.break_confirmation_peeked)
for name in ("REPORT.md", "FIGURES.json", "CLEANING.md", "SOURCE.json"):
assert (good / name).read_bytes() == (peeked / name).read_bytes()
assert acceptance.check_study(peeked).failed_gates == ("confirmation_untouched",)
# ---------------------------------------------------------------------------
# Reproducibility
# ---------------------------------------------------------------------------
def test_two_builds_produce_identical_markdown(workspace):
first = study.build_study(workspace / "repro-a") and workspace / "repro-a"
study.build_study(workspace / "repro-b")
second = workspace / "repro-b"
for name in ("REPORT.md", "CLEANING.md", "QUESTION.md", "RESEARCH_LOG.md"):
assert (first / name).read_bytes() == (second / name).read_bytes(), name
def test_two_builds_produce_identical_figures(workspace):
first, second = workspace / "repro-a", workspace / "repro-b"
for name in ("fig-01-pm25-by-station-type.png", "fig-02-pm25-distribution.png"):
assert (first / "figures" / name).read_bytes() == (
second / "figures" / name
).read_bytes(), name
def test_the_harness_detects_output_that_changed_after_the_manifest(good, tmp_path):
drifted = fx.variant(
good, tmp_path / "drifted", fx.break_reproducibility, rewrite_manifest=False
)
findings = acceptance.check_study(drifted).gate("outputs_reproducible").findings
assert len(findings) == 1
assert "REPORT.md" in findings[0]
assert "changed since the manifest was written" in findings[0]
def test_the_harness_detects_an_untracked_output(good, tmp_path):
def add(study_dir):
(study_dir / "scratch.md").write_text("# scratch\n")
probe = fx.variant(good, tmp_path / "untracked", add, rewrite_manifest=False)
findings = acceptance.check_study(probe).gate("outputs_reproducible").findings
assert findings == ("scratch.md exists but is not covered by MANIFEST.json",)
def test_the_harness_detects_a_manifest_entry_with_no_file(good, tmp_path):
def remove(study_dir):
(study_dir / "figures" / "fig-02-pm25-distribution.png").unlink()
probe = fx.variant(good, tmp_path / "gone", remove, rewrite_manifest=False)
findings = acceptance.check_study(probe).gate("outputs_reproducible").findings
assert any("which does not exist" in f for f in findings), findings
# ---------------------------------------------------------------------------
# The whole harness, end to end
# ---------------------------------------------------------------------------
def test_removing_one_required_element_fails_exactly_one_gate(good, tmp_path):
def remove_checksum(study_dir):
path = study_dir / "SOURCE.json"
payload = json.loads(path.read_text())
del payload["checksum_sha256"]
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
broken = fx.variant(good, tmp_path / "one-gone", remove_checksum)
verdict = acceptance.check_study(broken)
assert verdict.failed_gates == ("provenance_complete",)
assert verdict.findings == ("SOURCE.json is missing: checksum_sha256",)
def test_three_defects_produce_three_failed_gates(good, tmp_path):
def three(study_dir):
fx.break_missing_question(study_dir)
fx.break_grain(study_dir)
fx.break_figure_label(study_dir, index=1, key="claim")
broken = fx.variant(good, tmp_path / "three", three)
verdict = acceptance.check_study(broken)
assert set(verdict.failed_gates) == {
"question_recorded", "grain_asserted", "figures_documented"
}
def test_the_verdict_summary_reads_as_a_task_list(good, tmp_path):
broken = fx.variant(good, tmp_path / "summary", fx.break_missing_question)
summary_text = acceptance.check_study(broken).summary()
assert summary_text.startswith("NOT ACCEPTED:")
assert "[FAIL] question_recorded" in summary_text
assert "QUESTION.md is missing" in summary_text
assert summary_text.count("[PASS]") == 7
def test_gate_lookup_rejects_an_unknown_name(good):
with pytest.raises(KeyError):
acceptance.check_study(good).gate("no_such_gate")
metadata.yml (6738 bytes)
lesson_id: D140
day: 140
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/math-statistics-and-data/day-140-section-project-an-exploratory-study
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/python3 -c "import numpy, pandas, matplotlib; print(numpy.__version__, pandas.__version__, matplotlib.__version__)"
run_commands:
- 'cd examples && ../.venv/bin/python3 01_question_recorded.py && cd ..'
- 'cd examples && ../.venv/bin/python3 02_provenance_complete.py && cd ..'
- 'cd examples && ../.venv/bin/python3 03_grain_asserted.py && cd ..'
- 'cd examples && ../.venv/bin/python3 04_damage_report.py && cd ..'
- 'cd examples && ../.venv/bin/python3 05_confirmation_untouched.py && cd ..'
- 'cd examples && ../.venv/bin/python3 06_uncertainty_in_the_prose.py && cd ..'
- 'cd examples && ../.venv/bin/python3 07_figures_carry_claims.py && cd ..'
- 'cd examples && ../.venv/bin/python3 08_reproducibility.py && cd ..'
- 'cd examples && ../.venv/bin/python3 09_whole_harness.py && cd ..'
- .venv/bin/pytest examples -q -p no:cacheprovider
- .venv/bin/pytest starter -q -p no:cacheprovider
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- "find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +"
- rm -rf .pytest_cache
- 'rm -rf /tmp/day140-look # only if you built the study there to read it'
- 'rm -rf .venv # optional: removes the lab virtual environment'
- 'git checkout -- starter/ # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 60
last_executed: '2026-08-20'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, numpy 2.5.2, pandas 3.0.5, matplotlib 3.11.1, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 81 checks, 0 failure(s), exit 0 (captured directly, not through a pipeline). pytest examples -> 54 passed in 0.90s; pytest starter -> 1 passed, 32 skipped on an untouched checkout, and 33 passed against a fully solved copy of starter/acceptance.py (verified by copying the reference examples/acceptance.py into a SCRATCH copy of starter/ outside the lab directory, confirming all 33 tests passed, then breaking one gate in that scratch copy and confirming a red run -- the lab directory itself was never modified). A bare `pytest -q` with no path argument from the lab directory reports 55 passed, 32 skipped, matching pytest examples (54) plus pytest starter (1 passed) with no cross-suite bleed, proving both conftest.py import guards work for that invocation. All nine reference scripts exit 0 with every internal assertion holding, each ending with a line starting "OK:". Everything was run through a real lab-local .venv created by the documented setup commands. Section 9 of the harness proves the exercise suite can genuinely fail: it copies starter/ to a scratch directory, drops in the reference harness (33 passed), then rewrites the provenance gate so it stops recomputing the recorded checksum, and confirms the re-run exits 1 with test_provenance_gate_verifies_the_checksum named and its explanatory message printed. Separately, the run_tests.sh harness itself was proved able to fail: the expected row count in section 3 was changed from 264 to 265, the run reported "FAIL: the delivery carries 264 rows (expected [265], got [264])" and "81 checks, 1 failure(s)." with exit 1, and the original value was restored and a clean 81/0 run reconfirmed. Six honesty notes from this run. FIRST: seaborn, scipy, statsmodels, scikit-learn and Jupyter/nbconvert are all absent from this lab''s pinned requirements -- scipy, statsmodels and scikit-learn are not installed on the authoring machine at all -- and none of them is used anywhere. The confidence interval is built from math.erf alone (the Day 118 construction) and the two figures are plain matplotlib. No output is attributed to any of those five packages anywhere in this lab or its lesson. SECOND: the two PNG digests recorded in expected-output/worked-study-manifest.txt are machine-dependent, because matplotlib rasterises text with whatever fonts and FreeType build it finds; nothing in the harness asserts a PNG digest against a stored literal, and what IS asserted is the portable property -- two builds on the same machine produce identical bytes, checked by rebuilding and comparing. This is stated in expected-output/FIELDS.md rather than left for a reader to discover. THIRD: the uncertainty gate flagged a sentence in the worked study''s OWN report during development -- "the estimate is imprecise enough that a true difference anywhere between 3.80 and 7.21 ug/m3 would be consistent with what was seen" -- because "between x and y" was not in its list of interval patterns. That was a real false positive in the checker, not a defect in the report, and it was fixed by adding the pattern to acceptance.py with a comment recording why, rather than by rewording the report to suit the checker. FOURTH: the gate is a heuristic and the lab and lesson both say so. It scans the report''s Findings section only, and it classifies a sentence as an estimate when the sentence carries both a number and one of eleven estimate words. It cannot tell whether an interval is correctly computed -- only whether one is present. Five accepted forms of interval evidence and one rejected form are demonstrated by running them, in 06_uncertainty_in_the_prose.py. FIFTH: the worked study''s ingestion contract FAILS its grain assertion on arrival -- eight readings are delivered twice -- and INGEST.json records both numbers: grain_violations_on_arrival is 8, grain_verified is true for the cleaned frame the study proceeds with, and resolved_by names the cleaning step. An earlier draft wrote INGEST.json before cleaning and the harness correctly refused the study; the fix was to record both facts honestly rather than to move the assertion somewhere it would pass. SIXTH: SOURCE.json''s url points at https://example.invalid/, a name RFC 2606 reserves so that it can never resolve, and the record carries a retrieval_note saying in plain words that the file is generated by dataset.py and never fetched. No network request is made by anything in this lab after the one-time pip install. The exact values -- 264 rows in, 245 out, 8 grain violations, four measured cleaning steps, a 122/123 split, 4 comparisons, a measured difference of 5.50 ug/m3 with a 95% interval of 3.80 to 7.21 containing the planted true difference of 6.00 -- are fixed by the seeded generator and by arithmetic, and are identical on any machine running the pinned versions. Nothing in this lab is sampled and no assertion anywhere uses a tolerance or a timing.'
requirements/README.md (3530 bytes)
# What is installed, why, and what it costs
Four packages, all free and open source, all installed into a lab-local
virtual environment that `rm -rf .venv` completely undoes.
| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `numpy` | 2.5.2 | BSD 3-Clause | `numpy.random.default_rng` for the seeded dataset and the seeded exploration/confirmation split; array maths for the difference of means. |
| `pandas` | 3.0.5 | BSD 3-Clause | The frame the worked study ingests, asserts a grain on, cleans, splits and measures. |
| `matplotlib` | 3.11.1 | matplotlib licence (BSD-style, PSF-derived) | The worked study's two figures, rendered headless through the `Agg` backend and saved as byte-deterministic PNGs. |
| `pytest` | 9.1.1 | MIT | The reference suite (54 tests) and your running score in `starter/` (33 tests once every exercise is solved). |
There is no paid tier of anything in this lab, no account, no key and no
signup, personally or commercially. Everything else the lab needs is standard
library: `hashlib` for the checksums and the manifest, `json` for the study's
machine-readable records, `re` for the four Markdown parsers, `math.erf` for
the normal distribution behind the confidence interval, and `textwrap` for the
deterministic report layout.
## The one time the network is needed
```bash
.venv/bin/pip install -r requirements/requirements.txt
```
That is the only command in the lab that opens a connection. Everything after
it runs entirely offline against a CSV file committed inside the lab.
## Why the study directory uses JSON and not YAML
`SOURCE.json`, `INGEST.json`, `FIGURES.json` and `MANIFEST.json` would be YAML
in most real studies. They are JSON here because `json` is in the standard
library and no YAML parser is, so the harness carries no dependency the study
does not already need. Every one of the eight gates works unchanged against a
YAML study directory if you swap `json.loads` for `yaml.safe_load`.
## What is deliberately *not* installed
**`seaborn`** is not in this lab's `requirements.txt`, so it is not installed
into the lab's virtual environment. Day 129 used it for statistical plots and
it would draw this lab's box plot in one line; the worked study uses plain
matplotlib because a box plot and an overlapping histogram need nothing more,
and because one fewer dependency is one fewer version to pin for a figure that
has to hash identically on two runs. Seaborn's own documentation describes its
figure-level versus axes-level split; no seaborn output is reproduced anywhere
in this lab.
**`scipy`, `statsmodels` and `scikit-learn`** are not installed. The interval
in the worked study is built from `math.erf` alone, the Day 118 construction,
which is exact for the normal case and needs no package. No output from any of
those three is reproduced anywhere in this lab or its lesson.
**Jupyter / `nbconvert`** is not installed. Day 139 covers the notebook that
restarts and runs clean; this lab's study runs as a plain module so that
"rebuild it and compare the bytes" is a one-line assertion.
## If you cannot install anything at all
You need pandas, NumPy and matplotlib for the worked study: the frame, the
seeded split and the two figures are all built on them. The **harness** is a
different matter — `acceptance.py` imports only `hashlib`, `json`, `re`,
`dataclasses` and `pathlib`, all standard library. If you can run Python at
all, you can run `check_study` against a study directory somebody else built.
requirements/requirements.txt (60 bytes)
numpy==2.5.2
pandas==3.0.5
matplotlib==3.11.1
pytest==9.1.1
starter/00_brief.md (4269 bytes)
# Your brief — build the acceptance harness
Course 03 taught a dozen separable skills. A study is what happens when they
have to hold each other up, and the thing that breaks is never a single skill.
It is the seams: a clean dataset with an unstated question, a beautiful chart
of a leaked feature, a confident conclusion drawn from an exploration that
examined forty things. Every component correct, the study worthless.
You are building the checker that finds those seams. `check_study(path)` reads
a study directory and returns a verdict: eight gates, each passing or carrying
findings that name exactly what is missing.
## What you are given
| File | What it is |
| --- | --- |
| `dataset.py` | The synthetic source frame, with four deliberate defects and one real planted effect. Complete — do not edit. |
| `study.py` | The worked miniature study: question, provenance, ingestion, cleaning, split, figures, estimate, report, manifest. Complete — do not edit. |
| `fixtures.py` | Deliberately broken copies of that study, one defect at a time. Complete — do not edit. |
| `acceptance.py` | **Your work.** The verdict types, the file readers and the four Markdown parsers are given. The nine exercises are yours. |
| `test_starter.py` | Your running score. |
Build the worked study yourself first, so you can read what you are grading:
```bash
.venv/bin/python3 -c "
import sys; sys.path.insert(0, 'starter')
import fixtures, pathlib
print(fixtures.worked_study(pathlib.Path('/tmp/day140-look')))
"
```
Read `QUESTION.md`, `SOURCE.json`, `INGEST.json`, `CLEANING.md`,
`RESEARCH_LOG.md`, `FIGURES.json`, `REPORT.md` and `MANIFEST.json`. Ten
minutes there will save you an hour in the exercises. Delete the directory
afterwards.
## The nine exercises
Each one lives in `acceptance.py` with a docstring stating exactly what it
must accept and reject. Check yourself after each:
```bash
.venv/bin/pytest starter -q
```
1. **`gate_question_recorded`** — fail a study whose question file is missing
or empty, naming it.
2. **`gate_provenance_complete`** — fail a source record lacking a URL,
retrieval date or checksum, and name which. Recompute the checksum.
3. **`gate_grain_asserted`** — fail an ingestion with no row-grain assertion;
pass one that states a grain and records that it was verified.
4. **`gate_damage_report_quantified`** — fail a cleaning step documented
without a before/after measurement. A changelog is not a damage report.
5. **`gate_confirmation_untouched`** — detect a study whose confirmation split
was used during exploration, by checking the research log's ordering
against the split's first use.
6. **`gate_uncertainty_reported`** — fail a reported estimate with no
interval, and name the sentence.
7. **`gate_figures_documented`** — fail an unlabelled figure, pass a
documented one, and catch a figure file no record mentions.
8. **`gate_outputs_reproducible`** — detect a study whose output changed
between runs, by recomputing every manifest digest.
9. **`check_study`** — run all eight, never stopping at the first failure, and
return the verdict.
## Three rules the tests enforce
**Every finding names something.** "Provenance incomplete" is useless at 23:00
the night before a deadline. "SOURCE.json is missing: checksum_sha256" is a
task. Exercise 6 goes further and quotes the sentence, because on a
twelve-page report the filename is a re-read and the sentence is a fix.
**A failing gate always carries at least one finding.** `_failed` raises if you
try to fail a gate silently.
**Collect, do not stop.** Three missing fields give three findings; three
broken gates give three failed gates. A verdict is a task list.
## The one that will take longest
Exercise 5. The study that peeked at its confirmation set and the study that
did not produce **byte-identical** reports, figures and intervals — the test
`test_confirmation_gate_reads_only_the_log` proves it. The peek exists in the
research log's ordering and nowhere else. If your gate reads `REPORT.md`, it
cannot possibly work.
## When you are done
```bash
.venv/bin/pytest starter -q # 33 passed
bash tests/run_tests.sh # the full harness
```
Then point it at something of your own.
starter/acceptance.py (14682 bytes)
"""The acceptance harness -- YOUR skeleton.
Read `00_brief.md` first, then fill these in one at a time. Check yourself as
you go:
.venv/bin/pytest starter -q
Unattempted gates raise `NotImplementedError`, which the test suite reports as
SKIPPED, not failed. A skip means "not attempted yet"; a failure means
"attempted and wrong", and the message shows your answer next to the correct
one.
Everything above the exercises is GIVEN: the verdict types, the small file
readers, and the four parsers that turn the study's Markdown into data. The
parsing is plumbing. The nine exercises are the judgment.
The rules every gate follows, and the tests enforce:
* return `_passed(name)` when the gate is satisfied;
* return `_failed(name, findings)` otherwise, with at least one finding;
* every finding NAMES something -- the file, the field, the step, the
sentence. "Provenance incomplete" is not a finding. "SOURCE.json is
missing: checksum_sha256" is.
* collect every problem rather than returning on the first one. A verdict is
a task list, not an exception.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
# ===========================================================================
# GIVEN -- the verdict types
# ===========================================================================
@dataclass(frozen=True)
class GateResult:
"""One gate's outcome. A passing gate carries no findings; a failing gate
carries at least one, and each finding names what is wrong and where."""
name: str
ok: bool
findings: tuple[str, ...] = ()
def __str__(self) -> str: # pragma: no cover - convenience only
mark = "PASS" if self.ok else "FAIL"
if self.ok:
return f"[{mark}] {self.name}"
joined = "\n".join(f" - {f}" for f in self.findings)
return f"[{mark}] {self.name}\n{joined}"
@dataclass(frozen=True)
class StudyVerdict:
"""The whole harness's answer about one study directory."""
path: str
gates: tuple[GateResult, ...] = field(default_factory=tuple)
@property
def ok(self) -> bool:
return all(gate.ok for gate in self.gates)
@property
def failed_gates(self) -> tuple[str, ...]:
return tuple(gate.name for gate in self.gates if not gate.ok)
@property
def findings(self) -> tuple[str, ...]:
return tuple(f for gate in self.gates for f in gate.findings)
def gate(self, name: str) -> GateResult:
for gate in self.gates:
if gate.name == name:
return gate
raise KeyError(f"no such gate: {name!r} (have {[g.name for g in self.gates]})")
def summary(self) -> str:
head = "ACCEPTED" if self.ok else "NOT ACCEPTED"
lines = [f"{head}: {self.path}"]
lines += [str(gate) for gate in self.gates]
return "\n".join(lines)
def _passed(name: str) -> GateResult:
return GateResult(name=name, ok=True, findings=())
def _failed(name: str, findings) -> GateResult:
findings = tuple(findings)
if not findings: # a failing gate with nothing to say is a bug
raise ValueError(f"gate {name!r} failed without a finding")
return GateResult(name=name, ok=False, findings=findings)
# ===========================================================================
# GIVEN -- small readers
# ===========================================================================
def _read_text(study_dir: Path, name: str) -> str | None:
path = Path(study_dir) / name
if not path.is_file():
return None
return path.read_text(encoding="utf-8")
def _read_json(study_dir: Path, name: str):
"""Return (payload, error). Exactly one of the two is None."""
raw = _read_text(study_dir, name)
if raw is None:
return None, f"{name} is missing"
try:
return json.loads(raw), None
except json.JSONDecodeError as exc:
return None, f"{name} is not valid JSON: {exc}"
def sha256_of(path: Path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
# ===========================================================================
# GIVEN -- the file names, the patterns, and the four parsers
# ===========================================================================
QUESTION_FILE = "QUESTION.md"
SOURCE_FILE = "SOURCE.json"
REQUIRED_SOURCE_FIELDS = ("url", "retrieved", "checksum_sha256", "licence")
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
INGEST_FILE = "INGEST.json"
CLEANING_FILE = "CLEANING.md"
_STEP_RE = re.compile(r"^###\s+(?P<title>.+?)\s*$")
_MEASURE_RE = re.compile(r"^(?P<key>before|after)\s*:\s*(?P<value>-?[\d.]+)\s*$", re.I)
def cleaning_steps(text: str) -> list[tuple[str, dict[str, float]]]:
"""Split CLEANING.md into (step title, measurements) pairs."""
steps: list[tuple[str, dict[str, float]]] = []
current: str | None = None
values: dict[str, float] = {}
for line in text.splitlines():
header = _STEP_RE.match(line)
if header:
if current is not None:
steps.append((current, values))
current = header.group("title")
values = {}
continue
if current is None:
continue
measure = _MEASURE_RE.match(line.strip())
if measure:
values[measure.group("key").lower()] = float(measure.group("value"))
if current is not None:
steps.append((current, values))
return steps
LOG_FILE = "RESEARCH_LOG.md"
_ROW_RE = re.compile(r"^\|(?P<cells>.+)\|\s*$")
def research_log_rows(text: str) -> list[dict[str, str]]:
"""Read the research log's Markdown table into ordered dicts."""
header: list[str] | None = None
rows: list[dict[str, str]] = []
for line in text.splitlines():
match = _ROW_RE.match(line.strip())
if not match:
continue
cells = [cell.strip() for cell in match.group("cells").split("|")]
if header is None:
header = [cell.lower() for cell in cells]
continue
if all(set(cell) <= {"-", ":"} and cell for cell in cells):
continue # the ---|--- separator row
if len(cells) != len(header):
continue
rows.append(dict(zip(header, cells)))
return rows
REPORT_FILE = "REPORT.md"
FINDINGS_HEADING = "## Findings"
ESTIMATE_WORDS = (
"mean",
"average",
"median",
"difference",
"rate",
"estimate",
"higher",
"lower",
"increase",
"decrease",
"proportion",
)
_NUMBER_RE = re.compile(r"-?\d+(?:\.\d+)?")
_INTERVAL_RES = (
re.compile(r"\bci\b", re.I),
re.compile(r"confidence interval", re.I),
re.compile(r"credible interval", re.I),
re.compile(r"\binterval\b", re.I),
re.compile(r"±"),
re.compile(r"\+/-"),
re.compile(r"-?\d+(?:\.\d+)?\s+to\s+-?\d+(?:\.\d+)?"),
# "anywhere between 3.80 and 7.21" states an interval in words. This
# pattern was added because the harness flagged exactly that sentence in
# its own worked report -- a real false positive, fixed in the checker
# rather than papered over by rewording the report.
re.compile(r"between\s+-?\d+(?:\.\d+)?\s+and\s+-?\d+(?:\.\d+)?", re.I),
re.compile(r"\[\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\]"),
)
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
def findings_section(text: str) -> str | None:
"""The report's findings section only. The gate is deliberately scoped:
a methods paragraph mentioning a row count is not a claim, and flagging
it would train the reader to ignore the harness."""
lines = text.splitlines()
start = None
for index, line in enumerate(lines):
if line.strip().lower() == FINDINGS_HEADING.lower():
start = index + 1
break
if start is None:
return None
end = len(lines)
for index in range(start, len(lines)):
if lines[index].startswith("## "):
end = index
break
return "\n".join(lines[start:end])
def sentences_of(block: str) -> list[str]:
paragraphs = [p for p in block.split("\n\n") if p.strip()]
sentences = []
for paragraph in paragraphs:
flat = " ".join(paragraph.split())
if flat.startswith(("!", "|", "#", "-", "*")):
continue # images, tables, headings and bullets are not prose
sentences.extend(s.strip() for s in _SENTENCE_SPLIT.split(flat) if s.strip())
return sentences
FIGURES_FILE = "FIGURES.json"
FIGURES_DIR = "figures"
FIGURE_SUFFIXES = (".png", ".svg", ".jpg", ".jpeg", ".pdf")
MANIFEST_FILE = "MANIFEST.json"
_MANIFEST_EXCLUDE = {MANIFEST_FILE}
# ===========================================================================
# YOUR WORK -- nine exercises
# ===========================================================================
def gate_question_recorded(study_dir: Path) -> GateResult:
"""Exercise 1. Fail a study whose question file is missing or empty, and
name the file.
Pass when QUESTION.md exists, has non-heading body text, and at least one
of those body lines ends in a question mark. Fail otherwise, with one of:
"QUESTION.md is missing"
"QUESTION.md is empty"
"QUESTION.md contains only headings, no question text"
"QUESTION.md records no question sentence (no non-heading line ends in
a question mark)"
Use `_read_text(study_dir, QUESTION_FILE)`, which returns None when the
file is absent.
"""
raise NotImplementedError
def gate_provenance_complete(study_dir: Path) -> GateResult:
"""Exercise 2. Fail a source record missing a URL, retrieval date or
checksum, and name which one.
Read SOURCE.json with `_read_json`. For every key in
REQUIRED_SOURCE_FIELDS that is absent, None or blank, add the finding
"SOURCE.json is missing: <key>" -- one per key, so three missing fields
give three findings.
Two extras that make the gate worth running:
* if `retrieved` is present but does not match `_DATE_RE`, say so;
* if the record carries a `path`, recompute `sha256_of` that file and
compare it with `checksum_sha256`. A checksum nobody verifies is a
decoration.
"""
raise NotImplementedError
def gate_grain_asserted(study_dir: Path) -> GateResult:
"""Exercise 3. Fail a study whose ingestion has no row-grain assertion;
pass one that has it.
Read INGEST.json. Require a non-empty `grain` list, a `grain_verified`
key that is exactly True, and a `rows_in` count. A grain declared but
never verified is a hope with a schema, so say that plainly when
`grain_verified` is absent, and report `grain_violations` when it is
present and false.
"""
raise NotImplementedError
def gate_damage_report_quantified(study_dir: Path) -> GateResult:
"""Exercise 4. Fail a cleaning step documented without a before/after
measurement -- a changelog is not a damage report.
`cleaning_steps(text)` gives you (title, {"before": x, "after": y}) pairs.
Fail when a step is missing either measurement, naming the step, and fail
when before equals after -- a step that changed nothing measurable either
did not need doing or measured the wrong thing.
"""
raise NotImplementedError
def gate_confirmation_untouched(study_dir: Path) -> GateResult:
"""Exercise 5. Detect a study whose confirmation split was used during
exploration, by checking the research log's ordering against the split's
first use.
`research_log_rows(text)` gives you the log as ordered dicts with `seq`,
`timestamp`, `split`, `activity` and `outcome`. Find the index of the
first row whose `activity` contains "hypothesis declared", and the index
of the first row whose `split` is "confirmation". Fail when the second
index is less than or equal to the first, and say which entries.
Also fail when either is missing, and when the confirmation split is used
more than once.
"""
raise NotImplementedError
def gate_uncertainty_reported(study_dir: Path) -> GateResult:
"""Exercise 6. Fail a reported estimate with no interval, and name the
sentence.
Take `findings_section(text)`, then `sentences_of(block)`. A sentence is
an estimate if it contains a number (`_NUMBER_RE`) AND one of
ESTIMATE_WORDS. An estimate sentence must also match one of
`_INTERVAL_RES`. Quote the offending sentence in the finding, truncated
to about 160 characters, because on a twelve-page report the sentence is
the fix and the filename is a re-read.
Fail too when the findings section reports no numeric estimate at all.
"""
raise NotImplementedError
def gate_figures_documented(study_dir: Path) -> GateResult:
"""Exercise 7. Fail an unlabelled figure and pass a documented one.
FIGURES.json is a list of records. Each needs a `file` that exists under
the study directory, a non-empty `question` and a non-empty `claim`.
Then check the other direction: every file under `figures/` with a
FIGURE_SUFFIXES extension must appear in FIGURES.json, or it is a chart
that survived three drafts because nobody remembered what it was for.
"""
raise NotImplementedError
def gate_outputs_reproducible(study_dir: Path) -> GateResult:
"""Exercise 8. Detect a study whose output changed between runs.
MANIFEST.json holds {"algorithm": "sha256", "files": {path: digest}}.
Recompute each digest. Report a file that no longer matches, a manifest
entry with no file, a file on disk the manifest never mentions, and a
manifest that does not cover REPORT.md at all.
"""
raise NotImplementedError
GATES = (
gate_question_recorded,
gate_provenance_complete,
gate_grain_asserted,
gate_damage_report_quantified,
gate_confirmation_untouched,
gate_uncertainty_reported,
gate_figures_documented,
gate_outputs_reproducible,
)
GATE_NAMES = (
"question_recorded",
"provenance_complete",
"grain_asserted",
"damage_report_quantified",
"confirmation_untouched",
"uncertainty_reported",
"figures_documented",
"outputs_reproducible",
)
def check_study(path) -> StudyVerdict:
"""Exercise 9. Run every gate and return the verdict.
Raise `FileNotFoundError` when `path` is not a directory. Otherwise run
ALL eight gates -- never stop at the first failure -- and return a
`StudyVerdict` carrying the directory path and the eight results in
GATE_NAMES order.
"""
raise NotImplementedError
starter/conftest.py (1076 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain modules called `acceptance`, `study`,
`dataset` and `fixtures`, and pytest imports test files by putting their
directory on `sys.path`. Without this file, running `pytest` across both
directories at once would import whichever copy was seen first and reuse it
for the other -- so the starter tests would silently pass against the
reference solution instead of skipping. That is a wrong answer with a green
tick on it, which is the worst kind.
So: put this directory first on the import path, and drop any already-imported
module of those names that came from somewhere else.
"""
import sys
from pathlib import Path
HERE = str(Path(__file__).parent.resolve())
if HERE in sys.path:
sys.path.remove(HERE)
sys.path.insert(0, HERE)
for name in ("acceptance", "study", "dataset", "fixtures"):
module = sys.modules.get(name)
origin = getattr(module, "__file__", "") or ""
if module is not None and not origin.startswith(HERE):
del sys.modules[name]
starter/data/observations.csv (12265 bytes)
reading_id,station_id,captured_at,station_type,pm25_ug_m3,humidity_pct,temp_c
R0165,ST-06,2026-06-05, park ,-1.0,60.7,19.3
R0113,ST-04,2026-06-17,ROADSIDE,26.1,66.0,26.9
R0187,ST-06,2026-06-27, park ,9.56,51.7,27.3
R0133,ST-05,2026-06-05,PARK,12.73,58.2,25.3
R0103,ST-04,2026-06-07,roadside,16.59,40.5,17.7
R0039,ST-02,2026-06-07, roadside ,22.2,62.0,25.0
R0168,ST-06,2026-06-08,Park,5.53,56.1,21.4
R0095,ST-03,2026-06-31,ROADSIDE,17.51,56.5,25.4
R0241,ST-08,2026-06-17,Park,-1.0,84.5,22.6
R0041,ST-02,2026-06-09,ROADSIDE,16.17,89.0,18.9
R0143,ST-05,2026-06-15,PARK,8.88,45.2,19.7
R0144,ST-05,2026-06-16,park,19.44,83.0,22.0
R0042,ST-02,2026-06-10,roadside,16.21,45.3,16.9
R0214,ST-07,2026-06-22,park,14.45,52.8,19.8
R0126,ST-04,2026-06-30, roadside ,20.79,47.6,26.6
R0108,ST-04,2026-06-12,Roadside,12.05,58.7,16.1
R0019,ST-01,2026-06-19,ROADSIDE,23.87,87.4,18.3
R0156,ST-05,2026-06-28, park ,15.68,82.7,27.3
R0035,ST-02,2026-06-03,ROADSIDE,16.96,80.1,26.0
R0234,ST-08,2026-06-10,Park,13.87,53.7,27.9
R0210,ST-07,2026-06-18,Park,10.48,77.1,18.4
R0044,ST-02,2026-06-12, roadside ,17.62,75.4,26.0
R0140,ST-05,2026-06-12,Park,5.8,40.2,15.8
R0080,ST-03,2026-06-16,ROADSIDE,15.75,81.9,25.2
R0191,ST-06,2026-06-31,park,12.21,50.0,21.7
R0112,ST-04,2026-06-16, roadside ,14.05,55.9,28.6
R0189,ST-06,2026-06-29, park ,14.3,74.3,29.3
R0076,ST-03,2026-06-12,ROADSIDE,16.26,51.7,23.3
R0107,ST-04,2026-06-11, roadside ,27.61,43.6,17.9
R0021,ST-01,2026-06-21,roadside,15.79,57.4,25.0
R0212,ST-07,2026-06-20,Park,8.93,46.1,23.5
R0026,ST-01,2026-06-26,ROADSIDE,27.82,54.0,17.7
R0135,ST-05,2026-06-07, park ,10.05,87.1,16.4
R0252,ST-08,2026-06-28,PARK,13.22,40.9,24.5
R0072,ST-03,2026-06-08, roadside ,14.01,52.6,15.7
R0201,ST-07,2026-06-09, park ,12.41,82.7,16.4
R0131,ST-05,2026-06-03,Park,9.43,73.9,26.0
R0111,ST-04,2026-06-15,Roadside,10.85,85.1,20.5
R0121,ST-04,2026-06-25,ROADSIDE,13.53,64.4,17.9
R0231,ST-08,2026-06-07,Park,11.03,57.4,17.3
R0003,ST-01,2026-06-03, roadside ,13.78,89.0,16.5
R0181,ST-06,2026-06-21,park,11.26,80.7,18.3
R0170,ST-06,2026-06-10,PARK,11.88,63.1,23.5
R0172,ST-06,2026-06-12, park ,8.8,41.8,16.7
R0089,ST-03,2026-06-25,ROADSIDE,22.9,73.2,22.6
R0254,ST-08,2026-06-30, park ,14.26,60.0,17.1
R0104,ST-04,2026-06-08,roadside,7.61,52.4,19.5
R0199,ST-07,2026-06-07,PARK,-1.71,67.6,29.0
R0161,ST-06,2026-06-01,park,11.48,50.0,24.5
R0227,ST-08,2026-06-03,Park,11.47,86.6,20.8
R0178,ST-06,2026-06-18,Park,11.51,89.8,29.1
R0038,ST-02,2026-06-06,roadside,15.27,68.7,16.8
R0215,ST-07,2026-06-23,Park,19.63,74.2,22.3
R0034,ST-02,2026-06-02,roadside,12.82,51.4,20.5
R0186,ST-06,2026-06-26, park ,20.19,72.8,25.0
R0213,ST-07,2026-06-21,park,6.55,42.6,24.1
R0067,ST-03,2026-06-03,ROADSIDE,22.32,85.4,23.3
R0125,ST-04,2026-06-29,Roadside,19.84,55.3,20.4
R0163,ST-06,2026-06-03,PARK,14.66,55.0,29.4
R0088,ST-03,2026-06-24, roadside ,,72.4,15.7
R0096,ST-03,2026-06-32,roadside,17.76,83.7,26.9
R0082,ST-03,2026-06-18, roadside ,11.41,74.5,16.0
R0256,ST-08,2026-06-32,park,19.43,67.2,16.0
R0237,ST-08,2026-06-13,Park,7.81,79.9,28.8
R0074,ST-03,2026-06-10,Roadside,26.51,64.3,29.7
R0223,ST-07,2026-06-31, park ,1.62,59.7,15.0
R0148,ST-05,2026-06-20,Park,4.39,72.4,20.9
R0202,ST-07,2026-06-10, park ,22.3,83.3,29.5
R0027,ST-01,2026-06-27, roadside ,12.74,66.9,20.7
R0226,ST-08,2026-06-02,PARK,17.78,66.5,19.3
R0218,ST-07,2026-06-26,PARK,13.57,48.7,27.2
R0100,ST-04,2026-06-04,Roadside,18.37,80.8,21.1
R0142,ST-05,2026-06-14,park,5.24,87.0,23.5
R0173,ST-06,2026-06-13,Park,12.63,73.3,19.3
R0206,ST-07,2026-06-14,PARK,4.45,89.2,23.7
R0077,ST-03,2026-06-13,roadside,14.93,51.2,17.7
R0009,ST-01,2026-06-09,roadside,21.5,53.1,29.3
R0250,ST-08,2026-06-26, park ,10.08,83.4,27.8
R0247,ST-08,2026-06-23,Park,6.28,62.9,28.9
R0090,ST-03,2026-06-26,Roadside,,68.4,20.4
R0048,ST-02,2026-06-16,ROADSIDE,16.73,69.2,23.2
R0221,ST-07,2026-06-29,park,11.87,40.5,17.8
R0084,ST-03,2026-06-20,roadside,22.79,61.9,23.0
R0031,ST-01,2026-06-31, roadside ,11.37,41.8,22.3
R0079,ST-03,2026-06-15,roadside,23.33,43.8,16.1
R0032,ST-01,2026-06-32, roadside ,20.36,63.6,17.0
R0083,ST-03,2026-06-19,ROADSIDE,21.34,70.5,27.9
R0094,ST-03,2026-06-30,Roadside,17.79,67.8,22.9
R0250,ST-08,2026-06-26, park ,10.08,83.4,27.8
R0030,ST-01,2026-06-30,roadside,13.58,65.0,22.4
R0205,ST-07,2026-06-13,PARK,10.38,75.0,19.2
R0158,ST-05,2026-06-30,Park,8.12,63.3,21.2
R0106,ST-04,2026-06-10,ROADSIDE,18.35,41.4,21.1
R0162,ST-06,2026-06-02,PARK,9.9,61.0,24.8
R0024,ST-01,2026-06-24,roadside,13.39,79.4,23.1
R0081,ST-03,2026-06-17,roadside,25.16,56.8,29.5
R0064,ST-02,2026-06-32,Roadside,-1.0,43.9,18.8
R0057,ST-02,2026-06-25,Roadside,11.7,89.7,22.0
R0018,ST-01,2026-06-18, roadside ,24.8,83.9,25.8
R0004,ST-01,2026-06-04,roadside,20.49,41.6,21.2
R0157,ST-05,2026-06-29,PARK,-0.21,59.2,27.8
R0105,ST-04,2026-06-09,Roadside,23.6,67.5,16.0
R0184,ST-06,2026-06-24,park,13.92,69.1,27.7
R0115,ST-04,2026-06-19,Roadside,14.45,53.4,25.0
R0073,ST-03,2026-06-09, roadside ,18.9,79.8,15.3
R0117,ST-04,2026-06-21,roadside,16.2,51.4,27.3
R0146,ST-05,2026-06-18,PARK,8.65,72.5,28.2
R0016,ST-01,2026-06-16,Roadside,14.75,42.5,22.9
R0238,ST-08,2026-06-14, park ,6.83,67.5,19.3
R0219,ST-07,2026-06-27,park,16.12,86.7,19.0
R0141,ST-05,2026-06-13,park,14.36,47.3,17.7
R0070,ST-03,2026-06-06, roadside ,19.51,61.1,27.0
R0065,ST-03,2026-06-01, roadside ,12.14,76.0,23.7
R0207,ST-07,2026-06-15,Park,13.32,59.9,26.7
R0179,ST-06,2026-06-19,Park,14.9,62.0,16.1
R0134,ST-05,2026-06-06,park,16.34,79.4,21.5
R0056,ST-02,2026-06-24, roadside ,16.08,75.5,16.2
R0195,ST-07,2026-06-03,park,,55.9,29.9
R0006,ST-01,2026-06-06,ROADSIDE,17.21,40.3,26.7
R0249,ST-08,2026-06-25, park ,8.62,46.3,29.7
R0253,ST-08,2026-06-29,park,8.43,61.4,24.2
R0136,ST-05,2026-06-08, park ,0.19,76.4,24.6
R0017,ST-01,2026-06-17,Roadside,-1.0,53.5,29.8
R0203,ST-07,2026-06-11, park ,17.39,74.3,25.1
R0208,ST-07,2026-06-16, park ,16.24,47.1,15.5
R0150,ST-05,2026-06-22,park,13.94,82.6,26.7
R0051,ST-02,2026-06-19,roadside,10.48,51.6,23.4
R0160,ST-05,2026-06-32, park ,14.38,45.8,16.3
R0091,ST-03,2026-06-27,Roadside,21.46,63.1,29.0
R0093,ST-03,2026-06-29, roadside ,15.62,87.7,30.0
R0055,ST-02,2026-06-23,Roadside,14.65,79.0,18.0
R0182,ST-06,2026-06-22,PARK,18.0,60.9,20.3
R0025,ST-01,2026-06-25, roadside ,14.74,43.4,23.0
R0066,ST-03,2026-06-02, roadside ,18.82,70.0,19.5
R0109,ST-04,2026-06-13,Roadside,9.31,86.6,29.3
R0164,ST-06,2026-06-04, park ,10.9,70.7,17.5
R0066,ST-03,2026-06-02, roadside ,18.82,70.0,19.5
R0151,ST-05,2026-06-23,PARK,10.74,72.8,27.6
R0145,ST-05,2026-06-17,PARK,16.48,83.8,24.7
R0040,ST-02,2026-06-08,roadside,15.77,40.8,24.6
R0069,ST-03,2026-06-05,roadside,16.75,82.0,17.4
R0043,ST-02,2026-06-11,ROADSIDE,17.37,71.7,23.2
R0251,ST-08,2026-06-27,park,8.64,68.1,29.3
R0149,ST-05,2026-06-21,Park,13.93,44.7,25.0
R0075,ST-03,2026-06-11,roadside,20.01,57.0,27.6
R0147,ST-05,2026-06-19,park,11.98,58.9,28.9
R0123,ST-04,2026-06-27,ROADSIDE,11.82,41.6,15.3
R0216,ST-07,2026-06-24,Park,14.61,63.8,20.0
R0167,ST-06,2026-06-07,PARK,8.47,43.2,16.8
R0053,ST-02,2026-06-21,ROADSIDE,21.67,71.3,20.4
R0049,ST-02,2026-06-17,Roadside,21.62,78.5,25.3
R0028,ST-01,2026-06-28,ROADSIDE,22.0,88.1,25.7
R0155,ST-05,2026-06-27,Park,16.08,73.1,22.8
R0127,ST-04,2026-06-31,roadside,15.63,61.8,17.0
R0009,ST-01,2026-06-09,roadside,21.5,53.1,29.3
R0122,ST-04,2026-06-26, roadside ,23.12,52.2,20.1
R0139,ST-05,2026-06-11, park ,9.81,88.6,17.5
R0198,ST-07,2026-06-06,Park,13.52,85.6,20.6
R0092,ST-03,2026-06-28,ROADSIDE,16.62,87.8,22.3
R0068,ST-03,2026-06-04,roadside,15.53,79.0,25.4
R0113,ST-04,2026-06-17,ROADSIDE,26.1,66.0,26.9
R0013,ST-01,2026-06-13,Roadside,14.52,86.9,27.3
R0225,ST-08,2026-06-01,Park,16.85,55.9,21.2
R0166,ST-06,2026-06-06, park ,14.61,41.0,19.0
R0114,ST-04,2026-06-18, roadside ,,75.1,21.5
R0062,ST-02,2026-06-30,ROADSIDE,14.2,48.6,27.0
R0230,ST-08,2026-06-06,Park,16.47,87.6,17.2
R0217,ST-07,2026-06-25,park,5.49,40.4,29.3
R0242,ST-08,2026-06-18,PARK,8.74,67.5,19.1
R0177,ST-06,2026-06-17,Park,18.2,76.7,30.0
R0005,ST-01,2026-06-05,ROADSIDE,9.92,44.6,17.2
R0098,ST-04,2026-06-02,roadside,20.28,89.8,20.0
R0008,ST-01,2026-06-08,Roadside,11.19,55.9,26.1
R0001,ST-01,2026-06-01,ROADSIDE,27.0,75.4,18.3
R0159,ST-05,2026-06-31,PARK,,61.3,23.8
R0235,ST-08,2026-06-11, park ,17.38,42.9,21.9
R0138,ST-05,2026-06-10,PARK,17.81,60.0,22.1
R0022,ST-01,2026-06-22, roadside ,9.17,57.7,29.3
R0243,ST-08,2026-06-19, park ,13.73,73.7,24.5
R0107,ST-04,2026-06-11, roadside ,27.61,43.6,17.9
R0085,ST-03,2026-06-21,roadside,20.35,81.7,15.0
R0116,ST-04,2026-06-20, roadside ,14.91,45.9,29.7
R0209,ST-07,2026-06-17,Park,7.25,50.4,24.7
R0086,ST-03,2026-06-22, roadside ,21.56,66.4,21.4
R0185,ST-06,2026-06-25,Park,11.26,57.1,26.9
R0010,ST-01,2026-06-10,ROADSIDE,13.84,83.5,25.2
R0011,ST-01,2026-06-11,Roadside,12.75,44.6,21.2
R0183,ST-06,2026-06-23,Park,15.16,53.5,16.0
R0037,ST-02,2026-06-05, roadside ,22.15,87.0,26.6
R0222,ST-07,2026-06-30,Park,-1.0,55.9,26.7
R0059,ST-02,2026-06-27, roadside ,12.88,81.2,24.7
R0192,ST-06,2026-06-32, park ,7.35,53.4,22.5
R0036,ST-02,2026-06-04,Roadside,27.25,46.7,15.7
R0046,ST-02,2026-06-14,Roadside,8.25,50.9,21.3
R0054,ST-02,2026-06-22, roadside ,13.85,78.8,29.0
R0196,ST-07,2026-06-04,PARK,9.86,41.2,28.5
R0061,ST-02,2026-06-29,roadside,20.94,64.8,22.5
R0188,ST-06,2026-06-28,Park,16.89,48.8,27.8
R0132,ST-05,2026-06-04,PARK,9.33,85.0,27.8
R0101,ST-04,2026-06-05,Roadside,29.03,59.0,19.4
R0045,ST-02,2026-06-13, roadside ,7.48,41.6,25.4
R0012,ST-01,2026-06-12,ROADSIDE,22.7,60.3,18.3
R0023,ST-01,2026-06-23,roadside,17.96,55.6,22.4
R0029,ST-01,2026-06-29,ROADSIDE,19.43,85.9,23.7
R0087,ST-03,2026-06-23,Roadside,22.49,44.1,23.8
R0174,ST-06,2026-06-14,Park,11.15,81.9,23.1
R0002,ST-01,2026-06-02,ROADSIDE,9.66,67.9,22.0
R0050,ST-02,2026-06-18,ROADSIDE,16.04,89.4,22.6
R0102,ST-04,2026-06-06,ROADSIDE,15.63,80.6,17.8
R0052,ST-02,2026-06-20,ROADSIDE,18.21,56.3,29.0
R0013,ST-01,2026-06-13,Roadside,14.52,86.9,27.3
R0255,ST-08,2026-06-31,Park,17.46,57.3,20.1
R0130,ST-05,2026-06-02,park,9.11,48.1,17.8
R0047,ST-02,2026-06-15, roadside ,20.73,87.1,19.8
R0193,ST-07,2026-06-01,Park,9.99,60.8,17.4
R0152,ST-05,2026-06-24, park ,14.64,43.3,29.6
R0229,ST-08,2026-06-05,Park,17.42,69.5,19.8
R0235,ST-08,2026-06-11, park ,17.38,42.9,21.9
R0175,ST-06,2026-06-15,Park,17.29,52.7,24.3
R0124,ST-04,2026-06-28,ROADSIDE,-1.0,81.4,26.8
R0097,ST-04,2026-06-01,roadside,24.29,82.8,21.0
R0197,ST-07,2026-06-05,Park,7.92,55.8,21.8
R0240,ST-08,2026-06-16,park,18.93,64.9,21.8
R0020,ST-01,2026-06-20,roadside,10.85,75.2,19.4
R0137,ST-05,2026-06-09,park,11.32,43.3,16.4
R0233,ST-08,2026-06-09,Park,12.16,42.7,20.2
R0228,ST-08,2026-06-04, park ,7.22,76.5,17.2
R0248,ST-08,2026-06-24,Park,15.01,68.4,20.2
R0058,ST-02,2026-06-26,roadside,14.76,71.9,24.7
R0015,ST-01,2026-06-15,roadside,21.88,76.9,26.7
R0128,ST-04,2026-06-32,Roadside,8.27,67.3,17.6
R0054,ST-02,2026-06-22, roadside ,13.85,78.8,29.0
R0180,ST-06,2026-06-20,Park,10.28,62.9,15.5
R0220,ST-07,2026-06-28, park ,6.19,70.1,15.1
R0110,ST-04,2026-06-14,roadside,13.29,70.7,18.9
R0211,ST-07,2026-06-19,PARK,3.1,73.4,17.6
R0071,ST-03,2026-06-07, roadside ,27.73,64.0,27.7
R0120,ST-04,2026-06-24,ROADSIDE,14.33,67.2,16.9
R0236,ST-08,2026-06-12, park ,10.3,69.5,18.8
R0204,ST-07,2026-06-12,Park,10.89,48.0,17.5
R0154,ST-05,2026-06-26,park,9.14,58.5,16.7
R0007,ST-01,2026-06-07,roadside,24.48,47.5,26.5
R0060,ST-02,2026-06-28,Roadside,19.56,82.3,18.4
R0190,ST-06,2026-06-30, park ,7.85,84.6,24.1
R0118,ST-04,2026-06-22, roadside ,18.75,63.2,21.2
R0129,ST-05,2026-06-01,PARK,20.92,60.8,21.7
R0153,ST-05,2026-06-25, park ,15.19,69.7,29.6
R0176,ST-06,2026-06-16,park,2.91,51.9,23.6
R0232,ST-08,2026-06-08,park,8.77,45.5,18.5
R0119,ST-04,2026-06-23,roadside,22.67,74.5,18.6
R0033,ST-02,2026-06-01, roadside ,17.94,43.5,28.2
R0239,ST-08,2026-06-15, park ,6.49,83.1,28.9
R0194,ST-07,2026-06-02,PARK,11.19,40.3,22.0
R0099,ST-04,2026-06-03,ROADSIDE,18.29,61.9,18.5
R0078,ST-03,2026-06-14,roadside,22.99,87.1,29.7
R0200,ST-07,2026-06-08, park ,9.8,47.8,25.4
R0244,ST-08,2026-06-20,Park,16.12,87.1,24.6
R0171,ST-06,2026-06-11, park ,8.82,76.5,22.6
R0245,ST-08,2026-06-21, park ,14.32,42.3,15.9
R0014,ST-01,2026-06-14,ROADSIDE,21.27,86.5,23.8
R0169,ST-06,2026-06-09,park,12.46,77.6,23.1
R0063,ST-02,2026-06-31,roadside,13.77,87.0,15.6
R0246,ST-08,2026-06-22, park ,6.23,56.5,17.4
R0224,ST-07,2026-06-32,PARK,6.24,65.4,24.3
starter/dataset.py (5209 bytes)
"""The small synthetic dataset this lab's worked study is built on.
The study needs a source that is messy in *specific, nameable* ways, because
the point of the worked study is to show a damage report with real numbers in
it. So the frame is generated from a fixed seed with four deliberate defects:
1. inconsistent casing and stray whitespace in `station_type`;
2. eight duplicated `reading_id` values (the same reading delivered twice);
3. six readings where the sensor emitted its fault sentinel, -1.0;
4. five readings where `pm25_ug_m3` is simply blank.
Underneath the mess there is one real effect: roadside stations record higher
PM2.5 than park stations. The generator plants a true mean difference of
6.0 ug/m3 (roadside 18.0, park 12.0, both with a standard deviation of 5.0),
so the study has something honest to find, and the confirmation half has a
real chance of confirming it.
`observations.csv` in this directory is the saved output of
`generate_frame()`. It is committed so that the study has a genuine file on
disk with a genuine checksum, which is what the provenance gate checks.
`test_reference.py` asserts the committed file still matches the generator
byte for byte, so the two can never drift apart silently.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).parent.resolve()
SOURCE_CSV = HERE / "data" / "observations.csv"
DATASET_SEED = 140
ROADSIDE_STATIONS = ("ST-01", "ST-02", "ST-03", "ST-04")
PARK_STATIONS = ("ST-05", "ST-06", "ST-07", "ST-08")
ROADSIDE_MEAN = 18.0
PARK_MEAN = 12.0
PM25_SD = 5.0
TRUE_DIFFERENCE = ROADSIDE_MEAN - PARK_MEAN
READINGS_PER_STATION = 32
N_DUPLICATED = 8
N_SENTINEL = 6
N_BLANK = 5
FAULT_SENTINEL = -1.0
# The four casing variants the field units actually emit. Exactly one of them
# is the value the study wants; the other three are the same thing wearing a
# different coat, which is what the cleaning step measures.
CASINGS = ("roadside", "Roadside", "ROADSIDE", " roadside ")
PARK_CASINGS = ("park", "Park", "PARK", " park ")
ALPHA = 0.05
def generate_frame(seed: int = DATASET_SEED) -> pd.DataFrame:
"""Build the raw observation frame, defects and all, deterministically."""
rng = np.random.default_rng(seed)
stations = list(ROADSIDE_STATIONS) + list(PARK_STATIONS)
rows = []
for station in stations:
roadside = station in ROADSIDE_STATIONS
mean = ROADSIDE_MEAN if roadside else PARK_MEAN
casings = CASINGS if roadside else PARK_CASINGS
for day in range(READINGS_PER_STATION):
rows.append(
{
"station_id": station,
"captured_at": f"2026-06-{day + 1:02d}",
"station_type": casings[rng.integers(0, len(casings))],
"pm25_ug_m3": round(float(rng.normal(mean, PM25_SD)), 2),
"humidity_pct": round(float(rng.uniform(40.0, 90.0)), 1),
"temp_c": round(float(rng.uniform(15.0, 30.0)), 1),
}
)
frame = pd.DataFrame(rows)
# A stable id assigned in capture order, before any defect is introduced.
frame.insert(0, "reading_id", [f"R{i + 1:04d}" for i in range(len(frame))])
# Defect 3 and 4: the sensor fault sentinel, and outright blanks. Both are
# chosen from disjoint index pools so a single row never carries two.
damaged = rng.choice(frame.index, size=N_SENTINEL + N_BLANK, replace=False)
sentinel_rows = damaged[:N_SENTINEL]
blank_rows = damaged[N_SENTINEL:]
frame.loc[sentinel_rows, "pm25_ug_m3"] = FAULT_SENTINEL
frame.loc[blank_rows, "pm25_ug_m3"] = np.nan
# Defect 2: eight readings delivered twice. The duplicate is byte-identical
# to its original, which is exactly why a naive ingest never notices. The
# rows are drawn from the undamaged pool so that each defect stays
# separable in the damage report -- a duplicated blank would be two
# defects on one row and would blur the before/after counts.
undamaged = frame.index.difference(pd.Index(damaged))
repeated = rng.choice(undamaged, size=N_DUPLICATED, replace=False)
frame = pd.concat([frame, frame.loc[repeated]], ignore_index=True)
# Delivery order is not capture order. Shuffle so nothing downstream can
# accidentally depend on the rows arriving sorted.
order = rng.permutation(len(frame))
frame = frame.iloc[order].reset_index(drop=True)
return frame
def write_source_csv(path: Path | None = None, seed: int = DATASET_SEED) -> Path:
"""Write the generated frame to CSV exactly as the committed file was."""
target = Path(path) if path is not None else SOURCE_CSV
target.parent.mkdir(parents=True, exist_ok=True)
generate_frame(seed).to_csv(target, index=False, lineterminator="\n")
return target
def load_source_csv(path: Path | None = None) -> pd.DataFrame:
"""Read the committed CSV the way an ingest step would: everything as
text first, so nothing is silently coerced before the contract runs."""
source = Path(path) if path is not None else SOURCE_CSV
return pd.read_csv(source, dtype=str, keep_default_na=False)
starter/fixtures.py (8142 bytes)
"""Deliberately broken copies of the worked study, one defect at a time.
A harness that has only ever been run on a good study is an untested harness.
Each function here takes a complete, passing study directory and removes or
corrupts exactly one thing, so that a test can assert which gate fires and
what the finding says.
Every mutator rewrites MANIFEST.json afterwards by default. That is not
cosmetic: without it, deleting QUESTION.md would fail BOTH the question gate
and the reproducibility gate, and a test asserting "one gate fired" would be
asserting something untrue. The one exception is `break_reproducibility`,
whose whole point is to leave the manifest stale.
Nothing here writes outside the directory it is handed.
"""
from __future__ import annotations
import json
import re
import shutil
from pathlib import Path
import study
def worked_study(root: Path, name: str = "study", as_of: str = study.AS_OF) -> Path:
"""Build a complete, passing study directory under `root`."""
dest = Path(root) / name
study.build_study(dest, as_of=as_of)
return dest
def copy_of(source: Path, dest: Path) -> Path:
dest = Path(dest)
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(Path(source), dest)
return dest
def variant(source: Path, dest: Path, mutator, *, rewrite_manifest: bool = True) -> Path:
"""Copy `source` to `dest`, apply one defect, and refresh the manifest so
that exactly the intended gate fails."""
target = copy_of(source, dest)
mutator(target)
if rewrite_manifest:
study.write_manifest(target)
return target
# ---------------------------------------------------------------------------
# The defects
# ---------------------------------------------------------------------------
def break_missing_question(study_dir: Path) -> None:
(Path(study_dir) / "QUESTION.md").unlink()
def break_empty_question(study_dir: Path) -> None:
(Path(study_dir) / "QUESTION.md").write_text(" \n\n", encoding="utf-8")
def break_question_without_a_question(study_dir: Path) -> None:
"""The commonest real version: a heading and a topic, not a question."""
(Path(study_dir) / "QUESTION.md").write_text(
"# Question\n\nAir quality in the city network.\n", encoding="utf-8"
)
def break_provenance(
study_dir: Path, drop: tuple[str, ...] = ("url", "retrieved", "checksum_sha256")
) -> None:
path = Path(study_dir) / "SOURCE.json"
payload = json.loads(path.read_text(encoding="utf-8"))
for key in drop:
payload.pop(key, None)
payload.pop("path", None) # nothing left to verify a checksum against
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def break_provenance_checksum(study_dir: Path) -> None:
"""The record keeps its checksum, but the file it describes has moved on."""
path = Path(study_dir) / "SOURCE.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload["checksum_sha256"] = "0" * 64
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def break_grain(study_dir: Path) -> None:
"""An ingestion that reads the file and never says what a row is."""
path = Path(study_dir) / "INGEST.json"
payload = json.loads(path.read_text(encoding="utf-8"))
for key in ("grain", "grain_statement", "grain_verified", "grain_violations"):
payload.pop(key, None)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def break_grain_unverified(study_dir: Path) -> None:
"""The grain is declared but never checked -- a hope with a schema."""
path = Path(study_dir) / "INGEST.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload.pop("grain_verified", None)
payload.pop("grain_violations", None)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
CHANGELOG_STEP = "drop sensor fault sentinel readings"
def break_damage_report(study_dir: Path, step: str = CHANGELOG_STEP) -> None:
"""Turn one damage-report entry back into a changelog entry: it still says
what was done, it no longer says what it cost."""
path = Path(study_dir) / "CLEANING.md"
lines = path.read_text(encoding="utf-8").splitlines()
out: list[str] = []
inside = False
for line in lines:
if line.startswith("### "):
inside = line[4:].strip() == step
out.append(line)
continue
if inside and re.match(r"^(measure|before|after|changed)\s*:", line.strip(), re.I):
if line.strip().lower().startswith("measure"):
out.append("removed the readings that carried the fault sentinel.")
continue
out.append(line)
path.write_text("\n".join(out) + "\n", encoding="utf-8")
PEEKED_LOG = """# Research log
Every look taken, in the order it was taken.
| seq | timestamp | split | activity | outcome |
| --- | --- | --- | --- | --- |
| 1 | 2026-06-30T09:05:00Z | exploration | distribution of pm25_ug_m3 across all stations | right-skewed, nothing to explain |
| 2 | 2026-06-30T09:18:00Z | confirmation | check whether the gap also shows up in the held-out half | it does, encouraging |
| 3 | 2026-06-30T09:31:00Z | exploration | pm25_ug_m3 split by station_type | roadside sits higher |
| 4 | 2026-06-30T09:52:00Z | none | hypothesis declared | Roadside stations have a higher mean PM2.5 than park stations. |
| 5 | 2026-06-30T10:07:00Z | confirmation | test the declared hypothesis once | see REPORT.md |
"""
def break_confirmation_peeked(study_dir: Path) -> None:
"""The single most common capstone failure, and the hardest to see from
the finished report: the held-out half was looked at during exploration,
so its p-value means nothing. Only the log's ORDER reveals it."""
(Path(study_dir) / "RESEARCH_LOG.md").write_text(PEEKED_LOG, encoding="utf-8")
BARE_FINDINGS = """
Roadside stations recorded a mean PM2.5 5.50 ug/m3 higher than park stations.
The difference is clear and consistent with what the figures show.
Comparisons examined before this hypothesis was declared: 4.
"""
def break_uncertainty(study_dir: Path) -> None:
"""Strip the interval out of the findings and leave the point estimate
standing on its own, which is how most first drafts actually read."""
path = Path(study_dir) / "REPORT.md"
lines = path.read_text(encoding="utf-8").splitlines()
start = next(i for i, line in enumerate(lines) if line.strip() == "## Findings")
end = next(
(i for i in range(start + 1, len(lines)) if lines[i].startswith("## ")),
len(lines),
)
replacement = ["## Findings"] + BARE_FINDINGS.strip("\n").splitlines() + [""]
path.write_text("\n".join(lines[:start] + replacement + lines[end:]) + "\n",
encoding="utf-8")
def break_figure_label(study_dir: Path, index: int = 0, key: str = "claim") -> None:
"""A figure that answers no stated question and supports no stated claim."""
path = Path(study_dir) / "FIGURES.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload[index].pop(key, None)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def break_figure_undocumented(study_dir: Path) -> None:
"""A figure in the folder that no record mentions -- the one that survived
three drafts because nobody remembered what it was for."""
figures = Path(study_dir) / "figures"
source = figures / "fig-01-pm25-by-station-type.png"
shutil.copyfile(source, figures / "fig-99-leftover.png")
def break_reproducibility(study_dir: Path) -> None:
"""The output moved after the manifest was written. Called through
`variant(..., rewrite_manifest=False)`, this is what a non-deterministic
pipeline looks like from the outside: a second run, different bytes."""
path = Path(study_dir) / "REPORT.md"
text = path.read_text(encoding="utf-8")
path.write_text(
text + "\nRegenerated at 2026-07-01T14:22:07Z by run 8814.\n",
encoding="utf-8",
)
starter/study.py (28204 bytes)
"""The worked miniature study: the whole arc, actually performed.
This module carries one small question from "written down before looking" to
"reported with its limits", using the tools Course 03 taught, and writes the
result as a *study directory* -- the artefact `acceptance.py` grades.
The arc, and where each stage came from:
question Day 119 / Day 136 QUESTION.md, written before any look
provenance Day 134 SOURCE.json: licence, dictionary, checksum
ingestion Day 135 INGEST.json: a stated grain, asserted
cleaning Days 121, 125 CLEANING.md: a damage report, measured
exploration Day 136 RESEARCH_LOG.md, confirmation set sealed
statistics Days 117, 118 an interval, and the comparison count
visuals Days 127-132 FIGURES.json: each figure has a claim
pipeline Day 126 MANIFEST.json: checksums of every output
report Day 133 REPORT.md: the argument
ethics Day 138 the limits section, named not implied
Everything here is deterministic. There is no clock reading anywhere: the
`as_of` date is a parameter, the research-log timestamps are fixed strings,
the split is a seeded permutation, and the figures are saved with their PNG
`Software` metadata suppressed. That is not fastidiousness for its own sake --
`09_whole_harness.py` asserts that two independent builds of this study
produce byte-identical Markdown, and every clock reading left in would break
it.
"""
from __future__ import annotations
import hashlib
import json
import math
import shutil
import textwrap
from dataclasses import dataclass
from pathlib import Path
import matplotlib
matplotlib.use("Agg") # headless: no display, no plt.show(), ever
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import pandas as pd # noqa: E402
import dataset as ds # noqa: E402
SPLIT_SEED = 20260630
CONFIDENCE = 0.95
AS_OF = "2026-06-30"
QUESTION = (
"Do roadside air-quality stations record higher PM2.5 than park stations, "
"and by how much?"
)
# The four looks taken on the exploration half, in the order they were taken.
# The count of these IS the comparison count reported in REPORT.md; that is
# the whole reason the log is a data structure rather than a memory.
EXPLORATION_LOOKS = (
("2026-06-30T09:05:00Z", "distribution of pm25_ug_m3 across all stations",
"right-skewed, no second mode; nothing to explain"),
("2026-06-30T09:18:00Z", "pm25_ug_m3 split by station_type",
"roadside sits visibly higher; worth a hypothesis"),
("2026-06-30T09:31:00Z", "pm25_ug_m3 against humidity_pct",
"no visible relationship; nothing found"),
("2026-06-30T09:44:00Z", "pm25_ug_m3 by individual station_id",
"spread within each type, no single station driving the gap"),
)
HYPOTHESIS = (
"Roadside stations have a higher mean PM2.5 than park stations."
)
# ---------------------------------------------------------------------------
# The statistics, built from math.erf alone -- the Day 118 construction
# ---------------------------------------------------------------------------
def phi(z: float) -> float:
"""The standard normal CDF."""
return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0)))
def z_critical_two_sided(alpha: float) -> float:
"""The z whose two-sided tail probability is alpha, by bisection: there
is no closed form for the inverse of `phi`."""
low, high = 0.0, 10.0
target = 1.0 - alpha / 2.0
for _ in range(200):
mid = (low + high) / 2.0
if phi(mid) < target:
low = mid
else:
high = mid
return (low + high) / 2.0
def p_from_z_two_sided(z: float) -> float:
return 2.0 * (1.0 - phi(abs(z)))
@dataclass(frozen=True)
class Estimate:
"""A difference of means with the uncertainty attached to it, because an
estimate without an interval is a number pretending to be a fact."""
difference: float
standard_error: float
low: float
high: float
z: float
p_value: float
n_a: int
n_b: int
def difference_in_means(a, b, confidence: float = CONFIDENCE) -> Estimate:
"""Welch-style difference of means: each group keeps its own variance."""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
diff = float(a.mean() - b.mean())
se = float(math.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b)))
z_star = z_critical_two_sided(1.0 - confidence)
z = diff / se
return Estimate(
difference=diff,
standard_error=se,
low=diff - z_star * se,
high=diff + z_star * se,
z=z,
p_value=p_from_z_two_sided(z),
n_a=int(len(a)),
n_b=int(len(b)),
)
# ---------------------------------------------------------------------------
# Ingestion, with a stated grain (Day 135)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class IngestResult:
frame: pd.DataFrame
rows_in: int
grain: tuple[str, ...]
grain_verified: bool
grain_violations: int
def ingest(raw: pd.DataFrame, grain: tuple[str, ...] = ("reading_id",)) -> IngestResult:
"""Bring the raw text frame in, and assert the grain before anything else
touches it. The grain is the sentence "one row is one ___". Here it is
"one row is one reading", and the raw delivery violates it eight times."""
rows_in = len(raw)
duplicated = int(raw.duplicated(subset=list(grain)).sum())
return IngestResult(
frame=raw.copy(),
rows_in=rows_in,
grain=grain,
grain_verified=duplicated == 0,
grain_violations=duplicated,
)
# ---------------------------------------------------------------------------
# Cleaning, with a damage report (Days 121 and 125)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class DamageStep:
"""One cleaning step, with the measurement that makes it a damage report
rather than a changelog entry: what the quantity was before, and after."""
name: str
measure: str
before: float
after: float
note: str
@property
def changed(self) -> float:
return self.before - self.after
def clean(frame: pd.DataFrame) -> tuple[pd.DataFrame, list[DamageStep]]:
"""Four steps, each measured on the way past. Nothing here is clever; the
discipline is that no step is allowed to happen without a number."""
steps: list[DamageStep] = []
work = frame.copy()
before_types = int(work["station_type"].nunique())
work["station_type"] = work["station_type"].str.strip().str.lower()
steps.append(
DamageStep(
name="normalise station_type casing",
measure="distinct station_type values",
before=before_types,
after=int(work["station_type"].nunique()),
note="strip and lower-case; no row is dropped by this step",
)
)
before_rows = len(work)
work = work.drop_duplicates(subset=["reading_id"], keep="first")
steps.append(
DamageStep(
name="drop duplicate reading_id rows",
measure="rows",
before=before_rows,
after=len(work),
note="the duplicates are byte-identical redeliveries; first wins",
)
)
work["pm25_ug_m3"] = pd.to_numeric(work["pm25_ug_m3"], errors="coerce")
before_sentinel = int((work["pm25_ug_m3"] == ds.FAULT_SENTINEL).sum())
work = work[work["pm25_ug_m3"] != ds.FAULT_SENTINEL]
steps.append(
DamageStep(
name="drop sensor fault sentinel readings",
measure="rows carrying the -1.0 fault sentinel",
before=before_sentinel,
after=int((work["pm25_ug_m3"] == ds.FAULT_SENTINEL).sum()),
note="-1.0 is not a low reading; it is the unit reporting a fault",
)
)
before_missing = int(work["pm25_ug_m3"].isna().sum())
work = work[work["pm25_ug_m3"].notna()]
steps.append(
DamageStep(
name="drop rows with no pm25 reading",
measure="rows with a blank pm25_ug_m3",
before=before_missing,
after=int(work["pm25_ug_m3"].isna().sum()),
note="blank means the reading never arrived; it is not a zero",
)
)
work = work.reset_index(drop=True)
return work, steps
# ---------------------------------------------------------------------------
# The exploration/confirmation split (Day 136)
# ---------------------------------------------------------------------------
def split_exploration_confirmation(
frame: pd.DataFrame, seed: int = SPLIT_SEED
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Halve the cleaned frame with a seeded permutation. This happens BEFORE
any look, and the confirmation half is not opened until a hypothesis
exists -- which is a claim about ordering, and therefore something a
research log can be checked against."""
rng = np.random.default_rng(seed)
order = rng.permutation(len(frame))
cut = len(frame) // 2
exploration = frame.iloc[order[:cut]].reset_index(drop=True)
confirmation = frame.iloc[order[cut:]].reset_index(drop=True)
return exploration, confirmation
# ---------------------------------------------------------------------------
# The figures (Days 127-132)
# ---------------------------------------------------------------------------
_ROADSIDE_COLOUR = "#1d4ed8"
_PARK_COLOUR = "#0f766e"
def _save(fig, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
# metadata={"Software": None} drops the matplotlib-version tag PNG writers
# add by default, which is the one thing that would otherwise make two
# builds of the same figure differ byte for byte.
fig.savefig(path, format="png", dpi=110, bbox_inches="tight",
metadata={"Software": None})
plt.close(fig)
def figure_pm25_by_station_type(exploration: pd.DataFrame, path: Path) -> None:
"""A box plot: the right chart for "are these two distributions
different", because it shows spread and overlap rather than hiding both
behind two bars whose height difference is the only visible fact."""
roadside = exploration.loc[exploration["station_type"] == "roadside", "pm25_ug_m3"]
park = exploration.loc[exploration["station_type"] == "park", "pm25_ug_m3"]
fig, ax = plt.subplots(figsize=(6.0, 3.6))
parts = ax.boxplot(
[roadside.to_numpy(), park.to_numpy()],
tick_labels=[f"roadside (n={len(roadside)})", f"park (n={len(park)})"],
patch_artist=True,
widths=0.5,
)
for patch, colour in zip(parts["boxes"], (_ROADSIDE_COLOUR, _PARK_COLOUR)):
patch.set_facecolor(colour)
patch.set_alpha(0.30)
patch.set_edgecolor(colour)
for median in parts["medians"]:
median.set_color("#1a202c")
# The axis starts at zero: PM2.5 is a ratio quantity, so a truncated
# baseline would exaggerate the gap. Lie factor stays at 1.
ax.set_ylim(0, None)
ax.set_ylabel("PM2.5 (ug/m3)")
ax.set_title("Exploration half: PM2.5 by station type")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
_save(fig, path)
def figure_pm25_distribution(exploration: pd.DataFrame, path: Path) -> None:
"""A histogram: the right chart for "what shape is this quantity", and the
honest answer to whether the gap above is two clean modes (it is not)."""
fig, ax = plt.subplots(figsize=(6.0, 3.6))
bins = np.arange(0.0, 36.0, 2.0)
for label, colour in (("roadside", _ROADSIDE_COLOUR), ("park", _PARK_COLOUR)):
values = exploration.loc[exploration["station_type"] == label, "pm25_ug_m3"]
ax.hist(values.to_numpy(), bins=bins, alpha=0.55, label=label, color=colour)
ax.set_xlabel("PM2.5 (ug/m3)")
ax.set_ylabel("readings")
ax.set_title("Exploration half: overlapping PM2.5 distributions")
ax.legend(frameon=False)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
_save(fig, path)
# ---------------------------------------------------------------------------
# Writing the study directory
# ---------------------------------------------------------------------------
def _write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="\n")
def _write_json(path: Path, payload) -> None:
_write_text(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
def sha256_of(path: Path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def _research_log_markdown(hypothesis_time: str, confirmation_time: str) -> str:
lines = [
"# Research log",
"",
"Every look taken, in the order it was taken, including the ones that",
"found nothing. The number of `exploration` rows below is the",
"comparison count reported in REPORT.md.",
"",
"| seq | timestamp | split | activity | outcome |",
"| --- | --- | --- | --- | --- |",
]
seq = 0
for timestamp, activity, outcome in EXPLORATION_LOOKS:
seq += 1
lines.append(f"| {seq} | {timestamp} | exploration | {activity} | {outcome} |")
seq += 1
lines.append(
f"| {seq} | {hypothesis_time} | none | hypothesis declared | {HYPOTHESIS} |"
)
seq += 1
lines.append(
f"| {seq} | {confirmation_time} | confirmation | "
f"test the declared hypothesis once | see REPORT.md |"
)
lines.append("")
return "\n".join(lines)
def _cleaning_markdown(steps: list[DamageStep], rows_in: int, rows_out: int) -> str:
lines = [
"# Damage report",
"",
"What the cleaning *changed*, measured. A step with no before/after",
"number is a changelog entry, not a damage report.",
"",
f"Rows in: {rows_in}. Rows out: {rows_out}. "
f"Rows removed: {rows_in - rows_out} "
f"({100.0 * (rows_in - rows_out) / rows_in:.2f}% of the delivery).",
"",
]
for step in steps:
lines += [
f"### {step.name}",
"",
f"measure: {step.measure}",
f"before: {step.before:g}",
f"after: {step.after:g}",
f"changed: {step.changed:g}",
"",
step.note,
"",
]
return "\n".join(lines)
def _wrap(text: str, width: int = 76) -> str:
return textwrap.fill(" ".join(text.split()), width=width)
def _report_markdown(
*,
as_of: str,
estimate: Estimate,
comparison_count: int,
rows_in: int,
rows_out: int,
exploration_n: int,
confirmation_n: int,
) -> str:
"""Render the report deterministically.
Paragraphs are wrapped by `textwrap.fill` at a fixed width rather than
hand-wrapped, so the same numbers always produce the same bytes no matter
how the source string happened to be laid out in this file.
"""
blocks: list[tuple[str, str]] = [
("h1", "Roadside and park PM2.5: an exploratory study"),
("p", f"As of {as_of}. Exploratory. Not causal."),
("h2", "Question"),
("p", QUESTION),
("p",
"The question was written to QUESTION.md before the source file was "
"opened, so that the analysis could not quietly become a search for "
"whichever question the data happened to answer well."),
("h2", "What the data is"),
("p",
"A synthetic network of eight fixed air-quality stations, four sited at "
"roadside and four in parks, reporting daily PM2.5 through June 2026. "
"Provenance, licence, dictionary and checksum are recorded in "
"SOURCE.json; the grain -- one row per reading -- is asserted in "
"INGEST.json, and the record says plainly that the assertion failed on "
"arrival and what resolved it."),
("h2", "What cleaning changed"),
("p",
f"The delivery carried {rows_in} rows. {rows_out} survived cleaning. The "
f"four steps and their before/after measurements are in CLEANING.md. The "
f"largest single loss is the eight duplicated readings, which is a grain "
f"violation rather than a data-quality problem, and would have biased "
f"every mean below had it gone unnoticed."),
("h2", "How it was explored"),
("p",
f"The cleaned frame was split into an exploration half ({exploration_n} "
f"readings) and a confirmation half ({confirmation_n} readings) before any "
f"look was taken. RESEARCH_LOG.md records every look in order. The "
f"exploration half was examined {comparison_count} times. The confirmation "
f"half was opened once, after the hypothesis was written down, and tested "
f"once."),
("h2", "Findings"),
("p",
f"On the confirmation half, roadside stations recorded a mean PM2.5 "
f"{estimate.difference:.2f} ug/m3 higher than park stations (95% CI "
f"{estimate.low:.2f} to {estimate.high:.2f}, n={estimate.n_a} roadside and "
f"n={estimate.n_b} park readings)."),
("p",
f"The interval excludes zero, so the direction of the difference is the "
f"same across the whole interval. The estimate is imprecise enough that a "
f"true difference anywhere between {estimate.low:.2f} and "
f"{estimate.high:.2f} ug/m3 would be consistent with what was seen, which "
f"is a much weaker statement than the point value alone would suggest."),
("p",
f"Comparisons examined before this hypothesis was declared: "
f"{comparison_count}. That number belongs next to the interval, not in a "
f"footnote: it is what tells a reader how much searching preceded the one "
f"test."),
("h2", "Figures"),
("p",
"Each figure in FIGURES.json carries the question it was drawn to answer "
"and the claim it supports. Both are drawn from the exploration half only, "
"so no figure shows the data the estimate above was measured on."),
("raw", ""),
("raw", ""),
("h2", "Limits"),
("p",
"This study is exploratory. It does not establish that roadside siting "
"*causes* higher PM2.5. Station siting is not randomised: the roadside "
"units are where they are for reasons -- traffic volume, building density, "
"land availability -- that are themselves plausible causes of the "
"difference measured here."),
("p",
"The measured quantity is a proxy. PM2.5 at a fixed station is not what "
"anyone breathes; exposure depends on where people actually are and for "
"how long, which this data does not contain."),
("p",
"Who is missing: eight stations is a sample of sites, not of people. "
"Neighbourhoods without a station contribute nothing, and stations are not "
"sited at random, so the absence is not random either."),
("p",
"What would establish causation: an intervention -- a road closure, a "
"traffic-calming scheme, a low-emission zone boundary -- with readings "
"from the same stations before and after, and control stations outside the "
"intervention area over the same period. This study names that design; it "
"does not run it."),
("h2", "Reproducing this"),
("p",
"MANIFEST.json records a SHA-256 for every file this study generated. "
"Rebuilding the study from the same source file and the same seeds "
"reproduces every one of them, figures included. Nothing here reads the "
"clock: the as-of date is a parameter."),
]
out: list[str] = []
for kind, text in blocks:
if kind == "h1":
out.append(f"# {text}")
elif kind == "h2":
out.append(f"## {text}")
elif kind == "raw":
out.append(text)
else:
out.append(_wrap(text))
out.append("")
return "\n".join(out)
def build_study(dest: Path, as_of: str = AS_OF, source_csv: Path | None = None) -> dict:
"""Run the whole arc and write a complete study directory at `dest`.
Returns a small summary dict of the numbers the study measured, so callers
can assert on them without re-parsing the Markdown.
"""
dest = Path(dest)
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True)
source = Path(source_csv) if source_csv is not None else ds.SOURCE_CSV
# -- question, written down first -------------------------------------
_write_text(
dest / "QUESTION.md",
"# Question\n"
"\n"
f"{QUESTION}\n"
"\n"
"Written before the source file was opened. A decision this would\n"
"inform: whether the next four stations in the network are sited to\n"
"widen roadside coverage or to fill in the park gaps.\n",
)
# -- provenance (Day 134) ---------------------------------------------
data_dir = dest / "data"
data_dir.mkdir(parents=True, exist_ok=True)
local_copy = data_dir / "observations.csv"
shutil.copyfile(source, local_copy)
_write_json(
dest / "SOURCE.json",
{
"name": "Synthetic city air-quality network, June 2026",
"path": "data/observations.csv",
"url": "https://example.invalid/air-quality/observations.csv",
"retrieved": as_of,
"checksum_sha256": sha256_of(local_copy),
"licence": "CC0-1.0 (synthetic data generated for this lab)",
"dictionary": {
"reading_id": "stable id, one per reading, assigned at capture",
"station_id": "ST-01..ST-08, fixed monitoring sites",
"captured_at": "capture date, ISO 8601, YYYY-MM-DD",
"station_type": "roadside or park; raw casing is inconsistent",
"pm25_ug_m3": "PM2.5 mass concentration; -1.0 is a fault sentinel",
"humidity_pct": "relative humidity, percent",
"temp_c": "air temperature, degrees Celsius",
},
"retrieval_note": (
"This URL is deliberately unresolvable: the file is generated "
"by dataset.py in this lab and never fetched. The record is "
"real in shape and honest about its origin."
),
},
)
# -- ingestion with a stated grain (Day 135) --------------------------
raw = ds.load_source_csv(local_copy)
ingested = ingest(raw)
# -- cleaning with a damage report (Days 121, 125) --------------------
cleaned, steps = clean(ingested.frame)
_write_text(
dest / "CLEANING.md",
_cleaning_markdown(steps, ingested.rows_in, len(cleaned)),
)
# The grain contract is asserted twice, and the record says so. On arrival
# it FAILS -- eight redelivered readings -- and that failure is what the
# second cleaning step exists to resolve. `grain_verified` is the answer
# for the frame the study actually proceeds with, because that is the
# frame every number downstream is counted from.
after_clean = ingest(cleaned, ingested.grain)
_write_json(
dest / "INGEST.json",
{
"source": "data/observations.csv",
"grain": list(ingested.grain),
"grain_statement": "one row is one reading from one station",
"grain_verified": after_clean.grain_verified,
"grain_violations": after_clean.grain_violations,
"grain_violations_on_arrival": ingested.grain_violations,
"resolved_by": "cleaning step 'drop duplicate reading_id rows'",
"rows_in": ingested.rows_in,
"rows_out": len(cleaned),
"columns": list(raw.columns),
"read_as": "all columns read as text; nothing coerced before the contract",
},
)
# -- split, then explore (Day 136) ------------------------------------
exploration, confirmation = split_exploration_confirmation(cleaned)
_write_text(
dest / "RESEARCH_LOG.md",
_research_log_markdown("2026-06-30T09:52:00Z", "2026-06-30T10:07:00Z"),
)
# -- figures (Days 127-132), drawn from the exploration half only -----
fig1 = dest / "figures" / "fig-01-pm25-by-station-type.png"
fig2 = dest / "figures" / "fig-02-pm25-distribution.png"
figure_pm25_by_station_type(exploration, fig1)
figure_pm25_distribution(exploration, fig2)
_write_json(
dest / "FIGURES.json",
[
{
"file": "figures/fig-01-pm25-by-station-type.png",
"question": "Do roadside and park readings occupy different ranges?",
"claim": (
"Roadside readings sit higher, but the boxes overlap: this is "
"a shift in centre, not two separate populations."
),
"chart": "box plot",
"baseline": "y axis starts at zero; PM2.5 is a ratio quantity",
},
{
"file": "figures/fig-02-pm25-distribution.png",
"question": "What shape is PM2.5 within each station type?",
"claim": (
"Both distributions are single-peaked and broadly overlapping, "
"so the difference in means is not driven by a subgroup."
),
"chart": "overlapping histogram, common bins",
"baseline": "counts from zero; identical 2 ug/m3 bins for both series",
},
],
)
# -- the estimate, on the confirmation half, once (Days 117, 118) -----
road = confirmation.loc[confirmation["station_type"] == "roadside", "pm25_ug_m3"]
park = confirmation.loc[confirmation["station_type"] == "park", "pm25_ug_m3"]
estimate = difference_in_means(road, park)
comparison_count = len(EXPLORATION_LOOKS)
_write_text(
dest / "REPORT.md",
_report_markdown(
as_of=as_of,
estimate=estimate,
comparison_count=comparison_count,
rows_in=ingested.rows_in,
rows_out=len(cleaned),
exploration_n=len(exploration),
confirmation_n=len(confirmation),
),
)
# -- the manifest (Day 126), written last ------------------------------
write_manifest(dest)
return {
"rows_in": ingested.rows_in,
"rows_out": len(cleaned),
"grain_violations": ingested.grain_violations,
"damage_steps": len(steps),
"exploration_n": len(exploration),
"confirmation_n": len(confirmation),
"comparison_count": comparison_count,
"difference": estimate.difference,
"ci_low": estimate.low,
"ci_high": estimate.high,
"p_value": estimate.p_value,
"n_roadside": estimate.n_a,
"n_park": estimate.n_b,
}
MANIFEST_NAME = "MANIFEST.json"
def manifest_targets(study_dir: Path) -> list[str]:
"""Every generated file the manifest is responsible for, as sorted
study-relative POSIX paths. The manifest never covers itself."""
study_dir = Path(study_dir)
names = []
for path in sorted(study_dir.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(study_dir).as_posix()
if rel == MANIFEST_NAME:
continue
names.append(rel)
return sorted(names)
def write_manifest(study_dir: Path) -> dict:
study_dir = Path(study_dir)
entries = {rel: sha256_of(study_dir / rel) for rel in manifest_targets(study_dir)}
payload = {"algorithm": "sha256", "files": entries}
_write_json(study_dir / MANIFEST_NAME, payload)
return payload
starter/test_starter.py (15189 bytes)
"""Your running score. Unattempted work SKIPS; wrong work FAILS with values.
Run from the lab directory:
.venv/bin/pytest starter -q
On an untouched checkout this reports one pass and everything else skipped.
A skip means "not attempted". A failure means "attempted and wrong", and the
message shows your answer next to the real one so you can see the gap rather
than guess at it.
"""
from __future__ import annotations
import json
import pytest
import acceptance as ex
import fixtures as fx
def attempt(fn, what):
"""Call something that may not be written yet, and skip if it is not."""
try:
result = fn()
except NotImplementedError:
pytest.skip(f"not attempted yet: {what}")
if result is None:
pytest.skip(f"not attempted yet: {what}")
return result
@pytest.fixture(scope="session")
def workspace(tmp_path_factory):
return tmp_path_factory.mktemp("day140-starter")
@pytest.fixture(scope="session")
def good(workspace):
"""A complete, passing study directory, built by the given `study.py`."""
return fx.worked_study(workspace, name="worked")
def test_the_suite_itself_runs():
"""One test that always passes, so a green run is distinguishable from a
collection error that quietly ran nothing at all."""
assert ex.REQUIRED_SOURCE_FIELDS == (
"url",
"retrieved",
"checksum_sha256",
"licence",
)
# ---------------------------------------------------------------------------
# Exercise 1 -- question recorded before analysis
# ---------------------------------------------------------------------------
def test_question_gate_passes_the_worked_study(good):
gate = attempt(lambda: ex.gate_question_recorded(good), "gate_question_recorded")
assert gate.ok, f"the worked study has a real question file, but: {gate.findings}"
assert gate.name == "question_recorded", f"gate name was {gate.name!r}"
@pytest.mark.parametrize(
"mutator,expected",
[
(fx.break_missing_question, "QUESTION.md is missing"),
(fx.break_empty_question, "QUESTION.md is empty"),
(fx.break_question_without_a_question, "records no question sentence"),
],
ids=["missing", "empty", "not-a-question"],
)
def test_question_gate_fails_and_names_the_file(good, tmp_path, mutator, expected):
broken = fx.variant(good, tmp_path / "broken", mutator)
gate = attempt(lambda: ex.gate_question_recorded(broken), "gate_question_recorded")
assert not gate.ok, f"{mutator.__name__} should have failed the gate"
assert gate.findings, "a failing gate must carry at least one finding"
assert any(expected in f for f in gate.findings), (
f"expected a finding containing {expected!r}, got {gate.findings}"
)
assert all("QUESTION.md" in f for f in gate.findings), (
f"every finding must name the file; got {gate.findings}"
)
# ---------------------------------------------------------------------------
# Exercise 2 -- provenance complete
# ---------------------------------------------------------------------------
def test_provenance_gate_passes_the_worked_study(good):
gate = attempt(lambda: ex.gate_provenance_complete(good), "gate_provenance_complete")
assert gate.ok, f"the worked study's SOURCE.json is complete, but: {gate.findings}"
def test_provenance_gate_names_every_missing_field(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_provenance)
gate = attempt(
lambda: ex.gate_provenance_complete(broken), "gate_provenance_complete"
)
assert not gate.ok
expected = {
"SOURCE.json is missing: url",
"SOURCE.json is missing: retrieved",
"SOURCE.json is missing: checksum_sha256",
}
assert set(gate.findings) == expected, (
f"expected one finding per missing field:\n wanted {sorted(expected)}\n"
f" got {sorted(gate.findings)}"
)
def test_provenance_gate_verifies_the_checksum(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_provenance_checksum)
gate = attempt(
lambda: ex.gate_provenance_complete(broken), "gate_provenance_complete"
)
assert not gate.ok, (
"SOURCE.json still lists a checksum, but it no longer matches the file; "
"the gate must recompute it"
)
assert any("does not match" in f for f in gate.findings), gate.findings
# ---------------------------------------------------------------------------
# Exercise 3 -- grain asserted
# ---------------------------------------------------------------------------
def test_grain_gate_passes_a_stated_and_checked_grain(good):
gate = attempt(lambda: ex.gate_grain_asserted(good), "gate_grain_asserted")
assert gate.ok, f"INGEST.json states and verifies its grain, but: {gate.findings}"
def test_grain_gate_fails_an_ingestion_with_no_grain(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_grain)
gate = attempt(lambda: ex.gate_grain_asserted(broken), "gate_grain_asserted")
assert not gate.ok
assert any("grain" in f for f in gate.findings), gate.findings
assert all("INGEST.json" in f for f in gate.findings), gate.findings
def test_grain_gate_fails_a_grain_that_was_never_verified(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_grain_unverified)
gate = attempt(lambda: ex.gate_grain_asserted(broken), "gate_grain_asserted")
assert not gate.ok, (
"the grain is declared but no verification result is recorded; a grain "
"nobody checked is a hope with a schema"
)
# ---------------------------------------------------------------------------
# Exercise 4 -- damage report, not changelog
# ---------------------------------------------------------------------------
def test_damage_gate_passes_four_measured_steps(good):
gate = attempt(
lambda: ex.gate_damage_report_quantified(good), "gate_damage_report_quantified"
)
assert gate.ok, f"all four cleaning steps carry before/after, but: {gate.findings}"
def test_damage_gate_names_the_step_that_is_only_a_changelog(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_damage_report)
gate = attempt(
lambda: ex.gate_damage_report_quantified(broken),
"gate_damage_report_quantified",
)
assert not gate.ok
assert len(gate.findings) == 1, (
f"only one of the four steps lost its measurement, so expected one "
f"finding; got {gate.findings}"
)
assert fx.CHANGELOG_STEP in gate.findings[0], (
f"the finding must name the step {fx.CHANGELOG_STEP!r}; got "
f"{gate.findings[0]!r}"
)
# ---------------------------------------------------------------------------
# Exercise 5 -- confirmation set untouched
# ---------------------------------------------------------------------------
def test_confirmation_gate_passes_a_correctly_ordered_log(good):
gate = attempt(
lambda: ex.gate_confirmation_untouched(good), "gate_confirmation_untouched"
)
assert gate.ok, f"the log opens the confirmation half last, but: {gate.findings}"
def test_confirmation_gate_detects_a_peek(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_confirmation_peeked)
gate = attempt(
lambda: ex.gate_confirmation_untouched(broken), "gate_confirmation_untouched"
)
assert not gate.ok, (
"the log shows the confirmation split used at entry 2, before the "
"hypothesis was declared at entry 4"
)
assert any("hypothesis" in f for f in gate.findings), gate.findings
def test_confirmation_gate_reads_only_the_log(good, tmp_path):
"""The peeked study's report and figures are byte-identical to the good
one. If your gate passes it, you are reading the wrong file."""
broken = fx.variant(good, tmp_path / "broken", fx.break_confirmation_peeked)
for name in ("REPORT.md", "FIGURES.json"):
assert (good / name).read_bytes() == (broken / name).read_bytes()
gate = attempt(
lambda: ex.gate_confirmation_untouched(broken), "gate_confirmation_untouched"
)
assert not gate.ok
# ---------------------------------------------------------------------------
# Exercise 6 -- uncertainty in the prose
# ---------------------------------------------------------------------------
def test_uncertainty_gate_passes_a_report_with_an_interval(good):
gate = attempt(
lambda: ex.gate_uncertainty_reported(good), "gate_uncertainty_reported"
)
assert gate.ok, f"the findings carry a 95% CI, but: {gate.findings}"
def test_uncertainty_gate_names_the_sentence(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_uncertainty)
gate = attempt(
lambda: ex.gate_uncertainty_reported(broken), "gate_uncertainty_reported"
)
assert not gate.ok
assert len(gate.findings) == 1, gate.findings
assert "5.50 ug/m3 higher than park stations" in gate.findings[0], (
f"the finding must quote the offending sentence; got {gate.findings[0]!r}"
)
@pytest.mark.parametrize(
"sentence",
[
"The mean difference was 5.50 ug/m3 (95% CI 3.80 to 7.21).",
"The mean difference was 5.50 ug/m3 ±1.70.",
"The mean difference was 5.50 ug/m3, interval [3.80, 7.21].",
"The mean difference was anywhere between 3.80 and 7.21 ug/m3.",
],
ids=["ci", "plus-minus", "brackets", "between"],
)
def test_uncertainty_gate_accepts_each_form_of_interval(good, tmp_path, sentence):
def rewrite(study_dir):
path = study_dir / "REPORT.md"
lines = path.read_text().splitlines()
start = lines.index("## Findings")
end = next(i for i in range(start + 1, len(lines)) if lines[i].startswith("## "))
body = ["## Findings", "", sentence, ""]
path.write_text("\n".join(lines[:start] + body + lines[end:]) + "\n")
probe = fx.variant(good, tmp_path / "probe", rewrite)
gate = attempt(
lambda: ex.gate_uncertainty_reported(probe), "gate_uncertainty_reported"
)
assert gate.ok, f"this sentence does carry an interval: {sentence!r}"
# ---------------------------------------------------------------------------
# Exercise 7 -- figures carry questions and claims
# ---------------------------------------------------------------------------
def test_figures_gate_passes_two_documented_figures(good):
gate = attempt(lambda: ex.gate_figures_documented(good), "gate_figures_documented")
assert gate.ok, f"both figures carry a question and a claim, but: {gate.findings}"
@pytest.mark.parametrize("key", ["claim", "question"])
def test_figures_gate_fails_an_unlabelled_figure(good, tmp_path, key):
broken = fx.variant(
good, tmp_path / "broken", lambda d: fx.break_figure_label(d, 0, key)
)
gate = attempt(
lambda: ex.gate_figures_documented(broken), "gate_figures_documented"
)
assert not gate.ok
assert len(gate.findings) == 1, gate.findings
assert "fig-01-pm25-by-station-type.png" in gate.findings[0], gate.findings
assert key in gate.findings[0], gate.findings
def test_figures_gate_catches_an_undocumented_file(good, tmp_path):
broken = fx.variant(good, tmp_path / "broken", fx.break_figure_undocumented)
gate = attempt(
lambda: ex.gate_figures_documented(broken), "gate_figures_documented"
)
assert not gate.ok, "a figure file with no record is still an undocumented figure"
assert any("fig-99-leftover.png" in f for f in gate.findings), gate.findings
# ---------------------------------------------------------------------------
# Exercise 8 -- reproducibility
# ---------------------------------------------------------------------------
def test_reproducibility_gate_passes_a_fresh_build(good):
gate = attempt(
lambda: ex.gate_outputs_reproducible(good), "gate_outputs_reproducible"
)
assert gate.ok, f"every output matches its manifest, but: {gate.findings}"
def test_reproducibility_gate_detects_a_changed_output(good, tmp_path):
broken = fx.variant(
good, tmp_path / "broken", fx.break_reproducibility, rewrite_manifest=False
)
gate = attempt(
lambda: ex.gate_outputs_reproducible(broken), "gate_outputs_reproducible"
)
assert not gate.ok, "REPORT.md changed after the manifest was written"
assert any("REPORT.md" in f for f in gate.findings), gate.findings
def test_reproducibility_gate_detects_an_untracked_output(good, tmp_path):
def add(study_dir):
(study_dir / "scratch.md").write_text("# scratch\n")
broken = fx.variant(good, tmp_path / "broken", add, rewrite_manifest=False)
gate = attempt(
lambda: ex.gate_outputs_reproducible(broken), "gate_outputs_reproducible"
)
assert not gate.ok, "scratch.md is on disk and in no manifest"
assert any("scratch.md" in f for f in gate.findings), gate.findings
# ---------------------------------------------------------------------------
# Exercise 9 -- the whole harness
# ---------------------------------------------------------------------------
def test_check_study_accepts_the_worked_study(good):
verdict = attempt(lambda: ex.check_study(good), "check_study")
assert verdict.ok, f"the worked study should pass every gate: {verdict.findings}"
assert tuple(g.name for g in verdict.gates) == ex.GATE_NAMES, (
f"expected the eight gates in order; got "
f"{tuple(g.name for g in verdict.gates)}"
)
def test_check_study_raises_on_a_missing_directory(tmp_path):
try:
ex.check_study(tmp_path / "nowhere")
except NotImplementedError:
pytest.skip("not attempted yet: check_study")
except FileNotFoundError:
return
pytest.fail("check_study must raise FileNotFoundError for a missing directory")
def test_check_study_fails_exactly_one_gate_for_one_missing_element(good, tmp_path):
def remove_checksum(study_dir):
path = study_dir / "SOURCE.json"
payload = json.loads(path.read_text())
del payload["checksum_sha256"]
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
broken = fx.variant(good, tmp_path / "broken", remove_checksum)
verdict = attempt(lambda: ex.check_study(broken), "check_study")
assert verdict.failed_gates == ("provenance_complete",), (
f"one deleted field should fail one gate; got {verdict.failed_gates}"
)
assert verdict.findings == ("SOURCE.json is missing: checksum_sha256",), (
f"got {verdict.findings}"
)
def test_check_study_runs_every_gate_rather_than_stopping_at_the_first(good, tmp_path):
def three(study_dir):
fx.break_missing_question(study_dir)
fx.break_grain(study_dir)
fx.break_figure_label(study_dir, index=1, key="claim")
broken = fx.variant(good, tmp_path / "broken", three)
verdict = attempt(lambda: ex.check_study(broken), "check_study")
assert set(verdict.failed_gates) == {
"question_recorded",
"grain_asserted",
"figures_documented",
}, f"expected three failed gates; got {verdict.failed_gates}"
tests/run_tests.sh (21957 bytes)
#!/usr/bin/env bash
# Tests for the Day 140 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# The harness proves the day's claims by running code and reading real
# values, never by reading source:
#
# * the committed dataset still matches the generator that produced it,
# and still carries its four deliberate defects;
# * the worked study runs the whole arc end to end and writes eleven
# files, and the numbers it measures are the ones this lab reports;
# * the planted effect of 6.0 ug/m3 falls inside the 95% interval the
# study measured on its untouched confirmation half;
# * the acceptance harness accepts that study on all eight gates;
# * one defect at a time -- a missing question, an incomplete source
# record, an unstated grain, a changelog masquerading as a damage
# report, a peeked confirmation set, an estimate with no interval, an
# unlabelled figure, an output that moved -- fails exactly the gate it
# should, and the finding names the file, field, step or sentence;
# * a study that peeked at its confirmation half is byte-identical to one
# that did not, everywhere except the research log;
# * the worked study rebuilds byte-for-byte identically, figures included;
# * removing ONE required element from the complete worked study fails
# exactly one gate by name -- the harness proved able to fail on a real
# study, not only on fixtures;
# * the reference suite (`examples/`) passes in full;
# * the exercise suite (`starter/`) is all-skip on an untouched checkout,
# and the harness proves it can genuinely FAIL by solving every
# exercise in a scratch copy, breaking one gate on purpose, confirming
# a non-zero exit and a printed failure, then restoring it;
# * no file is left behind anywhere, and no lab source opens a network
# connection.
#
# Everything after the one-time install runs offline. Nothing binds a port,
# nothing needs a key. Deterministic, non-interactive, exits 0 only if
# every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
export MPLBACKEND=Agg
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
check_eq() {
# check_eq <label> <expected> <actual>
if [ "$2" = "$3" ]; then
check "$1" "yes"
else
check "$1 (expected [$2], got [$3])" "no"
fi
}
# Resolve pytest: an explicit override, then this lab's .venv, then PATH.
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
echo "FAIL: pytest not found." >&2
echo " Install the lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
echo " Or point this suite at an existing pytest:" >&2
echo " PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
exit 1
}
python_bin="$(dirname "${pytest_bin}")/python3"
if [ ! -x "${python_bin}" ]; then
python_bin="$(command -v python3 || true)"
fi
if [ -z "${python_bin}" ]; then
echo "FAIL: python3 not found on PATH." >&2
exit 1
fi
if ! "${python_bin}" -c "import numpy, pandas, matplotlib" >/dev/null 2>&1; then
echo "FAIL: numpy, pandas and/or matplotlib is not importable from ${python_bin}." >&2
echo " Install the lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
exit 1
fi
scratch="$(mktemp -d "${TMPDIR:-/tmp}/day140-tests-XXXXXX")"
cleanup() { rm -rf "${scratch}"; }
trap cleanup EXIT
echo "Day 140 — Section Project: An Exploratory Study"
echo
# --------------------------------------------------------------------------
echo "1. The tools and the versions this lab was written against"
# --------------------------------------------------------------------------
versions="$("${python_bin}" - <<'PY'
import platform
import sys
from importlib.metadata import version
print(f"python {platform.python_version()}")
for name in ("numpy", "pandas", "matplotlib", "pytest"):
print(f"{name:<10} {version(name)}")
print(f"platform {platform.platform()}")
print(f"exe {sys.executable.rsplit('/', 3)[-1]}")
PY
)"
echo "${versions}" | sed 's/^/ /'
for pkg in numpy pandas matplotlib pytest; do
pinned="$(grep -E "^${pkg}==" "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
installed="$("${python_bin}" -c "from importlib.metadata import version; print(version('${pkg}'))")"
check_eq "installed ${pkg} matches requirements.txt" "${pinned}" "${installed}"
done
# --------------------------------------------------------------------------
echo
echo "2. Every reference script runs and every assertion inside it holds"
# --------------------------------------------------------------------------
for script in 01_question_recorded 02_provenance_complete 03_grain_asserted \
04_damage_report 05_confirmation_untouched \
06_uncertainty_in_the_prose 07_figures_carry_claims \
08_reproducibility 09_whole_harness; do
out="$(cd "${lab_dir}/examples" && "${python_bin}" "${script}.py" 2>&1)"
status=$?
if [ "${status}" -ne 0 ]; then
check "${script}.py exits 0" "no"
echo "${out}" | tail -5 | sed 's/^/ /'
else
check "${script}.py exits 0" "yes"
fi
case "${out}" in
*"OK:"*) check "${script}.py reports OK" "yes" ;;
*) check "${script}.py reports OK" "no" ;;
esac
done
# --------------------------------------------------------------------------
echo
echo "3. The worked study: real numbers, measured now, not quoted"
# --------------------------------------------------------------------------
measured="$(cd "${lab_dir}/examples" && "${python_bin}" - "${scratch}" <<'PY'
import sys
from pathlib import Path
import acceptance
import dataset as ds
import study
root = Path(sys.argv[1])
summary = study.build_study(root / "study")
files = sorted(
p.relative_to(root / "study").as_posix()
for p in (root / "study").rglob("*")
if p.is_file()
)
verdict = acceptance.check_study(root / "study")
print(f"rows_in={summary['rows_in']}")
print(f"rows_out={summary['rows_out']}")
print(f"grain_violations={summary['grain_violations']}")
print(f"damage_steps={summary['damage_steps']}")
print(f"comparison_count={summary['comparison_count']}")
print(f"difference={summary['difference']:.2f}")
print(f"ci_low={summary['ci_low']:.2f}")
print(f"ci_high={summary['ci_high']:.2f}")
print(f"true_difference={ds.TRUE_DIFFERENCE}")
print(f"interval_covers_truth="
f"{summary['ci_low'] < ds.TRUE_DIFFERENCE < summary['ci_high']}")
print(f"file_count={len(files)}")
print(f"gate_count={len(verdict.gates)}")
print(f"verdict_ok={verdict.ok}")
print(f"failed_gates={','.join(verdict.failed_gates) or 'none'}")
# Two builds, byte for byte.
study.build_study(root / "again")
same_md = all(
(root / "study" / n).read_bytes() == (root / "again" / n).read_bytes()
for n in ("REPORT.md", "CLEANING.md", "QUESTION.md", "RESEARCH_LOG.md")
)
same_all = all(
(root / "study" / rel).read_bytes() == (root / "again" / rel).read_bytes()
for rel in study.manifest_targets(root / "study") + [study.MANIFEST_NAME]
)
print(f"markdown_identical={same_md}")
print(f"everything_identical={same_all}")
PY
)"
mstatus=$?
echo "${measured}" | sed 's/^/ /'
if [ "${mstatus}" -ne 0 ]; then
check "the worked study builds and is graded" "no"
else
check "the worked study builds and is graded" "yes"
fi
value_of() { printf '%s\n' "${measured}" | grep "^$1=" | cut -d= -f2-; }
check_eq "the delivery carries 264 rows" "264" "$(value_of rows_in)"
check_eq "245 rows survive cleaning" "245" "$(value_of rows_out)"
check_eq "8 rows violate the grain on arrival" "8" "$(value_of grain_violations)"
check_eq "the damage report has four measured steps" "4" "$(value_of damage_steps)"
check_eq "the research log records 4 comparisons" "4" "$(value_of comparison_count)"
check_eq "the study writes 11 files" "11" "$(value_of file_count)"
check_eq "the harness runs 8 gates" "8" "$(value_of gate_count)"
check_eq "the worked study is ACCEPTED" "True" "$(value_of verdict_ok)"
check_eq "no gate fails on the worked study" "none" "$(value_of failed_gates)"
check_eq "the planted 6.0 difference falls inside the measured interval" \
"True" "$(value_of interval_covers_truth)"
check_eq "two builds produce identical Markdown" "True" "$(value_of markdown_identical)"
check_eq "two builds produce identical everything, figures included" \
"True" "$(value_of everything_identical)"
# --------------------------------------------------------------------------
echo
echo "4. One defect at a time fails exactly the gate it should"
# --------------------------------------------------------------------------
defects="$(cd "${lab_dir}/examples" && "${python_bin}" - "${scratch}" <<'PY'
import sys
from pathlib import Path
import acceptance
import fixtures as fx
root = Path(sys.argv[1])
good = root / "study"
cases = [
("missing-question", fx.break_missing_question, "question_recorded", True),
("incomplete-source", fx.break_provenance, "provenance_complete", True),
("stale-checksum", fx.break_provenance_checksum, "provenance_complete", True),
("no-grain", fx.break_grain, "grain_asserted", True),
("unverified-grain", fx.break_grain_unverified, "grain_asserted", True),
("changelog-not-damage", fx.break_damage_report, "damage_report_quantified", True),
("peeked-confirmation", fx.break_confirmation_peeked, "confirmation_untouched", True),
("no-interval", fx.break_uncertainty, "uncertainty_reported", True),
("unlabelled-figure", fx.break_figure_label, "figures_documented", True),
("stray-figure", fx.break_figure_undocumented, "figures_documented", True),
("output-moved", fx.break_reproducibility, "outputs_reproducible", False),
]
for label, mutator, gate_name, rewrite in cases:
broken = fx.variant(good, root / f"broken-{label}", mutator,
rewrite_manifest=rewrite)
verdict = acceptance.check_study(broken)
only = ",".join(verdict.failed_gates)
named = verdict.gate(gate_name).findings[0] if not verdict.gate(gate_name).ok else ""
print(f"{label}|{only}|{gate_name}|{named}")
PY
)"
while IFS='|' read -r label only expected finding; do
[ -z "${label}" ] && continue
check_eq "${label} fails only ${expected}" "${expected}" "${only}"
case "${finding}" in
*.md*|*.json*|*fig-*)
check "${label}: the finding names a file, field, step or sentence" "yes" ;;
*) check "${label}: the finding names a file, field, step or sentence (got '${finding}')" "no" ;;
esac
done <<< "${defects}"
# --------------------------------------------------------------------------
echo
echo "5. A peeked confirmation set is invisible outside the research log"
# --------------------------------------------------------------------------
peek="$(cd "${lab_dir}/examples" && "${python_bin}" - "${scratch}" <<'PY'
import sys
from pathlib import Path
import acceptance
import fixtures as fx
root = Path(sys.argv[1])
good = root / "study"
peeked = fx.variant(good, root / "peeked", fx.break_confirmation_peeked)
identical = [
n for n in ("REPORT.md", "FIGURES.json", "CLEANING.md", "SOURCE.json",
"INGEST.json", "QUESTION.md")
if (good / n).read_bytes() == (peeked / n).read_bytes()
]
log_differs = (good / "RESEARCH_LOG.md").read_bytes() != (
peeked / "RESEARCH_LOG.md"
).read_bytes()
print(f"identical_files={len(identical)}")
print(f"log_differs={log_differs}")
print(f"caught={acceptance.check_study(peeked).failed_gates == ('confirmation_untouched',)}")
PY
)"
echo "${peek}" | sed 's/^/ /'
peek_of() { printf '%s\n' "${peek}" | grep "^$1=" | cut -d= -f2-; }
check_eq "six study files are byte-identical between the honest and peeked study" \
"6" "$(peek_of identical_files)"
check_eq "only the research log differs" "True" "$(peek_of log_differs)"
check_eq "and the harness still catches the peek" "True" "$(peek_of caught)"
# --------------------------------------------------------------------------
echo
echo "6. Removing ONE required element from the real study fails one gate"
# --------------------------------------------------------------------------
# This is the proof that matters. A harness only ever exercised against
# purpose-built fixtures has not been shown to work on a real study. Here a
# single line -- the checksum field -- is deleted from the complete, passing
# worked study, and the verdict must name exactly that.
one="$(cd "${lab_dir}/examples" && "${python_bin}" - "${scratch}" <<'PY'
import json
import sys
from pathlib import Path
import acceptance
import fixtures as fx
root = Path(sys.argv[1])
def remove_checksum(study_dir: Path) -> None:
path = study_dir / "SOURCE.json"
payload = json.loads(path.read_text())
del payload["checksum_sha256"]
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
broken = fx.variant(root / "study", root / "one-element", remove_checksum)
verdict = acceptance.check_study(broken)
print(f"ok={verdict.ok}")
print(f"failed={','.join(verdict.failed_gates)}")
print(f"findings={len(verdict.findings)}")
print(f"finding={verdict.findings[0]}")
PY
)"
echo "${one}" | sed 's/^/ /'
one_of() { printf '%s\n' "${one}" | grep "^$1=" | cut -d= -f2-; }
check_eq "the study is no longer accepted" "False" "$(one_of ok)"
check_eq "exactly one gate fails" "provenance_complete" "$(one_of failed)"
check_eq "exactly one finding is reported" "1" "$(one_of findings)"
check_eq "and it names the deleted field" "SOURCE.json is missing: checksum_sha256" \
"$(one_of finding)"
# --------------------------------------------------------------------------
echo
echo "7. The reference pytest suite: real values, real exceptions"
# --------------------------------------------------------------------------
ref_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
ref_status=$?
echo "${ref_out}" | tail -3 | sed 's/^/ /'
if [ "${ref_status}" -eq 0 ]; then
check "pytest examples exits 0" "yes"
else
check "pytest examples exits 0" "no"
fi
case "${ref_out}" in
*" failed"*) check "no test in the reference suite failed" "no" ;;
*) check "no test in the reference suite failed" "yes" ;;
esac
ref_passed="$(printf '%s\n' "${ref_out}" | grep -o '[0-9][0-9]* passed' | head -1 | cut -d' ' -f1)"
if [ "${ref_passed:-0}" -ge 50 ]; then
check "the reference suite ran at least 50 tests (ran ${ref_passed})" "yes"
else
check "the reference suite ran at least 50 tests (ran ${ref_passed:-0})" "no"
fi
# --------------------------------------------------------------------------
echo
echo "8. The starter suite skips unattempted work instead of failing it"
# --------------------------------------------------------------------------
start_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q -p no:cacheprovider 2>&1)"
start_status=$?
echo "${start_out}" | tail -3 | sed 's/^/ /'
if [ "${start_status}" -eq 0 ]; then
check "pytest starter exits 0 on an untouched checkout" "yes"
else
check "pytest starter exits 0 on an untouched checkout" "no"
fi
case "${start_out}" in
*" failed"*) check "the starter suite reports no failures" "no" ;;
*) check "the starter suite reports no failures" "yes" ;;
esac
case "${start_out}" in
*skipped*) check "unwritten exercises are reported as skipped, not passed" "yes" ;;
*) check "unwritten exercises are reported as skipped, not passed" "no" ;;
esac
# The import guard. Both directories contain modules called `acceptance`,
# `study`, `dataset` and `fixtures`; each directory's conftest.py prevents a
# cross-import. Auto-discovering both from the lab root must report the same
# skip count as `pytest starter` alone.
both_out="$(cd "${lab_dir}" && "${pytest_bin}" -q -p no:cacheprovider 2>&1)"
start_skipped="$(printf '%s\n' "${start_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
both_skipped="$(printf '%s\n' "${both_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
check_eq "auto-discovering both suites does not turn skips into passes" \
"${start_skipped:-none}" "${both_skipped:-none}"
# --------------------------------------------------------------------------
echo
echo "9. The starter suite can actually fail"
# --------------------------------------------------------------------------
# A green test suite proves nothing until you have watched it go red. This
# section solves every exercise in a SCRATCH copy of starter/ by dropping in
# the reference harness, breaks one gate on purpose, and asserts the suite
# reports the failure and exits non-zero. Nothing under the lab is modified.
solved="${scratch}/solved"
mkdir -p "${solved}"
cp "${lab_dir}/starter/"*.py "${solved}/"
cp "${lab_dir}/starter/00_brief.md" "${solved}/"
mkdir -p "${solved}/data"
cp "${lab_dir}/starter/data/observations.csv" "${solved}/data/"
cp "${lab_dir}/examples/acceptance.py" "${solved}/acceptance.py"
solved_out="$(cd "${scratch}" && "${pytest_bin}" solved -q -p no:cacheprovider 2>&1)"
solved_status=$?
echo "${solved_out}" | tail -2 | sed 's/^/ /'
if [ "${solved_status}" -eq 0 ]; then
check "a fully solved starter passes every exercise" "yes"
else
check "a fully solved starter passes every exercise" "no"
fi
solved_passed="$(printf '%s\n' "${solved_out}" | grep -o '[0-9][0-9]* passed' | head -1 | cut -d' ' -f1)"
check_eq "the solved starter runs all 33 exercises" "33" "${solved_passed:-0}"
# Now break exactly one thing: make the provenance gate stop verifying the
# checksum it was handed. The suite must notice.
"${python_bin}" - "${solved}/acceptance.py" <<'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
src = path.read_text()
needle = " recorded = payload.get(\"checksum_sha256\")\n local = payload.get(\"path\")"
assert needle in src, "the self-test needs the provenance checksum block"
path.write_text(src.replace(needle, " recorded = None\n local = None"))
PY
broken_out="$(cd "${scratch}" && "${pytest_bin}" solved -q -p no:cacheprovider 2>&1)"
broken_status=$?
echo "${broken_out}" | tail -3 | sed 's/^/ /'
if [ "${broken_status}" -ne 0 ]; then
check "breaking one gate makes the starter suite exit non-zero (${broken_status})" "yes"
else
check "breaking one gate makes the starter suite exit non-zero" "no"
fi
case "${broken_out}" in
*"test_provenance_gate_verifies_the_checksum"*)
check "the failing test is named in the output" "yes" ;;
*) check "the failing test is named in the output" "no" ;;
esac
case "${broken_out}" in
*"the gate must recompute it"*)
check "the failure message explains what went wrong" "yes" ;;
*) check "the failure message explains what went wrong" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "10. Nothing was left behind"
# --------------------------------------------------------------------------
if find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -print -quit 2>/dev/null | grep -q .; then
check "no __pycache__ directory left by the lab's own code" "no"
else
check "no __pycache__ directory left by the lab's own code" "yes"
fi
if find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -print -quit 2>/dev/null | grep -q .; then
check "no .pytest_cache directory left under the lab" "no"
else
check "no .pytest_cache directory left under the lab" "yes"
fi
# The scripts and the study builder write only into temporary directories.
# Nothing may have appeared under examples/ or starter/ beyond what ships.
expected_files="examples/01_question_recorded.py
examples/02_provenance_complete.py
examples/03_grain_asserted.py
examples/04_damage_report.py
examples/05_confirmation_untouched.py
examples/06_uncertainty_in_the_prose.py
examples/07_figures_carry_claims.py
examples/08_reproducibility.py
examples/09_whole_harness.py
examples/acceptance.py
examples/conftest.py
examples/data/observations.csv
examples/dataset.py
examples/fixtures.py
examples/study.py
examples/test_reference.py
starter/00_brief.md
starter/acceptance.py
starter/conftest.py
starter/data/observations.csv
starter/dataset.py
starter/fixtures.py
starter/study.py
starter/test_starter.py"
actual_files="$(cd "${lab_dir}" && find examples starter -type f | sed 's|^\./||' | LC_ALL=C sort)"
check_eq "the lab's own directories contain exactly the files that ship" \
"$(printf '%s\n' "${expected_files}" | LC_ALL=C sort | tr '\n' ' ')" \
"$(printf '%s\n' "${actual_files}" | LC_ALL=C sort | tr '\n' ' ')"
if [ -d "${lab_dir}/study" ] || [ -d "${lab_dir}/examples/study" ]; then
check "no study directory was written inside the lab" "no"
else
check "no study directory was written inside the lab" "yes"
fi
if grep -rqE 'urlopen|requests\.|socket\.|http://' \
"${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null; then
check "no lab source opens a network connection" "no"
else
check "no lab source opens a network connection" "yes"
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting
pytest starter reports passes for exercises you have not written
You ran pytest examples starter as a single command. Both directories carry
modules called acceptance, study, dataset and fixtures, and that
combined form is unreliable in this repository. Run them as two commands:
.venv/bin/pytest examples -q
.venv/bin/pytest starter -q
A bare pytest with no path argument is fine — the two conftest.py import
guards handle it, and section 8 of run_tests.sh proves the skip count is
unchanged.
A gate you wrote skips instead of failing
attempt() in test_starter.py treats both NotImplementedError and a
None return as "not attempted yet". If your gate falls off the end of a
function without returning, it returns None and the test skips. Every path
through a gate must return _passed(name) or _failed(name, findings).
_failed raises ValueError: gate '...' failed without a finding
That is deliberate. A failing gate with nothing to say is a bug: the reader learns only that something is wrong, which is exactly the verdict this lab exists to replace. Collect at least one finding before you fail.
A finding fails the "does it name something" test
The tests check the text. test_question_gate_fails_and_names_the_file
requires every finding to contain QUESTION.md;
test_uncertainty_gate_names_the_sentence requires the finding to quote the
offending sentence. Copy the exact strings from the docstrings in
starter/acceptance.py — they are the strings the tests match against.
Exercise 5 passes the peeked study
Your gate is almost certainly reading REPORT.md. It cannot work: the peeked
study's report, figures, interval and p-value are byte-identical to the
honest study's, and test_confirmation_gate_reads_only_the_log asserts
exactly that before checking your gate. The peek exists in one place only —
the order of rows in RESEARCH_LOG.md. Use research_log_rows(text) and
compare the index of the first confirmation row against the index of the
row whose activity contains hypothesis declared.
Exercise 8 passes a study whose output moved
You are probably reading the manifest and checking the files exist. The gate
must recompute each digest with sha256_of and compare it with the
recorded one. Existence is not a checksum.
Your figures do not hash identically across two runs
Three usual causes, in order of likelihood:
- Something reads the clock. Any
datetime.now(), anystrftimeon the current time, anywhere in the path from data to output. metadata={"Software": None}is missing fromsavefig. Matplotlib writes its own version into the PNG'stEXtchunk by default, and that tag is enough to move the digest between versions.- An unseeded
numpy.randomcall.default_rng()with no seed is a different generator every time.
The two PNG digests are still expected to differ between machines —
different fonts render different bytes. expected-output/FIELDS.md says so,
and nothing in this lab asserts a PNG digest against a stored literal.
matplotlib opens a window, or the run hangs
The lab sets matplotlib.use("Agg") before importing pyplot, and
run_tests.sh also exports MPLBACKEND=Agg. If you added your own plotting
code, do the same and never call plt.show(). Close figures with
plt.close(fig) so a long run does not leak them.
ModuleNotFoundError: No module named 'acceptance'
You are running a script from the lab root instead of from examples/. The
scripts import their siblings by plain name, so run them from their own
directory:
cd examples && ../.venv/bin/python3 01_question_recorded.py && cd ..
run_tests.sh says pytest is not found
Create the lab-local virtual environment, or point the suite at an existing pytest:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
## or
PYTEST=/path/to/pytest bash tests/run_tests.sh
A version check fails in section 1
requirements/requirements.txt pins the four versions this lab was written
and captured against. If your environment has a different one, the harness
says so rather than pretending the captured numbers still apply. Install the
pinned versions, or accept that expected-output/ may differ and say so in
your own notes — this course would rather you record a discrepancy than paper
over it.
run_tests.sh reports a file left behind
Section 10 compares examples/ and starter/ against an exact inventory. If
it fails, something wrote into the lab directory instead of a temporary one —
most often a study you built by hand while exploring. Delete it and re-run.
The find … -name __pycache__ … line in the Cleanup section of README.md
clears the other common case.
You want to see the suite go red
Change "264" to "265" in the the delivery carries 264 rows check in
tests/run_tests.sh, run it, and you should see
81 checks, 1 failure(s). with a non-zero exit. Change it back. A green suite
you have never watched fail is a suite you have no evidence about.
Security notes
Security notes
What this lab touches
- The network: once.
pip install -r requirements/requirements.txtis the only command in the lab that opens a connection. Everything afterwards runs offline against a 12 KB CSV file committed insideexamples/data/. - No credentials, no keys, no accounts. Nothing here authenticates to anything, and there is no paid tier of any component.
- No
sudo, no ports, no services. Nothing binds a socket. The lab starts no background process. - Writes stay inside a temporary directory. Every study the scripts and
tests build goes into a directory from
tempfile.mkdtempormktemp -d, and is removed on exit —run_tests.shremoves its scratch directory from anEXITtrap even when a check fails. Section 10 of the harness verifies that neitherexamples/norstarter/gained a file during the run.
The source URL points at a name that cannot resolve
SOURCE.json records
https://example.invalid/air-quality/observations.csv. .invalid is reserved
by RFC 2606 precisely so it can never be registered or resolved. The record is
real in shape — it has a URL, a retrieval date, a licence, a dictionary and a
verified checksum — and honest about its origin: SOURCE.json carries a
retrieval_note saying the file is generated by dataset.py and never
fetched. Nothing in the lab attempts to open it.
The one genuine caution: check_study reads a directory you hand it
The harness is designed to be pointed at somebody else's study, which makes that directory untrusted input. Two things to know.
It reads; it never executes. acceptance.py opens files, parses JSON with
json.loads, matches regular expressions and computes SHA-256 digests. It
never imports, execs or runs anything from the study directory. A study
containing a malicious study.py is, to this harness, a file with a size.
It walks the whole directory. gate_outputs_reproducible and
gate_figures_documented use rglob, so a study directory containing a
symlink to somewhere large or somewhere private will be followed by
Path.rglob in the same way any file walk would follow it. If you are
grading a directory you did not create, look at it before you point the
harness at it, or copy it somewhere isolated first.
Malformed input fails as a finding, not a crash. A SOURCE.json that is
not valid JSON produces the finding SOURCE.json is not valid JSON: ...
rather than an exception; a FIGURES.json that is an object rather than a
list produces FIGURES.json must be a JSON list of figure records. The one
deliberate exception is a path that is not a directory at all, which raises
FileNotFoundError — a typo in the path should stop you, not quietly report
eight failures.
Privacy
The dataset is entirely synthetic, generated from a fixed seed by
dataset.py, and describes no real place, station or person. There is no
personal data anywhere in this lab.
The lesson for this day discusses who is missing from a dataset and why
that absence is rarely random — that is Day 138's material applied to the
study's limits section, and the worked study's REPORT.md states it plainly:
eight stations is a sample of sites, not of people.
What the harness cannot tell you
Worth stating, because a checker that overclaims is worse than none.
gate_uncertainty_reported cannot tell whether an interval is correctly
computed — only whether one is present. gate_question_recorded cannot prove
the question was written before the looking. gate_confirmation_untouched
reads the research log, and a log can be back-dated by anyone willing to lie
to themselves. Every gate is a check on an artefact, not on an intention. It
raises the cost of self-deception; it does not eliminate it.