Math, Statistics, and DataWorking with Real Data › Day 140

Hands-on lab — 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

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.md states 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.erf alone, 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 43python3 -m venv and installing a package with pip.

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.exe in 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.
  • bash for 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

  1. bash tests/run_tests.sh exits 0 and prints 81 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.
  2. .venv/bin/pytest examples -q reports 54 passed.
  3. .venv/bin/pytest starter -q reports 1 passed and 32 skipped on an untouched checkout, and 33 passed once every exercise is solved.
  4. Each of the nine scripts exits 0 and ends with a line beginning OK:.
  5. Compare a script's output with the matching file in expected-output/; they differ only in the temporary directory path.
  6. Confirm the suite can fail: change "264" to "265" in the the delivery carries 264 rows check in tests/run_tests.sh, run it, see 81 checks, 1 failure(s). and a non-zero exit, then change it back. That was done during authoring and is recorded in metadata.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 starter reports passes for exercises you have not written. You ran pytest examples starter as one command. Run them separately.
  • A gate you wrote returns None and the test skips instead of failing. attempt() treats None as "not attempted". Return a GateResult.
  • Your figures do not hash identically across two runs. Something is reading a clock, or you dropped metadata={"Software": None} from savefig.

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

  1. A ninth gate: the decision. Day 119 asks whether an answer would change what anybody does. Add gate_decision_named requiring QUESTION.md to state a decision the answer informs, and see how many of your own past analyses would fail it.
  2. 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.
  3. A gate for the leaked feature. The worked study draws its figures from the exploration half only. Extend FIGURES.json with a split field and fail any figure drawn from the confirmation half.
  4. Port the harness to YAML. Swap json.loads for 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.
  5. Run it on your Week 20 project. The real extension. Point check_study at your own study directory and fix what it names — before anyone else reads it.
  • 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.

![PM2.5 by station type](figures/fig-01-pm25-by-station-type.png)

![PM2.5 distribution](figures/fig-02-pm25-distribution.png)

## 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", "![PM2.5 by station type](figures/fig-01-pm25-by-station-type.png)"),
        ("raw", "![PM2.5 distribution](figures/fig-02-pm25-distribution.png)"),
        ("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", "![PM2.5 by station type](figures/fig-01-pm25-by-station-type.png)"),
        ("raw", "![PM2.5 distribution](figures/fig-02-pm25-distribution.png)"),
        ("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:

  1. Something reads the clock. Any datetime.now(), any strftime on the current time, anywhere in the path from data to output.
  2. metadata={"Software": None} is missing from savefig. Matplotlib writes its own version into the PNG's tEXt chunk by default, and that tag is enough to move the digest between versions.
  3. An unseeded numpy.random call. 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.txt is the only command in the lab that opens a connection. Everything afterwards runs offline against a 12 KB CSV file committed inside examples/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.mkdtemp or mktemp -d, and is removed on exit — run_tests.sh removes its scratch directory from an EXIT trap even when a check fails. Section 10 of the harness verifies that neither examples/ nor starter/ 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.