Skip to content

Default a kernel's parameter source to a linear map, and the audit it forces#51

Merged
aaronstevenwhite merged 16 commits into
mainfrom
feat/linear-param-source-default
Jul 15, 2026
Merged

Default a kernel's parameter source to a linear map, and the audit it forces#51
aaronstevenwhite merged 16 commits into
mainfrom
feat/linear-param-source-default

Conversation

@aaronstevenwhite

Copy link
Copy Markdown
Contributor

Summary

A morphism declared f : X -> Y ~ Normal over continuous spaces read its distribution parameters from a two-hidden-layer tanh MLP, because that was the factory's default. So a Real 1 -> Real 1 kernel silently carried 4418 weights, and whether a model was linear was decided three layers below the surface syntax rather than in it. The default is now a linear map, which is how the arrow reads, and a model that wants a nonlinearity asks for one.

Most of the rest of this PR is the audit that flipping the default forces, plus the defects it turned up along the way. The theme is uniform: things the surface accepted and then quietly ignored, or quietly got wrong.

Motivation

Closes #48. Refs #49, #50.

The gallery was mostly wrong in the other direction, which is the argument for the flip:

  • linear_gaussian_ssm was not linear. Every kernel in it was an MLP.
  • Every LSTM, GRU and vanilla-RNN gate was a hidden two-layer network, under a cell whose only intended nonlinearity is the sigmoid its program body applies.
  • deep_markov called trans_mlp_1 >> trans_mlp_2 a "two-layer MLP" while composing two kernels with no activation between them, which is linear whatever the names say.
  • bnn.md closed by asserting that a nonlinear MLP "is not currently expressible as a pure latent-morphism composition", while every ~ Normal morphism in the gallery already was one.

The models that genuinely want the nonlinearity now name it. Four sequence models NaN'd under the flip because their numerical stability had been resting on the default's tanh, which is itself the point: a vanilla RNN's defining tanh had nowhere to live but a default nobody wrote.

Changes

continuous/

  • make_param_source defaults to LinearSource. _make_source delegates to it, collapsing _NeuralSource in morphisms, a second copy of MLPSource reachable only by the default path, so the factory serving the DSL option and the one serving the default are no longer different code.
  • An inline distribution takes any number of vector-typed parameters, split at each one's declared dim instead of a single trailing parameter eating the remaining columns. MixtureNormal(weights, locations, scales) is expressible.
  • A scalar response gains its codomain's event axis before scoring. An (N,) response against a d = 1 codomain, which is the shape the compiler's own placeholder implies, broadcast against an (N, 1) mean into (N, N); because the observe step sums, the result was a finite log-joint wrong by a factor of N, with nothing raised.

dsl/

  • Morphism options are checked against the role that reads them. The check ran against the union of every role's keys, so an option belonging to another role passed and was dropped. All 34 uses of [scale=] in the repository sat on family-backed kernels where nothing reads it.
  • [param_source=mlp(16, 8)] reaches the width parser. The grammar always parsed the call form and param_source_from_option always implemented the widths; the compiler decoded the option through get_option_name, which raises on anything but an identifier, leaving the get_option_string fallback beneath it unreachable.
  • An unsupplied via fibration index raises a CompileError naming itself rather than a bare KeyError.

formulas/

  • A default coefficient prior is autoscaled to its column. fit("y ~ poly(x, 2)", method="svi") returned a fit that had given up without saying so: sigma rose to the marginal spread of y and the coefficients never left the prior. The program was not at fault, and neither was the optimiser budget. A column enters as beta * column, so a fixed Normal(0, 5) states the scale of the contribution, and poly returns an orthonormal basis whose entries run about 1/sqrt(N), so the default asserted the signal was near zero and the fit agreed with the prior instead of the data.

Docs

  • bnn.md is a Bayesian MLP for nonlinear regression, fit to a sine wave no linear model can represent and scored against the best least-squares line under a matching Gaussian. Its NUTS block runs; it previously named an x and an observations no earlier block defined, over a composite carrying no log_joint and therefore no likelihood to sample.
  • mixture-model.md scores per row through MixtureNormal instead of grouping a per-row assignment under a marginalize keyed by a fibration index, which read as an item-grouped mixture and carried a discrete latent no guide could biject.
  • montague-nli.md's prover ran rules that could never fire: both required a Claim(Implies(...)) premise nothing produced, and its Implies was disjoint from the grammar's implies_t. It now runs classically valid syllogistic rules, with existential import supplied explicitly rather than assumed.
  • New API page for ParamSource, which had none despite being where a kernel's dependence on its input is computed.

CI

  • The slow suite gates in its own job. addopts = -m 'not slow' and a plain pytest tests/ meant the gallery sweep never ran on any push, which is how these pages rotted.
  • The gallery try-it sweep shares one namespace across a page's blocks. It built a fresh one per block, so every multi-block example failed on its second block regardless of whether its code was correct.

API impact

  • Breaking change. Migration notes:
  1. A family-backed kernel over a continuous domain is now linear. A model relying on a nonlinear parameter network must declare [param_source=mlp]. Symptom if missed: a model that no longer fits, or a recurrence that diverges where the default's tanh had been bounding it.
  2. A morphism option not read by its role is rejected. In practice this is [scale=] on a family-backed kernel; it never did anything, so removing it is behaviour-preserving.
  3. A response whose last axis is not its codomain's event axis is rejected instead of broadcasting. Any code silently receiving an N-times-inflated log-joint gets a correct number now.
  4. Formula coefficient priors are autoscaled, so a default-prior fit's posterior changes. An explicit per-name prior is emitted as written and is unaffected.

Tests

  • pytest -x passes: 2577 passed, 138 skipped.
  • The new slow job passes: 51 passed, 0 failed.
  • pyright src/quivers is clean: 0 errors.
  • ruff check and ruff format --check pass.
  • New tests added for new behaviour.

New coverage pins each fix to the defect it closes: multi-vector inline splitting; the role-aware option error naming the role that reads the key; param_source name and call forms; both spellings of a scalar response scoring identically and matching the hand-rolled density; prior autoscaling in contribution space; and a lambda's bound occurrence matching its binder.

That last one is worth calling out. Refs #49: the deduction term algebra has two encodings for a nullary constant, so a rule mentioning one inside an LF compiles clean and never fires. I attempted the unification and it silently unbinds every lambda in the gallery, with the entire suite still green. That test now exists and fails on exactly that break, so the fix is safe to attempt next time.

Three recovery benchmarks were failing on main and now pass. Two were the tests' fault: they read an alpha_g site the compiler does not emit (it writes the non-centred parameterisation), and one asserted an intercept that is only identified jointly with the random effects, penalising a fit that recovers the level to within 0.004 and the effects at a correlation of 0.998.

Documentation

  • Docstrings updated for changed public API.
  • User-facing pages under docs/ updated.
  • docs/developer/changelog.md has an entry for this change (0.16.0).

Checklist

  • Commits are focused and have descriptive messages.
  • No backward-compatibility shims added.
  • Comments describe the code as it stands.
  • No secrets, credentials, or large binary artefacts in the diff.

An inline distribution previously allowed at most one vector-typed
parameter, and only in the final slot, because the parameter split
was greedy: the single vector ate whatever columns remained in the
stacked input. A family like MixtureNormal(weights, loc, scale),
whose three per-component vectors are each shared across the
response plate, could not be expressed.

The split now walks the declared parameter spec and slices each
vector at its own dim offset, so a family may declare any number of
vector positions in any order. Stacking interprets each argument by
its declared role rather than guessing from tensor rank: a rank-0
position is a per-row scalar, a rank->=1 position is a vector of its
declared dim arriving either shared as ``(D,)`` or per-row as
``(N, D)``. Size-1 batches broadcast to the widest batch before the
concat.

