Skip to content

fix(survey): unit-level survey_metadata recompute reports raw weight scale - #801

Merged
igerber merged 4 commits into
mainfrom
fix/survey-metadata-raw-weights
Aug 29, 2026
Merged

fix(survey): unit-level survey_metadata recompute reports raw weight scale#801
igerber merged 4 commits into
mainfrom
fix/survey-metadata-raw-weights

Conversation

@igerber

@igerber igerber commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fix the unit-level survey_metadata recompute in four families — CallawaySantAnna (panel + repeated-cross-section lanes), the staggered DDD engine (TripleDifference/StaggeredTripleDifference), ContinuousDiD (analytical branch), and EfficientDiD — which passed resolve()'s mean-1 normalized weights as compute_survey_metadata's raw_weights, misreporting sum_weights and weight_range on the normalized scale instead of the user's original weight scale (TODO.md row, origin: DMLDiD survey PR feat(dml): DMLDiD survey-design and cluster support on both lanes (DML survey PR) #796 review; the EfficientDiD site was found by the repo-wide sibling audit — all other 17 call sites verified raw).
  • Capture the raw weight column per family following the DMLDiD feat(dml): DMLDiD survey-design and cluster support on both lanes (DML survey PR) #796 pattern: CS and the DDD engine read data via the shared _extract_unit_survey_weights helper (their working frame has first_treat/_never_treated overwritten); ContinuousDiD snapshots the column from pristine data before any working-frame mutation (so a weight column aliasing the mutable dose column still surfaces the original values) and reindexes to the dose-filtered unit order; EfficientDiD threads a fit-local raw array into _recompute_unit_survey_metadata as a parameter (no new instance state).
  • Metadata provenance only: all estimates, SEs, p-values, CIs, df_survey, n_strata, n_psu are byte-identical pre/post fix (verified on a captured baseline of all affected fits); effective_n/design_effect are scale-invariant ratios, unchanged within floating-point round-off.
  • Record the pre-existing ContinuousDiD analytic-vs-bootstrap metadata granularity divergence as a new TODO.md row (with a characterization pin), and reword the CS aggregate('total') survey-gate REGISTRY parenthetical whose "retains no raw record" claim the fix made stale (no methodology change).

Methodology references (required if estimator / math changes)

  • Method name(s): N/A — no methodology changes (survey-metadata provenance only; estimation and inference weights are untouched)
  • Paper / source link(s): N/A
  • Any intentional deviations from the source (and why): None

Validation

  • Tests added/updated: new tests/test_survey_metadata_raw_scale.py (15 pins): raw-scale sum_weights/weight_range/effective_n/design_effect per family on deliberately non-unit-scale weights; CS RC lane (panel=False) and injected-cluster (cluster= + PSU-less design) routes; bare-cluster= unreachability pins for the new asserts (CS both lanes + DDD engine); weights-None ones-fallback with an effective_n length guard (all four families); ContinuousDiD dose-filter alignment (fixture with zero-dose treated units so the filtered-frame re-resolve actually fires), weight-column-aliasing-dose regression, and a bootstrap-branch obs-level characterization pin; EfficientDiD shuffled-rows + control_group="last_cohort" alignment hardening.
  • Backtest / simulation / notebook evidence (if applicable): pre/post baseline capture over all affected fits — sum_weights/weight_range move to raw scale, everything else byte-identical (effective_n/design_effect within 1e-12).

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

…scale

CS (panel+RC), the staggered DDD engine, ContinuousDiD (analytic branch),
and EfficientDiD passed resolve()'s mean-1 normalized weights as
compute_survey_metadata's raw_weights, misreporting sum_weights and
weight_range (scale-invariant fields and all estimates/inference
unaffected). Capture the raw weight column per family (the DMLDiD #796
pattern), snapshotting before working-frame mutations so a weight column
aliasing a mutable role column (e.g. weights == dose in ContinuousDiD)
still surfaces the user's original values.

Metadata provenance only: estimates, SEs, p-values, CIs, df_survey,
n_strata, n_psu byte-identical; effective_n/design_effect unchanged
within float round-off.
@github-actions

Copy link
Copy Markdown

Overall assessment

⚠️ Needs changes — one unmitigated P1 finding.

Executive summary

  • Affected methods: survey metadata for Callaway–Sant’Anna, staggered DDD, ContinuousDiD, and EfficientDiD.
  • The raw-scale correction is sound for the normal paths.
  • ContinuousDiD mishandles the combined dose-filter plus weights="dose" case.
  • The analytical/bootstrap granularity divergence is properly tracked in TODO.md.

Methodology

P1 — ContinuousDiD metadata can describe different weights from inference

Location: diff_diff/continuous_did.py:L509-L517, L662-L678, L783-L788, L1110-L1132

Impact: When zero-dose treated units trigger filtering and survey_design.weights == dose, the survey is re-resolved after never-treated doses are zeroed. Inference therefore uses the mutated weights, while the new metadata recomputation uses pristine positive weights. These vectors are not proportional, so effective_n and design_effect change and no longer describe the inference design. This contradicts the stated scale-invariance and the compute_survey_metadata contract. The Registry documents dose zeroing, but not this mismatch.

