Reification
Reification converts a data instance back into a Python value. Together, relationalization and reification provide the following two-way process:
Python value --build_instance()--> atoms + relations + types + rootId
Python value <------reify()------- atoms + relations + types + rootId
reify() reconstructs the value represented by the selected root atom. It
uses atom types, labels, metadata, and relation tuples. The top-level types
table is not needed by the current reifier.
import json
from spytial import CnDDataInstanceBuilder, reify
shared = [7]
original = [shared, shared]
instance = CnDDataInstanceBuilder().build_instance(original)
restored = reify(json.loads(json.dumps(instance)))
assert restored == original
assert restored is not original
assert restored[0] is restored[1]
The reconstructed lists are new objects. The two references to the inner list still denote one object. The JSON step demonstrates that this example uses the recorded data rather than a hidden reference to the original lists.
Meaning of a round trip
Let F(v) be relationalization and G(D) be reification. For supported
values, G(F(v)) preserves the represented values and structure, subject to
the identity rules and limits below. It need not be the original object.
A round trip has several independent properties:
| Property | Question |
|---|---|
| Value | Are primitive values and container contents recovered? |
| Type | Does each reconstructed value have the expected Python type? |
| Structure | Are included fields, positions, entries, and memberships recovered? |
| Sharing | Do references to one recorded object still denote one reconstructed object? |
| Behavior | Does the result support the operations of the original value? |
| Reference identity | For a named reference, does lookup still return the same external object? |
Equality and repr() do not establish all of these properties. A file can
have the right type and still lack a working handle. A proxy can display the
original type name without being an instance of that type. NaN values do not
compare equal to themselves.
Both conversions can fail, and some successful returns lose information.
They are not universal inverses over all Python values or all data instances.
See Python type coverage for each standard hierarchy
category. This page describes the implementation at version 1.13.2,
source revision c1a9239.
Reconstruction procedure
- Index atom records by identifier. Group relation tuples by their first atom and relation name.
- Choose the root. Prefer a valid explicit
root_id, then a valid storedrootId. Otherwise, use the topology heuristic described below. - If an atom already has a reconstructed value, return that value.
- For a primitive type, parse or use its label. For a resolvable named reference, return the referenced object.
- Otherwise, use a registered custom reifier, a built-in container reifier, or the generic object reifier, in that order.
- Record the resulting value under its atom identifier and return it.
Mutable containers and generic objects register an empty object before processing their children. A back-reference then resolves to that object. Tuples and frozen sets are constructed after their children; they have no mutable placeholder. This distinction determines which cycles retain identity.
Reification groups relations by name, even when separate stored records have different identifiers. Built-in dictionary and list reifiers retain tuple boundaries. The generic object and custom reifier interfaces receive a flattened list of targets for each source and relation name.
Three reconstruction mechanisms
Rebuild a value
Primitives are reconstructed from their labels. Built-in containers are
reconstructed from idx, kv, contains, or the tuple-position relations.
Ranges use start, stop, and step. These mechanisms do not require the
original value to remain alive.
Primitive labels are therefore part of the stored value, even though they
also serve as display text. Changing an integer atom's label from "7" to
"8" changes the integer that reify() returns. Changing its identifier
alone does not change that integer.
Resolve a reference
Functions, class objects, modules, and enum members can carry verified import metadata. Capture verifies that the recorded module and name resolve to the original object. Reification resolves that name again.
This records a reference, not the object's implementation or state. The
receiving environment must provide the referenced definition. A name under
__main__ can work in the originating session without being portable to
another process. Lambdas, local definitions, and names already rebound at
capture time generally lack verified reference metadata and become proxies.
There is no verification against the original object at reconstruction time. If a name is rebound after capture, lookup can return its new value. A module's current globals and a function's current environment are used; their earlier state is not restored.
Allocate and populate an instance
For a generic instance, the reifier resolves the recorded class and calls
object.__new__(cls). It then uses object.__setattr__() to restore included
attributes. It does not call the class's constructor, __init__(),
__post_init__(), __reduce__(), or __setstate__().
This supports ordinary and slotted classes, frozen dataclasses, and many cycles. It also bypasses constructor validation and initialization. Native state, excluded attributes, and invariants established by the constructor may be missing. Property setters can still run during assignment. The generic reifier suppresses assignment exceptions and keeps the partially restored instance.
If the class cannot be resolved or allocated, the fallback is an attribute proxy. Its generated class has the original type name for display, but it does not inherit the original class or restore its methods and protocols.
Instance class metadata does not receive the identity verification used for reference atoms. If the recorded class name has been rebound, generic reconstruction can allocate an instance of a different class.
Known failures
Cycles through immutable containers
A tuple can participate in a cycle through a mutable object. If reconstruction starts from the tuple, the current reifier can create two tuples for its one atom:
from spytial import CnDDataInstanceBuilder, reify
items = []
original = (items,)
items.append(original)
restored = reify(CnDDataInstanceBuilder().build_instance(original))
assert original[0][0] is original
assert restored[0][0] is not restored # Current loss of sharing.
Starting from the list in this example closes the cycle correctly because the list is registered first. A frozen set containing an object that refers back to the frozen set has the same root-dependent limitation. A custom reifier that descends before registering a placeholder can instead recurse until it fails.
Primitive identity and type names
Distinct primitives with the same textual identifier are merged before
reification. Two distinct NaN keys in a dictionary can therefore become one
key. Subclasses can collide with base values: an int subclass holding 7
and a plain 7 share an identifier. The first collected atom determines
the type and label retained for both.
Reifier dispatch uses the atom's short type name. It does not infer a constructor from the stored type hierarchy. Consequently, primitive and container subclasses usually become proxies. A named tuple is included in this limitation. A user class whose name matches a built-in dispatch name can also select the wrong reconstruction rule. Custom reifier registrations for unrelated classes with the same short name share one registry key.
Missing state and unavailable behavior
The following losses cannot be repaired by the default reifier:
- Generic traversal omits class-mangled private attributes and selected callable or module attributes. Methods that depend on that state can fail.
- A function reference does not record code, defaults, or closure state. A bound method does not record its receiver/function binding.
- Generators, coroutines, frames, tracebacks, and code objects can produce inspectable attributes, but reconstruction does not restore execution.
- Files, buffers, and extension objects can require native state. Even a successfully allocated instance can be unusable.
- Generic properties describe observed values. A getter can raise during capture; a setter can reject a value during reconstruction.
These are distinct from outright exceptions. Receiving an object is not evidence that a round trip succeeded. The coverage tables state which paths return proxies or unusable values.
Ordering and incomplete data
The dictionary reifier inserts entries in stored tuple order. A direct round trip retains that order, but the relational meaning does not encode insertion order independently. A system that treats the relation as an unordered set and rearranges its tuples can change the reconstructed order. Set display order is not preserved.
Reification builds only from the selected root and the relations used by its reconstruction rule. Unreachable atoms and relations a rule does not use do not appear in the result. A build after reification therefore need not reproduce the original data instance or its identifiers.
Compatibility mechanisms and implementation shortcuts
These mechanisms can produce a value from incomplete or older data. They are not validation guarantees.
| Mechanism | Current behavior and consequence |
|---|---|
| Root inference | Without a valid root identifier, prefer a source that is never a target, then a source targeted only by itself, then the last atom. Multiple candidates and cycles make the choice ambiguous. An invalid explicit root identifier falls back rather than raising. |
| Primitive label parsing | Integers, floats, and complex numbers use their constructors; invalid labels can raise. Any Boolean label other than case-insensitive true becomes False. Bytes use ast.literal_eval() without a separate check that the result is bytes. |
| List indices | Convert indices with int(), skip invalid indices, sort, and append values. Gaps are compressed; repeated indices can add multiple elements. No original length is checked. |
| Tuple positions | Sort relations with numeric t suffixes. Missing positions are compressed, repeated targets can add elements, and unrelated names are ignored. |
| Dictionary keys | If assignment raises TypeError, retry using str(key). This can change key types or merge entries. A failing string conversion can still raise. |
| Legacy dictionaries | Relations other than kv are interpreted as older per-key relations: the relation name becomes a string key. |
| Tuple shape | Short idx or kv tuples are skipped; extra targets are ignored by their dedicated reifiers. The generic/custom interface instead flattens targets. |
| Missing range parameters | Default start and stop to 0, and step to 1. Non-integer parameters use these defaults; zero step becomes 1. |
| Generic field multiplicity | One target becomes one attribute value; several targets become a list. This does not recover the intended meaning of arbitrary n-ary relations. |
| Attribute assignment | The generic reifier suppresses all assignment exceptions. The returned object can therefore have missing fields. |
| Proxy fallback | A newly generated attribute class receives the original type name. It preserves captured attributes but not the original type or behavior. Its repr() has no cycle guard and can raise RecursionError. |
| Dataclass editor defaults | edit() registers special reifiers for the dataclass seed and dataclasses found through its field types. They apply declared defaults and factories before recorded fields. Plain reify() does not install this defaults pass. Required missing fields can remain unset. |
can_reify() |
Checks only a small part of the record shape. It does not check references, tuple validity, primitive labels, or whether the resulting value will work. |
Both traversal and reconstruction use recursion. The traversal has an explicit depth guard. Reconstruction can encounter Python's recursion limit independently.
Custom reconstruction
A custom relationalizer does not automatically define its inverse. Register a corresponding reifier on the builder used for reconstruction:
from spytial import CnDDataInstanceBuilder
class Cell:
def __init__(self, value):
self.value = value
def restore_cell(atom, relations, reify_atom, register):
cell = register(object.__new__(Cell))
cell.value = reify_atom(relations["value"][0])
return cell
builder = CnDDataInstanceBuilder()
builder.register_reifier("Cell", restore_cell)
restored = builder.reify(builder.build_instance(Cell(7)))
assert isinstance(restored, Cell) and restored.value == 7
register() stores the placeholder for the current atom before child
reconstruction. Use it when the type can participate in a cycle. The older
three-argument callback is still accepted, but has no placeholder callback.
The callback receives relation names mapped to flattened target identifier lists. It does not receive stored relation identifiers or tuple boundaries. Use a representation that this interface can reconstruct unambiguously.
Custom dispatch occurs after primitive parsing and successful reference
resolution. Registering a reifier for a built-in primitive type does not
override that earlier path. Registrations belong to the builder; the
top-level spytial.reify() creates a new builder and does not inherit them.
replit(D) is exactly repr(reify(D)). It is useful for inspecting a
reconstructed value, but inherits the failures above and is not a separate
serialization or validation mechanism.