API reference¶
Generated from the source docstrings. Layering rule: rheplicant.core is
domain-agnostic; rheplicant.radio and rheplicant.inference build on it; and
rheplicant.config sits above all three, importing them and imported by none of
them.
For the prose behind these signatures: the guided tour for the shape of a twin, the operator catalog for what lives at each graph node, inferring anything for the parameter-space machinery, contracts between stages for the refusals, values in a config document for the grammar the config layer resolves, and resources and paths for the loader built on top of it.
rheplicant.core¶
State: the immutable scientific context flowing through a Pipeline.
A State is a JAX pytree (an equinox.Module), so it can be passed through
jit / grad / vmap / scan directly. It carries everything a
pipeline might need — signal data, coordinates, environment telemetry,
metadata, randomness — even though not every field enters the forward model:
metadata and environment ride along for diagnostics, correlation studies,
sensitivity analysis and reproducibility.
Field taxonomy¶
- Traced (pytree leaves — differentiable, vmappable):
data,coords,env,aux,key- Static (part of the treedef — participates in the jit cache key):
meta
Rule of thumb: strings/labels/settings go in meta (changing them triggers
recompilation, by design); numbers and arrays go in aux/env/coords.
PRNG protocol¶
Operators that need randomness must consume it functionally:
subkey, state = state.next_key() # never reuse state.key directly
noise = jax.random.normal(subkey, shape)
and return the advanced state, so a single seed reproduces the entire run and keys are never reused across operators.
- class rheplicant.core.state.State(data=None, coords=None, env=None, aux=<factory>, key=None, meta=<factory>)[source]¶
Bases:
ModuleImmutable data/state container for differentiable pipelines.
- Parameters:
data (Any)
coords (Coordinates | None)
env (Environment | None)
key (Array | None)
meta (FrozenMapping)
- data¶
the signal/data payload — any pytree of arrays (a single array, a tuple, or a dict of named streams). Convention is set by the operators acting on it, not by State itself.
- Type:
Any
- coords¶
Coordinates(time / freq / pointing / extra), or None.- Type:
- env¶
Environment(numeric telemetry), or None.- Type:
- meta¶
static, hashable metadata (
FrozenMapping) — experiment info, hardware labels, observation IDs, arbitrary user fields.
Example:
state = State( coords=Coordinates(time=t, freq=f), key=jax.random.key(0), meta={"telescope": "RHINO", "obs_id": "demo-001"}, ) out = pipeline(state)
- replace(**changes)[source]¶
Return a new State with
changesapplied; the original is untouched.Built on
dataclasses.replace, so converters and validation re-run on every update. For surgical edits deep inside nested pytrees,equinox.tree_atremains the right tool.
- checkpoint(name='raw')[source]¶
Snapshot the current data into
aux["snapshot/<name>"].Zero-copy (JAX arrays are immutable — the snapshot is a reference), so checkpointing raw data before destructive processing (calibration, filtering) costs nothing. Retrieve with
state.aux["snapshot/raw"].- Raises:
StateValidationError – if there is no data to snapshot.
- Parameters:
name (str)
- Return type:
Coordinates: the traced coordinate container flowing with a State.
All fields are optional and traced (they are pytree leaves, so they can be
jitted / vmapped / differentiated through). Validation runs at construction and
again on every functional replace.
Angle convention (RHINO family): degrees in public-facing APIs, radians internally. This module stores whatever it is given — the convention is enforced by operators, not by the container.
One value check, and why it lives here. Validation in this package is
otherwise structural (ndim only) and therefore value-independent and jit-safe.
time is the exception, because storing it is itself lossy: the converter
below calls jnp.asarray, which is float32 unless x64 is enabled, and a
unix-second axis (~1.75e9) has a float32 resolution of 128 s. Samples 100 s
apart merge at store time, and no later subtraction can undo it — the
quantities every consumer reads are already the rounded ones, so a consumer’s
own consistency checks compare corrupted values against corrupted values and
see nothing wrong. That makes the container the last place the loss is
attributable to anything: one stage later there is only a shorter list of
plausible-looking timestamps. See
_refuse_a_time_axis_the_stored_dtype_cannot_carry().
- rheplicant.core.coordinates.MAX_TIME_RESOLUTION_IN_SAMPLES = 0.01¶
Largest fraction of one sample interval that
coords.time’s own representable resolution may occupy.1e-2 rather than something tighter because a sample is an AVERAGE over its own integration, so its time tag is only meaningful to within one interval to begin with; demanding that the representation error be a hundredth of that leaves two orders of magnitude of headroom below what the axis itself means. At a ratio of 1.0 samples merge outright, which is the measured failure — 100x beyond this cut. Seconds from the start of the run, of the day (86400 -> 3.9e-5 of a 100 s interval) or of the month (2.6e6 -> 2.5e-3) all pass; seconds from the start of the YEAR (3.15e7 -> 2e-2) do not, and should not, because elapsed times there are already wrong by 2 s.
What this costs a long run. For a uniform axis measured from its own start the peak is
n_samples * cadence, so the ratio isspacing(n*cadence) / cadence— and sincespacing(x)is within a factor of two ofx * 2**-23, the cadence very nearly cancels and what the cut constrains is the sample COUNT. The limit for float32 is therefore of order 1e5 uniform samples, anywhere in[1e-2 * 2**23, 1e-2 * 2**24]= [8.4e4, 1.7e5]; the exact count depends on wheren * cadencefalls inside its binade, so it is not one number (at 1 s cadence it is exactly 2**17 = 131072). A four-hour RHINO run at 1 s is 1.4e4 samples, an order of magnitude clear; the same run at 0.05 s is 2.9e5 and is refused. That is a real limit of fixing the axis by making it RELATIVE rather than float64, and it is stated rather than papered over: relative buys about five decimal orders over a unix epoch, not unlimited range.Stated once, here, because it is a property of how
coords.timeis STORED rather than of any one operator’s arithmetic:rheplicant.radio.instrument.calibrationimports it rather than keeping its own copy.
- rheplicant.core.coordinates.as_array_or_none(value)[source]¶
Converter: pass
Nonethrough, coerce everything else to a jax array.
- class rheplicant.core.coordinates.Coordinates(time=None, freq=None, pointing=None, extra=<factory>)[source]¶
Bases:
ModuleCoordinate axes of the data flowing through a pipeline.
Environment: traced environmental telemetry riding along with a State.
These fields typically do NOT enter the forward model — they are preserved for diagnostics, correlation studies, sensitivity analysis and reproducibility. Because they are traced pytree leaves (not static), they can also be promoted into the forward model later (e.g. temperature-dependent gain) without any structural change.
Non-numeric descriptors (weather strings, site names…) belong in
State.meta, not here.
- class rheplicant.core.environment.Environment(temperature=None, humidity=None, extra=<factory>)[source]¶
Bases:
ModuleNumeric environmental telemetry.
- extra¶
dict of additional traced telemetry arrays (wind speed, soil moisture, receiver enclosure temperature, …).
FrozenMapping: an immutable, hashable mapping for static metadata.
State.meta is a static pytree component: it lives in the treedef, not in
the traced leaves, so under jax.jit it participates in the compilation
cache key. That requires it to be hashable and support == — which plain
dict does not. FrozenMapping provides exactly that, and validates at
construction (the system boundary) that every value is itself hashable.
Rule of thumb for users: strings/labels/settings go in meta (changing them
recompiles); numbers and arrays go in State.aux / env / coords
(traced, differentiable).
- class rheplicant.core.frozen.FrozenMapping(data=None, **kwargs)[source]¶
Bases:
MappingImmutable, hashable
Mapping[str, Hashable].Functional updates return new instances:
meta = FrozenMapping(telescope="RHINO") meta2 = meta.set(obs_id="demo-001") # meta unchanged meta3 = meta2.remove("obs_id") merged = meta | {"band": "low"}
- set(**kwargs)[source]¶
Return a new FrozenMapping with
kwargsadded/overridden.- Parameters:
kwargs (Any)
- Return type:
Operator: the universal transformation interface.
Everything in rheplicant follows one contract:
state_out = operator(state_in) # State -> State, pure
An operator is an equinox.Module: its array-valued fields are traced
pytree leaves (and therefore differentiable parameters “for free”), while
non-array fields are static configuration. Select the trainable parameters
with the standard Equinox idiom:
params, static = eqx.partition(op, eqx.is_inexact_array)
Design note: AbstractOperator is an interface (one abstract
method), not a class hierarchy — there are deliberately no intermediate base
classes. Shared behaviour belongs in helper functions and composition
(Pipeline), not inheritance.
- class rheplicant.core.operator.AbstractOperator[source]¶
Bases:
ModuleA pure, differentiable transformation
State -> State.- requires¶
dotted State paths this operator reads, e.g.
("data", "coords.freq", "key"). Mostly documentation — with one enforced member,"key": see below.
- provides¶
dotted State paths this operator writes, e.g.
("data",). Documentation.
- graph_node¶
home node on a SignalGraph template (assembly);
Nonemeans “place explicitly with At(node, op)”.- Type:
ClassVar[str | None]
- must_precede¶
node ids this operator’s contribution must flow THROUGH — an ordering constraint on the signal path, checked by
assemble().
- must_precede_because¶
the physics behind that constraint, in one sentence, quoted back by the refusal. Empty is allowed and the refusal still names the operator, the constraint and the actual placement; it just cannot say what the wrong placement costs.
- Type:
ClassVar[str]
"key"inrequiresis a contract, not a note: it says this operator draws randomness throughnext_key(), and the inference layer refuses a model that contains one — a frozen draw from the template key would be added to every prediction alike, which is a bias no shape check, no linearity check and no rank test can see (the corruption is exactly affine and full rank).rheplicant.core.contractreads the declaration;refuse_stochastic_stages()is the consumer.The rest is descriptive, and that is a decision rather than an omission. Threading paths forward from a template State and refusing an operator whose
requiresnames an absent one is not implementable against the operators shipped here:GroundPickupOperatordeclares"env.temperature"and documents at_groundfallback for when it is missing, so its declaration means “reads if present”. Andprovidesis("data",)on nearly every declaring class, so threading it distinguishes nothing the graph’s own node kinds do not already say – the measured ratio is inrheplicant.core.contract, which is the one place that states it. These tuples therefore describe intent and carry exactly one enforced rule; they are not a checker waiting to be written.must_precedeis NOT one of their consumers and is deliberately a third declaration: requires and provides speak in State paths, and every operator on the receiver chain reads"data"and writes"data", so “before the gain” is not a sentence that vocabulary can form. Position on the signal path is the graph’s subject, so the constraint is stated in the graph’s nouns (node ids) and enforced where the graph is compiled.- Rules for implementors:
Never mutate the input state — return
state.replace(...)/state.with_data(...).Randomness must go through
subkey, state = state.next_key()and the advanced state must be the one returned.Only structural (shape/dtype) validation inside
__call__— value checks would break under jit.
- class rheplicant.core.operator.LambdaOperator(fn)[source]¶
Bases:
AbstractOperatorWrap a pure function
State -> Stateas an operator.The wrapped function is static (part of the module structure, not a traced leaf); it hashes by identity, so reuse the same LambdaOperator instance rather than re-creating identical lambdas if jit-cache hits matter.
Example:
clip = LambdaOperator.on_data(lambda d: jnp.clip(d, 0.0, 1.0))
- class rheplicant.core.operator.SnapshotOperator(name='raw')[source]¶
Bases:
AbstractOperatorSave the current data into
aux["snapshot/<name>"](zero-copy).Place at the start of a processing pipeline to preserve raw data through destructive steps (calibration application, filtering):
analysis = Pipeline(SnapshotOperator(name="raw"), apply_cal, sidereal_filter) raw = analysis(state).aux["snapshot/raw"]
- Parameters:
name (str)
Pipeline: ordered composition of operators — itself an operator.
Because Pipeline satisfies the same State -> State contract as any other
operator, pipelines nest freely (composite pattern):
instrument = Pipeline(beam, receiver, names=("beam", "rx"))
full = Pipeline(sky, instrument, backend)
Execution is a plain Python loop over heterogeneous stages: under jax.jit
this unrolls into one fused computation, which is exactly right when every
stage is a different operator. (A lax.scan over a homogeneous stack of
identical operators is a different, complementary pattern — deliberately not
built here.)
- rheplicant.core.pipeline.validate_operators(operators, owner)[source]¶
Shared constructor validation for composite operators (Pipeline, SumOperator).
- Parameters:
operators (Sequence[AbstractOperator])
owner (str)
- Return type:
- rheplicant.core.pipeline.resolve_names(operators, names)[source]¶
Shared name resolution/validation for composite operators.
- rheplicant.core.pipeline.check_stage_ordering(stages, names)[source]¶
Enforce
must_precedeagainst THIS SEQUENCE, for composition by hand.assemble()enforces the same declaration by reachability on a template, and for a while it was the only thing that did. The two routes compile to the same composition, so the refusal was one line of call site away from silence:assemble(..., At('noise', tone)) AssemblyError: 'bandpass' is not reachable Pipeline(sky, band, gain, tone) no error; the tone's response is 1.0
A tone injected downstream of the gain it is meant to track has a gain response of exactly 1.0 — it monitors nothing, which is the sentence
must_precede_becauseexists to say — and the run converges and reports healthy diagnostics.What this checks, and why it is weaker.
must_precedenames nodes in a graph’s vocabulary; a Pipeline is domain-agnostic and has onlynames. So the question it can ask is sequence-local: if a named stage is present here, it must come after me. A stage that is not present is not a violation — the identical rule_check_ordering()applies to a node that was never lit, and the reason is the same: an absent stage is one there is nothing to pass through, and refusing there would reject every partial model for the sake of a stage it never asked for.Three things it therefore cannot do, all of them pinned in
tests/core/test_ordering.pyrather than left to be discovered:refuse a constraint naming something no stage is called.
assemblerefuses an unknown node id, because a template has a node list and an unenforceable declaration is prose; a Pipeline has no such list, so a typo and a legitimately absent stage are the same observation here.see into a nested composite.
namesis one level deep; a target inside a stage that is itself a Pipeline or a combinator is not a name this sequence has.mean anything for the combinators.
SumOperatorandSelectOperatorrun their branches in parallel on the same input, so “precede” is not a relation between two of them and they deliberately do not call this — which is why this takes noownerargument the wayvalidate_operators()andresolve_names()do. A sequence is the only thing it has anything to say about.
Cost. Called from
Pipeline.__init__, which equinox does NOT go through when it rebuilds a Module:tree_unflattenreconstructs directly, so a jit trace, a gradient and aneqx.tree_atedit re-run this zero times (measured). It runs where a human composes a pipeline, which is the only place the order can be chosen.- Parameters:
stages (Sequence[AbstractOperator])
- Return type:
None
- class rheplicant.core.pipeline.Pipeline(*stages, names=None)[source]¶
Bases:
AbstractOperatorAn ordered, named composition of operators.
A stage whose physics depends on where it sits declares that with
must_precede, and construction refuses an order that breaks it — sequence-locally, which is all a domain-agnostic sequence can say. Seecheck_stage_ordering()for what that does and does not cover.- Parameters:
stages (tuple[AbstractOperator, ...])
- stages¶
the operators, applied first-to-last.
- names¶
unique stage names (static). Auto-derived from class names if not given; pass
names=for stable, meaningful labels. They are also the vocabularymust_precedeis read in, so a pipeline whose stages carry ordering constraints wants meaningful ones.
Example:
pipeline = Pipeline( SkyOperator(...), GainOperator(...), NoiseOperator(...), names=("sky", "gain", "noise"), ) out = pipeline(state) gain_op = pipeline["gain"]
- run_with_intermediates(state)[source]¶
Run the pipeline, also returning the state after every stage.
Diagnostics tool: keeps all intermediate states in memory, so use on small problems, not inside large jitted optimization loops. This is a separate method (not a flag on
__call__) so the operator contract stays uniform and pipelines keep nesting cleanly.
Combinators: parallel composition of operators.
Pipeline composes operators sequentially;
SumOperator composes them in parallel, summing their data
contributions. Together they express the typical structure of a physical
forward model, e.g. an antenna temperature assembled from independent
components:
astro = Pipeline(
SumOperator(global_signal, foregrounds, point_sources),
ionosphere, # distorts the astrophysical sum
)
t_ant = SumOperator(astro, ground_pickup)
Both combinators are themselves operators, so they nest freely.
- class rheplicant.core.combinators.SumOperator(*branches, names=None)[source]¶
Bases:
AbstractOperatorRun source-type branches on the same input state and sum their data.
- Semantics:
Every branch receives the input context (coords, env, meta) with
datastripped toNone— SumOperator is a source combinator whose branches each produce their own contribution on the shared coordinate grid. A branch that tries to read input data fails loudly instead of silently depending on caller state.Branches must not change coords/env/meta; the output state carries the input context with
data = sum(branch outputs).PRNG: each branch gets its own subkey split off the main chain, so stochastic branches draw independent randomness and a single seed reproduces the whole sum. The output state carries the advanced key.
Example:
sky = SumOperator( GlobalSignalOperator(...), ForegroundOperator(...), names=("signal", "foregrounds"), )
- Parameters:
branches (tuple[AbstractOperator, ...])
- replace_branch(index, operator)[source]¶
Return a new SumOperator with one branch swapped; names preserved.
Parity with
replace_stage().- Parameters:
operator (AbstractOperator)
- Return type:
- class rheplicant.core.combinators.SelectOperator(*branches, names=None, switch_key='switch_state')[source]¶
Bases:
AbstractOperatorSelect between source-type branches per time sample (a switch).
Models switched signal paths — e.g. calibration loads that REPLACE the antenna signal on a pre-defined switching cycle. Every branch runs on the input context (data stripped, per-branch PRNG subkeys, context writes discarded — exactly
SumOperator’s branch semantics), and the output at each time sample is the contribution of the branch selected by the switch state:data[t] = contribution[switch[t]][t]
The switch state is observation configuration, not an operator parameter: it is read from
state.coords.extra[switch_key]as an integer array of shape(n_time,)whose values index the branches in branch order (for graph-assembled selectors: the selector node’s in-edge declaration order). Out-of-range values select nothing (that sample is zero).Selecting is not masking. The selection is a
jnp.where, notleaf * mask. The two agree exactly while every branch is finite everywhere, and the multiply was what this class did; it is wrong because the identity it relies on —x * 0 == 0— fails forxnon-finite, and every branch is evaluated at every sample. Oneinfin a switched-OFF sample of any branch entered the sum asinf * 0 -> nanand took the whole output with it. Whether it did depended on the execution mode, since XLA may rewrite a multiply by a predicate into a select and does so in some fusion contexts and not others: the forward answer was correct by optimiser luck, andjax.disable_jitdid not have that luck. Awherenever reads the unselected value, in any mode.Precondition — branches must be finite at EVERY sample, including the ones they are switched off for. The
wherefixes the forward and cannot fix the gradient, which is a claim worth being exact about. Reverse mode differentiates every branch at every sample; a branch that returnsinfat a switched-off sample has an infinite residual there (d(a/t)/da = 1/t), and the selector’s zero cotangent for that sample multiplies it as0 * inf -> nan— inside the branch’s own backward pass, before anything this class does. The nan then propagates to that branch’s parameters while the forward stays right and the other branches’ gradients stay finite, which is exactly the kind of failure that fits through a shape check and a convergence plot alike.The remedy belongs to the branch, and the branch can carry it: it receives the same coordinates the selector read, so it can guard its own singularity with the standard double-
where(safe_t = jnp.where(t == 0, 1, t)) and stay differentiable everywhere. Nothing shipped trips this: the divisions in the radio operators are by configuration (width,ref_freq), never by a coordinate that can be zero on an observing grid. A user-supplied calibration-load branch with a reciprocal in it is the case to write the guard for.- Parameters:
branches (tuple[AbstractOperator, ...])
switch_key (str)
- branches¶
the selectable signal paths.
Reading an assembled tree through the operators’ own declarations.
AbstractOperator carries two declarative
ClassVars, requires and provides. This module is where they stop being
prose: it walks a built operator tree and answers which stages declare a given
State path, so a caller can refuse a composition on the strength of what its
stages say about themselves rather than on a hard-coded list of classes.
One path is enforced, and it is "key". An operator that names it in
requires draws randomness through
next_key(), and that is a property no shape
check, no linearity check and no rank test can see — which is why it is the
declaration worth consuming. stages_requiring() is the general form;
RANDOMNESS is the path that has a consumer today
(refuse_stochastic_stages()).
Why the rest is descriptive, deliberately. The obvious next step —
threading the paths a template State supplies forward and refusing an
operator whose requires names one that is absent — is not implementable
against the shipped operator set, and the counter-example is in the package:
GroundPickupOperator declares "env.temperature" and then documents a
t_ground fallback for when it is missing, so the declaration means “reads
if present”, not “needs”. A blanket availability rule would refuse a model the
package itself describes as legitimate. provides is weaker still: 25 of the
31 declaring classes provide exactly ("data",), so threading it forward
distinguishes almost nothing that the graph’s own source/transform kinds do not
already say. Both stay as documentation of intent, and the class docstring on
AbstractOperator says so in those words
rather than promising a checker.
The walk is by pytree position, not by the composite spine
rheplicant.core.graph._children uses. That is on purpose: this is a
safety check, so it must not miss a stage held by a composite type nobody
taught it about. Named composites (Pipeline,
SumOperator,
SelectOperator) contribute their stage
names to the label so a refusal can quote the graph node id; anything else
contributes structure without a name.
- rheplicant.core.contract.RANDOMNESS = 'key'¶
this operator draws randomness, so a model containing it is not a function of its parameters.
- Type:
The one
requirespath with an enforced consumer
- rheplicant.core.contract.walk_operators(op, _label='')[source]¶
Yield
(label, operator)foropand every operator nested in it.The root’s label is
"". Nested labels are/-joined stage names, so a noise stage inside a summed branch readst_ant/atmosphere.- Parameters:
op (AbstractOperator)
_label (str)
- Return type:
- rheplicant.core.contract.stages_requiring(op, path)[source]¶
Every stage of
opwhoserequiresnames the State pathpath.- Parameters:
op (AbstractOperator)
path (str)
- Return type:
tuple[tuple[str, AbstractOperator], …]
- rheplicant.core.contract.describe_stages(stages)[source]¶
"NoiseOperator at 'noise'"— the stage list a refusal quotes.- Parameters:
stages (tuple[tuple[str, AbstractOperator], ...])
- Return type:
SignalGraph: declarative signal-path templates and graph-guided assembly.
The composition of a physical forward model is implicit in its signal path.
A SignalGraph records that path once — sources, transforms, and sum
junctions — and assemble() compiles a set of operator instances into
the ordinary Pipeline / SumOperator nesting induced by the provided
nodes:
absent source nodes are pruned;
absent transform nodes contract to identity (the signal passes through);
a junction with one live incoming branch passes through, with two or more it materializes as a
SumOperator— branch order is the graph’s edge declaration order, never the call-site order, so the same provided set always folds to the same tree (same names, same PRNG stream, same jit cache entry).
The result is an Assembly — itself an operator — wrapping the folded
composite plus static metadata (which nodes are lit, which were skipped) for
rendering the lit/dim signal-path view. Nothing new exists at runtime: an
Assembly is inspectable, differentiable, and replaceable exactly like the
hand-built composite it compiles to.
Operators declare their home node via the graph_node ClassVar (resolved
through the MRO, so subclasses inherit it); At overrides placement
per instance — freely between nodes of the same kind, and not across the
source/transform line, because a node’s kind is what says whether the operator
there creates the data or acts on data reaching it. has_source, the
__call__ guard and the “a summed branch must contain a source” rule are all
read off that kind, so an operator disagreeing with its node makes all three
wrong at once; _check_slot_kinds() refuses the
disagreement instead.
Ordering. An operator whose physics depends on where it sits — a
calibration tone that only tracks a gain it passes through — declares that in
the graph’s own nouns with
must_precede, and
assemble() refuses a placement that violates it. Because At can put
any operator at any node, an ordering constraint stated only in a docstring is
one nothing checks: the tone assembles cleanly downstream of the gain, and its
gain response silently drops to 1.0.
Addressing. assembly[node_id] reaches the operator at a node whatever
the fold did with it. A many node holding several instances is the one
case a single id cannot answer for, so its instances are named x_1,
x_2, … and the bare x raises
AmbiguousNodeError listing them.
With one instance nothing changes — x is still the address, and a
ParameterSpace written against it is
untouched until a sibling actually arrives.
- class rheplicant.core.graph.NodeSpec(kind, doc='', many=False, segment='forward', reserved=False)[source]¶
Bases:
objectOne node of a signal-path template.
- Parameters:
- kind¶
"source"(creates data; in-degree 0),"transform"(data -> data; in-degree at most 1),"junction"(sum point), or"selector"(switched point: one branch selected per time sample viacoords.extra[<node_id>]). Junctions and selectors have in-degree >= 2 and are never operator slots.- Type:
Literal[‘source’, ‘transform’, ‘junction’, ‘selector’]
- many¶
sources only — allow multiple instances. They compose the way their CONSUMER composes: sibling Sum branches into a junction, sibling selector branches into a selector (one switch position each, in the order they were provided). For the sink-side
filters-style transform chain usemanyon a transform: instances chain in call order.- Type:
- class rheplicant.core.graph.At(node, op)[source]¶
Bases:
objectPlace
opatnoderegardless of its class registration.nodemay also be a tuple of node ids: the operator then covers that contiguous region of the template (it implements all of those stages at once). Regions are atomic — no other live branch may feed their interior — and are addressed by their LAST covered node id in the assembly.“Regardless of its class registration” stops at the node’s kind: an operator that declares a
graph_nodemay be moved to any other node of the same kind, and not across the source/transform line. A source at a transform node discards the signal reaching that node, and a transform at a source node is handeddata=None; neither is a placement, andassemble()refuses both — see_check_slot_kinds().- Parameters:
op (AbstractOperator)
- class rheplicant.core.graph.SignalGraph(name, nodes, edges)[source]¶
Bases:
objectAn immutable signal-path template (DAG with a single sink).
- Parameters:
name (str) – template identifier (used by Assembly metadata / renderers).
nodes (dict[str, NodeSpec]) – ordered
{node_id: NodeSpec}mapping (order fixeslitordering and toposort tie-breaking).edges (Sequence[tuple[str, str]]) –
(src, dst)pairs following signal flow. Edge declaration order is part of the contract: it fixes junction branch order.
Validated at construction: DAG-ness; every node reaches a unique sink; junctions have in-degree >= 2; sources have in-degree 0; transforms have in-degree AT MOST 1 — a parentless transform is permitted, because it is not a defect the template has to catch (see
__check_init__).- to_mermaid(lit=(), skipped=(), counts=None, theme='light')[source]¶
Render the template as a mermaid flowchart with lit/dim styling.
litnodes are highlighted,skipped(traversed-as-identity) nodes are half-lit, everything else is dimmed — the signal-path view of what an assembly simulates.countsmaps a node id to the number of operator instances sitting on it. Amanynode is one box however many instances it carries, so the count is shown in the label: an unannotated box would render two components as one.Operators are boxes; the two composition operations are symbols the wire runs through and are given shapes of their own — a circled plus for a sum, a rhombus for a switch. Mermaid has no line art, so the shape carries the distinction here;
to_svgdraws the switch’s lever. Both used to be circles differing only in their label, which made two operations on operators look like two more operators.themeis"light"or"dark", matchingto_svg()andto_html(). An unknown name raises rather than falling back to a default, because a silently-light diagram in a dark page is exactly the failure the argument exists to prevent.
- to_html(lit=(), skipped=(), title=None, counts=None, theme='light')[source]¶
Standalone HTML page of the template with lit/dim signal-path styling.
- rheplicant.core.graph.register_graph(graph)[source]¶
Register a template so Assembly.to_mermaid can find it by name.
- Parameters:
graph (SignalGraph)
- Return type:
- class rheplicant.core.graph.Assembly(operator, graph_name, lit, skipped, has_source, root_label='', instances=(), materialized=(), aliased=(), placements=())[source]¶
Bases:
AbstractOperatorA graph-assembled operator: the folded composite + lit-node metadata.
Call it like any operator. Access the operator placed at a node with
assembly[node_id](independent of fold nesting), swap one withreplace_node(), render the lit/dim signal path withto_mermaid().- Parameters:
- instances¶
(node_id, instance_ids)for every node carrying more than one operator.litnames template nodes, and a node is one node however many operators sit on it — this is where the multiplicity lives, and what makes the bare node id ambiguous.
- materialized¶
junction/selector nodes that actually became a
SumOperator/SelectOperator(rather than passing through).
- aliased¶
public, and the thing to check before writing a selector — nodes the fold embedded at more than one position, because their contribution reaches the sink by more than one path. Reading them is honest —
self[nid]IS the operator sitting there — so only writing refuses:replace_node()andvalidate()both consult this tuple, since either would otherwise rewrite one copy and leave the others live in the forward model. What neither can see is a hand-rolledeqx.tree_at(lambda a: a[nid].x, ...): that call goes through no framework code, so nothing intercepts it and it still rewrites one copy only. Consult.aliasedyourself before writing one —repr(assembly)names these nodes when there are any, and says nothing when there are none, so the condition reaches a reader who did not know to ask for it.
- placements¶
the recipe
without()re-assembles from, as(template nodes, address)per placed operator, in template order. Addresses rather than operators on purpose — see the field’s own comment.
If the assembly contains live sources it generates its data — calling it on a state that already carries data raises, because that data would be silently discarded (pass
data=None). Source-free assemblies are transform chains operating on caller data.has_sourceis read off the template’s node kinds, not off the operators, and that is only sound because_check_slot_kinds()refuses a placement whose operator disagrees with its node about creating data. An operator declaring nograph_nodecannot be screened, so it can still be placed on the wrong kind of node and make this flag — and therefore the guard above — wrong in either direction.- replace_node(node_id, operator)[source]¶
Return a new Assembly with the operator at
node_idswapped.Raises rather than swapping when
node_idnames something that is not one operator: amanynode carrying several instances (AmbiguousNodeError), a junction/selector that assembly materialized as a combinator, or a node the fold embedded at more than one position. In all threeeqx.tree_atwould happily rewrite one position — dropping live branches from the forward model, or leaving the node’s other copies in it, with no shape change and no complaint.operatormust be anAbstractOperator.Nonein particular is refused by name: it reads as “take this stage out”, and it used to return an Assembly whoselitand whose mermaid rendering both still claimed the stage, which then died on the next call withTypeError: 'NoneType' object is not callable. Removing a stage iswithout().- Parameters:
node_id (str)
operator (AbstractOperator)
- Return type:
- without(node_id)[source]¶
Return a new Assembly with the operator(s) at
node_iddropped.The supported answer to “this stage must not be here” — the sentence
refuse_stochastic_stages()says about a noise stage in a twin you infer with, and the onereplace_node()used to invite withNoneand then answer wrongly.Not tree surgery: this re-runs
assemble()over the remaining operators, recovered from the built tree by the addresses recorded inplacements. So the result is exactly the assembly you would have got by not providing that operator in the first place — same fold, samelit/skipped/has_source/materialized, and every assembly-time refusal re-run. If dropping the stage leaves something that cannot be assembled — a summed branch with no source left on it, say — you get that refusal, in assemble’s own words, rather than a model that quietly changed meaning. Dropping a stage another operator names inmust_precedeis NOT such a case: an absent node contracts to identity, so there is nothing left to pass through and nothing to violate — the same rule_check_ordering()applies to a node that was never lit.A
manynode is dropped whole: every instance on it goes. Useassemble()directly to keep some of them.- Raises:
AssemblyError – if
node_idcarries no operator in this assembly, if it is the only one, or if this Assembly was not built byassemble()(so there is no recipe to re-run).- Parameters:
node_id (str)
- Return type:
- to_mermaid(theme='light')[source]¶
Lit/dim mermaid rendering via the registered template.
themeis"light"or"dark"; seeSignalGraph.to_mermaid().
- rheplicant.core.graph.assemble(graph, *operators)[source]¶
Compile a set of operators into the sub-pipeline they induce on
graph.See the module docstring for the contraction rules. Raises
AssemblyErroron unknown/ambiguous placement, junction slots, duplicate single-instance nodes, an operator placed on a node of the other kind (_check_slot_kinds()— a source at a transform node or the reverse), a transform-rooted branch feeding a materialized junction (a sum branch must contain a source), or a violatedmust_precedeordering constraint.Limitation — the envelope is in-trees. The package’s promise is that any assembled graph serves both forward modelling and inference. That holds while every node reaches the sink by exactly one path, and it is stated here rather than assumed because assembly folds the graph to a tree: a node reached by several paths is folded in once per path, so one operator object ends up at several positions. The forward model is right either way — each path contributes as the graph says. What breaks is writing to such a node afterwards, because
eqx.tree_atrewrites the one position a selector reaches and leaves the other copies live: a finite, correctly-shaped, wrong model in which the parameter is only partly free. Those nodes are recorded inAssembly.aliased, and bothAssembly.replace_node()andvalidate()refuse to write through them rather than answer wrongly.No shipped graph is affected: every node of the radio template reaches the sink by exactly one path, so
aliasedis always empty there. This bites user-defined graphs only.- Parameters:
graph (SignalGraph)
operators (AbstractOperator | At)
- Return type:
Smooth (time, frequency) bases: the repair an identifiability refusal names.
identifiability() refuses a model
whose Jacobian has a null space and tells the caller what to do about it —
“a smooth basis in place of one free parameter per cell is the usual repair”.
This module is that basis, and nothing more. The framework needs no new
machinery to use it: a basis expansion is already a
Bind with an fn, over a latent
declared linear=True:
basis = SeparableBasis(
time=basis_matrix("legendre", n=n_time, n_basis=3),
freq=basis_matrix("legendre", n=n_freq, n_basis=4),
)
ParameterSpace(
latents=[Latent("t_coeff", init=basis.fit(t_ant_guess), linear=True)],
bindings=[Bind("t_coeff", into=lambda p: p["t_ant"].temperature,
fn=basis.expand)],
)
check_linearity verifies the linear=True claim against that binding,
linear_operator() exports A and Aᵀ
for it without forming a matrix, and both conjugate exits drive it. What was
missing was only the matrices, which are a modelling choice and not a
framework one — hence a small honest utility rather than a new abstraction.
Why the basis has to reach the antenna temperature. Measured on a known
5000 K calibration tone against a gain free per time sample (the numbers are
pinned in tests/radio/test_t_sys_basis.py, at a generic coefficient point,
for n_time=7, n_freq=5):
free-per-cell T_ant, tone ON (5000 K) n_par=42 rank=35 nullity=7
free-per-cell T_ant, tone OFF n_par=42 rank=35 nullity=7
(3,2)-basis T_ant, tone ON (5000 K) n_par=13 rank=13 nullity=0
(3,2)-basis T_ant, tone OFF n_par=13 rank=12 nullity=1
Against a free-per-cell antenna temperature the tone buys exactly nothing —
nullity stays at n_time either way, because the free cells absorb the whole
of g[t] x (tone profile) sample by sample, the tone’s own channels included.
So smoothing the noise waves alone would leave the tone useless: the basis has
to reach T_ant too. See
BasisTemperatureOperator, which puts it there.
And it is the FREQUENCY axis that does the work. Same model, same tone, varying which axis is restricted (also pinned):
n_k n_j nullity, tone ON tone OFF
3 2 0 1
7 (complete) 2 0 7
3 5 (complete) 1 1
7 (complete) 5 (complete) 7 7
A basis complete in frequency makes the tone worth nothing whatever the
time axis does — the tone’s profile is then inside the span and is reabsorbed,
nullity 1 with the tone and 1 without. A basis complete in time is fine as
long as frequency is restricted. Read that as: the tone is an argument for
frequency smoothness specifically, and n_j < n_freq is the condition it
needs. n_basis == n is therefore legal here rather than refused — it is a
perfectly well-conditioned, invertible matrix, and whether it costs anything is
a joint property of the model that only identifiability() can answer.
Orientation, once, because it is the thing to get wrong. A design matrix is
(n, n_basis) — one row per sample, one column per basis function, the
orientation numpy.polynomial’s *vander helpers use — and the expansion
is:
T = time @ coeff @ freq.T (n_time, n_freq)
(n_time, n_k) (n_k, n_j) (n_j, n_freq)
With n_time == n_freq and n_k == n_j a swapped pair of design matrices
is shape-legal and returns the transpose of the intended field: finite,
correctly shaped, wrong. Nothing here can catch that case, for the same reason
rheplicant.radio.instrument.noise_wave cannot tell a per-time vector
from a spectrum when the two axes are the same length. On any other grid the
shape checks below do catch it, which is why the tests are non-square
throughout.
Where this lives, and why not in rheplicant.inference. It reads like an
inference utility — identifiability() prescribes it, Bind consumes it —
and putting it there would have been a layering inversion. The design matrices
are held by an operator that sits ON the signal path
(BasisTemperatureOperator), so radio would
have had to import inference, which nothing in this package does and which
the inference layer’s own premise forbids: it “treats a Pipeline as data, never
lives inside it”. core is the one layer both may depend on, and the fit is
honest rather than merely convenient — this module builds no
State, holds no radio physics, and imports
nothing but NumPy, JAX and rheplicant.core.errors. The axes are named
time and freq because Coordinates
names them that HERE, at the core layer, for every model this package carries.
Its refusals are StateValidationError for the
same reason — that is core’s error for “constructed with invalid contents”, and
it is the one every operator in the package already raises.
ParameterSpaceError would read well for the Bind route and is
deliberately not part of rheplicant.core’s public surface.
- rheplicant.core.basis.BASIS_KINDS: tuple[str, ...] = ('legendre', 'polynomial', 'fourier')¶
The basis families
basis_matrix()builds, in the order its error message lists them."legendre"and"polynomial"span EXACTLY the same functions and are not interchangeable, because the choice is about conditioning and only about conditioning. Measuredcond(design):n n_basis legendre polynomial fourier 5 2 1.41 1.41 1.41 9 6 3.43 41.2 1.41 16 10 5.3 1.49e+03 1.41 32 16 7.86 2.81e+05 1.41
The raw monomials become nearly parallel as the degree grows —
x**14andx**16agree to a few percent over most of [-1, 1] — and that condition number lands directly onkappaof the block’s normal operator, which is whatwiener_solve()’s convergence guard bounds the error by. So:"legendre"for a smooth quantity, and"polynomial"only when a caller needs the raw monomial coefficients themselves (comparing againstnumpy.polyfit, say). The ordering is pinned bytest_legendre_is_far_better_conditioned_than_the_same_span_in_monomials."fourier"is orthogonal on its own grid by construction, hence the flat 1.41 — but it is a different SPAN, and a claim: that the quantity is periodic on this axis (a sidereal cycle, a standing wave in a cable). On a non-periodic axis its constant-plus-harmonics span forces the two ends to agree, which is a statement about the physics and not a numerical detail.
- class rheplicant.core.basis.SeparableBasis(time, freq)[source]¶
Bases:
objectA separable expansion of a
(n_time, n_freq)field:time @ C @ freq.T.Deliberately a plain dataclass rather than an
eqx.Module, for the same reasonLinearBlockis: this is a derived linear-algebra handle, something you build where you need it, not a pytree to carry through a model. Aneqx.Modulewould be actively wrong here, and specifically at the pattern this class exists to serve — equinox wraps a Module’s bound methods as pytrees, soBind(..., fn=basis.expand)would put the design matrices inBind’s STATIC field, where an array is compared by__eq__and equinox warns about it. An operator that wants the matrices as differentiable leaves holds them directly; seeBasisTemperatureOperator.eq=Falseis ordinary hygiene for a value object holding arrays, and it is worth stating exactly what it does and does not buy — measured, because the plausible version of this paragraph is wrong twice. A frozen dataclass defaults toeq=True, which compares the fields elementwise. Thenbasis_a == basis_banswersTruewhen the two hold the same array objects and raisesValueError: the truth value of an array with more than one element is ambiguousonly when they hold distinct-but-equal ones — so the failure is intermittent, which is worse than always. And__hash__does not becomeNone: on a frozen dataclass it is generated and raisesTypeError: unhashable type: 'ArrayImpl'.eq=Falsegives identity semantics for both, so the two ordinary things you can do to any Python object stay ordinary and stay predictable. It is NOT what makesfn=basis.expandsafe as a static field — a bound method compares and hashes its__self__by POINTER, so that route works either way. Being aneqx.Moduleis the thing that would break it.Separable rather than general on purpose. A general basis over the joint grid would be
(n_time * n_freq, n_coeff)and would say nothing about which axis a given function varies on; the separable form is the one whose smoothness claim decomposes — “n_kfunctions in time,n_jin frequency” — and the frequency count is the one the calibration tone argument turns on (see the module docstring).- expand(coeff)[source]¶
time @ coeff @ freq.T— the coefficients as a field on the grid.This is the function to hand to
Bind(..., fn=...). It is affine incoeff(linear, in fact), which is the claimLatent(..., linear=True)makes andcheck_linearitychecks.The result is ALWAYS 2-D and always the full grid, even for a one-coefficient basis on each axis. That matters where it is consumed:
rheplicant.radio.instrument.noise_waveaccepts(),(n_freq,),(n_time, 1)and(n_time, n_freq), and states that a bare 1-D array is unresolvably ambiguous whenn_time == n_freq. An expansion never produces one.
- fit(values)[source]¶
Least-squares coefficients for a field on this grid — the inverse of
expand(), where one exists.The natural way to build an
initfor the coefficient latent: hand it the field you would otherwise have declared per cell and it returns the closest thing this basis can say. That matters more than convenience —check_linearity()takes its probe scales frommax|init|, so an all-zero init makes the probes absolute and never reaches the regime a 3000 K temperature actually lives in.A field OUTSIDE the span is projected onto it rather than refused: the residual is what the basis cannot represent, and if that residual matters the answer is a bigger basis, not an exception here. Compare
valuesagainstexpand(fit(values))to see it.Uses the pseudo-inverse of each axis independently, which is the exact least-squares solution because
vec(A C Bᵀ) = (B ⊗ A) vec(C)and the pseudo-inverse of a Kronecker product is the Kronecker product of the pseudo-inverses.
- rheplicant.core.basis.basis_matrix(kind, *, n, n_basis)[source]¶
A
(n, n_basis)design matrix over a normalised axis.Built in NumPy float64 and converted once, so the recurrence that generates the Legendre columns runs in double precision whatever
jax_enable_x64says, and only the stored constant follows the caller’s configuration.- Parameters:
kind (str) – one of
BASIS_KINDS.n (int) – number of samples on the axis —
n_timeorn_freq.n_basis (int) – number of basis functions, i.e. of coefficients on this axis. Keyword-only, along with
n, becausebasis_matrix("legendre", 5, 7)reads plausibly in either order and one of the two orders is a silently over-complete basis.
- Returns:
one row per sample, one column per function, with the constant function first for every kind — so coefficient
[0, 0]of aSeparableBasisis always the mean level.- Return type:
(n, n_basis)- Raises:
StateValidationError – on an unknown
kind, a non-positivenorn_basis, orn_basis > n. NotParameterSpaceError– this module’s docstring argues that one deliberately is not part ofrheplicant.core’s public surface, so a caller who wroteexcept ParameterSpaceErroraround this would catch nothing and could not import the name to try.
Spectral diagnostics for matrix-free symmetric operators.
An iterative solver can cheaply report ‖M x - b‖; what a caller actually
wants to know is ‖x - x*‖. The two differ by the condition number, so any
honest convergence guard over a matrix-free operator needs the ends of its
spectrum — and needs them without ever forming a matrix.
Everything here works on pytrees and takes the operator as a callable, so it
knows nothing about rheplicant.inference.linear’s blocks. That is
deliberate: it keeps the numerics separable from the model machinery, and the
dependency pointing one way.
Why this lives in core. Two layers need it and they may not see each other.
rheplicant.inference.linear guards its Wiener solves with it, and
SkySpaceFilter guards its
map-making CG with it — but radio may not import inference (DESIGN.md’s
hard rule; core.basis is here for the same reason, D28). The alternative was
a second power iteration in radio, and a second copy of a subtle numeric is
the copy that goes stale.
- rheplicant.core.conditioning.POWER_ITERATIONS: int = 12¶
Power-iteration steps for
largest_eigenvalue(), for callers that guard a solve with it. It typically settles within three; this leaves margin at a fixed cost ofPOWER_ITERATIONSoperator applications per guarded solve. It was twice that whileextreme_eigenvalues()was the one callers used – see that function’s own docstring for why nothing here uses it any more.
- rheplicant.core.conditioning.tree_norm(parts)[source]¶
Euclidean norm over a pytree, scaled so it survives float32.
Squaring first overflows for entries beyond ~1.8e19, which turns the only convergence signal these solvers give into inf/inf = NaN exactly when the problem is badly scaled and the answer is most likely wrong.
- rheplicant.core.conditioning.largest_eigenvalue(operator, template, key, iterations)[source]¶
Top eigenvalue of a symmetric positive-definite operator, by power iteration.
Each step costs one application of
operator— for a normal operator that is the same JVP-plus-VJP a CG iteration costs, and no matrix is formed. The estimate approaches the true value from BELOW.
- rheplicant.core.conditioning.extreme_eigenvalues(operator, template, key, iterations)[source]¶
(λ_max, λ_min)of a symmetric positive-definite operator.λ_mincomes from a second power iteration onλ_max I - M, whose top eigenvalue isλ_max - λ_min. Measuring it beats bounding it: a caller who assumed the worst aboutλ_minwould call every well-conditioned operator ill-conditioned by the whole dynamic range of the problem.The difference is taken between two numbers of size
λ_max, so it is cancellation-prone precisely whenλ_minis tiny. Callers who hold an independent lower bound onλ_min— a prior’s curvature, say — should floor the result with it; that is both rigorous and the scale at which the cancellation bites.
Standalone HTML/SVG rendering of signal-path graphs with lit/dim styling.
Shows the full template with the provided nodes lit, traversed-as-identity nodes half-lit (“wire”), and everything else dimmed — the signal-path view of what an assembly simulates. Produced from Python so it always reflects the actual template. Two output forms, both self-contained (no external assets — the opacity classes are styled inside the SVG itself):
signal_path_html()— a full page; write it to a file and open it in a browser:pathlib.Path("signal_path.html").write_text(assembly.to_html())signal_path_svg()— just the<svg>element, for embedding in documentation or notebooks (assembly.to_svg()).
- rheplicant.core.render.signal_path_svg(graph, lit=(), skipped=(), title=None, counts=None, theme='light')[source]¶
Render
graphas a self-contained<svg>element (lit/dim styling).The opacity classes are styled inside the SVG, so the result embeds anywhere a plain image does — documentation pages, notebooks,
<img>.countsmaps a node id to how many operator instances an assembly put on it;manynodes carrying more than one say so in their label, because the template draws them as a single box either way.themeselects the palette ("light"or"dark"). One SVG cannot serve both: embedded as an<img>it cannot see the page’s theme class, which is why the documentation commits a pair and shows one of them.
- rheplicant.core.render.signal_path_html(graph, lit=(), skipped=(), title=None, counts=None, theme='light')[source]¶
Render
graphas a standalone HTML page with lit/dim signal-path styling.
Exception hierarchy for rheplicant.
All framework errors derive from DirtError so users can catch the
whole family with one except clause. Subclasses additionally derive from
the closest builtin (ValueError / RuntimeError) so generic handlers
keep working.
- exception rheplicant.core.errors.DirtError¶
Bases:
ExceptionBase class for all public rheplicant errors.
- exception rheplicant.core.errors.StateValidationError[source]¶
Bases:
DirtError,ValueErrorA State (or one of its containers) was constructed with invalid contents.
Raised only for structural problems (wrong ndim, wrong dtype, bad key types) — never for traced array values, so validation stays jit-safe.
- exception rheplicant.core.errors.MissingKeyError[source]¶
Bases:
DirtError,RuntimeErrorAn operator needed randomness but
State.keyisNone.Fix: construct the state with
key=jax.random.key(seed).
- exception rheplicant.core.errors.PipelineError[source]¶
Bases:
DirtError,ValueErrorA Pipeline was misconfigured (empty, bad stage type, name collision…).
- exception rheplicant.core.errors.ParameterSpaceError[source]¶
Bases:
DirtError,ValueErrorA parameter space was declared inconsistently.
Covers both halves of the declaration: latents that nothing binds, a binding naming a latent that was never declared, two bindings writing the same leaf, a produced value whose shape does not fit its target leaf. Every one of these would otherwise yield a finite, correctly-shaped, wrong inference — so they are errors, not warnings.
- exception rheplicant.core.errors.LinearityRefused(*args, errors, rtol, failed, weighted=None, weighted_rtol=None)[source]¶
Bases:
ParameterSpaceErrorcheck_linearitymeasured a departure from linearity, and refused.A SUBCLASS and not a new member of the family, deliberately: every
except ParameterSpaceErroralready written againstcheck_linearity()keeps catching this, and the refusal’s message is unchanged, so thepytest.raises(ParameterSpaceError, match=...)sites keep matching too. Nothing about the refusal itself is new.What is new is that the per-probe departures the message renders are also carried as NUMBERS. Before this class the failing branch put them in a sentence and dropped them, while the passing branch – where every value is 0.0 – returned them structured, so the only path with something to say was the only path with nothing to read. A consumer that wanted the numbers had to parse the prose, which is a mapping this package’s own source would not defend.
- Parameters:
- Return type:
None
- errors¶
{scale: relative departure}at every probed scale, PASSING probes included – the trend across scales is the diagnostic, and “departs at 1x and 1000x but not at 0.001x” is a different fault from “departs everywhere”.
- rtol¶
the tolerance the comparison actually used, which is derived from the prediction’s dtype when the caller passes none.
- failed¶
the scales that exceeded it, ascending – a subset of
errors’ keys, and the same tuple the message names. A scale can be in here because of EITHER criterion, which is why both columns are carried rather than one.
- weighted¶
{scale: departure in units of sigma}, orNonewhen the check was run without a noise model and the second criterion therefore did not apply.Nonerather than an empty mapping: “not measured” and “measured as nothing” are different answers, and this class exists because the second one used to be reported for both.
- weighted_rtol¶
the threshold that column was judged against, or
Nonefor the same reason.
Bases:
ParameterSpaceErrorA quantity
logcannot be taken of, where log space was being asked for.A SUBCLASS for the same reason
LinearityRefusedis one: everyexcept ParameterSpaceErroralready written keeps catching it and the message is unchanged. What the subclass buys is a NARROW catch.Discovering whether a latent has a log-linear block means asking
check_log_linearity()and reading a refusal as “no”. Two refusals mean that — a departure from affinity (LinearityRefused) and a prediction that is negative, zero or not finite (this one) — while the rest ofParameterSpaceError’s family, a latent of integer dtype or a name the space never declared, mean the question was never asked. Catching the base class to classify would file those as “not log-linear” and route a broken declaration to a gradient block with nothing said, which is the shape of failure this package spends its refusals avoiding.
- exception rheplicant.core.errors.DataIngestionError[source]¶
Bases:
DirtError,ValueErrorA data file could not be read, or its contents contradict what the caller declared about them.
Distinct from
StateValidationError, which covers structural problems with an in-memory State — wrong ndim, wrong dtype, bad key types. A value given in a unit other than the one the caller declared, and a record that violates the file’s own format rules, are neither: nothing is wrong with the shape of what was read, only with what it means. Both would otherwise propagate as a finite, correctly-shaped, wrong answer.
- exception rheplicant.core.errors.AssemblyError[source]¶
Bases:
DirtError,ValueErrorA provided operator set cannot be assembled on the signal graph.
- exception rheplicant.core.errors.AmbiguousNodeError[source]¶
Bases:
AssemblyErrorA node id was used as an address, but it holds more than one operator.
Raised by
Assembly’s__getitem__andreplace_nodefor amany=Truenode carrying several instances. (Those two are literals rather than:meth:roles:__getitem__is a dunder, whichautomoduleemits no target for, so the role would be a nitpicky-build warning rather than a link. Insidegraph.pythe same text was an UNQUALIFIED role and was never checked – moving the class here is what exposed it.) Answering with one of them (or with the fold over all of them) would silently pick a different operator than the caller means – and, throughreplace_node, silently delete the siblings. The message names every instance id instead.
rheplicant.radio¶
The canonical single-antenna signal-path graph and graph-guided assembly.
This is the flowchart that makes composition implicit: provide a set of
operators and assemble() lights up the connected sub-path they induce
and compiles it to the equivalent Pipeline/SumOperator nesting:
from rheplicant.radio.graph import assemble
twin = assemble(GlobalSignalOperator(...), ForegroundOperator(...),
GainOperator(...))
print(twin) # lit nodes + skipped-as-identity nodes
print(twin.to_mermaid()) # lit/dim signal-path rendering
Topology (v1.4; sum junctions marked (+)):
global_signal | foregrounds | point_sources | uniform_sky
-> (+) astro_sum -> ionosphere -> atmosphere_field* --\
ground_field* | rfi_field ----------------------> (+) field_sum -> beam --\
beam | observed_astro_sky -> (+) astro_ant_sum -> beam_spill --------------\
ground_pickup | t_sys_extra* | atmosphere ---------------------------------> (+) t_ant_sum
-> antenna_loss -> (SW) receiver_input <- cal_loads
-> noise_wave -> cw_tone -> bandpass -> gain
-> noise -> emi -> adc
-> snapshot -> flagging -> averaging -> apply_cal -> filters [processing segment]
Equivalent-entry leaves (the * nodes are reserved placeholders with no
shipped operator yet): the same physical effect may enter at different
stages in different forms — ground spill either as a field before the beam
(ground_field, to be convolved) or as an effective temperature after it
(ground_pickup / generic t_sys_extra); the atmosphere either as
strict radiative transfer on the astro branch before the beam
(atmosphere_field, reserved — opacity acts on the astro sky alone, never
on ground pickup) or as a beam-averaged additive emission temperature in the
antenna-temperature sum (atmosphere); the whole astro path either as
component fields through the shared beam node or pre-convolved via
observed_astro_sky (SkySourceOperator). Provide whichever form you
have; the graph keeps both entrances.
Switched calibration loads (elements taxonomy “calibration signals …
switched in and out on a pre-defined cycle”) enter through the
receiver_input selector node: with only the antenna chain provided it
passes through; provide CalLoadOperator too and each time sample takes
the branch chosen by coords.extra["receiver_input"]. cal_loads is
many=True and feeds only the selector, so each instance becomes its OWN
switch position rather than being summed with its siblings: with two loads the
switch indexes 0 = antenna, 1 = first load, 2 = second load — the edge
declaration order, then the order the loads were provided. How many distinct
sources an identifiable noise-wave fit needs is
noise_wave’s to say and is stated once
there — it depends on how many temperature families are free and on whether
they are free per channel, so it is not a fixed number and in particular is not
always three. assemble() expresses any of them directly: one
CalLoadOperator per switch position. Two instances are addressed as
cal_loads_1 / cal_loads_2; the bare cal_loads is an address only
while there is exactly one (see rheplicant.core.graph).
cw_tone is the one node on this template whose operator declares an
ordering constraint. The tone tracks g(t) only by passing through it, so
CWCalibrationOperator sets
must_precede = ("bandpass", "gain") and assemble() refuses a placement
that breaks it — At("noise", cw) used to compile cleanly and drop the
tone’s gain response to exactly 1.0. Everything downstream of cw_tone
inherits the tone’s protected channels through aux — a set, since a tone
with a line width wets more than one, and an (n_time, n_freq) waterfall
once it drifts between them (see
rheplicant.radio.protection), which is what keeps flagging — sitting
on the same trunk — from removing the calibrator as RFI.
beam_spill (v1.4) is the horizon split of a beam that does not stop at the
horizon: the part below it sees ground, not sky. It is the trunk stage of the
ASTRO branch — the two equivalent astro entrances (beam,
observed_astro_sky) meet at astro_ant_sum first — because the split
applies to the thing that genuinely is a beam integral over the celestial
sphere and to nothing else. The other t_ant_sum leaves are effective
temperatures by D13’s construction, already carrying whatever beam weighting
their author intended, and ground_pickup in particular IS a below-horizon
share; running them through the split would weight them twice.
antenna_loss (v1.3) is the antenna’s own ohmic dissipation, on the trunk
between t_ant_sum and the switch: it acts on everything the beam
collected (unlike atmospheric opacity — see D13) and on nothing that connects
downstream of the antenna, which is why the calibration loads must enter after
it. Absent an AntennaLossOperator the node is skipped as identity, which
is the lossless-antenna assumption made explicit rather than hidden.
The forward physical chain ends at adc (the raw waterfall); the
processing segment (snapshot/flagging/averaging/apply_cal/filters) is
data-side and applies identically to simulated and observed raw data.
snapshot has no shipped operator registered on it —
SnapshotOperator deliberately declares no
graph_node, because rheplicant.core may not name a node of a domain
graph (see rheplicant.core.operator). Place it with
At("snapshot", SnapshotOperator(name=...)) to preserve the raw waterfall
into aux before it is destructively processed; absent an operator the
node is skipped as identity, same as any other transform.
- rheplicant.radio.graph.assemble(*operators)[source]¶
Assemble radio operators on the canonical single-antenna graph.
- Parameters:
operators (AbstractOperator | At)
Sky models: parameters -> a sky representation on the frequency grid.
One half of the modular sky abstraction (the other half is
projection):
SkyModel (what the sky IS) -> (n_freq, n_pix) brightness maps Projector (how the sky is SEEN) -> (n_time, n_freq) antenna temperature
Keeping them separate means the same sky (e.g. moment-expanded foregrounds) can be observed through different engines (limTOD beam convolution, m-mode transfer matrices, …) and the same engine can observe different skies.
Representation contract: __call__(freq) -> Array[(n_freq, n_pix)] of real
brightness temperatures [K]. Pixelization is HEALPix RING in the real
implementations; the placeholders are pixelization-agnostic (any n_pix).
- class rheplicant.radio.sky.model.AbstractSkyModel[source]¶
Bases:
ModuleParameters -> sky brightness maps
(n_freq, n_pix)[K].Differentiable sky parameters (amplitudes, spectral indices, moment coefficients…) are ordinary array fields of the concrete model.
- class rheplicant.radio.sky.model.UniformSkyModel(amplitude, n_pix)[source]¶
Bases:
AbstractSkyModelPLACEHOLDER: spatially and spectrally uniform sky.
- class rheplicant.radio.sky.model.PowerLawSkyModel(amplitude, spectral_index, ref_freq, n_pix)[source]¶
Bases:
AbstractSkyModelPLACEHOLDER: power-law sky with a per-pixel amplitude map.
T(freq, pix) = amplitude[pix] * (freq / ref_freq) ** (-spectral_index)Real version: uncertain spectral-index maps / moment-expanded foregrounds (the identified foreground pain point) — same contract, more parameters.
- class rheplicant.radio.sky.model.MapSky(maps, freq)[source]¶
Bases:
AbstractSkyModelFixed brightness maps, and the frequency grid they were built on.
The stand-in for a GSM / pyGDSM realisation, and the shape every worked example in this package reaches for.
__call__returns the stored maps and does not consult its ``freq`` argument beyond checking that it has the same shape as the grid the maps were built on.What that check does and does not catch. A map built for 60-85 MHz and evaluated on a 60-85 MHz grid of a different length – or of the same length and a different rank, such as
(n_freq, 1)– is refused. A map built for 60-85 MHz and evaluated on a 100-125 MHz grid of the SAME length is not, and cannot be underjit– the values are traced, only the shape is static. That failure returns a smooth, plausible, wrong temperature, sofreqis stored to give it a name and a place for a config layer to check it before tracing begins.- maps¶
(n_freq, n_pix)brightness temperatures [K] – a differentiable leaf, so a sky can be inferred rather than assumed.- Type:
Sky projectors: how a sky representation is SEEN as antenna temperature.
The second half of the modular sky abstraction (see
model). A projector maps sky maps to the
(n_time, n_freq) time-ordered antenna temperature, given the observation
coordinates. Swapping the projector swaps the observation engine without
touching the sky model — and linear projectors additionally expose
adjoint, which SkySpaceFilter reuses for
map-making / sky-space filtering.
Three engines. The two that compute the physics live in sibling modules, named for the observation geometry they serve — look there first:
GeneralPointingProjector— pure JAX, any pointing, differentiable in sky and beam. The default.DriftScanProjector— the same physics for a drift scan (fixed pointing, only LST advancing) atO(lmax³ + n_time·lmax)instead ofO(n_time·lmax³). Equal to the general engine to float64 roundoff — an optimization, not an approximation — so on RHINO’s static zenith pointing it is simply the right engine.
and one that takes the projection as data, defined here:
MatrixProjector— a precomputed sky->TOD matrix (e.g. fromlimTOD.simulator.generate_sky2sys_projection). Fully differentiable TODAY: the matrix is built offline once, the JAX side is pure einsum, and it needs no optional dependency.
- class rheplicant.radio.sky.projection.AbstractSkyProjector[source]¶
Bases:
ModuleSky representation
(n_freq, n_pix)-> antenna temperature(n_time, n_freq).- abstractmethod forward(sky, coords)[source]¶
Observe the sky:
(n_freq, n_pix) -> (n_time, n_freq).- Parameters:
sky (Array)
coords (Coordinates)
- Return type:
- adjoint(tod, coords)[source]¶
Adjoint map
(n_time, n_freq) -> (n_freq, n_pix)(linear projectors only).Required by sky-space filtering / map-making. Every shipped engine is linear and implements it; a nonlinear one may leave it unimplemented.
- Parameters:
tod (Array)
coords (Coordinates)
- Return type:
- class rheplicant.radio.sky.projection.MatrixProjector(matrix)[source]¶
Bases:
AbstractSkyProjectorLinear projection by a precomputed sky->TOD matrix.
The matrix is exactly what
limTOD.simulator.generate_sky2sys_projectionproduces (beam-weighted pointing rows over selected sky pixels): build it once offline with the existing numpy limTOD, load it here, and the whole sky term is differentiable (w.r.t. the sky) with zero porting work. Valid while pointing and beam are fixed.For a drift scan specifically, prefer
DriftScanProjector: no offline matrix to build or store, differentiable in the beam as well as the sky, and it derives the projection on the fly for less than the matrix costs to apply.- Parameters:
matrix (Array)
- matrix¶
(n_time, n_pix)shared across frequency (achromatic beam), or(n_freq, n_time, n_pix)for a chromatic beam.- Type:
- forward(sky, coords)[source]¶
Observe the sky:
(n_freq, n_pix) -> (n_time, n_freq).- Parameters:
sky (Array)
coords (Coordinates)
- Return type:
- adjoint(tod, coords)[source]¶
Adjoint map
(n_time, n_freq) -> (n_freq, n_pix)(linear projectors only).Required by sky-space filtering / map-making. Every shipped engine is linear and implements it; a nonlinear one may leave it unimplemented.
- Parameters:
tod (Array)
coords (Coordinates)
- Return type:
The general-pointing sky engine: any pointing, one rotation per sample.
GeneralPointingProjector assumes nothing about the observation —
azimuth, elevation and self-rotation are per-sample DATA, so tracking,
raster scans and transits all work — and pays for that generality with one
O(lmax³) Wigner rotation per time sample. Pure JAX, differentiable w.r.t.
BOTH the sky maps and the beam alms, with the exact transpose that
SkySpaceFilter map-making requires.
Its sibling DriftScanProjector
covers the one geometry this engine cannot exploit: a drift scan, where the
pointing never moves. There the rotation happens ONCE for the whole scan,
reproducing this projector to float64 roundoff for a fraction of the cost.
Naming follows that split — the two real engines are named for the
observation geometry they serve, not for the package they were ported from
(both come from limtod_jax).
The heavy lifting lives in the limtod_jax package (shipped with the
limTOD repo: pip install "limTOD[jax]"); this adapter only wires it to
the AbstractSkyProjector seam. It is
imported lazily so rheplicant’s dependencies are unchanged.
Semantics: per frequency, forward equals numpy
limTOD.simulator.generate_TOD_sky(..., truncate_frac_thres=0.0) — the
LINEAR chain (the default 1e-10 truncation is a nonlinear cleanup
outside the port contract) — to float64 roundoff when x64 is enabled.
PRECISION: enable jax_enable_x64 for quantitative work. The map<->alm
steps (s2fft healpix transforms, Price-McEwen recursion) carry O(10%)
errors in float32 even at small lmax; the Wigner rotation core is
float32-stable, but the projector as a whole inherits the transform error
(see limtod_jax.hpx).
- class rheplicant.radio.sky.general_pointing.GeneralPointingProjector(beam_alms, lat_deg, lmax, nside, normalize_beam=False)[source]¶
Bases:
AbstractSkyProjectorPure-JAX limTOD sky projector: jit/vmap/grad-safe with exact adjoint.
Handles ARBITRARY pointing, one Wigner rotation per time sample. If the observation is a drift scan — fixed azimuth/elevation/self-rotation, only LST advancing — use
DriftScanProjectorinstead: it returns the same numbers to float64 roundoff for a single rotation over the whole scan (O(lmax³ + n_time·lmax)vsO(n_time·lmax³)).Coordinate conventions (degrees, per the RHINO family):
coords.extra["lst_deg"]—(n_time,)local sidereal times.coords.pointing—(n_time, 2)azimuth/elevation [deg].coords.extra["selfrot_deg"]— optional(n_time,)self-rotation (defaults to zero).
- beam_alms¶
(n_freq, n_alm)packed healpy beam alms (traced — beam parameters are differentiable). Compute them as numpy limTOD does (hp.map2alm(beam_map, lmax=lmax)) for oracle equivalence. Must be VALID real-field alms (m=0 coefficients real — automatic for map2alm output); forward/adjoint are exact transposes on that subspace.- Type:
- normalize_beam¶
numpy limTOD’s
normalize_beamsemantics — divide each sample by the rotated beam’s pixel sum (static).- Type:
- forward(sky, coords)[source]¶
Observe the sky:
(n_freq, n_pix) -> (n_time, n_freq).- Parameters:
sky (Array)
coords (Coordinates)
- Return type:
- adjoint(tod, coords)[source]¶
Adjoint map
(n_time, n_freq) -> (n_freq, n_pix)(linear projectors only).Required by sky-space filtering / map-making. Every shipped engine is linear and implements it; a nonlinear one may leave it unimplemented.
- Parameters:
tod (Array)
coords (Coordinates)
- Return type:
Drift-scan m-mode projector — the fast path for RHINO’s actual geometry.
m-mode analysis is the standard harmonic treatment of drift-scan (transit)
observations. This engine rests on one identity from it, in the conventions
of M-mode RIME explicit in beam, fringe and sky modes
(https://zh-zhang.com/myNotes/MmodeNote.pdf) — the last line of its Eq. (13):
rotate the beam into the celestial frame ONCE at a reference LST, and the
remainder of the sidereal day reduces to a per-m phase e^{-i m dphi}.
That is the whole reason the cost stops scaling with the number of samples.
DriftScanProjector needs no precomputed transfer matrices: it
derives the m-mode projection on the fly from the beam alms via
limtod_jax.driftscan (one Wigner rotation for the whole scan plus
per-m phases). For a genuine drift scan — fixed azimuth/elevation/
self-rotation, only LST advancing — it reproduces
GeneralPointingProjector to roundoff
at O(lmax³ + n_time·lmax) instead of O(n_time·lmax³), with the same
exact sky-slot adjoint. Tracking or scanning strategies still need the
general projector.
Where the general projectors read the pointing from coords.pointing
per call, here the pointing IS the projector configuration: az/el/selfrot
are static fields, and coords only supplies
coords.extra["lst_deg"]. A drift scan that needs a per-sample pointing
is not a drift scan. coords.pointing is therefore not consumed — but it
is not silently discarded either: pointing that AGREES with the projector’s
own passes (reusing a general projector’s coords is the expected way to
switch engines), while pointing that disagrees raises, because using this
projector’s value instead would simulate a different observation and return
a perfectly finite, perfectly wrong answer.
Two static opt-ins turn the projector from “correct” into “fast for inference”, both preserving full jit/vmap/grad behaviour:
DriftScanProjector.to_reference_frame()pays the O(lmax³) Wigner rotation ONCE and returns an equivalent projector that skips it on every later call — the difference between rotating once and rotating per likelihood evaluation;uniform_sampling=Trueroutes the time synthesis (and its adjoint) through real FFTs, O(n_time·log n_time) independent of lmax, when the LST grid is uniform over a full sidereal turn.
The optional horizon mask (horizon_mask=True) applies the physical
below-ground cut to the beam in the horizontal frame before projecting,
with cosine apodization (apod_deg) to tame the Gibbs ringing of a
hard cut at finite band-limit — see the ringing study in the limTOD docs
(docs/driftscan.md): narrow beams never need it; wide low-elevation
beams need it and 2–5° of apodization.
COST, and how to not pay it: on the "local" beam frame the mask is the most
expensive thing this projector can do. It adds a Wigner rotation into the
horizontal frame, a map synthesis, an iterative re-analysis (mask_iterations
rounds, 3 by default) and a rotation back — to EVERY call. Measured at nside 16
/ lmax 47: 14.6 ms against 1.79 ms unmasked, 8.2x.
None of that is inherent. The horizon is fixed in the horizontal frame, and a
drift scan’s pointing is fixed by definition, so the masked beam is a CONSTANT:
truncating the beam MAP once, before analysis, gives the same instrument for
1.04x (rheplicant.radio.beams.horizon_truncated_beam(); the two agree to
2.8e-5, the residual being the alm->map->alm round trip this path takes before
it masks). Prefer that. horizon_mask=True earns its keep when the pointing
is not zenith — there the beam-local and horizontal charts stop sharing a pole
and the cut is a tilted great circle, which limTOD’s rotation handles and a
map-space multiply does not. Either way, follow it with
DriftScanProjector.to_reference_frame(), which folds the masked beam into
the cached alms once and clears the flag; __check_init__ forbids the
combination that would apply it twice.
PRECISION: enable jax_enable_x64 for quantitative work (the map<->alm
steps inherit s2fft’s float32 limitation; see limtod_jax.hpx).
- class rheplicant.radio.sky.driftscan.DriftScanProjector(beam_alms, lat_deg, az_deg, el_deg, lmax, nside, selfrot_deg=0.0, normalize_beam=False, horizon_mask=False, apod_deg=0.0, mask_iterations=3, lst_ref_deg=None, beam_frame='local', uniform_sampling=False, beam_ref_lst_deg=None, freq_chunk=None)[source]¶
Bases:
AbstractSkyProjectorm-mode sky projector for drift scans: one beam rotation, per-m phases.
Equal to
GeneralPointingProjectorwith constant pointing, to float64 roundoff — but the whole scan costs a single Wigner rotation. Pure JAX (jit/vmap/grad-safe), exact transpose inadjoint(), andmmodes()exposes the m-mode coefficients (the Fourier series of the sidereal-day TOD) directly for m-mode analyses.Coordinate conventions (degrees, per the RHINO family):
coords.extra["lst_deg"]—(n_time,)local sidereal times.coords.pointing/coords.extra["selfrot_deg"]are not consumed — the drift pointing is projector configuration (the static fields below), not per-sample data. They may agree with it (so a general projector’s coords can be reused verbatim); if they disagree, the call is rejected rather than quietly simulating the projector’s pointing instead.
- Parameters:
beam_alms (Array)
lat_deg (float)
az_deg (float)
el_deg (float)
lmax (int)
nside (int)
selfrot_deg (float)
normalize_beam (bool)
horizon_mask (bool)
apod_deg (float)
mask_iterations (int)
lst_ref_deg (float | None)
beam_frame (str)
uniform_sampling (bool)
beam_ref_lst_deg (float | None)
freq_chunk (int | None)
- beam_alms¶
(n_freq, n_alm)packed healpy beam alms in the BEAM-LOCAL frame (traced — beam parameters are differentiable). Compute them as numpy limTOD does (hp.map2alm(beam_map)); valid real-field alms (m = 0 coefficients real).- Type:
- az_deg / el_deg / selfrot_deg
the fixed drift-scan pointing [deg] (static configuration).
- horizon_mask¶
apply the below-horizon cut to the beam in the horizontal frame before projecting (static; default off, matching numpy limTOD). Expensive on the hot path — see COST in the module docstring; pair it with
to_reference_frame().- Type:
- apod_deg¶
cosine-apodization width of the horizon cut [deg of elevation] (static; only used with
horizon_mask).- Type:
- lst_ref_deg¶
reference LST [deg] of the m-mode expansion (static);
Noneuses the first sample ofcoords.extra["lst_deg"]. Any value gives the same TOD — it only re-anchors the phases.- Type:
float | None
- beam_frame¶
"local"(default) —beam_almsare beam-local and the reference rotation happens on every call, keeping gradients w.r.t. the beam-local alms;"reference"— they are already the celestial-frame alms atlst_ref_deg, so the rotation is skipped. Build the latter withto_reference_frame()(static).- Type:
- uniform_sampling¶
use the FFT synthesis/adjoint (static). Requires
coords.extra["lst_deg"]to be a uniform grid over a full sidereal turn with2·lmax < n_time; validated bylimtod_jaxwhenever the values are concrete. Gradients w.r.t. the LST grid then live on a one-parameter family (a global shift), sodphi’s Jacobian is a single column — exact for any timing parameter that keeps the grid a uniform full turn, and undefined for per-sample perturbations, which are rejected or NaN-poisoned rather than fitted.- Type:
- freq_chunk¶
process the frequency axis in batches of this size instead of all at once (static;
None= all at once). Peak memory is linear inn_freq— 3.4 MB per channel at nside 64 / lmax 191, so 114 MB at 32 channels but ~1.8 GB at nside 256. Chunking trades time for that ceiling: measured at nside 64, chunk 8 cut the peak 3.2x for 1.7x the time, chunk 1 cut it 9.4x for 7.9x. Leave itNoneunless memory is the binding constraint; below the ceiling it is a pure loss.- Type:
int | None
- beam_ref_lst_deg¶
set only by
to_reference_frame()— the LST the cached beam was actually rotated to (static). In"reference"mode it must equallst_ref_deg; the pair is what makes an attempt to re-anchor the phases against a stale cached rotation fail loudly instead of silently.- Type:
float | None
- classmethod from_beam_maps(beam_maps, *, lat_deg, az_deg, el_deg, lmax, iterations=3, **kwargs)[source]¶
Build from HEALPix beam MAPS, the form a beam model actually has.
The sky enters this projector as maps; the beam otherwise has to enter as alms, leaving the user to run the analysis transform themselves — with two inequivalent transforms to choose between.
map2alm_quad(whatforward()uses on the SKY) returns quadrature alms; the beam needs TRUE alms, i.e. healpy’shp.map2alm(beam, lmax, iter=3). Picking the visible one silently rescales the beam bynpix/4π, so this constructor makes the correct choice the easy one.It is pure JAX (
limtod_jax.map2alm_iter, the oracle-locked healpy equivalent), so unlike an externalhp.map2almcall it stays inside the trace: gradients flow to the beam MAP, which is what a beam model is parameterized in.nsideis inferred from the map length.- Parameters:
beam_maps (Array) –
(n_freq, n_pix)HEALPix RING beam maps in the beam-local frame.el_deg (float) – site latitude and the fixed drift pointing [deg].
lmax (int) – harmonic band-limit of the analysis.
iterations (int) – healpy
iterequivalent for the analysis (static).**kwargs – forwarded to the constructor (
selfrot_deg,normalize_beam,horizon_mask,uniform_sampling…).lat_deg (float)
az_deg (float)
el_deg
- Return type:
- horizon_fraction()[source]¶
Above-horizon share of this beam’s solid angle, per frequency.
f_sky = int_above B dOmega / int_4pi B dOmega– the weight that turns a horizon-masked sky average into its share of the antenna temperature. Withhorizon_mask=Truea projector returns<T_sky>_masked, the average over the VISIBLE beam; the rest of the beam is looking at ground, so the antenna actually collects:f_sky * <T_sky>_masked + (1 - f_sky) * T_ground
and this supplies the
f_sky. Feed it toBeamSpillOperator, which applies both halves so they cannot disagree – or useBeamSpillOperator.from_projector(), which calls this for you.A thin adapter over
limtod_jax.horizon_beam_fraction()(limTOD >= 1.9). Everything that makes the number right – that it is a pixel-space partition rather than the band-limited masked beam’s own integral, and that the ring of pixels centred exactly on the horizon counts HALF – is decided and numerically locked there, where the beam physics lives. The two choices are worth 17 K and 8.6 K of a 200 K spill bias respectively; neither is a detail.For a zenith drift scan prefer
rheplicant.radio.beams.horizon_truncated_beam(), which returns the fraction alongside a beam already cut, needs no rotation, and makes the mask free rather than 8.2x.- Returns:
(n_freq,)above-horizon beam fraction.- Raises:
StateValidationError – on a
beam_frame="reference"projector.to_reference_frame()folds the mask into the cached alms and clears the flag, so the unmasked beam this needs is gone. Callhorizon_fraction()on the local-frame projector first, then cache.- Return type:
- static uniform_lst_grid(n_time, lst0_deg=0.0)[source]¶
The LST grid
uniform_sampling=Truerequires: a FULL sidereal turn.lst0 + 360·t/n_time— note the excluded endpoint. The naturaljnp.linspace(0, 360, n_time)INCLUDES it, which makes the grid a turn-plus-one-step and silently invalidates the FFT synthesis; that is a real regression this package has already been bitten by, so the correct grid is provided rather than described.
- sky_to_alms(sky)[source]¶
Analyse sky maps into the quadrature alms the engine consumes.
Pure JAX and differentiable, so this is a hoist, not an escape from the trace: a fixed sky can be analysed ONCE outside an inference loop and fed to
forward_alms()on every evaluation.This matters more than it looks. At nside 64 / lmax 191 / 32 channels the analysis is 176 ms of a 193 ms forward and 114 MB of its 114 MB peak — the engine’s entire cost, once the beam rotation is cached. Fitting a beam against a fixed sky therefore repays this call every single step for nothing.
Note the transform is the QUADRATURE one (
map2alm_quad), matching what numpy limTOD’s pixel-space beam-weighted sum implies — not the true-alm transformfrom_beam_maps()uses for the BEAM. The two differ bynpix/4pi; that is why this helper exists rather than a line in the docstring telling you which to call.
- forward(sky, coords)[source]¶
Observe sky MAPS — the
AbstractSkyProjectorcontract.A thin wrapper over
forward_alms(); when the sky is fixed, analyse it once withsky_to_alms()and call that directly.- Parameters:
sky (Array)
coords (Coordinates)
- Return type:
- forward_alms(sky_alms, coords)[source]¶
Observe pre-analysed sky alms — the engine’s native input.
- Parameters:
sky_alms (Array) –
(n_freq, n_alm)quadrature alms fromsky_to_alms().coords (Coordinates)
- Return type:
- adjoint(tod, coords)[source]¶
Adjoint map
(n_time, n_freq) -> (n_freq, n_pix)(linear projectors only).Required by sky-space filtering / map-making. Every shipped engine is linear and implements it; a nonlinear one may leave it unimplemented.
- Parameters:
tod (Array)
coords (Coordinates)
- Return type:
- mmodes(sky, coords)[source]¶
m-modes
Ṽ_mof the drift-scan TOD, per frequency.Returns a complex
(n_freq, lmax+1)array — the Fourier coefficients of the (sidereal-day-periodic) TOD, m ≥ 0 (real fields make the negative-m half redundant).coordssupplies the reference LST anchoring the phases (first sample unlesslst_ref_degis set); the coefficients’ magnitudes are sampling-independent.Requires
normalize_beam=False: normalization divides the TOD by the ones-map denominator, which is not part of the m-mode expansion, so these coefficients would no longer be the spectrum of whatforward()returns (measured ~18x off). Rejected rather than silently mismatched — the same policy as the"reference"/mask combination above.Takes MAPS, for symmetry with
forward(); with a fixed sky usesky_to_alms()once and callmmodes_alms().- Parameters:
sky (Array)
coords (Coordinates)
- Return type:
- mmodes_alms(sky_alms, coords)[source]¶
m-modes from pre-analysed sky alms — see
mmodes().- Parameters:
sky_alms (Array)
coords (Coordinates)
- Return type:
- to_reference_frame(lst_ref_deg=None)[source]¶
Precompute the beam rotation once; return an equivalent projector.
The returned projector holds the celestial-frame beam alms at
lst_ref_deg(mask already applied if it was configured) and skips the O(lmax³) Wigner rotation on every subsequentforward/adjoint/mmodes— the difference between paying the rotation once and paying it per likelihood evaluation. Call it OUTSIDE the inference loop.Fully functional:
selfis unchanged, and this is pure JAX, so it is itself differentiable and jit-safe. Gradients through the RESULT are with respect to the reference-frame alms; if you need gradients w.r.t. the beam-local alms (or w.r.t. pointing), keep the"local"projector — which is exactly the compute-vs-flexibility trade this method exposes rather than hides.- Parameters:
lst_ref_deg (float | None) – reference LST [deg]; defaults to this projector’s
lst_ref_deg, which must then be set (a cached rotation cannot depend on coords supplied later).- Return type:
SkySourceOperator: the modular sky slot of the forward model.
Composes the two halves of the sky abstraction:
SkySourceOperator(
sky_model=PowerLawSkyModel(...), # what the sky is (differentiable params)
projector=MatrixProjector(A), # how it is seen (swappable engine)
)
Either half swaps independently — e.g. replace the projector with
eqx.tree_at(lambda p: p["t_ant"]["sky"].projector, twin, DriftScanProjector(...))
without touching the sky parameters, or infer sky parameters through any
engine via rheplicant.inference.build_forward_fn.
- class rheplicant.radio.sky.source.SkySourceOperator(sky_model, projector)[source]¶
Bases:
AbstractOperatorProduce the sky’s antenna-temperature contribution: projector(sky_model).
- Parameters:
sky_model (AbstractSkyModel)
projector (AbstractSkyProjector)
- sky_model¶
what the sky is —
AbstractSkyModel.
- projector¶
how it is seen —
AbstractSkyProjector.
SkyOperator — PLACEHOLDER sky: one uniform brightness, no map and no beam.
The sky-TOD port is not coming here. This module used to promise it –
“sky maps with spectral models, observed along coords.pointing” – and that
port has since arrived as
SkySourceOperator composed with a
projector, which is real rather than a stand-in. Leaving the promise here would
have pointed at work that is finished somewhere else.
What stays placeholder is the sky: one brightness temperature over the whole (time, frequency) grid stands in for a real sky model. That is deliberately the cheap path, and it is what a test, a smoke run or an inference fixture usually wants when the sky is not the subject. Reach for the projector when the beam matters, and for this when it does not.
- class rheplicant.radio.sky.uniform.SkyOperator(amplitude)[source]¶
Bases:
AbstractOperatorFill
state.datawith a uniform sky brightness [K] (placeholder).- Parameters:
amplitude (Array)
GlobalSignalOperator — PLACEHOLDER 21 cm global signal.
Element: “21cm global signal (roughly const. in LST, smooth in freq.)”.
Real physics to come: physical global-signal models (e.g. parametrized absorption troughs, ARES/21cmFAST-style physical parameters) whose recovery is the science target. The placeholder is a Gaussian absorption feature — constant in time, smooth in frequency — with differentiable depth, centre, and width, so signal-recovery experiments work end-to-end already.
- class rheplicant.radio.sky.global_signal.GlobalSignalOperator(depth, centre, width)[source]¶
Bases:
AbstractOperatorProduce a Gaussian absorption trough on the (time, freq) grid (placeholder).
Contribution:
-depth * exp(-0.5 ((freq - centre) / width)^2), constant in time. All three parameters are differentiable leaves.
ForegroundOperator — PLACEHOLDER diffuse foregrounds.
Element: “Multiple diffuse FG components (variable in LST and freq.)”.
Real physics to come (port of limTOD’s sky handling): foreground maps with uncertain spectral-index structure (Anstey-style) improved via the moment expansion — the identified pain point where “reasonable” few-percent models must reach 0.1% accuracy. The placeholder is a single power law, constant in time (the real component varies in LST as the Galaxy transits).
- class rheplicant.radio.sky.foregrounds.ForegroundOperator(amplitude, spectral_index, ref_freq)[source]¶
Bases:
AbstractOperatorProduce a power-law foreground spectrum on the (time, freq) grid (placeholder).
Contribution:
amplitude * (freq / ref_freq) ** (-spectral_index), constant in time. Amplitude and spectral index are differentiable leaves.
PointSourceOperator — PLACEHOLDER bright point sources.
Element: “Bright point sources (these are mostly diluted by the beam, but still there at a low level)”.
Real physics to come: a catalogue of bright sources with spectra, entering through the (sidelobe-weighted) beam as the sky drifts. The placeholder is a constant low-level contribution.
- class rheplicant.radio.sky.point_sources.PointSourceOperator(level)[source]¶
Bases:
AbstractOperatorProduce a constant beam-diluted point-source level (placeholder).
- Parameters:
level (Array)
IonosphereOperator — PLACEHOLDER ionospheric distortion.
Element: “Ionosphere (complicated, varying in time and frequency, distorting the astrophysical signal)”.
Real physics to come: time-variable ionospheric absorption and refraction (chromatic, roughly ~ freq^-2), applied to the astrophysical signal only — which is why in a forward model this operator sits after the astrophysical sum but before terrestrial contributions are added. The placeholder is a static chromatic scaling.
- class rheplicant.radio.environment.ionosphere.IonosphereOperator(delta, ref_freq)[source]¶
Bases:
AbstractOperatorApply a chromatic ~freq^-2 distortion to existing data (placeholder).
data * (1 + delta * (freq / ref_freq)^-2)—deltais a differentiable leaf controlling the distortion amplitude.
AtmosphericEmissionOperator — PLACEHOLDER beam-averaged atmospheric emission.
The atmosphere enters the graph twice (equivalent-entry pair, like ground spill):
atmosphere(this operator): the beam-averaged emission as an additive effective temperature — a branch of the antenna-temperature sum, parallel toground_pickupandt_sys_extra. It sits before thereceiver_inputswitch (calibration loads do not see the sky) and before the noise-wave stage (atmospheric emission arrives through the antenna, so it suffers the(1-|Gamma|^2)reflection loss).atmosphere_field(reserved, no shipped operator): strict radiative transfer on the astro branch before the beam —e^(-tau sec z) T_sky + T_atm (1 - e^(-tau sec z))inside the beam integral. Opacity must act on the astro sky alone: applied after the antenna-temperature sum it would wrongly attenuate ground pickup, which never crosses the atmosphere.
Real physics to come: emission temperature from an atmospheric model (opacity x ambient temperature, beam-weighted airmass), slowly varying in time and frequency for a zenith-pointing drift scan.
- class rheplicant.radio.environment.atmosphere.AtmosphericEmissionOperator(t_atm)[source]¶
Bases:
AbstractOperatorProduce the beam-averaged atmospheric emission contribution [K].
The BODY is a placeholder: a constant effective temperature, optionally per channel, with no opacity, no elevation dependence and no weather. The contract is not. What is real here is the placement argument – before the
receiver_inputswitch, because calibration loads do not see the sky, and before the noise-wave stage, because emission arriving through the antenna suffers the(1-|Gamma|^2)reflection loss – and that argument survives whatever replaces the body.Real physics to come: emission from an opacity profile along the line of sight, elevation-dependent through the airmass, which is the
atmosphere_fieldnode reserved below.Source-type: a branch of the antenna-temperature
SumOperator, producing its own(n_time, n_freq)contribution on the shared grid.- Parameters:
t_atm (Array)
GroundPickupOperator — PLACEHOLDER ground spill-over.
Element: “Ground pickup via the beam sidelobes (depends on ambient temperature as well)”. Pain point: “a simple low-order spatial model might be worth trying, i.e. use the existing topographic template and allow its outline and internal structure to be modulated by relatively smooth functions of alt/az. This will couple into the beam effects however.”
Real physics to come: sidelobe-weighted topographic template with smooth
alt/az modulation (coupled to the beam model). The placeholder demonstrates
the environment coupling contract: the contribution is
coupling * T_ambient with the ambient temperature read from the traced
state.env when available — exactly why Environment is a traced (and thus
differentiable) part of State.
- class rheplicant.radio.environment.ground.GroundPickupOperator(coupling, t_ground)[source]¶
Bases:
AbstractOperatorProduce a ground-pickup contribution coupled to ambient temperature.
The BODY is a placeholder: one scalar coupling times one temperature, with no dependence on where the sidelobes actually point. The contract is not. What is real here is the environment-coupling seam – an ambient temperature read from the traced state with a declared parameter fallback, and a placement in the antenna-temperature sum parallel to atmospheric emission – and that seam does not change when the physics arrives.
Real physics to come: a sidelobe-weighted topographic template modulated by smooth functions of alt/az, which couples this operator to the beam model and makes
couplinga field rather than a scalar.Contribution:
coupling * T_ambwhereT_ambcomes fromstate.env.temperature(scalar or per-time) if present, else from thet_groundfallback parameter.
RFIOperator — PLACEHOLDER radio-frequency interference.
Element: “RFI (very complicated, mix of narrow and wideband signals with temporal structure on a variety of scales, some bright and some low-level)”. Pain point: “For low-level RFI, we can perhaps consider a stochastic process model that fits unknown added variance based on night to night variations.”
Real physics to come: a stochastic-process model for low-level unflagged RFI (the hardest pain point — no reasonable model exists yet), plus bright narrow/wideband transmitters. The placeholder draws a sparse random mask of constant-amplitude spikes via the State PRNG protocol.
- class rheplicant.radio.environment.rfi.RFIOperator(amplitude, occupancy)[source]¶
Bases:
AbstractOperatorProduce sparse random RFI spikes (placeholder).
Contribution:
amplitude * maskwheremask ~ Bernoulli(occupancy)per (time, freq) cell, drawn through the State PRNG protocol.
Measured/simulated beam patterns as HEALPix maps for the sky engines.
Thin adapters, deliberately. How a measured beam becomes a beam map, where
the horizon falls in it and what share of its solid angle survives are limTOD’s
subject (D20, D25) — exactly as the noise-wave data model is
rhino_cal_jax’s. This package’s job is to place the result on a signal
path. Everything here is a pass-through whose only added value is at the seam:
frequencies in Hz, because that is what Coordinates.freq carries, and
nside inferred from maps that already know it.
The physics and its conventions live upstream:
limTOD.cstbeam— CST Studio far-field exports onto HEALPix maps (read_cst_farfield,cst_frequency_table,cst_beam_maps), plus acst_beam_funcfor limTOD’s own simulator. Read that module’s conventions before trusting a beam, in particular that the CST azimuth’s offset and handedness are facts about the as-built horn which the export does not contain:phi0_degandphi_senseare assumptions to check, not results, and for RHINO’s horn — 30-60 % azimuthal structure around thetheta = 30deg ring — the handedness is not a detail.limtod_jax.horizon_truncated_beam()— the horizon cut and the surviving sky fraction.
Needs healpy and scipy, both already required by limTOD, which is
a dependency of this package, so nothing extra is needed for this module.
- rheplicant.radio.beams.read_cst_farfield(path)[source]¶
Read one CST far-field export — a pass-through to limTOD.
See
limTOD.cstbeam.read_cst_farfield(), which owns the format and the conventions.
- rheplicant.radio.beams.cst_frequency_table(directory, *, suffix='.txt')[source]¶
Map frequency [Hz] to file for a directory of CST exports.
A pass-through to
limTOD.cstbeam.cst_frequency_table(), whose keys are in MHz; the conversion is this seam’s whole contribution.
- rheplicant.radio.beams.cst_beam_maps(directory, freq_hz, *, nside, suffix='.txt', phi0_deg=0.0, phi_sense='ccw')[source]¶
Sample a directory of CST exports onto HEALPix maps — pass-through.
limTOD.cstbeam.cst_beam_maps()does the work and documents the conventions; this takesfreq_hzin Hz to matchCoordinates.freq.- Parameters:
directory (str | Path) – directory of per-frequency CST exports.
freq_hz –
(n_freq,)output frequencies [Hz].nside (int) – HEALPix resolution of the output maps (RING ordering).
suffix (str) – file extension of the exports.
phi0_deg (float) – CST azimuth landing on the beam-map
phi = 0meridian.phi_sense (str) –
"ccw"or"cw". A fact about the horn, not the file — seelimTOD.cstbeam.
- Returns:
(n_freq, 12 * nside ** 2)linear-power beam maps, unnormalized. Passnormalize_beam=Trueto the projector and let it divide by its own quadrature, which is the only way the band limit cancels exactly (seedocs/sky-engines.md).- Return type:
- rheplicant.radio.beams.horizon_truncated_beam(beam_maps, *, el_deg=90.0, apod_deg=0.0)[source]¶
Cut beam maps at the horizon — a thin pass-through to limTOD.
The physics, the conventions and their numerical locks live in
limtod_jax.horizon_truncated_beam()(limTOD >= 1.9); this exists only so thatnsideneed not be repeated when the maps already carry it.- Parameters:
- Returns:
(truncated_maps, sky_fraction), shapes(n_freq, npix)and(n_freq,). Hand the fraction straight toBeamSpillOperator.- Raises:
StateValidationError – if the maps are not a valid HEALPix length.
AntennaLossOperator — the antenna’s own ohmic loss, before the receiver.
A real antenna is not a lossless collector. Conductor and dielectric loss dissipate a fraction of everything it gathers, and — by Kirchhoff, since the lossy structure is a passive element in thermal equilibrium — re-emit that fraction as thermal noise at the antenna’s own physical temperature:
T_ant = eta * T_collected + (1 - eta) * T_phys
eta is the radiation efficiency. This is a different loss from the
(1 - |Gamma|^2)|F|^2 factor inside
NoiseWaveOperator: that one is
the impedance MISMATCH at the antenna-receiver interface, this one is
dissipation INSIDE the antenna. They multiply, and neither substitutes for the
other. An efficiency folded into the noise-wave couplings would be
indistinguishable from a mismatch in the fit while being wrong about the added
(1 - eta) T_phys term, which a mismatch does not produce.
Placement (graph v1.3). The node sits on the trunk between t_ant_sum and
the receiver_input switch, and the position is the physics:
AFTER
t_ant_sum, and applying to the WHOLE sum — sky, ground spill, atmospheric emission, everything the beam collected passes through the same lossy conductor. This is exactly the argument that D13 found false for the atmosphere (opacity must not attenuate ground pickup, which never crosses the atmosphere) and that holds here for the opposite reason: ohmic loss acts after collection, so provenance no longer matters.BEFORE
receiver_input— the calibration loads connect at the receiver input, downstream of the antenna, so they never see this loss. Putting it after the switch would attenuate the loads too and bias every noise-wave solution that uses them.
efficiency is physically in [0, 1] and t_physical is an ambient
temperature in kelvin, but neither is range-checked: both are differentiable
leaves that a calibrator may hold as tracers, so a value check would either fail
under jit or be skipped there — the two failure modes this codebase refuses.
Shapes are checked; values are the caller’s to keep physical.
- class rheplicant.radio.instrument.antenna_loss.AntennaLossOperator(efficiency, t_physical)[source]¶
Bases:
AbstractOperatorAttenuate by the radiation efficiency and add the antenna’s own emission.
- efficiency¶
radiation efficiency
eta; differentiable scalar or(n_freq,)spectrum.1.0is a lossless antenna and makes the operator an exact identity.- Type:
BeamSpillOperator — the horizon split of a beam that does not stop at it.
A real beam does not end at the horizon. RHINO’s horn puts 1-3 % of its solid angle below it, and that part is looking at ground, not sky. The antenna temperature is therefore a weighted mixture:
T_collected = f_sky * <T_sky>_masked + (1 - f_sky) * T_ground
with f_sky the above-horizon beam fraction and <T_sky>_masked the beam
average over the VISIBLE sky — which is exactly what a projector with
horizon_mask=True, normalize_beam=True returns.
Both halves live in one operator ON PURPOSE. Split across two objects — a
weight somewhere and a GroundPickupOperator somewhere else — the two
numbers can drift apart, and a sky branch weighted by f while the ground
branch uses 1 - f' is a bias no shape check can see. Here the weights sum
to one by construction.
f_sky IS the horizon mask, measured. Get it from
horizon_fraction(),
which computes it from the same band-limited masked beam the forward model
uses, or let BeamSpillOperator.from_projector() do both at once. Guessing
it, or taking an ideal pixel-space horizon cut instead, weights one beam by
another beam’s solid angle.
Placement (graph v1.4). beam_spill is the trunk stage of the ASTRO branch:
beam | observed_astro_sky -> astro_ant_sum -> beam_spill -> t_ant_sum. The
split applies to the thing that genuinely is a beam integral over the celestial
sphere and to nothing else. The other t_ant_sum leaves — ground_pickup,
atmosphere, t_sys_extra — are effective temperatures by D13’s
construction, already carrying whatever beam weighting their author intended;
running them through the split would weight them twice, and would attenuate a
ground term that IS the below-horizon share.
Relation to the two other losses on this path, none of which substitutes for another (they compose, in this order):
beam_spill— what the beam is pointed at. Mixing, no loss: an isotropic sky atTwith ground also atTstill givesT.AntennaLossOperator— ohmic dissipation inside the antenna, acting on the wholet_ant_sum.c_s = (1 - |Gamma|^2)|F|^2insideNoiseWaveOperator— the impedance mismatch at the receiver input.
The first two share an arithmetic form, a * x + (1 - a) * b, and are
deliberately NOT one operator: they carry independent physical parameters at
different points on the path, and merging them would make an efficiency and a
spill fraction indistinguishable in a fit.
USING IT WITH GroundPickupOperator: this operator already supplies the
below-horizon ground term, so a GroundPickupOperator alongside it adds a
SECOND, additional one. That is legitimate only if it stands for something else
(a nearby building, a ground screen’s own emission); as a model of the same beam
spill it double-counts. Nothing enforces this — both are legal leaves — so it is
the caller’s call to make deliberately.
- class rheplicant.radio.instrument.beam_spill.BeamSpillOperator(sky_fraction, t_ground)[source]¶
Bases:
AbstractOperatorMix the horizon-masked sky with the ground the rest of the beam sees.
- sky_fraction¶
f_sky, the above-horizon beam fraction; differentiable scalar or(n_freq,)spectrum.1.0means no spill and makes the operator an exact identity.- Type:
- t_ground¶
brightness temperature seen below the horizon [K]; differentiable scalar or
(n_freq,).- Type:
- classmethod from_projector(projector, *, t_ground)[source]¶
Take
f_skyfrom the projector that will supply the sky.The one call that cannot get the weight and the sky average out of step, because it reads the fraction off the same beam.
- Parameters:
projector – a projector exposing
horizon_fraction()— todayDriftScanProjector, on which the horizon cut is a fixed property of the pointing.t_ground – brightness temperature below the horizon [K].
- Raises:
StateValidationError – if the projector has no
horizon_fraction.- Return type:
Receiver bandpass — PLACEHOLDER.
Real physics to come: a frequency-dependent bandpass from measurement or an
instrument model. Not the reflection/impedance-mismatch effects this
docstring once promised — those arrived at the noise_wave node, where
NoiseWaveOperator carries the
source and receiver Gamma and is real physics rather than a stand-in.
Sky-side
additive temperatures (atmosphere, ground spill) are not receiver
business — they are branches of the antenna-temperature sum
(AtmosphericEmissionOperator,
GroundPickupOperator), entering
before the reflection/noise-wave terms and therefore seeing the
(1-|Gamma|^2) loss. The receiver temperature itself enters after the
reflection, as the noise-wave T_0 (see
NoiseWaveOperator) and the
post-gain thermal noise T_n.
THE BANDPASS/GAIN SCALE IS NOT IDENTIFIABLE, and the convention that fixes it
lives here. The prediction depends on b(nu) and g(t) only through their
product, so b -> c*b, g -> g/c leaves every predicted sample bit-for-bit
unchanged for any scalar c. Free both and the model has one exactly null
direction — measured, not asserted:
b free (5 ch) + g free (6 samples), T_ant known
n_par=11 rank=10 nullity=1 null singular value 8.4e-17 of s_max
participation {'bandpass': 0.50, 'gain': 0.50}
direction(0)['bandpass'] / b +0.2206 in every channel
direction(0)['gain'] / g -0.2206 in every sample
That last pair IS the trade, read straight off
direction():
a perturbation proportional to +b matched by one proportional to -g.
The convention: the bandpass carries only SHAPE (mean 1), the gain carries
the level. Build a bandpass latent with unit_mean_bandpass() and the
null direction is gone — n_par=10 rank=10 nullity=0, weakest identified
direction 0.41.
Why unit mean rather than pinning a reference channel to 1, the other obvious
convention? Both remove exactly one parameter and both work. Unit mean is the
better one for a real instrument on two counts: the reference is an average
over the band, so noise on it falls as sqrt(n_freq) instead of being
whatever one channel happened to do; and that channel can be flagged. RHINO
flags channels — it is a radio telescope — and a convention anchored to a
single channel makes the entire absolute gain scale hostage to the one channel
RFI happens to sit in. A band average degrades gracefully instead.
Normalising inside the binding is NOT enough, and this is the trap. Binding
a full (n_freq,) latent through fn=lambda b: b / mean(b) looks like the
same convention and is not: the prediction is now blind to the scale of the
RAW latent, so the null direction survives — measured n_par=11 rank=10
nullity=1, with participation now {'bandpass': 1.00, 'gain': 0.00}.
The degeneracy moved out of the b/g trade and into the bandpass latent’s own
scale ray; it did not go away. Removing a degeneracy means removing a
parameter, which is what unit_mean_bandpass() does — it takes
n_freq - 1 free values.
- rheplicant.radio.instrument.receiver.unit_mean_bandpass(free)[source]¶
Expand
n_freq - 1free values into ann_freqbandpass of mean 1.The convention that makes a jointly-free bandpass and gain identifiable — see the module docstring for the measurement. Use it as a binding’s
fn:Bind("bandpass_shape", into=lambda p: p["bandpass"].bandpass, fn=unit_mean_bandpass)
with
Latent("bandpass_shape", init=unit_mean_free(b_estimate)).The last channel is the dependent one, which is a property of these COORDINATES and not of the convention: the image of this map is the whole mean-1 hyperplane, so no channel is privileged in the model. It only means the flat parameter vector’s last entry is not “the last channel”.
- Raises:
StateValidationError – if
freeis not 1-D. A(1, n)array would otherwise concatenate along the wrong axis and return a bandpass of the wrong length, whichReceiverOperatorwould then reject with a channel-count message pointing at the wrong thing.- Parameters:
free (Array)
- Return type:
- rheplicant.radio.instrument.receiver.unit_mean_free(bandpass)[source]¶
The
unit_mean_bandpasscoordinates of an existing bandpass estimate.The inverse of
unit_mean_bandpass()on the mean-1 hyperplane:unit_mean_bandpass(unit_mean_free(b))isb / mean(b). Use it to turn a measured or modelled bandpass into aLatent’sinit, so the starting point is the estimate itself rather than a flat guess.The overall level of
bandpassis discarded, by construction — that is the gain’s to carry. Give the gain latent an init scaled by the discardedmean(bandpass)if the product is meant to be preserved.- Raises:
StateValidationError – if
bandpassis not 1-D.- Parameters:
bandpass (Array)
- Return type:
- class rheplicant.radio.instrument.receiver.ReceiverOperator(bandpass)[source]¶
Bases:
AbstractOperatorApply a frequency-dependent bandpass to
state.data(placeholder).When this bandpass and a
GainOperatorgain are inferred together, declare the bandpass throughunit_mean_bandpass(): free as-is, the two share one exactly null direction (the module docstring measures it).- Parameters:
bandpass (Array)
NoiseWaveOperator — the receiver stage of the noise-wave data model.
Implements the system temperature the receiver sees:
T_sys = T_src c_s + T_unc k_unc + T_cos k_cos + T_sin k_sin + T_rx
with the coupling spectra (c_s, k_unc, k_cos, k_sin) supplied by
rhino_cal_jax, which builds them from the source and receiver reflection
coefficients. The physics lives in that package, where it is cross-checked
against the numpy reference it was ported from; this module is the adapter that
gives it a State -> State face and a home on the signal graph.
Placement. This operator sits at the noise_wave node, downstream of the
receiver_input selector, so state.data already carries the selected
source’s T_src. What the selector discards is which source that was — and
that is precisely what the couplings depend on. The operator therefore carries
Gamma per source and re-reads the same switch array the selector used,
coords.extra["receiver_input"].
That is not a convenience, and the counting it supports is the number a real experiment picks its switching cadence from, so state it exactly.
The per-channel rank rule. Each switch position contributes exactly one
equation per frequency channel, so while every temperature is free per
channel the design matrix has rank min(n_src, k) * n_freq, where k is
the number of free temperature families. k is four — T_unc, T_cos,
T_sin, T_rx — whenever T_rx is fitted, and three only when T_rx is
taken as known: t_rx is a leaf of this operator like the other three, and
its coupling is 1 rather than absent. So a four-family per-channel fit needs
four distinct loads to be square, and three loads leave it deficient by
exactly n_freq. Sharing a single Gamma across the cycle collapses every
source onto the same row and drops the rank to n_freq whatever n_src
is — the fit then returns a finite, correctly-shaped, wholly prior-driven
answer.
Those are measurements, not arguments:
tests/radio/test_noise_wave.py::TestPerChannelRankRule sweeps n_src 1-5,
k in {3, 4} and n_freq in {3, 5, 7} through
identifiability().
The rule is per-channel and nothing more. The moment the temperatures
become coefficients of a frequency basis — which is what a smooth-spectrum
parameterization does, and what the next tranche makes ordinary — the basis
ties channels together, the per-channel counting stops applying in both
directions, and no counting rule replaces it. What survives is a bound,
rank <= min(n_src * n_freq, k * n_basis), and two measured facts about how
loosely it binds (TestBasisRegimeBreaksTheRule):
per-channel counting understates. Two loads and a 3-coefficient basis identify all
k * n_basis = 12coefficients atk = 4, wheremin(n_src, k) * n_basiswould have said 6.the bound overstates. A single load whose
Gammais itself linear in frequency gives rank 5 against a bound of 7, because a basis function times a low-order coupling is another low-order function and the products are not independent. Which loads do that is not visible fromn_src.
The scalar case is the n_basis = 1 corner of the same statement: frequency
structure in Gamma identifies all k scalar temperatures from a
single load, which is why a scalar demonstration says nothing about switching.
So: read a switching cadence off min(n_src, k) * n_freq for a per-channel
fit, and measure every other parameterization with
identifiability() — the instrument
every number above came from.
Gamma is stored as two real leaves rather than one complex leaf because
fisher_information() runs
jax.jacfwd, which refuses complex parameters.
The source temperature. T_src is whatever the selected source delivers, so
for the antenna branch it is the beam-convolved sky –
SkySourceOperator upstream feeds it
directly, and examples/sky_to_noise_wave.py runs that end to end. Two things
about that junction are the caller’s responsibility, because neither is a shape:
the sky projector must return a temperature.
DriftScanProjectorandGeneralPointingProjectordefault tonormalize_beam=False(numpy limTOD’s convention), which returnsint(B T)rather thanint(B T)/int(B). Usenormalize_beam=Truewhen the output is destined forT_src; a beam the caller normalized by hand is still biased at the percent level, since the band-limit truncates the denominator too.gamma_src’s row order must match the selector’s branch order (the graph’s in-edge declaration: antenna, thencal_loads). Both are(n_source, n_freq), so a transposition is shape-legal and costs tens of kelvin. Read the order off the assembled twin –assembly["receiver_input"].names– rather than assuming it.
A third join used to be the caller’s problem and no longer is. SwitchCycle
range-checks the switch array against its source count, but that check needs
concrete values and is skipped under tracing; JAX’s gather semantics would then
clamp the coupling lookup to a neighbouring source while SelectOperator
selected no branch at all. SwitchCycle.gather now fills out-of-range samples
with NaN, so the two consumers of one switch array cannot disagree in silence.
- class rheplicant.radio.instrument.noise_wave.NoiseWaveOperator(t_unc, t_cos, t_sin, t_rx, gamma_src_re, gamma_src_im, gamma_rec_re, gamma_rec_im, switch_key='receiver_input')[source]¶
Bases:
AbstractOperatorApply reflection couplings and add the noise-wave temperatures.
All four temperatures broadcast against the
(n_time, n_freq)couplings, so each independently takes() scalar,(n_freq,),(n_time, 1)or(n_time, n_freq). A bare 1-D array is always read as per-frequency — that isrhino_cal_jax.system_temperature’s convention, and this operator inherits it. To vary a temperature with time, pass an explicit(n_time, 1)column; a bare(n_time,)vector is refused at construction unlessn_time == n_freq, where no check can tell it from a spectrum.- Parameters:
- t_rx¶
receiver offset temperature [K] (the module docstring’s
T_rx; the numpy reference calls the same quantityt_0). Free like the other three — see the module docstring for what that costs in loads.- Type:
CWCalibrationOperator — the continuous-wave calibration tone, as a line.
Elements: “Calibration signals, which are switched in and out of the signal path on some pre-defined cycle. Each calibration source has its own signal shape, particularly in frequency, as well as typical power levels, stability, and additional reflection and noise contributions.”
RHINO’s central design choice (paper, Sect. 4): a continuous-wave source injects a known, narrow-band, large-amplitude signal so the overall gain level is monitored continuously, without Dicke switching.
THE MODEL¶
A monochromatic injection is never observed as a delta on one channel. It is observed through the spectrometer, so what lands in the data is
T_cw(t, nu_k) = A(t) * w_k(t), sum_k w_k(t) = 1
with w the channel response evaluated at the line’s offset from each
channel and normalised over the sampled channels, and
nu_c(t) = tone_freq + drift_rate * (t - t_0) [Hz] A(t) = amplitude * (1 + amplitude_drift_rate * (t - t_0)) [K]
Normalising over channels is the load-bearing choice: it makes the injected
TOTAL equal to amplitude whatever the lineshape, whatever the width, and
wherever the line falls between two channels. The tone’s level is the one
thing this operator knows; a total that moved with the channelisation would
make the known quantity unknown, which is the whole calibration argument.
The price is that the peak channel is no longer amplitude — a line
sitting halfway between two channels keeps only (2/pi)^2 = 0.4053 of it
(the classic half-bin scalloping loss of an unwindowed FFT, -3.92 dB), which
is real and is exactly the bias a delta-on-one-channel model hides.
WHAT IS ESTABLISHED, AND WHAT IS ASSUMED¶
Established in this repository: channel bandwidth equals channel spacing
equals band / n_freq (docs/sky-to-receiver.md, docs/inference.md,
examples/sky_to_noise_wave.py) — the critically-sampled convention.
NOT established anywhere here, in rhino-cal, or in rhino_cal_jax:
RHINO’s spectrometer window or polyphase-filterbank taps, the source’s own
linewidth, and its frequency and amplitude stability over a run. Those are
therefore PARAMETERS, not constants:
lineshape—"sinc2"(default) is the response of a critically sampled unwindowed FFT, the minimal assumption consistent with the convention above."gaussian"approximates an apodised PFB channel. A windowed spectrometer has a WIDER main lobe and far lower sidelobes thansinc2, so assumingsinc2under-protects the core and over-protects the tails. Set the shape you have.line_width— no default. It is a property of the spectrometer (and of the source), and guessing it silently mis-sizes the protection mask, which is the failure this operator exists to avoid.drift_rate/amplitude_drift_rate— first-order (linear) drift over a run, default zero. Linear is an assumption too: it is the leading term of any smooth drift over a run short compared with the oscillator’s thermal time constant, and nothing here establishes that it is short.
WHAT A TONE WITH WIDTH ACTUALLY MEASURES¶
A delta on one channel probes b(nu_cw) * g(t) — one bandpass value, which
is what the placeholder claimed and what an on-centre sinc2 tone still
delivers exactly. A line with width probes sum_k w_k b(nu_k) * g(t): the
lineshape-weighted AVERAGE of the bandpass across the line’s wings. On a
curved bandpass those are different numbers — measured at 2.37% apart for a
1.5-channel gaussian on a realistically curved band in
tests/radio/test_cw_lineshape.py — and reading the second as the first
biases the recovered bandpass at the tone’s channel by the curvature times the
line’s second moment. A narrower tone is a sharper probe; that is the trade
against the identifiability point below, which pushes the same way.
ORDERING CONSTRAINT: the tone is combined with the antenna signal before
the receiver chain — P_rec = g(nu,t) (T_ant + T_nw + T_cw)
+ T_n. This operator must therefore sit BEFORE the bandpass and gain
operators in a pipeline: the tone tracks g(t) drift only if it passes
through the gain, so that delta P_cw ~ g(nu_cw, t). That is no longer
prose — it is declared as must_precede and
assemble() refuses a placement that breaks it.
WHAT THE TONE BUYS, precisely: nothing on its own. Measured with
identifiability(), a known tone
leaves the nullity of a gain x T_ant model at n_time whether it is
switched on or off, because a free-per-cell antenna temperature absorbs the
gain sample by sample — including at the tone’s own channel. It earns its
keep only against a frequency-SMOOTH T_ant (nullity 1 -> 0), where a
narrow line is not in the span of the smooth basis and cannot be reabsorbed.
Giving the line a width makes that WORSE, not better, and the direction is
measured in tests/radio/test_cw_lineshape.py: the residual of the tone’s
channel profile outside a degree-4 polynomial basis falls by more than an
order of magnitude (0.84 -> 0.038) between a quarter-channel line and a line
at MAX_WIDTH_IN_BAND_FRACTION of that band, which is the widest line the
width guard admits at all.
A wide line moves into the span of the smooth basis it was supposed to be
distinguishable from. Realism here costs leverage; nothing in this module
makes the tone independently sufficient, and no docstring should imply it.
Real physics still to come: the tone’s own reflection and noise contributions, and the switched reference loads used for noise-wave calibration.
- rheplicant.radio.instrument.calibration.LINESHAPES = ('sinc2', 'gaussian')¶
Lineshapes this operator can evaluate, in the order the refusal names them.
- rheplicant.radio.instrument.calibration.MIN_WIDTH_IN_CHANNELS = {'gaussian': 0.25, 'sinc2': 1.0}¶
Narrowest line, in channel spacings, each shape may be given on a grid.
These are numerical floors, not physics claims, and they differ because the two shapes fail differently below them.
sinc2has zeros at every integer multiple ofline_width. Give it a width below one channel and the sampled channels start landing ON those zeros: at half a channel, a line midway between two channels sits at offsets +/-1, +/-3, +/-5 — every one a null — and the normalisation then divides by a sum that is float noise. At one channel exactly (the value a critically-sampled unwindowed FFT actually has) the nearest channel is never further than half a null-width away, so its weight never drops below (2/pi)^2 = 0.405 and the sum is always O(1).gaussianis evaluated with its peak exponent subtracted, so its largest weight is exactly 1 and the sum is never smaller than 1 — it cannot underflow at any width. Its floor guards the other end:(nu/sigma)**2overflows toinffor a sigma small enough, andinf - infis NaN. A quarter of a channel is FWHM 0.59 channels, already narrower than an unwindowed FFT’s 0.886, so nothing below it is a channel response.
- rheplicant.radio.instrument.calibration.WIDTH_FLOOR_RTOL = 1e-05¶
Slack on the width floor, because both sides of it are float32 arithmetic.
The canonical
sinc2width is exactly one channel, and the natural way to say that isfloat(freq[1] - freq[0])— which on a float32 grid differs from this module’smedian(diff(freq))in the seventh digit. Refusing the one width the convention names, over 1e-7, would be absurd. 1e-5 is many orders of magnitude below any width difference that changes a lineshape.
- rheplicant.radio.instrument.calibration.MAX_WIDTH_IN_BAND_FRACTION = 0.25¶
Widest line, as a fraction of the observed band, that is still a LINE.
The floor’s mirror, and the other direction the class docstring already names as a silent failure. Past some width the injection stops being a narrow feature and becomes a pedestal across the whole band: every channel lands above
protect_floorof the peak, the protection mask covers the band, and the RFI flagger is switched off for the entire run — genuine RFI surviving into the data, which is the “protect too much” half of the trade.The number is where that starts, at the default
protect_floorof 1e-2, for the worst-case placement (tone at one band edge, channel at the other, an offset of the full bandB). For a gaussian ofsigma = f Bthat channel keepsexp(-1/(2 f^2))of the peak; for asinc2of widthf Bits envelope keeps(f/pi)^2:f gaussian edge/peak sinc2 envelope edge/peak 0.25 3.4e-4 6.3e-3 both BELOW the 1e-2 floor 1/3 1.1e-2 1.1e-2 both ABOVE it
Both shapes cross the default floor within a percent of each other at
f = 1/3; 0.25 is the round value below that crossing, leaving the far side of the band outside the mask by 30x (gaussian) and 1.6x (sinc2).What this does NOT catch, stated because it is measured: a tone nearer the middle of a NARROW band saturates its mask sooner — on the 11-channel band of
tests/radio/test_cw_lineshape.pywith the tone on channel 4, a 2-channel gaussian (f = 0.2) already protects all 11. No fraction-of-band rule can express that, because it depends on where the tone sits and onprotect_floor. This ceiling refuses a width that is not a line on ANY placement; the level rule owns the rest.
- rheplicant.radio.instrument.calibration.MIN_CEILING_IN_CHANNELS = 2.0¶
Floor under the ceiling, in channel spacings, so a coarse grid cannot make a one-channel line “too wide”.
MAX_WIDTH_IN_BAND_FRACTION * (high - low)is0.25 * (n_freq - 1)channels, which drops BELOW one channel once the grid has four channels or fewer — a 4-channel grid would refuse the critically-sampled width the floor calls canonical. Two channel spacings because an apodised polyphase channel has a wider main lobe than the unwindowed FFT the floor is written for (seelineshapeabove), and two is a generous bound on that. It binds only on coarse grids:0.25 * (n_freq - 1) >= 2fromn_freq = 9up, so on any real spectrometer band the band fraction is the operative limit.
- rheplicant.radio.instrument.calibration.MAX_TIME_RESOLUTION_IN_SAMPLES = 0.01¶
Largest fraction of one sample interval that
coords.time’s own representable resolution may occupy, before a drift is measured against times that are not there. Re-exported, not redefined: the cut is a property of howcoords.timeis STORED rather than of this operator’s arithmetic, so it is stated once inrheplicant.core.coordinates— which now refuses such an axis at construction, ahead of every consumer — together with the calibration of the 1e-2 itself.What this operator adds on top of the container’s check is the measurement below. On an 11-channel 1 MHz grid, 4 samples 100 s apart,
drift_rate1e4 Hz/s — one channel per sample:coords.time elapsed peak ch protected/11 [0,100,200,300] [0,100,200,300] [4,5,6,7] 1, 1, 1, 1 1.75e9 + the same [0,128,256,256] [4,5,7,7] 1, 6, 8, 8
Two of the four samples collapse onto the same time, the tone lands in the wrong channel, and the mask blows out from one channel to eight of eleven — this operator’s own named silent failure. Nothing raises, nothing is NaN, every shape is right; the same run under
JAX_ENABLE_X64=1gives[4,5,6,7], so the cause is precision, not logic. Both the injection and the band guard readt - t[0], so they read the SAME corrupted elapsed values and the band guard cannot see it — subtracting the anchor cannot undo a rounding that already happened at store time.
- class rheplicant.radio.instrument.calibration.CWCalibrationOperator(amplitude, tone_freq, line_width, lineshape='sinc2', drift_rate=0.0, amplitude_drift_rate=0.0, protect_floor=0.01)[source]¶
Bases:
AbstractOperatorInject a CW tone with a lineshape, a width, and a drift.
Three things beyond the injection itself.
It declares its own position.
must_precedenames the stages the tone must flow through, andassemble()enforces it — see the module docstring.It protects what it wets. A narrow bright line is precisely what an RFI flagger is built to remove, and flagging sits downstream of this operator on the same trunk, so both shipped flaggers erase the tone at fraction 1.0 on the first observation. The protection therefore rides in
state.aux[PROTECTED_KEY], written HERE — the operator that injected the tone is the one that knows where it went — rather than as a flagger setting the user has to remember to switch on. A flagger has no way to tell a calibration tone from RFI; this operator has no way not to.The protected set is every channel where the tone contributes at least
protect_floorof its own peak channel. In Kelvin that cut sits atprotect_floor * amplitude * max_k w_k, which is the number to compare against a flagging threshold: with a 5000 K tone at the default 1e-2, the protection covers every channel carrying more than about 50 K of tone. Both directions are silent failures, which is why the rule is a stated level and not a guess: protect too little and the flagger takes the line’s shoulders, biasing the tone’s measured level low; protect too much and genuine RFI survives in channels the tone barely touched, which is sky thrown away. A level rule rather than a “fraction of the tone’s power” rule becausesinc2’s wings fall off only as 1/x^2 — containing 99% of an unwindowed FFT’s power takes ~40 channels, nearly all of them carrying a tone contribution no flagger would ever have noticed.A drifting tone protects a drifting set. When
drift_rateis zero the mask is a(n_freq,)channel mask, as before. When the line moves, the contaminated channels move with it and the mask becomes a(n_time, n_freq)waterfall — the shapeunflag_protected()already reads for a switched calibrator. A channel mask for a drifting tone would protect the right channel in the first sample and the wrong one in every other. Noteamplitude_drift_ratealone does NOT make the mask 2-D: the weights are normalised, so a changing level does not move the line.- Parameters:
- amplitude¶
the tone’s TOTAL contribution [K-equivalent], summed over channels — a known, STATIC scalar, not a differentiable leaf. Note this is the total, not the peak channel; see the module docstring.
- Type:
- tone_freq¶
tone centre frequency at the first sample [Hz]. Must lie inside the observing band for the whole run: a centre outside it spreads the line over channels it is nowhere near and calibrates nothing.
- Type:
- line_width¶
lineshape scale [Hz]. For
"sinc2"this is the offset to the first null — one channel spacing for a critically sampled unwindowed FFT. For"gaussian"it is the standard deviation (FWHM = 2.355 * line_width). No default: see the module docstring. It is the CHANNEL response, not the band, and it is guarded from both sides —MIN_WIDTH_IN_CHANNELSbelow,MAX_WIDTH_IN_BAND_FRACTIONabove.- Type:
- drift_rate¶
centre-frequency drift [Hz/s], linear in
coords.timefrom the first sample. Nonzero requirescoords.time, and requires it to be an axis whose stored precision can express the run’s own cadence — seeMAX_TIME_RESOLUTION_IN_SAMPLES.- Type:
- amplitude_drift_rate¶
FRACTIONAL level drift [1/s], linear from the first sample. Nonzero requires
coords.time, with the same precision condition.- Type:
- class rheplicant.radio.instrument.calibration.CalLoadOperator(t_load)[source]¶
Bases:
AbstractOperatorSwitched calibration load (PLACEHOLDER).
Elements: “Calibration signals, which are switched in and out of the signal path on some pre-defined cycle.” The load REPLACES the antenna signal on the switching cycle — modeled by the
receiver_inputselector node of the canonical graph: provide this operator alongside the antenna chain and put the switching cycle incoords.extra["receiver_input"](0 = antenna, 1 = load, in the graph’s edge order).Real physics to come: warm/hot loads with their own reflection coefficients and physical-temperature telemetry.
cal_loadsismany=Trueand feeds only thereceiver_inputselector, so instances compose the way that consumer composes: each one becomes its OWN switch position rather than being summed with its siblings. Provide two loads alongside the antenna chain and the switch indexes 0 = antenna, 1 = first load, 2 = second load — the graph’s in-edge order, then the order they were given, soassemble()expresses a switching cycle of any length with no hand-wiring. How long that cycle has to be forNoiseWaveOperator’s temperatures to be identifiable is stated once, innoise_wave— it ismin(n_src, k) * n_freqover thekFREE temperature families while they are free per channel, so three loads are enough only whenT_rxis held known, and no fixed number covers a basis parameterization at all. Read the order off the assembly (twin["receiver_input"].names) rather than assuming it: it is the ordergamma_src’s rows must match.- Parameters:
t_load (Array)
- t_load¶
load temperature [K], differentiable. Three accepted forms, and which axis a 1-D array runs along is a convention this package states once rather than guessing.
(n_time, n_freq)is NOT one of them: it is explicit and unambiguous, but a load whose spectrum also moves is a different model than this placeholder has, and a narrow guard is easier to widen when that arrives than to narrow after someone relies on it.scalar — one temperature for the whole run;
(n_freq,)— per channel. A bare 1-D array is ALWAYS read this way, matchingNoiseWaveOperator’s temperature leaves, so the two cannot disagree on a square grid;(n_time, 1)— per sample. This is the form a real recording takes: a load’s physical temperature drifts through a run and is logged per sample, not per channel. Spelled as an explicit column rather than a bare(n_time,)for the reasoncheck_noise_std_axis()gives at length — on a square grid(n,)reads equally well as either axis, and NumPy settles it by aligning trailing axes, silently;
rheplicant.radio.rhino.cal_load_operators()builds the(n_time, 1)form from a recording’s thermistor log.- Type:
- class rheplicant.radio.instrument.calibration.ApplyCalibrationOperator(gain)[source]¶
Bases:
AbstractOperatorApply an inferred gain solution:
data / gain(PLACEHOLDER).The inverse of
GainOperator— the bridge between calibration inference (rheplicant.inference, which produces the gain solution) and calibrated-data analysis (filters, map-making). Real version: full calibration application — bandpass division, noise-wave subtraction, tone-tracked g(t) interpolation.- Parameters:
gain (Array)
GainOperator — PLACEHOLDER time-dependent gain.
Real physics to come (port of limTOD / hydra-tod gain models): multiplicative gain g(t) with 1/f flicker fluctuations. The gain is the primary calibration target — it must stay a differentiable leaf so gradient-based and Bayesian calibration can infer it.
THIS GAIN CARRIES THE ABSOLUTE LEVEL. Inferred jointly with a free bandpass,
g(t) and b(nu) are separately unidentifiable: only their product enters
the prediction, so b -> c*b, g -> g/c changes nothing and the pair has one
exactly null direction. The package’s convention puts the whole scale here and
leaves the bandpass as pure shape — declare the bandpass latent with
unit_mean_bandpass(), which is
where the measurement and the reasoning are written down.
- class rheplicant.radio.instrument.gain.GainOperator(gain)[source]¶
Bases:
AbstractOperatorMultiply
state.databy a gain (placeholder).- Parameters:
gain (Array)
NoiseOperator — PLACEHOLDER radiometric noise.
Real physics to come (limTOD / hydra-tod noise models): correlated 1/f fluctuations, and a level that tracks the total power as it is drawn.
The radiometer equation itself is not future work on the inference side –
RadiometerNoise is
sigma = |prediction| / sqrt(delta_nu * tau) and is this package’s default
noise model. The asymmetry is real and worth knowing: a likelihood can scale
its sigma with the prediction it already has, while drawing a realisation
whose sigma depends on the total power needs that power first. So this
operator adds white Gaussian noise at a fixed sigma, and an example wanting a
radiometric draw scales it by hand.
- class rheplicant.radio.instrument.noise.NoiseOperator(sigma)[source]¶
Bases:
AbstractOperatorAdd white Gaussian noise to
state.data(placeholder).Consumes randomness through the State PRNG protocol: the returned state carries an advanced key, so repeated application gives fresh draws while a single seed reproduces the whole pipeline.
Note
The units do not close, and that is the placeholder.
sigmais declared in kelvin, but this operator’sgraph_nodeplaces it after the gain stage, wherestate.datais no longer a temperature. So it adds a kelvin quantity to a power-unit array: dimensionally incoherent, and finite, correctly shaped and wrong – the class of accident this package is built to refuse. It is tolerated here only because the body is a stand-in.The real radiometer noise on the signal path is
RadiometerNoiseOperatorbelow, whose multiplicatived -> d (1 + f w)carries no units at all and is therefore correct wherever it is placed. Prefer it. If this operator survives its placeholder status, eithersigmastops being kelvin or the node moves ahead of the gain.- Parameters:
sigma (Array)
- class rheplicant.radio.instrument.noise.RadiometerNoiseOperator(channel_width, integration_time)[source]¶
Bases:
AbstractOperatorDraw radiometer noise on the signal path:
d -> d (1 + f w).The GENERATOR half of the radiometer equation,
f = 1/sqrt(dnu tau)– the same statics and the same multiplicative form asrheplicant.inference.noise.RadiometerNoise.realise(), and for the same reason:sigma = |prediction| ftakes an absolute value that a generator must not, and the two forms differ in sign wherever the prediction does. Sitting on the path, this operator HAS the total power the module docstring above says a radiometer draw needs.Decided as D-C17 (2026-08-09), for a twin that must be self-contained. The recorded cost: a run can now carry two sigmas – this operator’s and
inference.noise’s – and nothing in the package keeps them equal, so the config layer’s validation (Plan 3) must cross-check them and refuse a disagreement, naming both paths. That duty travels with this class.
EMIOperator — PLACEHOLDER self-generated interference.
Element: “Self-generated EMI (e.g. due to power supply fluctuations, switched-mode power sources and some control signals from the Arduino control board, Odroid computer, or the SDR itself). Mostly looks like RFI.”
Real physics to come: characterised spectral lines of the system’s own electronics (switching harmonics form comb-like structures). The placeholder adds a constant-amplitude frequency comb.
- class rheplicant.radio.instrument.emi.EMIOperator(amplitude, period)[source]¶
Bases:
AbstractOperatorAdd a frequency comb of self-generated EMI lines (placeholder).
Every
period-th channel receives an extraamplitude.Note
``period`` counts CHANNELS, not hertz, and real EMI does not. A switching supply or a clock harmonic sits at a fixed frequency spacing; this comb sits at a fixed channel spacing, so the same operator models a different physical source at every channelisation. Re-bin the band and the lines move.
The consequence to keep in mind while it is a stand-in: a fit that constrains
amplitudeon one channelisation says nothing about the same instrument read out at another. When the body becomes real,periodshould become a frequency spacing in Hz and the comb should be built fromstate.coords.freqrather than fromarange, which is also what makes it independent ofn_freq.The comb is also exactly periodic and infinitely sharp: no line width, no per-line amplitude, no drift. Real EMI has all three.
ADCOperator — PLACEHOLDER digitization.
Real physics to come: true quantization (round to 2**n_bits levels) has zero gradient almost everywhere, so the differentiable version will need a straight-through estimator (identity gradient through the rounding) or a smooth surrogate. This placeholder applies scale + clip, which is differentiable almost everywhere and preserves the saturation behaviour.
- class rheplicant.radio.instrument.adc.ADCOperator(scale, n_bits)[source]¶
Bases:
AbstractOperatorScale and clip
state.datato the ADC dynamic range (placeholder).Note
Two things a real ADC does that this one does not, stated so the omission is not mistaken for the model:
No kelvin-to-counts link.
scaleis a free differentiable number, not a calibrated conversion, so the output is in “counts” only by assertion. Nothing checks thatscalecorresponds to the front-end gain, and the clip limit2**(n_bits-1)is therefore compared against a quantity whose units are whateverscalemade them.No quantization noise. The body clips and does not round, so the digitizer contributes no error at all. A real n-bit converter adds roughly
LSB/sqrt(12)of it, and for a global-signal experiment quantization is one of the systematics the ADC exists to represent.
What IS real here is the clipping, and it is the part worth having: it is where a mis-scaled model saturates rather than growing without bound, and the gradient through the clipped region is zero, which is visible in a fit.
- scale¶
pre-digitization scaling — differentiable scalar. Not a calibrated K-to-counts conversion; see the note above.
- Type:
BasisTemperatureOperator — a smooth effective T_sys, parameterized by coefficients.
Sits on the graph’s reserved t_sys_extra node, which the canonical
single-antenna template designates for a generic effective-T_sys contribution:
one of the t_ant_sum leaves, alongside ground_pickup and the
beam-averaged atmosphere. Like those it is an effective temperature by
D13’s construction — already carrying whatever beam weighting its author
intended — which is why it enters after beam_spill rather than through it.
It is parameterized by coefficients, not cells, and that is the whole point.
Measured on the assembled graph with a known 5000 K CW tone and a gain free per
time sample (tests/radio/test_t_sys_basis.py, at a generic coefficient
point, n_time=7, n_freq=5):
free-per-cell T_ant, tone ON (5000 K) n_par=42 rank=35 nullity=7
free-per-cell T_ant, tone OFF n_par=42 rank=35 nullity=7
(3,2)-basis T_ant, tone ON (5000 K) n_par=13 rank=13 nullity=0
(3,2)-basis T_ant, tone OFF n_par=13 rank=12 nullity=1
Against a free antenna temperature per (time, frequency) cell the tone buys
exactly nothing — the nullity is n_time with it and without it, because
the free cells absorb the whole of g[t] x (tone profile) sample by sample,
the tone’s own channels included.
RHINO’s central design choice (paper Sect. 4) therefore only pays once
T_ant is frequency-smooth, and this operator is where that smoothness is
made structural: there is no way to write a free-per-cell fit through it except
by handing it a basis that is complete on both axes — any square invertible
pair, of which the identity is the obvious one but complete Legendre matrices
do it just as well, as the free-per-cell row of the table below is built.
The user has confirmed RHINO’s antenna temperature IS frequency-smooth, so the
route is physically sound. Which axis matters is measured rather than assumed,
and it is frequency: a basis complete in frequency makes the tone worth nothing
whatever the time axis does — the nullity is the same with the tone on and off,
whatever that nullity happens to be on the grid in hand (1 on the square fixture
below, 7 on the 7x5 one) — while a basis complete in time is still rescued by
it. What is invariant is that the tone changes nothing, not the number. See
rheplicant.core.basis for the full sweep.
The gain and these coefficients cannot share a linear block. Each is affine
given the other and their product is not affine in the pair, so
check_linearity() refuses a group holding
both — correctly. They are two conditionally-linear blocks of one
SamplingPlan:
plan = SamplingPlan(space, Block("gain"), Block("t_coeff"))
est = plan.estimate(twin, state, observed, noise=sigma)
draws = plan.sample(twin, state, observed, noise=sigma, key=key, n_sweeps=300)
A sibling contribution is survivable, and only because the ids are stable.
t_sys_extra is many=True: a second contribution makes the bare
p["t_sys_extra"] ambiguous rather than silently resolving to the
SumOperator over both, and the per-instance ids t_sys_extra_1 /
t_sys_extra_2 are what a binding should name once there is more than one.
Read them off assembly.instances.
Two routes to the same expansion, and the difference is where smoothness
lives. This operator holds the coefficients, so the model is smooth and a
per-cell fit is not expressible through it. The other route is
SeparableBasis’s expand as a
Bind function, which leaves the operator holding a full (n_time,
n_freq) leaf and makes the smoothness a property of the
parameterization:
Bind("t_coeff", into=lambda p: p["noise_wave"].t_unc, fn=basis.expand)
That is the route the noise-wave temperatures need, since those leaves belong
to NoiseWaveOperator and are
full-grid by its contract. Both drive the conjugate exits identically; neither
is a wrapper for the other.
- class rheplicant.radio.t_sys.BasisTemperatureOperator(coeff, time_basis, freq_basis)[source]¶
Bases:
AbstractOperatorAn effective temperature expanded on a separable (time, frequency) basis.
Produces
time_basis @ coeff @ freq_basis.T— always the full(n_time, n_freq)grid, which is the one temperature shapenoise_wavecan never misread.The two design matrices are ordinary array leaves, the same convention
NoiseWaveOperatoruses for itsGammaspectra: a known quantity the operator carries, not a parameter. What is inferred iscoeff, and a ParameterSpace should bind into that leaf and no other — binding into a design matrix would infer the basis, which is a modelling choice and not a measurement.- classmethod from_basis(basis, coeff)[source]¶
Build from a
SeparableBasis.The ordinary way in, because it is the way that cannot get the two design matrices the wrong way round: the basis already knows which is which, and
basis.fit(field)turns a temperature you can write down into the coefficients that reproduce it.- Parameters:
basis (SeparableBasis)
- Return type:
- property basis: SeparableBasis¶
The two design matrices as one handle —
expand,fit, shapes.
Protected channels: keeping a known calibrator out of the RFI flags.
A continuous-wave calibration tone is a narrow, bright, persistent line —
which is, from a flagger’s point of view, the definition of RFI. Both shipped
flaggers duly flag it at fraction 1.0, and flagging sits downstream of
cw_tone on the same trunk, so the pipeline that is supposed to use the
calibrator destroys it on the first observation.
“Narrow” is not “one channel”. A tone is observed through the spectrometer’s
channel response, so it wets a set of channels, and if it drifts that set
moves during the run. Both mask shapes below therefore matter in practice:
(n_freq,) for a line that stays put, (n_time, n_freq) for one that
does not. Which channels a given tone actually wets is
CWCalibrationOperator’s to
decide — it is the only thing on the path that knows the lineshape.
The mechanism is an aux channel rather than a flagger setting, and that is
the whole design decision:
the operator that INJECTS the tone knows which channel it went into, and writes the protection itself (
protect());the flaggers read it if it is there (
unflag_protected()).
A flagger has no way to tell a calibration tone from RFI, and the operator has no way not to know. Put the switch on the flagger instead and it is one the user must remember to turn on for every run, with the failure showing up as a slightly worse calibration rather than as an error — the kind of setting that gets forgotten exactly once and then never noticed.
What this does NOT do: it removes the flag, it does not remove the tone’s
influence on the flagger’s own fit. MomentRFI fits a surface to the
waterfall, and a 5000 K spike biases that fit near the tone whether or not the
spike is flagged afterwards. Protecting a channel is not the same as excluding
it from the estimator, and only the first is claimed here.
Nor is protection free: a channel that is protected is a channel where genuine RFI now survives into the data. That is the deliberate trade — the tone channel is known-bright by construction, so a flagger’s verdict there carries no information anyway — but it is a trade, and the raw data still shows what happened.
- rheplicant.radio.protection.PROTECTED_KEY = 'protected'¶
state.auxkey carrying the protected-channel mask (True= keep).A boolean
(n_freq,)channel mask, or a full(n_time, n_freq)one for a calibrator that is switched in and out.
- rheplicant.radio.protection.protect(aux, mask)[source]¶
Return a new
auxwithmaskadded to the protected channels.Composing rather than clobbering, for the same reason the flaggers compose their masks: two calibrators (a tone and a switched load, say) both need their channels kept, and whichever ran second would otherwise unprotect the first. A
(n_freq,)channel mask composed with a(n_time, n_freq)waterfall gives a waterfall, which is right: a channel protected at all times stays protected at all times.The shape checks live here, at the WRITE, and not only in
unflag_protected(). Three reasons, all of them about where the error lands. The operator that built the mask is still on the stack here, so the traceback names it; a flagger three stages downstream can only say that something wrote a bad mask. A pipeline with no flagger in it never callsunflag_protectedat all, so a malformed mask would ride inauxunexamined to the end of the run. And two masks that cannot compose fail here as a sentence rather than as a raw broadcasting error from|.- Parameters:
- Raises:
StateValidationError – if
maskis neither a channel mask nor a waterfall mask, or if it cannot compose with a mask already there.- Return type:
- rheplicant.radio.protection.reduce_protection(mask, n_chunk)[source]¶
Bring a protection mask across an average into chunks of
n_chunk.The re-derivation
unflag_protected()tells the caller to perform. That message names the two ways out of a stale waterfall mask — drop it or re-derive it — and only one of them keeps the calibrator protected, so the re-derivation belongs here, next to the convention it depends on, rather than being re-invented by every stage that reshapes a run.The two mask shapes go different ways, and that asymmetry is the whole content of the function:
a
(n_freq,)channel mask is returned unchanged. It names channels, not samples, so no change to the time axis can stale it — the same reasonunflag_protected()broadcasts it over every row.a
(n_time, n_freq)waterfall mask is reduced withanyover each chunk. A chunk is protected in a channel if the calibrator wet that channel at ANY sample the chunk merged, because the chunk’s average carries that sample’s power whether or not the tone was on for the rest of it.allwould unprotect a chunk the tone contaminated in two rows of three, which is the failure protection exists to prevent.
- Parameters:
- Raises:
StateValidationError – if
maskis neither a channel mask nor a waterfall mask, or if its time axis is not divisible byn_chunk— a partial chunk has no honest reduction, and padding one would protect samples that were never observed.- Return type:
- rheplicant.radio.protection.unflag_protected(flags, aux)[source]¶
Clear
flagswhereverauxsays the channel is protected.A no-op when nothing declared protection, so a flagger keeps working unchanged in a pipeline with no calibrator in it.
A waterfall mask is bound to the TIME AXIS it was written on: its row
inames the channels the calibrator wet at sampleiof the axis that existed when the mask was built. Any stage that changes the number of samples — averaging into chunks, selecting a subset — leaves it stale, and a stale mask must be dropped or re-derived, not carried. That is why the time axis is checked here and not only the channel one.- Parameters:
- Raises:
StateValidationError – if the mask is neither a channel mask nor a full waterfall mask, if its channel axis does not match the flags’, or if it is a waterfall over a different number of samples. All three would otherwise broadcast — a
(n_freq,)mask against a transposed waterfall, a mask built for a different band, or a single-row waterfall left over from a shape-changing stage — and silently protect the wrong channels, or every one of them.- Return type:
FlaggingOperator — PLACEHOLDER RFI flagging.
Element: “We then apply various flagging, averaging, and calibration steps that can correct for some of these contributions, but can also introduce their own additional issues, e.g. if the models are slightly wrong/biased.”
MomentRFI-based flagging has arrived, in this module:
MomentRFIFlaggingOperator bridges to the numpy package and is the
permanent integration, not a stand-in. What is still a placeholder is
FlaggingOperator beside it, which thresholds the data and stores a
boolean mask in state.aux (the traced side-channel), leaving the data
itself untouched — useful precisely because it needs no optional dependency.
Flagging is a data processing operator living in the same pipeline formalism
— which is exactly how “processing steps introduce their own issues” becomes
modellable. Flags reaching the noise covariance is the seam
FlaggedNoise implements.
Both flaggers honour rheplicant.radio.protection — without it they flag
a CW calibration tone at fraction 1.0, correctly by their own lights and
fatally for the calibration, since flagging sits downstream of cw_tone on
the same trunk. Read that module for why the protection is declared by the
operator injecting the tone rather than configured on the flagger.
- rheplicant.radio.backend.flagging.FLAGS_KEY = 'flags'¶
state.auxkey carrying the RFI flag mask (True= flagged).It lives here because this is where it is WRITTEN – both flaggers below put a mask under it – and a contract is easiest to keep honest next to its producer. It is read by
FlaggedNoise,SkySpaceFilterandBackendOperator, and written again byrheplicant.radio.rhino.to_state(), which turns a recording’s settling mask into flags. Naming it once is what makes those five sites greppable as one contract rather than as five string literals.
- class rheplicant.radio.backend.flagging.FlaggingOperator(threshold)[source]¶
Bases:
AbstractOperatorStore a threshold-based flag mask in
state.aux["flags"](placeholder).Truemarks a flagged (bad) sample. Data is not modified; downstream operators (averaging, likelihoods) decide how to use the mask.Channels declared in
state.aux["protected"]are never flagged — a CW calibration tone is a bright narrow spike and would otherwise trip this threshold in every sample (seerheplicant.radio.protection).- Parameters:
threshold (float)
- class rheplicant.radio.backend.flagging.MomentRFIFlaggingOperator(config=<factory>, kernel_shapes=())[source]¶
Bases:
AbstractOperatorRFI flagging via MomentRFI’s
IterativeSurfaceFitter(host callback).The real flagger, beside
FlaggingOperator’s thresholding one. Flagging is inherently non-differentiable (a boolean decision), sojax.pure_callbackinto the numpy MomentRFI package is the permanent right integration — not a stopgap. Jit-compatible; not vmappable/differentiable (by nature).- Behaviour:
state.datamust be a positive, linear-scale(n_time, n_freq)waterfall (MomentRFI works on log10 internally).Existing
aux["flags"]are passed as MomentRFI’sprior_maskand included in the output — flaggers compose instead of clobbering.Channels in
aux["protected"]are cleared from the result. Note what that does and does not do: the tone is not flagged, but it still biased the surface fit that produced the flags. Seerheplicant.radio.protection.The result is written back to
aux["flags"](True = flagged).
The flags reach inference by wrapping the noise model, which is where a sample that was not observed belongs (D21):
noise = FlaggedNoise(RadiometerNoise(dnu, tau), state.aux[FLAGS_KEY])
That one object then carries them into the likelihood, the Fisher matrix, the weights of a Wiener solve or GCR draw, and a NumPyro observation scale.
MaskedGaussianLikelihoodandSkySpaceFilterweighting take the raw mask directly and remain available.Requires the optional
MomentRFIpackage (install it into the same environment); raises a helpful ImportError otherwise.- Parameters:
config (FrozenMapping)
- config¶
IterativeSurfaceFitterkeyword arguments (static, hashable; e.g.{"sigma_threshold": 4.0, "degree_freq": 10}).verboseis forced off.
BackendOperator — PLACEHOLDER backend processing.
Real physics to come: correlator/spectrometer integration, frequency
rebinning, waterfall product generation. (RFI flagging was once listed here
too; it landed on the flagging node instead, as
MomentRFIFlaggingOperator, which is
the right place for it — a separate step with its own node.) This placeholder
averages time chunks — and, importantly,
demonstrates the contract that shape-changing operators must update the
coordinates along with the data, and refuse what they cannot update.
- rheplicant.radio.backend.averaging.SNAPSHOT_PREFIX = 'snapshot/'¶
Prefix of the keys
checkpoint()writes.
- class rheplicant.radio.backend.averaging.BackendOperator(n_chunk)[source]¶
Bases:
AbstractOperatorAverage
state.dataover time chunks ofn_chunksamples (placeholder).Updates
coords.timeto the per-chunk mean times, reduces theauxentries whose semantics it knows onto the same chunk axis, and refuses by name anauxentry bound to the pre-average time axis that it does not know how to reduce.This is one of exactly two places in the package that does ARITHMETIC on
coords.timevalues rather than reading its length (the other isCWCalibrationOperator’s drift), and it is where a time axis the stored dtype cannot carry surfaces as a wrong number rather than as an exception. Measured on 8 samples 100 s apart on a unix-epoch axis,n_chunk=2, beforeCoordinatesguarded its own store:chunk times [1750000128, 1750000256, 1750000384, 1750000640] float64 truth[1750000050, 1750000250, 1750000450, 1750000650] error [s] [ +78, +6, -66, -10]
Wrong by 78 s of a 100 s cadence, with two of the eight input samples already merged before the mean ran. Nothing here can detect that – the merged values arrive indistinguishable from real ones – so the check lives at the store, in
Coordinates, and this operator inherits it: thereplacebelow re-runs it on the chunk-mean axis it produces.auxand the chunk axis¶dataandcoords.timeare not the only things bound to the time axis. Every per-time array instate.auxis stale the moment the axis changes length, and the three cases measured on one(6, 4)fixture withn_chunk=3, when this operator leftauxalone, failed with three different amounts of noise:auxentrywhat happened before
aux["flags"]carried at
(6, 4); refused two stages later byFlaggedNoise.stdaux["protected"], 2-Dcarried at
(6, 4); refused by the nextFlaggingOperator, which names the stalenessaux["switch"], 1-Dcarried at
(6,)with no error anywhereThe third is the one worth the guard. The first two are loud because something downstream knows the shape those keys are supposed to have; a key this package has never heard of has no such consumer, so a wrong-length array rides to the end of the run misaligned with
dataandcoords.timeand nothing can tell. Worse, each output chunk now spansn_chunkof its entries, so for[0, 1, 0, 1, 0, 1]atn_chunk=3there is no right value even in principle — every chunk covers both switch positions.So:
flagsand a 2-Dprotectedare reduced withanyover the chunk (see below);snapshot/...is carried unchanged, being deliberately a record of the pre-average axis; anything else whose leading axis is the pre-averagen_timeis refused, named. The refusal is on SHAPE alone, so it holds underjitand cannot be defeated by the values — including an integer switch index whose chunks happen to be constant, which is refused with the rest. Averaging one would silently return a float where an index was expected, and asking whether a chunk is constant is a value check this operator is not allowed to make.That rule is deliberately conservative: an array whose leading axis merely coincides with
n_timeis refused too, because nothing distinguishes it from a genuinely per-time one. The way out is in the message — reduce it before this stage, or pop it fromauxand put the reduced version back after — and it is a sentence rather than a wrong number.anyand notall, for both reduced keys: this placeholder averages every sample in the chunk, flagged ones included, so the chunk mean is contaminated if ANY sample in it was.allwould call a chunk clean with two of three samples RFI-blasted and carry that RFI forward as good data. When the mean learns to exclude flagged samples the two must change together — at that point a chunk is bad only when it has no good sample left, andallbecomes the right reduction.- Parameters:
n_chunk (int)
AbstractLinearFilter: the common shape of data filters.
Sidereal-repeat extraction, sky-space (map-making) filtering, and
fringe-rate/delay filtering are all linear projections — they differ only
in which subspace they project onto. The base class fixes the shared
semantics; concrete filters implement project and declare a static
mode field:
mode=”extract” -> P d (keep the projected component) mode=”remove” -> d - P d (subtract it)
Filters are ordinary operators, so analysis chains are ordinary Pipelines — and because filters are differentiable, the signal loss they induce (the transfer function) can itself be marginalised in inference later.
Filters typically run on calibrated data (see
ApplyCalibrationOperator);
preserve the raw data first with SnapshotOperator.
- class rheplicant.radio.filters.base.AbstractLinearFilter[source]¶
Bases:
AbstractOperatorBase for projection filters. Concrete classes declare a static
modefield.
SiderealFilter: project onto the day-repeating (sky-locked) subspace.
Structure that repeats every sidereal day is sky-locked (the drifting sky
through a fixed beam); everything else is instrument/environment. Averaging
the same LST bin across days is the orthogonal projection onto that
subspace — extract returns the repeating sidereal structure, remove
returns the non-repeating residual.
Run this on calibrated data: uncorrected gain drifts are not sky-locked and
would leak into the day-average. Real version: irregular/gapped LST sampling
via binning; the placeholder assumes the time axis is exactly n_days
concatenated identical LST grids (day-major ordering).
- class rheplicant.radio.filters.sidereal.SiderealFilter(n_days, mode='remove')[source]¶
Bases:
AbstractLinearFilterPer-LST mean across days, tiled back to full length.
- n_days¶
number of sidereal days concatenated along the time axis (static;
n_timemust be divisible by it).- Type:
SkySpaceFilter: map-make onto the sky, then reproject.
The JAX form of limTOD’s wiener_filter_map / HPW_mapmaking: solve the
regularised normal equations for the sky map best explaining the data,
(A^T N^-1 A + lam I) m = A^T N^-1 d,
with A any linear AbstractSkyProjector
(forward + adjoint) — the SAME object that generates the sky term in the
forward model. extract returns A m (the sky-locked component of the
data), remove returns the sky-subtracted residual.
The solve uses matrix-free conjugate gradients
(jax.scipy.sparse.linalg.cg, built on lax.custom_linear_solve), so
the whole filter is differentiable — filter transfer functions can be
marginalised in inference.
Convergence has to be asked for, and CG will not volunteer it. JAX’s cg
returns (x, None): there is no status to read, so a solve that ran out of
iterations comes back looking exactly like one that converged — and
mode="remove" subtracts it from the data all the same. require_convergence
turns that silence into a refusal.
A residual is not an accuracy, which is why the knob is stated as an error.
The two differ by the condition number of A^T W A + lam I, and for map-making
that number is large by construction: sky pixels the scan never touched are
held by the ridge alone, so lam is exactly the bottom of the spectrum while the
top is set by the data. CG then stops on a tiny residual with those pixels still
at their starting value. So the guard estimates kappa by power iteration
(rheplicant.core.conditioning) and limits kappa * residual, the bound
on the relative error. It is off by default: unlike a Wiener solve, which runs
once, a filter runs on every evaluation of the signal path, and the bound costs
a fixed POWER_ITERATIONS unrolled applications of the normal operator on
top of the one the residual needs.
Noise weighting: if state.aux["flags"] exists (e.g. from
MomentRFI flagging), flagged samples get zero weight in N^-1.
- class rheplicant.radio.filters.skyspace.SkySpaceFilter(projector, regularization, cg_tol=1e-08, cg_maxiter=100, mode='remove', require_convergence=None)[source]¶
Bases:
AbstractLinearFilterWiener-like sky projection filter built on a linear sky projector.
- Parameters:
- projector¶
linear sky projector supplying
forward/adjoint.
- regularization¶
ridge strength lam (differentiable scalar; acts as a white prior inverse-variance, stabilising unseen pixels).
- Type:
FourierBandFilter: project onto a Fourier band along one data axis.
One class covers the classic waterfall filters:
axis=0(time) -> fringe-rate filtering,axis=1(frequency) -> delay filtering,
and a high-pass along time (limTOD’s HP_filter_TOD) is
FourierBandFilter(axis=0, low=cutoff, high=0.5, mode="extract").
The band is specified in cycles/sample, 0 <= low < high <= 0.5 (Nyquist).
Projection: FFT along the axis, zero everything outside the band, inverse FFT.
The band is half-open on the right, low <= |f| < high, except when
high is exactly 0.5 (Nyquist), in which case the right edge is closed:
low <= |f| <= 0.5. This is deliberate, not an inconsistency: a half-open
right edge is what lets adjacent bands [a, b) and [b, c) partition the
axis without either one claiming the bin at f = b twice. But nothing lies
beyond Nyquist for a closed edge to double-count against, and a strict
< 0.5 would instead make the Nyquist bin unreachable by any band, even
one explicitly written to include it (high=0.5). Do not generalise the
closed edge to interior boundaries – that would reintroduce double-counting
at ordinary band boundaries.
For even n, jnp.fft.fftfreq(n) has an exact bin at Nyquist (returned
as -0.5, hence the jnp.abs before the band test). For odd n there
is no bin exactly at 0.5, so the closed-edge case is simply a no-op there
(DC survives only if low == 0, as always).
- class rheplicant.radio.filters.fourier.FourierBandFilter(axis, low, high, mode='remove')[source]¶
Bases:
AbstractLinearFilterBand projection in fringe-rate (axis=0) or delay (axis=1) space.
The band membership test is half-open on the right,
[low, high), except athigh == 0.5where it is closed,[low, 0.5]– see the module docstring for why.highis a static (Python-level) field, so this is a plain Python branch resolved at trace time, not a runtimejnp.where.
Neural surrogate operators: hybrid physics + ML in the same formalism.
A neural network is just another operator: an eqx.nn.MLP’s weights are
ordinary traced leaves, so NeuralOperator plugs into pipelines,
graph assembly, build_forward_fn, gradient calibration, Fisher forecasts,
and the NumPyro bridge exactly like a physical parameter set — no special
machinery anywhere.
NeuralOperator learns a positive multiplicative spectral response
data * exp(MLP(freq)) — a drop-in surrogate for any smooth
frequency-dependent stage (bandpass, beam chromaticity correction, …).
It deliberately has NO default graph node: a surrogate’s placement is a
modelling decision, so provide it explicitly, e.g.
At("bandpass", NeuralOperator.create(...)) to replace the physical
bandpass with a learned one (see examples/neural_surrogate.py).
- class rheplicant.radio.surrogate.NeuralOperator(mlp, f_min, f_max)[source]¶
Bases:
AbstractOperatorLearned positive spectral response:
data * exp(MLP(freq_normalized)).The MLP maps a normalized frequency in
[-1, 1]to a log-correction, so the response is positive by construction and initializes near unity (fresh MLPs output ~0). Weights are differentiable leaves.- mlp¶
eqx.nn.MLPwithin_size=1, out_size=1.- Type:
equinox.nn._mlp.MLP
- classmethod create(key, f_min, f_max, width=16, depth=2)[source]¶
Build a fresh surrogate (near-identity response) for a frequency window.
- response(freq)[source]¶
The learned spectral response, shape
(n_freq,)(positive).The log-correction is clipped to ±20 before exponentiation, so a wild training step cannot overflow to inf/NaN and permanently poison an optimizer’s moment estimates — the response stays finite in (~2e-9, ~5e8) and gradients keep flowing back toward sanity.
Ingestion¶
Readers for the two file formats RHINO records: the spectrometer’s HDF5
observations, and the Touchstone .sNp sweeps that supply the reflection
coefficients the noise-wave model consumes.
RHINO observation HDF5 files as a recording, and as a State.
Two layers on purpose. read_rhino_observation() produces
RhinoObservation, plain numpy that knows nothing about the signal
graph, so a waterfall can be plotted and a switch log inspected without
constructing anything. to_state() is the separate seam that places it on
the graph.
The two layers do not use the same time convention, on purpose.
RhinoObservation keeps the file’s own unix seconds;
to_state() stores seconds since the first kept sample and puts the
epoch in meta[TIME_EPOCH_META_KEY], because Coordinates stores in
float32 by default and a unix second there is quantised onto a 128 s grid –
argued, and measured, at to_state().
The file does not record its own frequency unit, and its two producers
disagree. rhino-cal’s ObservationHandler.save_to_hdf5 writes an
astropy Quantity in Hz; the RHINO_fully_simulated_calibration notebook
writes MHz. The reference reader, rhino-cal/gcr/data_processing.py’s
DataHandler, defaults to MHz – wrong for its own simulator’s output, and
silent about it, because the consequence is Gamma interpolated onto a band
10^6 away, which then clamps to constant edge values rather than raising.
freq_unit is therefore required here, with no default, and the declaration
is checked against the file’s values.
The schema, as both producers write it:
/sdr/sdr_freqs (n_freq,)
/sdr/sdr_times (n_time,) unix seconds
/sdr/sdr_waterfall (n_time, n_freq) raw power
/sdr/max_i_adc (n_time,) notebook-written files only
/sdr/max_q_adc (n_time,) notebook-written files only
/switches/switch_times (n_switch,) unix seconds
/switches/switch_states (n_switch,) bytes
/temperatures/temperatures (n_temp_time, n_column) CELSIUS
/temperatures/temperature_times (n_temp_time,) unix seconds
/aux_sdr and /obs_config are ignored. save_to_hdf5 creates
/aux_sdr/aux_sdr_waterfall with a dtype but no data= and no shape=,
which makes a scalar dataset rather than an array; there is nothing there to
read.
- rheplicant.radio.rhino.TIME_EPOCH_META_KEY = 'time_epoch_unix_s'¶
state.metakey under whichto_state()records the unix second the State’s time axis is measured from.metarather thancoordson purpose: it is one static number describing the run, not a traced quantity the forward model differentiates through, andState’s own taxonomy puts labels and settings there. The cost is that two observations differ in the jit cache key, which is one recompilation per recording – the same priceobs_idalready pays, and the reason the epoch is a scalar here rather than a per-sample array.
- class rheplicant.radio.rhino.RhinoObservation(freq_hz, time_s, waterfall, switch_label, settled, thermistor_k, transitions, n_leading_dropped, adc_max_i, adc_max_q)[source]¶
Bases:
objectOne RHINO recording, in numpy, in Hz / unix seconds / Kelvin.
- Parameters:
- freq_hz¶
(n_freq,)channel frequencies [Hz].- Type:
- time_s¶
(n_time,)sample times [unix seconds].- Type:
- waterfall¶
(n_time, n_freq)raw power, arbitrary scale.- Type:
- switch_label¶
(n_time,)per-sample switch state.- Type:
- settled¶
(n_time,)bool, True = usable. Note the polarity: it is the opposite ofaux["flags"], which is True-means-bad.- Type:
- thermistor_k¶
switch label ->
(n_time,)physical temperature [K]. Two labels may share a column and therefore hold equal arrays. Keyed by the labels actually present in this file’s switch log, not by every key in thethermistor_columnsmap passed to the reader – a label that map declares but this file never switched to has no entry here.thermistor_k[label]raisesKeyErrorfor such a label; it does not returnNoneor an empty array. Empty when the reader was called without ``thermistor_columns``, which is not the same statement as a missing label: it says the temperatures were never asked for, so the file’s thermistor log was not read and not judged. Both cases surface asKeyError; the caller distinguishes them by what it declared.- Type:
- transitions¶
the raw
(times, labels)switch log, kept for diagnosis.- Type:
- n_leading_dropped¶
samples that preceded the first transition and were dropped, because they have no defined switch state.
- Type:
- adc_max_i / adc_max_q
ADC monitors,
Nonewhen the file has none.
- rheplicant.radio.rhino.read_rhino_observation(path, *, freq_unit, thermistor_columns=None, settle_seconds=5.0, thermistor_unit='celsius')[source]¶
Read a RHINO observation HDF5 file.
- Parameters:
path – the
.hd5f/.hdf5file.freq_unit (str) –
"Hz"or"MHz". Required – see the module docstring.thermistor_columns (Mapping[str, int] | None) – switch label -> column of
/temperatures. Omit it (or passNone) to skip the thermistor log entirely; see below.settle_seconds (float) – samples within this long after a transition are marked unsettled. The reference is inconsistent here (5 s in the notebook, 2 s and 1 s in two rhino-cal functions); 5 s is the most conservative.
thermistor_unit (str) –
"celsius"(the file’s convention) or"kelvin". Unused whenthermistor_columnsis omitted.
- Return type:
What reaches the signal path, and what does not.
to_state()placeswaterfallondata,time_soncoords.time(relative – see there),freq_hzoncoords.freq,switch_labelas the integercoords.extra["receiver_input"], andsettled, inverted and broadcast, onaux["flags"]. Those five are the recording.thermistor_k,transitions,n_leading_dropped,adc_max_iandadc_max_qare diagnostic: nothing inrheplicantconsumes them, and a caller that wants them reads them off theRhinoObservationdirectly.thermistor_columnsis therefore opt-in._thermistors_in_kelvinrefuses a thermistor log ending short of the SDR axis, and refuses a non-finite reading in a used column; both refusals are argued there and both are right for a caller who wants the temperatures. Running them unconditionally made a whole recording unreadable over a column nothing downstream consumes – the waterfall, the switch log and the settling mask are all intact in such a file, and they are what a forward model needs.Omitting the map is not a default guess. The positional convention linking a switch label to a
/temperaturescolumn is shared between writer and reader with nothing in the file to enforce it, so_thermistors_in_kelvindemands a declaration rather than assuming rhino-cal’s order; omitting it declares that no temperatures are wanted, andthermistor_kcomes back empty. When the map IS given, every check it used to make still runs.Nothing under
/temperaturesis read at all in that case, so a file that has no such group – or a malformed one – is readable for its waterfall.
- rheplicant.radio.rhino.to_state(obs, *, source_order)[source]¶
Place a recording on the signal graph.
- Parameters:
obs (RhinoObservation) – a recording.
source_order (Sequence[str]) – switch labels in the graph’s in-edge order. Read it off the assembled twin –
assembly["receiver_input"].names– rather than assuming it: it is the orderNoiseWaveOperator’sgamma_srcrows must match, and a transposition there is shape-legal and costs tens of kelvin.
- Returns:
A State carrying, and only carrying,
data(the waterfall),coords.time,coords.freq,coords.extra["receiver_input"],aux["flags"]andmeta[TIME_EPOCH_META_KEY]. Everything else on the recording is diagnostic – seeread_rhino_observation().- Raises:
DataIngestionError – if
source_orderrepeats a label, if the recording switches to a labelsource_orderdoes not name, ifobs.settledis not boolean, or if the recording holds no samples.- Return type:
coords.timeis seconds since the first kept sample, not unix seconds, andmeta[TIME_EPOCH_META_KEY]holds the unix second it is measured from – someta[TIME_EPOCH_META_KEY] + coords.timerecoversobs.time_sexactly. This is a behaviour change, and it is a fix rather than a convenience.Coordinatesstores its axes throughjnp.asarray, which is float32 unless x64 is enabled, and a unix second near 1.75e9 has a float32 resolution of 128 s. Measured on six samples at offsets [0, 100, 250, 450, 700, 1000] s from a 1.75e9 epoch, with the axis handed over absolute:stored offsets [0, 128, 256, 512, 640, 1024] error [s] [0, +28, +6, +62, -60, +24]
All six values stay distinct, so no shape, count, dtype or finiteness check can see it, while
BackendOperator’s chunk timestamps come out wrong by tens of seconds and a driftingCWCalibrationOperatortone lands in the wrong channel. Subtracting the epoch before the store removes the cause: the offsets become small integers, which float32 holds exactly. Detecting it afterwards is not possible from the stored values alone, which is whyCoordinatesrefuses such an axis outright rather than repairing it.The epoch is the first kept sample, not the first sample in the file: the leading drop removes samples with no defined switch state, and they are not part of the run the State describes.
The settling mask is inverted on the way in.
aux["flags"]is True-means-flagged (radio/backend/flagging.py, andFlaggedNoiseconsumes it that way) whilesettledis True-means-usable. Getting this backwards yields a finite, correctly-shaped result that discards every good sample and keeps every transient – nothing about the shape or dtype would reveal it.aux["flags"]is also broadcast to ``(n_time, n_freq)``, matchingstate.data, even though settling is inherently a per-time quantity – every channel of an unsettled sample is unsettled, so the broadcast changes nothing about what is being said. The shape is not this function’s choice; it is set by every consumer:FlaggedNoise.std(inference/noise.py) raises ifflagsdisagrees in shape with the prediction it masks,SkySpaceFilter(radio/filters/skyspace.py) multiplies1 - flagselementwise against the data, and bothFlaggingOperatorandMomentRFIFlaggingOperator(radio/backend/flagging.py) produce and expect(n_time, n_freq).obs.settleditself stays(n_time,)onRhinoObservation, for a caller who wants the per-time form directly.
- rheplicant.radio.rhino.cal_load_operators(obs, *, labels=None)[source]¶
Build one
CalLoadOperatorper switched load, carrying that load’s measured physical temperature.This is the route from file to model that was missing.
read_rhino_observationparses the thermistor log, interpolates it onto the SDR axis and refuses a recording whose readings are short or non-finite – and thento_statedropped it, because aStatehas nowhere to put a per-load temperature. So the loads’ temperatures were parsed, validated and discarded, and the warm/hot-load noise-wave path had no way to reach the model from a recording.Kept OUT of
to_state()deliberately, and the reason is a type rather than a preference.to_statereturns aState; wiring operators from it would either change that return type – breaking the module’s two-layer split, where reading a file and building a model are separate steps a caller composes – or move the temperature into theStatefor the operator to read, which changesCalLoadOperator.requiresand therefore what the operator declares about itself. A separate function costs the caller one line and changes neither.The temperature is passed as an explicit
(n_time, 1)column, never a bare(n_time,). A load’s physical temperature drifts through a run, so it IS per-sample; and on a square grid a bare 1-D array reads equally well as per-channel, whichCalLoadOperatorresolves by always reading 1-D as per-FREQUENCY. Spelling the column here is what keeps this function from depending onn_time != n_freq.- Parameters:
obs (RhinoObservation) – a recording read WITH
thermistor_columns. Without itobs.thermistor_kis empty and this raises rather than returning nothing, since a caller asking for load operators and getting an empty mapping would build a model with no loads and no warning.labels (Sequence[str] | None) – which switch labels to build for.
None(the default) means every label whose temperature this file actually carries. Naming them explicitly is how a caller pins the switch ORDER, which is the ordergamma_src’s rows must match – seeCalLoadOperator.
- Returns:
label -> CalLoadOperator, insertion-ordered bylabelswhen given and byobs.thermistor_kotherwise. AnnotatedAnyrather than the class: importing it at module level would make this reader depend on the operator layer, which is the dependency the two-layer split exists to avoid.- Raises:
DataIngestionError – if the recording carries no thermistor temperatures at all, or if a requested label has none.
- Return type:
Touchstone .s1p / .s2p files as complex S-parameter arrays.
A thin adapter, in the same sense as rheplicant.radio.beams: it turns
bytes on disk into numpy, in the units this package carries at its seams (Hz),
and adds nothing else. What an S-parameter means – how a reflection
coefficient becomes a coupling spectrum – is rhino_cal_jax’s subject.
Ported from rhino-cal’s utils/utils.py::read_s2p, with its silent
failure modes turned into errors. The one that matters most: that function
skips any data row whose column count is not exactly nine, so a trailing !
comment or a truncated line removes a frequency point without a word, and the
caller gets a shorter sweep that still interpolates cleanly.
Column order. A Touchstone 2-port data row is freq S11 S21 S12 S22.
The second pair is S21, not S12. This is the single most likely thing to get
wrong here, because every other 2x2 convention in this package is row-major and
because a test that only checks s11 cannot see the error.
No unstated frequency unit. Touchstone v1 defaults an omitted frequency
unit to GHz; this reader raises instead of applying that default silently. A
wrong frequency axis does not crash downstream – interpolation onto an
observing band still returns a finite, correctly-shaped array, just one built
from the wrong slice of the measurement. This package’s RHINO observation-HDF5
reader applies the same refusal, for the identical reason: its freq_unit
argument has no default. The stakes here are higher, since a missed unit token
is a 10⁹ error rather than 10⁶, and Touchstone files that omit the token are
rare enough among VNA exports that raising costs less than a silently rescaled
calibration sweep would.
- class rheplicant.radio.touchstone.Touchstone(freq_hz, s, z0)[source]¶
Bases:
objectParsed Touchstone contents.
- freq_hz¶
(n,)strictly ascending frequencies [Hz].- Type:
- s¶
(n, p, p)complex S-parameters,pin{1, 2}.- Type:
- rheplicant.radio.touchstone.read_touchstone(path, *, flipped=False)[source]¶
Read a Touchstone v1
.s1por.s2pfile.- Parameters:
- Raises:
DataIngestionError – on any malformed content. Nothing is skipped.
- Return type:
- rheplicant.radio.touchstone.interpolate_onto(freq_hz, source, *, component='s11', allow_extrapolation=False)[source]¶
Interpolate one S-parameter of
sourceontofreq_hz[Hz].componentis checked here only against the four canonical S-parameter names. Whethersourceactually carries that component – a 1-port file has no s12/s21/s22 – isTouchstone’s own concern: its property getters already raise a precise, reason-namingDataIngestionError(seeTouchstone._entry), and duplicating that check here would only be a second place for the message to drift out of sync with the first.- Parameters:
freq_hz (ndarray)
source (Touchstone)
component (str)
allow_extrapolation (bool)
- Return type:
rheplicant.inference¶
Parameter spaces: what gets inferred, and how it enters the forward model.
Two words carry the whole design, so they are worth defining precisely.
Latent — a named quantity you infer. It is the thing a sampler draws or
an optimizer steps: it has a name, an initial value (which fixes its shape and
dtype), optionally a prior, and optionally a declaration that it enters the
model linearly. A latent knows nothing about the pipeline. log_gain is
a latent; so is a 10⁴-element vector of sky alm coefficients.
Bind — a rule turning latents into pipeline leaf values. It names the
latents it consumes, the pipeline leaves it writes (eqx.tree_at selectors),
and optionally the function between them. A bind knows nothing about priors.
The split is the point. A pipeline leaf is what the instrument model holds; a latent is what you chose to infer. They are usually not the same object: two scalars can determine a beam’s whole harmonic expansion, one scalar can drive several stages at once, and a positive quantity is best sampled in its logarithm. Keeping the two apart means re-parameterizing never requires editing the instrument description — which is the promise of D7.
Three shapes cover essentially everything:
ParameterSpace(
latents=[
Latent("fwhm_deg", init=12.0, prior=dist.Uniform(5.0, 30.0)),
Latent("log_e", init=0.0, prior=dist.Normal(0.0, 0.3)),
Latent("log_gain", init=0.0, prior=dist.Normal(0.0, 0.1)),
Latent("sky_alms", init=alms0, prior=..., linear=True),
],
bindings=[
# derived: two scalars -> one high-dimensional leaf
Bind(("fwhm_deg", "log_e"),
into=lambda p: p["t_ant"]["sky"].projector.beam_alms,
fn=lambda f, e: gaussian_beam_alms(f, jnp.exp(e), lmax=LMAX)),
# tied: one latent -> several leaves, through a positivity transform
Bind("log_gain",
into=(lambda p: p["gain"].gain, lambda p: p["ref_gain"].gain),
fn=jnp.exp),
# direct: straight into one leaf
Bind("sky_alms", into=lambda p: p["t_ant"]["sky"].sky_model.alms),
],
)
Anything the blocks cannot express goes through ParameterSpace.raw(),
which takes a bind function outright.
Binding never changes the pipeline’s pytree structure — only leaf values.
Every downstream consumer depends on that: eqx.filter_vmap over posterior
samples, ravel_pytree for Fisher matrices, and jit all assume a fixed
treedef. ParameterSpace.validate() checks it.
- rheplicant.inference.parameters.DISTRIBUTE: str = 'distribute'¶
Bind(fan="distribute")—fnreturns one value PERintoselector.
- rheplicant.inference.parameters.BROADCAST: str = 'broadcast'¶
Bind(fan="broadcast")—fnreturns ONE value, written to everyintoselector. This is parameter tying.
- rheplicant.inference.parameters.FAN_MODES: tuple[str, ...] = ('distribute', 'broadcast')¶
The declarable fan-out modes.
- exception rheplicant.inference.parameters.AmbiguousFanWarning[source]¶
A
Bindwhose fan-out mode could not be inferred from what it produced, only guessed.Raised as a warning rather than an error in exactly one situation — a single
intoselector fed a container — because there the guess cannot give a wrong answer, only an undeclared one. SeeBind.fan.
- rheplicant.inference.parameters.refuse_stochastic_stages(pipeline, caller)[source]¶
Refuse a forward model that draws its own randomness. Raises, or returns
None.Inference closes the model over ONE template state, so an operator that consumes the PRNG draws ONE realisation and every prediction the engine compares against the data carries that same frozen field. The likelihood is then a likelihood of the wrong model, and nothing downstream can tell: adding a constant field is exactly affine, so
check_linearity()sees residual 0.0 andidentifiability()reports full rank. Measured on the fixture intests/inference/test_stochastic_twin.py— an 8x8 grid,key(0), data from the honest model at g = 1.1 plus 2 K scatter atkey(7), and aNoiseOperator(sigma=20)the only difference between the two twins:twin
estimate
error bar
clean
1.100162
0.0025000
stochastic
1.073513
0.0025000
That is 10.6 sigma of bias, and BOTH exits report the same error bar to every digit. The magnitude is the realisation; the invisibility is structural. Both exits of the workflow wrong by the same amount, with no diagnostic moving, is the failure this refusal exists for.
The digits are pinned in
test_stochastic_twin.pyrather than left here alone. An earlier version of this paragraph quoted 1.1015 -> 1.0824, 0.002451 and 7.8 sigma, which two independent re-measurements could not reproduce — numbers in a docstring that nothing executes are exactly the claim this package refuses to make about anything else.The detector is the operators’ own declaration —
RANDOMNESSinrequires— so a new stochastic operator is covered the day it declares what it reads, with nothing here to update.What it therefore cannot catch, stated rather than implied. An operator that draws randomness without declaring
"key"is invisible here, and so is one hidden inside a static field (aLambdaOperatorwhosefncloses over a draw). Nothing static can see either: there is no numerical symptom, which is the premise of this whole guard. What CAN be checked is that the shipped operators declare honestly, andtests/test_operator_declarations.pydoes exactly that, mechanically: consuming the PRNG and declaring"key"must agree for every operator in the package, and the drawing set is pinned. A user-written operator is the user’s declaration to make.- Parameters:
pipeline (AbstractOperator) – the forward model.
caller (str) – what to name in the message.
- Raises:
ParameterSpaceError – naming every stochastic stage and its label.
- Return type:
None
- class rheplicant.inference.parameters.Latent(name, init, prior=None, linear=False, scope='global')[source]¶
A named quantity to infer — the unit a sampler or optimizer works on.
Deliberately ignorant of the pipeline: a latent says what is inferred, a
Bindsays where it goes.- name¶
identifier. Doubles as the NumPyro sample-site name and the key in the
dicta forward function consumes, so it should read like a physical quantity ("fwhm_deg","sky_alms").- Type:
- init¶
initial value — also the authority on shape and dtype. Plain Python numbers are converted to arrays.
- Type:
- prior¶
a NumPyro distribution, or
None.Nonemeans a free parameter: usable by the optimizers, rejected by the Bayesian bridge (a parameter with no prior has no place in a posterior). Read by every inference exit, not only the sampler: a Gaussian declared here is theSthatwiener_solve()andgcr_sample()solve with, so the two routes to a posterior cannot drift apart. A prior with no conjugate Gaussian form is fine — it is simply an error at those exits rather than silently ignored there.- Type:
Any
- linear¶
assert that the prediction is an affine function of this latent, holding the others fixed. Unlocks
linear_operator()and the conjugate-Gaussian machinery built on it. The claim is checkable — seecheck_linearity()— and is checked before it is exploited.- Type:
- scope¶
over what extent of data this quantity is constant.
"global"(default) – one value for the whole campaign."per_epoch"– re-drawn independently each epoch, and integrated out at compression time, which is why it must have a prior."linked"– a Markov chain across epochs, which needs a declared transition. Likelinear, this is a checkable claim about the quantity itself and not a statement about the pipeline: whether a gain is re-drawn nightly is as physical as whether the prediction is affine in it. Declaring a slowly-drifting quantity"per_epoch"marginalises one physical fluctuation N times against independent priors, injecting information that is not there.- Type:
- class rheplicant.inference.parameters.Bind(latents, into, fn=None, fan=None)[source]¶
A rule turning latent values into pipeline leaf values.
- Parameters:
- latents¶
name, or tuple of names, of the latents this rule consumes. They are passed to
fnpositionally, in this order.
- into¶
an
eqx.tree_atselector (lambda p: p["gain"].gain), or a tuple of them. Several selectors is how one latent drives several leaves.- Type:
- fn¶
latent values -> leaf value(s).Nonemeans identity, which requires exactly one latent. Iffnreturns a single array it is written to every selector ininto(this is parameter tying); if it returns a tuple, its length must matchinto.- Type:
collections.abc.Callable | None
- fan¶
"broadcast","distribute", orNoneto infer from whatfnproduced, which is what happens below and what the rest of this docstring is about.- Type:
str | None
The two modes are different physics, and with ``fan=None`` a Python container type is the only thing that tells them apart.
"broadcast"writes one produced value into every selector — parameter tying, one number driving several stages."distribute"writes thek-th produced value into thek-th selector — several stages each getting their own. Measured on two scalar leaves, an antenna efficiency and a gain, with the same 2-vector[2, 5]:fn = lambda v: v -> broadcast pred[0, 0] = 2 * 2 = 4.0 fn = lambda v: list(v) -> distribute pred[0, 0] = 2 * 5 = 10.0
vandlist(v)are the SAME DATA. One is a JAX array and one is a Python list of its elements, and that difference — invisible in the values, invisible in every shape, invisible tocheck_linearity()and toidentifiability()— selects between a tie and an element-wise split. A user who meant “write this whole vector into both leaves” and reached forlist(v)gets a finite, correctly-shaped, silently wrong model, off by a factor of 2.5 here and by whatever the leaves happen to be worth in general.fan=is that intent, written down and therefore checkable.Nonekeeps the inference, becauseBindis public and appears in every example and every doc page and a refusal by default would break all of them. Declaring it turns the guess into a refusal: a declared broadcast that produced a container, or a declared distribute that produced a single value, is a contradiction between what the caller said and whatfndid, and is refused naming both.The one case no inference can decide even in principle. With a single
intoselector the length test that separates the modes,len(produced) == len(into), is satisfied by a length-1 container under EITHER intent, so the container is unwrapped on a guess. That guess is warned about (AmbiguousFanWarning), not refused, and deliberately: a Python list is not an array leaf, so unwrapping is the only reading that can yield a valid pipeline at all — broadcasting the container would change the pipeline’s pytree structure and be refused byParameterSpace.validate()a moment later. There is no wrong answer to prevent here, only an undeclared one, so refusing would break working code and buy no correctness.fan="distribute"declares it and the warning goes.- evaluate(values)[source]¶
Produce one value per selector in
into.The fan-out mode is
fanwhen declared and inferred from whetherfnreturned a Pythontuple/listwhen it is not. See the class docstring for why that inference is not something to rely on.- Raises:
ParameterSpaceError – if a declared
fancontradicts whatfnproduced, or if a distribution’s length does not matchinto.- Warns:
AmbiguousFanWarning – if a container reached a lone
intoselector with nofandeclared, where the inference is a coin flip that happens to have only one landing.- Parameters:
- Return type:
- class rheplicant.inference.parameters.ParameterSpace(latents, bindings=(), raw_bind=None, joint_prior=None)[source]¶
The declaration inference engines read: latents plus how they bind.
- Parameters:
- latents¶
the quantities to infer, in declaration order.
- Type:
- bindings¶
how they reach the pipeline. Empty only when
raw_bindis supplied.- Type:
- raw_bind¶
escape hatch —
(pipeline, values) -> pipeline, used instead of compilingbindings. Build one withraw().- Type:
collections.abc.Callable | None
- joint_prior¶
a prior over a BLOCK of latents rather than over one — today
JeffreysPrior, andNoneby default, which is the whole feature switched off. Declared here rather than passed at an exit for the same reasonLatent(prior=...)is: the declaration is what every exit reads, so a prior cannot be in force at one exit and absent at another. A latent it covers must NOT also carryLatent(prior=...)— that is two priors on one quantity, multiplied, with no diagnostic that can report it — and both refusals are below.- Type:
- classmethod raw(latents, bind, bindings=None)[source]¶
Build a space from a bind function instead of declarative blocks.
The escape hatch for parameterizations the blocks cannot express. The structural checks in
validate()still apply, so a bind function that quietly changes the pipeline’s pytree structure is still caught.- Parameters:
bind (Callable[[AbstractOperator, dict[str, Array]], AbstractOperator])
- Return type:
- classmethod direct(name, init, into, prior=None, fn=None, linear=False, fan=None)[source]¶
One latent, one binding — the common case, in one call.
fanis threaded straight through toBind. It is worth having here rather than only on the long form becauseintoaccepts a tuple of selectors, so the shorthand reaches the tie-versus- distribute ambiguity too.
- validate(pipeline)[source]¶
Check this space against a pipeline. Raises, or returns
None.Every check runs on shapes alone (
jax.eval_shape): no array is ever computed. It still traces the bindings, so a derivedfndoing real work costs one trace — negligible against a fit, but not literally zero. Called for you byforward_fn()and by the Bayesian bridge, once per build rather than per evaluation.The failure modes it exists to prevent all share a shape: they produce a finite, correctly-shaped, wrong inference rather than an exception.
One of them is a property of the pipeline alone rather than of the pair: a stage that draws randomness (
refuse_stochastic_stages()). It is checked here because this is the one place every inference exit passes through —forward_fn()calls it, and so doto_numpyro_model()andsimulate_pairs()directly.- Parameters:
pipeline (AbstractOperator)
- Return type:
None
- bind(pipeline, values)[source]¶
Return a copy of
pipelinecarryingvalues.Pure:
pipelineis untouched. All bindings are applied in a singleeqx.tree_atcall.Note what that does NOT give you:
eqx.tree_atresolves replacements by leaf identity and lets the LAST write to a leaf win, silently. The guarantee that no leaf is written twice comes fromvalidate(), which every entry point (forward_fn(), the Bayesian bridge) runs first. Callingbinddirectly on an unvalidated space skips it.- Parameters:
pipeline (AbstractOperator)
- Return type:
- forward_fn(pipeline, state_template)[source]¶
Build
forward(values) -> predictionand the starting values.The D7 seam, re-expressed over named parameters:
build_forward_fn()hands back a ghost pipeline whose leaves are the trainables, which is right when the answer is “train this whole subtree”; this hands back a plaindictof named arrays, which is right when the parameters are chosen, transformed, or shared. Both feed the same calibrators, Fisher tooling and posterior-predictive machinery — a dict is a pytree.The space is validated against the pipeline first. Validation reads shapes only and happens once per build, not per evaluation, so there is no reason to make it skippable.
- Parameters:
pipeline (AbstractOperator) – the forward model.
state_template (State) – the state it is evaluated on. Closed over, fixed.
- Returns:
(forward, values0).values0isinitial_values(), soforward(values0)is the model at its declared starting point.- Return type:
tuple[Callable[[dict[str, Array]], Array], dict[str, Array]]
Declared-linear parameter blocks: check the claim, then export the operator.
Some parameters enter the forward model linearly — sky alm
coefficients, noise-wave amplitudes, any component whose contribution is a
matrix acting on it. Those blocks are also the big ones: a sky at lmax=191
across 32 channels is ~10⁶ real degrees of freedom, where gradient-based
samplers are hopeless but a conjugate-Gaussian solve is exactly right.
Declaring Latent(..., linear=True) promises that, holding every other
latent fixed, the prediction is an affine function of this one:
prediction(x) = A x + b
Two things follow. First, the promise is checkable, and this module checks
it before anything exploits it — check_linearity() compares the model
against its own linearization. A false declaration would otherwise produce a
confident, wrong posterior instead of an error.
Second, A and Aᵀ are available without ever forming a matrix:
jax.linearize gives the forward action and jax.vjp the adjoint, at the
cost of one trace. linear_operator() packages them as a
LinearBlock, which is the whole interface the conjugate-Gaussian
routines here need: wiener_solve() for the posterior mean and
gcr_sample() for an exact posterior draw.
Because the block is affine only given the other latents, both take at=
to rebuild it wherever those currently are — which is what makes a Gibbs
sweep possible: draw the linear block exactly, update the nonlinear ones
however you like, repeat.
One block may hold several latents. linear_operator(..., names=("t_nw",
"t_ant")) exports the joint operator over a group, whose x is a
{name: array} dict rather than one array — and whose solve returns the same
dict, so the physical names survive instead of the caller slicing an anonymous
stacked vector and getting the offsets right by hand. Nothing is concatenated:
the group’s domain is a pytree, cg already solves over pytrees, and the
prior is block-diagonal by construction because each latent’s S sits on its
own leaf.
Grouping is not cosmetic. Two latents the data cannot tell apart are solved
together in one CG here, whereas alternating between them as two blocks
converges at the rate of their correlation — and reports a per-block residual
of 1e-7 and a per-block κ of 1 the whole way down, because both numbers are
computed from the block and neither can see across the partition. The joint
κ this module reports for the grouped block can; so can
identifiability(), which is the
right instrument for choosing the partition in the first place. And a group
whose members are only pairwise linear — a gain against an antenna
temperature — is refused by check_linearity(), which probes the joint map
and finds it bilinear, not affine.
Where the prior comes from. S is read off Latent(prior=...) — the
same declaration to_numpyro_model()
reads, so one space handed to NUTS and to gcr_sample() targets one
posterior. The prior_std= / prior_mean= keywords remain for a
prior-free latent, but a keyword that contradicts a declaration is refused
rather than allowed to win, and a declared prior with no conjugate Gaussian
form is refused rather than approximated by its first two moments. Both would
otherwise be a finite, confident posterior for a model nobody declared, which
is the failure mode every guard in this module is placed against.
Probe at extreme scales. check_linearity() probes at 10⁻³, 1 and 10³
times the latent’s own magnitude, because near-linearity is scale-dependent:
x + εx² is indistinguishable from linear near the origin and grossly
nonlinear far from it. A probe suite that only samples “reasonable” values
signs off on exactly the blocks that will fail in a sampler’s tails.
A residual is not an accuracy. The solvers here are iterative, and what an
iterative method can cheaply report is ‖M x - b‖, not ‖x - x*‖. The two
differ by the condition number of M = AᵀN⁻¹A + S⁻¹, and κ is large here by
design: whenever the data does not fully identify the block — which is the
case the prior is for — λ_min(M) is exactly 1/prior_std² and κ runs to
1e6 and beyond. A solve can then sit at a relative residual of 1e-7 with the
prior-dominated directions untouched, and a draw comes back with almost no
scatter where it should have carried the whole prior width. So the guard on
these solves bounds the error, κ · residual, and condition_estimate()
exposes κ for choosing tol.
- class rheplicant.inference.linear.LinearBlock(name, shape, dtype, offset, forward, adjoint, prior=None)[source]¶
The affine action of one latent on the prediction:
A x + offset.Deliberately a plain dataclass rather than an
eqx.Module: this is a derived linear-algebra handle, not a differentiable model.forwardandadjointare closures over a traced computation, so the block is something you build where you need it, not a pytree to carry around.A block may hold ONE latent or a GROUP of them, and the difference is carried by
name: astrfor one, atuple[str, ...]for a group. Everything else follows from whatxthen is. For one latentxis an array andshape/dtype/priordescribe it directly; for a groupxis a{name: array}dict and each of the three is a dict keyed the same way — the shape of a pytree being a pytree of shapes, which is the readingjax.eval_shapealready uses.That is the whole of the generalization, and it is deliberately NOT a concatenation over real degrees of freedom. Every solve in this module runs on
jax.tree.mapandjax.scipy.sparse.linalg.cg, both of which take pytrees; keeping the group a pytree means there is no offset arithmetic to invert, no ordering to state beyond JAX’s own, andSis block-diagonal because each latent’s variance sits on its own leaf rather than being spliced into a stacked vector at the right index.- Parameters:
- name¶
the latent this block belongs to — or, for a group, the tuple of them in the caller’s own order.
namesnormalizes the two.
- dtype¶
dtype of
x; for a group,{name: dtype}.- Type:
Any
- offset¶
prediction(0)— everything the other parameters contribute. For a group, everything OUTSIDE the group contributes.- Type:
- forward¶
x -> A x, fromjax.linearize.- Type:
collections.abc.Callable[[Any], jax.Array]
- adjoint¶
y -> Aᵀ y, fromjax.vjp, shaped likex.- Type:
collections.abc.Callable[[jax.Array], Any]
- prior¶
the latent’s declared prior, carried through from the
Latent.Nonefor a prior-free latent, and for a block assembled by hand; for a group,{name: prior}with aNoneper prior-free member. It is what letswiener_solve()andgcr_sample()readSoff the declaration instead of making the caller hand-pass — and hand-sync — the same two numbers at every exit.- Type:
Any
Adjoint convention, which matters as soon as
xis complex (skyalmcoefficients are):adjointis exactlyjax.vjp, and JAX returns the conjugate gradient for complex inputs. The identity that holds is therefore the one over the real inner product:Re sum(x * adjoint(y)) == sum(forward(x) * y)
and NOT the sesquilinear
sum(conj(x) * adjoint(y)). The real pairing is the one a Gaussian likelihood forms, so this is the useful convention as well as the honest one;tests/inference/test_linear_blocks.pypins both halves so the distinction cannot rot into a silent factor.- property names: tuple[str, ...]¶
The latents in this block, in the caller’s order — one, or several.
- as_dict(x)[source]¶
xas the{name: array}mapping every consumer downstream reads.A solve returns this block’s own domain — a bare array for a
name=block, a{name: array}dict for anames=group — and only the second is the shape anything else takes.space.forward_fn’sforward,bind(),fisher_information(),identifiability()’sat=,linear_operator()’sat=andconditional_potential()all index by latent name, and all six raise on the bare form — with six different exceptions, none of which names the actual mistake (TypeError: JAX does not support string indexing; got idx='gain'is the friendliest of them, and it arrives from inside a trace).So this is the wrap, and it is deliberately idempotent over the two spellings: the same one call is correct whether the block was built with
name=or withnames=, which is what lets calling code stop caring which it was. It returns a new dict; the block is untouched.- Raises:
ParameterSpaceError – for a group, if
xis not a dict with one entry per member — that is someone else’s solution, and wrapping it would put an array under a name it does not belong to.- Parameters:
x (Any)
- Return type:
- rheplicant.inference.linear.DEFAULT_AT_POINTS: int = 3¶
How many values of the OUTSIDE latents a check looks at (D16 axis 2, ruled 2026-08-27). Imported from the sibling package rather than spelled again: one statement of the number, both sides reading it.
- rheplicant.inference.linear.check_linearity(space, pipeline, state_template, name=None, *, names=None, at=None, scales=(0.001, 1.0, 1000.0), rtol=None, at_points=None, noise=None, key=None)[source]¶
Verify that the prediction really is affine in one latent — or in a group.
Compares the model against its own linearization at zero, at several magnitudes of probe. Costs one linearization plus one forward evaluation per scale.
- Parameters:
space (ParameterSpace) – the model under test.
pipeline (AbstractOperator) – the model under test.
state_template (State) – the model under test.
name (str | None) – which latent. Optional when exactly one is declared linear.
names (Sequence[str] | str | None) – several latents, checked jointly — the claim a grouped
linear_operator()block makes. Mutually exclusive withname. This is strictly stronger than checking each in turn, and the difference is the whole reason a bilinear model needs more than one block: a gain and an antenna temperature are each affine given the other, and their product is not affine in the pair, so a group holding both is refused here rather than solved as if it were linear.at (dict[str, Array] | None) – values for the latents OUTSIDE the block. Linearity is a claim given them, so check it where the sampler will actually be. Defaults to the declared initial values.
at_points (Sequence[dict[str, Array]] | None) – the outside values to check at, in full. Defaults to
atplusDEFAULT_AT_POINTS - 1draws from those latents’ own priors (D16 axis 2, ruled 2026-08-27). Passing a single point is how a check becomes a moderate-parameter probe, which is the failure modeboundary-validation.mdexists to prevent; do it only when the model is used at exactly one outside value. Measured before the default moved: a model affine inuexactly when the outside latent sits at its declared init reported 0.0 at every scale and was accepted. A latent with no Gaussian prior keeps itsatvalue at every point – a free parameter has no distribution to draw from.scales (Sequence[float]) – probe magnitudes, as multiples of the latent’s own scale, taken from its prior width — per latent, for a group, since two latents in one block are routinely in different units. The default spans six orders of magnitude on purpose — see the module docstring. The prior is where the sampler will actually go, which is why it and not
initsets the magnitude: an all-zeroinitis the ordinary declaration for a sky, and anchoring on it made the probes absolute. A latent with no Gaussian prior to read falls back tomax|init|, and to 1.0 if that is zero too.rtol (float | None) – tolerance on the relative departure from affinity. Default:
1e4 * epsof the prediction dtype, which leaves room for accumulated roundoff in a long reduction without admitting real curvature.noise (Any | None) – the model’s noise, enabling a SECOND criterion — the departure in units of sigma, against
WEIGHTED_RTOL(D16 axis 3, ruled 2026-08-27). “Small” has to be small compared to something, and a departure far underrtolcan still be many noise widths wide, which is the regime a conjugate solve gets wrong. Measured on one such model, the relative column reads0.000e+00at every probe while the weighted one reads6.262e-02. Omitted, only the relative criterion applies and the verdict is the one this function gave before the axis moved — a weaker check, not a different one, so no warning: unlike the log route, nothing here makes a positive claim that a missing noise model would render unsafe.key (Array | None) – PRNG key for the probes. Fixed by default, so the check is reproducible. For a group the per-latent sub-keys are folded in by position in the SORTED names, so permuting
namesprobes the model at the same points and returns the same verdict.
- Returns:
{scale: relative error}— useful for reporting how linear a block is, not only whether it passes.- Raises:
ParameterSpaceError – if
nameandnamesare both given.LinearityRefused – if any scale departs from affinity by more than
rtol. It IS aParameterSpaceError– an existingexcept ParameterSpaceErrorneeds no change – and it carries the same per-scale numbers this returns on the passing branch, so a caller can report the departure instead of quoting the sentence.
- Return type:
- rheplicant.inference.linear.linear_operator(space, pipeline, state_template, name=None, *, names=None, at=None, check=True, scales=(0.001, 1.0, 1000.0), rtol=None)[source]¶
Export
A,Aᵀand the offset for a declared-linear latent — or a group.No matrix is ever formed:
Acomes fromjax.linearizeandAᵀfromjax.vjp, so a 10⁶-dimensional block costs the same as one forward evaluation per application. That is what makes conjugate-Gaussian solves tractable here — seewiener_solve().- Parameters:
space (ParameterSpace) – the model.
pipeline (AbstractOperator) – the model.
state_template (State) – the model.
name (str | None) – which latent. Optional when exactly one is declared linear. The block’s
xis then one array, and so is the solve’s answer.names (Sequence[str] | str | None) –
several latents, exported as ONE block. Mutually exclusive with
name. The block’sxis then a{name: array}dict — and so is the answer, which is the point: the physical names survive the solve instead of the caller slicing an anonymous stacked vector.names=("gain",)is a legitimate group of one, and is how a partition can hold one-latent and many-latent blocks without the caller special-casing either.Solving a group JOINTLY is not the same as alternating over its members: two latents the data barely tells apart are resolved in one CG here, where alternation converges at the rate of their correlation while reporting a converged residual and a κ of ~1 at every step. The joint κ that
condition_estimate()reports for this block is the honest one.at (dict[str, Array] | None) – values for the latents OUTSIDE the block, fixing where it is built. Defaults to the declared initial values — right exactly once, so a Gibbs sweep must pass the current values here every sweep.
check (bool) – verify the linearity claim first (
check_linearity()). Leave it on. Turning it off costs three forward evaluations less and buys a class of silent, confident errors. For a group the claim checked is JOINT affinity, which a bilinear pair fails.scales (Sequence[float]) – forwarded to
check_linearity().rtol (float | None) – forwarded to
check_linearity().
- Raises:
ParameterSpaceError – if both
nameandnamesare given; ifnamesis empty, repeats a latent, or names an undeclared or non-linear one; or if the linearity claim fails.- Return type:
- rheplicant.inference.linear.wiener_solve(block, observed, *, noise_std, prior_std=None, prior_mean=None, tol=1e-06, maxiter=None, require_convergence=None)[source]¶
Posterior mean of a linear-Gaussian block — the Wiener filter, by CG.
With
d = A x + offset + n,n ~ N(0, N)andx ~ N(m, S):x̂ = (AᵀN⁻¹A + S⁻¹)⁻¹ [AᵀN⁻¹ (d - offset) + S⁻¹m]
solved with conjugate gradients, so the normal operator is only ever applied, never formed. Each iteration costs one JVP and one VJP through the forward model — which is why a block with 10⁶ degrees of freedom is tractable at all.
The normal operator and the right-hand side are both obtained as gradients of the objective itself rather than assembled from
AandAᵀby hand. That is not a shortcut: it makes the operator symmetric positive definite by construction over the real degrees of freedom, with no adjoint-convention arithmetic left to get wrong for complex latents.This is the posterior mean, not a sample. For a draw, see
gcr_sample(), which adds a fluctuation term to this same right-hand side and costs exactly the same solve.- Parameters:
block (LinearBlock) – from
linear_operator().observed (Array) – the data, shaped like
block.offset.noise_std (Any) –
noise standard deviation — a scalar, or an array whose SHAPE says which axis of the data it runs along:
(n_time, 1)for a per-time sigma,(1, n_freq)for a per-channel one. A bare 1-D vector is accepted only where its length matches a single axis of the data; on a square grid it matches two, both readings are legitimate, and the one broadcasting picks is not the one most callers mean — so that case raises rather than being resolved by trailing-axis alignment. Seecheck_noise_std_axis().An array, never a
NoiseModel, and the keyword name is the signal rather than an accident of history.noise_std=is a sigma that has already been decided;noise=— oniterative_gls()and onSamplingPlan— is the rule that decides one. A conjugate solve has no prediction to evaluate a rule at, the prediction being what it solves for, so a model is refused here by name rather than quietly frozen at some arbitrary point: see_refuse_a_noise_model_at_the_conjugate_seamfor the message and_check_solve_argumentsfor the two measured reasons this seam is not routed throughas_noise_model. Freeze it yourself —noise.std(prediction)— and pass that array.prior_std (Any) – prior standard deviation on the latent — scalar or broadcastable to it. Defaults to the latent’s declared prior; required only when there is none, because without a prior the normal operator can be singular and CG would return a finite, arbitrary answer instead of complaining. Passing a value that contradicts the declaration raises rather than one silently winning — see the note below.
prior_mean (Any) – centre of the prior. Defaults to the declared prior’s location, and to zero when nothing is declared — which is wrong for most physical quantities, a noise-wave temperature sitting near 250 K. Equivalent to an affine binding that adds the same offset, but says what it means.
tol (float) – CG tolerance — a bound on the relative RESIDUAL, which is not the same as accuracy. See the note on conditioning below.
maxiter (int | None) – CG iteration cap.
Nonelets JAX choose.require_convergence (float | None) –
raise unless the relative ERROR can be bounded by this. Defaults to
1e-3;Nonedisables the guard and returns whatever CG produced. On by default because jax’scgreports no convergence status, so an unconverged solve otherwise comes back looking exactly like a converged one.The bound is
κ · relative_residual, withκbounded bycondition_estimate(). Guarding on the residual alone would certify nothing in the regime that matters — see below — so this costsPOWER_ITERATIONSextra operator applications. That is not free: on a well-conditioned block, where CG itself converges in a few iterations, it is a real fraction of the solve. In a Gibbs sweep, where the conditioning barely moves from sweep to sweep, callcondition_estimate()once outside the loop, choosetolfrom it, and passrequire_convergence=Noneinside — the same bargainlinear_operator()’scheckoffers.
- Returns:
(x̂, relative_residual), the residual being‖M x̂ - b‖ / ‖b‖over the real degrees of freedom. Note that this is the residual, not the error; multiply bycondition_estimate()for the error bound.x̂is the block’s own domain, so its shape follows the spelling that built the block: a{name: array}dict fornames=, and a bare array forname=. The bare form is not what anything downstream reads —space.forward_fn’sforward,bind(),fisher_information(),identifiability()’sat=,linear_operator()’s ownat=andconditional_potential()all index by latent name and all six raise on it. Wrap it as{block.name: x̂}first;LinearBlock.as_dict()is that call, and does nothing to the grouped form, so it is correct either way.- Return type:
Note
Conditioning, and why ``tol`` is not accuracy. Residual and error differ by the condition number of
M = AᵀN⁻¹A + S⁻¹:‖x̂ - x*‖ / ‖x*‖ ≤ κ(M) · ‖M x̂ - b‖ / ‖b‖
For a block the data does not fully identify — one calibration load against three unknowns, a flagged channel, a short integration — the prior is the only thing holding the blind directions down, so
λ_min(M)is exactly1/prior_std²andκ ≈ ‖AᵀN⁻¹A‖ · prior_std²runs to 1e6 and beyond. At κ=1e7 the defaulttol=1e-6bounds the relative error by 10: no digits at all. CG stops on a residual that looks converged, having left the prior-dominated directions at their starting value, and the draw comes back with far too little scatter.This is exactly the regime these solvers exist for, so the accuracy target is stated as an error and not a residual. To solve rather than refuse, pass
tol ≈ require_convergence / κwith amaxiterto match. Pastκ · epsno tolerance helps and only precision does; the guard says so in its own words.It is OFF by default, and that is a recent, deliberate retreat. The guard shipped on, against a κ that
_condition_estimate()has since been shown to under-report by up to a factor of 700 — so what was on by default was a promise to bound the error that did not bound it. The κ here is now a rigorous UPPER bound, and the same measurement that made it sound made it conservative: on a block the data DOES identify in every direction it can read five orders of magnitude high (1.44e+06 measured against a true κ under 10, because λ_min is then set by the data and not by the prior, which is all the bound knows about). On by default, that refuses correct solves wholesale. So the choice is yours to make per solve, and when you make it the answer means something: a solve this guard passes has its error bounded, and one it refuses may still be fine — the bound could not prove it.Note
Where S comes from.
Latent(prior=dist.Normal(m, s))is the package’s one statement of what a latent is a priori, and it is the statementto_numpyro_modelreads. So it is the statement this solve reads too: declare it once and both exits target the same posterior. The keywords remain, for a prior-free latent and for overriding a declaration you are deliberately solving away from — but a keyword that contradicts a declaration raises, because the alternative is one of the two silently winning and the two exits quietly disagreeing. A declared prior with no conjugate Gaussian form (a Half-Normal, a Uniform) raises here as well; NUTS is where that space belongs.
- rheplicant.inference.linear.condition_bound(block, *, noise_std, prior_std=None, iterations=12, key=None)[source]¶
An UPPER BOUND on the conditioning of the system this block is solved with.
κ(AᵀN⁻¹A + S⁻¹)says how much a solver’s residual understates its error: for a solutionxwith relative residualr,‖x - x*‖ / ‖x*‖ ≤ κ · r
so a residual of 1e-6 against κ=1e7 certifies nothing. This is the number to divide an accuracy target by — for a target relative accuracy
a, askwiener_solve()orgcr_sample()for roughlytol = a / condition_bound(...)— and it is the numberrequire_convergenceitself reads.condition_estimate()measures κ instead and is biased LOW; a tolerance chosen from it is too loose by that bias.λ_max · max(prior_variance).AᵀN⁻¹Ais positive semi-definite, soλ_min ≥ 1/max(prior_variance)exactly.λ_maxis approached from BELOW and geometrically, so the estimate can only make the bound smaller.A large bound is not a defect; it is what the bound is entitled to say. On a block whose data constrains every direction, λ_min is set by the data rather than by the prior and this reads five decades high — measured, 1.44e+06 against a true κ under 10. It costs iterations, not correctness.
Costs
iterationsapplications of the normal operator, half whatcondition_estimate()costs, and forms no matrix.- Parameters:
block (LinearBlock) – from
linear_operator().noise_std (Any) – as for
condition_estimate(), with the same refusals.prior_std (Any) – as for
condition_estimate().iterations (int) – power-iteration steps for
λ_max.key (Array | None) – PRNG key for the starting vector. Fixed by default.
- Returns:
The bound, as a scalar array.
- Return type:
- rheplicant.inference.linear.condition_estimate(block, *, noise_std, prior_std=None, iterations=12, key=None)[source]¶
The MEASURED conditioning of the system this block is solved with.
κ(AᵀN⁻¹A + S⁻¹)is the number that says how much a solver’s residual understates its error: for a solutionxwith relative residualr,‖x - x*‖ / ‖x*‖ ≤ κ · r
so a residual of 1e-6 against κ=1e7 certifies nothing at all.
Do not divide an accuracy target by this number.
condition_bound()is the one to divide by, and it is whatrequire_convergenceitself reads. This one measuresλ_minby a second power iteration, whose leading eigenvalues crowd againstλ_maxwith vanishing gaps on a graded spectrum, so theλ_minit returns is too LARGE and this κ too SMALL — measured on a 20-point geometric spectrum at a true κ of 1e4, λ_min came back 33.9× high and κ 33.9× low; at 1e7 over 50 points the factor was ~700 and 2000 iterations did not close it. Atolcomputed from it is too LOOSE by that factor, which is the direction that certifies an answer it should have refused. See_condition_estimate()for where those numbers came from.What it is good for is the thing a bound cannot do: it can SEE a degeneracy. A near-degenerate partition shows up entirely in
λ_min, which the bound replaces with the prior’s floor and therefore cannot report. Measured intests/inference/test_linear_groups.py::TestGroupedVsAlternating: the joint operator’s κ exceeds its members’ by orders of magnitude here, and by a factor of 1.7 under the bound. Read it as a diagnostic — “how badly conditioned is this partition?” — and never as a certificate.Large κ is not a defect here, it is the design: for a block the data does not fully identify,
λ_minis exactly1/prior_std²whileλ_maxis set by the data, so κ grows with how much better the data constrains one direction than the prior constrains another.Costs
2 · iterationsapplications of the normal operator — each the same JVP-plus-VJP a CG iteration costs — and no matrix is ever formed.condition_bound()costs half that, measuring only the top.- Parameters:
block (LinearBlock) – from
linear_operator().noise_std (Any) – the same decided sigma array those solves take, and a
NoiseModelis as wrong here as it is there — a κ is the conditioning of one particular normal operator, so it needs the covariance settled, not a rule for producing one. Both refusals the solves apply run here too: a model is refused by name, and a 1-D sigma whose axis the prediction cannot settle is refused bycheck_noise_std_axis(). They have to, because a diagnostic is only about the system it describes: a κ computed under a different reading of the same array answers a different question than the solve it was computed for, and would report the conditioning of an operator nobody builds.prior_std (Any) – as for
wiener_solve()— it defaults to the latent’s declared prior, so the κ reported here is the κ of the system those solves will build rather than of a system nobody solves.iterations (int) – power-iteration steps per end of the spectrum. The default is comfortable; the estimate typically settles within three.
key (Array | None) – PRNG key for the starting vectors. Fixed by default, so the estimate is reproducible.
- Returns:
The measured condition number, as a scalar array.
- Return type:
- rheplicant.inference.linear.gcr_sample(block, observed, *, noise_std, prior_std=None, key, prior_mean=None, tol=1e-06, maxiter=None, require_convergence=None)[source]¶
Draw an EXACT posterior sample of a linear-Gaussian block.
The constrained-realization (GCR) identity: solve the same system
wiener_solve()does, but with two white-noise terms added to the right-hand side:(AᵀN⁻¹A + S⁻¹) x = AᵀN⁻¹(d - offset) + S⁻¹m + AᵀN⁻¹ᐟ² ω₁ + S⁻¹ᐟ² ω₂
with
ω₁,ω₂standard normal on the data and on the latent. The right-hand side then has the posterior-mean numerator as its mean and covarianceAᵀN⁻¹A + S⁻¹— the operator itself — sox = M⁻¹bhas the posterior mean and covarianceM⁻¹M M⁻¹ = M⁻¹exactly. Not an approximation and not a Markov chain: every call is an independent draw, with no burn-in and nothing to diagnose for convergence.This is what makes a 10⁶-dimensional block samplable at all. It costs one CG solve — the same as the mean — because the fluctuation enters the right-hand side, never the operator.
In a Gibbs scheme, this draws the linear block conditional on the nonlinear parameters; rebuild the block with
linear_operator(..., check=False)()each sweep, having checked the linearity claim once outside the loop. The conditioning guard is worth hoisting the same way:condition_estimate()once to fixtol, thenrequire_convergence=Nonein the loop. What you must NOT do is leavetolat its default and the guard off — that is the combination this module returned a silently over-confident posterior for.- Parameters:
block (LinearBlock) – from
linear_operator().observed (Array) – the data, shaped like
block.offset.noise_std (Any) – noise standard deviation, exactly as for
wiener_solve()— the same axis contract on a 1-D sigma (both exits share_check_solve_arguments, so a shape one refuses the other refuses), and the same refusal of aNoiseModelat this seam. The keyword is the signal:noise_std=takes a decided sigma,noise=takes the rule that decides one, and a draw has no prediction to evaluate a rule at any more than the mean does.prior_std (Any) – prior standard deviation on the latent. Defaults to the latent’s declared prior, as for
wiener_solve(), and required only when there is none. For a complex latent this is the width of the real and imaginary parts independently.key (Array) – PRNG key.
vmapover split keys for many independent draws.prior_mean (Any) – centre of the prior; defaults to the declared prior’s location, and to zero when nothing is declared. With uninformative data the draws fall back to
N(prior_mean, prior_std²), which is the check that it is wired in correctly.tol (float) – CG tolerance — a bound on the residual, not on the accuracy.
maxiter (int | None) – CG iteration cap.
require_convergence (float | None) – as for
wiener_solve(), including the conditioning note there, which a draw is MORE exposed to than the mean. The fluctuation termS⁻¹ᐟ²ω₂puts weight on every direction of the latent by construction, including the ones the data is blind to — so a draw always has something to resolve where the operator is worst conditioned, whereas the mean does only whenprior_meanis nonzero.
- Returns:
(x, relative_residual). An unconverged CG returns a draw from the WRONG distribution — and a distribution that is too NARROW, since the directions left unresolved are the prior-dominated ones that should have carried the most scatter.require_convergenceis worth passing here more than anywhere; it is off by default for the reasonwiener_solve()gives, which is about the bound’s conservatism and not about this risk being small.xcarries the block’s domain, dict or bare array, exactly aswiener_solve()’s does; see the note there, andLinearBlock.as_dict()for the wrap.- Return type:
Note
Sis read offLatent(prior=...)when the keywords are omitted; see the corresponding note onwiener_solve()for what that does and does not permit. It matters more here than for the mean: with a declared prior ignored, the fluctuation termS⁻¹ᐟ²ω₂is drawn at the wrong width, so every draw is wrong in the one direction the mean can be right in.
Multiplicative noise, taken to logs: a conjugate block where there was none.
RadiometerNoise generates
d = mu (1 + f w) with f = 1 / sqrt(delta_nu tau) — the multiplicative
form the radiometer equation actually states. Take logs:
log d = log mu + log(1 + f w)
and log(1 + f w) -> N(0, f^2) to first order. Two things follow, and the
second is the one that is easy to miss.
A block whose ``log mu`` is affine becomes conjugate. mu = exp(A x) is
not affine in x, so linear_operator()
refuses it and the only route is a gradient block — NUTS, and inside a
SamplingPlan a gradient block’s potential
carries no sum log sigma, so it targets the GLS-flavoured posterior rather
than the full one. In log space the same block is an ordinary
LinearBlock, so it gets
wiener_solve() and an exact
gcr_sample() draw instead.
And the log-space sigma does not depend on the prediction.
Var[log(1 + f w)] is a function of f alone — measured at prediction
magnitudes 1, 1e3 and 1e6 it moves in the fifth significant figure, which is
the Monte Carlo floor of the measurement. So the reweighting that
iterative_gls() exists to perform has nothing
left to do for that block: one solve, not a fixed point, and
depends_on_prediction is genuinely False rather than approximately so.
The whole GLS-versus-full-likelihood distinction that
rheplicant.inference.noise sets out is a consequence of sigma tracking
the prediction, and in log space it does not.
A summed sky is no obstacle, which is the fact that makes this useful here.
For d = g (T_ant + T_nw + tone), log of the sum is not affine in the
sky coefficients — but the GAIN block does not need it to be. Conditional on
the sky, log d = log g + log S with log S a known constant, and a known
constant added to the prediction is exactly what
offset holds. So the gain is
log-linear whatever the sky is made of, and the sky block stays an ordinary
linear block in the original space. Each block takes the space its own
conditional is affine in.
The approximation, and its size. First order is not exact:
E[log(1 + f w)] = -f^2 / 2 to leading order, and Var exceeds f^2.
Both are corrected or bounded here rather than left implicit —
to_log_space() adds the f^2 / 2 back, and f above
FIRST_ORDER_MAX_FRACTIONAL is refused. Measured, over 2e7 draws:
|
|
|
|---|---|---|
0.001 |
below the floor |
1.00 |
0.004 |
below the floor |
1.00 |
0.06 |
0.0088 |
1.006 |
0.10 |
0.0258 |
1.016 |
0.30 |
0.3983 |
1.185 |
The operating range is the top of that table and not the bottom: f for the
configurations in this repository runs 4.0e-4 (3.125e6 x 2.0) to 4.1e-3
(61e3 x 1.0), where the mean shift is 8e-8 to 8.2e-6 and the variance
error is below what 2e7 draws can resolve. The refusal at 0.06 is therefore
not a limit anyone meets by observing; it fires on a noise model that has been
mis-specified, which is the only way to reach it.
Why positivity is checked and not left to arithmetic. log of a
non-positive sample is NaN, and NaN fails every comparison — so a NaN
departure in check_linearity()’s
departure > rtol test reads as passing. A guard that cannot fail is the
defect this package hunts, so both exits here refuse a non-positive value by
name before any log is taken. Flagged samples are exempt: an unobserved sample
informs nothing, may hold anything, and is carried through at infinite sigma.
- rheplicant.inference.loglinear.LOG_DEFAULT_SCALES: tuple[float, ...] = (0.001, 0.01, 0.1, 1.0)¶
Probe magnitudes for the log-space affinity check, as multiples of the latent’s own scale. Deliberately NOT
DEFAULT_SCALES, whose top entry is1e3: here the probe is fed through an exponential before the log is taken, andexpoverflows above about 88 in float32. A probe 1000x a log-latent’s scale therefore measures the dtype, not the model — the map is still exactly affine in log space where its exponential is not representable. The span kept is four decades of probe, which is what the linear check’s own docstring says the sweep is for.
- rheplicant.inference.loglinear.LOG_ROUTE_REFUSALS: frozenset[str] = frozenset({'fractional_too_large', 'noise_additive'})¶
The named reasons a noise model has no log route.
The vocabulary is deliberately the same two names bayesmith’s
NOT_LOG_LINEAR_REASONSuses for the same two verdicts, so a reader comparing the packages is comparing words as well as behaviour.
- rheplicant.inference.loglinear.check_log_linearity(space, pipeline, state_template, name=None, *, names=None, at=None, scales=(0.001, 0.01, 0.1, 1.0), rtol=None, key=None)[source]¶
Verify that
log(prediction)is affine in one latent — or in a group.The log-space counterpart of
check_linearity(), asking the same question oflogof the same map, at the same probe points — the probe scheme is imported rather than restated, so the two cannot drift into probing different models while both reporting on “linearity”.- Parameters:
space (ParameterSpace) – the model under test.
pipeline (AbstractOperator) – the model under test.
state_template (State) – the model under test.
name (str | None) – which latent. Optional when exactly one is declared linear.
names (Sequence[str] | str | None) – several, checked jointly, as for the linear check. A gain and a sky are jointly log-linear no more than they are jointly linear.
at (dict[str, Array] | None) – values for the latents OUTSIDE the block. For a log-linear gain this is where the sky goes, and it matters:
log Sis the block’s offset, so the claim is made given it.scales (Sequence[float]) – as for
check_linearity().rtol (float | None) – as for
check_linearity().key (Array | None) – as for
check_linearity().
- Returns:
{scale: relative error}, as the linear check returns.- Raises:
ParameterSpaceError – if both spellings are given, or if the prediction is non-positive anywhere the log would be taken.
LinearityRefused – if any scale departs from affinity by more than
rtol.
- Return type:
- rheplicant.inference.loglinear.has_log_linear_block(space, pipeline, state_template, name=None, *, names=None, at=None, scales=(0.001, 0.01, 0.1, 1.0), rtol=None)[source]¶
The same question as
check_log_linearity(), answered rather than raised.Discovery needs a verdict:
auto_blocks()asks this of every latent that is not already declared linear, and a “no” is an ordinary answer rather than a fault.check_log_linearity()stays the exit that refuses, because a caller who has asserted log-linearity wants the departure numbers and the remedy, not aFalse.Exactly two refusals are read as “no” — a departure from affinity (
LinearityRefused) and a predictionlogcannot be taken of (LogSpaceUnavailable). Anything else propagates: a latent of integer dtype or a name the space does not declare means the question was never asked, and filing that as “not log-linear” would route a broken declaration to a gradient block silently.
- rheplicant.inference.loglinear.log_route_refusal(noise)[source]¶
Which of
LOG_ROUTE_REFUSALSapplies tonoise, orNone.The predicate, extracted so there is exactly one of it. Whether a log route exists is asked in two places — here, at PARTITION time by
auto_blocks(), and at SOLVE time byto_log_space(), which raises the full refusal — and the two must not be able to disagree. Before this function they could: auto_blocks took no noise model at all, so it could produce alog_conjugateblock thatto_log_spacethen refused, whichrheplicant.inference.partition’s own docstring names as the failure that module exists to prevent. Measured on 2026-08-27 by D17’s dual-run protocol, against bayesmith’s graph-side probe, which reads the noise when it partitions.Returns a REASON rather than raising, because at partition time “no log route here” is a blameless verdict that routes the latent to a gradient block; the raise belongs where a caller asked for the transform by name.
- rheplicant.inference.loglinear.log_linear_operator(space, pipeline, state_template, name=None, *, names=None, at=None, check=True, scales=(0.001, 0.01, 0.1, 1.0), rtol=None)[source]¶
Export
log(prediction)as a linear block, for data taken to logs.The returned
LinearBlockis an ordinary one — nothing downstream knows or needs to know that itsoffsetislogof something. Feed it towiener_solve()orgcr_sample()with the data and sigma thatto_log_space()returns:block = log_linear_operator(space, twin, state, "log_gain", at=values) y, sigma = to_log_space(observed, noise) draw, info = gcr_sample(block, y, noise_std=sigma, prior_std=P, key=key)
offsetislogof the prediction with the block at zero — for a gain bound asexp(log_gain), that islogof the sky, which is why a summed sky costs this nothing.- Parameters:
space (ParameterSpace) – the model.
pipeline (AbstractOperator) – the model.
state_template (State) – the model.
name (str | None) – one latent or a group, as for
linear_operator().names (Sequence[str] | str | None) – one latent or a group, as for
linear_operator().at (dict[str, Array] | None) – values for the latents outside the block. A log-linear gain block is log-linear given the sky, so a sweep must pass the current sky here every sweep.
check (bool) – verify the claim first with
check_log_linearity(). This is the eager path — it converts values to Python floats, so a caller rebuilding the block inside a jitted sweep passescheck=Falseand checks once outside, exactly as the linear machinery does.rtol (float | None) – forwarded to the check.
- Returns:
A
LinearBlockover log space.- Return type:
- rheplicant.inference.loglinear.to_log_space(observed, noise)[source]¶
Take the data to logs, and hand back the sigma that goes with it.
One function for both because they must agree: the transformed data and the log-space sigma are two halves of one claim about the noise, and a caller that took the log itself and reached for
fseparately could get thef^2 / 2shift on one and not the other.log d = log mu + log(1 + f w)andE[log(1 + f w)] = -f^2 / 2, so the log data sits low by a CONSTANT — the same for every sample, independent of the prediction — and adding it back is exact arithmetic rather than an estimate. Measured, the leading-order shift accounts for the true mean to 0.6 % at f = 0.06 and to better than 0.5 % below it.- Parameters:
observed (Array) – the data, as recorded. Strictly positive wherever it is not flagged.
noise (NoiseModel) – a multiplicative model —
RadiometerNoise, optionally wrapped inFlaggedNoise.
- Returns:
(y, sigma).yislog(observed) + f^2 / 2;sigmaisfbroadcast to the data’s shape, carryinginfat flagged samples so they keep the zero weight the flags gave them.sigmais a plain array and not aNoiseModel, which is what the conjugate solvers accept — and it is CONSTANT, which is the point: no reweighting loop.- Raises:
ParameterSpaceError – if the noise is not multiplicative; if its
fis aboveFIRST_ORDER_MAX_FRACTIONAL; or if an unflagged sample is non-positive.- Return type:
Cross-block identifiability: the rank test a per-block guard cannot perform.
Every convergence guard in rheplicant.inference.linear is computed
from one block. That is not an oversight, it is arithmetic: a residual
‖M x - b‖ and a condition number κ(AᵀN⁻¹A + S⁻¹) are both properties of
the operator for the block being solved, so neither can see a degeneracy whose
two halves live in different blocks.
check_linearity() cannot see it either, and it is right not to: each conditional
of a bilinear model genuinely is affine.
The failure that follows is silent and large. An alternating solve over
gain × T_ant with a free antenna temperature per (time, frequency) cell
reports κ ≈ 1.47 and a CG residual of ~1e-7 while sitting thousands of
kelvin from the truth. Nothing in the sweep is wrong; the partition is, and
no per-block number is entitled to say so.
How far from the truth is not a property of the degeneracy — it is the initial
offset, carried along the null direction and left there. Measured in
tests/inference/test_degenerate_partition.py: start 1 % off and land 27 K
out, start 100 % off and land 2962 K out, start ON the truth and stay. The
guards read the same in all three, to within 3 % on κ and an order of
magnitude on a residual already at 1e-7 — so they do not merely miss the
error, they are blind to four decades of it, including the difference between
the run that is right and the run that is catastrophically wrong. That is why
the remedy is a different measurement and not a tighter tolerance: there is no
threshold to put between those rows.
What can say so is the rank of the Jacobian of the prediction with respect to all the parameters at once:
free-per-cell T_ant, tone ON (5000 K) n_par=72 rank=64 nullity=8
free-per-cell T_ant, tone OFF n_par=72 rank=64 nullity=8
(3,3)-basis T_ant, tone ON (5000 K) n_par=17 rank=17 nullity=0
(3,3)-basis T_ant, tone OFF n_par=17 rank=16 nullity=1
Read that as: a known calibration tone buys exactly nothing against a
free-per-cell antenna temperature — the free cell at the tone’s channel absorbs
the gain sample by sample, so the nullity stays at n_time either way — and
everything against a frequency-smooth one, where a delta at one channel is
not in the span of three smooth basis functions and cannot be reabsorbed.
Three things about the method are not decoration.
The Jacobian’s columns are normalised. A latent whose natural scale is 1e3 and one whose scale is 1e-3 produce columns differing by 1e6 in norm, and a rank verdict taken on those reports the choice of units rather than the identifiability of the model. Column normalisation measures each parameter in units of its own effect on the prediction, which is the only scale-free question there is to ask.
It runs in float64 regardless of the caller’s configuration. The measured
separation between an identified and a non-identified basis model is
s_min/s_max = 6.8e-2 versus 6.6e-17. Computed in float32 the same null
direction of the same model surfaces at 3.1e-8 — 3.1x above the default
tolerance — and the degenerate model is reported as fully identified.
That is fragility rather than impossibility, and the difference is worth
stating precisely rather than overselling. In float32 the window between this
model’s weakest identified direction (4.8e-5) and its null one (3.1e-8) is
still 3.2 decades wide; it simply no longer contains the default. A
per-precision retune of rtol would therefore recover this model. It would
not recover one a few decades worse conditioned, which float64 still resolves
with eight decades to spare. Forcing float64 is what lets one default be right
for both. See DEFAULT_RANK_RTOL for how that default is chosen against
the spectrum.
The result is named. An anonymous index into a flattened vector tells a
user they have a problem and nothing about which; IdentifiabilityReport.
direction() hands back {"gain": ..., "t_ant": ...}, shaped like the latents,
so “the degenerate direction is this combination of gain and antenna
temperature” is something you can read and act on.
One thing about it is a limit rather than a feature. Cost: a dense Jacobian
and a dense SVD: O(n_data · n_par · min(...))
time and n_data · n_par float64 words of memory. This is a design-time
diagnostic for tens to a few thousand parameters — the size a Gibbs partition
is chosen at — not something to run inside a sweep over a 10⁶-coefficient sky
block. For that block the matrix-free relative is
condition_estimate(), which reports the
conditioning of one block without forming anything.
- rheplicant.inference.identifiability.DEFAULT_RANK_RTOL: float = 1e-08¶
Re-exported from bayesmith, which now owns both the number and the measurement behind it. Not a second copy: the whole argument for the value – the 8.7-decade window, the null direction at 7.5e-17, the weakest identified at 4.8e-5, and (as of D9) the family sweep showing that no float32 counterpart exists to be written – lives beside the arithmetic that uses it. A constant justified in one place and spelled in two is the defect this migration exists to remove; see bayesmith.diagnose.identifiability.
- class rheplicant.inference.identifiability.IdentifiabilityReport(names, shapes, spans, n_par, n_data, rank, nullity, singular_values, null_space, jacobian, column_norms, rtol, threshold)[source]¶
What the joint Jacobian’s rank says about a set of latents.
Deliberately a plain frozen dataclass rather than an
eqx.Module, for the same reasonLinearBlockis: this is a derived linear-algebra verdict, not a differentiable model. It holds numpy arrays, not JAX ones — a float64 JAX array that escapes the x64 context truncates, with a warning, the moment a default-precision caller touches it, which would throw away exactly the precision the diagnostic went to trouble to obtain. Andrank/nullityare Python ints, which is why this function cannot be jitted: a rank is a decision, and a traced decision is one you cannot branch on.- Parameters:
- spans¶
(start, stop)of each latent within the flat parameter vector, in the same order.
- n_data¶
size of the flattened prediction.
nullitycan never be belown_par - n_data: more parameters than data points is a null space by counting alone.- Type:
- nullity¶
n_par - rank— the dimension of the space of parameter perturbations the prediction is blind to.- Type:
- singular_values¶
(n_par,)descending. Whenn_data < n_parthe SVD returns onlyn_datavalues and the rest are exact zeros; they are included rather than dropped, sorankis always the count of entries above the threshold and never needs a caveat.- Type:
- null_space¶
(nullity, n_par)orthonormal rows, in the column-normalised coordinates the rank verdict is taken in. Usedirection()for raw latent coordinates.- Type:
- jacobian¶
(n_data, n_par)column-normalised, as analysed.- Type:
- property weakest_identified: float¶
s[rank-1] / s[0]— how well the worst identified direction is seen.The headline number: how much less the data says about the direction it constrains least than about the one it constrains most.
0.0when nothing at all is identified, which is the only case where the ratio has no meaning.
- direction(index)[source]¶
One null direction in RAW latent coordinates, split by name.
Add a small multiple of this to the latents and the prediction does not move, to first order — that is the whole content of the report, and it is the form a caller acts in. Note that it is NOT the raw SVD row: the SVD is taken of the column-normalised Jacobian, so a null vector there has to be divided by the column norms again to become a perturbation of the parameters themselves. Returned with unit 2-norm over the flat vector; the scale is arbitrary, only the direction means anything.
For per-latent weights that can be compared across quantities in different units, use
participation()instead — in raw kelvin and dimensionless gain, a null direction’s two halves are not comparable numbers.
- participation(index)[source]¶
Fraction of a null direction carried by each latent, summing to 1.
Measured in the COLUMN-NORMALISED coordinates, not raw ones: a 3000 K antenna temperature and a gain near 1 cannot be compared in their own units, and a raw-unit share would report which quantity is numerically larger rather than which one the degeneracy involves. In normalised coordinates the bilinear
gain × T_antdegeneracy comes out at 0.50/0.50, which is the true statement about it.
- rheplicant.inference.identifiability.identifiability(space, pipeline, state_template, *, names=None, at=None, rtol=1e-08)[source]¶
Rank of the joint Jacobian: what the data cannot tell apart.
The diagnostic that sees ACROSS Gibbs blocks. See the module docstring for why no per-block guard can, and for the measured case that motivates it.
- Parameters:
space (ParameterSpace) – the parameter declaration.
pipeline (AbstractOperator) – the forward model.
state_template (State) – the state it is evaluated on.
names (Sequence[str] | None) – which latents to differentiate with respect to — a sequence, or a bare string for one.
None(the default) means all of them, in declaration order. A subset asks the conditional question a Gibbs block faces — “is this block identified, with the others held fixed?” — and the answer is routinely yes for every block of a partition whose joint model is degenerate. That is the whole reason this function takes the joint by default.at (dict[str, Array] | None) – values for the latents NOT selected (and starting values for those that are). Identifiability is a LOCAL property of a nonlinear model, so a sweep has to ask it where the sampler currently is; defaults to the space’s declared initial values, which is right exactly once. Same contract as
linear_operator()’sat.rtol (float) – singular values at or below
rtol * s_maxare called null. SeeDEFAULT_RANK_RTOLfor how the default is chosen and when to override it.
- Returns:
An
IdentifiabilityReport. The three numbers to read first aren_par,nullityand, when the nullity is non-zero,report.participation(0)— which names the latents the degenerate direction mixes:report = identifiability(space, pipeline, state) if report.nullity: print(report.nullity, "blind directions;", report.participation(0))
- Raises:
ParameterSpaceError – if
namesis empty, repeats a latent, or names one that is not declared; ifatnames an undeclared latent; or if a selected latent is complex or non-floating.StateValidationError – if the model computes its prediction in single precision, where the rank verdict cannot be supported.
- Return type:
Note
This runs with
jax_enable_x64forced on for the duration and restores the caller’s setting afterwards — including on the way out of an exception. The setting is process-global, so this is not thread-safe against other JAX work.
Turn a Pipeline into a parametric forward function for inference.
This is the seam between forward modelling and inference: the pipeline stays a
clean instrument description, and inference engines (gradient calibrators,
NumPyro, future neural surrogates) see only f(params) -> prediction.
The mechanism is the standard Equinox partition/combine idiom:
params, static = eqx.partition(pipeline, filter_spec)
prediction = eqx.combine(params, static)(state_template).data
- rheplicant.inference.forward.build_forward_fn(pipeline, state_template, filter_spec=<function is_inexact_array>)[source]¶
Build
forward(params) -> predictionfrom a pipeline and a template state.- Parameters:
pipeline (AbstractOperator) – any operator (usually a Pipeline) describing the forward model.
state_template (State) – the input state the forward model is evaluated on (coordinates, PRNG key, metadata…). Closed over, held fixed.
filter_spec (Any) – which pipeline leaves are trainable parameters. Default: every inexact (floating-point) array. Pass a pytree-of-bools (e.g. built with
jax.tree.map(lambda _: False, pipeline)+eqx.tree_at) to train a subset.
- Returns:
the forward function and the initial parameter pytree extracted from the pipeline.
forward(params0)reproducespipeline(state_template).dataexactly.- Return type:
(forward, params0)- Raises:
ParameterSpaceError – if the pipeline contains a stage that draws randomness.
state_templateis closed over, soforwardwould return one frozen noise realisation on every call and a calibrator would fit the wrong model without complaint — seerefuse_stochastic_stages(). To build a simulator closure over a stochastic pipeline, use theeqx.partition/eqx.combineidiom in the module docstring directly, and vary the key per call: that is a different object from a fit target, and the point of refusing here is that the two look identical once built.
Calibration: infer pipeline parameters from observed data.
Deliberately OUTSIDE the forward model — a calibrator consumes the
forward(params) function built by build_forward_fn()
and never reaches into operators. GradientCalibrator is a minimal
working demonstration (fixed-step gradient descent, pure JAX); Bayesian
inference goes through rheplicant.inference.numpyro_bridge, uncertainty
forecasts through rheplicant.inference.uncertainty — all via the same seam.
- class rheplicant.inference.calibrate.GradientCalibrator(learning_rate=0.01, n_steps=100)[source]¶
Bases:
ModuleFixed-step gradient descent on a forward model (minimal demonstrator).
- fit(forward, params0, observed, loss_fn=<function mean_squared_error>)[source]¶
Minimize
loss_fn(forward(params), observed)fromparams0.- Returns:
the fitted parameter pytree and the per-step loss history, shape
(n_steps,).- Return type:
(params_fit, losses)- Raises:
ParameterSpaceError – if
observedis not shaped exactly likeforward(params0), or ifloss_fnis a log-density rather than an error. Both minimize something other than what was asked and report a small, converged loss for it.- Parameters:
- class rheplicant.inference.calibrate.AdamCalibrator(learning_rate=0.01, n_steps=1000, beta1=0.9, beta2=0.999, eps=1e-08)[source]¶
Bases:
ModuleAdam optimizer on a forward model (pure JAX — no optax dependency).
Adaptive per-parameter step sizes make this the right tool where fixed-step gradient descent stalls or diverges — notably neural surrogate stages (
NeuralOperator) and other poorly-conditioned parameter sets. Same interface asGradientCalibrator.- fit(forward, params0, observed, loss_fn=<function mean_squared_error>)[source]¶
Minimize
loss_fn(forward(params), observed)fromparams0.- Returns:
fitted parameters and per-step loss history, shape
(n_steps,).- Return type:
(params_fit, losses)- Raises:
ParameterSpaceError – if
observedis not shaped exactly likeforward(params0), or ifloss_fnis a log-density rather than an error — seeGradientCalibrator.fit().- Parameters:
Noise models: the one object every inference route asks for sigma.
A noise model answers a single question — given this prediction, how noisy is the datum? — and everything statistical follows from the answer: the likelihood, the loss, the weights a Wiener solve or a GCR draw uses, the Fisher matrix, the scale of a NumPyro observation site.
Before this module those five routes each took a bare noise_std argument,
which quietly assumed the answer is given and constant. For a radiometer
it is neither:
sigma(d) = |d| / sqrt(delta_nu * tau)
Sigma is a function of the very thing being inferred. That single fact is what
forces iteratively-reweighted least squares (rheplicant.inference.gls),
what puts a log-determinant term in a gradient-sampled posterior, and what RFI
flags flow into.
Three models, composed rather than configured:
Model |
sigma |
|
|---|---|---|
a constant, per-sample or scalar |
|
|
|
|
|
the wrapped model, or |
inherited |
FlaggedNoise is how flags reach the covariance: by wrapping a noise
model, not by bolting a flags= keyword onto five separate functions. An
infinite sigma is a self-describing encoding of “this sample was not
observed”, and NoiseModelLikelihood and inverse_variance() both
give it a clean zero rather than a NaN.
depends_on_prediction is the property downstream code branches on: False
means one solve, True means a loop.
On which axis a sigma vector runs along. “Scalar or broadcastable to the
data” was the whole contract until check_noise_std_axis(), and it is not
one: against a square (n_time, n_freq) grid a length-n sigma vector reads
equally well as one sigma per time sample and as one per frequency channel.
NumPy settles the tie by aligning trailing axes, so a per-time vector is applied
per-frequency and the resulting error bar is flat where the sigma it was built
from spans two orders of magnitude. Both readings are legitimate, which is
precisely why neither may be assumed — the ambiguous vector is refused and the
caller writes (n, 1) or (1, n).
On the log-determinant. The Gaussian log-density is:
log p = -1/2 sum_i [ r_i^2 / sigma_i(theta)^2 + log 2 pi sigma_i(theta)^2 ]
When sigma is constant the second term is an additive constant and dropping it
changes nothing. When sigma depends on the prediction it does not, and dropping
it — which is precisely what generalized least squares does — gives a
different estimator, one with no penalty for shrinking the prediction to make
the variance small. For the multiplicative model both are solvable in closed
form: GLS returns sum d^2 / sum d, biased high by (1 + f^2), while the
full density is asymptotically unbiased. So NoiseModelLikelihood
keeps the term by default, and include_logdet=False is the explicit,
documented GLS variant rather than an oversight.
- class rheplicant.inference.noise.NoiseModel(*args, **kwargs)[source]¶
Bases:
ProtocolContract:
sigma = noise.std(prediction), shaped like the prediction.- depends_on_prediction¶
whether
stdactually reads its argument.Falselets a solver skip the reweighting loop entirely, so it is a claim about the model, not a hint.- Type:
- realise(prediction, *, key)[source]¶
Draw one noisy observation of
predictionunder this model.The generator to
std’s assumption. A caller that draws with this and weights withstdcannot have the two disagree, which is the failure mode of every hand-writtendata + sigma * normalline beside a likelihood carrying its own sigma.
- class rheplicant.inference.noise.HomoscedasticNoise(sigma)[source]¶
Bases:
ModuleConstant noise: sigma independent of what the model predicts.
The behaviour every
noise_std=argument had before this module existed, named so that it is a choice rather than a default.- Parameters:
sigma (Array)
- sigma¶
standard deviation — a scalar, or an array shaped so that which axis it runs along is written down:
(n_time, 1)for a per-time sigma,(1, n_freq)for a per-channel one. A bare 1-D vector is fine only where its length matches a single axis of the prediction; where it matches more than one the reading is ambiguous andcheck_noise_std_axis()refuses it. Either way a differentiable leaf, so sigma can itself be inferred.- Type:
- class rheplicant.inference.noise.RadiometerNoise(channel_width, integration_time, floor=0.0)[source]¶
Bases:
ModuleThe radiometer equation: sigma proportional to the prediction itself.
sigma = |prediction| * fwith the fractional levelf = 1 / sqrt(delta_nu * tau)— the multiplicative form,d -> d(1+w), which is what the radiometer equation actually says and what the noise-wave radiometer equation writes. This is rheplicant’s default noise model.Because sigma tracks the prediction, a solve for the prediction and a weighting by its noise are the same problem: see
iterative_gls().- channel_width¶
channel bandwidth
delta_nu[Hz] (static — instrument metadata, known rather than fitted).- Type:
- floor¶
lower bound applied to
|prediction|before scaling [K]. Defaults to0.0, i.e. the exact physics: a prediction that passes through zero then has zero sigma and infinite weight, which is a loud failure. A reweighting iterate can cross zero where the physics cannot, and a floor is the remedy there.- Type:
- realise(prediction, *, key)[source]¶
Multiplicative:
d (1 + f w),f = 1/sqrt(delta_nu tau).The multiplicative form, not
d + sigma(d) w– becausesigma = |prediction| * fuses an absolute value that a generator must not, and the two forms differ in sign wherever the prediction does.flooris deliberately not applied here: it is a remedy for a reweighting iterate crossing zero, and a generator has no iterate.
- class rheplicant.inference.noise.FlaggedNoise(base, flags)[source]¶
Bases:
ModuleWrap a noise model so flagged samples carry infinite variance.
The seam where RFI flagging meets the noise covariance: a flagged sample was not observed, so it must inform nothing. Encoding that as
sigma = infkeeps the fact inside the noise model, where every consumer already looks, instead of as a parallelflags=argument each of them has to remember to honour.infis only ever an encoding —NoiseModelLikelihoodandinverse_variance()both turn it into a clean zero contribution rather than lettinginf * 0become NaN.- Parameters:
base (NoiseModel)
flags (Array)
- base¶
the noise model in force on unflagged samples.
- realise(prediction, *, key)[source]¶
The wrapped model’s draw, unchanged.
Flags say a sample was not OBSERVED, not that it had no true value, so they belong to the likelihood’s covariance and not to the generator.
stdputsinfat the flagged samples; drawing at that sigma would produce a data set no instrument could record, and every consumer that turnsinfinto a clean zero weight expects the datum underneath to be finite.
- rheplicant.inference.noise.check_noise_std_axis(noise_std, prediction_shape, caller)[source]¶
Refuse a 1-D
noise_stdwhose axis the prediction cannot settle.“Scalar or broadcastable to the data” is not a contract. Against a square grid —
(n_time, n_freq)withn_time == n_freq, which is not exotic but the shape a test rig and a single scan block both land on — a length-n sigma vector reads equally well as one sigma per time sample and as one per frequency channel. NumPy settles it by aligning trailing axes, so the per-time reading is applied per-frequency; every downstream number is finite, correctly shaped, and answers a question nobody asked.Measured on an 8x8 grid with a per-time gain latent and
sigma = linspace(0.01, 1.0, 8): the explicit(8, 1)gives an error bar spanning 0.00004 to 0.00354, and the bare(8,)gives a flat 0.00010 — the ~90x structure the sigma vector describes, averaged away without a word. So the vector is refused and the caller says which axis it meant by giving it one.Only SHAPES are read, which is the point rather than an economy. A NaN defeats every comparison-based guard —
nan < 0andnan != nanboth come back the answer that lets it through — so a guard that reached for the values would be exactly the one a poisoned sigma sails past. Shapes are integers; there is nothing here for a NaN to defeat, and nothing that has to be re-checked inside a trace.- Parameters:
noise_std (Any) – the argument as the caller passed it — a scalar, an array, or a
NoiseModel. Wrapped constant sigmas are unwrapped; a prediction-dependent model is exempt by construction.prediction_shape (Any) – the shape the model predicts, from
jnp.shapeorjax.eval_shape. No array need be computed.caller (str) – the exit to name in the message.
- Raises:
StateValidationError – if
noise_stdis 1-D and its length matches more than one axis of the prediction.- Return type:
None
- rheplicant.inference.noise.inverse_variance(noise, prediction)[source]¶
Per-sample weights
1 / sigma^2, with a clean zero where unobserved.The quantity every weighted solve wants. An infinite sigma (see
FlaggedNoise) becomes exactly0.0rather than an underflowed denominator, so the weight array is finite by construction.- Parameters:
noise (NoiseModel)
prediction (Array)
- Return type:
- rheplicant.inference.noise.log_determinant(noise, prediction)[source]¶
sum log sigmaover the OBSERVED samples – half a log-determinant.The term that separates a Gaussian log-density from a chi-squared. It is a constant, and so invisible to any estimator, exactly when
noise.depends_on_predictionisFalse; when it isTruethis is the difference between the full likelihood and generalized least squares, and the two are different estimators – see this module’s docstring for the closed forms and which way the bias runs.Named
log_determinantfor0.5 log|C|withCdiagonal, which is what it is; the factor of two lives in the caller’s0.5 * chi2beside it.An unobserved sample (infinite sigma, see
FlaggedNoise) contributes exactly zero. Taking the limit would not work –log sigma -> inf– so one flagged channel would otherwise send every log-density and every potential built on it to infinity. Same rule, and the same reason, asinverse_variance()’s clean zero.Deliberately WITHOUT the
0.5 n log 2 pithatNoiseModelLikelihoodcarries. That is a constant in the sample count, so it changes no answer, and it is not free: added to a gradient block’s potential it tripled the magnitude NUTS takes differences of, and at float32 the scanned transition’s agreement withMCMC.rundegraded from 3.0e-06 to 3.9e-04.tests/inference/test_noise_log_determinant.pypins this againstNoiseModelLikelihoodso the two cannot drift.- Parameters:
noise (NoiseModel)
prediction (Array)
- Return type:
- class rheplicant.inference.noise.NoiseModelLikelihood(noise, include_logdet=True)[source]¶
Bases:
ModuleGaussian log-density under a
NoiseModel.Generalizes
GaussianLikelihood(which is this withHomoscedasticNoise) andMaskedGaussianLikelihood(this withFlaggedNoisearound it); both remain, and both agree with this to roundoff.- Parameters:
noise (NoiseModel)
include_logdet (bool)
- noise¶
the noise model — supplies sigma at the prediction.
- include_logdet¶
keep the
log 2 pi sigma^2normalization (default). Setting itFalsegives generalized least squares. That is only the same objective when sigma does not depend on the prediction; when it does, GLS is a different estimator — see this module’s docstring for the closed forms and which way the bias runs. Static: it selects an objective, not a value.- Type:
Iteratively reweighted least squares: finding the covariance to solve at.
gcr_sample() is a linear sampler given a
covariance, and wiener_solve() is the
corresponding mean. Both take noise_std and neither cares where it came
from. Under HomoscedasticNoise it comes
from the caller and there is nothing more to say.
Under the default RadiometerNoise there
is. Sigma tracks the prediction:
sigma_i = |A x + offset|_i / sqrt(delta_nu * tau)
so the weights depend on the solution and the solution depends on the weights. Neither is available first. This module supplies the missing half — the covariance — and changes nothing about the two solvers:
found = iterative_gls(block, observed, noise=RadiometerNoise(dnu, tau),
prior_std=PRIOR)
draw, _ = gcr_sample(block, observed, noise_std=found.noise_std,
prior_std=PRIOR, key=key)
The algorithm is a fixed-point iteration: solve with the current weights,
recompute the weights at the new prediction, repeat. It is the same
iteratively-reweighted GLS as hydra-tod’s
hydra_tod.linear_sampler.iterative_gls, but matrix-free — hydra-tod
forms a dense design matrix U and a dense N_inv, while here the same
algorithm runs on the LinearBlock’s JVP
and VJP, which is what makes a block with 10^6 degrees of freedom possible at
all.
What this estimator is, and is not. Freezing sigma inside each solve is
what makes each step a linear-Gaussian problem, and it is also what makes the
converged answer generalized least squares rather than the maximum of the
full Gaussian likelihood: the log-determinant’s dependence on the solution is
held fixed rather than differentiated. The two differ, in a known direction —
see rheplicant.inference.noise. GLS is the right estimator to condition
a constrained realization on, because a GCR draw is exactly a draw from a
linear-Gaussian posterior at a given covariance; if you want the full
likelihood’s mode or posterior, that is a job for a gradient sampler
(rheplicant.inference.numpyro_bridge), not for this.
- rheplicant.inference.gls.MIN_REWEIGHTS: int = 5¶
Reweighting steps taken before the convergence test is consulted. Matches hydra-tod’s default: the first steps of a fixed-point iteration can be nearly stationary without being near the fixed point.
- rheplicant.inference.gls.MAX_REWEIGHTS: int = 100¶
Cap on reweighting steps, so a non-contracting problem terminates and says so through
convergedrather than spinning.
- rheplicant.inference.gls.REWEIGHT_TOL_EPS: float = 8.0¶
Multiple of the working precision’s epsilon used as the default
reweight_tol. Seeiterative_gls()for why the default cannot be a fixed number.
- class rheplicant.inference.gls.GLSResult(noise_std, solution, residual, iterations, delta, converged)[source]¶
What a reweighting run produced.
A NamedTuple, so it is a pytree and survives
jitunchanged.noise_std— the converged sigma: the covariance, and the whole point of the exercise. Feed it togcr_sample()orwiener_solve()asnoise_std=.solution— the GLS point estimate at that covariance, shaped like the latent.residual— relative CG residual of the final solve. Not an accuracy; seewiener_solve().iterations— reweighting steps taken, the first solve included.delta— relative change of the last step,‖x_new - x‖ / ‖x_new‖.converged— whetherdeltafell belowreweight_tolwithinmax_reweights. False here means the returned covariance is not a fixed point, and everything conditioned on it inherits that.
- Parameters:
- rheplicant.inference.gls.iterative_gls(block, observed, *, noise, prior_std=None, prior_mean=None, tol=1e-06, maxiter=None, reweight_tol=None, min_reweights=5, max_reweights=100, require_convergence=None)[source]¶
Find the covariance a prediction-dependent noise model implies.
Repeats: solve at the current sigma, recompute sigma at the new prediction. When
noise.depends_on_predictionisFalsethere is nothing to repeat and this is a singlewiener_solve().- Parameters:
block (LinearBlock) – from
linear_operator().observed (Array) – the data, shaped like
block.offset.noise (NoiseModel) – the noise model — supplies sigma at each prediction.
prior_std (Any) – prior standard deviation on the latent. Defaults to the latent’s declared prior, and required only when there is none, for the reason
wiener_solve()requires it. Resolved once here and passed down explicitly, so every inner solve sees the sameS.prior_mean (Any) – centre of the prior; defaults to the declared prior’s location, and to zero when nothing is declared.
tol (float) – CG tolerance for each inner solve.
maxiter (int | None) – CG iteration cap for each inner solve.
reweight_tol (float | None) –
stop when the latent’s relative change falls below this. The default cannot be a fixed number, because two independent floors bound how small a step is measurable at all, and it defaults to
max(8 * eps, tol):the arithmetic’s own epsilon — a relative step below it is rounding, not a measurement. float32’s is
1.2e-7, so a plausible-looking1e-8is exactly this trap;the inner solver’s tolerance
tol— consecutive solves differ by roughly their own CG residual no matter what the outer iteration is doing, so a step smaller thantolmeasures CG, not the fixed point. This is the binding floor in float64, where a tighttol=1e-10sits five orders of magnitude aboveeps.
Ask for less than either and the run does not fail quietly: it spends
max_reweightssteps and reportsconverged=Falsefor a fixed point it had in fact reached.min_reweights (int) – steps taken before the test is consulted.
max_reweights (int) – cap on steps.
require_convergence (float | None) – bound on the relative error of the final solve, as for
wiener_solve(). Deliberately applied once, at the converged covariance, and not inside the loop: the guard costsPOWER_ITERATIONSextra operator applications, which is the same bargainwiener_solve()’s own docstring recommends for a Gibbs sweep. It bounds the error of what is returned; it says nothing about the intermediate steps, which do not need it.
- Returns:
A
GLSResult. Check ``converged`` — a covariance that is not a fixed point is still a number, and a draw conditioned on it is still a draw.- Return type:
Note
The iteration starts from sigma evaluated at the data rather than from hydra-tod’s unweighted least squares. It is the natural first guess (the data is an estimate of the prediction), it costs one solve less, and it honours flags from the first step where unit weights would not. A fixed point does not depend on where the iteration started, so the two agree where either converges.
Built on
lax.while_loop, so it is jittable but not reverse-mode differentiable. That is not the limitation it looks like: the result is a fixed point, so implicit differentiation — not unrolling — is the right way to take a gradient through it.
Likelihoods: score a forward-model prediction against observed data.
A likelihood is any callable (prediction, observed) -> scalar log-prob.
The Protocol below documents the contract; GaussianLikelihood is the
minimal concrete instance. Real instrument likelihoods (radiometer-equation
noise, 1/f covariance, Toeplitz solvers ported from hydra-tod/comat) will
implement the same contract.
The contract’s one unwritten precondition — that the two arguments describe the
same data — is written here, as check_observed_shape(). It lives at this
seam because this is where prediction meets observed, and every inference route
that consumes observed (the calibrators, the NumPyro observation site, the
conjugate-Gaussian solves) calls it at its own entry point rather than
re-deriving the refusal.
- class rheplicant.inference.likelihood.Likelihood(*args, **kwargs)[source]¶
Bases:
ProtocolContract:
logp = likelihood(prediction, observed)(scalar).The Protocol cannot express the one thing a caller most needs to know about a scoring function — whether it is to be maximized or minimized — because both senses have exactly this signature.
isinstance(mean_squared_error, Likelihood)isTrue, and so isisinstance(GaussianLikelihood(1.0), Likelihood), while handing the second to a minimizer walks a log-density unbounded below and reports a beautifully improving loss the whole way down (measured: g = -30.7 against a truth of 1.0, loss -3.2e+07 -> -1.3e+11).So the sense is carried as an attribute instead.
senseis"maximize"on every likelihood in this package and absent on plain error functions, which default to"minimize". It is advisory — a caller may declare nothing — which is whyrheplicant.inference.calibratealso measures the sense at entry rather than trusting the declaration alone.
- rheplicant.inference.likelihood.DEFAULT_SENSE = 'minimize'¶
Read from a scoring function that does not declare
Likelihood.sense."minimize"because the un-annotated case is a plain error function – a barelambda p, o: jnp.mean((p - o) ** 2)– and because defaulting the other way would refuse every such lambda in every example in the package.
- rheplicant.inference.likelihood.sense_of(scoring_function)[source]¶
The declared sense of a scoring function, or the default.
- Parameters:
scoring_function (Any) – any callable
(prediction, observed) -> scalar.- Returns:
"maximize"or"minimize".- Raises:
ParameterSpaceError – if
senseis present but is neither. A typo in a declaration must not silently read as the default, which is the permissive direction and the one that loses a fit.- Return type:
- rheplicant.inference.likelihood.check_observed_shape(prediction_shape, observed, *, predictor='this block')[source]¶
Refuse an
observedthe prediction would have to broadcast against.Every scoring rule in this package subtracts the two, and NumPy broadcasting makes
(24, 8) - (8,)a legal, finite, wrong residual. The failure has no symptom: the loss converges, the Fisher matrix inverts, NUTS reports a healthyr_hat, and every recovered parameter is the average of a problem nobody posed. So the mismatch is an error at entry, not a warning later.Shapes are static, so this costs nothing at run time — call it once where the arguments arrive, never inside a jitted step or a gradient evaluation.
- Parameters:
- Raises:
ParameterSpaceError – if the shapes differ at all. Broadcast-compatible is not the same as equal, and it is exactly the compatible cases that are dangerous.
- Return type:
None
- class rheplicant.inference.likelihood.GaussianLikelihood(noise_std)[source]¶
Bases:
ModuleIndependent Gaussian likelihood with fixed noise level.
- Parameters:
noise_std (Array)
- noise_std¶
noise standard deviation — scalar or broadcastable to the data shape; a differentiable leaf (so it can itself be inferred).
- Type:
- class rheplicant.inference.likelihood.MaskedGaussianLikelihood(noise_std, flags=None)[source]¶
Bases:
ModuleGaussian likelihood that ignores flagged samples.
The seam where RFI flags inform the noise covariance: pass
flagsfromstate.aux["flags"](True = flagged/bad); flagged samples contribute zero to the log-probability, equivalent to infinite noise variance on those samples.- flags¶
boolean mask, True = excluded;
Nonebehaves exactly likeGaussianLikelihood.- Type:
jax.Array | None
- sense¶
"maximize"— seeGaussianLikelihood.- Type:
ClassVar[str]
- rheplicant.inference.likelihood.mean_squared_error(prediction, observed)[source]¶
Plain MSE — the default loss for quick gradient calibration.
Declares no
sense, and so reads as"minimize": the un-annotated case is a plain error function, which is what a minimizer wants.
Bridge a Pipeline/Assembly to a NumPyro probabilistic model.
Bayesian inference through the same seam as everything else (D7): a
ParameterSpace says what is
inferred and how it enters the model, and this module turns it into priors
plus a likelihood:
import numpyro.distributions as dist
from rheplicant.inference import ParameterSpace, to_numpyro_model
space = ParameterSpace.direct(
"log_gain", init=0.0, into=lambda p: p["gain"].gain, fn=jnp.exp,
prior=dist.Normal(0.0, 0.2),
)
model = to_numpyro_model(twin, state_template, space, noise_std=0.5)
mcmc = numpyro.infer.MCMC(numpyro.infer.NUTS(model), num_warmup=500,
num_samples=500)
mcmc.run(jax.random.key(0), observed=observation.data)
Sample sites are named by their latents, so NUTS explores — and the
samples come back keyed by — the coordinates the model was declared in.
log_gain above is one site, even though its value reaches a pipeline leaf
called gain, and it would remain one site if it drove five stages at once.
IMPORTANT — stochastic operators: in a Bayesian model the noise lives in the
LIKELIHOOD, not in the forward model. Build the pipeline you hand to
to_numpyro_model without NoiseOperator/RFIOperator draws (the framework
already separates them as their own stages), or their fixed-key draws would
be treated as deterministic signal.
Posterior predictive / pushforward: predict_from_samples() runs the
pipeline over MCMC samples (pairs with
rheplicant.inference.uncertainty’s summaries).
- rheplicant.inference.numpyro_bridge.PREDICTION_SITE: str = 'prediction'¶
The deterministic site this package has always recorded the prediction at. Not the graph’s internal
__mu__: a caller is invited to readmcmc.get_samples()["prediction"]by this module’s own docstring and byexamples/tutorial_nuts.py, so it is a public name and kept (D26).
- rheplicant.inference.numpyro_bridge.to_numpyro_model(pipeline, state_template, space, noise_std, flags=None, obs_name='obs', *, allow_sampled_noise_std=False)[source]¶
Build a NumPyro model: priors -> bound pipeline -> Gaussian likelihood.
- Parameters:
pipeline (AbstractOperator) – the (deterministic) forward model.
state_template (State) – input state the model is evaluated on (closed over).
space (ParameterSpace) – what to infer and how it binds. Every latent needs a prior — either its own
Latent(prior=...)or the space’sjoint_prior. A declaredJeffreysPrioris evaluated here and nowhere else: its latents get improper flat sample sites, its block is checked for rank once before any sample is drawn, and0.5 log det Iis added at the"joint_prior"factor site with the same noise object the likelihood uses.noise_std (Any) –
how noisy the data is. Three forms:
a scalar or array standard deviation;
a
NoiseModel— in particularRadiometerNoise, whose sigma tracks the prediction and therefore the sampled parameters;a NumPyro distribution, to infer a constant sigma (sampled at site
"noise_std").
flags (Array | None) – optional boolean mask (True = flagged); flagged samples are excluded from the likelihood (RFI flags -> noise covariance). Equivalent to wrapping
noise_stdinFlaggedNoise.obs_name (str) – name of the observed sample site.
allow_sampled_noise_std (bool) – take a sampled
noise_stdtogether with a declaredjoint_priordeliberately. Off by default, and the refusal explains what it costs: the"noise_std"site is in no ParameterSpace, so a Jeffreys prior overplatents tilts its posterior bysigma^-pwith nothing reporting it — measured at about 1.0 sigma forp = 32. Inert when nojoint_prioris declared.
- Returns:
A NumPyro model
model(observed=None)— condition by passingobserved=data; run without it for prior-predictive checks. The noiseless prediction is recorded at the deterministic site"prediction". A conditioningobservedwhose shape is not exactly the prediction’s is refused (check_observed_shape()) while the model is traced, before any sample is drawn: broadcasting it would give NUTS a different posterior to explore, and it would explore it successfully.
Note
A prediction-dependent sigma brings its log-determinant with it, and that is the point of routing it through here.
Normal(loc, scale).log_probcontains-log scale, so whenscaleis a function of the sampled parameters the term is part of the potential automatically — this is the full Gaussian density, not the generalized least squares thatiterative_gls()converges to (which freezes that dependence in order to keep each step linear). The two answers differ, in the directionrheplicant.inference.noisegives in closed form.An unobserved sample — infinite sigma, from
FlaggedNoiseorflags— is masked out rather than given an infinite scale, which would send the whole potential to-inf. Masking is the limit that exists.The model itself is bayesmith’s. This function declares the graph through
to_graph()and hands it tobayesmith.to_numpyro; the sites, the mask and the joint-prior factor are emitted there, from the declaration. What stays here is what a graph cannot spell: the refusals above, the site NAMES (D26 — the graph calls its own nodes__mu__and__data__, and this package has always called them"prediction"andobs_name), and the meaning ofobserved=None, which is the prior predictive here and “each node’s declared data” there.
- rheplicant.inference.numpyro_bridge.init_to_declared(space)[source]¶
A NumPyro init strategy that starts where the
ParameterSpacesays.Latent(..., init=...)already states where the model starts, and the calibrators andcheck_linearity()both use it. NUTS does not: a kernel built without aninit_strategyuses NumPyro’s defaultinit_to_uniform, which draws in the unconstrained space with no knowledge of the declaration. Pass this instead:kernel = numpyro.infer.NUTS(model, init_strategy=init_to_declared(space))
This is not a tuning knob. On the ring toy of
examples/tutorial_nuts.py— 1024 samples constraining three beam parameters — the default initialization givesr_hat = 840and an effective sample size of 2 out of 8000 draws, while the identical model started here givesr_hat = 1.002andn_eff = 1327. Neither tightening the priors nor tripling the warmup moved those numbers at all; only the starting point did.The mechanism is ordinary and worth recognising: a posterior far narrower than its prior is a needle,
init_to_uniformlands in the haystack, and warmup adapts a step size for wherever it landed. The declaredinitdoes not have to be good — the one in that tutorial is deliberately mis-set, some 11000 nats below the peak — it only has to be somewhere a gradient can be followed.- Parameters:
space (ParameterSpace) – the space the model was built from.
- Returns:
A NumPyro init strategy, ready for
NUTS(model, init_strategy=...).
- rheplicant.inference.numpyro_bridge.predict_from_samples(pipeline, state_template, space, samples)[source]¶
Posterior predictive: run the pipeline over MCMC samples.
- Parameters:
pipeline (AbstractOperator) – as given to
to_numpyro_model().state_template (State) – as given to
to_numpyro_model().space (ParameterSpace) – as given to
to_numpyro_model().samples (dict[str, Array]) –
mcmc.get_samples()— latent name ->(n_samples, ...).
- Returns:
(n_samples, *data.shape)noiseless predictions (add likelihood noise separately if you want the full predictive).- Return type:
Amortized neural posterior estimation: inference that never writes a likelihood.
Every other engine in this package evaluates a likelihood. NUTS does it once
per leapfrog step; gcr_sample() exploits its
conjugate form. Simulation-based inference does not evaluate one at all: it
draws pairs (theta, x) from the prior and the simulator, fits a conditional
density q(theta | x) to them, and then reads the posterior off q at the
data actually observed.
Two properties follow, and they are why this exists alongside the exact solvers rather than instead of them:
Amortized. The training cost is paid once. Evaluating the posterior for a new observation is a forward pass — no chain, no burn-in, no re-solving. For a nightly re-calibration over many observations that is the difference between hours and milliseconds.
Likelihood-free. Nothing here needs the noise to be Gaussian, or the forward model to be differentiable, or a normalization to be tractable. It needs a simulator, which the twin already is.
The price is that the answer is only as good as the fit, and an approximate
posterior has no internal notion of being wrong — a badly-trained q
returns a confident, smooth, incorrect distribution and reports nothing amiss.
So this module is deliberately built to be checkable: on a linear-Gaussian
problem the exact posterior is available from
gcr_sample(), and the package’s tests hold
the estimator to it. Validate on a case you can solve before trusting one you
cannot.
The density is a conditional Gaussian mixture — an MLP mapping a summary of
the data to the weights, means and scales of a mixture over the latent vector.
A normalizing flow is more expressive; a mixture is a few dozen lines, is exact
for a Gaussian posterior at one component, and keeps the failure modes legible.
Adam is hand-rolled here for the same reason it is in
rheplicant.inference.calibrate: no optax dependency.
Usage:
thetas, bank = simulate_pairs(twin, state, space, noise=noise,
key=jax.random.key(0), n_simulations=20_000)
q = NeuralPosterior.create(thetas, bank, key=jax.random.key(1))
q, losses = train_posterior(q, thetas, bank, key=jax.random.key(2))
draws = q.sample(observed, key=jax.random.key(3), n_samples=4000)
- rheplicant.inference.npe.MIN_SCALE: float = 0.001¶
Floor on a mixture component’s scale, as a fraction of the standardized latent’s unit width. A collapsed component – one sitting on a single training point – would take the log-density to infinity, and this keeps a component’s width away from that.
Measured 2026-08-29: the floor guards a LIMIT, not a reachable value. The scale is
softplus(raw) + min_scale(NeuralPosterior._mixture, below), andsoftplusis strictly positive – overrawin[-80, 80]its minimum is1.8e-35, never zero. Somin_scale = 0does not give a zero scale: a deliberately collapsible bank (eight distinct thetas, eight components) returns a finitelog_probat0.0(-3.6740) as at the default (-3.6689). This comment previously said the collapse happens “without it”, which is not what the arithmetic does, and the config layer’s refusal ofmin_scale: 0cites this line for that claim – seeconfig/sections/npe.py::_positive. bayesmith’samortize.MIN_SCALErecords the same measurement independently and refuses only a NEGATIVE floor.
- rheplicant.inference.npe.simulate_pairs(pipeline, state_template, space, *, noise, key, n_simulations)[source]¶
Draw
(theta, x)pairs from the prior and the simulator.The “simulation” in simulation-based inference, and it is the twin doing it: priors from the
ParameterSpace, the forward model from the pipeline, the scatter from the noise model — the same three objects every other engine uses.- Parameters:
pipeline (AbstractOperator) – the deterministic forward model.
state_template (State) – the input state it is evaluated on.
space (ParameterSpace) – what is inferred. Every latent needs a prior, since the prior is what is being sampled from.
noise (NoiseModel) – supplies sigma at each simulated prediction. Under
RadiometerNoisethe scatter is multiplicative, exactly as in the data – because the draw is taken with the model’s ownrealise, so the simulator and the likelihood cannot disagree about the law.key (Array) – PRNG key.
n_simulations (int) – how many pairs.
- Returns:
(thetas, data)—thetasis(n_simulations, n_latent_values)with each latent’s values raveled and concatenated inspace.namesorder;datais(n_simulations, *data.shape).- Return type:
Note
A sample the noise model reports as unobserved (infinite sigma, from
FlaggedNoise) is simulated without scatter. It carries no information either way, and the right place to remove it is the estimator’sembed— leaving it in the input feeds the network a constant.
- class rheplicant.inference.npe.NeuralPosterior(net, embed, n_components, n_params, theta_mean, theta_scale, data_mean, data_scale, min_scale=0.001)[source]¶
Bases:
NeuralPosteriorq(theta | x): a conditional Gaussian mixture over the latent vector.Subclasses :class:`bayesmith.amortize.NeuralPosterior` as of the Wave C `npe` switch (migration ledger D10, owner-authorised 2026-08-27, scoped by D42). The network, the standardization, the mixture and both exits are inherited; the whole of this class is the
createoverride below, and the whole of that override is one exception translation.Why a subclass and not a re-export. Class identity is not pinned anywhere – 27 references across 7 test files, zero
isinstanceortype() isassertions – so a re-export would preserve every name. What it would not preserve is the exception class: seven guard tests intests/inference/test_inference_construction_guards.pyexpectStateValidationError, and the far side raisesStructureErrorthroughout. That is what D10(3)’s “thin wrapper” is for, and it is measured rather than assumed.Why a subclass and not a held instance. D12 found that subclassing cannot translate an exception raised in
__check_init__, because the base class raises during construction before a subclass can intervene. These three refusals are increate(), a classmethod, and both sides end theirs withreturn cls(...)– so the override is clean. Holding a far-side instance instead would nest the pytree; inheriting keeps the nine field names in order and the fourteen leaves flat, which the config layer’s reading ofcreate’s signature andsample’s positional contract depends on.Measured across the seam before the near-side implementation was deleted, and it cannot be re-measured now: untrained
log_prob,log_probafter 200 training steps, and all threeTrainingHistoryfields agreed atmax|delta| = 0.0. Seedocs/superpowers/specs/2026-08-29-wave-C-npe-opening.mdin bayesmith.- Parameters:
- Inherited unchanged, nine of them in the order the far side declares
- ``net``, ``embed``, ``n_components``, ``n_params``, ``theta_mean``,
- ``theta_scale``, ``data_mean``, ``data_scale``, ``min_scale``.
- classmethod create(thetas, data, *, key, embed=<PjitFunction of <function ravel>>, n_components=4, width=64, depth=3, min_scale=0.001)[source]¶
The far side’s, with its refusals raised as this package’s class.
The parameter list is restated rather than forwarded as ``*args, **kwargs``, and that is not a style choice. The config layer DERIVES its
npe:grammar from this signature – which keys exist, which are optional, which default to what, and thatembedbelongs tocreateand not totrain– so a signature of*argsis a grammar of nothing. Measured: forwarding took seven tests intests/config/test_config_section_npe.py:: TestTheGrammarMatchesTheSignaturesdown at once.Restating creates the drift risk that forwarding was meant to avoid, so it is guarded rather than hoped:
test_npe_signatures_match_the_far _sideasserts this list equalsbayesmith.amortize.NeuralPosterior.create()’s, parameter for parameter and default for default.
- sample(datum, key, n_samples)[source]¶
n_samplesdraws fromq(theta | datum), flat and standardized back.Declared rather than inherited, for the same reason :meth:`create` restates its signature. Several guards read this package’s public surface out of
src/by walking the AST, and an inherited member is invisible to them: they resolve base classes within this package, and this class’s base is bayesmith’s. Measured – inheriting it silently tooktests/test_docs_claims.pydown twice over, once forNeuralPosterior.samplenaming a member the walk could not find, and once forn_samples=naming a keyword no parameter insrc/accepted any more. Both sentences were still true for a caller; they had merely stopped being checkable.docs/config-inference.mdmaps the document’sn_draws:onto thisn_samples, andconfig/sections/npe.pystates that this method takes it positionally – so the name and the position are both contract here, not only over there.The body is the far side’s, unchanged.
- class rheplicant.inference.npe.TrainingHistory(train, validation, best_step)[source]¶
Bases:
NamedTupleRe-exported rather than wrapped: it carries no refusals to translate, its three fields (train, validation, best_step) match the far side’s by name and order, and nothing pins its identity.
- rheplicant.inference.npe.train_posterior(posterior, thetas, data, *, key, n_steps=3000, batch_size=256, learning_rate=0.001, validation_fraction=0.1, beta1=0.9, beta2=0.999, eps=1e-08)[source]¶
Train
posterioron(thetas, data), returning the best step.Delegates to :func:`bayesmith.amortize.train_posterior` (D10). The three refusals it carries – a non-positive
n_steps, avalidation_fractionoutside[0, 1), and a fraction that rounds to zero held-out simulations – are the far side’s, re-raised here asStateValidationError. Their text is unchanged: each of the three is pinned upstream, and every pin was checked by running its pattern against the far side’s real message rather than by reading the two side by side.- Returns:
(posterior, history)– the estimator at its best validation step and aTrainingHistory. The returned estimator is whatever the far side built, so it is a plainbayesmith.amortize.NeuralPosteriorrather than the subclass above. That is invisible to every caller here: no test asserts the class, and the subclass adds no field and no method beyondcreate’s translation.- Parameters:
- Return type:
Note
Over-fitting an NPE makes it over-confident, which is the failure that does not look like one. Measured on this package’s own linear-Gaussian test problem, with a bank of 8192 simulations: at 1500 steps the fitted posterior width is 0.88 of the exact one; at 4000 steps with four mixture components it is 0.60. The training loss improves throughout. Nothing about the resulting density looks wrong – it is smooth, it integrates to one, and it is centred correctly. Holding out a split and returning the best step is what turns that into something visible, and it is the default for that reason.
Relatedly, prefer few components. A Gaussian posterior is exact at
n_components=1, and extra components mostly buy capacity to memorize the bank.
Uncertainty propagation through differentiable forward models.
Two complementary routes, both riding on the framework’s differentiability:
Linear (Fisher / delta-method) — the domain-standard forecasting tool.
fisher_information()buildsF = J^T N^-1 Jfrom the exact Jacobian offorward(jax.jacfwd— no finite differences), so parameter forecasts and error bars are one linear solve away (parameter_covariance()), andpropagate_covariance()pushes a parameter covariance to a per-sample prediction standard deviation (delta method). Exact for models linear in the parameters; a local approximation otherwise.F = J^T N^-1 Jis the LIKELIHOOD’s information and nothing else, which is a different quantity from the posterior precision the other exits target. Passspace=aParameterSpaceand the declared Gaussian priors’ curvature is added, so a forecast and a NUTS run over one declaration answer the same question; leave it out and the result sayskind="fisher", meaning exactly what it says.Monte Carlo pushforward —
push_forward()vmapsforwardover a stack of parameter samples (e.g. a NumPyro posterior viapredict_from_samples()), giving the full predictive distribution with no linearity assumption.
A Laplace approximation is the composition of the two: MAP-fit with
GradientCalibrator, take
parameter_covariance() at the fit, sample from the Gaussian, and
push_forward().
forward here is the f(params) -> prediction callable produced by
build_forward_fn() — uncertainty tooling
connects through the same seam as every other inference engine (D7).
- class rheplicant.inference.uncertainty.FlatMatrix(matrix, structure, kind='matrix', names=None, spans=None, shapes=None)[source]¶
Bases:
ModuleA matrix over a FLATTENED parameter vector, carrying its provenance.
ravel_pytreeordering depends on the parameter pytree’s structure, so a Fisher/covariance matrix is only meaningful together with the treedef it was flattened against. Carrying the structure letspropagate_covariance()reject a covariance built for a different parameterization instead of silently returning wrong numbers.When the parameters came from a
ParameterSpace— a flat{name: array}dict — the rows also carry their names, so error bars can be asked for by the name the model was declared in (cov.sigma("fwhm_deg")) rather than by position.- Parameters:
- structure¶
treedef of the parameter pytree it was computed for.
- Type:
jaxlib._jax.pytree.PyTreeDef
- kind¶
which quantity this is. Not decoration, twice over.
"fisher"and"posterior_precision"are precisions:sqrt(diag(.))is not an error bar, sosigma()refuses to pretend otherwise on either."covariance"and"posterior_covariance"are the two thingssigma()can report, and they are different quantities. The first is the Cramer-Rao bound — the width the DATA alone supports, with no prior in it. The second includes the declaredLatent(prior=...)curvature and is what a NUTS chain over the same space is a sample from. Comparing the two as if they were one quantity is the failure this field exists to make visible: under an informative prior they can differ by orders of magnitude, and both are finite, correctly shaped and plausible."matrix"is the default for anything constructed by hand.- Type:
- names, spans, shapes
per-parameter name,
(start, stop)span in the flat vector, and original shape.Nonefor unnamed pytrees.
- sigma(name)[source]¶
Marginal standard deviation(s) of one named parameter.
Only meaningful on a covariance: raises on either precision kind rather than returning
sqrt(diag(.)), which looks like an error bar and is not one — inverting is exactly the step that couples the parameters.What it reports depends on
kindand the caller has to know which: on a"covariance"it is the Cramer-Rao bound from the likelihood alone, on a"posterior_covariance"it is the posterior width including the declared priors.
- rheplicant.inference.uncertainty.as_noise_model(noise_std, flags=None, *, prediction_shape=None, caller='as_noise_model')[source]¶
Normalize a
noise_stdargument into aNoiseModel.A bare scalar or array becomes
HomoscedasticNoise; a noise model is passed through;flagswrap either inFlaggedNoise. This is what lets everynoise_std=argument in the package accept the seam without any of their signatures changing.The discrimination is by
depends_on_prediction, not bystd: jax and numpy arrays both have a.stdmethod, so the protocol’s data member is the only unambiguous marker.- Parameters:
noise_std (Any) – a scalar, an array, or a
NoiseModel.flags (Array | None) – optional boolean mask;
True= not observed.prediction_shape (Any | None) – the shape the model predicts, when the caller knows it. Supplying it turns on
check_noise_std_axis(), which refuses a 1-D sigma whose axis the prediction cannot settle. Optional because this function is also called from inside a NumPyro model body, before the prediction exists; omitting it is the old behaviour exactly.caller (str) – the exit to name if that check refuses.
- Return type:
- rheplicant.inference.uncertainty.fisher_information(forward, params, noise_std, flags=None, *, space=None)[source]¶
Fisher information at
params— likelihood-only unless given a space.With
space=None(the default, and what this function has always done) the matrix isF = J^T N^-1 J: the information the DATA carries, and nothing else. It is not a posterior precision, and its inverse is a Cramer-Rao bound rather than an error bar you could compare with a NUTS posterior run under informative priors. That distinction used to be invisible, and it mattered:Latent(prior=...)is the package’s one statement of what a latent is a priori and every other exit reads it —wiener_solve()solves with it asSand refuses a prior-free linear latent by name — while this function never saw theParameterSpaceat all. Tightening a declared prior by a factor of 5,000,000 moved the reported error bar by exactly zero.Pass
space=and the declared Gaussian priors’ own curvature is added at each latent’s span, giving the posterior precision atparams; the result is taggedkind="posterior_precision"and its inversekind="posterior_covariance", so which quantity was reported survives into the object rather than living in the caller’s memory.- Parameters:
params (Any) – where to evaluate.
noise_std (Any) – standard deviation — a scalar, or an array whose axes say which axis of the prediction it runs along (
(n, 1)/(1, n); seecheck_noise_std_axis()) — or aNoiseModel.flags (Array | None) – optional boolean mask; flagged samples carry zero weight, the same convention as
MaskedGaussianLikelihood.space (ParameterSpace | None) – the declaration
paramswas built from. Optional, and its absence is a real answer rather than a missing argument — the likelihood Fisher is the standard forecasting quantity. When given, every latent must declare a Gaussian prior: a prior-free one, or one with no quadratic form (a Uniform, a Half-Normal, a LogNormal), raisesParameterSpaceErrorby name rather than being approximated away. A space declaring ajoint_prioris refused outright: aJeffreysPrioris defined assqrt(det I), so it cannot be a term insideI.
- Returns:
A
FlatMatrix— the(n_params, n_params)matrix (.matrix) over the flattened parameter vector, tagged with the parameter structure it belongs to and withkindsaying which quantity it is.- Return type:
Note
When the noise depends on the parameters, ``J^T N^-1 J`` is not the Fisher matrix. For
d ~ N(mu(theta), Sigma(theta))the information has a second term from the covariance’s own parameter dependence:F = J^T Sigma^-1 J + 1/2 tr(Sigma^-1 dSigma Sigma^-1 dSigma)
which for a diagonal covariance is
2 (d log sigma/d theta)^T (d log sigma/d theta). It is included automatically whenever the noise model reportsdepends_on_prediction, and omitted otherwise (where it is exactly zero). UnderRadiometerNoisewith fractional levelfthe correction is a clean factor:F = (1 + 2 f^2) J^T N^-1 J. Reporting only the first term would forecast error bars that are too wide bysqrt(1 + 2 f^2)— a plausible number, and the wrong one.
- rheplicant.inference.uncertainty.parameter_covariance(fisher, jitter=0.0)[source]¶
Invert a Fisher matrix (or a posterior precision) into a covariance.
Inversion does not change which quantity is being inverted, so
kindis carried across rather than reset: a likelihood Fisher gives a"covariance"— the Cramer-Rao bound, what the data alone can do — and a"posterior_precision"gives a"posterior_covariance", whoseFlatMatrix.sigma()is a posterior width comparable with a NUTS chain run under the same declaration. The two are different numbers and used to come back under the same label.- Parameters:
fisher (FlatMatrix) – output of
fisher_information().jitter (float) – optional Tikhonov term added to the diagonal for near-degenerate parameter combinations (prior-like regularizer). Note what it is on a likelihood Fisher: an undeclared Gaussian prior of width
1/sqrt(jitter), chosen for numerical comfort rather than declared.fisher_information(..., space=...)is the same regularization with the prior written down.
- Raises:
StateValidationError – if the condition number exceeds what the values’ own dtype can carry, or if this is handed a covariance. Both refusals are decided across the seam and re-raised here in this package’s class; see the notes below.
- Return type:
Note
This inversion IS gated on conditioning, and the gate is the far side’s.
F = J^T N^-1 JSQUARES the design’s condition number, so an ordinary model reaches the arithmetic’s limit: measured atkappa(J) = 1e3, the float32 covariance is 2.4% wrong while the float64 one is wrong by 1.08e-12, and neither used to say so. The ceiling is1/sqrt(eps)read from the values’ own dtype (float32: 2.90e+03, float64: 6.71e+07) – the point where inverting has spent half the digits available – and it arrives with this function’s delegation rather than being written a second time here (D29).Note what the remedy is NOT: wrapping this call in
with jax.enable_x64(True):recovers nothing. The context does not widen an array traced outside it, and even forcing the upcast leaves the error at 2.45e-02 against 2.41e-02 for doing nothing, because the digits were spent formingF. The arithmetic has to be widened around building the model, which is what the refusal’s own message says.Note
Why the refusals are caught by class and not by ``translate``. This is the one delegation in the module that reaches no graph, so none of the three families
translateknows about can arise on this path – the far side’s only refusals here are its own two plainValueError``s. Catching ``ValueErrorand re-raising is therefore narrow rather than broad: it names the exit a caller actually called, and it keeps the exception class this module promises, which is the same reasonnumpyro_bridgerefuses ahead of a bareAssertionErrorfrom the far side.
- rheplicant.inference.uncertainty.propagate_covariance(forward, params, param_cov)[source]¶
Delta-method prediction uncertainty:
std = sqrt(diag(J Sigma J^T)).- Parameters:
params (Any) – expansion point (pytree, same structure as the covariance’s flattening).
param_cov (Array) – covariance over the flattened parameter vector — a
FlatMatrixfromparameter_covariance()(structure is verified againstparams), or a raw(n_params, n_params)array (external covariances; only the size can be checked — YOU must guarantee the flattening order matches).
- Returns:
Per-sample prediction standard deviation, shaped like the prediction.
- Raises:
StateValidationError – for a covariance whose provenance does not match
params(three checks, all of them ahead of the seam because the graph would erase what they are about), or for a PRECISION – the same tableFlatMatrix.sigma()refuses on, and the same remedy. A Fisher matrix and a covariance are the same shape, so putting one where the other belongs returns an error bar wrong by the square of everything and says nothing.- Return type:
Note
The graph this builds synthesises a noise model and data, and neither can reach the answer. The delta method reads the Jacobian of the prediction and the covariance it was handed; it does not read the residual, and it does not weight anything. So a homoscedastic sigma of 1.0 and zeros of the prediction’s shape are enough to give the far side a graph to differentiate – the same argument D22 makes for the rank test, and, like that one, MEASURED rather than asserted:
TestTheSynthesisedGraphCannotReachTheAnswerbuilds it three times over different synthetic sigmas and data and compares the reports bitwise.
- rheplicant.inference.uncertainty.push_forward(forward, param_samples)[source]¶
Monte Carlo pushforward: run
forwardover stacked parameter samples.- Parameters:
- Returns:
(n_samples, *prediction.shape)stacked predictions — summarize with e.g.mean(0)/std(0)/ quantiles.- Return type:
Prior sensitivity: how far the declared prior moved the answer.
Every exit in this package reads Latent(prior=...). None of them says what
the prior did. That is a different question from “is the posterior right”,
and it is the one a referee asks: the mode you report sits somewhere between
where the data put it and where the prior wanted it, and the only honest way to
quote it is with the distance stated.
A chain cannot answer this about itself. On the tour’s nonlinear pair the
declared fg_beta ~ Normal(2.3, 0.3) moves the mode by 0.0069 sigma. The
Monte Carlo standard error of a posterior mean from n_eff draws is
1/sqrt(n_eff) sigma, so seeing 0.0069 sigma at all needs n_eff of order
2 x 10^4, and measuring it needs a second chain with the prior removed to
difference against — two chains whose noise adds in quadrature. A chain that
reaches n_eff = 500, which is a healthy 4 x 1000 NUTS fit, carries an MCSE
of 0.045 sigma: six times the effect it would be looking for. Running longer is
not a small ask, it is a 10^4-fold one, and it answers a question two Newton
solves answer exactly.
Two routes, and they are both deterministic:
The closed form. Expand the log-posterior about the mode theta_hat. The
mode the likelihood ALONE would choose is displaced from it by
with H the LIKELIHOOD’s curvature at the mode and (m, s) the declared
prior’s location and scale. Not the posterior’s H + P, which is the matrix
already in hand for sigma_post: putting that here is wrong by
diag((H + P)^-1 P), the prior’s share of the posterior precision, and that
is 6.9e-5 at the tour’s declared beta but 5.9e-2 by s = 0.01 — invisible
where the prior is weak and unbounded as it tightens. Per latent, in units of
that latent’s own posterior width, the weak-prior form of the diagonal is
sigma_post * |m - theta_hat| / s^2 — the law
PriorSensitivityReport.criterion_std inverts — plus a cross term from
every OTHER latent’s prior. The cross term is not decoration. On the tour’s two
latents, measured:
latent |
diagonal |
cross |
total |
|---|---|---|---|
fg_log_amp |
-2.66e-04 |
+2.74e-03 |
+2.47e-03 |
fg_beta |
-7.03e-03 |
+1.04e-04 |
-6.92e-03 |
For fg_log_amp the cross term is ten times the latent’s own pull and of
the opposite sign: a per-latent scalar formula would report that this prior
pushes the amplitude down, when what happens is that beta’s prior drags it up.
The same reversal is what makes PriorSensitivityReport.shift_at() come
back positive for beta at s = 3.0, where beta’s own prior has gone
loose enough for the amplitude’s to take over.
The refit. Newton to the mode with the priors on, Newton again with them
off, difference the two. No expansion, no linearisation — the answer the closed
form is approximating. Measured against each other on the tour: 9.6e-6
(log-amp) and 2.1e-6 (beta) relative at the declared priors, which is the
model’s nonlinearity over the displacement and nothing else, the derivation
being exact on a quadratic. PriorSensitivityReport.shift_at()’s
counterfactual s-ladder is exact on one too, and is the same identity written
about the likelihood mode; what it cannot escape is the same nonlinearity,
which costs it 3.2e-4 by s = 0.025 and 1.8e-3 at s = 0.01, where the
shift has reached six sigma. Which is why both routes are reported and
PriorSensitivityReport.verified says whether they agreed.
Which of the two is the approximate one depends on the model, which is the
reason for shipping both rather than picking. On a latent the prediction is
affine in — a noise-wave temperature, say — the log-posterior is exactly
quadratic and the closed form is exact, while the refit has to recover a
5e-4 K displacement by differencing two modes of about 290 K each and loses
some six digits to cancellation before its linear solves’ own roundoff. The
measured disagreement there is 2.4e-10, and it is the refit’s floor, not the
closed form’s error: 290 * eps / 5e-4 predicts 1.3e-10. On the tour’s
nonlinear pair the roles reverse.
The number to act on is PriorSensitivityReport.criterion_std: the
prior width at which this latent’s shift would reach
CRITERION_SHIFT. Inverting the diagonal law gives
sqrt(sigma_post * |m - theta_hat| / 0.1), and for the tour’s beta that is
0.0795 against a declared 0.3 — a factor of 3.8 of margin, which is the
statement “the prior is not driving this fit” with a number attached.
Three things this does NOT do, stated so they are not assumed.
It is local. Both routes expand about one mode; a multimodal posterior has more than one, and the prior’s job there may be to select between them, which is not a displacement and is not measured here.
It reads the declared prior only. A linear=True latent whose prior
arrives as wiener_solve()’s prior_std=
keyword is refused by name rather than reported as prior-free — see
prior_sensitivity()’s Raises.
It needs the likelihood’s mode to exist. Along a direction the data cannot see there is no such mode, only a ray, and the displacement from a ray is not a number — so a selection whose likelihood-only mode is not there is refused.
The verdict is taken on the REST TERM’s own curvature, not on the observed
Jacobian’s rank (ledger D23), and this paragraph said otherwise until
2026-08-28. The rank test was this module’s own, before the arithmetic moved
to bayesmith.diagnose.sensitivity; the criterion that has actually been
running since is the curvature’s, with a condition-number ceiling of
1/sqrt(eps) read from the dtype. The two disagree in both directions:
the curvature accepts a selection held only by a DOWNSTREAM density (
child ~ Normal(parent, s)), which a rank test refuses — a legitimate question. That shape is not declarable here, because aLatent’s prior is built at declaration time out of concrete arrays;the curvature REFUSES a near-collinear design whose observed Jacobian is full rank, which a rank test accepts. Measured on a two-parameter model in float64: at a column separation of 1e-3 the rank is 2 of 2 and the shift is refused. That direction is reachable by anyone, and
tests/inference/test_d23_refusal_criterion.pyis what pins it.
When the refusal fires, the NAMING is still delegated to
identifiability() wherever its
verdict agrees — it is the tool that knows how to say which latents a
degeneracy mixes. Where the two disagree, the message reports the curvature’s
measured spectrum instead of borrowing a rank verdict that does not hold.
- rheplicant.inference.sensitivity.CRITERION_SHIFT: float = 0.1¶
The shift
PriorSensitivityReport.criterion_stdsolves for, in posterior sigmas.Chosen against what a chain can see rather than against a convention. The MCSE of a posterior mean from
n_effeffective draws is1/sqrt(n_eff)sigma; a well-run 4 x 1000 NUTS fit lands atn_effof a few hundred, so its own noise is 0.04-0.06 sigma. A shift of 0.1 sigma is therefore the smallest bias such a run could distinguish from its own scatter at about 2 sigma of separation — the point below which “is the prior moving this?” stops being answerable by sampling and starts needing this function.It is also small against the thing being biased: 0.1 sigma moves a 68% interval’s endpoints by 10% of the interval’s half-width, which shifts the reported central value without visibly changing the error bar. That is the regime where a bias is easiest to publish by accident.
- rheplicant.inference.sensitivity.MAX_BACKTRACKS: int = 40¶
Halvings of a Newton step allowed before the line search gives up.
2^-40 is 9e-13 of the full step: past that the step is doing nothing and the problem is the direction, not its length.
- rheplicant.inference.sensitivity.MAX_NEWTON_STEPS: int = 100¶
Newton steps allowed before the solve is called failed.
The tour’s MAP takes 7 from the declared init and its likelihood-only refit 3 from the MAP, so this is 14x the measured need. It is a ceiling on a loop that either converges quadratically or is not going to. Re-exported from bayesmith, which owns the Newton solve this bounds. Not a second copy: patching THIS name no longer changes anything, which is why the two tests that used to do so now patch bayesmith’s.
- rheplicant.inference.sensitivity.NEWTON_TOL: float = 1e-13¶
max(|dx| / (1 + |x|)) < NEWTON_TOL.Mixed relative/absolute so a latent at 1e-8 and one at 1e8 are held to the same standard. 1e-13 is two decades above float64 eps, which is where a quadratically-converging step size stops shrinking and starts jittering.
- Type:
Convergence test
- rheplicant.inference.sensitivity.VERIFY_ATOL: float = 1e-06¶
Absolute floor under
VERIFY_RTOL, in posterior sigmas.A relative comparison of two numbers that are both 1e-9 sigma reports the Newton solver’s own convergence floor and nothing else. One millionth of a sigma is four decades below
CRITERION_SHIFTand below any MCSE a chain could reach, so a disagreement smaller than this is not a disagreement about anything.
- rheplicant.inference.sensitivity.VERIFY_RTOL: float = 0.003¶
Relative tolerance at which the closed form and the refit are called agreed.
What it has to cover is the model’s NONLINEARITY over the displacement, not an error in the derivation: on an affine model the two routes agree to 2.4e-10, which is the refit’s own cancellation floor. Measured on the tour’s nonlinear pair, whose mode the priors move by 0.007 sigma: 9.6e-6 (log-amp) and 2.1e-6 (beta). 3e-3 sits two and a half decades above that, deliberately — a posterior that a prior moves by a whole sigma curves over that distance far more than one moved by 0.007 — and a factor of 3 below the 1e-2 scale at which a “0.1 sigma or not” verdict could flip. Pinned in
tests/inference/test_prior_sensitivity.py.
- class rheplicant.inference.sensitivity.PriorSensitivityReport(names, shapes, spans, n_par, mode, prior_loc, prior_std, mean_offset, sigma_post, shift_sigma, shift_sigma_refit, verified, criterion_std, precision, newton_steps, refit_steps, refit_converged)[source]¶
Bases:
objectWhat the declared priors did to the mode, per latent, in posterior sigmas.
A plain frozen dataclass holding numpy, for the same reasons
IdentifiabilityReportis one: this is a derived verdict rather than a differentiable model, its float64 contents would silently truncate the moment a default-precision JAX caller touched them, andverifiedis a decision, which is not something a traced program can branch on.Every array is flat over the SELECTED latents, in the order they were asked for —
namesandspansare the only coordinate system in the object. That order is the declaration order, not sorted order, and the difference is live:fisher_information()flattens its dict in sorted key order, so on a space declaring("fg_log_amp", "fg_beta")its rows come back the other way round. Borrowing a row from there by position would hand back one latent’s width under another’s name, with every shape agreeing.- Parameters:
- mode¶
theta_hat, the MAP found by Newton on the exact log-posterior.- Type:
- prior_loc¶
the declared
m, broadcast per element.- Type:
- prior_std¶
the declared
s, broadcast per element.- Type:
- mean_offset¶
|m - theta_hat|. A magnitude — the direction of the pull lives inshift_sigma’s sign, where a reader will look for it.- Type:
- sigma_post¶
sqrt(diag(Sigma))at the mode, from the exact Hessian of the negative log-posterior. Notfisher_information()’s expected information: the two differ by 3.3e-4 relative in the tour’s beta block, and the observed curvature is the one the Newton refit walks on, so it is the one the two routes have to share if their agreement is to mean anything.- Type:
- shift_sigma¶
the closed form, signed, in units of
sigma_post. Negative means the prior pulled the latent DOWN.- Type:
- shift_sigma_refit¶
the same displacement from an actual second Newton solve with the priors removed. All-NaN if that solve did not converge, in which case
refit_convergedisFalse.- Type:
- verified¶
per element, whether the two routes agreed to
VERIFY_RTOL(withVERIFY_ATOLunderneath).- Type:
- criterion_std¶
the prior width at which this latent’s shift would reach
CRITERION_SHIFT, from the diagonal lawsqrt(sigma_post * mean_offset / 0.1). Compare it withprior_std: a declared width comfortably ABOVE it is the statement that the prior is not driving the fit.0.0when the prior mean sits exactly on the mode, which means no tightening of it moves anything.- Type:
- precision¶
the
(n_par, n_par)posterior precision at the mode.- Type:
- newton_steps, refit_steps
what the two solves cost.
- for_latent(name)[source]¶
Every per-element quantity for one latent, reshaped like the latent.
The form a caller acts in:
report.for_latent("fg_beta")["shift_sigma"]is a number about beta, not an offset into a vector whose layout the caller has to have got right.
- property worst: tuple[str, int, float]¶
(latent, index within it, signed shift), by largest magnitude.What to print first. An anonymous argmax over the flat vector would name a position in a layout the caller did not choose.
- shift_at(name, prior_std)[source]¶
The shift this latent would suffer under a DIFFERENT prior width.
The counterfactual
criterion_stdinverts, evaluated exactly rather than through the diagonal law: onlyname’s entries ofPare replaced, and(H + P_s)^-1 P_s (I + H^-1 P_d)(m - theta_hat)is solved whole. Every other latent’s prior stays as declared, cross terms included — which is why this can return a shift of the opposite sign toname’s own pull oncename’s prior is loose enough for a neighbour’s to dominate.Anchoring. That is not the expression
shift_sigmauses, and the comment on the solve below derives why: the two are the same displacement written about different modes, and a counterfactual can only stand on the likelihood’s. The(I + H^-1 P_d)factor is how it gets there without a second fit, and it makes the whole thing exact on a quadratic. Two consequences worth stating. AtP_s = P_dit collapses toshift_sigmaalgebraically, soshift_at(name, declared_width)returns the reported shift to the last bit — if it did not, one of the two would be wrong. And what is left at tight widths is the model, not the method: against an actual re-run at the hypothesised width, 1.6e-5 ats = 0.1and 1.8e-3 ats = 0.01, the latter being the tour’s nonlinearity over six sigma of travel.Reported in the sigma the caller actually HAS —
sigma_post, at the declared priors — and not in the sigma the counterfactual prior would produce. Dividing each row of a ladder by its own width would fold the prior’s shrinking of the error bar into a number meant to report only the movement of the mode, and ‘a 0.1 sigma shift’ would then mean a different displacement in every row.- Parameters:
- Returns:
The signed shift in posterior sigmas, shaped like the latent.
- Raises:
StateValidationError – if
nameis not in this report, or the width is not positive and finite.- Return type:
- rheplicant.inference.sensitivity.prior_sensitivity(space, pipeline, state_template, observed, noise_std, flags=None, *, names=None, at=None)[source]¶
How far the declared priors moved the mode, in posterior sigmas.
See the module docstring for why a NUTS run cannot be asked this and for the two routes taken instead. Both are deterministic; nothing here samples.
The work is two Newton solves — one on the exact log-posterior, one on the likelihood alone — plus one dense Jacobian for the rank check. That is a design-time cost for tens to a few thousand parameters, the same envelope
identifiability()states, and for the same reason: a dense SVD is taken.- Parameters:
space (ParameterSpace) – the parameter declaration. Every SELECTED latent must declare a Gaussian
Latent(prior=...).pipeline (AbstractOperator) – the forward model.
state_template (State) – the state it is evaluated on.
observed (Array) – the data. Must match the prediction’s shape exactly.
noise_std (Any) – a scalar, an array, or a
NoiseModel— the same seam every other exit takes.flags (Array | None) – optional boolean mask;
True= not observed.names (Sequence[str] | None) – which latents to analyse — a sequence, or a bare string for one.
Nonemeans all of them, in declaration order. A subset asks the CONDITIONAL question, holding the rest atat: the tour’s beta has a marginal width of 2.499e-3 and a width of 2.302e-3 once the foreground amplitude is pinned, and those are different sigmas to report a shift in.at (dict[str, Array] | None) – values for the latents NOT selected, and the Newton starting point for those that are. Defaults to the space’s declared initial values.
- Returns:
A
PriorSensitivityReport. The two numbers to read first areshift_sigmaandcriterion_std:report = prior_sensitivity(space, model, state, data, sigma) name, index, shift = report.worst criterion = report.for_latent(name)["criterion_std"].ravel()[index] print(f"{name}[{index}] moved {shift:+.4f} sigma by its prior; " f"0.1 sigma would need s = {criterion:.3g}")
- Raises:
ParameterSpaceError – if
names/atname an undeclared latent; ifobserveddoes not match the prediction’s shape; if a selected latent is complex or non-floating; if a selected latent declares no prior, declares one with no quadratic form (a Uniform, a LogNormal, a Half-Normal), or islinear=Truewith its prior living in aprior_std=call-site argument this function cannot see; or if the selection’s Jacobian at the mode is rank-deficient.StateValidationError – if the Newton solve for the mode does not converge.
- Return type:
Note
Runs with
jax_enable_x64forced on and restores the caller’s setting afterwards, including on the way out of an exception. The setting is process-global, so this is not thread-safe against other JAX work.
Joint priors — a prior over a BLOCK of latents, declared on the space.
Latent(prior=...) says what one quantity is a priori and is the only prior
declaration the rest of the package needs. A Jeffreys prior is not of that
shape: it is a single density over several latents at once, it is a function of
the forward model and the noise rather than of the latent alone, and it moves
when the model does. So it is declared once, on the
ParameterSpace, where D14’s rule
applies — the declaration is what every exit reads:
ParameterSpace(
latents=[...],
bindings=[...],
joint_prior=JeffreysPrior(over=("fg_log_amp", "fg_beta")),
)
Default None. Nothing here is on unless it is asked for.
- class rheplicant.inference.priors.JeffreysPrior(over, rank_rtol=None)[source]¶
Bases:
Modulep(theta) = sqrt(det I(theta))over a named block, conditional on the rest.Read this first: under the package’s default noise model this prior is the flat prior. Under
RadiometerNoise, for a bare power lawmu = A (nu/nu0)^-betaover(log A, beta), the half-log-determinant measured on the 8x8 fixture oftests/inference/test_jeffreys_prior.pyis +15.80169853 at every one of the nine grid pointslog A in {6.8, 7.8, 8.8} x beta in {2.05, 2.55, 3.05}— identical to the last printed digit — and its gradient there is(0.000000e+00, -1.387779e-17). Switching it on for that model is the same thing as deleting theprior=keyword, at the cost of a Jacobian per leapfrog step. The algebra says why, and says it exactly rather than approximately:sigma = |mu| fgivesN^-1 = 1 / (mu^2 f^2)whileJ_{k,i} = mu_k g_i(nu_k), so everymucancels andI_ij = (1 + 2 f^2) / f^2 * sum_k g_i(nu_k) g_j(nu_k)— a constant matrix in(log A, beta).Under
HomoscedasticNoisethe same block givesp(log A) proportional to A^2: the half-log-determinant is exactly linear inlog Awith slope +2.000000 measured over six decades,log A in [-3, +3]. That is improper upward — the density grows without bound as the amplitude does — so it is a prior that needs the likelihood to be doing the work, and it is not a neutral choice.Those two sentences are the same prior under two noise models, which is the thing to take from them: the noise model chooses the prior’s shape. On the same power law with a fixed 300 K floor added, where the radiometer variance no longer factorises,
d(half-logdet)/d betais-1.366854e-02underRadiometerNoiseand+8.052944e-03underHomoscedasticNoise— opposite signs, on one model, from the noise declaration alone. This is also why the prior carries no noise model of its own: it is evaluated with the noise the exit was given, so a likelihood/prior noise mismatch is not something this API can express.- over¶
the latents the prior is over — a name, or a sequence of them. Mandatory and explicit. It is the CONDITIONAL Jeffreys prior of that block, with every other latent held at whatever value the exit currently has, and the conditional and the full-space priors are different priors. They are not interchangeable and the full-space one often does not exist: on the tour’s own 34-latent space
identifiability()reports rank 32 of 34, sodet Iis zero over the full space andsqrt(det I)is not a density at all. Naming the block is therefore not boilerplate — it is the part of the declaration that decides whether the object exists.
- rank_rtol¶
the relative cut separating a null eigenvalue from a small one.
None(the default) meansDEFAULT_RANK_RTOL, read from the one place in this package where that number is justified against a measured spectrum.- Type:
float | None
Why the determinant comes from
eighand not from the two obvious routes. On the exactly degenerate block oftests/inference/test_jeffreys_prior.py— a 3-latent space whose amplitude isexp(a + b), soaandbare the same parameter twice — measured:route
what it returns
jnp.linalg.slogdetsign
+1.0, half-logdet+6.420496jnp.linalg.choleskyall-finite, half-logdet
+6.566517, smallest pivot9.755e-05(positive, so it did not fail)eigvalsh+ rank floor-338.05— effectively zero densityBoth of the first two came back with a plausible, finite, positive-definite answer for a matrix that is singular by construction, because the null eigenvalue lands at
-2.117e-09against a largest of1.281e+08and the sign of that roundoff is a coin flip. A determinant that came back finite is not a guard. So the eigenvalues are taken explicitly, everything at or belowrank_rtol * maxis floored to the smallest positive number the dtype has, and the result is the-338that says what is true.That floor is the arithmetic; the refusal is
check_identified(), which every exit reading this declaration calls once at build time and which delegates its verdict toidentifiability()— an SVD of the column-normalised Jacobian, which does not square the condition number the wayJ^T N^-1 Jdoes, and which already knows how to name a degeneracy as a combination of latents.Which exits read it.
to_numpyro_model()evaluates it: each covered latent gets an improper flat site and the half-log-determinant is added at the"joint_prior"factor site, with the same noise object the likelihood uses.fisher_information()refusesspace=outright while one is declared — a Jeffreys prior defined assqrt(det I)cannot be added toI.SamplingPlanrefuses a block partition that splitsoveracross two blocks. That last one is a check on the declaration and not an evaluation: a plan’s engines build their conditional potentials fromLatent(prior=...), so the NUTS route throughto_numpyro_modelis the route that applies this prior today.- validate_against(declared, with_prior)[source]¶
Check
overagainst a space’s latents. Raises, or returnsNone.Called by
__check_init__()so the wording lives with the class that owns the concept.
- check_identified(space, pipeline, state_template, *, at=None, caller='This JeffreysPrior')[source]¶
Refuse a block whose information matrix is rank-deficient.
sqrt(det I)withdet I = 0is not a density: it is zero everywhere the rank is deficient, which is everywhere, so there is no prior to normalise and nothing for a sampler to explore. The verdict is delegated toidentifiability(), which takes the rank of the column-normalised Jacobian rather than ofJ^T N^-1 J— the Jacobian’s own condition number, not its square — and which reports the degenerate direction as a share of each latent.- Parameters:
space (Any) – the declaration (this prior’s own space).
pipeline (Any) – the forward model.
state_template (Any) – the state it is evaluated on.
at (dict[str, Array] | None) – where to ask. Identifiability is a LOCAL property of a nonlinear model;
Nonemeans the space’s declared initial values, which is where a build-time check can ask.caller (str) – what to name in the message.
- Returns:
The
IdentifiabilityReport, when the block is identified.- Raises:
ParameterSpaceError – naming the nullity and the latents each null direction mixes.
- Return type:
- information(forward, values, noise_std, flags=None)[source]¶
The CONDITIONAL information matrix over
over, atvalues.Every latent not in
overis held at its entry invalues— that is what makes this the conditional prior and not the full-space one.space=Noneis passed tofisher_information()and is load-bearing: withspace=that function returns the POSTERIOR precision, likelihood Fisher plus declared prior curvature, and a Jeffreys prior built from it would sit inside its own definition.fisher_information()refuses the combination outright for the same reason.The second Fisher term —
2 (d log sigma/d theta)^T (d log sigma/d theta), the information the variance carries when sigma tracks the prediction — comes along automatically, because that function already adds it whenever the noise model reportsdepends_on_prediction. It is not decoration: it is half of why the radiometer prior for a bare power law is exactly flat, the(1 + 2 f^2)inI_ij = (1 + 2 f^2) / f^2 sum_k g_i g_j.Row order is ``sorted(over)``, not ``over``.
fisher_information()flattens by sorted key, soover=("b", "a")andover=("a", "b")return the IDENTICAL matrix. The determinant does not care — a symmetric permutation leaves it alone, which is why the prior itself is unaffected — but a caller reading row 0 as “the first name I passed” is wrong by 7.4e+1 on the tour’s own block.- Parameters:
- Return type:
- log_density(forward, values, noise_std, flags=None)[source]¶
0.5 * log det Iover the block — the log prior, up to a constant.Jit-safe and differentiable, which is the whole requirement: NUTS differentiates it at every leapfrog step. It therefore cannot refuse a rank-deficient block, because a rank is a decision and a traced decision is one you cannot branch on —
check_identified()is that refusal and the exits call it once, before sampling. What this returns on a degenerate block is the floored value,-338.05on the measured fixture: an honest zero rather than the plausible+6.42slogdetgives.Arguments are
information()’s.
- half_log_determinant(matrix)[source]¶
0.5 * log detby eigendecomposition, with the rank floor applied.Separate from
log_density()so a caller holding an information matrix already — or a test pinning this againstslogdetandcholeskyon the same array — can reach the arithmetic without re-differentiating the model.
Sampling plans: one declared partition, two exits — a point estimate and a draw.
Everything under rheplicant.inference up to here builds one block’s
answer. wiener_solve() is a linear-Gaussian
block’s posterior mean and gcr_sample() is an
exact draw from the same conditional, sharing one private solve that differs by
a single argument. This module promotes that: a
SamplingPlan says how the whole space is partitioned into blocks, and
then
plan = SamplingPlan(
space,
Block("t_nw", "t_ant"), # one conjugate solve over both
Block("gain"), # another
Block("beam_fwhm", steps=20), # not linear -> gradient engine
)
est = plan.estimate(twin, state, observed, noise=noise)
draws = plan.sample(twin, state, observed, noise=noise, key=k, n_sweeps=200)
Two methods, not a mode flag. key=None | k is the right implementation and
the wrong interface: a caller’s intent is “give me the best fit” or “give me
draws”, not “here is a PRNG key”. Making them two methods also makes the invalid
combinations unrepresentable rather than validated — key is required on
sample() and absent from estimate(), so
“asked for samples and forgot the key” cannot be written down; n_sweeps and
warmup belong to one and max_iter and tol to the other because they
mean nothing to the other. And the layer below already names the two exits
differently, so two methods continue an idiom rather than adding a third.
The engine is derived, never restated. Latent(..., linear=True) already
says which exit a latent takes, so Block("t_nw", "t_ant") needs no
engine=: a block whose members are all declared linear is solved by the
conjugate machinery, anything else is stepped by gradient. An explicit
engine= is an override for the one case that is genuinely ambiguous — a
block mixing declared-linear and non-linear latents, which is an error unless
the caller says to downgrade the whole block to gradient.
The partition is checked, and the check is the point. Every latent of the space in exactly one block: a latent the plan forgets would sit at its initial value while every other number in the run looked healthy, and a latent in two blocks would be updated twice per sweep against a conditional that no longer holds. Both are refused by name.
What this exists to prevent. A hand-rolled alternating solve over a bilinear
gain x T_ant model, with a free antenna temperature per (time, frequency)
cell, lands thousands of kelvin from the truth while every guard this package
ships reports green — CG residual ~1e-7, per-block condition number ~1.47,
check_linearity passing at every sweep because each conditional genuinely
is affine. Nothing in the sweep is wrong. The partition is, and no
per-block number is entitled to notice: a residual and a condition number are
both computed from the block being solved.
“Thousands” rather than a number, because the distance is the initial offset
carried along the null direction, not a property of the model: 27 K from a
1 %-off start, 2962 K from a 100 %-off start, and the guards read alike in
both (tests/inference/test_degenerate_partition.py). Iterating does not
help either — the answer at five sweeps and at two hundred agrees to four
figures, because the solve reaches the solution manifold at once and then has
nowhere left to move.
Two things here can notice, and both are on by default.
identifiability() sees across
blocks and refuses the model before a sweep runs, naming the degenerate
directions by latent; and the convergence monitor is the joint chi-squared
at the current parameter tuple across sweeps, never a per-block residual — which
is precisely the number that read ~1e-7 on an answer thousands of kelvin wrong.
The identifiability check costs a dense Jacobian and a dense SVD, n_data x
n_par float64 words, so check_identifiability= is the caller’s explicit
choice and not a size heuristic. "once" (the default) checks before the
first sweep; "each_sweep" checks at every parameter tuple the run visits,
which for a small model is cheap and strictly more informative — a nonlinear
model’s identifiability is a property of where you are, so a check only at the
start misses a degeneracy that opens up near the parameters you actually reach.
False skips it, which is also how a complex latent (which the rank test
cannot analyse) or a 10^6-coefficient sky block (which it cannot afford) gets
through. Switching between these by size would be the “guess instead of refuse”
this package rejects.
Both exits check it, and the point estimate is the more dangerous one. The
instinct is that only sampling needs an identifiability guard. It is backwards:
a chain at least has r_hat to scream with — 1.824 for a non-identified gain
against 1.002 with an identifying tone — while a point estimate has no
diagnostic at all and CG converges quietly onto an arbitrary point of the null
space.
Exactness, stated rather than hidden. A linear-Gaussian block’s GCR draw is
an exact conditional draw, so Gibbs over conjugate blocks is an exact sampler.
The moment one block takes a finite number of NUTS steps the scheme becomes
Metropolis-within-Gibbs: still valid, still targeting the right stationary
distribution, and no longer exact — the inner step count now affects mixing.
Block(..., steps=20) looks like a performance knob and is a statistical
assumption. See gradient_draw().
Relationship to iterative_gls.
iterative_gls() is a fixed-point loop over a
prediction-dependent noise model for ONE block; a plan is a loop over blocks.
They are not nested here, and deliberately: a plan re-evaluates sigma at the
current joint prediction before every block update, so for a
RadiometerNoise the sweep IS the
reweighting iteration. Nesting iterative_gls inside a block would run the
same fixed point twice, one inside the other, at the product of their costs.
The consequence to know is statistical rather than numerical: freezing sigma
inside a draw makes it an exact draw from a linear-Gaussian conditional at that
covariance, which is not the full model’s conditional when sigma depends on the
prediction — the same GLS-versus-full-likelihood difference
rheplicant.inference.noise gives in closed form.
PlanDiagnostics.noise_depends_on_prediction records whether that
applied.
The gradient blocks used to omit the same term, by a different route, and
that half went unsaid for longer. A conjugate block freezes sigma to keep
its step linear; a gradient block does not freeze it – sigma is re-evaluated
at the current prediction inside
conditional_potential() – but that
potential was 0.5 * chi2 - log_prior, and chi2 carries no
sum log sigma. So when sigma depended on the prediction, a gradient block
targeted the density divided by prod sigma(theta): the GLS-flavoured
posterior again, not the full one. The contrast that made it visible is
to_numpyro_model(), whose
observation site is a Normal whose log_prob carries -log scale
automatically – so the nuts exit sampled the full density while a
gradient block sampled the GLS-flavoured one, from the same declared model.
It is the same distinction
BayesMemory refuses to mix under its
estimator field, which is why it was written down here rather than left to
be discovered.
Closed 2026-08-28 (migration ledger B1).
neg_log_likelihood() is now
0.5 * chi2 + log_determinant, and both potential builders take it – the
single-argument one the optimiser gets and the lifted one NUTS gets, since
fixing one alone would have rebuilt the same two-targets defect a layer down.
chi2 itself is deliberately unchanged: it is the convergence monitor, and
a monitor that changed units the moment a noise model started reading its
argument would be worse than the omission it replaced.
The gradient block moved from 6.248269 to 5.004059 on the fixture
below, against an unbiased closed form of 5.104641 – so onto the unbiased
side, 2.0% from the closed form where it had been 22% away. That remaining
2.0% is the prior and not a residue of B1: the fixture declares w with
mu = exp(w) x, so a Normal prior on w is a 1/scale prior on
the recovered scale. tests/inference/test_potential_carries_the_logdet.py
asserts the identity rather than the landing place for exactly that reason,
and compares the two routes across the seam named above.
One declaration cannot be honoured by a plan and is refused instead of
overridden: inference.noise.include_logdet: false selects generalized
least squares, which is a point estimator and is not a posterior, so
plan.estimate and plan.sample decline it by name
(config/sections/exits.py::_a49_is_not_honourable_by_a_plan). Threading it
down would have made one word mean two things at the two exits, which is the
defect B1 IS.
It is the BLOCK TYPE that decides, not the exit – worth stating plainly,
because this paragraph and the migration spec both first described it as
something plan.sample does. Measured across both packages on
mu = w x with sigma = 0.5 |mu|, n=40, prior N(0, 100), where the
two closed forms are 5.104641 (log-determinant kept, the unbiased one)
and 6.258841 (dropped, GLS-flavoured):
exit |
lands at |
which side |
|---|---|---|
|
5.104558 |
unbiased |
|
6.248269 |
GLS-flavoured |
the same, after B1 closed |
5.004059 |
unbiased |
So estimate showed it too, and a conjugate block never did: freezing sigma
per inner solve puts its fixed point on the unbiased side, which is the same
argument iterative_gls() makes for itself.
The ratio was 1.2261 against (1 + f^2) = 1.25 at f = 0.5, the gap
being finite-sample scatter at n=40. The difference is O(f^2) with
f = 1/sqrt(delta_nu tau) and was small in most regimes – but it was
attached to which ENGINE ran, and reading it as a property of one exit is
what left the estimate path unexamined for as long as it was.
The first two rows are kept at their measured values rather than dropped. The conjugate row still holds; the gradient row is what the defect looked like, and a fix whose record does not say what it changed cannot be checked.
The numbers are bayesmith’s cross-check
(tests/crosscheck/test_dispatch.py, recorded in
docs/migration/plan.md), where the port’s own conjugate estimate agrees
with the first row to 9e-12 and its nonlinear path declines to give a
point estimate at all.
- rheplicant.inference.plan.CHECK_EACH_SWEEP: str = 'each_sweep'¶
check_identifiability="each_sweep"— at every parameter tuple visited.
- rheplicant.inference.plan.CHECK_ONCE: str = 'once'¶
check_identifiability="once"— the rank test runs at the starting values.
- rheplicant.inference.plan.DEFAULT_CHI2_TOL: float = 1e-08¶
Relative PROGRESS in the JOINT chi-squared below which a point estimate has converged —
chi2[k-1] - chi2[k], relative tomax(|chi2|, 1).A decrease rather than a change, and that is the whole design of the test. Block-coordinate descent cannot increase the objective, so once a sweep stops reducing it there is nothing left to reach. Testing
|chi2[k] - chi2[k-1]|instead walks into the trapiterative_gls()documents for its ownreweight_tol: consecutive sweeps differ by roughly the inner solver’s own noise whatever the outer iteration is doing, so a threshold below that floor measures CG and never passes. Measured on the motivating model in float32: the plateau sits at chi2 = 2.7e-3 and jitters by 1.2e-3 a sweep, so a converged run would have been refused for 300 sweeps and counting.
- rheplicant.inference.plan.DEFAULT_MAX_ITER: int = 100¶
Sweep cap for
SamplingPlan.estimate().
- rheplicant.inference.plan.DEFAULT_RHAT_MAX: float = 1.05¶
Split-
r_hatabove which a run’s draws are reported unmixed. 1.05 rather than the modern 1.01 because this isr_hatof a single scalar summary of a single chain, where 1.01 is noise-dominated at the draw counts a Gibbs sweep over an expensive forward model can afford.
- rheplicant.inference.plan.MIN_DRAWS: int = 4¶
two halves of two. Below this the diagnostic is not weak, it is undefined.
- Type:
Fewest post-warmup draws a split-
r_hatcan be computed from at all
- rheplicant.inference.plan.MIN_SWEEPS: int = 3¶
Sweeps taken before the convergence test is consulted at all. The first steps of a coordinate descent can be nearly stationary without being near the minimum — the same reason
MIN_REWEIGHTSexists, at a LOWER count because a sweep here is several block solves rather than one.This read “at a third the count” until 2026-08-28, and the two constants are 3 against 5. Corrected to the relation that holds rather than to a ratio, because the ratio is not the design — what is, is that a sweep costs more than a reweight and so fewer of them are spent before asking.
- class rheplicant.inference.plan.Block(*names, steps=None, engine=None, learning_rate=None)[source]¶
Bases:
objectOne group of latents, updated together, by one engine.
Block("t_nw", "t_ant")puts two latents in one conjugate solve;Block("gain")is a block of one. Which engine a block takes is derived fromLatent(..., linear=True)and is not restated here — seeSamplingPlanfor the derivation and for whenengine=is a legitimate override.- Parameters:
- steps¶
inner steps for a gradient block — Adam steps at
SamplingPlan.estimate(), NUTS steps atSamplingPlan.sample().NonetakesDEFAULT_GRADIENT_STEPS.This reads as a performance knob and it is a statistical assumption: a conjugate block’s draw is an exact conditional draw, so a plan of conjugate blocks is an exact Gibbs sampler, while a finite number of NUTS steps is a transition that merely leaves the conditional invariant. The scheme is then Metropolis-within-Gibbs — valid, and with mixing that depends on this number. Giving it to a conjugate block is an error rather than an ignored argument, because a conjugate solve has no inner steps for it to mean.
- Type:
int | None
- learning_rate¶
Adam step size for a gradient block at
SamplingPlan.estimate(), as a fraction ofmax|init|.NonetakesDEFAULT_LEARNING_RATE. It has no meaning atSamplingPlan.sample(), where NUTS adapts its own step size, and none for a conjugate block, which has no iterate to step. Giving it to a conjugate block is an error rather than an ignored argument, for the same reasonstepsis.- Type:
float | None
- class rheplicant.inference.plan.Draws(samples, diagnostics)[source]¶
Bases:
objectPosterior draws: a stack per latent, plus what the run measured.
- Parameters:
diagnostics (PlanDiagnostics)
- diagnostics¶
see
PlanDiagnostics. Readrhatbefore believingmean.
- property mean: dict[str, Array]¶
Posterior mean per latent — comparable with
Estimate.values.
- class rheplicant.inference.plan.Estimate(values, diagnostics)[source]¶
Bases:
objectA point estimate: one value per latent, plus what the run measured.
- Parameters:
diagnostics (PlanDiagnostics)
- values¶
{name: array}, in the space’s declaration order. Keyed by name so the physical names survive the solve — a caller never slices an anonymous stacked vector and never has to get an offset right.
- diagnostics¶
see
PlanDiagnostics.
- class rheplicant.inference.plan.PlanDiagnostics(chi2, sweeps, converged, engines, block_residuals, identifiability, noise_depends_on_prediction, warmup=None, rhat=None)[source]¶
Bases:
objectWhat a run measured, shared by both exits.
- Parameters:
- chi2¶
the JOINT chi-squared, one entry per sweep — the monitored quantity. For
SamplingPlan.estimate()it decreases towards a fixed point; forSamplingPlan.sample()it fluctuates around a stationary value, which is whatrhattests.- Type:
- converged¶
for a point estimate, whether the joint chi-squared settled within
tol(Nonewhen the test was disabled). For a draw, whetherrhatcame in under the caller’s threshold. False here means the answer is not what it looks like — the same reading asconverged.- Type:
bool | None
- block_residuals¶
each conjugate block’s last relative CG residual, and each gradient block’s last conditional potential. Recorded because it is worth having and not because it is a verdict: these are the numbers that read ~1e-7 on an answer thousands of kelvin wrong. Read
chi2.
- identifiability¶
the last rank report taken, or
Nonewhen the check was disabled.
- noise_depends_on_prediction¶
whether sigma was re-evaluated at the prediction each block update. When
Truea draw is exact for the linear-Gaussian conditional at the frozen covariance, which is not the full model’s conditional — see the module docstring.- Type:
- class rheplicant.inference.plan.PlanResult(*args, **kwargs)[source]¶
Bases:
ProtocolWhat both exits guarantee: diagnostics, and answers keyed by latent name.
One result currency in two shapes rather than four unrelated ones. Whatever a plan returns,
result.diagnosticsis aPlanDiagnosticsandresult.namesare the latents — so a caller can log, compare or assert on a run without knowing which exit produced it. What differs is what the answer is, which is the honest difference: a point estimate has values, a sampling run has draws.
- class rheplicant.inference.plan.SamplingPlan(space, *blocks)[source]¶
Bases:
objectA partition of a parameter space into blocks, with two exits.
See the module docstring for the design and for the measured failure this exists to prevent.
- Parameters:
space (ParameterSpace) – the parameter declaration this plan partitions.
*blocks (Block) – the
Blocks, in the order a sweep visits them.
- Raises:
ParameterSpaceError – if no blocks are given; if a block names something the space does not declare; if a latent appears in more than one block; if a latent appears in none; if a block mixes declared-linear and non-linear latents without an explicit
engine=; ifengine="conjugate"is asked for a block with a non-linear member; or ifsteps=is given to a conjugate block.
- classmethod automatic(space, pipeline, state_template, **options)[source]¶
The same plan, over a partition derived from the model.
auto_blocks()groups the declared-linear latents into as many conjugate blocks as the model needs — one per factor of a multilinear form, since latents that are each affine alone need not be affine together — and puts everything else in one gradient block.**optionsare that function’s; it owns the vocabulary, sosteps=and the probe’s tolerances are spelled once rather than restated here.This needs the pipeline, which
__init__does not: which latents may share a conjugate block is a property of the prediction, and no declaration carries it.- Parameters:
space (ParameterSpace) – the parameter declaration to partition.
pipeline (AbstractOperator) – the model, probed to find the partition.
state_template (State) – the model, probed to find the partition.
**options (Any) – forwarded to
auto_blocks().
- Returns:
A plan whose blocks were derived. Nothing else differs — the partition is checked, and each conjugate block’s joint linearity re-verified at the first sweep, exactly as for a declared one.
- Return type:
- estimate(pipeline, state_template, observed, *, noise, max_iter=100, tol=1e-08, min_sweeps=3, check_identifiability='once', solve_tol=1e-06, solve_guard=None)[source]¶
Best fit: block-coordinate descent to a fixed point of the whole model.
Every block is updated to its conditional best — a Wiener solve for a conjugate block, Adam on the conditional posterior for a gradient one — and the sweep repeats until the joint chi-squared stops moving.
- Parameters:
pipeline (AbstractOperator) – the forward model.
state_template (State) – the state it is evaluated on.
observed (Array) – the data. Refused unless shaped exactly like the prediction.
noise (Any) – a
NoiseModel, or a bare sigma (wrapped asHomoscedasticNoise).max_iter (int) – sweep cap.
tol (float | None) – relative PROGRESS in the joint chi-squared below which the run has converged — see
DEFAULT_CHI2_TOLfor why it is a decrease and not a change.Noneruns exactlymax_itersweeps and makes no convergence claim at all — the only way to get an answer back without one.min_sweeps (int) – sweeps taken before the test is consulted.
check_identifiability (Any) –
"once","each_sweep"orFalse. See the module docstring; a point estimate is the exit that needs it most, because it has no other diagnostic.solve_tol (float) – CG tolerance for conjugate blocks.
solve_guard (float | None) – bound on each conjugate solve’s relative ERROR, as for
wiener_solve().Noneskips the condition-number estimate, which is what a 10^6-coefficient block wants — see that function’s own note on the bargain.
- Returns:
An
Estimate.- Raises:
ParameterSpaceError – if the model is not identified; if
observedis mis-shaped; or if the joint chi-squared has not converged withinmax_itersweeps. That last one is an error rather than a flag here and a flag rather than an error atsample(), and the asymmetry is deliberate: a chain hasr_hatto scream with, and a point estimate has nothing.- Return type:
- sample(pipeline, state_template, observed, *, noise, key, n_sweeps, warmup=None, check_identifiability='once', rhat_max=1.05, solve_tol=1e-06, solve_guard=None)[source]¶
Posterior draws: a Gibbs sweep over the same partition.
Each conjugate block is drawn EXACTLY by
gcr_sample(), so a plan of conjugate blocks is an exact Gibbs sampler with nothing tuned. A gradient block takesstepsNUTS steps instead, which makes the whole scheme Metropolis-within-Gibbs — seeBlock’sstepsandgradient_draw().- Parameters:
pipeline (AbstractOperator) – as for
estimate().state_template (State) – as for
estimate().observed (Array) – as for
estimate().noise (Any) – as for
estimate().key (Array) – PRNG key. Required — that is the point of this being a separate method rather than
estimate(key=...).n_sweeps (int) – total sweeps, warmup included.
warmup (int | None) – sweeps discarded. Defaults to half of
n_sweeps. NUTS tuning for gradient blocks adapts through warmup and is frozen afterwards, because a kernel that keeps adapting from the states it visits is no longer a valid transition.check_identifiability (Any) – as for
estimate().rhat_max (float) – split-
r_hatof the post-warmup joint chi-squared above whichPlanDiagnostics.convergedisFalse. Reported, not raised: unlike a point estimate, a chain hands you the diagnostic along with the draws, and throwing away expensive draws over a scalar summary would be the worse trade.solve_tol (float) – as for
estimate().solve_guard (float | None) – as for
estimate().
- Returns:
A
Draws. Read ``diagnostics.rhat``. The measured difference between a non-identified gain and the same model with an identifying tone is 1.824 against 1.002.- Raises:
ParameterSpaceError – if the model is not identified; if
observedis mis-shaped; ifn_sweepsorwarmupis not a sensible count; if fewer thanMIN_DRAWSdraws would be kept; or if a gradient block has a member with no declared prior.- Return type:
- rheplicant.inference.plan.split_rhat(trace)[source]¶
Split-
r_hatof a one-dimensional trace.The standard single-chain mixing diagnostic: cut the trace in half, treat the halves as two chains, and compare the variance between them against the variance within them. 1.0 is perfect agreement; anything much above says the two halves are describing different distributions, which for a Gibbs run means it has not reached stationarity.
Applied here to the JOINT chi-squared trace, which is the whole point — a per-block quantity would be blind to exactly the cross-block degeneracy this module exists to catch.
A trace with no variance within its halves has nothing to mix: identical halves are reported as 1.0, and halves that are each constant at different values as
inf, which is the honest reading of a chain that moved once and stopped.A trace too short to halve is refused, not answered.
SamplingPlan.sample()enforcesMIN_DRAWSon the draws it keeps and this function is public and exported, so it enforces the same minimum rather than trusting its one in-package caller. What it refuses used to be returned: two halves of one have no within-half variance, soddof=1gavenanunder a numpyRuntimeWarningnothing here surfaces. That nan is worse than either bare exception below it, because nan defeats a comparison in both directions —rhat <= rhat_maxis False andrhat > rhat_maxis False too, so a threshold guard reads an undefined diagnostic as whichever answer the caller happened to test for.- Parameters:
trace (Any) – any array-like; flattened first, so its shape does not matter.
- Returns:
The split-
r_hat, orinffor halves each constant at different values.- Raises:
ParameterSpaceError – if fewer than
MIN_DRAWSvalues are given.- Return type:
Deriving the partition, rather than being handed one.
SamplingPlan takes a partition and checks
it. This module produces one, from the model itself:
plan = SamplingPlan.automatic(space, pipeline, state)
# the same thing, with the blocks in reach to inspect or amend
blocks = auto_blocks(space, pipeline, state)
plan = SamplingPlan(space, *blocks)
The rule is the one a reader would guess — conjugate blocks for the latents
declared linear=True, one gradient block for everything else — plus the
one refinement without which it is wrong on the models this package exists for.
Why “every linear latent in one block” is wrong. Latent(..., linear=True)
is a claim about ONE latent: the prediction is affine in it with the others
held fixed. A conjugate block over several of them makes a strictly stronger
claim — that the prediction is affine in them JOINTLY — and for a multilinear
model that claim is false while every member’s own declaration is true. A gain
and an antenna temperature are each affine given the other; their product is
affine in neither pair member’s company. The correct partition puts each factor
of the multilinear form in its own conjugate block, and all of one factor’s
latents together in that block, which is the arrangement
linear_operator() groups for.
Grouping them wrongly is not a silent error — the plan’s first sweep hands
every conjugate block to
check_linearity(), which probes the joint
map and refuses. But a refusal is not a partition, and producing one is this
module’s whole job.
What is probed, and why PAIRS settle it. For a group of latents each already known to be affine on its own, every diagonal block of the group’s Hessian vanishes. Joint affinity is exactly the statement that the whole Hessian vanishes, so what remains to establish is that every off-diagonal block does — a question about pairs. Probing the C(n, 2) pairs therefore settles a property of all 2^n - n - 1 subsets, and no subset larger than two has to be tried. The pairwise verdicts define a graph, “these two may share a block”, and the partition is a colouring of it.
The colouring is first-fit in the space’s own declaration order: each latent joins the first existing group whose every member it is compatible with, and otherwise opens a new one. Deterministic, and on a multilinear model it recovers the factors — but first-fit is not guaranteed to find the fewest groups, and a needless extra group costs mixing rather than correctness. A caller who knows better writes the blocks by hand; that route did not go away.
The loop itself is bayesmith’s —
bayesmith.dispatch.factor.first_fit(), the same function its own
graph-native factor_partition colours with. What stays here is the probing
(this package’s probes read a Pipeline through a ParameterSpace;
bayesmith’s read a Graph) — the RULE deciding what the probes’ verdicts
mean is one piece of code with one home, so the two packages cannot drift into
partitioning by different logic.
The cost is probes, and it is the caller’s to accept. One
check_linearity() per declared-linear latent,
then at most one per pair — each a linearization plus one forward evaluation
per entry in scales. For the handful of latents a partition is written over
this is cheap; it is quadratic, so for very many it is not. Nothing here
guesses from a size, for the reason
estimate()’s
check_identifiability does not either.
No pair is probed twice, and no cache is needed to say so: the groups are
disjoint, so a latent meets any other latent in at most one of them, and the
all() short-circuits the moment a group is ruled out. A memo here would be
a cache that cannot be hit — which reads as an optimization and is one line of
machinery standing in for a fact about the loop.
What this does NOT decide. Whether the partition it found is one the model
can support: two conjugate blocks that are coupled is precisely the
configuration whose degenerate case
rheplicant.inference.plan documents as converging quietly onto an
arbitrary point thousands of kelvin from the truth, with every per-block guard
green. The guard that sees it is
identifiability(), which both plan
exits run by default. Auto-partitioning does not make that check optional, and
this module deliberately does not run it early — a partition is not a reason to
believe a model.
- exception rheplicant.inference.partition.UncheckedLogRouteWarning[source]¶
A latent looked log-linear, and no noise model was there to confirm it.
Whether a log-conjugate block exists is half a question about the prediction (is
log(prediction)affine?) and half a question about the likelihood (does taking logs SIMPLIFY this noise, and is its fractional level inside the first-order ceiling?).auto_blocks()can answer the first from the model alone; the second needsnoise=.A warning rather than a refusal, because there is nothing wrong with the model: the caller simply did not supply what the second half needs, and the conservative verdict – gradient – is always sound. It is not silent for the same reason the partition is conservative: this module’s job is to produce a partition its own solvers will accept, and before 2026-08-27 it could hand out a
log_conjugateblock thatto_log_spacethen refused.
- rheplicant.inference.partition.auto_blocks(space, pipeline, state_template, *, at=None, noise=None, steps=None, learning_rate=None, scales=(0.001, 1.0, 1000.0), log_scales=(0.001, 0.01, 0.1, 1.0), rtol=None)[source]¶
Derive a partition: closed-form blocks by factor, one gradient block.
See the module docstring for why latents are grouped by a pairwise probe rather than swept into a single block, and for why pairs settle it.
The blocks come back closed-form first, gradient last. Order is a Gibbs sweep’s visiting order and does not affect the stationary distribution, but it does affect the first sweeps: a gradient block visited first takes its NUTS steps against linear latents still sitting at their declared init — often a zero sky — and adapts its step size to that conditional, whereas one visited last conditions on an exact draw.
- Parameters:
space (ParameterSpace) – the model to partition. Unlike
SamplingPlan, which needs only the declaration, this needs the model: which latents may share a block, and which have a log-linear one at all, are properties of the prediction and of nothing declared.pipeline (AbstractOperator) – the model to partition. Unlike
SamplingPlan, which needs only the declaration, this needs the model: which latents may share a block, and which have a log-linear one at all, are properties of the prediction and of nothing declared.state_template (State) – the model to partition. Unlike
SamplingPlan, which needs only the declaration, this needs the model: which latents may share a block, and which have a log-linear one at all, are properties of the prediction and of nothing declared.at (dict[str, Array] | None) – values for the latents outside each probed pair, as for
check_linearity(). Defaults to the declared initial values, which is where the plan’s own first-sweep check evaluates the same claim.noise (Any | None) – the model’s noise. Read twice, for two different questions. For the affinity check it enables the second criterion — the departure in units of sigma (D16 axis 3) — which is what tells a curvature that is small against the signal from one that is small against the noise. Omitted, only the relative criterion applies. A log-conjugate block is a claim about the LIKELIHOOD, not only about the prediction, so whether one exists cannot be settled without it: taking logs simplifies a multiplicative noise and merely restates an additive one, and the first-order equivalence holds only up to
FIRST_ORDER_MAX_FRACTIONAL. Given one, those two refusals are applied HERE, and a latent they reject is filed to the gradient block — the same verdict, the same constant, simply reached before a partition is handed out rather than at the first sweep. Omitted, no log-conjugate block is claimed at all andUncheckedLogRouteWarningnames any latent that would have qualified on its prediction alone.steps (int | None) – passed to the gradient block. They have no meaning without one, so a space with no gradient latents refuses them rather than accepting a tuning that reaches nothing.
learning_rate (float | None) – passed to the gradient block. They have no meaning without one, so a space with no gradient latents refuses them rather than accepting a tuning that reaches nothing.
scales (Sequence[float]) – probe magnitudes for the LINEAR checks.
log_scales (Sequence[float]) – probe magnitudes for the LOG-linear checks, which are a separate argument rather than the same one. Feeding the linear default’s
1e3entry to a log probe sends it through an exponential that overflows, the check refuses, and a genuinely log-linear latent is filed as gradient — a misclassification that would cost a conjugate block and report nothing. SeeLOG_DEFAULT_SCALES.rtol (float | None) – forwarded to every probe.
- Returns:
Blocks, closed-form first, ready to splat into a
SamplingPlan.- Raises:
LinearityRefused – if a latent declared
linear=Trueis not in fact affine in itself. Checked before any pair is tried, so that “these two may not share a block” always means a coupling between two sound declarations and never one broken declaration poisoning every pair it appears in.ParameterSpaceError – if
stepsorlearning_rateis given for a gradient block this space has no latents for.
- Return type:
The engines a Gibbs block can be updated by, and the conditioning they share.
rheplicant.inference.plan decides which latents go in a block and
which engine that block takes; this module is what an engine actually does to
one block, given everything outside it held fixed.
There are three, and the split is not a taxonomy — it is the same split the rest
of the package already makes. A block whose latents are all declared
linear=True is a linear-Gaussian conditional, so its point estimate is
wiener_solve() and its draw is
gcr_sample(), which is an exact
conditional draw. A block whose prediction is exp of an affine map is the
same thing once the DATA is taken to logs, under the multiplicative noise the
radiometer equation gives — LOG_CONJUGATE, and
rheplicant.inference.loglinear is where that transform and its
first-order caveat live. Anything else has only a gradient, so its point
estimate is a descent and its draw is NUTS.
The first two share every line of their update but one, so they share a
function: _conjugate_update() takes which transition to build, and the
difference between them is exactly that a log block does not re-evaluate sigma.
Conditioning is one function, used by both. Conditioning closes
over the model once and hands out three things: the joint prediction, the joint
sigma, and the joint chi-squared. Every engine reads the block’s neighbours out
of the same values dict, so “condition on everything outside this block” is
never written by a caller and never written twice here.
The two exits share this file, not their signatures. Each engine exposes an
..._estimate and a ..._draw, and for the conjugate engine they differ by
one argument to one private function — bayesmith.exact.solve._conjugate_solve’s
key=None | k,
which is where this package already said that a point estimate and a posterior
sample are two exits from one workflow. That argument stays private: what a
caller says is estimate() or
sample().
Where sigma is evaluated. Immediately before each block’s update, at the
current joint values. For a constant noise model that is a no-op. For a
prediction-dependent one
(RadiometerNoise) it makes the sweep
itself the reweighting iteration that
iterative_gls() performs for a single block —
which is why a plan does not nest iterative_gls inside a block. The two
loops would be the same loop, one inside the other, converging to the same
fixed point at the product of their costs.
- rheplicant.inference.engines.CLOSED_FORM: tuple[str, ...] = ('conjugate', 'log_conjugate')¶
The engines that solve in closed form, and so have no inner step count and no step size. Named once because three places refuse those arguments.
- rheplicant.inference.engines.CONJUGATE: str = 'conjugate'¶
The engine for a block whose latents are ALL declared
linear=True.
- rheplicant.inference.engines.DEFAULT_GRADIENT_STEPS: int = 25¶
Inner steps a gradient block takes when
Block(..., steps=)says nothing. Small on purpose: within a Gibbs sweep the block is revisited every sweep, so the useful quantity is steps x sweeps, and a large inner count spends its effort exploring a conditional that is about to move.
- rheplicant.inference.engines.DEFAULT_LEARNING_RATE: float = 0.01¶
Adam step size for a gradient block’s point estimate, as a FRACTION of the latent’s own magnitude (
max|init|, falling back to 1.0). A single absolute step cannot serve a beam width near 12 degrees and a log-gain near 0.1 at once; a relative one can.
- rheplicant.inference.engines.ENGINES: tuple[str, ...] = ('conjugate', 'log_conjugate', 'gradient')¶
The three, in the order a message should list them.
- rheplicant.inference.engines.GRADIENT: str = 'gradient'¶
The engine for anything else — and the legitimate downgrade for a block the caller wants stepped by gradient even though it could be solved.
- rheplicant.inference.engines.LOG_CONJUGATE: str = 'log_conjugate'¶
The engine for a block that is conjugate once the DATA is taken to logs — the prediction is
exp(affine), solinear_operatorrefuses it whilelog_linear_operator()does not. There is noLatent(log_linear=True)to derive this from, deliberately: it is discovered by probing (auto_blocks()) or asked for explicitly, never inferred from a declaration that does not exist.
- class rheplicant.inference.engines.Conditioning(space, pipeline, state_template, observed, noise, forward, log_observed=None, log_sigma=None)[source]¶
The model, closed over once, with everything a block update needs.
Deliberately a plain frozen dataclass and not an
eqx.Module, for the reasonLinearBlockis: it holds a traced closure (forward) and is built where it is needed rather than carried around as a differentiable pytree.forwardisforward_fn()’s, built ONCE per run. That matters:forward_fnvalidates the space against the pipeline, which costs several abstract traces, and a sweep loop that rebuilt it per block per sweep would pay that hundreds of times for an answer that cannot change.- Parameters:
space (ParameterSpace)
pipeline (AbstractOperator)
state_template (State)
observed (Array)
noise (NoiseModel)
log_observed (Array | None)
log_sigma (Array | None)
- space, pipeline, state_template
the model under inference.
- noise¶
the noise model, normalized (a bare sigma is already wrapped).
- forward¶
{name: value} -> prediction.- Type:
- log_observed, log_sigma
the data and sigma a
LOG_CONJUGATEblock solves against, fromto_log_space().Nonewhen the partition has no such block.Computed ONCE per run rather than per sweep, and that is not an optimization — it is the log-space claim showing up in the code.
log_sigmadoes not depend on the prediction, so unlikesigma()there is nothing for a sweep to re-evaluate; andto_log_spacerefuses a non-positive sample eagerly, which a jitted transition could not do at all.
- chi2(values)[source]¶
The JOINT chi-squared at the current parameter tuple.
The quantity a plan monitors for convergence, and the reason it is here rather than in a block: it is computed from the whole parameter tuple against the whole data set, so it is the one number in a Gibbs scheme that no partition can hide anything from. A per-block CG residual read ~1e-7 on an answer thousands of kelvin wrong — and read the SAME ~1e-7 on the run that was right, which is the sharper complaint. It was not lying; it simply cannot see across the partition it is computed inside (
tests/inference/test_degenerate_partition.py).An unobserved sample (infinite sigma, from
FlaggedNoise) contributes exactly zero rather than0 * inf.
- neg_log_likelihood(values)[source]¶
-log p(data | values), log-determinant included.The difference from
0.5 * chi2issum log sigma(plus a constant), which is zero-slope only when sigma does not depend on the prediction. That term is the whole of migration ledger B1: while the potentials below were built fromchi2()alone, a gradient block descended the GLS-flavoured target whileto_numpyro_model()’sdist.Normal(prediction, sigma)sampled the full one, from the same declared model. Measured onmu = exp(w) xwithsigma = 0.5|mu|, the two optima are 21% apart (tests/inference/test_potential_carries_the_logdet.py).Both halves are asked of code that already owns them –
chi2()andlog_determinant()– so the rule an unobserved sample needs (log sigmaat an infinite sigma isinf, and one flagged channel would otherwise take the whole potential with it) is not written a second time here.The
0.5 n log 2 pithatNoiseModelLikelihoodalso carries is absent, deliberately and at measured cost – see that function.chi2()is deliberately NOT extended to include this. It is the convergence monitor, and a monitor that silently changed units the moment a noise model started reading its argument would be worse than the omission this replaces.
- rheplicant.inference.engines.conditional_potential(cond, names, values)[source]¶
x -> -log p(x | everything else), up to a constant.Conditioning.neg_log_likelihood()minus the block’s log prior, withxa{name: array}dict over the block’s members and every other latent frozen atvalues. This is the objective the gradient engine descends and the potential NUTS samples, so the two exits of that engine cannot target different distributions.It read
0.5 chi2 - log prioruntil B1 was closed, which made that sentence true of the two exits and false against every OTHER route to the same model – seeConditioning.neg_log_likelihood().
- rheplicant.inference.engines.conjugate_draw(cond, names, values, *, key, **kwargs)[source]¶
An EXACT conditional draw, by
gcr_sample().Exact is the operative word and it is what makes a plan of conjugate blocks an exact Gibbs sampler: no accept step, no inner step count, nothing tuned. See
gradient_draw()for what changes the moment a block is not one of these.
- rheplicant.inference.engines.conjugate_estimate(cond, names, values, **kwargs)[source]¶
The block’s conditional posterior MEAN, by
wiener_solve().
- rheplicant.inference.engines.log_conjugate_draw(cond, names, values, *, key, **kwargs)[source]¶
An exact draw from the LOG-SPACE conditional.
Exact for that conditional, which is the multiplicative model’s own only to first order in
f— the one qualification aCONJUGATEblock does not carry. See_log_conjugate_transition()for the size of it.
- rheplicant.inference.engines.log_conjugate_estimate(cond, names, values, **kwargs)[source]¶
The same mean, solved in log space. See
_log_conjugate_transition().
- rheplicant.inference.engines.gradient_draw(cond, names, values, *, key, steps, tuning=None, adapt=True, programs=None, **_ignored)[source]¶
stepsNUTS steps on the block’s conditional potential, last state kept.This is where a plan stops being an exact sampler. A conjugate block’s draw is an exact conditional draw, so Gibbs over conjugate blocks is exact. A finite number of NUTS steps is not a draw from the conditional — it is a Markov transition that leaves the conditional invariant, which makes the whole scheme Metropolis-within-Gibbs. That is still a valid sampler with the right stationary distribution, and it is not the same thing: the inner step count now affects mixing, and
steps=20is a statistical assumption wearing the clothes of a performance knob.- Parameters:
cond (Conditioning) – the conditioning, as everywhere in this module.
names (tuple[str, ...]) – the conditioning, as everywhere in this module.
values (dict[str, Array]) – the conditioning, as everywhere in this module.
key (Array) – PRNG key for this block at this sweep.
steps (int) – inner NUTS steps.
tuning (Any) –
(step_size, inverse_mass_matrix)carried from the previous sweep, orNoneto adapt from scratch.adapt (bool) – whether NUTS may adapt during this call. True through the plan’s warmup and False afterwards: adapting a kernel from the states it is visiting destroys the reversibility the transition’s validity rests on, so the tuning is frozen for every sweep whose draws are kept.
programs (dict[Any, Any] | None) –
a caller-owned cache of compiled transitions, keyed on
(names, steps, adapting). A plan threads one dict through its whole run so the block compiles once instead of once per sweep;Nonecompiles fresh, which is correct but pays 300 ms.The key deliberately does not include the conditioning. Keying on
id(cond)would be the obvious shortcut and is a trap: CPython reuses ids after collection, so a stale program could be served against a differentobserved— a confident wrong answer with every guard still green. Callers own the cache and therefore own its lifetime; a new conditioning gets a new dict.
- Returns:
(values, tuning)— the block updated to the chain’s last state, and the tuning to hand to the next sweep.- Return type:
- rheplicant.inference.engines.gradient_estimate(cond, names, values, *, steps, learning_rate=0.01, **_ignored)[source]¶
Descend the block’s conditional potential for
stepsAdam steps.Returns the updated values and the potential reached, which stands in the residual’s place in the conjugate engine’s return — a number to record, never a convergence verdict. The verdict is the joint chi-squared, one level up.
- rheplicant.inference.engines.require_priors(space, names, where)[source]¶
A gradient DRAW needs a prior on every member; a point estimate does not.
Same rule, and the same reason, as
to_numpyro_model()’s: a prior-free latent is a free parameter, which is meaningful to an optimizer and meaningless in a posterior. Stated here rather than left to NUTS because without it the potential is flat in that latent and the chain wanders off to wherever the geometry lets it, reporting nothing wrong.- Parameters:
space (ParameterSpace)
where (str)
- Return type:
None
The square-root information form: a log-quadratic that cannot go indefinite.
A term is [R | z] with log L(x) = -0.5 * ||R x - z||^2 + offset, so the
Fisher information it carries is F = R^T R – positive semi-definite by
construction rather than by hope. Three consequences the accumulation layer
depends on:
Rank deficiency is representable and cheap. One epoch rarely constrains every global parameter; its
Rsimply has fewer rows than columns. The equivalent statement in(F, b)form isF v = 0, which survives addition but not the sequence of explicit Schur complements a filter needs.The working condition number is the square root.
kappa(R) = sqrt(kappa(F)), which is what keeps a thousand-epoch accumulation inside float64. AccumulatingFdirectly and taking explicit Schur complements goes indefinite in float64 on a realistic near-degenerate campaign.Accumulation is a QR. Stacking two factors vertically and re-triangularising is the sum of the two quadratic forms, so order-invariance and associativity hold to roundoff by construction.
The class knows nothing about epochs, priors or pipelines. That is deliberate:
it keeps the numerics separable from the model machinery, as
rheplicant.core.conditioning does for spectral diagnostics.
- class rheplicant.inference.sqrtinfo.SqrtInfo(factor, target, offset, names, shapes)[source]¶
Bases:
Modulelog L(x) = -0.5 ||R x - z||^2 + offsetover a flat, named vector.- Parameters:
- factor¶
(r, n)arrayR.r < nmeans the term constrains only anr-dimensional subspace – the normal case for a single epoch, not an error.- Type:
- offset¶
scalar; every part of the log-density that does not depend on the latents – the residual chi-square about the storage origin and the (masked) Gaussian normalisation.
- Type:
- classmethod null(names, shapes)[source]¶
A term that says nothing – the identity of
combine().Square rather than zero-row so the accumulator’s pytree keeps a fixed treedef across a whole campaign, which is what stops
jitretracing once per epoch.
- classmethod combine(first, second)[source]¶
The term whose log-density is the sum of the two given ones.
Stack the augmented factors and re-triangularise. Writing
y = [x; -1]so that[R | z] y = R x - z, the stacked product has the same norm as its triangular factor becauseQhas orthonormal columns:||R_a x - z_a||^2 + ||R_b x - z_b||^2 = ||R_tot x - z_tot||^2 + rho^2
rhois the corner of the triangular factor – the part of the two residuals that no single quadratic form inxcan express. It is a constant, so it belongs in the offset; dropping it leaves every combined term wrong by an amount that grows with the campaign and is invisible in the posterior’s shape.
- rheplicant.inference.sqrtinfo.marginalise_arrays(factor, target, offset, n_block)[source]¶
The Schur complement in square-root form, with no Python control flow.
The block is the leading
n_blockcolumns; permuting is the caller’s job, because a caller that already knows its layout should not pay for a name lookup once per epoch inside alax.scan.This exists because
marginalise()cannot be traced. It concretises twice –float(jnp.max(...))for the pivot scale andnp.finfo(...)on a materialised dtype for the floor – and the chain filter evaluates this arithmetic inside the theta likelihood, underjax.lax.scan, differentiating it with respect to the transition’s own parameters. Measured on the checked path, on a(6, 4)term:eager 0.2497233081987414 jit ConcretizationTypeError grad ConcretizationTypeError
gradis the half that matters. A correlation time that is inferred rather than pinned at compression time is differentiated on every leapfrog step, so amarginalisethat could be jitted but not differentiated would still be unusable here.The refusal is not weakened, it is moved – and this function cannot make it. What
marginalise()catches is a block that does not constrain itself, which makes the integral divergent. Under a trace that judgement is unavailable: it needs a comparison against a value, and there is no value. What that costs, measured on one term with its block column rescaled:block column
this function
healthy (scale 1)
+0.203 nats
accepted
rounding-scale (1e-10)
+23.23 nats
refused
identically zero
+infrefused
scaled by
nannanrefused
scaled by
infnanrefused
The zero column is the easy half:
-log|pivot|is a true+infand any finiteness check downstream catches it. The dangerous half is the rounding-scale row, which is what a genuinely near-degenerate night looks like – finite, the right sign, the right order of magnitude for a good night’s evidence, growing as-log(pivot)(27.8 at 1e-12) and unbounded in principle. Nothing downstream tests for that.The last two rows are new, and the third column used to read “accepted”. The table stopped at the zero column and so did the checked path’s own test; the threshold there is relative to
max(pivots), so onenananywhere in the term made the thresholdnanand every comparison against it False. Both are pinned bytest_the_kernel_cannot_see_what_the_checked_path_refuses, which readspivotsat all five scales, and bytest_a_poisoned_block_is_refused_rather_than_marginalised_to_nan.What it hands back instead is the evidence:
pivots, as data. An eager caller judges them (marginalise()does). A chain does not need to, becauseLinearGaussianTransitionrefuses a non-positiveprocess_stdorinitial_stdat construction and those rows are what constrain everyzeta_e– one eager check at declaration instead of one traced check per epoch. A caller that has neither owns the gap, and should look atpivotsitself.- Parameters:
factor (Array) –
(r, n)arrayR, block columns first.target (Array) –
(r,)arrayz.offset (Array) – scalar; the constant this term already carries.
n_block (int) – how many leading columns to integrate out.
0is legal and is the identity on the density – the re-triangularisation folds any excess rows into the corner and the offset absorbs it.
- Returns:
(factor, target, offset, pivots)– the retained form, the offset with the Gaussian integral’s constant folded in, and|diag(R)|of the re-triangularisation so a checked caller can test it.- Return type:
Delegated to
bayesmith.marginal.sqrtinfo.marginalise_arrays(), so the Schur complement exists once. Measured bitwise identical on all four returned arrays across widths 2, 3, 6 and 12, block sizes 1 throughwidth - 1, and input scales spanning 1e-6 to 1e8, three seeds each – worst absolute difference 0.0.The delegation carries no refusal in either direction: neither side raises anywhere in this function, which is the whole reason it is the clean half of this module.
marginalise()keeps its own five, this package’s exception class and this package’s wording included, and it reads thepivotsreturned here to raise them.
- rheplicant.inference.sqrtinfo.marginalise(info, block)[source]¶
Integrate named latents out of a square-root information form, exactly.
Permute the block’s columns first, re-triangularise, and drop the leading rows and columns. That drop is the Schur complement, and the Gaussian integral over the block contributes exactly
+ (n_block/2) log(2 pi) - sum log|R_bb,ii| - 0.5 rho^2and nothing else. In particular it does not contribute the block’s own prior normalisation: whoever appended the prior rows owns
-sum(log(std)) - (n/2) log(2 pi), and the two2 pihalves cancel whilesum(log(std))has nothing to cancel against. Plan A shipped that second term missing – 1.07 nats for three nuisances atstd=0.7, 27.47 for twenty-five atstd=3, and exactly zero atstd=1, which is how a probe built on unit priors passed. A constant is invisible in a posterior’s shape, so the tests for this function compare absolute log-densities against a dense oracle and use a non-unit prior.Written once here rather than at each caller because the chain filter marginalises
zeta_eby the same three lines, and two copies of a constant is how one of them gets fixed.The arithmetic itself lives in
marginalise_arrays(), which takes arrays and can be traced; this function is that call plus the two checks, which cannot – calling this one underjitorgradraisesConcretizationTypeError, andmarginalise_arrays()is what to reach for there. The chain filter calls the kernel directly from inside alax.scan. There is still exactly one copy of the constant, andtest_the_kernel_and_the_checked_path_return_the_same_numberscompares the two paths element-wise rather than trusting this sentence.Marginalising every name is legal and returns a zero-width term –
factorshape(0, 0),names=()– whoselog_prob({})is the marginal likelihood. That is what T1 does at eachthetawhen it integrates outphi_e, so refusing it would mean writing the nuisance and no-nuisance paths twice. Marginalising nothing is likewise legal and is the identity on the log-density: the re-triangularisation folds any excess rows intorhoand the offset absorbs it, which isSqrtInfo.combine()’s arithmetic with one term.- Parameters:
- Returns:
A term over
info’s remaining names, in their original relative order, whose log-density is the integral ofinfo’s overblock.- Raises:
StateValidationError – if a name is repeated or not in
info; if the term’s re-triangularisation is not finite – the degeneracy test below is relative to the largest pivot, so onenanorinfanywhere in the term used to make the thresholdnanand the answer “accepted”, returning aSqrtInfowhose offset wasnanpast a__check_init__that validates shapes only; or if the block is not constrained – an unconstrained direction makes the integral divergent, and finite arithmetic returns a large plausible number for it rather than an infinity anyone would notice.- Return type:
Which latents survive an epoch, and which are integrated out inside it.
A ParameterSpace says what is
inferred and how it reaches the pipeline. A Factorization adds the
one thing a streaming analysis needs on top: over what extent of data each
quantity is constant, which is what decides whether it is sampled against the
whole campaign or integrated away one epoch at a time.
It is derived, not declared twice. The user writes one space, tagging latents
with scope=; this class partitions it and exposes the global view –
names, shapes and priors, and nothing that resolves against a pipeline. That
matters because BayesMemory has no
pipeline by construction: the whole point is that the raw data and the forward
evaluation are gone. Handing the memory a ParameterSpace would either
require dead bindings into a pipeline nobody will run, or open a sample site
for a per-epoch latent that compression already integrated – D14’s own named
failure, an unreached latent sampling happily and returning its prior, while a
marginalised copy of the same nuisance sits inside every stored term.
- class rheplicant.inference.factorize.Factorization(space, linked=<factory>, hyper=<factory>, represents=<factory>)[source]¶
Bases:
ModuleA parameter space split by scope, plus what the non-global scopes need.
- Parameters:
- space¶
the single declaration everything is derived from.
- linked¶
{latent name: transition}for everyscope="linked"latent. The transition object is consumed by the chain filter; this class only checks that one exists for each linked latent and none for anything else.- Type:
collections.abc.Mapping[str, Any]
- hyper¶
{per-epoch latent: (global latent names, builder)}. The builder receives those globals’ values and returns the per-epoch latent’s prior, which is how a hierarchicalpi(phi_e | psi)is expressed. Its presence means the epoch’s nuisance cannot be integrated at compression time (the hyperparameter would be frozen); the joint block is stored instead and the integral is done at evaluation.- Type:
collections.abc.Mapping[str, tuple[tuple[str, …], collections.abc.Callable]]
- represents¶
{input product: global latent names}– which shared calibration products the campaign actually models. Section 9.5’s refusal reads it: two epochs that share an input-product hash are not conditionally independent, and the memory refuses to sum them as though they were unless the product is represented here. This mechanises section 1’s “shared structure belongs in theta” instead of leaving it as advice, and it is a declaration because no data-driven diagnostic can recover it – the in-span half of a coherent error biases theta identically in every epoch and leaves no residual anywhere.- Type:
collections.abc.Mapping[str, tuple[str, …]]
- property global_names: tuple[str, ...]¶
Names of the latents the memory accumulates over, in declared order.
One epoch’s data, compressed into a factor of the campaign likelihood.
The protocol mirrors Likelihood
one seam further along: that one scores a prediction against data, this one
has already absorbed the data and scores the latents. The name says
“likelihood” because that is the invariant the whole accumulation layer rests
on – a stored term contains no factor of the prior, so the prior can be
applied exactly once, at the end, by whoever holds the
Factorization.
Provenance travels with the term because two of the package’s existing
distinctions become silent bugs the moment terms are summed. Under
RadiometerNoise the noise level is a function of the prediction (D21), so a
term built with the covariance frozen at the iterative_gls solution encodes
generalized least squares, while one built with the log-determinant live
encodes the full Gaussian posterior (D23). They are different estimators.
Adding them produces a finite, correctly-shaped, meaningless number, so
QuadraticLikelihood.estimator exists and the memory refuses to mix.
- class rheplicant.inference.compressed.CompressedLikelihood(*args, **kwargs)[source]¶
Bases:
ProtocolContract:
logL = term(values), over the global latents alone.Every member below is one
BayesMemoryactually reads, and the list is that long for a reason. An earlier version declared onlylatents,epoch_id,estimatorand__call__, which made the protocol a narrower claim than the code relied on: a term satisfying it passedisinstance, passedremember, contributed to the accumulated density, and then raisedAttributeError: 'X' object has no attribute 'n_observed'fromaudit()– a diagnostic, reached long after the term had already been folded irreversibly into the QR.exactandn_observedare read byaudit();prior_sharebyremember’s tempering refusal. A published contract that omits them is an invitation to write a class that cannot work.The last five are section 9’s per-epoch residual summary and section 9.5’s input provenance, and they are on the contract for the same reason: they are read after the term has been folded irreversibly into a QR – three of them by
save_memory()’s manifest, two byrheplicant.inference.diagnostics.RawLikelihoodcarries them as structurally empty properties rather than being dropped from the contract, so that the refusal a caller of T0 actually needs –info’s, which names the tier and its remedy – is the one they reach.
- rheplicant.inference.compressed.REQUIRED_TERM_MEMBERS = ('latents', 'epoch_id', 'estimator', 'n_observed', 'exact', 'prior_share', 'residual_chi2', 'residual_dof', 'template_names', 'template_projections', 'inputs')¶
What
BayesMemoryand the code that outlives it read off a term. Checked by name atrememberso an incomplete term is refused at the door rather than at a diagnostic, and listed here so this tuple and the protocol above cannot drift.The last five are section 9’s, and they are on this list for the reason the first six are: they are read after the QR that made the term irreversible –
residual_dof,template_namesandinputsbysave_memory()’s manifest, the other two byrheplicant.inference.diagnostics. A term admitted without them contributes to the accumulated density and then raisesAttributeErrorfrom a diagnostic, by which time the campaign already depends on it.
- class rheplicant.inference.compressed.QuadraticLikelihood(info, epoch_id, n_observed, exact=True, support=None, include_logdet=True, noise_frozen_at='none', prior_share=(0, 1), residual_chi2=0.0, template_projections=None, residual_dof=0, template_names=(), inputs=())[source]¶
Bases:
ModuleA log-quadratic factor: a
SqrtInfoplus what it means.- Parameters:
- info¶
the square-root information form carrying the numbers.
- epoch_id¶
the recording’s identity, supplied by the ingestion layer – the data hash, not a filename. This is what makes appending the same night twice refusable.
- Type:
- n_observed¶
unflagged samples that went into it. Needed to interpret the residual chi-square, and the natural weight if a Consensus-Monte-Carlo share is ever assigned.
- Type:
- exact¶
whether the term is a sufficient statistic (all latents linear, noise frozen) or an expansion. An expansion is only meaningful inside its
support, which is why one is required.- Type:
- support¶
{latent: (low, high)}where an approximate term is trustworthy.Nonefor an exact term, and required otherwise.
- include_logdet¶
whether the Gaussian normalisation was kept.
Falseis generalized least squares (D21), not a cheaper version of the same estimator.- Type:
- noise_frozen_at¶
"none"for a genuinely fixed covariance, or the procedure that produced the frozen one ("gls").- Type:
(numerator, denominator)of the prior tempering this term carries.(0, 1)– no prior at all – is the invariant, and the only value the streaming path produces. Stored as integers so that “the shares sum to one” is a statement that can be true for any N and any summation routine, which a float equality is not.
- residual_chi2, residual_dof, template_names, template_projections
section 9.3’s per-epoch residual summary, computed by
compress_linear()while the data still exists. Each field carries its own note on why it is dynamic or static.
- inputs¶
((product, content hash), ...)for this epoch’s shared input products, sorted. A tuple of pairs rather than a mapping so it is hashable and can be static.
- residual_chi2: Array | float = 0.0¶
Section 9.3’s per-epoch residual summary, computed where the data still exists and stored because
audit()runs after it is gone. Dynamic, not static: it is a function OF THE DATA, so concretising it at compression would turnjax.gradandjax.vmapoverobserved– both pinned in Plan B’s tests – into a ConcretizationTypeError.
- template_projections: Array | None = None¶
(n_templates,), orNonewhen the epoch named none. Dynamic for the reason above, and additionally because equinox puts a static field into the treedef, where array__eq__decides treedef equality.
- residual_dof: int = 0¶
A static field, exactly as
n_observedis – a sample count minus a rank, computable from sigma and the design alone. (Do not open this comment with a word and a colon: napoleon reads the first line of an attribute docstring astype: descriptionand emits:type: Static, which is a py:class Sphinx then cannot resolve.)
The prior share as an exact rational.
- class rheplicant.inference.compressed.RawLikelihood(predict, observed, sigma, names, epoch_id, include_logdet=True, noise_frozen_at='none')[source]¶
Bases:
ModuleT0 – the epoch’s likelihood with the raw data still inside it.
This tier deliberately does not compress. It holds
observedand a livepredict, so it defeats every one of the four bottlenecks §0 lists and has no place in a campaign. It exists because D24’s posture requires it: an approximate posterior is only trustworthy where an exact one exists, and from T1 upward “exact” is defined as “agrees with this, absolutely, at every probe”. §12.12’s boundary validation is not writable without it.Two refusals keep it out of the places it must not reach.
inforaises rather than returning a quadratic form, soremember()– which folds a term into the running QR – stops at the door instead of at a diagnostic.save_memory()refuses it for the same reason:predictis a Python callable, andeqx.tree_serialise_leaveswould take it from whatever template it was handed, so a reloaded T0 would evaluate a different model against the same data with no error and no warning.The masked normalisation is D21’s, not a variant of it: a flagged sample is
sigma = inf, whose inverse variance is a clean zero but whoselog(2 pi sigma^2)is+inf. Only finite-sigma samples are summed. The residual is SELECTED on that same mask before it is divided, never weighted by a zero afterwards – a flagged sample is usually flagged because it holds a NaN, and0.0 * nanisnan.- Parameters:
- predict¶
values -> prediction, shaped likeobserved. Static: it is code, not data, and it is what makes this tier unarchivable.- Type:
- sigma¶
the noise standard deviation, already resolved to an array.
infmarks an unobserved sample.- Type:
- include_logdet¶
whether the Gaussian normalisation is kept.
Falseis generalized least squares (D21), a different estimator rather than a cheaper version of this one.- Type:
- rheplicant.inference.compressed.COEFFICIENTS = '__basis_coefficients__'¶
The name the coefficient vector is carried under inside a T1 term’s
SqrtInfo. Not a latent: it is the vectorc(theta)the basis expands the prediction in, and it is named so that accumulating T1 terms uses exactly Plan A’s QR rather than a parallel implementation of it.
- class rheplicant.inference.compressed.ReducedBasisLikelihood(basis, info, joint, epoch_id, n_observed, support, nuisance_names=(), nuisance_shapes=(), include_logdet=True, noise_frozen_at='none', prior_share=(0, 1), frozen_noise_residual=0.0, bias_gradient=None, bias_names=(), residual_chi2=0.0, template_projections=None, residual_dof=0, template_names=(), inputs=())[source]¶
Bases:
ModuleT1 – one epoch’s likelihood as a quadratic in the basis coefficients.
-2 log L_e(theta) = || S_e^T (c(theta) - c_ref) - r_e ||^2 + const, which iscompress_linear’s arithmetic with the whitened basis rows in place of a design matrix. The consequence worth stating: the stored numbers are aSqrtInfoover an(n_S,)vector, so everything Plan A proved of that form transfers –F = R^T Ris PSD by construction, a rank-deficient epoch is a shortR, accumulation is the QR of stacked factors, and the QR’s corner is a constant that belongs in the offset.§4.1’s
(chi2_r, p, R)is the same triple in different coordinates:chi2_r = ||z||^2 + rho^2andp = R^T z.The two constants in the offset are the whole reason this class is tested against absolute log-densities. On the RHINO fixture the masked normalisation
-0.5 sum log(2 pi sigma^2)is+200.738nats and the QR corner-0.5 rho^2is-51.321; both are pure offsets, so a term built without either has exactly the right shape, the right gradient and the right curvature, and the wrong evidence. Plan A shipped both errors once. The measured gap between this tier and T0 at probes one prior sigma from the truth is at most1.3e-6nats – eight orders below either constant – so comparing absolute densities to a thousandth of a nat has room to see a dropped term and no room to be fooled by the truncation.The basis is shared, not copied.
basisis a reference to oneReducedBasisper campaign, so N epochs costn_S * n_dataonce plusO(n_S^2)each.``exact=False``, always. T1 is exact where
mulies in the span, and nothing can certify that for everytheta. The honest guard is §5 requirement 6’s:supportis the region the training bank populated, the projection error is uniformly bounded there, and the operative diagnostic is re-measuring fidelity at draws from the accumulated posterior – which needs the forward model, not the raw data, and therefore survives archiving.- Parameters:
- basis¶
the shared dictionary and the live coefficient map.
- Type:
Any
- info¶
the epoch’s statistics, over
COEFFICIENTS.
- joint¶
the un-marginalised block over
(phi_e, coefficients), orNonewhen the epoch declared no nuisance. Stored even thoughinfois what evaluation reads, because the Schur complement destroys precisely the quantity whose time correlation would falsify aper_epochdeclaration – and once the raw data is gone, a mis-declaration that cannot be falsified is permanent (§4.2).- Type:
- nuisance_names, nuisance_shapes
what
jointwas marginalised over.
- frozen_noise_residual¶
the largest
|log L_frozen - log L_live|, in nats, over2 n_theta + 1probes spanningsupport– section 8’s mandatory measurement of what freezingNcost this epoch.0.0exactly for a noise model that does not depend on the prediction, arithmetically rather than by a skipped branch, because the two sigma arrays are then the same array. It deliberately excludes the projection error, which is section 7’sbias_gradient: a single number covering both would make the refusal’s message name the wrong remedy.
- bias_gradient¶
d/dtheta [ this term - the oracle ]at the storage origin, ravelled in flatten order – section 7’s whole budget inn_thetafloats. It is a gradient and not a magnitude because a constant offset has exactly zero effect on a posterior while an arbitrarily small theta-dependent tilt has unbounded effect. It is taken at compression because that is the last moment T0 exists: the next line of a campaign releases the raw data.- Type:
jax.Array | None
- bias_names¶
the latent names
bias_gradient’s blocks are in, sorted, because that is the orderjaxflattens a dict into and therefore the order every named matrix in this package is built in. Stored rather than re-derived so thataudit()can check them against its own instead of trusting that two callers agreed – a permutation here is silent, since the shapes still match.
- epoch_id, n_observed, support, include_logdet, noise_frozen_at,
prior_share: as on
QuadraticLikelihood, and read by the same code.
- residual_chi2: Array | float = 0.0¶
Section 9.3’s per-epoch residual summary, on the same terms as
QuadraticLikelihood’s. The residual is the one perpendicular to the epoch-whitened basis rows, not to a design matrix, which is the only difference between the two tiers here: a T1 epoch’s “best fit” is the best the dictionary can do, and what is left over is what no coefficient vector could have absorbed.
Turn one epoch’s data into a factor of the campaign likelihood.
Plan A ships one method, and it is the one with an exact oracle: a model affine in every latent, Gaussian noise, and linear-Gaussian per-epoch nuisances. The stored term is then a sufficient statistic – no anchor, no validity region, order-invariant – which is what lets the exactness pin compare a streamed campaign against a batch solve to roundoff.
Two pieces of arithmetic carry the whole module.
Marginalisation is a QR drop. Order the columns nuisance-first, append the
nuisance prior’s whitened rows, and triangularise. The leading n_phi rows
and columns are then the nuisance block; dropping them is the Schur
complement, exactly. Two constants come out of that drop and BOTH belong in the
offset: -sum(log|diag|) off the discarded block, and -sum(log(std)),
the nuisance prior’s own Gaussian normalisation. The (n_phi/2) log(2 pi)
that the Gaussian integral contributes cancels against the prior’s copy of the
same factor; the sum(log(std)) has nothing to cancel against. Omitting it
leaves every term wrong by a constant that grows with the nuisance count –
measured at 1.07 for three nuisances at std=0.7, 27.5 for twenty-five at
std=3 – and a constant is invisible in the posterior’s shape, so gradients
and curvature look perfect while the evidence is wrong.
The normalisation is masked. FlaggedNoise encodes a flagged sample as
sigma = inf. Its inverse variance is a clean zero, but log(2 pi sigma^2)
is +inf, so an unmasked normalisation makes one flagged channel send the
whole term – and then the whole campaign – to -inf, with every gradient
NaN. Only finite-sigma samples are summed, and n_observed records how many
there were.
- rheplicant.inference.compress.compress_linear(design, observed, noise_std, shapes, epoch_id, offset_prediction=None, nuisance_design=None, nuisance_prior_std=None, nuisance_prior_mean=None, nuisance_shapes=None, templates=None, inputs=None)[source]¶
Compress one epoch of a linear-Gaussian model into a sufficient statistic.
- Parameters:
design (Mapping[str, Array]) –
{global latent: (n_data, n_i) block}. Column order follows iteration order of this mapping, which is also the term’s latent order.observed (Array) –
(n_data,)data for this epoch.noise_std (Any) – a scalar, an array, or a
NoiseModel. A prediction-dependent model must already have been resolved – passiterative_gls(...).noise_std, and record that in the term.shapes (Mapping[str, tuple[int, ...]]) – each global latent’s shape.
epoch_id (str) – the recording’s data hash.
offset_prediction (Array | None) – the model’s constant part, subtracted before compression. Storing the statistics about this origin rather than about zero is an exact change of variable, and it is what keeps the residual chi-square from being the time-bandwidth product.
nuisance_design (Mapping[str, Array] | None) –
{nuisance latent: (n_data, n_j) block}for the latents integrated out here.nuisance_prior_std (Mapping[str, Any] | None) – each nuisance latent’s prior standard deviation. Required for every entry of
nuisance_design.nuisance_prior_mean (Mapping[str, Array] | None) – each nuisance latent’s prior mean; zero if absent.
nuisance_shapes (Mapping[str, tuple[int, ...]] | None) – each nuisance latent’s shape.
templates (Mapping[str, Array] | None) –
{name: (n_data,) shape}– named systematic templates the epoch’s residual is projected onto, in the model’s own units. Section 9.3, and it can only be done here:audit()runs after the recording is archived, and the residual is gone by then.inputs (Mapping[str, str] | None) –
{product: content hash}for the shared products this epoch was built from – one calibration solution, one beam model, one flag table. Section 9.5’s conditional-independence refusal reads these, and a campaign that has forgotten which nights shared a solution will cheerfully sum them.
- Returns:
A prior-free, exact
QuadraticLikelihoodover the global latents.- Return type:
- rheplicant.inference.compress.compress_reduced_basis(basis, observed, noise, epoch_id, *, support=None, noise_frozen_at='none', frozen_tolerance=None, nuisance_design=None, nuisance_prior_std=None, nuisance_prior_mean=None, nuisance_shapes=None, nuisance_tolerance=1e-06, templates=None, inputs=None)[source]¶
Compress one epoch against a shared reduced basis – section 4.1’s T1.
The prediction is expanded in the basis and the epoch’s likelihood becomes a quadratic in the coefficient vector. That is
compress_linear’s QR withS_e^Tin place of the design matrix, so the rank-deficiency handling, the masked normalisation and the retained corner term are the same three lines rather than a second implementation of them.The metric is the epoch’s, the dictionary is the campaign’s.
S_eis the shared rows whitened by this epoch’s sigma, so the stored form is the exact likelihood of the modelS^T cunderN_e. The coefficient mapc(theta)was built in the reference metric and is shared, which is what makes evaluationO(n_S^2)per epoch instead ofO(n_S n_data); the mismatch that buys is what the bias budget measures per epoch.Sigma comes from the basis’s reference prediction, not from this epoch’s data. That looks as though it should make this call jittable where
compress_linearis not –rows^T c_refis concrete numbers carried on the dictionary, so the flag pattern is known before the data arrives. It does not, and the reason is omnistaging: inside ajax.jittrace everyjnpcall is staged into the jaxpr whether or not its operands are traced, sobroadcast_toof a concrete sigma still returns a tracer and the guard fires. Underjax.gradandjax.vmapit does not, because those traces lift only values that are actually traced – which is the same splitcompress_linearhas, reached by a different route, and the reason the guard tests sigma rather than the data.The bias gradient is taken here because here is where the oracle still exists. Section 7 budgets the theta-gradient of the compression error, not its magnitude, and evaluating that needs the epoch’s raw data. This is the last moment the raw data is in hand, so
bias_gradientis computed before the return and stored asn_thetafloats;audit()turns the campaign’s sum of them into a bias per named direction.- Parameters:
basis (Any) – the shared
ReducedBasis.observed (Array) – this epoch’s data.
noise (Any) – this epoch’s
NoiseModel, evaluated at the basis’s reference prediction. UnderRadiometerNoisethat evaluation is the frozen-N step of section 8, andnoise_frozen_atis how the term says so.epoch_id (str) – the recording’s data hash.
support (dict[str, tuple[float, float]] | None) – overrides the basis’s. Refused if neither supplies one.
noise_frozen_at (str) – provenance.
"none"for a genuinely fixed covariance,"reference"for a sigma frozen at the basis’s reference prediction,"gls"for one fromiterative_gls(). Two terms with different values are different estimators andBayesMemoryrefuses to sum them. Not overridden for a noise model that does not depend on the prediction: a sigma computed byiterative_glsand handed back asHomoscedasticNoiseis constant and frozen at the GLS solution, and"gls"is the true answer there.frozen_tolerance (float | None) – refuse when freezing the covariance costs more than this many nats anywhere in
support.Nonemeasures and records without refusing.nuisance_design (Mapping[str, Array] | None) –
{nuisance latent: (n_data, n_j) block}for the per-epoch latents integrated out here. Every column must lie inside the basis – pass the same block tobuild_reduced_basisasextra_directions=.nuisance_prior_std (Mapping[str, Any] | None) – each nuisance latent’s prior standard deviation. Required for every entry of
nuisance_design: the block is integrated exactly once and an improper integral is not a likelihood, which is condition C3.nuisance_prior_mean (Mapping[str, Array] | None) – each nuisance latent’s prior mean; zero if absent.
nuisance_shapes (Mapping[str, tuple[int, ...]] | None) – each nuisance latent’s shape. Defaults to the design block’s column count as a flat vector.
nuisance_tolerance (float) – how far outside the basis a nuisance column may lie before it is refused, as a fraction of its own
N^-1norm.templates (Mapping[str, Array] | None) –
{name: (n_data,) shape}– named systematic templates the epoch’s residual is projected onto, in the model’s own units (section 9.3). Here “the epoch’s best fit” is the best the dictionary can do, so a template lying inside the basis’s span projects to zero: at T1 the residual summary is blind to exactly what the basis can absorb, which is a wider blindness than T2’s and is the honest reading of the number.inputs (Mapping[str, str] | None) –
{product: content hash}for this epoch’s shared input products. Section 9.5.
- Returns:
A prior-free, approximate
ReducedBasisLikelihoodover the basis coefficients.- Return type:
- rheplicant.inference.compress.compress(observed, epoch_id, *, design=None, basis=None, noise=None, noise_std=None, shapes=None, **tier_arguments)[source]¶
Route one epoch to the tier that can represent it – section 4’s ladder.
Two methods exist today and the choice is structural rather than a heuristic: a model affine in every latent has a sufficient statistic (T2), and one that is not needs a dictionary to be expanded in (T1). Anything else – an emulator (T3), a Monte Carlo integral (T4) – is refused by name rather than approximated by whichever of these two is nearer, because a finite plausible number from the wrong tier is exactly the failure the tier ladder exists to prevent.
Being a dispatcher makes this subject to boundary validation: the tiers must agree at every threshold and at extreme parameter values, which
tests/evidence/test_tier_boundaries.pychecks by calling each tier directly rather than through this function – routing one input to one method can only show the function’s own continuity, never that two methods agree.- Parameters:
observed (Array) – this epoch’s data.
epoch_id (str) – the recording’s data hash.
design (Mapping[str, Array] | None) –
{latent: block}– the claim that the model is affine in every latent. Routes tocompress_linear().basis (Any) – a
ReducedBasis– the claim that the prediction is expanded in this dictionary. Routes tocompress_reduced_basis().noise (Any) – the noise model, for the reduced route.
noise_std (Any) – the noise scale, for the linear route.
shapes (Mapping[str, tuple[int, ...]] | None) – each latent’s shape, for the linear route.
**tier_arguments (Any) – passed through to whichever tier was chosen, so a keyword the tier does not take is a TypeError naming that tier rather than a silently ignored argument here.
- Raises:
StateValidationError – if neither a design nor a basis is given, or if both are.
- Return type:
A reduced basis over PARAMETER space – not the smooth basis over the data grid.
Two different objects share the word. rheplicant.core.basis holds
SeparableBasis: Legendre/Fourier columns in time
and frequency, the repair an identifiability refusal names (D28). This module
holds the other one: a dictionary of snapshots of the prediction itself, in
which mu_e(theta) ~ sum_k c_k(theta) s_k – the reduced-basis surrogate of
Field, Galley, Hesthaven, Kaye & Tiglio (2014, PRX 4, 031006). ROQ proper
(Canizares et al. 2013, 2015) retains model calls at EIM nodes and is a
different scheme; the special case s_k = d mu / d theta_k at a fiducial is
MOPED (Heavens, Jimenez & Lahav 2000) and score compression (Alsing & Wandelt
2018).
The metric is applied once, at construction. The rows are kept in the
model’s own units and the whitened copy rows * weight is derived beside
them, so “orthonormal in the N^-1 metric the likelihood uses” is just
“orthonormal”, the Gram matrix is the identity, and the projector is a matrix
product. That is not a convenience: with any other convention the projector is
not self-adjoint in the likelihood’s own inner product, the truncation residual
stops being orthogonal to the span, and the score at the truth acquires a term
that does not vanish – a bias, not a loss of sensitivity. Both copies are
stored because neither alone is enough: the projector needs the whitened one,
and an epoch whose flag pattern differs from the reference’s needs the raw one,
since whitened / weight is infinite at exactly the samples the reference
could not see. There is no inverse back to unwhitened data there, and that is
what weight = 0 means rather than a limitation.
Selection and basis are different things. select_svd and
select_greedy choose candidates; orthonormalise() turns candidates
into a basis. Storing raw candidates is what gives a Gram matrix no float64
quadratic form survives – c^T G c then returns a finite and occasionally
negative number.
The science direction is seeded, not hoped for. Singular values of a bank of
prior draws order modes by prior-induced amplitude, and at RHINO’s band the
foreground spread (~200 K) sits three to four orders above the 21 cm trough
(~0.2 K), so the direction the campaign exists to measure is the last retained
and the first dropped. score_directions puts d mu / d theta_j for every
named global latent into the candidate set first, which repairs it by
construction rather than by choosing n_S large enough.
- rheplicant.inference.reduced_basis.orthonormal_transform(candidates, rtol=None)[source]¶
(M, kept)withM @ candidatesorthonormal, in candidates’ own metric.Delegates to
bayesmith.exact.reduced_basis.orthonormal_transform()as of the Wave C reduced_basis half-switch (migration ledger D60). Measured bitwise across the seam under x64 before the near-side body was removed:max|delta| = 0.0. That comparison cannot be re-taken now.Two refusals arrive with the delegation and are declared in D60 under iron law 3: the far side refuses ambient float32 at every entry (D41 – the Gram matrix squares the condition number, so the retention cut is 3.4e-04 at float32 against 1.5e-08 at float64, and a foreground-dominated bank silently loses the directions this basis exists to keep), and it refuses a non-2-D candidate array. This module’s own callers all live in the x64 session, so the first never fires for them.
- rheplicant.inference.reduced_basis.orthonormalise(candidates, rtol=None)[source]¶
The orthonormal rows themselves, when the transform is not needed.
Delegates to
bayesmith.exact.reduced_basis.orthonormalise()as of the Wave C reduced_basis half-switch (migration ledger D60). Measured bitwise across the seam under x64 before the near-side body was removed:max|delta| = 0.0. That comparison cannot be re-taken now.Two refusals arrive with the delegation and are declared in D60 under iron law 3: the far side refuses ambient float32 at every entry (D41 – the Gram matrix squares the condition number, so the retention cut is 3.4e-04 at float32 against 1.5e-08 at float64, and a foreground-dominated bank silently loses the directions this basis exists to keep), and it refuses a non-2-D candidate array. This module’s own callers all live in the x64 session, so the first never fires for them.
- rheplicant.inference.reduced_basis.score_directions(space, pipeline, state_template, names=None, at=None)[source]¶
d mu / d theta_j, one row per scalar degree of freedom, per named latent.Section 5 requirement 3, and the repair for the SVD’s blindness: singular values of a prior bank order modes by prior-induced amplitude, so a direction whose amplitude is a thousandth of the foreground’s is the last retained and the first dropped. Seeding these makes each parameter’s signature present by construction, at any
n_S.Built on
forward_fn()rather thanbuild_forward_fn(), which returns a ghost pipeline whose leaves carry no latent names – and “for every named global latent” is the whole requirement.- Parameters:
space (Any) – the
ParameterSpace. Validated against the pipeline byforward_fn.pipeline (Any) – the forward model.
state_template (Any) – the state it is evaluated on.
names (Sequence[str] | None) – which latents.
Nonemeans all of them.at (dict[str, Array] | None) – where to differentiate. Latents are a local property of a nonlinear model, so this is the question “what does the prediction look like here”; defaults to the declared initial values.
- Returns:
{name: (size, n_data)}, ravelled over the data axis, in the ordernamesasked for – or the space’s declared order. A latent of shape(4,)contributes four rows.- Raises:
ParameterSpaceError – if
namesoratmentions a latent the space does not declare.- Return type:
- class rheplicant.inference.reduced_basis.ReducedBasis(rows, weight, predict, reference, seeded=(), orthonormal=True, support=None, reference_values=None)[source]¶
Bases:
ModuleA dictionary of directions in the data space, shared by every epoch.
- Parameters:
- rows¶
(n_S, n_data)directions in the model’s own units. Stored raw rather than only whitened because an epoch’s flag pattern need not match the reference’s, andwhitened / weightis infinite at exactly the samples the reference could not see.- Type:
- weight¶
(n_data,)reference1/sigma, exactly0where the reference epoch had nothing to say. The reference metric: each epoch stores its ownG_eagainst these rows rather than re-orthonormalising, so thatc(theta)is one map for the whole campaign and evaluation costsO(n_S^2)per epoch instead ofO(n_S * n_data). The mismatch that buys is measured, per epoch, by the bias budget (section 7).- Type:
- whitened¶
rows * weightwhere the weight is non-zero and exactly0.0where it is not – a select, not a product, because0.0 * infisnan. Orthonormal whenorthonormal, which is the supported case. Derived at construction and kept as a leaf because every projection reads it.- Type:
- factor¶
(n_S, n_S)upper-triangularRwithR^T R = G. The identity to roundoff once orthonormalised; kept as a leaf so a deliberately un-orthonormalised basis is still evaluable and its conditioning measurable.- Type:
- c_ref¶
coefficients of the reference prediction. The storage origin – every epoch’s statistics are residual-centred on
expand(c_ref), an exact change of variable, which is what keeps the residual chi-square from being the time-bandwidth product.- Type:
- predict¶
values -> prediction. Static, and the reason a basis is code plus numbers: section 10’s behaviour fingerprint exists because these two halves can desynchronise with no shape or dtype change.- Type:
- support¶
{latent: (low, high)}– the region the training bank populated. Section 5 requirement 6: the projection error is uniformly bounded over the prior box, but the fitted coefficient map is a surrogate whose error is measured on draws from the same prior, so it is a prior-weighted in-distribution average and blind to a sparsely-sampled corner the accumulated posterior may concentrate in. The guard is therefore worded as training-bank coverage, and it lives on the basis rather than on the caller because “the region the bank populated” is a fact about the bank.
- reference_values¶
the latent values
referencewas taken at. Recorded so the bias gradient (section 7) and the storage origin are the same point by construction, rather than two callers agreeing to use the same one. A dynamic field, unlikeseededandsupportbeside it: these are arrays, and equinox puts a static field into the treedef, where array__eq__decides treedef equality. With a scalar latent that silently works; with a latent of shape(4,)– whichscore_directionsexplicitly supports – comparing two treedefs raises “the truth value of an array with more than one element is ambiguous”, measured. Equinox says so itself, with “A JAX array is being set as static”, andtests/core/test_basis.pypins the same rule forBind.fn.
- project(prediction)[source]¶
Coefficients of the
N^-1-orthogonal projection ofprediction.Solving with
Grather than taking a bare inner product is what keeps this an orthogonal projector when the rows are not orthonormal. When they are,Gis the identity and the solve is free.
- expand(coefficients)[source]¶
S^T c– back to the whitened data vector, not to raw data.There is no way back to raw data at a flagged sample, where the weight is exactly zero. That is what “not observed” means.
- residual_fraction(direction)[source]¶
||(I - Pi) d|| / ||d||in theN^-1metric – section 5’sr_j.nanwhen the direction is identically zero in this metric: the prediction does not respond to whatever produced it, so there is no direction to be faithful to.basis_fidelitynames that case rather than dividing by zero.
- fingerprint()[source]¶
Content hash of the stored numbers, for the identity refusals.
Two terms compressed against different dictionaries are quadratic forms in different vectors; summing them is not a likelihood. This is what
BayesMemorycompares. It deliberately does NOT coverpredict: the live half needs section 10’s behaviour canary, which is a different check with a different failure mode.- Return type:
- rheplicant.inference.reduced_basis.numerical_rank(whitened_bank)[source]¶
Largest
kwiths_k / s_0 > sqrt(eps)– section 5 requirement 5.Delegates to
bayesmith.exact.reduced_basis.numerical_rank()as of the Wave C reduced_basis half-switch (migration ledger D60). Measured bitwise across the seam under x64 before the near-side body was removed:max|delta| = 0.0. That comparison cannot be re-taken now.Two refusals arrive with the delegation and are declared in D60 under iron law 3: the far side refuses ambient float32 at every entry (D41 – the Gram matrix squares the condition number, so the retention cut is 3.4e-04 at float32 against 1.5e-08 at float64, and a foreground-dominated bank silently loses the directions this basis exists to keep), and it refuses a non-2-D candidate array. This module’s own callers all live in the x64 session, so the first never fires for them.
- rheplicant.inference.reduced_basis.select_svd(whitened_bank, count)[source]¶
The
countleading right singular directions of the bank.Delegates to
bayesmith.exact.reduced_basis.select_svd()as of the Wave C reduced_basis half-switch (migration ledger D60). Measured bitwise across the seam under x64 before the near-side body was removed:max|delta| = 0.0. That comparison cannot be re-taken now.Two refusals arrive with the delegation and are declared in D60 under iron law 3: the far side refuses ambient float32 at every entry (D41 – the Gram matrix squares the condition number, so the retention cut is 3.4e-04 at float32 against 1.5e-08 at float64, and a foreground-dominated bank silently loses the directions this basis exists to keep), and it refuses a non-2-D candidate array. This module’s own callers all live in the x64 session, so the first never fires for them.
- rheplicant.inference.reduced_basis.select_greedy(whitened_bank, count)[source]¶
Greedy EIM-style selection: the worst-represented draw, repeatedly.
Delegates to
bayesmith.exact.reduced_basis.select_greedy()as of the Wave C reduced_basis half-switch (migration ledger D60). Measured bitwise across the seam under x64 before the near-side body was removed:max|delta| = 0.0. That comparison cannot be re-taken now.Two refusals arrive with the delegation and are declared in D60 under iron law 3: the far side refuses ambient float32 at every entry (D41 – the Gram matrix squares the condition number, so the retention cut is 3.4e-04 at float32 against 1.5e-08 at float64, and a foreground-dominated bank silently loses the directions this basis exists to keep), and it refuses a non-2-D candidate array. This module’s own callers all live in the x64 session, so the first never fires for them.
- rheplicant.inference.reduced_basis.build_reduced_basis(space, pipeline, state_template, *, noise, bank, n_basis, at=None, names=None, method='svd', seed_scores=True, support=None, extra_directions=None)[source]¶
Score directions first, bank directions after, orthonormalised once.
The order is the substance. Seeding puts every named latent’s signature in the span at any
n_basis; the bank then completes it with whatever else the prior actually produces, chosen on the residual after the scores so the leading singular direction does not simply restate the mean.Measured on the four-latent RHINO fixture (60-85 MHz, 128 channels, 400 draws), the residual fraction of the
t21_depthscore direction against a plain SVD basis is0.562atn_S = 3; seeding that one direction at the samen_S = 3takes it to1.5e-16. The repair is complete, not incremental, and it does not depend on choosingn_Slarge enough.Where the deletion stops depends on the bank, and both numbers are real. That fixture recovers the direction unseeded by
n_S = 4, because four near-linear latents span their own tangent space once four vectors are allowed. A richer pre-planning bank measured0.3147atn_S = 3,0.0289at 5,0.0040at 8 and0.0000only at 13. Seeding is what makes the answer independent of which of those a campaign happens to have.- Parameters:
space (Any) – the model.
pipeline (Any) – the model.
state_template (Any) – the model.
noise (Any) – the epoch’s
NoiseModel, evaluated at the reference prediction to give the metric. UnderRadiometerNoisethis is the frozen-N step of section 8, and the caller owes it anoise_frozen_atprovenance downstream.bank (Array) –
(n_draws, ...)predictions at draws from the prior. The training bank, whose extent is thesupporta T1 term claims.n_basis (int) –
n_S. Refused above the bank’s numerical rank.at (dict[str, Array] | None) – where the reference prediction and the scores are taken.
names (Sequence[str] | None) – which latents to seed.
Nonemeans all of them.method (str) –
"svd"or"greedy"for the non-seeded remainder.seed_scores (bool) – off only to build the failure the tests pin.
support (dict[str, tuple[float, float]] | None) –
{latent: (low, high)}covered bybank. Carried onto every term compressed against this basis, so that “the region the bank populated” is recorded where it was known rather than re-promised by each caller.extra_directions (Array | None) –
(k, n_data)rows in the model’s own units, placed in the candidate set immediately after the scores and before the bank residual is taken. This is where an epoch’s affine nuisance design goes. It is a declared part of the span rather than something the bank is expected to discover: section 4.2(b) integratesphi_eout by expanding it in the same dictionary, and a nuisance column the dictionary cannot represent is one the marginalisation cannot remove – it reappears as signal, with every other diagnostic clean.compress_reduced_basisrefuses such a column rather than projecting it quietly, and this argument is the remedy its message names.
- Raises:
StateValidationError – if
n_basisis below the number of declared rows, above the bank’s numerical rank, or if the declared rows are themselves linearly dependent.- Return type:
- class rheplicant.inference.reduced_basis.FidelityReport(residuals, full, projected)[source]¶
Bases:
ModuleWhat a basis retains of each named latent’s signature.
- Parameters:
full (FlatMatrix)
projected (FlatMatrix)
- residuals¶
{name: r_j}withr_j = ||(I - Pi) dmu/dtheta_j||_{N^-1} / ||dmu/dtheta_j||_{N^-1}.nanwhere the prediction does not respond to the latent at all.
- full¶
the Fisher of the score directions against the data, named rows.
- projected¶
the same Fisher after projection onto the basis. The difference between the two is what the truncation cost, and D14’s named-row rendering is what attaches a latent’s name to a collapsed eigenvalue – a scalar fidelity number names no culprit, which is the whole reason section 5 requirement 4 exists.
- refuse_above(tolerance)[source]¶
Raise if any direction is worse than a declared tolerance.
Two failures, deliberately separate messages. A direction the basis cannot represent is a truncation to fix; a direction that does not exist is a model to fix, and reporting the second as the first would send the caller to raise
n_Sagainst a derivative that is identically zero.- Parameters:
tolerance (float)
- Return type:
None
- rheplicant.inference.reduced_basis.basis_fidelity(basis, scores)[source]¶
Per-direction fidelity, plus the two named Fishers – section 5 requirement 4.
- Parameters:
basis (ReducedBasis) – the dictionary under test.
scores (dict[str, Array]) –
{name: (size, n_data)}fromscore_directions().
- Returns:
A
FidelityReport. Its matrices are rendered in flatten order, derived from the actual flattening of a template rather than from the orderscoreshappens to iterate in –jaxsorts a dict’s keys, and a matrix built in one order and labelled in the other is wrong by a permutation that is the identity exactly when the latents are named alphabetically.- Return type:
What the twin remembers of a campaign after the recordings are archived.
The invariant is one sentence: the memory holds likelihood factors and
exactly one prior. Everything else in this module exists to make that
impossible to violate by accident. Stored terms are prior-free
(QuadraticLikelihood), the prior
lives on the Factorization’s global
latents, and the two accessors below differ by exactly one application of it.
Three refusals are worth the words they cost, because each one otherwise produces a smooth, correctly-shaped, over-confident answer:
The same night twice. A retried epoch appended again adds its information a second time; the posterior stays centred and narrows by
sqrt(2)for a duplicated batch. Terms therefore carry the recording’s data hash andrememberrefuses a repeat, in the posture D17 takes onBeamSpillOperatorplusGroundPickupOperator: legitimate double-counting is a choice made deliberately, withduplicate=True.Two estimators. Full-likelihood and GLS terms (D21/D23) are different estimators; their sum is neither.
A tempered term. A term carrying a share of the prior breaks the invariant, and then
log_posteriorwould apply the prior twice.
There are two accumulators, not one. A T2 term’s stored numbers are a
quadratic form in theta; a T1 term’s are a quadratic form in the basis
coefficients, because c(theta) is nonlinear and that is the whole reason
the tier exists. Summing the two would be the same error as summing terms over
different latents, one level down, so they are kept apart and
BayesMemory.log_likelihood() adds their densities rather than their
factors. SqrtInfo.combine refuses the mixture by name in any case, which is
what makes the routing defence in depth rather than the only defence.
Each accumulator’s pytree keeps a fixed treedef for the life of a campaign.
That is not tidiness: an eqx.filter_jit-ed log-density over a pytree whose
child count grows with N retraces once per epoch, which is measurable well
before the thousand epochs the design targets. Note what is fixed and what is
not – archive gains one term per epoch by design, so it is the density
path (the two accumulators plus the shared dictionary) that keeps its shape,
and BayesMemory.to_numpyro_model() closes over exactly that rather than
over self.
The archive is one pytree leaf, not one per term, and the reason is not the
sum. An earlier version of this sentence said the archive “is never re-summed
on the sampling path”, which was true and beside the point: nothing summed it,
and it still cost O(N) per call. Equinox wraps every non-magic bound method as a
BoundMethod, which is a Module with a dataclass __init__, and that
constructor flattens (args, kwargs) – self among them – to check each
leaf for a jax-transformed function. So memory.log_likelihood(v) paid for
every array in every stored term before executing a line of its own body –
measured at 1,000 / 2,000 / 4,000 epochs, 1.43 / 3.18 / 7.24 ms – and a
NUTS chain pays that once per leapfrog step.
Holding the terms behind _Archive, which is not a registered pytree
node and is therefore a single opaque leaf, makes the memory’s leaf count
independent of the campaign’s length: 12,007 leaves at 4,000 epochs before, 8 at
any length after. The same three sizes then measure 0.13 / 0.16 / 0.15 ms,
which is flat and is also nearly free – accumulated.log_prob on the same
values costs 0.109 ms at N = 4,000, so what the memory’s own wrapper still adds
is 0.025 ms and a constant. remember fell from 2.42 / 4.51 / 10.22 ms per
epoch to 0.32 / 0.36 / 0.37, and a 4,000-epoch campaign from 27.0 s to 1.7 s.
eqx.field(static=True) would also have taken the terms out of the leaf list,
and would have been wrong: a static field goes into the treedef, where array
__eq__ decides treedef equality. Equinox warns “A JAX array is being set as
static” for exactly that, and both ReducedBasis.reference_values and
BayesMemory.basis carry the same note. An opaque leaf keeps the arrays on
the dynamic side, where their identity rather than their contents is compared.
The one thing that becomes explicit rather than automatic is serialisation:
eqx.tree_serialise_leaves walks leaves it recognises and skips one it does
not, silently, so rheplicant.inference.archive now writes
(memory, tuple(memory.archive)) and says so in its format version.
- rheplicant.inference.memory.reject_bad_term(term, held, ids, duplicate, latents_ok, represents, shared_inputs, repeat_remedy)[source]¶
The admission rules every accumulator shares.
latents_okis the one question a bag and a chain answer differently: a bag refuses a term carrying a linked latent’s columns, a chain requires one. Everything else – the protocol members, the prior share, the estimator, the repeated epoch, the shared input product – is identical, and identical is what it has to be, because a rule enforced in one accumulator and not the other is a rule with a way round it.representsandshared_inputshave no defaults, and that is the point of adding them here rather than in each caller: a default would let a third accumulator be written that never passes them and never runs section 9.5’s refusal, which is the way round this function exists to close.- Parameters:
term (CompressedLikelihood) – the candidate.
held (tuple[CompressedLikelihood, ...]) – the terms already accumulated, oldest first. The first is read for the estimator; all of them for the input-product clash.
duplicate (bool) – allow an
epoch_idalready present.latents_ok (Callable[[CompressedLikelihood], None]) – raises if this term’s columns do not belong here.
represents (Any) – the factorization’s
{input product: global latents}. Only its keys are read – a product modelled as a latent is integrated with the rest of theta, so sharing its hash is no longer a claim of independence about something unmodelled.shared_inputs (bool) – admit a term whose input product is already held. D17’s posture, the same one
duplicatetakes: legitimate double-counting is a choice made deliberately and by name.repeat_remedy (str) – what the caller should do about an
epoch_idthat is already held. The rule is shared and the remedy is not, and that split is measured rather than stylistic: the bag’s remedy isduplicate=True, which a chain refuses outright, because a chain appends the repeat last and so reorders the campaign as well as double-counting it. A single sentence here would have told half the callers to pass a flag that raises. No default, for the reasonrepresentshas none.
- Return type:
None
- class rheplicant.inference.memory.BayesMemory(factorization, accumulated=None, archive=(), coefficients=None, basis=None)[source]¶
Bases:
ModuleAccumulated evidence from a campaign, sampled without the raw data.
- Parameters:
factorization (Factorization)
accumulated (SqrtInfo)
archive (Any)
coefficients (SqrtInfo | None)
basis (Any)
- factorization¶
the single declaration – which latents are global, and the campaign’s only prior.
- coefficients¶
the second running
SqrtInfo, over the reduced basis coefficients.Noneuntil the first T1 term arrives.- Type:
- basis¶
the shared
ReducedBasisthatcoefficientsis a quadratic form in.Nonealongside it. A dynamic field: it holds arrays, and equinox puts a static field into the treedef, where array__eq__decides treedef equality – it warns “A JAX array is being set as static” for exactly this, andReducedBasis.reference_valuescarries the same note for the same reason.- Type:
Any
New fields go last, with defaults: Plan A’s tests construct this positionally as
BayesMemory(factorization, accumulated, archive), and that third argument is still a plain tuple of terms.- property archive: tuple[CompressedLikelihood, ...]¶
The terms as remembered, oldest first.
Kept for diagnostics, re-anchoring and the smoother. The one part of the memory that legitimately grows with the campaign – and therefore the one held behind
_Archive, so that growing costs one pytree leaf rather than N.A read-only property rather than a field, because the stored object is not the tuple: every reader iterates in Python (
len,archive[0], a comprehension over the terms), so this hands back the plain tuple they already expect and the wrapper stays an implementation detail of the flattening. Documented here and not in the class’sAttributesblock because it is no longer a field at all, and describing it in both places is what autodoc reports as a duplicate object description.
- remember(term, duplicate=False, shared_inputs=False)[source]¶
A new memory holding this term as well. The original is unchanged.
- Parameters:
term (CompressedLikelihood) – one epoch’s compressed likelihood.
duplicate (bool) – allow an
epoch_idalready present. Off by default, because the common cause is a retried run, and the effect is a posterior that narrows for no reason.shared_inputs (bool) – allow an input product this memory already holds under the same hash. Off by default: two nights built from one calibration solution are not conditionally independent, and summing them is a shared error with no variance to give it away.
- Return type:
- log_likelihood(values)[source]¶
Sum of the stored factors, quadratic and reduced-basis alike. No prior.
The two accumulators are added as densities, one forward call apart: the reduced half is a quadratic in
c(theta) - c_ref, so evaluating it costs one pass through the model to getcand thenO(n_S^2), independent of how many epochs went into it.
- fisher(at=None)[source]¶
sum_e F_eover the stored terms, with named rows.Excludes the prior’s curvature, so it may legitimately be singular at small N: a single epoch usually constrains only a subspace, and that is exactly what the square-root form is for.
Permuted into flatten order, not left in declared order. A
FlatMatrixcarries the treedef its rows were flattened against, andjaxsorts a dict’s keys, whileSqrtInfo’s columns follow the order the latents were declared in. For a space declared("width", "depth")the two disagree, and returning the raw accumulator would hand back a matrix whosestructurefield describes an ordering the numbers do not have. Measured on that space with per-latent information 9 and 49: unpermuted,matrixreadsdiag(9, 49)againstnames=("width", "depth")whilestructuresays{'depth', 'width'}.Nothing downstream returns wrong numbers today –
sigmaandblockreadnamesandspanstogether, andpropagate_covariancecatches the mismatch on its_named_spanscheck. But it catches it as “computed for {‘width’: (), ‘depth’: ()} but params is {‘depth’: (), ‘width’: ()}”, which reads as a shape disagreement between two identical shapes. Permuting here removes the cause instead: every other named matrix in the package derives its names from the actual flattening, deliberately “rather than from an assumption about dict ordering”, and this was the one place that did not.- Parameters:
at (dict[str, Array] | None) – where to pull a reduced-basis term’s coefficient-space information back into theta. Required once the memory holds one, and refused rather than defaulted – see
_theta_fisher. Ignored for a memory of T2 terms alone, whose information is already a quadratic in theta.- Return type:
- to_numpyro_model(**unsupported)[source]¶
A NumPyro model that samples the global latents against this memory.
Unlike
to_numpyro_model()there is no pipeline, no observed data and no noise model here: the terms already absorbed all three. Passingnoise_std=is therefore refused rather than ignored – silently ignoring it would let a caller believe they had changed the likelihood.Both accumulators reach the factor. A memory holding only T1 terms has an empty theta accumulator, so a model built on
accumulatedalone would sample a smooth, finite, perfectly well-behaved posterior that is exactly the prior – no error, no warning, and no data. The closure is over the density path (both accumulators and the dictionary) and deliberately not overself:archivegrows with the campaign, and capturing it would retrace the sampler’s log-density once per epoch.- Parameters:
unsupported (Any)
- audit(at=None, bias_tolerance=None, systematic_floor=None, modelled=())[source]¶
What the memory can say about its own trustworthiness.
fisher_lambda_minandfisher_conditiondescribe the theta accumulator alone, and there is no honest way to merge the two: the second accumulator’s curvature is in coefficient space, and mapping it back throughdc/dthetawould be a Fisher at one point rather than a property of the stored numbers. So it gets its own pair, and the dictionary it is a form in is named. Reporting only the first for an archive holding both would sayfisher_lambda_min = 0andfisher_condition = inffor a T1-only campaign, which reads as a degenerate memory when the memory is simply not quadratic in theta.bias_over_sigmais section 7’s budget, per named direction, andunconstrainednames the directions whose ratio is0/0.- Parameters:
at (dict[str, Array] | None) – where to pull the coefficient-space information back into theta for the bias ratio. Defaults to the basis’s recorded
reference_values– and that default is the point, not a convenience: the stored gradients were taken at the storage origin, so a Fisher taken anywhere else makes the ratio a quotient of two different linearisations.fisher()refuses to default it for exactly the opposite reason – there the question has no privileged point.bias_tolerance (float | None) – refuse when any constrained direction’s
|bias| / sigma_Nexceeds this. Directions the campaign does not yet constrain are listed under"unconstrained"instead of refused: their ratio is0/0, and treating that as a failure would refuse every young campaign.systematic_floor (dict[str, float] | None) –
{global latent: declared prior width of the shared calibration products, in that latent's units}. Section 9.4. Refuse to report a posterior tighter than that floor while those products are unmodelled. Seesystematic_floor()for what the number means and how the crossing epoch is computed.modelled (tuple[str, ...]) – the shared input products this campaign does model, by name. Each must appear in
factorization.represents, whose latents are then exempt from the floor – a product carried as a global latent is integrated with the rest of theta, so its uncertainty is insidesigma_Nrather than under it.
- Return type:
A nuisance that drifts across epochs, and the recursion that integrates it out.
A per_epoch latent is re-drawn every night and integrated away inside its own
epoch. A linked one is not: it is a Markov chain, and condition C1b says that
declaring one per_epoch marginalises a single physical fluctuation N times
against independent priors, injecting information that is not there. The exact
alternative is this module.
The recursion. Carry a joint square-root information factor over
(theta, zeta_e). Fold in an epoch by stacking its rows and re-triangularising
– combine()’s arithmetic. Advance
to the next epoch by widening to (theta, zeta_e, zeta_{e+1}), appending the
transition’s rows, and marginalising zeta_e: permute it first,
re-triangularise, drop row and column. That drop is the Schur complement, in
square root, which is what keeps a thousand-epoch accumulation inside float64
where the explicit (F, b) form goes indefinite. theta is never
marginalised, so what comes back is log p(d_1:N | theta) exactly.
Two sub-scopes, because “linear-Gaussian” is not enough. An OU with an
inferred correlation time is still linear-Gaussian, so a caveat phrased that way
is satisfied while its claim fails: Q(theta), phi(theta) and the Schur
complement all become functions of theta, and a filter run at compression time
pins them silently. The distinction lives in the type: a
LinearGaussianTransition holds numbers and the theta posterior is exact
under filtering; a HyperTransition holds a builder and is resolved
inside the theta likelihood, so the whole recursion is a differentiable
lax.scan over the stored per-epoch blocks. One code path serves both, because
the recursion is traceable either way – which is also why the fixed case is
validated by the same tests rather than by a second implementation of the same
arithmetic.
The constant bookkeeping is not optional, and it is where this module can be
wrong while looking right. Six constants reach the answer; the recursion’s
shape, gradient and curvature are correct without any of them. Measured on
tests/evidence/chain_bank.py at theta = (0.4, -1.1), the cost of
dropping one:
dropped |
nats |
|---|---|
initial |
+0.9189 |
per-transition |
+2.8618 |
the spec’s shorthand for it ( |
+4.5947 |
marginalisation constant, six of them |
+7.2619 |
the fold corner |
+45.9502 |
the masked data normalisation |
-6.8408 |
Two of those belong to nobody else. The corner is
combine()’s and the data
normalisation is compress’s, and a reader will
assume the rest are handled elsewhere too; the initial prior normalisation
and the final marginalisation are new here.
Note also what does not appear: the marginalisation’s own corner is exactly
zero in this recursion, because that QR is square and upper[keep:, width] is
a length-zero slice. Measured through the filter, deleting it moves the answer by
0.0 nats bit for bit. A test asserting it matters would pass vacuously, so
tests/evidence/test_chain_filter.py pins the zero instead and pins the
fold’s corner as the one that is worth 45.95.
- class rheplicant.inference.chain.LinearGaussianTransition(phi, process_std, initial_std, initial_mean=None, hyper=())[source]¶
Bases:
Modulezeta_{e+1} = phi zeta_e + w,w ~ N(0, diag(process_std)^2).- Parameters:
- phi¶
(n, n). A full matrix, because a multi-component drift can rotate; the process and initial spreads are diagonal because a correlated innovation is a modelling claim nobody has made and a silently-accepted full covariance would need its own Cholesky refusal.- Type:
- hyper¶
empty for a fixed transition. Present so that
Factorizationcan ask one question of either type.
Positivity is checked here and nowhere else, on purpose. The rows this class contributes are what constrain every
zeta_e, so a strictly positive spread makes each marginalisation’s block full-rank by construction – and that is what lets the filter callmarginalise_arrays()inside alax.scaninstead of the checkedmarginalise(), which concretises and therefore cannot be traced or differentiated. One eager check at declaration, not one traced check per epoch of a thousand.A traced spread is not checked, and cannot be: a
HyperTransitionbuilds these blocks from theta, and under NUTS theta goes wherever it likes. Parameterise the builder so that positivity is structural – returnjnp.exp(log_sigma), never a raw sampled scale – which is why this class takes standard deviations rather than a covariance.
- rheplicant.inference.chain.ornstein_uhlenbeck(tau, sigma, width=1, hyper=())[source]¶
A stationary OU chain: correlation time
tauin epochs, spreadsigma.phi = exp(-1/tau)andprocess_std = sigma sqrt(1 - phi^2), sovar(zeta_{e+1}) = phi^2 var + Qreturnssigma^2when it starts there – stationarity is arithmetic here, not an assumption, andtests/evidence/test_transition.pypins it.A function, not a class: section 11’s sketch writes
OrnsteinUhlenbeck(...)as though it were a type, but the type the filter consumes – and the type aHyperTransitionbuilder must return – isLinearGaussianTransition. An OU is a way of constructing one, and this package spells constructors in lower case.
- class rheplicant.inference.chain.HyperTransition(build, hyper, width)[source]¶
Bases:
ModuleA transition whose blocks are functions of theta – section 6’s
linked_hyper.- Parameters:
- build¶
{global latent: value} -> LinearGaussianTransition. Static: it is code. Called inside the theta likelihood, so it must be traceable, and it must return blocks that are positive for every theta the sampler can reach –expof a sampled log-scale, not a sampled scale.
- hyper¶
which global latents
buildreads. Declared rather than inferred, becauseFactorizationchecks them and a closure’s free variables are not inspectable.
- width¶
how many components the chain carries. Declared for the same reason the shape of anything else is: the filter needs it before it has a value to look at.
- Type:
- rheplicant.inference.chain.chain_marginal(blocks, transition, values, names, shapes)[source]¶
zeta_1:Nintegrated out exactly, leaving a quadratic form in theta.- Parameters:
blocks (tuple[Array, Array, Array]) –
(factor (N, w, w), target (N, w), offset (N,))– one square per-epoch joint form over(*names, zeta),zeta’s columns last. Square rather than ragged becauselax.scanneeds one shape per iteration;SqrtInfo.combine(SqrtInfo.null(...), info)is the padding, and it is the same QR the accumulator uses, so the offset it produces is the one this consumes, corner included.transition (Any) – a
LinearGaussianTransitionor aHyperTransition. Resolved once, here, againstvalues– which is what makes an inferred correlation time inferred rather than pinned at compression time.values (dict[str, Array]) – the global latents. Read for the transition’s hyperparameters and for the returned form’s evaluation point.
names (tuple[str, ...]) – the global latents, in the same column order the blocks were built in.
shapes (tuple[tuple[int, ...], ...]) – each of those latents’ shapes, in the same order.
- Returns:
A
SqrtInfoovernameswhoselog_probislog p(d_1:N | theta). Prior-free, like every other stored factor in this layer.- Return type:
Why the scan stops one short, and what that is *not* about. The plan this was built from warned that scanning over all
Nblocks and then marginalising once more would integrate azeta_{N+1}no data constrained and come back “finite and wrong by one transition’s worth of constants”. Measured, by writing it that way: it comes back exact, 4.5e-13 from the dense oracle at all four probes – because the extra transition density integrates to one over its own argument, so its normalisation and the extra marginalisation’s constant cancel term for term. The reason to stop one short is therefore cost, one QR per call, not correctness. What the extra step does change is the count of each constant, five transitions becoming six and six marginalisations becoming seven, which is whattest_the_transition_normalisation_is_the_whole_density_not_half_of_itandtest_the_marginalisation_constant_is_carriednotice and the exactness tests cannot.
- rheplicant.inference.chain.chain_log_likelihood(blocks, transition, values, names, shapes)[source]¶
log p(d_1:N | theta), the chain integrated out exactly. No prior.
- rheplicant.inference.chain.smooth(blocks, transition, values, names, shapes)[source]¶
p(zeta_e | d_1:N, theta)for every epoch – mean and variance.thetais conditioned on, not marginalised: the question a smoother answers is “given this receiver model, what did the drift do?”, and marginalising theta would answer a different one with the same shapes.How, and why not the classical backward pass. Section 6 names an RTS smoother. What this computes is the same quantity – the exact smoothed marginals – by assembling the block-tridiagonal joint information form over
zeta_1:N(each epoch’s stored rows with theta substituted, the initial prior, and the transition couplings) and triangularising it once. The arithmetic is thenSqrtInfo’s and the transition rows are the filter’s, so there is one implementation of the algebra rather than two; the failure mode of two is that one of them gets fixed. It is an offline diagnostic and not on the sampling path, so what this costs buys the absence of a second numerical route.What it costs is ``O((N n_zeta)^2)``, not ``O(N n_zeta^2)``. The variances are the row norms of
R^-1, andR^-1is a denseT-by-Ttriangular solve however sparseRis – 8 MB of float64 at a thousand epochs of a scalar chain, 128 MB at four thousand. Affordable offline and not on the filter’sO(1)carry, which is the whole reason these are two functions.The covariance does not depend on ``theta``, and the mean does. For a linear-Gaussian chain the posterior spread of the drift is a property of the designs, the noise and the transition alone, so a test that pinned only the covariance would be blind to every error in how
thetais substituted – which is whytests/evidence/test_chain_smoother.pypins the mean at four probes and the full covariance once.- Returns:
(mean (N, n_zeta), variance (N, n_zeta)). Variances rather than full per-epoch covariances because that is what the diagnostics read and because a full one invites the reader to believe the cross-epoch blocks are in there; they are not returned, though the joint form has them and_joint_covarianceis where the tests get at them.- Parameters:
- Return type:
- class rheplicant.inference.chain.ChainMemory(factorization, stacked=None, epochs=())[source]¶
Bases:
ModuleA campaign whose nuisance drifts across epochs. Ordered.
The difference from
BayesMemoryis one sentence: a bag is exchangeable and a chain is not, so a bag can fold each term into a running QR and forget it, while a chain must keep the per-epoch blocks and run the recursion. Section 6 puts that distinction in the type rather than in a flag, and the two refusals are symmetric –BayesMemory.rememberrefuses a term carrying a linked latent’s columns, and this one requires it.The stack grows, and a jitted density therefore retraces once per night. Section 11’s compile-cost measurement applies to the bag’s fixed-treedef accumulator; a chain cannot have one, because section 6 spends O(N) work per likelihood call by design – that is what buys an exact inferred correlation time. Measured: one trace per
rememberand none thereafter. During a NUTS run N is fixed, so the cost is one compilation, not one per step.Ordered is a property of the type, not of ``remember``. The stack is what the recursion reads and the archive is what names it, and until
_reject_a_foreign_stack()existed the constructor related the two in no way at all – a reversed archive over an unchanged stack, a reversed stack under an unchanged archive, six blocks with two epochs and six blocks with none were all accepted, and the first two answer with a plausible number. See that function for what each one costs.- factorization¶
the single declaration. Its
linkedentry supplies the transition, and__check_init__has already refused a transition built from anything that is not global.- Type:
Any
- stacked¶
(factor (N, w, w), target (N, w), offset (N,)), epochs in the order they were remembered,zeta’s columns last. Checked at construction against the archive, block by block.
- property epoch_ids: tuple[str, ...]¶
The recordings’ data hashes, in the order they were remembered.
- remember(term, duplicate=False, shared_inputs=False)[source]¶
A new memory holding this epoch last. The original is unchanged.
Order is the content here, not a convenience: epoch e’s drift is correlated with e-1’s and not with e+3’s, so appending out of order is a different model rather than the same one shuffled. Measured on
tests/evidence/chain_bank.py, swapping two adjacent epochs moves the campaign’s log-likelihood by 0.0752 nats; a bag’sremembermoves it by roundoff, which is what its own tests pin. Small in absolute terms and 1e12 times the recursion’s own 9.1e-13 disagreement with the dense oracle, which is the comparison that makes it evidence.``duplicate=True`` is refused here, where the bag takes it, and the asymmetry is the same one the two types exist to carry. For a bag it means “count this recording twice”: the terms are exchangeable, the result is a well-defined posterior that is too narrow by a known amount, and a caller who wrote it meant it. A chain has no such reading. The repeat lands last, so it does not say “
e1twice”, it says “e1happened, thene2, thene1again” – one night’s drift correlated with itself across two transitions. Measured atPROBES[0]:('e0', 'e1', 'e2')is -58.9892 nats, appendinge1gives -68.6127, and the same double count placed in order gives -68.4998. The 0.1129 nats between the last two is the part no bag can produce, and it is larger than the 0.0752 above. There is no flag value that means “twice, in the right place”, because a chain has no right place for a night that happened once: useBayesMemoryif the epochs really are exchangeable, or give the second recording its ownepoch_id.- Parameters:
term (Any) – one epoch’s compressed likelihood, over this memory’s globals and its linked latent.
duplicate (bool) – refused. Present so that a caller who reaches for the bag’s flag gets a refusal that says why rather than a
TypeErrorabout a keyword.shared_inputs (bool) – allow an input product this memory already holds under the same hash. Section 9.5, and it is the bag’s rule reused rather than restated: a chain already says the epochs are dependent through
zeta, and a shared calibration solution is a second dependence the chain does not model.
- Return type:
- fisher(at)[source]¶
sum_e F_eafter the chain is integrated out, with named rows.atis required rather than defaulted, for the reasonfisher()refuses to default it one layer along: with aHyperTransitionthe marginal curvature is a function of theta, and a fixed default point would be a linearisation nobody declared and nothing could see – the matrix comes back finite, symmetric and PSD whichever point it was taken at.The permutation into flatten order is
fisher()’s, reached by wrapping the marginal in a throwaway bag rather than by building aFlatMatrixhere. A second copy would reintroduce Plan A’s own bug invisibly:chain_marginalreturns columns in declared order, and the two orders coincide exactly when the latents are alphabetical.
- to_numpyro_model(**unsupported)[source]¶
Sample the globals against this chain. Refuses a
noise_std=.The closure is over the stacked blocks and the transition – the density path – and not over
self, which also holds the archive. That archive grows with the campaign, but the stack does too (deviation 12), so what this buys is one retrace perrememberrather than none: N is fixed for the whole of a sampling run, and the compilation is paid once.- Parameters:
unsupported (Any)
What a campaign can say about its own trustworthiness, from stored terms alone.
A separate module from rheplicant.inference.memory for two reasons. The
practical one: memory.py is already near this project’s 800-line ceiling.
The one that matters: a diagnostic is something you run on a memory, not
something a memory is, and the import graph should say so. Nothing here reads a
pipeline, a forward model or a byte of raw data – only the fixed-size summaries
compress_linear() stored while the data
still existed.
The blindness is the design, not a gap. Section 9’s honest content is that a deterministic error shared across every night splits into two halves, and only one of them is visible from data at all:
the out-of-span half leaves a residual, and is what
coherent_mode()reports – at sqrt(N), because a mean over N epochs is resolved at sqrt(N);the in-span half is absorbed into theta identically in every epoch. It leaves no residual, so it passes chi-square, split-half and leave-one-out, and it biases the answer without limit as the campaign grows.
That is why sections 9.4 and 9.5 are refusals based on what the analyst declares rather than reports based on the numbers: there is no statistic to improve.
- class rheplicant.inference.diagnostics.EpochResidual(epoch_id, chi2, dof, reduced_chi2, templates)[source]¶
Bases:
objectOne night’s row of section 9.3’s table.
A plain frozen dataclass rather than an
eqx.Module: this is a report, it is never differentiated, never jitted and never stored, and making it a pytree would invite someone to put it somewhere that flattens.- reduced_chi2¶
chi2 / dof, ornanwhendofis zero – an epoch whose design saturates its data has no residual to speak of, and a zero here would read as a perfect fit.- Type:
- rheplicant.inference.diagnostics.epoch_residuals(terms)[source]¶
Section 9.3’s per-epoch table, in the order the terms were given.
Order is preserved rather than sorted by anything: for a
ChainMemorythe archive order is the campaign’s time order, and a diagnostic that reordered it would make a drift look like scatter.- Parameters:
- Return type:
tuple[EpochResidual, …]
- rheplicant.inference.diagnostics.coherent_mode(terms)[source]¶
Is there a fault common to every night? – section 9.3’s whole question.
A deterministic error shared across epochs – one calibration solution, one beam model, one flag table – contributes no variance. Split-half agrees to roundoff, leave-one-out returns the same scores, the posterior width is the same array element for element, and the answer is wrong. What it does move is a mean, and a mean over N epochs is resolved at sqrt(N), which is why this reports z-scores rather than magnitudes.
Section 9’s list of what a common mode passes includes “per-epoch chi-square”, and measurement says otherwise: its mean is exactly what
chi2_zbelow reports as a detection, and its scatter is inflated by noncentrality – 5.5467 againstsqrt(2 * 6) = 3.4641on this fixture, with no new randomness injected. The statistic whose scatter a shift really does leave alone is the named template projection, below.Two statistics, and neither is a substitute for the other.
chi2_zneeds no guess about what the fault looks like and therefore cannot say what it is; a named template says what it is and is silent when the guess was wrong – measured on the repeated-design fixture, a template orthogonal to the true mode givesz = -1.01whilechi2_zstill gives+31.92.scatteris reported beside every mean and is the part a reader should look at second: a mean-level fault leaves it at 1.0 – measured 1.0020 on the clean campaign and 1.0020 on the biased one, the same number to four decimals – while an under-estimated noise model raises both together.What this cannot see. The half of a coherent error lying inside the design’s column space is absorbed into theta identically in every epoch. It leaves no residual, so it is invisible here, invisible to a held-out z, and invisible to split-half. On the fixture this docstring’s numbers come from, that half has whitened norm
2.3070against the visible half’s2.0094– comparable, and one of them reportable. That is not a gap to be closed with a better statistic; it is why sections 9.4 and 9.5 are refusals based on what the analyst declares.
- class rheplicant.inference.diagnostics.HeldOut(epoch_id, chi2, dof, z)[source]¶
Bases:
objectOne night’s row of section 9.1’s table.
A frozen dataclass for the same reason
EpochResidualis one: a report is never differentiated, never jitted and never stored.
- rheplicant.inference.diagnostics.held_out_z(terms, prior_fisher, prior_mean=None)[source]¶
Section 9.1: how surprising is each night to the rest of the campaign?
For a linear-Gaussian model this is exact and needs no simulation. Write the leave-one-out posterior
N(mu_{-e}, Sigma_{-e})and the epoch’s own factor[R_e | z_e]. Thenz_e = R_e theta_true + epswith unit-covarianceeps– that is what the square-root form means – andmu_{-e} - theta_trueis independent ofeps, som = R_e mu_{-e} - z_e ~ N(0, I + R_e Sigma_{-e} R_e^T)and
m^T (I + V)^-1 mis chi-square onrank(R_e)degrees of freedom. The returnedzis that, standardised.Computed from the archive rather than by downdating the accumulator: a QR accumulation cannot be un-summed stably. The campaign total is formed once in
(F, b)form and one epoch’s contribution subtracted per row, which isO(N)rather thanO(N^2)and loses at mostlog10(N)digits of the sixteen float64 carries – affordable because this is an offline diagnostic, and safe because the subtraction is of one PSD summand out of N, not of a triangular factor out of its own product.What it can see, measured. On a campaign whose nights genuinely differ, a single rogue epoch scores
+72.96while the largest of the other 59 scores4.24; and a common-mode error over 300 varying nights lifts the campaign mean to+22.05sigma against+0.87for the clean run.What it cannot see, measured. If every night carries the same design – the realistic case, and the one section 1 describes – a coherent error’s in-span half shifts
z_eandmu_{-e}by amounts that cancel inm. The clean and the biased campaign then return the same scores: the largest disagreement over 640 epochs is4.9e-05, and it shrinks as the prior’s share of the posterior does –1.9e-04at N = 160 against4.9e-05at N = 640 – while the answer is wrong by52.6sigma. The spec promotes this diagnostic to primary; it is primary for a single rogue night and for a campaign whose nights genuinely differ, and it is blind to the fault section 12.11 is about. Read it besidecoherent_mode(), never instead of it.- Parameters:
terms (Sequence[Any]) – the archive, in any order – this statistic is exchangeable even when the campaign is not, because each epoch is scored against all the others.
prior_fisher (Any) –
F_priorover the same latents in the same column order. Required, not optional:Sigma_{-e}is singular at small N without it, and section 2.2 says a single epoch legitimately constrains only a subspace.prior_mean (Any) – the prior’s mean, zero if absent.
- Returns:
One
HeldOutper epoch, in the order the terms were given.- Raises:
ValueError – if the campaign is empty, if its epochs are over different latents, if
prior_fisherorprior_meanis over a different number of columns, if the leave-one-out information is singular, or if any score comes out non-finite.- Return type:
- rheplicant.inference.diagnostics.shrinkage_power(sigmas)[source]¶
The fitted exponent of
sigma_N ~ N^p. A sanity check, not a test.Kept because section 9 says to keep it, and returned as a bare float only from here;
shrinkage_report()is what a caller should print, because it carries the caveat in the same object as the number.For a Gaussian model
sigma_N = (sum_e F_e + F_prior)^-1/2does not read the data. So this quantity is data-independent,p = -0.5holds by construction, and a uniform rescalingF_e -> (1+c) F_emoves it by exactly nothing – verified in the spec forcin {0, +0.5, -0.3} and again intests/evidence/test_coherent_bias.py. Measured on the repeated-design fixture, the clean and the deliberately-biased campaign return-0.49991034and-0.49991034, from per-N sigma arrays that are equal element for element.(The plan quoted
-0.49989592. That is the first coordinate’s own slope; the second is-0.49992476. A single float over a two-parameter campaign is the pooled fit, which is what this returns, and for the balanced grid a campaign audit actually has it equals the mean of the per-coordinate slopes.)v1’s plan to prove a diagnostic works by injecting a shared systematic and watching this number was self-refuting, and that is the whole reason section 9 exists in its present form.
- Parameters:
sigmas (Mapping[int, Any]) –
{campaign size: posterior widths}. A scalar width is accepted and treated as a one-element array. Every size must report the same number of widths, in the same order.- Returns:
The ordinary-least-squares slope of
log sigmaonlog N, pooled over the widths. The intercept is free, so a uniform rescaling of every sigma cannot move the result.- Raises:
ValueError – for fewer than two campaign sizes, a non-positive size, a ragged table, or a sigma that is not finite and strictly positive.
- Return type:
Delegates to :func:`bayesmith.marginal.diagnostics.shrinkage_power` as of the Wave D step-one batch (D61’s ordering). Measured bitwise across the seam BEFORE the near-side body was removed –
|delta| = 0.0on an exactn**-0.5bank, an exactn**-1.0bank and a noisy one – and that comparison cannot be re-taken now that this calls that.All four refusals live in the far side’s
_shrinkage_tablewith the same sentences, and itsStructureErroris aValueError(ValueErroris in its MRO), so this module’spytest.raises(ValueError, ...)pins hold through the seam unchanged.
- rheplicant.inference.diagnostics.shrinkage_report(sigmas)[source]¶
shrinkage_power()with its limits attached to it.detects_coherent_biasisFalse, always, and it is a field rather than a sentence in a docstring because a number and its caveat travel together or they do not travel. A deterministic common-mode error – one calibration solution, one beam model, one flag table applied to every night – contributes no variance to the campaign’s information: the posterior width is the same array element for element, split-half agrees to roundoff, leave-one-out returns the same scores, and the answer is wrong by 52.6 sigma at N = 640. Readcoherent_mode()for a diagnostic that can fire.One measured refinement on section 9’s own wording: the per-epoch chi-square scatter is not among the things a common mode leaves alone. It is inflated by noncentrality – 5.5467 against
sqrt(2 * 6) = 3.4641on the fixture – without any new randomness being injected. What keeps its scatter exactly is the named template projection, 1.00200 clean and 1.00200 biased, because there the fault is a pure additive shift.Delegates to :func:`bayesmith.marginal.diagnostics.shrinkage_report` as of the Wave D step-one batch (D61’s ordering). Measured bitwise across the seam BEFORE the near-side body was removed –
|delta| = 0.0on an exactn**-0.5bank, an exactn**-1.0bank and a noisy one – and that comparison cannot be re-taken now that this calls that.All four refusals live in the far side’s
_shrinkage_tablewith the same sentences, and itsStructureErroris aValueError(ValueErroris in its MRO), so this module’spytest.raises(ValueError, ...)pins hold through the seam unchanged.The caveat is this package’s own and is NOT taken from over there. The far side’s ends “Use template_modes(), coherent_mode() and the systematic floor”, and
template_modesdoes not exist here – advice naming a function the reader’s package lacks is worse than none. This one also carries rheplicant’s own measurement, the twelve digits on a campaign biased by 52.6 sigma, which is evidence rather than phrasing.
- rheplicant.inference.diagnostics.systematic_floor(memory, floors, at=None)[source]¶
Section 9.4: has this campaign out-run its own calibration?
The floor is the declared prior width of a shared calibration product – one solution, one beam model, one flag table serving every night – projected into theta units by the analyst. It is a declaration and not a measurement, and that is forced rather than lazy: the in-span half of a coherent error biases theta identically in every epoch and leaves no residual anywhere, so it passes per-epoch chi-square, split-half and leave-one-out, and no statistic computed from the stored terms can recover it. Measured on
tests/evidence/campaign_bank.py, the answer is wrong by 52.6 sigma at N = 640 with every data-driven diagnostic clean.What the campaign does know is its own width, and that width falls as
N^-1/2while the shared product’s does not fall at all. So the whole content of this function is: when does one pass under the other.sigmais the width of the tightest direction of a latent’s marginal posterior – the square root of the smallest eigenvalue of its covariance block – not the tightest coordinate, not the loosest and not the mean. A vector latent has a width in every direction and a floor is one number; the tightest is the first to go under, so it is the one a refusal must watch, and for a correlated posterior it is never a coordinate. Measured on the campaign fixture with a floor of 0.05 and a posterior correlation of 0.5131, the tightest direction crosses at N = 5, the tightest coordinate at N = 8 and the loosest at N = 13. See_tightest_direction(), which also records the near-collinear campaign whose error bar sits 7.9 times under the floor with every coordinate width an order of magnitude above it.crossing_epochis computed from the observed width, not quoted: the campaign’s ownsigma_Nis extrapolated assigma_N sqrt(N / N')and solved forsigma_{N'} = floor, givingN' = ceil(N (sigma_N / floor)^2). That extrapolation ignores the prior’s share, which shrinks as N grows, so it predicts the crossing marginally early; measured on the campaign fixture it is exact – 8 predicted from N = 4 and from N = 16, 8 observed – because the prior is 0.25 against a per-epoch 42.5.- Parameters:
memory (Any) – a
BayesMemory. Read for its Fisher, its factorization’s priors and the campaign’s length.floors (Mapping[str, Any]) –
{global latent: declared width in that latent's units}. Every entry must name a latent the memory accumulates and be finite and strictly positive.at (Mapping[str, Any] | None) – where to pull a reduced-basis term’s coefficient-space information back into theta, and where to differentiate the priors. Defaults to each latent’s declared
init.
- Returns:
One entry per latent name, each a dict of
"sigma","floor","below_floor","crossing_epoch"and"direction". Spelled out rather than written as{name: {...}}, because napoleon splits a Google-styleReturns:block at its first colon to find a return TYPE – and it does not exempt colons inside an inline literal, so that form had its opening backticks eaten into the rtype and left the closing pair orphaned. One Sphinx warning, and the paragraph rendered wrong. Keep this description colon-free.below_flooris the refusal’s own comparison, computed here and nowhere else so that the NaN-safe form exists in one place;crossing_epochisNonewhen the width is not a finite positive number, because there is then no crossing to extrapolate;directionis the unit combination of that latent’s raveled components whose widthsigmais, andNonefor a poisoned block.- Raises:
StateValidationError – for an empty campaign, a floor naming a latent the memory does not accumulate, a floor that is not a finite positive width, a prior with non-finite curvature, or an information matrix that is not positive definite.
- Return type:
Write a memory to disk so that reading it back cannot lie about it.
eqx.tree_serialise_leaves walks the arrays of a pytree and takes
everything else from the template it is given. Every static field of a stored
term – whether it is exact, which estimator it encodes, what support it claims,
how many samples it saw – therefore round-trips as whatever the template
happened to hold, with no error and no warning. Measured on this repo’s
equinox 0.13.8: include_logdet=False comes back True,
noise_frozen_at="gls" comes back "none", n_observed=777 comes back
0. A reloaded campaign would describe itself as a set of exact,
full-likelihood factors regardless of what was written, and the whole premise
of this layer is that the raw data is gone and cannot contradict it.
So the manifest is not provenance. It is the reconstruction spec: the
arrays come from the binary, and every static field, every dtype, and the
writer’s x64 state come from the JSON beside it. load_memory builds the
template from the manifest and refuses – not warns – on any mismatch with
the running environment.
The archive is written as its own pytree, beside the memory, and that is not
redundancy. BayesMemory holds its terms behind an opaque leaf so that a
ten-thousand-epoch campaign costs the same to flatten as a one-epoch one (see
rheplicant.inference.memory). eqx.tree_serialise_leaves does not
object to a leaf it cannot serialise – measured on equinox 0.13.8, it skips it,
writes a shorter file, and tree_deserialise_leaves returns the template’s
arrays in its place. Every stored factor would come back as the zeros
load_memory builds its template from: not an error, not a warning, just a
campaign that has forgotten its evidence and still reports the right epoch
count. So the pair (memory, tuple(memory.archive)) is serialised
explicitly, and _FORMAT_VERSION moved to 2 because the byte layout changed
– a version-1 file read by this code would deserialise the memory and then run
off the end of the file.
Version 3 adds section 9.3’s per-epoch residual summary and section 9.5’s input
provenance. Both are on the term because both must outlive the recording: the
summary is computed where the data still exists, and the provenance is what the
memory’s conditional-independence refusal reads. residual_dof,
template_names and inputs are static and therefore go in the manifest –
eqx.tree_serialise_leaves would take them from whatever template it was
handed, which for inputs means a reloaded campaign that has forgotten which
nights shared a calibration solution and will cheerfully sum them.
template_projections needs one more field than its name suggests.
template_names = () with None and template_names = () with a
length-zero array are the same claim to a reader and different pytrees to
equinox: one has a leaf at that position and the other has an empty subtree, so
a template built with the wrong one reads every later leaf from the wrong
offset. QuadraticLikelihood.__check_init__ refuses the empty-array spelling
outright, and n_template_projections records None or a length so the
template is reconstructed from the file rather than from a convention.
- rheplicant.inference.archive.save_memory(memory, path)[source]¶
Write
memorytopathplus a manifest atpath.json.The binary goes first, the manifest last, and that order is the commit. The manifest is this format’s reconstruction spec, so its presence is what says a readable archive exists. Written first – as this did – a failing
tree_serialise_leavesleft a manifest describing a file that was never created, andload_memorythen died on a rawFileNotFoundErrorfrom equinox rather than on anything this module says. Written last, a crash mid-save leaves an orphan binary, which nothing reads, and the archive is simply absent rather than corrupt.Both writes are still separate operations, so this is crash-consistent, not atomic: a reader can catch the instant between them. That window is diagnosable – the manifest is missing, which
load_memorynames – where the reverse window was not.That argument holds only when the destination was previously EMPTY, and re-archiving to a stable path is the ordinary case. Measured 2026-08-29 (adversarial review of D39): save a campaign, recalibrate, and re-archive to the same path with the process dying between these two writes, and the NEW binary is left beside the OLD manifest. When only static fields changed – a corrected
n_observed, a newinputsdigest – the two files are the same length, soload_memory()’s byte check cannot see it and the load succeeds: arrays from the new run, every static field from the old. Worse than a wrong field, the staleinputsthen lets_reject_shared_inputsADMIT an epoch it exists to refuse.So the window is diagnosable only on a first write. Closing it needs a digest of the binary in the manifest, which is a
_FORMAT_VERSIONbump – see ledger D39 step 3 and the ordering it must follow.The memory is checked before its terms are. A foreign term has been refused by name since this module existed; a foreign memory was refused by
AttributeError: 'ChainMemory' object has no attribute 'accumulated', from the manifest line that reads a bag’s running QR. That names an implementation detail of the class it was not given, says nothing about what this format is, and offers no remedy.
- rheplicant.inference.archive.load_memory(path, factorization)[source]¶
Read a memory back, refusing anything the manifest says it cannot be.
- Parameters:
factorization (Factorization)
rheplicant.config¶
The value grammar: what a fragment of a document may say, and how the resolved value reaches a field. Values in a config document is the prose; these are the signatures.
ConfigError: one refusal type for the whole config layer.
The package’s other error classes each name a stage – a State was built wrong, a pipeline was misconfigured, a file contradicted its declaration. A config document can be wrong in all of those ways at once and in one place, so splitting the refusal by stage would ask the reader to guess which stage a key belongs to before they can catch it. One class, and the message carries the distinction – which is where this package puts distinctions anyway.
Not DataIngestionError: that one is scoped by
its own docstring to “a data file could not be read, or its contents
contradict what the caller declared about them”, and it is confined to
radio/touchstone.py and radio/rhino.py. A config refusal is about what
a document meant.
- exception rheplicant.config.errors.ConfigError(*args, report=None)¶
Bases:
DirtError,ValueErrorA configuration document was refused.
LiveNames: a registry’s key set, read at the moment it is asked for.
Four registries in this layer are filled by more than one module – value
forms, file readers, derivations, resource kinds – and every “unknown X”
refusal lists what is available. A module-level tuple(_TABLE) would freeze
that list at import time and quietly go short as later modules register, so
the message would name a set the loader does not actually have. This is the
same discipline core/graph.py:350’s get_graph applies by listing
list(_GRAPHS) inside the refusal rather than beside it.
- class rheplicant.config.registry.LiveNames(table)[source]¶
Bases:
CollectionA sorted, always-current view of a registry’s keys.
Iterating, sizing,
inandreprall read the underlying mapping at the moment they are called, so a name registered after this object was built is still reported.
Units: a closed alphabet, a quotient grammar, and conversion on read.
Field names in this package already carry their unit – lat_deg,
apod_deg, lst_ref_deg – so a config cannot put the unit in the key.
It goes in the value, and the redundancy becomes a free consistency check
instead of a contradiction.
The alphabet is deliberately small. Every token is either a canonical unit the
schema names, or a spelling one of the package’s two existing readers already
accepts (radio/rhino.py:70 {"hz", "mhz"}; radio/touchstone.py:40
{"HZ", "KHZ", "MHZ", "GHZ"}). A wider table is a place to be quietly
wrong: an unconvertible unit is better refused by name than passed through,
because a factor of 1e6 on a frequency grid produces a finite,
correctly-shaped, wrong answer and nothing downstream can tell.
Compound units exist for one measured reason. Five fields carry no unit
anywhere in the source – adc.scale, gain.gain, apply_cal.gain,
flagging.threshold, filters[].regularization – and decision D-C1
declares the post-gain trunk to be adc_count. That makes
adc_count/K x K = adc_count an identity a validator can check, which is
what promotes the unit rule from a spelling convention to a real one.
- rheplicant.config.units.ACCEPTED_UNITS: tuple[str, ...] = ('Hz', 's', 'unix_s', 'K', 'deg', 'm', 'ohm', 'dimensionless', 'count', 'samples', 'bits', 'channels', 'cycles', 'adc_count')¶
Every accepted spelling, in canonical form, for messages and for callers.
- rheplicant.config.units.UNIT_SPELLINGS: dict[str, tuple[str, ...]] = {'Hz': ('Hz', 'kHz', 'MHz', 'GHz'), 'K': ('K', 'celsius'), 'adc_count': ('adc_count',), 'bits': ('bits',), 'channels': ('channels',), 'count': ('count',), 'cycles': ('cycles',), 'deg': ('deg', 'rad'), 'dimensionless': ('dimensionless',), 'm': ('m',), 'ohm': ('ohm',), 's': ('s', 'ms'), 'samples': ('samples',), 'unix_s': ('unix_s',)}¶
Canonical unit -> every spelling this layer accepts for it, canonical first.
Written out rather than derived from
_ATOMS, whose keys are lower-cased for lookup: a table built from them would offermhz, which parses but reads as millihertz, so the number beside it would be wrong by nine orders of magnitude in the reader’s head while being right in the file. A form offering a unit choice offers these and writes the chosen spelling through verbatim; it never converts the number, becausecelsiusis affine and a silent conversion is exactly the finite, correctly-shaped wrong answer the alphabet above refuses to produce.tests/config/test_config_units.pypins this against_ATOMSboth ways, so a new atom cannot be added without being spelled here.
- class rheplicant.config.units.Unit(canonical, factor, offset, numerator, denominator, dimension)[source]¶
Bases:
NamedTupleA parsed unit: what to multiply by, and what the result is called.
A bullet list rather than an
Attributes:section, asGLSResultininference/gls.pyalready is: autodoc documents a NamedTuple’s field aliases itself, so napoleon’s second copy is a duplicate object description and sphinx warns on every field.canonical– the canonical spelling, e.g."Hz"or"adc_count/K".factor– multiply a declared value by this to reach canonical.offset– add after scaling. Non-zero only for a bare affine atom (celsius), which is why an affine atom may not compose.numerator– canonical atom spellings above the line.denominator– canonical atom spellings below it.dimension– the single dimension when this unit is one atom, else None.
- Parameters:
- rheplicant.config.units.canonical_unit(token)[source]¶
Parse a unit token into a
Unit.- Parameters:
token (str) – an atom (
"MHz"), a product ("K*s") or a quotient with at most one/("adc_count/K","Hz/s").- Raises:
ConfigError – on an unknown atom, a second
/, an exponent or any other syntax – this is a unit alphabet, not an expression language, and the boundary is the same one §2.3 draws for values.- Return type:
- rheplicant.config.units.convert_to_canonical(value, token)[source]¶
Convert
valuefromtokeninto canonical units.- Returns:
(converted, unit).convertedkeepsvalue’s own type where the conversion is exact (factor 1, offset 0), so a Pythonintdestined for a static field is not turned into a float on the way.- Parameters:
token (str)
- rheplicant.config.units.check_field_name_unit(field_name, unit)[source]¶
Cross-check a unit-suffixed Python field name against a declared unit.
lat_degstores degrees, so declaring radians is legal – the value is converted before it is stored and the canonical unit is what the suffix describes. Declaring kelvin is not.- Raises:
ConfigError – when the field’s suffix names a dimension and the canonical unit is in a different one.
- Parameters:
- Return type:
None
Shape symbols: a closed table, an integer offset, and nothing more.
examples/radio_digital_twin.py:71-78 writes the frequency grid length by
hand five times in one 90-line script with nothing tying the copies together.
A symbol table fixes that without opening the door a value grammar closes:
there is no operator, no precedence and no evaluation order here, only a name,
an optional integer multiple and an optional integer offset.
n_pix and n_alm are the exception, and deliberately so – see
resolve_extent().
- rheplicant.config.symbols.SHAPE_SYMBOLS: tuple[str, ...] = ('n_time', 'n_freq', 'n_source', 'n_pix', 'n_alm', 'n_load')¶
The closed table. Adding to it is a schema change, not a convenience.
- class rheplicant.config.symbols.ShapeScope(n_time, n_freq, n_source=1, nside=None, lmax=None, candidates=())[source]¶
Bases:
objectThe extents a shape symbol may resolve against.
- Parameters:
- nside¶
set only inside an entry that declares its own;
n_pixresolves from it and refuses without it.- Type:
int | None
- candidates¶
dotted names of entries that DO declare an
nsideorlmax, quoted in the refusal so the reader is told what could have been meant instead of being told to guess.
- rheplicant.config.symbols.resolve_extent(value, scope)[source]¶
Resolve one integer position of a shape.
- Parameters:
value – a Python
int, a bare symbol, or"<int> * <symbol>"/"<symbol> +|- <int>". The multiple and the offset are each optional and may both appear, in which case the multiple binds to the symbol:"2 * n_freq - 1"is2 * n_freqminus one, not twicen_freq - 1. That is a fixed combination rule, not precedence in the expression-language sense this module’s refusals disclaim – there is still no operator to apply, nothing to nest and no evaluation order to reason about. It is written down here rather than left to be inferred from an example.scope (ShapeScope) – the extents in force at this position.
- Raises:
ConfigError – on an unknown symbol, on
n_pix/n_almwith nothing to resolve against, or on anything the two legal arithmetic forms do not cover.- Return type:
- rheplicant.config.symbols.literal_shadowing_a_symbol(value, scope)[source]¶
The symbol a literal integer in a shape position equals, if any.
Check A41 in the schema, and a report rather than a refusal: a literal 8 may genuinely be 8. What it cannot be is tied to the grid, which is the whole failure – five hand-copied grid lengths in one 90-line script.
Where two extents are equal the symbol reported is the first in
SHAPE_SYMBOLSorder, which is what the loop below walks. On a tie both answers are true, so this is a stated convention rather than a correctness claim – but it is stated, so a later reordering is a deliberate change rather than an accident.- Parameters:
scope (ShapeScope)
- Return type:
str | None
- rheplicant.config.symbols.resolve_shape(spec, scope, *, form, instead)[source]¶
Resolve a whole shape, and report the literals that shadow a symbol.
The resolution and the check A41 report are one function because they are one pass over one list, and because splitting them let them diverge: the array constructors paired them and the draw forms did not, so
{zeros: [8]}reported a hand-copied grid length and{normal: {shape: [8]}}said nothing about the same 8. A report that depends on which constructor the writer reached for is worse than no report – it is read as authoritative.It lives here rather than in either caller because both halves consult
SHAPE_SYMBOLSand the scope, which is this module’s subject, and because the alternative on offer wasdrawsimporting a private name out ofarrays.- Parameters:
spec – the shape as written – a list of integers and shape symbols.
scope (ShapeScope) – the extents in force at this position.
form (str) – the form key, quoted in the refusal.
instead (str) – what to write if this is a scalar rather than a shape. It is the one clause that is genuinely the caller’s to say and not this module’s – a scalar zero is
{value: 0.0}and a scalar draw is an empty shape, and neither is deducible from the other. Required and undefaulted, so a form added later states it rather than inheriting whichever caller happened to be written first.
- Returns:
(extents, shadowed).shadowedmaps position -> the symbol a literal integer there happens to equal, and is empty for a shape written entirely in symbols.- Raises:
ConfigError – when
specis not a list, and fromresolve_extent()for anything in it that is not a shape position.- Return type:
ResolutionContext: everything a value node may resolve against.
It is a frozen dataclass and every widening returns a new one. That is the
package’s own rule, and here it earns its keep twice: {ref: ...} resolves
to the same Python object, so a test can assert is; and a resource under
construction cannot see itself, which is what makes a cycle in resources
detectable rather than an infinite recursion.
- rheplicant.config.context.using_resolution_audit(layer, trace, origin_lookup, capture=None)[source]¶
Give every context created during one layer its audit authority.
- Parameters:
layer (LayerIdentity)
trace (TraceSink | None)
origin_lookup (OriginLookup | None)
capture (CaptureService | None)
- rheplicant.config.context.current_resolution_audit()[source]¶
Return the current layer audit during document construction.
- Return type:
ResolutionAudit | None
- class rheplicant.config.context.ResolutionContext(freq=None, time=None, dtype='float32', base_dir=None, roots=(), seed=None, seeds=<factory>, switch_order=(), resources=<factory>, n_source_override=None, ingest=None, dimensions=<factory>, layer=<factory>, trace=<factory>, origin_lookup=<factory>, capture=<factory>, audit=<factory>)[source]¶
Bases:
objectThe scope a value node resolves in.
- Parameters:
freq (Array | None)
time (Array | None)
dtype (str)
base_dir (str | None)
seed (int | None)
n_source_override (int | None)
ingest (Any)
dimensions (DimensionEnvironment)
layer (LayerIdentity)
trace (TraceSink | None)
origin_lookup (OriginLookup | None)
capture (CaptureService | None)
audit (ResolutionAudit | None)
- freq¶
the run’s frequency grid, Hz.
{from_grid: freq}reads it and{from: channel_spacing}measures it.- Type:
jax.Array | None
- resources¶
constructed resources by dotted name.
refreturns the object stored here, not a copy.
- ingest¶
the ingested recording (
RhinoObservation) whenobservation.from_filewas declared;from: thermistorsreads its thermistor log.- Type:
Any
- property shape_scope: ShapeScope¶
The extents a shape symbol resolves against, taken off the axes.
- use_default(path, value)[source]¶
Return one chosen default and record it when this load is audited.
- Parameters:
path (str)
value (T)
- Return type:
T
The value grammar: eighteen form keys in eight families, one dispatcher, one result type.
Resolution knows nothing about the destination. It turns a fragment of a
document into a canonical-unit number and a record of which form produced it;
rheplicant.config.delivery then decides how that reaches a field. The
split is not tidiness – the form is only answerable from the document and the
representation is only answerable from the target class, so a function taking
both could be tested against neither.
- rheplicant.config.values.VALUE_FORMS: tuple[str, ...] = ('value', 'zeros', 'ones', 'full', 'list', 'linspace', 'arange', 'modulo', 'from_grid', 'basis_fit', 'normal', 'uniform', 'file', 'ref', 'from', 'stack', 'from_switch_order', 'python')¶
Every form key. A mapping value node holds exactly one of these.
- rheplicant.config.values.VALUE_MODIFIERS: tuple[str, ...] = ('unit', 'dtype', 'as', 'axis', 'column', 'scale', 'offset', 'part', 'normalize')¶
The nine modifier keys – the schema’s eight plus the delivery declaration
as:.
- class rheplicant.config.values.ResolvedValue(value, unit, source, modifiers)[source]¶
Bases:
NamedTupleWhat a value node resolved to, before any destination is considered.
A bullet list rather than an
Attributes:section, for the reason given onrheplicant.config.units.Unit: napoleon’s copy of a NamedTuple’s fields duplicates the one autodoc already emits.value– the canonical-unit value: a Python scalar, ajnparray, or an arbitrary object forrefandpython.unit– the parsed unit, orNonefor a bare number.source– the form key that produced it. Check A40 reads this, and every refusal quotes it.modifiers– the modifier keys as written, for the caller that needsaxis:(which is recorded, never applied). No default: a mutable default on a NamedTuple is shared between every instance, and every construction site here has the dict to hand anyway.
- class rheplicant.config.values.ResolutionTarget(destination, spec, expected, explicit_unit, inherits_outer_unit=False, formula_name=None, formula_role='result')[source]¶
Bases:
objectThe catalog row and concrete document address governing one value.
- rheplicant.config.values.validate_declared_unit(node, target)[source]¶
Validate only the node’s declaration, before a resolver can do I/O.
- Parameters:
node (object)
target (ResolutionTarget)
- Return type:
- rheplicant.config.values.resolve_value(node, context, *, destination=None)[source]¶
Resolve one value node.
- Parameters:
node (Any) – a bare number, a
"<number> <unit>"shorthand string, or a mapping holding exactly one form key plus any modifiers.context (ResolutionContext) – the scope to resolve against.
destination (DestinationDescriptor | None)
- Raises:
ConfigError – on zero or several form keys, on an unknown key beside a form, and on anything a form’s own resolver refuses.
- Return type:
- rheplicant.config.values.resolve_registered_form(node, context, target)[source]¶
The sole value-form dispatcher, after destination validation.
- Parameters:
node (Any)
context (ResolutionContext)
target (ResolutionTarget | None)
- Return type:
- rheplicant.config.values.register_form(name, *, arguments=frozenset({}))[source]¶
Register a resolver for one value form. Returns the function.
Form 2: the array constructors.
Every shape position goes through rheplicant.config.symbols.resolve_extent(),
so a grid length is written once and referred to by name. Where a literal
integer happens to equal one of the run’s extents the node records it under
_shadowed rather than refusing – check A41 is a report, because a literal
8 may genuinely be 8, and what it cannot be is tied to the grid.
Form 3: a value drawn from a distribution, with its seed named in one place.
Three of the five stress-ported example scripts were blocked without this
(driftscan_mmode.py:61, sky_to_noise_wave.py:109,
three_ways_to_a_posterior.py:76): their skies are drawn, not read, and
the alternative – ship a binary blob whose provenance the config cannot state
– defeats the whole argument for a resolved-config artefact.
A draw is a generator, not a computation: it has no operands to compose and no result to feed back, so it does not open the door §2.3 closes.
seed: names an entry of runtime.seeds and may not be a literal. That
is what keeps every realisation in a run enumerated in one place, which is
what provenance.json records. A name runtime.seeds does not declare is
derived from the root seed by a blake2s digest, not by Python’s hash:
the built-in string hash is salted per process, so v0’s fallback gave a
different sky on every run of the same file.
- rheplicant.config.draws.seed_for(name, context)[source]¶
The reportable integer seed for
name, declared or derived.This is the number that lands in
provenance.json, and it is also the number the draw is made from –_keyis defined in terms of this function rather than deriving a key of its own. See there. A literal rather than a cross-reference, because_keyis private and autodoc does not collect it, so the reference would never resolve – the same warninga3f96f0removed frominference/noise.py.- Raises:
ConfigError – when the run declares no root seed at all –
seed: nullis a legal, recorded state (examples/sky_to_noise_wave.pybuilds its State with no key), and it is incompatible with drawing.- Parameters:
name (str)
context (ResolutionContext)
- Return type:
Form 4: a file reference, its search path, its reader and its hash.
The reader table is a registry rather than an if chain for one reason: a
registry means the refusal for an unknown format lists what is actually
available today instead of a set someone remembered to update. That is the
shape core/graph.py:350 register_graph established.
Plan 1B registers exactly one new format here, touchstone – an object
reader (array=False: it returns a
Touchstone, not an array; see
register_reader()). Two more formats the schema names are real but are
not read through a value node at all: cst_dir and healpix build a
beam’s raw array at resources.beams instead, because the frequency grid,
nside and (for cst_dir) phi0_deg/phi_sense are all in scope
there in a way a bare value node cannot express – _ELSEWHERE below names
the route for both, so the refusal points somewhere real instead of claiming
the capability is absent. eqx_leaves arrives at model.<node>.eqx_leaves
(Plan 2A Task 10): it reconstructs operator state onto a template built from
the node’s own declared fields. rhino_hdf5 is registered by
rheplicant.config.sections.ingest as an object reader.
Every file reference is hashed. The cost is one read of a file that is about
to be read anyway, and it is what lets config.resolved.yaml state which
bytes a run saw – so a rerun that disagrees is detectable rather than merely
suspected.
What a document is trusted to do. This is the one place the value grammar
reaches outside itself, so the assumption is worth writing down rather than
leaving to be inferred from what the code does not check. Path resolution
applies no containment: ~ and ${ENV} expand, an absolute path is taken
as written, and a relative one may climb out of the document’s directory with
... That is deliberate, and every alternative breaks a spelling the
package is actually used with – ~/data/beams/... on a workstation,
${SCRATCH}/... on a cluster, ../data/... in a repository whose configs
and data are siblings. The assumption underneath is that whoever wrote the
document is whoever is running the pipeline, in which case the document can
already do nothing its author could not do at a shell.
That assumption stops holding the moment a document arrives from somewhere
else: a shared root, a CI artefact, a collaborator’s YAML. Then a file:
entry naming ~/.ssh/id_ed25519 is read by this process, and what lands in
config.resolved.yaml is its _path and its _sha256. The digest is
the part that turns a read into a disclosure – it confirms a guess about a
file’s contents to anyone holding the artefact, and the artefact exists to be
shared. At that point an opt-in “every resolved path must be under
base_dir or a declared root” belongs in resolve_file_path(),
recorded alongside the roots it was checked against. It is not written yet
because on by default it would refuse all three spellings above, and the
threat it answers is not the one this layer was designed under. The decision
is recorded here, next to the function it would change, rather than in an
issue nobody reading this file would find.
- rheplicant.config.files.register_reader(name, extra_keys=frozenset({}), *, array=True)[source]¶
Register a file reader under
name. Returns the function.- Parameters:
name (str) – the format token, as written in
format:.extra_keys (frozenset[str]) – the keys this format accepts beyond path/format/sha256.
array (bool) –
True(the default) for a reader whose return value is an array –jnp.asarray’d and eligible forunit:like every other format.Falsefor a reader that returns some other object (rheplicant.radio.touchstone.Touchstone, today): the object is handed back unwrapped, and the node it came from takes no modifiers, because a modifier describes what an array’s numbers ARE and an object-valued reader returns something else entirely.
- rheplicant.config.files.FILE_FORMATS = ['csv', 'eqx_leaves', 'npy', 'npz', 'rhino_hdf5', 'touchstone', 'txt']¶
Every registered format, live. Plan 1B adds to it by importing its module.
- rheplicant.config.files.resolve_file_path(raw, context, *, must_exist=True)[source]¶
Resolve a declared path: the document’s directory, then roots, then absolute.
~and${ENV}expand first. The order is the schema’s, and the refusal names every place that was tried – a path that resolved somewhere unexpected is the failure this ordering exists to make visible.- Parameters:
raw (str)
context (ResolutionContext)
must_exist (bool)
- Return type:
Form 6: derivations – a closed registry, one entry per package function.
Every entry names a real call. That is the difference between this and an expression language: there is nothing here a user can compose, only quantities the package already computes and that a config would otherwise ask a human to recompute by hand.
channel_spacing and sample_cadence are not conveniences. Schema check
A13 requires cw_tone.line_width to be written with no default, and it must
lie above MIN_WIDTH_IN_CHANNELS[lineshape] * median(|diff(freq)|) – note
that MIN_WIDTH_IN_CHANNELS is a per-lineshape dict
(radio/instrument/calibration.py:142, {"sinc2": 1.0, "gaussian": 0.25}),
not one number. The arithmetic v0 handed the user was
25e6 / (N_FREQ - 1) = 806451.6129032258; rounding that to 0.8 MHz is
refused at trace time and rounding the other way silently mis-sizes the
protection mask.
The measurement is a median and not a mean, which on the uniform grids
the tests build is a distinction of one float32 ulp and on a real observing
grid is the whole point: a band with a flagged block dropped out of it has one
enormous gap, and the mean of the gaps is then a spacing no pair of adjacent
channels actually has.
- rheplicant.config.derive.register_derivation(name, arguments=frozenset({}), refused=frozenset({}))[source]¶
Register one derivation. Returns the function.
- rheplicant.config.derive.DERIVATIONS = ['basis_matrix', 'channel_spacing', 'horizon_fraction', 'interpolate_onto', 'sample_cadence', 'unit_mean_free']¶
Every registered derivation, live. Plans 1B and 2 add to it.
Forms 5 and 7: reading a named object back, and stacking by name.
{ref: ...} returns the same Python object, not a copy. That is not an
optimisation: BeamSpillOperator.from_projector is documented as “the one
call that cannot get the weight and the sky average out of step”, and
examples/driftscan_mmode.py:84 hands one engine’s beam_alms to another
so that both see the same analysis – a loader that rebuilds each reference
passes every shape check and destroys the 2e-16 agreement the comparison
exists to demonstrate. Identity is the contract; a test asserting is is
the only way to hold it.
Nothing here calls jnp.asarray on a bare ref, for the same reason and
one more: a beam, a projector and an operator are all legal ref targets and
none of them is an array. The single modifier exit point in
rheplicant.config.values.resolve_value() is identity-preserving on an
empty modifier dict, which is what lets ref be both a form and a pass.
{from_switch_order: ...} is sugar for a stack over the entries named
by observation.switching.order, matched by name. The row order of
noise_wave.gamma_src is fixed by that list, and a transposition there is
shape-legal and costs tens of kelvin.
- rheplicant.config.refs.SWITCH_PARTS: tuple[str, ...] = ('re', 'im')¶
part:inside afrom_switch_order. Narrower than the modifier alphabet on purpose: NoiseWaveOperator takes gamma_src_re and gamma_src_im as separate fields, so a switch-ordered stack is written once per half, and abs or angle there would be a quantity no field reads.
- rheplicant.config.refs.resolve_reference(dotted, context)[source]¶
The object a
refnames – identically, not a copy.- Parameters:
dotted (str) –
resources.<kind>.<name>, optionally followed by one of that entry’s own attributes.context (ResolutionContext) – the scope to resolve against.
- Raises:
ConfigError – when the name does not start at
resources., when no resource carries it, and when the trailing segment is not an attribute the named object offers.- Return type:
Form 8: the one escape hatch, and the cost it states.
Everything the value grammar deliberately cannot do goes here: elementwise
arithmetic beyond scale/offset, trig, powers, logs, nested calls with
positional operands, a pipeline topology outside RADIO_GRAPH.
30*cos(linspace(0,3,8)) needs an evaluator, precedence and a namespace,
and that is a programming language rather than a schema.
Composition of named quantities does not need the hatch:
resources.arrays.<name> binds a value node to a name and {ref: ...}
reads it back, which gives a DAG of named quantities and nothing more.
The cost, stated rather than implied. The run is no longer reproducible
from the config alone – the hash covers the string, not the code. The
object must be importable in the run process and, inside the gradient path,
jax-traceable. On a static field it is hashed by identity, so an equivalent
re-created function misses the jit cache. With raw_bind,
ParameterSpace.validate’s per-selector checks cannot run at all.
What a document is trusted to do. rheplicant.config.files records
the same assumption next to path resolution; this is the sharper half of it,
so it belongs beside the function that acts on it rather than in one place for
both. A document that reaches this form runs arbitrary named code in the
process that loaded it, with that process’s privileges – there is no sandbox
here and no allow-list of importable packages, and neither would be honest
about what the form is for. Importing alone is sufficient. Measured:
import_target() calls importlib.import_module, which executes the
named module’s body whether or not anything is subsequently called, so
{python: "pkg.mod:anything"} runs pkg/mod.py even with no args:
key – resolving a document is enough, and a loader that only resolves a
document in order to report on it has already executed it. The assumption
underneath is the one files states: whoever wrote the document is whoever
is running the pipeline, in which case the document can do nothing its author
could not do at a shell.
That assumption stops holding the moment a document arrives from somewhere
else – a shared root, a CI artefact, a collaborator’s YAML. Two consequences
follow and are worth writing down. The top-level plugins: key this
module’s own refusals point at – whose loader arrives with the document
loader, in Plan 1B – is not a declaration of intent but a list of module
bodies to execute, because importing for the registration side effect is the
whole mechanism by which a plugin registers; and those imports have to happen
before any value node is resolved, since resolution is what needs the names.
Reading the plugins: of an unfamiliar document is therefore reading code
that will run first, unconditionally, whatever else the document says. And a
published config.resolved.yaml records the target string –
_python: "pkg.mod:fn" – which says which code ran and nothing whatever
about what that code was, so the artefact is evidence of provenance and not of
behaviour. Two runs whose resolved configs match byte for byte can have done
different things if the named package changed between them; the file hash a
file: entry records has no counterpart here, because there is no single
artefact to hash. The remedy, when a document is not your own, is to read its
plugins: and its python: targets before loading it, in a process that
can afford what they do. This is recorded here rather than in an issue because
it is a property of the form and not a defect in it: an escape hatch that
could be made safe would not be an escape hatch.
Calling is spelled by the document, not inferred from the object. Writing
args: or literal: – either of them, even empty – calls the attribute;
writing neither delivers the attribute itself. The alternative considered and
rejected was “call it if it turns out to be callable”, which reads well on
{python: "math:pi"} and has two defects. It decides what a node means
from a property of code the document does not contain, which is the exact
reproducibility cost this module exists to state rather than spread; and it
leaves no spelling at all for handing over a named function, which
core/operator.py:117 needs – LambdaOperator.fn is a static
Callable[[State], State] and rheplicant.config.delivery records
that this hatch is the only route to a field like it. Under this rule the two
intents are one key apart and both are visible in the document:
{python: "pkg:fn"} hands over fn, {python: "pkg:fn", args: {}}
hands over fn().
- rheplicant.config.hatch.import_target(target)[source]¶
Import
"package.module:attribute"and return the attribute.- Raises:
ConfigError – on a target that is not one colon-separated pair, on a module that cannot be imported, and on an attribute the module does not carry.
- Parameters:
target (str)
- Return type:
The eight modifiers: what a value IS, said alongside what it holds.
Each of these is a declaration the array itself cannot carry. normalize:
is the clearest case: a beam’s numbers do not say whether they are a unit-sum
map or raw CST gain, and the output’s unit is decided by the pair
(beam normalisation, normalize_beam) – 32838 K against 200 K on a uniform
200 K sky. column: is the second: on a square grid (n,) and (n, 1)
are indistinguishable by shape and mean per-frequency and per-sample.
scale:/offset: is the boundary of what this grammar computes. One
level, no nesting, no precedence: scale * v + offset. Anything past it –
trig, powers, a product of two value nodes – goes to the python: hatch
and pays the stated cost.
- rheplicant.config.modifiers.PARTS: tuple[str, ...] = ('re', 'im', 'abs', 'angle')¶
part:– which component of a complex value node.
- rheplicant.config.modifiers.NORMALIZATIONS: tuple[str, ...] = ('none', 'mean1', 'pixel_sum', 'max1')¶
normalize:– a declaration of convention, never a computation the array could have implied.
- rheplicant.config.modifiers.DTYPES: tuple[str, ...] = ('float32', 'float64', 'complex64', 'complex128')¶
dtype:– the four the run may ask for.
- rheplicant.config.modifiers.NOISE_AXES: tuple[str, ...] = ('time', 'freq', 'none')¶
axis:– mandatory for a 1-D noise sigma; recorded here, checked by inference/noise.py:264 check_noise_std_axis.
- rheplicant.config.modifiers.REAL_DTYPES: tuple[str, ...] = ('float32', 'float64')¶
The members of
DTYPESthat cannot hold a phase. Derived from the table rather than written out, so the two stay in step.
- rheplicant.config.modifiers.apply_modifiers(value, modifiers, *, form, context=None)[source]¶
Apply every modifier that transforms the value, in a fixed order.
Order:
dtype->part->scale/offset->normalize->column. It is fixed rather than written so that two documents with the same keys cannot mean two different things; each step is documented on the function that performs it.unit:is applied by the form itself (it decides what the number means, not what it looks like) andas:andaxis:are recorded rather than applied –as:is cross-checked against the destination inrheplicant.config.delivery, andaxis:is read by the noise model.Every key is looked up by name and anything else in the mapping is passed over rather than refused. That is not laxity – the dispatcher has already refused any key the document wrote that is not a modifier – it is what lets a form record its own findings in the same dict:
arrays._finishwrites_shadowedthere for check A41.- Parameters:
value (Any) – the form’s result, already in canonical units.
modifiers (dict[str, Any]) – the node’s modifier keys as written, plus whatever the form recorded alongside them.
form (str) – the form key that produced
value, quoted in refusals.context (ResolutionContext | None)
- Raises:
ConfigError – on a
part,normalize,dtypeoraxisoutside its table, and oncolumn: trueover anything not 1-D.- Return type:
Any
Delivery: the destination field decides how a resolved value arrives.
A config value is a number in a document. Whether it reaches an operator as a
Python int or as a traced jnp array is not the document’s choice and
not this layer’s – it is written on the target class, in the dataclasses
field metadata equinox populates. So delivery reads the class first.
Four measurements are the whole argument for this module.
ADCOperator(n_bits=jnp.asarray(12))warnsA JAX array is being set as static!and then raises.ForegroundOperator(ref_freq=jnp.asarray(1.4e8))only warns: it constructs, the forward numbers are bit-identical, andeqx.filter_gradthen returns1.4e+08where a gradient belongs.FlaggingOperator.thresholdhas no__check_init__at all and takes a whole array, detonating later at an unrelated pytree comparison.AntennaLossOperator(efficiency=1)storesint32. An integer array is not an inexact array, soeqx.partition(op, eqx.is_inexact_array)returns[]and the field is silently untrainable. A YAML1and a YAML1.0must not differ in what can be inferred.A YAML sequence is a Python
list. On a statictuplefield it constructs fine and makes the module unhashable.astype("float64")returnsfloat32whenjax_enable_x64is off, with no warning, and every later dtype check then agrees with the downcast value. The flag is process-global and must be set before any array exists, so a document cannot make float64 true merely by asking for it – andgeneral_pointing.pyputs the cost of getting this wrong at O(10%).
The model is CWCalibrationOperator’s converters
(radio/instrument/calibration.py:242-255): coerce to a clean static scalar
before equinox’s static check runs, and refuse with a message rather than a
warning. This module does the same thing one step earlier.
- rheplicant.config.delivery.DELIVERY_MODES: tuple[str, ...] = ('traced', 'static_int', 'static_float', 'static_str', 'static_bool', 'static_tuple', 'static_mapping')¶
The values
as:may take, and what each claims about the destination.
- rheplicant.config.delivery.ARRAY_FORMS: frozenset[str] = frozenset({'arange', 'basis_fit', 'file', 'from_grid', 'full', 'linspace', 'list', 'modulo', 'normal', 'ones', 'stack', 'uniform', 'zeros'})¶
Forms that produce an array. None of them can land on a static field.
- class rheplicant.config.delivery.FieldSpec(name, annotation, static, converter, required)[source]¶
Bases:
NamedTupleWhat a destination field says about itself.
A bullet list rather than an
Attributes:section, for the reason given onrheplicant.config.units.Unit: napoleon’s copy of a NamedTuple’s fields duplicates the one autodoc already emits.name– the Python field name, also what a refusal quotes.annotation– the resolved type object (int,float,jax.Array…).static–Truewhen equinox will put this field in the treedef.converter– the field’s own converter, orNone.required– no default and no default_factory.
- Parameters:
- rheplicant.config.delivery.field_specs(cls)[source]¶
Every
initfield of aneqx.Modulesubclass, by name.typing.get_type_hintsrather thanf.type: four modules in the package usefrom __future__ import annotations, and although none of them currently defines aneqx.Module, a string annotation would make every branch below fall through to “traced” silently.
- rheplicant.config.delivery.mode_of(spec)[source]¶
The delivery mode a field’s own declaration implies.
- rheplicant.config.delivery.declared_or_inferred_mode(context, destination, spec, declared)[source]¶
Return the exact mode delivery consumes, recording an omitted one.
- Parameters:
context (ResolutionContext)
destination (DestinationDescriptor)
spec (FieldSpec)
declared (str | None)
- Return type:
str | None
- rheplicant.config.delivery.deliver(value, spec, *, dtype, source='scalar', declared_as=None, destination=None)[source]¶
Public compatibility wrapper for destination-aware delivery.
- rheplicant.config.delivery.origin_for_delivery(context, destination, *, defaulted=False)[source]¶
Find the payload authority exactly; never fabricate a user origin.
- Parameters:
context (ResolutionContext)
destination (DestinationDescriptor)
defaulted (bool)
- Return type:
Origin
- rheplicant.config.delivery.canonical_unit_for_delivery(context, destination, explicit, *, expected=<object object>)[source]¶
The canonical unit a destination receives, including implicit A9 units.
- Parameters:
context (ResolutionContext)
destination (DestinationDescriptor)
explicit (Unit | None)
expected (DimensionSignature | None | object)
- Return type:
str | None
- rheplicant.config.delivery.record_resolved_delivery(context, destination, unit, *, defaulted=False, expected=<object object>)[source]¶
Record a non-model value at the point its typed owner accepts it.
- Parameters:
context (ResolutionContext)
destination (DestinationDescriptor)
unit (Unit | None)
defaulted (bool)
expected (DimensionSignature | None | object)
- Return type:
None
- rheplicant.config.delivery.deliver_checked(value, spec, *, dtype, source='scalar', declared_as=None, destination=None)[source]¶
Coerce a resolved value into what
spec’s field will accept.- Parameters:
value (Any) – the resolved, canonical-unit value.
spec (FieldSpec) – the destination, from
field_specs().dtype (str) – the run’s floating dtype,
"float32"or"float64".source (str) – the value form’s name, for check A40 and for the message.
declared_as (str | None) – the document’s own
as:claim, cross-checked.destination (DestinationDescriptor | None)
- Raises:
ConfigError – on an array form landing on a static field (A40), on a declared
as:the field contradicts, and on any value the destination’s type cannot hold.- Return type:
rheplicant.config, continued: resources and paths¶
The resource loader and the path grammar, built on top of the value grammar above. Resources and paths in a config document is the prose; these are the signatures.
The path grammar: a dotted string, the selector it compiles to, and refusal.
Bind.into holds callables, not strings (inference/parameters.py:338),
and ParameterSpace._resolve_targets invokes them against a copy of the
twin whose every leaf has been replaced by its own key path. So a path here
compiles to a callable that walks the object’s own accessors. Synthesising key
paths directly would not survive Pipeline.__getitem__: p["gain"]
resolves through self.names.index("gain") into a positional stage, and the
string "gain" never appears in the path that comes back
(.stages[0].gain does).
That mismatch is also why every refusal below names both spellings. The
package’s own messages quote keystr(path) – .stages[0].gain – and a
reader who wrote gain.gain has never seen that string. Naming only the
structural path would be reusing wording at the cost of the reader.
Resolution happens eagerly, against a tagged twin, before the forward
function is built and anything is traced. That twin must already exist by
this point, built from already-constructed resources – so the claim is not
“before any file is read”; it is “before assembly proceeds to building and
tracing the model”, which is where schema §6’s “before any expensive work”
promise is aimed. The alternative is the same refusal arriving from
ParameterSpace once that build is already underway.
- rheplicant.config.paths.parse_path(path)[source]¶
Split a path into its head and steps.
head ( "." step )*where a step is an identifier, an identifier with a non-negative integer subscript, or a bare subscript. Returns a tuple whose entries arestrfor an attribute or key andintfor an index.
- rheplicant.config.paths.compile_path(path)[source]¶
Compile a path into the selector
Bind(into=...)takes.The returned callable walks with
__getitem__for the head (which is how a node id is addressed) andgetattr/__getitem__for the steps.
- class rheplicant.config.paths.ResolvedPath(declared, key_path, keystr, leaf, selector)[source]¶
Bases:
NamedTupleA path that reached a real array leaf.
declared— the string the document wrote.key_path— the JAX key path, as_resolve_targetsproduces it.keystr— that path injax.tree_util.keystrform, which is what the package’s own refusals quote.leaf— the current value of the leaf.selector— the callable, ready forBind(into=...).
- rheplicant.config.paths.resolve_path_on(path, twin)[source]¶
Resolve
pathagainsttwinand refuse if it reaches no array leaf.- Raises:
ConfigError – on any of three conditions found here – independent of the schema’s own refusal numbering, which none of the three maps to one-for-one. (1) The walk itself raises: a bad head, an ambiguous
manynode (AmbiguousNodeError), or an unknown attribute. (2) The walk lands short of a leaf: either on an operator or other pytree container with fields still below it, or on a genuine static field. (3) The walk reaches a real pytree leaf that is not an array. The whole-document checks – two paths sharing a leaf, a path into an aliased node, and a region’s config key not equal to its last covered node – arerefuse_duplicate_targets,refuse_aliased_targetandrefuse_misaddressed_regionbelow; a fifth whole-document check (atwin.replacetarget colliding with a binding’s) is Plan 2’s, onceinference.parameters’ replace targets exist for it to fold in.- Parameters:
- Return type:
- rheplicant.config.paths.refuse_duplicate_targets(paths, twin)[source]¶
Refusal 4: two declared paths that reach one leaf.
Both spellings are named.
keystralone would tell the reader that.stages[0].gainis written twice without saying which two keys of their document did it.
- rheplicant.config.paths.refuse_aliased_target(path, twin)[source]¶
Refusal 3: the path’s head names a node folded in at more than one place.
Assembly.aliasedis empty for every shipped graph and is the documented hazard for user-defined ones. Binding would rewrite the one branch this path reaches and leave the others in the forward model, which would then answer as if the latent were frozen everywhere but that branch – finite, correctly shaped, wrong. The package’s own guard (inference/parameters.py:704-738) is path-based rather than spelling-based, so naming the second copy by index is refused identically; this one is a pre-flight on the head and does not replace it.
- rheplicant.config.paths.refuse_misaddressed_region(config_key, region)[source]¶
Refusal 6 / check A47: a region’s config key must equal
at[-1].Atwith a tuple of node ids covers a contiguous region, and the fold labels the covering operator with the LAST node id (core/graph.py:131-134, implemented atcore/fold.py:409-414andcore/graph.py:1071). A config key naming any other covered node resolves to nothing, and the failure is a bareKeyErrorrather than the refusal the schema promised.
The resources DAG: build each named entry once, and hand back the object.
{ref: ...} is object identity, not a copy, and that is a physics
requirement rather than an optimisation:
BeamSpillOperator.from_projector is documented as “the one call that
cannot get the weight and the sky average out of step”, and check B9 asks
whether two projectors nominally sharing a beam actually share the array. A
loader that rebuilds each reference passes every shape check and decouples
them silently.
Build order comes from the reference graph rather than from the document’s
own order, so a reader does not have to know which entry to write first. A
cycle is refused by name – resources.arrays is a let-binding, and a
let-binding that refers to itself has no value.
extends: deep-merges over a sibling of the same kind: mappings merge,
lists replace, {append: [...]} extends, ~key: null deletes. Lists
replacing rather than merging is the rule that forces a whole comparison into
one document – split across two files, the halves can silently disagree in
exactly the keys the comparison is about.
- rheplicant.config.resources.register_kind(name)[source]¶
Register a resource-kind builder. Returns the function.
- Parameters:
name (str)
- rheplicant.config.resources.RESOURCE_KINDS = ['arrays', 'bases', 'beams', 'projectors', 's_params', 'sky_models']¶
Every registered kind, live. Kind modules add to it on import.
- rheplicant.config.resources.check_unknown_keys(name, spec, allowed, *, label, note='', hints=None)[source]¶
Refuse any key
labeldoes not consume, naming what it does.Shared rather than reimplemented per kind, because the shape it exists to rule out has already appeared twice independently:
kinds/sky_models.py’s own_check_unknown_keysand (before this helper) the same sweep written by hand inkinds/beams.py. Before either existed, a branch that only read the keys it happened to consume left a stray sibling key –{kind: uniform, ..., spectral_index: 2.5}– silently discarded rather than refused. A discriminated union (kind:,format:) is exactly the shape where that happens, because each branch is naturally written to read its own keys and nothing else, and nothing forces it to also account for what is left over.- Parameters:
name (str) – the entry’s dotted name, quoted first in the refusal.
spec (dict) – the mapping under inspection.
label (str) – what the sweep speaks for (
"kind: touchstone").note (str) – a sentence appended to every refusal from this call site.
hints (Mapping[str, str] | None) – per-key sentences appended only when that key is among the unknown ones – the z0-belongs-to-termination redirect, without the call site hand-rolling its own sweep to say it.
- Return type:
None
- class rheplicant.config.resources.BuiltResources(resources, shared_objects, order)[source]¶
Bases:
NamedTupleWhat a
resources:section produced.resources— dotted name -> object, ready to put in aResolutionContext.shared_objects— groups of names that ended up as one object, forconfig.resolved.yaml’sshared_objects:map. Grouped byid(), which treats interned scalars (small ints,None, short strings) as “shared” even when they were built independently – a kind builder must hand back arrays or other real objects, never a bare Python scalar, or this map over-reports.order— the order entries were built in, for provenance.
- Parameters:
Alias for field number 1
- rheplicant.config.resources.resolved_specs(section)[source]¶
resources:->{"resources.<kind>.<name>": spec-after-``extends:}``.THE KEY IS THE DOTTED STRING, exactly
build_resources()’ own and exactlyBuiltResources.resources’ – never the bare name, never a tuple. Callers select a kind withk.startswith("resources.projectors.").TOTAL: IT NEVER RAISES, and that is the whole reason this function exists rather than each reader calling
_resolved_specitself. Measured,_resolved_specraises by name on six malformed shapes: anextends:cycle, a self-extend, a dangling parent, a cross-kind parent, a non-stringextends:, and{append: ...}beside a sibling key. A pre-flight check that let one escape would be wrapped by the pass as “pre-flight check ‘A11’ RAISED ConfigError: …” – which aborts the pass and hides every finding after it, while every existingmatch=pin still passes, becausematch=searches. A green suite and a stack-trace-shaped user message is the worst of the two failures available here.So each malformed entry is DROPPED from the mapping. A check that finds an entry missing stands down on it: refusing on “I could not tell” refuses documents that build.
What the backstop is, stated narrowly. For the six shapes above,
build_resources()says the right sentence at the right phase – each is aConfigErrornaming the entry. For a shape it does not model, the backstop is only the builder’s own exception, and that may not be this layer’s voice at all: measured,{arrays: {a: {1: 'x'}}}– a spec whose KEY is not a string – passes through here untouched and dies insidebuild_resourcesas a bareTypeError: 'int' object is not subscriptable. What this function guarantees is narrower than “the right sentence”: it is that the PASS is not aborted, so every other finding on the document still reaches the user.It reads ONE LAYER. A check must not call it once on
document["resources"]and stop – that closes the base route and leaves thevariants:twin open. Walk the layers withpreflight/document.py::_task3_over_layersand call this per layer:return _task3_over_layers(document, lambda layer: _per_layer( resolved_specs(layer.get("resources"))))
The walk belongs to the CALLER, and the reason is not an import cycle. An earlier draft of this docstring said a head import of
_task3_over_layershere would close one; a reviewer tried it and it imports cleanly from all four entry points, ruff green. The real reason is placement: this module sits BELOW both passes so that both can read it without either importing the other, and layering is apreflight/concept – the axes pass is handed a document that has already had its variant applied and has no layers to walk at all.- Parameters:
section (Mapping[str, Any] | None) – a layer’s
resources:block, orNone/anything else – a non-mapping is a shapebuild_resources()refuses, and a reader that has not built yet must not pre-empt that sentence.- Returns:
Dotted name -> the resolved spec, for every entry that resolves. A SHALLOW copy per entry, so a caller cannot edit the user’s document through it; nested values are still shared, as they are with
build_resources().- Return type:
- rheplicant.config.resources.build_resources(section, context)[source]¶
Build every entry of a
resources:section, once each, in dependency order.- Raises:
ConfigError – on an unknown kind, an
extends:across kinds, a reference to an entry no kind declares, or a cycle.- Parameters:
section (dict)
context (ResolutionContext)
- Return type:
Importing this package is how every resource-kind builder gets registered.
All six, as of this commit – one import each, until the set is complete.
One import rather than six: a kind that is defined but never imported is a kind the registry does not have, and the failure is an “unknown kind” refusal that lists a set which is silently short.
resources.arrays: any value node, bound to a name.
There is no package constructor behind this kind and there is nothing to
mirror – find src -name "*array*" returns nothing. It exists because the
value grammar deliberately has no expression language, and a schema that
cannot say f(g(x), y) cannot express the seven reflection coefficients
examples/gibbs_plan.py:112-119 builds by nested rhino_cal_jax calls.
Naming is the answer: bind the inner call to a name, reference it from the
outer one. It is a let-binding, not an expression – there is no operator, no
precedence and no evaluation order to reason about, and the resulting DAG is
exactly the ref graph resources already is.
- rheplicant.config.kinds.arrays.build_array(name, spec, context)[source]¶
Resolve one named value node.
nameis the dottedresources.arrays.<name>the caller built this under – every kind builder takes it, because_KINDS[kind]is called the same way for all six kinds, but this builder has no use for it: the value node it resolves does not need to know its own name.- Parameters:
name (str)
spec (dict)
context (ResolutionContext)
- Return type:
resources.bases: a SeparableBasis whose sample count comes from the grid.
n is never written. radio/t_sys.py states the failure it prevents –
“a basis built for another band would return a smooth, plausible, wrong
temperature” – and taking n from observation.time.grid /
observation.freq.grid makes that structurally impossible rather than
merely discouraged. For the same reason a file: route for a design matrix
is refused: schema §7 names it, and open question 11.8 recommends keeping the
refusal, because a copied built_for: provenance block is exactly what a
copied basis comes with.
One orientation hazard the package states and nothing can catch
(core/basis.py:63-77): T = time @ coeff @ freq.T, so with
n_time == n_freq and n_k == n_j a swapped pair of design matrices is
shape-legal and returns the transpose of the intended field. Writing the two
axes under their own keys is the only protection there is.
- rheplicant.config.kinds.bases.build_basis(name, spec, context)[source]¶
Build one
SeparableBasisfrom two per-axis declarations.- Parameters:
name (str)
spec (dict)
context (ResolutionContext)
- Return type:
resources.sky_models: what the sky IS, separately from how it is seen.
The sky and the beam meet at exactly one node – model.observed_astro_sky,
whose two fields are sky_model and projector – and they are declared
apart because they are separately reusable and separately expensive. Giving
the sky its own top-level section would put the beam in two places, which is
the coupling {ref: ...} identity exists to prevent.
kind: maps builds MapSky, and this is the one
place in the config layer that can catch the failure that class’s own
docstring names: MapSky.__call__ returns the stored maps and does not
consult its freq argument beyond the shape, so maps built for 60-85 MHz
evaluated on a 100-125 MHz grid of the same length come back finite,
plausible and wrong – and cannot be caught under jit, because the values
are traced and only the shape is static. Here both grids are in hand at once.
- rheplicant.config.kinds.sky_models.build_sky_model(name, spec, context)[source]¶
Build one sky model, discriminated on
kind.- Parameters:
name (str)
spec (dict)
context (ResolutionContext)
- Return type:
resources.beams: a raw (n_freq, n_pix) array, plus what the file cannot say.
A beam is not an object in this package – cst_beam_maps returns a bare
np.ndarray and every one of its six arguments is unrecoverable from it. So
this kind returns a small container holding the maps and the sky fraction,
because horizon_truncated_beam produces two products from one call and the
fraction is exactly what BeamSpillOperator(sky_fraction=) wants. v0
consumed the maps and dropped the fraction, which left the user with
from: projector – and on a truncated beam that returns approximately 1.0
and silently deletes the (1 - f_sky) * T_ground term.
Two declarations have no default and no preset may supply them.
normalize: – the output’s unit is decided by the pair (beam
normalisation, normalize_beam): 32838 K against 200 K on a uniform 200 K
sky for an unnormalised beam, and 100.42 K against a 99.79 K sky for a
unit-pixel-sum one. Neither half can be inferred from the numbers.
phi0_deg / phi_sense – required for format: cst only, and
refused for every other format. radio/beams.py calls them “a fact about
the as-built horn, not the file”. Decision D-C3 rejected the option of letting
a preset supply them marked provisional, for a measurement-shaped reason: a
mirrored beam passes every integral, every peak and every
azimuthally-symmetric diagnostic unchanged. There is no numerical symptom, so
the only protection is that the value was stated by someone who knew it.
format: uvbeam and format: healpix (D-C7, decided 2026-08-09, after
this module’s first draft) both build the raw array from a file, and neither
takes phi0_deg/phi_sense: uvbeam because the limTOD bridge carries
its own azimuth convention, and healpix because RING-versus-NESTED, not a
CST meridian, is the fact its file cannot state. healpix still needs
frame:, the same as any other raw array.
- class rheplicant.config.kinds.beams.Beam(maps, sky_fraction, nside, normalize)[source]¶
A beam resource: the maps, and the fraction of sky they see.
- sky_fraction¶
(n_freq,). All ones where nothing was truncated – written rather than omitted, soBeamSpillOperatoralways has a fraction to be handed and the “which one did it use” question never arises.- Type:
- rheplicant.config.kinds.beams.build_beam(name, spec, context)[source]¶
Build one beam resource.
- Parameters:
name (str)
spec (dict)
context (ResolutionContext)
- Return type:
resources.projectors: how the sky is seen, with the beam inside it.
Field names are the Python names verbatim, which is the v0 contradiction removed – v0 stripped the unit suffixes here while the path grammar kept them, so one field had two spellings.
Four rules this module exists to enforce, each with a measurement behind it.
normalize_beam has no default and must be written: false returns
integral(B . T dOmega), which is not a temperature – 32838 K against
200 K on a uniform 200 K sky.
beam_frame and beam_ref_lst_deg are not writable. They are set only
by to_reference_frame(), and __check_init__ exists to catch a hand-set
pair; a YAML that wrote them would drive the object into exactly the state
that guard is for. The config route is the ordered optimizations: list.
beam_iterations is from_beam_maps(iterations=) – the map2alm_iter
count of the beam analysis, which directly sets beam_alms, the
projector’s only traced array. It is a constructor argument and not a stored
field (verified: hasattr(projector, 'iterations') is False), so it is
unrecoverable from the built object and the config key is the only place it can
ever be captured. Do not confuse it with the field mask_iterations, which
also defaults to 3 and is a different quantity.
nside may not be written on the from_beam_maps path: the classmethod
infers it from the map length and passes it itself, so a config that also
passed it raises TypeError: got multiple values for keyword argument
'nside'.
- rheplicant.config.kinds.projectors.build_projector(name, spec, context)[source]¶
Build one projector, discriminated on
engine.- Parameters:
name (str)
spec (dict)
context (ResolutionContext)
- Return type:
resources.s_params: reflection coefficients, from a file or from rhino-cal.
z0 appears under kind: termination and nowhere else, and that is not an
oversight: Touchstone.z0 is parsed and never read by any other module,
while termination_gamma(z0=) is read. A key exists where it is
consumed.
flipped: is recorded even though the WIRING cannot be checked – whether
the device really was connected to the VNA reversed is not something any
config layer can verify after the fact. The PLUMBING is a different claim
and this module’s own tests pin it directly: read_touchstone applies the
port reversal into s before Touchstone is even constructed, so a
2-port fixture with distinct s11/s22 sweeps proves flipped: written here
actually reaches read_touchstone(flipped=).
One column-order hazard worth carrying: a Touchstone 2-port data row is
freq S11 S21 S12 S22. The second pair is S21, not S12. The module
docstring calls it “the single most likely thing to get wrong here”; this
layer never reorders, it only names a component and lets the reader answer.
kind: touchstone reads its file through the PUBLIC file: value node
(resolve_value({"file": file_spec}, context)), not a private reach into
files._READERS. That is only possible because format: touchstone is
registered with array=False: the reader returns a
Touchstone, not an array, and
files.py’s file: form hands an array=False reader’s result back
unwrapped – no jnp.asarray, which would either mangle or refuse a
dataclass – and refuses any modifiers on that node, because a modifier
describes what an array’s numbers ARE and a Touchstone is not one.