Skip to content

fix(household_characteristics): residence filter was silently deleting 8 entire wave-cells#594

Merged
ligon merged 3 commits into
developmentfrom
fix/roster-characteristics-wave-deletion
Jul 12, 2026
Merged

fix(household_characteristics): residence filter was silently deleting 8 entire wave-cells#594
ligon merged 3 commits into
developmentfrom
fix/roster-characteristics-wave-deletion

Conversation

@ligon

@ligon ligon commented Jul 12, 2026

Copy link
Copy Markdown
Owner

The bug: household_characteristics silently deletes entire waves

Country.household_characteristics() is derived at runtime (_ROSTER_DERIVED), and
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. Where the resolved months series is unusable for a wave,
the keep-mask is all-False for that whole wave and the table returns nothing for it —
while household_roster is fully populated. 8 wave-cells were destroyed.

Three distinct defects:

defect fix waves
D1 the if/elif chain picked the first column present, not populated. Ethiopia's country frame carries both MonthsAway (0 non-null in W4/W5) and WeeksAway (28,634 / 20,504 non-null — the questionnaire switched to weeks, exactly as CLAUDE.md documents). monthsaway won the elifms all-NaN → both waves deleted. per-row coalesce across the three sources (priority MonthsSpent > MonthsAway > WeeksAway) Ethiopia 2018-19, 2021-22
D2 no all-NaN guard. CotedIvoire 1985-89 and Mali 2021-22 never asked a residence question — the column exists in the frame only because a later wave supplies it. per-t fallback to the documented "countries without any residence column are unaffected — the old count-everyone behavior continues" (CLAUDE.md), applied at wave granularity, and it now warns instead of deleting silently CotedIvoire ×4, Mali 2021-22
D3 untranslated French labels: Burkina Faso 2014 MonthsSpent (EMC B3A) is '6 mois ou plus' (77,268) / 'moins de 6 mois' (943). errors='coerce' → NaN → wave deleted. config, not code: a mapping: block in the wave's data_info.yml, following the EHCVM Oui/Non → 12/0 pattern CLAUDE.md already prescribes Burkina_Faso 2014

The diagnosis was confirmed empirically before any edit (raw B3A value counts; per-wave
non-null counts of each residence column) — no part of it turned out to be wrong.

The adversarial constraint: the filter must keep working

The residence filter exists because counting everyone produced a 1315-household drift on
Uganda
vs. the replication pipeline. The D2 fallback is that old behaviour, so it must fire
only for a wave with no usable residence datum at all. Two independent proofs:

  1. The guard is per-t, not global. A wave with even one non-null residence value is
    filtered exactly as before.
  2. Census, all 34 countries that expose household_characteristics, warm cache — per-wave
    row counts before vs. after: the 8 dead waves come back (+34,974 households); all 88 other
    wave-cells are byte-identical.
    Uganda's 8 waves: unchanged, and the D2 warning does not
    fire for it.

Waves that moved (the only 8)

country wave before after roster rows available
Burkina_Faso 2014 0 10,729 78,543
CotedIvoire 1985-86 0 1,588 14,398
CotedIvoire 1986-87 0 1,600 13,867
CotedIvoire 1987-88 0 1,600 11,847
CotedIvoire 1988-89 0 1,600 10,560
Ethiopia 2018-19 0 6,768 29,503
Ethiopia 2021-22 0 4,946 25,374
Mali 2021-22 0 6,143 43,472
total 443,613 478,587 +34,974

(Sanity: CotedIvoire 1985-86 → 1,588 HH is the known LSMS sample size; Ethiopia ESS4/ESS5 →
6,768 / 4,946 HH match the published wave sizes.)

Attribution — each defect is fixed by its own fix, nothing is masked

Verified per country after the change:

  • Burkina_Faso: the D2 fallback warning does not fire → 2014 is resurrected by the
    config mapping (D3), not masked by the fallback. MonthsSpent is now 12.0 ×77,268 /
    0.0 ×943 / NaN ×332 — exactly the raw label counts. 1,097 members still dropped (the
    short-stay + not-asked members, minus infants).
  • Ethiopia: no fallback warning → W4/W5 resurrected by the coalesce (D1) via
    WeeksAway, and the filter genuinely fires there (884 / 4,872 members dropped).
  • Mali 2021-22, CotedIvoire 1985-89: fallback fires (and warns), counting everyone — the
    documented no-residence-column behaviour.
  • Uganda: no warning; per-wave drops identical to before.

Full per-wave census (all 96 wave-cells)

expand
country wave before after delta
Albania 2002 3599 3599 0
Albania 2003 1780 1780 0
Albania 2004 1797 1797 0
Albania 2005 3840 3840 0
Albania 2008 3599 3599 0
Albania 2012 6669 6669 0
Armenia n/a n/a no microdata (errors on base too)
Azerbaijan 1995 2016 2016 0
Benin 2018-19 7991 7991 0
Burkina_Faso 2014 0 10729 +10729
Burkina_Faso 2018-19 7004 7004 0
Burkina_Faso 2021-22 3226 3226 0
Cambodia 2019-20 1512 1512 0
China 1995-97 784 784 0
CotedIvoire 1985-86 0 1588 +1588
CotedIvoire 1986-87 0 1600 +1600
CotedIvoire 1987-88 0 1600 +1600
CotedIvoire 1988-89 0 1600 +1600
CotedIvoire 2018-19 12979 12979 0
Ethiopia 2011-12 3629 3629 0
Ethiopia 2013-14 5232 5232 0
Ethiopia 2015-16 4953 4953 0
Ethiopia 2018-19 0 6768 +6768
Ethiopia 2021-22 0 4946 +4946
EthiopiaRHS 1989 338 338 0
EthiopiaRHS 1994a 1477 1477 0
EthiopiaRHS 1994b 1476 1476 0
EthiopiaRHS 1995 1477 1477 0
EthiopiaRHS 1997 1475 1475 0
EthiopiaRHS 1999 1475 1475 0
GhanaLSS 1987-88 3136 3136 0
GhanaLSS 1988-89 3190 3190 0
GhanaLSS 1991-92 4543 4543 0
GhanaLSS 1998-99 5996 5996 0
GhanaLSS 2005-06 8687 8687 0
GhanaLSS 2012-13 16769 16769 0
GhanaLSS 2016-17 13990 13990 0
Guatemala 2000 7276 7276 0
Guinea-Bissau 2018-19 5348 5348 0
Guyana 1992 1495 1495 0
India 1997-98 2252 2252 0
Iraq 2006-07 2146 2146 0
Iraq 2012 25145 25145 0
Kazakhstan 1996 1992 1992 0
Kosovo 2000 2880 2880 0
Liberia 2018-19 2878 2878 0
Malawi 2004-05 11276 11276 0
Malawi 2010-11 12262 12262 0
Malawi 2013-14 3997 3997 0
Malawi 2016-17 14783 14783 0
Malawi 2019-20 14600 14600 0
Mali 2014-15 3779 3779 0
Mali 2017-18 8386 8386 0
Mali 2018-19 6476 6476 0
Mali 2021-22 0 6143 +6143
Nepal n/a n/a no microdata (errors on base too)
Niger 2011-12 3961 3961 0
Niger 2014-15 3558 3558 0
Niger 2018-19 6024 6024 0
Niger 2021-22 6564 6564 0
Nigeria 2010Q3 4995 4995 0
Nigeria 2011Q1 4915 4915 0
Nigeria 2012Q3 4685 4685 0
Nigeria 2013Q1 4768 4768 0
Nigeria 2015Q3 4611 4611 0
Nigeria 2016Q1 4582 4582 0
Nigeria 2018Q3 5051 5051 0
Nigeria 2019Q1 4980 4980 0
Nigeria 2023Q3 4771 4771 0
Nigeria 2024Q1 4716 4716 0
Pakistan 1991 4794 4794 0
Senegal 2018-19 7150 7150 0
Senegal 2021-22 7114 7114 0
Serbia 2007 5557 5557 0
Serbia and Montenegro 2002 6386 6386 0
Serbia and Montenegro 2003 2548 2548 0
South Africa 1993 8799 8799 0
Tajikistan 1999 1977 1977 0
Tajikistan 2003 4159 4159 0
Tajikistan 2007 4644 4644 0
Tajikistan 2009 1503 1503 0
Tanzania 2008-09 3264 3264 0
Tanzania 2010-11 3921 3921 0
Tanzania 2012-13 5000 5000 0
Tanzania 2014-15 4336 4336 0
Tanzania 2019-20 1183 1183 0
Tanzania 2020-21 4700 4700 0
Timor-Leste 2001 1800 1800 0
Timor-Leste 2007-08 4477 4477 0
Togo 2018 6146 6146 0
Uganda 2005-06 3122 3122 0
Uganda 2009-10 2975 2975 0
Uganda 2010-11 2685 2685 0
Uganda 2011-12 2843 2843 0
Uganda 2013-14 3117 3117 0
Uganda 2015-16 3305 3305 0
Uganda 2018-19 3241 3241 0
Uganda 2019-20 3076 3076 0
TOTAL 443613 478587 +34974

Tests

tests/test_roster_residence_filter.py (new, synthetic rosters — no microdata needed):

  • TestD1PerRowCoalesce — the Ethiopia shape (MonthsAway in w1, WeeksAway in w2, each NaN in
    the other): both waves survive, both still filtered; MonthsSpent wins on a row where two
    sources are populated.
  • TestD2AllNaNWaveGuard — an all-NaN wave is not deleted, counts everyone, and the
    populated wave in the same frame is still filtered
    ; the warning names the dead wave only;
    works with no t level too.
  • TestFilterStillFiresWhereItShould — the Uganda guarantee: 0-month member dropped, NaN
    member dropped, infant (age < 1) kept, no fallback warning; string '12' / '12.0'
    months still parse.
  • TestD3BurkinaFaso2014Config — pins the BF-2014 mapping: block so a config regression is
    caught.

6 of these fail on the pre-fix code (verified by stashing the fix).

Verification

  • pytest (full suite, warm, from the worktree): 3567 passed, 154 skipped, 4 xfailed,
    1 xpassed, 1 failed
    . The one failure is test_currency.py::test_feature_ghana_per_wave,
    which fails identically on the base branch (confirmed by stashing this PR's changes) —
    pre-existing, unrelated.
  • bench/feature_audit/scan.py --features household_characteristics household_roster (32
    countries): 0 errors, 0 fails. Diffing the check-level findings against the same scan on
    the pre-fix code, the only difference is the expected row-count baseline
    (454,342 → 478,587); the 3 B-class findings (GH bug: framework silently drops rows on duplicate canonical index (groupby().first()) #323 duplicate collapse, canonical m level
    absent) are unchanged and pre-existing.

Ledger

.coder/ledger/roster-characteristics-wave-deletion.md (own commit). §5 marks the BF-2014
label mapping as reuse of the EHCVM mapping: idiom and the per-t guard as the single
new piece. §6 carries two open questions for the human:

  1. BF-2014 B3A is a 6-month binary, so 12/0 (EHCVM convention) drops the 943 short-stay
    members. If the project would rather keep them, the mapping becomes 12 / 6.
  2. CotedIvoire 1985-89 and Mali 2021-22 now count everyone — correct per the documented
    behaviour, but those waves are not filter-comparable with their EHCVM siblings.

Notes

  • No CLAUDE.md edit (out of scope for an agent), but its "MonthsSpent / MonthsAway /
    WeeksAway" section would benefit from a sentence on the per-row coalesce + per-wave fallback.
  • Incidental finding, not fixed here (pre-existing, unreachable in practice):
    transformations.dummies() raises on a single-row frame — v[s].squeeze() returns a
    scalar and zip(*[scalar]) iterates its characters.
  • Also incidental: CLAUDE.md's scrum-master addendum says PYTHONPATH cannot redirect
    lsms_library to a worktree. It can — from a neutral cwd, PYTHONPATH beats the .pth.
    The real trap is that a script run (e.g. bench/feature_audit/scan.py) puts the
    script's directory on sys.path[0], so without PYTHONPATH it silently imports the main
    checkout. That bit this PR's first audit run; it was re-run correctly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NtARb9se2un1DLFJrrpjHe

ligon and others added 2 commits July 12, 2026 06:28
…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
…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
ligon added a commit that referenced this pull request Jul 12, 2026
…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>
@ligon

ligon commented Jul 12, 2026

Copy link
Copy Markdown
Owner Author

Decision on the BF-2014 B3A open question: keep 12 / 0 (Ethan, 2026-07-12)

Your mapping as filed is correct. No change needed. Recording the reasoning so nobody "fixes" it later.

The question is sharper than it looks

B3A's variable label is a duration question — "Au cours des 12 derniers [mois], durant combien de mois [NOM] a vécu dans le ménage" — but the released variable is collapsed to a 6-month binary:

B3A value labels: {1: '6 mois ou plus', 2: 'moins de 6 mois', 9: 'Valeur manquante'}
distribution:      1 -> 77,268   |   2 -> 943   |   NaN -> 332   (n = 78,543)

So 'moins de 6 mois' does not mean "lived zero months" — those 943 people (1.2% of the roster) did live in the household, for 1–5 months. Mapping them to 0 is, read literally, false, and the residence filter then drops them.

This differs from the EHCVM precedent it borrows from: s01q12 ("lived continuously 6+ months? Oui/Non") is a membership test, where Non0 is a faithful encoding. B3A is a duration question that was binned.

Why 12 / 0 anyway

The 0 is an encoding of "not a de-facto household member", not a claim about months lived. The 6-month rule is the standard LSMS de-facto membership convention, and it is already what the EHCVM waves do. Consistency across the EHCVM family wins over literal fidelity to a binned duration variable.

12 / 6 was rejected on a second ground worth noting: 6 is the one value the respondent explicitly ruled out. If we ever decide to keep these members, the honest fill is 3 (the interval midpoint), not 6.

Ask

Please add a short comment above the mapping in Burkina_Faso/2014/_/data_info.yml recording that (a) B3A is a binned duration, not a membership test, (b) 0 means "not a de-facto member" rather than "zero months", and (c) 12/0 is a deliberate choice for EHCVM consistency — so the next reader doesn't "correct" it. Then this is good to merge from my side.

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
@ligon
ligon merged commit 68b9425 into development Jul 12, 2026
10 checks passed
@ligon
ligon deleted the fix/roster-characteristics-wave-deletion branch July 12, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant