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: Module

Immutable data/state container for differentiable pipelines.

Parameters:
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:

rheplicant.core.coordinates.Coordinates | None

env

Environment (numeric telemetry), or None.

Type:

rheplicant.core.environment.Environment | None

aux

dict of additional traced user arrays (weights, masks, caches).

Type:

dict[str, Any]

key

a typed JAX PRNG key (jax.random.key(seed)), or None.

Type:

jax.Array | None

meta

static, hashable metadata (FrozenMapping) — experiment info, hardware labels, observation IDs, arbitrary user fields.

Type:

rheplicant.core.frozen.FrozenMapping

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 changes applied; 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_at remains the right tool.

Parameters:

changes (Any)

Return type:

State

with_data(data)[source]

Shorthand for state.replace(data=data) — the most common update.

Parameters:

data (Any)

Return type:

State

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:

State

next_key()[source]

Split the PRNG key: return (subkey, state_with_advanced_key).

Raises:

MissingKeyError – if this State carries no key.

Return type:

tuple[Array, State]

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 is spacing(n*cadence) / cadence — and since spacing(x) is within a factor of two of x * 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 where n * cadence falls 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.time is STORED rather than of any one operator’s arithmetic: rheplicant.radio.instrument.calibration imports it rather than keeping its own copy.

rheplicant.core.coordinates.as_array_or_none(value)[source]

Converter: pass None through, coerce everything else to a jax array.

Parameters:

value (Any)

Return type:

Array | None

class rheplicant.core.coordinates.Coordinates(time=None, freq=None, pointing=None, extra=<factory>)[source]

Bases: Module

Coordinate axes of the data flowing through a pipeline.

Parameters:
time

(n_time,) sample times [seconds].

Type:

jax.Array | None

freq

(n_freq,) channel frequencies [Hz].

Type:

jax.Array | None

pointing

(n_time, k) pointing coordinates (e.g. alt/az pairs, k=2).

Type:

jax.Array | None

extra

dict of additional traced coordinate arrays (e.g. spatial grids).

Type:

dict[str, Any]

replace(**changes)[source]

Functional update: return a new Coordinates with changes applied.

Re-runs converters and validation (unlike raw eqx.tree_at).

Parameters:

changes (Any)

Return type:

Coordinates

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: Module

Numeric environmental telemetry.

Parameters:
temperature

ambient temperature(s) [K] — scalar or (n_time,).

Type:

jax.Array | None

humidity

relative humidity — scalar or (n_time,).

Type:

jax.Array | None

extra

dict of additional traced telemetry arrays (wind speed, soil moisture, receiver enclosure temperature, …).

Type:

dict[str, Any]

replace(**changes)[source]

Functional update: return a new Environment with changes applied.

Parameters:

changes (Any)

Return type:

Environment

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: Mapping

Immutable, 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"}
Parameters:
  • data (Mapping[str, Any] | Iterable[tuple[str, Any]] | None)

  • kwargs (Any)

set(**kwargs)[source]

Return a new FrozenMapping with kwargs added/overridden.

Parameters:

kwargs (Any)

Return type:

FrozenMapping

remove(key)[source]

Return a new FrozenMapping without key (KeyError if absent).

Parameters:

key (str)

Return type:

FrozenMapping

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: Module

A 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.

Type:

ClassVar[tuple[str, …]]

provides

dotted State paths this operator writes, e.g. ("data",). Documentation.

Type:

ClassVar[tuple[str, …]]

graph_node

home node on a SignalGraph template (assembly); None means “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().

Type:

ClassVar[tuple[str, …]]

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" in requires is a contract, not a note: it says this operator draws randomness through next_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.contract reads 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 requires names an absent one is not implementable against the operators shipped here: GroundPickupOperator declares "env.temperature" and documents a t_ground fallback for when it is missing, so its declaration means “reads if present”. And provides is ("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 in rheplicant.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_precede is 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: AbstractOperator

Wrap a pure function State -> State as 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))
Parameters:

fn (Callable[[State], State])

classmethod on_data(fn)[source]

Lift an Array -> Array (or pytree -> pytree) function onto state.data.

Parameters:

fn (Callable[[Any], Any])

Return type:

LambdaOperator

class rheplicant.core.operator.SnapshotOperator(name='raw')[source]

Bases: AbstractOperator

Save 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:
Return type:

tuple[AbstractOperator, …]

rheplicant.core.pipeline.resolve_names(operators, names)[source]

Shared name resolution/validation for composite operators.

Parameters:
Return type:

tuple[str, …]

rheplicant.core.pipeline.check_stage_ordering(stages, names)[source]

Enforce must_precede against 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_because exists to say — and the run converges and reports healthy diagnostics.

What this checks, and why it is weaker. must_precede names nodes in a graph’s vocabulary; a Pipeline is domain-agnostic and has only names. 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.py rather than left to be discovered:

  • refuse a constraint naming something no stage is called. assemble refuses 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. names is 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. SumOperator and SelectOperator run 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 no owner argument the way validate_operators() and resolve_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_unflatten reconstructs directly, so a jit trace, a gradient and an eqx.tree_at edit 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:
Return type:

None

class rheplicant.core.pipeline.Pipeline(*stages, names=None)[source]

Bases: AbstractOperator

An 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. See check_stage_ordering() for what that does and does not cover.

Parameters:
stages

the operators, applied first-to-last.

Type:

tuple[rheplicant.core.operator.AbstractOperator, …]

names

unique stage names (static). Auto-derived from class names if not given; pass names= for stable, meaningful labels. They are also the vocabulary must_precede is read in, so a pipeline whose stages carry ordering constraints wants meaningful ones.

Type:

tuple[str, …]

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.

Parameters:

state (State)

Return type:

tuple[State, tuple[State, …]]

replace_stage(index, operator)[source]

Return a new Pipeline with one stage swapped; names are preserved.

(For surgical edits inside a stage — e.g. one parameter — use eqx.tree_at instead of rebuilding.)

Parameters:
Return type:

Pipeline

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: AbstractOperator

Run source-type branches on the same input state and sum their data.

Semantics:
  • Every branch receives the input context (coords, env, meta) with data stripped to None — 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:
replace_branch(index, operator)[source]

Return a new SumOperator with one branch swapped; names preserved.

Parity with replace_stage().

Parameters:
Return type:

SumOperator

class rheplicant.core.combinators.SelectOperator(*branches, names=None, switch_key='switch_state')[source]

Bases: AbstractOperator

Select 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, not leaf * 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 for x non-finite, and every branch is evaluated at every sample. One inf in a switched-OFF sample of any branch entered the sum as inf * 0 -> nan and 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, and jax.disable_jit did not have that luck. A where never reads the unselected value, in any mode.

Precondition — branches must be finite at EVERY sample, including the ones they are switched off for. The where fixes 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 returns inf at 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 as 0 * 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

the selectable signal paths.

Type:

tuple[rheplicant.core.operator.AbstractOperator, …]

names

unique branch names (static).

Type:

tuple[str, …]

switch_key

key into coords.extra holding the switch state (static; graph assembly sets it to the selector node id).

Type:

str

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 requires path with an enforced consumer

rheplicant.core.contract.walk_operators(op, _label='')[source]

Yield (label, operator) for op and every operator nested in it.

The root’s label is "". Nested labels are /-joined stage names, so a noise stage inside a summed branch reads t_ant/atmosphere.

Parameters:
Return type:

Iterator[tuple[str, AbstractOperator]]

rheplicant.core.contract.stages_requiring(op, path)[source]

Every stage of op whose requires names the State path path.

Parameters:
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:

str

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: object

One node of a signal-path template.

Parameters:
  • kind (Literal['source', 'transform', 'junction', 'selector'])

  • doc (str)

  • many (bool)

  • segment (str)

  • reserved (bool)

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 via coords.extra[<node_id>]). Junctions and selectors have in-degree >= 2 and are never operator slots.

Type:

Literal[‘source’, ‘transform’, ‘junction’, ‘selector’]

doc

one-line description shown in renderings.

Type:

str

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 use many on a transform: instances chain in call order.

Type:

bool

segment

grouping label for rendering (e.g. “forward”, “processing”).

Type:

str

reserved

node exists in the physics but has no shipped operator yet (an equivalent-entry placeholder leaf).

Type:

bool

class rheplicant.core.graph.At(node, op)[source]

Bases: object

Place op at node regardless of its class registration.

node may 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_node may 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 handed data=None; neither is a placement, and assemble() refuses both — see _check_slot_kinds().

Parameters:
class rheplicant.core.graph.SignalGraph(name, nodes, edges)[source]

Bases: object

An 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 fixes lit ordering 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.

lit nodes are highlighted, skipped (traversed-as-identity) nodes are half-lit, everything else is dimmed — the signal-path view of what an assembly simulates.

counts maps a node id to the number of operator instances sitting on it. A many node 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_svg draws 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.

theme is "light" or "dark", matching to_svg() and to_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.

Parameters:
Return type:

str

to_html(lit=(), skipped=(), title=None, counts=None, theme='light')[source]

Standalone HTML page of the template with lit/dim signal-path styling.

Parameters:
Return type:

str

to_svg(lit=(), skipped=(), title=None, counts=None, theme='light')[source]

Self-contained <svg> of the template, for embedding (docs, notebooks).

theme is "light" or "dark". An <img>-embedded SVG cannot read the host page’s theme, so a page that switches renders a pair.

Parameters:
Return type:

str

rheplicant.core.graph.register_graph(graph)[source]

Register a template so Assembly.to_mermaid can find it by name.

Parameters:

graph (SignalGraph)

Return type:

SignalGraph

class rheplicant.core.graph.Assembly(operator, graph_name, lit, skipped, has_source, root_label='', instances=(), materialized=(), aliased=(), placements=())[source]

Bases: AbstractOperator

A 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 with replace_node(), render the lit/dim signal path with to_mermaid().

Parameters:
lit

template nodes this assembly claims, in template order.

Type:

tuple[str, …]

skipped

nodes traversed as identity between lit ones.

Type:

tuple[str, …]

instances

(node_id, instance_ids) for every node carrying more than one operator. lit names 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.

Type:

tuple[tuple[str, tuple[str, …]], …]

materialized

junction/selector nodes that actually became a SumOperator/SelectOperator (rather than passing through).

Type:

tuple[str, …]

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() and validate() 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-rolled eqx.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 .aliased yourself 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.

Type:

tuple[str, …]

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.

Type:

tuple[tuple[tuple[str, …], str], …]

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_source is 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 no graph_node cannot 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_id swapped.

Raises rather than swapping when node_id names something that is not one operator: a many node 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 three eqx.tree_at would 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.

operator must be an AbstractOperator. None in particular is refused by name: it reads as “take this stage out”, and it used to return an Assembly whose lit and whose mermaid rendering both still claimed the stage, which then died on the next call with TypeError: 'NoneType' object is not callable. Removing a stage is without().

Parameters:
Return type:

Assembly

without(node_id)[source]

Return a new Assembly with the operator(s) at node_id dropped.

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 one replace_node() used to invite with None and then answer wrongly.

Not tree surgery: this re-runs assemble() over the remaining operators, recovered from the built tree by the addresses recorded in placements. So the result is exactly the assembly you would have got by not providing that operator in the first place — same fold, same lit/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 in must_precede is 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 many node is dropped whole: every instance on it goes. Use assemble() directly to keep some of them.

Raises:

AssemblyError – if node_id carries no operator in this assembly, if it is the only one, or if this Assembly was not built by assemble() (so there is no recipe to re-run).

Parameters:

node_id (str)

Return type:

Assembly

to_mermaid(theme='light')[source]

Lit/dim mermaid rendering via the registered template.

theme is "light" or "dark"; see SignalGraph.to_mermaid().

Parameters:

theme (str)

Return type:

str

to_html(title=None, theme='light')[source]

Standalone HTML page: the full graph with this assembly’s nodes lit.

Parameters:
  • title (str | None)

  • theme (str)

Return type:

str

to_svg(title=None, theme='light')[source]

Self-contained <svg> with this assembly’s nodes lit, for embedding.

theme is "light" or "dark"; see SignalGraph.to_svg().

Parameters:
  • title (str | None)

  • theme (str)

Return type:

str

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 AssemblyError on 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 violated must_precede ordering 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_at rewrites 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 in Assembly.aliased, and both Assembly.replace_node() and validate() 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 aliased is always empty there. This bites user-defined graphs only.

Parameters:
Return type:

Assembly

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. Measured cond(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**14 and x**16 agree to a few percent over most of [-1, 1] — and that condition number lands directly on kappa of the block’s normal operator, which is what wiener_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 against numpy.polyfit, say). The ordering is pinned by test_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: object

A 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 reason LinearBlock is: this is a derived linear-algebra handle, something you build where you need it, not a pytree to carry through a model. An eqx.Module would be actively wrong here, and specifically at the pattern this class exists to serve — equinox wraps a Module’s bound methods as pytrees, so Bind(..., fn=basis.expand) would put the design matrices in Bind’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; see BasisTemperatureOperator.

eq=False is 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 to eq=True, which compares the fields elementwise. Then basis_a == basis_b answers True when the two hold the same array objects and raises ValueError: the truth value of an array with more than one element is ambiguous only when they hold distinct-but-equal ones — so the failure is intermittent, which is worse than always. And __hash__ does not become None: on a frozen dataclass it is generated and raises TypeError: unhashable type: 'ArrayImpl'. eq=False gives 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 makes fn=basis.expand safe as a static field — a bound method compares and hashes its __self__ by POINTER, so that route works either way. Being an eqx.Module is 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_k functions in time, n_j in frequency” — and the frequency count is the one the calibration tone argument turns on (see the module docstring).

Parameters:
time

(n_time, n_k) design matrix on the time axis.

Type:

jax.Array

freq

(n_freq, n_j) design matrix on the frequency axis.

Type:

jax.Array

property shape: tuple[int, int]

(n_time, n_freq) — the grid expand() lands on.

property coeff_shape: tuple[int, int]

(n_k, n_j) — the shape of the coefficient this basis takes.

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 in coeff (linear, in fact), which is the claim Latent(..., linear=True) makes and check_linearity checks.

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_wave accepts (), (n_freq,), (n_time, 1) and (n_time, n_freq), and states that a bare 1-D array is unresolvably ambiguous when n_time == n_freq. An expansion never produces one.

Parameters:

coeff (Any)

Return type:

Array

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 init for 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 from max|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 values against expand(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.

Parameters:

values (Any)

Return type:

Array

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_x64 says, 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_time or n_freq.

  • n_basis (int) – number of basis functions, i.e. of coefficients on this axis. Keyword-only, along with n, because basis_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 a SeparableBasis is always the mean level.

Return type:

(n, n_basis)

Raises:

StateValidationError – on an unknown kind, a non-positive n or n_basis, or n_basis > n. Not ParameterSpaceError – this module’s docstring argues that one deliberately is not part of rheplicant.core’s public surface, so a caller who wrote except ParameterSpaceError around 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 of POWER_ITERATIONS operator applications per guarded solve. It was twice that while extreme_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.

Parameters:

parts (Any)

Return type:

Array

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.

Parameters:
  • operator (Callable[[Any], Any]) – the symmetric positive-definite map, pytree to pytree.

  • template (Any) – a pytree of the operator’s domain, used for shapes and dtypes.

  • key (Array) – PRNG key for the starting vector.

  • iterations (int) – number of steps.

Return type:

Array

rheplicant.core.conditioning.extreme_eigenvalues(operator, template, key, iterations)[source]

(λ_max, λ_min) of a symmetric positive-definite operator.

λ_min comes 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 λ_min would 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 λ_min is 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.

Parameters:
Return type:

tuple[Array, Array]

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 graph as 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>.

counts maps a node id to how many operator instances an assembly put on it; many nodes carrying more than one say so in their label, because the template draws them as a single box either way.

theme selects 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.

Parameters:
Return type:

str

rheplicant.core.render.signal_path_html(graph, lit=(), skipped=(), title=None, counts=None, theme='light')[source]

Render graph as a standalone HTML page with lit/dim signal-path styling.

Parameters:
Return type:

str

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: Exception

Base class for all public rheplicant errors.

exception rheplicant.core.errors.StateValidationError[source]

Bases: DirtError, ValueError

A 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, RuntimeError

An operator needed randomness but State.key is None.

Fix: construct the state with key=jax.random.key(seed).

exception rheplicant.core.errors.PipelineError[source]

Bases: DirtError, ValueError

A Pipeline was misconfigured (empty, bad stage type, name collision…).

exception rheplicant.core.errors.ParameterSpaceError[source]

Bases: DirtError, ValueError

A 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: ParameterSpaceError

check_linearity measured a departure from linearity, and refused.

A SUBCLASS and not a new member of the family, deliberately: every except ParameterSpaceError already written against check_linearity() keeps catching this, and the refusal’s message is unchanged, so the pytest.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}, or None when the check was run without a noise model and the second criterion therefore did not apply. None rather 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 None for the same reason.

exception rheplicant.core.errors.LogSpaceUnavailable[source]

Bases: ParameterSpaceError

A quantity log cannot be taken of, where log space was being asked for.

A SUBCLASS for the same reason LinearityRefused is one: every except ParameterSpaceError already 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 of ParameterSpaceError’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, ValueError

A 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, ValueError

A provided operator set cannot be assembled on the signal graph.

exception rheplicant.core.errors.AmbiguousNodeError[source]

Bases: AssemblyError

A node id was used as an address, but it holds more than one operator.

Raised by Assembly’s __getitem__ and replace_node for a many=True node carrying several instances. (Those two are literals rather than :meth: roles: __getitem__ is a dunder, which automodule emits no target for, so the role would be a nitpicky-build warning rather than a link. Inside graph.py the 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, through replace_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: Module

Parameters -> 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: AbstractSkyModel

PLACEHOLDER: spatially and spectrally uniform sky.

Parameters:
amplitude

brightness temperature [K] — differentiable scalar.

Type:

jax.Array

n_pix

number of sky pixels (static configuration).

Type:

int

class rheplicant.radio.sky.model.PowerLawSkyModel(amplitude, spectral_index, ref_freq, n_pix)[source]

Bases: AbstractSkyModel

PLACEHOLDER: 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.

Parameters:
amplitude

(n_pix,) amplitude map at ref_freq [K] (or scalar).

Type:

jax.Array

spectral_index

power-law index — differentiable scalar.

Type:

jax.Array

ref_freq

reference frequency [Hz] (static configuration).

Type:

float

n_pix

number of sky pixels (static configuration).

Type:

int

class rheplicant.radio.sky.model.MapSky(maps, freq)[source]

Bases: AbstractSkyModel

Fixed 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 under jit – the values are traced, only the shape is static. That failure returns a smooth, plausible, wrong temperature, so freq is stored to give it a name and a place for a config layer to check it before tracing begins.

Parameters:
maps

(n_freq, n_pix) brightness temperatures [K] – a differentiable leaf, so a sky can be inferred rather than assumed.

Type:

jax.Array

freq

(n_freq,) the frequency grid the maps were built on [Hz].

Type:

jax.Array

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) at O(lmax³ + n_time·lmax) instead of O(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. from limTOD.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: Module

Sky 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:
Return type:

Array

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:
Return type:

Array

class rheplicant.radio.sky.projection.MatrixProjector(matrix)[source]

Bases: AbstractSkyProjector

Linear projection by a precomputed sky->TOD matrix.

The matrix is exactly what limTOD.simulator.generate_sky2sys_projection produces (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:

jax.Array

forward(sky, coords)[source]

Observe the sky: (n_freq, n_pix) -> (n_time, n_freq).

Parameters:
Return type:

Array

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:
Return type:

Array

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: AbstractSkyProjector

Pure-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 DriftScanProjector instead: it returns the same numbers to float64 roundoff for a single rotation over the whole scan (O(lmax³ + n_time·lmax) vs O(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).

Parameters:
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:

jax.Array

lat_deg

site latitude [deg] (static).

Type:

float

lmax

harmonic band-limit; must match beam_alms length (static).

Type:

int

nside

HEALPix nside of the sky maps, RING ordering (static).

Type:

int

normalize_beam

numpy limTOD’s normalize_beam semantics — divide each sample by the rotated beam’s pixel sum (static).

Type:

bool

forward(sky, coords)[source]

Observe the sky: (n_freq, n_pix) -> (n_time, n_freq).

Parameters:
Return type:

Array

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:
Return type:

Array

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=True routes 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: AbstractSkyProjector

m-mode sky projector for drift scans: one beam rotation, per-m phases.

Equal to GeneralPointingProjector with constant pointing, to float64 roundoff — but the whole scan costs a single Wigner rotation. Pure JAX (jit/vmap/grad-safe), exact transpose in adjoint(), and mmodes() 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

(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:

jax.Array

lat_deg

site latitude [deg] (static).

Type:

float

az_deg / el_deg / selfrot_deg

the fixed drift-scan pointing [deg] (static configuration).

lmax

harmonic band-limit matching beam_alms (static).

Type:

int

nside

HEALPix nside of the sky maps, RING ordering (static).

Type:

int

normalize_beam

numpy limTOD’s normalize_beam semantics (static).

Type:

bool

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:

bool

apod_deg

cosine-apodization width of the horizon cut [deg of elevation] (static; only used with horizon_mask).

Type:

float

mask_iterations

healpy-equivalent map2alm iterations in the mask re-analysis (static).

Type:

int

lst_ref_deg

reference LST [deg] of the m-mode expansion (static); None uses the first sample of coords.extra["lst_deg"]. Any value gives the same TOD — it only re-anchors the phases.

Type:

float | None

beam_frame

"local" (default) — beam_alms are 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 at lst_ref_deg, so the rotation is skipped. Build the latter with to_reference_frame() (static).

Type:

str

uniform_sampling

use the FFT synthesis/adjoint (static). Requires coords.extra["lst_deg"] to be a uniform grid over a full sidereal turn with 2·lmax < n_time; validated by limtod_jax whenever the values are concrete. Gradients w.r.t. the LST grid then live on a one-parameter family (a global shift), so dphi’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:

bool

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 in n_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 it None unless 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 equal lst_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 (what forward() uses on the SKY) returns quadrature alms; the beam needs TRUE alms, i.e. healpy’s hp.map2alm(beam, lmax, iter=3). Picking the visible one silently rescales the beam by npix/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 external hp.map2alm call it stays inside the trace: gradients flow to the beam MAP, which is what a beam model is parameterized in. nside is 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 iter equivalent 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:

DriftScanProjector

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. With horizon_mask=True a 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 to BeamSpillOperator, which applies both halves so they cannot disagree – or use BeamSpillOperator.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. Call horizon_fraction() on the local-frame projector first, then cache.

Return type:

Array

static uniform_lst_grid(n_time, lst0_deg=0.0)[source]

The LST grid uniform_sampling=True requires: a FULL sidereal turn.

lst0 + 360·t/n_time — note the excluded endpoint. The natural jnp.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.

Parameters:
  • n_time (int) – number of samples per sidereal day; the FFT path needs 2·lmax < n_time (sampling theorem — m = lmax must stay off the Nyquist bin).

  • lst0_deg (float) – LST of the first sample [deg].

Return type:

Array

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 transform from_beam_maps() uses for the BEAM. The two differ by npix/4pi; that is why this helper exists rather than a line in the docstring telling you which to call.

Parameters:

sky (Array)

Return type:

Array

forward(sky, coords)[source]

Observe sky MAPS — the AbstractSkyProjector contract.

A thin wrapper over forward_alms(); when the sky is fixed, analyse it once with sky_to_alms() and call that directly.

Parameters:
Return type:

Array

forward_alms(sky_alms, coords)[source]

Observe pre-analysed sky alms — the engine’s native input.

Parameters:
Return type:

Array

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:
Return type:

Array

mmodes(sky, coords)[source]

m-modes Ṽ_m of 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). coords supplies the reference LST anchoring the phases (first sample unless lst_ref_deg is 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 what forward() 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 use sky_to_alms() once and call mmodes_alms().

Parameters:
Return type:

Array

mmodes_alms(sky_alms, coords)[source]

m-modes from pre-analysed sky alms — see mmodes().

Parameters:
Return type:

Array

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 subsequent forward/adjoint/mmodes — the difference between paying the rotation once and paying it per likelihood evaluation. Call it OUTSIDE the inference loop.

Fully functional: self is 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:

DriftScanProjector

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: AbstractOperator

Produce the sky’s antenna-temperature contribution: projector(sky_model).

Parameters:
sky_model

what the sky is — AbstractSkyModel.

Type:

rheplicant.radio.sky.model.AbstractSkyModel

projector

how it is seen — AbstractSkyProjector.

Type:

rheplicant.radio.sky.projection.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: AbstractOperator

Fill state.data with a uniform sky brightness [K] (placeholder).

Parameters:

amplitude (Array)

amplitude

sky brightness temperature [K] — a differentiable scalar.

Type:

jax.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: AbstractOperator

Produce 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.

Parameters:
depth

trough depth [K] (positive number gives absorption).

Type:

jax.Array

centre

trough centre frequency [Hz].

Type:

jax.Array

width

trough Gaussian width [Hz].

Type:

jax.Array

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: AbstractOperator

Produce 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.

Parameters:
amplitude

brightness temperature at ref_freq [K].

Type:

jax.Array

spectral_index

power-law index (synchrotron-like ~2.5).

Type:

jax.Array

ref_freq

reference frequency [Hz] (static configuration).

Type:

float

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: AbstractOperator

Produce a constant beam-diluted point-source level (placeholder).

Parameters:

level (Array)

level

effective contribution [K] — differentiable scalar.

Type:

jax.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: AbstractOperator

Apply a chromatic ~freq^-2 distortion to existing data (placeholder).

data * (1 + delta * (freq / ref_freq)^-2)delta is a differentiable leaf controlling the distortion amplitude.

Parameters:
delta

fractional distortion at ref_freq.

Type:

jax.Array

ref_freq

reference frequency [Hz] (static configuration).

Type:

float

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 to ground_pickup and t_sys_extra. It sits before the receiver_input switch (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: AbstractOperator

Produce 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_input switch, 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_field node 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)

t_atm

emission temperature — differentiable scalar or (n_freq,).

Type:

jax.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: AbstractOperator

Produce 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 coupling a field rather than a scalar.

Contribution: coupling * T_amb where T_amb comes from state.env.temperature (scalar or per-time) if present, else from the t_ground fallback parameter.

Parameters:
coupling

sidelobe coupling fraction — differentiable scalar.

Type:

jax.Array

t_ground

fallback ground temperature [K] — differentiable scalar.

Type:

jax.Array

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: AbstractOperator

Produce sparse random RFI spikes (placeholder).

Contribution: amplitude * mask where mask ~ Bernoulli(occupancy) per (time, freq) cell, drawn through the State PRNG protocol.

Parameters:
amplitude

spike amplitude [K] — differentiable scalar.

Type:

jax.Array

occupancy

probability a cell hosts RFI (static configuration; the mask draw is not differentiable anyway).

Type:

float

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 a cst_beam_func for 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_deg and phi_sense are assumptions to check, not results, and for RHINO’s horn — 30-60 % azimuthal structure around the theta = 30 deg 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.

Returns:

(theta_deg, phi_deg, directivity); directivity is linear power (10 ** (dBi / 10)), not dB.

Parameters:

path (str | Path)

Return type:

tuple[ndarray, ndarray, ndarray]

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.

Parameters:
Return type:

dict[float, Path]

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 takes freq_hz in Hz to match Coordinates.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 = 0 meridian.

  • phi_sense (str) – "ccw" or "cw". A fact about the horn, not the file — see limTOD.cstbeam.

Returns:

(n_freq, 12 * nside ** 2) linear-power beam maps, unnormalized. Pass normalize_beam=True to the projector and let it divide by its own quadrature, which is the only way the band limit cancels exactly (see docs/sky-engines.md).

Return type:

ndarray

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 that nside need not be repeated when the maps already carry it.

Parameters:
  • beam_maps(n_freq, npix) or (npix,) HEALPix RING beam maps in the beam-local frame.

  • el_deg (float) – boresight elevation [deg]; only 90 is supported — see limTOD.

  • apod_deg (float) – cosine-apodization width of the cut [deg of elevation].

Returns:

(truncated_maps, sky_fraction), shapes (n_freq, npix) and (n_freq,). Hand the fraction straight to BeamSpillOperator.

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: AbstractOperator

Attenuate by the radiation efficiency and add the antenna’s own emission.

Parameters:
efficiency

radiation efficiency eta; differentiable scalar or (n_freq,) spectrum. 1.0 is a lossless antenna and makes the operator an exact identity.

Type:

jax.Array

t_physical

the antenna structure’s physical temperature [K]; differentiable scalar or (n_freq,).

Type:

jax.Array

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 at T with ground also at T still gives T.

  • AntennaLossOperator — ohmic dissipation inside the antenna, acting on the whole t_ant_sum.

  • c_s = (1 - |Gamma|^2)|F|^2 inside NoiseWaveOperator — 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: AbstractOperator

Mix the horizon-masked sky with the ground the rest of the beam sees.

Parameters:
sky_fraction

f_sky, the above-horizon beam fraction; differentiable scalar or (n_freq,) spectrum. 1.0 means no spill and makes the operator an exact identity.

Type:

jax.Array

t_ground

brightness temperature seen below the horizon [K]; differentiable scalar or (n_freq,).

Type:

jax.Array

classmethod from_projector(projector, *, t_ground)[source]

Take f_sky from 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() — today DriftScanProjector, 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:

BeamSpillOperator

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 - 1 free values into an n_freq bandpass 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 free is not 1-D. A (1, n) array would otherwise concatenate along the wrong axis and return a bandpass of the wrong length, which ReceiverOperator would then reject with a channel-count message pointing at the wrong thing.

Parameters:

free (Array)

Return type:

Array

rheplicant.radio.instrument.receiver.unit_mean_free(bandpass)[source]

The unit_mean_bandpass coordinates of an existing bandpass estimate.

The inverse of unit_mean_bandpass() on the mean-1 hyperplane: unit_mean_bandpass(unit_mean_free(b)) is b / mean(b). Use it to turn a measured or modelled bandpass into a Latent’s init, so the starting point is the estimate itself rather than a flat guess.

The overall level of bandpass is discarded, by construction — that is the gain’s to carry. Give the gain latent an init scaled by the discarded mean(bandpass) if the product is meant to be preserved.

Raises:

StateValidationError – if bandpass is not 1-D.

Parameters:

bandpass (Array)

Return type:

Array

class rheplicant.radio.instrument.receiver.ReceiverOperator(bandpass)[source]

Bases: AbstractOperator

Apply a frequency-dependent bandpass to state.data (placeholder).

When this bandpass and a GainOperator gain are inferred together, declare the bandpass through unit_mean_bandpass(): free as-is, the two share one exactly null direction (the module docstring measures it).

Parameters:

bandpass (Array)

bandpass

(n_freq,) dimensionless bandpass — differentiable.

Type:

jax.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 = 12 coefficients at k = 4, where min(n_src, k) * n_basis would have said 6.

  • the bound overstates. A single load whose Gamma is 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 from n_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. DriftScanProjector and GeneralPointingProjector default to normalize_beam=False (numpy limTOD’s convention), which returns int(B T) rather than int(B T)/int(B). Use normalize_beam=True when the output is destined for T_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, then cal_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: AbstractOperator

Apply 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 is rhino_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 unless n_time == n_freq, where no check can tell it from a spectrum.

Parameters:
t_unc

uncorrelated noise-wave temperature [K].

Type:

jax.Array

t_cos

in-phase noise-wave temperature [K].

Type:

jax.Array

t_sin

quadrature noise-wave temperature [K].

Type:

jax.Array

t_rx

receiver offset temperature [K] (the module docstring’s T_rx; the numpy reference calls the same quantity t_0). Free like the other three — see the module docstring for what that costs in loads.

Type:

jax.Array

gamma_src_re

(n_source, n_freq) real part of each source’s Gamma.

Type:

jax.Array

gamma_src_im

(n_source, n_freq) imaginary part.

Type:

jax.Array

gamma_rec_re

(n_freq,) real part of the receiver’s Gamma.

Type:

jax.Array

gamma_rec_im

(n_freq,) imaginary part.

Type:

jax.Array

switch_key

key in coords.extra holding the per-sample source index.

Type:

str

property n_source: int

Number of switchable sources this operator carries a Gamma for.

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 than sinc2, so assuming sinc2 under-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.

sinc2 has zeros at every integer multiple of line_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).

gaussian is 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)**2 overflows to inf for a sigma small enough, and inf - inf is 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 sinc2 width is exactly one channel, and the natural way to say that is float(freq[1] - freq[0]) — which on a float32 grid differs from this module’s median(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_floor of 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_floor of 1e-2, for the worst-case placement (tone at one band edge, channel at the other, an offset of the full band B). For a gaussian of sigma = f B that channel keeps exp(-1/(2 f^2)) of the peak; for a sinc2 of width f B its 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.py with 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 on protect_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) is 0.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 (see lineshape above), and two is a generous bound on that. It binds only on coarse grids: 0.25 * (n_freq - 1) >= 2 from n_freq = 9 up, 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 how coords.time is STORED rather than of this operator’s arithmetic, so it is stated once in rheplicant.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_rate 1e4 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=1 gives [4,5,6,7], so the cause is precision, not logic. Both the injection and the band guard read t - 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: AbstractOperator

Inject a CW tone with a lineshape, a width, and a drift.

Three things beyond the injection itself.

It declares its own position. must_precede names the stages the tone must flow through, and assemble() 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_floor of its own peak channel. In Kelvin that cut sits at protect_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 because sinc2’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_rate is 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 shape unflag_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. Note amplitude_drift_rate alone 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:

float

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:

float

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_CHANNELS below, MAX_WIDTH_IN_BAND_FRACTION above.

Type:

float

lineshape

"sinc2" or "gaussian".

Type:

str

drift_rate

centre-frequency drift [Hz/s], linear in coords.time from the first sample. Nonzero requires coords.time, and requires it to be an axis whose stored precision can express the run’s own cadence — see MAX_TIME_RESOLUTION_IN_SAMPLES.

Type:

float

amplitude_drift_rate

FRACTIONAL level drift [1/s], linear from the first sample. Nonzero requires coords.time, with the same precision condition.

Type:

float

protect_floor

protect every channel at or above this fraction of the tone’s peak channel contribution. In (0, 1].

Type:

float

class rheplicant.radio.instrument.calibration.CalLoadOperator(t_load)[source]

Bases: AbstractOperator

Switched 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_input selector node of the canonical graph: provide this operator alongside the antenna chain and put the switching cycle in coords.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_loads is many=True and feeds only the receiver_input selector, 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, so assemble() expresses a switching cycle of any length with no hand-wiring. How long that cycle has to be for NoiseWaveOperator’s temperatures to be identifiable is stated once, in noise_wave — it is min(n_src, k) * n_freq over the k FREE temperature families while they are free per channel, so three loads are enough only when T_rx is 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 order gamma_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, matching NoiseWaveOperator’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 reason check_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:

jax.Array

class rheplicant.radio.instrument.calibration.ApplyCalibrationOperator(gain)[source]

Bases: AbstractOperator

Apply 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)

gain

inferred gain — differentiable scalar or (n_time,) array.

Type:

jax.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: AbstractOperator

Multiply state.data by a gain (placeholder).

Parameters:

gain (Array)

gain

differentiable scalar (constant gain) or (n_time,) array (per-sample gain, broadcast across frequency).

Type:

jax.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: AbstractOperator

Add 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. sigma is declared in kelvin, but this operator’s graph_node places it after the gain stage, where state.data is 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 RadiometerNoiseOperator below, whose multiplicative d -> 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, either sigma stops being kelvin or the node moves ahead of the gain.

Parameters:

sigma (Array)

sigma

noise standard deviation [K] — differentiable scalar. See the note above: the kelvin is not honoured at this node.

Type:

jax.Array

class rheplicant.radio.instrument.noise.RadiometerNoiseOperator(channel_width, integration_time)[source]

Bases: AbstractOperator

Draw 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 as rheplicant.inference.noise.RadiometerNoise.realise(), and for the same reason: sigma = |prediction| f takes 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.

Parameters:
channel_width

channel bandwidth [Hz] – static, part of the jit key.

Type:

float

integration_time

per-sample integration [s] – static.

Type:

float

property fractional: float

1/sqrt(dnu tau) – the fractional radiometer scatter.

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: AbstractOperator

Add a frequency comb of self-generated EMI lines (placeholder).

Every period-th channel receives an extra amplitude.

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 amplitude on one channelisation says nothing about the same instrument read out at another. When the body becomes real, period should become a frequency spacing in Hz and the comb should be built from state.coords.freq rather than from arange, which is also what makes it independent of n_freq.

The comb is also exactly periodic and infinitely sharp: no line width, no per-line amplitude, no drift. Real EMI has all three.

Parameters:
amplitude

line amplitude [K-equivalent] — differentiable scalar.

Type:

jax.Array

period

channel spacing of the comb (static configuration). Channels, not hertz — see the note above.

Type:

int

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: AbstractOperator

Scale and clip state.data to 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:

  1. No kelvin-to-counts link. scale is a free differentiable number, not a calibrated conversion, so the output is in “counts” only by assertion. Nothing checks that scale corresponds to the front-end gain, and the clip limit 2**(n_bits-1) is therefore compared against a quantity whose units are whatever scale made them.

  2. 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.

Parameters:
scale

pre-digitization scaling — differentiable scalar. Not a calibrated K-to-counts conversion; see the note above.

Type:

jax.Array

n_bits

ADC bit depth (static configuration; clip limit is 2**(n_bits-1)).

Type:

int

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: AbstractOperator

An 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 shape noise_wave can never misread.

The two design matrices are ordinary array leaves, the same convention NoiseWaveOperator uses for its Gamma spectra: a known quantity the operator carries, not a parameter. What is inferred is coeff, 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.

Parameters:
coeff

(n_k, n_j) coefficients [K] — the differentiable target.

Type:

jax.Array

time_basis

(n_time, n_k) design matrix on the time axis.

Type:

jax.Array

freq_basis

(n_freq, n_j) design matrix on the frequency axis.

Type:

jax.Array

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:

BasisTemperatureOperator

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.aux key 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 aux with mask added 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 calls unflag_protected at all, so a malformed mask would ride in aux unexamined 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:
  • aux (dict[str, Any]) – the state’s aux mapping.

  • mask (Array) – (n_freq,) channel mask or (n_time, n_freq) waterfall mask, True = keep.

Raises:

StateValidationError – if mask is neither a channel mask nor a waterfall mask, or if it cannot compose with a mask already there.

Return type:

dict[str, Any]

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 reason unflag_protected() broadcasts it over every row.

  • a (n_time, n_freq) waterfall mask is reduced with any over 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. all would unprotect a chunk the tone contaminated in two rows of three, which is the failure protection exists to prevent.

Parameters:
  • mask (Array) – (n_freq,) channel mask or (n_time, n_freq) waterfall mask, True = keep.

  • n_chunk (int) – samples merged into each output chunk.

Raises:

StateValidationError – if mask is neither a channel mask nor a waterfall mask, or if its time axis is not divisible by n_chunk — a partial chunk has no honest reduction, and padding one would protect samples that were never observed.

Return type:

Array

rheplicant.radio.protection.unflag_protected(flags, aux)[source]

Clear flags wherever aux says 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 i names the channels the calibrator wet at sample i of 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:
  • flags (Array) – (n_time, n_freq) boolean flags, True = flagged.

  • aux (dict[str, Any]) – the state’s aux mapping.

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:

Array

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.aux key 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, SkySpaceFilter and BackendOperator, and written again by rheplicant.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: AbstractOperator

Store a threshold-based flag mask in state.aux["flags"] (placeholder).

True marks 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 (see rheplicant.radio.protection).

Parameters:

threshold (float)

threshold

flag samples with data > threshold (static configuration; thresholding is not differentiable anyway).

Type:

float

class rheplicant.radio.backend.flagging.MomentRFIFlaggingOperator(config=<factory>, kernel_shapes=())[source]

Bases: AbstractOperator

RFI flagging via MomentRFI’s IterativeSurfaceFitter (host callback).

The real flagger, beside FlaggingOperator’s thresholding one. Flagging is inherently non-differentiable (a boolean decision), so jax.pure_callback into the numpy MomentRFI package is the permanent right integration — not a stopgap. Jit-compatible; not vmappable/differentiable (by nature).

Behaviour:
  • state.data must be a positive, linear-scale (n_time, n_freq) waterfall (MomentRFI works on log10 internally).

  • Existing aux["flags"] are passed as MomentRFI’s prior_mask and 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. See rheplicant.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. MaskedGaussianLikelihood and SkySpaceFilter weighting take the raw mask directly and remain available.

Requires the optional MomentRFI package (install it into the same environment); raises a helpful ImportError otherwise.

Parameters:
config

IterativeSurfaceFitter keyword arguments (static, hashable; e.g. {"sigma_threshold": 4.0, "degree_freq": 10}). verbose is forced off.

Type:

rheplicant.core.frozen.FrozenMapping

kernel_shapes

broad-round box-kernel shapes, e.g. ((3, 3), (1, 9)) (static; empty runs round 0 only).

Type:

tuple[tuple[int, int], …]

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: AbstractOperator

Average state.data over time chunks of n_chunk samples (placeholder).

Updates coords.time to the per-chunk mean times, reduces the aux entries whose semantics it knows onto the same chunk axis, and refuses by name an aux entry 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.time values rather than reading its length (the other is CWCalibrationOperator’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, before Coordinates guarded 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: the replace below re-runs it on the chunk-mean axis it produces.

aux and the chunk axis

data and coords.time are not the only things bound to the time axis. Every per-time array in state.aux is stale the moment the axis changes length, and the three cases measured on one (6, 4) fixture with n_chunk=3, when this operator left aux alone, failed with three different amounts of noise:

aux entry

what happened before

aux["flags"]

carried at (6, 4); refused two stages later by FlaggedNoise.std

aux["protected"], 2-D

carried at (6, 4); refused by the next FlaggingOperator, which names the staleness

aux["switch"], 1-D

carried at (6,) with no error anywhere

The 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 data and coords.time and nothing can tell. Worse, each output chunk now spans n_chunk of its entries, so for [0, 1, 0, 1, 0, 1] at n_chunk=3 there is no right value even in principle — every chunk covers both switch positions.

So: flags and a 2-D protected are reduced with any over 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-average n_time is refused, named. The refusal is on SHAPE alone, so it holds under jit and 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_time is 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 from aux and put the reduced version back after — and it is a sentence rather than a wrong number.

any and not all, 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. all would 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, and all becomes the right reduction.

n_chunk

samples per integration chunk (static configuration).

Type:

int

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: AbstractOperator

Base for projection filters. Concrete classes declare a static mode field.

abstractmethod project(data, state)[source]

Return the projected component P d (same shape as data).

Parameters:
Return type:

Array

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: AbstractLinearFilter

Per-LST mean across days, tiled back to full length.

Parameters:
n_days

number of sidereal days concatenated along the time axis (static; n_time must be divisible by it).

Type:

int

mode

"extract" (repeating structure) or "remove" (residual).

Type:

str

project(data, state)[source]

Return the projected component P d (same shape as data).

Parameters:
Return type:

Array

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: AbstractLinearFilter

Wiener-like sky projection filter built on a linear sky projector.

Parameters:
projector

linear sky projector supplying forward/adjoint.

Type:

rheplicant.radio.sky.projection.AbstractSkyProjector

regularization

ridge strength lam (differentiable scalar; acts as a white prior inverse-variance, stabilising unseen pixels).

Type:

jax.Array

cg_tol

conjugate-gradient tolerance (static).

Type:

float

cg_maxiter

conjugate-gradient iteration cap (static).

Type:

int

mode

"extract" (sky-locked component) or "remove" (residual).

Type:

str

require_convergence

bound on the solve’s relative ERROR, or None (the default) to run unchecked as before; see the module docstring for why an error and not a residual, and what checking costs.

Type:

float | None

project(data, state)[source]

Return the projected component P d (same shape as data).

Parameters:
Return type:

Array

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: AbstractLinearFilter

Band projection in fringe-rate (axis=0) or delay (axis=1) space.

Parameters:
axis

data axis to transform (static; 0=time, 1=frequency).

Type:

int

low

band lower edge, cycles/sample (static).

Type:

float

high

band upper edge, cycles/sample (static; up to 0.5 = Nyquist).

Type:

float

mode

"extract" (keep band) or "remove" (notch band).

Type:

str

The band membership test is half-open on the right, [low, high), except at high == 0.5 where it is closed, [low, 0.5] – see the module docstring for why. high is a static (Python-level) field, so this is a plain Python branch resolved at trace time, not a runtime jnp.where.

project(data, state)[source]

Return the projected component P d (same shape as data).

Parameters:
Return type:

Array

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: AbstractOperator

Learned 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.

Parameters:
mlp

eqx.nn.MLP with in_size=1, out_size=1.

Type:

equinox.nn._mlp.MLP

f_min

lower edge of the frequency normalization window [Hz] (static).

Type:

float

f_max

upper edge [Hz] (static).

Type:

float

classmethod create(key, f_min, f_max, width=16, depth=2)[source]

Build a fresh surrogate (near-identity response) for a frequency window.

Parameters:
Return type:

NeuralOperator

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.

Parameters:

freq (Array)

Return type:

Array

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.meta key under which to_state() records the unix second the State’s time axis is measured from. meta rather than coords on purpose: it is one static number describing the run, not a traced quantity the forward model differentiates through, and State’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 price obs_id already 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: object

One RHINO recording, in numpy, in Hz / unix seconds / Kelvin.

Parameters:
freq_hz

(n_freq,) channel frequencies [Hz].

Type:

numpy.ndarray

time_s

(n_time,) sample times [unix seconds].

Type:

numpy.ndarray

waterfall

(n_time, n_freq) raw power, arbitrary scale.

Type:

numpy.ndarray

switch_label

(n_time,) per-sample switch state.

Type:

numpy.ndarray

settled

(n_time,) bool, True = usable. Note the polarity: it is the opposite of aux["flags"], which is True-means-bad.

Type:

numpy.ndarray

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 the thermistor_columns map passed to the reader – a label that map declares but this file never switched to has no entry here. thermistor_k[label] raises KeyError for such a label; it does not return None or 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 as KeyError; the caller distinguishes them by what it declared.

Type:

dict[str, numpy.ndarray]

transitions

the raw (times, labels) switch log, kept for diagnosis.

Type:

tuple[numpy.ndarray, numpy.ndarray]

n_leading_dropped

samples that preceded the first transition and were dropped, because they have no defined switch state.

Type:

int

adc_max_i / adc_max_q

ADC monitors, None when 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 / .hdf5 file.

  • 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 pass None) 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 when thermistor_columns is omitted.

Return type:

RhinoObservation

What reaches the signal path, and what does not. to_state() places waterfall on data, time_s on coords.time (relative – see there), freq_hz on coords.freq, switch_label as the integer coords.extra["receiver_input"], and settled, inverted and broadcast, on aux["flags"]. Those five are the recording. thermistor_k, transitions, n_leading_dropped, adc_max_i and adc_max_q are diagnostic: nothing in rheplicant consumes them, and a caller that wants them reads them off the RhinoObservation directly.

thermistor_columns is therefore opt-in. _thermistors_in_kelvin refuses 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 /temperatures column is shared between writer and reader with nothing in the file to enforce it, so _thermistors_in_kelvin demands a declaration rather than assuming rhino-cal’s order; omitting it declares that no temperatures are wanted, and thermistor_k comes back empty. When the map IS given, every check it used to make still runs.

Nothing under /temperatures is 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 order NoiseWaveOperator’s gamma_src rows 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"] and meta[TIME_EPOCH_META_KEY]. Everything else on the recording is diagnostic – see read_rhino_observation().

Raises:

DataIngestionError – if source_order repeats a label, if the recording switches to a label source_order does not name, if obs.settled is not boolean, or if the recording holds no samples.

Return type:

State

coords.time is seconds since the first kept sample, not unix seconds, and meta[TIME_EPOCH_META_KEY] holds the unix second it is measured from – so meta[TIME_EPOCH_META_KEY] + coords.time recovers obs.time_s exactly. This is a behaviour change, and it is a fix rather than a convenience. Coordinates stores its axes through jnp.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 drifting CWCalibrationOperator tone 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 why Coordinates refuses 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, and FlaggedNoise consumes it that way) while settled is 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)``, matching state.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 if flags disagrees in shape with the prediction it masks, SkySpaceFilter (radio/filters/skyspace.py) multiplies 1 - flags elementwise against the data, and both FlaggingOperator and MomentRFIFlaggingOperator (radio/backend/flagging.py) produce and expect (n_time, n_freq). obs.settled itself stays (n_time,) on RhinoObservation, for a caller who wants the per-time form directly.

rheplicant.radio.rhino.cal_load_operators(obs, *, labels=None)[source]

Build one CalLoadOperator per switched load, carrying that load’s measured physical temperature.

This is the route from file to model that was missing. read_rhino_observation parses the thermistor log, interpolates it onto the SDR axis and refuses a recording whose readings are short or non-finite – and then to_state dropped it, because a State has 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_state returns a State; 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 the State for the operator to read, which changes CalLoadOperator.requires and 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, which CalLoadOperator resolves by always reading 1-D as per-FREQUENCY. Spelling the column here is what keeps this function from depending on n_time != n_freq.

Parameters:
  • obs (RhinoObservation) – a recording read WITH thermistor_columns. Without it obs.thermistor_k is 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 order gamma_src’s rows must match – see CalLoadOperator.

Returns:

label -> CalLoadOperator, insertion-ordered by labels when given and by obs.thermistor_k otherwise. Annotated Any rather 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:

dict[str, Any]

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: object

Parsed Touchstone contents.

Parameters:
freq_hz

(n,) strictly ascending frequencies [Hz].

Type:

numpy.ndarray

s

(n, p, p) complex S-parameters, p in {1, 2}.

Type:

numpy.ndarray

z0

reference impedance [ohm] from the option line.

Type:

float

rheplicant.radio.touchstone.read_touchstone(path, *, flipped=False)[source]

Read a Touchstone v1 .s1p or .s2p file.

Parameters:
  • path (str | Path) – the file.

  • flipped (bool) – swap the two ports (s11``<->``s22, s12``<->``s21) as a genuine reversal of the parsed matrix, not a relabelling of the return values. Set it when the device under test was wired to the VNA with this codebase’s port 1 and port 2 reversed.

Raises:

DataIngestionError – on any malformed content. Nothing is skipped.

Return type:

Touchstone

rheplicant.radio.touchstone.interpolate_onto(freq_hz, source, *, component='s11', allow_extrapolation=False)[source]

Interpolate one S-parameter of source onto freq_hz [Hz].

component is checked here only against the four canonical S-parameter names. Whether source actually carries that component – a 1-port file has no s12/s21/s22 – is Touchstone’s own concern: its property getters already raise a precise, reason-naming DataIngestionError (see Touchstone._entry), and duplicating that check here would only be a second place for the message to drift out of sync with the first.

Parameters:
Return type:

ndarray

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.

Latenta 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.

Binda 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")fn returns one value PER into selector.

rheplicant.inference.parameters.BROADCAST: str = 'broadcast'

