Math, Statistics, and DataData Visualization › Day 133

Hands-on lab — Day 133: Building an EDA Report

Commands

Setup

cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import matplotlib, pandas; print(matplotlib.__version__, pandas.__version__)"

Run

.venv/bin/pytest examples
.venv/bin/pytest starter

Test

bash tests/run_tests.sh

File tree

examples/analysis.py
examples/conftest.py
examples/data.py
examples/report.py
examples/test_report.py
expected-output/examples-run.txt
expected-output/FIELDS.md
expected-output/report-sample.md
expected-output/starter-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/00_brief.md
starter/analysis.py
starter/conftest.py
starter/data.py
starter/report.py
starter/test_report.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 133 lab — A Report That Argues

Lesson

  • Lesson title: Building an EDA Report
  • Day number: 133 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-133-building-an-eda-report
  • Lab files: everything you need is in this directory — follow “How to run” below.
  • Browse the course locally: from the repository root, this lab also appears in the course website at /labs/day-133-building-an-eda-report when the site is running.

Purpose

You build the checks that decide whether an exploratory analysis is fit to be read, and you wire them into a report generator that refuses to produce a report that fails them.

The generator in report.py takes a dataset and a list of candidate figures, and writes a Markdown report with each figure embedded beside its caption and its claim. It will not let you add a figure that has no stated question. It will not accept a caption that is a label rather than a claim. It renders the conclusion before the evidence, keeps the discarded candidates as one-line null results, and produces byte-identical Markdown on two runs over the same input.

Twelve candidate figures go into analysis.candidate_figures(). Five come out. That ratio is the lesson: exploration is wide and private, a report is narrow and public, and most of the charts you make should not survive into the second one.

Nine numbered exercises, all headless via the Agg backend, all writing into temporary directories that are deleted when the test finishes.

Learning objectives

By the end of this lab you will be able to:

  • Enforce, in code, that every figure in a report answers a stated question, and explain why a figure whose question you cannot state does not belong in the report.
  • Distinguish a descriptive caption from a caption that carries a claim, implement the distinction as a check, and state honestly what such a check can and cannot detect.
  • Generate a report whose prose numbers are interpolated from computed values, and prove that changing one input value changes the sentence.
  • Detect an orphan figure — an image on disk the text never refers to.
  • Require that every reported estimate carries a 95% interval or an explicit note saying why none is available, and catch a bare point estimate.
  • Prove that two runs over the same input produce byte-identical Markdown, and say precisely what that guarantee does and does not cover for the figure files.
  • Assert the reader's ordering: conclusion first, evidence beneath it, caveats and provenance where they can be found.
  • Apply a "so what" filter to a set of candidate figures, measure the survival rate, and keep the discarded ones as null results.
  • Turn a colourblind-safe palette and labelled axes into a build check that fails a red-against-green chart.

Prerequisites

  • Day 126 — the reproducible cleaning pipeline, whose idempotence this lab applies to prose rather than to data.
  • Day 127 — chart choice and the perceptual ranking.
  • Day 128 — matplotlib's Figure/Axes/Artist object model. The accessibility check here reads colours straight off the artists.
  • Days 117 and 118 — sampling, standard error and intervals. Every interval in this lab is a percentile bootstrap.
  • Day 132 — chart honesty, which this lab turns into a check that runs.
  • Comfort with pytest, and a working python3 on your PATH.

Supported operating systems

  • macOS (Intel or Apple Silicon) — the machine this lab was written and run on: macOS 26.5.2, arm64.
  • Linux — any distribution with Python 3.11 or newer. Every command below is identical.
  • Windows — use WSL2 and follow the Linux path. Native PowerShell works too if you substitute .venv\Scripts\python.exe for .venv/bin/python3 and run the harness under Git Bash; the harness is a bash script and will not run in cmd.exe.

Hardware requirements

Nothing special. The dataset is 192 rows and every figure is 600 by 350 pixels. The full harness runs in a few seconds on a laptop and needs well under 500 MB of memory. No GPU, no display, no network after install.

Required software

  • Python 3.11 or newer (3.14.0 here).
  • The pins in requirements/requirements.txt: matplotlib 3.11.1, seaborn 0.13.2, pandas 3.0.5, NumPy 2.5.2, pytest 9.1.1.
  • bash for the test harness (3.2 or newer; macOS's system bash is fine).

Everything else the lab uses — hashlib, re, shutil, tempfile, dataclasses, pathlib — is standard library.

Free and open-source options

Every tool in this lab is free and open source, and there is no paid tier anywhere in it. matplotlib and NumPy are BSD-licensed, seaborn and pandas BSD-3-Clause, pytest MIT. Python itself is under the PSF licence.

The report generator writes plain Markdown on purpose. Markdown renders in every code host, every static-site generator, every wiki and every text editor, and it diffs line by line in Git, so a report that changed its conclusion shows you which sentence changed. The commercial and semi-commercial alternatives in this space — hosted notebook services, BI dashboards with a report export — are discussed in the lesson; none of them is needed here, and none is used.

Installation

cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import matplotlib, pandas; print(matplotlib.__version__, pandas.__version__)"

That last line should print 3.11.1 3.0.5. The pip install is the only step that touches the network.

File structure

day-133-building-an-eda-report/
├── README.md                 this file
├── metadata.yml              lab metadata and the literal result of the real run
├── security.md               what this lab does to your machine
├── troubleshooting.md        the failures you are most likely to hit
├── requirements/
│   ├── README.md             why the pins are exact
│   └── requirements.txt      matplotlib, seaborn, pandas, NumPy, pytest
├── starter/                  your work
│   ├── 00_brief.md           the nine exercises, explained
│   ├── conftest.py           Agg backend, fixtures, temporary directories
│   ├── data.py               the dataset, deterministic under default_rng(133)
│   ├── report.py             the report generator and its six checks
│   ├── analysis.py           twelve candidate figures; five survive
│   └── test_report.py        nine exercises, each currently a pytest.skip
├── examples/                 the reference answers — read after you try
│   ├── conftest.py           identical to starter/conftest.py
│   ├── data.py               identical to starter/data.py
│   ├── report.py             identical to starter/report.py
│   ├── analysis.py           identical to starter/analysis.py
│   └── test_report.py        the nine exercises, solved
├── tests/
│   └── run_tests.sh          the bash harness: 42 checks
└── expected-output/
    ├── FIELDS.md             what is exact, what may differ, and why
    ├── report-sample.md      the Markdown the generator really produced
    ├── examples-run.txt      pytest examples -q
    ├── starter-run.txt       pytest starter -q
    └── test-run.txt          bash tests/run_tests.sh

How to run

Read starter/00_brief.md first, then starter/report.py. Then:

## your work, from the lab directory
.venv/bin/pytest starter -v

## the reference answers, once you have tried
.venv/bin/pytest examples -q

## everything, including the proof that the suite can fail
bash tests/run_tests.sh

Run pytest starter and pytest examples as two separate commands. Both directories contain a module named test_report.py, and pytest collects test modules by dotted name; a single pytest examples starter aborts collection with an import file mismatch. Section 5 of the harness runs that combined form on purpose and asserts it fails, so the warning in this README is checked rather than merely stated.

What the commands do

Command What happens
.venv/bin/pytest starter -v Runs your nine exercises. On an untouched checkout all nine skip, and each skip message tells you what to assert
.venv/bin/pytest examples -q Runs the reference answers. Should print 9 passed
bash tests/run_tests.sh The full harness: version pins, the generator's own rules driven directly from Python, both suites, the collision check, the fail-then-restore proof, and the cleanliness checks
.venv/bin/python -c "import data, analysis; ..." Renders the report yourself — see "Extension exercises"

Section 2 of the harness is the interesting one. It does not read source code: it builds reports, renders them into temporary directories, and reads the documents back to check that the generator really refuses what it claims to refuse.

Expected output

The final section of a green run:

7. Offline, and nothing left behind
  ok: no URLs inside examples/ or starter/
  ok: no image files anywhere inside the lab
  ok: no generated report.md left inside the lab
  ok: no __pycache__ or .pytest_cache left behind
  ok: no d133 temporary directory left in the system temp directory

-------------------------------------------------------------
42 checks, 0 failure(s)

The full capture is in expected-output/test-run.txt, and the Markdown the generator actually produced is in expected-output/report-sample.md. expected-output/FIELDS.md records which captured numbers are exact everywhere and which are specific to this machine — notably that the byte-identity of the figure PNGs is asserted only across two runs on one machine, never across machines.

Validation steps

  1. bash tests/run_tests.sh prints 42 checks, 0 failure(s) and exits 0 (echo $? immediately after, with no pipe in between — a pipeline reports the last command's status and will hide a real failure).
  2. .venv/bin/pytest examples -q prints 9 passed.
  3. .venv/bin/pytest starter -q prints 9 skipped before you start, and 9 passed when you are done.
  4. Open expected-output/report-sample.md and read it as a reader would. Every claim in the conclusion is a caption from a figure below it.
  5. Render the report yourself and confirm that deleting one figure's image and re-checking makes orphan_figures complain.

Tests

tests/run_tests.sh is a bash assert harness. It prints N checks, M failure(s), exits 0 only when M is zero, and covers:

  1. the installed versions against requirements/requirements.txt;
  2. the generator's own rules, driven directly: it refuses a question-less figure and a label caption, keeps 5 of 12 candidates, renders byte-identical Markdown, moves its numbers when the input moves, detects an orphan, catches a bare point estimate, orders the sections for the reader, and fails a red-against-green chart with four named problems;
  3. examples/ passing in full;
  4. starter/ skipping in full on an untouched checkout;
  5. the pytest examples starter collision, run and asserted;
  6. the proof that the suite can fail — the harness copies the solved suite into a scratch directory, confirms 9 passed, rewrites assert len(kept) == 5 to == 99, confirms a non-zero exit and a printed failure, restores the file, and confirms 9 passed again;
  7. no URLs in the exercise code, and no image, no generated report.md, no __pycache__ and no stray temporary directory left behind.

Cleanup

The harness cleans up after itself, before and after every run. To reset completely:

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf .venv          # optional: removes the lab virtual environment
git checkout -- starter/   # optional: throws away your work and restores the skips

Nothing else exists to remove. Every report this lab renders goes into a tempfile.TemporaryDirectory that is deleted when the test that made it finishes, which is why the harness can assert that no image file exists anywhere inside the lab.

Troubleshooting

troubleshooting.md covers the failures in detail. The three most common:

  • pytest: command not found — you have not created the .venv, or you are calling bare pytest instead of .venv/bin/pytest. The harness also accepts PYTEST=/path/to/pytest bash tests/run_tests.sh.
  • import file mismatch — you ran pytest examples starter in one command. Run them as two.
  • A window tries to open, or matplotlib complains about a display — something imported pyplot before matplotlib.use("Agg") ran. The conftest.py in each directory sets the backend first; if you added an import above it, move it back down.

Security notes

security.md has the full account. In short: one network connection ever (the pip install), no port bound, no sudo, no credential, no API key, nothing written outside this lab's own directory and the temporary directories it deletes. The dataset is synthetic and generated in data.py — no real person's data is anywhere in this lab.

Extension exercises

  1. Render the report and read it. From examples/: python -c "import data, analysis; f=data.monthly_sales(); print(analysis.build_report(f).render('out', f))". Open out/report.md. Then delete out/ — the lab's own runs never leave it behind, and neither should yours.
  2. Break the claim rule. Change one caption in analysis.py to a bare label and watch build_report raise before a single figure is drawn.
  3. Sharpen the claim heuristic. Add "tripled" and "trebled" to CLAIM_WORDS, then find another real claim it still refuses. You will not run out. That is the honest ceiling on this kind of check.
  4. Promote a discarded candidate. Give one of the seven a question, a draw and an analyse, and watch the survival rate move from 5/12 to 6/12 — and the null-results section shrink by exactly one line.
  5. Add a check of your own. For example: no figure may be referenced before the conclusion mentions its number, or no caption may exceed 200 characters. Write the check, then write the test that proves it fails.
  6. Point the generator at your own data. build_report takes a DataFrame. The candidates are yours to rewrite; the six checks are not meant to change.
  • Previous day: Day 132 — Visual Storytelling and Chart Honesty (labs/sections/math-statistics-and-data/day-132-visual-storytelling-and-chart-honesty/).
  • Next day: Day 134, opening Week 20 (labs/sections/math-statistics-and-data/).
  • Week 19 project: the week's project directory (labs/sections/math-statistics-and-data/projects/week-19/), where you produce a narrated exploratory report on a dataset of your own. This lab is the method and the machinery; the project is your analysis.

Expected output

FIELDS.md

# What in these captures is exact, and what may differ

Captured from a real run on 2026-08-20, in this lab's own `.venv`, on
matplotlib 3.11.1, seaborn 0.13.2, pandas 3.0.5, NumPy 2.5.2, pytest
9.1.1, Python 3.14.0, macOS (arm64). scipy is not installed on this
machine, which is why every interval in this lab is a hand-written
percentile bootstrap rather than a call into `scipy.stats`.

Four files sit here:

| File | What it is |
| --- | --- |
| `test-run.txt` | The full `bash tests/run_tests.sh` output |
| `examples-run.txt` | `pytest examples -q` |
| `starter-run.txt` | `pytest starter -q` on an untouched checkout |
| `report-sample.md` | The Markdown the generator actually produced, captured verbatim |

`report-sample.md` links to `figures/*.png` that are **not** in this
directory. That is deliberate: the harness asserts no image file exists
anywhere inside the lab, because the lab renders into a temporary
directory and deletes it. Render it yourself to see the figures.

## Exact everywhere, on any correct install

- `test-run.txt` ends with `42 checks, 0 failure(s)` and exit 0.
  `examples-run.txt` ends with `9 passed`; `starter-run.txt` ends with
  `9 skipped`. All three counts are structural — 9 test functions per
  file, 42 `check` calls in the harness — and do not depend on the
  machine.
- **12 candidate figures, 5 survivors, 7 discarded.** These are
  hand-authored in `analysis.candidate_figures()`, not sampled. The
  survival rate is 41.7%.
- **8 of 192 rows (4.2%) have no revenue, and 100% of them are partner
  rows.** `MISSING_RATE * 192` rounds to 8, and `data.monthly_sales`
  draws the blanked rows only from partner positions, so the 100% is by
  construction and exact.
- The partner channel's median region-month is **45%** of the direct
  median. `PARTNER_SHARE` is 0.45 and the multiplicative noise is
  symmetric around 1.0, so this holds to the printed precision.
- `East month 18 as a multiple of the region median` carries **no
  interval**, and the report says why: one observation has no sampling
  interval. That branch of `Estimate` is exercised by the real report,
  not only by a test.
- The accessibility check reports **exactly four** problems on
  `analysis.draw_inaccessible`: an unlabelled x axis, an unlabelled y
  axis, `#ff0000` and `#008000`. Both colours are literal matplotlib
  named colours, so the hex values are fixed.

## Deterministic given the seed, and exact on this NumPy

Everything below comes from `numpy.random.default_rng(133)` in
`data.monthly_sales`, or from a bootstrap seeded at 133. `default_rng`'s
bit generator is part of NumPy's stable public API, so these should
reproduce identically on any NumPy 2.x; only the values on NumPy 2.5.2
were directly verified here.

| Quantity | Captured value |
| --- | --- |
| Data fingerprint (sha256, first 12) | `2ba806a5cbf5` |
| Share of rows with missing revenue | 4.2% (95% interval 1.6% to 7.3%) |
| Median partner region-month revenue | 16468 USD (95% interval 15826 to 17001) |
| Fitted slope, revenue per extra order | 174 USD |
| R-squared of the straight-line fit | 97.4% |
| Mean revenue per order | 180.1 USD (95% interval 178.4 to 181.8) |
| Region change across month 13 | North +12.5%, South +1.7%, East +47.5%, West -8.6% |
| East change with month 18 removed | +8.0% |
| West change, 95% bootstrap interval | -11.6% to -5.1% |
| West change on the perturbed frame | -54.3% |
| East month 18, as a multiple of the region median | 3.3x (neighbouring months 1.05x) |

## Measured on this machine only

- **Figure PNG bytes are byte-identical across two runs.** Exercise 6 and
  harness check `figure_bytes_identical_same_machine` both assert this,
  and both compare **two runs on the same machine within one test
  session** — which is where the guarantee actually holds. PNG bytes from
  matplotlib depend on the font files installed and on the FreeType build
  that rasterises them, so two different machines can legitimately produce
  different bytes from identical code. Nothing here promises cross-machine
  byte-identity for images, and you should not build a pipeline that
  depends on it.
- **Markdown byte-identity is the stronger claim**, and it is the one the
  lesson leans on. The rendered Markdown contains no clock reading, no
  hostname and no unseeded random number — provenance is a hash of the
  input data, not a note about the run — so two runs over the same input
  produce the same bytes. Harness check
  `no_clock_reading_in_output` asserts the absence directly.

## Honesty notes from this run

**One. The claim heuristic is crude in both directions, and the lab
asserts both.** `report.carries_claim` passes
`"revenue doubled in every region"` on data where revenue halved, because
it cannot read the data. It also *refuses*
`"revenue tripled in all four regions"`, which is a perfectly good claim
written with a word that is not on `CLAIM_WORDS`. The first limit was
expected; the second was found by writing the test and watching it fail,
and it is kept rather than patched away by adding "tripled" to the list,
because the false negative is the more instructive half. What the check
actually buys is narrow and worth having: it makes the **absence** of a
claim impossible to ship by accident. It is not, and cannot be, a judge
of whether a claim is true.

**Two. Six observations per side is a thin bootstrap, and the report says
so.** The West's -8.6% is a comparison of two six-month windows, and its
interval (-11.6% to -5.1%) is correspondingly wide. That width is
reported in the document and named in the report's own caveats rather
than being quietly dropped, which is the whole point of exercise 5.

**Three. The step change is real in this data because it was put there.**
`data.py` scales the West by `WEST_PRICING_FACTOR` from month 13. The
report states plainly in its caveats that this is observational data and
that an association in time is not a causal claim — the generated
document argues honestly about data whose truth we happen to know, which
is the only way to check that the argument is honest.

**Four. The East's +47.5% is one month.** The generated report quotes the
figure and then immediately gives the number with the single month-18
observation removed (+8.0%), computed from the same frame. Quoting the
inflated figure alone would have been exactly the Day 132 failure this
week is about, so `analyse_segments` computes both.

**Five. pandas' Styler could not be run here, and the brief was wrong
about that.** The day brief this lab was written from said pandas Styler
was installed and could be used. Measured directly on this machine:

```
>>> df.style
AttributeError: The '.style' accessor requires jinja2
```

pandas 3.0.5 is installed, but `.style` is an optional accessor that
imports jinja2 to render its templates, and jinja2 is not in this
environment. So the lesson describes Styler from the pandas documentation
and says plainly that no Styler output is reproduced. Nothing in this lab
uses it.

**Six. Nothing else in this lab is attributed to a tool that is not
installed either.** Jupyter, nbconvert, Quarto and scipy are all absent
from this machine — `jupyter` and `quarto` are not on PATH and
`import nbconvert`, `import nbformat`, `import IPython` and
`import scipy` all fail. Every one of them is described in the lesson
from public documentation only, and no output is reproduced from any of
them anywhere in this lab or its lesson.

examples-run.txt

.........                                                                [100%]
9 passed in 1.95s

report-sample.md

# Where did the West's revenue go?

**Question.** Four regions sell through two channels. Did any region's revenue trajectory change around the month-13 pricing change, and is the change big enough and clean enough to act on?

**Decision this feeds.** Whether to roll the month-13 pricing change back in the West before it is extended to the other three regions.

## Conclusion

1. 8 of 192 rows (4.2%) have no revenue, and 100% of those gaps are partner rows -- the missingness is a channel problem, not random loss (Figure 1)
2. Revenue is two populations rather than one: the median partner region-month is 45% of the median direct region-month, so any average taken across both channels describes a mixture nobody sells into (Figure 2)
3. Revenue rises about 174 USD per additional order and the straight-line fit accounts for 97.4% of the variation, so a region-month that missed its revenue missed its order count (Figure 3)
4. 3 regions grew across the pricing change while the West fell 8.6%, and the break lands in month 13 in that region only (Figure 4)
5. East month 18 is 3.3 times the region's median month while the months either side sit at 1.05 times it, so this is one observation and not a level change (Figure 5)

- share of rows with missing revenue: 4.2% (95% interval 1.6% to 7.3%)
- median partner region-month revenue: 16468 USD (95% interval 15826 USD to 17001 USD)
- mean revenue per order: 180.1 USD (95% interval 178.4 USD to 181.8 USD)
- West change across the pricing change (six months either side): -8.6% (95% interval -11.6% to -5.1%)
- East month 18 as a multiple of the region median: 3.3x (no interval: a single observation has no sampling interval; one point is one point)

## What we looked at and found nothing in

- A cumulative revenue curve was drawn and discarded: a cumulative series rises whatever the underlying months do, so it answered no question the monthly series had not already answered
- The order-count distribution was checked for a second population; it shows the same channel split the revenue column already shows, so it adds no evidence of its own
- Region-by-channel interaction was checked; the partner channel runs at the same share of direct in all four regions, so there is no interaction to report
- Revenue per order was compared across the four regions; they are indistinguishable on this measure, which is worth one line here so the next reader does not spend an afternoon on it
- Calendar seasonality was checked by lining the two years up month against month; nothing stood out above the month-to-month noise
- The missing revenue rows were checked for a time pattern as well as a channel pattern; they are scattered across the two years rather than clustered in any single month
- A region-by-month heatmap was drawn and discarded: it held the same information as the trend lines while making the month-13 break harder to see, which is the wrong trade for a report

## Evidence

### Figure 1 — Which rows have no revenue, and is the missingness concentrated anywhere?

![Which rows have no revenue, and is the missingness concentrated anywhere?](figures/01-missing-revenue.png)

**Figure 1.** 8 of 192 rows (4.2%) have no revenue, and 100% of those gaps are partner rows -- the missingness is a channel problem, not random loss

Every other column is complete. Because the gaps sit entirely in one channel, any figure that pools the two channels and drops missing rows silently under-counts partner activity, so the rest of this report uses direct-channel rows wherever a level is being compared.

*share of rows with missing revenue: 4.2% (95% interval 1.6% to 7.3%)*

### Figure 2 — Is monthly revenue one population, or several stacked on top of each other?

![Is monthly revenue one population, or several stacked on top of each other?](figures/02-channel-populations.png)

**Figure 2.** Revenue is two populations rather than one: the median partner region-month is 45% of the median direct region-month, so any average taken across both channels describes a mixture nobody sells into

The two histograms barely overlap. A single mean over this column would land in the empty gap between them and describe no real region-month at all -- Day 116's warning about a summary that discards the thing you needed, met again in a column you would have been tempted to average.

*median partner region-month revenue: 16468 USD (95% interval 15826 USD to 17001 USD)*

### Figure 3 — How tightly do orders and revenue move together, and what is one extra order worth?

![How tightly do orders and revenue move together, and what is one extra order worth?](figures/03-orders-vs-revenue.png)

**Figure 3.** Revenue rises about 174 USD per additional order and the straight-line fit accounts for 97.4% of the variation, so a region-month that missed its revenue missed its order count

There is no second cluster off the line and no curvature worth naming. That is a boring finding, and it is in the report precisely because it closes a question the reader would otherwise have to ask: revenue here is not being moved by price. The fitted slope (174 USD) sits a little below the mean revenue per order (180 USD) because the fit carries a non-zero intercept of 778 USD; the two numbers answer slightly different questions and the report says which is which rather than quoting whichever is larger.

*mean revenue per order: 180.1 USD (95% interval 178.4 USD to 181.8 USD)*

### Figure 4 — Did any region's trajectory change at the month-13 pricing change?

![Did any region's trajectory change at the month-13 pricing change?](figures/04-region-trend.png)

**Figure 4.** 3 regions grew across the pricing change while the West fell 8.6%, and the break lands in month 13 in that region only

Comparing the six months before month 13 with the six months after, the four regions move North +12.5%, South +1.7%, East +47.5%, West -8.6%. The West is the only one that changes direction, and it changes it at the month the price moved. This is an association in observational data, not a controlled comparison: nothing here rules out a third cause that happened to the West in the same month. East's +47.5% is not what it looks like either: drop the single month-18 observation and it falls to +8.0%, which is why Figure 5 exists.

*West change across the pricing change (six months either side): -8.6% (95% interval -11.6% to -5.1%)*

### Figure 5 — Is the East's month-18 jump a level change or a single outlier?

![Is the East's month-18 jump a level change or a single outlier?](figures/05-east-anomaly.png)

**Figure 5.** East month 18 is 3.3 times the region's median month while the months either side sit at 1.05 times it, so this is one observation and not a level change

The distinction matters for what you do next. A level change is a fact about the business and belongs in the forecast; a single spike is a fact about one month and belongs with whoever can explain it. Until someone does, the honest move is to report both the figure including it and the figure excluding it, and to say which one the decision was made on.

*East month 18 as a multiple of the region median: 3.3x (no interval: a single observation has no sampling interval; one point is one point)*

## Caveats

- This is observational data, not an experiment. The month-13 break is an association in time; it is not proof that the pricing change caused it.
- The regional comparison uses six months either side of the change. Six observations per side is a small window, and the interval on that number is correspondingly wide -- read the interval, not the point estimate.
- Level comparisons use direct-channel rows only, because the partner channel is the one with missing revenue. Partner totals in this report are therefore lower bounds.
- Every figure here uses a colourblind-safe palette and labelled axes, and no axis in this report is truncated below zero.

## Provenance

- Source: synthetic monthly sales, 4 regions x 2 channels x 24 months, generated by data.monthly_sales() with numpy default_rng(133)
- Shape: 192 rows, 5 columns
- Data fingerprint (sha256, first 12): `2ba806a5cbf5`
- This document was generated by code from the input above. Nothing in it was typed by hand, so re-running it on new data cannot leave the prose disagreeing with the figures.

starter-run.txt

sssssssss                                                                [100%]
9 skipped in 0.01s

test-run.txt

Day 133 — A Report That Argues

1. The tools and the versions this lab was written against
python     3.14.0
matplotlib 3.11.1
seaborn    0.13.2
pandas     3.0.5
numpy      2.5.2
pytest     9.1.1

  ok: installed packages match requirements.txt exactly

2. The generator's own rules, exercised directly
refuses_questionless=yes
questionless_left_no_panel=yes
accepts_with_question=yes
rejects_label_caption=yes
accepts_claim_caption=yes
candidates_total=12
candidates_kept=5
candidates_dropped=7
markdown_byte_identical=yes
figure_bytes_identical_same_machine=yes
changed_input_changes_document=yes
no_clock_reading_in_output=yes
west_change_original=-8.6
west_change_perturbed=-54.3
sections_in_reader_order=yes
conclusion_before_first_figure=yes
figures_written=5
no_orphans_in_report=yes
orphan_is_detected=yes
dropped_slugs_absent_from_report=yes
null_results_kept_as_one_line_each=yes
every_estimate_has_uncertainty=yes
estimates_with_interval=4
estimates_with_explicit_note=1
bare_point_estimate_is_caught=yes
every_figure_passes_accessibility=yes
inaccessible_chart_problem_count=4
inaccessible_chart_names_both_colours=yes

  ok: the generator ran without error
  ok: refuses a figure with no stated question
  ok: a refused figure leaves no half-added panel
  ok: accepts the identical figure once it has a question
  ok: rejects the caption 'revenue by region' as a label
  ok: accepts a caption carrying a number
  ok: 12 candidate figures go in
  ok: 5 survive the 'so what' filter and 7 do not
  ok: two runs over the same input produce byte-identical Markdown
  ok: figure PNG bytes match across two runs on this machine
  ok: changing one input value changes the document
  ok: nothing in the output is a clock reading
  ok: the West figure in the prose moved with the data
  ok: conclusion, omissions, evidence, caveats, provenance are in reader order
  ok: the conclusion is rendered before Figure 1
  ok: five figures written, none of them orphaned
  ok: a deliberately orphaned figure is detected
  ok: no discarded slug reaches the rendered report
  ok: every discarded candidate survives as a one-line null result
  ok: every reported estimate carries an interval or an explicit note
  ok: four estimates carry an interval and one carries an explicit note
  ok: a bare point estimate is caught
  ok: every figure passes the accessibility contract
  ok: the red-against-green chart fails with four named problems

3. Reference suite -- examples/ must pass in full
.........                                                                [100%]
9 passed in 1.98s
  ok: examples/ exits 0
  ok: examples/ reports 9 passed, 0 failed

4. Exercise suite -- starter/ is all-skip on an untouched checkout
sssssssss                                                                [100%]
9 skipped in 0.01s
  ok: starter/ (untouched) exits 0
  ok: starter/ (untouched) reports 9 skipped, 0 failed

5. Never run 'pytest examples starter' in one invocation -- both
   directories define a module named test_report.py, and pytest
   collects by dotted module name. Documented, and run as two commands.
  ok: 'pytest examples starter' aborts rather than silently passing
  ok: the collision is reported as an import file mismatch

6. Prove the suite can genuinely FAIL: solve every exercise in a
   scratch copy, confirm green, break one assertion on purpose,
   confirm a non-zero exit and a printed failure, then restore.
  ok: scratch copy of the solved suite exits 0
  ok: scratch copy reports 9 passed
  ok: broken scratch copy exits non-zero
  ok: broken scratch copy prints a failure
  ok: restored scratch copy exits 0 again
  ok: restored scratch copy reports 9 passed again

7. Offline, and nothing left behind
  ok: no URLs inside examples/ or starter/
  ok: no image files anywhere inside the lab
  ok: no generated report.md left inside the lab
  ok: no __pycache__ or .pytest_cache left behind
  ok: no d133 temporary directory left in the system temp directory

-------------------------------------------------------------
42 checks, 0 failure(s)

Source files

examples/analysis.py (21952 bytes)
"""The twelve candidate figures, and the five that survive the filter.

This module is the *content* of the Day 133 report; `report.py` is the
machinery. Reading them together is the point of the day: exploration
produced twelve candidate figures, seven of them answer no stated
question, and `report.survivors` throws those seven out before they can
reach the reader. Their `dropped_because` lines survive, though, in the
report's "what we looked at and found nothing in" section -- because a
null result you keep to yourself is a null result the next person has to
rediscover.

Every number in every caption is computed from the frame that is passed
in. Nothing is a typed literal. Change the input and the sentences change
with it, which is what exercises 3 and 6 check.
"""

from __future__ import annotations

import numpy as np
import pandas as pd
from matplotlib.axes import Axes

from data import ANOMALY_MONTH, ANOMALY_REGION, PRICING_CHANGE_MONTH, REGIONS, source_description
from report import (
    SAFE_PALETTE,
    Candidate,
    Estimate,
    Finding,
    Report,
    bootstrap_interval,
    discarded,
    survivors,
)

#: The two six-month windows either side of the pricing change.
BEFORE_WINDOW = (7, 12)
AFTER_WINDOW = (13, 18)


def _direct(frame: pd.DataFrame) -> pd.DataFrame:
    """Direct-channel rows only -- the subset with no missing revenue."""
    return frame[frame["channel"] == "direct"]


def _window_mean(frame: pd.DataFrame, region: str, window: tuple[int, int]) -> float:
    rows = _direct(frame)
    low, high = window
    selected = rows[(rows["region"] == region) & rows["month"].between(low, high)]
    return float(selected["revenue"].mean())


def _window_values(frame: pd.DataFrame, region: str, window: tuple[int, int]) -> np.ndarray:
    rows = _direct(frame)
    low, high = window
    selected = rows[(rows["region"] == region) & rows["month"].between(low, high)]
    return selected["revenue"].to_numpy(dtype=float)


def _ratio_interval(
    before: np.ndarray, after: np.ndarray, *, resamples: int = 2000, seed: int = 133
) -> tuple[float, float]:
    """Percentile bootstrap for a percentage change between two small windows.

    Six observations on each side is not much, and the interval this returns
    is correspondingly wide. That width is information, not an embarrassment:
    it is the report telling the reader how hard the number should be leaned on.
    """
    rng = np.random.default_rng(seed)
    draws = []
    for _ in range(resamples):
        b = rng.choice(before, size=before.size, replace=True).mean()
        a = rng.choice(after, size=after.size, replace=True).mean()
        draws.append(100.0 * (a / b - 1.0))
    values = np.asarray(draws)
    return float(np.quantile(values, 0.025)), float(np.quantile(values, 0.975))


# ---------------------------------------------------------------------------
# Figure 1 -- data quality
# ---------------------------------------------------------------------------


def draw_missing_by_column(ax: Axes, frame: pd.DataFrame) -> None:
    counts = frame.isna().sum()
    ax.bar(list(counts.index), list(counts.to_numpy()), color=SAFE_PALETTE[0])
    ax.set_xlabel("column")
    ax.set_ylabel("rows with no value")
    ax.set_title("Missing values by column")


def analyse_missing(frame: pd.DataFrame) -> Finding:
    missing = frame["revenue"].isna()
    n_missing = int(missing.sum())
    pct = 100.0 * n_missing / len(frame)
    partner_share = 100.0 * float((frame.loc[missing, "channel"] == "partner").mean())
    indicator = missing.to_numpy(dtype=float)
    low, high = bootstrap_interval(indicator, statistic=lambda a: 100.0 * a.mean())
    return Finding(
        caption=(
            f"{n_missing} of {len(frame)} rows ({pct:.1f}%) have no revenue, and "
            f"{partner_share:.0f}% of those gaps are partner rows -- the missingness "
            "is a channel problem, not random loss"
        ),
        prose=(
            "Every other column is complete. Because the gaps sit entirely in one "
            "channel, any figure that pools the two channels and drops missing rows "
            "silently under-counts partner activity, so the rest of this report uses "
            "direct-channel rows wherever a level is being compared."
        ),
        estimate=Estimate(
            label="share of rows with missing revenue",
            value=pct,
            unit="%",
            low=low,
            high=high,
        ),
    )


# ---------------------------------------------------------------------------
# Figure 2 -- univariate
# ---------------------------------------------------------------------------


def draw_channel_populations(ax: Axes, frame: pd.DataFrame) -> None:
    direct = frame.loc[frame["channel"] == "direct", "revenue"].dropna()
    partner = frame.loc[frame["channel"] == "partner", "revenue"].dropna()
    bins = np.histogram_bin_edges(frame["revenue"].dropna(), bins="fd")
    ax.hist(direct, bins=bins, color=SAFE_PALETTE[0], alpha=0.75, label="direct")
    ax.hist(partner, bins=bins, color=SAFE_PALETTE[1], alpha=0.75, label="partner")
    ax.set_xlabel("monthly revenue for one region and channel")
    ax.set_ylabel("number of region-months")
    ax.set_title("Revenue is a mixture of two channels")
    ax.legend()


def analyse_populations(frame: pd.DataFrame) -> Finding:
    direct = frame.loc[frame["channel"] == "direct", "revenue"].dropna().to_numpy(float)
    partner = frame.loc[frame["channel"] == "partner", "revenue"].dropna().to_numpy(float)
    ratio = 100.0 * float(np.median(partner)) / float(np.median(direct))
    low, high = bootstrap_interval(partner, statistic=np.median)
    return Finding(
        caption=(
            f"Revenue is two populations rather than one: the median partner "
            f"region-month is {ratio:.0f}% of the median direct region-month, so any "
            "average taken across both channels describes a mixture nobody sells into"
        ),
        prose=(
            "The two histograms barely overlap. A single mean over this column would "
            "land in the empty gap between them and describe no real region-month at "
            "all -- Day 116's warning about a summary that discards the thing you "
            "needed, met again in a column you would have been tempted to average."
        ),
        estimate=Estimate(
            label="median partner region-month revenue",
            value=float(np.median(partner)),
            unit=" USD",
            low=low,
            high=high,
            decimals=0,
        ),
    )


# ---------------------------------------------------------------------------
# Figure 3 -- relationship
# ---------------------------------------------------------------------------


def draw_orders_vs_revenue(ax: Axes, frame: pd.DataFrame) -> None:
    rows = frame.dropna(subset=["revenue"])
    x = rows["orders"].to_numpy(dtype=float)
    y = rows["revenue"].to_numpy(dtype=float)
    slope, intercept = np.polyfit(x, y, 1)
    grid = np.linspace(x.min(), x.max(), 50)
    ax.scatter(x, y, s=12, color=SAFE_PALETTE[0], label="region-month")
    ax.plot(grid, slope * grid + intercept, color=SAFE_PALETTE[3], label="least-squares fit")
    ax.set_xlabel("orders in the month")
    ax.set_ylabel("revenue in the month (USD)")
    ax.set_title("Orders and revenue")
    ax.legend()


def analyse_relationship(frame: pd.DataFrame) -> Finding:
    rows = frame.dropna(subset=["revenue"])
    x = rows["orders"].to_numpy(dtype=float)
    y = rows["revenue"].to_numpy(dtype=float)
    slope, intercept = np.polyfit(x, y, 1)
    predicted = slope * x + intercept
    r_squared = 1.0 - float(np.sum((y - predicted) ** 2) / np.sum((y - y.mean()) ** 2))
    per_order = y / x
    low, high = bootstrap_interval(per_order)
    return Finding(
        caption=(
            f"Revenue rises about {slope:.0f} USD per additional order and the "
            f"straight-line fit accounts for {100.0 * r_squared:.1f}% of the variation, "
            "so a region-month that missed its revenue missed its order count"
        ),
        prose=(
            "There is no second cluster off the line and no curvature worth naming. "
            "That is a boring finding, and it is in the report precisely because it "
            "closes a question the reader would otherwise have to ask: revenue here "
            "is not being moved by price. The fitted slope "
            f"({slope:.0f} USD) sits a little below the mean revenue per order "
            f"({per_order.mean():.0f} USD) because the fit carries a non-zero "
            f"intercept of {intercept:,.0f} USD; the two numbers answer slightly "
            "different questions and the report says which is which rather than "
            "quoting whichever is larger."
        ),
        estimate=Estimate(
            label="mean revenue per order",
            value=float(per_order.mean()),
            unit=" USD",
            low=low,
            high=high,
        ),
    )


# ---------------------------------------------------------------------------
# Figure 4 -- segments
# ---------------------------------------------------------------------------


def draw_region_trend(ax: Axes, frame: pd.DataFrame) -> None:
    rows = _direct(frame)
    for index, region in enumerate(REGIONS):
        series = rows[rows["region"] == region].sort_values("month")
        ax.plot(
            series["month"].to_numpy(),
            series["revenue"].to_numpy(),
            color=SAFE_PALETTE[index],
            label=region,
        )
    ax.axvline(
        PRICING_CHANGE_MONTH - 0.5,
        color=SAFE_PALETTE[7],
        linestyle="--",
        label=f"pricing change (month {PRICING_CHANGE_MONTH})",
    )
    ax.set_xlabel("month")
    ax.set_ylabel("direct-channel revenue (USD)")
    ax.set_title("Direct revenue by region")
    ax.legend(fontsize=7, ncols=2)


def analyse_segments(frame: pd.DataFrame) -> Finding:
    changes: dict[str, float] = {}
    for region in REGIONS:
        before = _window_mean(frame, region, BEFORE_WINDOW)
        after = _window_mean(frame, region, AFTER_WINDOW)
        changes[region] = 100.0 * (after / before - 1.0)

    west = changes["West"]
    grew = [region for region, change in changes.items() if change > 0]
    low, high = _ratio_interval(
        _window_values(frame, "West", BEFORE_WINDOW),
        _window_values(frame, "West", AFTER_WINDOW),
    )

    # The East's after-window contains the single month-18 spike. Recompute it
    # without that one observation, because quoting the inflated figure without
    # saying so would be exactly the failure Day 132 named.
    clean_after = _direct(frame)
    clean_after = clean_after[
        (clean_after["region"] == ANOMALY_REGION)
        & clean_after["month"].between(*AFTER_WINDOW)
        & (clean_after["month"] != ANOMALY_MONTH)
    ]
    east_without_anomaly = 100.0 * (
        float(clean_after["revenue"].mean()) / _window_mean(frame, ANOMALY_REGION, BEFORE_WINDOW)
        - 1.0
    )
    direction = "fell" if west < 0 else "rose"
    return Finding(
        caption=(
            f"{len(grew)} regions grew across the pricing change while the West "
            f"{direction} {abs(west):.1f}%, and the break lands in month "
            f"{PRICING_CHANGE_MONTH} in that region only"
        ),
        prose=(
            "Comparing the six months before month "
            f"{PRICING_CHANGE_MONTH} with the six months after, the four regions move "
            + ", ".join(f"{region} {change:+.1f}%" for region, change in changes.items())
            + ". The West is the only one that changes direction, and it changes it at "
            "the month the price moved. This is an association in observational data, "
            "not a controlled comparison: nothing here rules out a third cause that "
            f"happened to the West in the same month. {ANOMALY_REGION}'s "
            f"{changes[ANOMALY_REGION]:+.1f}% is not what it looks like either: drop the "
            f"single month-{ANOMALY_MONTH} observation and it falls to "
            f"{east_without_anomaly:+.1f}%, which is why Figure 5 exists."
        ),
        estimate=Estimate(
            label="West change across the pricing change (six months either side)",
            value=west,
            unit="%",
            low=low,
            high=high,
        ),
    )


# ---------------------------------------------------------------------------
# Figure 5 -- anomaly
# ---------------------------------------------------------------------------


def draw_east_anomaly(ax: Axes, frame: pd.DataFrame) -> None:
    rows = _direct(frame)
    series = rows[rows["region"] == ANOMALY_REGION].sort_values("month")
    months = series["month"].to_numpy()
    colours = [
        SAFE_PALETTE[3] if month == ANOMALY_MONTH else SAFE_PALETTE[0] for month in months
    ]
    ax.bar(months, series["revenue"].to_numpy(), color=colours)
    ax.set_xlabel("month")
    ax.set_ylabel("direct-channel revenue (USD)")
    ax.set_title(f"{ANOMALY_REGION}: one month is not like the others")


def analyse_anomaly(frame: pd.DataFrame) -> Finding:
    rows = _direct(frame)
    series = rows[rows["region"] == ANOMALY_REGION]
    spike = float(series.loc[series["month"] == ANOMALY_MONTH, "revenue"].iloc[0])
    others = series.loc[series["month"] != ANOMALY_MONTH, "revenue"].to_numpy(float)
    multiple = spike / float(np.median(others))
    neighbours = series[series["month"].isin([ANOMALY_MONTH - 1, ANOMALY_MONTH + 1])]
    neighbour_multiple = float(neighbours["revenue"].mean()) / float(np.median(others))
    return Finding(
        caption=(
            f"{ANOMALY_REGION} month {ANOMALY_MONTH} is {multiple:.1f} times the "
            f"region's median month while the months either side sit at "
            f"{neighbour_multiple:.2f} times it, so this is one observation and not a "
            "level change"
        ),
        prose=(
            "The distinction matters for what you do next. A level change is a fact "
            "about the business and belongs in the forecast; a single spike is a fact "
            "about one month and belongs with whoever can explain it. Until someone "
            "does, the honest move is to report both the figure including it and the "
            "figure excluding it, and to say which one the decision was made on."
        ),
        estimate=Estimate(
            label=f"{ANOMALY_REGION} month {ANOMALY_MONTH} as a multiple of the region median",
            value=multiple,
            unit="x",
            no_interval_note=(
                "a single observation has no sampling interval; one point is one point"
            ),
        ),
    )


# ---------------------------------------------------------------------------
# The twelve candidates
# ---------------------------------------------------------------------------


def candidate_figures() -> list[Candidate]:
    """Everything exploration produced, in the order it was produced.

    Five carry a question. Seven do not, and `report.survivors` drops them.
    """
    return [
        Candidate(
            slug="missing-revenue",
            question="Which rows have no revenue, and is the missingness concentrated anywhere?",
            draw=draw_missing_by_column,
            analyse=analyse_missing,
        ),
        Candidate(
            slug="cumulative-revenue",
            dropped_because=(
                "A cumulative revenue curve was drawn and discarded: a cumulative "
                "series rises whatever the underlying months do, so it answered no "
                "question the monthly series had not already answered"
            ),
        ),
        Candidate(
            slug="channel-populations",
            question="Is monthly revenue one population, or several stacked on top of each other?",
            draw=draw_channel_populations,
            analyse=analyse_populations,
        ),
        Candidate(
            slug="orders-histogram",
            dropped_because=(
                "The order-count distribution was checked for a second population; it "
                "shows the same channel split the revenue column already shows, so it "
                "adds no evidence of its own"
            ),
        ),
        Candidate(
            slug="orders-vs-revenue",
            question="How tightly do orders and revenue move together, and what is one extra order worth?",
            draw=draw_orders_vs_revenue,
            analyse=analyse_relationship,
        ),
        Candidate(
            slug="region-channel-interaction",
            dropped_because=(
                "Region-by-channel interaction was checked; the partner channel runs "
                "at the same share of direct in all four regions, so there is no "
                "interaction to report"
            ),
        ),
        Candidate(
            slug="region-trend",
            question="Did any region's trajectory change at the month-13 pricing change?",
            draw=draw_region_trend,
            analyse=analyse_segments,
        ),
        Candidate(
            slug="revenue-per-order-by-region",
            dropped_because=(
                "Revenue per order was compared across the four regions; they are "
                "indistinguishable on this measure, which is worth one line here so "
                "the next reader does not spend an afternoon on it"
            ),
        ),
        Candidate(
            slug="east-anomaly",
            question="Is the East's month-18 jump a level change or a single outlier?",
            draw=draw_east_anomaly,
            analyse=analyse_anomaly,
        ),
        Candidate(
            slug="calendar-seasonality",
            dropped_because=(
                "Calendar seasonality was checked by lining the two years up month "
                "against month; nothing stood out above the month-to-month noise"
            ),
        ),
        Candidate(
            slug="missing-by-month",
            dropped_because=(
                "The missing revenue rows were checked for a time pattern as well as "
                "a channel pattern; they are scattered across the two years rather "
                "than clustered in any single month"
            ),
        ),
        Candidate(
            slug="revenue-heatmap",
            dropped_because=(
                "A region-by-month heatmap was drawn and discarded: it held the same "
                "information as the trend lines while making the month-13 break "
                "harder to see, which is the wrong trade for a report"
            ),
        ),
    ]


def build_report(frame: pd.DataFrame) -> Report:
    """Run the whole pipeline: candidates, filter, panels, ready to render."""
    candidates = candidate_figures()
    report = Report(
        title="Where did the West's revenue go?",
        question=(
            "Four regions sell through two channels. Did any region's revenue "
            "trajectory change around the month-13 pricing change, and is the change "
            "big enough and clean enough to act on?"
        ),
        decision=(
            "Whether to roll the month-13 pricing change back in the West before it "
            "is extended to the other three regions."
        ),
        provenance=source_description(),
        caveats=[
            "This is observational data, not an experiment. The month-13 break is an "
            "association in time; it is not proof that the pricing change caused it.",
            "The regional comparison uses six months either side of the change. Six "
            "observations per side is a small window, and the interval on that number "
            "is correspondingly wide -- read the interval, not the point estimate.",
            "Level comparisons use direct-channel rows only, because the partner "
            "channel is the one with missing revenue. Partner totals in this report "
            "are therefore lower bounds.",
            "Every figure here uses a colourblind-safe palette and labelled axes, and "
            "no axis in this report is truncated below zero.",
        ],
        null_results=[candidate.dropped_because for candidate in discarded(candidates)],
    )
    for candidate in survivors(candidates):
        report.add_panel(candidate, frame)
    return report


# ---------------------------------------------------------------------------
# Two deliberately broken specimens, used by the exercises
# ---------------------------------------------------------------------------


def draw_inaccessible(ax: Axes, frame: pd.DataFrame) -> None:
    """A chart that breaks the accessibility contract in three ways at once.

    Red against green is the classic pair that vanishes for the commonest
    form of colour vision deficiency, and neither axis is labelled. Exercise 9
    asserts that the build check catches all of it.
    """
    rows = _direct(frame)
    for region, colour in zip(REGIONS[:2], ("red", "green")):
        series = rows[rows["region"] == region].sort_values("month")
        ax.plot(series["month"].to_numpy(), series["revenue"].to_numpy(), color=colour)


def bare_point_estimate_candidate() -> Candidate:
    """A candidate whose finding reports a number with no uncertainty at all."""

    def analyse(frame: pd.DataFrame) -> Finding:
        total = float(frame["revenue"].sum())
        return Finding(
            caption=f"Total recorded revenue across the two years is {total:,.0f} USD",
            prose="A number with nothing attached to say how firm it is.",
            estimate=Estimate(label="total recorded revenue", value=total, unit=" USD", decimals=0),
        )

    return Candidate(
        slug="bare-total",
        question="What is the total recorded revenue?",
        draw=draw_missing_by_column,
        analyse=analyse,
    )
examples/conftest.py (1390 bytes)
"""Shared fixtures and headless matplotlib setup.

pytest finds this file by itself -- nothing imports it. `matplotlib.use`
must run before `pyplot` is imported anywhere, which is why it happens
here, first and unconditionally. Every figure this lab opens is closed by
the code that opened it; `_close_all_figures` is a backstop, not a
substitute.

Every directory fixture is a real temporary directory that is deleted when
the test finishes, so a full run of this lab leaves no image and no
Markdown file behind anywhere on your disk.
"""

import tempfile
from pathlib import Path

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import pytest

from analysis import candidate_figures
from data import monthly_sales, perturbed


@pytest.fixture
def frame():
    return monthly_sales()


@pytest.fixture
def perturbed_frame():
    return perturbed()


@pytest.fixture
def candidates():
    return candidate_figures()


def _temporary_directory():
    with tempfile.TemporaryDirectory(prefix="d133-report-") as name:
        yield Path(name)


@pytest.fixture
def report_dir():
    yield from _temporary_directory()


@pytest.fixture
def second_report_dir():
    yield from _temporary_directory()


@pytest.fixture
def third_report_dir():
    yield from _temporary_directory()


@pytest.fixture(autouse=True)
def _close_all_figures():
    yield
    plt.close("all")
examples/data.py (4679 bytes)
"""The dataset the Day 133 report is built from, and one perturbed copy.

Everything here is deterministic. `monthly_sales()` draws from a fixed
`numpy.random.default_rng(133)`, so two calls return identical frames and
two runs of the lab produce identical numbers. That determinism is not a
convenience -- exercise 6 asserts byte-identical output across two runs,
and a report generator that cannot be re-run to the same answer is not a
report generator, it is a one-off.

The frame deliberately contains four things a real exploratory analysis
would have to find:

1. **Missing revenue, not spread evenly.** About 4% of rows have no
   revenue, and every one of them is a `partner` row. Week 18's damage
   report exists to catch exactly this shape.
2. **Two populations in one column.** `partner` rows run at roughly 45%
   of the `direct` row for the same region and month, so the revenue
   column is a mixture, not a single distribution.
3. **A step change in one segment.** From month 13 the West is scaled by
   `WEST_PRICING_FACTOR`, standing in for a pricing change. The other
   three regions keep their steady monthly growth.
4. **One anomaly.** East, month 18, is scaled by `ANOMALY_FACTOR`. It is
   a single point, which is why the report that quotes it says plainly
   that no interval is available for it.

`perturbed()` returns a copy with one input changed. Exercises 3 and 6
use it to prove that the numbers printed in the report's prose come from
the data rather than from typed literals.
"""

from __future__ import annotations

import numpy as np
import pandas as pd

REGIONS: tuple[str, ...] = ("North", "South", "East", "West")
CHANNELS: tuple[str, ...] = ("direct", "partner")

N_MONTHS = 24
SEED = 133

#: From this month onward the West is scaled by WEST_PRICING_FACTOR.
PRICING_CHANGE_MONTH = 13
WEST_PRICING_FACTOR = 0.88

#: The single anomalous observation.
ANOMALY_REGION = "East"
ANOMALY_MONTH = 18
ANOMALY_FACTOR = 3.0

#: Share of rows whose revenue is missing. Every one is a partner row.
MISSING_RATE = 0.04

#: Partner rows run at this share of the same region-month direct row.
PARTNER_SHARE = 0.45

_BASE_LEVEL = {"North": 42000.0, "South": 31000.0, "East": 27000.0, "West": 36000.0}
_MONTHLY_GROWTH = {"North": 1.012, "South": 1.008, "East": 1.015, "West": 1.010}


def monthly_sales() -> pd.DataFrame:
    """Twenty-four months of revenue and orders for four regions and two
    channels: 192 rows, eight of them missing their revenue."""
    rng = np.random.default_rng(SEED)
    records: list[dict[str, object]] = []

    for month in range(1, N_MONTHS + 1):
        for region in REGIONS:
            for channel in CHANNELS:
                level = _BASE_LEVEL[region] * _MONTHLY_GROWTH[region] ** (month - 1)
                if channel == "partner":
                    level *= PARTNER_SHARE
                if region == "West" and month >= PRICING_CHANGE_MONTH:
                    level *= WEST_PRICING_FACTOR
                if region == ANOMALY_REGION and month == ANOMALY_MONTH:
                    level *= ANOMALY_FACTOR

                revenue = float(level * rng.normal(1.0, 0.05))
                order_value = float(rng.normal(180.0, 12.0))
                records.append(
                    {
                        "month": month,
                        "region": region,
                        "channel": channel,
                        "orders": int(round(revenue / order_value)),
                        "revenue": revenue,
                    }
                )

    frame = pd.DataFrame.from_records(records)

    partner_positions = np.asarray(frame.index[frame["channel"] == "partner"])
    n_missing = int(round(MISSING_RATE * len(frame)))
    blanked = rng.choice(partner_positions, size=n_missing, replace=False)
    frame.loc[blanked, "revenue"] = np.nan

    return frame


def perturbed() -> pd.DataFrame:
    """The same frame with exactly one input changed: the West's post-change
    scaling is halved again.

    Nothing else moves. If a number printed in the report's prose is the
    same for `monthly_sales()` and for this frame, that number was typed
    rather than computed -- which is the whole point of exercise 3.
    """
    frame = monthly_sales()
    hit = (frame["region"] == "West") & (frame["month"] >= PRICING_CHANGE_MONTH)
    frame.loc[hit, "revenue"] = frame.loc[hit, "revenue"] * 0.5
    return frame


def source_description() -> str:
    """One line of provenance, quoted verbatim in the rendered report."""
    return (
        "synthetic monthly sales, 4 regions x 2 channels x 24 months, "
        "generated by data.monthly_sales() with numpy default_rng(133)"
    )
examples/report.py (17065 bytes)
"""A small EDA report generator that refuses to build a bad report.

This is the course-supplied tool for Day 133. It is deliberately small
enough to read in one sitting, because the point is not the code -- it is
the four rules the code refuses to bend:

* **A figure must have a stated question.** `Report.add_panel` raises
  `ReportError` on a candidate whose `question` is missing or blank.
* **A caption must carry a claim.** `carries_claim` demands a number or a
  comparative word, so "revenue by region" is rejected and "the West fell
  8.4% after the pricing change" is accepted. The heuristic is crude on
  purpose: it can tell that a claim was *made*, never whether the claim is
  *true*. Judging truth is the reader's job and yours.
* **Every figure is referenced.** `orphan_figures` compares what was
  written to disk against what the markdown links to.
* **Every reported estimate carries its uncertainty**, either as an
  interval or as an explicit note that no interval is available.
  `missing_uncertainty` lists the ones that carry neither.

Two more checks sit alongside those: `check_axes` enforces the
accessibility contract (a colourblind-safe palette and labelled axes), and
`survivors` is the "so what" filter that drops candidate figures with no
question before they ever reach the report.

The renderer writes Markdown with embedded figures. It contains no clock
reading and no random number, so two runs over the same input produce
byte-identical Markdown -- which exercise 6 asserts directly.
"""

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterable, Sequence

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.colors import to_hex

__all__ = [
    "SAFE_PALETTE",
    "ReportError",
    "Estimate",
    "Finding",
    "Candidate",
    "Panel",
    "Report",
    "carries_claim",
    "claim_problem",
    "survivors",
    "orphan_figures",
    "missing_uncertainty",
    "axes_colours",
    "check_axes",
    "bootstrap_interval",
]


#: seaborn's "colorblind" palette, as hex. Read off
#: `seaborn.color_palette("colorblind")` on seaborn 0.13.2 and frozen here so
#: the accessibility check has an exact set to compare against.
SAFE_PALETTE: tuple[str, ...] = (
    "#0173b2",
    "#de8f05",
    "#029e73",
    "#d55e00",
    "#cc78bc",
    "#ca9161",
    "#fbafe4",
    "#949494",
    "#ece133",
    "#56b4e9",
)


class ReportError(ValueError):
    """Raised when a figure would break one of the report's own rules."""


# ---------------------------------------------------------------------------
# The caption rule: a caption must carry a claim
# ---------------------------------------------------------------------------

#: Words that turn a label into a comparison. Not exhaustive, and not meant
#: to be -- see `carries_claim` for what this check can and cannot do.
CLAIM_WORDS: frozenset[str] = frozenset(
    {
        "above",
        "below",
        "beyond",
        "concentrated",
        "decrease",
        "decreased",
        "double",
        "doubled",
        "drop",
        "dropped",
        "every",
        "exceeds",
        "fall",
        "fell",
        "grew",
        "growth",
        "half",
        "halved",
        "higher",
        "increase",
        "increased",
        "less",
        "lower",
        "more",
        "no",
        "none",
        "only",
        "outgrew",
        "rise",
        "rose",
        "shrank",
        "than",
        "times",
        "unchanged",
        "versus",
        "while",
    }
)


def claim_problem(caption: str) -> str | None:
    """Return why `caption` fails the claim rule, or None if it passes.

    A caption passes if it contains a digit, a percent sign, or one of
    `CLAIM_WORDS`. That is a crude test and it is meant to be: it detects
    whether a *claim was made*, and it has no way at all to judge whether
    the claim is *true*. A caption reading "revenue tripled" passes this
    check on data where revenue halved. The check buys you one thing --
    it makes the absence of a claim impossible to ship by accident.
    """
    text = caption.strip()
    if not text:
        return "the caption is empty"
    if any(character.isdigit() for character in text) or "%" in text:
        return None
    words = set(re.findall(r"[a-z]+", text.lower()))
    if words & CLAIM_WORDS:
        return None
    return (
        "the caption is a label, not a claim: it contains no number and no "
        "comparative word, so there is nothing in it a reader could disagree with"
    )


def carries_claim(caption: str) -> bool:
    """True when `caption` states something a reader could disagree with."""
    return claim_problem(caption) is None


# ---------------------------------------------------------------------------
# Estimates and their uncertainty
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Estimate:
    """One number the report asserts, with its uncertainty attached.

    Either give it an interval (`low` and `high`, from Day 118's machinery
    or `bootstrap_interval` below) or an explicit `no_interval_note` saying
    why one is not available. A bare point estimate carries neither, and
    `missing_uncertainty` will find it.
    """

    label: str
    value: float
    unit: str = ""
    low: float | None = None
    high: float | None = None
    no_interval_note: str | None = None
    decimals: int = 1

    def has_uncertainty(self) -> bool:
        interval = self.low is not None and self.high is not None
        return interval or bool(self.no_interval_note)

    def text(self) -> str:
        value = f"{self.value:.{self.decimals}f}{self.unit}"
        if self.low is not None and self.high is not None:
            low = f"{self.low:.{self.decimals}f}{self.unit}"
            high = f"{self.high:.{self.decimals}f}{self.unit}"
            return f"{self.label}: {value} (95% interval {low} to {high})"
        if self.no_interval_note:
            return f"{self.label}: {value} (no interval: {self.no_interval_note})"
        return f"{self.label}: {value}"


def bootstrap_interval(
    values: Sequence[float] | np.ndarray,
    *,
    statistic: Callable[[np.ndarray], float] = np.mean,
    resamples: int = 2000,
    level: float = 0.95,
    seed: int = 133,
) -> tuple[float, float]:
    """A percentile bootstrap interval, seeded so the report stays reproducible.

    Day 118's interval, computed by resampling rather than by formula, which
    is what you reach for when the statistic has no tidy standard error.
    """
    sample = np.asarray(values, dtype=float)
    sample = sample[~np.isnan(sample)]
    if sample.size < 2:
        raise ReportError("a bootstrap interval needs at least two observations")
    rng = np.random.default_rng(seed)
    draws = rng.choice(sample, size=(resamples, sample.size), replace=True)
    stats = np.array([float(statistic(row)) for row in draws])
    tail = (1.0 - level) / 2.0
    return float(np.quantile(stats, tail)), float(np.quantile(stats, 1.0 - tail))


# ---------------------------------------------------------------------------
# Candidates, findings and panels
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Finding:
    """What one figure turned out to say, computed from the data."""

    caption: str
    prose: str
    estimate: Estimate | None = None


@dataclass(frozen=True)
class Candidate:
    """A figure someone made during exploration. Most do not survive.

    `question` is the whole filter. A candidate with no question answers no
    question, and `survivors` drops it before it can reach the report --
    but `dropped_because` is kept, because "we looked and found nothing"
    is worth one line to the next reader.
    """

    slug: str
    question: str | None = None
    draw: Callable[[Axes, pd.DataFrame], None] | None = None
    analyse: Callable[[pd.DataFrame], Finding] | None = None
    dropped_because: str = ""


@dataclass
class Panel:
    """A candidate that survived, with its finding resolved against the data."""

    slug: str
    question: str
    finding: Finding
    draw: Callable[[Axes, pd.DataFrame], None]
    number: int = 0
    image: str = ""


def survivors(candidates: Iterable[Candidate]) -> list[Candidate]:
    """The "so what" filter: keep only candidates that answer a stated question."""
    return [c for c in candidates if c.question and c.question.strip()]


def discarded(candidates: Iterable[Candidate]) -> list[Candidate]:
    """The complement of `survivors` -- what the report will not show."""
    return [c for c in candidates if not (c.question and c.question.strip())]


# ---------------------------------------------------------------------------
# Accessibility contract
# ---------------------------------------------------------------------------


def axes_colours(ax: Axes) -> list[str]:
    """Every colour actually painted on `ax`, as lowercase hex, alpha dropped."""
    found: list[str] = []
    for patch in ax.patches:
        found.append(to_hex(patch.get_facecolor()))
    for line in ax.get_lines():
        found.append(to_hex(line.get_color()))
    for collection in ax.collections:
        for rgba in np.atleast_2d(collection.get_facecolor()):
            found.append(to_hex(rgba))
    return [colour.lower() for colour in found]


def check_axes(ax: Axes) -> list[str]:
    """Problems with `ax` against the report's accessibility contract.

    Days 127 and 132, turned into a build check: every mark must be drawn in
    a colourblind-safe colour, and both axes must be labelled. Returns an
    empty list when the axes pass.
    """
    problems: list[str] = []
    if not ax.get_xlabel().strip():
        problems.append("the x axis has no label")
    if not ax.get_ylabel().strip():
        problems.append("the y axis has no label")
    safe = {colour.lower() for colour in SAFE_PALETTE}
    for colour in sorted(set(axes_colours(ax))):
        if colour not in safe:
            problems.append(f"colour {colour} is not in the colourblind-safe palette")
    return problems


def accessibility_problems(
    draw: Callable[[Axes, pd.DataFrame], None], frame: pd.DataFrame
) -> list[str]:
    """Draw into a throwaway figure and run `check_axes` on the result."""
    figure, ax = plt.subplots(figsize=(6.0, 3.5), dpi=100)
    try:
        draw(ax, frame)
        return check_axes(ax)
    finally:
        plt.close(figure)


# ---------------------------------------------------------------------------
# The report
# ---------------------------------------------------------------------------

_IMAGE_LINK = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")


def orphan_figures(markdown: str, figure_dir: Path | str) -> list[str]:
    """Image files on disk that the markdown never links to.

    An orphan is not a cosmetic problem. It is either a figure you meant to
    discuss and forgot, or a leftover from a previous run that will confuse
    whoever opens the directory next.
    """
    referenced = {Path(target).name for target in _IMAGE_LINK.findall(markdown)}
    directory = Path(figure_dir)
    if not directory.is_dir():
        return []
    return sorted(p.name for p in directory.glob("*.png") if p.name not in referenced)


def missing_uncertainty(report: "Report") -> list[str]:
    """Slugs of panels whose estimate carries neither an interval nor a note."""
    return [
        panel.slug
        for panel in report.panels
        if panel.finding.estimate is not None
        and not panel.finding.estimate.has_uncertainty()
    ]


@dataclass
class Report:
    """An ordered argument with evidence attached."""

    title: str
    question: str
    decision: str
    provenance: str
    caveats: list[str] = field(default_factory=list)
    null_results: list[str] = field(default_factory=list)
    panels: list[Panel] = field(default_factory=list)
    data_fingerprint: str = ""

    def add_panel(self, candidate: Candidate, frame: pd.DataFrame) -> Panel:
        """Resolve one candidate against the data and admit it to the report.

        Raises `ReportError` when the candidate has no stated question, when
        its caption is a label rather than a claim, or when it has no way to
        draw itself or to compute its finding.
        """
        if not candidate.question or not candidate.question.strip():
            raise ReportError(
                f"figure {candidate.slug!r} has no stated question; a figure whose "
                "question you cannot state is a figure that does not belong in the report"
            )
        if candidate.draw is None or candidate.analyse is None:
            raise ReportError(
                f"figure {candidate.slug!r} has a question but no way to answer it"
            )

        finding = candidate.analyse(frame)
        problem = claim_problem(finding.caption)
        if problem is not None:
            raise ReportError(f"figure {candidate.slug!r}: {problem}")

        panel = Panel(
            slug=candidate.slug,
            question=candidate.question.strip(),
            finding=finding,
            draw=candidate.draw,
            number=len(self.panels) + 1,
        )
        panel.image = f"figures/{panel.number:02d}-{panel.slug}.png"
        self.panels.append(panel)
        return panel

    # -- rendering ---------------------------------------------------------

    def render(self, outdir: Path | str, frame: pd.DataFrame) -> str:
        """Write the figures and `report.md` into `outdir`, and return the markdown.

        The output contains no clock reading and no unseeded random number,
        so two runs over the same input are byte-identical. Provenance is a
        fingerprint of the data, not a timestamp of the run.
        """
        directory = Path(outdir)
        figure_dir = directory / "figures"
        figure_dir.mkdir(parents=True, exist_ok=True)

        for panel in self.panels:
            figure, ax = plt.subplots(figsize=(6.0, 3.5), dpi=100)
            try:
                panel.draw(ax, frame)
                figure.tight_layout()
                figure.savefig(directory / panel.image)
            finally:
                plt.close(figure)

        markdown = self._markdown(frame)
        (directory / "report.md").write_text(markdown, encoding="utf-8")
        return markdown

    def fingerprint(self, frame: pd.DataFrame) -> str:
        """A short content hash of the input, so provenance names the data."""
        payload = frame.to_csv(index=False).encode("utf-8")
        return hashlib.sha256(payload).hexdigest()[:12]

    def _markdown(self, frame: pd.DataFrame) -> str:
        lines: list[str] = []
        add = lines.append

        add(f"# {self.title}")
        add("")
        add(f"**Question.** {self.question}")
        add("")
        add(f"**Decision this feeds.** {self.decision}")
        add("")

        add("## Conclusion")
        add("")
        if not self.panels:
            add("No figure in this analysis answered a stated question.")
        for panel in self.panels:
            add(f"{panel.number}. {panel.finding.caption} (Figure {panel.number})")
        add("")
        for panel in self.panels:
            if panel.finding.estimate is not None:
                add(f"- {panel.finding.estimate.text()}")
        add("")

        add("## What we looked at and found nothing in")
        add("")
        if self.null_results:
            for note in self.null_results:
                add(f"- {note}")
        else:
            add("- Nothing was set aside; every candidate figure answered a question.")
        add("")

        add("## Evidence")
        add("")
        for panel in self.panels:
            add(f"### Figure {panel.number} — {panel.question}")
            add("")
            add(f"![{panel.question}]({panel.image})")
            add("")
            add(f"**Figure {panel.number}.** {panel.finding.caption}")
            add("")
            add(panel.finding.prose)
            add("")
            if panel.finding.estimate is not None:
                add(f"*{panel.finding.estimate.text()}*")
                add("")

        add("## Caveats")
        add("")
        for caveat in self.caveats:
            add(f"- {caveat}")
        add("")

        add("## Provenance")
        add("")
        add(f"- Source: {self.provenance}")
        add(f"- Shape: {len(frame)} rows, {len(frame.columns)} columns")
        add(f"- Data fingerprint (sha256, first 12): `{self.fingerprint(frame)}`")
        add(
            "- This document was generated by code from the input above. Nothing in "
            "it was typed by hand, so re-running it on new data cannot leave the "
            "prose disagreeing with the figures."
        )
        add("")

        return "\n".join(lines)
examples/test_report.py (11647 bytes)
"""Reference solutions -- Day 133, A Report That Argues.

Nine exercises. Each one asserts on real behaviour of the report generator
in `report.py` running over the real frame in `data.py` -- never on image
bytes, and never on the presence of a file alone.

Run with: pytest examples -q
"""

from __future__ import annotations

import re
import shutil
from pathlib import Path

import pytest

from analysis import (
    analyse_missing,
    bare_point_estimate_candidate,
    build_report,
    candidate_figures,
    draw_inaccessible,
    draw_missing_by_column,
)
from report import (
    Candidate,
    Finding,
    Report,
    ReportError,
    accessibility_problems,
    carries_claim,
    claim_problem,
    discarded,
    missing_uncertainty,
    orphan_figures,
    survivors,
)


def line_containing(markdown: str, needle: str) -> str:
    """The one line of `markdown` that contains `needle`."""
    hits = [line for line in markdown.splitlines() if needle in line]
    assert len(hits) >= 1, f"no line contains {needle!r}"
    return hits[0]


def percentage_in(text: str) -> float:
    """The first signed percentage in `text`, as a float."""
    match = re.search(r"(-?\d+(?:\.\d+)?)%", text)
    assert match is not None, f"no percentage in {text!r}"
    return float(match.group(1))


def blank_report() -> Report:
    return Report(
        title="A scratch report",
        question="Does the generator hold its own line?",
        decision="Whether to trust anything this generator produces.",
        provenance="the same synthetic frame the real report uses",
    )


# --------------------------------------------------------------------------
# Exercise 1 -- a figure must have a question.
# --------------------------------------------------------------------------


def test_01_a_figure_must_have_a_question(frame):
    report = blank_report()

    nameless = Candidate(
        slug="pretty-chart",
        draw=draw_missing_by_column,
        analyse=analyse_missing,
    )
    with pytest.raises(ReportError) as raised:
        report.add_panel(nameless, frame)
    assert "no stated question" in str(raised.value)
    assert report.panels == [], "a refused figure must not be half-admitted"

    blank = Candidate(
        slug="pretty-chart",
        question="   ",
        draw=draw_missing_by_column,
        analyse=analyse_missing,
    )
    with pytest.raises(ReportError):
        report.add_panel(blank, frame)

    asked = Candidate(
        slug="pretty-chart",
        question="Which rows have no revenue?",
        draw=draw_missing_by_column,
        analyse=analyse_missing,
    )
    panel = report.add_panel(asked, frame)
    assert panel.question == "Which rows have no revenue?"
    assert len(report.panels) == 1
    assert panel.number == 1
    assert panel.image == "figures/01-pretty-chart.png"


# --------------------------------------------------------------------------
# Exercise 2 -- the caption carries the claim.
# --------------------------------------------------------------------------


def test_02_caption_carries_a_claim(frame):
    # A label. Nothing in it a reader could disagree with.
    assert carries_claim("revenue by region") is False
    assert "label, not a claim" in claim_problem("revenue by region")
    assert carries_claim("monthly revenue, all regions") is False
    assert carries_claim("") is False

    # A claim: it has a number in it.
    assert carries_claim(
        "three regions grew, and the fourth fell by 12% after the March pricing change"
    )
    # A claim: no number, but a comparison.
    assert carries_claim("partner revenue is lower than direct revenue in every region")

    # The generator refuses a panel whose caption is only a label.
    report = blank_report()
    labelled = Candidate(
        slug="labelled",
        question="What does revenue look like by region?",
        draw=draw_missing_by_column,
        analyse=lambda _frame: Finding(caption="revenue by region", prose="No claim here."),
    )
    with pytest.raises(ReportError) as raised:
        report.add_panel(labelled, frame)
    assert "label, not a claim" in str(raised.value)

    # The honest limits of the check, in both directions. It passes a caption
    # that is flatly false, because it cannot read the data ...
    assert carries_claim("revenue doubled in every region")
    # ... and it refuses a genuine claim written without a number and without
    # one of its comparative words. "tripled" is simply not on the list. The
    # check makes the ABSENCE of a claim impossible to ship by accident; it is
    # not, and cannot be, a judge of whether the claim is right.
    assert carries_claim("revenue tripled in all four regions") is False


# --------------------------------------------------------------------------
# Exercise 3 -- numbers in the prose come from the data.
# --------------------------------------------------------------------------


def test_03_numbers_come_from_the_data(frame, perturbed_frame, report_dir, second_report_dir):
    original = build_report(frame).render(report_dir, frame)
    changed = build_report(perturbed_frame).render(second_report_dir, perturbed_frame)

    needle = "West change across the pricing change"
    original_line = line_containing(original, needle)
    changed_line = line_containing(changed, needle)
    assert original_line != changed_line

    original_pct = percentage_in(original_line)
    changed_pct = percentage_in(changed_line)
    assert original_pct < 0.0
    # One input value moved; the sentence moved with it by a wide margin.
    assert changed_pct < original_pct - 20.0

    # The fingerprint in the provenance section moved too, because it is a
    # hash of the input rather than a note about the run.
    assert line_containing(original, "Data fingerprint") != line_containing(
        changed, "Data fingerprint"
    )


# --------------------------------------------------------------------------
# Exercise 4 -- every figure is referenced.
# --------------------------------------------------------------------------


def test_04_no_orphan_figures(frame, report_dir):
    markdown = build_report(frame).render(report_dir, frame)
    figure_dir = report_dir / "figures"

    written = sorted(p.name for p in figure_dir.glob("*.png"))
    assert len(written) == 5
    assert orphan_figures(markdown, figure_dir) == []
    for name in written:
        assert f"figures/{name}" in markdown

    # A leftover from an earlier run is exactly what this check is for.
    shutil.copy(figure_dir / written[0], figure_dir / "99-left-over.png")
    assert orphan_figures(markdown, figure_dir) == ["99-left-over.png"]


# --------------------------------------------------------------------------
# Exercise 5 -- uncertainty is stated, not implied.
# --------------------------------------------------------------------------


def test_05_uncertainty_is_present(frame):
    report = build_report(frame)
    assert missing_uncertainty(report) == []

    estimates = [panel.finding.estimate for panel in report.panels]
    assert all(estimate is not None for estimate in estimates)

    with_interval = [e for e in estimates if e.low is not None and e.high is not None]
    with_note = [e for e in estimates if e.no_interval_note]
    assert len(with_interval) == 4
    assert len(with_note) == 1
    assert "single observation" in with_note[0].no_interval_note
    for estimate in with_interval:
        assert estimate.low < estimate.value < estimate.high
        assert "95% interval" in estimate.text()

    # A bare point estimate carries neither, and the check finds it.
    report.add_panel(bare_point_estimate_candidate(), frame)
    assert missing_uncertainty(report) == ["bare-total"]


# --------------------------------------------------------------------------
# Exercise 6 -- reproducibility.
# --------------------------------------------------------------------------


def test_06_two_runs_are_byte_identical(
    frame, perturbed_frame, report_dir, second_report_dir, third_report_dir
):
    first = build_report(frame).render(report_dir, frame)
    second = build_report(frame).render(second_report_dir, frame)

    assert first == second
    assert (report_dir / "report.md").read_bytes() == (
        second_report_dir / "report.md"
    ).read_bytes()

    # Figure bytes too, on this machine: same backend, same fonts, same run.
    for path in sorted((report_dir / "figures").glob("*.png")):
        twin = second_report_dir / "figures" / path.name
        assert path.read_bytes() == twin.read_bytes()

    # Change one input value and the document changes.
    third = build_report(perturbed_frame).render(third_report_dir, perturbed_frame)
    assert third != first

    # Nothing in the output is a clock reading.
    assert not re.search(r"\b20\d\d-\d\d-\d\d\b", first)


# --------------------------------------------------------------------------
# Exercise 7 -- ordering for the reader.
# --------------------------------------------------------------------------


def test_07_conclusion_comes_before_the_evidence(frame, report_dir):
    markdown = build_report(frame).render(report_dir, frame)

    conclusion = markdown.index("## Conclusion")
    omissions = markdown.index("## What we looked at and found nothing in")
    evidence = markdown.index("## Evidence")
    caveats = markdown.index("## Caveats")
    provenance = markdown.index("## Provenance")

    assert conclusion < omissions < evidence < caveats < provenance
    assert conclusion < markdown.index("### Figure 1")
    # The conclusion is literally the list of captions: the claim the caption
    # carries is the finding, so the two can never drift apart.
    for panel in build_report(frame).panels:
        assert f"{panel.number}. {panel.finding.caption} (Figure {panel.number})" in markdown


# --------------------------------------------------------------------------
# Exercise 8 -- the "so what" filter.
# --------------------------------------------------------------------------


def test_08_the_so_what_filter(frame, candidates, report_dir):
    kept = survivors(candidates)
    dropped = discarded(candidates)

    assert len(candidates) == 12
    assert len(kept) == 5
    assert len(dropped) == 7
    assert len(kept) + len(dropped) == len(candidates)
    # Most of what exploration produced does not survive. That is the ratio.
    assert len(kept) / len(candidates) < 0.5

    markdown = build_report(frame).render(report_dir, frame)
    figure_dir = report_dir / "figures"

    for candidate in dropped:
        assert candidate.slug not in markdown
        assert list(figure_dir.glob(f"*{candidate.slug}*")) == []
        # But the null result survives as one line, so nobody repeats it.
        assert candidate.dropped_because in markdown

    for candidate in kept:
        assert any(candidate.slug in p.name for p in figure_dir.glob("*.png"))


# --------------------------------------------------------------------------
# Exercise 9 -- the accessibility contract, as a build check.
# --------------------------------------------------------------------------


def test_09_accessibility_contract(frame, candidates):
    for candidate in survivors(candidates):
        assert accessibility_problems(candidate.draw, frame) == [], candidate.slug

    problems = accessibility_problems(draw_inaccessible, frame)
    assert "the x axis has no label" in problems
    assert "the y axis has no label" in problems
    assert any("#ff0000" in problem for problem in problems)
    assert any("#008000" in problem for problem in problems)
    assert len(problems) == 4
metadata.yml (3810 bytes)
lesson_id: D133
day: 133
kind: guided-build
languages:
  - python
  - bash
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - >-
    .venv/bin/python3 -c "import matplotlib, pandas; print(matplotlib.__version__,
    pandas.__version__)"
run_commands:
  - .venv/bin/pytest examples
  - .venv/bin/pytest starter
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 .venv  # optional: removes the lab virtual environment'
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 50
last_executed: '2026-08-20'
executed_on: >-
  macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, matplotlib 3.11.1, seaborn 0.13.2,
  pandas 3.0.5, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 42
  checks, 0 failure(s), exit 0. pytest examples -> 9 passed. pytest starter -> 9 skipped
  (untouched checkout). Everything was run through a real lab-local .venv created by the
  documented setup commands. Section 6 of the harness solves every exercise in a scratch
  copy (9 passed), deliberately breaks exercise 8s survivor-count assertion (len(kept)
  == 5 -> len(kept) == 99), confirms a non-zero exit with a printed failure, restores
  the file and confirms 9 passed again, so the suite is demonstrated to be capable of
  failing rather than merely claimed to be. Separately, exercise 8s assertion was broken
  in examples/ itself (== 5 -> == 4) and the whole harness was re-run: it reported 42
  checks, 6 failure(s) and exited 1; the file was restored and the harness returned to
  42 checks, 0 failure(s), exit 0. Section 5 confirms directly that `pytest examples
  starter` in one invocation aborts collection with `import file mismatch` (both
  directories define a module named test_report.py) rather than silently letting one
  shadow the other. MEASURED RESULTS: 12 candidate figures enter the pipeline and 5
  survive the so-what filter (41.7 per cent); the rendered Markdown is byte-identical
  across two runs, and so are all five figure PNGs on this machine within one session.
  THREE HONESTY CALLS. FIRST: the day brief said pandas Styler was installed and could
  be used; measured directly, df.style raises AttributeError: The .style accessor
  requires jinja2, because jinja2 is not in this environment. The lesson therefore
  describes Styler from the pandas documentation and reproduces no Styler output;
  nothing in the lab uses it. SECOND: the claim heuristic has false negatives as well as
  false positives, and the lab asserts both -- it passes the caption revenue doubled in
  every region on data where revenue halved, and it refuses revenue tripled in all four
  regions, which is a real claim written with a word that is not on its list. The false
  negative was found by writing the test and watching it fail, and it was kept rather
  than patched away, because it is the more instructive half. THIRD: the byte-identity
  of the figure PNGs is asserted only across two runs on the same machine in one
  session, never across machines -- matplotlib rasterises text through FreeType, so a
  different FreeType build or font set can legitimately produce different bytes from
  identical code. Only the Markdown byte-identity is claimed generally. Jupyter,
  nbconvert, Quarto and scipy are not installed here (jupyter and quarto are not on
  PATH; import nbconvert, import nbformat, import IPython and import scipy all fail);
  the lesson describes each from public documentation only and reproduces no output from
  any of them.
requirements/README.md (1777 bytes)
# Requirements

`requirements.txt` pins the exact versions this lab was written and run
against on 2026-08-20. Everything else it uses — `hashlib`, `re`,
`shutil`, `tempfile`, `dataclasses`, `pathlib` — is in the Python
standard library.

Install into a lab-local virtual environment so the pins cannot collide
with anything else on your machine:

```bash
cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
```

Only that install step needs the network. Everything after it runs
offline: the lab reads no URL, opens no socket, and needs no API key.

## Why the pins are exact

Section 1 of `tests/run_tests.sh` compares every installed version against
this file and fails on a mismatch. That is deliberate. Two of this lab's
measured facts are version-sensitive:

- the colourblind-safe palette in `report.SAFE_PALETTE` is
  `seaborn.color_palette("colorblind")` as it stands in seaborn 0.13.2,
  frozen to hex so the accessibility check has an exact set to compare
  against;
- the byte-identity of the rendered PNGs in exercise 6 is a property of
  one matplotlib build with one font stack. It is asserted only across two
  runs on the same machine, which is where the guarantee actually holds.

`seaborn` is pinned because `SAFE_PALETTE` came from it, even though the
lab's own drawing code calls matplotlib directly.

## If a pin will not install

Any recent matplotlib 3.x, pandas 2.2+ or NumPy 2.x will almost certainly
run the lab. The version check in section 1 of the harness will complain;
the nine exercises should still pass. If they do not, `expected-output/FIELDS.md`
records exactly which captured values are version-sensitive and which are
exact everywhere.
requirements/requirements.txt (76 bytes)
matplotlib==3.11.1
seaborn==0.13.2
pandas==3.0.5
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (5155 bytes)
# A Report That Argues — the nine exercises

You are not writing an analysis today. You are writing the **checks that
decide whether an analysis is fit to be read**, and you are wiring them
into a generator that refuses to build a report that fails them.

Four files sit in this directory:

| File | What it is |
| --- | --- |
| `data.py` | The dataset. Deterministic: `numpy.random.default_rng(133)`, so every number below is the same on your machine as on the machine this lab was written on |
| `report.py` | The generator: `Candidate`, `Report`, `Estimate`, and the six checks. Read this first |
| `analysis.py` | The twelve candidate figures exploration produced, and `build_report`, which filters them down to five |
| `test_report.py` | Your nine exercises. Each one currently calls `pytest.skip` |

Replace each `pytest.skip(...)` with real assertions, and delete the skip
line. Run `pytest starter -v` as often as you like. Never run
`pytest examples starter` in one command — both directories hold a module
named `test_report.py` and pytest aborts collection on the clash. Run the
two directories as two separate commands.

Every directory fixture is a real temporary directory that is deleted the
moment the test finishes. When you are done, no image and no Markdown file
exists anywhere outside this repository's checkout.

---

## Exercise 1 — a figure must have a question

`Report.add_panel` raises `ReportError` on a `Candidate` whose `question`
is `None` or blank. Prove both, prove that the refused figure is not
half-admitted (`report.panels` is still empty), and then prove the same
candidate is accepted once it has a question.

This is the day's thesis compiled into an exception. A figure whose
question you cannot state is a figure that does not belong in the report,
and the generator will not let you add one by accident.

## Exercise 2 — the caption carries the claim

`carries_claim` demands a number, a percent sign, or a comparative word.
Show it rejects `"revenue by region"` and accepts a caption with a figure
in it, and show `add_panel` refusing a `Finding` whose caption is only a
label.

Then prove **both** honest limits of the heuristic. It passes
`"revenue doubled in every region"` even on data where revenue halved —
it cannot read the data, so it cannot judge truth. And it refuses
`"revenue tripled in all four regions"`, which is a real claim written
with a word that is simply not on the list. The check buys you exactly
one thing: it makes the *absence* of a claim impossible to ship by
accident.

## Exercise 3 — the numbers in the prose come from the data

`data.perturbed()` returns the same frame with one input changed. Render
the report from both frames and prove the sentence about the West's
change moved with the data. If a number is the same in both documents, it
was typed rather than computed, and it will be wrong the first time the
data is refreshed.

## Exercise 4 — every figure is referenced

`orphan_figures` compares the PNG files on disk against the image links in
the Markdown. Prove the generated report has none, then copy one figure to
a new name and prove the check finds it. An orphan is either a figure you
meant to discuss and forgot, or a leftover from a previous run.

## Exercise 5 — uncertainty is stated, not implied

Days 117 and 118 put the interval in the error bar. This exercise puts it
in the sentence. Prove every one of the five panels reports an `Estimate`,
that four of them carry a 95% interval and one carries an explicit note
saying why no interval is available, and that a bare point estimate is
caught by `missing_uncertainty`.

## Exercise 6 — reproducibility

Day 126's idempotence, applied to prose. Render the same input twice into
two directories and prove the Markdown is byte-identical — and that each
figure's PNG bytes are identical too, on this machine and this run. Then
prove that changing the input changes the document, and that nothing in
the output is a clock reading. Provenance here is a hash of the data, not
a timestamp of the run, which is exactly why two runs agree.

## Exercise 7 — ordering for the reader

Prove the conclusion appears before the evidence, and the caveats before
the provenance. Then prove something stronger: each panel's caption
appears verbatim as its numbered line in the conclusion. The conclusion is
not a separate summary that can drift; it *is* the list of captions.

## Exercise 8 — the "so what" filter

Twelve candidate figures went in. Prove that five survive and seven do
not, that fewer than half survive at all, that no discarded slug reaches
the Markdown and no file was written for one — and that every discarded
candidate's one-line reason **is** in the report, under "what we looked at
and found nothing in". Deleting the chart and keeping the sentence is the
whole move.

## Exercise 9 — the accessibility contract

Days 127 and 132 as a build check. Prove all five surviving figures pass
`accessibility_problems` — colourblind-safe palette, both axes labelled —
and that `draw_inaccessible` (red against green, no axis labels) fails
with exactly four named problems.
starter/analysis.py (21952 bytes)
"""The twelve candidate figures, and the five that survive the filter.

This module is the *content* of the Day 133 report; `report.py` is the
machinery. Reading them together is the point of the day: exploration
produced twelve candidate figures, seven of them answer no stated
question, and `report.survivors` throws those seven out before they can
reach the reader. Their `dropped_because` lines survive, though, in the
report's "what we looked at and found nothing in" section -- because a
null result you keep to yourself is a null result the next person has to
rediscover.

Every number in every caption is computed from the frame that is passed
in. Nothing is a typed literal. Change the input and the sentences change
with it, which is what exercises 3 and 6 check.
"""

from __future__ import annotations

import numpy as np
import pandas as pd
from matplotlib.axes import Axes

from data import ANOMALY_MONTH, ANOMALY_REGION, PRICING_CHANGE_MONTH, REGIONS, source_description
from report import (
    SAFE_PALETTE,
    Candidate,
    Estimate,
    Finding,
    Report,
    bootstrap_interval,
    discarded,
    survivors,
)

#: The two six-month windows either side of the pricing change.
BEFORE_WINDOW = (7, 12)
AFTER_WINDOW = (13, 18)


def _direct(frame: pd.DataFrame) -> pd.DataFrame:
    """Direct-channel rows only -- the subset with no missing revenue."""
    return frame[frame["channel"] == "direct"]


def _window_mean(frame: pd.DataFrame, region: str, window: tuple[int, int]) -> float:
    rows = _direct(frame)
    low, high = window
    selected = rows[(rows["region"] == region) & rows["month"].between(low, high)]
    return float(selected["revenue"].mean())


def _window_values(frame: pd.DataFrame, region: str, window: tuple[int, int]) -> np.ndarray:
    rows = _direct(frame)
    low, high = window
    selected = rows[(rows["region"] == region) & rows["month"].between(low, high)]
    return selected["revenue"].to_numpy(dtype=float)


def _ratio_interval(
    before: np.ndarray, after: np.ndarray, *, resamples: int = 2000, seed: int = 133
) -> tuple[float, float]:
    """Percentile bootstrap for a percentage change between two small windows.

    Six observations on each side is not much, and the interval this returns
    is correspondingly wide. That width is information, not an embarrassment:
    it is the report telling the reader how hard the number should be leaned on.
    """
    rng = np.random.default_rng(seed)
    draws = []
    for _ in range(resamples):
        b = rng.choice(before, size=before.size, replace=True).mean()
        a = rng.choice(after, size=after.size, replace=True).mean()
        draws.append(100.0 * (a / b - 1.0))
    values = np.asarray(draws)
    return float(np.quantile(values, 0.025)), float(np.quantile(values, 0.975))


# ---------------------------------------------------------------------------
# Figure 1 -- data quality
# ---------------------------------------------------------------------------


def draw_missing_by_column(ax: Axes, frame: pd.DataFrame) -> None:
    counts = frame.isna().sum()
    ax.bar(list(counts.index), list(counts.to_numpy()), color=SAFE_PALETTE[0])
    ax.set_xlabel("column")
    ax.set_ylabel("rows with no value")
    ax.set_title("Missing values by column")


def analyse_missing(frame: pd.DataFrame) -> Finding:
    missing = frame["revenue"].isna()
    n_missing = int(missing.sum())
    pct = 100.0 * n_missing / len(frame)
    partner_share = 100.0 * float((frame.loc[missing, "channel"] == "partner").mean())
    indicator = missing.to_numpy(dtype=float)
    low, high = bootstrap_interval(indicator, statistic=lambda a: 100.0 * a.mean())
    return Finding(
        caption=(
            f"{n_missing} of {len(frame)} rows ({pct:.1f}%) have no revenue, and "
            f"{partner_share:.0f}% of those gaps are partner rows -- the missingness "
            "is a channel problem, not random loss"
        ),
        prose=(
            "Every other column is complete. Because the gaps sit entirely in one "
            "channel, any figure that pools the two channels and drops missing rows "
            "silently under-counts partner activity, so the rest of this report uses "
            "direct-channel rows wherever a level is being compared."
        ),
        estimate=Estimate(
            label="share of rows with missing revenue",
            value=pct,
            unit="%",
            low=low,
            high=high,
        ),
    )


# ---------------------------------------------------------------------------
# Figure 2 -- univariate
# ---------------------------------------------------------------------------


def draw_channel_populations(ax: Axes, frame: pd.DataFrame) -> None:
    direct = frame.loc[frame["channel"] == "direct", "revenue"].dropna()
    partner = frame.loc[frame["channel"] == "partner", "revenue"].dropna()
    bins = np.histogram_bin_edges(frame["revenue"].dropna(), bins="fd")
    ax.hist(direct, bins=bins, color=SAFE_PALETTE[0], alpha=0.75, label="direct")
    ax.hist(partner, bins=bins, color=SAFE_PALETTE[1], alpha=0.75, label="partner")
    ax.set_xlabel("monthly revenue for one region and channel")
    ax.set_ylabel("number of region-months")
    ax.set_title("Revenue is a mixture of two channels")
    ax.legend()


def analyse_populations(frame: pd.DataFrame) -> Finding:
    direct = frame.loc[frame["channel"] == "direct", "revenue"].dropna().to_numpy(float)
    partner = frame.loc[frame["channel"] == "partner", "revenue"].dropna().to_numpy(float)
    ratio = 100.0 * float(np.median(partner)) / float(np.median(direct))
    low, high = bootstrap_interval(partner, statistic=np.median)
    return Finding(
        caption=(
            f"Revenue is two populations rather than one: the median partner "
            f"region-month is {ratio:.0f}% of the median direct region-month, so any "
            "average taken across both channels describes a mixture nobody sells into"
        ),
        prose=(
            "The two histograms barely overlap. A single mean over this column would "
            "land in the empty gap between them and describe no real region-month at "
            "all -- Day 116's warning about a summary that discards the thing you "
            "needed, met again in a column you would have been tempted to average."
        ),
        estimate=Estimate(
            label="median partner region-month revenue",
            value=float(np.median(partner)),
            unit=" USD",
            low=low,
            high=high,
            decimals=0,
        ),
    )


# ---------------------------------------------------------------------------
# Figure 3 -- relationship
# ---------------------------------------------------------------------------


def draw_orders_vs_revenue(ax: Axes, frame: pd.DataFrame) -> None:
    rows = frame.dropna(subset=["revenue"])
    x = rows["orders"].to_numpy(dtype=float)
    y = rows["revenue"].to_numpy(dtype=float)
    slope, intercept = np.polyfit(x, y, 1)
    grid = np.linspace(x.min(), x.max(), 50)
    ax.scatter(x, y, s=12, color=SAFE_PALETTE[0], label="region-month")
    ax.plot(grid, slope * grid + intercept, color=SAFE_PALETTE[3], label="least-squares fit")
    ax.set_xlabel("orders in the month")
    ax.set_ylabel("revenue in the month (USD)")
    ax.set_title("Orders and revenue")
    ax.legend()


def analyse_relationship(frame: pd.DataFrame) -> Finding:
    rows = frame.dropna(subset=["revenue"])
    x = rows["orders"].to_numpy(dtype=float)
    y = rows["revenue"].to_numpy(dtype=float)
    slope, intercept = np.polyfit(x, y, 1)
    predicted = slope * x + intercept
    r_squared = 1.0 - float(np.sum((y - predicted) ** 2) / np.sum((y - y.mean()) ** 2))
    per_order = y / x
    low, high = bootstrap_interval(per_order)
    return Finding(
        caption=(
            f"Revenue rises about {slope:.0f} USD per additional order and the "
            f"straight-line fit accounts for {100.0 * r_squared:.1f}% of the variation, "
            "so a region-month that missed its revenue missed its order count"
        ),
        prose=(
            "There is no second cluster off the line and no curvature worth naming. "
            "That is a boring finding, and it is in the report precisely because it "
            "closes a question the reader would otherwise have to ask: revenue here "
            "is not being moved by price. The fitted slope "
            f"({slope:.0f} USD) sits a little below the mean revenue per order "
            f"({per_order.mean():.0f} USD) because the fit carries a non-zero "
            f"intercept of {intercept:,.0f} USD; the two numbers answer slightly "
            "different questions and the report says which is which rather than "
            "quoting whichever is larger."
        ),
        estimate=Estimate(
            label="mean revenue per order",
            value=float(per_order.mean()),
            unit=" USD",
            low=low,
            high=high,
        ),
    )


# ---------------------------------------------------------------------------
# Figure 4 -- segments
# ---------------------------------------------------------------------------


def draw_region_trend(ax: Axes, frame: pd.DataFrame) -> None:
    rows = _direct(frame)
    for index, region in enumerate(REGIONS):
        series = rows[rows["region"] == region].sort_values("month")
        ax.plot(
            series["month"].to_numpy(),
            series["revenue"].to_numpy(),
            color=SAFE_PALETTE[index],
            label=region,
        )
    ax.axvline(
        PRICING_CHANGE_MONTH - 0.5,
        color=SAFE_PALETTE[7],
        linestyle="--",
        label=f"pricing change (month {PRICING_CHANGE_MONTH})",
    )
    ax.set_xlabel("month")
    ax.set_ylabel("direct-channel revenue (USD)")
    ax.set_title("Direct revenue by region")
    ax.legend(fontsize=7, ncols=2)


def analyse_segments(frame: pd.DataFrame) -> Finding:
    changes: dict[str, float] = {}
    for region in REGIONS:
        before = _window_mean(frame, region, BEFORE_WINDOW)
        after = _window_mean(frame, region, AFTER_WINDOW)
        changes[region] = 100.0 * (after / before - 1.0)

    west = changes["West"]
    grew = [region for region, change in changes.items() if change > 0]
    low, high = _ratio_interval(
        _window_values(frame, "West", BEFORE_WINDOW),
        _window_values(frame, "West", AFTER_WINDOW),
    )

    # The East's after-window contains the single month-18 spike. Recompute it
    # without that one observation, because quoting the inflated figure without
    # saying so would be exactly the failure Day 132 named.
    clean_after = _direct(frame)
    clean_after = clean_after[
        (clean_after["region"] == ANOMALY_REGION)
        & clean_after["month"].between(*AFTER_WINDOW)
        & (clean_after["month"] != ANOMALY_MONTH)
    ]
    east_without_anomaly = 100.0 * (
        float(clean_after["revenue"].mean()) / _window_mean(frame, ANOMALY_REGION, BEFORE_WINDOW)
        - 1.0
    )
    direction = "fell" if west < 0 else "rose"
    return Finding(
        caption=(
            f"{len(grew)} regions grew across the pricing change while the West "
            f"{direction} {abs(west):.1f}%, and the break lands in month "
            f"{PRICING_CHANGE_MONTH} in that region only"
        ),
        prose=(
            "Comparing the six months before month "
            f"{PRICING_CHANGE_MONTH} with the six months after, the four regions move "
            + ", ".join(f"{region} {change:+.1f}%" for region, change in changes.items())
            + ". The West is the only one that changes direction, and it changes it at "
            "the month the price moved. This is an association in observational data, "
            "not a controlled comparison: nothing here rules out a third cause that "
            f"happened to the West in the same month. {ANOMALY_REGION}'s "
            f"{changes[ANOMALY_REGION]:+.1f}% is not what it looks like either: drop the "
            f"single month-{ANOMALY_MONTH} observation and it falls to "
            f"{east_without_anomaly:+.1f}%, which is why Figure 5 exists."
        ),
        estimate=Estimate(
            label="West change across the pricing change (six months either side)",
            value=west,
            unit="%",
            low=low,
            high=high,
        ),
    )


# ---------------------------------------------------------------------------
# Figure 5 -- anomaly
# ---------------------------------------------------------------------------


def draw_east_anomaly(ax: Axes, frame: pd.DataFrame) -> None:
    rows = _direct(frame)
    series = rows[rows["region"] == ANOMALY_REGION].sort_values("month")
    months = series["month"].to_numpy()
    colours = [
        SAFE_PALETTE[3] if month == ANOMALY_MONTH else SAFE_PALETTE[0] for month in months
    ]
    ax.bar(months, series["revenue"].to_numpy(), color=colours)
    ax.set_xlabel("month")
    ax.set_ylabel("direct-channel revenue (USD)")
    ax.set_title(f"{ANOMALY_REGION}: one month is not like the others")


def analyse_anomaly(frame: pd.DataFrame) -> Finding:
    rows = _direct(frame)
    series = rows[rows["region"] == ANOMALY_REGION]
    spike = float(series.loc[series["month"] == ANOMALY_MONTH, "revenue"].iloc[0])
    others = series.loc[series["month"] != ANOMALY_MONTH, "revenue"].to_numpy(float)
    multiple = spike / float(np.median(others))
    neighbours = series[series["month"].isin([ANOMALY_MONTH - 1, ANOMALY_MONTH + 1])]
    neighbour_multiple = float(neighbours["revenue"].mean()) / float(np.median(others))
    return Finding(
        caption=(
            f"{ANOMALY_REGION} month {ANOMALY_MONTH} is {multiple:.1f} times the "
            f"region's median month while the months either side sit at "
            f"{neighbour_multiple:.2f} times it, so this is one observation and not a "
            "level change"
        ),
        prose=(
            "The distinction matters for what you do next. A level change is a fact "
            "about the business and belongs in the forecast; a single spike is a fact "
            "about one month and belongs with whoever can explain it. Until someone "
            "does, the honest move is to report both the figure including it and the "
            "figure excluding it, and to say which one the decision was made on."
        ),
        estimate=Estimate(
            label=f"{ANOMALY_REGION} month {ANOMALY_MONTH} as a multiple of the region median",
            value=multiple,
            unit="x",
            no_interval_note=(
                "a single observation has no sampling interval; one point is one point"
            ),
        ),
    )


# ---------------------------------------------------------------------------
# The twelve candidates
# ---------------------------------------------------------------------------


def candidate_figures() -> list[Candidate]:
    """Everything exploration produced, in the order it was produced.

    Five carry a question. Seven do not, and `report.survivors` drops them.
    """
    return [
        Candidate(
            slug="missing-revenue",
            question="Which rows have no revenue, and is the missingness concentrated anywhere?",
            draw=draw_missing_by_column,
            analyse=analyse_missing,
        ),
        Candidate(
            slug="cumulative-revenue",
            dropped_because=(
                "A cumulative revenue curve was drawn and discarded: a cumulative "
                "series rises whatever the underlying months do, so it answered no "
                "question the monthly series had not already answered"
            ),
        ),
        Candidate(
            slug="channel-populations",
            question="Is monthly revenue one population, or several stacked on top of each other?",
            draw=draw_channel_populations,
            analyse=analyse_populations,
        ),
        Candidate(
            slug="orders-histogram",
            dropped_because=(
                "The order-count distribution was checked for a second population; it "
                "shows the same channel split the revenue column already shows, so it "
                "adds no evidence of its own"
            ),
        ),
        Candidate(
            slug="orders-vs-revenue",
            question="How tightly do orders and revenue move together, and what is one extra order worth?",
            draw=draw_orders_vs_revenue,
            analyse=analyse_relationship,
        ),
        Candidate(
            slug="region-channel-interaction",
            dropped_because=(
                "Region-by-channel interaction was checked; the partner channel runs "
                "at the same share of direct in all four regions, so there is no "
                "interaction to report"
            ),
        ),
        Candidate(
            slug="region-trend",
            question="Did any region's trajectory change at the month-13 pricing change?",
            draw=draw_region_trend,
            analyse=analyse_segments,
        ),
        Candidate(
            slug="revenue-per-order-by-region",
            dropped_because=(
                "Revenue per order was compared across the four regions; they are "
                "indistinguishable on this measure, which is worth one line here so "
                "the next reader does not spend an afternoon on it"
            ),
        ),
        Candidate(
            slug="east-anomaly",
            question="Is the East's month-18 jump a level change or a single outlier?",
            draw=draw_east_anomaly,
            analyse=analyse_anomaly,
        ),
        Candidate(
            slug="calendar-seasonality",
            dropped_because=(
                "Calendar seasonality was checked by lining the two years up month "
                "against month; nothing stood out above the month-to-month noise"
            ),
        ),
        Candidate(
            slug="missing-by-month",
            dropped_because=(
                "The missing revenue rows were checked for a time pattern as well as "
                "a channel pattern; they are scattered across the two years rather "
                "than clustered in any single month"
            ),
        ),
        Candidate(
            slug="revenue-heatmap",
            dropped_because=(
                "A region-by-month heatmap was drawn and discarded: it held the same "
                "information as the trend lines while making the month-13 break "
                "harder to see, which is the wrong trade for a report"
            ),
        ),
    ]


def build_report(frame: pd.DataFrame) -> Report:
    """Run the whole pipeline: candidates, filter, panels, ready to render."""
    candidates = candidate_figures()
    report = Report(
        title="Where did the West's revenue go?",
        question=(
            "Four regions sell through two channels. Did any region's revenue "
            "trajectory change around the month-13 pricing change, and is the change "
            "big enough and clean enough to act on?"
        ),
        decision=(
            "Whether to roll the month-13 pricing change back in the West before it "
            "is extended to the other three regions."
        ),
        provenance=source_description(),
        caveats=[
            "This is observational data, not an experiment. The month-13 break is an "
            "association in time; it is not proof that the pricing change caused it.",
            "The regional comparison uses six months either side of the change. Six "
            "observations per side is a small window, and the interval on that number "
            "is correspondingly wide -- read the interval, not the point estimate.",
            "Level comparisons use direct-channel rows only, because the partner "
            "channel is the one with missing revenue. Partner totals in this report "
            "are therefore lower bounds.",
            "Every figure here uses a colourblind-safe palette and labelled axes, and "
            "no axis in this report is truncated below zero.",
        ],
        null_results=[candidate.dropped_because for candidate in discarded(candidates)],
    )
    for candidate in survivors(candidates):
        report.add_panel(candidate, frame)
    return report


# ---------------------------------------------------------------------------
# Two deliberately broken specimens, used by the exercises
# ---------------------------------------------------------------------------


def draw_inaccessible(ax: Axes, frame: pd.DataFrame) -> None:
    """A chart that breaks the accessibility contract in three ways at once.

    Red against green is the classic pair that vanishes for the commonest
    form of colour vision deficiency, and neither axis is labelled. Exercise 9
    asserts that the build check catches all of it.
    """
    rows = _direct(frame)
    for region, colour in zip(REGIONS[:2], ("red", "green")):
        series = rows[rows["region"] == region].sort_values("month")
        ax.plot(series["month"].to_numpy(), series["revenue"].to_numpy(), color=colour)


def bare_point_estimate_candidate() -> Candidate:
    """A candidate whose finding reports a number with no uncertainty at all."""

    def analyse(frame: pd.DataFrame) -> Finding:
        total = float(frame["revenue"].sum())
        return Finding(
            caption=f"Total recorded revenue across the two years is {total:,.0f} USD",
            prose="A number with nothing attached to say how firm it is.",
            estimate=Estimate(label="total recorded revenue", value=total, unit=" USD", decimals=0),
        )

    return Candidate(
        slug="bare-total",
        question="What is the total recorded revenue?",
        draw=draw_missing_by_column,
        analyse=analyse,
    )
starter/conftest.py (1390 bytes)
"""Shared fixtures and headless matplotlib setup.

pytest finds this file by itself -- nothing imports it. `matplotlib.use`
must run before `pyplot` is imported anywhere, which is why it happens
here, first and unconditionally. Every figure this lab opens is closed by
the code that opened it; `_close_all_figures` is a backstop, not a
substitute.

Every directory fixture is a real temporary directory that is deleted when
the test finishes, so a full run of this lab leaves no image and no
Markdown file behind anywhere on your disk.
"""

import tempfile
from pathlib import Path

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import pytest

from analysis import candidate_figures
from data import monthly_sales, perturbed


@pytest.fixture
def frame():
    return monthly_sales()


@pytest.fixture
def perturbed_frame():
    return perturbed()


@pytest.fixture
def candidates():
    return candidate_figures()


def _temporary_directory():
    with tempfile.TemporaryDirectory(prefix="d133-report-") as name:
        yield Path(name)


@pytest.fixture
def report_dir():
    yield from _temporary_directory()


@pytest.fixture
def second_report_dir():
    yield from _temporary_directory()


@pytest.fixture
def third_report_dir():
    yield from _temporary_directory()


@pytest.fixture(autouse=True)
def _close_all_figures():
    yield
    plt.close("all")
starter/data.py (4679 bytes)
"""The dataset the Day 133 report is built from, and one perturbed copy.

Everything here is deterministic. `monthly_sales()` draws from a fixed
`numpy.random.default_rng(133)`, so two calls return identical frames and
two runs of the lab produce identical numbers. That determinism is not a
convenience -- exercise 6 asserts byte-identical output across two runs,
and a report generator that cannot be re-run to the same answer is not a
report generator, it is a one-off.

The frame deliberately contains four things a real exploratory analysis
would have to find:

1. **Missing revenue, not spread evenly.** About 4% of rows have no
   revenue, and every one of them is a `partner` row. Week 18's damage
   report exists to catch exactly this shape.
2. **Two populations in one column.** `partner` rows run at roughly 45%
   of the `direct` row for the same region and month, so the revenue
   column is a mixture, not a single distribution.
3. **A step change in one segment.** From month 13 the West is scaled by
   `WEST_PRICING_FACTOR`, standing in for a pricing change. The other
   three regions keep their steady monthly growth.
4. **One anomaly.** East, month 18, is scaled by `ANOMALY_FACTOR`. It is
   a single point, which is why the report that quotes it says plainly
   that no interval is available for it.

`perturbed()` returns a copy with one input changed. Exercises 3 and 6
use it to prove that the numbers printed in the report's prose come from
the data rather than from typed literals.
"""

from __future__ import annotations

import numpy as np
import pandas as pd

REGIONS: tuple[str, ...] = ("North", "South", "East", "West")
CHANNELS: tuple[str, ...] = ("direct", "partner")

N_MONTHS = 24
SEED = 133

#: From this month onward the West is scaled by WEST_PRICING_FACTOR.
PRICING_CHANGE_MONTH = 13
WEST_PRICING_FACTOR = 0.88

#: The single anomalous observation.
ANOMALY_REGION = "East"
ANOMALY_MONTH = 18
ANOMALY_FACTOR = 3.0

#: Share of rows whose revenue is missing. Every one is a partner row.
MISSING_RATE = 0.04

#: Partner rows run at this share of the same region-month direct row.
PARTNER_SHARE = 0.45

_BASE_LEVEL = {"North": 42000.0, "South": 31000.0, "East": 27000.0, "West": 36000.0}
_MONTHLY_GROWTH = {"North": 1.012, "South": 1.008, "East": 1.015, "West": 1.010}


def monthly_sales() -> pd.DataFrame:
    """Twenty-four months of revenue and orders for four regions and two
    channels: 192 rows, eight of them missing their revenue."""
    rng = np.random.default_rng(SEED)
    records: list[dict[str, object]] = []

    for month in range(1, N_MONTHS + 1):
        for region in REGIONS:
            for channel in CHANNELS:
                level = _BASE_LEVEL[region] * _MONTHLY_GROWTH[region] ** (month - 1)
                if channel == "partner":
                    level *= PARTNER_SHARE
                if region == "West" and month >= PRICING_CHANGE_MONTH:
                    level *= WEST_PRICING_FACTOR
                if region == ANOMALY_REGION and month == ANOMALY_MONTH:
                    level *= ANOMALY_FACTOR

                revenue = float(level * rng.normal(1.0, 0.05))
                order_value = float(rng.normal(180.0, 12.0))
                records.append(
                    {
                        "month": month,
                        "region": region,
                        "channel": channel,
                        "orders": int(round(revenue / order_value)),
                        "revenue": revenue,
                    }
                )

    frame = pd.DataFrame.from_records(records)

    partner_positions = np.asarray(frame.index[frame["channel"] == "partner"])
    n_missing = int(round(MISSING_RATE * len(frame)))
    blanked = rng.choice(partner_positions, size=n_missing, replace=False)
    frame.loc[blanked, "revenue"] = np.nan

    return frame


def perturbed() -> pd.DataFrame:
    """The same frame with exactly one input changed: the West's post-change
    scaling is halved again.

    Nothing else moves. If a number printed in the report's prose is the
    same for `monthly_sales()` and for this frame, that number was typed
    rather than computed -- which is the whole point of exercise 3.
    """
    frame = monthly_sales()
    hit = (frame["region"] == "West") & (frame["month"] >= PRICING_CHANGE_MONTH)
    frame.loc[hit, "revenue"] = frame.loc[hit, "revenue"] * 0.5
    return frame


def source_description() -> str:
    """One line of provenance, quoted verbatim in the rendered report."""
    return (
        "synthetic monthly sales, 4 regions x 2 channels x 24 months, "
        "generated by data.monthly_sales() with numpy default_rng(133)"
    )
starter/report.py (17065 bytes)
"""A small EDA report generator that refuses to build a bad report.

This is the course-supplied tool for Day 133. It is deliberately small
enough to read in one sitting, because the point is not the code -- it is
the four rules the code refuses to bend:

* **A figure must have a stated question.** `Report.add_panel` raises
  `ReportError` on a candidate whose `question` is missing or blank.
* **A caption must carry a claim.** `carries_claim` demands a number or a
  comparative word, so "revenue by region" is rejected and "the West fell
  8.4% after the pricing change" is accepted. The heuristic is crude on
  purpose: it can tell that a claim was *made*, never whether the claim is
  *true*. Judging truth is the reader's job and yours.
* **Every figure is referenced.** `orphan_figures` compares what was
  written to disk against what the markdown links to.
* **Every reported estimate carries its uncertainty**, either as an
  interval or as an explicit note that no interval is available.
  `missing_uncertainty` lists the ones that carry neither.

Two more checks sit alongside those: `check_axes` enforces the
accessibility contract (a colourblind-safe palette and labelled axes), and
`survivors` is the "so what" filter that drops candidate figures with no
question before they ever reach the report.

The renderer writes Markdown with embedded figures. It contains no clock
reading and no random number, so two runs over the same input produce
byte-identical Markdown -- which exercise 6 asserts directly.
"""

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterable, Sequence

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.colors import to_hex

__all__ = [
    "SAFE_PALETTE",
    "ReportError",
    "Estimate",
    "Finding",
    "Candidate",
    "Panel",
    "Report",
    "carries_claim",
    "claim_problem",
    "survivors",
    "orphan_figures",
    "missing_uncertainty",
    "axes_colours",
    "check_axes",
    "bootstrap_interval",
]


#: seaborn's "colorblind" palette, as hex. Read off
#: `seaborn.color_palette("colorblind")` on seaborn 0.13.2 and frozen here so
#: the accessibility check has an exact set to compare against.
SAFE_PALETTE: tuple[str, ...] = (
    "#0173b2",
    "#de8f05",
    "#029e73",
    "#d55e00",
    "#cc78bc",
    "#ca9161",
    "#fbafe4",
    "#949494",
    "#ece133",
    "#56b4e9",
)


class ReportError(ValueError):
    """Raised when a figure would break one of the report's own rules."""


# ---------------------------------------------------------------------------
# The caption rule: a caption must carry a claim
# ---------------------------------------------------------------------------

#: Words that turn a label into a comparison. Not exhaustive, and not meant
#: to be -- see `carries_claim` for what this check can and cannot do.
CLAIM_WORDS: frozenset[str] = frozenset(
    {
        "above",
        "below",
        "beyond",
        "concentrated",
        "decrease",
        "decreased",
        "double",
        "doubled",
        "drop",
        "dropped",
        "every",
        "exceeds",
        "fall",
        "fell",
        "grew",
        "growth",
        "half",
        "halved",
        "higher",
        "increase",
        "increased",
        "less",
        "lower",
        "more",
        "no",
        "none",
        "only",
        "outgrew",
        "rise",
        "rose",
        "shrank",
        "than",
        "times",
        "unchanged",
        "versus",
        "while",
    }
)


def claim_problem(caption: str) -> str | None:
    """Return why `caption` fails the claim rule, or None if it passes.

    A caption passes if it contains a digit, a percent sign, or one of
    `CLAIM_WORDS`. That is a crude test and it is meant to be: it detects
    whether a *claim was made*, and it has no way at all to judge whether
    the claim is *true*. A caption reading "revenue tripled" passes this
    check on data where revenue halved. The check buys you one thing --
    it makes the absence of a claim impossible to ship by accident.
    """
    text = caption.strip()
    if not text:
        return "the caption is empty"
    if any(character.isdigit() for character in text) or "%" in text:
        return None
    words = set(re.findall(r"[a-z]+", text.lower()))
    if words & CLAIM_WORDS:
        return None
    return (
        "the caption is a label, not a claim: it contains no number and no "
        "comparative word, so there is nothing in it a reader could disagree with"
    )


def carries_claim(caption: str) -> bool:
    """True when `caption` states something a reader could disagree with."""
    return claim_problem(caption) is None


# ---------------------------------------------------------------------------
# Estimates and their uncertainty
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Estimate:
    """One number the report asserts, with its uncertainty attached.

    Either give it an interval (`low` and `high`, from Day 118's machinery
    or `bootstrap_interval` below) or an explicit `no_interval_note` saying
    why one is not available. A bare point estimate carries neither, and
    `missing_uncertainty` will find it.
    """

    label: str
    value: float
    unit: str = ""
    low: float | None = None
    high: float | None = None
    no_interval_note: str | None = None
    decimals: int = 1

    def has_uncertainty(self) -> bool:
        interval = self.low is not None and self.high is not None
        return interval or bool(self.no_interval_note)

    def text(self) -> str:
        value = f"{self.value:.{self.decimals}f}{self.unit}"
        if self.low is not None and self.high is not None:
            low = f"{self.low:.{self.decimals}f}{self.unit}"
            high = f"{self.high:.{self.decimals}f}{self.unit}"
            return f"{self.label}: {value} (95% interval {low} to {high})"
        if self.no_interval_note:
            return f"{self.label}: {value} (no interval: {self.no_interval_note})"
        return f"{self.label}: {value}"


def bootstrap_interval(
    values: Sequence[float] | np.ndarray,
    *,
    statistic: Callable[[np.ndarray], float] = np.mean,
    resamples: int = 2000,
    level: float = 0.95,
    seed: int = 133,
) -> tuple[float, float]:
    """A percentile bootstrap interval, seeded so the report stays reproducible.

    Day 118's interval, computed by resampling rather than by formula, which
    is what you reach for when the statistic has no tidy standard error.
    """
    sample = np.asarray(values, dtype=float)
    sample = sample[~np.isnan(sample)]
    if sample.size < 2:
        raise ReportError("a bootstrap interval needs at least two observations")
    rng = np.random.default_rng(seed)
    draws = rng.choice(sample, size=(resamples, sample.size), replace=True)
    stats = np.array([float(statistic(row)) for row in draws])
    tail = (1.0 - level) / 2.0
    return float(np.quantile(stats, tail)), float(np.quantile(stats, 1.0 - tail))


# ---------------------------------------------------------------------------
# Candidates, findings and panels
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Finding:
    """What one figure turned out to say, computed from the data."""

    caption: str
    prose: str
    estimate: Estimate | None = None


@dataclass(frozen=True)
class Candidate:
    """A figure someone made during exploration. Most do not survive.

    `question` is the whole filter. A candidate with no question answers no
    question, and `survivors` drops it before it can reach the report --
    but `dropped_because` is kept, because "we looked and found nothing"
    is worth one line to the next reader.
    """

    slug: str
    question: str | None = None
    draw: Callable[[Axes, pd.DataFrame], None] | None = None
    analyse: Callable[[pd.DataFrame], Finding] | None = None
    dropped_because: str = ""


@dataclass
class Panel:
    """A candidate that survived, with its finding resolved against the data."""

    slug: str
    question: str
    finding: Finding
    draw: Callable[[Axes, pd.DataFrame], None]
    number: int = 0
    image: str = ""


def survivors(candidates: Iterable[Candidate]) -> list[Candidate]:
    """The "so what" filter: keep only candidates that answer a stated question."""
    return [c for c in candidates if c.question and c.question.strip()]


def discarded(candidates: Iterable[Candidate]) -> list[Candidate]:
    """The complement of `survivors` -- what the report will not show."""
    return [c for c in candidates if not (c.question and c.question.strip())]


# ---------------------------------------------------------------------------
# Accessibility contract
# ---------------------------------------------------------------------------


def axes_colours(ax: Axes) -> list[str]:
    """Every colour actually painted on `ax`, as lowercase hex, alpha dropped."""
    found: list[str] = []
    for patch in ax.patches:
        found.append(to_hex(patch.get_facecolor()))
    for line in ax.get_lines():
        found.append(to_hex(line.get_color()))
    for collection in ax.collections:
        for rgba in np.atleast_2d(collection.get_facecolor()):
            found.append(to_hex(rgba))
    return [colour.lower() for colour in found]


def check_axes(ax: Axes) -> list[str]:
    """Problems with `ax` against the report's accessibility contract.

    Days 127 and 132, turned into a build check: every mark must be drawn in
    a colourblind-safe colour, and both axes must be labelled. Returns an
    empty list when the axes pass.
    """
    problems: list[str] = []
    if not ax.get_xlabel().strip():
        problems.append("the x axis has no label")
    if not ax.get_ylabel().strip():
        problems.append("the y axis has no label")
    safe = {colour.lower() for colour in SAFE_PALETTE}
    for colour in sorted(set(axes_colours(ax))):
        if colour not in safe:
            problems.append(f"colour {colour} is not in the colourblind-safe palette")
    return problems


def accessibility_problems(
    draw: Callable[[Axes, pd.DataFrame], None], frame: pd.DataFrame
) -> list[str]:
    """Draw into a throwaway figure and run `check_axes` on the result."""
    figure, ax = plt.subplots(figsize=(6.0, 3.5), dpi=100)
    try:
        draw(ax, frame)
        return check_axes(ax)
    finally:
        plt.close(figure)


# ---------------------------------------------------------------------------
# The report
# ---------------------------------------------------------------------------

_IMAGE_LINK = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")


def orphan_figures(markdown: str, figure_dir: Path | str) -> list[str]:
    """Image files on disk that the markdown never links to.

    An orphan is not a cosmetic problem. It is either a figure you meant to
    discuss and forgot, or a leftover from a previous run that will confuse
    whoever opens the directory next.
    """
    referenced = {Path(target).name for target in _IMAGE_LINK.findall(markdown)}
    directory = Path(figure_dir)
    if not directory.is_dir():
        return []
    return sorted(p.name for p in directory.glob("*.png") if p.name not in referenced)


def missing_uncertainty(report: "Report") -> list[str]:
    """Slugs of panels whose estimate carries neither an interval nor a note."""
    return [
        panel.slug
        for panel in report.panels
        if panel.finding.estimate is not None
        and not panel.finding.estimate.has_uncertainty()
    ]


@dataclass
class Report:
    """An ordered argument with evidence attached."""

    title: str
    question: str
    decision: str
    provenance: str
    caveats: list[str] = field(default_factory=list)
    null_results: list[str] = field(default_factory=list)
    panels: list[Panel] = field(default_factory=list)
    data_fingerprint: str = ""

    def add_panel(self, candidate: Candidate, frame: pd.DataFrame) -> Panel:
        """Resolve one candidate against the data and admit it to the report.

        Raises `ReportError` when the candidate has no stated question, when
        its caption is a label rather than a claim, or when it has no way to
        draw itself or to compute its finding.
        """
        if not candidate.question or not candidate.question.strip():
            raise ReportError(
                f"figure {candidate.slug!r} has no stated question; a figure whose "
                "question you cannot state is a figure that does not belong in the report"
            )
        if candidate.draw is None or candidate.analyse is None:
            raise ReportError(
                f"figure {candidate.slug!r} has a question but no way to answer it"
            )

        finding = candidate.analyse(frame)
        problem = claim_problem(finding.caption)
        if problem is not None:
            raise ReportError(f"figure {candidate.slug!r}: {problem}")

        panel = Panel(
            slug=candidate.slug,
            question=candidate.question.strip(),
            finding=finding,
            draw=candidate.draw,
            number=len(self.panels) + 1,
        )
        panel.image = f"figures/{panel.number:02d}-{panel.slug}.png"
        self.panels.append(panel)
        return panel

    # -- rendering ---------------------------------------------------------

    def render(self, outdir: Path | str, frame: pd.DataFrame) -> str:
        """Write the figures and `report.md` into `outdir`, and return the markdown.

        The output contains no clock reading and no unseeded random number,
        so two runs over the same input are byte-identical. Provenance is a
        fingerprint of the data, not a timestamp of the run.
        """
        directory = Path(outdir)
        figure_dir = directory / "figures"
        figure_dir.mkdir(parents=True, exist_ok=True)

        for panel in self.panels:
            figure, ax = plt.subplots(figsize=(6.0, 3.5), dpi=100)
            try:
                panel.draw(ax, frame)
                figure.tight_layout()
                figure.savefig(directory / panel.image)
            finally:
                plt.close(figure)

        markdown = self._markdown(frame)
        (directory / "report.md").write_text(markdown, encoding="utf-8")
        return markdown

    def fingerprint(self, frame: pd.DataFrame) -> str:
        """A short content hash of the input, so provenance names the data."""
        payload = frame.to_csv(index=False).encode("utf-8")
        return hashlib.sha256(payload).hexdigest()[:12]

    def _markdown(self, frame: pd.DataFrame) -> str:
        lines: list[str] = []
        add = lines.append

        add(f"# {self.title}")
        add("")
        add(f"**Question.** {self.question}")
        add("")
        add(f"**Decision this feeds.** {self.decision}")
        add("")

        add("## Conclusion")
        add("")
        if not self.panels:
            add("No figure in this analysis answered a stated question.")
        for panel in self.panels:
            add(f"{panel.number}. {panel.finding.caption} (Figure {panel.number})")
        add("")
        for panel in self.panels:
            if panel.finding.estimate is not None:
                add(f"- {panel.finding.estimate.text()}")
        add("")

        add("## What we looked at and found nothing in")
        add("")
        if self.null_results:
            for note in self.null_results:
                add(f"- {note}")
        else:
            add("- Nothing was set aside; every candidate figure answered a question.")
        add("")

        add("## Evidence")
        add("")
        for panel in self.panels:
            add(f"### Figure {panel.number} — {panel.question}")
            add("")
            add(f"![{panel.question}]({panel.image})")
            add("")
            add(f"**Figure {panel.number}.** {panel.finding.caption}")
            add("")
            add(panel.finding.prose)
            add("")
            if panel.finding.estimate is not None:
                add(f"*{panel.finding.estimate.text()}*")
                add("")

        add("## Caveats")
        add("")
        for caveat in self.caveats:
            add(f"- {caveat}")
        add("")

        add("## Provenance")
        add("")
        add(f"- Source: {self.provenance}")
        add(f"- Shape: {len(frame)} rows, {len(frame.columns)} columns")
        add(f"- Data fingerprint (sha256, first 12): `{self.fingerprint(frame)}`")
        add(
            "- This document was generated by code from the input above. Nothing in "
            "it was typed by hand, so re-running it on new data cannot leave the "
            "prose disagreeing with the figures."
        )
        add("")

        return "\n".join(lines)
starter/test_report.py (8030 bytes)
"""Your exercises for Day 133 -- "A Report That Argues".

Nine exercises. Every test below currently calls `pytest.skip(...)` --
replace the skip with real assertions and delete the skip line. Read
`00_brief.md` for the exercise-by-exercise explanation, `report.py` for
the generator you are testing, and `analysis.py` for the twelve candidate
figures it is fed.

Check yourself at any point:

    pytest starter -v

The reference answer key lives in `examples/test_report.py` -- read it
AFTER you have tried, never before.
"""

from __future__ import annotations

import re
import shutil
from pathlib import Path

import pytest

from analysis import (
    analyse_missing,
    bare_point_estimate_candidate,
    build_report,
    candidate_figures,
    draw_inaccessible,
    draw_missing_by_column,
)
from report import (
    Candidate,
    Finding,
    Report,
    ReportError,
    accessibility_problems,
    carries_claim,
    claim_problem,
    discarded,
    missing_uncertainty,
    orphan_figures,
    survivors,
)


def line_containing(markdown: str, needle: str) -> str:
    """The one line of `markdown` that contains `needle`."""
    hits = [line for line in markdown.splitlines() if needle in line]
    assert len(hits) >= 1, f"no line contains {needle!r}"
    return hits[0]


def percentage_in(text: str) -> float:
    """The first signed percentage in `text`, as a float."""
    match = re.search(r"(-?\d+(?:\.\d+)?)%", text)
    assert match is not None, f"no percentage in {text!r}"
    return float(match.group(1))


def blank_report() -> Report:
    return Report(
        title="A scratch report",
        question="Does the generator hold its own line?",
        decision="Whether to trust anything this generator produces.",
        provenance="the same synthetic frame the real report uses",
    )


# --------------------------------------------------------------------------
# Exercise 1 -- a figure must have a question.
# --------------------------------------------------------------------------


def test_01_a_figure_must_have_a_question(frame):
    pytest.skip(
        "Build a Candidate with no question (and one with a blank '   ' question) "
        "and assert blank_report().add_panel(...) raises ReportError mentioning "
        "'no stated question' and leaves report.panels empty; then add the same "
        "candidate WITH a question and assert the panel is admitted, numbered 1, "
        "with image 'figures/01-pretty-chart.png'"
    )


# --------------------------------------------------------------------------
# Exercise 2 -- the caption carries the claim.
# --------------------------------------------------------------------------


def test_02_caption_carries_a_claim(frame):
    pytest.skip(
        "Assert carries_claim('revenue by region') is False and that "
        "claim_problem() explains it is a label rather than a claim; assert a "
        "caption with a number in it passes; assert add_panel refuses a Finding "
        "whose caption is only a label; then assert BOTH honest limits of the "
        "heuristic -- 'revenue doubled in every region' passes even though the "
        "check cannot know it is false, and 'revenue tripled in all four regions' "
        "is refused even though it is a real claim"
    )


# --------------------------------------------------------------------------
# Exercise 3 -- numbers in the prose come from the data.
# --------------------------------------------------------------------------


def test_03_numbers_come_from_the_data(frame, perturbed_frame, report_dir, second_report_dir):
    pytest.skip(
        "Render build_report(frame) and build_report(perturbed_frame) into the two "
        "temporary directories, pull the line containing 'West change across the "
        "pricing change' out of each with line_containing, and assert the two lines "
        "differ, that the original percentage is negative, and that the perturbed "
        "one is more than 20 points lower; assert the 'Data fingerprint' lines "
        "differ too"
    )


# --------------------------------------------------------------------------
# Exercise 4 -- every figure is referenced.
# --------------------------------------------------------------------------


def test_04_no_orphan_figures(frame, report_dir):
    pytest.skip(
        "Render the report, assert five PNG files were written and that "
        "orphan_figures(markdown, report_dir / 'figures') is empty and every "
        "written name appears in the markdown; then shutil.copy one figure to "
        "'99-left-over.png' and assert orphan_figures now returns exactly that name"
    )


# --------------------------------------------------------------------------
# Exercise 5 -- uncertainty is stated, not implied.
# --------------------------------------------------------------------------


def test_05_uncertainty_is_present(frame):
    pytest.skip(
        "Assert missing_uncertainty(build_report(frame)) is empty, that all five "
        "panels carry an Estimate, that four of them carry a 95% interval "
        "(low < value < high) and one carries an explicit no_interval_note; then "
        "add bare_point_estimate_candidate() and assert missing_uncertainty now "
        "returns ['bare-total']"
    )


# --------------------------------------------------------------------------
# Exercise 6 -- reproducibility.
# --------------------------------------------------------------------------


def test_06_two_runs_are_byte_identical(
    frame, perturbed_frame, report_dir, second_report_dir, third_report_dir
):
    pytest.skip(
        "Render the same frame twice into two directories and assert the returned "
        "markdown strings are equal, the two report.md files are byte-identical, "
        "and each figures/*.png pair is byte-identical; then render the perturbed "
        "frame and assert it differs; finally assert no ISO date appears anywhere "
        "in the output (re.search(r'\\b20\\d\\d-\\d\\d-\\d\\d\\b', markdown) is None)"
    )


# --------------------------------------------------------------------------
# Exercise 7 -- ordering for the reader.
# --------------------------------------------------------------------------


def test_07_conclusion_comes_before_the_evidence(frame, report_dir):
    pytest.skip(
        "Render the report and assert markdown.index() puts '## Conclusion' before "
        "'## What we looked at and found nothing in', before '## Evidence', before "
        "'## Caveats', before '## Provenance', and before '### Figure 1'; then "
        "assert each panel's numbered caption line appears verbatim in the "
        "conclusion, so the claim and the finding cannot drift apart"
    )


# --------------------------------------------------------------------------
# Exercise 8 -- the "so what" filter.
# --------------------------------------------------------------------------


def test_08_the_so_what_filter(frame, candidates, report_dir):
    pytest.skip(
        "Assert there are 12 candidates, that survivors() keeps 5 and discarded() "
        "drops 7, and that fewer than half survive; render the report and assert "
        "no dropped slug appears in the markdown and no figure file was written "
        "for one, while every dropped candidate's dropped_because line IS in the "
        "markdown, and every surviving slug has a figure file"
    )


# --------------------------------------------------------------------------
# Exercise 9 -- the accessibility contract, as a build check.
# --------------------------------------------------------------------------


def test_09_accessibility_contract(frame, candidates):
    pytest.skip(
        "Assert accessibility_problems(candidate.draw, frame) is empty for every "
        "surviving candidate; then assert accessibility_problems(draw_inaccessible, "
        "frame) reports exactly four problems -- an unlabelled x axis, an "
        "unlabelled y axis, and the two off-palette colours #ff0000 and #008000"
    )
tests/run_tests.sh (20410 bytes)
#!/usr/bin/env bash
# Tests for the Day 133 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The harness proves the day's claims by running the real report generator
# over the real frame and reading the document it produces -- never by
# reading source and never by comparing image bytes across machines:
#
#   * the generator REFUSES a figure with no stated question, and accepts
#     the identical figure once a question is attached;
#   * it REFUSES a caption that is a label ("revenue by region") and
#     accepts one carrying a number;
#   * the numbers printed in the prose move when one input value moves,
#     so the text cannot drift away from the figures;
#   * the rendered report has no orphan figure, and a deliberately
#     orphaned file is detected;
#   * every reported estimate carries a 95% interval or an explicit note
#     saying why none is available, and a bare point estimate is caught;
#   * two runs over the same input produce byte-identical Markdown, and
#     changing one input value changes it;
#   * the conclusion is rendered before the detailed evidence;
#   * 12 candidate figures go in and 5 come out -- the "so what" filter,
#     with the 7 discarded ones surviving only as one-line null results;
#   * every figure passes the accessibility contract, and a red-against-
#     green chart with unlabelled axes fails it with four named problems;
#   * 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 assertion on purpose,
#     confirming a non-zero exit and a printed failure, then restoring it;
#   * no image and no generated Markdown is left behind anywhere.
#
# Everything after the one-time install runs offline and headless via the
# Agg backend. 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
}

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 matplotlib, pandas, numpy" >/dev/null 2>&1; then
  echo "FAIL: matplotlib, pandas or numpy 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

echo "Day 133 — A Report That Argues"
echo

# --------------------------------------------------------------------------
echo "1. The tools and the versions this lab was written against"
# --------------------------------------------------------------------------

versions="$("${python_bin}" - <<'PY'
import platform
from importlib.metadata import version

print(f"python     {platform.python_version()}")
for name in ("matplotlib", "seaborn", "pandas", "numpy", "pytest"):
    try:
        print(f"{name:<10} {version(name)}")
    except Exception as exc:  # pragma: no cover
        print(f"{name:<10} NOT INSTALLED ({exc})")
PY
)"
echo "${versions}"
echo

mismatch=0
while IFS= read -r line; do
  [ -z "${line}" ] && continue
  pkg="${line%%==*}"
  pinned="${line#*==}"
  installed="$("${python_bin}" -c "from importlib.metadata import version; print(version('${pkg}'))" 2>/dev/null || echo "MISSING")"
  if [ "${installed}" != "${pinned}" ]; then
    mismatch=1
    echo "  version mismatch: ${pkg} pinned ${pinned}, installed ${installed}"
  fi
done < "${lab_dir}/requirements/requirements.txt"
check "installed packages match requirements.txt exactly" "$( [ ${mismatch} -eq 0 ] && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "2. The generator's own rules, exercised directly"
# --------------------------------------------------------------------------

behaviour="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY'
"""Drive the report generator and print one machine-readable line per claim."""
import re
import shutil
import tempfile
from pathlib import Path

import data
import analysis
import report as R

frame = data.monthly_sales()
results = {}


def record(key, value):
    results[key] = value


# -- a figure must have a question -----------------------------------------
blank = R.Report(title="t", question="q", decision="d", provenance="p")
nameless = R.Candidate(
    slug="pretty", draw=analysis.draw_missing_by_column, analyse=analysis.analyse_missing
)
try:
    blank.add_panel(nameless, frame)
    record("refuses_questionless", "no")
except R.ReportError as exc:
    record("refuses_questionless", "yes" if "no stated question" in str(exc) else "no")
record("questionless_left_no_panel", "yes" if not blank.panels else "no")

asked = R.Candidate(
    slug="pretty",
    question="Which rows have no revenue?",
    draw=analysis.draw_missing_by_column,
    analyse=analysis.analyse_missing,
)
blank.add_panel(asked, frame)
record("accepts_with_question", "yes" if len(blank.panels) == 1 else "no")

# -- the caption must carry a claim ----------------------------------------
record("rejects_label_caption", "no" if R.carries_claim("revenue by region") else "yes")
record(
    "accepts_claim_caption",
    "yes"
    if R.carries_claim("three regions grew, and the fourth fell by 12% after the March change")
    else "no",
)

# -- the so-what filter ----------------------------------------------------
candidates = analysis.candidate_figures()
kept = R.survivors(candidates)
dropped = R.discarded(candidates)
record("candidates_total", str(len(candidates)))
record("candidates_kept", str(len(kept)))
record("candidates_dropped", str(len(dropped)))

# -- render twice, and once from a perturbed frame -------------------------
first_dir = Path(tempfile.mkdtemp(prefix="d133-a-"))
second_dir = Path(tempfile.mkdtemp(prefix="d133-b-"))
third_dir = Path(tempfile.mkdtemp(prefix="d133-c-"))
try:
    first = analysis.build_report(frame).render(first_dir, frame)
    second = analysis.build_report(frame).render(second_dir, frame)
    changed_frame = data.perturbed()
    third = analysis.build_report(changed_frame).render(third_dir, changed_frame)

    record(
        "markdown_byte_identical",
        "yes"
        if (first_dir / "report.md").read_bytes() == (second_dir / "report.md").read_bytes()
        else "no",
    )
    png_same = all(
        p.read_bytes() == (second_dir / "figures" / p.name).read_bytes()
        for p in sorted((first_dir / "figures").glob("*.png"))
    )
    record("figure_bytes_identical_same_machine", "yes" if png_same else "no")
    record("changed_input_changes_document", "yes" if third != first else "no")
    record("no_clock_reading_in_output", "no" if re.search(r"\b20\d\d-\d\d-\d\d\b", first) else "yes")

    def west(text):
        line = [ln for ln in text.splitlines() if "West change across the pricing change" in ln][0]
        return float(re.search(r"(-?\d+(?:\.\d+)?)%", line).group(1))

    record("west_change_original", f"{west(first):.1f}")
    record("west_change_perturbed", f"{west(third):.1f}")

    # -- ordering ----------------------------------------------------------
    order = [
        first.index("## Conclusion"),
        first.index("## What we looked at and found nothing in"),
        first.index("## Evidence"),
        first.index("## Caveats"),
        first.index("## Provenance"),
    ]
    record("sections_in_reader_order", "yes" if order == sorted(order) else "no")
    record(
        "conclusion_before_first_figure",
        "yes" if first.index("## Conclusion") < first.index("### Figure 1") else "no",
    )

    # -- orphans -----------------------------------------------------------
    figure_dir = first_dir / "figures"
    record("figures_written", str(len(list(figure_dir.glob("*.png")))))
    record("no_orphans_in_report", "yes" if R.orphan_figures(first, figure_dir) == [] else "no")
    shutil.copy(sorted(figure_dir.glob("*.png"))[0], figure_dir / "99-left-over.png")
    record(
        "orphan_is_detected",
        "yes" if R.orphan_figures(first, figure_dir) == ["99-left-over.png"] else "no",
    )

    # -- dropped candidates never reach the document -----------------------
    record(
        "dropped_slugs_absent_from_report",
        "yes" if all(c.slug not in first for c in dropped) else "no",
    )
    record(
        "null_results_kept_as_one_line_each",
        "yes" if all(c.dropped_because in first for c in dropped) else "no",
    )
finally:
    for directory in (first_dir, second_dir, third_dir):
        shutil.rmtree(directory, ignore_errors=True)

# -- uncertainty -----------------------------------------------------------
built = analysis.build_report(frame)
record("every_estimate_has_uncertainty", "yes" if R.missing_uncertainty(built) == [] else "no")
estimates = [p.finding.estimate for p in built.panels]
record("estimates_with_interval", str(sum(1 for e in estimates if e.low is not None)))
record("estimates_with_explicit_note", str(sum(1 for e in estimates if e.no_interval_note)))
built.add_panel(analysis.bare_point_estimate_candidate(), frame)
record(
    "bare_point_estimate_is_caught",
    "yes" if R.missing_uncertainty(built) == ["bare-total"] else "no",
)

# -- accessibility ---------------------------------------------------------
clean = all(R.accessibility_problems(c.draw, frame) == [] for c in kept)
record("every_figure_passes_accessibility", "yes" if clean else "no")
bad = R.accessibility_problems(analysis.draw_inaccessible, frame)
record("inaccessible_chart_problem_count", str(len(bad)))
record(
    "inaccessible_chart_names_both_colours",
    "yes" if any("#ff0000" in p for p in bad) and any("#008000" in p for p in bad) else "no",
)

for key, value in results.items():
    print(f"{key}={value}")
PY
)"
behaviour_status=$?
echo "${behaviour}"
echo

value_of() { echo "${behaviour}" | grep "^$1=" | cut -d= -f2-; }

check "the generator ran without error" "$( [ ${behaviour_status} -eq 0 ] && echo yes || echo no )"
check "refuses a figure with no stated question" "$( [ "$(value_of refuses_questionless)" = yes ] && echo yes || echo no )"
check "a refused figure leaves no half-added panel" "$( [ "$(value_of questionless_left_no_panel)" = yes ] && echo yes || echo no )"
check "accepts the identical figure once it has a question" "$( [ "$(value_of accepts_with_question)" = yes ] && echo yes || echo no )"
check "rejects the caption 'revenue by region' as a label" "$( [ "$(value_of rejects_label_caption)" = yes ] && echo yes || echo no )"
check "accepts a caption carrying a number" "$( [ "$(value_of accepts_claim_caption)" = yes ] && echo yes || echo no )"
check "12 candidate figures go in" "$( [ "$(value_of candidates_total)" = 12 ] && echo yes || echo no )"
check "5 survive the 'so what' filter and 7 do not" "$( [ "$(value_of candidates_kept)" = 5 ] && [ "$(value_of candidates_dropped)" = 7 ] && echo yes || echo no )"
check "two runs over the same input produce byte-identical Markdown" "$( [ "$(value_of markdown_byte_identical)" = yes ] && echo yes || echo no )"
check "figure PNG bytes match across two runs on this machine" "$( [ "$(value_of figure_bytes_identical_same_machine)" = yes ] && echo yes || echo no )"
check "changing one input value changes the document" "$( [ "$(value_of changed_input_changes_document)" = yes ] && echo yes || echo no )"
check "nothing in the output is a clock reading" "$( [ "$(value_of no_clock_reading_in_output)" = yes ] && echo yes || echo no )"
check "the West figure in the prose moved with the data" "$( [ "$(value_of west_change_original)" != "$(value_of west_change_perturbed)" ] && echo yes || echo no )"
check "conclusion, omissions, evidence, caveats, provenance are in reader order" "$( [ "$(value_of sections_in_reader_order)" = yes ] && echo yes || echo no )"
check "the conclusion is rendered before Figure 1" "$( [ "$(value_of conclusion_before_first_figure)" = yes ] && echo yes || echo no )"
check "five figures written, none of them orphaned" "$( [ "$(value_of figures_written)" = 5 ] && [ "$(value_of no_orphans_in_report)" = yes ] && echo yes || echo no )"
check "a deliberately orphaned figure is detected" "$( [ "$(value_of orphan_is_detected)" = yes ] && echo yes || echo no )"
check "no discarded slug reaches the rendered report" "$( [ "$(value_of dropped_slugs_absent_from_report)" = yes ] && echo yes || echo no )"
check "every discarded candidate survives as a one-line null result" "$( [ "$(value_of null_results_kept_as_one_line_each)" = yes ] && echo yes || echo no )"
check "every reported estimate carries an interval or an explicit note" "$( [ "$(value_of every_estimate_has_uncertainty)" = yes ] && echo yes || echo no )"
check "four estimates carry an interval and one carries an explicit note" "$( [ "$(value_of estimates_with_interval)" = 4 ] && [ "$(value_of estimates_with_explicit_note)" = 1 ] && echo yes || echo no )"
check "a bare point estimate is caught" "$( [ "$(value_of bare_point_estimate_is_caught)" = yes ] && echo yes || echo no )"
check "every figure passes the accessibility contract" "$( [ "$(value_of every_figure_passes_accessibility)" = yes ] && echo yes || echo no )"
check "the red-against-green chart fails with four named problems" "$( [ "$(value_of inaccessible_chart_problem_count)" = 4 ] && [ "$(value_of inaccessible_chart_names_both_colours)" = yes ] && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "3. Reference suite -- examples/ must pass in full"
# --------------------------------------------------------------------------

examples_output="$(cd "${lab_dir}" && "${pytest_bin}" examples -q 2>&1)"
examples_status=$?
echo "${examples_output}" | tail -5
check "examples/ exits 0" "$( [ ${examples_status} -eq 0 ] && echo yes || echo no )"
examples_passed_line="$(echo "${examples_output}" | grep -E '^[0-9]+ passed' || true)"
check "examples/ reports 9 passed, 0 failed" "$( echo "${examples_passed_line}" | grep -qE '^9 passed' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "4. Exercise suite -- starter/ is all-skip on an untouched checkout"
# --------------------------------------------------------------------------

starter_output="$(cd "${lab_dir}" && "${pytest_bin}" starter -q 2>&1)"
starter_status=$?
echo "${starter_output}" | tail -5
check "starter/ (untouched) exits 0" "$( [ ${starter_status} -eq 0 ] && echo yes || echo no )"
check "starter/ (untouched) reports 9 skipped, 0 failed" "$( echo "${starter_output}" | grep -qE '^9 skipped' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "5. Never run 'pytest examples starter' in one invocation -- both"
echo "   directories define a module named test_report.py, and pytest"
echo "   collects by dotted module name. Documented, and run as two commands."
# --------------------------------------------------------------------------

combined_output="$(cd "${lab_dir}" && "${pytest_bin}" examples starter -q 2>&1)"
combined_status=$?
check "'pytest examples starter' aborts rather than silently passing" "$( [ ${combined_status} -ne 0 ] && echo yes || echo no )"
check "the collision is reported as an import file mismatch" "$( echo "${combined_output}" | grep -qi 'import file mismatch' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "6. Prove the suite can genuinely FAIL: solve every exercise in a"
echo "   scratch copy, confirm green, break one assertion on purpose,"
echo "   confirm a non-zero exit and a printed failure, then restore."
# --------------------------------------------------------------------------

scratch_dir="$(mktemp -d "${TMPDIR:-/tmp}/d133-scratch.XXXXXX")"
cleanup_scratch() { rm -rf "${scratch_dir}"; }
trap cleanup_scratch EXIT

for module in test_report.py analysis.py report.py data.py conftest.py; do
  cp "${lab_dir}/examples/${module}" "${scratch_dir}/${module}"
done

solved_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
solved_status=$?
check "scratch copy of the solved suite exits 0" "$( [ ${solved_status} -eq 0 ] && echo yes || echo no )"
check "scratch copy reports 9 passed" "$( echo "${solved_output}" | grep -qE '^9 passed' && echo yes || echo no )"

# Break exercise 8's exact survivor count on purpose.
sed -i.bak 's/assert len(kept) == 5/assert len(kept) == 99/' "${scratch_dir}/test_report.py"

broken_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
broken_status=$?
check "broken scratch copy exits non-zero" "$( [ ${broken_status} -ne 0 ] && echo yes || echo no )"
check "broken scratch copy prints a failure" "$( echo "${broken_output}" | grep -qiE 'failed|assert' && echo yes || echo no )"

mv "${scratch_dir}/test_report.py.bak" "${scratch_dir}/test_report.py"
restored_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
restored_status=$?
check "restored scratch copy exits 0 again" "$( [ ${restored_status} -eq 0 ] && echo yes || echo no )"
check "restored scratch copy reports 9 passed again" "$( echo "${restored_output}" | grep -qE '^9 passed' && echo yes || echo no )"

cleanup_scratch
trap - EXIT
echo

# --------------------------------------------------------------------------
echo "7. Offline, and nothing left behind"
# --------------------------------------------------------------------------

url_hits="$(grep -rEl 'https?://|ftp://' "${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null || true)"
check "no URLs inside examples/ or starter/" "$( [ -z "${url_hits}" ] && echo yes || echo no )"

image_hits="$(find "${lab_dir}" -name '.venv' -prune -o -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.svg' -o -iname '*.pdf' \) -print 2>/dev/null || true)"
check "no image files anywhere inside the lab" "$( [ -z "${image_hits}" ] && echo yes || echo no )"

report_hits="$(find "${lab_dir}" -name '.venv' -prune -o -type f -name 'report.md' -print 2>/dev/null || true)"
check "no generated report.md left inside the lab" "$( [ -z "${report_hits}" ] && echo yes || echo no )"

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

stray="$(find "${lab_dir}" -name '.venv' -prune -o \( -type d -name '__pycache__' -print -o -type d -name '.pytest_cache' -print \) 2>/dev/null || true)"
check "no __pycache__ or .pytest_cache left behind" "$( [ -z "${stray}" ] && echo yes || echo no )"

leftover_tmp="$(find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'd133-*' -print 2>/dev/null || true)"
check "no d133 temporary directory left in the system temp directory" "$( [ -z "${leftover_tmp}" ] && echo yes || echo no )"
echo

echo "-------------------------------------------------------------"
echo "${checks} checks, ${failures} failure(s)"
if [ "${failures}" -gt 0 ]; then
  exit 1
fi
exit 0

Troubleshooting

Troubleshooting

pytest: command not found, or the harness exits before any check

You have not created the lab's virtual environment, or you are calling bare pytest rather than the one in .venv.

cd labs/sections/math-statistics-and-data/day-133-building-an-eda-report
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
bash tests/run_tests.sh

The harness looks for .venv/bin/pytest first, then anything on your PATH. To point it at an interpreter of your own:

PYTEST=/path/to/pytest bash tests/run_tests.sh

E ImportError while loading conftest or ModuleNotFoundError: No module named 'report'

You ran pytest from the wrong directory, or with an import mode that does not put the test file's own directory on sys.path. Run from the lab directory and name the directory, not the file:

.venv/bin/pytest starter          # correct
.venv/bin/pytest starter/test_report.py   # also fine
cd starter && pytest .            # also fine

import file mismatch and a collection error

You ran pytest examples starter in one command. starter/test_report.py and examples/test_report.py have the same module name, and pytest collects test modules by dotted name, so the second import collides with the first. Run them as two commands:

.venv/bin/pytest starter -q
.venv/bin/pytest examples -q

Section 5 of the harness runs the combined form deliberately and asserts it fails, so this is a documented behaviour rather than a surprise.

A window tries to open, or RuntimeError: main thread is not in main loop

Something imported matplotlib.pyplot before matplotlib.use("Agg") ran. The backend can only be set before the first pyplot import. conftest.py sets it on its second and third lines for exactly this reason. If you added an import above it, move yours below. If you are running the generator outside pytest, report.py sets Agg itself at import — but only if report is the first thing that imports pyplot.

Belt and braces:

MPLBACKEND=Agg .venv/bin/pytest starter -q

version mismatch in section 1 of the harness

The harness compares every installed version against requirements/requirements.txt and reports any difference. This is a real check, not a formality: report.SAFE_PALETTE is seaborn.color_palette("colorblind") frozen as it stands in seaborn 0.13.2, and exercise 9 compares against it exactly.

If you cannot install the pins, the nine exercises will very likely still pass on any matplotlib 3.x, pandas 2.2+ and NumPy 2.x. expected-output/FIELDS.md records exactly which captured numbers are version-sensitive.

Exercise 6 fails on the figure bytes

Exercise 6 asserts that two renders of the same input produce identical PNG bytes. That comparison is between two runs in the same session on the same machine, and it should hold. If it does not, the usual cause is that something non-deterministic crept into a draw function — a random call without a seed, a dictionary iterated in a different order, or a colour picked from a set.

What this assertion never claims is that your PNG bytes match the bytes on some other machine. They legitimately may not: matplotlib rasterises text through FreeType, and a different FreeType build or a different set of installed fonts produces different pixels from identical code. The byte-identity that the lesson leans on is the Markdown, which contains no rendered glyphs at all.

Exercise 3 passes but the numbers look identical

If the West's percentage is the same for monthly_sales() and perturbed(), you rendered the same frame twice. build_report(frame) and render(directory, frame) both take the frame, and they must be the same one:

changed = data.perturbed()
markdown = analysis.build_report(changed).render(directory, changed)   # both `changed`

Passing monthly_sales() to one and perturbed() to the other produces a document whose figures and prose disagree — which is, with some irony, precisely the failure the exercise exists to prevent.

ReportError when you did not expect one

That is usually the generator doing its job. The three messages:

Message contains What it means
no stated question The candidate's question is None or blank
has a question but no way to answer it draw or analyse is None
label, not a claim The caption has no number and no comparative word

The third is the one that surprises people. "revenue by region" is a label. "revenue fell 12% in the West" is a claim. The check cannot tell whether the claim is true — only that one was made.

The lab left something behind

It should not, and section 7 of the harness checks. If you find a stray directory, it is almost certainly from a manual render you did yourself rather than from the tests:

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache out

Windows

Use WSL2 and follow the Linux instructions. Native Windows works for the Python parts if you substitute .venv\Scripts\python.exe and .venv\Scripts\pytest.exe, but tests/run_tests.sh is a bash script and needs Git Bash or WSL; it will not run in cmd.exe or PowerShell.

Security notes

Security notes

What this lab does to your machine

  • Opens one network connection, ever: pip install -r requirements/requirements.txt, which downloads matplotlib, seaborn, pandas, NumPy and pytest from PyPI into this lab's own .venv. Everything after that runs completely offline. Section 7 of the harness asserts directly that no URL of any kind appears in starter/ or examples/.
  • Renders headless via matplotlib's Agg backend — matplotlib.use("Agg") runs in each conftest.py before pyplot is imported anywhere, and report.py sets it again at import for anyone who uses the generator outside pytest. MPLBACKEND=Agg is exported by the harness as a third belt. No window opens and no display server is needed, which matters on a CI runner or a machine with no screen.
  • Writes only inside its own .venv (created by you), transient __pycache__ and .pytest_cache directories the harness removes both before and after every run, and temporary directories created with tempfile.TemporaryDirectory and mktemp -d that are deleted when the test or the harness section that made them finishes.
  • Never binds a port, never needs sudo, never reads or writes a file outside this lab's directory and those temporary directories.
  • Needs no credential, API key, or account of any kind.

What the data in this lab is

data.monthly_sales() generates every row from a fixed numpy.random.default_rng(133) call. Nothing is loaded from a file, downloaded, or derived from any real organisation's or person's data. The "pricing change", the missing partner rows and the single anomalous month are all put there deliberately by data.py, and the module says so at the top.

That matters for more than privacy. A generated report that argues about data whose truth you already know is the only way to check that the argument is honest — you can compare what the report claims against what you put in.

The part of this lab that is actually a security habit

A report generator writes files, and two of its behaviours are worth naming as controls rather than as features:

  • Nothing is interpolated from user-controlled text into a shell or a template engine. The generator builds Markdown by joining strings in Python. It does not shell out, it does not eval, and it does not render through a template language, so there is no injection surface in it at all.
  • Provenance is a hash of the input, not a note about the run. The report records sha256 of the input frame's CSV serialisation, first twelve hex characters. That is what makes two runs byte-identical, and it is also what lets a reader ask "was this built from the data I think it was built from?" and get an answer. A report that records only a timestamp cannot answer that question.

What to be careful about if you point this at real data

The generator embeds computed numbers directly into prose. That is the point — but it means anything in the input reaches the output. Before you run it on a real dataset:

  • Check what your captions and prose would print. A caption interpolating a "top customer by revenue" prints a customer name into a document you may be about to share.
  • The sha256 fingerprint is of the data, so it changes when the data changes. It is not a secret, but it is a fingerprint: two people can tell whether they hold the same input without either showing it.
  • Figures are raster images with no metadata scrubbing applied. matplotlib does not embed a filename or a user name in a PNG by default, but if you add savefig(..., metadata=...) you are responsible for what goes in.

Cleanup

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf .venv

Nothing else is created. Section 7 of the harness checks that claim rather than asserting it: it looks for image files, for a generated report.md, for __pycache__, for .pytest_cache, and for any d133-* directory left in the system temporary directory, and fails if it finds one.