Skip to content

fix(#323): Mali 2021-22 cluster_features — delete the 4.3M-phantom-row cartesian (88% of #627's census)#641

Merged
ligon merged 1 commit into
developmentfrom
fix/323-mali-cartesian
Jul 22, 2026
Merged

fix(#323): Mali 2021-22 cluster_features — delete the 4.3M-phantom-row cartesian (88% of #627's census)#641
ligon merged 1 commit into
developmentfrom
fix/323-mali-cartesian

Conversation

@ligon

@ligon ligon commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Clears 88% of PR #627's phantom-row census — 4,324,668 of the 4,907,774 phantom rows in the whole 40-country corpus, from one cell that had no owner.

CONFIG-ONLY. Nothing under lsms_library/*.py. No aggregation: key. The cartesian is cured at the merge (#323 decision D1); nothing is reduced afterwards.

Mali 2021-22 cluster_features:  393,480 x 6,143 -> 4,718,148 rows, 4,324,668 phantom
                        after:  single-file extraction ->  6,143 rows,         0 phantom

Root cause — established from the data, not assumed

Mali/2021-22/_/data_info.yml declared cluster_features as a two-sub-frame dfs: merge on the cluster key v:

sub-frame file rows grain rows per grappe
df_main ehcvm_conso_mli2021.dta 393,480 household × food item 767
df_geo s00_me_mli2021.dta 6,143 household cover page 12

513 grappes, and both sides repeat every key value — so the join is many-to-many by construction: every consumption line-item pairs with every household of its grappe. _normalize_dataframe_index then collapsed 4.7M rows to 513 with groupby().first(), and the L2-country parquet is written post-collapse. The table looked clean at every point anyone ever looked; the phantoms lived only in the wave-level frame. (CLAUDE.md, "Grain Collapse": the bug hid behind the cache the bug poisoned.)

The fix: delete the merge

The brief's hypothesis was that Region/Rural are on the cover page too. They are — but not under the names it guessed. s00_me_mli2021.dta has no literal region / milieu column; it carries the EHCVM cover-page geography codes. Checked against the data:

canonical cover page consumption file agreement
Region s00q01 region 513/513 grappes, same 9 labels
Rural s00q04 milieu 513/513 grappes, same 2 labels
Latitude/Longitude GPS__Latitude/GPS__Longitude (absent)

Both are constant within grappe in both files, and the two files cover the identical 6,143 (grappe, menage) households. Prior art in this same repo: sample.df_cover in this very file already reads Rural from s00q04, and 2017-18's cluster_features already reads Region: s0q01 / Rural: s0q04 from its cover page. So the merge was never needed — the block is now a single-file extraction, per CLAUDE.md's "collapse to a single-file extraction" / "grandfathered but should be collapsed when touched".

Also verified rather than assumed: the 2021-22 GPS fix is one distinct coordinate pair per grappe, 513/513 — the survey's displaced cluster fix stamped on each household (GH #161), so the (t, v) collapse cannot pick one household's coordinates over another's.

Before → after (cold, isolated LSMS_DATA_DIR, #627's detector on PYTHONPATH)

wave wave-level rows before after phantom before after
2014-15 3,804 3,804 0 0
2017-18 8,390 8,390 0 0
2018-19 366,639 366,639 0 0
2021-22 4,718,148 6,143 4,324,668 0

Final country-level cluster_features: 3,006 rows, of which 513 for 2021-22 — exactly Mali's grappe count (513 distinct grappe in both source files).

This changes no value. At all.

The full country-level frame — 3,006 rows × 4 columns, all four waves — is bit-for-bit identical before and after: DataFrame.equals() is True, index equal, dtypes equal. The 2021-22 slice alone is likewise identical over all 513 grappes. This PR removes 4.3M invented rows and alters nothing a user has ever been served.

Other Mali waves — checked, and they are fine

Their df_geo is one row per grappe, so those merges are m:1, not m:n:

wave df_geo rows unique grappe
2014-15 eaci_geovariables_2014.dta 989 989
2017-18 eaci_geovariables_2017.dta 953 953
2018-19 grappe_gps_mli2018.dta 549 549

Consistent with #627's census, which flagged only 2021-22. Deferred (waste, not a defect): 2018-19 still reads a 366,639-row consumption file to produce 551 cluster rows. It manufactures nothing, and unlike 2021-22 it cannot be collapsed to one file — its GPS genuinely lives in a separate grappe_gps_mli2018.dta. Slimming df_main to the cover page would be pure performance; left alone so this PR alters no value anywhere.

Tests, with the negative control actually run

tests/test_gh323_mali_cartesian.py — 11 tests. They assert at the wave level on purpose, and the docstring says why: the country-level frame is 513 rows with or without the bug, and its values are identical, so a country-level assertion passes with the bug fully present.

Negative control (pre-fix YAML restored, fresh isolated data root):

2 failed, 9 passed
  test_wave_cluster_features_is_not_a_cartesian[2021-22]
    AssertionError: Mali/2021-22 cluster_features returned 4718148 wave-level
    rows, expected 6143.
  test_2021_22_cluster_features_is_a_single_file_extraction

…and the four country-level tests passed right through it — which is the point. After the fix: 11 passed.

The equivalence that licenses the whole change (s00q01 == region, s00q04 == milieu, per grappe) is itself a test, not a YAML comment — an unevidenced "these are the same column" claim is exactly the kind that rots silently.

Targeted regression set (test_grain_collapse, test_grain_collapse_cluster_features, test_declared_spellings, test_coverage_matrix, test_schema_consistency, + the new file): 313 passed.

Full suite: 3,762 passed, 167 skipped, 9 xfailed, 1 xpassed — zero failures, zero errors (41m43s).

Heeding #627's warning that the suite's green is a lie: test_table_structure reads var/*.parquet directly and never rebuilds, so it cannot see this. That is why every number above comes from a cold build in an isolated LSMS_DATA_DIR (only dvc-cache symlinked in), measured with origin/fix/323-site4-dfs-merge's _cartesian_keys detector on PYTHONPATH — under development's core the merge silently succeeds and you see nothing.

Not reused

origin/fix/323-mali (a8fe61cc) patches country.py + local_tools.py — rejected D1 core changes, and about pid/EACI visits, a different defect. Ignored, as directed.

Known cosmetic inconsistency, deliberately unchanged

cluster_features.Rural ships the raw French Urbain while sample.Rural maps it to Urban. Pre-existing and visible in 2014-15 too; fixing it would change returned values, which this PR does not do. Adjacent to GH #602.

Ledger: .coder/ledger/323-mali-cartesian.md. Diagnosis: Mali/_/CONTENTS.org. Refs #323, #627.

🤖 Generated with Claude Code

@ligon
ligon force-pushed the fix/323-mali-cartesian branch 3 times, most recently from 8e4cf98 to b796b9c Compare July 21, 2026 22:07
ligon pushed a commit that referenced this pull request Jul 21, 2026
Four PRs in a single day went red for the same non-reason (#625, #632, #641,
#644): a newly added test module builds real country data, the CI `unit-tests`
job runs deliberately data-free (`LSMS_SKIP_AUTH=1`, no S3 secrets -- only
`data-tests` carries them), and the module lacked the skip guard its neighbours
have.  Each cost a diagnosis cycle to establish the failure had nothing to do
with the change under review.  #644's is the worst of them: it CATCHES
`NoCredentialsError` and re-raises it as a message about the geo file spelling
its coordinate columns differently, which is a confident and completely wrong
diagnosis.

There was no `tests/conftest.py` at all, which is why the same
`_aws_creds_available()` helper had been copy-pasted into three modules already,
each noting that centralising it was wanted "but not in a CI-red hotfix".

This adds it, plus `requires_s3`, plus a `pytest_runtest_makereport` wrapper
that converts a missing-credentials failure into a skip -- covering the `setup`
phase too, since the usual shape is a module-scoped fixture building a country,
where one error takes out the whole module.

## It cannot hide a real failure

The conversion is conditioned on the ENVIRONMENT, not on the exception:

  * credentials absent  -> convert to skip;
  * credentials present -> report the failure, unchanged.

A test silently skipped everywhere is worse than a test red somewhere, so this
was verified in both directions rather than assumed:

  creds absent   NoCredentials -> SKIPPED,  `assert 1 == 2` -> still FAILED
  creds present  NoCredentials -> FAILED,   `assert 1 == 2` -> still FAILED

## The subtlety that made verifying this hard, now documented in the file

`lsms_library/countries/.dvc/s3_creds` is NOT tracked in git -- it is written at
IMPORT TIME by the auto-unlock.  So a fresh `git worktree` looks credential-free
until the first import silently creates it, and the guard then reads True.  Only
`LSMS_SKIP_AUTH=1` reproduces CI's actual state.  My first three attempts to
verify this hook "failed" for exactly that reason.

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

The `dfs:` block joined `ehcvm_conso_mli2021.dta` (393,480 rows, household x
food item) to `s00_me_mli2021.dta` (6,143 rows, household cover page) on the
CLUSTER key `v`.  Both sides repeat `v` across 513 grappes, so the merge was
many-to-many by construction: 393,480 x 6,143 -> 4,718,148 rows, of which
4,324,668 were phantoms that exist in no survey.  That is 88% of the 4,907,774
phantom rows PR #627's 40-country census found, from one unowned cell.
`_normalize_dataframe_index` collapsed the wreck to 513 rows with
groupby().first() and the L2-country parquet is written post-collapse, so the
table looked clean at every point anyone ever looked.

The merge was never needed.  s00_me_mli2021.dta carries the geography itself:
s00q01 == `region` and s00q04 == `milieu`, agreeing on 513/513 grappes, each
constant within its grappe, over the identical 6,143 (grappe, menage)
households.  So this is now a single-file extraction -- the cartesian is cured
AT THE MERGE (#323 decision D1), not laundered by a reducer afterwards.  Prior
art: `sample.df_cover` in this same file already reads Rural from s00q04, and
2017-18's cluster_features already reads Region/Rural off its cover page.

Value-preserving: the full country-level frame (3,006 rows x 4 columns, all
four waves) is bit-for-bit identical before and after -- DataFrame.equals()
True, index equal, dtypes equal.  4,718,148 -> 6,143 wave rows; 513 country
rows for 2021-22, exactly Mali's grappe count.

Mali's other three waves are not cartesian: their df_geo is one row per grappe
(989 / 953 / 549), so those merges are m:1.  2018-19 still reads a 366,639-row
consumption file to produce 551 cluster rows -- wasteful, not wrong, and it
cannot be collapsed to one file because its GPS lives in a separate
grappe_gps_mli2018.dta.  Deferred; documented.

Tests assert at the WAVE level on purpose: the country-level frame is 513 rows
with or without the bug, so a country-level assertion passes with the bug fully
present.  Negative control run with the pre-fix YAML restored in a fresh
isolated data root: 2 failed, 9 passed (the four country-level tests passed
right through the bug).  After: 11 passed.  Targeted regression set: 313
passed.  All measurements cold, in an isolated LSMS_DATA_DIR, with #627's
_cartesian_keys detector on PYTHONPATH.

Config-only: no lsms_library/*.py, no `aggregation:` key.

Ledger: .coder/ledger/323-mali-cartesian.md
Diagnosis: lsms_library/countries/Mali/_/CONTENTS.org

Refs #323, #627.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ligon
ligon force-pushed the fix/323-mali-cartesian branch from b796b9c to 2014b7a Compare July 22, 2026 02:52
@ligon

ligon commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — APPROVE_WITH_NOTES (one MEDIUM: a test claims a guard it does not have)

I tried to break this from the data up, cold, and the substantive claims hold. Everything below was re-derived independently, not read off the PR body.

Setup

Two isolated LSMS_DATA_DIRs (nothing but dvc-cache symlinked to ~/.local/share/lsms_library/dvc-cache), library code from the shared .venv, config trees swapped with LSMS_COUNTRIES_ROOTorigin/development worktree for "before", the PR worktree for "after". No dvc CLI was invoked; all reads went through get_dataframe().

My first "before" run was wrong (I pointed at the master working tree, which differs from development on Mali 2017-18 Rural) and produced a spurious 164-row diff. Re-run against a true origin/development worktree, it vanished. Flagging that so nobody repeats it.

Headline numbers — reproduced

claim measured, cold
2021-22 wave-level rows before 4,718,148
after 6,143
phantoms 4,718,148 − 393,480 = 4,324,668
country-level rows 3,006 (989 / 953 / 551 / 513) ✅
values unchanged pre.equals(post)True; index equal, dtypes equal, 0 diffs in all four columns ✅

The 393,480 × 6,143 -> 4,718,148 notation is loose: read as a literal product it is 2,417,147,640. The true identity is Σ_g n_conso(g)·n_hh(g) = 4,718,148 (mean 767.0 line-items × 11.97 households over 513 grappes) — which I computed and it lands exactly. Cosmetic, but it is in the YAML comment and in CONTENTS.org permanently. See LOW-1.

The source-equivalence, checked myself

  • s00q01 and region: 9 labels each, identical sets, 0 NA in both.
  • s00q04 and milieu: {Rural, Urbain} in both, 0 NA.
  • Household sets: (grappe, menage) symmetric difference between s00_me_mli2021.dta and ehcvm_conso_mli2021.dta is 0 over 6,143 households; 513 grappes each.
  • GPS: 0 NA, one distinct (lat, lon) per grappe, 513/513.

So no "invariance by missingness" (nothing is null), no entity-splitting (no new index level was introduced at all), and no catch-all bucket. Not applicable failure modes, checked rather than assumed.

Other waves — the m:1 claim is exactly right

eaci_geovariables_2014 989 rows / 989 grappes; eaci_geovariables_2017 953/953; grappe_gps_mli2018 549/549 against 551 grappes in ehcvm_conso_mli2018 (2 clusters simply have no GPS). Merged wave-row counts equal the df_main counts to the row, so none of those three manufactures anything. Confirmed.

Negative control — verified, not taken on trust

Ran the PR's test file against the pre-fix config in a fresh data root: 2 failed, 9 passed, the wave test reporting assert 4718148 == 6143 and the structural test catching the dfs: block — exactly as the body says, and the four country-level tests do sail straight through the bug. Against the fix: 11 passed. Targeted batch (test_grain_collapse, test_grain_collapse_cluster_features, test_gh323_grain_contract, test_declared_spellings, test_coverage_matrix, test_schema_consistency, + this file): 328 passed.

Warm-cache upgrade — checked, works

The pre-fix L2-wave parquet really did persist all 4,718,148 rows to disk (pq.metadata.num_rows on Mali/2021-22/_/cluster_features.parquet, lsms_cache_hash=5ee91a0a…). I copied that poisoned cache and ran the fixed config against it: the hash flips to 9089405c…, the wave rebuilds to 6,143, and the country frame is byte-identical to the cold post-fix build. Existing users get the fix without a manual cache clear.

Scope / policy

Config + docs + tests only; nothing under lsms_library/*.py; no aggregation: key; no reducer. The cartesian is cured at the merge (D1 satisfied). No duplicate of merged work — origin/development's Mali 2021-22 block is still the two-sub-frame merge, and #616 (fix/323-mali-pid) is a different defect. Leaving the extraction at household grain is fine: the residual 12-rows-per-grappe dedup goes through _normalize_dataframe_index_audit_index_collapse, which classifies exact duplicates as lossless and would still warn if a future wave's households disagreed.


MEDIUM — the test docstring claims a guard the test does not implement

tests/test_gh323_mali_cartesian.py, docstring:

"if a future edit repoints Region/Rural at a column that does NOT agree with the consumption file, the test says so."

It does not. test_cover_page_geography_agrees_with_the_consumption_file hard-codes s00q01 / s00q04 and reads them straight out of the .dta; it never looks at what data_info.yml declares. test_2021_22_..._single_file_extraction only asserts 'dfs' not in info and info['file'], and the wave-row test only counts rows.

Mutation-tested. I edited the branch's YAML to Region: s00q02 (cercle — 57 values instead of 9 regions, i.e. cluster_features.Region silently becomes a district) and re-ran the file in a fresh data root:

11 passed

Nothing caught it. The grain audit does not catch it either — cercle is also constant within grappe, so the collapse stays lossless.

Given this repo's own standard ("an unevidenced these are the same column claim is exactly the kind that rots silently"), a claim that the guard exists is worse than not making it. Cheap fix — read the names out of the config instead of hard-coding them:

myvars = mali['2021-22'].resources['cluster_features']['myvars']
region_col, rural_col = myvars['Region'], myvars['Rural']

…then use those in the groupby. That makes the docstring true and turns the mutation above into a failure. (Or, if you prefer to keep it a pure data-invariant test, delete the sentence.)

LOW-1 — 393,480 × 6,143 -> 4,718,148 is not a product

2,417,147,640 is. Suggest 393,480 ⋈ 6,143 on v -> 4,718,148 (= Σ_g 767 × 12) in the YAML comment and CONTENTS.org, so a future reader who does the multiplication does not conclude the note is confused.

LOW-2 — EXPECTED_WAVE_ROWS['2018-19'] = 366639 pins the waste this PR defers

The PR itself proposes slimming 2018-19's df_main to the cover page. When someone does that, this test fails with a message accusing them of reintroducing a many-to-many merge. Consider assert len(df) <= EXPECTED (or a comment on that entry) so the deferred cleanup does not read as a regression.

LOW-3 — stale cost note left behind in country.py (not this PR's file)

lsms_library/country.py:4259 still says "~3 s on the largest cell, Mali/cluster_features/2021-22 at 4.7M rows". That cell is now 6,143 rows. Correctly out of scope here; worth sweeping in whichever #323 PR next touches that function.


What I tried and could not break

Repointed the source column (caught nothing — that is MEDIUM above, not a defect in the data path); looked for entity-splitting and invariance-by-missingness (neither applies: no new key, zero NAs in all four columns); checked whether the dropped final_index: [t, v] changes the returned index (it does not — ['t','v'] both ways); checked conservation on the full 3,006 × 4 frame rather than on totals that could cancel (DataFrame.equals is True, so no cancellation is possible); checked the 2021-22 slice alone (identical over all 513 grappes); checked that the fix propagates through a warm poisoned cache; verified the other three waves' geo files really are one row per grappe; verified the negative control fails and the positive passes.

Not verified: the denominator of the "88%" — the 4,907,774 corpus-wide phantom count comes from unmerged fix/323-site4-dfs-merge (#627). The numerator (4,324,668) I re-derived exactly; the ratio is only as good as #627's census.

@ligon
ligon merged commit a7cdee6 into development Jul 22, 2026
10 checks passed
@ligon
ligon deleted the fix/323-mali-cartesian branch July 22, 2026 03:55
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