mixle.epistemic package

The epistemic loop: belief tracking, hypothesis portfolios, and EIG-driven action selection.

A library realization of the control loop OBSERVE -> UPDATE -> ABDUCE -> PREDICT -> DISCRIMINATE -> ACT: maintain a weighted portfolio of typed hypotheses plus an explicit open-world mass (the “none of the above” slot), reweight it against new observations through a pluggable likelihood strategy, optionally propose new hypotheses when the evidence surprises every current one, pick the next observation/action by expected information gain, and log every step to a replayable, JSON-serializable decision journal.

This module is built entirely on existing mixle contracts rather than inventing new ones: mixle.inference.mcmc for the SMC resampling precedent, mixle.doe.active / mixle.doe.oracle for expected-information-gain estimation and verifiability tiers, mixle.evolve.ledger for the append-only JSON-serializable journal shape, and mixle.data.exchangeability for the permutation-test precedent behind the coherence checks. Nothing here fits a specific scientific domain: every test and example uses synthetic toy models.

Scope, deliberately narrow (see notes/epistemic-loop-integration-workplan.md for the full design and the two source specification documents it distills):

  • In scope: HypothesisPortfolio (typed weighted hypotheses + open-world mass), discrepancy (KL/JS/Wasserstein/MMD between distributions or samples – the “compare predicted vs. observed” hinge), likelihood (pluggable reweighting strategies at a declared verifiability tier), step() (one loop iteration: update, optional abduction on surprise, optional EIG-based action selection), EpistemicJournal (append-only, replayable decision log), and coherence (exchangeability / martingale / evidence-conservation checks as plain testable functions).

  • Out of scope, explicitly: modality encoders/decoders (use mixle.represent / mixle.reason at their current scope), any data corpus or training recipe, a simulator farm or MCP tool encapsulation (callers supply their own likelihood/action callables), RL, grammar- constrained token decoding, and any named scientific domain. Those remain future, separate work.

class DiscrepancyResult(value, metric, degraded, seed=None, n_samples=None, reproducible=True)[source]

Bases: object

One discrepancy evaluation: the value, which metric computed it, and whether it was exact.

seed and n_samples record what a Monte Carlo estimate needs for exact reproduction: pass seed back into discrepancy_report() and the same value comes back out. Both are None when nothing was sampled – an exact closed form, or a metric applied directly to caller-supplied arrays – since recording a seed there would misleadingly imply randomness that was never used. seed is also None when the caller passed their own np.random.RandomState instance rather than an int: that object’s reproducibility is already the caller’s to manage, and there is no single integer that reconstructs it.

reproducible is False when a sampled side draws from randomness this module cannot control – a bare .sample(n) that takes no RNG/seed argument and reads NumPy’s global state. A seed passed to such a call never reaches the sampler, so two identically seeded reports return genuinely different values; seed is therefore also None in that case rather than recording an integer that does not reproduce anything. Give the sampler an rng / random_state / generator / seed keyword, or expose mixle’s .sampler(seed) shape, to make a path reproducible.

Parameters:
discrepancy_report(predicted, observed, *, metric='auto', seed=None)[source]

The actual delta_m(o_hat, o) entry point: compare a predicted and an observed value/distribution.

metric="auto" picks kl_divergence when both sides look like distributions (expose log_density), else mmd over raw arrays (the “predicted is a distribution, observed is a concrete measurement” case reduces to comparing observed against samples drawn from predicted). degraded=True whenever the underlying computation fell back to a Monte Carlo / sample-based estimate rather than an exact closed form – callers that need to know whether a number is exact or estimated read this field rather than guessing from the metric name.

Every path that draws samples does so from a seeded RNG, never from unrecorded OS entropy: pass seed for a run you choose to make reproducible, or leave it unset and read the seed this function picked back off result.seed – either way, calling again with that same seed reproduces the exact same value, PROVIDED the sampled objects let this module control their randomness. When one does not, result.reproducible is False and result.seed is None: the guarantee is reported as unavailable rather than quietly broken. See DiscrepancyResult for that contract and for when n_samples comes back None.

Parameters:
Return type:

DiscrepancyResult

kl_divergence(p, q, *, n=10_000, seed=None)[source]

KL(p || q) in nats: exact closed form when a known pair matches, else a Monte Carlo estimate.

The one closed-form entry in the dispatch table today is two univariate Gaussians (the exact formula, not an approximation); every other pair falls back to mean_{x ~ p}[log p(x) - log q(x)] using n samples drawn from p. Extending the closed-form table to more conjugate pairs (Categorical-Categorical, Dirichlet-Dirichlet, …) is legitimate future work – it was deliberately left at one entry here rather than half-built across several families with incompatible parameterizations (mixle’s categorical distribution keys its simplex by a pmap over arbitrary hashable labels, not a fixed-order probability vector, which is a real complication left to a dedicated follow-up rather than papered over).

Parameters:
Return type:

float

js_divergence(p, q, *, n=10_000, seed=None)[source]

Jensen-Shannon divergence: symmetric, bounded, computed via the sample-mixture estimator.

0.5 * KL(p || m) + 0.5 * KL(q || m) where m is the equal mixture of p and q; each term is estimated by sampling from the corresponding side and evaluating log m(x) = log(0.5 p(x) + 0.5 q(x)) via logaddexp for numerical stability. Symmetric by construction up to Monte Carlo noise (both halves use independent sample draws).

Parameters:
Return type:

float

wasserstein_distance(p, q, *, n=10_000, seed=None)[source]

1-Wasserstein (earth-mover) distance between two 1D distributions, via sorted sample matching.

Draws n samples from each side; the empirical 1D optimal transport cost is the mean absolute difference between the two sorted sample sequences (exact for the empirical distributions, a consistent estimator of the true distance as n grows). Samples are squeezed to plain 1D before sorting (_as_1d_samples()) rather than sorted-then-raveled – sorting a (n, 1) column vector in its original shape is a silent no-op (see that function’s docstring), which would silently return the mean absolute difference between the two unsorted sample sequences instead of the true empirical Wasserstein distance. Raises NotImplementedError for genuinely multivariate input rather than silently computing a coordinate-wise number that isn’t the true multivariate Wasserstein distance – there is no cheap exact estimator for that case, and returning a wrong-but-plausible-looking number would be worse than refusing.

Parameters:
Return type:

float

mmd(samples_p, samples_q, *, kernel='rbf', bandwidth=None)[source]

Maximum Mean Discrepancy between two raw sample sets: sqrt(max(mmd_squared(...), 0)).

mmd_squared() estimates squared MMD and is deliberately signed, so returning it under the name mmd published a “distance” that could be negative – identical inputs scored below zero, and because a discrepancy feeds DiscrepancyLikelihood’s exp(-discrepancy / temperature), a negative value produced a likelihood above 1 and reversed the intended penalty ordering right around zero, where the two distributions are most alike.

Taking the square root of the clipped estimate restores the contract the public discrepancy API is written against: non-negative, zero when the samples are indistinguishable, growing with separation, and in the same units as the data. The clip means this is not unbiased near zero (it can only overstate a true zero, never understate it); when unbiasedness is what matters – two-sample testing, permutation nulls – call mmd_squared() directly and handle its sign yourself.

Parameters:
Return type:

float

mmd_squared(samples_p, samples_q, *, kernel='rbf', bandwidth=None)[source]

The unbiased estimator of squared MMD between two raw sample sets – a SIGNED quantity.

This is Gretton et al.’s U-statistic, and unbiasedness is exactly why it can come out negative: under the null (both sample sets from the same distribution) the true value is 0, so an unbiased estimator of it must scatter to both sides. Identical arrays [0, 1] give -0.3934693403; identical singletons give -2.0. Those are correct outputs of this estimator, not errors – which is why it is named for what it estimates. Use it when you want the unbiased test statistic (a two-sample test threshold, a permutation null); use mmd() when you want a discrepancy that behaves like a distance.

Unlike the other functions here, this takes samples directly rather than distribution objects – it works even when neither side is a mixle.stats distribution (e.g. a real observation array vs. a synthesized/predicted one). bandwidth defaults to the median pairwise distance heuristic over the pooled samples. Only the RBF kernel is implemented; other kernel names raise NotImplementedError.

Parameters:
Return type:

float

class Hypothesis(id, payload, active=True)[source]

Bases: object

One typed hypothesis in a portfolio. payload is opaque to the portfolio itself.

Parameters:
class HypothesisPortfolio(hypotheses, weights, w_open=0.0)[source]

Bases: object

A weighted, typed hypothesis set with an explicit reserved open-world mass w_open.

weights is a private copy of what the constructor was given, exposed read-only: the constructor’s invariant checks run once, and a writable or caller-aliased array would let them be undone immediately afterwards. Build a new portfolio (or use reweight()/prune()/ resample()/resurrect(), which all return one) to change weights; weights.copy() gives a writable array when a caller genuinely wants scratch space.

Parameters:
  • hypotheses (Sequence[Hypothesis])

  • weights (np.ndarray)

  • w_open (float)

reweight(observation, likelihood_fn, *, open_world_likelihood=None)[source]

Bayesian-reweight every active hypothesis by likelihood_fn(h, observation).

open_world_likelihood(observation) reweights w_open too; it defaults to a flat constant baseline of 1.0 – an implicit “moderately plausible, independent of how badly the current hypotheses fit” prior – which is what makes the surprise mechanism work without extra wiring: when every active hypothesis’s likelihood collapses toward zero on an out-of-support observation, the (unchanged) open-world baseline dominates the renormalization and w_open grows on its own, exactly the “the residual resists the current hypothesis schema” signal the program plan’s surprise trigger names. If every likelihood (including the open-world baseline) is zero, all mass moves to w_open – the honest “nothing, including the reserved slot, explains this” outcome, rather than raising or producing NaNs.

Every likelihood (including the open-world one) must be finite and non-negative – see _checked_likelihood(). That all-zero branch is reserved for genuinely unsupported observations; invalid evidence raises ValueError instead of being routed through it.

Parameters:
Return type:

HypothesisPortfolio

resample(*, method='systematic', ess_threshold=0.5, rng=None)[source]

Resample the active particle set if effective sample size drops below ess_threshold * n.

w_open is untouched – it is a reserved mass, not a particle. Resampled duplicates of the same source hypothesis get id-suffixed copies ("h2", "h2#1", …) so every hypothesis id in the returned portfolio stays unique, which resurrect()/the journal rely on.

method and ess_threshold are validated up front, before any early return (MXR-080-1759). Validating them lazily – at the point each is first used – made the contract depend on the current weights: an unknown method was silently accepted whenever ESS happened to clear the threshold, so the identical call raised or did not depending on data. ess_threshold compares against ess / n, which lies in (0, 1], so the meaningful domain is [0, 1] (0 disables resampling, 1 resamples anything short of uniform weights); a negative threshold silently disabled resampling, and NaN made every comparison false and forced an unrequested resample on every call.

Raises:

ValueError – if method is not a known resampler, or ess_threshold is not a finite number in [0, 1].

Parameters:
Return type:

HypothesisPortfolio

prune(*, min_weight)[source]

Deactivate (never delete) active hypotheses below min_weight; their mass folds into w_open.

min_weight must be finite and non-negative. weight < NaN is false for every weight, so a NaN threshold pruned nothing at all and returned an unchanged portfolio – outwardly identical to “no hypothesis was below the threshold”, which is the one reading that makes a caller move on.

Parameters:

min_weight (float)

Return type:

HypothesisPortfolio

resurrect(hypothesis_id, *, floor_weight=1e-3)[source]

Reactivate a deactivated hypothesis, taking its floor weight out of w_open (mass-conserving).

Parameters:
  • hypothesis_id (str)

  • floor_weight (float)

