Skip to content

fix(spec): scale the B_exog prior to the data instead of pinning sigma=1 - #237

Merged
thomaspinder merged 4 commits into
mainfrom
fix/192-exog-prior-scale
Jul 29, 2026
Merged

fix(spec): scale the B_exog prior to the data instead of pinning sigma=1#237
thomaspinder merged 4 commits into
mainfrom
fix/192-exog-prior-scale

Conversation

@thomaspinder

Copy link
Copy Markdown
Owner

Summary

The most severe open defect in the tracker: B_exog ~ Normal(0, 1) regardless of regressor scale — silently wrong inference on the NUTS+exog path. Measured demonstration (2-var VAR(1), T=200, tiny-scale regressor z ~ N(0, 0.01²) with true coefficient 50):

posterior mean 94% HDI
OLS reference 49.04
Old Normal(0,1) prior 3.94 [2.18, 5.73]
New scale-adaptive prior 49.06 [48.01, 50.15]

A 12.4× crush, with the truth ~46 posterior sds outside the reported interval and no warning anywhere.

The fix: sd(B_exog[i,j]) = exog_prior_scale · σᵢ / sⱼ where σᵢ is the conjugate path's canonical AR(1) residual scale and sⱼ the exog column's sd over the lag-trimmed rows the likelihood actually sees, floored against near-constants. Default exog_prior_scale = 100 — deliberately loose-and-scaled, following the library's own diffuse-deterministic tradition (the conjugate intercept's Vc = 10e6); the asymmetry of failure modes decides it (too-tight recreates this bug; too-loose costs only mild shrinkage), and the knob is a documented VAR field. ADR-0012 records the formula, the loose-vs-tight argument, and why internal standardisation was rejected (6-module blast radius for the same inferential fix).

Guards added (the silent-unidentifiability class this PR closes): exactly-constant exog columns rejected at VARData construction (they duplicate the always-present intercept), and columns left constant at any level by lag-trimming rejected at fit time with a "reduce lags" remedy — the latter found by review (a post-initial-conditions dummy previously drew a 1e5·σ prior on an intercept-collinear coefficient).

Breaking changes (pre-v0.1)

  1. Every PyMC-path posterior with exogenous regressors changes. No pinned-value tests existed; downstream users' numbers will move — toward the data.
  2. Constant exog columns are now rejected at construction; lag-trim-constant columns at fit.

Merge-order note: whichever of this PR and #191 (feat/135-deterministic-regressors) merges second must update docs/how-to/deterministic-regressors.md and the Trend docstring — both describe the old fixed Normal(0, 1) prior; Trend.scale's rationale softens to interpretable-units + sampler geometry.

Closes #192

Review

Planned by a Fable-tier planner; independently reviewed (verdict: approve after two minor P2s, both fixed). The reviewer re-derived all three ADR sanity numbers to the digit, verified the OLS-agreement invariant (shrinkage factor 0.99999998 — the redesigned slow test asserts what a near-flat prior actually promises), confirmed the dist_params graph accessor is the durable API, and endorsed c=100 explicitly. The reviewer also caught the ADR misattributing the σᵢ scale to MinnesotaPrior on the PyMC path — corrected, with the knock-on consequence rewritten honestly.

Tests

+36 (formula/floor/dummy-edge units, graph-wiring via dist_params, knob validation, both constant-column rejection layers end-to-end, and the slow recovery demonstration verified across {pymc, nutpie} × 3 seeds). Fast suite: 637 passed, 30 deselected; slow recovery green; ruff/ty clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

thomaspinder and others added 4 commits July 29, 2026 10:51
A constant exog column is perfectly collinear with the intercept every VAR
carries, so its coefficient is not identified — the likelihood cannot split
the level between the two, and the split is decided entirely by the priors.
It also has zero sample spread, so the scale-adaptive B_exog prior that
follows in this stack has nothing to key off.

VARData now rejects such columns at construction, naming every offender and
pointing at the fix (drop it, or encode a level shift as a dummy that changes
value within the sample). Non-finite columns still fall through to the
finiteness check, whose message names the real problem.

Refs #192

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
`VAR.fit` built the exogenous coefficients as `Normal(0, 1)` regardless of
the data. But `B_exog[i, j]` converts regressor `j`'s units into variable
`i`'s, so a unit prior encodes a wildly different belief for every dataset:
it crushes coefficients on small-scale regressors and leaves coefficients on
large-scale ones effectively unrestricted, with no warning either way.