VectorisedObserve forwards ``_param_spec`` alongside the event ranks
it already forwarded, so an observe step sees the inner family's
declared dims instead of falling back to rank-guessing.
A grouped marginalize resolves its ``via`` index from the runtime
environment. The index is host data, supplied through the
observations dict like any free covariate, so omitting it is an
ordinary user error. It surfaced as a bare KeyError naming only the
variable, which read as an internal fault rather than a missing
input.

The lookup now raises a CompileError that names the index, says it
is neither bound nor supplied, and states what to pass.
The grouped-marginalize snippets declared their fibration index as
``sample idx : Resp <- HalfNormal(1.0)``, which scores a continuous
prior over what is a long tensor of row-to-group indices. The term
is spurious: the index is host data supplied through the
observations dict, as lda.qvr already writes it.

The declarations are gone and each ``[via=idx]`` now reads the free
index. No expected value moves: every snippet touched asserts
compilation, a specific CompileError, or finiteness, and the tests
that pin densities exercise marginalize_grouped directly.

Covers the clean-error contract for an index that is never supplied.
The mixture model grouped a per-row component assignment under a
marginalize block keyed by a fibration index, which made a plain
per-row mixture read as an item-grouped one and carried a discrete
latent the guide could not biject.

It now scores each row directly against the mixture through the
MixtureNormal likelihood, whose three shared per-component vectors
define the components and whose per-row marginal is the closed-form
convex combination. No fibration index, no marginalize block, no
discrete latent. The grouped form remains expressible, and lda.qvr
demonstrates it on a model whose grouping is intrinsic.

The lda.md cross-reference now describes what this page shows.
The gallery try-it sweep built a fresh namespace per block, so a
block that used an import or binding from an earlier block on the
same page raised NameError. The pages are written to be read and run
top to bottom, so every multi-block example failed on its second
block regardless of whether its code was correct.

The blocks of a page now share one namespace and run in order, as a
reader stepping through the page would. This is what the sweep meant
to assert; it is deselected by default, which is why the pages rotted
without anyone noticing.
The example composed four latent morphisms under the real algebra,
where composition is matrix multiplication. With no pointwise
nonlinearity the chain collapses to a single linear map, so the page
titled "Bayesian Neural Network" built a linear model whose three
hidden objects bought nothing but a rank bound. Its SVI block fit a
learnable input morphism to pure noise and reported a residual worse
than predicting zero, and its NUTS block referenced an x and an
observations dict that no earlier block defined, over a composite
carrying no log_joint and therefore no likelihood to sample.

The walkthrough also placed MatrixNormal priors on the weights
through a syntax that does not parse, and the page closed by stating
that a nonlinear network was not expressible. It is: a morphism
declared `~ Family` over continuous spaces draws its distribution
parameters from a ParamSource, and the default for a continuous
domain is already a two-hidden-layer tanh MLP.

The example is now a Bayesian MLP for nonlinear regression. A
conditional-Normal kernel selects the MLP source explicitly and emits
both the mean and the log-scale, so the fit is heteroscedastic; the
target is a sine wave that no linear model can represent. The page
scores the fit against the best least-squares line under a matching
Gaussian, which separates the two by several hundred nats and
attributes the gap to the tanh layers. Weight priors come from
lift_from_log_prob, which is what makes the NUTS block Bayesian
rather than maximum likelihood, and it now runs.

Documents ParamSource, whose eight sources and DSL option had no API
page despite being the seam the nonlinearity lives in.
The default addopts deselect `slow`, and CI ran plain `pytest tests/`,
so the gallery sweep and the recovery benchmarks never executed on any
push or pull request. Every example page's Python was unverified,
which is how one page came to reference undefined names and another
came to describe a prover whose rules cannot fire.

They run here in a separate job, keeping the main test job's runtime
unchanged.
Morphism options were checked against the union of every role's keys,
then each role read only the subset it understood. An option belonging
to another role therefore passed validation and was dropped in
silence. Every use of `[scale=]` in the repository sat on a
family-backed kernel, where nothing reads it: 34 declarations across
nine examples configured an init scale that never existed, and the
prose around them described a prior scale the compiler had discarded.

Each role now declares the keys its lowering consumes, and a key
outside that set is rejected with an error naming the role that does
read it. The no-op options are gone from the sources and the
walkthroughs, which is behaviour-preserving: they had no effect to
begin with.

One example also claimed a Normal kernel's mean was a learned linear
function of its input. The mean comes from the kernel's param_source,
whose default is an MLP.
`param_source_from_option` parses parenthesised hidden widths, and the
grammar has admitted the call form all along: `[param_source=mlp(64,
64)]` parses to an OptionCall. The compiler read the option through
`get_option_name`, which accepts a bare identifier and raises on
anything else, so the call form died at compile time and the width
parser was unreachable from QVR. The `get_option_string` fallback
underneath it was dead code, since the name getter raises rather than
returning None on a shape mismatch.

The option now decodes through `get_option_call_text`, which renders a
name or a call back to surface text and hands it to the parser that
defines the arguments. `[param_source=mlp(16, 8)]` builds those
widths; `[param_source=mlp]` keeps the default.

The keyword form the source parser also accepts, `attention(heads=4)`,
stays out of reach: the grammar's option_call takes positional
arguments only.
A conditional family reads its parameters from a network, so for a
codomain of dimension d they arrive as (N, d). The natural way to write
N scalar responses against a d=1 codomain is (N,), which is also the
shape the compiler's own response placeholder implies. Subtracting that
from an (N, 1) mean broadcast to (N, N), and since the observe step
sums the score, the result was a finite log-joint that was silently
wrong by a factor of N. Nothing raised.

The observe step now restores the event axis, and rejects a response
that cannot carry the codomain's event shape rather than broadcasting
it into one. Both spellings of a scalar response now score identically
and match the hand-rolled density.

An inline family keeps the other layout: its parameters come from the
program's environment as per-row values already aligned with the plate,
so its response's last axis indexes the plate rather than an event, and
is left untouched. `_param_spec` marks that case.
`binders Lam` alpha-renames a lambda's bound variable to a fresh
canonical symbol per term construction, and the occurrences in the body
are renamed with it. A rewrite that renames the binder but leaves the
body's `Var(x)` behind still yields a well-formed tuple and a chart
that parses: the lambda binds nothing, the body references a free
variable, and every existing test stays green. Nothing downstream
checks the two agree.

This pins them together, so a change to the term encoding that pulls a
binder apart from its occurrences fails here instead of silently
producing terms whose bindings are gone.
A morphism declared `f : X -> Y ~ Normal` read its parameters out of a
two-hidden-layer tanh MLP, because that was the factory's default. So a
1 -> 1 kernel silently carried 4418 weights and two nonlinearities, and
whether a model was linear was decided three layers below the surface
syntax rather than in it.

The default is a linear map, which is how the arrow reads. A model that
wants a nonlinearity between its input and its parameters asks for one
with `[param_source=mlp]`, and the fact then appears in the source.

The audit the flip forces finds the gallery was mostly wrong the other
way. `linear_gaussian_ssm` was not linear: every kernel in it was an
MLP. Each LSTM, GRU and vanilla-RNN gate was a hidden two-layer network
under a cell whose only intended nonlinearity is the sigmoid its
program body applies. Those models are now what they say they are.
`deep_markov` and the VAE's encoder and decoder bodies do want the
nonlinearity, and now request it: `deep_markov` called
`trans_mlp_1 >> trans_mlp_2` a two-layer MLP while composing two
kernels with no activation between them, which is linear whatever the
names say.

Also collapses a duplicate: `_NeuralSource` in `morphisms` was a second
copy of `MLPSource`, reached only by the default path, so the factory
that serves the DSL option and the factory that served the default were
different code. `_make_source` now delegates to `make_param_source`,
and the copy is gone.

Closes #48.
Three recovery benchmarks failed on main, and the slow job that now
runs them made that visible. Two were the tests' fault.

