Skip to content

pg.Object semantic spectrum

pg.Object covers a spectrum of class semantics — from a flat, dataclass-equivalent object to a fully symbolic, tree-aware value object — along one top-level axis: the class-level topo knob. There is no separate "dataclass" type in pygx; users pick a point on the spectrum by setting topo (and refining with validate / eq / mutability), not by switching frameworks.

This doc describes what each end of the spectrum means semantically, what the framework adds on top of dataclass behavior, and what stays strictly implementation (and is therefore amenable to perf optimization without changing observable behavior).

The spectrum

        topo=False (default)                    topo=True (opt-in)
        ┌─────────────────┐                    ┌──────────────────────┐
        │ FLAT, VALIDATED │ ─────────────────→ │ SYMBOLIC NODE        │
        │ raw members,    │                    │ validated + wrapped  │
        │ reference       │                    │ tree-addressable     │
        │ semantics,      │                    │ (single position)    │
        │ no tree         │                    │                      │
        └─────────────────┘                    └──────────────────────┘

topo is the top-level axis. topo=False (the default) is a flat object with reference semantics — no symbolic tree, no single-position rule, raw dict / list members. topo=True (opt-in, inherited by subclasses; topo=None means "inherit the base's mode") is a symbolic tree node, and it is what an unauthored container spec follows when deciding storage. validate defaults on in both modes; the sub-dials:

  • validate — the ValueSpec.apply pipeline (type-check, coerce, default-fill); class-level (class Foo(pg.Object, validate=...)) or per field (pg.field(validate=...)). Meaningful in both topo modes.
  • storage — declared by the ANNOTATION (§6): pg.Dict[str, V] / pg.List[E] store symbolic containers, builtin dict[str, V] / list[E] store raw, and an unauthored spec follows the topo axis. (The former per-field pg.field(wrap=...) flag was removed in 0.4.) validate=False short-circuits the apply pipeline before the wrap callback can fire, so (validate=False, wrap=True) collapses onto raw storage.
  • eq, attr_write, frozen — equality and mutability; meaningful in both modes (e.g. topo=False, eq=True is a value-equal dataclass).

The lower bound: dataclass-equivalent semantics

With topo=False, validate=False, a pg.Object subclass behaves semantically equivalently to a @dataclasses.dataclass for all the behaviors that dataclass defines:

Behavior Dataclass pg.Object (lower bound)
Synthesized keyword __init__ from annotations
Required fields without defaults raise on omission
Unknown kwargs raise TypeError
Field declaration order preserved
Values stored verbatim (no type checking) ✓ (under validate=False)
Values stored verbatim (no transformation) ✓ (under topo=False)
Identity preserved: obj.x is original_dict
type(obj.x) is dict for raw dict assignment
Reference semantics: assigning a shared child aliases (no clone) ✓ (under topo=False)
Value-based __eq__ / __hash__ (when eq=True) ✓ (via sym_eq / sym_hash)
Synthesized < / <= / > / >= (when order=True) ✓ (@dataclass(order=True)) ✓ (order=True) — lexicographic over compare=True fields
__repr__ enumerates field values ✓ (via format)
Mutable default aliasing bug ✗ (requires default_factory) ✓ (auto-copied) — safer than dataclass

The single user-visible difference at the lower bound is that pg.Object copies mutable defaults per instance automatically, while @dataclass aliases them unless the user remembered default_factory. This is a strict improvement; it doesn't break dataclass expectations (no one wants the aliasing bug).

Additive features (don't conflict with dataclass semantics)

pg.Object provides several capabilities that @dataclass doesn't, but that don't change the dataclass-equivalent observable behavior unless the user actively uses them:

Feature What it is When it conflicts with dataclass semantics
sym_clone, sym_rebind, topo_path, topo_parent, topo_root Symbolic-tree methods on the instance Never — they're additional methods. A dataclass user who never calls them sees no difference.
to_json / from_json via _type registration Built-in JSON round-trip Never — feature is opt-in via the call. Dataclass needs dataclasses-json to do the same; pygx provides it built-in.
on_sym_change / on_sym_bound notifications Mutation callbacks Never — only fires if the user subscribes. Default: silent.
frozen=True with pg.as_sealed(False) context Mutability gate with scoped override Neverpg.as_sealed is an opt-in escape hatch a dataclass user wouldn't know exists.
Schema introspection via Foo.__schema__ Programmatic access to field types Never — additive.
pg.Inferentiable / ValueFromParentChain resolution at attribute access obj.x resolves a stored inferential placeholder to the inferred value Only if the user stores an inferential value in a field. A dataclass-style user wouldn't have these objects; they require importing and instantiating framework types. Opt-in.

Every additive feature is reachable only by calling it or using a framework type explicitly. Code that doesn't touch them gets dataclass semantics. (Under topo=False what is withheld is position: the tree coordinates — topo_path, topo_parent, topo_root, topo_ancestor — their setters, and contextual resolution all raise SymbolicModeError. Everything that speaks about value keeps working, including writes that descend: sym_rebind accepts nested path keys and rebinder callables, and sym_clone, to_json, sym_eq/sym_hash, and format are unaffected.)

Single position: the one semantic divergence

There is exactly one place where topo=True diverges from plain reference semantics: a node has one position in the tree, so holding an already-held value a second time is an error rather than an aliasing assignment.

class Node(pg.Object, topo=True):
    name: str = ''
    children: pg.List['Node'] = pg.field(default_factory=list)

c = Node(name='c')
a = Node(children=[c])           # adopted: a.children[0] is c, at 'children[0]'
b = Node(children=[c])           # SharedHoldError: `c` is already held

# The second hold has to say which it meant:
b = Node(children=[pg.maybe_clone(c)])   # independent COPY
b = Node(children=[pg.maybe_ref(c)])     # SHARE the one node

This is load-bearing for the symbolic tree: topo_path / topo_parent / topo_root are single-valued functions of a node, and change notification routes along that one chain. Both resolutions are correct for some code and wrong for other code — sharing keeps the holders coupled, copying forks the state at that moment — so pygx asks instead of guessing. Either spelling is safe on a value that may or may not already be held: a free value is adopted in place by both.

This replaced implicit clone-on-reparent

Historically the second hold was silently copied. That made the same expression mean different things depending on whether some unrelated holder happened to get there first — and on construction order, which decided which holder aliased the caller's object. SharedHoldError names the ambiguity instead of resolving it arbitrarily.

Reference-like nodes are exempt, since holding them twice creates no ambiguity: pg.Ref and other inferential values (which is what makes pg.maybe_ref usable more than once), and hyper.DerivedValue, which deliberately resolves relative to each parent.

topo=False removes the whole question. A flat object holds its children by reference, exactly like a dataclass: obj.child is original always, and a second holder is just another reference. A topo=False object is itself treated as an opaque, reference-held leaf when nested inside a topo=True tree (not adopted, not descended — like any foreign Python object), and a topo=True value nested inside a topo=False holder stays its own root.

Mutating an opaque leaf does not notify its symbolic parents

Because a topo=False object is held by reference (it is never adopted), the same instance can sit under several topo=True parents at once. Mutating it in place — f.n = 2, or the batch form f.sym_rebind(n=2); both allowed by default (flat objects are mutable, attr_write follows the topo axis) — updates every parent that references it but fires no on_sym_change on any of them: the parents never descend into an opaque leaf, so they can't observe its internal change (the leaf's OWN on_sym_change does fire). Any cached or derived state a parent computes from the leaf is therefore not invalidated.

class Flat(pg.Object):     # topo=False and writable, by default
    n: int = 0

class Holder(pg.Object, topo=True):
    leaf: Flat | None = None

f = Flat(n=1)
p1, p2 = Holder(leaf=f), Holder(leaf=f)   # f aliased into both
f.n = 99                                  # p1.leaf.n == p2.leaf.n == 99,
                                          # but no on_sym_change fires

If you need change propagation, keep the value symbolic (topo=True) and mutate it through sym_rebind, or replace the whole leaf on the parent (p1.sym_rebind(leaf=Flat(n=99))) so the parent sees a field-level change.

What's implementation, not semantics

These differences are not observable to user code that respects the public API:

  • Storage in _sym_attributes (a pg.Dict) vs self.__dict__. Both produce the same obj.x value. Where the attribute lives, and what it costs to reach, is an implementation choice: the hot paths run in the native Rust core (pygx-core), and attribute reads now sit at parity with a dataclass.
  • Tree bookkeeping under topo=True. Positions are maintained for tree traversal, but they're invisible unless topo_path / topo_parent is called — and the cost does not show up as a construction penalty.
  • _sym_attributes Dict allocation per instance. An implementation detail of where fields are stored, not part of the contract.

For the actual figures, see the performance report, which is regenerated from a benchmark rather than quoted here — inlined numbers drift.

Treating these as implementation (not semantics) means the spectrum is a semantics contract, not a performance dial. Choosing a point on it is a statement about what the object means — whether it has a position in a tree, whether it validates, whether it wraps — and not a way to buy speed. The measurements bear this out: topo=False and topo=True construct and read at parity, so the mode you pick should follow from the semantics you want, not from a perf budget.

Why this matters

  1. One class, one mental model. Users learn pg.Object and tune it via flags as their needs evolve. No "should I use dataclass or pg.Object?" decision, no migration when requirements grow.

  2. Code stays unchanged as you move up the spectrum. A class declared flat (the default) for a plain record can later opt into topo=True to enable symbolic wrapping + the tree without refactoring callers, field declarations, or storage.

  3. Performance is an implementation problem, not an API problem. The native core delivers sub-µs validated construction and dataclass-parity reads across the whole spectrum — including the fully symbolic upper bound — without changing observable behavior. Where you sit on the semantic spectrum is independent of how fast it runs.

  4. No parallel dataclass implementation to maintain. Users who would otherwise reach for @dataclass for a flat record can use a bare pg.Object subclass (flat is the default) and get dataclass-shaped semantics today, with a one-flag path (topo=True) to the symbolic tree tomorrow.

