Design notes from a review discussion on PR #12, recorded here to be actioned in PLAN Phase 5 (automation). Nothing below is implemented yet; this is the supporting research for the builder architecture and a copy-able template.
Why builders are plain .py scripts, not notebooks
The question that prompted this: we could run a notebook to generate a dataset — but extracting tracebacks and failure states from a notebook run is difficult. That difficulty is structural, not incidental, and it settles the architecture:
- Errors must propagate as exceptions with a full traceback and a non-zero exit. A plain script gives CI exactly that for free. Papermill/nbconvert can execute a notebook, but a failure surfaces as a truncated cell-execution error embedded in JSON output — the natural stack trace is lost, and the exit-code contract is murkier.
- CI needs to distinguish two failure classes. A
ValidationError (the data is wrong — needs a human) is not an infrastructure failure (network/credentials — retry). A script makes this a clean except boundary; a notebook blurs it.
- The manifest's
schema block is the validation spec. The builder's validate() stage reads the dataset's sidecar <filename>.yml and enforces its columns, row_count_floor, known_nulls and date_range. The builder and the PR-CI check then enforce the same contract from one source of truth — no drift between what the manifest claims and what the builder guarantees.
- Atomic write + last-good guarantee. Validate the in-memory frame before writing; write to a temp file in the same directory, then
os.replace() into place. A failed refresh or a crash mid-write leaves the previously-committed snapshot untouched — which is the AGENTS.md promise that an upstream outage may fail a refresh but must never break a lecture build.
Notebook-origin datasets
Common for QuantEcon: a dataset's construction starts life in a lecture notebook. The rule:
- If it needs to refresh (dynamic snapshot): port the notebook logic to a plain
.py builder — or pair the notebook with jupytext, whose .py:percent twin is the runnable, diffable, traceback-clean artifact that becomes the committed builder. The notebook stays in the lecture repo as pedagogy.
- If it was a one-off construction that will not refresh: record the notebook as provenance in the manifest's
builder field, with a builder_status that signals it is not a CI-runnable builder (open sub-question: a distinct status value such as notebook, vs reusing not-applicable with a note — decide when the first such dataset lands).
- Papermill-executing a notebook as the builder is the fallback only when the notebook itself must be the deliverable, accepting the worse failure ergonomics.
Template
The four-stage contract (AGENTS.md: fetch → pre-process → validate → write), with preprocess kept pure (no I/O) so it is unit-testable in isolation:
"""Builder for <output>.csv — <one-line description>.
Contract (AGENTS.md / PLAN Phase 5): fetch -> pre-process -> validate -> write.
Writes ONLY on validation pass, atomically, so a failed refresh leaves the
last-good snapshot untouched and never breaks a lecture build.
Run: python scripts/<name>.py
Exit: 0 on success; non-zero WITH a traceback on any failure (CI-readable).
"""
from __future__ import annotations
import logging, os, tempfile
import pandas as pd, yaml
# import wbgapi as wb # or the relevant upstream client
HERE = os.path.dirname(os.path.abspath(__file__))
LECTURES = os.path.join(os.path.dirname(HERE), "lectures")
OUTPUT = "example.csv" # the published filename
MANIFEST = OUTPUT + ".yml" # its sidecar — the validation spec
log = logging.getLogger(OUTPUT)
class ValidationError(Exception):
"""Data failed an invariant — distinct from an infrastructure failure, so CI
can tell 'the data is wrong' (human) from 'the network was down' (retry)."""
def fetch():
"""Pull raw data from upstream. Network/credential failures surface here."""
raise NotImplementedError
def preprocess(raw) -> pd.DataFrame:
"""Pure raw -> published frame. No I/O, so it's unit-testable in isolation."""
raise NotImplementedError
def validate(df: pd.DataFrame, spec: dict, previous: pd.DataFrame | None) -> None:
"""Enforce the manifest's `schema` block — the single source of truth."""
schema = spec["schema"]
expected = [c["name"] for c in schema["columns"]]
if list(df.columns) != expected:
raise ValidationError(f"columns {list(df.columns)} != manifest {expected}")
if len(df) < schema["row_count_floor"]:
raise ValidationError(f"{len(df)} rows < floor {schema['row_count_floor']}")
known = schema.get("known_nulls") or {}
for col in df.columns:
n = int(df[col].isna().sum())
if n != known.get(col, 0):
raise ValidationError(f"{col}: {n} nulls, manifest allows {known.get(col, 0)}")
# Overlap window: history must not change vs the last-good vintage.
if previous is not None:
common = previous.index.intersection(df.index)
a, b = df.loc[common], previous.loc[common]
# NOTE: float columns need a tolerance — use np.isclose, not `!=`.
changed = ((a != b) & a.notna() & b.notna()).to_numpy().any()
if changed:
raise ValidationError("values changed in the overlap window vs last-good")
def write(df: pd.DataFrame, path: str) -> None:
"""Atomic write: temp file in the same dir, then os.replace() (atomic on POSIX).
A crash mid-write can never leave a partial file where a lecture would read it."""
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
os.close(fd)
try:
df.to_csv(tmp)
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.remove(tmp)
def run() -> None:
out = os.path.join(LECTURES, OUTPUT)
spec = yaml.safe_load(open(os.path.join(LECTURES, MANIFEST)))
previous = pd.read_csv(out, index_col=0) if os.path.exists(out) else None
log.info("fetch"); raw = fetch()
log.info("pre-process"); df = preprocess(raw)
log.info("validate"); validate(df, spec, previous) # raises -> no write
log.info("write"); write(df, out)
log.info("ok: %d rows", len(df))
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s")
run() # uncaught exceptions -> full traceback + non-zero exit (CI-friendly)
Deliberately deferred
- A shared harness.
atomic_write, ValidationError, and manifest-driven validate() are obviously shared machinery, but the repo has exactly one dynamic builder today (business_cycle.py). Keep builders self-contained; extract scripts/_builder.py when the second one lands.
- Manifest write-back — stamping
retrieved / integrity on a successful refresh belongs to the refresh-as-PR wiring, i.e. Phase 5 proper, not the template.
When Phase 5 is actioned
- Commit the template as
scripts/_template_builder.py and add a "Builder architecture" section to scripts/README.md.
- Retrofit
business_cycle.py to the four-stage contract — it currently writes three files with no validate stage (AGENTS.md already flags this).
- Decide the notebook-origin
builder_status sub-question above when the first such dataset lands.
Part of #8
See QuantEcon/meta#338
Design notes from a review discussion on PR #12, recorded here to be actioned in PLAN Phase 5 (automation). Nothing below is implemented yet; this is the supporting research for the builder architecture and a copy-able template.
Why builders are plain
.pyscripts, not notebooksThe question that prompted this: we could run a notebook to generate a dataset — but extracting tracebacks and failure states from a notebook run is difficult. That difficulty is structural, not incidental, and it settles the architecture:
ValidationError(the data is wrong — needs a human) is not an infrastructure failure (network/credentials — retry). A script makes this a cleanexceptboundary; a notebook blurs it.schemablock is the validation spec. The builder'svalidate()stage reads the dataset's sidecar<filename>.ymland enforces itscolumns,row_count_floor,known_nullsanddate_range. The builder and the PR-CI check then enforce the same contract from one source of truth — no drift between what the manifest claims and what the builder guarantees.os.replace()into place. A failed refresh or a crash mid-write leaves the previously-committed snapshot untouched — which is theAGENTS.mdpromise that an upstream outage may fail a refresh but must never break a lecture build.Notebook-origin datasets
Common for QuantEcon: a dataset's construction starts life in a lecture notebook. The rule:
.pybuilder — or pair the notebook with jupytext, whose.py:percenttwin is the runnable, diffable, traceback-clean artifact that becomes the committedbuilder. The notebook stays in the lecture repo as pedagogy.builderfield, with abuilder_statusthat signals it is not a CI-runnable builder (open sub-question: a distinct status value such asnotebook, vs reusingnot-applicablewith a note — decide when the first such dataset lands).Template
The four-stage contract (
AGENTS.md: fetch → pre-process → validate → write), withpreprocesskept pure (no I/O) so it is unit-testable in isolation:Deliberately deferred
atomic_write,ValidationError, and manifest-drivenvalidate()are obviously shared machinery, but the repo has exactly one dynamic builder today (business_cycle.py). Keep builders self-contained; extractscripts/_builder.pywhen the second one lands.retrieved/integrityon a successful refresh belongs to the refresh-as-PR wiring, i.e. Phase 5 proper, not the template.When Phase 5 is actioned
scripts/_template_builder.pyand add a "Builder architecture" section toscripts/README.md.business_cycle.pyto the four-stage contract — it currently writes three files with no validate stage (AGENTS.mdalready flags this).builder_statussub-question above when the first such dataset lands.Part of #8
See QuantEcon/meta#338