Return type:

HypothesisPortfolio

surprise_score(observation, likelihood_fn)[source]

Joint improbability of observation under every active hypothesis, in [0, 1).

baseline / (baseline + weighted_mean_likelihood) against the same flat baseline = 1.0 reweight() uses by default – close to 0 when some active hypothesis explains the observation well, close to 1 when every active hypothesis assigns it near-zero likelihood (program plan §3.5’s “improbable under every live hypothesis” surprise condition). A heuristic scalar, not a calibrated probability – callers threshold it, this method just computes it.

The [0, 1) range is only a range if every likelihood is finite and non-negative, which _checked_likelihood() enforces here: weighted_lik of -1 makes the denominator zero and the score inf, anything below -1 makes it negative, NaN propagates, and inf collapses it to exactly 0. Each of those is a number outside the advertised range that a caller’s threshold test would nonetheless silently accept.

Parameters:
Return type:

float

class LikelihoodStrategy(*args, **kwargs)[source]

Bases: Protocol

A (hypothesis, observation) -> likelihood callable that declares its verifiability tier.

class DiscrepancyLikelihood(predict_fn, *, tier, temperature=1.0)[source]

Bases: object

Likelihood from mixle.epistemic.discrepancy.discrepancy_report(): exp(-discrepancy / temperature).

predict_fn(hypothesis) -> predicted_observation is the hypothesis’s epistemic-synthesis step (program plan §3.7’s “for each live hypothesis, generate the observation you would expect to see”); this class only does the comparison, not the prediction. tier is a required constructor argument rather than something inferred from discrepancy_report’s degraded flag, because whether predict_fn itself calls a certified simulator under the hood is invisible to the discrepancy computation – inferring it here would risk silently misreporting a tier (notes/epistemic-loop-integration-workplan.md §5 Q2).

Parameters:
  • predict_fn (Callable[[Hypothesis], Any])

  • tier (str)

  • temperature (float)

class CallableLikelihood(fn, *, tier)[source]

Bases: object

Wrap any plain fn(hypothesis, observation) -> float as a LikelihoodStrategy.

fn is arbitrary caller code, so its return value is checked here before it is presented as a likelihood: finite and non-negative, the same contract DiscrepancyLikelihood.__call__() already enforces on its own output. 0.0 is legitimate (“this hypothesis gives the observation no support”); a negative, NaN, or infinite value is not, and downstream it does not fail loudly – it moves belief mass and produces out-of-range surprise scores that look like ordinary numbers. This wrapper is the boundary where the mistake is still attributable to the function that made it.

Parameters:
  • fn (Callable[[Hypothesis, Any], float])

  • tier (str)

class EpistemicStep(observation, portfolio_before, portfolio_after, surprise, next_action, next_action_eig)[source]

Bases: object

The full outcome of one loop iteration – everything EpistemicJournal logs.

Parameters:
  • observation (Any)

  • portfolio_before (HypothesisPortfolio)

  • portfolio_after (HypothesisPortfolio)

  • surprise (float)

  • next_action (Any | None)

  • next_action_eig (float | None)

step(portfolio, observation, likelihood, *, action_space=None, simulate_fn=None, action_likelihood=None, cost_fn=None, lam=1.0, surprise_threshold=None, propose_fn=None, n_outer=64, n_inner=64, rng=None)[source]

One loop iteration: reweight on observation, optionally abduce on surprise, optionally act.

UPDATE: portfolio.reweight(observation, likelihood). ABDUCE: only when surprise_threshold is set and the portfolio’s surprise_score() on observation meets or exceeds it, propose_fn(updated_portfolio) is called; a non-None return is folded in via _add_hypothesis() (program plan §3.5’s surprise trigger, at the scope this plan covers – schema-expansion / human-checkpoint semantics are not modeled here). ACT: when action_space is given, each candidate is scored by EIG(a) - lam * cost_fn(a) (program plan §2’s a* = argmax_a EIG(a) - lambda*cost(a)) via _portfolio_eig_nmc against the updated portfolio, and the argmax is returned; action_space=None is a valid “just update the belief” call and returns next_action=None. Raises ValueError if action_space is given without simulate_fn – EIG estimation needs a way to generate a predicted observation per hypothesis per action, and there’s no honest default for that.

