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), andcoherence(exchangeability / martingale / evidence-conservation checks as plain testable functions).Out of scope, explicitly: modality encoders/decoders (use
mixle.represent/mixle.reasonat 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:
objectOne discrepancy evaluation: the value, which metric computed it, and whether it was exact.
- 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"pickskl_divergencewhen both sides look like distributions (exposelog_density), elsemmdover raw arrays (the “predicted is a distribution, observed is a concrete measurement” case reduces to comparingobservedagainst samples drawn frompredicted).degraded=Truewhenever 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.
- 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)]usingnsamples drawn fromp. 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 apmapover arbitrary hashable labels, not a fixed-order probability vector, which is a real complication left to a dedicated follow-up rather than papered over).
- 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)wheremis the equal mixture ofpandq; each term is estimated by sampling from the corresponding side and evaluatinglog m(x) = log(0.5 p(x) + 0.5 q(x))vialogaddexpfor numerical stability. Symmetric by construction up to Monte Carlo noise (both halves use independent sample draws).
- wasserstein_distance(p, q, *, n=10_000, seed=None)[source]
1-Wasserstein (earth-mover) distance between two 1D distributions, via sorted sample matching.
Draws
nsamples 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 asngrows). RaisesNotImplementedErrorfor 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.
- 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.statsdistribution (e.g. a real observation array vs. a synthesized/predicted one).bandwidthdefaults to the median pairwise distance heuristic over the pooled samples. Only the RBF kernel is implemented; other kernel names raiseNotImplementedError.
- class Hypothesis(id, payload, active=True)[source]
Bases:
objectOne typed hypothesis in a portfolio.
payloadis opaque to the portfolio itself.
- class HypothesisPortfolio(hypotheses, weights, w_open=0.0)[source]
Bases:
objectA 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)reweightsw_opentoo; it defaults to a flat constant baseline of1.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 andw_opengrows 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 tow_open– the honest “nothing, including the reserved slot, explains this” outcome, rather than raising or producing NaNs.
- 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_openis 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, whichresurrect()/the journal rely on.
- prune(*, min_weight)[source]
Deactivate (never delete) active hypotheses below
min_weight; their mass folds intow_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).
- surprise_score(observation, likelihood_fn)[source]
Joint improbability of
observationunder every active hypothesis, in[0, 1).baseline / (baseline + weighted_mean_likelihood)against the same flatbaseline = 1.0reweight()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.
- class LikelihoodStrategy(*args, **kwargs)[source]
Bases:
ProtocolA
(hypothesis, observation) -> likelihoodcallable that declares its verifiabilitytier.
- class DiscrepancyLikelihood(predict_fn, *, tier, temperature=1.0)[source]
Bases:
objectLikelihood from
mixle.epistemic.discrepancy.discrepancy_report():exp(-discrepancy / temperature).predict_fn(hypothesis) -> predicted_observationis 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.tieris a required constructor argument rather than something inferred fromdiscrepancy_report’sdegradedflag, because whetherpredict_fnitself 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).
- class CallableLikelihood(fn, *, tier)[source]
Bases:
objectWrap any plain
fn(hypothesis, observation) -> floatas aLikelihoodStrategy.
- class EpistemicStep(observation, portfolio_before, portfolio_after, surprise, next_action, next_action_eig)[source]
Bases:
objectThe full outcome of one loop iteration – everything
EpistemicJournallogs.
- 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 whensurprise_thresholdis set and the portfolio’ssurprise_score()onobservationmeets or exceeds it,propose_fn(updated_portfolio)is called; a non-Nonereturn 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: whenaction_spaceis given, each candidate is scored byEIG(a) - lam * cost_fn(a)(program plan §2’sa* = argmax_a EIG(a) - lambda*cost(a)) via_portfolio_eig_nmcagainst the updated portfolio, and the argmax is returned;action_space=Noneis a valid “just update the belief” call and returnsnext_action=None. RaisesValueErrorifaction_spaceis given withoutsimulate_fn– EIG estimation needs a way to generate a predicted observation per hypothesis per action, and there’s no honest default for that.- Parameters:
portfolio (HypothesisPortfolio)
observation (Any)
likelihood (LikelihoodStrategy)
simulate_fn (Callable[[Hypothesis, Any, RandomState], Any] | None)
lam (float)
surprise_threshold (float | None)
propose_fn (Callable[[HypothesisPortfolio], Hypothesis | None] | None)
n_outer (int)
n_inner (int)
rng (Any)
- 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:
objectOne journaled decision: what was believed, what was considered, what was chosen, and why.
- class EpistemicJournal(records=None)[source]
Bases:
objectAn 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
stepand return it.timestampis caller-supplied, never sampled here.
- replay(portfolio0=None)[source]
Reconstruct the belief trajectory from the journal’s stored snapshots alone.
portfolio0is accepted for interface symmetry with the loop’s ownstep(portfolio, ...)signature but is not required for reconstruction here: every record already carries its own fullportfolio_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,portfolio0is prepended to the returned trajectory.- Parameters:
portfolio0 (HypothesisPortfolio | None)
- Return type:
list[HypothesisPortfolio]
- 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
portfolio0throughobservationsin its given order, then again throughn_permutationsrandom 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.
- 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) -> observationmust 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 overnresampled predictive observations.
- evidence_conservation_violation(portfolio0, observation, likelihood)[source]
Whether re-ingesting the identical
observationa 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
likelihoodwith 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. Alikelihoodthat is itself dedup-aware (e.g. returns a neutral1.0for anobservationit 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.