Practical guidance

  • Default (topo=False) — flat, validated, dataclass-shaped: reference semantics (a second holder is just another reference), raw dict / list members, no tree. You still get validated construct + assignment, sym_clone (incl. override= — the dataclasses.replace() analog), sym_rebind (nested path keys and rebinder callables included), to_json / from_json, value-equality, diff/format, and the on_sym_* hooks; only the tree-position operations are unavailable. Use this for most records and configs.
  • topo=True — the symbolic tree: adoption, topo_path / topo_parent, change notification that travels upward, contextual resolution, and unauthored container fields storing symbolic so they join the tree (an explicit dict / list annotation still stays raw). Reach for it when algorithms need to address, rewrite, or search over the object. It is a semantic choice — a node gains a single tree position — and it inherits to subclasses, so one symbolic base covers a hierarchy.
  • topo=True + a builtin dict / list annotation — validate, but leave them raw. Useful for fields that hold plain JSON-ish data passed to third-party libraries doing type(x) is dict checks, or hot paths where the symbolic features aren't used on a specific field's subtree.
  • Don't choose by speed. Both topo modes run on the same substrate at essentially the same cost; pick flat vs. tree (and the sub-dials) for semantics — reference vs. adoption, raw vs. validated/wrapped values — not for performance.

Combining flags: merge rule vs. semantic validity

Two separate questions arise once a class sets several flags, or a subclass overrides an inherited one. Keep them distinct:

  • Merge rulehow a flag's value is resolved. The class-level options (topo / attr_read / frozen / eq / validate) are inherited: a subclass takes the base's resolved value unless it passes the keyword explicitly. attr_write is the one exception: it follows the topo axis (True under topo=False, False under topo=True) and re-derives on a topo= flip, unless some class statement pinned it with an explicit bool — the pin then inherits like the other flags (attr_write=None explicitly un-pins, mirroring the topo=None tri-state). Per-field enable_* flags merge like the inherited flags — an explicit value on the field wins; a field that stays silent inherits.
  • Semantic validitywhether the resulting combination is coherent. The merge rule always produces a value; it does not judge whether that value makes sense next to the others. The combinations below run, but are discouraged — they are footguns, not features.

Discouraged single-class combinations

Combination What actually happens Prefer
frozen=True + attr_write=True frozen seals the object, so every write raises — attr_write=True is dead. Drop attr_write; use pg.as_sealed(False) for scoped mutation.
attr_read=False + attr_write=True obj.x = v succeeds but obj.x raises AttributeError — a write-only attribute, readable only via sym_get. Keep attr_read and attr_write aligned.
topo=False + a field defaulting to an Inferential (e.g. ValueFromParentChain) Construction succeeds, but reading the field's inferred default raises SymbolicModeError (contextual resolution needs a tree). Only safe if every instance sets the field explicitly. Use topo=True for fields that rely on contextual inference.

Discouraged inheritance overrides (narrowing a capability)

Because options inherit, a mismatch only arises when a subclass explicitly narrows a capability the base granted — the symbolic analogue of overriding a public method as private. Code that treats the subclass as its base then breaks (Liskov substitution). pygx emits a UserWarning at class creation for each of these (silence with pg.warn_on_capability_narrowing(False)):

Override Effect on a subclass instance Why it bites
attr_read True → False obj.x works for fields declared on the base (the base's accessor is inherited) but raises for fields declared on the child. Inconsistent access; a template-method base that reads self.child_field breaks.
topo True → False Tree-position ops raise (topo_path / topo_parent / topo_root / topo_ancestor and topo_setparent / topo_setpath), as does contextual resolution; sym_rebind keeps working at any depth, and — via the follow rule — obj.x = v now succeeds. Code using the base as a tree node breaks; code relying on the base's write-immutability silently gains mutation.
attr_write True → False obj.x = v raises (sym_rebind still works — frozen alone seals it). Mutation through a base-typed reference breaks.
frozen False → True the subclass is sealed at construction. Mutation through a base-typed reference breaks (weakest case — immutable subtypes are sometimes intentional).

The reverse direction — widening a capability (False → True) — is generally fine and does not warn. One wrinkle: a topo=False base materializes raw storage onto its fields, so a topo=True subclass inherits those fields raw unless it re-declares the field with a pg.Dict[...] / pg.List[...] annotation — an explicit per-field declaration wins over the inherited value. Keep capability flags consistent across a hierarchy, or only ever widen them down the chain.

  • §Class-level behaviors in the style guide — lists the topo axis and the validate / eq / attr_read / attr_write / frozen knobs.
  • §Container storage — full reference for the per-field wrapping-control knob and its (validate, wrap) combinations.