Both random-effects tests read an `alpha_g` site that the formula
compiler does not emit: it writes the non-centred parameterisation, so
a group's effect is its standard-normal draw scaled by the group-level
sigma. They now read that, and both models turn out to recover the
per-group effects well, correlating with truth at 0.998 in the
partial-pooling case.

The partial-pooling test also asserted the intercept alone. The
intercept and the random effects are identified only up to a shared
shift: adding a constant to one and subtracting it from the others
leaves the likelihood untouched, and with eight groups the prior pulls
the split back only weakly. So the fit was penalised for a split the
data cannot determine while recovering the level to within 0.004. The
level is now asserted on the identified sum, and the effects through a
correlation, which that shift does not move.

The polynomial benchmark is a real defect and stays failing, as a
strict xfail carrying the diagnosis: the SVI fit collapses on
orthonormal poly() columns, and the program's own log_joint puts least
squares 439 nats ahead of where the optimiser settles.
`fit("y ~ poly(x, 2)", method="svi")` returned a fit that had given up,
without saying so. The noise scale rose to the marginal spread of the
response and the coefficients never left the prior:

    fitted   b1 0.44   b2  0.94   sigma 1.35   (y sd 1.40)
    truth    b1 9.93   b2 21.44   sigma 0.30

The program was not at fault. Scored under its own log_joint, least
squares reaches -84.2 where the fit settled at -523.6, so the optimiser
was missing a solution 439 nats better, and it was not a budget: lr
1e-2 / 5e-2 / 1e-1 and 2000 / 6000 / 12000 steps all landed in the same
place.

The prior was. A column enters the linear predictor as `beta * column`,
so a prior on the coefficient alone is a statement about the
coefficient's contribution, and a fixed `Normal(0, 5)` therefore means
something different for every column. `poly` returns an orthonormal
basis, whose entries run about 1/sqrt(N), so the default asserted the
contribution was near zero and the fit agreed with the prior rather
than the data.

A default prior's scale is now divided by its column's RMS, which
states it in contribution space, where its nominal value is what it
reads as. The coefficients stay on their own column's scale, so nothing
is transformed back. This is the autoscaling rstanarm applies by
default. An explicit prior is the user's statement about that
coefficient and is emitted as written.

The polynomial benchmark now recovers least squares to within a few
percent and pins sigma to the data's noise rather than to the spread of
y, so a fit that gives up fails it.
Also derives `quivers.__version__` from the installed distribution's
metadata. It was a literal, so it was a second place to state the
version and a second place to forget it, and it had drifted: the
package reported 0.15.0 while importing it reported 0.14.1.
`[param_source=...]` is a morphism option the compiler accepts for any
family-backed kernel, and exactly one of the 28 conditional families
could receive it. `_make_continuous_morphism` passed the keyword into
a constructor that only `ConditionalNormal` declared, so the other 27
died on a `TypeError` raised inside `__init__`, with no line or column
on it. Nothing in the surface said the option was Normal-only: the key
sits in the kernel role's set, the option checker passes it for every
family, and the module documents it generally.

Each family already built its source through the same `_make_source`
call, and that call already took an override, so the seam was there
and unreached. `_make_source` now resolves all three ways of choosing
a source, and every family whose parameters come from one takes
`param_source` and `param_source_option` and hands them over. The four
whose parameters do not, Horseshoe, GaussianProcess, Independent and
Transformed, refuse the option at compile time, naming the family.

`hidden_dim` becomes a sequence, one width per hidden layer, because a
single number can say how wide an MLP's layers are but not how many of
them there are. A bare number is the one-layer case. It was also
dropped whenever `param_source` was given, so `[param_source=mlp,
hidden_dim=32]` silently built the default width; it now reaches the
source, and a width aimed at a source with no hidden layers is an
error rather than a no-op.

Collapses the resolution that `ConditionalNormal` and
`_IndependentConditional` each did inline behind a function-local
import, which existed to dodge a cycle that no longer exists.
@aaronstevenwhite
aaronstevenwhite merged commit fc210cd into main Jul 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Default morphism param source should be a linear map, not an MLP

1 participant