Skip to content

Objects

An ordinary Python object forgets how it was made. Foo(1, 2) runs its __init__, sets up whatever internal state it needs, and the arguments that produced it are gone — you can read back whatever __init__ chose to store, but not the call itself.

A symbolic object keeps that call. Alongside its ordinary runtime state, it retains the arguments it was constructed from, so the object is simultaneously something you execute and something you can inspect and rewrite. Because those arguments are still present, they can be changed after the fact — and when they are, the object recomputes its state to match. The two views never drift apart:

  • the executable view — data members and methods, as in any Python object;
  • the symbolic view — the stored arguments, addressable by name or by path, and rewritable in place.
flowchart LR
    sym["<b>Symbolic representation</b><br/>(sym_init_args, schema,<br/>parent, path)"]
    exec["<b>Executable state</b><br/>(instance attributes,<br/>methods)"]
    sym <-->|sym_rebind / on_sym_change| exec

Concretely, that is the difference between these two classes:

class Plain:
    def __init__(self, x, y):
        self._sum = x + y

p = Plain(x=1, y=2)
# `p._sum` is 3, but `x` and `y` are gone.


class Sym(pg.Object):
    x: int
    y: int

s = Sym(x=1, y=2)
s.sym_init_args        # {'x': 1, 'y': 2} — the call is still there
s.sym_rebind(x=8)      # and can be rewritten afterwards

The rest of this page is about what that retained structure makes possible.

topo=False objects are still symbolic — the tree is what's opt-in

Every pg.Object is a symbolic object, in either mode. A plain class Foo(pg.Object) keeps its construction arguments, validates them, compares by value, serializes, and rebinds — everything this page's opening contrast is about. topo= does not change what an object is; it changes only whether the object is linked to a parent. The decorators — pg.symbolize, pg.functor, pg.wrap — share the same topo=False default. The topo axis below spells out what topo=True adds and what it costs; everything on this page that involves tree position assumes it.

Symbolization

Any class or function can be given this behavior — including ones you did not write. pg.symbolize takes an existing type and returns one whose instances keep their construction arguments:

@pg.symbolize
def foo(x, y):
    return x + y


# `f` is a bound call of `foo` — held, not yet evaluated.
f = foo(1, 2)


@pg.symbolize
class Foo:

    def __init__(self, x, y):
        self._x = x
        self._y = y

    def sum(self):
        return self._x + self._y


# `f` is a `Foo` that remembers it was built with (1, 2).
f = Foo(1, 2)

pg.symbolize takes options — turning on validation, or using value equality as the default comparison. When you are writing the class yourself, subclassing pg.Object is the more direct route; see Types for both.

Logical layout

The stored __init__ arguments are the object's fields. Change one and PyGX recomputes whatever the object derived from it, so its runtime state never falls out of step with the call it was built from. Under topo=True, each object also keeps a reference to its parent, so a change is reported upward to whatever contains it; a topo=False object has no parent link — it is notified of its own changes and the news stops there.

classDiagram
    class SymbolicObject {
        +sym_init_args : Dict
        +topo_parent : Symbolic | None
        +topo_path : KeyPath
        +__schema__ : Schema
        +instance attrs (computed)
        +sym_rebind(...)
        +on_sym_change(updates)
    }
    SymbolicObject --> SymbolicObject : topo_parent

The topo axis

Every symbolic type carries one mode flag, declared where the type is made and inherited by subclasses:

class Value(pg.Object):              # topo=False — the default everywhere
    ...

class Node(pg.Object, topo=True):    # a tree node
    ...

@pg.functor(topo=True)               # decorators take the same flag
def search_space(a, b): ...

Both modes share the entire symbolic foundation — what differs is whether the value is linked to a parent, and everything that single link makes possible:

topo=False (default) topo=True
sym_init_args, validation, pg.eq, JSON ✅ ✅
sym_rebind, at any depth ✅ ✅
on_sym_change on the rebound object ✅ ✅
Change climbs to the holder — ✅
topo_parent / topo_path / topo_root / topo_ancestor raises ✅
on_topo_parent_change / on_topo_path_change events — ✅
Contextual values (resolve through the parent chain) raises ✅
Adoption + the single-position rule (SharedHoldError) — ✅
Plain list / dict members auto-convert to pg.List / pg.Dict — ✅

What topo=True buys

The parent link turns a value into a tree citizen:

  • Position. The object knows where it is: topo_path addresses it from the root, topo_parent / topo_root / topo_ancestor walk upward, and the on_topo_parent_change / on_topo_path_change events fire as it is attached, moved, or detached.
  • Upward notification. A change anywhere in the subtree reports to every ancestor's on_sym_change, so a container can react to its members — the basis of the reactive recompute shown below.
  • Contextual resolution. A field can defer to whatever tree it lands in (pg.contextual resolves by walking the parent chain) — see Contextual.
  • Members join the tree. Plain list / dict values convert to pg.List / pg.Dict on the way in, so nested changes are visible too (see Automatic conversion).

What it costs

The link is also a constraint — the semantic shift, not just a feature list:

  • One position. A tree node can be held in exactly one place; holding it a second time raises SharedHoldError and asks you to choose sharing or copying (see Pass-by-value vs. pass-by-reference). A topo=False value has plain Python reference semantics — held anywhere, any number of times.
  • Ownership on the way in. Constructing a tree node adopts its arguments; constructing a flat value borrows them. This is why the flat default suits function calls: a call does not take ownership of what it is passed.
  • Construction cost. Adoption, path stamping and container conversion are work; flat construction skips all of it.

Asking a flat value a tree question raises TopoNotEnabled rather than returning None — the mode is explicit, never silently empty.

pg.Dict / pg.List themselves are tree nodes, and the library types that require the tree stay pinned topo=True: pg.Ref, InferredValue, pg.Compound. Everything else — pg.Object, pg.Functor, the decorators — defaults flat, and an explicit topo= always wins.

The tree

A field usually holds a simple value (int, str, ...) or another object. Objects holding objects form a tree, and every node in it has an address: the sequence of keys leading down to it, called a key path (pg.KeyPath). A key path lets you read or rewrite any node, at any depth, without walking there yourself:

class Node(pg.Object):
    value: Any
    children: Any = None


tree = Node(value=1, children=[
    Node(value=2, children=[Node(value=3), Node(value=4)]),
    Node(value=5, children=[Node(value=6), Node(value=7)]),
])

# Mutate a tree by key paths.
tree.sym_rebind({
    # Mutate the root node's value from 1 to 8.
    'value': 8,

    # Mutate the first grandchild node's value from 3 to 9.
    'children[0].children[0].value': 9,
})

What to put in a field

A field will hold any Python value, but what you put in decides what the tree can do. Values PyGX can reason about — numbers, strings, booleans, other pg.Objects, and lists/tuples/dicts of those — can be compared and serialized. Anything else is carried along as an opaque payload.

A lambda is the classic example. It fits a callable field, and PyGX will even serialize it — but only by marshalling its CPython bytecode, which is tied to the interpreter version that wrote it. A symbolic function serializes as a plain reference to the function instead, so the JSON stays readable and portable:

class Foo(pg.Object):
    x: Callable = pg.field(
        value_spec=pg.typing.Callable([pg.typing.Int()]))


# Accepted, but the function is opaque on the wire.
f = Foo(x=lambda v: v ** 2)
pg.to_json_str(f)
# {"_type": "...Foo", "x": {"_type": "function", "code": "4wEAAAAA..."}}
#                                                 ^ marshalled bytecode


# Recommended.
@pg.functor()
def square(v):
    return v ** 2


g = Foo(x=square.partial())
g2 = Foo(x=square.partial())
assert pg.eq(g, g2)
assert pg.from_json_str(pg.to_json_str(g)) == g
pg.to_json_str(g)
# {"_type": "...Foo", "x": {"_type": "...square"}}   <- a readable reference

Pass-by-value vs. pass-by-reference

Which of the two you get is decided by the held value's mode, not the holder's.

A topo=False value — the default, including everything the decorators produce — is held by reference, exactly like any ordinary Python object. It can sit in any number of holders at once, and every holder shares the one instance:

n = Node(value=1)    # Node is topo=False — the default
l = pg.List()

l.append(n)
l.append(n)          # fine: a flat value has no position to protect

assert n is l[0]
assert n is l[1]     # one instance, two slots — mutations visible via both

A topo=True value is a tree node. To report changes upward, it maintains a reference to its parent — which gives it exactly one position: topo_path / topo_parent are single-valued, and change notification routes along that single chain, so the structure is a tree rather than a directed acyclic graph (DAG). A free value is adopted in place; holding an already-held value a second time would make its position ambiguous, so PyGX raises SharedHoldError and asks which you meant, rather than guessing:

  • pg.maybe_ref(v) — keep ONE node, shared. The holders observe each other's writes.
  • pg.maybe_clone(v) — give this holder its own COPY, independent from now on.
class TreeNode(pg.Object, topo=True):
    value: Any
    children: Any = None


t = TreeNode(value=1)
l = pg.List()

# `t` is adopted as the first element since it does not have a container yet.
l.append(t)

# A bare second `l.append(t)` would raise SharedHoldError — say which you
# meant instead:
l.append(pg.maybe_clone(t))

assert t is l[0]
assert t is not l[1]

Both resolvers are safe on a value that may or may not already be held: a free topo=True value is adopted in place by either, and on a topo=False value they are no-ops (nothing to resolve — it is already shareable).

The single-position rule is also what decides which parts of a search space a materialized program owns and which it shares — search spaces are built from topo=True types — see What a materialized program owns.

Automatic list/dict conversion

A field may hold a list or a dict — node.children above is a list of child nodes. Whether the container is converted to its symbolic counterpart (pg.List / pg.Dict) follows the class's topo axis:

  • Under the default topo=False, a plain container stays a plain container — tree.children is an honest list. Key-path reads and writes still descend into it (the rebind above works), and a path write still notifies the holder; what a raw member lacks is a symbolic identity of its own.
  • Under topo=True, plain containers are converted on the way in, so the members join the tree and their changes propagate upward — the same tree.children on a topo=True node type is a pg.List.
type(Node(value=1, children=[Node(value=2)]).children)
# list  — Node is topo=False

type(TreeNode(value=1, children=[TreeNode(value=2)]).children)
# pg.List — members join the tree

For pg.Object subclasses, the annotation decides

The axis rule above applies where nothing declared the field's storage — as with pg.symbolize / pg.functor, whose parameters carry no annotation. On a pg.Object subclass, an explicit list[E] / dict[K, V] annotation means what it says and stores raw even under topo=True, while pg.List[E] / pg.Dict[K, V] declares the symbolic container on both axes. See Container storage.

Programming properties

Symbolic objects have several useful programming properties. This section demonstrates them by comparing programs with and without them.

Symbolic

A regular object is created by evaluating its class's __new__ method. Once the evaluation is finished, the binding between the type and values is lost. For example, after the evaluation of Foo(1), the value 1 that was associated with the class Foo in the creation of the object f is no longer accessible:

class Foo:

    def __init__(self, x):
        self._value = x + 1


# The value used for creating `f` is not accessible beyond this point.
f = Foo(1)

A symbolic object keeps those arguments for its whole lifetime, so x is still there to read afterwards:

class SymbolicFoo(pg.Object):
    x: int


f = SymbolicFoo(x=1)

# Returns 1.
f.sym_init_args['x']

"Symbolic" refers to the ability to use the binding information to achieve advanced programming capabilities. That makes an object usable for:

  • Locating an object within its tree:

    print(f.topo_path)
    
  • Traversing its sub-nodes:

    def visit(path, value, parent):
        print(path, value)
    
    pg.traverse(f, visit)
    
  • Printing it in human-readable format:

    print(f)
    
  • Comparing, hashing, and differentiating by value rather than identity:

    assert pg.eq(f, SymbolicFoo(1))
    assert pg.hash(f) == pg.hash(SymbolicFoo(1))
    print(pg.diff(f, SymbolicFoo(2)))
    
  • Replicating it:

    pg.clone(f)
    
  • Serializing and deserializing it:

    json_str = pg.to_json_str(f)
    assert pg.eq(f, pg.from_json_str(json_str))
    
  • Mutating it:

    f.sym_rebind(x=2)
    assert f._value == 3
    
  • Encoding and decoding it relative to a search space:

    f_space = SymbolicFoo(pg.oneof([1, 3, 5]))
    assert pg.template(f_space).encode(SymbolicFoo(3)) == pg.DNA(1)
    assert pg.eq(pg.materialize(f_space, pg.DNA(1)), SymbolicFoo(3))
    