Concrete fix: Re-resolve the filtered survey design from immutable copies of its original design columns, aligned to retained rows. Alternatively, reject mutable-role aliases when re-resolution is required.

Code Quality

No additional findings.

Performance

No findings.

Maintainability

No additional findings.

Tech Debt

P3 — Analytical/bootstrap metadata granularity divergence

Impact: ContinuousDiD still reports unit-level analytical metadata versus observation-level bootstrap metadata.

Concrete fix: None required for approval; this is explicitly tracked in TODO.md:L40.

Security

No findings.

Documentation/Tests

P1 — Combined interaction is untested

Location: tests/test_survey_metadata_raw_scale.py:L339-L380

Impact: Filtering and weights="dose" aliasing are tested separately, allowing the methodology mismatch above to pass.

Concrete fix: Add an analytical test containing both zero-dose treated units and nonzero-dose controls. Compare SurveyDesign(weights="dose") against an immutable duplicate weight column, asserting equal estimates, SEs, and all metadata fields.

Path to Approval

  1. Preserve original survey-design columns through ContinuousDiD’s filtered re-resolution and add the combined alias/filter parity test described above.

…data rows

The dose-filter re-resolve read the mutated working frame, so a design
column aliasing a mutated role column (weights == dose) zero-weighted
every never-treated unit on filtered fits ('No valid (g,t) cells') and
diverged from the unfiltered path's pristine-data resolution. Resolve on
the pristine kept-unit rows instead (row-for-row identical to df;
byte-identical on non-aliased designs) and pin the combined
filter + aliasing case against an immutable duplicate column (CI review
finding).
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: db1e9cd17a6870b554a56e1d451583816949ef5a


Overall assessment

⚠️ Needs changes — one newly identified P1 remains.

Executive summary

  • The prior ContinuousDiD alias/filter finding is resolved and regression-tested.
  • The metadata changes match the compute_survey_metadata raw-weight contract; estimator formulas are otherwise unchanged.
  • Three new branches mishandle a valid empty-string weight-column name, silently reporting unweighted metadata.
  • ContinuousDiD’s analytical/bootstrap metadata granularity difference is properly tracked.
  • Tests could not run because this environment lacks pytest and NumPy; static parsing passed.

Methodology

P1 — [Newly identified] Falsy weight-column names produce incorrect metadata

Location: diff_diff/continuous_did.py:L515-L517, diff_diff/efficient_did.py:L1405-L1413, diff_diff/staggered.py:L2426-L2433

Impact: SurveyDesign.resolve() treats any non-None string—including weights=""—as a column name. These new branches instead test truthiness, substituting ones for an empty-string column. Estimates and inference use the actual weights, while sum_weights, weight_range, effective_n, and design_effect silently describe an unweighted design.

Concrete fix: Replace these checks with survey_design.weights is not None. Add nonuniform SurveyDesign(weights="") metadata tests for ContinuousDiD analytical, EfficientDiD, and CS repeated-cross-section routes.

The previous ContinuousDiD P1 is resolved: filtered survey designs now resolve from pristine retained rows (continuous_did.py:L783-L797), with combined alias/filter parity covered at tests/test_survey_metadata_raw_scale.py:L382-L438.

Code Quality

No additional findings.

Performance

No findings.

Maintainability

No additional findings.

Tech Debt

P3 — Tracked analytical/bootstrap metadata granularity difference

Impact: ContinuousDiD reports unit-level analytical metadata but observation-level bootstrap metadata.

Concrete fix: None required for approval; tracked in TODO.md:L40.

Security

No findings.

Documentation/Tests

P2 — Changelog overstates byte-identical behavior

Location: CHANGELOG.md:L19-L22

Impact: The combined ContinuousDiD alias/filter correction changes a formerly failing inference path into a valid fit, so “metadata-provenance only” is not universally accurate.

Concrete fix: Qualify the byte-identity claim to previously successful, non-aliased designs and mention the alias/filter correction.

Path to Approval

  1. Use explicit is not None checks at the three raw-weight capture sites and add the empty-string column regression tests described above.

… column names)

resolve() treats any non-None string as a column name, so truthiness
checks on survey_design.weights silently substituted all-ones metadata
for a column literally named ''. Fix the three new raw-capture sites
(CI review finding) and the same pre-existing pattern at 13 sibling
sites repo-wide (_resolve_survey_for_fit included); add nonuniform
weights='' pins for CS RC, EfficientDiD, and ContinuousDiD. Qualify the
CHANGELOG byte-identity claim to non-aliased designs and document the
alias/filter correction.
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: b38e8bf7ce397a77574806866029304d574c4218


Overall assessment

⚠️ Needs changes — one newly identified P1 remains.

Executive summary

  • The previous three empty-string metadata failures and ContinuousDiD alias/filter issue are fixed.
  • Raw metadata now follows compute_survey_metadata’s original-scale contract.
  • Two remaining truthiness checks still mishandle valid weights="" designs.
  • ContinuousDiD’s branch-granularity difference is properly tracked in TODO.md.
  • Tests could not run because NumPy/pytest are unavailable; static parsing passed.

Methodology

P1 — [Newly identified] Empty-string weight handling remains incomplete

Locations: diff_diff/sun_abraham.py:L1150-L1157, diff_diff/wooldridge.py:L1402-L1428

Impact: SurveyDesign.resolve() accepts any non-None column name, including "" (diff_diff/survey.py:L206-L221), but these sites still test truthiness.

  • SunAbraham consequently uses raw observation counts for cohort/event-time aggregation instead of survey-weighted mass, silently changing effects, SEs, and inference. This contradicts its aggregation contract and Registry IW weighting requirements (docs/methodology/REGISTRY.md:L1800-L1805).
  • WooldridgeDiD treats zero-weight rows as comparison support and skips zero-weight-group validation, conflicting with its weight-aware W2025 support rules (docs/methodology/REGISTRY.md:L2396-L2401).

Concrete fix: Use explicit is not None checks at both sites. Add:

  1. SunAbraham parity between identical nonuniform weight columns named "" and "weight_copy", covering event-study and overall inference.
  2. WooldridgeDiD parity using zero-weight comparison cells, verifying both aliases trigger identical support filtering or rejection.

The intended CS, staggered DDD, ContinuousDiD, and EfficientDiD metadata changes otherwise preserve the documented estimator and variance formulas.

Code Quality

No additional findings.

Performance

No findings.

Maintainability

No additional findings.

Tech Debt

P3 — Tracked ContinuousDiD metadata granularity difference

Impact: Analytical metadata is unit-level while bootstrap metadata remains observation-level.

Concrete fix: None required for approval; tracked in TODO.md:L40.

Security

No findings.

Documentation/Tests

No separate findings. The prior changelog overstatement is corrected. Runtime tests were unavailable; all 16 changed Python files parsed successfully.

Path to Approval

  1. Replace the two remaining weight-column truthiness checks with is not None.
  2. Add the SunAbraham and WooldridgeDiD empty-string regression tests described above.

…am, WooldridgeDiD)

SunAbraham's cohort-aggregation weight-column resolution and
WooldridgeDiD's pre-exclusion zero-weight-group validation still used
truthiness, so a column literally named '' silently fell back to
unweighted cohort mass (SA: moved att AND se, execution-verified) or
skipped the early zero-weight rejection (Wooldridge). Both now check
'is not None' like the rest of the surface; exhaustive grep finds no
remaining weight-name truthiness (HAD's isinstance(str) guard already
handled ''). Adds SA ''-vs-named bit-parity (overall + event study) and
a Wooldridge zero-weight-comparison-unit parity pin (CI review round 3).
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 1ef24253d12c7e3cab47fe9eeb08341377264fa4


Overall assessment

Looks good — no unmitigated P0/P1 findings.

Executive summary

  • Both prior P1 findings are resolved in SunAbraham and WooldridgeDiD.
  • Raw-scale metadata now matches SurveyMetadata’s documented contract.
  • Estimator formulas and variance/SE paths remain unchanged.
  • ContinuousDiD’s branch-granularity difference is properly tracked.
  • Runtime tests were unavailable because NumPy, pandas, and pytest are not installed; all 16 changed Python files parsed successfully.

Methodology

No findings.

Affected methods—CallawaySantAnna, staggered TripleDifference, ContinuousDiD, EfficientDiD, SunAbraham, and WooldridgeDiD—were checked against their Registry entries and cited sources. The changes correctly preserve original survey-weight scale for metadata while retaining normalized weights for estimation.

The previous issues are fixed at diff_diff/sun_abraham.py:L1150-L1157 and diff_diff/wooldridge.py:L1421-L1430, with regressions at tests/test_survey_metadata_raw_scale.py:L293-L368.

Code Quality

No findings. The repository-wide truthiness pattern was consistently replaced with explicit is not None handling.

Performance

No findings.

Maintainability

No findings. Raw arrays are aligned using each estimator’s existing unit-order mechanism.

Tech Debt

P3 — Tracked ContinuousDiD metadata granularity difference

  • Impact: Analytical fits report unit-level metadata while bootstrap fits retain observation-level metadata.
  • Concrete fix: None required for approval; explicitly tracked in TODO.md:L40 and characterized at tests/test_survey_metadata_raw_scale.py:L474-L482.

Security

No findings.

Documentation/Tests

No findings. The Registry wording accurately distinguishes raw-scale metadata scalars from retaining a per-observation raw-weight vector.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Aug 29, 2026
@igerber
igerber merged commit 61b8aa7 into main Aug 29, 2026
39 of 40 checks passed
@igerber
igerber deleted the fix/survey-metadata-raw-weights branch August 29, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant