Skip to content

fix(#323): Mali pid was keyed on the RESPONDENT, not the person — recovers 32,026 people#616

Merged
ligon merged 2 commits into
developmentfrom
fix/323-mali-pid
Jul 20, 2026
Merged

fix(#323): Mali pid was keyed on the RESPONDENT, not the person — recovers 32,026 people#616
ligon merged 2 commits into
developmentfrom
fix/323-mali-pid

Conversation

@ligon

@ligon ligon commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Two source columns, one character apart. 32,026 people.

Fixes the GH #323 defect in Mali household_roster and individual_education. Config-only — zero changes to lsms_library/*.py.

Part of the #323 consolidation (slurm_logs/DESIGN_grain_collapse_sites_2026-07-13.org). This is the case the whole no-aggregation policy is argued from — and it turns out the fix is a typo, exactly as the policy predicts: duplicates on a declared index mean the identifier is broken, so fix the identifier. Never declare a reducer.

The bug

Mali/2014-15/_/data_info.yml wired pid's third component to s01q. Straight from EACIIND_p1.dta's own variable labels:

  • s01q00"Numero d'ordre" — the person's own roster line. ✅
  • s01q"Code du répondant" — the roster line of whoever answered for the household. ❌

s01q is a household-level fact stamped onto every member row. It is constant within 3,335 of 3,804 households; 28,054 of 37,175 rows carry s01q == 1 (the head answered); and every household's s01q value is itself one of that household's own s01q00 lines — it is a reference into the person numbering, not a person id.

So the declared index (t, i, pid) was really keyed by who answered. groupby().first() kept one person per household and the rest vanished silently.

The fix

-        pid: [grappe, menage, s01q]
+        pid: [grappe, menage, s01q00]

In two tables — household_roster and individual_education, which share the source file and had the identical broken pid block.

Before → after (cold builds, LSMS_NO_CACHE=1)

before after source truth
household_roster 2014-15 rows 5,149 37,175 37,175 rows in the .dta
members/HH (mean) 1.354 9.773 9.7726
individual_education 2014-15 rows 2,093 4,097 4,097 rows w/ non-null s02q23
roster, all waves 188,706 220,732 +32,026 people

On the uniqueness proof — the caveat matters. The returned frame tests is_unique == True both before and after. That is the disease, not the cure: the collapse is what makes it unique. The load-bearing check is uniqueness of the declared key in the source:

  • (grappe, menage, s01q) = 5,149 groups over 37,175 rows — broken
  • (grappe, menage, s01q00) = 37,175 / 37,175 — exact

Spot-checks against the raw .dta: HH (1,3) — 9 members, ages [0,1,2,3,5,7,20,28,43], 6M/3F, pid 1003001..1003009. HH (1,6) — all 15 members, identical age vector. household_characteristics picks it up for free (log HSize = ln(9), ln(15)).

Scope swept

Every Mali (wave × table) declaring pid was checked. 2014-15 is the only affected wave. 2017-18 (codeid) and 2018-19 / 2021-22 (numind) were already unique in source — row counts byte-identical before and after.

Worth a maintainer's eye

  • The correct formula was already in the tree. Mali/2014-15/_/people_last7days.py:48 already builds pid as (grappe, menage, s01q00) and asserts (t,i,pid) uniqueness. data_info.yml had simply drifted out of that keyspace. The repo already contained the evidence that the wiring was wrong.
  • 469 of 3,804 households have an s01q that varies across member rows — different respondents answered for different members. Harmless now (pid no longer uses it and it reaches no table), but if respondent identity is ever wanted, it belongs in myvars, never as an index level.
  • interview_date is deliberately untouched — it needs the {const: value} idxvars primitive and ships separately (per D3).

Tests: 231 passed. Ledger: .coder/ledger/323-mali-pid.md.

Refs #323.

🤖 Generated with Claude Code

ligon and others added 2 commits July 13, 2026 07:01
…ored)

Mali declares household_roster and individual_education on (t, i, pid), but
2014-15 wired `pid` to `s01q`.  Per EACIIND_p1.dta's own variable labels:

    s01q00 = "Numero d'ordre"     -- the person's roster line
    s01q   = "Code du répondant"  -- the line of whoever ANSWERED for the HH

`s01q` is a HOUSEHOLD-level fact stamped onto every member row.  It is constant
within 3,335 of the 3,804 households, 28,054 of 37,175 rows carry s01q == 1 (the
head answered), and every household's s01q value is itself one of that
household's own s01q00 lines -- it is a reference INTO the person numbering, not
a person id.

So the declared index was not unique in the source:

    uniq (grappe, menage, s01q)   =  5,149
    uniq (grappe, menage, s01q00) = 37,175   (= one row per person, exactly)

_normalize_dataframe_index then reduced the declared index with
groupby().first(), keeping ONE PERSON PER HOUSEHOLD and deleting 32,026 people
with no signal.  household_roster reported a mean household size of 1.354.

Fixing the identifier -- rather than declaring a reducer for it, which would only
sign the corpse (no reducer is correct: `first` keeps one person, `sum` is
meaningless on Sex) -- restores the table:

    household_roster     2014-15:  5,149 -> 37,175 rows   (1.354 -> 9.773 mem/HH)
    individual_education 2014-15:  2,093 ->  4,097 rows   (= the 4,097 source
                                                            rows with non-null
                                                            s02q23)

and makes (t, i, pid) unique IN THE SOURCE, 37,175/37,175.  Note the returned
frame tested as index-unique both before and after -- that is the disease, not
the cure: the collapse is what made it unique.  All measurements are cold builds
(LSMS_NO_CACHE=1); the L2 parquet is written post-collapse and cannot show the
loss.

This also re-converges household_roster.pid with _/people_last7days.py, which
already keyed on (grappe, menage, s01q00) and asserted uniqueness -- the correct
formula was in the tree the whole time.

Verified against the raw .dta: HH (grappe=1, menage=3) has 9 members, ages
[0,1,2,3,5,7,20,28,43], 6M/3F -- the API now returns exactly that, with pid
1003001..1003009; HH (1, 6) likewise returns all 15 members.  Derived
household_characteristics picks the fix up for free (log HSize for those two
households = ln(9) and ln(15)).

Blast radius checked across every Mali (wave x table) declaring pid: 2017-18
(`codeid`) and 2018-19 / 2021-22 (`numind`) were already unique in source and are
unchanged.  2014-15 was the only affected wave.

Config-only; no lsms_library/*.py touched (core is owned by #614 et al).
Scope: the pid identifier only.  The interview_date/`visit` half of the original
fix/323-mali branch needs a new {const: value} core primitive and ships
separately.

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

Records that (grappe, menage, s01q00) was not invented for this fix: the wave's
own people_last7days.py already built pid that way and asserted (t,i,pid)
uniqueness.  data_info.yml had simply drifted out of that keyspace.  Marks the
quantity as REUSE, and records the §4 invariant this task turns on -- a declared
index must be unique in the SOURCE, because the L2 parquet is written
post-collapse and a warm read cannot see the loss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ligon
ligon merged commit db45c8c into development Jul 20, 2026
10 checks passed
@ligon
ligon deleted the fix/323-mali-pid branch July 20, 2026 04:11
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