Skip to content

fix(identification): weakref-validated posterior cache for ProxySVAR - #234

Merged
thomaspinder merged 1 commit into
mainfrom
fix/203-weakref-posterior-caches
Jul 29, 2026
Merged

fix(identification): weakref-validated posterior cache for ProxySVAR#234
thomaspinder merged 1 commit into
mainfrom
fix/203-weakref-posterior-caches

Conversation

@thomaspinder

Copy link
Copy Markdown
Owner

Summary

ProxySVAR's memoisation was keyed on id(posterior) — if a dead posterior's address were reused by a new object of matching shape, a stale cached impact matrix would return silently (unreproduced in 200 forced-GC attempts, but the exposure is real and the failure mode is wrong numbers with no signal).

New module-level _PosteriorCache (single-slot, __slots__): validity token is ref() is owner — a dead referent dereferences to None, which can never be an owner, so one check subsumes both "collected" and "different object", and no id() survives in the key. Non-weakref-able owners decline to cache (lose the speed-up, never an unsafe key). Verified in-venv that both owners ProxySVAR keys on (xr.Dataset, VARData) support weakrefs and that idata.posterior has stable identity across accesses, so the per-t loop's warn-once and compute-once semantics are preserved exactly (pinned by call-count and warning tests).

The helper's docstring records the one-line adoptions for MaxShare._spectral_cache (#200) and LongRunRestriction._lr_cache (#183) — I'll fold those in during each branch's post-merge rebase, which completes #203.

Refs #203 (closes fully once the two branch adoptions land)

Tests

+20: weakref mechanics (dead-referent miss after gc.collect(), live-different-object miss, tuple/single-owner normalisation, non-weakref-able decline), ProxySVAR cache-hit/invalidation via monkeypatched call counts, and the warn-once pin. Fast suite: 628 passed, 29 deselected; ruff/ty clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

Caches keyed on `id(posterior)` return a stale value silently once the
cached posterior is collected and its address is recycled by a new object
of matching shape. Not reproduced in 200 forced-GC attempts, but the
failure mode is wrong numbers with no error.

Add `_PosteriorCache`, a single-slot memo that stores `weakref.ref(owner)`
alongside the scalar key tail and treats a dead referent — or a live
referent that is not the object being looked up — as a miss. The identity
check `ref() is owner` subsumes an `id()` comparison exactly, so no `id()`
is kept in the key. Owners that cannot be weakly referenced make `set`
decline to cache rather than fall back to an unsafe key.

Port `ProxySVAR._impact_cache` to it, preserving cache-hit semantics: the
weak-instrument warning and the first-stage diagnostics are still produced
on the computing call only, so the per-t identification loop pays for the
residual reconstruction once.

`MaxShare._spectral_cache` and `LongRunRestriction._lr_cache` land on
unmerged branches; the helper docstring records the one-line adoption for
each.

Refs #203

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.4%. Comparing base (70a412c) to head (8aadc2e).

Additional details and impacted files
@@          Coverage Diff          @@
##            main    #234   +/-   ##
=====================================
  Coverage   93.3%   93.4%           
=====================================
  Files         41      41           
  Lines       2506    2539   +33     
  Branches     298     301    +3     
=====================================
+ Hits        2339    2372   +33     
  Misses       119     119           
  Partials      48      48           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thomaspinder
thomaspinder merged commit a7edd38 into main Jul 29, 2026
9 checks passed
thomaspinder added a commit that referenced this pull request Jul 29, 2026
`LongRunRestriction._lr_cache` keyed its memoised long-run screen on
`id(posterior)`. An address is not a validity token: once the posterior
is collected the address can be reused, and a lookup with a different
posterior that lands on it returns another model's `M` and singular
mask silently.

Swap it for the weakref-validated `_PosteriorCache` helper added in
#234, which ties the entry to `weakref.ref(posterior)` and treats a
dead or different referent as a miss. Semantics are unchanged: the
screen still computes once per (posterior, n_lags), and `_report`
still fires its singular/explosive warnings only on a miss, so the
per-t identification loop warns once rather than T times.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder added a commit that referenced this pull request Jul 29, 2026
`MaxShare._spectral_cache` keyed its memoised frequency sweep on
`id(posterior)`. An address is not a validity token: once the posterior
is collected the address can be reused, and a lookup with a different
posterior that lands on it returns another model's accumulator `K`,
singular mask and spectral radii silently.

Swap it for the weakref-validated `_PosteriorCache` helper added in
#234, which ties the entry to `weakref.ref(posterior)` and treats a
dead or different referent as a miss. Semantics are unchanged: the
sweep still computes once per (posterior, n_lags, target_index), and
`_report` still fires its singular/explosive warnings only on a miss,
so the per-t identification loop warns once rather than T times.
`_warn_weakly_identified` is untouched — the eigenvalue ratio depends
on `L`, so it is legitimately evaluated on every call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder added a commit that referenced this pull request Jul 29, 2026
`LongRunRestriction._lr_cache` keyed its memoised long-run screen on
`id(posterior)`. An address is not a validity token: once the posterior
is collected the address can be reused, and a lookup with a different
posterior that lands on it returns another model's `M` and singular
mask silently.

