Skip to content

[Mirror] SC exact NNLS donor weights - CI-review mirror of PR #842 - #848

Closed
igerber wants to merge 14 commits into
mainfrom
fix/sc-exact-simplex-weights-mirror
Closed

igerber wants to merge 14 commits into
mainfrom
fix/sc-exact-simplex-weights-mirror

Conversation

@igerber

@igerber igerber commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Same-repo mirror of contributor PR #842 (ilgrad:fix/sc-exact-simplex-weights, fixes #838) so the CI reviewer can run on it. Everything here is pushed back to the contributor's branch once clean; #842 is what gets merged and this PR is closed unmerged.

Summary

  • dd6f4681 (contributor, rebased onto main after fix(synthetic_control): put the treated unit in every in-space placebo's donor pool #841): SyntheticControl solves its donor weights exactly by one non-negative least squares (Lawson-Hanson active set, in-house so the vertex returned at an exact-fit tie does not move with the SciPy release) instead of a budgeted Frank-Wolfe that stopped on inner_max_iter on ordinary factor-model panels, failing the treated fit closed or dropping placebos. The same solve replaces Frank-Wolfe in the CWZ conformal proxy and in rank_control_units' informational synthetic_weight. Results move by the former solver's gap (Tutorial 25 is an exact-fit tie: ATT 6.79 -> 6.69, pre-RMSPE 0.987 -> 0.934); REGISTRY records the tie convention as a Deviation from R Synth.
  • 82122cad: two cv-infeasibility warnings still named inner_max_iter; the fix(synthetic_control): put the treated unit in every in-space placebo's donor pool #841 donor-order comment said Frank-Wolfe (the active set also breaks exact gradient ties by position, so the canonicalization stays); Tutorial 25 re-executed on top of fix(synthetic_control): put the treated unit in every in-space placebo's donor pool #841 (constant-effect set [3.47, 20.45], V exactly uniform; the prose quotes neither).
  • ae129c77: the NNLS conditioning scale is computed from the max-normalized difference block, so finite panels of any magnitude give the same weights (a raw sqrt(mean(Z**2)) overflowed beyond ~1e154 and let the active set return an arbitrary vertex as converged); a non-finite difference fails closed (uniform, converged=False); the ridge for zeta > 0 is applied per passive set instead of a dense J x J identity; the treated-fit non-convergence messages name the inner step guard beside the outer V search and scope the remedy to the latter.
  • fb64bc1c: a ridge-dominated passive set (large lambda_reg or a tiny outcome scale in rank_control_units) is solved by its regularized normal equations; the stacked SVD lost the O(1/ridge^2) coefficients (returned [0.4986, 0.5014] as converged where the optimum is [0.5, 0.5]).
  • cafefb13: each donor column is scaled to unit norm inside the reduction (the sum-constraint row carries the inverse norms), so heterogeneous donor scales are solved exactly (a 1e16 donor beside O(1) donors returned a 0.5 residual as converged under a single global scale); the entering column is still chosen on the raw-unit gradient, so the vertex an exact-fit tie returns is unchanged (Tutorial 25 and its drift bands are untouched).
  • 6c21a798: regression test pinning objective parity for two nearly opposing 1e8 donors beside an O(1) one (the candidate optima differ by ~1e-17, below the rounding of the data; the returned point is no worse than the alternative vertex and its residual is within one ulp).
  • f6770636: rank_control_units' synthetic_weight docstring, solver comment and test describe the column as the exact solve thresholded at 1e-6 and renormalized, not the optimum to the last digit.
  • 274e35f8: every intermediate of the conditioned problem is checked finite and the active set refuses a non-finite problem, so subnormal or near-overflow data (column norms or their reciprocals outside ~1e-300..1e300) fails closed as a non-converged fit instead of passing a NaN problem as converged; the docs scope the magnitude guarantee to that range.
  • 244d85d8: inner_max_iter / inner_min_decrease are deprecated warn-and-discard no-ops (ledger M-148 / M-149, deprecated 4.0, removed 5.0; migration guide, design-spec phase cell and naming-guard tokens updated) instead of silent no-ops, and the dead budget plumbing is removed from the fit snapshot, the inner/outer solvers and the conformal helpers. A step-guard exhaustion in the active set (50 steps per donor, not reached in practice) returns the feasible iterate flagged converged=False and takes the existing fail-closed paths instead of raising RuntimeError out of a nested V search or a conformal grid.

Validation on this branch (Rust backend, macOS)

  • Issue [Bug]: SyntheticControl's Frank-Wolfe stops on its budget at the default inner_max_iter; in_space_placebo() then fails closed or drops placebos on 33-40 of 40 panels #838 reproducer, uniform V, 40 panels per design: on main the placebo test failed closed or dropped placebos on 40 / 40 / 35 panels (treated pre-RMSPE a median 1.2e-4 above the exact optimum); here 0 / 0 / 0, pre-RMSPE at the optimum to 2e-16.
  • Solver: KKT conditions of the simplex QP hold to 1e-15 on 400 random problems (exact-fit ties and ridge included); bit-matches scipy.optimize.nnls where the optimum is unique; duplicated columns, zero rows, identical donors, J=1 and wide problems handled; identical weights at panel scales from 1e-300 to 1e300 and under a 1e16 within-row dynamic range; subnormal / overflowing inputs fail closed.
  • Nested-default fit time per panel, main -> here: 2.1s -> 0.5s (19 donors, 20 pre), 45s -> 1.6s (39 donors), 19s -> 0.9s (40 pre). The Rust Frank-Wolfe kernel is no longer on the SyntheticControl path; SyntheticDiD's is untouched.
  • Basque Tier-1 parity at atol=2e-5 (was 1e-3); Tutorial 25 drift bands; 2002 targeted tests across the SC, prep, diagnostic/business-report, base-estimator, ledger, naming-guard, tracking-file, changelog, guide and docs-IA suites.

Methodology references (required if estimator / math changes)

  • Method name(s): synthetic control weight problem (Abadie, Diamond & Hainmueller 2010); CWZ conformal proxy (Chernozhukov, Wuthrich & Zhu 2021); non-negative least squares by Lawson & Hanson's active set (Solving Least Squares Problems, 1974, ch. 23).
  • Paper / source link(s): https://doi.org/10.1198/jasa.2009.ap08746, https://doi.org/10.1080/01621459.2021.1920957
  • Any intentional deviations from the source (and why): none from the papers (the same estimator, solved to its optimum). From R Synth: at an exact-fit tie the reported W is the active set's vertex where ipop tends to an interior point (REGISTRY Note (deviation from R)).

Validation

  • Tests added/updated: tests/test_methodology_synthetic_control.py (NNLS KKT / vertex / scipy-parity / rounding-guard / step-guard tests, exact inner solve on a factor panel, every placebo kept, conformal proxy converges, deprecation shim, budget-flag propagation, non-convergence paths via a monkeypatched truncation, Basque at 2e-5, CWZ proxy vs SLSQP at 1e-7), tests/test_prep.py (exact synthetic_weight, uniform on an unbalanced panel), tests/test_t25_synthetic_control_policy_drift.py, tests/test_naming_guard.py, tests/test_diagnostic_report.py, tests/test_business_report.py.
  • Backtest / simulation / notebook evidence (if applicable): Tutorial 25 re-executed; issue reproducer and timing above.

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

Changelog

  • changelog.d/ fragment added (or N/A - no user-visible change): Yes (changelog.d/20261006-sc-exact-simplex-weights.md: Fixed, Behavioral Changes, Deprecated)

ilgrad and others added 9 commits October 15, 2026 17:14
…ve least squares

Frank-Wolfe with a budget of inner_max_iter steps stopped short of the simplex
least-squares optimum on ordinary factor-model panels, so the treated fit failed
closed, placebos were dropped as failed, and the nested V search ranked V by where
truncated solves stopped. The problem reduces exactly to one non-negative least
squares, solved here by Lawson-Hanson's active set, which terminates finitely and,
being diff-diff's own, returns the same vertex at a tie under every SciPy release.
The CWZ conformal proxy and rank_control_units' synthetic_weight column solve the
same problem and use the same solve.

Fixes #838
…-execute Tutorial 25 on top of #841

Two cv-infeasibility warnings (in-space placebo and leave-one-out) still
named inner_max_iter as the budget not to raise; with the exact inner solve
the only budget left is n_starts. The donor-order canonicalization comment
and its test described the tie-break as Frank-Wolfe's; the active set also
breaks an exact gradient tie by position (the first tied column enters), so
the canonicalization is still needed and the comments now say so.

Tutorial 25 is re-executed on top of #841 (treated unit in every placebo
pool): the constant-effect confidence set prints [3.47, 20.45] and the
nested V search keeps its uniform start exactly. The prose quotes neither
number; the drift bands hold.
…s and flag a step-guard exhaustion instead of raising

inner_max_iter and inner_min_decrease have had no effect since the donor
weights moved to an exact non-negative least squares. Keeping them as
silent no-ops hides that from a caller who tunes them; the repo precedent
for an inert constructor parameter (SyntheticDiD lambda_reg, ledger M-001)
is a warn-and-discard shim. Both now default to None, emit a
DeprecationWarning when passed, and are stored as None (get_params and
set_params see None, so a probe re-init warns once). Ledger rows M-148 and
M-149 (deprecated 4.0, removed 5.0; 4.0 is the locked ladder's next
release), migration-guide rows and prose, the design spec's phase-5 cell,
and the naming guard's token predicate are updated together. The fit
snapshot, _inner_solve_W, _v_starts, _outer_solve_V, _outer_solve_V_cv,
_cwz_proxy_fit and the conformal grid helpers drop the dead budget
plumbing (including the proxy's unused warm start), and the convenience
function's docstring no longer lists the two names.

_nnls_lawson_hanson no longer raises RuntimeError when it exhausts its step
guard (50 steps per donor, not reached in practice). It returns the current
feasible iterate flagged converged=False, _sc_weight_nnls threads the flag
through (return_convergence=True), and _inner_solve_W and the conformal
proxy report it, so the fail-closed paths that were kept (non-converged
treated fit, dropped placebo, indeterminate grid point) handle it instead
of a bare exception escaping from the middle of a nested V search or a
confidence-interval grid. The post-fit inner-solve warning names the step
guard instead of an iteration count.

Tests: the budget-exhaustion test asserts a feasible flagged iterate rather
than a raise; a new test monkeypatches the solve to exhaust and checks the
flag reaches the fit warning, the fail-closed placebo and the conformal
proxy; a new test pins the deprecation (warning text, discard to None,
set_params, identical fit to the default, no warning on a default
construction). Every inner_min_decrease=1e-3 in the suites, the tutorial-25
kwarg lock and the notebook's fit call drop the inert argument (the
notebook's outputs are unchanged by it).

Docs: REGISTRY inner-solve line and the three placebo failure notes, the
llms-full signature, the changelog fragment (Deprecated section; the
step-guard contract), and the resolved TODO row.
…ed on a non-finite difference, and apply the ridge per passive set

_sc_weight_nnls conditioned the problem by sqrt(mean(Z**2)) on the raw
donor-minus-treated block. For finite inputs beyond about 1e154 (below
about 1e-154) that overflowed to inf (underflowed to 0), the division
erased the fitting block, and the active set returned an arbitrary vertex
flagged converged: Y = [[0, 1e200, 9e199]] gave [1, 0] instead of
[0.1, 0.9], and an exact-match panel through
SyntheticControl(standardize="none") put weight 1 on the wrong donor with
_fit_converged still true. The scale is now computed from the
max-normalized block (an RMS of O(1) numbers), so any finite magnitude
gives the same weights; a non-finite Z (a difference that itself
overflows) fails closed as uniform weights flagged converged=False, and a
non-finite result does the same.

zeta > 0 stacked a dense J x J identity under Z and carried it through
every passive-set least squares (800 MB at 10,000 controls in
rank_control_units). _nnls_lawson_hanson takes the ridge as a scalar and
applies it inside each passive-set solve (an identity sized to the passive
set) and in the gradient; the result equals the stacked form to 1e-12.

The treated-fit non-convergence messages (in-space / in-time placebo,
leave-one-out, the diagnostic-report reason text and the in_space_placebo
docstring) attributed every non-converged fit to the outer V search and
prescribed its knobs; they now name the inner step guard as the other
cause and scope the remedy to the outer search. The conformal grid
docstring no longer describes a warm-started Frank-Wolfe refit, and the
REGISTRY donor-order Note names the active set's positional tie-break.

Tests: scale invariance at 1e-300 .. 1e300 (same weights, KKT, converged);
fail-closed on an overflowing difference; ridge equals the stacked
identity; an exact-match fit at a 1e150 outcome scale keeps its weight,
zero ATT and converged flag.
…rmal equations

When the ridge dwarfs the fitting block (rank_control_units with a large
lambda_reg, or a tiny outcome scale), the NNLS coefficients are
O(1/ridge^2) and an SVD of the stacked [M; ridge I] system loses them: the
information sits in O(1/ridge) entries of U computed to O(eps) absolute.
Y = [[0, 2e-14, 1.8e-14]] with zeta = 1 returned [0.4986, 0.5014] as
converged where the optimum is [0.5, 0.5] to 1e-28. With the ridge at or
above the block's Frobenius norm the Gram matrix is well conditioned
(cond <= 2), so the passive-set solve now uses (M'M + ridge^2 I) z = M'd
there and keeps the stacked least squares otherwise; at the branch point
the two agree to 1e-12. SyntheticControl and the conformal proxy run with
zeta = 0 and are unaffected.

The comment above test_outer_v_nonconvergence_warning no longer describes
a loose inner tolerance that no longer exists.
…o heterogeneous donor scales are solved exactly

A single global conditioning scale hid small donors behind a large one:
with A = [[1e16, 1, 2]] and b = 1.5 the two O(1) donors fell below the
active set's tolerance after the block was normalized by the 1e16 column,
and the solve returned w = (0, 1, 0) (residual -0.5) as converged. Each
donor column of Z is now scaled to unit norm first (B = Z D, w = D v, the
sum constraint becoming (D1)'v = 1, that row itself scaled to a unit
maximum), which keeps the reduction exact for any scale while giving every
column its own rounding level; the active set's entering test is per
column. The entering column is still chosen on the gradient in the
original units (pivot_scale), so the vertex an exact-fit tie returns is
the one the un-scaled reduction returned: Tutorial 25 and its drift bands
are unchanged. The ridge for zeta > 0 becomes a per-column vector. A donor
identical to the target gets weight 1 directly (zeta = 0).

Tests: the heterogeneous-scale problem is solved to a zero residual at
scales 1 and 1e+-200, as is the CWZ proxy on the same donors; an
end-to-end standardize="none" fit with a 1e12 donor beside two O(1)
donors reproduces the treated pre-path exactly. The tie-vertex,
scale-invariance, ridge and KKT tests pass unchanged.
… and scope its magnitude claim to the float64 range

Subnormal data (entries near 1e-320) makes a reciprocal column norm
overflow, and entries near 1e308 make a column norm itself overflow; a
NaN or inf then reaches the active set, whose comparisons are all False,
so it returned the zero iterate as converged and the wrapper substituted
uniform weights under that flag. Every intermediate (column norms, their
reciprocals, the equilibrated block, the scale, the ridge vector and the
constraint row) is now checked finite before the active set runs, and the
active set itself refuses a non-finite problem; both paths return uniform
weights flagged converged=False so the estimator's fail-closed paths
(non-converged fit warning, skipped diagnostics) fire. The docstring and
REGISTRY no longer claim finite inputs of any magnitude: the guarantee is
the normal float64 range (column norms and reciprocals between about
1e-300 and 1e300), with the regimes outside it named as fail-closed.

Tests: a subnormal panel and an overflowing column norm both fail closed;
a NaN problem fails closed inside the active set; an end-to-end fit on
subnormal outcomes reports a non-converged fit with the warning.
…rs beside a small one

Two nearly opposing 1e8 donors next to an O(1) donor, with a 1e-8 wrinkle
in a second predictor: the candidate optima differ in objective by about
1e-17, below the rounding of the 1e8 entries (ulp 1.5e-8), so a float64
solver can only choose among them by rounding. The test pins the contract
that matters: the returned simplex vector is flagged converged, its
objective is no worse than the alternative vertex evaluated in the same
arithmetic, and its residual is within one ulp of the data.
…d exact solve

rank_control_units() solves its simplex least squares exactly and then
zeroes weights below 1e-6 and renormalizes for readability, so the column
is the optimum up to that cleanup, not to the last digit (a donor whose
optimal weight is below the threshold is dropped). The docstring, the
solver comment and the test's name and docstring now say so; the test's
assertion is unchanged (no optimal weight on its panel falls below the
threshold).
@github-actions

Copy link
Copy Markdown

Overall assessment

⚠️ Needs changes

Executive summary

  • The NNLS reduction preserves the ADH and CWZ estimands; the tie behavior is documented in the Methodology Registry.
  • P1: rank_control_units() silently presents fallback uniform weights as an exact solution when NNLS fails.
  • P2: Tutorial 25 incorrectly claims that a non-unique weight solution still identifies the counterfactual path.
  • Deprecated parameters appear consistently propagated, and a changelog fragment is present.
  • No performance, maintainability, or security blockers found.

Methodology

  • P3 — Documented R deviation: The active-set solver selects a vertex when simplex weights are non-unique, unlike R Synth’s typical interior solution.
    Impact: Weights, ATT, and diagnostics may differ at exact-fit ties.
    Concrete fix: None required. This is explicitly documented under “Note (deviation from R)” in docs/methodology/REGISTRY.md:L3824-L3825 and is a valid numerical selection convention.

  • The affected methods are classic Synthetic Control donor weighting, the CWZ constrained-LS proxy, and the informational rank_control_units() weights. The new solver optimizes the same paper-specified simplex least-squares objectives.

Code Quality

  • P1 — Solver failure is silently converted into purported exact weights (diff_diff/prep.py:L1045-L1059)
    Impact: _sc_weight_nnls() can return (uniform, False) for finite extreme-scale inputs or step-guard exhaustion, but this caller does not request the convergence flag. It consequently thresholds and reports the uniform fallback as the “exact” synthetic-weight solution. This is a silent incorrect public diagnostic, although it does not affect quality_score or causal estimates.
    Concrete fix: Call with return_convergence=True; on failure, emit a UserWarning and report unavailable/NaN weights or another explicitly identified fallback. Add a test that forces converged=False and verifies it cannot be silently reported as an exact solution.

Performance

No findings. The passive-set ridge implementation avoids an unconditional dense donor-by-donor ridge block.

Maintainability

No findings. Removed inner-budget plumbing is consistently propagated through nested/CV searches, placebo refits, sparse diagnostics, conformal inference, and fit snapshots.

Tech Debt

  • P3 — Tracked: Exact-fit weight-selection policy and conformal auto-grid refinement are recorded in TODO.md:L67-L68.
    Impact: Known limitations remain visible and do not block this PR.
    Concrete fix: None required for this PR.

Security

No findings. No secrets, unsafe execution paths, or sensitive-data additions observed.

Documentation/Tests

  • P2 — Tutorial overstates identification (docs/tutorials/25_synthetic_control_policy.ipynb:L315-L320, docs/tutorials/25_synthetic_control_policy.ipynb:L1010-L1011)
    Impact: The tutorial says the counterfactual path is identified even when weights are not. Donors on the same optimal pre-treatment face can have different post-treatment outcomes, so the solver’s tie-break can change the counterfactual and ATT—as this PR’s own reported ATT change demonstrates.
    Concrete fix: State that the pre-treatment optimum may not identify weights or the post-treatment counterfactual; causal identification additionally relies on SCM’s factor-model/long-pre-period assumptions, and tie-break sensitivity should be assessed.

  • P3 — Release notes present: changelog.d/20261006-sc-exact-simplex-weights.md follows the fragment convention.

Path to Approval

  1. Propagate _sc_weight_nnls(..., return_convergence=True) through rank_control_units(), surface failure rather than silently labeling uniform weights exact, and add a regression test for the non-converged path.

…presenting its fallback as the solution

rank_control_units() called the simplex solve without its convergence
flag, so a fail-closed return (a non-finite intermediate or an exhausted
step guard, neither reached in practice) came back as uniform weights that
were then thresholded and reported as the informational synthetic_weight
column. The call now requests the flag; a non-converged solve leaves the
column NaN and emits a UserWarning saying so, while quality_score and the
ranking (which never used the column) are unaffected. Regression test
monkeypatches the solve to fail closed.

Tutorial 25 said synthetic control identifies the counterfactual path even
where the weights are not identified. At an exact pre-period fit it does
not: donors on the same optimal face can differ post-period, so the
solver's tie-break can move the synthetic path and the ATT. The caveat and
the closing checklist now say that identification rests on the
factor-model assumptions and a long pre-period, and point at the
robustness diagnostics for tie-break sensitivity. Markdown only; the
notebook's outputs are unchanged.
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 366afa706af1040496b2b7ff05394177eae1702c


Overall assessment

⚠️ Needs changes

Executive summary

  • The ADH donor-weight and CWZ proxy objectives remain methodologically faithful.
  • The prior non-convergence finding is fixed for solver-reported failures.
  • P1 [Newly identified]: infinite outcomes bypass that fix and silently produce uniform “exact” weights.
  • The tutorial now correctly explains that tied pre-period fits do not identify the counterfactual.
  • The R tie deviation is documented, tested, and non-blocking.

Methodology

  • P3 — Documented R deviation (docs/methodology/REGISTRY.md:L3824-L3825)
    Impact: At non-unique optima, the active-set NNLS selects a vertex while R Synth commonly returns an interior solution, potentially changing weights and ATT. This is explicitly documented and is a valid numerical selection convention.
    Concrete fix: None required.

The affected methods—ADH Synthetic Control weighting and the CWZ all-period constrained-LS proxy—continue to optimize their paper-specified simplex objectives. Analytical inference remains jointly NaN through safe_inference().

Code Quality

  • P1 [Newly identified] — Infinite outcomes are treated as a valid uniform solution (diff_diff/prep.py:L1047-L1056)
    Impact: _converged starts as True, and any non-finite matrix skips NNLS and receives uniform weights. The documented NaN/unbalanced-panel fallback is intentional, but ±Inf follows the same branch without warning, so synthetic_weight is silently presented as a valid exact diagnostic; ranking scores can also become non-finite.
    Concrete fix: Distinguish missing values from infinities: preserve the documented uniform fallback for NaNs, but reject ±Inf with ValueError. Add a parametrized regression test covering infinite treated and donor pre-period outcomes.

Performance

No findings.

Maintainability

No findings. Deprecated inner-solver parameters are consistently removed from snapshots and downstream solver paths.

Tech Debt

  • P3 — Tracked limitations (TODO.md:L67-L68)
    Impact: Auto-grid resolution and exact-tie selection policy remain known follow-ups.
    Concrete fix: None required for this PR.

Security

No findings.

Documentation/Tests

  • P3 — Tutorial references a nonexistent later diagnostic (docs/tutorials/25_synthetic_control_policy.ipynb:L323)
    Impact: It says the “sparse-subset diagnostics below” assess tie sensitivity, but no sparse-subset example follows.
    Concrete fix: Remove “below,” link to sparse_synthetic_control(), or add the promised example.

The changelog fragment is present and the prior counterfactual-identification overstatement is corrected.

Path to Approval

  1. Reject ±Inf outcomes in rank_control_units() before the missing-data fallback, and add treated/donor infinity regression tests.

…instead of routing them to the missing-data fallback

The uniform synthetic_weight column for an unbalanced panel is keyed on a
non-finite pre-period block, so an infinite outcome took the same branch
and was presented as a valid diagnostic (and could leave the ranking
scores non-finite). The pre-period pivot is now checked for infinities
right after it is built and raises ValueError naming the outcome column;
NaN keeps its documented meaning of a missing cell. Parametrized test for
an infinite treated and an infinite control outcome.

Tutorial 25's tie-sensitivity caveat pointed at "sparse-subset
diagnostics below" that the notebook does not run; it now points at the
leave-one-out diagnostic below and names sparse_synthetic_control().
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 5d635f0a3656235baf5ad4fc804945241e39c5a6


Overall assessment

⚠️ Needs changes

Executive summary

  • The prior infinity-handling finding is fixed for active treated and control units.
  • P1: the new guard also rejects infinities in explicitly excluded units, preventing otherwise valid rankings.
  • ADH and CWZ simplex objectives remain methodologically faithful; the R tie behavior is documented.
  • Changelog, methodology notes, and targeted solver tests are present.

Methodology

  • P3 — Documented R tie deviation (docs/methodology/REGISTRY.md:L3824-L3825)
    Impact: Exact-fit ties may select a different optimal weight vector and ATT than R Synth. This is explicitly documented and is a valid numerical convention.
    Concrete fix: None required.

The affected ADH donor-weight and CWZ proxy objectives match their cited sources. Analytical inference remains jointly NaN through safe_inference().

Code Quality

  • P1 [New code since last review] — Infinity validation includes excluded units (diff_diff/prep.py:L968-L987)
    Impact: exclude_units is applied to control_candidates, but the infinity check scans the entire pivot. An infinite outcome in a unit explicitly excluded from the control group now raises ValueError, although that unit cannot affect weights, scores, or output.
    Concrete fix: Check only treated_list + control_candidates after both lists are resolved.

Performance

No findings.

Maintainability

No findings. Deprecated parameters were removed consistently from solver and snapshot plumbing.

Tech Debt

  • P3 — Tracked limitations (TODO.md:L67-L68)
    Impact: Conformal grid resolution and exact-tie selection remain known follow-ups.
    Concrete fix: None required for this PR.

Security

No findings.

Documentation/Tests

  • P3 — Tests still pass deprecated no-op parameters (tests/test_prep.py:L1259-L1263, tests/test_prep.py:L1297-L1301)
    Impact: These tests now emit unnecessary DeprecationWarnings.
    Concrete fix: Remove inner_min_decrease from both constructions.

The changelog fragment is present, and the previous tutorial diagnostic-reference issue is resolved.

Path to Approval

  1. Restrict the infinity check to active treated units and non-excluded control candidates.
  2. Add a regression test where an excluded control has an infinite pre-period outcome and confirm ranking proceeds without including that unit.

…he ranking

The guard scanned the whole pre-period pivot, so an infinite outcome in a
unit listed in exclude_units raised although that unit cannot reach any
output. The check now runs after the treated list and the non-excluded
control candidates are resolved and covers only those columns, on the
valid pre-periods. Test: an excluded control with an infinite outcome
neither raises nor appears, and the ranking columns stay finite.

Two prep tests still passed the deprecated inner_min_decrease to
SyntheticControl and emitted DeprecationWarnings; the kwarg is dropped
(the prep suite now runs clean with DeprecationWarning as an error).
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: de654315f75df3efdc3483993262717f86a6423d


Overall assessment

✅ Looks good

Executive summary

  • The prior P1 is resolved: excluded units are no longer included in infinity validation, with regression coverage.
  • ADH donor weighting and CWZ conformal proxy objectives match the cited methods.
  • The different exact-tie solution is explicitly documented as an R deviation.
  • NaN analytical inference remains consistently handled through safe_inference().
  • Changelog and targeted solver tests are present.

Methodology

  • P3 — Documented exact-tie convention (docs/methodology/REGISTRY.md:L3824-L3825)
    Impact: At non-unique optima, the active set can select a different valid weight vector and ATT than R Synth. The objective remains identical, and the deviation is explicitly documented.
    Concrete fix: None required.

No unmitigated methodology, variance, assumption-check, or inference findings.

Code Quality

  • P3 — Non-convergence warning overstates the cause (diff_diff/synthetic_control.py:L587-L594, diff_diff/utils.py:L2353-L2390)
    Impact: _sc_weight_nnls() can return converged=False because conditioning or finite-range checks fail, but the warning always says the active-set step guard was exhausted. Results still fail closed correctly; only the diagnosis is misleading.
    Concrete fix: Describe both possible causes in the warning and related docstrings.

Performance

No findings.

Maintainability

No findings. Deprecated inner-solver parameters were removed consistently from downstream solver and snapshot plumbing.

Tech Debt

  • P3 — Tracked limitations (TODO.md:L67-L68)
    Impact: Default conformal-grid resolution and exact-tie selection remain known limitations.
    Concrete fix: None required; both are properly tracked.

Security

No findings.

Documentation/Tests

No findings. The behavioral changelog fragment is present, and the excluded-infinite-unit regression appears at tests/test_prep.py:L977-L996.

Test execution was unavailable because this review environment lacks pytest and NumPy; the assessment is based on static inspection.

…rgence in the warning

_sc_weight_nnls reports converged=False either when the active set
exhausts its step guard or when the conditioned problem leaves the
float64 range (an overflowing column norm or reciprocal, a non-finite
intermediate), but the fit-time warning named only the step guard. The
warning and the _inner_solve_W, conformal proxy and in_space_placebo
docstrings now name both; the fail-closed behavior is unchanged.
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: bcb063bc8c814cc02385ad02bf5021a3ec173507


Overall assessment

✅ Looks good

Executive summary

  • The prior warning finding is resolved: both step-guard exhaustion and float64-range failures are now named.
  • ADH donor weighting and the CWZ proxy remain faithful to their cited objectives.
  • The exact-tie behavior is explicitly documented as an R deviation.
  • Changelog and targeted edge-case tests are present.
  • No unmitigated P0/P1 findings.

Methodology

  • P3 — Documented exact-tie convention (docs/methodology/REGISTRY.md:L3824-L3825)
    Impact: Non-unique optima can produce different valid weights and ATT than R Synth. The objective is unchanged and the deviation is documented.
    Concrete fix: None required.

Code Quality

No findings. The prior diagnostic issue is fixed at diff_diff/synthetic_control.py:L587-L599.

Performance

No findings.

Maintainability

No findings. Deprecated inner-solver parameters were consistently removed from downstream plumbing.

Tech Debt

  • P3 — Tracked limitations (TODO.md:L67-L68)
    Impact: Conformal grid resolution and exact-tie selection remain known limitations.
    Concrete fix: None required; both are tracked.

Security

No findings.

Documentation/Tests

No findings. The behavioral changelog fragment and targeted solver, non-finite-data, and fail-closed tests are present. Static parsing passed; runtime tests were unavailable because NumPy/pytest are not installed.

…ing, in the opposing-large-donors case

test_sc_weight_nnls_opposing_large_donors_beside_a_small_one asserted that
the returned weights' objective was no worse than the vertex
(0, 1e-8, 1 - 1e-8). Both candidates sit at the rounding floor of the 1e8
entries (objectives 8.1e-17 vs 1.0e-16 on Accelerate, 1.5e-16 vs 1.0e-16
on OpenBLAS), so the ordering is decided by BLAS rounding and the test
failed on every x86 CI leg while passing on macOS and arm. The test now
asserts what the case was pinned for: a converged simplex vector whose
residual is within the rounding of the 1e8 entries (two ulps, since
1e8 * (w1 - w0) carries the rounding of two O(0.5) weights).
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 345dc212c28f2398a8df51a7cb021268b2780923


Overall assessment

✅ Looks good

Executive summary

  • The prior warning issue is resolved: both step-guard and float64-range failures are named.
  • ADH donor weighting and the CWZ proxy preserve their cited simplex least-squares objectives.
  • Exact-fit tie behavior is explicitly documented as an R deviation.
  • Deprecated parameters are consistently removed from downstream paths.
  • No unmitigated P0/P1 findings.

Methodology

  • P3 — Documented exact-tie convention (docs/methodology/REGISTRY.md:L3824-L3825)
    Impact: Non-unique optima may return different valid weights and ATT than R Synth; the estimand’s objective is unchanged.
    Concrete fix: None required.

Code Quality

No findings. The prior diagnostic issue is fixed at diff_diff/synthetic_control.py:L587-L599.

Performance

No findings. The active-set solve removes repeated budget exhaustion without changing the mathematical objective.

Maintainability

No findings. Deprecated inner-solver parameters are consistently removed from snapshots, CV, placebo, sparse, and conformal paths.

Tech Debt

  • P3 — Tracked limitations (TODO.md:L67-L68)
    Impact: Conformal auto-grid resolution and exact-fit selection remain known limitations.
    Concrete fix: None required; both are tracked.

Security

No findings.

Documentation/Tests

No findings. The changelog fragment covers all user-visible changes, and tests cover KKT conditions, scale conditioning, non-finite inputs, fail-closed behavior, parameter deprecation, and CWZ parity. The latest rounding-floor test remains appropriately platform-independent. Static Python and notebook parsing passed; runtime tests were unavailable because NumPy is not installed.

@igerber igerber closed this Oct 17, 2026
@igerber
igerber deleted the fix/sc-exact-simplex-weights-mirror branch October 17, 2026 13: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

2 participants