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— theValueSpec.applypipeline (type-check, coerce, default-fill); class-level (class Foo(pg.Object, validate=...)) or per field (pg.field(validate=...)). Meaningful in bothsymmodes.wrap— per field (pg.field(wrap=...)), whether rawdict/listmembers wrap intopg.Dict/pg.List. Defaults to thesymaxis.validate=Falseshort-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=Trueis 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 | Never — pg.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(apg.Dict) vsself.__dict__. Both produce the sameobj.xvalue. The pg.Object path is currently slower because of the framework hop throughsym_getattr— a pure-Python implementation cost, not an inherent one. A Rust prototype (branchrust-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_pathbookkeeping. Maintained on everypg.Objectinstance for tree traversal. A dataclass doesn't have these, but they're invisible unlesssym_path/sym_parentis called. Cost is allocation + a few pointer writes per construction._sym_attributesDict 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¶
-
One class, one mental model. Users learn
pg.Objectand tune it via flags as their needs evolve. No "should I use dataclass or pg.Object?" decision, no migration when requirements grow. -
Code stays unchanged as you move up the spectrum. A class declared flat (the default) for a plain record can later opt into
sym=Trueto enable symbolic wrapping + the tree without refactoring callers, field declarations, or storage. -
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. -
No parallel dataclass implementation to maintain. Users who would otherwise reach for
@dataclassfor a flat record can use a barepg.Objectsubclass (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), rawdict/listmembers, no tree. You still get validated construct + assignment,sym_clone(incl.override=— thedataclasses.replace()analog),to_json/from_json, value-equality, diff/format, and theon_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,wrapon sodict/listmembers 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 doingtype(x) is dictchecks, or hot paths where the symbolic features aren't used on a specific field's subtree.- Don't choose by speed. Both
symmodes 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 rule — how 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_writeis the one exception: it follows thesymaxis (Trueundersym=False,Falseundersym=True) and re-derives on asym=flip, unless some class statement pinned it with an explicit bool — the pin then inherits like the other flags (attr_write=Noneexplicitly un-pins, mirroring thesym=Nonetri-state). Per-fieldenable_*flags merge like the inherited flags — an explicit value on the field wins; a field that stays silent inherits. - Semantic validity — whether 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.
Related¶
- §Class-level behaviors in
the style guide — lists the
symaxis and thevalidate/eq/attr_read/attr_write/frozenknobs. - §The
wrapflag — full reference for the per-field wrapping-control knob and its(validate, wrap)combinations.