Skip to content

Release v0.9.0: development → master#663

Merged
ligon merged 137 commits into
masterfrom
development
Jul 22, 2026
Merged

Release v0.9.0: development → master#663
ligon merged 137 commits into
masterfrom
development

Conversation

@ligon

@ligon ligon commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Release merge for v0.9.0. development at fb749f03, CI green, 135 commits / 45 merges ahead of master.

Closes #548, #597, #602, #645

Headline

#655 to_parquet was nulling the literal strings 'None'/'nan'/'<NA>', and 'None' is the canonical "no education" label — dropna(how='all') then deleted the row. Guatemala recovers 8,849 rows (30% of individual_education), Tajikistan 5,328, Ethiopia 242. Closes #645.
#627 GH #323 Site 4 — the dfs: outer merge that manufactures the duplicate rows every other site then collapses.
#629 assets Value is additive — ₦294,594,970 recovered for Nigeria (25.6% of W2 asset value), with the grain audit kept loud rather than silenced.
#641 #653 4.68M phantom rows cleared — Mali 4,324,668, Malawi 355,042, Guinea-Bissau 59.
#605 Declared spellings are now enforced; 6 countries were shipping non-canonical values. Closes #602.
#604 GhanaLSS split households silently merged. Closes #548.
#599 discover_waves() searched only collection='lsms'. Closes #597.

Plus GH #323 country fixes across Uganda, Tanzania, Malawi, Benin, Togo, Ethiopia, Niger, Nigeria, Albania, Pakistan, India and Guinea-Bissau, and the #637 key-soundness reviews.

⚠️ Users rebuild their cache once

Two changes each invalidate every cached parquet, and they are in this release deliberately together so the rebuild happens once rather than twice:

Bugs fixed here that are live on master today

  • Uganda 2005-06 sample().Rural returned the literal '0' for 2,263 rural households, so df[df.Rural == 'Rural'] returned zero rows.
  • Mali Urbain/Urbano spellings were absent, leaving unharmonised French in cluster_features.
  • CLAUDE.md's .pth/PYTHONPATH guidance was inverted, sending contributors to build a needless fresh venv.

Documentation

Deliberately NOT closed

#323, #601 and #637 remain open — each has real work outstanding (Nigeria's cartesian cells, the population-record design, the remaining .first() inventory). Listing them here would auto-close umbrellas that are not resolved.

ligon and others added 30 commits June 21, 2026 09:29
Phase 3 of GH #218.  Rewrite Panama food_acquired to the canonical long form
(index (t,i,j,u,s); columns [Quantity, Expenditure, Price]; materialize: make)
and register it, add a minimal sample table so v joins at API time, and drop
the dead derived script.  Derived food_{expenditures,prices,quantities} now
auto-surface via the framework's _FOOD_DERIVED.

i/j swap fixed: legacy mapped form/hogar(household)->j and item->i; the new
wave scripts map household->i, item->j.  Country('Panama').food_acquired() now
gives n_i=8634 households >> n_j=81 items.

s-axis driven by the SOURCE-FLAG columns, not the obtained-quantity bucket
(fixes the round-1 refutation).  Per wave the non-purchased 15-day quantity
(ga110a / gai10a / s11a10a) is split by the survey's yes/no source flags:
  - own production (ga111a / gai111 / s11a11a)        -> s='produced'
  - gift / donation (ga111b / gai112 / s11a11b)       -> s='inkind'
  - part-of-payment + business (ga111c/d, gai113/114,
    s11a11c/d)                                        -> s='other'
The MONTHLY recall bucket (ga109a / gai09a / s11a9a) is deliberately NOT used:
it overlaps ~100% with the 15-day bucket and summing both double-counts.

Purchased Expenditure reconciles to raw per wave (sentinel-masked):
  - 1997 356079.56 == raw ga106c<9999  356079.56  (exact)
  - 2003 396676.87 == raw gai06c minus the open-ended "otro" catch-all slots
    (which cannot map to a named item) 396676.88  (exact on the harmonizable
    universe; full raw 397466.44)
  - 2008 547159.13 == raw s11a6c<99999 547159.13  (exact)

sample table (index (i,t), v=upm formatted via panama.py's v()): 1997 from
INFORM.DTA (v only -- no weight var), 2003 from E03BASE.DTA (the food file
lacks upm) with weight=factor, 2008 from 02hogar.dta with weight=factor.

food_items.org: add the 2003 spelling for Condimentos (was '---', a gap; the
named item now harmonizes, closing the 2003 reconciliation).

Verified (LSMS_COUNTRIES_ROOT=worktree, LSMS_NO_CACHE=1): food_acquired index
(t,v,i,j,u,s) with v joined; is_this_feature_sane().ok True (only the
framework-joined-v warn); derived tables non-empty; Panama present (modal,
not excluded) in the full Feature('food_acquired')() (18 countries) and in
Feature('food_acquired')(['Panama','Uganda']).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #581 added the Panama config + registration but omitted Panama from the
Currency: section, so test_currency.py::test_every_country_has_currency[Panama]
failed (Panama missing from Currency: section). Panama's ENV survey is
denominated in Balboas (PAB), pegged 1:1 to USD; USD also circulates (dual
currency), mirroring the Liberia entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nical

feat(#218): canonical Panama food_acquired + register (actual 15-day recall, sample from upm)
The `blessed` tier was already fully implemented -- loader, tier
promotion, colour, the `★` glyph, docs -- but its file was a 21-byte
header, so the top of the ladder had never once been used.

`sane` and `blessed` answer different questions, and the gap between them
is where quiet errors live:

  sane     the automated checks passed.  No human has necessarily looked
           at a single number.
  blessed  a human read the actual numbers for that cell and believes
           them.

A feature can build cleanly, pass every sanity check, and still be wired
to the wrong source column or carry an unverified unit conversion.  For
anything feeding published analysis, `sane` is not enough.

Adopting the tier cost zero library code:

- blessed.csv schema extended to
  `country,feature,wave,blessed_by,date,note`.  `load_blessed()` reads
  via `r.get(...)`, so the provenance columns are free -- and they are
  what make a blessing auditable rather than a bare assertion.
- The rule, documented in CLAUDE.md and docs/guide/coverage.md: if you
  used a cell in real analysis and looked at its numbers, bless it in the
  same PR.

Seeded empty, deliberately.  Blessings accrete; they are never
bulk-seeded.  An empty blessing file is honest -- bulk-blessing the 1208
`sane` cells would make `blessed` a synonym for `sane`, destroy the only
distinction the tier draws, and assert a human review that never
happened.

Two things found while wiring it:

- CLAUDE.md never mentioned the coverage matrix at all (it landed in
  June and the guide never learned about it).  Added a section.
- `load_blessed()` calls `pd.read_csv(..., keep_default_na=False)` with
  no `comment=` argument, so a `#` comment line in blessed.csv parses as
  a *row* -- a phantom blessed cell.  Documented; file kept header-only
  rather than touching the loader.

Also records the corollary that bit two live issues: a `sane` cell is not
proof an issue is fixed.  The matrix is a cold build, so it structurally
cannot see #588's warm-cache-only divergence, and it does not check
currency labels (#589).  Both look green and are genuinely open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…teristics is the only site; BF-2014 French labels are a config fix (reuse the EHCVM mapping: idiom), the per-t all-NaN guard is the one 'new' piece

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtARb9se2un1DLFJrrpjHe
… grader)

The coverage matrix measures, but it does not measure right.  A census
(2026-07-11) found that 595 of its 1849 cells -- 32% -- carry a tier that
is wrong or unactionable, and that its errors bias in BOTH directions: it
cries wolf about what we have, and stays silent about what we lack.  A
workplan built on those numbers is built on sand.  This is the first half
of fixing the instrument.

Two defects, and the sequencing between them matters.

## 1. The wave-slice grader applied a COUNTRY-level check (128 cells)

`coverage_matrix.grade_feature` grades a wave by slicing the country frame
on `t` and calling `is_this_feature_sane` on the slice -- but
`_check_no_all_null_columns` is a *country-level* check.  A question simply
not fielded in a given wave is legitimately all-null within that wave's
slice, and was graded `builds` (a defect).

It is a grading bug, not a data bug: of the 124 columns it flagged, ZERO
are all-null country-wide.  Every one is populated in some other wave.

Fix: `is_this_feature_sane` gains an `extra_optional=` kwarg (default None
-> historical behaviour byte-for-byte; it has many callers).  The slice
grader passes the set of columns populated somewhere in the parent frame.
A column that is all-null across the WHOLE country still (correctly) fails
-- we relaxed the slice, not the country.

Verified on Malawi/Uganda/Niger: 434 cells, 37 changed, all `builds`->
`sane`, none unexplained by `no_all_null_columns`, ZERO regressions, and
the 5 genuinely-broken cells (`no_null_index_levels` -- the `v`-join
failure) correctly stayed `builds`.  The fix is surgical.

### The sequencing rule this taught us

A regrade is NOT a fix.  Re-tiering 128 cells to `sane` would have SILENTLY
BURIED two genuine gaps that the buggy grading was catching only by
accident.  Both were inventoried as issues BEFORE this landed:

  GH #591  Nigeria `food_prices()` silently drops ~99% of rows in 6 of 8
           waves.  A `Quantity == 0` sentinel (never decoded to NA) makes
           `Price = Expenditure/0 = inf`, which transformations.py:933
           deletes.  675-1106 rows survive out of 120k-160k.  All 6 cells
           were graded `sane`.
  GH #592  18 waves across 6 countries have ZERO GPS in cluster_features.

Whenever a fix changes how cells are GRADED rather than what they CONTAIN,
first inventory what the old grading was catching by accident.

## 2. `absent` was a black hole (456 cells)

`absent` said only "not declared for this wave", conflating states that
could not be more different -- so the number could never reach zero, and
every probe's findings evaporated into the prober's head.

Verdicts now live in `.coder/coverage/absent_verdicts.csv`:

  todo                   data IS there; config missing.  Stays `absent`
                         (still work) but is now sized and SOURCED.
  asked-not-distributed  the instrument asked; the shipped extract lacks
                         the variables.  An ACQUISITION problem -- a
                         different queue.  CLOSES the cell.
  not-asked              genuinely never asked.  CLOSES the cell.
  unsure                 a required check could not run.  Stays in the
                         queue and RECORDS WHY.  Silence is never evidence.

`asked-not-distributed` is not a hypothetical: Albania 2004's questionnaire
lists MODULE 2 DURABLE GOODS, and the variables are simply not in the
shipped .dta.  Data-side checks cannot see that, which is why C4 (the
questionnaire) is mandatory before any permanent close.

### Evidence is load-bearing, and this already went wrong

`load_verdicts()` REFUSES a closing verdict with an empty `evidence` field.
A closing verdict is a permanent, unsupervised write; an unevidenced
negative is unfalsifiable and therefore permanent whether or not it is
true.

`Albania/_/data_scheme.yml` asserted "earlier waves have no shocks module".
It is FALSE.  Verified against source: Albania 2005's `migrationE_cl.dta`
carries `m6e_q00 = 'Type of Shock Code'` with ten shock types; 2008 is
identical; 2003 and 2004 carry "faced any shocks in last 12 months".  The
claim was wrong, nobody could catch it (nothing recorded HOW it was
reached), and it silently suppressed ~5 cells.  Comment corrected; the
four waves are seeded as `todo` with their file+variable citations, and
2002 as an honest `unsure` with its blocker named.

A 38-cell pilot returned 25 todo / 13 unsure / **0 not-asked** -- so
`absent` is not hiding "the survey never asked", it is hiding a backlog.
This mechanism does not shrink the work.  It makes the work knowable.

Tests: 12 new (grader exemption, country-wide-null still fails, back-compat
default, all four verdicts, and the evidence refusal).  Docs in CLAUDE.md +
docs/guide/coverage.md.  Design: slurm_logs/DESIGN_coverage_discipline_2026-07-11.org

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ole waves

`roster_to_characteristics()` is handed the *country-level* concat of every
wave's `household_roster`, so a residence-duration column contributed by one
wave is unioned (all-NaN) onto every other wave.  Three defects followed, each
silently returning NOTHING for a wave whose roster is fully populated:

D1  The resolution was an if/elif chain over the columns *present*, not
    *populated*.  Ethiopia carries both MonthsAway (W1-W3) and WeeksAway
    (W4-W5, when the questionnaire switched to weeks -- exactly as CLAUDE.md
    documents).  MonthsAway won the elif, resolved to all-NaN on W4-W5, and
    both waves vanished.  -> per-ROW coalesce across the three sources,
    priority MonthsSpent > MonthsAway > WeeksAway.

D2  No all-NaN guard.  CotedIvoire 1985-89 and Mali 2021-22 never asked a
    residence question; their MonthsSpent column exists only because a later
    wave supplies it.  The keep-mask was all-False -> 5 waves vanished.
    -> per-`t` fallback to the documented "count everyone" behaviour ("countries
    without any residence column are unaffected", CLAUDE.md), applied at wave
    granularity, and it now warns rather than deleting silently.

D3  Burkina Faso 2014's MonthsSpent (EMC B3A) is the raw French 6-month binary
    '6 mois ou plus' / 'moins de 6 mois'; to_numeric(errors='coerce') NaN'd the
    wave away.  -> fixed in CONFIG (the wave's data_info.yml `mapping:`),
    following the EHCVM Oui/Non -> 12/0 pattern.  No French in library code.

The filter itself is untouched wherever it has data: the fallback fires only
for a wave with no usable residence datum at all.  Census over all 34 countries
that expose household_characteristics: the 8 dead wave-cells come back (+34,974
households) and all 88 other waves are byte-identical, Uganda included -- so the
1315-HH Uganda drift the filter was introduced to fix stays fixed.

Ledger: .coder/ledger/roster-characteristics-wave-deletion.md
Tests:  tests/test_roster_residence_filter.py (6 of them fail pre-fix)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtARb9se2un1DLFJrrpjHe
…the matrix

The six defects in the coverage instrument, the four-way adjudication of
`absent`, the NOT-ASKED evidence standard (and the pilot that refuted my
original premise: 25 todo / 13 unsure / 0 not-asked), and a workplan ordered
by leverage.

Cited by PR #593.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tier,

and stop a scoped run from destroying the snapshot

Three more defects in the coverage instrument, plus one found while fixing them.

## Defect 4 — eleven countries were INVISIBLE

`catalog.countries()` admits a directory only if it carries `_/data_scheme.yml`,
so country dirs holding downloaded microdata and no config never appeared in the
matrix at all.  That hid **11 countries and 35 waves of already-downloaded data**
(Afghanistan, Bosnia-Herzegovina, Brazil, Bulgaria, KenyaLPS, Kyrgyz Republic,
Nicaragua, Panama, Peru, Rwanda, Tanzania_Kegera) -- ten of which Ethan has
confirmed are in remit.

New `unconfigured` tier + `unconfigured_countries()`.  They are now visibly red
rather than absent from the report.  A denominator that omits the work you have
not started is not a denominator.

## Defect 6 — `broken` was mis-tiered for the known-no-microdata countries

All 8 `broken` cells in the cube were Armenia (2) and Nepal (6) -- both already
documented in CLAUDE.md as having no source data in the repo.  They cannot build;
that is a data-availability fact, not a defect.

Declared in the canonical `data_info.yml` (config, not code -- GH #436) and
graded `n/a`.  This matters beyond tidiness: the most alarming tier in the ladder
contained *nothing actionable*, so a genuine build failure would have been lost
among them.

## Found while fixing the above: a scoped run DESTROYED the snapshot

`save_snapshot()` wrote wholesale, so `make matrix C="Uganda"` -- which grades
only Uganda -- silently erased every cell it did not grade.

`docs/guide/coverage.md` documents exactly that as the "spot refresh" procedure,
immediately followed by `git add .coder/coverage/latest.csv && git commit`.
Following the guide verbatim therefore committed a 67-cell snapshot over the
authoritative 1849-cell one.  I did it to myself while testing the renderer.

`save_snapshot(..., merge=True)` (now the default) upserts on
`(country, feature, wave)` and leaves un-graded cells untouched.  A full sweep
still rewrites everything it grades, so the authoritative run is unaffected -- it
simply no longer depends on *not* being scoped.

**A partial measurement must never be able to erase a complete one.**

## Notes

- Two full-suite failures investigated, neither caused by this work:
  `test_feature_ghana_per_wave` is pre-existing and already tracked as **GH #589**
  (its title is literally that test name); the Albania `sample` failure was a
  parquet-cache race from running three heavy builds concurrently right after a
  `data_scheme.yml` edit invalidated Albania's content hash.  Passes in isolation.
- 11 new tests (34 in test_coverage_matrix.py total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s wrong

Addendum #3 claimed "`PYTHONPATH` alone does NOT redirect imports of
`lsms_library` to a worktree".  That is FALSE -- PYTHONPATH does beat the
`.pth`.  The wrong claim sent agents to a needless mitigation (build a fresh
venv inside the worktree, or verify by static diff only).

The ACTUAL trap is `python <script>.py`.  Python sets `sys.path[0]` to the
SCRIPT's own directory, not cwd -- so `cd`-ing into the worktree does not
protect a script run: cwd never enters `sys.path` at all, the `.pth`'s
main-repo path wins, and you import the MAIN checkout while believing you are
testing the worktree.  Verified (cwd = worktree throughout):

  python -c "import lsms_library"      PYTHONPATH unset  -> worktree  (cwd is sys.path[0])
  python bench/scan.py                 PYTHONPATH unset  -> MAIN CHECKOUT  <-- the trap
  python bench/scan.py                 PYTHONPATH=<wt>   -> worktree

This is nastier than the old text described, because the agent has done
everything that LOOKS right (cd'd into the worktree, run the worktree's own
script) and still tested the wrong code -- silently.  It has already cost a
worker agent a full wasted audit run (found while reviewing PR #594).

Since the failure is silent and looks like success, the guidance now says to
ASSERT it rather than assume it:

    import lsms_library
    assert 'worktrees' in lsms_library.__file__, lsms_library.__file__

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ethan's call: keep 12/0.  The reasoning is not obvious and someone will
otherwise "fix" it later, so it now lives above the mapping itself and in the
ledger (§6, open question -> resolution):

- B3A's variable label is a DURATION question ("durant combien de mois [NOM] a
  vecu dans le menage"), but the released variable is collapsed to a 6-month
  binary: {1: '6 mois ou plus', 2: 'moins de 6 mois', 9: 'Valeur manquante'},
  distributed 77,268 / 943 / (332 NaN).
- So 'moins de 6 mois' does NOT mean "lived zero months" -- those 943 people
  (1.2%) DID live in the household, for 1-5 months.  Read literally, mapping
  them to 0 is false.
- That is where B3A differs from the EHCVM precedent it borrows: s01q12
  ("lived continuously 6+ months? Oui/Non") is a MEMBERSHIP test, where Non->0
  is faithful.  B3A is a binned duration.
- We keep 12/0 anyway because the 0 encodes "not a de-facto household member"
  (the standard LSMS 6-month rule), not a claim about months lived -- and
  consistency with the EHCVM family wins.
- 12/6 was rejected partly because 6 is the one value the respondent explicitly
  ruled out.  If we ever keep these members, the honest fill is 3 (the 1-5
  month interval midpoint), not 6.

Comment-only + ledger; no behaviour change (tests/test_roster_residence_filter.py
still 13/13, including the mapping assertion it reads back out of the YAML).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtARb9se2un1DLFJrrpjHe
…ves on it

discover_waves() decided whether we already held a WB catalog entry by
rebuilding a wave *label* from the entry's year range and string-matching it
against directory names.  Labels collide, so this was wrong in both
directions:

  * False positive -- Nigeria/2018-19/ holds GHS-Panel Wave 4 (id 3557).  The
    Living Standards Survey (id 3827) also spans 2018-2019, rendered to the
    same "2018-19" label, and was therefore reported as already held.  It is
    not.  Ethiopia 2671 (Land & Soil Experimental Research) was likewise
    masked by ESS W2 (2247).
  * False negative -- WB id 1001 (UGA_2005-2009_UNPS) renders as "2005-10",
    matching no directory, so it looked missing even though we hold it, split
    across Uganda/2005-06/ and Uganda/2009-10/.

Wave provenance is now explicit and machine-readable.  lsms_library/
provenance.py extends the SOURCE.org that add_wave() already writes -- rather
than introducing a parallel mechanism -- with org keyword lines (#+CATALOG_ID,
#+CATALOG_IDNO, #+PROVENANCE_SOURCE in {worldbank, external, unknown}, ...).
The original URL stays the file's first URL, so the legacy _read_source_url()
reader is unaffected, and human-written prose is preserved verbatim under a
"* Notes (preserved ...)" heading.  The writer is idempotent.

discover_waves() now matches on the recorded catalog id.  Where no id is
recorded it falls back to the old label heuristic but reports
local_status='unknown' with local=False -- silence must not masquerade as
knowledge, and an unverified claim must not read as "held".  `local` remains a
bool, so existing callers are unaffected.

SOURCE.org turns out to be load-bearing: Country.waves (country.py) falls back
to scanning for subdirectories containing Documentation/SOURCE.org when a
country declares no explicit wave list, so *creating* one silently promotes a
directory into a wave.  The backfill therefore only ever UPDATES an existing
SOURCE.org, or CREATES one in a directory the library already declares a wave;
13 directories that are not declared waves (a stray Benin/2018-2019/ duplicate,
Bulgaria's five, Afghanistan's two, Brazil's, four KenyaLPS) are reported and
left alone.  Country.waves is verified byte-identical to pristine for all 47
countries.

Also fixed:
  * Serbia and Montenegro -> SCG (ids 80, 81 match our 2002/ and 2003/); it
    previously had no ISO code and returned [] with an error.
  * GhanaLSS/GhanaSPS (both GHA) and Tanzania/Tanzania_Kegera (both TZA) are
    disambiguated by an idno_pattern in the new _COUNTRY_CATALOG registry;
    each previously saw the other's waves as its own missing waves.
  * EthiopiaRHS (IFPRI/Harvard Dataverse) and KenyaLPS are marked
    discoverable=False with a reason, rather than merely left out.

Backfilled via scripts/backfill_wave_provenance.py: of 136 wave dirs, 123 are
declared waves and were stamped -- 118 resolved (52 from a recorded
/catalog/{id} URL, 48 by resolving a WB Microdata DOI against the catalog's
doi field, 17 external, 1 manual override) and 5 recorded as unknown.  The
unknowns are declared waves with no SOURCE.org; none is guessed from its
directory name -- that heuristic is the bug being removed.

The single manual override is Nigeria/2018-19, whose SOURCE.org recorded 3827
(the LSS) while the directory holds sect*_plantingw4 / sect*_harvestw4 /
nga_*_y4 -- i.e. GHS-Panel W4 (3557), consistent with every sibling Nigeria
wave.  The superseded URL is retained in the record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtARb9se2un1DLFJrrpjHe
…" claim was FALSE

I wrote, in the design doc, in commit 8dcd2bc's message, and in PR #593:

    "none of the 136 wave directories records the WB catalog id it came from
     ... not one pre-existing wave has one (find ... -name SOURCE.org -> 0)"

That is false.  **117 of the 136 waves had a SOURCE.org all along.**  My `find`
used `-maxdepth 3`; the file lives at
`{Country}/{wave}/Documentation/SOURCE.org` -- depth **4**.  The command
returned 0 because it could not see the files, not because they were absent.

I ran one command, it returned zero, and I called it proof.  That is exactly
the *unevidenced negative* this very design doc spends its evidence-standard
section warning against -- the same failure as Albania's "earlier waves have no
shocks module".  The rule applies to me too: silence is not evidence, and a
null result from an instrument you have not validated is a null result *about
the instrument*.

The real defect is WORSE than the one I described.  The provenance is not
merely missing -- in places it is WRONG:

    Nigeria/2018-19/Documentation/SOURCE.org  ->  catalog 3827 (Nigeria LSS)
    Nigeria/2018-19/Data/                     ->  aux_plantingw4, aux_harvestw4
                                                  = GHS-Panel W4 (3557)

A backfill that TRUSTED SOURCE.org would have cemented that error rather than
fixed it.  Absent provenance is a gap; wrong provenance is a lie, and it is the
harder failure.  PR #595 therefore validates rather than trusts, and keeps
`none` (definitively not a WB dataset) distinct from `unknown` (we don't know)
-- collapsing those would recreate the `absent`-black-hole mistake of Defect 1
one level down.

Also records a trap found while implementing it: **SOURCE.org is load-bearing.**
`Country.waves` (country.py:1452) falls back to scanning for dirs containing
`Documentation/SOURCE.org`, so *creating* one PROMOTES a directory into a wave.
Stamping all 136 broke `test_sample.py::test_covers_all_waves[Benin]`, because
`Benin/2018-2019/` is a stray duplicate of `Benin/2018-19/` and stamping it
promoted the duplicate into a wave `sample()` does not cover.  Metadata that
silently changes the shape of the data model is not metadata.

Credit: caught by the wave-provenance agent (PR #595), not by me.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Benin/2018-2019/` was a `Data/`-only staging duplicate of the configured
`Benin/2018-19/` wave.  Removed at Ethan's direction (2026-07-12).

Verified safe before deleting:
- all **52** DVC sidecars are **md5-identical** to `2018-19/Data/`'s;
- **zero** files unique to either directory;
- no `_/` config, no `Documentation/`, not in `Country('Benin').waves`;
- referenced by nothing but the `Benin/_/CONTENTS.org` note describing it.

The DVC blobs are content-addressed and shared, so removing the duplicate
sidecars orphans no data.

## It was not merely clutter — it was a trapdoor

`Country.waves` (`country.py:1452`) falls back to scanning for directories
containing `Documentation/SOURCE.org`, so **writing a SOURCE.org promotes a
directory into a wave**.  A provenance-backfill pass that stamped every
directory therefore turned this duplicate into a wave that `sample()` does not
cover, breaking `test_sample.py::test_covers_all_waves[Benin]` (found in
PR #595).  Removing the directory closes that trapdoor rather than working
around it.

Post-delete: `Country('Benin').waves == ['2018-19']`, `sample()` (8012, 5),
`pytest tests/test_sample.py -k Benin` 9 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0: fix the coverage ruler (blessed tier + absent verdicts + wave-slice grader)
…letion

fix(household_characteristics): residence filter was silently deleting 8 entire wave-cells
feat(provenance): record each wave's WB catalog id; match discover_waves on it
Every wave dir that ships a SOURCE.org now records a terminal answer: 123
stamped, 0 unknown.  Each of the five resolutions is validated against the
directory's actual contents and the live WB catalog -- a year match alone
proves nothing (that is what put the wrong id in Nigeria/2018-19).

Resolved to a catalog id:
  * Niger/2011-12    -> 2050.  Data/ contains a directory literally named
    NER_2011_ECVMA_v01_M_Stata8 -- the WB idno itself.
  * Senegal/2021-22  -> 6278.  58 ehcvm_*_sen2021 files; the only SEN EHCVM-2
    entry (4297 is the 2018-19 wave).
  * Nepal/2003-04    -> 74.  Matched on idno + year range only: this wave's
    Data/ is EMPTY (Nepal's microdata is NSO-hosted, not WB).  So provenance
    is KNOWN while the data is ABSENT -- two different facts, and this work
    exists to stop conflating them.

Reclassified unknown -> none (a terminal state, not a gap):
  * GhanaSPS/2013-14, GhanaSPS/2017-18.  EGC-ISSER (Yale/ISSER) panel waves 2
    and 3, not WB datasets.  A GHA query across the ENTIRE WB catalog (all
    collections, 152 entries) returns exactly one GSPS study -- 2534, the
    2009-2010 wave -- and nothing later; the file naming (01a_consent.dta,
    00_comm_info.dta) is the EGC-ISSER instrument.  Leaving these 'unknown'
    would mean someone re-searches them forever: the same absent-vs-unknown
    black hole this work closes, one level down.

New #+PROVENANCE_VALIDATION: field records how strongly an id is corroborated
-- content-validated (the wave's own data files confirm it) vs catalog-only
(catalog metadata alone; no local data to check, as for Nepal).  "Right for
the right reason" and "right by coincidence" must not look identical.

_declared_waves() now folds wave_folder_map values in: Country.waves is not
always a list of directory names (Nigeria's are rounds -- 2010Q3, 2011Q1;
Tanzania's are logical waves), and provenance is recorded per directory.
Caught by the new zero-unknown test.

Rebased onto development, which deleted the stray Benin/2018-2019/ duplicate
this branch surfaced.  Country.waves verified identical to pristine for all 47
countries.  Full suite: 3638 passed, 0 failed (test_currency.py::
test_feature_ghana_per_wave deselected -- it fails identically on development).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtARb9se2un1DLFJrrpjHe
The state between "obtainable" and "does not exist": the data EXISTS, we KNOW
where it is, and we CANNOT get it because the acquisition channel is broken.

  Nepal (3 waves)  NSO Nepal hosts the microdata, not the WB.  Their site is
                   unreachable and no human contact has been reached.
  Albania 1996     directory + WB catalog entry (2311, ALB_1996_EWS) both exist;
                   zero data files.

## I had this wrong, and the mistake was instructive

Two commits ago I graded these `n/a` under "Countries Without Microdata"
(Defect 6).  But `n/a` means "no readiness applies" -- it reads as *nothing to
see here*, and it BURIED a gap we actively want.  That is the exact lumping
error this module exists to prevent, committed by its author, in the fix for a
different lumping error.

`blocked` is *visible* (it counts against us) but *not actionable by us* --
nobody closes it by trying harder, and an RA who picks it up will waste a day
rediscovering that the NSO site is down.  It sorts below the real defects and
above the settled tiers.

## The load-bearing part is the probe, not the label

A blocker is a CONDITION THAT CHANGES.  A broken website heals; a dead contact
gets replaced.  **"We hope we'll find it someday" is not a plan; a scheduled
recheck is.**  So `load_blocked()` REFUSES a row with no `probe` URL (it could
never be rechecked, and would silently become the permanent close it explicitly
is not) or no `blocker` (an unexplained block is indistinguishable from
neglect).  `bin/coverage_refresh.sbatch` -- which already self-resubmits, is
connectivity-gated, and already opens GitHub issues -- re-probes them.

## Two further bugs this flushed out

**Blockedness is a property of the SOURCE, not of a build.**  I first wired it
only into the build-failure branch, so `make matrix-coverage` reported
`blocked=0` while four sources were plainly blocked.  We do not need to try and
fail to build Nepal to know NSO's site is down.  Hoisted; it now applies to the
cheap coverage-only layer.

**A blocked source we do not MODEL as a wave still has to appear.**
`Albania/1996` has a directory, Documentation, and a catalog id -- but no `_/`
and no `Data/`, so `Country.waves` never enumerates it and every per-wave loop
skipped it in silence.  The blocked list is AUTHORITATIVE about sources we want;
it does not defer to whether the model happens to know about them.  Otherwise
the denominator quietly omits exactly the waves we most want and cannot get.

## "Countries Without Microdata" is now EMPTY, and that is the point

Both of its entries were misclassified live gaps, each hidden behind a label
that reads "nothing to see here":

  Nepal   -> `blocked` (above).
  Armenia -> NOT MISSING AT ALL.  `discover_waves()` defaults to
             collection='lsms', but Armenia's **18 waves of Integrated Living
             Conditions Survey (2001-2018)** sit under repositoryid='central' --
             invisible to our discovery, not absent from the world.  Plausibly
             the largest actionable acquisition gap in the repo.  Filed as
             **GH #597**, which also covers South Africa's General Household
             Survey 2015-2025 (repositoryid='datafirst', 10 waves).

#597 matters beyond those two countries: we are teaching the matrix to report an
honest denominator of waves the WB publishes that we lack -- and that fix is
INCOMPLETE while `discover_waves` itself can only see one collection.  We would
have built a denominator that is confidently wrong.

The ruler was miscalibrated.  So was the ruler's own instrument.

Tests: 10 new (blocked beats n/a; probe/blocker refusal; un-modelled wave still
appears; a genuine build failure is still `broken`).  49 passing in
test_coverage_matrix.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0 was meant to make the denominator honest.  It did -- and the honest
denominator is far larger than the matrix ever suggested, because the instrument
could not see most of it.

Armenia (18 ILCS waves, repo=central) and South Africa (10 GHS waves,
repo=datafirst) are both IN REMIT (Ethan, 2026-07-12) and both structurally
invisible to discover_waves(collection='lsms').

The queue as now known: ~43 waves to ACQUIRE, ~35 already-downloaded waves to
CONFIGURE, on top of the 456 absent cells.  None of this is new work appearing.
It is work that was always there and could not be counted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ources

Backlog reconciliation, and a prior-art miss I should own: **GH #552 (June 2026)
already audited WB coverage gaps "tiered by obtainability"** — Tier 1
(WB-direct) / Tier 2 (NSO-redirect) / Tier 3 (licensed, fee-gated) — and even
cites the Nepal->NSO precedent.  I built the `blocked` tier without reading it.
CLAUDE.md tells me to run the prior-art-ledger skill at the start of a
non-trivial task; I skipped it.

The reconciliation was productive in BOTH directions.

## What #552 gives us: `blocker_kind`

#552's tiering is the right axis, and it belongs in the machinery as STRUCTURE
rather than prose, because the kinds route to completely different people:

  nso-redirect  the WB catalogues it but redirects to a national statistics
                office whose channel is broken.  -> chase a human.
  fee-gated     obtainable, for money and permission (Vietnam GSO: USD 200-2000
                + consent).  -> a BUDGET decision, not an engineering one.
                NEVER queue this for an RA.
  unconfirmed   we believe it is blocked but have not proven it; it may be a
                plain obtainable TODO (Albania 1996).  The honest state when you
                have not actually checked.

"The site is down" and "it costs $2,000" are not the same blocker.  Flattening
them is how a money question silently becomes an engineering backlog item that
nobody can ever close.

blocked_sources.csv grows from 4 rows to 8, absorbing #552's Tier 2/3:
Jamaica (JSLC, STATIN redirect), Ecuador (ECV 1995/1998, INEC redirect), and
Vietnam (VLSS/VHLSS x5, GSO, fee-gated).  They are now git-tracked, probed, and
re-checked — not a snapshot in an issue that ages quietly.

## What we give #552: two of its claims are stale, one is wrong in fact

- **"Kyrgyz Republic — wholly-missing"**: NOT missing.  4 waves, 146 DVC
  sidecars (~378 MB) are already downloaded; only the CONFIG is absent.  It is
  one of the 10 `unconfigured` countries (#593).  An add-feature job, not an
  add-wave one — a materially cheaper class of work than #552 assumes.
- **"South Africa: no later WB-LSMS waves"**: true within `collection='lsms'`,
  FALSE in fact.  The General Household Survey 2015-2025 sits under
  `repositoryid='datafirst'`.  Same blind spot hides Armenia's 18 ILCS waves
  under `repositoryid='central'`.  Both now in remit; see GH #597.

  #552's conclusions are correct GIVEN ITS INSTRUMENT, and its instrument could
  only see one collection.  *A negative result from an uncalibrated instrument
  is a result about the instrument.*  That is the third time that exact lesson
  has landed in this series -- twice on me.

Also closed as verified-fixed during the sweep: #571 (Guatemala), #572 (Serbia),
#573 (Panama — its data_scheme.yml landed today in a0ba3e1), #574 (GhanaSPS
food_acquired; its sibling #579 stays open — GhanaSPS.sample() still raises).
And corrected #587, whose stated hypothesis for its largest bloc (Nigeria's 22
`dropped` cells as "false positives") is refuted: the built `t` values are valid
wave labels from the OPPOSITE PP/PH round.  They are real half-missing features,
not a reporting artefact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g the bookkeeping

Organizing principle: **the GitHub backlog should hold exactly what the coverage
matrix cannot.**  Any issue that IS a cell is duplicate bookkeeping -- the
Phase-1 sweep re-derives it, sized and sourced, whether or not an issue exists.
Maintaining both is how a backlog becomes a museum.

  the matrix owns   WHICH cells are missing / broken / blocked  (the WHAT)
  the backlog owns  WHY a cell is hard, cross-cutting bugs, and everything the
                    matrix CANNOT SEE

That last clause is not a throwaway.  The grader has known blind spots, and they
are exactly where issues earn their keep: #588 (Uganda earnings is sane COLD and
broken WARM -- the matrix is a cold build and structurally cannot see it) and
#589 (currency labels -- it does not check them).

Track what the instrument cannot see.  Let the instrument track the rest.

## Severity rule: order by HARM, not effort or age

  1 silently wrong    the user gets a wrong number and does not know.  The only
                      UNRECOVERABLE class -- it propagates into published work.
  2 silently missing  the user gets less data than they think.
  3 loudly broken     it raises.  Annoying, harmless.  A crash is a gift.
  4 untidy            noise, dead code, ergonomics.

## Top of the board: #503 (Guyana), and it is not close

`i = [ED, HH]` CONFLATES DISTINCT HOUSEHOLDS.  The source is at (ED, HH, SN)
granularity: COVERN.dta has 1502 unique (ED,HH) vs 1807 unique (ED,HH,SN).  So
roughly 300 real households are silently merged into others.

That is not missing data.  It is WRONG DATA THAT LOOKS FINE -- it builds clean
and passes every sanity check.  Every Guyana number we have produced is suspect,
and nothing in the matrix flags it.  The purest instance of the session's theme:
`sane` is not `blessed`.

## And #537 is that theme INVERTED

`validate_acquisition_source` has ZERO call sites, while its docstring asserts it
is wired into `_finalize_result`.  A validation we believe is running, and is
not: an unevidenced POSITIVE.  Same disease, opposite sign.  A guarantee that
does not execute is worse than no guarantee, because it is trusted.

## Also

- Wave 4 is DECISIONS, not tickets (#575 purchased-value-only; #226; #279;
  #438/#439; Vietnam's USD 200-2000 fee gate).  An RA cannot close these by
  working harder, and leaving a judgement in an engineering queue is how it
  silently becomes a default.
- A large fraction of the ~29 open issues are not defects at all -- they are
  cells, decisions, or acquisitions filed as issues because there was nowhere
  else to put them.  Now there is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(provenance): resolve the last 5 unknown waves from evidence (0 unknown)
feat(coverage): the `blocked` tier — wanted, known, unobtainable (+ empty out "Countries Without Microdata")
…ually lives

`discover_waves()` searched a single hard-coded collection (`lsms`).  Whole
household-survey series that the World Bank publishes under OTHER repositories
were therefore not "not yet fetched" but structurally unfindable:

  * Armenia's Integrated Living Conditions Survey -- 18 annual waves,
    2001-2018 -- sits in `central`.  `lsms` carries only the 1996 Household
    Budget Survey.  Armenia was recorded as a country "without microdata".
  * South Africa's General Household Survey -- 21 waves, 2002-2025 -- sits in
    `datafirst`.  `lsms` carries only the 1993 Integrated Household Survey.

That is 39 waves of in-remit data invisible behind a default argument, and it
made the coverage matrix's "honest denominator" (#593, #595) confidently wrong:
it reported no missing waves for Armenia while 18 sat in the catalog.

The fix is a per-country `repositories` list on CountryCatalog, defaulting to
("lsms",), unioned and deduplicated on the catalog id.  34 of 36 countries are
unaffected.

Deliberately NOT a removal of the collection filter.  Searching everything
inflates results 30-400x (Findex, DHS, Afrobarometer, enterprise surveys;
`datafirst` alone returns 320 South African rows -- censuses, election studies,
school registers, media surveys).  That trades a false-negative problem for a
false-positive one, and the second is worse: it buries the real gaps in noise.

So widening is paired with an `idno_pattern` that pins the survey series --
the lever #595 already built.  The two compose: `repositories` says where to
look, `idno_pattern` says what counts.  Both are config, not heuristics.

The pin also defends against a trap that id-matching alone cannot see: the WB
catalogues some studies TWICE, in two repositories, under two different ids.
`central` 3016 (MWI_2010_IHS-III_v01_M_v01_A_ML) is the same Malawi IHS3 as
`lsms` 1003, which we hold as Malawi/2010-11/; `datafirst` 902
(ZAF_1993_PSLSD) is the same 1993 survey as `lsms` 297 (ZAF_1993_IHS), which we
hold as South Africa/1993/.  Nothing in the catalog metadata links those pairs.
Naive widening reports both as missing waves.  They are not.

Armenia stays listed under "Countries Without Microdata" -- we still hold zero
data files, and delisting it would flip its coverage cells from `n/a` to
`broken`.  What changes is the reason: an acquisition backlog, not an absence.

No data is acquired here; that is a separate `add-wave` job.  `Country.waves` is
byte-identical to pristine for all 36 countries / 108 wave dirs.

Refs #597.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtARb9se2un1DLFJrrpjHe
Ethan's rulings on #597:

  * South Africa -- configure the GHS *series* (21 waves, 2002-2025), not the
    2015-2025 range.  Already what this branch did; no change.
  * Liberia HIES (central 2563, 2986) -- IN REMIT.  Configured here.

Liberia now searches ("lsms", "central") pinned by r"_(HIES|NHFS)_", which
yields exactly three rows: the two HIES waves (missing) and the National
Household Forest Survey (id 3787, which we hold as 2018-19/).  The `central`
noise -- energy-access framework, survey of public servants, Afrobarometer,
and the global WLD_* rows tagged to every country -- is excluded.

The NHFS is in the pattern for IDENTITY, not remit: it is the catalog entry
backing a wave dir we hold, so discovery must be able to report it as held.  By
the remit predicate proposed in the PR, a forest-resources survey is NOT in
remit -- Liberia is held-but-out-of-remit, the same class as KenyaLPS.  This is
where the two axes visibly diverge, and the divergence is deliberate:
`idno_pattern` answers "is this catalog row this country's?", not "is this
survey in remit?".  Do not conflate them.

The "widened => must pin a series" invariant is now asserted over EVERY country
in _COUNTRY_CATALOG, not just the two named ones, so a future widening cannot
silently skip the pin.

Census: 75 rows; 47 missing in-remit waves (Armenia 18, South Africa 21,
Liberia 2, plus 6 already visible in `lsms`).  Still no data acquired.

Refs #597.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtARb9se2un1DLFJrrpjHe
…time

Ethan's rulings on #597: South Africa takes the GHS **and** the IES + LCS
(28 waves).  Each series is now acquired for a STATED reason:

  GHS (21)  roster, housing, education, employment.  NO consumption module --
            it cannot populate `food_acquired`.
  IES (5)   income + expenditure + acquisition diary.
  LCS (2)   living conditions + expenditure diary.
            IES and LCS are what feed the demand-estimation path.

So the ZAF pin becomes r"_(IHS|GHS|IES|LCS)_" -- 29 rows, zero over-match,
against the 320 ZAF rows `datafirst` actually returns.

CAPABILITY PRE-POPULATES `not_asked`
------------------------------------
When SA-GHS lands, `South Africa/food_acquired/2015` grades `absent`, someone
files it as a gap, and a probe (label sweep + sibling differential +
questionnaire) eventually rediscovers a fact the WB catalog told us the day we
acquired it.  That is archaeology to recover knowledge we already had.

`lsms_library/capability.py` records what a *series* measures when it is
acquired.  A `SeriesCapability` names the features it `provides` and `lacks`;
`proposed_absent_verdicts()` turns each `lacks` entry into rows for
`.coder/coverage/absent_verdicts.csv` in the schema `load_verdicts()` already
reads.  The probe sweep then only adjudicates cells inherited WITHOUT a
capability record -- a shrinking set, not a permanent tax.

AND IT MAY NOT CLOSE A CELL ON A CATALOG BLURB
----------------------------------------------
This is the whole discipline.  A capability asserted from a cataloguer's topic
list is NOT C4.  Closing a cell on it would be the Albania mistake with better
paperwork -- an unevidenced negative is unfalsifiable, and therefore permanent
whether or not it is true.

So each record carries a `validation` level, and the verdict is DERIVED from it
rather than chosen by the caller:

  catalog-only             WB topics / abstract        -> `unsure`     (no close)
  data-validated           C1 label sweep negative     -> `unsure`     (no close)
  questionnaire-validated  C4: the questionnaire read  -> `not-asked`  (closes)

`data-validated` deliberately does not close either: a negative label sweep is
exactly as consistent with `asked-not-distributed` as with `not-asked`.  That is
precisely why C4 is mandatory.

Every record shipped here is `catalog-only`, so every one proposes `unsure`.
The 21 GHS `food_acquired` rows it would emit are all `unsure`, `checks_run`
empty, evidence prefixed `[catalog-only; C4 NOT run]`.  Upgrading a series to
`questionnaire-validated` -- one RA, one PDF -- is what converts them into a
permanent `not-asked`: a bounded human step per SERIES, not per cell.

`capability.audit()` enforces the invariant; `tests/test_capability.py` proves a
catalog-only record cannot close a cell *through the real `load_verdicts()`*,
and that the questionnaire upgrade does.

No verdict rows are written -- SA is not acquired.  The mechanism is proven, the
shape is up for review.

Rebased onto development: #596 resolved the Nepal/Niger/Senegal/Nigeria
provenance gaps I had reported, and #598's `blocked` tier supersedes my
data_info.yml edit (its emptied "Countries Without Microdata" map is taken
wholesale).  Census: 79 rows, 54 missing in-remit waves.

Refs #597.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtARb9se2un1DLFJrrpjHe
…-canonical values (#602)

`data_info.yml` declares accepted values via `spellings` blocks, but nothing
enforced them.  `_enforce_canonical_spellings()` maps known variants and never
rejects unknown ones, and no sanity check read the declarations at all.  Uganda's
`sample.Rural` was the literal string '0' for 2,263 households (72% of the
2005-06 wave), so `df[df.Rural == 'Rural']` silently returned ZERO rows -- and
`is_this_feature_sane(...).ok` returned True.

The sweep is the deliverable; the check is how you do it.  Sweeping all 34
countries x every spellings-constrained (table, column) through the live API
found 10 violations, of which Uganda was the LEAST severe.

The check
---------
* `diagnostics._check_declared_spellings` -- status `fail`, naming offending
  values + row counts.  Wired into both `is_this_feature_sane` and
  `validate_feature`.
* Keyed on **(table, column)**, never column name: `housing.Tenure` is a
  legitimately different vocabulary (dwelling tenure) in ~12 countries and
  `housing` declares none; a name-keyed check emits ~12 false failures.
* Membership is tested AFTER applying the variant map, so the check is correct on
  both pre-`_finalize_result` cached parquets (which legitimately hold known
  variants such as Malawi's `rural`/`RURAL`) and post-finalize API frames.
* Reads `Columns` itself rather than reusing `country._load_canonical_spellings()`,
  whose `if variant_map:` guard DROPS columns whose variant lists are all empty --
  `Affinity`, `Tenure`, `TenureSystem` would otherwise go unchecked.

Schema
------
* Declare `sample:` under `Columns:`.  It was absent entirely, which made
  `_enforce_canonical_spellings` a TOTAL no-op on `sample()` -- not merely failing
  to reject unknowns, but never mapping the known variants.  That is the
  Tajikistan bug: 9,020 rows of lowercase 'rural'/'urban' next to canonical
  values, so `== 'Rural'` returned 2,419 of 8,209 true rural households.
  `Rural` is declared WITHOUT `required: true` (many samples have no such column).
* Extend the `Rural` vocabulary with the French (EHCVM) / Portuguese labels
  ('Urbain', 'Urbano', ...), and with `Informal` -- South Africa 1993's third
  settlement stratum, kept VISIBLE rather than guessed into Urban (the source's
  own `metro` variable groups it with the non-metro areas).

Data fixes (each a distinct root cause)
---------------------------------------
* Uganda 2005-06: YAML key TYPE mismatch.  Raw `urban` is object-dtype but MIXED
  -- python int 0 (2263) + str 'urban' (860).  The declared key was the STRING
  '0', which never matches.  Commit 9959b9f ("close GH #163 item 3") introduced
  exactly that string key and sat silently dead for three months.  Fix: `0`.
* Malawi cluster_features: the `mapping:` stanzas converted good labels into 0/1
  and got the DIRECTION wrong -- the Cross_Sectional and Panel files disagree on
  case, so 2016-17/2019-20 mapped rural households onto BOTH codes, SIGN-FLIPPING
  the indicator between waves.  Every raw value is already canonical or a declared
  variant, so the mappings are deleted and the label passed through.  Agreement
  with the (correct) sample.Rural goes 0.0% -> 87-100% in every wave.
* India 1997-98: `Rural` was derived from `stratum` via a float-keyed map against
  a string column, so it never fired and the raw stratum label ('UP-other',
  ' B-qual', ...) reached the user as `Rural` for 100% of households.  `stratum`
  is a state x phase stratum (collinear with `state`) carrying NO urban/rural
  information, so `Rural` is DELETED, not invented.
* Mali 2017-18 / GhanaLSS 2016-17: French strata labels, and a TRAILING SPACE
  ('Urban ') that looks right in a value_counts but compares false.
* GhanaLSS 1991-92: the `rural` code->label table claimed 1 = 'Semi-urban'.
  loc2=1 nests exactly onto loc3 in {1,2} (Accra 459 + Other Urban 1119 = 1578),
  so code 1 is the union of the two URBAN strata.  Latent (not currently reaching
  sample() in a clean build) but provably wrong; fixed in the org + CONTENTS.

Remaining, deliberately not force-fitted
----------------------------------------
Five plot_features Tenure/TenureSystem pairs (Albania, Cambodia, Kosovo,
Timor-Leste, Tajikistan) ship raw unharmonized survey labels.  The check correctly
FAILS them; they are enumerated in `tests.test_declared_spellings.KNOWN_UNHARMONIZED`
and xfailed rather than downgraded to `warn`.  Mapping them needs the questionnaire
codebooks, and guessing would replace a visibly wrong value with an invisibly wrong
one -- the exact class-1 failure this issue is about.

BEFORE -> AFTER (isolated LSMS_DATA_DIR, race-free):
  violations                     10 -> 5 (all 5 enumerated + xfailed)
  Uganda  (Rural=='Rural')        0 -> 2263  of 3123
  Malawi  cf vs sample agreement  0.0% -> 87.2/100/90/97.5/96.1% by wave

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_ids.py

`GhanaLSS/_/panel_ids.py` built its rename map with a bare
`dict(zip(cur_i, prev_i))`.  `PANELC.DAT` is PERSON-level and records two GLSS1
households that SPLIT into two GLSS2 households each, so that dict is silently
MANY-TO-ONE: both split-offs are told to rename onto the same 1987-88 id.
`id_walk` renames both, and `_normalize_dataframe_index`'s `groupby().first()` /
additive `sum()` collapses them -- inside `load_from_waves`, i.e. BEFORE the
L2-country parquet is written, so the loss is baked into the cache and every
warm read is silent.

Not a "rename-onto-occupied" collision as #548 states: the targets (101332,
114008) are 1987-88 ids and are absent from the live 1988-89 id set, so #536's
`cur_set` guard is a no-op here.  The guard that applies is the one this script
bypassed: `local_tools.update_id`'s `_N` split suffix (the Malawi IHPS
convention).  Route the linkage through `local_tools.panel_ids()` (the Burkina
pattern) instead of hand-rolling the maps.

Damage (cold rebuild, before -> after):
  food_acquired (i=101332, t=1988-89) Expenditure  15,850 -> 5,570
      15,850 was two different households' food summed into one
      (5,570 for HH 204932 + 10,280 for HH 204922); 101332_1 now carries 10,280.
  household_roster: 101332 was reported as a 6-person household whose members
      were entirely HH 204922's -- HH 204932 (an 83-year-old head + 3 others)
      was ERASED.  Now 101332 = 4 persons, 101332_1 = 6.
  rows: sample 56,328->56,330 (+2), interview_date +2, household_roster +10,
      individual_education +3, food_acquired +35 un-summed tuples
      (-> food_expenditures +29, food_prices/quantities +35).
  GH#323 cold-build collapse warnings: 2/2/10/8 -> 0/0/0/0.
Every row outside the two split lineages is byte-identical, including all of
1987-88.  housing / plot_features / cluster_features / food_security: unchanged.
`panel_ids.json` is byte-identical (the chain stores pre-suffix prev-ids, per
`local_tools.panel_ids`); `updated_ids.json` changes exactly 2 of 714 entries.

Also:
- GhanaLSS carries #536's full-cover `cur_set` guard too (0 hits today, from
  Y00A.DAT), plus post-conditions that raise if the map is ever non-injective or
  collides with a live id -- turning a future class-1 silent defect into a crash.
- Burkina_Faso `cur_set` built from the full 2021-22 cover rather than
  panel-only candidates (the unfixed #536 pattern; latent because its cover is
  100% panel today).  Both JSONs byte-identical, 95 skipped chains unchanged.
- `diagnostics._check_panel_ids_targets_exist` now accepts `base_N` split ids as
  consistent -- that IS the library's convention.  Malawi flips fail -> pass
  (1410/5686 = 25% false "inconsistent" entries removed).

`local_tools` (id_walk / update_id / _close_id_map) is deliberately UNTOUCHED:
the framework is correct; one bespoke script refused to use it.

Verification: new tests in tests/test_panel_id_collisions.py fail on pre-fix code
(3 failed) and pass after; the cross-country injectivity invariant flags only
GhanaLSS -- the other 8 shipped updated_ids.json pass, so the negative results
are about the data, not the instrument.  Full pytest on an isolated data root:
3229 passed, 3 failed -- the same 3 that fail on pristine development
(test_feature_ghana_per_wave / GH #589, 2x CotedIvoire/cluster_features).
bench/feature_audit/scan.py over GhanaLSS: identical before and after (56
records, same 4 pre-existing `assembly_nonempty` fails).

NOT fixed here (needs its own issue): Niger's 59 duplicate (i,t) tuples in
2014-15 are NOT this bug -- they are pre-walk, from `EXTENSION` in {0,1,2} in
ECVMA2_MS00P1.dta while the wave's household id omits EXTENSION.  pre-walk dups
== post-walk dups == 59; `updated_ids['2014-15']` has 0 many-to-one targets and
0 rename-onto-occupied entries.  Panel_ids.py cannot fix it.

Prior-art ledger: .coder/ledger/548-panelids.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rebase Uganda sample baseline

Follow-ups from the full-suite run, each a consequence of the #602 fixes:

* India `_/data_scheme.yml` still declared `Rural: str` under `sample` after the
  column was removed from the wave YAML, so `_check_declared_columns` (and hence
  `is_this_feature_sane`) failed India/sample on a column that intentionally no
  longer exists.  Removed the declaration; documented in India/_/CONTENTS.org.

* `test_india_sample_has_no_fabricated_rural` asserted the wrong thing: 'UP-other'
  is a LEGITIMATE stratum label (only the Bihar pair carried a leading space).
  It now asserts Rural is absent and `strata` == the four true labels, whitespace
  normalised.

* `tests/fixtures/uganda_baseline.json`: `var/sample.parquet` content_hash rebased.
  Uganda 2005-06's 2,263 '0' values are now 'Rural', so the parquet's contents
  legitimately change.  Shape [24362, 6], columns, dtypes and index_names are all
  UNCHANGED -- the diff is exactly one line (the hash).

Full suite, isolated LSMS_DATA_DIR: 3 failed / 3239 passed / 9 xfailed.
Pristine development, same harness:  3 failed / 3224 passed / 4 xfailed.
The 3 failures are IDENTICAL and pre-existing (test_feature_ghana_per_wave = GH
#589; CotedIvoire/cluster_features x2).  Zero new failures.  The +5 xfails are the
enumerated KNOWN_UNHARMONIZED plot_features pairs.

Regression net (123 country/table pairs, live API, both branches):
  112 BYTE-IDENTICAL; 7 changed, every one an intended fix; row counts unchanged
  everywhere (only India loses the fabricated Rural column).
  Feature('cluster_features'): 41376 rows / 32 countries / 0 dup-warnings on BOTH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ligon and others added 27 commits July 21, 2026 20:55
fix(Tanzania): describe a cluster from inside it; re-key 2020-21 (GH #323, Site 2)
verify(#323): Ethiopia is GREEN under #627 (already fixed by #628) — unblocks #627; + one real individual_education index bug
… claim

Adversarial review found that this PR's four tests all pass on plain
`development` WITHOUT the config change, so they pinned an invariant rather
than testing the fix.

Cause: commit `c8c25f68` (GH #602/#605) had already added `Urbano, urbano,
URBANO` to `Columns.cluster_features.Rural.spellings`, and it is an ancestor of
this branch.  `_enforce_canonical_spellings` therefore repairs the value at API
time, so `Country(...).cluster_features()` is canonical with or without this
diff.  Every API-level assertion is blind to the thing being fixed.

The config change is still worth keeping: it fixes the EXTRACTION, so the stored
L2-wave parquet is canonical rather than relying on a read-time net.  That
matters because the parquet is a consumer-visible artifact (`docs/guide/
caching.md`), and CLAUDE.md notes cached parquets hold PRE-transformation data
-- so anything the net repairs is invisible in storage.

Adds `test_wave_parquet_stores_canonical_rural`, which reads the wave parquet
directly and so bypasses the net.  Measured cold in an isolated `LSMS_DATA_DIR`:

    with the fix     stored Rural = {'Rural': 3396, 'Urban':  2014}
    without the fix  stored Rural = {'Rural': 3396, 'Urbano': 2014}

Note on method: my first negative control was INVALID -- I used `git stash`,
which found nothing because the config change is committed on this branch, so I
rebuilt with the fix still in place and got a false "no difference".  Reverting
the file to `origin/development` is what actually removes it.  Recording this
because a negative control that silently tests nothing is the exact failure this
commit exists to fix.

The module docstring now states plainly that the API-level assertions do NOT
discriminate, and which single test does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assembles the per-country documentation sweep into one reviewable evidence
document at slurm_logs/POPULATION_STATEMENTS_2026-07-21.org, recording what
each wave's own documentation says its sample represents.

Scope: 111 waves across 38 country directories.  104 have a documented
statement of some kind; 7 have none.  On the stricter reading -- "does any
document NAME the population this sample represents?" -- the answer is no for
10 waves, because 3 more record NOT FOUND for a universe while offering only a
fallback coverage claim.

Standing rule the sweep obeys: quotes are verbatim (sic spellings, source
typography and internal contradictions preserved; every PDF-extraction repair
declared), gaps are recorded as gaps with the full search record so the claim
is falsifiable, and nothing is inferred from the data -- no .dta file was
opened.  Where inheriting a sibling wave's universe would have been defensible
(Mali / Niger / Senegal 2021-22 EHCVM-2 panels) it was declined and the entry
says so.

Structure:
  1. what this is, how it was gathered, and the rule it obeys
  2. summary table (country, wave, source_type, confidence, universe tag) --
     the universe tag is marked throughout as an EDITORIAL reading, not a quote
  3. the verbatim record per wave: population statement, translation where
     present, exclusions called out separately, source + locator, and the
     extractor's evidence notes
  4. the gaps, unsoftened, with what was searched for each
  5. what the evidence suggests, with disagreements flagged and NOT resolved

Findings worth the maintainer's attention: 41 of 111 waves' population record
exists only in WB catalog metadata, not in this repo, and three of those have
a <universe> element that is present and empty -- so a record built by linking
would track a moving target.  A field literally labelled "Universe" exists for
only 47 of 111 waves.  The single most repeated sentence in the corpus is WB
NADA boilerplate appearing in 15 waves (including Ethiopia and Nigeria, not
just the WAEMU countries), so its presence attests a metadata template rather
than a statistical office's claim.  19 waves subtract a named region by design;
exactly one declares an urban exclusion (Ethiopia ESS1).  Specialization mostly
appears INSIDE waves, not as whole waves.  Section 5.7 lists 17 unresolved
disagreements, including one where the editorial tag contradicts the wave's own
UNIVERSE field (Mali 2018-19), flagged rather than adjudicated.

No proposal is made.  Per #601's own note, a proposal encoding a judgement
about what the library will pool on a user's behalf is a direction decision for
Ethan.

Refs #603, #601.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
`to_parquet` serialized object columns as
`astype(str).astype('string[pyarrow]').replace({'nan': None, 'None': None,
'<NA>': None})` -- it stringified FIRST and then tried to recover the nulls by
matching the resulting characters.  After `astype(str)` a genuine missing and a
legitimate value spelling 'None' are the same characters, so the recovery is
wrong in principle.  `None` was the canonical library label for "no education",
so every never-schooled person was nulled IN THE ACT OF WRITING the cache, and
`_finalize_result`'s `dropna(how='all')` then deleted the row -- the table has
exactly one column, so a nulled value is a deleted person.

Measured cold, isolated LSMS_DATA_DIR, both directions:

  Guatemala   individual_education  20,678 / 20,678 -> 29,527  (+8,849, 30%)
  Tajikistan  individual_education  59,297 / 54,462 -> 59,790  (+493 / +5,328)
  Ethiopia    individual_education  63,139 / 62,939 -> 63,181  (+42 / +242)

Guatemala was cold == warm == wrong, so no A/B comparison could see it and the
coverage matrix graded it `sane`.  All three are now cold == warm, pinned as a
test.

Changes:

* local_tools.to_parquet -- capture the null mask BEFORE the cast, restore it
  after.  The pyarrow guard itself is KEPT: a genuinely mixed object column
  still raises ArrowTypeError under pandas 3.0.2 / pyarrow 23, and narrowing
  the block would change the stored dtype of homogeneous non-str object columns
  (silently changing returned data corpus-wide -- the very failure class this
  issue is about).

* LSMS_CACHE_SCHEMA 4 -> 5.  The loss happened in the act of writing, so every
  existing L2-wave and L2-country parquet already has the nulls baked in, and
  the per-table input hashes are unchanged.  Without the bump nothing rebuilds
  and the fix is invisible on exactly the machines where the corruption is
  present.  Forces one full rebuild; this changes returned data.

* Canonical label `None` -> `No education` (38 rows across 20
  harmonize_education tables, canonical_education_labels.org, 3 wave
  data_info.yml inline mappings, China/_/china.py,
  Uganda/_/_education_helpers.py).  A canonical value colliding with the null
  literal under str()/YAML/CSV/parquet keeps re-arming the trap.  `None` /
  `NONE` / `none` stay as accepted VARIANTS in a new data_info.yml `spellings`
  block, so historical caches and un-updated countries resolve at API time.

* Ethiopia 2013-14 `individual_education` df_edit hook: one row of 12,583
  carries a bare unlabelled float (76.0) that cannot be mapped in the org table
  (mixed-text Original Label => string keys can never match a float source).
  Folded onto `Unknown`, the vocabulary's own "unmappable" sentinel.  Zero
  effect on the row count; on the current base (post-#644) it is what keeps the
  API frame inside its own declared vocabulary.  Surfaced by
  declaring the vocabulary.

* country.py: `dropna(how='all')` now logs how many rows it removed.  This is
  where a value corruption becomes a silent deletion, and 30% of a table
  vanished here without a word.

Blast radius swept: 28 data-bearing countries cold (all cold == warm, all
in-vocabulary) + 107 freshly-built parquets audited against the declared
vocabulary (0 failures).  Nigeria/food_acquired is the one other behaviour
change: its deliberate `u = fillna('None')` sentinel now survives as the
literal instead of becoming a NaN index key -- same row count (1,005,656) and
identical Quantity_kg.  Guyana / South Africa `housing.Toilet == 'None'`
measured identical before and after (those columns arrive as `str`, so the
block never fired).

Tests: tests/test_gh645_to_parquet_null_coercion.py (17).  Negative control
against unmodified origin/development: 14 of 17 fail.  GH #323 guards re-run
green (no `aggregation:` key, no reducer).

Ledger: .coder/ledger/645-to-parquet-null-coercion.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
`pytest.raises(Exception)` would have passed on any error at all, including
one caused by the test's own frame construction -- which is exactly the trap
this file exists to guard against (an earlier draft of this very test built a
mis-indexed frame and silently asserted nothing).  Name the two errors pyarrow
actually raises for a mixed object column, and write into a TemporaryDirectory
instead of `tempfile.mktemp`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
First CI run on this PR failed at collection with BOTH import spellings:

  from tests.conftest import requires_s3  -> ModuleNotFoundError: 'tests.tests'
  from conftest import requires_s3        -> ImportError: cannot import name
                                            'requires_s3' from 'conftest'
                                            (<repo root>/conftest.py)

The repo has conftest.py at BOTH the root and in tests/, and under the CI
rootdir the root one wins the bare name while 'tests' resolves relative to
tests/ itself.  This is a live trap for #648's helper: tests/conftest.py's own
docstring recommends 'from conftest import aws_creds_available', which no
module had yet exercised and which does not work in CI.

Load it by file path instead — import-mode agnostic, and still #648's single
source of truth rather than a fourth private copy of the creds check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
…inert

The note's diagnosis is right and is kept verbatim: wave 2's asset module is a
PER-UNIT roster, `item_seq` is exactly the level the canonical (t,i,j) grain
drops, and `first()` destroys N147,297,485 (25.6%) in each quarter.

Its PRESCRIPTION was wrong, and is now a tracked file telling the next reader to
do something that cannot work.

Edit 1 -- `index_info assets -> (t,i,j,item_seq)` -- is INERT FOR NIGERIA.
Verified by experiment (PR #629, 2026-07-22): `item_seq` declared in 2012-13's
`idxvars` AND `final_index`, wave rebuilt cold, and the frame still returns index
['i','t','j'] with identical rows and Value.  The `dfs:` merge in
`Wave.grab_data` drops the level BEFORE `final_index` applies -- that is GH #323
SITE 4 (PR #627), not Site 1.  Nigeria cannot emit `item_seq` at all today.

Worse than inert: making it canonical would leave edit 2 as dead code AND
silently disable the GH #498 guard for all 25 assets countries, none of which
carry the level either.

Edit 2 -- `_ADDITIVE_MEASURE_COLUMNS['assets'] = ('Value',)` -- is correct and is
implemented in PR #629.  The note now also records the trap found while doing
it, because it generalises to any future registration: adding a table to that
dict ALSO SILENCES ITS GRAIN AUDIT.  Harmless while `food_acquired` was the sole
entry (its collapse reconciles every column it carries); `assets` carries `Age`,
which no reducer reconciles at (t,i,j), so the naive registration converted a
loud 13,447-destroyed-rows-per-round report into silence -- #323 reintroduced by
its own fix.

"Age wants mean" is SUPERSEDED and marked so: the grain contract's P2 requires
every cell to hold an OBSERVED value or NA (a mean is neither), and core's last
aggregation was retired at Site 2.  Both are 2026-07-13 decisions that postdate
the note.

Comment-only; `Country('Nigeria').data_scheme` parses unchanged (24 tables).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-cartesian

# Conflicts:
#	lsms_library/countries/Malawi/2010-11/_/data_info.yml
#	lsms_library/countries/Malawi/2019-20/_/data_info.yml
#	lsms_library/countries/Malawi/_/CONTENTS.org
#	tests/test_gh323_malawi_cluster_key.py
…r instead

PR #648 added `tests/conftest.py` and told test modules to do:

    from conftest import aws_creds_available

That does not work, and I shipped it merged. There is a tracked **repo-root**
`conftest.py`, so the bare name resolves to THAT file, which has no such
function. The other obvious spelling, `from tests.conftest import ...`, fails as
`tests.tests` under pytest's import mode here. Both were tried on CI by the #645
work and both failed — the exact class of breakage #648 exists to prevent, in
#648 itself.

Fix: make the guard reachable WITHOUT an import.

  * register a `requires_s3` marker in `pytest_configure`;
  * add `pytest_collection_modifyitems` to skip marked items when credentials
    are absent.

A test module now writes:

    pytestmark = pytest.mark.requires_s3

and imports nothing. The docstring records both broken spellings so the next
person does not re-derive them.

Verified both directions:

    creds present : 1 passed   (marker does not over-skip)
    creds absent  : 1 skipped  (CI's shape, reason reported)

The automatic `pytest_runtest_makereport` net from #648 is unchanged and still
catches modules that forget the marker entirely; this adds the explicit,
declarative path that was advertised but unusable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olumn present" after the transforms

Two review findings on the site-4 guard.  The code was judged correct; these
are the two places where it disagreed with machinery that had since merged.

1. `_merge_subframes` read `LSMS_GRAIN_STRICT` with a bare `os.environ.get`
   truthiness test.  PR #614 merged `_grain_strict()` into this same module
   (`.lower() in {"1","true","yes"}`), and the private copy was NOT
   bit-identical: measured, `LSMS_GRAIN_STRICT=0` made site 4 RAISE while
   sites 1-2 stayed in warn mode.  One lever, one reader.

2. The dropped-sub-df required-column check judged presence between
   `set_index` and `apply_derived` -- before `derived:`, `drop:` and the
   per-request `df_edit` hook -- while its country-level twin
   `_assert_built_required_columns` deliberately judges post-finalize.  A
   required column supplied by a transformer or a wave hook would have been
   reported ENTIRELY ABSENT.  Latent today; latent is a property of today's
   configs, not of the code.  Moved below the hook.

Also: the error message recommended `optional: true` "if they are genuinely
unavailable for this wave", which misrepresents a COUNTRY-grain escape hatch as
per-wave.  It now says so, and points at the column name as the real fix -- in
all ten cells this guard caught, the data existed (casing, wrong key, or a
sibling file).

Tests: +5, two of them proved discriminating by reverting the change.
- test_grain_strict_is_read_through_the_one_shared_predicate: 4 failures with
  the old env read (0/false/no/off).
- test_required_column_supplied_by_the_wave_hook_is_not_reported_absent:
  RuntimeError with the check in its old position.
- test_hard_error_says_optional_true_is_country_wide pins the message.
28 pass; no skip, no xfail, no microdata -- the module builds a synthetic
country in tmp_path, so none of it can go vacuous in a data-free CI job.

Refs #323, #515, #614.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
…measure the census

A false claim in a tracked file outlives the PR thread.  Each correction below
was re-derived here, not copied from the review.

1. `merge_how: left` prose (CLAUDE.md, the `sample` skill, the source comment)
   said the `outer` orphans "collapse together into one phantom null-keyed
   row".  They do not.  Ethiopia 2013-14, two isolated cold processes differing
   only in that key: `outer` 5,287 wave rows / 25 null-`v`; `left` 5,262 / 0;
   the delivered table is 433 clusters with sum(Latitude) 4070.3702 EITHER WAY,
   because the cluster-grain collapse DELETES null keys.  `merge_how: left` is
   not a data fix -- it stops manufacturing rows for site 1 to delete, and
   stops the merge widening `District` from int8 to float64.  Its cost is the
   lost signal `outer` gave you.
   (The "phantom 434th EA" story is not false, it is about the PRE-#628 config
   where `df_geo` was keyed on `v`.  Ethiopia/_/CONTENTS.org and the 2013-14
   `merge_how` comment still tell that story beside the new config; flagged for
   #628's owner, not edited here.)

2. Ledger §6 opened "67 `dfs:` merges exercised across 19 countries (the other
   21 declare none)".  The declaration count is statically checkable and is
   **76 blocks / 20 countries** at the branch base (80 / 20 after merging
   development).  67 was the number EXECUTED.  Two quantities reported as one.

3. Ledger §6's Niger row said the geo file "has no lat/lon column at all" and
   prescribed `optional: true`.  Half true and wholly the wrong conclusion:
   `NER_HouseholdGeovars_Y1.dta` has none, but the sibling `NER_EA_Offsets.dta`
   in the same DVC-tracked directory is 271 x [grappe, LAT_DD_MOD, LON_DD_MOD],
   270 unique grappes, lat 11.876-18.747 N -- re-verified from source here.
   This is exactly the unevidenced "no module here" claim CLAUDE.md warns
   against.

4. Ledger §7's "0 of the 67 sub-frames is drawn from a `_pp_`/`_ph_` round
   file" overstated a FILENAME test.  Nigeria's `df_main` is
   `Post Planting Wave 1/.../secta_plantingw1.dta`.  The substantive conclusion
   (no cross-round cartesian at this site) rests on `t` being constant within
   every `dfs:` merge, and stands.

Also corrected: §2/§5 (LSMS_GRAIN_STRICT IS on development now -- reuse
`_grain_strict()`), §4 (the required-column check's blast radius was three
countries and ten cells, not one), §9 (the three raises are fixed; the reason
the suite was blind is `test_table_structure`'s read-only contract, NOT the
v0.8.0 hash missing a core change -- see §11.5), §10 (sequencing discharged:
#625, #628, #644 all merged; all three countries re-verified cold).

New §11 answers each review finding, including one DISPUTED with measurement
(§11.5: `country.py` build-path code IS in the v0.8.0 hash -- editing only
`_merge_subframes`'s body moves `build_transforms_fingerprint`, and Nigeria's
warm parquet grades `stale`).  New §12 re-runs the whole census on the merged
tree: 80 blocks / 90 merges exercised / **3 cartesian cells left**, whose
phantom counts (183,812 + 171,230 + 59) reproduce §6 to the row.

Refs #323, #515.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
fix(Nigeria): correct the assets KNOWN OPEN DEFECT note — its edit 1 is inert
docs(Tanzania): back-casting is a country fact, not a food_acquired quirk (+ dispatcher rule)
docs(#603): population-statement survey — all 111 waves, verbatim
fix(#323): Guinea-Bissau is Lusophone — French Urbain keys matched nothing, 169 clusters off-schema
fix(#323): add a `condition` index level to Uganda crop_production
fix(#323/#627): Malawi's and Guinea-Bissau's cartesian dfs: merges, and #637's three Malawi .first() sites
fix(#323): assets Value is additive — recovers ₦294,594,970 (25.6% of Nigeria W2, × 2 rounds)
fix(#645): to_parquet stops deleting real data via string-matched nulls
fix(#648): the documented conftest import does not work — use a marker instead
…xisting on development

`pytest tests/` on the merged branch: 2 failed, 3935 passed, 142 skipped.
Both failures are the CotedIvoire/cluster_features pair, and both reproduce on
a clean origin/development worktree with none of this PR's code — verified by
running exactly those two node ids there, not by assertion.  Zero new failures.

Recorded with the §9 caveat attached: a green suite is necessary and not
sufficient here, because `test_table_structure` reads cached parquets and never
asks for a build.  The evidence for this PR is the cold 90-merge census (§12).

Refs #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
…in 38 country CONTENTS.org

For each country, a new "Sampling universe, exclusions and sub-samples"
section records *what the survey documentation claims the sample
represents* -- verbatim, with a file/page or catalog-field citation for
every quote, and with gaps recorded as gaps (naming what was searched)
rather than filled by inference.

This is EVIDENCE ONLY.  No schema, YAML key, or code change is proposed
here: GH #603 owns the representation decision.  Nothing in these
sections is inferred from the .dta files -- what a survey claims and what
its microdata contain are kept strictly apart.

Placement follows each file's existing structure: sections sit beside the
country's existing "Sampling Design" / "Survey Program" material where
that exists, and above the issue list otherwise.  Malawi and Uganda are
filed one level down as the last subsection of their existing "Sampling
Design" section.  Four countries had no _/CONTENTS.org and get one
(Afghanistan, Armenia, Peru, Serbia and Montenegro).

Where the new evidence contradicts text already in a file, both sides are
quoted and the disagreement is flagged in place -- nothing existing was
edited or deleted (Azerbaijan weights, CotedIvoire CILSS panel shape,
Ethiopia ESS1 coverage, GhanaLSS GLSS1/2 rotating panel, Guinea-Bissau
vague/grappe, Mali EHCVM-vs-EAC-I, Nepal panel, Nigeria wt_wave5, Uganda
refresh cadence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
fix(#323) Site 4: the dfs: merge MANUFACTURES the duplicates — 4.9M phantom rows (UNBLOCKED: Ethiopia/Niger/Nigeria configs merged)
docs(#601): record the sampling universe, exclusions and sub-samples in 38 country CONTENTS.org
Lands on development so the development -> master release merge (#663) carries
it, matching the docs/releases/v0.8.0.md precedent.

Leads with the cache rebuild, because both invalidating changes ship in this
release deliberately together (LSMS_CACHE_SCHEMA 4->5 and grab_data's
build_transform fingerprint) so users rebuild once rather than twice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ligon
ligon merged commit d0d6221 into master Jul 22, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment