Skip to content

fix: make legacy dataset provenance explicit - #723

Merged
igerber merged 18 commits into
igerber:mainfrom
shawcharles:fix/dataset-loader-provenance
Jul 27, 2026
Merged

fix: make legacy dataset provenance explicit#723
igerber merged 18 commits into
igerber:mainfrom
shawcharles:fix/dataset-loader-provenance

Conversation

@shawcharles

Copy link
Copy Markdown
Contributor

Fixes #722.

Summary

  • replace the dead Card-Krueger, Castle Doctrine, and mpdta URLs with immutable commit-pinned sources and verify every downloaded or cached byte sequence against a pinned SHA-256
  • validate source schemas, categories, panel keys, treatment/cohort invariants, dimensions, missingness, and numeric domains before marking data canonical
  • make load_divorce_laws() explicitly warn and return its existing synthetic fallback because no verified source reproduces its composite public schema without additional analytical choices
  • mark all successful frames with a stable df.attrs["source"]; expected source failures emit one UserWarning containing SYNTHETIC and use source="synthetic_fallback"
  • bound cache reads/downloads to 50 MiB and replace cache files atomically, preserving a valid cached copy if replacement fails

The verified artefacts are:

  • Card-Krueger public.dat at commit 07bc929f1d6552db117bd27a7cf0d881d16e9494, SHA-256 04bde0cad5540980f32ce099c6dad369e2f05494698071d8a65b3e1cbe9ca53a
  • Cheng-Hoekstra castle.dta at commit ca4279a87a6f0759f6b6f02841a53bdd68e27d3c, SHA-256 804633c161827b6c0824f86f239046386d1a8266a866f83bf5ddb2aa762a5f29
  • Callaway-Sant'Anna mpdta.csv at commit 7ad707385354cb3924b8da94ef7e62a76bf55a4d, SHA-256 2283bea1221a152420f98dfa20f633c5d054ea51d881115c8cd702a97bcd3167

mpdta is now the genuine packaged example data. The Wooldridge/Callaway estimator cross-check remains on the pre-existing controlled synthetic DGP so that it tests estimators rather than source availability or a different finite-sample estimand.

Validation

  • python -m pytest tests/test_datasets.py -q (Python 3.12): 69 passed
  • python3.9 -m pytest tests/test_datasets.py -q in an isolated Python 3.9.25 environment: 69 passed
  • affected estimator, results, event-study, methodology, and documentation suites: 543 passed, 13 skipped, 2 deselected
  • python -m ruff check on all touched Python files
  • python -m black --check on all touched Python files
  • python -m mypy --follow-imports=skip diff_diff/datasets.py
  • live smoke test of all four loaders with an empty temporary cache, followed by cache reload equality checks

All tests are deterministic and required CI does not depend on live network access.

@shawcharles

Copy link
Copy Markdown
Contributor Author

Thanks. I pushed a small review-response follow-up in b611147f.

Changes since the initial PR:

  • verified fresh downloads are now returned even if best-effort cache persistence fails;
  • _load_verified_dataset() warnings now use caller-facing stacklevel attribution;
  • list_datasets() now marks divorce_laws as synthetic fallback only;
  • added regression tests for the cache-write failure path, warning attribution, and catalogue wording;
  • updated the changelog entry.

Validation:

  • python -m pytest tests/test_datasets.py -q -> 71 passed
  • Python 3.9 throwaway venv: tests/test_datasets.py -> 71 passed
  • python -m ruff check diff_diff/datasets.py tests/test_datasets.py -> passed
  • python -m black --check diff_diff/datasets.py tests/test_datasets.py -> passed
  • python -m mypy --follow-imports=skip diff_diff/datasets.py -> passed

From my side this is ready for maintainer review / CI.

@igerber

igerber commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Thanks Charles — and more broadly, thank you for the run of work you've put into this library. Five merged PRs since June, and each one has followed the same pattern: find something genuinely wrong, write it up with a reproducer, then fix it. That's unusually disciplined, and it's exactly the kind of contribution that compounds. #722 and this PR are a good example — you didn't just report the dead URLs, you did the archaeology to find pinned, verifiable replacements.

I checked the main claims rather than taking the description on faith, and they hold up: all three pinned URLs resolve with matching checksums, and with an empty cache three loaders return real data tagged with df.attrs["source"]. Tests pass here too — 71 in test_datasets.py, plus 551 across the estimator and docs suites. Warning and marking rather than hard-failing is the right call; it keeps the library usable offline while removing the silence that caused the bug.

Four things and we can get CI running.

1. Two failure paths skip the warning. _get_cache_path() and _get_cache_path_binary() create the cache directory before the error handling starts, so if that directory can't be written the call crashes instead of warning and returning the marked fallback:

HOME=<read-only dir> python -c "from diff_diff.datasets import load_mpdta; load_mpdta()"
PermissionError: [Errno 13] Permission denied: '.../.cache/diff_diff/datasets'

http.client.IncompleteRead escapes the same way — it isn't in the list of caught download errors. Both contradict what the PR promises, and a read-only home directory is common in containers and locked-down CI. Suggested fix: let cache-directory creation fail quietly so a verified download can still be returned without being saved, and add IncompleteRead to the caught errors. A test for each would be good — one with an unwritable cache directory, one where the read raises.

2. Castle Doctrine loses the partial first year. The source has a cdl column giving the fraction of the adoption year the law was actually in effect. I confirmed it: 0 before adoption, 1 after, and a fraction between 0.16 and 0.84 in the adoption year itself — 21 rows. _prepare_castle_doctrine() drops that column and sets treated = 1 for the whole adoption year.

This isn't something you broke — the old synthetic data was 0/1 as well, so nothing regressed. It only matters now because the real data carries information the fake data never had. Could you add a treatment_exposure column holding cdl as-is, and leave treated exactly as it is? A column rather than a loader argument, so nothing changes for existing users and nobody has to know which mode produced a frame. A test that an adoption-year row has treated == 1 and treatment_exposure strictly between 0 and 1 would lock it in.

Please don't add anything to docs/methodology/REGISTRY.md — methodology notes are maintainer-owned here, and we'll write that part.

3. Say plainly that divorce_laws is synthetic-only. With load_source=None it now returns generated data on every call, permanently — there's no source configured at all. That's fine as an interim state, but a user shouldn't have to read a warning to discover it. Could you either mark it deprecated, or clearly label it synthetic-only wherever it's advertised — the docstring, list_datasets(), and llms-full.txt:2315, which still describes it as ordinary divorce-law data? We'll take on repointing it at the real Stevenson-Wolfers data separately; that means changing the schema, so it isn't yours to carry.

4. Please delete test_ols_etwfe_att_matches_callaway_santanna rather than repairing it — and thank you for surfacing this one. Swapping in the real data exposed a test that had been quietly asserting something false, possibly for as long as that URL has been dead. That's a genuinely valuable find, and it came out of your work even though it sits outside what you set out to fix.

Here's what we learned chasing it. We ran Stata's jwdid and csdid against the real mpdta you pinned: ETWFE and Callaway-Sant'Anna genuinely disagree, most visibly at (2007, 2007) where they differ by 0.017. Our implementations each match their own Stata reference almost exactly, so the disagreement is a real property of the two estimators rather than a bug in either. The original claim that they agree within 5e-3 was simply false on real data — it only ever passed because the synthetic panel happens to satisfy it.

Your instinct to move the test off live data was right, but the test can't do what its name claims either way. On the synthetic panel it passes at 0.0017 against a 5e-3 tolerance, so it's really just checking that two numbers land inside a loose bound on one fabricated dataset. Gross breakage in ETWFE is already caught more precisely by the R goldens in test_methodology_wooldridge.py.

We're replacing it with direct Stata comparisons for both estimators, so there's nothing for you to write in its place — deleting is simplest, and it removes the estimation_method="reg" question with it. (That line was inert, for what it's worth: it only affects how covariates are adjusted for, and the test passes none, so dr, reg and ipw give identical results there.)

Separately — we reproduced #724 and you're right about that too. The rank handling drops g2004_t2004 and g2006_t2007, both genuine post-treatment cells, instead of the t = g-1 references, and (2004, 2004) disappears from the results entirely. That's a real defect and we'll pick it up. Two independent estimator bugs surfaced by what started as a dataset cleanup is a good argument that the fake data was hiding more than anyone realised.

Everything else — the pinning, schema checks, atomic cache writes, size limit, and the mpdta swap — looks good. Your branch is a few commits behind main; happy to rebase it for you if that's easier.

@shawcharles
shawcharles force-pushed the fix/dataset-loader-provenance branch from b611147 to 0eaba4f Compare July 27, 2026 09:37
@shawcharles

shawcharles commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and pushed the requested revisions in 0eaba4f2.

  • Cache-directory creation now happens only during best-effort persistence after checksum verification, so an unwritable cache still returns verified fresh data. http.client.IncompleteRead now follows the explicit synthetic-fallback path when no valid cache exists; both cases have regression coverage.
  • Castle Doctrine now exposes source cdl unchanged as treatment_exposure, while retaining the binary treated contract. Synthetic fallback frames carry the same additive column with binary exposure. The full canonical source smoke check found 550 rows and 21 fractional adoption-year exposures, all with treated == 1.
  • divorce_laws is now explicitly synthetic-only in its docstring, list_datasets(), and llms-full.txt.
  • The invalid ETWFE-vs-Callaway-Sant'Anna equivalence test is absent after rebasing; fix(v4): ETWFE reference-period normalization (#724) + Stata jwdid/csdid parity #729's Stata-parity coverage remains intact.
  • No docs/methodology/REGISTRY.md changes.

Validation:

  • python -m pytest tests/test_datasets.py tests/test_wooldridge.py tests/test_etwfe_cs_stata_parity.py tests/test_methodology_wooldridge.py tests/test_methodology_callaway.py tests/test_doc_snippets.py -q -> 597 passed, 2 deselected
  • Python 3.9.25: tests/test_datasets.py -> 74 passed
  • Ruff, Black, mypy (diff_diff/datasets.py), and git diff --check -> passed

This is ready for CI from my side.

charlesshawmsix and others added 16 commits July 27, 2026 13:03
…refresh

Records the Cheng-Hoekstra (2013) treatment-variable convention in REGISTRY:
the paper's regressor is CDL_it, the proportion of the year the law was in
effect (p. 830), preserved verbatim as treatment_exposure. The binary treated
column follows the year >= first_treat staggered-DiD convention, which matches
neither the paper's main specification nor its drop-the-adoption-year
robustness check, and differs from the source file's own post indicator on 21
of 550 rows.

Also refreshes surfaces the loader rewrite left stale: the datasets.rst intro
still described the pre-checksum cache-fallback behaviour, the doc-snippet stub
catalog still described divorce_laws as staggered-adoption data, and the
backlog row this work closes was still open.
…venance claims

The download handler named IncompleteRead explicitly, but BadStatusLine,
LineTooLong and the rest of http.client's protocol errors are siblings under
HTTPException and none derive from OSError, so they escaped the documented
warn-and-fall-back boundary and propagated out of the loaders. Catch the base
class instead, with a regression test covering two additional subclasses.

The datasets.rst provenance paragraph also overstated the contract: it claimed
commit-pinned sources and parse/validation fallback for every loader, but
load_prop99/load_walmart read a mutable SSC path over plain HTTP and run their
validation outside the try/except, so a validation failure raises rather than
falling back. Scoped both claims to the loaders that honour them.
…et prose

_write_cache_atomically bound temp_path only after the write succeeded, so a
mid-write failure (ENOSPC, quota) left the delete=False temporary file stranded
in the cache directory, and repeated attempts accumulated them. Bind the path
at creation instead, with a regression test that fails the write and asserts an
empty cache directory.

Also aligns per-dataset prose left contradicting the new provenance contract:
the datasets.rst divorce section presented always-synthetic data as Stevenson &
Wolfers, the mpdta section still called the now-canonical R data 'simulated',
list_datasets() advertised 'real-world datasets', and _construct_mpdta_data's
docstring claimed to match the R package it only approximates.
…ide provenance

The castle income field is documented as per-capita income, but the pinned
Stata metadata labels it 'state median income'; the values were never wrong,
only the description. (Pre-existing, but now materially misleading since the
loader ships the real data.)

The REGISTRY note claimed cdl*365 'is an exact integer' for the 17 non-half
adoption-year exposures. It is not: cdl is stored as float32, so none of the 17
are bit-exact and the max deviation is 1.03e-5. Reworded to say the products
recover integer day counts to within storage rounding.

llms.txt still advertised the catalog as 'Real-world datasets' and listed
divorce laws among them, and llms-full.txt's section heading likewise, so an
agent reading the guide would treat synthetic divorce output as empirical.
…ication

The divorce section ran CallawaySantAnna on load_divorce_laws() output and then
told the reader the result showed 'effects near zero (validating parallel
trends)' and reproduced the 'spike and fade' Wolfers (2006) documented. That
data is generated, so the notebook was presenting fabricated numbers as
confirmation of a published empirical finding - the exact failure mode this
branch exists to remove.

Reframes the section as a teaching simulation: a warning callout at the top,
provenance surfaced via df.attrs['source'] in the load cell, the parallel-trends
and reproduction claims removed, and the summary marking the panel synthetic.
The estimator mechanics it teaches are unchanged. Card-Krueger and Castle
Doctrine now say plainly that they run on checksum-verified replication data.

Markdown and one print statement only; no outputs were committed and none need
relocking.
…ete survey

The real survey is missing 12 emp_pre and 14 emp_post readings; the synthetic
fallback is complete. The load_card_krueger docstring example and the
datasets.rst example both melt to long format and fit immediately, so they
estimated fine while the loader served synthetic data and raise
'Column employment contains missing values' now that the canonical source
resolves. The tutorial already dropped NaNs, which is why it kept passing.

Adds the dropna to both examples, documents the missing-value counts in the
loader Notes, and adds a regression test that asserts both halves: the raw
workflow raises, and the documented one estimates finite output.

Also narrows two overclaims in tutorial 09: the intro said the first two
sections 'reproduce the published designs', and the Castle section presented
its estimates without noting that it regresses the level rate on a binary
indicator rather than Cheng-Hoekstra's log rate on fractional CDL_it.
…ce provenance in tutorial

The changelog stated categorically that download failures warn and return
synthetic data. They do not when a checksum-valid cache entry exists: those
bytes already passed the pinned SHA-256, so the loader returns canonical data
silently - including under force_download=True, which bypasses the cache on the
way in but still falls back to it when the download fails. Qualifies the wording
and adds a regression test covering both force_download values.

The tutorial intro asserted that the Card and Castle sections run on
checksum-verified data, but offline they run on the synthetic fallback and
neither cell showed which it got. Both load cells now print df.attrs['source'],
and the intro says 'when their canonical sources are reachable'.
…availability note

doc-deps.yaml listed only the API page and tutorial for datasets.py, so
/docs-impact would not flag the new 'Castle Doctrine treatment coding' registry
section when the loader changes - the same drift the map exists to prevent.

test_methodology_lwdid.py justified committing castle_lw_subset.csv on the
grounds that 'the load_castle_doctrine upstream source is currently
unavailable'. That is no longer true as of this branch. Reworded to say the
subset is retained as the pinned artifact its goldens were captured against,
and that consolidating onto the loader is a separate decision.
Round 5 corrected the categorical 'any download failure warns and returns
synthetic data' claim in CHANGELOG.md and docs/api/datasets.rst but left it
standing in the three loader docstrings and the tutorial intro - four of six
surfaces still asserted a contract the code does not honour.

All six now say the same thing: a download failure falls back to a
checksum-valid cache entry when one exists and returns that canonical data
silently; the SYNTHETIC warning and synthetic_fallback marker apply only when
neither a verified cache entry nor a verified fresh download is available, or
when the source fails to parse or validate.
…files

_write_cache_atomically creates .<name>.<ext>.<suffix> beside the cache entry.
The write-failure path unlinks it, but a hard kill between creation and
os.replace strands one, and it matches neither *.csv nor *.dta - so it survived
clear_cache(), the only remedy the docs offer, and accumulated across runs.

Adds the two hidden patterns to the glob, guards on is_file(), and tests that
an unrelated file in the cache directory is left alone. Also covers the
missing-directory case, which is now reachable because the cache directory is
created on write rather than on path construction.
…ilure

A transport error fell back to a checksum-valid cache, but a size-limit breach
or checksum mismatch raised straight through to the synthetic fallback. So with
force_download=True and canonical bytes already on disk, a tampered or moved
upstream silently replaced real data with generated data - the opposite of the
fail-closed behaviour the pinned-mirror comment claimed, and a contradiction of
the contract the previous two commits aligned six surfaces to.

The cache is now read up front and retained under force_download, and all three
failure modes recover from it. A checksum mismatch additionally warns that the
upstream no longer matches the pin: an integrity event should not pass
unnoticed, and the warning deliberately omits SYNTHETIC because no synthetic
data is involved and callers key provenance checks on that word.

The warning's stacklevel is computed rather than fixed. The text and binary
loaders reach the download helper at different depths, so the constant that was
correct for prop99/walmart pointed inside datasets.py for mpdta/card_krueger.
… raises

The semantic sweep after the cache-recovery fix searched for the phrasings I had
written, so it missed two pre-existing ones: _download_with_cache_binary's
docstring ('a checksum mismatch on freshly downloaded bytes raises') and the
module docstring ('if a source cannot be verified, the loader warns and returns
a synthetic fallback'). Both now describe cache recovery as the first resort.
…taset cache

The test called clear_cache() with _CACHE_DIR unpatched, so every suite run
deleted ~/.cache/diff_diff/datasets. That was survivable while the loaders
served synthetic data; now that the cache holds verified canonical downloads,
it forces a re-fetch of all three pinned sources after every test run. Pinned
to tmp_path, and an AST sweep confirms no other test touches a loader or
clear_cache without either pinning the cache directory or mocking the download.

Also refreshes the CI-canary backlog row, which pointed at 'the loader-fallback
repair row above' - a row this branch removed as completed.
@igerber
igerber force-pushed the fix/dataset-loader-provenance branch from 0eaba4f to d126b6d Compare July 27, 2026 17:23
@igerber

igerber commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Thanks Charles, the revisions check out - both failure paths behave as intended, and treatment_exposure matches the source cdl exactly.

Fair warning that this grew well past the registry note I said I would add. Going through the branch in detail turned up several things small enough to fold in rather than bounce back to you, and they are all now pushed to your branch:

  • The download handler catches HTTPException rather than IncompleteRead. BadStatusLine and its siblings are not OSError subclasses either, so they were still escaping the fallback boundary. That one is on my ask last round, not your execution - I named the wrong class.
  • A verified cache now survives every fresh-download failure, not just transport errors. A tampered or moved upstream was downgrading everyone holding canonical bytes to synthetic data; a checksum mismatch now returns the cache and warns that the pin no longer matches.
  • _write_cache_atomically stranded its temp file when the write itself failed, and clear_cache() could not remove those scratch files afterwards.
  • The documented Card-Krueger workflow raised on canonical data. The real survey is missing 26 employment readings and the synthetic frame was complete, so the docstring and datasets.rst examples estimated fine right up until the source became reachable.
  • Plus the registry note, and the doc surfaces that had gone stale against the change - datasets.rst, the LLM guides, tutorial 09, and a backlog row.

All of it has regression coverage. I also rebased onto current main, which picked up #730 - worth knowing because tests/test_wooldridge.py calls load_mpdta() in 26 places, and those were quietly running on synthetic data until your fix made the source resolve. They pass either way; I checked both with a cleared cache.

Starting the workflows now.

Every other canonical-path test substitutes an already-normalized fabricated
frame, so the real parse - column naming, first.treat renaming, dtypes - was
only ever covered by a live download and never by CI. The canonical bytes are
already committed at benchmarks/data/real/mpdta.csv and hash to exactly the
pinned digest, so the full loader path runs offline for free.

Also guards the pin: re-pinning to a revision that does not match the committed
fixture now fails here rather than silently at runtime. Verified the test has
teeth by perturbing the digest and confirming it fails.

Raised by the CI reviewer on the igerber#723 mirror (igerber#731).
@igerber igerber added the ready-for-ci Triggers CI test workflows label Jul 27, 2026
…ng indentation

Two CI failures, both defects in my own additions rather than the library.

Windows (4 failures, all in test_datasets.py; macOS/Ubuntu/arm all green):
the cache-recovery tests seeded the cache with Path.write_text(), which
translates \n to \r\n on Windows. The digest they pinned was computed from
the untranslated string, so the bytes on disk never matched, the loader
correctly refused the cache, and the assertions saw synthetic_fallback. Seed
and hash the same bytes via write_bytes(). The library was never affected --
it writes cache entries in binary mode.

Sphinx -W (4 warnings, all from load_castle_doctrine): the hanging indent I
added under the treatment_exposure bullet in the Returns block reads as a
block quote to docutils, producing 'Unexpected indentation' plus 'Block quote
ends without a blank line'. The bullet is one line again and the
canonical-vs-fallback caveat moved to Notes as prose.

Verified by building the docs locally with -W (passes) and by reproducing the
CRLF digest mismatch directly.
@igerber
igerber merged commit 7cc15a2 into igerber:main Jul 27, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make legacy dataset-loader synthetic fallbacks explicit and source-verified

3 participants