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, seed=None, n_samples=None, reproducible=True)[source]
Bases:
objectOne discrepancy evaluation: the value, which metric computed it, and whether it was exact.
seedandn_samplesrecord what a Monte Carlo estimate needs for exact reproduction: passseedback intodiscrepancy_report()and the samevaluecomes back out. Both areNonewhen 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.seedis alsoNonewhen the caller passed their ownnp.random.RandomStateinstance rather than an int: that object’s reproducibility is already the caller’s to manage, and there is no single integer that reconstructs it.reproducibleisFalsewhen 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;seedis therefore alsoNonein that case rather than recording an integer that does not reproduce anything. Give the sampler anrng/random_state/generator/seedkeyword, or expose mixle’s.sampler(seed)shape, to make a path reproducible.
- 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"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.Every path that draws samples does so from a seeded RNG, never from unrecorded OS entropy: pass
seedfor a run you choose to make reproducible, or leave it unset and read the seed this function picked back offresult.seed– either way, calling again with that sameseedreproduces the exact samevalue, PROVIDED the sampled objects let this module control their randomness. When one does not,result.reproducibleisFalseandresult.seedisNone: the guarantee is reported as unavailable rather than quietly broken. SeeDiscrepancyResultfor that contract and for whenn_samplescomes backNone.- Parameters:
predicted (Any)
observed (Any)
metric (str)
seed (int | RandomState | None)
- 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)]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). 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. RaisesNotImplementedErrorfor 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.
- 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 namemmdpublished a “distance” that could be negative – identical inputs scored below zero, and because a discrepancy feedsDiscrepancyLikelihood’sexp(-discrepancy / temperature), a negative value produced a likelihood above1and 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.
- 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); usemmd()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.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.weightsis 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 usereweight()/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)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.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 raisesValueErrorinstead of being routed through it.
- 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.methodandess_thresholdare 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 unknownmethodwas silently accepted whenever ESS happened to clear the threshold, so the identical call raised or did not depending on data.ess_thresholdcompares againstess / n, which lies in(0, 1], so the meaningful domain is[0, 1](0disables resampling,1resamples 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
methodis not a known resampler, oress_thresholdis 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 intow_open.min_weightmust be finite and non-negative.weight < NaNis 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).
- 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.The
[0, 1)range is only a range if every likelihood is finite and non-negative, which_checked_likelihood()enforces here:weighted_likof-1makes the denominator zero and the scoreinf, anything below-1makes it negative, NaN propagates, andinfcollapses it to exactly0. Each of those is a number outside the advertised range that a caller’s threshold test would nonetheless silently accept.
- 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.fnis arbitrary caller code, so its return value is checked here before it is presented as a likelihood: finite and non-negative, the same contractDiscrepancyLikelihood.__call__()already enforces on its own output.0.0is 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.
- 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, 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 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.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, seeActionLikelihood) declares the ACTION-CONDITIONED outcome densityp(y|h,a)that ACT’s own formula names. Supply it wheneversimulate_fn’s outcome law depends on the action – with it left atNonethe two-argumentlikelihoodis 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 usinglikelihood.MXR-080-1896, selection vs. final evidence:
next_action_eigis 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.054nats on a~0.2nat 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:
lamand everycost_fnresult must be finite and non-negative, andn_outer/n_innermust be exact positive integers. Without those checks a NaN cost or EIG madescore > best_scorefalse 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 negativelaminverted the declared cost penalty into a bonus.- Parameters:
portfolio (HypothesisPortfolio)
observation (Any)
likelihood (LikelihoodStrategy)
simulate_fn (Callable[[Hypothesis, Any, RandomState], Any] | None)
action_likelihood (Callable[[Hypothesis, Any, Any], float] | 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, prev_hash='0000000000000000000000000000000000000000000000000000000000000000', record_hash='')[source]
Bases:
objectOne journaled decision: what was believed, what was considered, what was chosen, and why.
belief_snapshot_hashcontent-addressesportfolio_snapshotalone.record_hashcovers the whole record – surprise, actions considered, action chosen, EIG, timestamp, rationale, step index, andprev_hash– and because it includesprev_hashit is a hash chain: the hash of recordidepends on every record before it, so removing, reordering, or splicing records breaks the chain rather than going unnoticed.
- class EpistemicJournal(records=None)[source]
Bases:
objectAn 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 throughto_json()/from_json()(andreplay()) exactly; any other payload type is stored as a one-waystr()snapshot (with aUserWarningat 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, andlen()are unchanged; useappend()to add. (Record content is still reachable through the returned records, which is whyverify()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
stepand return it.timestampis caller-supplied, never sampled here.belief_snapshot_hashis computed overstep.portfolio_after’s type-faithful encoding (see_tag_encode()): a hypothesis payload outside the supported type set is stored anyway (as a one-waystr()snapshot) rather than rejected, since real callers journal arbitrary typed hypothesis payloads (e.g. fitted model objects) by design – but aUserWarningis raised so this is never silent.
- 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.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-waystr()snapshot that was actually stored (see the module docstring). Replaying a journal that was never serialized (noto_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 toverify(expect_head=...).- Return type:
- 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_snapshotagainstbelief_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 returnedTrue. 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_indexmatches its position; itsprev_hashmatches the previous record’srecord_hash(_GENESIS_HASHfor the first); its snapshot still matchesbelief_snapshot_hash; and itsrecord_hashstill matches the whole canonical record. Because eachrecord_hashcoversprev_hash, the records form a chain: altering recordiinvalidates 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-waystr()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_hashstill matches its predecessor and everyrecord_hashstill 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_headis 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_lengthalone 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.
- to_json(**dumps_kwargs)[source]
Serialize the journal as JSON that a strict parser actually accepts.
allow_nanis forced off rather than left at Python’s default. json.dumps otherwise writes bareNaN/Infinity/-Infinitytokens, 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.
- 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 – whichverify()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_headtoverify()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
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.n_permutationsmust be a positive exact integer: a zero budget returned0.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 onw_openalone; see_max_abs().observationsmust 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 identically0.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).
- 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.nmust be a positive exact integer:n=0divided the (zero) accumulator by zero, producing aZeroDivisionErroron 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().
- 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.The comparison is purely absolute, at
CONSERVATION_ATOL; useevidence_conservation_deviation()for the magnitude behind this Boolean.