Bind(fan="broadcast")fn returns ONE value, written to every into selector. 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 Bind whose 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 into selector fed a container — because there the guess cannot give a wrong answer, only an undeclared one. See Bind.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 and identifiability() reports full rank. Measured on the fixture in tests/inference/test_stochastic_twin.py — an 8x8 grid, key(0), data from the honest model at g = 1.1 plus 2 K scatter at key(7), and a NoiseOperator(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.py rather 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 — RANDOMNESS in requires — 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 (a LambdaOperator whose fn closes 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, and tests/test_operator_declarations.py does 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 Bind says where it goes.

Parameters:
name

identifier. Doubles as the NumPyro sample-site name and the key in the dict a forward function consumes, so it should read like a physical quantity ("fwhm_deg", "sky_alms").

Type:

str

init

initial value — also the authority on shape and dtype. Plain Python numbers are converted to arrays.

Type:

jax.Array

prior

a NumPyro distribution, or None. None means 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 the S that wiener_solve() and gcr_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 — see check_linearity() — and is checked before it is exploited.

Type:

bool

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. Like linear, 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:

str

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 fn positionally, in this order.

Type:

tuple[str, …]

into

an eqx.tree_at selector (lambda p: p["gain"].gain), or a tuple of them. Several selectors is how one latent drives several leaves.

Type:

tuple[collections.abc.Callable, …]

fn

latent values -> leaf value(s). None means identity, which requires exactly one latent. If fn returns a single array it is written to every selector in into (this is parameter tying); if it returns a tuple, its length must match into.

Type:

collections.abc.Callable | None

fan

"broadcast", "distribute", or None to infer from what fn produced, 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 the k-th produced value into the k-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

v and list(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 to check_linearity() and to identifiability() — selects between a tie and an element-wise split. A user who meant “write this whole vector into both leaves” and reached for list(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. None keeps the inference, because Bind is 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 what fn did, and is refused naming both.

The one case no inference can decide even in principle. With a single into selector 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 by ParameterSpace.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 fan when declared and inferred from whether fn returned a Python tuple/list when it is not. See the class docstring for why that inference is not something to rely on.

Raises:

ParameterSpaceError – if a declared fan contradicts what fn produced, or if a distribution’s length does not match into.

Warns:

AmbiguousFanWarning – if a container reached a lone into selector with no fan declared, where the inference is a coin flip that happens to have only one landing.

Parameters:

values (dict[str, Array])

Return type:

tuple[Array, …]

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:

tuple[rheplicant.inference.parameters.Latent, …]

bindings

how they reach the pipeline. Empty only when raw_bind is supplied.

Type:

tuple[rheplicant.inference.parameters.Bind, …]

raw_bind

escape hatch — (pipeline, values) -> pipeline, used instead of compiling bindings. Build one with raw().

Type:

collections.abc.Callable | None

joint_prior

a prior over a BLOCK of latents rather than over one — today JeffreysPrior, and None by default, which is the whole feature switched off. Declared here rather than passed at an exit for the same reason Latent(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 carry Latent(prior=...) — that is two priors on one quantity, multiplied, with no diagnostic that can report it — and both refusals are below.

Type:

rheplicant.inference.priors.JeffreysPrior | None

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:
Return type:

ParameterSpace

classmethod direct(name, init, into, prior=None, fn=None, linear=False, fan=None)[source]

One latent, one binding — the common case, in one call.

fan is threaded straight through to Bind. It is worth having here rather than only on the long form because into accepts a tuple of selectors, so the shorthand reaches the tie-versus- distribute ambiguity too.

Parameters:
Return type:

ParameterSpace

property names: tuple[str, ...]

Latent names, in declaration order.

latent(name)[source]

Look a latent up by name.

Parameters:

name (str)

Return type:

Latent

initial_values()[source]

The starting point: {name: init}, ready for an optimizer.

Return type:

dict[str, Array]

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 derived fn doing real work costs one trace — negligible against a fit, but not literally zero. Called for you by forward_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 do to_numpyro_model() and simulate_pairs() directly.

Parameters:

pipeline (AbstractOperator)

Return type:

None

bind(pipeline, values)[source]

Return a copy of pipeline carrying values.

Pure: pipeline is untouched. All bindings are applied in a single eqx.tree_at call.

Note what that does NOT give you: eqx.tree_at resolves replacements by leaf identity and lets the LAST write to a leaf win, silently. The guarantee that no leaf is written twice comes from validate(), which every entry point (forward_fn(), the Bayesian bridge) runs first. Calling bind directly on an unvalidated space skips it.

Parameters:
Return type:

AbstractOperator

forward_fn(pipeline, state_template)[source]

Build forward(values) -> prediction and 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 plain dict of 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). values0 is initial_values(), so forward(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. forward and adjoint are 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: a str for one, a tuple[str, ...] for a group. Everything else follows from what x then is. For one latent x is an array and shape/dtype/prior describe it directly; for a group x is 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 reading jax.eval_shape already 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.map and jax.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, and S is 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. names normalizes the two.

Type:

str | tuple[str, …]

shape

shape of x; for a group, {name: shape}.

Type:

tuple[int, …] | dict[str, tuple[int, …]]

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:

jax.Array

forward

x -> A x, from jax.linearize.

Type:

collections.abc.Callable[[Any], jax.Array]

adjoint

y -> Aᵀ y, from jax.vjp, shaped like x.

Type:

collections.abc.Callable[[jax.Array], Any]

prior

the latent’s declared prior, carried through from the Latent. None for a prior-free latent, and for a block assembled by hand; for a group, {name: prior} with a None per prior-free member. It is what lets wiener_solve() and gcr_sample() read S off 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 x is complex (sky alm coefficients are): adjoint is exactly jax.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.py pins both halves so the distinction cannot rot into a silent factor.

property grouped: bool

Whether this block holds several latents at once.

property names: tuple[str, ...]

The latents in this block, in the caller’s order — one, or several.

as_dict(x)[source]

x as 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 a names= group — and only the second is the shape anything else takes. space.forward_fn’s forward, bind(), fisher_information(), identifiability()’s at=, linear_operator()’s at= and conditional_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 with names=, 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 x is 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:

dict[str, Any]

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 with name. 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 at plus DEFAULT_AT_POINTS - 1 draws 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 mode boundary-validation.md exists to prevent; do it only when the model is used at exactly one outside value. Measured before the default moved: a model affine in u exactly 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 its at value 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 init sets the magnitude: an all-zero init is the ordinary declaration for a sky, and anchoring on it made the probes absolute. A latent with no Gaussian prior to read falls back to max|init|, and to 1.0 if that is zero too.

  • rtol (float | None) – tolerance on the relative departure from affinity. Default: 1e4 * eps of 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 under rtol can still be many noise widths wide, which is the regime a conjugate solve gets wrong. Measured on one such model, the relative column reads 0.000e+00 at every probe while the weighted one reads 6.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 names probes 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 name and names are both given.

  • LinearityRefused – if any scale departs from affinity by more than rtol. It IS a ParameterSpaceError – an existing except ParameterSpaceError needs 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:

dict[float, float]

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: A comes from jax.linearize and Aᵀ from jax.vjp, so a 10⁶-dimensional block costs the same as one forward evaluation per application. That is what makes conjugate-Gaussian solves tractable here — see wiener_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 x is 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’s x is 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 name and names are given; if names is empty, repeats a latent, or names an undeclared or non-linear one; or if the linearity claim fails.

Return type:

LinearBlock

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) and x ~ 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 A and Aᵀ 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. See check_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= — on iterative_gls() and on SamplingPlan — 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_seam for the message and _check_solve_arguments for the two measured reasons this seam is not routed through as_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. None lets JAX choose.

  • require_convergence (float | None) –

    raise unless the relative ERROR can be bounded by this. Defaults to 1e-3; None disables the guard and returns whatever CG produced. On by default because jax’s cg reports no convergence status, so an unconverged solve otherwise comes back looking exactly like a converged one.

    The bound is κ · relative_residual, with κ bounded by condition_estimate(). Guarding on the residual alone would certify nothing in the regime that matters — see below — so this costs POWER_ITERATIONS extra 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, call condition_estimate() once outside the loop, choose tol from it, and pass require_convergence=None inside — the same bargain linear_operator()’s check offers.

Returns:

(x̂, relative_residual), the residual being ‖M - b‖ / ‖b‖ over the real degrees of freedom. Note that this is the residual, not the error; multiply by condition_estimate() for the error bound.

is the block’s own domain, so its shape follows the spelling that built the block: a {name: array} dict for names=, and a bare array for name=. The bare form is not what anything downstream reads — space.forward_fn’s forward, bind(), fisher_information(), identifiability()’s at=, linear_operator()’s own at= and conditional_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:

tuple[Any, Array]

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 exactly 1/prior_std² and κ ‖AᵀN⁻¹A‖ · prior_std² runs to 1e6 and beyond. At κ=1e7 the default tol=1e-6 bounds 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 a maxiter to match. Past κ · eps no 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 statement to_numpyro_model reads. 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 solution x with relative residual r,

‖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, ask wiener_solve() or gcr_sample() for roughly tol = a / condition_bound(...) — and it is the number require_convergence itself 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⁻¹A is positive semi-definite, so λ_min 1/max(prior_variance) exactly. λ_max is 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 iterations applications of the normal operator, half what condition_estimate() costs, and forms no matrix.

Parameters:
Returns:

The bound, as a scalar array.

Return type:

Array

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 solution x with relative residual r,

‖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 what require_convergence itself reads. This one measures λ_min by a second power iteration, whose leading eigenvalues crowd against λ_max with vanishing gaps on a graded spectrum, so the λ_min it 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. A tol computed 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 in tests/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, λ_min is exactly 1/prior_std² while λ_max is set by the data, so κ grows with how much better the data constrains one direction than the prior constrains another.

Costs 2 · iterations applications 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 NoiseModel is 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 by check_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:

Array

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 covariance AᵀN⁻¹A + S⁻¹ — the operator itself — so x = M⁻¹b has the posterior mean and covariance M⁻¹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 fix tol, then require_convergence=None in the loop. What you must NOT do is leave tol at 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 a NoiseModel at 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. vmap over 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 term S⁻¹ᐟ²ω₂ 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 when prior_mean is 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_convergence is worth passing here more than anywhere; it is off by default for the reason wiener_solve() gives, which is about the bound’s conservatism and not about this risk being small.

x carries the block’s domain, dict or bare array, exactly as wiener_solve()’s does; see the note there, and LinearBlock.as_dict() for the wrap.

Return type:

tuple[Any, Array]

Note

S is read off Latent(prior=...) when the keywords are omitted; see the corresponding note on wiener_solve() for what that does and does not permit. It matters more here than for the mean: with a declared prior ignored, the fluctuation term S⁻¹ᐟ²ω₂ 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:

f

Var / f^2 - 1

mean / (-f^2 / 2)

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 is 1e3: here the probe is fed through an exponential before the log is taken, and exp overflows 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_REASONS uses 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 of log of 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 S is 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:

dict[float, float]

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 a False.

Exactly two refusals are read as “no” — a departure from affinity (LinearityRefused) and a prediction log cannot 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.

Parameters:
Return type:

bool

rheplicant.inference.loglinear.log_route_refusal(noise)[source]

Which of LOG_ROUTE_REFUSALS applies to noise, or None.

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 by to_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 a log_conjugate block that to_log_space then refused, which rheplicant.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.

Parameters:

noise (Any)

Return type:

str | None

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 LinearBlock is an ordinary one — nothing downstream knows or needs to know that its offset is log of something. Feed it to wiener_solve() or gcr_sample() with the data and sigma that to_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)

offset is log of the prediction with the block at zero — for a gain bound as exp(log_gain), that is log of 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 passes check=False and checks once outside, exactly as the linear machinery does.

  • scales (Sequence[float]) – forwarded to the check.

  • rtol (float | None) – forwarded to the check.

Returns:

A LinearBlock over log space.

Return type:

LinearBlock

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 f separately could get the f^2 / 2 shift on one and not the other.

log d = log mu + log(1 + f w) and E[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:
Returns:

(y, sigma). y is log(observed) + f^2 / 2; sigma is f broadcast to the data’s shape, carrying inf at flagged samples so they keep the zero weight the flags gave them. sigma is a plain array and not a NoiseModel, 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 f is above FIRST_ORDER_MAX_FRACTIONAL; or if an unflagged sample is non-positive.

Return type:

tuple[Array, Array]

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 reason LinearBlock is: 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. And rank/nullity are 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:
names

the latents analysed, in the order the caller asked for.

Type:

tuple[str, …]

shapes

their shapes, in the same order.

Type:

tuple[tuple[int, …], …]

spans

(start, stop) of each latent within the flat parameter vector, in the same order.

Type:

tuple[tuple[int, int], …]

n_par

total number of real parameters — sum of the latents’ sizes.

Type:

int

n_data

size of the flattened prediction. nullity can never be below n_par - n_data: more parameters than data points is a null space by counting alone.

Type:

int

rank

number of singular values of the COLUMN-NORMALISED Jacobian strictly above threshold.

Type:

int

nullity

n_par - rank — the dimension of the space of parameter perturbations the prediction is blind to.

Type:

int

singular_values

(n_par,) descending. When n_data < n_par the SVD returns only n_data values and the rest are exact zeros; they are included rather than dropped, so rank is always the count of entries above the threshold and never needs a caveat.

Type:

numpy.ndarray

null_space

(nullity, n_par) orthonormal rows, in the column-normalised coordinates the rank verdict is taken in. Use direction() for raw latent coordinates.

Type:

numpy.ndarray

jacobian

(n_data, n_par) column-normalised, as analysed.

Type:

numpy.ndarray

rtol

the relative tolerance used.

Type:

float

threshold

rtol * singular_values[0], the absolute cutoff.

Type:

float

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.0 when 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.

Parameters:

index (int)

Return type:

dict[str, ndarray]

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_ant degeneracy comes out at 0.50/0.50, which is the true statement about it.

Parameters:

index (int)

Return type:

dict[str, float]

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()’s at.

  • rtol (float) – singular values at or below rtol * s_max are called null. See DEFAULT_RANK_RTOL for how the default is chosen and when to override it.

Returns:

An IdentifiabilityReport. The three numbers to read first are n_par, nullity and, 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 names is empty, repeats a latent, or names one that is not declared; if at names 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:

IdentifiabilityReport

Note

This runs with jax_enable_x64 forced 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) -> prediction from 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) reproduces pipeline(state_template).data exactly.

Return type:

(forward, params0)

Raises:

ParameterSpaceError – if the pipeline contains a stage that draws randomness. state_template is closed over, so forward would return one frozen noise realisation on every call and a calibrator would fit the wrong model without complaint — see refuse_stochastic_stages(). To build a simulator closure over a stochastic pipeline, use the eqx.partition/eqx.combine idiom 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: Module

Fixed-step gradient descent on a forward model (minimal demonstrator).

Parameters:
learning_rate

step size (static configuration).

Type:

float

n_steps

number of gradient steps (static configuration).

Type:

int

fit(forward, params0, observed, loss_fn=<function mean_squared_error>)[source]

Minimize loss_fn(forward(params), observed) from params0.

Returns:

the fitted parameter pytree and the per-step loss history, shape (n_steps,).

Return type:

(params_fit, losses)

Raises:

ParameterSpaceError – if observed is not shaped exactly like forward(params0), or if loss_fn is 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: Module

Adam 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 as GradientCalibrator.

Parameters:
learning_rate

Adam step size (static).

Type:

float

n_steps

number of steps (static).

Type:

int

beta1

first-moment decay (static).

Type:

float

beta2

second-moment decay (static).

Type:

float

eps

numerical floor (static).

Type:

float

fit(forward, params0, observed, loss_fn=<function mean_squared_error>)[source]

Minimize loss_fn(forward(params), observed) from params0.

Returns:

fitted parameters and per-step loss history, shape (n_steps,).

Return type:

(params_fit, losses)

Raises:

ParameterSpaceError – if observed is not shaped exactly like forward(params0), or if loss_fn is a log-density rather than an error — see GradientCalibrator.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

depends_on_prediction

HomoscedasticNoise

a constant, per-sample or scalar

False

RadiometerNoise

|prediction| / sqrt(delta_nu * tau)

True

FlaggedNoise

the wrapped model, or inf where flagged

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: Protocol

Contract: sigma = noise.std(prediction), shaped like the prediction.

depends_on_prediction

whether std actually reads its argument. False lets a solver skip the reweighting loop entirely, so it is a claim about the model, not a hint.

Type:

bool

realise(prediction, *, key)[source]

Draw one noisy observation of prediction under this model.

The generator to std’s assumption. A caller that draws with this and weights with std cannot have the two disagree, which is the failure mode of every hand-written data + sigma * normal line beside a likelihood carrying its own sigma.

Parameters:
Return type:

Array

class rheplicant.inference.noise.HomoscedasticNoise(sigma)[source]

Bases: Module

Constant 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 and check_noise_std_axis() refuses it. Either way a differentiable leaf, so sigma can itself be inferred.

Type:

jax.Array

realise(prediction, *, key)[source]

Additive: d + sigma * w, w ~ N(0, 1).

Parameters:
Return type:

Array

class rheplicant.inference.noise.RadiometerNoise(channel_width, integration_time, floor=0.0)[source]

Bases: Module

The radiometer equation: sigma proportional to the prediction itself.

sigma = |prediction| * f with the fractional level f = 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().

Parameters:
channel_width

channel bandwidth delta_nu [Hz] (static — instrument metadata, known rather than fitted).

Type:

float

integration_time

per-sample integration time tau [s] (static).

Type:

float

floor

lower bound applied to |prediction| before scaling [K]. Defaults to 0.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:

float

property fractional: float

1 / sqrt(delta_nu * tau) — the fractional noise per sample.

realise(prediction, *, key)[source]

Multiplicative: d (1 + f w), f = 1/sqrt(delta_nu tau).

The multiplicative form, not d + sigma(d) w – because sigma = |prediction| * f uses an absolute value that a generator must not, and the two forms differ in sign wherever the prediction does. floor is deliberately not applied here: it is a remedy for a reweighting iterate crossing zero, and a generator has no iterate.

Parameters:
Return type:

Array

class rheplicant.inference.noise.FlaggedNoise(base, flags)[source]

Bases: Module

Wrap 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 = inf keeps the fact inside the noise model, where every consumer already looks, instead of as a parallel flags= argument each of them has to remember to honour.

inf is only ever an encoding — NoiseModelLikelihood and inverse_variance() both turn it into a clean zero contribution rather than letting inf * 0 become NaN.

Parameters:
base

the noise model in force on unflagged samples.

Type:

rheplicant.inference.noise.NoiseModel

flags

boolean array shaped like the data; True = flagged.

Type:

jax.Array

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. std puts inf at the flagged samples; drawing at that sigma would produce a data set no instrument could record, and every consumer that turns inf into a clean zero weight expects the datum underneath to be finite.

Parameters:
Return type:

Array

rheplicant.inference.noise.check_noise_std_axis(noise_std, prediction_shape, caller)[source]

Refuse a 1-D noise_std whose axis the prediction cannot settle.

“Scalar or broadcastable to the data” is not a contract. Against a square grid — (n_time, n_freq) with n_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 < 0 and nan != nan both 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.shape or jax.eval_shape. No array need be computed.

  • caller (str) – the exit to name in the message.

Raises:

StateValidationError – if noise_std is 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 exactly 0.0 rather than an underflowed denominator, so the weight array is finite by construction.

Parameters:
Return type:

Array

rheplicant.inference.noise.log_determinant(noise, prediction)[source]

sum log sigma over 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_prediction is False; when it is True this 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_determinant for 0.5 log|C| with C diagonal, which is what it is; the factor of two lives in the caller’s 0.5 * chi2 beside 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, as inverse_variance()’s clean zero.

Deliberately WITHOUT the 0.5 n log 2 pi that NoiseModelLikelihood carries. 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 with MCMC.run degraded from 3.0e-06 to 3.9e-04. tests/inference/test_noise_log_determinant.py pins this against NoiseModelLikelihood so the two cannot drift.

Parameters:
Return type:

Array

class rheplicant.inference.noise.NoiseModelLikelihood(noise, include_logdet=True)[source]

Bases: Module

Gaussian log-density under a NoiseModel.

Generalizes GaussianLikelihood (which is this with HomoscedasticNoise) and MaskedGaussianLikelihood (this with FlaggedNoise around it); both remain, and both agree with this to roundoff.

Parameters:
noise

the noise model — supplies sigma at the prediction.

Type:

rheplicant.inference.noise.NoiseModel

include_logdet

keep the log 2 pi sigma^2 normalization (default). Setting it False gives 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:

bool

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 converged rather than spinning.

rheplicant.inference.gls.REWEIGHT_TOL_EPS: float = 8.0

Multiple of the working precision’s epsilon used as the default reweight_tol. See iterative_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 jit unchanged.

  • noise_std — the converged sigma: the covariance, and the whole point of the exercise. Feed it to gcr_sample() or wiener_solve() as noise_std=.

  • solution — the GLS point estimate at that covariance, shaped like the latent.

  • residual — relative CG residual of the final solve. Not an accuracy; see wiener_solve().

  • iterations — reweighting steps taken, the first solve included.

  • delta — relative change of the last step, ‖x_new - x‖ / ‖x_new‖.

  • converged — whether delta fell below reweight_tol within max_reweights. False here means the returned covariance is not a fixed point, and everything conditioned on it inherits that.

Parameters:
noise_std: Array

Alias for field number 0

solution: Any

Alias for field number 1

residual: Array

Alias for field number 2

iterations: Array

Alias for field number 3

delta: Array

Alias for field number 4

converged: Array

Alias for field number 5

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_prediction is False there is nothing to repeat and this is a single wiener_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 same S.

  • 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-looking 1e-8 is 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 than tol measures CG, not the fixed point. This is the binding floor in float64, where a tight tol=1e-10 sits five orders of magnitude above eps.

    Ask for less than either and the run does not fail quietly: it spends max_reweights steps and reports converged=False for 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 costs POWER_ITERATIONS extra operator applications, which is the same bargain wiener_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:

GLSResult

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: Protocol

Contract: 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) is True, and so is isinstance(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. sense is "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 why rheplicant.inference.calibrate also measures the sense at entry rather than trusting the declaration alone.

sense: str

"maximize" for a log-density, "minimize" for an error. Optional; an object that does not declare it is read as "minimize".

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 bare lambda 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 sense is 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:

str

rheplicant.inference.likelihood.check_observed_shape(prediction_shape, observed, *, predictor='this block')[source]

Refuse an observed the 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 healthy r_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:
  • prediction_shape (Any) – the shape the model predicts. A tuple, from jnp.shape or jax.eval_shape — no array need be computed.

  • observed (Any) – the data. Only its shape is read.

  • predictor (str) – how the caller names the thing that predicts, for the message (“this block”, “this forward model”, …).

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: Module

Independent 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:

jax.Array

sense

"maximize". This is a log-density: it is unbounded below, so a minimizer handed this object walks away from the truth and reports an improving loss the entire way.

Type:

ClassVar[str]

class rheplicant.inference.likelihood.MaskedGaussianLikelihood(noise_std, flags=None)[source]

Bases: Module

Gaussian likelihood that ignores flagged samples.

The seam where RFI flags inform the noise covariance: pass flags from state.aux["flags"] (True = flagged/bad); flagged samples contribute zero to the log-probability, equivalent to infinite noise variance on those samples.

Parameters:
noise_std

noise standard deviation — scalar or broadcastable.

Type:

jax.Array

flags

boolean mask, True = excluded; None behaves exactly like GaussianLikelihood.

Type:

jax.Array | None

sense

"maximize" — see GaussianLikelihood.

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.

Parameters:
Return type:

Array

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 read mcmc.get_samples()["prediction"] by this module’s own docstring and by examples/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’s joint_prior. A declared JeffreysPrior is evaluated here and nowhere else: its latents get improper flat sample sites, its block is checked for rank once before any sample is drawn, and 0.5 log det I is 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 particular RadiometerNoise, 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_std in FlaggedNoise.

  • obs_name (str) – name of the observed sample site.

  • allow_sampled_noise_std (bool) – take a sampled noise_std together with a declared joint_prior deliberately. Off by default, and the refusal explains what it costs: the "noise_std" site is in no ParameterSpace, so a Jeffreys prior over p latents tilts its posterior by sigma^-p with nothing reporting it — measured at about 1.0 sigma for p = 32. Inert when no joint_prior is declared.

Returns:

A NumPyro model model(observed=None) — condition by passing observed=data; run without it for prior-predictive checks. The noiseless prediction is recorded at the deterministic site "prediction". A conditioning observed whose 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_prob contains -log scale, so when scale is 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 that iterative_gls() converges to (which freezes that dependence in order to keep each step linear). The two answers differ, in the direction rheplicant.inference.noise gives in closed form.

An unobserved sample — infinite sigma, from FlaggedNoise or flags — 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 to bayesmith.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" and obs_name), and the meaning of observed=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 ParameterSpace says.

Latent(..., init=...) already states where the model starts, and the calibrators and check_linearity() both use it. NUTS does not: a kernel built without an init_strategy uses NumPyro’s default init_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 gives r_hat = 840 and an effective sample size of 2 out of 8000 draws, while the identical model started here gives r_hat = 1.002 and n_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_uniform lands in the haystack, and warmup adapts a step size for wherever it landed. The declared init does 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:
Returns:

(n_samples, *data.shape) noiseless predictions (add likelihood noise separately if you want the full predictive).

Return type:

Array

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), and softplus is strictly positive – over raw in [-80, 80] its minimum is 1.8e-35, never zero. So min_scale = 0 does not give a zero scale: a deliberately collapsible bank (eight distinct thetas, eight components) returns a finite log_prob at 0.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 of min_scale: 0 cites this line for that claim – see config/sections/npe.py::_positive. bayesmith’s amortize.MIN_SCALE records 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 RadiometerNoise the scatter is multiplicative, exactly as in the data – because the draw is taken with the model’s own realise, so the simulator and the likelihood cannot disagree about the law.

  • key (Array) – PRNG key.

  • n_simulations (int) – how many pairs.

Returns:

(thetas, data)thetas is (n_simulations, n_latent_values) with each latent’s values raveled and concatenated in space.names order; data is (n_simulations, *data.shape).

Return type:

tuple[Array, Array]

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’s embed — 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: NeuralPosterior

q(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 create override 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 isinstance or type() is assertions – so a re-export would preserve every name. What it would not preserve is the exception class: seven guard tests in tests/inference/test_inference_construction_guards.py expect StateValidationError, and the far side raises StructureError throughout. 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 in create(), a classmethod, and both sides end theirs with return 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 of create’s signature and sample’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_prob after 200 training steps, and all three TrainingHistory fields agreed at max|delta| = 0.0. See docs/superpowers/specs/2026-08-29-wave-C-npe-opening.md in 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 that embed belongs to create and not to train – so a signature of *args is a grammar of nothing. Measured: forwarding took seven tests in tests/config/test_config_section_npe.py:: TestTheGrammarMatchesTheSignatures down 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 _side asserts this list equals bayesmith.amortize.NeuralPosterior.create()’s, parameter for parameter and default for default.

Parameters:
Return type:

NeuralPosterior

sample(datum, key, n_samples)[source]

n_samples draws from q(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 took tests/test_docs_claims.py down twice over, once for NeuralPosterior.sample naming a member the walk could not find, and once for n_samples= naming a keyword no parameter in src/ accepted any more. Both sentences were still true for a caller; they had merely stopped being checkable.

docs/config-inference.md maps the document’s n_draws: onto this n_samples, and config/sections/npe.py states 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.

Parameters:
Return type:

Array

class rheplicant.inference.npe.TrainingHistory(train, validation, best_step)[source]

Bases: NamedTuple

Re-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.

Parameters:
train: Array

Alias for field number 0

validation: Array

Alias for field number 1

best_step: Array

Alias for field number 2

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 posterior on (thetas, data), returning the best step.

Delegates to :func:`bayesmith.amortize.train_posterior` (D10). The three refusals it carries – a non-positive n_steps, a validation_fraction outside [0, 1), and a fraction that rounds to zero held-out simulations – are the far side’s, re-raised here as StateValidationError. 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 a TrainingHistory. The returned estimator is whatever the far side built, so it is a plain bayesmith.amortize.NeuralPosterior rather 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 beyond create’s translation.

Parameters:
Return type:

tuple[NeuralPosterior, TrainingHistory]

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() builds F = J^T N^-1 J from the exact Jacobian of forward (jax.jacfwd — no finite differences), so parameter forecasts and error bars are one linear solve away (parameter_covariance()), and propagate_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 J is the LIKELIHOOD’s information and nothing else, which is a different quantity from the posterior precision the other exits target. Pass space= a ParameterSpace and 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 says kind="fisher", meaning exactly what it says.

  • Monte Carlo pushforwardpush_forward() vmaps forward over a stack of parameter samples (e.g. a NumPyro posterior via predict_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: Module

A matrix over a FLATTENED parameter vector, carrying its provenance.

ravel_pytree ordering 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 lets propagate_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:
matrix

the (n_params, n_params) array.

Type:

jax.Array

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, so sigma() refuses to pretend otherwise on either.

"covariance" and "posterior_covariance" are the two things sigma() 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 declared Latent(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:

str

names, spans, shapes

per-parameter name, (start, stop) span in the flat vector, and original shape. None for unnamed pytrees.

span(name)[source]

(start, stop) of one parameter within the flattened vector.

Parameters:

name (str)

Return type:

tuple[int, int]

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 kind and 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.

Parameters:

name (str)

Return type:

Array

block(name, other=None)[source]

The sub-matrix for one parameter, or the cross-block of two.

Parameters:
  • name (str)

  • other (str | None)

Return type:

Array

rheplicant.inference.uncertainty.as_noise_model(noise_std, flags=None, *, prediction_shape=None, caller='as_noise_model')[source]

Normalize a noise_std argument into a NoiseModel.

A bare scalar or array becomes HomoscedasticNoise; a noise model is passed through; flags wrap either in FlaggedNoise. This is what lets every noise_std= argument in the package accept the seam without any of their signatures changing.

The discrimination is by depends_on_prediction, not by std: jax and numpy arrays both have a .std method, 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:

NoiseModel

rheplicant.inference.uncertainty.fisher_information(forward, params, noise_std, flags=None, *, space=None)[source]

Fisher information at paramslikelihood-only unless given a space.

With space=None (the default, and what this function has always done) the matrix is F = 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 as S and refuses a prior-free linear latent by name — while this function never saw the ParameterSpace at 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 at params; the result is tagged kind="posterior_precision" and its inverse kind="posterior_covariance", so which quantity was reported survives into the object rather than living in the caller’s memory.

Parameters:
  • forward (Callable[[Any], Array]) – f(params) -> prediction.

  • 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); see check_noise_std_axis()) — or a NoiseModel.

  • flags (Array | None) – optional boolean mask; flagged samples carry zero weight, the same convention as MaskedGaussianLikelihood.

  • space (ParameterSpace | None) – the declaration params was 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), raises ParameterSpaceError by name rather than being approximated away. A space declaring a joint_prior is refused outright: a JeffreysPrior is defined as sqrt(det I), so it cannot be a term inside I.

Returns:

A FlatMatrix — the (n_params, n_params) matrix (.matrix) over the flattened parameter vector, tagged with the parameter structure it belongs to and with kind saying which quantity it is.

Return type:

Array

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 reports depends_on_prediction, and omitted otherwise (where it is exactly zero). Under RadiometerNoise with fractional level f the 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 by sqrt(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 kind is 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", whose FlatMatrix.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:

FlatMatrix

Note

This inversion IS gated on conditioning, and the gate is the far side’s. F = J^T N^-1 J SQUARES the design’s condition number, so an ordinary model reaches the arithmetic’s limit: measured at kappa(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 is 1/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 forming F. 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 translate knows about can arise on this path – the far side’s only refusals here are its own two plain ValueError``s. Catching ``ValueError and 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 reason numpyro_bridge refuses ahead of a bare AssertionError from 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:
  • forward (Callable[[Any], Array]) – f(params) -> prediction.

  • params (Any) – expansion point (pytree, same structure as the covariance’s flattening).

  • param_cov (Array) – covariance over the flattened parameter vector — a FlatMatrix from parameter_covariance() (structure is verified against params), 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 table FlatMatrix.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:

Array

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: TestTheSynthesisedGraphCannotReachTheAnswer builds 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 forward over stacked parameter samples.

Parameters:
  • forward (Callable[[Any], Array]) – f(params) -> prediction.

  • param_samples (Any) – a params pytree whose every array leaf carries a leading sample axis of common length n_samples.

Returns:

(n_samples, *prediction.shape) stacked predictions — summarize with e.g. mean(0) / std(0) / quantiles.

Return type:

Array

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

\[\Delta = H^{-1} P \, (m - \hat\theta), \qquad P = \mathrm{diag}(s^{-2})\]

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 a Latent’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.py is 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_std solves for, in posterior sigmas.

Chosen against what a chain can see rather than against a convention. The MCSE of a posterior mean from n_eff effective draws is 1/sqrt(n_eff) sigma; a well-run 4 x 1000 NUTS fit lands at n_eff of 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_SHIFT and 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: object

What the declared priors did to the mode, per latent, in posterior sigmas.

A plain frozen dataclass holding numpy, for the same reasons IdentifiabilityReport is 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, and verified is 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 — names and spans are 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:
names

the latents analysed, in the order the caller asked for.

Type:

tuple[str, …]

shapes

their shapes, in the same order.

Type:

tuple[tuple[int, …], …]

spans

(start, stop) of each latent in the flat vector.

Type:

tuple[tuple[int, int], …]

n_par

total number of real parameters.

Type:

int

mode

theta_hat, the MAP found by Newton on the exact log-posterior.

Type:

numpy.ndarray

prior_loc

the declared m, broadcast per element.

Type:

numpy.ndarray

prior_std

the declared s, broadcast per element.

Type:

numpy.ndarray

mean_offset

|m - theta_hat|. A magnitude — the direction of the pull lives in shift_sigma’s sign, where a reader will look for it.

Type:

numpy.ndarray

sigma_post

sqrt(diag(Sigma)) at the mode, from the exact Hessian of the negative log-posterior. Not fisher_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:

numpy.ndarray

shift_sigma

the closed form, signed, in units of sigma_post. Negative means the prior pulled the latent DOWN.

Type:

numpy.ndarray

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_converged is False.

Type:

numpy.ndarray

verified

per element, whether the two routes agreed to VERIFY_RTOL (with VERIFY_ATOL underneath).

Type:

numpy.ndarray

criterion_std

the prior width at which this latent’s shift would reach CRITERION_SHIFT, from the diagonal law sqrt(sigma_post * mean_offset / 0.1). Compare it with prior_std: a declared width comfortably ABOVE it is the statement that the prior is not driving the fit. 0.0 when the prior mean sits exactly on the mode, which means no tightening of it moves anything.

Type:

numpy.ndarray

precision

the (n_par, n_par) posterior precision at the mode.

Type:

numpy.ndarray

newton_steps, refit_steps

what the two solves cost.

refit_converged

whether the likelihood-only solve reached a mode.

Type:

bool

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.

Parameters:

name (str)

Return type:

dict[str, ndarray]

mode_of(name)[source]

theta_hat for one latent, shaped like it.

Parameters:

name (str)

Return type:

ndarray

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_std inverts, evaluated exactly rather than through the diagonal law: only name’s entries of P are 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 to name’s own pull once name’s prior is loose enough for a neighbour’s to dominate.

Anchoring. That is not the expression shift_sigma uses, 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. At P_s = P_d it collapses to shift_sigma algebraically, so shift_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 at s = 0.1 and 1.8e-3 at s = 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:
  • name (str) – the latent to re-prior.

  • prior_std (Any) – its hypothetical width — a scalar, or anything broadcastable to the latent’s shape.

Returns:

The signed shift in posterior sigmas, shaped like the latent.

Raises:

StateValidationError – if name is not in this report, or the width is not positive and finite.

Return type:

ndarray

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. None means all of them, in declaration order. A subset asks the CONDITIONAL question, holding the rest at at: 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 are shift_sigma and criterion_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/at name an undeclared latent; if observed does 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 is linear=True with its prior living in a prior_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:

PriorSensitivityReport

Note

Runs with jax_enable_x64 forced 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: Module

p(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 law mu = A (nu/nu0)^-beta over (log A, beta), the half-log-determinant measured on the 8x8 fixture of tests/inference/test_jeffreys_prior.py is +15.80169853 at every one of the nine grid points log 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 the prior= keyword, at the cost of a Jacobian per leapfrog step. The algebra says why, and says it exactly rather than approximately: sigma = |mu| f gives N^-1 = 1 / (mu^2 f^2) while J_{k,i} = mu_k g_i(nu_k), so every mu cancels and I_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 HomoscedasticNoise the same block gives p(log A) proportional to A^2: the half-log-determinant is exactly linear in log A with 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 beta is -1.366854e-02 under RadiometerNoise and +8.052944e-03 under HomoscedasticNoise — 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.

Parameters:
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, so det I is zero over the full space and sqrt(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.

Type:

tuple[str, …]

rank_rtol

the relative cut separating a null eigenvalue from a small one. None (the default) means DEFAULT_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 eigh and not from the two obvious routes. On the exactly degenerate block of tests/inference/test_jeffreys_prior.py — a 3-latent space whose amplitude is exp(a + b), so a and b are the same parameter twice — measured:

route

what it returns

jnp.linalg.slogdet

sign +1.0, half-logdet +6.420496

jnp.linalg.cholesky

all-finite, half-logdet +6.566517, smallest pivot 9.755e-05 (positive, so it did not fail)

eigvalsh + rank floor

-338.05 — effectively zero density

Both 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-09 against a largest of 1.281e+08 and 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 below rank_rtol * max is floored to the smallest positive number the dtype has, and the result is the -338 that 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 to identifiability() — an SVD of the column-normalised Jacobian, which does not square the condition number the way J^T N^-1 J does, 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() refuses space= outright while one is declared — a Jeffreys prior defined as sqrt(det I) cannot be added to I. SamplingPlan refuses a block partition that splits over across two blocks. That last one is a check on the declaration and not an evaluation: a plan’s engines build their conditional potentials from Latent(prior=...), so the NUTS route through to_numpyro_model is the route that applies this prior today.

property rank_tolerance: float

rank_rtol, or identifiability’s default when it is None.

covers(name)[source]

Whether this prior is the prior on latent name.

Parameters:

name (str)

Return type:

bool

property label: str

How a message names this block.

validate_against(declared, with_prior)[source]

Check over against a space’s latents. Raises, or returns None.

Called by __check_init__() so the wording lives with the class that owns the concept.

Parameters:
  • declared (Sequence[str]) – every latent name the space declares.

  • with_prior (Sequence[str]) – those of them that also carry Latent(prior=...).

Return type:

None

check_identified(space, pipeline, state_template, *, at=None, caller='This JeffreysPrior')[source]

Refuse a block whose information matrix is rank-deficient.

sqrt(det I) with det I = 0 is 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 to identifiability(), which takes the rank of the column-normalised Jacobian rather than of J^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; None means 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:

Any

information(forward, values, noise_std, flags=None)[source]

The CONDITIONAL information matrix over over, at values.

Every latent not in over is held at its entry in values — that is what makes this the conditional prior and not the full-space one.

space=None is passed to fisher_information() and is load-bearing: with space= 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 reports depends_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) in I_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, so over=("b", "a") and over=("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:
  • forward (Callable[[dict[str, Array]], Array]) – f(values) -> prediction over the FULL latent dict.

  • values (dict[str, Array]) – every latent’s current value.

  • noise_std (Any) – whatever the exit was given — a scalar, an array, or a NoiseModel. This prior carries none of its own.

  • flags (Array | None) – optional boolean mask, as elsewhere.

Return type:

Array

log_density(forward, values, noise_std, flags=None)[source]

0.5 * log det I over 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.05 on the measured fixture: an honest zero rather than the plausible +6.42 slogdet gives.

Arguments are information()’s.

Parameters:
Return type:

Array

half_log_determinant(matrix)[source]

0.5 * log det by eigendecomposition, with the rank floor applied.

Separate from log_density() so a caller holding an information matrix already — or a test pinning this against slogdet and cholesky on the same array — can reach the arithmetic without re-differentiating the model.

Parameters:

matrix (Array)

Return type:

Array

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

plan.estimate, CONJUGATE block

5.104558

unbiased

plan.estimate, GRADIENT block

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 to max(|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 trap iterative_gls() documents for its own reweight_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_hat above which a run’s draws are reported unmixed. 1.05 rather than the modern 1.01 because this is r_hat of 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_hat can 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_REWEIGHTS exists, 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: object

One 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 from Latent(..., linear=True) and is not restated here — see SamplingPlan for the derivation and for when engine= is a legitimate override.

Parameters:
names

the latents in this block, in the caller’s own order.

Type:

tuple[str, …]

steps

inner steps for a gradient block — Adam steps at SamplingPlan.estimate(), NUTS steps at SamplingPlan.sample(). None takes DEFAULT_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

engine

"conjugate", "gradient", or None to derive. An override, not the norm.

Type:

str | None

learning_rate

Adam step size for a gradient block at SamplingPlan.estimate(), as a fraction of max|init|. None takes DEFAULT_LEARNING_RATE. It has no meaning at SamplingPlan.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 reason steps is.

Type:

float | None

property label: str

How a message names this block.

class rheplicant.inference.plan.Draws(samples, diagnostics)[source]

Bases: object

Posterior draws: a stack per latent, plus what the run measured.

Parameters:
samples

{name: (n_draw, *latent.shape)}, warmup already discarded.

Type:

dict[str, jax.Array]

diagnostics

see PlanDiagnostics. Read rhat before believing mean.

Type:

rheplicant.inference.plan.PlanDiagnostics

property names: tuple[str, ...]

The latents, in the order the plan’s space declares them.

property n_draw: int

How many draws were kept.

property mean: dict[str, Array]

Posterior mean per latent — comparable with Estimate.values.

property std: dict[str, Array]

Posterior standard deviation per latent.

class rheplicant.inference.plan.Estimate(values, diagnostics)[source]

Bases: object

A point estimate: one value per latent, plus what the run measured.

Parameters:
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.

Type:

dict[str, jax.Array]

diagnostics

see PlanDiagnostics.

Type:

rheplicant.inference.plan.PlanDiagnostics

property names: tuple[str, ...]

The latents, in the order the plan’s space declares them.

class rheplicant.inference.plan.PlanDiagnostics(chi2, sweeps, converged, engines, block_residuals, identifiability, noise_depends_on_prediction, warmup=None, rhat=None)[source]

Bases: object

What 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; for SamplingPlan.sample() it fluctuates around a stationary value, which is what rhat tests.

Type:

numpy.ndarray

sweeps

sweeps actually run.

Type:

int

converged

for a point estimate, whether the joint chi-squared settled within tol (None when the test was disabled). For a draw, whether rhat came in under the caller’s threshold. False here means the answer is not what it looks like — the same reading as converged.

Type:

bool | None

engines

which engine each block took, keyed by the block’s names.

Type:

dict[tuple[str, …], str]

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.

Type:

dict[tuple[str, …], float]

identifiability

the last rank report taken, or None when the check was disabled.

Type:

rheplicant.inference.identifiability.IdentifiabilityReport | None

noise_depends_on_prediction

whether sigma was re-evaluated at the prediction each block update. When True a 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:

bool

warmup

sweeps discarded before collecting draws (None for a point estimate).

Type:

int | None

rhat

split-r_hat of the post-warmup joint chi-squared (None for a point estimate).

Type:

float | None

class rheplicant.inference.plan.PlanResult(*args, **kwargs)[source]

Bases: Protocol

What 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.diagnostics is a PlanDiagnostics and result.names are 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: object

A 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 Block s, 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=; if engine="conjugate" is asked for a block with a non-linear member; or if steps= 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. **options are that function’s; it owns the vocabulary, so steps= 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:

SamplingPlan

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 as HomoscedasticNoise).

  • max_iter (int) – sweep cap.

  • tol (float | None) – relative PROGRESS in the joint chi-squared below which the run has converged — see DEFAULT_CHI2_TOL for why it is a decrease and not a change. None runs exactly max_iter sweeps 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" or False. 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(). None skips 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 observed is mis-shaped; or if the joint chi-squared has not converged within max_iter sweeps. That last one is an error rather than a flag here and a flag rather than an error at sample(), and the asymmetry is deliberate: a chain has r_hat to scream with, and a point estimate has nothing.

Return type:

Estimate

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 takes steps NUTS steps instead, which makes the whole scheme Metropolis-within-Gibbs — see Block’s steps and gradient_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_hat of the post-warmup joint chi-squared above which PlanDiagnostics.converged is False. 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 observed is mis-shaped; if n_sweeps or warmup is not a sensible count; if fewer than MIN_DRAWS draws would be kept; or if a gradient block has a member with no declared prior.

Return type:

Draws

rheplicant.inference.plan.split_rhat(trace)[source]

Split-r_hat of 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() enforces MIN_DRAWS on 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, so ddof=1 gave nan under a numpy RuntimeWarning nothing here surfaces. That nan is worse than either bare exception below it, because nan defeats a comparison in both directionsrhat <= rhat_max is False and rhat > rhat_max is 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, or inf for halves each constant at different values.

Raises:

ParameterSpaceError – if fewer than MIN_DRAWS values are given.

Return type:

float

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’sbayesmith.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 needs noise=.

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_conjugate block that to_log_space then 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 and UncheckedLogRouteWarning names 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 1e3 entry 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. See LOG_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=True is 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 steps or learning_rate is given for a gradient block this space has no latents for.

Return type:

tuple[Block, …]

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), so linear_operator refuses it while log_linear_operator() does not. There is no Latent(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 reason LinearBlock is: it holds a traced closure (forward) and is built where it is needed rather than carried around as a differentiable pytree.

forward is forward_fn()’s, built ONCE per run. That matters: forward_fn validates 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, pipeline, state_template

the model under inference.

observed

the data. Shaped like the prediction — checked at the exits.

Type:

jax.Array

noise

the noise model, normalized (a bare sigma is already wrapped).

Type:

rheplicant.inference.noise.NoiseModel

forward

{name: value} -> prediction.

Type:

collections.abc.Callable[[dict[str, jax.Array]], jax.Array]

log_observed, log_sigma

the data and sigma a LOG_CONJUGATE block solves against, from to_log_space(). None when 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_sigma does not depend on the prediction, so unlike sigma() there is nothing for a sweep to re-evaluate; and to_log_space refuses a non-positive sample eagerly, which a jitted transition could not do at all.

sigma(values)[source]

Noise sigma at the current joint prediction, shaped like the data.

Parameters:

values (dict[str, Array])

Return type:

Array

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 than 0 * inf.

Parameters:

values (dict[str, Array])

Return type:

Array

neg_log_likelihood(values)[source]

-log p(data | values), log-determinant included.

The difference from 0.5 * chi2 is sum 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 from chi2() alone, a gradient block descended the GLS-flavoured target while to_numpyro_model()’s dist.Normal(prediction, sigma) sampled the full one, from the same declared model. Measured on mu = exp(w) x with sigma = 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() and log_determinant() – so the rule an unobserved sample needs (log sigma at an infinite sigma is inf, 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 pi that NoiseModelLikelihood also 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.

Parameters:

values (dict[str, Array])

Return type:

Array

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, with x a {name: array} dict over the block’s members and every other latent frozen at values. 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 prior until B1 was closed, which made that sentence true of the two exits and false against every OTHER route to the same model – see Conditioning.neg_log_likelihood().

Parameters:
Return type:

Callable[[dict[str, Array]], Array]

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 a CONJUGATE block 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]

steps NUTS 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=20 is 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, or None to 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; None compiles 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 different observed — 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:

tuple[dict[str, Array], Any]

rheplicant.inference.engines.gradient_estimate(cond, names, values, *, steps, learning_rate=0.01, **_ignored)[source]

Descend the block’s conditional potential for steps Adam 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.

Parameters:
Return type:

tuple[dict[str, Array], Array]

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:
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 R simply has fewer rows than columns. The equivalent statement in (F, b) form is F 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. Accumulating F directly 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: Module

log L(x) = -0.5 ||R x - z||^2 + offset over a flat, named vector.

Parameters:
factor

(r, n) array R. r < n means the term constrains only an r-dimensional subspace – the normal case for a single epoch, not an error.

Type:

jax.Array

target

(r,) array z.

Type:

jax.Array

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:

jax.Array

names

the latents this term is over, in the order they are ravelled.

Type:

tuple[str, …]

shapes

each latent’s shape, in the same order. () for a scalar.

Type:

tuple[tuple[int, …], …]

property width: int

Number of columns the named latents ravel to.

ravel(values)[source]

Flatten {name: array} into this term’s column order.

Parameters:

values (dict[str, Array])

Return type:

Array

log_prob(values)[source]

The log-density this term encodes, at the given latent values.

Parameters:

values (dict[str, Array])

Return type:

Array

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 jit retracing once per epoch.

Parameters:
Return type:

SqrtInfo

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 because Q has orthonormal columns:

||R_a x - z_a||^2 + ||R_b x - z_b||^2 = ||R_tot x - z_tot||^2 + rho^2

rho is the corner of the triangular factor – the part of the two residuals that no single quadratic form in x can 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.

Parameters:
Return type:

SqrtInfo

fisher()[source]

F = R^T R – the Fisher information this term carries.

May legitimately be singular: a single epoch usually constrains only a subspace, and only the campaign total plus the prior need be positive definite.

Return type:

Array

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_block columns; 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 a lax.scan.

This exists because marginalise() cannot be traced. It concretises twice – float(jnp.max(...)) for the pivot scale and np.finfo(...) on a materialised dtype for the floor – and the chain filter evaluates this arithmetic inside the theta likelihood, under jax.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

grad is the half that matters. A correlation time that is inferred rather than pinned at compression time is differentiated on every leapfrog step, so a marginalise that 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

marginalise()

healthy (scale 1)

+0.203 nats

accepted

rounding-scale (1e-10)

+23.23 nats

refused

identically zero

+inf

refused

scaled by nan

nan

refused

scaled by inf

nan

refused

The zero column is the easy half: -log|pivot| is a true +inf and 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 one nan anywhere in the term made the threshold nan and every comparison against it False. Both are pinned by test_the_kernel_cannot_see_what_the_checked_path_refuses, which reads pivots at all five scales, and by test_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, because LinearGaussianTransition refuses a non-positive process_std or initial_std at construction and those rows are what constrain every zeta_e – one eager check at declaration instead of one traced check per epoch. A caller that has neither owns the gap, and should look at pivots itself.

Parameters:
  • factor (Array) – (r, n) array R, block columns first.

  • target (Array) – (r,) array z.

  • offset (Array) – scalar; the constant this term already carries.

  • n_block (int) – how many leading columns to integrate out. 0 is 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:

tuple[Array, Array, Array, Array]

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 through width - 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 the pivots returned 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^2

and 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 two 2 pi halves cancel while sum(log(std)) has nothing to cancel against. Plan A shipped that second term missing – 1.07 nats for three nuisances at std=0.7, 27.47 for twenty-five at std=3, and exactly zero at std=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_e by 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 under jit or grad raises ConcretizationTypeError, and marginalise_arrays() is what to reach for there. The chain filter calls the kernel directly from inside a lax.scan. There is still exactly one copy of the constant, and test_the_kernel_and_the_checked_path_return_the_same_numbers compares the two paths element-wise rather than trusting this sentence.

Marginalising every name is legal and returns a zero-width term – factor shape (0, 0), names=() – whose log_prob({}) is the marginal likelihood. That is what T1 does at each theta when it integrates out phi_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 into rho and the offset absorbs it, which is SqrtInfo.combine()’s arithmetic with one term.

Parameters:
  • info (SqrtInfo) – the joint form, prior rows already appended by the caller.

  • block (Sequence[str]) – which names to integrate out.

Returns:

A term over info’s remaining names, in their original relative order, whose log-density is the integral of info’s over block.

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 one nan or inf anywhere in the term used to make the threshold nan and the answer “accepted”, returning a SqrtInfo whose offset was nan past 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:

SqrtInfo

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: Module

A parameter space split by scope, plus what the non-global scopes need.

Parameters:
space

the single declaration everything is derived from.

Type:

rheplicant.inference.parameters.ParameterSpace

linked

{latent name: transition} for every scope="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 hierarchical pi(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.

property global_shapes: tuple[tuple[int, ...], ...]

Shapes of those latents, in the same order.

property global_priors: dict[str, Any]

{name: prior} for the global latents – the campaign’s only prior.

property per_epoch_names: tuple[str, ...]

Names of the latents integrated out inside each epoch.

property linked_names: tuple[str, ...]

Names of the latents that form a Markov chain across epochs.

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: Protocol

Contract: logL = term(values), over the global latents alone.

Every member below is one BayesMemory actually reads, and the list is that long for a reason. An earlier version declared only latents, epoch_id, estimator and __call__, which made the protocol a narrower claim than the code relied on: a term satisfying it passed isinstance, passed remember, contributed to the accumulated density, and then raised AttributeError: 'X' object has no attribute 'n_observed' from audit() – a diagnostic, reached long after the term had already been folded irreversibly into the QR.

exact and n_observed are read by audit(); prior_share by remember’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 by rheplicant.inference.diagnostics. RawLikelihood carries 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 BayesMemory and the code that outlives it read off a term. Checked by name at remember so 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_names and inputs by save_memory()’s manifest, the other two by rheplicant.inference.diagnostics. A term admitted without them contributes to the accumulated density and then raises AttributeError from 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: Module

A log-quadratic factor: a SqrtInfo plus what it means.

Parameters:
info

the square-root information form carrying the numbers.

Type:

rheplicant.inference.sqrtinfo.SqrtInfo

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:

str

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:

int

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:

bool

support

{latent: (low, high)} where an approximate term is trustworthy. None for an exact term, and required otherwise.

Type:

dict[str, tuple[float, float]] | None

include_logdet

whether the Gaussian normalisation was kept. False is generalized least squares (D21), not a cheaper version of the same estimator.

Type:

bool

noise_frozen_at

"none" for a genuinely fixed covariance, or the procedure that produced the frozen one ("gls").

Type:

str

prior_share

(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.

Type:

tuple[int, int]

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.

Type:

tuple[tuple[str, str], …]

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 turn jax.grad and jax.vmap over observed – both pinned in Plan B’s tests – into a ConcretizationTypeError.

template_projections: Array | None = None

(n_templates,), or None when 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_observed is – 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 as type: description and emits :type: Static, which is a py:class Sphinx then cannot resolve.)

property latents: tuple[str, ...]

The global latents this term is a function of.

property estimator: tuple[str, ...]

What must match before two terms may be summed.

property share: Fraction

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: Module

T0 – the epoch’s likelihood with the raw data still inside it.

This tier deliberately does not compress. It holds observed and a live predict, 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. info raises rather than returning a quadratic form, so remember() – 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: predict is a Python callable, and eqx.tree_serialise_leaves would 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 whose log(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, and 0.0 * nan is nan.

Parameters:
predict

values -> prediction, shaped like observed. Static: it is code, not data, and it is what makes this tier unarchivable.

Type:

collections.abc.Callable[[dict[str, jax.Array]], jax.Array]

observed

the epoch’s data.

Type:

jax.Array

sigma

the noise standard deviation, already resolved to an array. inf marks an unobserved sample.

Type:

jax.Array

names

the latents predict consumes.

Type:

tuple[str, …]

epoch_id

the recording’s data hash.

Type:

str

include_logdet

whether the Gaussian normalisation is kept. False is generalized least squares (D21), a different estimator rather than a cheaper version of this one.

Type:

bool

noise_frozen_at

"none" for a genuinely fixed covariance, or the procedure that produced the frozen one.

Type:

str

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 vector c(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: Module

T1 – 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 is compress_linear’s arithmetic with the whitened basis rows in place of a design matrix. The consequence worth stating: the stored numbers are a SqrtInfo over an (n_S,) vector, so everything Plan A proved of that form transfersF = R^T R is PSD by construction, a rank-deficient epoch is a short R, 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^2 and p = 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.738 nats and the QR corner -0.5 rho^2 is -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 most 1.3e-6 nats – 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. basis is a reference to one ReducedBasis per campaign, so N epochs cost n_S * n_data once plus O(n_S^2) each.

``exact=False``, always. T1 is exact where mu lies in the span, and nothing can certify that for every theta. The honest guard is §5 requirement 6’s: support is 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.

Type:

rheplicant.inference.sqrtinfo.SqrtInfo

joint

the un-marginalised block over (phi_e, coefficients), or None when the epoch declared no nuisance. Stored even though info is what evaluation reads, because the Schur complement destroys precisely the quantity whose time correlation would falsify a per_epoch declaration – and once the raw data is gone, a mis-declaration that cannot be falsified is permanent (§4.2).

Type:

rheplicant.inference.sqrtinfo.SqrtInfo | None

nuisance_names, nuisance_shapes

what joint was marginalised over.

frozen_noise_residual

the largest |log L_frozen - log L_live|, in nats, over 2 n_theta + 1 probes spanning support – section 8’s mandatory measurement of what freezing N cost this epoch. 0.0 exactly 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’s bias_gradient: a single number covering both would make the refusal’s message name the wrong remedy.

Type:

jax.Array | float

bias_gradient

d/dtheta [ this term - the oracle ] at the storage origin, ravelled in flatten order – section 7’s whole budget in n_theta floats. 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 order jax flattens a dict into and therefore the order every named matrix in this package is built in. Stored rather than re-derived so that audit() can check them against its own instead of trusting that two callers agreed – a permutation here is silent, since the shapes still match.

Type:

tuple[str, …]

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.

property latents: tuple[str, ...]

The global latents the coefficient map consumes.

coefficient_shift(values)[source]

Delta_c = c(theta) - c_ref – the live half, one forward call.

Parameters:

values (dict[str, Array])

Return type:

Array

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 – pass iterative_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 QuadraticLikelihood over the global latents.

Return type:

QuadraticLikelihood

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 with S_e^T in 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_e is the shared rows whitened by this epoch’s sigma, so the stored form is the exact likelihood of the model S^T c under N_e. The coefficient map c(theta) was built in the reference metric and is shared, which is what makes evaluation O(n_S^2) per epoch instead of O(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_linear is not – rows^T c_ref is 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 a jax.jit trace every jnp call is staged into the jaxpr whether or not its operands are traced, so broadcast_to of a concrete sigma still returns a tracer and the guard fires. Under jax.grad and jax.vmap it does not, because those traces lift only values that are actually traced – which is the same split compress_linear has, 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_gradient is computed before the return and stored as n_theta floats; 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. Under RadiometerNoise that evaluation is the frozen-N step of section 8, and noise_frozen_at is 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 from iterative_gls(). Two terms with different values are different estimators and BayesMemory refuses to sum them. Not overridden for a noise model that does not depend on the prediction: a sigma computed by iterative_gls and handed back as HomoscedasticNoise is 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. None measures 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 to build_reduced_basis as extra_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^-1 norm.

  • 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 ReducedBasisLikelihood over the basis coefficients.

Return type:

ReducedBasisLikelihood

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.py checks 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 to compress_linear().

  • basis (Any) – a ReducedBasis – the claim that the prediction is expanded in this dictionary. Routes to compress_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:

Any

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) with M @ candidates orthonormal, 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.

Parameters:
Return type:

tuple[Array, tuple[int, …]]

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.

Parameters:
Return type:

Array

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 than build_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 by forward_fn.

  • pipeline (Any) – the forward model.

  • state_template (Any) – the state it is evaluated on.

  • names (Sequence[str] | None) – which latents. None means 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 order names asked for – or the space’s declared order. A latent of shape (4,) contributes four rows.

Raises:

ParameterSpaceError – if names or at mentions a latent the space does not declare.

Return type:

dict[str, Array]

class rheplicant.inference.reduced_basis.ReducedBasis(rows, weight, predict, reference, seeded=(), orthonormal=True, support=None, reference_values=None)[source]

Bases: Module

A 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, and whitened / weight is infinite at exactly the samples the reference could not see.

Type:

jax.Array

weight

(n_data,) reference 1/sigma, exactly 0 where the reference epoch had nothing to say. The reference metric: each epoch stores its own G_e against these rows rather than re-orthonormalising, so that c(theta) is one map for the whole campaign and evaluation costs O(n_S^2) per epoch instead of O(n_S * n_data). The mismatch that buys is measured, per epoch, by the bias budget (section 7).

Type:

jax.Array

whitened

rows * weight where the weight is non-zero and exactly 0.0 where it is not – a select, not a product, because 0.0 * inf is nan. Orthonormal when orthonormal, which is the supported case. Derived at construction and kept as a leaf because every projection reads it.

Type:

jax.Array

factor

(n_S, n_S) upper-triangular R with R^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:

jax.Array

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:

jax.Array

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:

collections.abc.Callable[[dict[str, jax.Array]], jax.Array]

seeded

names of the latents whose score directions were placed first.

Type:

tuple[str, …]

orthonormal

whether the rows were orthonormalised.

Type:

bool

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.

Type:

dict[str, tuple[float, float]] | None

reference_values

the latent values reference was 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, unlike seeded and support beside 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,) – which score_directions explicitly 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”, and tests/core/test_basis.py pins the same rule for Bind.fn.

Type:

dict[str, jax.Array]

property n_basis: int

n_S – how many directions this dictionary holds.

gram()[source]

G = S N^-1 S^T in the reference metric. The identity, if orthonormal.

Return type:

Array

condition()[source]

kappa(G). Above 1/eps the quadratic form is not computable.

Return type:

float

whiten(prediction)[source]

w * mu, ravelled – the same vector space the rows live in.

Parameters:

prediction (Any)

Return type:

Array

project(prediction)[source]

Coefficients of the N^-1-orthogonal projection of prediction.

Solving with G rather than taking a bare inner product is what keeps this an orthogonal projector when the rows are not orthonormal. When they are, G is the identity and the solve is free.

Parameters:

prediction (Any)

Return type:

Array

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.

Parameters:

coefficients (Array)

Return type:

Array

residual_fraction(direction)[source]

||(I - Pi) d|| / ||d|| in the N^-1 metric – section 5’s r_j.

nan when 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_fidelity names that case rather than dividing by zero.

Parameters:

direction (Any)

Return type:

Array

coefficients(values)[source]

c(theta) – the live half of the surrogate.

Parameters:

values (dict[str, Array])

Return type:

Array

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 BayesMemory compares. It deliberately does NOT cover predict: the live half needs section 10’s behaviour canary, which is a different check with a different failure mode.

Return type:

str

rheplicant.inference.reduced_basis.numerical_rank(whitened_bank)[source]

Largest k with s_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.

Parameters:

whitened_bank (Array)

Return type:

int

rheplicant.inference.reduced_basis.select_svd(whitened_bank, count)[source]

The count leading 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.

Parameters:
Return type:

Array

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.

Parameters:
Return type:

Array

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_depth score direction against a plain SVD basis is 0.562 at n_S = 3; seeding that one direction at the same n_S = 3 takes it to 1.5e-16. The repair is complete, not incremental, and it does not depend on choosing n_S large 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 measured 0.3147 at n_S = 3, 0.0289 at 5, 0.0040 at 8 and 0.0000 only 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. Under RadiometerNoise this is the frozen-N step of section 8, and the caller owes it a noise_frozen_at provenance downstream.

  • bank (Array) – (n_draws, ...) predictions at draws from the prior. The training bank, whose extent is the support a 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. None means 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 by bank. 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) integrates phi_e out 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_basis refuses such a column rather than projecting it quietly, and this argument is the remedy its message names.

Raises:

StateValidationError – if n_basis is below the number of declared rows, above the bank’s numerical rank, or if the declared rows are themselves linearly dependent.

Return type:

ReducedBasis

class rheplicant.inference.reduced_basis.FidelityReport(residuals, full, projected)[source]

Bases: Module

What a basis retains of each named latent’s signature.

Parameters:
residuals

{name: r_j} with r_j = ||(I - Pi) dmu/dtheta_j||_{N^-1} / ||dmu/dtheta_j||_{N^-1}. nan where the prediction does not respond to the latent at all.

Type:

dict[str, float]

full

the Fisher of the score directions against the data, named rows.

Type:

rheplicant.inference.uncertainty.FlatMatrix

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.

Type:

rheplicant.inference.uncertainty.FlatMatrix

worst()[source]

(name, r_j) of the least faithful direction. nan sorts first.

Return type:

tuple[str, float]

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_S against 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:
Returns:

A FidelityReport. Its matrices are rendered in flatten order, derived from the actual flattening of a template rather than from the order scores happens to iterate in – jax sorts 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:

FidelityReport

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 and remember refuses a repeat, in the posture D17 takes on BeamSpillOperator plus GroundPickupOperator: legitimate double-counting is a choice made deliberately, with duplicate=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_posterior would 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_ok is 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.

represents and shared_inputs have 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.

  • ids (frozenset[str]) – their epoch ids, as a set.

  • duplicate (bool) – allow an epoch_id already 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 duplicate takes: legitimate double-counting is a choice made deliberately and by name.

  • repeat_remedy (str) – what the caller should do about an epoch_id that is already held. The rule is shared and the remedy is not, and that split is measured rather than stylistic: the bag’s remedy is duplicate=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 reason represents has none.

Return type:

None

class rheplicant.inference.memory.BayesMemory(factorization, accumulated=None, archive=(), coefficients=None, basis=None)[source]

Bases: Module

Accumulated evidence from a campaign, sampled without the raw data.

Parameters:
factorization

the single declaration – which latents are global, and the campaign’s only prior.

Type:

rheplicant.inference.factorize.Factorization

accumulated

the running SqrtInfo. Fixed treedef.

Type:

rheplicant.inference.sqrtinfo.SqrtInfo

coefficients

the second running SqrtInfo, over the reduced basis coefficients. None until the first T1 term arrives.

Type:

rheplicant.inference.sqrtinfo.SqrtInfo | None

basis

the shared ReducedBasis that coefficients is a quadratic form in. None alongside 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, and ReducedBasis.reference_values carries 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’s Attributes block 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_id already 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:

BayesMemory

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 get c and then O(n_S^2), independent of how many epochs went into it.

Parameters:

values (dict[str, Array])

Return type:

Array

log_posterior(values)[source]

The stored factors plus the prior, applied exactly once.

Parameters:

values (dict[str, Array])

Return type:

Array

fisher(at=None)[source]

sum_e F_e over 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 FlatMatrix carries the treedef its rows were flattened against, and jax sorts a dict’s keys, while SqrtInfo’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 whose structure field describes an ordering the numbers do not have. Measured on that space with per-latent information 9 and 49: unpermuted, matrix reads diag(9, 49) against names=("width", "depth") while structure says {'depth', 'width'}.

Nothing downstream returns wrong numbers today – sigma and block read names and spans together, and propagate_covariance catches the mismatch on its _named_spans check. 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:

FlatMatrix

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. Passing noise_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 accumulated alone 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 over self: archive grows 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_min and fisher_condition describe 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 through dc/dtheta would 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 say fisher_lambda_min = 0 and fisher_condition = inf for a T1-only campaign, which reads as a degenerate memory when the memory is simply not quadratic in theta.

bias_over_sigma is section 7’s budget, per named direction, and unconstrained names the directions whose ratio is 0/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_N exceeds this. Directions the campaign does not yet constrain are listed under "unconstrained" instead of refused: their ratio is 0/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. See systematic_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 inside sigma_N rather than under it.

Return type:

dict[str, Any]

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 zeta prior normalisation

+0.9189

per-transition -0.5 logdet(2 pi Q), five of them

+2.8618

the spec’s shorthand for it (0.5 logdet Q^-1)

+4.5947

marginalisation constant, six of them

+7.2619

the fold corner -0.5 rho^2, six of them

+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: Module

zeta_{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:

jax.Array

process_std

(n,), strictly positive.

Type:

jax.Array

initial_std

(n,), strictly positive – sd(zeta_1).

Type:

jax.Array

initial_mean

(n,). Zero unless declared.

Type:

jax.Array

hyper

empty for a fixed transition. Present so that Factorization can ask one question of either type.

Type:

tuple[str, …]

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 call marginalise_arrays() inside a lax.scan instead of the checked marginalise(), 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 HyperTransition builds these blocks from theta, and under NUTS theta goes wherever it likes. Parameterise the builder so that positivity is structural – return jnp.exp(log_sigma), never a raw sampled scale – which is why this class takes standard deviations rather than a covariance.

property width: int

How many components the chain carries.

at(values)[source]

Itself. A fixed transition does not depend on theta – that is the claim.

Parameters:

values (dict[str, Array])

Return type:

LinearGaussianTransition

rheplicant.inference.chain.ornstein_uhlenbeck(tau, sigma, width=1, hyper=())[source]

A stationary OU chain: correlation time tau in epochs, spread sigma.

phi = exp(-1/tau) and process_std = sigma sqrt(1 - phi^2), so var(zeta_{e+1}) = phi^2 var + Q returns sigma^2 when it starts there – stationarity is arithmetic here, not an assumption, and tests/evidence/test_transition.py pins 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 a HyperTransition builder must return – is LinearGaussianTransition. An OU is a way of constructing one, and this package spells constructors in lower case.

Parameters:
Return type:

LinearGaussianTransition

class rheplicant.inference.chain.HyperTransition(build, hyper, width)[source]

Bases: Module

A 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 – exp of a sampled log-scale, not a sampled scale.

Type:

collections.abc.Callable[[dict[str, jax.Array]], rheplicant.inference.chain.LinearGaussianTransition]

hyper

which global latents build reads. Declared rather than inferred, because Factorization checks them and a closure’s free variables are not inspectable.

Type:

tuple[str, …]

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:

int

at(values)[source]

Resolve the blocks at these latent values.

Parameters:

values (dict[str, Array])

Return type:

LinearGaussianTransition

rheplicant.inference.chain.chain_marginal(blocks, transition, values, names, shapes)[source]

zeta_1:N integrated 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 because lax.scan needs 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 LinearGaussianTransition or a HyperTransition. Resolved once, here, against values – 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 SqrtInfo over names whose log_prob is log p(d_1:N | theta). Prior-free, like every other stored factor in this layer.

Return type:

SqrtInfo

Why the scan stops one short, and what that is *not* about. The plan this was built from warned that scanning over all N blocks and then marginalising once more would integrate a zeta_{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 what test_the_transition_normalisation_is_the_whole_density_not_half_of_it and test_the_marginalisation_constant_is_carried notice 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.

Parameters:
Return type:

Array

rheplicant.inference.chain.smooth(blocks, transition, values, names, shapes)[source]

p(zeta_e | d_1:N, theta) for every epoch – mean and variance.

theta is 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 then SqrtInfo’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, and R^-1 is a dense T-by-T triangular solve however sparse R is – 8 MB of float64 at a thousand epochs of a scalar chain, 128 MB at four thousand. Affordable offline and not on the filter’s O(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 theta is substituted – which is why tests/evidence/test_chain_smoother.py pins 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_covariance is where the tests get at them.

Parameters:
Return type:

tuple[Array, Array]

class rheplicant.inference.chain.ChainMemory(factorization, stacked=None, epochs=())[source]

Bases: Module

A campaign whose nuisance drifts across epochs. Ordered.

The difference from BayesMemory is 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.remember refuses 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 remember and 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.

Parameters:
factorization

the single declaration. Its linked entry 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.

Type:

tuple[jax.Array, jax.Array, jax.Array]

property linked_name: str

The one latent that is a Markov chain across epochs.

property transition: Any

Its transition – fixed, or a builder resolved inside the likelihood.

property archive: tuple[Any, ...]

The stored terms, oldest first.

property epoch_ids: tuple[str, ...]

The recordings’ data hashes, in the order they were remembered.

property column_order: tuple[str, ...]

What a stored block is a quadratic form in, zeta last.

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’s remember moves 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 “e1 twice”, it says “e1 happened, then e2, then e1 again” – one night’s drift correlated with itself across two transitions. Measured at PROBES[0]: ('e0', 'e1', 'e2') is -58.9892 nats, appending e1 gives -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: use BayesMemory if the epochs really are exchangeable, or give the second recording its own epoch_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 TypeError about 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:

ChainMemory

log_likelihood(values)[source]

log p(d_1:N | theta) with the chain integrated out. No prior.

Parameters:

values (dict[str, Array])

Return type:

Array

log_posterior(values)[source]

The chain’s likelihood plus the prior, applied exactly once.

Parameters:

values (dict[str, Array])

Return type:

Array

marginal(values)[source]

The campaign’s quadratic form in theta, at these transition values.

Parameters:

values (dict[str, Array])

Return type:

SqrtInfo

fisher(at)[source]

sum_e F_e after the chain is integrated out, with named rows.

at is required rather than defaulted, for the reason fisher() refuses to default it one layer along: with a HyperTransition the 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 a FlatMatrix here. A second copy would reintroduce Plan A’s own bug invisibly: chain_marginal returns columns in declared order, and the two orders coincide exactly when the latents are alphabetical.

Parameters:

at (dict[str, Array])

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 per remember rather 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: object

One 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.

Parameters:
epoch_id

the recording’s data hash, as the term carries it.

Type:

str

chi2

the epoch’s residual chi-square, after its own best fit.

Type:

float

dof

unflagged samples minus the rank of what the epoch fitted.

Type:

int

reduced_chi2

chi2 / dof, or nan when dof is 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:

float

templates

{name: projection}, each a standard normal under the null.

Type:

dict[str, float]

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 ChainMemory the archive order is the campaign’s time order, and a diagnostic that reordered it would make a drift look like scatter.

Parameters:

terms (Sequence[Any])

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_z below reports as a detection, and its scatter is inflated by noncentrality – 5.5467 against sqrt(2 * 6) = 3.4641 on 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_z needs 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 gives z = -1.01 while chi2_z still gives +31.92.

scatter is 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.3070 against the visible half’s 2.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.

Returns:

{"n_epochs", "chi2_mean", "chi2_dof", "chi2_z", "templates"}, where templates is {name: {"mean", "scatter", "z"}}.

Raises:

ValueError – if the epochs do not all name the same templates, or if the campaign is empty.

Parameters:

terms (Sequence[Any])

Return type:

dict[str, Any]

class rheplicant.inference.diagnostics.HeldOut(epoch_id, chi2, dof, z)[source]

Bases: object

One night’s row of section 9.1’s table.

A frozen dataclass for the same reason EpochResidual is one: a report is never differentiated, never jitted and never stored.

Parameters:
epoch_id

the recording’s identity, as the term carries it.

Type:

str

chi2

m^T (I + V)^-1 m for the held-out residual m.

Type:

float

dof

rows of the epoch’s factor – the dimension of m.

Type:

int

z

(chi2 - dof) / sqrt(2 dof), a standard normal under the model.

Type:

float

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]. Then z_e = R_e theta_true + eps with unit-covariance eps – that is what the square-root form means – and mu_{-e} - theta_true is independent of eps, so

m = R_e mu_{-e} - z_e  ~  N(0, I + R_e Sigma_{-e} R_e^T)

and m^T (I + V)^-1 m is chi-square on rank(R_e) degrees of freedom. The returned z is 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 is O(N) rather than O(N^2) and loses at most log10(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.96 while the largest of the other 59 scores 4.24; and a common-mode error over 300 varying nights lifts the campaign mean to +22.05 sigma against +0.87 for 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_e and mu_{-e} by amounts that cancel in m. The clean and the biased campaign then return the same scores: the largest disagreement over 640 epochs is 4.9e-05, and it shrinks as the prior’s share of the posterior does – 1.9e-04 at N = 160 against 4.9e-05 at N = 640 – while the answer is wrong by 52.6 sigma. 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 beside coherent_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_prior over 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 HeldOut per epoch, in the order the terms were given.

Raises:

ValueError – if the campaign is empty, if its epochs are over different latents, if prior_fisher or prior_mean is over a different number of columns, if the leave-one-out information is singular, or if any score comes out non-finite.

Return type:

tuple[HeldOut, …]

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/2 does not read the data. So this quantity is data-independent, p = -0.5 holds by construction, and a uniform rescaling F_e -> (1+c) F_e moves it by exactly nothing – verified in the spec for c in {0, +0.5, -0.3} and again in tests/evidence/test_coherent_bias.py. Measured on the repeated-design fixture, the clean and the deliberately-biased campaign return -0.49991034 and -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 sigma on log 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:

float

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.0 on an exact n**-0.5 bank, an exact n**-1.0 bank 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_table with the same sentences, and its StructureError is a ValueError (ValueError is in its MRO), so this module’s pytest.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_bias is False, 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. Read coherent_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.4641 on 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.0 on an exact n**-0.5 bank, an exact n**-1.0 bank 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_table with the same sentences, and its StructureError is a ValueError (ValueError is in its MRO), so this module’s pytest.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_modes does 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.

Parameters:

sigmas (Mapping[int, Any])

Return type:

dict[str, Any]

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/2 while the shared product’s does not fall at all. So the whole content of this function is: when does one pass under the other.

sigma is 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_epoch is computed from the observed width, not quoted: the campaign’s own sigma_N is extrapolated as sigma_N sqrt(N / N') and solved for sigma_{N'} = floor, giving N' = 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-style Returns: 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_floor is the refusal’s own comparison, computed here and nowhere else so that the NaN-safe form exists in one place; crossing_epoch is None when the width is not a finite positive number, because there is then no crossing to extrapolate; direction is the unit combination of that latent’s raveled components whose width sigma is, and None for 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:

dict[str, dict[str, Any]]

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 memory to path plus a manifest at path.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_leaves left a manifest describing a file that was never created, and load_memory then died on a raw FileNotFoundError from 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_memory names – 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 new inputs digest – the two files are the same length, so load_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 stale inputs then lets _reject_shared_inputs ADMIT 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_VERSION bump – 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.

Parameters:

path (str | Path)

Return type:

None

rheplicant.inference.archive.load_memory(path, factorization)[source]

Read a memory back, refusing anything the manifest says it cannot be.

Parameters:

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, ValueError

A configuration document was refused.

Parameters:
Return type:

None

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: Collection

A sorted, always-current view of a registry’s keys.

Iterating, sizing, in and repr all read the underlying mapping at the moment they are called, so a name registered after this object was built is still reported.

Parameters:

table (Mapping[str, Any])

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 offer mhz, 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, because celsius is affine and a silent conversion is exactly the finite, correctly-shaped wrong answer the alphabet above refuses to produce.

tests/config/test_config_units.py pins this against _ATOMS both 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: NamedTuple

A parsed unit: what to multiply by, and what the result is called.

A bullet list rather than an Attributes: section, as GLSResult in inference/gls.py already 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:
canonical: str

Alias for field number 0

factor: float

Alias for field number 1

offset: float

Alias for field number 2

numerator: tuple[str, ...]

Alias for field number 3

denominator: tuple[str, ...]

Alias for field number 4

dimension: str | None

Alias for field number 5

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:

Unit

rheplicant.config.units.convert_to_canonical(value, token)[source]

Convert value from token into canonical units.

Returns:

(converted, unit). converted keeps value’s own type where the conversion is exact (factor 1, offset 0), so a Python int destined 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_deg stores 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: object

The extents a shape symbol may resolve against.

Parameters:
n_time

len(observation.time.grid).

Type:

int

n_freq

len(observation.freq.grid).

Type:

int

n_source

len(observation.switching.order); 1 when not switching.

Type:

int

nside

set only inside an entry that declares its own; n_pix resolves from it and refuses without it.

Type:

int | None

lmax

likewise for n_alm.

Type:

int | None

candidates

dotted names of entries that DO declare an nside or lmax, quoted in the refusal so the reader is told what could have been meant instead of being told to guess.

Type:

tuple[str, …]

within(**extents)[source]

A new scope with per-entry extents added. Never mutates.

Return type:

ShapeScope

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" is 2 * n_freq minus one, not twice n_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_alm with nothing to resolve against, or on anything the two legal arithmetic forms do not cover.

Return type:

int

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_SYMBOLS order, 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_SYMBOLS and the scope, which is this module’s subject, and because the alternative on offer was draws importing a private name out of arrays.

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). shadowed maps position -> the symbol a literal integer there happens to equal, and is empty for a shape written entirely in symbols.

Raises:

ConfigError – when spec is not a list, and from resolve_extent() for anything in it that is not a shape position.

Return type:

tuple[tuple[int, …], dict[int, str]]

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: object

The scope a value node resolves in.

Parameters:
  • freq (Array | None)

  • time (Array | None)

  • dtype (str)

  • base_dir (str | None)

  • roots (tuple[str, ...])

  • seed (int | None)

  • seeds (dict[str, int])

  • switch_order (tuple[str, ...])

  • resources (dict[str, Any])

  • 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

time

the run’s time grid, seconds from the start of the run.

Type:

jax.Array | None

dtype

"float32" or "float64" – the run’s floating dtype.

Type:

str

base_dir

the directory of the document that mentioned a path.

Type:

str | None

roots

extra roots a relative path is tried against, in order.

Type:

tuple[str, …]

seed

the run’s root PRNG seed, or None when the run realises nothing.

Type:

int | None

seeds

runtime.seeds – an open namespace of user-named ints.

Type:

dict[str, int]

switch_order

observation.switching.order; index 0 is antenna.

Type:

tuple[str, …]

resources

constructed resources by dotted name. ref returns the object stored here, not a copy.

Type:

dict[str, Any]

n_source_override

set when switch_order is not yet known.

Type:

int | None

ingest

the ingested recording (RhinoObservation) when observation.from_file was declared; from: thermistors reads 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

with_resource(name, value)[source]

A new context carrying one more constructed resource.

Parameters:
Return type:

ResolutionContext

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: NamedTuple

What a value node resolved to, before any destination is considered.

A bullet list rather than an Attributes: section, for the reason given on rheplicant.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, a jnp array, or an arbitrary object for ref and python.

  • unit – the parsed unit, or None for 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 needs axis: (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.

Parameters:
value: Any

Alias for field number 0

unit: Unit | None

Alias for field number 1

source: str

Alias for field number 2

modifiers: dict[str, Any]

Alias for field number 3

class rheplicant.config.values.ResolutionTarget(destination, spec, expected, explicit_unit, inherits_outer_unit=False, formula_name=None, formula_role='result')[source]

Bases: object

The catalog row and concrete document address governing one value.

Parameters:
  • destination (DestinationDescriptor)

  • spec (DimensionSpec)

  • expected (DimensionSignature | None)

  • explicit_unit (Unit | None)

  • inherits_outer_unit (bool)

  • formula_name (str | None)

  • formula_role (str)

rheplicant.config.values.validate_declared_unit(node, target)[source]

Validate only the node’s declaration, before a resolver can do I/O.

Parameters:
Return type:

ResolutionTarget

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:

ResolvedValue

rheplicant.config.values.resolve_registered_form(node, context, target)[source]

The sole value-form dispatcher, after destination validation.

Parameters:
Return type:

ResolvedValue

rheplicant.config.values.register_form(name, *, arguments=frozenset({}))[source]

Register a resolver for one value form. Returns the function.

Parameters:
  • name (str) – the form key.

  • arguments (frozenset[str] | None) – the sibling keys this form takes beside the modifiers, or None when the legal siblings depend on the node’s own content, in which case the form is responsible for refusing its own strays.

Return type:

Callable[[Any], Any]

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 – _key is defined in terms of this function rather than deriving a key of its own. See there. A literal rather than a cross-reference, because _key is private and autodoc does not collect it, so the reference would never resolve – the same warning a3f96f0 removed from inference/noise.py.

Raises:

ConfigError – when the run declares no root seed at all – seed:     null is a legal, recorded state (examples/sky_to_noise_wave.py builds its State with no key), and it is incompatible with drawing.

Parameters:
Return type:

int

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 for unit: like every other format. False for 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:
Return type:

Path

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.

Parameters:
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 a from_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 ref names – 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:

Any

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:

Any

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 DTYPES that 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) and as: and axis: are recorded rather than applied – as: is cross-checked against the destination in rheplicant.config.delivery, and axis: 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._finish writes _shadowed there 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, dtype or axis outside its table, and on column: true over 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.

  1. ADCOperator(n_bits=jnp.asarray(12)) warns A 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, and eqx.filter_grad then returns 1.4e+08 where a gradient belongs. FlaggingOperator.threshold has no __check_init__ at all and takes a whole array, detonating later at an unrelated pytree comparison.

  2. AntennaLossOperator(efficiency=1) stores int32. An integer array is not an inexact array, so eqx.partition(op, eqx.is_inexact_array) returns [] and the field is silently untrainable. A YAML 1 and a YAML 1.0 must not differ in what can be inferred.

  3. A YAML sequence is a Python list. On a static tuple field it constructs fine and makes the module unhashable.

  4. astype("float64") returns float32 when jax_enable_x64 is 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 – and general_pointing.py puts 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: NamedTuple

What a destination field says about itself.

A bullet list rather than an Attributes: section, for the reason given on rheplicant.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…).

  • staticTrue when equinox will put this field in the treedef.

  • converter – the field’s own converter, or None.

  • required – no default and no default_factory.

Parameters:
name: str

Alias for field number 0

annotation: Any

Alias for field number 1

static: bool

Alias for field number 2

converter: Callable[[Any], Any] | None

Alias for field number 3

required: bool

Alias for field number 4

rheplicant.config.delivery.field_specs(cls)[source]

Every init field of an eqx.Module subclass, by name.

typing.get_type_hints rather than f.type: four modules in the package use from __future__ import annotations, and although none of them currently defines an eqx.Module, a string annotation would make every branch below fall through to “traced” silently.

Parameters:

cls (type)

Return type:

dict[str, FieldSpec]

rheplicant.config.delivery.mode_of(spec)[source]

The delivery mode a field’s own declaration implies.

Parameters:

spec (FieldSpec)

Return type:

str

rheplicant.config.delivery.declared_or_inferred_mode(context, destination, spec, declared)[source]

Return the exact mode delivery consumes, recording an omitted one.

Parameters:
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.

Parameters:
  • value (Any)

  • spec (FieldSpec)

  • dtype (str)

  • source (str)

  • declared_as (str | None)

  • destination (DestinationDescriptor | None)

Return type:

Any

rheplicant.config.delivery.origin_for_delivery(context, destination, *, defaulted=False)[source]

Find the payload authority exactly; never fabricate a user origin.

Parameters:
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:
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:
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:

Any

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 are str for an attribute or key and int for an index.

Parameters:

path (str)

Return type:

tuple[str | int, …]

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) and getattr / __getitem__ for the steps.

Parameters:

path (str)

Return type:

Callable[[Any], Any]

class rheplicant.config.paths.ResolvedPath(declared, key_path, keystr, leaf, selector)[source]

Bases: NamedTuple

A path that reached a real array leaf.

  • declared — the string the document wrote.

  • key_path — the JAX key path, as _resolve_targets produces it.

  • keystr — that path in jax.tree_util.keystr form, which is what the package’s own refusals quote.

  • leaf — the current value of the leaf.

  • selector — the callable, ready for Bind(into=...).

Parameters:
declared: str

Alias for field number 0

key_path: tuple

Alias for field number 1

keystr: str

Alias for field number 2

leaf: Any

Alias for field number 3

selector: Callable[[Any], Any]

Alias for field number 4

rheplicant.config.paths.resolve_path_on(path, twin)[source]

Resolve path against twin and 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 many node (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 – are refuse_duplicate_targets, refuse_aliased_target and refuse_misaddressed_region below; a fifth whole-document check (a twin.replace target colliding with a binding’s) is Plan 2’s, once inference.parameters’ replace targets exist for it to fold in.

Parameters:
Return type:

ResolvedPath

rheplicant.config.paths.refuse_duplicate_targets(paths, twin)[source]

Refusal 4: two declared paths that reach one leaf.

Both spellings are named. keystr alone would tell the reader that .stages[0].gain is written twice without saying which two keys of their document did it.

Parameters:
Return type:

None

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.aliased is 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.

Parameters:
Return type:

None

rheplicant.config.paths.refuse_misaddressed_region(config_key, region)[source]

Refusal 6 / check A47: a region’s config key must equal at[-1].

At with 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 at core/fold.py:409-414 and core/graph.py:1071). A config key naming any other covered node resolves to nothing, and the failure is a bare KeyError rather than the refusal the schema promised.

Parameters:
Return type:

None

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 label does 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_keys and (before this helper) the same sweep written by hand in kinds/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.

  • allowed (frozenset[str]) – every key this consumer reads.

  • 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: NamedTuple

What a resources: section produced.

  • resources — dotted name -> object, ready to put in a ResolutionContext.

  • shared_objects — groups of names that ended up as one object, for config.resolved.yaml’s shared_objects: map. Grouped by id(), 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:
resources: dict[str, Any]

Alias for field number 0

shared_objects: tuple[frozenset[str], ...]

Alias for field number 1

order: tuple[str, ...]

Alias for field number 2

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 exactly BuiltResources.resources’ – never the bare name, never a tuple. Callers select a kind with k.startswith("resources.projectors.").

TOTAL: IT NEVER RAISES, and that is the whole reason this function exists rather than each reader calling _resolved_spec itself. Measured, _resolved_spec raises by name on six malformed shapes: an extends: cycle, a self-extend, a dangling parent, a cross-kind parent, a non-string extends:, 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 existing match= pin still passes, because match= 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 a ConfigError naming 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 inside build_resources as a bare TypeError: '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 the variants: twin open. Walk the layers with preflight/document.py::_task3_over_layers and 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_layers here 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 a preflight/ 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, or None/anything else – a non-mapping is a shape build_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:

dict[str, dict]

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:
Return type:

BuiltResources

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.

name is the dotted resources.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:
Return type:

Any

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 SeparableBasis from two per-axis declarations.

Parameters:
Return type:

SeparableBasis

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:
Return type:

Any

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.

Parameters:
maps

(n_freq, n_pix) linear power, HEALPix RING.

Type:

jax.Array

sky_fraction

(n_freq,). All ones where nothing was truncated – written rather than omitted, so BeamSpillOperator always has a fraction to be handed and the “which one did it use” question never arises.

Type:

jax.Array

nside

the declared resolution, kept so a projector can check it.

Type:

int

normalize

the declared convention, kept for the resolved artefact.

Type:

str

rheplicant.config.kinds.beams.build_beam(name, spec, context)[source]

Build one beam resource.

Parameters:
Return type:

Beam

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:
Return type:

Any

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.

rheplicant.config.kinds.s_params.build_s_param(name, spec, context)[source]

Build one reflection-coefficient array, discriminated on kind.

Parameters:
Return type:

Any