UPDATE evaluates each likelihood exactly once (see _OnceLikelihood): surprise and the posterior are derived from the same evidence, so a stateful or stochastic likelihood can no longer make one step report two different beliefs, and an expensive likelihood is not paid for twice.

action_likelihood (MXR-080-1896, see ActionLikelihood) declares the ACTION-CONDITIONED outcome density p(y|h,a) that ACT’s own formula names. Supply it whenever simulate_fn’s outcome law depends on the action – with it left at None the two-argument likelihood is used for scoring, which is right only when it does not. UPDATE is unaffected either way: it conditions on an observation the caller has already obtained, not on a simulated one, so it keeps using likelihood.

MXR-080-1896, selection vs. final evidence: next_action_eig is NOT the score that won the argmax. max_a EIG_hat(a) over noisy Monte Carlo estimates is the winner’s curse – it is biased high for the winner precisely because an upward error is what made a candidate win (measured at +0.054 nats on a ~0.2 nat probe, a ~27% overstatement). The argmax selects; the winner’s EIG is then re-estimated once on an INDEPENDENT draw stream and that unbiased number is what gets reported and journaled. Only the reported magnitude changes – which action is chosen is still decided by the first pass, since re-scoring every candidate would just recreate the same curse.

The ACT economics are validated rather than trusted: lam and every cost_fn result must be finite and non-negative, and n_outer/n_inner must be exact positive integers. Without those checks a NaN cost or EIG made score > best_score false for every candidate, so a one-candidate action space returned “no action worth taking” instead of reporting that its evidence was invalid; a negative cost was scored as a reward, and a negative lam inverted the declared cost penalty into a bonus.

Parameters:
Return type:

EpistemicStep

class DecisionRecord(step_index, belief_snapshot_hash, portfolio_snapshot, surprise, action_considered=<factory>, action_chosen=None, action_eig=None, timestamp=None, rationale=None, prev_hash='0000000000000000000000000000000000000000000000000000000000000000', record_hash='')[source]

Bases: object

One journaled decision: what was believed, what was considered, what was chosen, and why.

belief_snapshot_hash content-addresses portfolio_snapshot alone. record_hash covers the whole record – surprise, actions considered, action chosen, EIG, timestamp, rationale, step index, and prev_hash – and because it includes prev_hash it is a hash chain: the hash of record i depends on every record before it, so removing, reordering, or splicing records breaks the chain rather than going unnoticed.

Parameters:
  • step_index (int)

  • belief_snapshot_hash (str)

  • portfolio_snapshot (dict)

  • surprise (float)

  • action_considered (list[Any])

  • action_chosen (Any | None)

  • action_eig (float | None)

  • timestamp (float | None)

  • rationale (str | None)

  • prev_hash (str)

  • record_hash (str)

class EpistemicJournal(records=None)[source]

Bases: object

An ordered, JSON-serializable, replayable log of EpistemicSteps.

See the module docstring for the precise type-fidelity contract: JSON-native values, tuple, numpy scalars, and numpy arrays round-trip through to_json()/from_json() (and replay()) exactly; any other payload type is stored as a one-way str() snapshot (with a UserWarning at journal time) and does not.

Parameters:

records (list[DecisionRecord] | None)

property records: tuple[DecisionRecord, ...]

The journaled records, as a read-only snapshot.

The backing list used to be the public attribute, so a caller could clear(), del, or reorder the journal in place – an append-only decision log that anything holding a reference could rewrite. Indexing, iteration, and len() are unchanged; use append() to add. (Record content is still reachable through the returned records, which is why verify() exists and now covers the whole record and its position in the chain.)

append(step, *, action_considered=(), rationale=None, timestamp=None)[source]

