Skip to content

Builder architecture: four-stage template, traceback-clean failures, notebook policy (Phase 5) #14

Description

@mmcky

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. Commit the template as scripts/_template_builder.py and add a "Builder architecture" section to scripts/README.md.
  2. Retrofit business_cycle.py to the four-stage contract — it currently writes three files with no validate stage (AGENTS.md already flags this).
  3. Decide the notebook-origin builder_status sub-question above when the first such dataset lands.

Part of #8
See QuantEcon/meta#338

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions