Changelog¶
Everything worth noting lands here. The format follows Keep a Changelog, and versions follow semantic versioning. While we're pre-1.0, treat the minor version as the place breaking changes can show up.
[Unreleased]¶
Warrant plumbing and the slogdet oracle audit. The two-level SearchWarrant becomes a
shared three-level Warrant that checks can reach, and a check now reports what it found
alongside how well it was warranted and what it was measured against. The audit half
confirmed what v0.4.3 left behind: the kernel's log-determinant guard was never applied to
the NumPy oracle the headline H* = 7 is checked against.
The H = 7 numbers did not move. They are bit-identical across the guard, so it corrected
an exposure rather than a result.
Added¶
Provenance(warrantlib 0.2.0) — which ref registered a claim and which one measured it, plus one line saying what a reviewer will find at the first. APROVEDreport requires one, on the same terms as it requires evidence, and carries them as a tuple for the same reason the evidence is one. A ref is a git commit SHA, an http(s) URL or a DOI; a path, a branch, a tag andHEADare refused, because each resolves to a different tree every time it is read. Where the two refs name one commit the render says the ordering is not established by history, which is the honest reading whenever a check and the derivation behind it land together. The type cannot order two refs, only compare them, sotests/test_provenance_ordering.pyasks git. Three of the suite's nine sources fail that check today and are markedxfail: the hand derivation inresearch/c4_hand_derivation.mdlanded 2026-08-17, after the suites citing it, which ADR-037 already discloses (ADR-041).warrantlib— the vocabulary every check labels itself from, published as its own distribution and re-exported ascpomdp.warrantso existing import paths keep working. It depends on the standard library alone, so a suite can label its findings without a numerical stack; cpomdp depends on it and adds nothing to it (ADR-039).Warrantcarries the prover class (PROVED,CERTIFIED,CORROBORATED),Outcomewhat a registered falsifier did,Tierwhat it was measured against (EXACT,BOUNDED,COMPUTED), andCheckReportall four with a reason.CERTIFIEDis new: validated numerics prove a universal over a compact domain, which is stronger than a sample and weaker than a decision, and forcing it into either neighbour overclaims or throws away the bound (ADR-035). Every member is a word rather than a letter, so a row says what it means without a reader knowing the ordering. The prover sub-modes read the same way in prose:Prover 3 · sample,Prover 3 · enumeration,Prover 3 · validated. The canonical table is atresearch/warrant_ledger.md.cpomdp.SymbolicReduction— evidence for aPROVEDclaim decided by argument rather than by enumeration (Provers 1 and 2). A CAS establishes that one expression equals another and has nothing to say about whether those are the expressions the analytic claim is about, which the warrant ledger records as a human obligation. The type carries the claim, where the setup was hand derived against the problem, and the assumptions the identity is contingent on. Blank fields do not construct, so acorrespondencenobody can fill honestly routes a check toCORROBORATEDinstead of passing silently. Blank means blank to a reader: whitespace, the zero-width formatting characters, a line break into a one-line render, and a field that is not text are all refused, withassumptionschecked entry by entry.check_summaryprintsn registered, m tested here, k fired, then counts per(warrant × outcome). Registering four falsifiers and testing two is a different claim from testing four, and one number cannot carry both.cpomdp.diagnostics.logdet_pd—ln detbehind a Cholesky positive-definiteness guard, NaN otherwise.slogdetreports a sign separately, so reading only the magnitude returnsln 2fordiag(-1, -2), a plausible number for a matrix whose log-determinant does not exist.epistemic_valueand the crossover demo's oracle both route through it.examples/ffg/crossover.pygainsflip_margin_error, the error the already-declaredCOND_CEILINGallows on a difference of two scores.H* = 7rested on a bare inequality between two computed floats with nothing said about how far apart they had to be. The bar is derived from a ceilingtests/test_rollout_hygiene.pyalready enforces rather than invented for the claim.warrant_numbers.mdrecords the derivation, that it was written after the measurement, and why a stricter guard cannot rescue a failing result.
Fixed¶
EFESelectordescribed varying-sequence search as a deferred v0.4GradientEFESelectorseam, in its rendered class docstring and in theValueErrorap > 1caller hits. That search shipped in v0.4.4 asEnumeratedEfeSearch, whose own docstring says it supportsp >= 1and varying sequences (ADR-031). No such class has ever existed undersrc/. The name is still a planned continuous-action selector elsewhere, so only these two strings were wrong, not the name itself. The error path was the worse of the two: it told a caller to reimplement a search the package already had. Both now nameEnumeratedEfeSearchand theFiniteActionSetit needs.- The NumPy oracle in
examples/ffg/crossover.pydiscardedslogdet's sign on both halves of its per-step epistemic. v0.4.3 fixed the shipped kernel and the changelog did not say whether the oracle path went with it. It did not. Both paths now reject a matrix with an even number of negative eigenvalues, which a two-route agreement check cannot catch on its own: two routes that both discard the sign agree, and are both wrong. - The crossover falsifiers computed their outcome from
|ΔG| > bound, a magnitude test on a direction question. Nothing could emitFIRED, and a reversed argmin would have printed as a survivor beside a hardcoded "argmin is cue-ward". Outcome and detail are now computed from the exhaustive argmin, and the measurement is injectable so a refuting result is something a test can execute. CompletenessCertificate(expected=9, visited=8, warrant=PROVED)used to construct and readcomplete = False, a certificate recording its own shortfall with the contradiction one attribute access away. It now raises. The honest label for a partial enumeration isCORROBORATED.
Changed¶
- cpomdp requires
warrantlib>=0.2, up from>=0.1.cpomdp.warrantre-exportsProvenance, so an installed 0.1 fails atimport cpomdp. Breaking for anyone pinning warrantlib 0.1. - Breaking: the model's fields are
dynamics_matrix,control_matrix,observation_modelanddynamics_noise_model(weredynamics,control,observation,process_noise). One rule now covers every field:_matrixis a linear map,_noisea fixed covariance,_modelthe state-dependent stand-in for one of them. Before this the fixed process noise wasdynamics_noiseand the state-dependent oneprocess_noise, the same channel under two roots, while A wasdynamicsand C wasobservation_matrix. Keyword-only construction is what made the names load-bearing: every argument after the first is typed out at each call site, so a member on its own scheme is a question the caller has to answer each time.GaussianTransition.dynamicsand theCouplingGraphBackend(control=...)argument move with it, as do the demo constants (SENSORisOBSERVATION_MATRIX,PROCESS_NOISEisDYNAMICS_NOISE).LinearGaussianModel.observation_modelandInferenceBackend.observation_model()now share a name on two classes: the field holds the declaration, the method returns the(C, R)it resolves to. The.A/.B/.C/.Q/.Raliases are unchanged, and no numerical behaviour moved. - Breaking: the epistemic term's aim is named
info_nodeandinfo_block, nottarget.targetcarried two unrelated types on exported API: a desired value (Float64[Array, "n"]) onStateGoalandObservationGoal, and the joint-state indices the epistemic term reads (Sequence[int]) onFfgEfeSelector,policy_efe_ffgand bothover_backendclass methods. A reader who learned the first from the Goal classes and passed a goal vector to the FFG API gotG = nanwith no raise, and_argminmaps nan to+inf, so a different action was selected in silence. The two are one address at two resolutions:info_nodenames a graph node,backend.block(node)returns theinfo_blockof joint-state coordinates it occupies.nodeandblockare the words the rest of the tree already uses for those two kinds.ObservationGoal.info_targetbecomesinfo_node.StateGoal.targetandObservationGoal.targetkeep the name, which now has one meaning. - Breaking:
ObservationGoaltakesaction_boundsby keyword.StateGoalalready takes everything after its first argument by keyword, so this was the odd one out in a public pair a reader meets together. The two positional slots also transposed without raising for a 2-D increasing target, since(lo, hi)then satisfies the target's shape check and the target satisfieslo < hi. 24 call sites moved to keywords. - Breaking:
FixedSensorandGaussianObservationtakeobservation_noiseby keyword. The observation pair is (m, n) against (m, m), so the two are distinguishable by shape only when m != n, and a 1-D sensor is the case where they coincide. Only the noise is content-checked, and a square symmetric observation matrix passes that check, so a transposed pair built a sensor whose Jacobian was its noise.CallableSensorandCallableGaussianObservationare left alone: their arguments are a matrix, a callable and a pytree, so a swap raises rather than going quiet. 46 call sites moved to keywords. - Breaking:
GaussianTransitiontakesdynamics_noiseby keyword, andGaussianTransition.from_outakesstationary_varanddtby keyword. Both followLinearGaussianModelfor the same reason, and the transition factor is the worse of the two: itsdynamics_matrixanddynamics_noiseare both (n, n) at every dimension, so a transposed pair needs no square-shape coincidence to pass. Only the noise is content-checked.from_ou'stauanddtare both positive scalars in the same time unit andA = exp(-dt/tau)is finite either way round. 45 call sites moved to keywords. - Breaking: every
LinearGaussianModelargument afterdynamicsis keyword-only. The four matrices are two maps and two covariances of the same rank, and only the covariances are content-checked, so a transposed pair constructed in silence whenever the maps were square and symmetric.LinearGaussianModel(Q, C, A, R, prior)built a model and returned a posterior of[0.900, -0.444]where the answer is[0.896, -0.434]. Detecting the mistake after the fact needs a rule for which of two same-shaped matrices is which. Naming them at the call site needs none. One positional construction existed in the tree,examples/efe_collapse_figure.py, now converted.tyreports the arity error statically, so a stale positional call fails the type gate rather than running. - Breaking:
sensor_modelis nowobservation_matrix, on the same classes plus_JointObservationandepistemic_value. C is the observation matrix in the same literature that gives R its name, so the two halves of the measurement equation now read from one vocabulary. C is notobservation_model: that name means the whole(C, R)channel, both onInferenceBackend.observation_model()and on the model field the entry above renames. The 2026-06-13 sketch wanted plainobservationfor C, which the state- dependent sensor field later claimed. The.Calias is unchanged, and no numerical behaviour moved. - Breaking:
sensor_noiseis nowobservation_noise, onLinearGaussianModel,FixedSensor,CallableSensor, the FFG observation factors andepistemic_value. R is the observation noise covariance in the state-space literature this library writes in, and it is the noun in "state-dependent observation noise", the phrase theR(x)work is published under. The backends already said it.observation_noise_athas always been the state-dependent accessor, so the model was the one place still calling R a sensor quantity. The 2026-06-13 naming sketch inDECISIONS.mdproposedobservation_noiseat the outset. The code drifted. The.Ralias is unchanged, and no numerical behaviour moved. - The repository is a uv workspace. cpomdp stays the root;
packages/warrantlibandresearchare members, sharing oneuv.lockand one.venv.warrantlibpublishes to PyPI beside cpomdp.cpomdp-researchnever publishes, and holds the check suites along with the scipy and sympy they need, which have left cpomdp'sdevgroup (ADR-039). CompletenessCertificateis defined inwarrantlibbesideSymbolicReduction, the other evidence kind, and re-exported fromcpomdp.enumerationwhere an enumeration produces one. It used to live incpomdp.enumeration, which made the warrant module import it at call time to run theisinstanceguard behindPROVED.- The check suites moved to
research/src/research/checks/. Theresearch.checks.<module>import path is unchanged, and so is every registered count. SearchWarrantis an alias ofWarrant. Every call site keeps its members and its return type, soEFESelector.warrantstill readsCORROBORATEDandEnumeratedEfeSearch.warrantstill readsPROVED.PROVEDwithout evidence does not construct, enforced inCheckReport. Two kinds qualify, one per decisive prover: a completeness certificate for an exhaustive enumeration, aSymbolicReductionfor a theorem or a symbolic identity (Provers 1 and 2). Evidence is a tuple, so a claim quantified over several enumerations carries all their certificates rather than one of them. Every item is checked for its kind, so a path naming where the proof lives cannot satisfy the precondition by being present.- A check that never ran carries no warrant.
CORROBORATEDasserts sampling-grade evidence was obtained, so attributing it to a falsifier void by construction claims evidence that does not exist. That cell reads—, enforced at construction. examples/ffg/crossover.py --checkreports its falsifiers on two axes. Rows 1 and 2 readPROVEDfrom the enumeration certificates andBOUNDEDfrom the error bound. TheH* = 7upper-bound qualifier now travels in both detail strings, not only in the write-up. The declared set clips the reach at-2while-3reaches the goal in one step. A margin inside its bound reportsNOT RESOLVEDrather than raising, since a tie is a finding about the measurement and an exception erases it.- ADR-029 said the check vocabulary must never ship in
cpomdp. ADR-035 reverses that on placement and extends it on content: three outcomes become five,NOT RUN HEREpromoted from a prose rule to a member,NOT RESOLVEDadded for a genuine tie.PASSis absent from the enum. A falsifier does not pass, and printingPASSbeside a prover column that disambiguates it inverts the rule the prover column exists to satisfy.
[0.4.4] — 2026-08-04¶
The multi-step slice of expected free energy. Scoring one policy over a horizon was already
here; this release adds searching over a policy family and certifying that the search was
exhaustive, which is what the crossover measurement needed. The headline is H* = 7: the
horizon at which the best plan on the coupled-tree cue task stops being a direct reach and
becomes a two-phase sense-then-commit walk. Every horizon is a complete enumeration, so the
flip is decided rather than sampled.
Nothing on the H = 1 path moved. The existing suite passes unmodified and the one-step arithmetic stays byte-identical to 0.4.3.
examples/crossover_horizon_figure.py puts the same crossover on the flat Kalman/EFE
route, with a frozen-R twin as its control. examples/ffg/cue_maze.py builds the cue
task for any number of dimensions. About 390 lines duplicated across eight scripts moved
into examples/gallery.py, and every committed asset re-renders byte-identical across the
move. Three example checks that could exit zero on a failure are now gated in
tests/test_example_checks.py. Two demos were removed.
Added¶
cpomdp.enumeration— the exhaustive search family, deliberately a separate object fromEFESelector's continuous grid so the two warrants cannot be confused.FiniteActionSetis versioned, so an action added after results are seen shows up in the diff rather than in the prose.EnumeratedEfeSearchenumeratesA^Hand returns the argmin policy with the fullGvector.CompletenessCertificateasserts expected against visited and raisesIncompleteEnumerationErroron a mismatch, which is what earns the search aPROVEDwarrant where a grid earns onlyCORROBORATED(ADR-030, ADR-031).EnumeratedEfeSearch.over_backendscores enumerated policies on an FFG backend instead of a flat model. Scoring is injected as a strategy, so the enumeration, the certificate and the cost accounting are shared rather than duplicated. A reduce-to-flat oracle gates it: on a coupling-free backend it reproduces the flat search exactly (ADR-034).RecedingHorizonSelectorandOpenLoopSelector, bothActionSelectors wrapping the enumerated search. They stay separate because the two modes genuinely differ, and a measurement has to declare which one it used. Both reportcost_per_plan = |A|^H · Hand areplan_interval. Neither reports the grid'sn_candidates × horizon, which would under-count the real work by a factor of|A|^(H−1).policy_efe_trace, returning aPolicyEfeTrace: the per-stepg, pragmatic, epistemic, μ⁺, Σ⁺, Σ_post and S that the rollout scan already computed and then discarded. It sits beside the hot path rather than in it, so the selector stays allocation-free. Its sums equal the scalarspolicy_efereturns underassert_array_equal, which is the proof that it is the same arithmetic and not a second implementation.policy_efe_ffgandpolicy_efe_ffg_trace, the H-step rollout on a coupling graph, with the epistemic term aimed at a named node at the coupling-resolved μ⁺. Gated from both ends: at H = 1 it is byte-identical to the one-step FFG kernel, and with no coupling it matchespolicy_efe(ADR-032).cpomdp.crossover—crossover_statistic,crossover_horizonandCrossoverStatistic. The horizon aggregation is the symmetric between-policy contrastΔG = Δc − Δε. At H = 1 it collapses to the anchorsΔε = 1.7232,Δc = 4.4910,ΔG = +2.7678nats, pinned at a tolerance of1e-4(ADR-033).diagnostics.rollout_conditioning— per-stepcond(Σ⁺),cond(S),cond(Σ_post), the minimum eigenvalue of the posterior covariance, and a positive-definiteness flag, computed host-side. ThreeSre-inversions underR(x)against a contracting Σ is where orders of magnitude go missing quietly. The eigenvalue bar is an assertion and not a clamp, because a clamp launders the failure it was meant to catch.examples/ffg/crossover.py— the crossover measurement, gated. It prints the exhaustive argmin per horizon, the mechanism split (disclosed as post-selection, because the pair it scores was found by the search), an independent NumPy kernel on the headline number, the counterfactual showing the flip is epistemically driven, and the conditioning against its registered bars.examples/ffg/crossover_sweep.py— the constant-action null. A constant walk overshoots the cue and never exploits, so the constant family cannot express the two-phase plan at all. That null is the reason the exhaustive search is necessary, which is why it ships as a check rather than as a footnote.-
A
slowpytest marker for the exhaustive gate. Pull requests run without it; merges to main and releases run with it. -
examples/crossover_horizon_figure.py— a showcase of the epistemic/pragmatic crossover on an open plane, and the first demo of it on the flat Kalman/EFE route rather than the factor graph. An augmented statex = (p, g)carries a position the control moves and a static latent goal it does not.R(p)makes the channel that readsgsharp only near a beacon off the direct path. The animation runs the same world once per planning horizon: at shortHthe agent settles where its prior said without ever checking, and past a crossing it detours to the beacon, finds out it was wrong, and goes to the real goal. Nothing changes butH. The marginΔG(H) = G(detour, H) − G(direct, H), in nats, crosses zero exactly once, because the epistemic pull is flat while the pragmatic gradient decays under it. A frozen-R twin re-runs the whole sweep withRpinned and never crosses, so the behaviour is attributable to the state-dependent sensor and the horizon together rather than to the beacon-and-goal geometry. Under a fixed sensor the epistemic term is the same for both plans at every horizon, so it drops out of the margin. The term itself stays nonzero and grows withH(ADR-003). Renders the animation plus a three-panel companion.--checkprints the sweep and asserts what the figures claim, gated intests/test_example_checks.py. Its crossing lands atH = 7, which is the same integer as this release's registeredH*and is unrelated to it: different model, different backend, whole-state epistemic rather than node-restricted, and no search at all. The demo says so in its own output, and neither number should be quoted as the other. examples/ffg/cue_maze.py. The cue-maze task, built for anyn_dims >= 1. A hidden context decides which of two goals pays, the prior points at the wrong one, and a cue elsewhere in the arena reads the context sharply while the agent stands on it.axis_action_setgrows as2·n·|magnitudes| + 1rather than as a product grid, so two dimensions cost exactly what the corridor did.enumeration_costprices a sweep before you commit to it, andbest_reachable_noisecatches the failure that motivated it. A cue the action lattice cannot land on is one no policy can read, and the null that produces is indistinguishable from "information is never worth the detour". It is pure geometry.examples/gallery.py. The shared machinery the demos were each copying: three palettes, the GIF writer, the figure and still writers, the bacillus glyph as a style object, the covariance-ellipse geometry, the EFE sweep and decomposition panel, the action grid, and the two-route agreement report. Roughly 390 lines of duplication removed. Eight scripts touched. Every committed asset re-renders byte-identical to its pre-refactor baseline.tests/test_horizon_dimensions.pybuilds its arena fromcue_mazerather than inlining a copy of it.tests/test_cue_maze_parity.py.cue_maze.build_maze(1)is claimed to reproduceepistemic_dissociation_figure.build_backend(cue_x=CUE_DETOUR_X)element for element, and the registered crossover is measured on the latter. The claim held on two files kept in sync by hand. Now it holds on dims, root, A, Q, B, the coupling and its noise, C, andR(x)sampled the length of the corridor rather than at one point. The frozen twins are excluded, because they genuinely differ andcue_maze.pysays why.mkdocs_hooks.py. Expands--8<--snippet includes and rewrites the relative links inside them to site-local targets, soexamples/README.mdcan carry links that work both on GitHub and on the published site without hard-coding either.tests/test_docs_hooks.pygates it, dead links included.
Changed¶
EFESelectornow reports awarrantofCORROBORATED. It samples a continuum, so it can corroborate a universal over the action space but never decide one. The enumerated search'sPROVEDprints in different words for exactly that reason.-
examples/ffg/crossover.py --checkreports the four registered falsifiers one line each, in three-valued outcomes, rather than closing on a singlePASS. A falsifier that is void by construction did not pass a test, and one the gate skips now says so instead of borrowing the write-up's answer (ADR-029). -
The README's crossover section says what the horizon result is for. It now cites arXiv:2607.20306, which establishes that
R(x)makes the epistemic term non-constant, and separates that from the question this repo answers: the horizon at which the term starts changing which plan gets picked. The answer given is the one-dimensionalH* = 7, with its warrant stated (exhaustive over the declared{0, ±1, ±2}, completeness certificate, upper bound because the grid clips the reach), and the plane figure is labelled as the readable illustration rather than the proof.examples/README.mdcarries the same distinction at length. examples/bacillus_uncertain_food.pyis gated.check_backend_agreement—KalmanBackendagainst the FFGChainBackendon one scripted input sequence,atol=1e-7— now runs intests/test_example_checks.py. The README's flagship had no test of any kind before this.gallery.precision_fieldsamples the model's ownnoise_fninstead of re-deriving the well from its parameters, and takes the channel to read as a required argument. It had been asserting the sensor was aCallableSensorand then ignoring it, so on the flagship it drew the right channel by coincidence of how that model'sblock_diagis ordered. A demo with a different layout would have got a silently wrong figure that still passed the assert. Onevmapreplaces theres²Python-level calls, so the field is also cheaper to draw. The flagship's field agrees with the old computation to 2e-15, float noise from thevmapand nothing else, well under the width of a contour band.gallery.print_two_route_agreementischeck_two_route_agreementand raises on a disagreement. It printedFAILand returned, and neither caller was gated, so a broken equivalence would have exited zero.coupling_graph_figure.checkandchemotaxis_figure.checkare public and run intests/test_example_checks.py.DECISIONS.mdstates at the top that a path inside an ADR is the path as of that ADR's date.examples/bacillus_seeking_food.pyandexamples/bacillus_lqr.pyare named by ADR-008 and ADR-013 and no longer exist, which the append-only rule requires and which the file previously left the reader to work out.mkdocs_hooks.pyskips a fenced block wherever it is indented, and skips inline code spans. Both were anchored at column zero, so a fence inside a numbered step, which is ordinary markdown, had its links rewritten and reported dead, andmkdocs build --strictaborted the docs deploy on a correct README edit. Reference definitions ([label]: path) are now repointed and dead-link checked too. They resolve to real anchors and previously escaped both, so the repository's layout shipped to the site. A four-space indented code block is still not recognised, and the module docstring says so and says why: the same pattern matches four-space list continuation, so closing it would trade a loud build failure for a silently wrong link. All four included sources rewrite byte-identically across the change.cue_maze.enumeration_costtakescontext_dim. It hardcoded a joint width of1 + 2·n_dimswhilebuild_mazebuildscontext_dim + 2·n_dims, and the estimate is quadratic in that width, so a two-wide context was priced 1.44x low and a four-wide one 2.56x low, on top of the 1.6x floor the docstring already warns about, against a number whose whole job is to stop a sweep taking the machine down.tests/test_horizon_dimensions.pyrecovers the width the estimate charged for and pins it to the built backend's ownn_totalacross five(n_dims, context_dim)pairs.- The
slowpytest marker is a wall-clock rule now, not a description of one test. Anything pastconftest.SLOW_TEST_SECONDS(20s) carries it and runs on merge-to-main and release rather than on pull requests.conftest.pymeasures each run and prints any unmarked test that has drifted over, so the rule does not depend on anyone remembering it. One test crosses the line and is newly marked:test_double_integrator_horizon.py::test_h2_choice_matches_brute_force_oracle(~21s). Marks are decided on an isolated run on the owner's development machine, whichconftest.pynow records as the reference. Wall clock is machine-relative, and without one the same suite marks differently per reviewer. Pull-request coverage is unaffected at 93.8% against the 80% gate.
Removed¶
- The
## The journeysection ofexamples/README.md, and with it the gallery entries forbacillus_seeking_food.py,bacillus_lqr.py,efe_collapse_figure.pyandinternal_noise_figure.py.efe_collapse_figure.pyandinternal_noise_figure.pystay, keep theircheck()gates, and keep their figures, whichDECISIONS.mdstill cites.docs/assets/bacillus.gifanddocs/assets/bacillus_lqr.gifhad no other referrer and go with the section, 4.2 MB of them. examples/bacillus_lqr.py. Every claim it demonstrated is asserted elsewhere and more strongly: the LQR reduction at exact array equality intests/test_agent.py::test_state_goal_path_is_byte_identical_to_lqr, and the epistemic collapse three times over intests/test_efe.py,tests/test_theorem.pyandefe_collapse_figure.py's gatedcheck(). Nothing imported it.examples/bacillus_seeking_food.py. It had stopped being a demo and become an undeclared dependency:bacillus_uncertain_food.pyimported its beacon falloff, its palette, its glyph and its precision field at module scope, so the README's flagship could not run without it and no test would have said so. The shared parts are nowgallery.beacon_noise,gallery.precision_fieldandgallery.SMALL_BACILLUS; the beacon geometry moved into the flagship, which is the only thing that used it. The flagship's GIF re-renders byte-identical to the committed one across the move. Its own claims were already asserted intests/test_agent.py,tests/test_efe_selector.pyandtests/test_efe.py, whoseTestPrecisionControlsBalancewas written for it. ADR-008 and ADR-013 still name the path;DECISIONS.mdis append-only, so those references stand as the historical record.
Deferred and unsupported¶
Named here so each boundary is a statement rather than an omission. Each has an issue.
- A closed-loop
FfgEfeSelectorabove H = 1 stays unsupported, not untested. The rollout it would need now exists and the FFG-backed drivers would make it nearly free, but nothing in scope uses it. (#52) - No pruning of the
|A|^Hsearch. A pruner that cannot prove what it discarded would destroy the completeness certificate, which is the only reason the search is worth having, so it waits for a design that starts at the certificate. (#53) - No continuous or gradient search over varying sequences. This one is a deliberate wall: gradient ascent on a non-convex objective corroborates and cannot decide, so letting it near the enumerated evidence would downgrade the certificate that evidence exists to earn. It is registered as a separate, separately-labelled track. (#54)
Q(x)above H = 1 remains undocumented and unclaimed. It may work; nothing says so and nothing tests it. (#56)
[0.4.3] — 2026-07-27¶
Closes the code-side divergences found by auditing the library against the paper written
about it (PAPER_DIVERGENCE.md). The paper's own quoted numbers are unchanged and both
demonstrations' --check gates still pass; the paper-side items are tracked separately.
Added¶
cpomdp.diagnostics— the conditions a state-dependent sensor has to meet, asked over the states an action can actually reach.probe_model(model, belief, actions)predicts under each candidate action and reports aSensorReport: whetherChas full row rank, whetherRstays positive definite, whetherRmoves at all across the reachable means, and whether the epistemic value moves with it.is_positive_definite,epistemic_valueandloewner_orderare exposed alongside for use on their own.probe_modelandSensorReportare re-exported at the top level. A declaredR(x)that no action can move now has something that says so.tests/test_theorem.py— one test per published claim: the pinning result and its rank-deficient counterexample, the dual effect on both the posterior covariance and the gain, the impossibility of a fixed noise schedule, the scalar monotonicity, the zero-innovation realisation, the planning-reduction equivalence, the level-set construction where the covariance moves but the epistemic value does not, and the worked one-step numbers (gains 2/3 and 2/7, ½ln3 and ½ln(7/5)).tests/test_diagnostics.pycovering the new module.examples/efe_collapse_figure.py --checknow also asserts the dual effect directly — the posterior variance and the Kalman gain moving across the action grid — rather than only the epistemic scalar that rides on it.
Fixed¶
- The FFG epistemic term had no positive-definiteness guard.
_efe_stepmapped a non-positive-definiteRto NaN so it would lose the selector's argmin;_state_info_gain, the term the coupling-graph path actually scores, discarded the determinant signs and returned a finite, plausible-but-wrong value that could win. Both now share one_logdet_pdhelper, which tests positive definiteness by Cholesky rather than by determinant sign — the sign passes any matrix with an even number of negative eigenvalues,diag(-1, -2)among them. CouplingGraphBackend.modelhanded out a frozenR. For a state-dependent graph the flat model carried the noise evaluated once at a representative state, so anything filtering with it silently used the wrong noise at every step. The model now carries a_JointObservationthat linearizes each node'sRat that node's slice of the mean, the same point the backend's own filter uses.Agentcould not see a graph's state-dependence. Because that flat model declared no observation model, a coupledR(x)backend read as a fixed sensor and aStateGoalwas accepted onto it, silently selecting actions with the certainty-equivalent LQR controller. It now raises, as it always did on the flat path.- The theorem's own model class could not be flattened. A single chain with no
couplings raised
NotImplementedError; it now emits a faithful state-dependent flat model, verified to reproduce the native filter to ~1e-16. It is a state-dependent model, not a fixed one — no fixed one exists. examples/ffg/epistemic_dissociation_figure.pynumbered its first two results the opposite way round to the write-up, and printed them out of order. Result 4 asserted only that the best action B rates below the pragmatic-only agent is cue-ward; it now asserts the claim actually made, that every such action is.
Changed¶
- The
IncompatibleLinearizationErrormessage gains a closing sentence: the guard reads the sensor's declared state-dependence, and a declaredR(x)that ignores the state is constant in fact and does flatten. The original two sentences are unchanged, so the message is now a strict extension of what came before. WhetherRreally varies over reachable means is whatcpomdp.diagnostics.probe_modelis for. - Trimmed prose in
src/that had gone stale: references to anrfcs/directory that does not exist, sensors and selectors described as arriving in a release that has shipped, and the unqualified claim that the predicted covariance is action-independent — true of a single step, false over a horizon underR(x)orQ(x).
[0.4.2] — 2026-07-18¶
Archival release accompanying the paper. No library code changed — the public API and numerics are identical to 0.4.1. The changes are in the example check suite, which the paper's reproducibility claims quote, and are now gated in CI.
Added¶
- A single-chain theorem-instance check on the EFE-collapse demo
(
examples/efe_collapse_figure.py --check): the model class of the paper's section 3.1 — one node, no couplings, additive control, a state-dependentR(x)sensor — run through the flatKalmanBackend(CallableSensor)route, asserting Theorem 1 clauses (i) and (iii): the one-step epistemic value varies across the action grid, its frozen-Rtwin is flat (the ADR-003 collapse), andR(μ⁻)traces a curve. Runs with no plotting deps. tests/test_example_checks.pyruns both demos'check()gates in CI, so their paper-facing assertions fire on every test run rather than only when the script is run by hand.
Changed¶
- Strengthened Result 4 of the epistemic-dissociation demo
(
examples/ffg/epistemic_dissociation_figure.py --check):_boundary_scannow also returns the pragmatic term, and Result 4 prints and asserts the horizon-1 orderingepistemic pull < pragmatic gradient— the "reaches without walking" claim, previously an unasserted diagnostic number, is now a checked fact.
[0.4.1] — 2026-07-13¶
Maintenance release cut for the paper-1 Zenodo archive. No library code changed — the public API and numerics are identical to 0.4.0.
Changed¶
- Reworked the epistemic-dissociation flagship figure
(
examples/ffg/epistemic_dissociation_figure.py): renamed the candidate action touto stop it clashing with "Agent A", moved theR(mu+u)label into a clean gap, and added paper-facing assertions that pin Result 2 at the geometry where only B's edge over A survives. Regenerateddocs/assets/epistemic_dissociation_boundary.png.
[0.4.0] — 2026-07-06¶
Forney factor-graph (FFG) message passing — the continuous-state generalisation of the Kalman/EFE path to a branching model the chain cannot draw cleanly (ADR-012, ADR-014). The chain is the degenerate case and stays validated byte-numerically against the Kalman filter.
Added¶
- FFG message passing in canonical/information form, owned from-scratch in JAX, reachable
under
cpomdp.ffg.*(the model-construction symbols are also re-exported at the top level — see the public-surface entry below, ADR-021): CanonicalGaussian(cpomdp.ffg.message) — the(Λ, h)message payload; factor product is addition, marginalisation a Schur complement, moment form a readout view.- Tier-1 linear-Gaussian factor nodes (
cpomdp.ffg.factors.linear_gaussian):GaussianObservation,GaussianTransition,GaussianCoupling. ChainBackend(cpomdp.ffg.chain) — anInferenceBackendover a linear chain, numerically identical toKalmanBackend(atol 1e-7, the keystone gate), including state-dependentR(x)/Q(x)parity.CouplingGraph(cpomdp.ffg.graph) — the v0.4 core representation: integer-indexed nodes coupled into a rooted tree with a shared node of degree > 2, inferred by a hand-authored deepest-first tree-collect schedule (only the root crosses moment form, so the inversion cost is per-root, not per-node).levels()is deferred (ADR-015).CouplingGraphBackend(cpomdp.backends.coupling) — the branching FFG as a recursive Gaussian filter satisfyingInferenceBackend(issue #25): driven-relaxation composition (each node's own dynamics + its structural parent's drive every slice, ADR-017), the exact full-joint carry (ADR-016),marginal/readoutfor any node, andto_flat_model(structural couplings as within-slice pseudo-observations). Validated at atol 1e-7 against an independent NumPy joint-precision oracle,KalmanBackend, andRxInferBackendon the flattened model.- Carry partition on
CouplingGraphBackend(ADR-016): apartitionof the node set controls which between-cluster correlations survive the time boundary —[[all]](the default) keeps the exact full joint, singletons fully factor it. The carry factors the joint covariance (not the precision), so every node's marginal stays exact within a slice (ADR-017); only the cross-cluster correlation carried forward is dropped. A purepartition_errorreports the severed mass (a covariance magnitude, not a rate), androlloutprofiles it over a whole sequence in one tracedlax.scanpass. A fully-factored (singleton) partition runs cheap two-pass tree belief propagation (CouplingGraph.infer_all, plusGaussianCoupling.message_to_childandCanonicalGaussian.__sub__) in place of the dense joint solve — O(tree) rather than O(n³), matching the dense path to atol 1e-7. The exact endpoint stays byte-identical under[[all]]. - State-dependent sensing
R(x)on the branching backend (issue #27, ADR-019): aCallableGaussianObservationfactor and a two-pass linearisation that readsRat the coupling-resolved predictive mean μ⁺ — the dual effect (observation_noise_at,predicted_belief). FfgEfeSelectorscores the per-candidate epistemic term over the FFG, so anAgentwith anObservationGoal(info_target=…)seeks information through a branch.severed_efe_edges/InadmissiblePartitionErrorreject a carry partition that would sever an EFE-relevant edge (ADR-018).IncompatibleLinearizationError(cpomdp.backends.coupling):to_flat_modelraises it when a coupledR(x)model is asked to flatten. A mean-shifting coupling makes μ⁺ ≠ μ⁻, so no fixed linear-Gaussian model reproducesR(μ⁺)(ADR-019, ADR-020).- Public surface (ADR-021): the branching construction symbols (
CouplingGraph,Coupling,CouplingGraphBackend,IncompatibleLinearizationError, the four Gaussian factors) and the selector family (ActionSelector,EFESelector,FfgEfeSelector,LQRSelector) now sit in the top-levelcpomdpnamespace.FfgEfeSelectorgainsEFESelector'sn_candidates/horizon/cost_per_cycle. - Examples:
epistemic_dissociation_figure.py, the v0.4 flagship — two agents on one maze, differing only in a state-dependent vs. fixed cue sensor; theR(x)agent resolves the hidden context through a branch and crosses to the reward, the fixed-sensor agent collapses to LQR, and theR(x)model can't be flattened (IncompatibleLinearizationError).bacillus_uncertain_food.py, the instrumental-epistemics demo — the beacon resolves an explicit food latent rather than the agent's own position (ADR-013), on bothKalmanBackendandChainBackend.coupling_graph_figure.py, the difference demo — a branching tree resolved natively vs. the hand-flattened joint precision.chemotaxis_figure.py, the same on a real branching network (the E. coli chemotaxis pathway's shape, native FFG vs flattened Kalman) — illustrative only, no biophysics (ADR-020).
Validation¶
- RxInfer oracle (behind the
rxinfermarker) extended to the branching tree, alongside the existing chain checks; jit/grad/vmap smoke tests gate every new public inference entry point.
[0.3.0] — 2026-06-22¶
Epistemic action selection. v0.2 could perceive and pursue a goal; v0.3 lets the agent seek information — it minimises Expected Free Energy G = pragmatic − epistemic, so it will detour to where its sensor is sharp, localise, then act. Under a fixed sensor this collapses exactly to the v0.2 LQR behaviour (ADR-003), so that path is unchanged.
Added¶
- Expected Free Energy action selection:
expected_free_energy(the one-step kernel) andEFESelector(a front-loaded candidate grid,argmin G). Observation-space cross-entropy pragmatic minus state info-gain epistemic (ADR-005). - State-dependent sensing and dynamics:
CallableSensorforR(x)andCallableProcessNoiseforQ(x), honoured in both the planner and the Kalman filter (ADR-006, ADR-008). The fixed-matrix path stays byte-identical. - Typed objectives:
StateGoal(state-space → LQR) andObservationGoal(observation-space → EFE). TheAgentdispatches on the objective's type, so an illegal pairing is unrepresentable rather than a runtime check (ADR-007). - H-step horizon:
EFESelector(horizon=H)scores constant-action policies over an H-step rollout (defaultH=1), making delayed consequences visible — e.g. acting on velocity to steer an observed position (ADR-009). ModelStructure: optional, declarable factor / Markov-blanket / sensory-channel metadata on a model, with inspection and an opt-in, experimentalvalidate()that checks the declaration against the matrix sparsity (ADR-010).- Public building blocks for the above:
ObservationModel,FixedSensor,DynamicsNoise.
Changed¶
- Breaking (pre-1.0):
Agenttakes a typed objective instead ofgoal=/goal_precision=keywords.Agent(model, goal=[1.0, 0.0])becomesAgent(model, StateGoal([1.0, 0.0])); observation-seeking usesObservationGoal(...)(ADR-007).
[0.2.0] — 2026-06-16¶
The array backend moves from NumPy to JAX (ADR-004). v0.1 was a proof of concept; this is the groundwork for the autodiff and batching v0.3 is aiming at.
Changed¶
- The core runs on
jax.numpy.BeliefandLinearGaussianModelnow holdjax.Arrays, and the Kalman filter and LQR are purejnp. If you were reaching past the public API and expectingnumpy.ndarrayoffbelief.mean, you'll get ajax.Arraynow — both still hand off to NumPy, so most code won't notice. - Importing
cpomdpswitches JAX into float64 mode (jax_enable_x64) process-wide. The library is validated to 1e-9 against the RxInfer oracle and JAX defaults to float32, so this keeps the numbers right — but it does change float behaviour for any other JAX code in the same process.
Added¶
BeliefandLinearGaussianModelare registered JAX pytrees, so they flow throughjit,vmap, andgradas data.- The Kalman step is split into pure,
jit-compiled kernels — one filter step nowvmaps over a batch of beliefs.
Dependencies¶
- Added
jaxandjaxtyping. NumPy stays: JAX pulls it in anyway, and the RxInfer backend still hands real NumPy arrays across the Julia bridge.
[0.1.1] — 2026-06-15¶
A metadata-only re-release, functionally identical to 0.1.0. The 0.1.0 release has been removed from PyPI, so use 0.1.1.
Changed¶
- Trimmed the author entry in the package metadata.
- README: the DECISIONS.md link is now an absolute URL, so it resolves on PyPI.
[0.1.0] — 2026-06-15¶
The first cut. Linear-Gaussian active inference, end to end: perceive with a Kalman filter, act with LQR, all behind a pymdp-style Agent.
Added¶
Agent, the stateful façade you actually drive.infer_statesto perceive,sample_actionto act, the same loop pymdp users know. It remembers the last action it took, so you don't have to thread that back in by hand. Build it without a goal and it's a pure tracker that perceives but won't act.LinearGaussianModel, the generative model. Matrices are named for their role (dynamics,control,sensor_model,dynamics_noise,sensor_noise), with the control-theory letters (A/B/C/Q/R) kept as aliases for when you're reading the maths.Belief, an immutable Gaussian belief: a mean and a covariance, validated on the way in.KalmanBackend, exact Kalman filtering. Has an optional steady-state mode that solves the gain once up front and reuses it.LQRController, steady-state LQR action selection. For a linear-Gaussian sensor this is the expected-free-energy-optimal action rather than a stand-in for it (the why is in DECISIONS.md, ADR-003).RxInferBackend, an optional RxInfer (Julia) backend. It re-derives the same filtering results through completely separate machinery and exists as a correctness oracle for the native path. Lives behind therxinferextra so the core install stays Julia-free.InferenceBackend, the protocol the backends satisfy, so you can drop in your own engine.
This is pre-alpha. The API works and is tested against the RxInfer oracle, but it can still move before 1.0.