mixle.epistemic.journal module¶
EpistemicJournal – an append-only, replayable decision log for a sequence of loop steps.
The program plan’s decision journal (§3.4: “timestamp, model version, belief snapshot hash, options
considered with their EIG/cost/risk scores, chosen action, rationale claims…”), following
mixle.evolve.ledger.EvolutionLedger’s existing shape (flat, JSON-serializable, append-only,
no model objects stored directly) rather than inventing a third bespoke logging pattern.
One deliberate refinement over a hash-only ledger: each record carries the full serialized belief
snapshot (portfolio_snapshot, from to_dict())
alongside its content-address (belief_snapshot_hash). A hash alone cannot be reversed back into a
portfolio, so “an auditor can reconstruct the full belief trajectory from the ledger alone” (program
plan §2) requires the snapshot content to actually be there; the hash is what lets verify()
catch tampering/corruption of that stored content, which is the property a bare append-only list
doesn’t get for free.
Type fidelity of the payload content within a snapshot (Hypothesis.payload, and the journal’s own
action_chosen/action_considered fields, both declared Any) is bounded by what JSON can
actually represent. Plain json.dumps/json.loads silently turns a tuple into a list –
a different Python value ((1, 2) != [1, 2]) that happens to render as identical JSON text, so a
hash computed over that JSON text cannot tell the two apart either. _tag_encode()/
_tag_decode() close this for the types it is small and safe to do so for: JSON-native values plus
tuple, numpy scalars (numpy.generic), and numpy arrays round-trip as themselves (see
_tag_encode()’s docstring for the exact list and the versioned tag format), and verify()
hashes this type-faithful encoding rather than JSON’s lossy shadow of it – a tuple silently replayed
as a list now actually fails verification instead of passing it.
Anything else (an arbitrary custom object – e.g. a fitted model used directly as a hypothesis payload,
which real callers such as mixle.task.discrepancy_invention_loop.run_discrepancy_invention_loop()
do) has no small, general, always-correct JSON encoding of its own; building one is a substantially
larger undertaking (already partially addressed, for the types that opt into it, by
mixle.utils.serialization) and out of scope for this journal. Such a payload is instead stored
as a one-way str() snapshot, with a UserWarning raised at journal time so the caller knows:
EpistemicJournal.replay() hands back that string, not the original object, and verify() for
such a record only attests that the string is unchanged – not that the original object would be
recovered. This keeps the journal usable for arbitrary hypothesis/action payloads without ever
silently overclaiming a fidelity it can’t deliver.
- 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