Relationalization
Relationalization represents values as atoms and records their structure as
relation tuples. For example, if node.value is 7, the node and the integer
7 become atoms. The field name value names a relation containing the
tuple (node atom, integer atom).
Spytial performs this conversion before evaluating selectors and laying out a diagram. The data instance records structure. Constraints and directives control its presentation.
The reverse operation, reify(), reconstructs a Python
value from the data instance. The Python type coverage
tables describe both directions for every standard type hierarchy category,
including cases that lose information or cannot be reconstructed.
This page describes the current built-in conversion rules. A custom relationalizer can replace the rule for a value. The conversion does not promise a complete or lossless representation of every Python object.
What becomes an atom
Under the built-in rules, each value reached during traversal is represented by an atom, unless the traversal rules exclude that value. This includes:
- Primitive values: integers, floating-point numbers, complex numbers,
booleans, strings, bytes, bytearrays,
None,NotImplemented, andEllipsis. - Objects: dataclass instances and other object instances. The object itself is an atom; each included attribute value is also represented by an atom.
- Containers: lists, tuples, dictionaries, sets, frozen sets, and ranges. The container itself is an atom, including when it is empty. Its included contents are represented by further atoms.
- Dictionary keys: a key is a value and is processed in the same way as any other value. A string key becomes a string atom; a tuple key becomes a tuple atom with its own contents.
- List indices: each included position introduces an integer atom for its zero-based index. Tuple positions are encoded in relation names instead.
- Reference values: enum members, functions, class objects, and modules, when reached through a path that includes them. These are represented by leaf atoms; their internal contents are not traversed.
An occurrence does not necessarily create a new atom. Shared objects reuse
the same atom. Primitive occurrences with the same identifier also reuse an
atom. For example, [7, 7] produces one list atom, one atom for 7, and two
index atoms, 0 and 1.
Variable names and field names do not themselves create atoms. A type name classifies an atom; it does not create a separate atom for the class unless the class object is itself an input value.
What becomes a relation
A structural association contributes a tuple to a named relation. The association can be a field holding a value, an element occupying a position, a dictionary entry, or set membership.
The table specifies the built-in rules. Names inside tuple parentheses stand for the atoms representing those values.
| Python structure | Relation name | Tuple contributed |
|---|---|---|
An included field or attribute f holds a value. |
The field name f |
(owner, field value) |
A list has an element at index i. |
idx |
(list, index, element) |
A tuple has an element at position i. |
t0, t1, and so on |
(tuple, element) |
| A dictionary maps a key to a value. | kv |
(dictionary, key, value) |
| A set or frozen set contains an element. | contains |
(set, element) |
| A range has start, stop, and step parameters. | start, stop, step |
(range, parameter value) in each corresponding relation. |
For example, node.value = 7 contributes one tuple to the relation value.
If several objects have a field named value, their tuples belong to the
same relation under the built-in rules. A field name is therefore a relation
name, and a particular field value contributes one tuple to that relation.
A dictionary key does not become a relation name. For {"value": 7}, the
dictionary, the string "value", and the integer 7 become atoms. The entry
contributes a tuple to kv. It does not create a relation named value.
Primitive leaves, reference values, and values handled by the final fallback contribute no tuples of their own. They can still occur in tuples emitted by an enclosing object or container. A declared but unassigned field can also produce an empty relation, as described below.
Terms and notation
| Term | Meaning |
|---|---|
| Root value | The value passed to build_instance(). |
| Atom | A record with an identifier, a type name, and a display label. |
| Primary atom | The atom that represents a value being processed. |
| Auxiliary atom | An additional atom introduced by a conversion rule. List indices are an example. |
| Tuple | An ordered sequence of atom identifiers. |
| Arity | The number of positions in a tuple. A binary tuple has arity 2; a ternary tuple has arity 3. |
| Relation record | A record with an identifier, a name, and a set of tuples. |
| Relationalizer | The component that defines the conversion rule for a kind of value. |
Write the result of a successful conversion as:
relationalize(v) = (A, R, T, r)
Here, A is the set of atom records, R is the set of relation records,
T is the type information, and r is the identifier of the root atom.
Atom identifiers are unique within A. Relation identifiers are unique
within R.
In the rules below, a(v) denotes the identifier of the primary atom for
v. The notation f(a(v), a(w)) means that the tuple (a(v), a(w)) belongs
to the relation named f. It does not denote a Python function call.
A tuple contains references to atoms, not copies of their values. Each tuple
position refers to an atom in A. An atom can occur in several tuples and
in several positions of one tuple.
For a relation of arity k, its tuples form a subset of U^k, where U is
the set of atom identifiers. The storage format also permits one relation
record to contain tuples of different arities. Such a record has no single
arity.
Atom identity
Atom identity determines whether two references denote the same atom. Labels do not determine identity.
Objects and containers
By default, containers and other non-primitive objects use Python object identity. Two references to the same object use the same atom identifier. Two distinct objects use distinct identifiers even if they compare equal. For example, two separate empty lists produce two list atoms.
The builder assigns identifiers such as n0, n1, and n2 in encounter
order. It records an object's identifier before processing its contents.
A later reference to that object returns the recorded identifier without
processing its contents again. This rule preserves shared references and
permits cycles, including an object that refers to itself.
An object identifier assigned by Spytial annotations takes precedence over an automatically assigned identifier. An optional identity resolver can also map distinct objects to one atom. See Sequences for identity across successive builds. Automatically assigned identifiers in an ordinary build are local to that build; they are not persistent identifiers for stored data.
Primitive values
Most primitive values use identifiers derived from their textual value:
| Value kind | Identifier rule |
|---|---|
int, float, bool, complex, None |
str(value) |
str |
The string enclosed in double quotes. |
bytes |
repr(value) |
NotImplemented, Ellipsis |
str(value) |
Repeated occurrences with the same identifier produce one atom. For
example, two occurrences of the integer 7 refer to the atom with identifier
7. The string "7" has a different identifier, including the double quotes.
An integer used as a list index shares its atom with the same integer used
as data.
This rule is based on the identifier text, not Python equality. The values
True, 1, and 1.0 have different identifiers. Conversely, the identifier
does not include the type, so values with the same identifier text can
collapse to one atom. Distinct floating-point NaN values are one example.
If atom records have the same identifier, the builder retains the first
record collected.
bytearray is a primitive leaf for conversion but uses object identity
because it is mutable. Enum members also use object identity and are handled
before numeric or string primitives.
Structural details
A list rule also creates an integer atom for each included index. A tuple rule records the position in the relation name and creates no index atom. A dictionary processes both keys and values through the normal conversion rules. A range records its three parameters without enumerating its members.
Containers are atoms in their own right. If v.children is a list c, the
field produces children(a(v), a(c)). Each element of c then produces an
idx tuple. The field does not directly relate v to the list elements.
Tuple order is significant: the three positions of kv mean dictionary,
key, and value, in that order. The order of tuples within a relation has no
structural meaning. List and tuple positions are explicit in the rules;
dictionary insertion order and set iteration order are not encoded as
separate facts.
Attribute selection
The dataclass rule processes the fields returned by dataclasses.fields().
It includes fields whose names start with an underscore. An unassigned field
whose access raises AttributeError produces no tuple.
The generic object rule enumerates members with inspect.getmembers().
It can include inherited attributes, properties, and values exposed by
descriptors. It excludes dunder names, class-mangled private names such as
_Node__cache, methods, functions, modules, and built-in callables. A single
leading underscore does not exclude an attribute.
Attribute access can execute Python code. In particular, evaluating a property can have side effects or raise an exception. Relationalization is therefore not limited to reading stored instance fields. A function or module supplied directly, or through a container, can become a reference atom even though the generic attribute rule excludes it.
Absent fields, None, and empty containers
These cases have different representations:
| State | Representation |
|---|---|
A field holds None. |
A field tuple points to the atom with identifier None and type NoneType. |
| A declared field has no assigned value. | No tuple is emitted for that field on that object. |
| A field holds an empty list. | A field tuple points to a list atom. That list has no idx tuples. |
Dataclasses declare every dataclass field name. Generic objects declare included names from class annotations and slots throughout the inheritance hierarchy. If a declared name has no emitted tuples anywhere in the build, the builder creates an empty relation record with that name. Its default arity is 2. If another object populates the name, the existing relation already carries the declaration.
Containers and primitives declare no relation names. An empty list alone
therefore produces a list atom but no idx relation record. Declaring a
field records its name; it does not create a target atom from the field's
type annotation.
Conversion procedure
CnDDataInstanceBuilder.build_instance(v) performs the following steps:
- Clear the atoms, relations, visited-object map, and declarations for the new build.
- Process the root value. For each value, check the depth limit and refusal rules. If its object identity is already recorded, return its identifier.
- Select the first registered relationalizer whose
can_handle()method accepts the value. Relationalizers are checked in descending priority order. - Collect its declared relation names. Invoke its conversion rule. Built-in rules reserve the primary identifier before recursively processing child values, then return atom records and relation tuples.
- Collect the returned atoms and tuples. Use the first returned atom as the primary atom for that value.
- Group tuples by relation identifier and remove duplicate tuples within each group. Add empty records for unpopulated declared names.
- Remove duplicate atom records by identifier, retaining the first record. Build the type information and return the data instance with its root identifier.
The rules visit only values reached from the root through the selected relationalizers. They do not enumerate every object in the Python process. Custom relationalizers can introduce additional atoms and relations; they must keep tuple references consistent with the atoms they emit. A custom rule that follows cycles needs to reserve the primary identifier before recursion, as the built-in rules do.
The current walker is recursive. A build that exceeds its depth limit raises
RecursionError; it does not return a truncated instance. Lists, dictionaries,
and sets are iterated through local snapshots. This does not make the whole
build an atomic snapshot if the underlying objects change during conversion.
The walker omits nested Spytial infrastructure objects, such as sequence
recorders. A refused value produces no reference tuple. It is distinct from
the Python value None, which produces an atom. The active builder and its
internal collections cannot be used as the root.
Data instance format
The public entry point returns a Python dictionary:
from spytial import CnDDataInstanceBuilder
instance = CnDDataInstanceBuilder().build_instance([7, 7])
| Key | Contents |
|---|---|
atoms |
Atom records with id, type, and label. |
relations |
Relation records with id, name, types, and tuples. |
types |
Type records with id, types, atoms, and isBuiltin. |
rootId |
The identifier of the root atom. |
Each relation tuple contains atoms, an ordered list of identifiers, and
types, the corresponding atom type names. For a relation whose tuples all
have arity k, the record-level types contains k copies of "object".
It is not a declaration of the Python field types. For mixed arities, this
summary is an empty list.
Type records group atoms by their primary type name and record the type hierarchy. For ordinary objects, these names come from Python class names and the method resolution order. Type names are not qualified by module, so unrelated classes with the same name share a type name in the instance.
Labels are display text. Primitive labels normally use str(value);
bytes and bytearrays use a bytes literal. Lists use labels such as list[2].
Other built-in rules can use a variable name, a generated label, or a
reference name. Labels are not required to be unique or stable across builds.
For primitives, the label also stores the value parsed by reify(); changing
it can change the reconstructed value.
Atoms can also carry metadata for reconstruction, such as a class's module and qualified name. Reference atoms record an importable identity only when that identity resolves to the original value at build time. This metadata is separate from the relation tuples.
Relation identifiers and names
The built-in rules use the relation name as its identifier. All fields named
next, for example, contribute tuples to one next record, including fields
on different classes.
A custom rule can assign separate identifiers to records with the same name.
Selectors use the name and see the union of those records' tuples. Reusing
one identifier with different names raises ValueError. See
Relation identity for the custom
API.
Worked example
The following value contains a shared reference and a cycle:
from dataclasses import dataclass
from spytial import CnDDataInstanceBuilder
@dataclass
class Node:
value: int
next: object = None
node = Node(7)
node.next = node
root = [node, node]
instance = CnDDataInstanceBuilder().build_instance(root)
With a fresh builder and the built-in rules, the atoms are as follows. Labels and reconstruction metadata are omitted from this table.
| Identifier | Type | Meaning |
|---|---|---|
n0 |
list |
The root list. |
n1 |
Node |
The shared node. |
7 |
int |
The node's value. |
0 |
int |
The first list index. |
1 |
int |
The second list index. |
The complete relation contents are:
value = { (n1, 7) }
next = { (n1, n1) }
idx = { (n0, 0, n1), (n0, 1, n1) }
rootId = n0
The two list positions refer to one Node atom. The next tuple records
the self-reference. No NoneType atom is needed because the instance's
next field holds the node, despite its declared default of None.
For example, the stored idx record is:
{
"id": "idx",
"name": "idx",
"types": ["object", "object", "object"],
"tuples": [
{"atoms": ["n0", "0", "n1"], "types": ["list", "int", "Node"]},
{"atoms": ["n0", "1", "n1"], "types": ["list", "int", "Node"]}
]
}
The reverse conversion preserves the shared node and its self-reference:
from spytial import reify
restored = reify(instance)
assert restored[0] is restored[1]
assert restored[0].next is restored[0]
assert restored[0].value == 7
The full reconstruction procedure and its known failures are described in Reification.
Use the evaluator to inspect a data instance before adding layout rules. See Selectors for querying its atoms and relations.