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)[source]

Bases: object

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

Parameters:
discrepancy_report(predicted, observed, *, metric='auto')[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.

Parameters:
  • predicted (Any)

  • observed (Any)

  • metric (str)

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). Raises NotImplementedError for 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 (unbiased estimator).

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.

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.

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.

Parameters:
Return type:

HypothesisPortfolio

prune(*, min_weight)[source]

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

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.

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.

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

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)[source]

Bases: object

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

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)

class EpistemicJournal(records=None)[source]

Bases: object

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

Parameters:

records (list[DecisionRecord] | None)

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

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

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.

Parameters:

portfolio0 (HypothesisPortfolio | None)

Return type:

list[HypothesisPortfolio]

verify()[source]

Return whether every record’s stored snapshot still matches its recorded content-address.

Return type:

bool

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.

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.

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.

Parameters:
Return type:

bool

Submodules