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— theValueSpec.applypipeline (type-check, coerce, default-fill); class-level (class Foo(pg.Object, validate=...)) or per field (pg.field(validate=...)). Meaningful in bothtopomodes.- storage — declared by the ANNOTATION (§6):
pg.Dict[str, V]/pg.List[E]store symbolic containers, builtindict[str, V]/list[E]store raw, and an unauthored spec follows thetopoaxis. (The former per-fieldpg.field(wrap=...)flag was removed in 0.4.)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.topo=False, eq=Trueis 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 | 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.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(apg.Dict) vsself.__dict__. Both produce the sameobj.xvalue. 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 unlesstopo_path/topo_parentis called — and the cost does not show up as a construction penalty. _sym_attributesDict 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¶
-
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
topo=Trueto enable symbolic wrapping + the tree without refactoring callers, field declarations, or storage. -
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.
-
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 (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), rawdict/listmembers, no tree. You still get validated construct + assignment,sym_clone(incl.override=— thedataclasses.replace()analog),sym_rebind(nested path keys and rebinder callables included),to_json/from_json, value-equality, diff/format, and theon_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 explicitdict/listannotation 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 builtindict/listannotation — validate, 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
topomodes 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
(
topo/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 thetopoaxis (Trueundertopo=False,Falseundertopo=True) and re-derives on atopo=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 thetopo=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. |
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.
Related¶
- §Class-level behaviors in
the style guide — lists the
topoaxis and thevalidate/eq/attr_read/attr_write/frozenknobs. - §Container storage — full reference
for the per-field wrapping-control knob and its
(validate, wrap)combinations.