Append one record for step and return it. timestamp is caller-supplied, never sampled here.

belief_snapshot_hash is computed over step.portfolio_after’s type-faithful encoding (see _tag_encode()): a hypothesis payload outside the supported type set is stored anyway (as a one-way str() snapshot) rather than rejected, since real callers journal arbitrary typed hypothesis payloads (e.g. fitted model objects) by design – but a UserWarning is raised so this is never silent.

Parameters:
  • step (EpistemicStep)

  • action_considered (list[Any])

  • rationale (str | None)

  • timestamp (float | None)

Return type:

DecisionRecord

replay(portfolio0=None)[source]

Reconstruct the belief trajectory from the journal’s stored snapshots alone.

portfolio0 is accepted for interface symmetry with the loop’s own step(portfolio, ...) signature but is not required for reconstruction here: every record already carries its own full portfolio_snapshot, so replay is deserialization, not re-simulation (re-simulation would additionally need the original observations and likelihood callables, which are deliberately not journaled – they may not be JSON-serializable, and the snapshot is the thing an audit actually needs). If given, portfolio0 is prepended to the returned trajectory.

A hypothesis payload that round-tripped through to_json()/from_json() comes back as its original type for the supported set (JSON-native, tuple, numpy scalar/array); otherwise it comes back as the one-way str() snapshot that was actually stored (see the module docstring). Replaying a journal that was never serialized (no to_json()/from_json() round trip) always returns the original in-memory payload objects unchanged, since nothing was ever encoded.

Parameters:

portfolio0 (HypothesisPortfolio | None)

Return type:

list[HypothesisPortfolio]

head()[source]

The chain’s current tip: the last record’s record_hash, or the genesis anchor if empty.

This is what verify() needs in order to detect truncation. Store it outside the journal’s own storage each time you finish appending, and hand it back to verify(expect_head=...).

Return type:

str

verify(*, expect_length=None, expect_head=None)[source]

Return whether the journal is intact: every record’s content AND its place in the chain.

This used to compare only portfolio_snapshot against belief_snapshot_hash, so everything the log exists to attest – the surprise, the actions considered, the action actually chosen, its EIG, the timestamp, the rationale, the step index – could be rewritten and this still returned True. So could deleting or reordering records, since nothing tied a record to its position or its predecessor.

Four things are now checked per record, in order: its step_index matches its position; its prev_hash matches the previous record’s record_hash (_GENESIS_HASH for the first); its snapshot still matches belief_snapshot_hash; and its record_hash still matches the whole canonical record. Because each record_hash covers prev_hash, the records form a chain: altering record i invalidates every record after it, so no localized edit can be made to verify.

Hashing is over the type-faithful encoding (_tag_encode()), not a lossy JSON shadow of it: two snapshots differing only in payload type (a tuple silently replaced by an equal-valued list) hash differently and correctly fail. For a payload stored as a one-way str() snapshot (see the module docstring), this only proves that string is unchanged, not that the original object would be recovered – there is no stronger claim to make once the exact object is gone.

Truncation from the end cannot be detected from the records alone, and no hash chain can do it. Deleting the final record leaves a shorter chain that is entirely self-consistent: every remaining prev_hash still matches its predecessor and every record_hash still verifies, because a prefix of a valid chain is a valid chain. Catching it requires one fact the journal does not itself hold – how many records there should be, or what the tip was:

head = journal.head() # store this outside the journal … journal.verify(expect_head=head, expect_length=n)

expect_head is the stronger anchor: it pins the exact final record, so it also rejects a journal truncated and then re-extended back to its original length. expect_length alone only notices a change in count. With neither, this still proves internal consistency – but it is not evidence that nothing was dropped off the end, and an auditor should not read it as such.

Parameters:
  • expect_length (int | None)

  • expect_head (str | None)

Return type:

bool

to_json(**dumps_kwargs)[source]

Serialize the journal as JSON that a strict parser actually accepts.