Swap it for the weakref-validated `_PosteriorCache` helper added in
#234, which ties the entry to `weakref.ref(posterior)` and treats a
dead or different referent as a miss. Semantics are unchanged: the
screen still computes once per (posterior, n_lags), and `_report`
still fires its singular/explosive warnings only on a miss, so the
per-t identification loop warns once rather than T times.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder added a commit that referenced this pull request Jul 29, 2026
…ns (#183)

* feat(identification): LongRunRestriction — Blanchard-Quah long-run zeros (#143)

Adds a closed-form long-run (cumulative) identification scheme. Where
Cholesky and sign restrictions constrain the impact matrix Theta(0) = P,
this constrains Theta(1) = C(1) P, the cumulative effect of each shock on
each variable's level, with C(1) = (I - sum_j A_j)^-1.

Construction uses the QR route: with L L' = Sigma, factor (M^-1 L)' = Q R
and set P = L Q, sign-fixed so diag(R) > 0. Equivalent to the textbook
P = M chol(C(1) Sigma C(1)'), but P P' = Sigma holds exactly (P is L times
an orthogonal matrix) and the conditioning of C(1) Sigma C(1)' is never
squared. It also makes explicit that the scheme is a rotation of the
Cholesky factor, solved in closed form rather than searched for.

Scope is recursive (triangular) patterns only; arbitrary non-recursive
long-run zeros need the ARW machinery and are deferred to #144.

Two failure modes are separated. A near-singular M means C(1) does not
exist numerically: those draws are NaN'd (on_undefined="nan", default) or
raise, screened by np.linalg.cond against max_condition. Explosive draws
(companion spectral radius above 1) are an interpretation failure, not an
arithmetic one, so they are always warned about and never blanked —
posteriors near a unit root routinely contain them.

The screen is memoised on (id(posterior), n_lags) so the per-t SV path
does not redo the eigendecomposition, or re-warn, T times.

Rows of the returned matrix follow the data's variable order; ordering
only fixes the coordinates in which Theta(1) is triangular.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* fix(identified): keep undefined FEVD draws NaN instead of reporting 0.0 (#143)

fevd()'s zero-total guard exists for the degenerate horizon-0 case, but
`NaN > 0` is False, so a NaN draw fell through to the 0.0 branch and was
reported as a clean "this shock explains nothing" share. Harmless while
every scheme returned finite matrices; LongRunRestriction blanks draws
whose long-run multiplier is numerically undefined, which exposes it.

Extracted the normalisation into _shares() and re-imposed NaN after the
guard. Behaviour is unchanged for finite inputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* feat(identification): export LongRunRestriction and pin the pipeline behaviour (#143)

Adds the lazy export plus end-to-end tests through
FittedVAR -> IdentifiedVAR: shock-matrix labelling and diagnostics attrs,
FEVD shares, historical-decomposition additivity, and NaN-draw survival
through both.

The headline test computes the long run twice by independent routes —
imposed as Theta(1) = G at identification time, then recovered by summing
200 horizons of the IRF (0.6^200 is around 1e-45, so the truncation is
immaterial). Agreement to 1e-8 catches transpose and lag-order mistakes
that a self-consistency check would miss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* test(identification): from_zero_restrictions and long-run recovery (#143)

Covers the named-zeros constructor (happy path, kwarg forwarding, and
every rejection branch) and an end-to-end recovery check: simulate 2000
observations from the known truth, fit a ConjugateVAR, identify, and
confirm the posterior-median impact matrix lands within 0.05 of the
matrix the data were generated from (observed error 0.023).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* docs(identification): long-run restrictions explanation, how-to, and vocabulary (#143)

Adds the "Long-run restrictions" section to the identification explainer
(Theta(0) vs Theta(1), the C(1) formula, the closed-form construction,
the sign convention, and the two failure modes), a how-to covering both
constructors, the diagnostics surface, and the undefined-draw policy, and
the Blanchard-Quah bibliography entry.

Both pages lead with the differencing requirement. It is the assumption
that makes the restriction mean what users think it means, and the
library cannot check it.

CONTEXT.md gains the long-run multiplier C(1) and the cumulative MA impact
matrix Theta(1), plus a relationships bullet recording that schemes may
consume posterior lag coefficients — L_t is the minimum a scheme asks
for, not the maximum.

Also lists ProxySVAR in the identification reference page, which was
missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* test(identification): cover the multi-lag path and the ordering guard (#143)

The p = 1 tests cannot see a lag-block mistake: with one lag the long-run
sum has one term and the companion matrix has no subdiagonal. Adds a
VAR(2) case that checks identification against the truth, brute-force
sums 400 moving-average coefficients to confirm the cumulative impact is
the imposed G, and checks the companion spectral radius against a hand-
built companion matrix.

Also covers n_lags inference from B's trailing axis and the guard that
rejects an ordering not covering every variable — silently wrong
otherwise. identification.py is now at 99% line coverage, the remainder
pre-existing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* fix(identification): reserved-prefix guard covers the shock_names=None fallback

With shock_names=None, shock_coords falls back to the ordering, so a
variable literally named unidentified_* leaked the reserved prefix into
the shock labels and downstream FEVD/HD masking. Validate the effective
labels instead of shock_names alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

* refactor(identification): adopt _PosteriorCache (#203)

`LongRunRestriction._lr_cache` keyed its memoised long-run screen on
`id(posterior)`. An address is not a validity token: once the posterior
is collected the address can be reused, and a lookup with a different
posterior that lands on it returns another model's `M` and singular
mask silently.

Swap it for the weakref-validated `_PosteriorCache` helper added in
#234, which ties the entry to `weakref.ref(posterior)` and treats a
dead or different referent as a miss. Semantics are unchanged: the
screen still computes once per (posterior, n_lags), and `_report`
still fires its singular/explosive warnings only on a miss, so the
per-t identification loop warns once rather than T times.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@thomaspinder thomaspinder added the bug Something isn't working label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants