mixle.utils.immutable module¶
An immutable stand-in for a value a receipt must record but cannot convert.
Several receipt/snapshot paths recursively convert a payload into immutable form: lists become
tuples, dicts become MappingProxyType, arrays are copied and sealed. A custom object has no
immutable counterpart to convert into, and those paths used to fall back to copy.deepcopy plus a
warning when the copy failed or returned the original. That fallback fails the contract three ways
at once (MXR-080-1872):
copy.deepcopyinvokes__deepcopy__/__reduce__, which is caller-defined code. Running it is a side effect, and a forensic snapshot is exactly the wrong place to have one.a
__deepcopy__that returnsselfrestores the alias the snapshot exists to sever, and a warning does not un-alias it.even a successful deep copy is still a mutable object, so what the receipt retains can be edited through it.
opaque_snapshot() replaces the copy with a record: the object’s type and its own recorded
attribute state, deeply frozen by the caller’s freezer. Reading __dict__ and __slots__ runs
no dunder hook, the result shares no mutable structure with the caller, and it is immutable. What is
lost is the object’s behavior – the snapshot is evidence about the value, not a substitute for it.
That is the honest trade: a payload with no immutable counterpart was already out of contract for the
paths that call this.
- class OpaqueSnapshot(type_name, module, state=None)[source]
Bases:
objectAn immutable, non-aliased record of an object with no immutable counterpart.
stateisNonewhen the object exposed no readable attribute state, or when it was reached through a reference cycle and has already been recorded higher up this walk.
- detach_receipt_container(value)[source]
Copy a receipt’s containers, preserving their concrete types.
The weaker sibling of
freeze_receipt_container(), for fields whose type is load-bearing. It severs the caller’s alias – the half of the defect that lets a mutation after construction rewrite a recorded decision – without convertingdicttomappingproxyorlisttotuple.Use it where the concrete type is observable: a field that is content-addressed or serialized with type fidelity (
mixle.epistemic.journaldeliberately distinguishes a tuple from an equal-valued list, and hashes what it stores), a field a consumer type-tests withisinstance(x, dict), or a field on a record that gets pickled –mappingproxycannot be pickled or deep-copied. Applying the stronger freezer to those fields broke the journal’s hash chain and a training receipt’s own validation (MXR-080-1876).The receipt’s own field stays writable, which is a real and stated limitation: this defends against the caller, not against the holder.
- freeze_receipt_container(value)[source]
Detach and seal the containers in a receipt field, preserving element identity.
This is the structural half of receipt integrity, and it is the one the aliasing defect actually needs: a caller hands a
dictorlistto a frozen dataclass, the dataclass stores the reference, and the caller mutates its own copy afterwards – rewriting a decision that had already been recorded. Copying severs that alias; the read-only view stops anyone editing the receipt through the field itself.Elements are passed through by identity rather than judged. A receipt may legitimately hold a frozen dataclass, a distribution, or an array, and refusing those would reject the receipts the library actually produces. Where a receipt additionally advertises JSON-serializable output, the stricter per-module freezers keep that separate contract – this one does not claim it.
Mapping -> MappingProxyTypeis deliberately chosen over a plain copy because it still compares equal to the original dict, so consumers that compare receipts keep working. Sequences become tuples, which does NOT compare equal to a list; call sites converted to this helper were checked for consumers that mutate or type-test the field (MXR-080-1876).
- is_immutable_atom(value)[source]
Whether
valueis already immutable and can be retained as itself.A freezer must ask this before falling back to
opaque_snapshot(). The recursive freezers that call it enumerate the containers they convert and reach the fallback for everything else, which previously routed throughcopy.deepcopy– and deep-copying anintreturns theint. Replacing that fallback without this predicate turned every scalar the container branches did not name into a snapshot of itself.Membership is by EXACT type, against a closed set (MXR-080-1880). The predicate has to fail in the safe direction on both sides, and an
isinstancetest failed in the unsafe direction on one and the destructive direction on the other:too permissive –
numbers.Numberis an abstract base class, soclass Sneaky(Number): self.payload = []answered “immutable” and was retained by reference, leaving the caller’s mutable object aliased inside a receipt that claims not to be.too destructive –
UUIDmatched nothing in the old tuple, so an ordinary immutable domain value became anOpaqueSnapshot, changing what a fully observed rollout draw contains.
NumPy scalars are admitted separately below: they are genuinely immutable, and
np.genericis a concrete leaf hierarchy rather than a registrable ABC, so it does not carry the same risk. An enum MEMBER is admitted when its value is itself an atom –Enumalone is not enough, since a subclass may attach mutable state to its members.
- opaque_snapshot(value, freeze)[source]
Record
valueas an immutable snapshot, freezing its attribute state withfreeze.freezeis the caller’s own recursive freezer, so nested containers are converted by the same rules the rest of that payload was, and a nested custom object lands back here.