allow_nan is forced off rather than left at Python’s default. json.dumps otherwise writes bare NaN/Infinity/-Infinity tokens, which no JSON grammar admits: the journal round-tripped fine inside Python (json.loads reads those tokens back) while being unreadable to every other consumer – and an audit trail nobody else can read is the one thing this class exists to prevent. _tag_encode() now carries non-finite floats through the versioned tag scheme, so this switch should never fire; it is here so that a value that escapes the codec fails loudly instead of writing a document that lies about being JSON.

Parameters:

dumps_kwargs (Any)

Return type:

str

classmethod from_json(s)[source]

Rebuild a journal from to_json() output, checking the anchor it carries.

A document written by to_json() is an envelope holding the records plus the length and chain tip they had when written; both are verified here, so a record deleted from the end – which verify() alone cannot see – is caught on load.

A bare JSON array is still accepted for documents written before the envelope existed. Those carry no anchor, so loading one proves only that the records are internally consistent; pass a separately stored expect_head to verify() for the stronger check.

Parameters:

s (str)

Return type:

EpistemicJournal

exchangeability_violation(portfolio0, observations, likelihood, *, n_permutations=20, rng=None)[source]

Max posterior-weight deviation across random reorderings of observations (program plan §2(i)).

Sequentially reweights portfolio0 through observations in its given order, then again through n_permutations random permutations of the same set; returns the largest per-hypothesis (or open-world) weight deviation seen across permutations. Zero (up to float noise) for a likelihood whose per-step reweighting is a pure multiplicative update with no hidden order dependence – successive Bayesian multiply-and-renormalize steps commute exactly in that case.

n_permutations must be a positive exact integer: a zero budget returned 0.0 – read by any caller as “no violation” – having permuted nothing at all. An open-world-only portfolio (no active hypotheses) is a valid input and reports on w_open alone; see _max_abs().

observations must hold at least two entries, for the same reason on the other axis: a sequence of length 0 or 1 has exactly one ordering, so every “permutation” is the base order, every deviation is identically 0.0, and the function reports “no violation” from a test it could not run. Order-dependence is undefined on a sequence that has no alternative order, so this refuses rather than certifies (MXR-080-1758).

Parameters:
Return type:

float

martingale_violation(portfolio, observation_sampler, likelihood, *, n=1000, rng=None)[source]

|E[w_{t+1} | B_t] - w_t| under the model’s own predictive (program plan §2(ii)).

observation_sampler(rng) -> observation must draw from the portfolio’s own predictive distribution (e.g. sample a hypothesis proportional to its current weight, then simulate one observation from it) – the martingale property is a statement about self-consistency under the model’s own predictive measure, not about any particular real data-generating process. Returns the largest per-hypothesis (or open-world) deviation between the prior weight and the weight averaged over n resampled predictive observations.

n must be a positive exact integer: n=0 divided the (zero) accumulator by zero, producing a ZeroDivisionError on the open-world term and a NaN weight vector rather than reporting that no measurement was requested. An open-world-only portfolio is a valid input; see _max_abs().

Parameters:
Return type:

float

evidence_conservation_violation(portfolio0, observation, likelihood)[source]

Whether re-ingesting the identical observation a second time changes the posterior further.

Program plan §2(iii): “the same underlying measurement, ingested twice through different routes, updates once.” This function tests the math given an already-deduped input path – it does not itself provide the content-addressed dedup key that real conservation needs (program plan §3.1 is where that lives, a storage-layer concern outside this plan’s scope). Concretely: a plain likelihood with no memory of what it has already seen WILL show a violation here (double application double-counts the evidence, changing the weights again) – that is the honest, correct outcome for an undeduped path, not a bug in this function. A likelihood that is itself dedup-aware (e.g. returns a neutral 1.0 for an observation it has already scored, tracked by identity/content-key in a closure the caller owns) shows no violation, demonstrating the property holds once dedup is actually wired in.

The comparison is purely absolute, at CONSERVATION_ATOL; use evidence_conservation_deviation() for the magnitude behind this Boolean.

Parameters:
Return type:

bool

Submodules