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 sym knob. There is no separate "dataclass" type in pygx; users pick a point on the spectrum by setting sym (and refining with validate / wrap / 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

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

sym is the top-level axis. sym=False (the default) is a flat object with reference semantics — no symbolic tree, no clone-on-reparent, raw dict / list members. sym=True (opt-in, inherited by subclasses; sym=None means "inherit the base's mode") is a symbolic tree node, with wrap flipping on alongside it. 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 sym modes.
  • wrap — per field (pg.field(wrap=...)), whether raw dict / list members wrap into pg.Dict / pg.List. Defaults to the sym axis. 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. sym=False, eq=True is a value-equal dataclass).

The lower bound: dataclass-equivalent semantics

With sym=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 sym=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 sym=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, sym_path, sym_parent, sym_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.Inferential / ValueFromParentChain resolution at attribute access obj.x resolves a stored Inferential placeholder to the inferred value Only if the user stores Inferential 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 sym=False the tree-mutation / attachment members — sym_rebind, sym_setparent, sym_setpath — and contextual resolution raise SymbolicModeError. The tree-position reads stay non-raising and report a free-floating node: sym_path is the empty KeyPath(), sym_parent/sym_ancestor are None, and sym_root is the object itself. The pure-value members — sym_clone, to_json, sym_eq/sym_hash, format — keep working.)

Clone-on-reparent: the one semantic divergence

There is exactly one place where sym=True diverges from plain reference semantics: assigning a child that already has a parent clones it, to preserve the single-parent tree invariant.

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

c = Node(name='c')
a = Node(children=[c])           # adopted: a.children[0] is c
b = Node(children=[c])           # c already parented -> b.children[0] is a *clone*
b.children[0].sym_rebind(name='x')   # does NOT affect a's child

This is load-bearing for the symbolic tree (every node has a single sym_parent and a well-defined sym_path), but it is the one behavior a dataclass user would find surprising: the same assignment line aliases or clones depending on whether the value is already parented.

sym=False removes it. A flat object holds its children by reference, exactly like a dataclass: no clone-on-reparent, obj.child is original always. A sym=False object is itself treated as an opaque, reference-held leaf when nested inside a sym=True tree (not adopted, not cloned, not descended — like any foreign Python object), and a sym=True value nested inside a sym=False holder stays its own root. If you need reference sharing within a sym=True tree, pg.Ref is the escape hatch.

Mutating an opaque leaf does not notify its symbolic parents

Because a sym=False object is held by reference (no clone-on-reparent), the same instance can sit under several sym=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 sym 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):     # sym=False and writable, by default
    n: int = 0

class Holder(pg.Object, sym=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 (sym=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. The pg.Object path is currently slower because of the framework hop through sym_getattr — a pure-Python implementation cost, not an inherent one. A Rust prototype (branch rust-symbolic-core) reimplements the container core and reaches dataclass-grade attribute reads (~13 ns vs ~4 ns) and construction (~104 ns vs ~92 ns) with full symbolic topology retained, collapsing the gap without changing what users observe.
  • _sym_parent, _sym_path bookkeeping. Maintained on every pg.Object instance for tree traversal. A dataclass doesn't have these, but they're invisible unless sym_path / sym_parent is called. Cost is allocation + a few pointer writes per construction.
  • _sym_attributes Dict allocation per instance. One extra allocation today; could be elided when no symbolic features are active.

Treating these as implementation (not semantics) means the spectrum is a semantics contract, not a performance dial. Turning the knobs off barely changes perf today — validate / wrap move construction by ~10% and reads by 0%; the cost is the symbolic substrate above, paid roughly uniformly wherever you sit on the spectrum. That cost is a fixable implementation property, and the fix spans the whole spectrum, not just the lower bound: the Rust prototype above runs the full symbolic object (topology + validation) at near-dataclass speed — validated construction ~150–270 ns vs ~3–17 µs today, beating pydantic-v2. See the Rust-core tracking issue.

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 sym=True to enable symbolic wrapping + the tree without refactoring callers, field declarations, or storage.

  3. Performance is an implementation problem, not an API problem. This is no longer speculative: a Rust prototype (rust-symbolic-core) delivers near-dataclass construction and reads across the whole spectrum — including the fully symbolic upper bound and Rust-side validation (sub-µs) — 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 (sym=True) to the symbolic tree tomorrow.

Practical guidance

  • Default (sym=False) — flat, validated, dataclass-shaped: reference semantics (no clone-on-reparent), raw dict / list members, no tree. You still get validated construct + assignment, sym_clone (incl. override= — the dataclasses.replace() analog), to_json / from_json, value-equality, diff/format, and the on_sym_* hooks; only the tree-position / tree-mutation operations are unavailable. Use this for most records and configs.
  • sym=True — the symbolic tree: adoption, sym_path / sym_parent, sym_rebind, change notification, contextual resolution, wrap on so dict / list members join the tree. Reach for it when algorithms need to address, rewrite, or search over the object. It is a semantic choice — clone-on-reparent replaces reference semantics — and it inherits to subclasses, so one symbolic base covers a hierarchy.
  • sym=True + pg.field(wrap=False) — type-validate values, 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 sym 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 (sym / 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 sym axis (True under sym=False, False under sym=True) and re-derives on a sym= 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 sym=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.
sym=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 sym=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.
sym True → False Tree-shaped ops raise (sym_setparent / sym_setpath, path-keyed or rebinder sym_rebind); depth-1 sym_rebind and — via the follow rule — obj.x = v now succeed; sym_parent is None. 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 sym=False base materializes wrap=False onto its fields, so a sym=True subclass inherits those fields raw unless it re-declares the field with an explicit pg.field(wrap=True) — an explicit per-field flag 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 sym axis and the validate / eq / attr_read / attr_write / frozen knobs.
  • §The wrap flag — full reference for the per-field wrapping-control knob and its (validate, wrap) combinations.