Measured on a 2-var VAR(1) with a regressor of sd 0.01 and a true coefficient
of 50 (contribution sd 0.5 against a shock sd of 0.1 — a 5-sigma signal), OLS
recovers 49.04 while the old prior returned a posterior mean of 3.94 with a
94% HDI of [2.18, 5.73]. The truth was excluded by an order of magnitude and
nothing in the output said so.

The prior now lives in contribution space:

    sd[i, j] = exog_prior_scale * sigma_i / s_j

with `sigma_i` the AR(1) residual sd of variable `i` (the scale the Minnesota
prior already uses) and `s_j` the sample sd of the lag-trimmed regressor. One
prior sd of `B_exog[i, j]` then moves variable `i` by `exog_prior_scale` of
its own residual sds per one sd of the regressor. `s_j` is floored at 1e-3 of
the column's peak magnitude so a numerically flat column cannot send the prior
to infinity, and a column that is identically zero after lag-trimming raises
rather than dividing by zero.

The new `VAR.exog_prior_scale` field (default 100.0, loose by design — cf. the
conjugate engine's `Vc = 10e6` on the intercept) is a `VAR` field rather than a
`Prior`-protocol extension: `B_exog` sits outside that protocol, and widening
it would break third-party priors for a term they do not model.

BREAKING: every posterior fitted through `VAR.fit` with exogenous regressors
changes. Pre-v0.1.

Refs #192

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
ADR-0012 states the formula, why the default is loose rather than tight
(the conjugate engine's Vc = 10e6 precedent), why the knob is a VAR field
and not a Prior-protocol extension, why internal standardisation was
rejected on blast radius, and what this change deliberately leaves alone —
uncentred trend geometry, the intercept's identical units problem, and
degenerate endogenous columns.

Refs #192

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

The all-zero guard was too narrow. A dummy that switches inside the initial
conditions — 0 for rows 0-1, 1 thereafter, fitted with lags=4 — passes
VARData's whole-sample check and then arrives at the prior as a column of
ones. Its standard deviation is zero, so the 1e-3 floor engaged off the
column's *level* and handed it a prior sd of 1e5 * sigma_i, on a coefficient
exactly collinear with the intercept over the estimation sample. That is the
same silently-unidentified failure this stack exists to close, reintroduced
by the safety net.

The check now runs on the raw standard deviation, before the floor, and fires
for a constant column at any level. The floor keeps its job: taming columns
with tiny-but-real variation, not manufacturing a scale for columns with
none. The remedy in the message is now "reduce `lags`" — extending the sample
only helps if it is extended backwards.

ADR-0012 gains the matching consequence, and drops an incorrect claim: on the
PyMC path `sigma_i` is *not* the scale MinnesotaPrior uses, because
`build_priors(n_vars, n_lags)` never receives the data. That scale is the
conjugate path's, via `minnesota_dummies`. The same correction narrows the
degenerate-endogenous-column note, which MinnesotaPrior does not in fact
share.

Refs #192

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.8%. Comparing base (70a412c) to head (202768e).

Additional details and impacted files
@@           Coverage Diff           @@
##            main    #237     +/-   ##
=======================================
+ Coverage   93.3%   93.8%   +0.4%     
=======================================
  Files         41      41             
  Lines       2506    2526     +20     
  Branches     298     300      +2     
=======================================
+ Hits        2339    2370     +31     
+ Misses       119     113      -6     
+ Partials      48      43      -5     

☔ 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 8e2aee4 into main Jul 29, 2026
9 checks passed
thomaspinder added a commit that referenced this pull request Jul 29, 2026
#237 made VARData reject exog columns that are constant within the
sample, so the all-ones "const" column this guard used no longer
constructs. Swap it for a draw from the shared `rng` fixture; the test
only needs the model to carry *some* exogenous data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder added a commit that referenced this pull request Jul 29, 2026
#237 made VARData reject exog columns that are constant within the
sample, so the all-ones "x" column the `_fitted`/`_holdout` helpers and
the name-mismatch test used no longer constructs. Swap it for a
deterministic ramp; B_exog is pinned to zero in these fixtures, so the
exog values never enter the predictive mean — only their presence,
count and names matter to the assertions.

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

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
…135)

Main gained a scale-adaptive prior on B_exog (#237): the prior standard
deviation is now `exog_prior_scale * sigma_i / s_j`, not a fixed
`Normal(0, 1)` in coefficient space. Both the `Trend` docstring and the
how-to justified `scale` by the old fixed prior, which is no longer true.

`Trend.scale` is still worth setting — it fixes the coefficient's units
("change per decade") and keeps the design column O(1) for the sampler —
so the guidance stands; only its reason changes.

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
…ph seam (#56)

Rebasing onto main brought in #237, which computes the `B_exog` prior
standard deviation from the data (`_exog_prior_sigma`) inside the region
this branch extracted into `VAR._build_pymc_model`. The extraction is what
makes the merge safe: the scaled prior lives in the shared seam, so
`prior_predictive` draws from the same graph `fit` samples. Had it stayed
in `fit`, a prior-predictive check would have described a unit-scale model
that was never estimated — silently.

Nothing pinned that, so:

* `TestExogPriorWiredIntoModel` gains two graph tests mirroring the
  existing `dist_params`-based fit-path ones — the `_build_pymc_model`
  graph's `B_exog` sigma equals `_exog_prior_sigma(...)`, honours
  `exog_prior_scale`, and is identical to the sigma the fit path builds.
* `TestPriorPredictive` gains an end-to-end check that the simulated
  `B_exog` draws actually spread at that sigma rather than at 1.

Also refreshes the two docstrings the merge made stale: `exog_prior_scale`
no longer claims to apply to `fit` alone, and `_build_pymc_model` now
states that every prior — the exog one included — lives in the seam.

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
#237 made VARData reject exog columns that are constant within the
sample, so the all-ones "const" column this guard used no longer
constructs. Swap it for a draw from the shared `rng` fixture; the test
only needs the model to carry *some* exogenous data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
thomaspinder added a commit that referenced this pull request Jul 29, 2026
#237 made VARData reject exog columns that are constant within the
sample, so the all-ones "x" column the `_fitted`/`_holdout` helpers and
the name-mismatch test used no longer constructs. Swap it for a
deterministic ramp; B_exog is pinned to zero in these fixtures, so the
exog values never enter the predictive mean — only their presence,
count and names matter to the assertions.

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

Main gained a scale-adaptive prior on B_exog (#237): the prior standard
deviation is now `exog_prior_scale * sigma_i / s_j`, not a fixed
`Normal(0, 1)` in coefficient space. Both the `Trend` docstring and the
how-to justified `scale` by the old fixed prior, which is no longer true.

`Trend.scale` is still worth setting — it fixes the coefficient's units
("change per decade") and keeps the design column O(1) for the sampler —
so the guidance stands; only its reason changes.

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
…ph seam (#56)

Rebasing onto main brought in #237, which computes the `B_exog` prior
standard deviation from the data (`_exog_prior_sigma`) inside the region
this branch extracted into `VAR._build_pymc_model`. The extraction is what
makes the merge safe: the scaled prior lives in the shared seam, so
`prior_predictive` draws from the same graph `fit` samples. Had it stayed
in `fit`, a prior-predictive check would have described a unit-scale model
that was never estimated — silently.

Nothing pinned that, so:

* `TestExogPriorWiredIntoModel` gains two graph tests mirroring the
  existing `dist_params`-based fit-path ones — the `_build_pymc_model`
  graph's `B_exog` sigma equals `_exog_prior_sigma(...)`, honours
  `exog_prior_scale`, and is identical to the sigma the fit path builds.
* `TestPriorPredictive` gains an end-to-end check that the simulated
  `B_exog` draws actually spread at that sigma rather than at 1.

Also refreshes the two docstrings the merge made stale: `exog_prior_scale`
no longer claims to apply to `fit` alone, and `_build_pymc_model` now
states that every prior — the exog one included — lives in the seam.

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
… columns)

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
…ttedVAR (#230)

* refactor(spec): extract _build_pymc_model and fitted_values seams

Behaviour-neutral extraction ahead of the predictive APIs (#56):

* `VAR._build_pymc_model(data) -> (pm.Model, n_lags)` lifts the graph
  construction out of `VAR.fit` verbatim (the `sv/spec.py` precedent).
  `fit` now builds, samples, and wraps; `prior_predictive` will build
  and draw from the same graph.
* `_residuals.fitted_values(posterior, data, n_lags)` lifts the
  conditional-mean computation out of `reduced_form_residuals`, which
  is now `y_obs - fitted_values(...)`. The same seam feeds
  `posterior_predictive`.

No graph, no posterior, and no numerical output changes.

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

* feat(predictive): VAR.prior_predictive (#56)

`VAR.prior_predictive(data, *, draws=500, random_seed=None)` builds the
same graph `fit` builds via `_build_pymc_model` and calls
`pymc.sample_prior_predictive` on it, so the simulated prior is by
construction the prior that gets sampled — no hand-rolled simulator to
drift out of sync (the anti-pattern the issue calls out).

Semantics: the simulated `obs` paths are one-step-ahead given the
OBSERVED lags, `y_t = c + B x_t^obs (+ B_exog z_t) + L_t eps_t`, because
the design matrices are baked into the graph. That is what
`az.plot_ppc(..., group="prior")` expects and what puts the prior on the
data's own time axis. Returns PyMC's InferenceData as-is: `prior`,
`prior_predictive` (dims `(chain, draw, time, var)`, chain=1) and
`observed_data`.

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

* feat(predictive): FittedVAR.posterior_predictive (#56)

`FittedVAR.posterior_predictive(*, simulate_innovations=True, seed=None)`
replicates the estimation sample from the posterior:

    y_rep[t] = intercept + B x_t^obs (+ B_exog z_t) + L_t eps_t

Two deliberate readings, both asserted by tests:

* One-step-ahead conditioned on the OBSERVED lags — the object
  `pm.sample_posterior_predictive` returns on the fitted graph and the one
  `az.plot_ppc` consumes. Not an iterated simulated path; that is
  `forecast()`.
* `L_t` comes from the volatility seam (`cholesky_path`), so the
  innovations use the model's own Sigma — per-draw and per-t under SV.
  The issue's "residual covariance of each posterior draw" wording would
  flatten exactly the heteroscedasticity an SV fit exists to capture.

Computed in NumPy, not on a PyMC graph: ConjugateVAR posteriors (which
have no graph) get the method for free, `simulate_innovations=False`
becomes a plain mean, and there is no backend divergence. The drift risk
that buys is fenced by a slow moment-comparison against
`pm.sample_posterior_predictive` on a fit with strongly correlated shocks
(a near-diagonal Sigma would let a Cholesky-orientation bug through).

`self.idata` is never mutated — a fresh InferenceData with
`posterior_predictive` and `observed_data` comes back, and the docstring
shows the `fitted.idata.extend(ppc)` recipe.

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

* docs(predictive): how-to page, ADR-0011 and CONTEXT entries (#56)

* `docs/how-to/predictive-checks.md` (unexecuted MyST, zero CI cost):
  prior check via `az.plot_ppc(..., group="prior")`, posterior check plus
  a coverage snippet, mean mode for residual diagnostics, the
  `idata.extend` recipe, the memory note, and a table separating the two
  checks from `forecast` / `conditional_forecast`.
* ADR-0011 records why the posterior predictive is NumPy rather than
  `pm.sample_posterior_predictive` (ConjugateVAR parity, mean mode,
  backend independence, the volatility seam) and names the slow test
  that fences the resulting drift risk.
* CONTEXT.md gains a "Predictive check" term, a relationship line and a
  dialogue entry, all insisting these are estimation-window objects and
  not forecasts.

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

* fix(predictive): keep the new annotations beartype-resolvable (#56)

`impulso.enable_runtime_checks()` beartype-wraps the public classes, and
beartype resolves return annotations on call. It cannot resolve a DOTTED
forward reference whose module is TYPE_CHECKING-only: `-> tuple["pm.Model",
int]` on `_build_pymc_model` raised BeartypeCallHintForwardRefException on
the first `prior_predictive` call under checks. Nothing in the suite
exercised that combination.

* `_build_pymc_model` now returns `tuple[Any, int]` — the same reason
  `FittedVAR.pymc_model` is `Any`, and PyMC stays lazily imported.
* `spec.py` imports arviz at module level (it is already loaded by the
  time `import impulso` returns, so this costs nothing) and annotates
  `prior_predictive` with the real `az.InferenceData`.
* New slow test drives both methods in a throwaway interpreter with
  checks enabled, and asserts a bad argument is still flagged.

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

* test(predictive): pin the scale-adaptive exog prior to the shared graph seam (#56)

Rebasing onto main brought in #237, which computes the `B_exog` prior
standard deviation from the data (`_exog_prior_sigma`) inside the region
this branch extracted into `VAR._build_pymc_model`. The extraction is what
makes the merge safe: the scaled prior lives in the shared seam, so
`prior_predictive` draws from the same graph `fit` samples. Had it stayed
in `fit`, a prior-predictive check would have described a unit-scale model
that was never estimated — silently.

Nothing pinned that, so:

* `TestExogPriorWiredIntoModel` gains two graph tests mirroring the
  existing `dist_params`-based fit-path ones — the `_build_pymc_model`
  graph's `B_exog` sigma equals `_exog_prior_sigma(...)`, honours
  `exog_prior_scale`, and is identical to the sigma the fit path builds.
* `TestPriorPredictive` gains an end-to-end check that the simulated
  `B_exog` draws actually spread at that sigma rather than at 1.

Also refreshes the two docstrings the merge made stale: `exog_prior_scale`
no longer claims to apply to `fit` alone, and `_build_pymc_model` now
states that every prior — the exog one included — lives in the seam.

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

* fix(spec): keep the sampling tail in fit and rethread error_dist after rebase

The rebase onto main duplicated fit's sampling tail into _build_pymc_model
and dropped error_dist from fit's model_construct call.

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 added a commit that referenced this pull request Jul 29, 2026
… columns)

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
…ss (#226)

* feat(granger): posterior causal-strength query on FittedVAR (#154)

`FittedVAR.granger_causality(cause, effect)` reports the posterior of the
Euclidean norm of the tested lag coefficients of `cause` in the `effect`
equation, plus the per-lag posteriors behind it. A magnitude, not a test
statistic: nothing divides through by the posterior covariance, so a small
effect stays distinguishable from an imprecise one.

An optional `rope` adds `p_rope = P(||b|| < rope | data)` — practical
negligibility at a threshold the analyst names, deliberately with no
default. It is not the probability of no causality: `b = 0` has probability
zero under continuous coefficient priors, so that quantity needs a
spike-and-slab prior Impulso does not fit. The `GrangerCausalityResult`
docstring carries the full statement.

The engine lives in a new private `_granger.py` (the `conditional_forecast`
delegation precedent), and its extraction is pinned by tests against a
hand-built posterior with every entry distinct, cross-checked against
`lag_matrices` so the lag-major layout cannot drift between the two
consumers.

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

* feat(granger): toda_yamamoto() lag-augmented mode (#154)

`toda_yamamoto(data, cause, effect)` runs the Toda-Yamamoto (1995)
procedure for possibly-integrated systems: fit the VAR in levels with
`p + d` lags, test only the first `p`. The augmented lags are never tested
and the reported test lag order is never silently changed to match the fit
— the result carries `n_lags_tested` and `n_lags_fitted` separately, with
`augmentation` and `augmentation_source` recording where the extra lags
came from.

`d` comes from `integration_order` unless the caller pins it. Honouring the
consumer contract frozen by #140/#197: when the diagnostics leave anything
in `inconclusive`, `d_max` is a floor rather than a finding, so this
refuses to run — naming the variables, pointing at `.summary()` and at the
`d=` override — rather than under-augmenting silently. An explicit `d`
skips the diagnostics entirely, so the route also works without
statsmodels installed.

The fit uses the closed-form conjugate estimator, since augmentation
inflates the lag order; exogenous regressors it cannot consume are refused
with a message naming the manual `VAR(lags=p + d)` route.

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

* docs(granger): how-to, reference, CONTEXT terms, and ADR-0010 (#154)

New how-to covering the fitted-model query, how to read `summary()`, why
there is no probability of no causality, the Toda-Yamamoto happy path plus
its refusal and the `d=` override, the manual NUTS route, and a worked
carbon-dioxide/temperature example with an explicit statement of what it
does and does not license (predictive precedence not intervention; omitted
forcings; bidirectional physical coupling; annual aggregation). Cross-links
with the climate-pitfalls page both ways.

New reference page for `toda_yamamoto`; `GrangerCausalityResult` added to
the results page. CONTEXT gains three terms — Granger causality, ROPE,
Toda-Yamamoto augmentation — plus the two relationships that place the
query on `FittedVAR` and wire the augmentation to the integration-order
contract.

ADR-0010 records the decision: the norm of the tested coefficients as the
headline with an analyst-supplied ROPE, against the rejected alternatives
(a Wald quadratic headline, a fixed default epsilon, spike-and-slab, and
Savage-Dickey).

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

* test(granger): pin the posterior dim-realignment fallbacks (#154)

A transposed posterior must be realigned by its canonical dim names, and
an unlabelled one must fall back to the positional (chain, draw, var,
coeff) convention — the same contract `dynamic_multiplier` relies on.
Neither path was exercised. Takes `_granger.py` to full branch coverage.

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

* docs(granger): state p_rope as the probability of the event, not the event

The honesty paragraph's one imprecise sentence read the probability as an
assertion of practical negligibility; a p_rope of 0.02 says no such thing.

Refs #154

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

* test(granger): use a non-constant exog fixture (#237 rejects constant columns)

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 breaking-change Breaking changes label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Breaking changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

B_exog prior is fixed at unit scale and does not adapt to regressor magnitude

2 participants