Math, Statistics, and Datapandas and Data Wrangling › Day 126

Hands-on lab — Day 126: A Reproducible Cleaning Pipeline

Commands

Setup

cd labs/sections/math-statistics-and-data/day-126-a-reproducible-cleaning-pipeline
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"

Run

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

Test

bash tests/run_tests.sh

File tree

examples/conftest.py
examples/data.py
examples/pipeline.py
examples/steps.py
examples/test_pipeline.py
expected-output/examples-run.txt
expected-output/FIELDS.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/conftest.py
starter/data.py
starter/pipeline.py
starter/steps.py
starter/test_pipeline.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 126 lab — A Pipeline You Can Re-run

Lesson

Purpose

Nine numbered exercises, each proving one real property a reproducible pandas 3.0.5 cleaning pipeline must have, by running code and reading real values. The through-line: a notebook that produced the right answer once is not a pipeline. The opening failure this lab is built on: a clip step that recomputes its threshold from whatever data is CURRENTLY passing through it looks correct the first time and produces a different, silently wrong result the second time it runs on its own output — exactly what a retried scheduled job does by accident, routinely. pipeline(pipeline(df)) must equal pipeline(df), exactly, and exercise 1 makes both halves of that sentence concrete: the failure, then the fix. Every later exercise adds one more property a real pipeline needs — determinism and an explicit tie-break, a step log that reconciles, contracts at both ends that can genuinely fail, .pipe() equivalence, declared order-dependence, a Parquet checkpoint that preserves dtypes exactly, and a manifest that answers "which data produced this number?" without guessing.

Learning objectives

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

  • Demonstrate that a step which recomputes its threshold from the current frame is not idempotent, and fix it by reading the threshold from configuration instead.
  • Assert pipeline(pipeline(df)) equals pipeline(df) exactly for a correctly designed pipeline.
  • Show that two independent runs on the same input hash identically, and that an explicit tie-break makes a sort's result independent of arrival order among tied values.
  • Read a step log and confirm it reconciles: every step's rows-out equals the next step's rows-in, and the total change equals the sum of the per-step deltas.
  • Write an input contract that raises, naming the column, on a missing column or a wrong dtype.
  • Write an output contract that raises, naming the violated condition, when a step is sabotaged — proving the contract can genuinely fail.
  • Prove a .pipe() chain produces a frame identical to sequential function application.
  • Demonstrate that swapping two steps changes the result, and state which order the pipeline declares and why.
  • Prove a Parquet checkpoint round-trip preserves every dtype exactly, including a nullable Int64 column's missing value.
  • Build a manifest recording an input hash, a config hash, a step log and an output hash, and show it is stable across runs and sensitive to a one-byte input change.

Prerequisites

  • Day 120 — Series and DataFrames, dtypes and Copy-on-Write.
  • Day 121 — loading and inspecting data; Parquet preserving dtypes where CSV does not, used directly in exercise 8.
  • Day 122 — boolean masks and the partition invariant, the ancestor of this lab's step-log reconciliation habit.
  • Day 123groupby, split-apply-combine, and the reconciliation habit this lab's step log generalises to a whole pipeline.
  • Day 124 — merging and reshaping, and pandas 3.0's str extension dtype for plain string columns, which this lab's input contract and idempotence guard are written against directly.
  • Day 125 — the cleaning techniques (imputation, to_numeric(errors= "coerce"), string normalisation) this lab's steps apply; this lab does not re-teach them, only the engineering that makes them reproducible.
  • A working python3 on your PATH to create the lab's virtual environment.

Supported operating systems

System Status
macOS (Apple Silicon or Intel) Captured here — macOS 26.5.2, arm64
Linux (any current distribution) Expected identical, given the pinned versions below
Windows Use WSL and follow the Linux path. mktemp -d is used inside tests/run_tests.sh; native Windows was not tested and no output is claimed for it

Hardware requirements

Anything. The largest structure built in this lab is a seven-row DataFrame. No GPU, no network beyond the one-time install, no meaningful disk use — the only files this lab writes live inside its own .venv or inside a pytest-managed temporary directory that pytest itself removes.

Required software

Tool Minimum Used here Why
python3 3.11 3.14.0 Runs everything; standard library venv builds the lab's environment
pandas 3.0.5 exactly 3.0.5 Every step, .pipe() chain, to_csv, to_parquet/read_parquet
pyarrow 25.0.1 25.0.1 pandas 3.0's Parquet engine, used by exercise 8's checkpoint
numpy 2.5.2 2.5.2 A pandas dependency; not called directly in this lab
pytest 9.1.1 9.1.1 The test harness, plus monkeypatch and tmp_path
bash 3.2 3.2.57 The outer test harness

hashlib, json, logging and pathlib are Python standard library — already present, no install, no cost — and do the hashing, serialisation and path handling in pipeline.py.

Check your Python in one line: python3 --version.

Free and open-source options

Everything here is free.

  • pandas (BSD 3-Clause), NumPy (BSD 3-Clause) and pytest (MIT) are fully open source with no paid tier.
  • PyArrow (Apache 2.0) is the Arrow project's Python bindings, also fully open source.
  • pandera (MIT) and Great Expectations (Apache 2.0), described from their documentation in the lesson's Tools section rather than run here, offer declarative alternatives to this lab's hand-written contracts, both free with paid hosted tiers for the surrounding platform, not the validation library itself.
  • Prefect and Dagster, also described from documentation only, offer free open-source cores with paid managed-cloud tiers for scheduling and observability at scale.

No account, no key, no paid tier, and no part of this lab is degraded without one.

Installation

cd labs/sections/math-statistics-and-data/day-126-a-reproducible-cleaning-pipeline
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"

If your tools live somewhere unusual, tests/run_tests.sh takes an override rather than guessing:

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

File structure

day-126-a-reproducible-cleaning-pipeline/
├── README.md                     this file
├── metadata.yml                  lab metadata and the recorded run
├── security.md                   what this lab does to your machine
├── troubleshooting.md            grouped by the message you actually see
├── requirements/
│   ├── README.md                  versions, and what each package is for
│   └── requirements.txt           pandas==3.0.5, pyarrow==25.0.1, numpy==2.5.2, pytest==9.1.1
├── starter/                      YOUR work happens here
│   ├── 00_brief.md                exercise-by-exercise instructions
│   ├── data.py                    raw messy orders + CONFIG (read, do not edit)
│   ├── steps.py                   seven pure steps + one deliberately broken step
│   ├── pipeline.py                contracts, step log, hashing, checkpoint, manifest
│   ├── conftest.py                fixtures wrapping data.py
│   └── test_pipeline.py           nine exercises, each a pytest.skip to replace
├── examples/                     the reference. Read AFTER you have tried
│   ├── data.py
│   ├── steps.py
│   ├── pipeline.py
│   ├── conftest.py
│   └── test_pipeline.py           the fully worked, 17-assertion answer key
├── tests/
│   └── run_tests.sh               16 checks of real behaviour
└── expected-output/               captured from a real run on 2026-08-19
    ├── FIELDS.md                   what must match and what may differ
    ├── examples-run.txt            pytest examples -v, captured
    ├── starter-run.txt             pytest starter -v, captured (all skip)
    └── test-run.txt                the full harness run

How to run

## 1. The reference suite. Read this AFTER you have tried the exercises,
##    never before -- it is the answer key.
.venv/bin/pytest examples
.venv/bin/pytest examples -v

## 2. Where you stand on the exercises. An untouched checkout reports
##    17 skipped, 0 failed.
.venv/bin/pytest starter -v

## 3. Your work: open starter/test_pipeline.py and starter/00_brief.md,
##    and replace each pytest.skip(...) with real assertions.
.venv/bin/pytest starter -v -k test_1
.venv/bin/pytest starter -v -k test_2
## ... and so on through test_9, or just:
.venv/bin/pytest starter -v

## 4. Check everything, including the harness's own proof that it can fail.
bash tests/run_tests.sh

Never run pytest examples starter in one command. Every module in this lab — data, steps, pipeline, conftest, test_pipeline — is defined identically in both directories; pytest imports modules by their dotted name, and the second directory's collection aborts outright with an import file mismatch rather than quietly shadowing the first. Run them as two separate commands, always, as shown above.

What the commands do

.venv/bin/pytest examples runs the fully worked reference suite: 17 tests across the nine exercises, each asserting a real property of the pipeline defined in examples/pipeline.py and examples/steps.py.

.venv/bin/pytest starter runs your own suite against the identical pipeline modules, copied into starter/. On an untouched checkout, every one of the 17 tests calls pytest.skip(...) and is reported as s, so the run exits 0 with nothing yet proven. Replace a skip with real assertions and delete the skip line; when all 17 are written and passing, the exercise is done.

bash tests/run_tests.sh confirms the installed pandas matches requirements.txt exactly, runs pytest examples and requires 17 passed, runs pytest starter and requires 17 skipped on the checked-in state, confirms pytest examples starter in one invocation fails to collect at all (rather than quietly shadowing), then solves every exercise in a scratch copy made with mktemp -d (never touching the real starter/test_pipeline.py), confirms that copy passes in full, deliberately breaks one assertion inside it, confirms the run now exits non-zero with a failure reported, restores the line, and confirms it passes again — proving the suite can genuinely fail rather than merely claiming to. It finishes by checking no file in examples/ or starter/ contains a URL, that no .parquet, .json or .csv artifact is left anywhere in the lab, and that nothing else is left on disk.

Expected output

The harness ends with a real captured line:

16 checks, 0 failure(s)

and exits 0. pytest examples ends with:

17 passed in 0.08s

pytest starter, on the checked-in state, ends with:

17 skipped in 0.02s

The reconciliation this whole lab is built on, exactly as captured:

raw_orders                          = 7 rows
dedupe_orders step                  = -1 (order_id 3, a resubmission, is caught)
pipeline output                     = 6 rows
sum of every step's delta           = -1  (matches the total change exactly)

The full capture of both suites is in expected-output/, and expected-output/FIELDS.md says which values are specific to pandas 3.0.5, which are specific to this machine, and which would not differ on any correctly installed copy of this exact version.

Validation steps

  1. bash tests/run_tests.sh ends with 16 checks, 0 failure(s) and exits 0.
  2. The deliberately broken clip step is NOT idempotent: order 7's amount is approximately 1236.5 after one call and approximately 1223.675 after a second call on the same output. The real pipeline IS idempotent: apply_steps_logged(apply_steps_logged(df, config)[0], config)[0] equals apply_steps_logged(df, config)[0] exactly.
  3. Two independent runs of the real pipeline on fresh build_raw_orders() calls produce byte-identical content hashes.
  4. The step log reconciles: every step's rows_out equals the next step's rows_in, and the total change (-1) equals the sum of the per-step deltas.
  5. The input contract raises ContractError naming the missing or wrong-dtype column; the output contract raises ContractError mentioning the clip ceiling when the clip step is sabotaged into a no-op, and raises nothing on the real, unmodified pipeline.
  6. run_pipeline_via_pipe and run_pipeline produce identical frames.
  7. The declared order (normalise, then dedupe) catches the resubmitted order (6 rows, order_id 3 gone); the reversed order misses it (7 rows, both order_id 1 and 3 present).
  8. A Parquet checkpoint preserves every dtype exactly, including priority's Int64 dtype and its missing value at order_id 4.
  9. The manifest's input_hash, config_hash and output_hash are identical across two independent runs on the same input, and changing one character in raw_orders["amount"] changes both input_hash and output_hash while leaving config_hash unchanged.

Tests

bash tests/run_tests.sh
echo "exit code: $?"

16 checks, exit 0 when they all pass and non-zero otherwise. They are value checks, not file-existence checks: the reference suite's 17 assertions are exercised through pytest, the exercise suite is confirmed all-skip on the checked-in state, and a scratch copy proves the suite can genuinely fail and then recover.

Override, if your tools are somewhere unusual:

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

Cleanup

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

tests/run_tests.sh clears __pycache__ and .pytest_cache both before and after it runs, and every Parquet checkpoint this lab writes lives in pytest's own tmp_path — cleaned up by pytest itself, never inside this lab's own directory — so if you only ran the harness, there is nothing left to clean up.

To remove the lab's virtual environment entirely: rm -rf .venv.

To reset your own work and start the exercises again:

git checkout -- starter/

Troubleshooting

troubleshooting.md has the full list, grouped by the message you actually see. The ones you are most likely to meet:

  • pytest examples starter fails with import file mismatch — do not run both directories in one invocation; every module in this lab is defined identically in both.
  • ContractError: ... dtype 'float64', expected 'str' — you fed a pipeline's own output into run_pipeline (which checks the INPUT contract) instead of apply_steps_logged (which does not); idempotence is checked with the latter.
  • Exercise 1's broken step "looks" idempotent to you — confirm you ran the earlier steps (parse, normalise, dedupe, impute) before calling the broken clip step, in that order.

Security notes

security.md has the full account. In short: this lab opens the network exactly once, to install its four pinned packages, and everything else runs offline, writes only inside its own .venv or pytest's own temporary directory, needs no credential, and touches no real data — every row is a small invented literal.

Extension exercises

  1. Add a tenth step that genuinely needs the reconciliation habit. Write a step that removes rows where amount is negative, add it to the declared order, and extend the step log assertions to prove it removed exactly the rows you expect.
  2. Replace one hand-written contract with pandera. Read pandera's documentation (it is not installed here) and write down, in your own words, what pandera.DataFrameSchema would need to express to replace check_input_contract — you do not need to install or run it.
  3. Measure .pipe()'s inspectability cost directly. Rewrite run_pipeline_via_pipe so you can print df.shape after the third step without breaking the chain into two statements, and write down what you had to change to do it.
  4. Add a config parameter that changes the pipeline's behaviour without touching steps.py or pipeline.py. For example, add a dedupe_subset variant that also ignores priority, run the pipeline with both configs, and compare the row counts and the two configs' hashes.
  5. Simulate the scheduled-retry failure directly. Write a short script that calls run_pipeline on the same raw data three times in a row, feeding each output back in as the next call's input via apply_steps_logged, and confirm all three outputs are identical — then swap in the broken clip step and watch the three outputs diverge.
  • Previous day: Day 125 — Cleaning Messy Data (labs/sections/math-statistics-and-data/day-125-cleaning-messy-data/).
  • Next day: Week 19 begins (labs/sections/math-statistics-and-data/).
  • Week 18 project: the week's project directory (labs/sections/math-statistics-and-data/projects/week-18/), "Messy Dataset Rescue" — building directly on this lab's contracts, step log and manifest habits, applied to a dataset of the learner's own.

Expected output

FIELDS.md

# Expected output — what is stable and what may differ

Captured from a real run on 2026-08-19: macOS 26.5.2 (Apple Silicon,
arm64), Python 3.14.0, pandas 3.0.5, pyarrow 25.0.1, NumPy 2.5.2,
pytest 9.1.1.

## Stable on any correctly installed copy of this exact pandas version

- **Row counts and the step log.** The raw table has 7 rows; the real
  pipeline's output has 6 (`dedupe_orders` removes exactly 1); every other
  step's `delta` is 0. This is arithmetic on fixed literals, not a
  measurement — it will not differ on any machine.
- **Every numeric value in the pipeline's output**: `amount` values
  (60.0, 75.0, 120.5, 497.1, 900.0, 900.0), `amount_zscore` values, and the
  broken clip step's two results for order 7 (approximately 1236.5, then
  approximately 1223.675). These come from fixed literals and fixed
  arithmetic — no randomness, no timing, no machine-dependent rounding at
  the precision asserted.
- **The properties the manifest hashes are asserted to have**: equal
  across two independent runs on the same input; different when one input
  byte changes; the config hash unchanged when only the data changes.
  These are properties of the hash FUNCTION, not specific hex digests, and
  hold on any machine.
- **The Parquet round-trip dtypes**, including `priority` staying `Int64`
  with its missing value preserved. Parquet's schema is explicit about
  nullability, unlike CSV, so this is a property of the file format, not
  of this machine.
- **`pytest examples` and `pytest starter` reporting `17 passed` /
  `17 skipped` respectively**, and `pytest examples starter` failing to
  collect with `import file mismatch`. Verified directly in this
  repository, not assumed.
- **The harness total, `16 checks, 0 failure(s)`, exit 0.**

## Specific to pandas 3.0.5 (would differ on an older pandas)

- **`region` and `amount`'s input dtype is `str`, not `object`.** pandas
  3.0's default string-inference gives a plain Python string column its
  own dedicated `str` extension dtype. `pipeline.REQUIRED_INPUT_COLUMNS`
  encodes this explicitly, and it is the reason `parse_currency_amount`'s
  idempotence guard checks `is_numeric_dtype` rather than `dtype ==
  object` — the same correction Day 124's lab made for the identical
  reason.

## Machine-dependent — recorded here so it is never mistaken for universal

- **The literal hex strings of `content_hash` and `config_hash`.**
  `content_hash` is built from `DataFrame.to_csv()`'s exact bytes, which
  depend on pandas' float-to-string formatting; that formatting has been
  stable across recent pandas patch releases in this repository's
  experience but is not a documented guarantee of the CSV writer. This
  lab's tests never assert a specific digest for this reason — only that
  two runs on the same input agree, and that a changed input disagrees.
  Do not treat any digest printed by this lab as a value to check your own
  run against; check that YOUR two runs agree with each other instead.
- **`platform darwin`, the Python interpreter path, and `rootdir`** in
  `expected-output/examples-run.txt` and `expected-output/starter-run.txt`
  — captured from the authoring machine and sanitised to `<repo>` in place
  of the local filesystem path; your own run will show your own platform
  and path.
- **Wall-clock timing** in every pytest summary line (`in 0.08s`, and
  similar) — reported by pytest itself, and will differ machine to
  machine and run to run. Nothing in this lab's assertions depends on it.

examples-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-126-a-reproducible-cleaning-pipeline/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-126-a-reproducible-cleaning-pipeline
collecting ... collected 17 items

examples/test_pipeline.py::test_1_broken_clip_step_is_not_idempotent PASSED [  5%]
examples/test_pipeline.py::test_1_real_pipeline_is_idempotent PASSED     [ 11%]
examples/test_pipeline.py::test_2_two_independent_runs_produce_an_identical_hash PASSED [ 17%]
examples/test_pipeline.py::test_2_tie_break_makes_the_final_order_deterministic_regardless_of_arrival_order PASSED [ 23%]
examples/test_pipeline.py::test_3_step_log_reconciles_between_consecutive_steps PASSED [ 29%]
examples/test_pipeline.py::test_3_step_log_shows_exactly_where_the_row_count_changed PASSED [ 35%]
examples/test_pipeline.py::test_4_input_contract_raises_on_a_missing_column PASSED [ 41%]
examples/test_pipeline.py::test_4_input_contract_raises_on_a_wrong_dtype PASSED [ 47%]
examples/test_pipeline.py::test_5_output_contract_raises_when_the_clip_step_is_sabotaged PASSED [ 52%]
examples/test_pipeline.py::test_5_output_contract_passes_once_the_step_is_restored PASSED [ 58%]
examples/test_pipeline.py::test_6_pipe_chain_equals_sequential_application PASSED [ 64%]
examples/test_pipeline.py::test_7_declared_order_catches_the_resubmitted_order PASSED [ 70%]
examples/test_pipeline.py::test_7_reversed_order_misses_the_resubmitted_order PASSED [ 76%]
examples/test_pipeline.py::test_8_parquet_checkpoint_preserves_every_dtype_exactly PASSED [ 82%]
examples/test_pipeline.py::test_9_manifest_hashes_are_stable_across_independent_runs PASSED [ 88%]
examples/test_pipeline.py::test_9_changing_one_input_byte_changes_both_input_and_output_hash PASSED [ 94%]
examples/test_pipeline.py::test_9_manifest_is_json_serialisable PASSED   [100%]

============================== 17 passed in 0.08s ==============================

starter-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-126-a-reproducible-cleaning-pipeline/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-126-a-reproducible-cleaning-pipeline
collecting ... collected 17 items

starter/test_pipeline.py::test_1_broken_clip_step_is_not_idempotent SKIPPED [  5%]
starter/test_pipeline.py::test_1_real_pipeline_is_idempotent SKIPPED     [ 11%]
starter/test_pipeline.py::test_2_two_independent_runs_produce_an_identical_hash SKIPPED [ 17%]
starter/test_pipeline.py::test_2_tie_break_makes_the_final_order_deterministic_regardless_of_arrival_order SKIPPED [ 23%]
starter/test_pipeline.py::test_3_step_log_reconciles_between_consecutive_steps SKIPPED [ 29%]
starter/test_pipeline.py::test_3_step_log_shows_exactly_where_the_row_count_changed SKIPPED [ 35%]
starter/test_pipeline.py::test_4_input_contract_raises_on_a_missing_column SKIPPED [ 41%]
starter/test_pipeline.py::test_4_input_contract_raises_on_a_wrong_dtype SKIPPED [ 47%]
starter/test_pipeline.py::test_5_output_contract_raises_when_the_clip_step_is_sabotaged SKIPPED [ 52%]
starter/test_pipeline.py::test_5_output_contract_passes_once_the_step_is_restored SKIPPED [ 58%]
starter/test_pipeline.py::test_6_pipe_chain_equals_sequential_application SKIPPED [ 64%]
starter/test_pipeline.py::test_7_declared_order_catches_the_resubmitted_order SKIPPED [ 70%]
starter/test_pipeline.py::test_7_reversed_order_misses_the_resubmitted_order SKIPPED [ 76%]
starter/test_pipeline.py::test_8_parquet_checkpoint_preserves_every_dtype_exactly SKIPPED [ 82%]
starter/test_pipeline.py::test_9_manifest_hashes_are_stable_across_independent_runs SKIPPED [ 88%]
starter/test_pipeline.py::test_9_changing_one_input_byte_changes_both_input_and_output_hash SKIPPED [ 94%]
starter/test_pipeline.py::test_9_manifest_is_json_serialisable SKIPPED   [100%]

============================= 17 skipped in 0.02s ==============================

test-run.txt

Day 126 — A Pipeline You Can Re-run

1. The tools and the versions this lab was written against
python   3.14.0
pandas   3.0.5
pyarrow  25.0.1
numpy    2.5.2
pytest   9.1.1

  ok: installed pandas matches requirements.txt exactly

2. Reference suite -- examples/ must pass in full
.................                                                        [100%]
17 passed in 0.08s
  ok: examples/ exits 0
  ok: examples/ reports 17 passed, 0 failed

3. Exercise suite -- starter/ is all-skip on an untouched checkout
sssssssssssssssss                                                        [100%]
17 skipped in 0.02s
  ok: starter/ (untouched) exits 0
  ok: starter/ (untouched) reports 17 skipped, 0 failed

4. Never run 'pytest examples starter' in one invocation -- every
   module name (data, steps, pipeline, conftest, test_pipeline) is
   shared between both directories, so the second collected can
   shadow, or outright collide with, the first. Checked below.
  ok: pytest examples starter (one invocation) does NOT exit 0
  ok: pytest examples starter reports an import file mismatch, not a quiet partial run

5. 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 FAIL, then restore.
  ok: scratch copy of the solved suite exits 0
  ok: scratch copy reports 17 passed
  ok: broken scratch copy exits non-zero
  ok: broken scratch copy prints a FAIL/failed line
  ok: restored scratch copy exits 0 again
  ok: restored scratch copy reports 17 passed again

6. Nothing in examples/ or starter/ opens a network connection
  ok: no URLs inside examples/ or starter/

7. A pipeline day that litters would be embarrassing -- confirm no
   .parquet, .json or .csv artifact is left behind anywhere in the lab
  ok: no stray .parquet/.json/.csv files under the lab (outside expected-output/)

8. Cleanliness -- nothing left behind by THIS run
  ok: no __pycache__ or .pytest_cache left behind

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

Source files

examples/conftest.py (540 bytes)
"""Shared fixtures. pytest finds this file by itself -- nothing imports it.

`raw_orders` returns a FRESH copy of the raw data on every test, so one
test's mutation can never leak into the next. `config` returns a fresh
COPY of the dict too, for the same reason -- a test that mutates its own
config must never affect another test's.
"""

import copy

import pytest

from data import CONFIG, build_raw_orders


@pytest.fixture
def raw_orders():
    return build_raw_orders()


@pytest.fixture
def config():
    return copy.deepcopy(CONFIG)
examples/data.py (2975 bytes)
"""Raw messy orders data and pipeline configuration for Day 126's lab.

`build_raw_orders()` returns a small, deliberately messy DataFrame built to
exercise every property this lab tests:

- `amount` arrives as currency-formatted strings ("$120.50"), including one
  genuinely missing value -- the input a real intake system would hand you.
- `region` carries whitespace and inconsistent casing (" north", "South").
- Two rows (order_id 1 and 3) are the SAME real-world order resubmitted
  under a new order_id, differing only in region's whitespace/casing --
  they become visible as duplicates only after normalisation. This is
  exercise 6's order-dependence case, built into the data on purpose.
- `priority` is a nullable pandas Int64 column with one missing value
  (order_id 4), carried through the whole pipeline untouched, so exercise 8
  can prove a Parquet checkpoint preserves it exactly (Day 121's result,
  used in anger).

CONFIG carries every threshold, mapping and column list the pipeline reads,
so re-running with different parameters never means editing steps.py or
pipeline.py -- only this dict.
"""

from __future__ import annotations

import pandas as pd

CONFIG: dict = {
    # Fixed ceiling for outlier clipping. NEVER recomputed from the data at
    # run time -- that is exactly the mistake exercise 1's broken step
    # demonstrates. Chosen so two rows collide exactly at the ceiling,
    # which is what makes exercise 2's explicit tie-break necessary.
    "amount_clip_max": 900.0,
    # Columns compared when deciding whether two rows describe the same
    # real-world order. Deliberately excludes order_id, because a
    # resubmitted order is assigned a NEW order_id by the intake system.
    "dedupe_subset": ["region", "amount", "priority"],
    # Fixed reference statistics for the z-score step, computed once from
    # this lab's known-good raw data and frozen here -- never recomputed
    # from whatever frame happens to be passing through the pipeline.
    "amount_reference_mean": 300.0,
    "amount_reference_std": 150.0,
}


def build_raw_orders() -> pd.DataFrame:
    """Seven rows, one duplicate pair, one missing amount, one missing
    priority. Every value below is a literal -- nothing here is randomised,
    so every hash and every assertion in this lab is exactly reproducible.
    """
    return pd.DataFrame(
        {
            "order_id": pd.array([1, 2, 3, 4, 5, 6, 7], dtype="int64"),
            "region": [
                " north",
                "South",
                "north",
                "EAST ",
                "South",
                "west",
                "East",
            ],
            "amount": [
                "$120.50",
                "$980.00",
                "$120.50",
                "$75.00",
                None,
                "$60.00",
                "$1,250.00",
            ],
            "priority": pd.array([1, 2, 1, None, 3, 2, 1], dtype="Int64"),
        }
    )
examples/pipeline.py (9823 bytes)
"""Pipeline orchestration: contracts at both ends, a step log that
reconciles, two equivalent ways to compose the steps, content hashing,
a Parquet checkpoint, and a manifest tying an output back to the input
and configuration that produced it.

The declared, required order is:

    parse_currency_amount
    normalize_region_strings
    dedupe_orders
    impute_missing_amount
    clip_amount_to_fixed_ceiling
    add_amount_zscore
    sort_deterministic

`normalize_region_strings` before `dedupe_orders` is not arbitrary: this
lab's data contains a resubmitted order whose region string is only
recognisable as a duplicate of an earlier order once whitespace and casing
are normalised (see `data.py` and exercise 6). Swapping those two steps
changes which rows survive.
"""

from __future__ import annotations

import hashlib
import json
from pathlib import Path

import pandas as pd

from steps import (
    add_amount_zscore,
    clip_amount_to_fixed_ceiling,
    dedupe_orders,
    impute_missing_amount,
    normalize_region_strings,
    parse_currency_amount,
    sort_deterministic,
)

# --------------------------------------------------------------------------
# Contracts at both ends. A step that cannot meet its contract fails loudly
# -- ContractError, naming the offending column -- rather than passing a
# subtly wrong frame further down the pipeline.
# --------------------------------------------------------------------------

REQUIRED_INPUT_COLUMNS: dict[str, str] = {
    "order_id": "int64",
    # pandas 3.0's default string-inference gives plain Python string
    # columns pandas' own dedicated "str" extension dtype, not "object" --
    # a real, version-specific fact confirmed in this lab's own run, and
    # recorded again in expected-output/FIELDS.md.
    "region": "str",
    "amount": "str",
    "priority": "Int64",
}


class ContractError(ValueError):
    """Raised when a frame fails an input or output contract."""


def check_input_contract(df: pd.DataFrame) -> None:
    for column, expected_dtype in REQUIRED_INPUT_COLUMNS.items():
        if column not in df.columns:
            raise ContractError(f"input contract violated: missing required column '{column}'")
        actual_dtype = str(df[column].dtype)
        if actual_dtype != expected_dtype:
            raise ContractError(
                f"input contract violated: column '{column}' has dtype "
                f"'{actual_dtype}', expected '{expected_dtype}'"
            )


def check_output_contract(df: pd.DataFrame, config: dict) -> None:
    if df["amount"].isna().any():
        raise ContractError("output contract violated: 'amount' still has missing values")
    if not pd.api.types.is_float_dtype(df["amount"]):
        raise ContractError(f"output contract violated: 'amount' has dtype '{df['amount'].dtype}', expected float")
    ceiling = config["amount_clip_max"]
    if df["amount"].max() > ceiling:
        raise ContractError(
            f"output contract violated: 'amount' has a value above the clip ceiling "
            f"{ceiling} (max seen: {df['amount'].max()})"
        )
    if "amount_zscore" not in df.columns:
        raise ContractError("output contract violated: missing required output column 'amount_zscore'")


# --------------------------------------------------------------------------
# The step log. Every step records rows in, rows out, and the resulting
# net change -- read after the fact, it is how you discover that a step is
# quietly discarding rows nobody meant to discard.
# --------------------------------------------------------------------------


def _run_logged(name: str, func, df: pd.DataFrame, log: list[dict]) -> pd.DataFrame:
    rows_in = len(df)
    result = func(df)
    rows_out = len(result)
    log.append({"step": name, "rows_in": rows_in, "rows_out": rows_out, "delta": rows_out - rows_in})
    return result


def apply_steps_logged(df: pd.DataFrame, config: dict) -> tuple[pd.DataFrame, list[dict]]:
    """The pipeline itself: the seven steps, in the declared order, with NO
    contract checks. This is deliberately the function idempotence is
    checked against (exercise 1), because the input contract below is a
    check on raw, freshly-ingested data specifically (`amount` still a
    string) -- feeding a pipeline's OWN output back into that same contract
    would fail for a reason that has nothing to do with idempotence
    (`amount` is now float64, exactly as the pipeline is supposed to leave
    it). Idempotence is a property of the TRANSFORMATION, checked by
    applying it twice in a row; the input contract is a property of
    external data arriving from outside the pipeline for the first time.
    Both matter; they are not the same check.
    """
    log: list[dict] = []
    out = df
    out = _run_logged("parse_currency_amount", parse_currency_amount, out, log)
    out = _run_logged("normalize_region_strings", normalize_region_strings, out, log)
    out = _run_logged("dedupe_orders", lambda d: dedupe_orders(d, config), out, log)
    out = _run_logged("impute_missing_amount", lambda d: impute_missing_amount(d, config), out, log)
    out = _run_logged("clip_amount_to_fixed_ceiling", lambda d: clip_amount_to_fixed_ceiling(d, config), out, log)
    out = _run_logged("add_amount_zscore", lambda d: add_amount_zscore(d, config), out, log)
    out = _run_logged("sort_deterministic", sort_deterministic, out, log)
    return out, log


def run_pipeline(raw_df: pd.DataFrame, config: dict) -> tuple[pd.DataFrame, list[dict]]:
    """The pipeline's real entry point for freshly-ingested data: checks
    the input contract, runs `apply_steps_logged`, checks the output
    contract, and returns the result and its step log.
    """
    check_input_contract(raw_df)
    df, log = apply_steps_logged(raw_df, config)
    check_output_contract(df, config)
    return df, log


def run_pipeline_swapped_order(raw_df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """The SAME seven steps, with `dedupe_orders` run BEFORE
    `normalize_region_strings` instead of after -- the declared order,
    reversed. Used only to demonstrate order-dependence (exercise 6); never
    called from `run_pipeline`.
    """
    check_input_contract(raw_df)
    df = parse_currency_amount(raw_df)
    df = dedupe_orders(df, config)  # swapped: dedupe before normalising
    df = normalize_region_strings(df)
    df = impute_missing_amount(df, config)
    df = clip_amount_to_fixed_ceiling(df, config)
    df = add_amount_zscore(df, config)
    df = sort_deterministic(df)
    return df


def run_pipeline_via_pipe(raw_df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """The same seven steps, in the same declared order, composed with
    `DataFrame.pipe` instead of sequential assignment. Must produce a
    frame identical to `run_pipeline`'s -- exercise 4 proves it.

    `.pipe()` chaining reads well top to bottom, but it comes at a real
    cost: there is nowhere to put a breakpoint or a `print(df.shape)`
    between two links in the chain without breaking the chain apart again,
    which `run_pipeline`'s sequential form gives you for free. That
    tradeoff -- readability against inspectability -- is real, not
    cosmetic, and this lab ran both forms to show it rather than asserting
    it.
    """
    check_input_contract(raw_df)
    df = (
        raw_df.pipe(parse_currency_amount)
        .pipe(normalize_region_strings)
        .pipe(dedupe_orders, config)
        .pipe(impute_missing_amount, config)
        .pipe(clip_amount_to_fixed_ceiling, config)
        .pipe(add_amount_zscore, config)
        .pipe(sort_deterministic)
    )
    check_output_contract(df, config)
    return df


# --------------------------------------------------------------------------
# Determinism: content hashing, a Parquet checkpoint, and the manifest that
# ties an output back to the input and configuration that produced it.
# --------------------------------------------------------------------------


def content_hash(df: pd.DataFrame) -> str:
    """A SHA-256 hex digest of the frame's exact CSV bytes.

    Deterministic for a given pandas/NumPy version and a given frame's
    values, column order and dtypes -- change any one value, one column's
    dtype, or the column or row order, and the digest changes. See this
    lab's `expected-output/FIELDS.md` for exactly what is and is not
    guaranteed to be identical on a different machine.
    """
    payload = df.to_csv(index=False).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()


def config_hash(config: dict) -> str:
    """A SHA-256 hex digest of the config, serialised with sorted keys so
    the digest does not depend on the dict's insertion order.
    """
    payload = json.dumps(config, sort_keys=True).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()


def checkpoint_to_parquet(df: pd.DataFrame, path: Path) -> None:
    """Write a checkpoint. Parquet, not CSV, because Parquet preserves
    dtypes exactly -- including a nullable Int64 column's missing values
    -- where CSV round-trips everything through text and loses them
    (Day 121).
    """
    df.to_parquet(path, index=False)


def load_checkpoint(path: Path) -> pd.DataFrame:
    return pd.read_parquet(path)


def build_manifest(raw_df: pd.DataFrame, config: dict, step_log: list[dict], output_df: pd.DataFrame) -> dict:
    """The provenance record: which input, which configuration, which
    steps ran and what each one did to the row count, and what came out --
    everything needed, months later, to answer "which data produced this
    number?" without guessing.
    """
    return {
        "input_hash": content_hash(raw_df),
        "config_hash": config_hash(config),
        "steps": step_log,
        "output_hash": content_hash(output_df),
    }
examples/steps.py (6374 bytes)
"""Individual pipeline steps.

Each step is a pure function from frame to frame, named for what it does,
and testable on its own against a small fixture -- the cure for the
notebook's defining hazard, out-of-order cell execution. No step mutates
its input in place; every step returns a new frame built with `.copy()`.

Two steps clip `amount` to an upper bound, and only one of them belongs in
the real pipeline:

- `clip_amount_to_fixed_ceiling` reads its threshold from `config` --
  computed once, outside the pipeline, and frozen. Idempotent: clipping
  already-clipped data to the same fixed number changes nothing.
- `clip_amount_to_recomputed_percentile` is kept here ONLY as the lesson's
  worked failure. It recomputes its threshold from whatever data happens to
  be passing through it, which means a second call sees already-clipped
  data and computes a NEW, lower threshold from it -- non-idempotent by
  construction. `pipeline.py` never calls it; `test_pipeline.py` calls it
  directly to prove the failure before the fix.
"""

from __future__ import annotations

import pandas as pd


def parse_currency_amount(df: pd.DataFrame) -> pd.DataFrame:
    """Strip '$' and ',' from `amount` and convert to float64.

    Idempotent by inspection, not just by luck: once `amount` is numeric,
    the "still text" guard below is False and the step is a no-op on a
    second call, rather than re-stripping characters that are no longer
    there. The guard checks for a non-numeric dtype rather than the
    specific `object` dtype, because pandas 3.0's default string inference
    gives a plain Python string column its own `str` extension dtype, not
    `object` -- a version-specific fact this lab's own run confirmed.
    """
    df = df.copy()
    if not pd.api.types.is_numeric_dtype(df["amount"]):
        cleaned = df["amount"].str.replace(r"[$,]", "", regex=True)
        df["amount"] = pd.to_numeric(cleaned, errors="coerce")
    return df


def normalize_region_strings(df: pd.DataFrame) -> pd.DataFrame:
    """Strip whitespace and title-case `region` (" north" -> "North").

    Idempotent: an already-normalised string title-cases to itself.
    """
    df = df.copy()
    df["region"] = df["region"].str.strip().str.title()
    return df


def dedupe_orders(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Drop rows that describe the same real-world order, keeping the
    first occurrence after an explicit, deterministic sort by `order_id`.

    The sort matters: `drop_duplicates(keep="first")` depends on row
    order, and pandas does not promise the input arrived in `order_id`
    order. Sorting first makes "first occurrence" mean the same thing on
    every run, not whatever order the source happened to hand rows over
    in.

    Order-dependent by design (exercise 6): comparing `region` before it
    has been normalised treats " north" and "north" as different values,
    so the resubmitted order in this lab's data is NOT recognised as a
    duplicate unless `normalize_region_strings` already ran.
    """
    df = df.sort_values("order_id", kind="stable").reset_index(drop=True)
    df = df.drop_duplicates(subset=config["dedupe_subset"], keep="first")
    return df.reset_index(drop=True)


def impute_missing_amount(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Fill a missing `amount` with the column's own mean.

    Idempotent in the sense this pipeline relies on: once every `amount`
    is filled, `fillna` on a column with no missing values is a no-op, so
    a second call changes nothing -- provided no later step reintroduces a
    missing value, which none of this pipeline's steps do.
    """
    df = df.copy()
    df["amount"] = df["amount"].fillna(df["amount"].mean())
    return df


def clip_amount_to_fixed_ceiling(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Clip `amount` to `config["amount_clip_max"]` -- a fixed number read
    from configuration, never recomputed from the data. This is the
    correct, idempotent version. Compare `clip_amount_to_recomputed_percentile`
    below, which is not.
    """
    df = df.copy()
    df["amount"] = df["amount"].clip(upper=config["amount_clip_max"])
    return df


def clip_amount_to_recomputed_percentile(df: pd.DataFrame) -> pd.DataFrame:
    """DELIBERATELY NON-IDEMPOTENT. Never called by `pipeline.run_pipeline`.

    Computes its own threshold -- the 99th percentile of `amount` -- from
    whatever frame is passed in, every time it runs. The first call clips
    the true outliers down to the raw data's 99th percentile. Because the
    ceiling is now lower than it was, the SECOND call sees a narrower
    column and computes a new, lower 99th percentile from it, clipping
    again. `pipeline(pipeline(df))` therefore does not equal `pipeline(df)`
    when this step is used -- kept here only so
    `test_pipeline.py::test_1` can run it, watch it fail, and then run the
    fixed version and watch it pass.
    """
    df = df.copy()
    ceiling = df["amount"].quantile(0.99)
    df["amount"] = df["amount"].clip(upper=ceiling)
    return df


def add_amount_zscore(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Attach a z-score computed against FIXED reference statistics in
    `config`, never against this frame's own (possibly already-clipped,
    already-deduplicated) mean and standard deviation. Recomputing the
    reference from the current frame would make this step non-idempotent
    for exactly the same reason the broken clip step is.
    """
    df = df.copy()
    mean = config["amount_reference_mean"]
    std = config["amount_reference_std"]
    df["amount_zscore"] = (df["amount"] - mean) / std
    return df


def sort_deterministic(df: pd.DataFrame) -> pd.DataFrame:
    """Sort by `amount` with `order_id` as an explicit tie-break.

    Two rows in this lab's data collide exactly at the clip ceiling
    (900.0), so sorting by `amount` alone leaves their relative order
    unspecified -- a "stable" sort only preserves whatever order the ROWS
    ARRIVED in, which is not the same thing as a deterministic order
    across two independently built frames. Naming `order_id` as the
    tie-break makes the final row order a fact about the values, not
    about arrival order.
    """
    df = df.copy()
    return df.sort_values(["amount", "order_id"], kind="stable").reset_index(drop=True)
examples/test_pipeline.py (12864 bytes)
"""The worked reference suite for Day 126 -- "A Pipeline You Can Re-run".

Nine exercises, each proving one real property of a reproducible pandas
3.0.5 cleaning pipeline by running code and reading real values -- never by
reading source. Run it:

    pytest examples

Every table and every configuration value these tests use comes from
`data.py`. `steps.py` holds the seven pure step functions plus the one
deliberately broken step. `pipeline.py` composes them, checks contracts,
logs steps, hashes content, checkpoints to Parquet, and builds the
manifest. Read `starter/00_brief.md` for the exercise-by-exercise
explanation; this file is the answer key.
"""

from __future__ import annotations

import copy
import json
import tempfile
from pathlib import Path

import pandas as pd
import pytest

import pipeline as P
import steps as S
from data import CONFIG, build_raw_orders

# --------------------------------------------------------------------------
# Exercise 1 -- idempotence. pipeline(pipeline(df)) must equal pipeline(df)
# exactly. First, the worked failure: a step that recomputes its threshold
# from whatever data is currently passing through it is NOT idempotent.
# --------------------------------------------------------------------------


def test_1_broken_clip_step_is_not_idempotent(raw_orders, config):
    # Get to the point in the pipeline right before clipping, using the
    # real, correct earlier steps.
    prepared = S.parse_currency_amount(raw_orders)
    prepared = S.normalize_region_strings(prepared)
    prepared = S.dedupe_orders(prepared, config)
    prepared = S.impute_missing_amount(prepared, config)

    once = S.clip_amount_to_recomputed_percentile(prepared)
    twice = S.clip_amount_to_recomputed_percentile(once)

    # The two calls do NOT agree: the second call sees already-clipped
    # data, computes a new (lower) 99th percentile from it, and clips
    # again. This is exactly the failure the lesson opens with.
    assert not once.equals(twice), "the recomputing-percentile step is expected to NOT be idempotent"

    # Order 7's amount was clipped twice, to two different values.
    order_7_once = once.loc[once["order_id"] == 7, "amount"].iloc[0]
    order_7_twice = twice.loc[twice["order_id"] == 7, "amount"].iloc[0]
    assert order_7_once == pytest.approx(1236.5)
    assert order_7_twice == pytest.approx(1223.675)
    assert order_7_once != order_7_twice


def test_1_real_pipeline_is_idempotent(raw_orders, config):
    once, _ = P.apply_steps_logged(raw_orders, config)
    twice, _ = P.apply_steps_logged(once, config)

    # Every step in the real pipeline reads its thresholds from `config`,
    # never from the frame passing through it -- applying the whole
    # pipeline to its own output changes nothing.
    assert once.equals(twice)


# --------------------------------------------------------------------------
# Exercise 2 -- determinism. Two independent runs on the same input must
# produce an identical content hash, including after an explicit
# tie-breaking sort.
# --------------------------------------------------------------------------


def test_2_two_independent_runs_produce_an_identical_hash(config):
    df_a, _ = P.run_pipeline(build_raw_orders(), config)
    df_b, _ = P.run_pipeline(build_raw_orders(), config)

    assert df_a.equals(df_b)
    assert P.content_hash(df_a) == P.content_hash(df_b)


def test_2_tie_break_makes_the_final_order_deterministic_regardless_of_arrival_order(raw_orders, config):
    prepared, _ = P.apply_steps_logged(raw_orders, config)
    # Two rows -- order_id 2 and order_id 7 -- collide exactly at the clip
    # ceiling (900.0), so their relative order after a sort by `amount`
    # alone is not determined by the VALUES.
    tied = prepared.loc[prepared["amount"] == 900.0, "order_id"].tolist()
    assert sorted(tied) == [2, 7]

    # A stable sort by amount ALONE merely preserves whatever order the
    # rows happened to arrive in -- shuffle the arrival order and the tie
    # is broken differently.
    forward = prepared.sort_values("amount", kind="stable").reset_index(drop=True)
    reversed_input = prepared.iloc[::-1].reset_index(drop=True)
    backward = reversed_input.sort_values("amount", kind="stable").reset_index(drop=True)
    forward_tied_order = forward.loc[forward["amount"] == 900.0, "order_id"].tolist()
    backward_tied_order = backward.loc[backward["amount"] == 900.0, "order_id"].tolist()
    assert forward_tied_order != backward_tied_order, (
        "a sort with no tie-break is expected to depend on arrival order among tied rows"
    )

    # sort_deterministic names order_id as an explicit tie-break, so the
    # SAME two arrival orders now agree with each other.
    forward_final = S.sort_deterministic(prepared)
    backward_final = S.sort_deterministic(reversed_input)
    assert forward_final.equals(backward_final)
    assert forward_final.loc[forward_final["amount"] == 900.0, "order_id"].tolist() == [2, 7]


# --------------------------------------------------------------------------
# Exercise 3 -- the step log reconciles: every step's rows-out equals the
# next step's rows-in, and the total change equals the sum of the
# per-step changes.
# --------------------------------------------------------------------------


def test_3_step_log_reconciles_between_consecutive_steps(raw_orders, config):
    _, log = P.run_pipeline(raw_orders, config)

    for earlier, later in zip(log, log[1:]):
        assert earlier["rows_out"] == later["rows_in"], (
            f"{earlier['step']}'s rows_out must equal {later['step']}'s rows_in"
        )

    total_change = log[-1]["rows_out"] - log[0]["rows_in"]
    sum_of_deltas = sum(step["delta"] for step in log)
    assert total_change == sum_of_deltas == -1  # exactly one row deduplicated away


def test_3_step_log_shows_exactly_where_the_row_count_changed(raw_orders, config):
    _, log = P.run_pipeline(raw_orders, config)
    by_name = {step["step"]: step for step in log}

    assert by_name["dedupe_orders"]["delta"] == -1
    for name, step in by_name.items():
        if name != "dedupe_orders":
            assert step["delta"] == 0, f"{name} was expected to change no rows, delta was {step['delta']}"


# --------------------------------------------------------------------------
# Exercise 4 -- the input contract raises on a frame with a missing column
# or a wrong dtype, naming the offending column.
# --------------------------------------------------------------------------


def test_4_input_contract_raises_on_a_missing_column(raw_orders, config):
    broken = raw_orders.drop(columns=["priority"])
    with pytest.raises(P.ContractError, match="priority"):
        P.run_pipeline(broken, config)


def test_4_input_contract_raises_on_a_wrong_dtype(raw_orders, config):
    broken = raw_orders.copy()
    broken["order_id"] = broken["order_id"].astype("float64")
    with pytest.raises(P.ContractError, match="order_id"):
        P.run_pipeline(broken, config)


# --------------------------------------------------------------------------
# Exercise 5 -- the output contract raises when a step is sabotaged so its
# post-condition fails -- proving the contract can genuinely fail, not
# just pass.
# --------------------------------------------------------------------------


def test_5_output_contract_raises_when_the_clip_step_is_sabotaged(raw_orders, config, monkeypatch):
    def no_op_clip(df, config):  # a "clip" step that clips nothing
        return df

    monkeypatch.setattr(P, "clip_amount_to_fixed_ceiling", no_op_clip)

    with pytest.raises(P.ContractError, match="clip ceiling"):
        P.run_pipeline(raw_orders, config)


def test_5_output_contract_passes_once_the_step_is_restored(raw_orders, config):
    # Sanity check that the sabotage above is what triggers the failure,
    # not something else -- the unmodified pipeline must still pass.
    df, _ = P.run_pipeline(raw_orders, config)
    P.check_output_contract(df, config)  # raises nothing


# --------------------------------------------------------------------------
# Exercise 6 -- a .pipe() chain gives exactly the same frame as sequential
# application.
# --------------------------------------------------------------------------


def test_6_pipe_chain_equals_sequential_application(raw_orders, config):
    sequential, _ = P.run_pipeline(raw_orders, config)
    via_pipe = P.run_pipeline_via_pipe(raw_orders, config)
    assert sequential.equals(via_pipe)


# --------------------------------------------------------------------------
# Exercise 7 -- order dependence. Swapping normalize_region_strings and
# dedupe_orders changes the result: the declared order (normalise, then
# dedupe) catches a resubmitted order that arrives with different region
# casing; the reversed order misses it.
# --------------------------------------------------------------------------


def test_7_declared_order_catches_the_resubmitted_order(raw_orders, config):
    df, _ = P.run_pipeline(raw_orders, config)
    assert len(df) == 6
    assert 3 not in df["order_id"].tolist()  # the resubmission, order_id 3, is gone
    assert 1 in df["order_id"].tolist()  # the original, order_id 1, survives


def test_7_reversed_order_misses_the_resubmitted_order(raw_orders, config):
    swapped = P.run_pipeline_swapped_order(raw_orders, config)
    assert len(swapped) == 7  # nothing was deduplicated
    assert {1, 3}.issubset(set(swapped["order_id"].tolist()))  # BOTH survive


# --------------------------------------------------------------------------
# Exercise 8 -- a Parquet checkpoint round-trip preserves every dtype
# exactly, including a nullable Int64 column with a missing value.
# --------------------------------------------------------------------------


def test_8_parquet_checkpoint_preserves_every_dtype_exactly(raw_orders, tmp_path):
    before = S.normalize_region_strings(S.parse_currency_amount(raw_orders))
    checkpoint_path = tmp_path / "checkpoint.parquet"

    P.checkpoint_to_parquet(before, checkpoint_path)
    after = P.load_checkpoint(checkpoint_path)

    assert list(before.dtypes.astype(str)) == list(after.dtypes.astype(str))
    assert before.equals(after)

    # The nullable Int64 column, missing value included, round-trips exactly.
    assert str(before["priority"].dtype) == "Int64"
    assert str(after["priority"].dtype) == "Int64"
    assert before["priority"].isna().tolist() == after["priority"].isna().tolist()
    assert after.loc[after["order_id"] == 4, "priority"].isna().iloc[0]


# --------------------------------------------------------------------------
# Exercise 9 -- the manifest's hashes are stable across runs, and changing
# one input byte changes the input hash AND the output hash.
# --------------------------------------------------------------------------


def test_9_manifest_hashes_are_stable_across_independent_runs(config):
    df_a, log_a = P.run_pipeline(build_raw_orders(), config)
    manifest_a = P.build_manifest(build_raw_orders(), config, log_a, df_a)

    df_b, log_b = P.run_pipeline(build_raw_orders(), config)
    manifest_b = P.build_manifest(build_raw_orders(), config, log_b, df_b)

    assert manifest_a["input_hash"] == manifest_b["input_hash"]
    assert manifest_a["config_hash"] == manifest_b["config_hash"]
    assert manifest_a["output_hash"] == manifest_b["output_hash"]
    assert manifest_a["steps"] == manifest_b["steps"]


def test_9_changing_one_input_byte_changes_both_input_and_output_hash(raw_orders, config):
    original_df, original_log = P.run_pipeline(raw_orders, config)
    original_manifest = P.build_manifest(raw_orders, config, original_log, original_df)

    changed = raw_orders.copy()
    changed.loc[5, "amount"] = "$60.01"  # was "$60.00" -- one character different
    changed_df, changed_log = P.run_pipeline(changed, config)
    changed_manifest = P.build_manifest(changed, config, changed_log, changed_df)

    assert original_manifest["input_hash"] != changed_manifest["input_hash"]
    assert original_manifest["output_hash"] != changed_manifest["output_hash"]
    # The config did not change, so its hash must not change either.
    assert original_manifest["config_hash"] == changed_manifest["config_hash"]
    # Row count is unaffected -- only the one value changed.
    assert len(original_df) == len(changed_df) == 6


def test_9_manifest_is_json_serialisable(raw_orders, config):
    df, log = P.run_pipeline(raw_orders, config)
    manifest = P.build_manifest(raw_orders, config, log, df)
    # A manifest that cannot round-trip through JSON is not much of a
    # provenance record -- prove it actually can.
    serialised = json.dumps(manifest, sort_keys=True)
    reloaded = json.loads(serialised)
    assert reloaded["input_hash"] == manifest["input_hash"]
    assert reloaded["steps"] == manifest["steps"]
metadata.yml (3485 bytes)
lesson_id: D126
day: 126
kind: guided-build
languages: [python, bash]
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-126-a-reproducible-cleaning-pipeline
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/python3 -c "import pandas; print(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: 45
last_executed: '2026-08-19'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, pandas 3.0.5, pyarrow 25.0.1, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 16 checks, 0 failure(s), exit 0. pytest examples -> 17 passed. pytest starter -> 17 skipped (untouched checkout). pytest examples starter (one invocation) -> collection aborts with "import file mismatch", exit non-zero, confirmed directly rather than assumed, because every one of this lab''s five modules (data, steps, pipeline, conftest, test_pipeline) is defined identically in both directories. Section 5 of the harness solves every exercise in a scratch copy (17 passed), deliberately breaks exercise 1''s idempotence assertion (once.equals(twice) -> not once.equals(twice)), confirms the run exits non-zero with a printed FAIL, restores the file, and confirms 17 passed again. Separately, the coordinator broke the identical line directly in examples/test_pipeline.py (not a scratch copy) and re-ran the full harness: 1 failed, 16 passed from pytest, and 6 of the harness''s own 16 checks failed with overall exit 1; restoring the line returned the harness to 16 checks, 0 failure(s), exit 0. Everything was run through a real lab-local .venv created by the documented setup commands. Two honesty notes from this run. FIRST: pandas 3.0.5 infers the plain Python string columns `region` and `amount` as its own dedicated `str` extension dtype by default, not the historical `object` -- the same correction Day 124''s lab made for the identical reason -- and pipeline.py''s input contract and steps.py''s idempotence guard on parse_currency_amount are written against that fact rather than against `object`, confirmed by running the contract check and reading the raised error before writing the fix. SECOND: idempotence in this lab is checked against pipeline.apply_steps_logged (the seven steps with no contract checks), never against pipeline.run_pipeline directly on a pipeline''s own output -- feeding a pipeline''s OUTPUT (amount already float64) back into the INPUT contract (which requires amount to still be the str dtype of freshly-ingested data) raises ContractError for a reason that has nothing to do with idempotence, and conflating the two was an early design mistake caught by actually running the code, not assumed correct from the design. matplotlib, scipy, scikit-learn, polars, pandera and Great Expectations are not installed in this environment; pandera, Great Expectations and a workflow runner such as Prefect or Dagster are described in the lesson''s Tools section from public documentation only, and no output attributed to any of them is reproduced anywhere in this lab or its lesson.'
requirements/README.md (2233 bytes)
# What is installed, why, and what it costs

Four packages, all free and open source, installed into a lab-local
virtual environment that `rm -rf .venv` completely undoes.

| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `pandas` | 3.0.5 | BSD 3-Clause | Every step function, `.pipe()` chain, `to_csv` (content hashing), `to_parquet`/`read_parquet` (checkpointing). |
| `pyarrow` | 25.0.1 | Apache 2.0 | pandas 3.0's Parquet engine — exercise 8's checkpoint round-trip goes through it. |
| `numpy` | 2.5.2 | BSD 3-Clause | Pulled in as a pandas dependency; nothing in this lab calls NumPy directly. |
| `pytest` | 9.1.1 | MIT | The test harness every exercise is written against, plus `monkeypatch` and `tmp_path` for exercises 5 and 8. |

`hashlib`, `json`, `logging` and `pathlib` — all standard library, no
install, no cost — do the rest: `hashlib` builds every hash in
`pipeline.py`, `json` serialises the manifest and the config for hashing,
and `pathlib.Path` is the type every checkpoint path uses.

There is no paid tier of anything in this lab, no account, no key and no
signup, personally or commercially.

## The one time the network is needed

```bash
.venv/bin/pip install -r requirements/requirements.txt
```

That is the only command in the lab that opens a connection. Every script
and test after that runs completely offline.

## What is deliberately *not* installed

**matplotlib**, **scipy**, **scikit-learn**, **polars**, **pandera** and
**Great Expectations** are not installed in this environment. The lesson's
Tools section describes `pandera`, Great Expectations, and a workflow
runner such as Prefect or Dagster from their public documentation as
design contrasts to this lab's hand-written contracts — no output from any
of them is reproduced anywhere in this lab or its lesson; every place they
are mentioned says so plainly.

## If you cannot install anything at all

pandas is not in the Python standard library, and there is no reduced path
through this lab without it. If pandas genuinely cannot be installed, read
the lesson's captured output and `expected-output/` directory instead;
every number there came from a real run and is not invented.
requirements/requirements.txt (57 bytes)
pandas==3.0.5
pyarrow==25.0.1
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (6067 bytes)
# Day 126 lab — the brief

Nine exercises, in order. Work top to bottom in `test_pipeline.py`. The
pipeline itself lives in three modules you read but do NOT edit:

- `data.py` — `build_raw_orders()`, the seven-row messy input, and
  `CONFIG`, every threshold and mapping the pipeline reads.
- `steps.py` — seven pure step functions, each frame-to-frame, plus one
  deliberately broken step (`clip_amount_to_recomputed_percentile`) kept
  only so exercise 1 can run it and watch it fail.
- `pipeline.py` — orchestration: `check_input_contract`,
  `check_output_contract`, `apply_steps_logged`, `run_pipeline`,
  `run_pipeline_swapped_order`, `run_pipeline_via_pipe`, `content_hash`,
  `config_hash`, `checkpoint_to_parquet`, `load_checkpoint`,
  `build_manifest`.

Read all three once before you start. Every fixture you need
(`raw_orders`, `config`) comes from `conftest.py`.

Check yourself at any point:

```bash
.venv/bin/pytest starter -v
```

On an untouched checkout that prints `17 skipped`. A **skip** means "not
attempted". Replace a `pytest.skip(...)` line with real assertions and
delete it — when every skip is gone and the suite is green, you are
finished:

```bash
.venv/bin/pytest starter -q
echo $?
```

Assert exact values everywhere. Nothing in this lab depends on timing.

---

## Exercise 1 — idempotence

`pipeline(pipeline(df))` must equal `pipeline(df)` exactly. First reproduce
the failure that makes this worth checking: `steps.clip_amount_to_recomputed_percentile`
computes its clip threshold — the 99th percentile — from whatever data is
CURRENTLY passing through it. Run the real early steps by hand (parse,
normalise, dedupe, impute), then call the broken clip step once and again
on its own output. Assert the two results are NOT equal, and that order 7's
amount differs between the two calls (`approx(1236.5)`, then
`approx(1223.675)`).

Then prove the real pipeline does not share this flaw:
`pipeline.apply_steps_logged` reads every threshold from `config`, never
from the frame passing through it. Call it once, then again on its own
output, and assert the two frames are `.equals()` — exactly, no `approx`.

## Exercise 2 — determinism

Run the real pipeline twice, each time on a **fresh** `build_raw_orders()`
call. Assert the two output frames are `.equals()` and that
`pipeline.content_hash` agrees on both.

Then the tie-break. Orders 2 and 7 both land on `amount == 900.0` after
clipping. Show that sorting by `amount` alone gives a DIFFERENT tie order
depending on whether the rows arrived forward or reversed
(`prepared.iloc[::-1]`), then show that `steps.sort_deterministic` gives
the SAME order either way, because it names `order_id` as an explicit
tie-break.

## Exercise 3 — the step log reconciles

Run the pipeline and read its step log. For every consecutive pair of
steps, assert the earlier one's `rows_out` equals the later one's
`rows_in`. Assert the total change (`last rows_out - first rows_in`)
equals the sum of every step's `delta`, and that this equals `-1` — one row
deduplicated away. Then assert `dedupe_orders` is the ONLY step with a
non-zero delta.

## Exercise 4 — the input contract

Drop the `priority` column from `raw_orders` and assert
`pipeline.run_pipeline` raises `pipeline.ContractError` naming `priority`
in its message. Then cast `order_id` to `float64` and assert it raises
naming `order_id`.

## Exercise 5 — the output contract

Use `monkeypatch.setattr(pipeline, "clip_amount_to_fixed_ceiling", ...)` to
replace the clip step with a function that returns its input unchanged —
sabotage the pipeline's own promise that `amount` never exceeds the
configured ceiling. Assert `run_pipeline` now raises `ContractError`
mentioning "clip ceiling". Then, as a sanity check, run the UNMODIFIED
pipeline and call `pipeline.check_output_contract` on its result directly —
it must raise nothing, confirming the sabotage above, not something else,
was what triggered the failure.

## Exercise 6 — `.pipe()` equivalence

Assert `pipeline.run_pipeline(raw_orders, config)[0]` and
`pipeline.run_pipeline_via_pipe(raw_orders, config)` are `.equals()`.

## Exercise 7 — order dependence

`normalize_region_strings` before `dedupe_orders` is the pipeline's
declared order, and this lab's data makes it matter: order_id 3 is a
resubmission of order_id 1, differing only in region's whitespace and
casing (`" north"` vs `"north"`). Run the real pipeline and assert the
result has 6 rows, order_id 3 is gone, and order_id 1 survives. Then run
`pipeline.run_pipeline_swapped_order` (the same steps, dedupe run first)
and assert it has 7 rows — nothing deduplicated — with BOTH order_id 1 and
order_id 3 present.

## Exercise 8 — the Parquet checkpoint

Parse and normalise `raw_orders` (stop there — do not dedupe yet, so
order_id 4's missing `priority` is still present). Checkpoint it to
`tmp_path / "checkpoint.parquet"` with `pipeline.checkpoint_to_parquet`,
reload it with `pipeline.load_checkpoint`, and assert every dtype matches
exactly, the two frames are `.equals()`, and the reloaded `priority` column
is still `Int64` with order_id 4's value still missing.

## Exercise 9 — the manifest

Build a manifest from two independent `build_raw_orders()` runs through the
pipeline (`pipeline.build_manifest`). Assert `input_hash`, `config_hash`,
`output_hash` and `steps` all agree between the two. Then build a manifest
for `raw_orders`, change order_id 6's amount from `"$60.00"` to `"$60.01"`
(one character), and build a second manifest — assert `input_hash` and
`output_hash` both differ, `config_hash` stays the same (the config did not
change), and the row count is unaffected. Finally, round-trip a manifest
through `json.dumps`/`json.loads` and assert the reloaded `input_hash` and
`steps` match the original.

---

Prove your suite is not vacuous once you are green: re-break one assertion
on purpose (flip a comparison, change an expected number), confirm the run
exits non-zero with a printed `FAIL`, then restore it and confirm green
again.
starter/conftest.py (540 bytes)
"""Shared fixtures. pytest finds this file by itself -- nothing imports it.

`raw_orders` returns a FRESH copy of the raw data on every test, so one
test's mutation can never leak into the next. `config` returns a fresh
COPY of the dict too, for the same reason -- a test that mutates its own
config must never affect another test's.
"""

import copy

import pytest

from data import CONFIG, build_raw_orders


@pytest.fixture
def raw_orders():
    return build_raw_orders()


@pytest.fixture
def config():
    return copy.deepcopy(CONFIG)
starter/data.py (2975 bytes)
"""Raw messy orders data and pipeline configuration for Day 126's lab.

`build_raw_orders()` returns a small, deliberately messy DataFrame built to
exercise every property this lab tests:

- `amount` arrives as currency-formatted strings ("$120.50"), including one
  genuinely missing value -- the input a real intake system would hand you.
- `region` carries whitespace and inconsistent casing (" north", "South").
- Two rows (order_id 1 and 3) are the SAME real-world order resubmitted
  under a new order_id, differing only in region's whitespace/casing --
  they become visible as duplicates only after normalisation. This is
  exercise 6's order-dependence case, built into the data on purpose.
- `priority` is a nullable pandas Int64 column with one missing value
  (order_id 4), carried through the whole pipeline untouched, so exercise 8
  can prove a Parquet checkpoint preserves it exactly (Day 121's result,
  used in anger).

CONFIG carries every threshold, mapping and column list the pipeline reads,
so re-running with different parameters never means editing steps.py or
pipeline.py -- only this dict.
"""

from __future__ import annotations

import pandas as pd

CONFIG: dict = {
    # Fixed ceiling for outlier clipping. NEVER recomputed from the data at
    # run time -- that is exactly the mistake exercise 1's broken step
    # demonstrates. Chosen so two rows collide exactly at the ceiling,
    # which is what makes exercise 2's explicit tie-break necessary.
    "amount_clip_max": 900.0,
    # Columns compared when deciding whether two rows describe the same
    # real-world order. Deliberately excludes order_id, because a
    # resubmitted order is assigned a NEW order_id by the intake system.
    "dedupe_subset": ["region", "amount", "priority"],
    # Fixed reference statistics for the z-score step, computed once from
    # this lab's known-good raw data and frozen here -- never recomputed
    # from whatever frame happens to be passing through the pipeline.
    "amount_reference_mean": 300.0,
    "amount_reference_std": 150.0,
}


def build_raw_orders() -> pd.DataFrame:
    """Seven rows, one duplicate pair, one missing amount, one missing
    priority. Every value below is a literal -- nothing here is randomised,
    so every hash and every assertion in this lab is exactly reproducible.
    """
    return pd.DataFrame(
        {
            "order_id": pd.array([1, 2, 3, 4, 5, 6, 7], dtype="int64"),
            "region": [
                " north",
                "South",
                "north",
                "EAST ",
                "South",
                "west",
                "East",
            ],
            "amount": [
                "$120.50",
                "$980.00",
                "$120.50",
                "$75.00",
                None,
                "$60.00",
                "$1,250.00",
            ],
            "priority": pd.array([1, 2, 1, None, 3, 2, 1], dtype="Int64"),
        }
    )
starter/pipeline.py (9823 bytes)
"""Pipeline orchestration: contracts at both ends, a step log that
reconciles, two equivalent ways to compose the steps, content hashing,
a Parquet checkpoint, and a manifest tying an output back to the input
and configuration that produced it.

The declared, required order is:

    parse_currency_amount
    normalize_region_strings
    dedupe_orders
    impute_missing_amount
    clip_amount_to_fixed_ceiling
    add_amount_zscore
    sort_deterministic

`normalize_region_strings` before `dedupe_orders` is not arbitrary: this
lab's data contains a resubmitted order whose region string is only
recognisable as a duplicate of an earlier order once whitespace and casing
are normalised (see `data.py` and exercise 6). Swapping those two steps
changes which rows survive.
"""

from __future__ import annotations

import hashlib
import json
from pathlib import Path

import pandas as pd

from steps import (
    add_amount_zscore,
    clip_amount_to_fixed_ceiling,
    dedupe_orders,
    impute_missing_amount,
    normalize_region_strings,
    parse_currency_amount,
    sort_deterministic,
)

# --------------------------------------------------------------------------
# Contracts at both ends. A step that cannot meet its contract fails loudly
# -- ContractError, naming the offending column -- rather than passing a
# subtly wrong frame further down the pipeline.
# --------------------------------------------------------------------------

REQUIRED_INPUT_COLUMNS: dict[str, str] = {
    "order_id": "int64",
    # pandas 3.0's default string-inference gives plain Python string
    # columns pandas' own dedicated "str" extension dtype, not "object" --
    # a real, version-specific fact confirmed in this lab's own run, and
    # recorded again in expected-output/FIELDS.md.
    "region": "str",
    "amount": "str",
    "priority": "Int64",
}


class ContractError(ValueError):
    """Raised when a frame fails an input or output contract."""


def check_input_contract(df: pd.DataFrame) -> None:
    for column, expected_dtype in REQUIRED_INPUT_COLUMNS.items():
        if column not in df.columns:
            raise ContractError(f"input contract violated: missing required column '{column}'")
        actual_dtype = str(df[column].dtype)
        if actual_dtype != expected_dtype:
            raise ContractError(
                f"input contract violated: column '{column}' has dtype "
                f"'{actual_dtype}', expected '{expected_dtype}'"
            )


def check_output_contract(df: pd.DataFrame, config: dict) -> None:
    if df["amount"].isna().any():
        raise ContractError("output contract violated: 'amount' still has missing values")
    if not pd.api.types.is_float_dtype(df["amount"]):
        raise ContractError(f"output contract violated: 'amount' has dtype '{df['amount'].dtype}', expected float")
    ceiling = config["amount_clip_max"]
    if df["amount"].max() > ceiling:
        raise ContractError(
            f"output contract violated: 'amount' has a value above the clip ceiling "
            f"{ceiling} (max seen: {df['amount'].max()})"
        )
    if "amount_zscore" not in df.columns:
        raise ContractError("output contract violated: missing required output column 'amount_zscore'")


# --------------------------------------------------------------------------
# The step log. Every step records rows in, rows out, and the resulting
# net change -- read after the fact, it is how you discover that a step is
# quietly discarding rows nobody meant to discard.
# --------------------------------------------------------------------------


def _run_logged(name: str, func, df: pd.DataFrame, log: list[dict]) -> pd.DataFrame:
    rows_in = len(df)
    result = func(df)
    rows_out = len(result)
    log.append({"step": name, "rows_in": rows_in, "rows_out": rows_out, "delta": rows_out - rows_in})
    return result


def apply_steps_logged(df: pd.DataFrame, config: dict) -> tuple[pd.DataFrame, list[dict]]:
    """The pipeline itself: the seven steps, in the declared order, with NO
    contract checks. This is deliberately the function idempotence is
    checked against (exercise 1), because the input contract below is a
    check on raw, freshly-ingested data specifically (`amount` still a
    string) -- feeding a pipeline's OWN output back into that same contract
    would fail for a reason that has nothing to do with idempotence
    (`amount` is now float64, exactly as the pipeline is supposed to leave
    it). Idempotence is a property of the TRANSFORMATION, checked by
    applying it twice in a row; the input contract is a property of
    external data arriving from outside the pipeline for the first time.
    Both matter; they are not the same check.
    """
    log: list[dict] = []
    out = df
    out = _run_logged("parse_currency_amount", parse_currency_amount, out, log)
    out = _run_logged("normalize_region_strings", normalize_region_strings, out, log)
    out = _run_logged("dedupe_orders", lambda d: dedupe_orders(d, config), out, log)
    out = _run_logged("impute_missing_amount", lambda d: impute_missing_amount(d, config), out, log)
    out = _run_logged("clip_amount_to_fixed_ceiling", lambda d: clip_amount_to_fixed_ceiling(d, config), out, log)
    out = _run_logged("add_amount_zscore", lambda d: add_amount_zscore(d, config), out, log)
    out = _run_logged("sort_deterministic", sort_deterministic, out, log)
    return out, log


def run_pipeline(raw_df: pd.DataFrame, config: dict) -> tuple[pd.DataFrame, list[dict]]:
    """The pipeline's real entry point for freshly-ingested data: checks
    the input contract, runs `apply_steps_logged`, checks the output
    contract, and returns the result and its step log.
    """
    check_input_contract(raw_df)
    df, log = apply_steps_logged(raw_df, config)
    check_output_contract(df, config)
    return df, log


def run_pipeline_swapped_order(raw_df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """The SAME seven steps, with `dedupe_orders` run BEFORE
    `normalize_region_strings` instead of after -- the declared order,
    reversed. Used only to demonstrate order-dependence (exercise 6); never
    called from `run_pipeline`.
    """
    check_input_contract(raw_df)
    df = parse_currency_amount(raw_df)
    df = dedupe_orders(df, config)  # swapped: dedupe before normalising
    df = normalize_region_strings(df)
    df = impute_missing_amount(df, config)
    df = clip_amount_to_fixed_ceiling(df, config)
    df = add_amount_zscore(df, config)
    df = sort_deterministic(df)
    return df


def run_pipeline_via_pipe(raw_df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """The same seven steps, in the same declared order, composed with
    `DataFrame.pipe` instead of sequential assignment. Must produce a
    frame identical to `run_pipeline`'s -- exercise 4 proves it.

    `.pipe()` chaining reads well top to bottom, but it comes at a real
    cost: there is nowhere to put a breakpoint or a `print(df.shape)`
    between two links in the chain without breaking the chain apart again,
    which `run_pipeline`'s sequential form gives you for free. That
    tradeoff -- readability against inspectability -- is real, not
    cosmetic, and this lab ran both forms to show it rather than asserting
    it.
    """
    check_input_contract(raw_df)
    df = (
        raw_df.pipe(parse_currency_amount)
        .pipe(normalize_region_strings)
        .pipe(dedupe_orders, config)
        .pipe(impute_missing_amount, config)
        .pipe(clip_amount_to_fixed_ceiling, config)
        .pipe(add_amount_zscore, config)
        .pipe(sort_deterministic)
    )
    check_output_contract(df, config)
    return df


# --------------------------------------------------------------------------
# Determinism: content hashing, a Parquet checkpoint, and the manifest that
# ties an output back to the input and configuration that produced it.
# --------------------------------------------------------------------------


def content_hash(df: pd.DataFrame) -> str:
    """A SHA-256 hex digest of the frame's exact CSV bytes.

    Deterministic for a given pandas/NumPy version and a given frame's
    values, column order and dtypes -- change any one value, one column's
    dtype, or the column or row order, and the digest changes. See this
    lab's `expected-output/FIELDS.md` for exactly what is and is not
    guaranteed to be identical on a different machine.
    """
    payload = df.to_csv(index=False).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()


def config_hash(config: dict) -> str:
    """A SHA-256 hex digest of the config, serialised with sorted keys so
    the digest does not depend on the dict's insertion order.
    """
    payload = json.dumps(config, sort_keys=True).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()


def checkpoint_to_parquet(df: pd.DataFrame, path: Path) -> None:
    """Write a checkpoint. Parquet, not CSV, because Parquet preserves
    dtypes exactly -- including a nullable Int64 column's missing values
    -- where CSV round-trips everything through text and loses them
    (Day 121).
    """
    df.to_parquet(path, index=False)


def load_checkpoint(path: Path) -> pd.DataFrame:
    return pd.read_parquet(path)


def build_manifest(raw_df: pd.DataFrame, config: dict, step_log: list[dict], output_df: pd.DataFrame) -> dict:
    """The provenance record: which input, which configuration, which
    steps ran and what each one did to the row count, and what came out --
    everything needed, months later, to answer "which data produced this
    number?" without guessing.
    """
    return {
        "input_hash": content_hash(raw_df),
        "config_hash": config_hash(config),
        "steps": step_log,
        "output_hash": content_hash(output_df),
    }
starter/steps.py (6374 bytes)
"""Individual pipeline steps.

Each step is a pure function from frame to frame, named for what it does,
and testable on its own against a small fixture -- the cure for the
notebook's defining hazard, out-of-order cell execution. No step mutates
its input in place; every step returns a new frame built with `.copy()`.

Two steps clip `amount` to an upper bound, and only one of them belongs in
the real pipeline:

- `clip_amount_to_fixed_ceiling` reads its threshold from `config` --
  computed once, outside the pipeline, and frozen. Idempotent: clipping
  already-clipped data to the same fixed number changes nothing.
- `clip_amount_to_recomputed_percentile` is kept here ONLY as the lesson's
  worked failure. It recomputes its threshold from whatever data happens to
  be passing through it, which means a second call sees already-clipped
  data and computes a NEW, lower threshold from it -- non-idempotent by
  construction. `pipeline.py` never calls it; `test_pipeline.py` calls it
  directly to prove the failure before the fix.
"""

from __future__ import annotations

import pandas as pd


def parse_currency_amount(df: pd.DataFrame) -> pd.DataFrame:
    """Strip '$' and ',' from `amount` and convert to float64.

    Idempotent by inspection, not just by luck: once `amount` is numeric,
    the "still text" guard below is False and the step is a no-op on a
    second call, rather than re-stripping characters that are no longer
    there. The guard checks for a non-numeric dtype rather than the
    specific `object` dtype, because pandas 3.0's default string inference
    gives a plain Python string column its own `str` extension dtype, not
    `object` -- a version-specific fact this lab's own run confirmed.
    """
    df = df.copy()
    if not pd.api.types.is_numeric_dtype(df["amount"]):
        cleaned = df["amount"].str.replace(r"[$,]", "", regex=True)
        df["amount"] = pd.to_numeric(cleaned, errors="coerce")
    return df


def normalize_region_strings(df: pd.DataFrame) -> pd.DataFrame:
    """Strip whitespace and title-case `region` (" north" -> "North").

    Idempotent: an already-normalised string title-cases to itself.
    """
    df = df.copy()
    df["region"] = df["region"].str.strip().str.title()
    return df


def dedupe_orders(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Drop rows that describe the same real-world order, keeping the
    first occurrence after an explicit, deterministic sort by `order_id`.

    The sort matters: `drop_duplicates(keep="first")` depends on row
    order, and pandas does not promise the input arrived in `order_id`
    order. Sorting first makes "first occurrence" mean the same thing on
    every run, not whatever order the source happened to hand rows over
    in.

    Order-dependent by design (exercise 6): comparing `region` before it
    has been normalised treats " north" and "north" as different values,
    so the resubmitted order in this lab's data is NOT recognised as a
    duplicate unless `normalize_region_strings` already ran.
    """
    df = df.sort_values("order_id", kind="stable").reset_index(drop=True)
    df = df.drop_duplicates(subset=config["dedupe_subset"], keep="first")
    return df.reset_index(drop=True)


def impute_missing_amount(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Fill a missing `amount` with the column's own mean.

    Idempotent in the sense this pipeline relies on: once every `amount`
    is filled, `fillna` on a column with no missing values is a no-op, so
    a second call changes nothing -- provided no later step reintroduces a
    missing value, which none of this pipeline's steps do.
    """
    df = df.copy()
    df["amount"] = df["amount"].fillna(df["amount"].mean())
    return df


def clip_amount_to_fixed_ceiling(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Clip `amount` to `config["amount_clip_max"]` -- a fixed number read
    from configuration, never recomputed from the data. This is the
    correct, idempotent version. Compare `clip_amount_to_recomputed_percentile`
    below, which is not.
    """
    df = df.copy()
    df["amount"] = df["amount"].clip(upper=config["amount_clip_max"])
    return df


def clip_amount_to_recomputed_percentile(df: pd.DataFrame) -> pd.DataFrame:
    """DELIBERATELY NON-IDEMPOTENT. Never called by `pipeline.run_pipeline`.

    Computes its own threshold -- the 99th percentile of `amount` -- from
    whatever frame is passed in, every time it runs. The first call clips
    the true outliers down to the raw data's 99th percentile. Because the
    ceiling is now lower than it was, the SECOND call sees a narrower
    column and computes a new, lower 99th percentile from it, clipping
    again. `pipeline(pipeline(df))` therefore does not equal `pipeline(df)`
    when this step is used -- kept here only so
    `test_pipeline.py::test_1` can run it, watch it fail, and then run the
    fixed version and watch it pass.
    """
    df = df.copy()
    ceiling = df["amount"].quantile(0.99)
    df["amount"] = df["amount"].clip(upper=ceiling)
    return df


def add_amount_zscore(df: pd.DataFrame, config: dict) -> pd.DataFrame:
    """Attach a z-score computed against FIXED reference statistics in
    `config`, never against this frame's own (possibly already-clipped,
    already-deduplicated) mean and standard deviation. Recomputing the
    reference from the current frame would make this step non-idempotent
    for exactly the same reason the broken clip step is.
    """
    df = df.copy()
    mean = config["amount_reference_mean"]
    std = config["amount_reference_std"]
    df["amount_zscore"] = (df["amount"] - mean) / std
    return df


def sort_deterministic(df: pd.DataFrame) -> pd.DataFrame:
    """Sort by `amount` with `order_id` as an explicit tie-break.

    Two rows in this lab's data collide exactly at the clip ceiling
    (900.0), so sorting by `amount` alone leaves their relative order
    unspecified -- a "stable" sort only preserves whatever order the ROWS
    ARRIVED in, which is not the same thing as a deterministic order
    across two independently built frames. Naming `order_id` as the
    tie-break makes the final row order a fact about the values, not
    about arrival order.
    """
    df = df.copy()
    return df.sort_values(["amount", "order_id"], kind="stable").reset_index(drop=True)
starter/test_pipeline.py (9702 bytes)
"""YOUR test suite for Day 126 -- "A Pipeline You Can Re-run". Nine exercises.

Run it from the lab directory, not from here:

    pytest starter -v

Every exercise below ends in a `pytest.skip(...)` line. pytest reports a
skip as `s` in the dot line and moves on, so an unfinished suite still
exits 0. Replace each skip with real assertions -- deleting the skip line
is part of the exercise. `starter/00_brief.md` explains each exercise in
full; `data.py`, `steps.py` and `pipeline.py` are the pipeline itself, not
exercises -- read them before you start.

Assert exact values everywhere; nothing in this lab depends on timing.
"""

from __future__ import annotations

import json

import pandas as pd
import pytest

import pipeline as P
import steps as S
from data import CONFIG, build_raw_orders

# --------------------------------------------------------------------------
# EXERCISE 1 -- idempotence. pipeline(pipeline(df)) must equal pipeline(df)
# exactly. First reproduce the worked failure, then prove the real
# pipeline does not share it. See starter/00_brief.md exercise 1.
#
# Check with:   pytest starter -v -k test_1
# --------------------------------------------------------------------------


def test_1_broken_clip_step_is_not_idempotent(raw_orders, config):
    pytest.skip(
        "exercise 1a: run parse_currency_amount, normalize_region_strings, dedupe_orders "
        "and impute_missing_amount, then call clip_amount_to_recomputed_percentile once and "
        "again on its own output. Assert the two results are NOT equal, and that order_id 7's "
        "amount differs between the two calls (approx 1236.5, then approx 1223.675)."
    )


def test_1_real_pipeline_is_idempotent(raw_orders, config):
    pytest.skip(
        "exercise 1b: call pipeline.apply_steps_logged on raw_orders, then call it again on "
        "that result. Assert the two frames are equal exactly (.equals(), no approx)."
    )


# --------------------------------------------------------------------------
# EXERCISE 2 -- determinism. Two independent runs on the same input must
# hash identically, and an explicit tie-break must make the final row
# order independent of arrival order.
#
# Check with:   pytest starter -v -k test_2
# --------------------------------------------------------------------------


def test_2_two_independent_runs_produce_an_identical_hash(config):
    pytest.skip(
        "exercise 2a: run the pipeline twice, each time on a FRESH build_raw_orders() call. "
        "Assert the two output frames are .equals() and that pipeline.content_hash agrees on both."
    )


def test_2_tie_break_makes_the_final_order_deterministic_regardless_of_arrival_order(raw_orders, config):
    pytest.skip(
        "exercise 2b: order_id 2 and order_id 7 both land on amount 900.0 after clipping. "
        "Show that sorting by 'amount' ALONE gives a different tie order depending on whether "
        "the rows arrived forward or reversed (prepared.iloc[::-1]), then show that "
        "steps.sort_deterministic gives the SAME order either way, because it names order_id "
        "as an explicit tie-break."
    )


# --------------------------------------------------------------------------
# EXERCISE 3 -- the step log reconciles: every step's rows-out equals the
# next step's rows-in, and the total change equals the sum of the
# per-step changes.
#
# Check with:   pytest starter -v -k test_3
# --------------------------------------------------------------------------


def test_3_step_log_reconciles_between_consecutive_steps(raw_orders, config):
    pytest.skip(
        "exercise 3a: run the pipeline and get its step log. For every consecutive pair of "
        "steps, assert the earlier one's rows_out equals the later one's rows_in. Assert the "
        "total change (last rows_out minus first rows_in) equals the sum of every step's delta, "
        "and that this equals -1."
    )


def test_3_step_log_shows_exactly_where_the_row_count_changed(raw_orders, config):
    pytest.skip(
        "exercise 3b: from the same step log, assert 'dedupe_orders' has delta -1 and every "
        "other step has delta 0."
    )


# --------------------------------------------------------------------------
# EXERCISE 4 -- the input contract raises on a frame with a missing column
# or a wrong dtype, naming the offending column.
#
# Check with:   pytest starter -v -k test_4
# --------------------------------------------------------------------------


def test_4_input_contract_raises_on_a_missing_column(raw_orders, config):
    pytest.skip(
        "exercise 4a: drop the 'priority' column from raw_orders and assert pipeline.run_pipeline "
        "raises pipeline.ContractError with 'priority' in the message (pytest.raises(..., match=...))."
    )


def test_4_input_contract_raises_on_a_wrong_dtype(raw_orders, config):
    pytest.skip(
        "exercise 4b: cast 'order_id' to float64 and assert run_pipeline raises ContractError "
        "with 'order_id' in the message."
    )


# --------------------------------------------------------------------------
# EXERCISE 5 -- the output contract raises when a step is sabotaged so its
# post-condition fails -- proving the contract can genuinely fail.
#
# Check with:   pytest starter -v -k test_5
# --------------------------------------------------------------------------


def test_5_output_contract_raises_when_the_clip_step_is_sabotaged(raw_orders, config, monkeypatch):
    pytest.skip(
        "exercise 5a: use monkeypatch.setattr(pipeline, 'clip_amount_to_fixed_ceiling', a function "
        "that returns its input unchanged), then assert run_pipeline raises ContractError "
        "mentioning 'clip ceiling'."
    )


def test_5_output_contract_passes_once_the_step_is_restored(raw_orders, config):
    pytest.skip(
        "exercise 5b: run the UNMODIFIED pipeline and call pipeline.check_output_contract on "
        "its result directly -- it must raise nothing, confirming the sabotage above, not "
        "something else, was what triggered exercise 5a's failure."
    )


# --------------------------------------------------------------------------
# EXERCISE 6 -- a .pipe() chain gives exactly the same frame as sequential
# application.
#
# Check with:   pytest starter -v -k test_6
# --------------------------------------------------------------------------


def test_6_pipe_chain_equals_sequential_application(raw_orders, config):
    pytest.skip(
        "exercise 6: assert pipeline.run_pipeline(raw_orders, config)[0] and "
        "pipeline.run_pipeline_via_pipe(raw_orders, config) are .equals()."
    )


# --------------------------------------------------------------------------
# EXERCISE 7 -- order dependence. normalize_region_strings before
# dedupe_orders is the declared order, and it is not arbitrary.
#
# Check with:   pytest starter -v -k test_7
# --------------------------------------------------------------------------


def test_7_declared_order_catches_the_resubmitted_order(raw_orders, config):
    pytest.skip(
        "exercise 7a: run the real pipeline. Assert the result has 6 rows, order_id 3 (the "
        "resubmission) is gone, and order_id 1 (the original) survives."
    )


def test_7_reversed_order_misses_the_resubmitted_order(raw_orders, config):
    pytest.skip(
        "exercise 7b: run pipeline.run_pipeline_swapped_order. Assert it has 7 rows -- nothing "
        "was deduplicated -- and that BOTH order_id 1 and order_id 3 survive."
    )


# --------------------------------------------------------------------------
# EXERCISE 8 -- a Parquet checkpoint round-trip preserves every dtype
# exactly, including a nullable Int64 column with a missing value.
#
# Check with:   pytest starter -v -k test_8
# --------------------------------------------------------------------------


def test_8_parquet_checkpoint_preserves_every_dtype_exactly(raw_orders, tmp_path):
    pytest.skip(
        "exercise 8: parse and normalise raw_orders (do not dedupe yet, so order_id 4's missing "
        "priority is still present), checkpoint it to tmp_path / 'checkpoint.parquet' with "
        "pipeline.checkpoint_to_parquet, reload it with pipeline.load_checkpoint, and assert "
        "every dtype matches exactly, the frames are .equals(), and the reloaded 'priority' "
        "column is still Int64 with order_id 4's value still missing."
    )


# --------------------------------------------------------------------------
# EXERCISE 9 -- the manifest's hashes are stable across runs, and changing
# one input byte changes the input hash AND the output hash.
#
# Check with:   pytest starter -v -k test_9
# --------------------------------------------------------------------------


def test_9_manifest_hashes_are_stable_across_independent_runs(config):
    pytest.skip(
        "exercise 9a: build a manifest from two independent build_raw_orders() runs through the "
        "pipeline. Assert input_hash, config_hash, output_hash and steps all agree between the two."
    )


def test_9_changing_one_input_byte_changes_both_input_and_output_hash(raw_orders, config):
    pytest.skip(
        "exercise 9b: build a manifest for raw_orders, then change order_id 6's amount from "
        "'$60.00' to '$60.01' (one character) and build a second manifest. Assert input_hash and "
        "output_hash both differ, config_hash stays the same, and the row count is unaffected."
    )


def test_9_manifest_is_json_serialisable(raw_orders, config):
    pytest.skip(
        "exercise 9c: build a manifest, round-trip it through json.dumps/json.loads, and assert "
        "the reloaded input_hash and steps match the original manifest's."
    )
tests/run_tests.sh (11841 bytes)
#!/usr/bin/env bash
# Tests for the Day 126 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running code and reading real
# values, never by reading source:
#
#   * a step that recomputes its clip threshold from whatever data is
#     currently passing through it is NOT idempotent -- pipeline(pipeline(df))
#     changes order 7's amount from 1236.5 to 1223.675 -- while the real
#     pipeline, which reads its threshold from config, is idempotent exactly;
#   * two independent runs on the same input hash identically, and an
#     explicit order_id tie-break makes the final row order the same
#     regardless of which order two tied rows arrived in;
#   * the step log reconciles: every step's rows-out equals the next step's
#     rows-in, and the total change equals the sum of the per-step deltas;
#   * the input contract raises, naming the column, on a missing column or a
#     wrong dtype;
#   * the output contract raises, naming the violated condition, when the
#     clip step is sabotaged into a no-op;
#   * a .pipe() chain produces a frame identical to sequential application;
#   * normalising region strings before deduplicating catches a resubmitted
#     order that reversing the order misses entirely;
#   * a Parquet checkpoint round-trip preserves every dtype exactly,
#     including a nullable Int64 column's missing value;
#   * a manifest's input, config and output hashes are stable across
#     independent runs, and changing one input byte changes both the input
#     hash and the output hash;
#   * 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 FAIL, then restoring it;
#   * nothing -- no .parquet, .json or .csv file, no __pycache__ -- is left
#     behind by this run.
#
# Everything after the one-time install runs offline. Nothing binds a port,
# nothing writes outside the lab, nothing needs a key. Deterministic,
# non-interactive, exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# Bytecode left by an EARLIER command is not this run's litter. The README
# documents `pytest starter -q` and `pytest examples -q` separately, and
# running either writes .pyc files that would then fail the cleanliness
# check at the end of this script -- failing the reader for following the
# instructions. Clearing them here makes that final check measure what it
# claims to: what THIS run left behind. `.venv` is untouched, because the
# packages' own bytecode is theirs, not ours.
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true

failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

check_eq() {
  # check_eq <label> <expected> <actual>
  if [ "$2" = "$3" ]; then
    check "$1" "yes"
  else
    check "$1 (expected [$2], got [$3])" "no"
  fi
}

# Resolve pytest: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with instructions rather than silently skipping checks.
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 pandas" >/dev/null 2>&1; then
  echo "FAIL: pandas 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 126 — A Pipeline You Can Re-run"
echo

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

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

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

pandas_version="$("${python_bin}" -c "import pandas; print(pandas.__version__)" 2>/dev/null || echo "")"
pinned_pandas="$(grep -m1 '^pandas==' "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
check_eq "installed pandas matches requirements.txt exactly" "${pinned_pandas}" "${pandas_version}"
echo

# --------------------------------------------------------------------------
echo "2. 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 17 passed, 0 failed" "$( echo "${examples_passed_line}" | grep -qE '^17 passed' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "3. 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 17 skipped, 0 failed" "$( echo "${starter_output}" | grep -qE '^17 skipped' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "4. Never run 'pytest examples starter' in one invocation -- every"
echo "   module name (data, steps, pipeline, conftest, test_pipeline) is"
echo "   shared between both directories, so the second collected can"
echo "   shadow, or outright collide with, the first. Checked below."
# --------------------------------------------------------------------------

both_output="$(cd "${lab_dir}" && "${pytest_bin}" examples starter -q 2>&1)"
both_status=$?
check "pytest examples starter (one invocation) does NOT exit 0" "$( [ ${both_status} -ne 0 ] && echo yes || echo no )"
check "pytest examples starter reports an import file mismatch, not a quiet partial run" "$( echo "${both_output}" | grep -qi 'import file mismatch' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "5. 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 FAIL, then restore."
# --------------------------------------------------------------------------

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

cp "${lab_dir}/examples/test_pipeline.py" "${scratch_dir}/test_pipeline.py"
cp "${lab_dir}/examples/data.py" "${scratch_dir}/data.py"
cp "${lab_dir}/examples/steps.py" "${scratch_dir}/steps.py"
cp "${lab_dir}/examples/pipeline.py" "${scratch_dir}/pipeline.py"
cp "${lab_dir}/examples/conftest.py" "${scratch_dir}/conftest.py"

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 17 passed" "$( echo "${solved_output}" | grep -qE '^17 passed' && echo yes || echo no )"

# Break test_1's exact idempotence assertion on purpose.
sed -i.bak 's/assert once\.equals(twice)/assert not once.equals(twice)/' "${scratch_dir}/test_pipeline.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 FAIL/failed line" "$( echo "${broken_output}" | grep -qiE 'failed|assert' && echo yes || echo no )"

mv "${scratch_dir}/test_pipeline.py.bak" "${scratch_dir}/test_pipeline.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 17 passed again" "$( echo "${restored_output}" | grep -qE '^17 passed' && echo yes || echo no )"

cleanup_scratch
trap - EXIT
echo

# --------------------------------------------------------------------------
echo "6. Nothing in examples/ or starter/ opens a network connection"
# --------------------------------------------------------------------------

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 )"
echo

# --------------------------------------------------------------------------
echo "7. A pipeline day that litters would be embarrassing -- confirm no"
echo "   .parquet, .json or .csv artifact is left behind anywhere in the lab"
# --------------------------------------------------------------------------

artifact_hits="$(find "${lab_dir}" -name '.venv' -prune -o -name 'expected-output' -prune -o \
  \( -type f \( -name '*.parquet' -o -name '*.json' -o -name '*.csv' \) -print \) 2>/dev/null || true)"
check "no stray .parquet/.json/.csv files under the lab (outside expected-output/)" "$( [ -z "${artifact_hits}" ] && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "8. Cleanliness -- nothing left behind by THIS run"
# --------------------------------------------------------------------------

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 )"
echo

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

Troubleshooting

Troubleshooting

Grouped by the message you actually see.

ModuleNotFoundError: No module named 'pandas'

The lab's dependencies live in its own .venv, not on your system Python.

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Or point the test suite at a Python that already has pandas 3.0.5 installed: PYTHON=/path/to/python3 bash tests/run_tests.sh.

pytest examples starter fails with import file mismatch

This is expected, not a bug — do not try to work around it by renaming files or adding __init__.py. starter/ and examples/ both define modules named data, steps, pipeline, conftest and test_pipeline; pytest imports test modules by their dotted name, and when the second directory's data.py (for example) tries to import under a name pytest already bound to the first directory's data.py, collection aborts outright rather than silently running one directory's code under the other's name. Run the two directories as two separate commands, always:

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

pipeline.ContractError: input contract violated: column 'amount' has dtype 'float64', expected 'str'

You passed a pipeline's OUTPUT back into pipeline.run_pipeline (which checks the INPUT contract) instead of pipeline.apply_steps_logged (which does not). This is not a bug in the pipeline — the input contract exists to catch freshly-ingested data with the wrong shape, and a pipeline's own output is supposed to have amount as a float, exactly as the contract would then correctly reject if it were re-checked. Idempotence is checked with apply_steps_logged, never run_pipeline, for exactly this reason — see exercise 1 and the docstring on apply_steps_logged in pipeline.py.

Exercise 1's "broken" step does not look non-idempotent to you

Print once["amount"] and twice["amount"] for order_id 7 side by side. The first call clips to the 99th percentile of the ORIGINAL data (roughly 1236.5); by the time the second call runs, the top value has already been pulled down, so the second call's 99th percentile is computed from a narrower column and produces a lower ceiling (roughly 1223.675). If your numbers do not match, confirm you ran parse_currency_amount, normalize_region_strings, dedupe_orders and impute_missing_amount first, in that order, before calling the broken clip step.

Exercise 2's tie-break test shows the same order both ways

Confirm you actually reversed the row order before the second sort (prepared.iloc[::-1]), not just re-sorted the same frame twice. A stable sort's tie-break IS the arrival order — you have to change the arrival order to see it matter.

pipeline.ContractError is never raised in exercise 5

Confirm monkeypatch.setattr targets the pipeline module's own name for the function (pipeline.clip_amount_to_fixed_ceiling), not steps.clip_amount_to_fixed_ceiling. pipeline.py imported the function by name into its own module namespace at import time; patching the copy inside steps does not affect the name pipeline.apply_steps_logged actually looks up when it calls it.

Exercise 7's swapped-order result does not have 7 rows

Confirm you called pipeline.run_pipeline_swapped_order, not pipeline.run_pipeline. The declared, correct order lives in run_pipeline; the deliberately reversed order (used only to demonstrate order-dependence) lives in the separate function run_pipeline_swapped_order.

Exercise 8's reloaded priority column is not Int64

Confirm you checkpointed BEFORE calling dedupe_orders — the missing priority value belongs to order_id 4, which survives deduplication either way, but checkpointing straight after parse_currency_amount and normalize_region_strings keeps the exercise closest to what the lesson demonstrates. If you see float64 with NaN instead of Int64 with a proper missing value, you likely wrote the frame through CSV at some point instead of Parquet — CSV round-trips everything through text and cannot represent a nullable integer's missing value without losing the integer dtype (Day 121).

pip install fails or hangs

You are offline, or a corporate proxy is blocking PyPI. This is the only network-dependent step in the entire lab. Retry on a connection that can reach pypi.org, or ask whoever manages your network for a mirror.

Security notes

Security notes

What this lab does to your machine

  • Opens one network connection, ever: pip install -r requirements/requirements.txt, to download pandas, pyarrow, NumPy and pytest from PyPI into this lab's own .venv. Every script and test after that runs completely offline.
  • Writes only inside its own .venv directory (created by you, via python3 -m venv .venv), transient __pycache__ / .pytest_cache directories the test harness removes both before and after every run, and Parquet files written only to tmp_path — a pytest-managed temporary directory outside the lab, cleaned up automatically by pytest itself.
  • Never opens a network socket, binds a port, needs sudo, or reads or writes any file outside this lab's own directory and pytest's own temporary directory.
  • Needs no credential, API key, or account of any kind.

What the data in this lab is

data.py's build_raw_orders() is seven literal rows invented for this lab's exercises. Nothing here is real personal, financial or otherwise sensitive data, and nothing is downloaded from any external dataset.

The design point this day is actually about

A cleaning pipeline that is not idempotent will drift without ever failing: run it twice by accident — a re-triggered scheduled job, a retried API call, a notebook cell run out of order — and it produces a DIFFERENT, silently wrong result, with no exception anywhere to flag it. This lab's exercise 1 is not a hypothetical: it is the concrete mechanism by which a step that looks correct once (clip outliers to the 99th percentile) becomes actively harmful the moment it runs again on its own output, which is exactly the situation a production scheduler creates routinely and a person testing a notebook by hand almost never does.

The manifest in exercise 9 is this lab's other security-adjacent point, and it is also this lesson's AI thread: a training set is the output of a pipeline, and when a model's behaviour changes unexpectedly, the first question is whether the data changed. Without a manifest recording the input hash and the configuration that produced a given output, that question has no answer, and debugging a model regression becomes guesswork rather than a lookup.