For more operations, see Operations.

Abstract

Regular objects are concrete: their arguments must be provided and their values must conform to the constructor's expectations. Symbolic objects, on the other hand, can be abstract, with arguments that may be only partially specified or represented by pure symbolic values (defined by interface pg.PureSymbolic).

For example, foo is a partial object of Foo:

class Foo(pg.Object):
    x: int
    y: int

    def on_sym_ready(self):
        super().on_sym_ready()
        self.z = self.x * self.y


# `partial` must be called to create a partial object from a symbolic class.
# `foo` is partial since the argument for `y` is not provided.
foo = Foo.partial(x=1)

assert pg.is_partial(foo)
assert pg.is_abstract(foo)

And foo_space is pure symbolic because it is placeheld by pg.oneof:

# `foo_space` is a space of `foo` objects.
foo_space = Foo(x=pg.oneof([1, 2, 3]), y=pg.oneof([4, 5]))

assert pg.is_pure_symbolic(foo_space)
assert pg.is_abstract(foo_space)

An abstract symbolic object cannot be evaluated until its missing or marker arguments are replaced with concrete values. As such, an abstract symbolic object can only be used for symbolic manipulation and not for evaluation:

# Raises: required argument `y` is missing.
foo.z

foo.sym_rebind(y=2)
# Okay: `y` is now provided, so `foo.z` is computed.
foo.z

# Raises: `foo_space.z` is not yet assigned because `x` and `y`
# are still pure symbolic.
foo_space.z

# Obtain a material `Foo` from the `foo_space`.
foo1 = pg.materialize(foo_space, pg.DNA([0, 1]))

# Okay: `foo1` is bound with x=1, y=5.
foo1.z

Important

Abstract symbolic objects are an essential aspect of symbolic object-oriented programming. They enable the programmer to incorporate high-level descriptions into a program and substitute them with concrete values later. This allows for the creation of domain-specific languages (such as pg.oneof) with ease, making the programming language more extensible and higher-level by separating the expression of ideas (the what) from the implementation of ideas (the how).

See Marker for more details about pure symbolic, partial, and abstract objects.

Symbolically validated

When a symbolic object is abstract, the call to its __init__ is delayed. Therefore, we cannot depend on user-written validation logic to perform value checks when an abstract object is created. However, the programmer can still catch invalid arguments as soon as the object is created or modified, rather than waiting until the object is evaluated. Symbolic objects are validated based on the rules declared alongside the symbolic fields, which define the acceptable keys and values for symbolic attributes. We call this mechanism symbolic validation. For example, for a regular class that validates its input as follows:

class Foo:

    def __init__(self, x):
        if x < 0:
            raise ValueError('`x` should be non-negative.')
        self.x = x

the symbolic validation rule is defined as:

class Foo(pg.Object):
    x: int = pg.field(value_spec=pg.typing.Int(min_value=0))

Aside from the need to trigger validation on creation and modification, symbolic validation has the following benefits:

  • It eliminates boilerplate code for argument validation, allowing developers to focus on the core program logic.
  • The validation rules define both acceptable types and their values, removing the need to document them in the docstring. They describe the rules at a higher abstraction level than equivalent logic in __init__ and are thus more readable (e.g., see the kernel_size_spec below).
  • The validation rules are reusable across class definitions, so developers can create modular validation rules that are consistent throughout the software system:
def kernel_size_spec():
    """Kernel size is a positive integer or a pair of positive integers."""
    return pg.typing.Union([
        pg.typing.Int(min_value=1),
        pg.typing.Tuple([
            pg.typing.Int(min_value=1),
            pg.typing.Int(min_value=1),
        ]),
    ])


class Conv2D(pg.Object):
    kernel_size: Any = pg.field(value_spec=kernel_size_spec())


class MaxPool(pg.Object):
    pool_size: Any = pg.field(value_spec=kernel_size_spec())

Deeply mutable

Ordinary objects are sealed once built; these are not. For example, we cannot change or even access the bound argument x once foo is created:

class Foo:

    def __init__(self, x):
        self.value = x ** 2


foo = Foo(x=1)

However, we can mutate x with its symbolic counterpart:

class SymbolicFoo(pg.Object):
    x: int

    def on_sym_ready(self):
        super().on_sym_ready()
        self.value = self.x ** 2


foo = SymbolicFoo(x=1)
assert foo.value == 1

# `foo` will be `SymbolicFoo(x=2)` after rebinding.
foo.sym_rebind(x=2)

# The internal state is recomputed when the binding information is modified.
assert foo.value == 4

Two things stand out about this mutability:

  • Strong consistency. Updates to symbolic attributes cause the symbolic object to become invalid, triggering recomputation or adjustment of its internal states. This not only occurs on the modified object itself, but also on the containing objects along the object hierarchy. This is important to ensure that the object and its related objects remain consistent and correct:

    class Foo(pg.Object, topo=True):
        x: int
    
    
    class Bar(pg.Object, topo=True):
        y: Foo
    
        def on_sym_ready(self):
            super().on_sym_ready()
            self.z = self.y.x
    
    
    foo = Foo(x=1)
    bar = Bar(y=foo)
    assert foo.x == 1
    assert bar.z == 1
    
    # Manipulation on child symbolic objects causes the parent symbolic
    # objects to recompute their internal states.
    foo.sym_rebind(x=2)
    assert bar.z == 2
    
  • Deep manipulability. Given any object, the user can manipulate not only its immediate children but also the children of its children, and so on. For example:

    bar.sym_rebind({
        'y.x': 2,
    })
    assert foo.x == 2
    

Contextual

One consequence of that mutability is that objects are also contextual. To maintain consistency of state, changes made to child objects must inform their parent objects to recalculate their state. As a result, each symbolic object has knowledge of its parent and its location within the containing tree. Users can subscribe to the on_topo_parent_change and on_topo_path_change events to handle context changes:

class ContextAwareFoo(pg.Object, topo=True):

    def on_topo_parent_change(self, old_parent, new_parent):
        super().on_topo_parent_change(old_parent, new_parent)
        print('Parent has changed', old_parent, new_parent)

    def on_topo_path_change(self, old_path, new_path):
        super().on_topo_path_change(old_path, new_path)
        print('Location has changed', old_path, new_path)


f = ContextAwareFoo()
# `f.on_topo_parent_change` is triggered: from None to `x`;
# `f.on_topo_path_change` is also triggered: from '' to 'a'.
x = pg.Dict(a=f)

# `f.on_topo_path_change` is triggered: from 'a' to '[0].a'.
y = pg.List([x])

# `f.on_topo_parent_change` is triggered: from `x` to None;
# `f.on_topo_path_change` is also triggered: from 'a' to ''.
x.clear()

See Events for more details.

Better software design

Using them leads to a more object-oriented software design and better utilizes the composability of reusable building blocks.

More object-oriented

PyGX promotes the use of symbolic functions — classes that align the programming style between classes and functions, resulting in more consistent object bindings:

@pg.functor()
def bar(y):
    return y ** 2


class Foo(pg.Object):
    x: int
    y: bar


Foo(x=1, y=bar(2))

Better compositionality

They also lead to more hierarchical bindings, which enables the creation of large-scale compositions using smaller, reusable building blocks.

Functions use arguments to customize the behavior defined in the function body, which often calls other functions. Therefore, when we need to control the behaviors at deeper levels, we need to pass down the arguments across the call hierarchy (ignoring globals in this analysis as it is not best practice). For example, in order to customize bar's behavior inside foo, y needs to be passed down through foo:

def foo(x, y):
    return x + bar(y)

If we need to further control the function called within foo, we modify the signature of foo as follows:

def foo(x, bar_fn, y):
    return x + bar_fn(y)

This leads to a flat binding. For a program that uses functions extensively, flat bindings can lead to a long argument list at outer scopes. As a result, the program becomes either less reusable (with a short argument list) or less usable (with a long argument list).

However, with classes the program bindings become hierarchical, which allows a large number of binding parameters to be specified in semantic groups without sacrificing usability:

def foo(x, bar):
    return bar()


class Bar:

    def __init__(self, y):
        self.y = y

    def __call__(self):
        return self.y


foo(x, Bar(y))