Skip to content

Symbolic Placeholding

A regular Python object serves as a program state after its creation, so it must be constructed with all required arguments fully specified and meet the type definition. Symbolic objects, in contrast, can serve as pure representations and can exist before being fully specified. This enables developers to start with an unfinished representation and gradually make it concrete. This is achieved through symbolic placeholding, which results in abstract objects.

Abstract objects

Abstract objects are symbolic objects that are not concrete, meaning they are not yet ready for triggering the __init__ logic upon creation. For example, Add(x=TBD(), y=1) represents an addition between a to-be-determined value and 1. Abstract objects can be partial objects or pure symbolic objects.

Partial objects

Partial objects are objects that have missing parts, which can be instantiated through the class method partial. Under the hood, the missing parts in the symbolic object are placeheld with pg.MISSING_VALUE. Such placeholding can occur at the immediate-children level or deeper into sub-trees. For example:

@pg.symbolize
class Exp:

    def __init__(self, x, y):
        self.x = x
        self.y = y
        print('`__init__` is called.')


# `a` is a partial object as `a.y` is not specified.
a = Exp.partial(x=1)
assert pg.is_partial(a)
assert a.sym_init_args['y'] == pg.MISSING_VALUE

# `b` is also a partial object as it contains partial object `a` as its sub-node.
b = Exp.partial(x=a, b=2)
assert pg.is_partial(b)

# Make partial objects concrete.
# At this point, we see the message "`__init__` is called" printed.
a.sym_rebind(x=2)
assert not pg.is_partial(a)
assert not pg.is_partial(b)

More on partial-object creation

Partial objects need to be explicitly created with partial. For example:

# Raises: `y` is not provided.
Exp(x=1)

# Raises: `y` is partial.
Exp(x=1, y=Exp.partial(x=1))

This means that when users need to create a hierarchy of partial objects, every containing class needs to call partial explicitly. This prevents human errors, but is also inconvenient. PyGX offers the context manager pg.allow_partial for this scenario, allowing partial objects to be created using standard class constructors:

with pg.allow_partial():
    a = Exp(x=1, y=Exp(1))
assert pg.is_partial(a)

For a partial object, the missing values in the object hierarchy can be queried via sym_missing:

# Shall print {'y.y': pg.MISSING_VALUE}
a.sym_missing()

Partial functions

For functions, there is a distinction between a partially bound function and a partial function object.

A partially bound function is a pg.Functor object whose arguments are partially specified, but each specified argument is concrete. For example:

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


@pg.symbolize
def bar(a, b):
    return a() + b()


# `f` is partially bound, but not partial.
f = foo(1)
assert not f.fully_bound
assert not pg.is_partial(f)
# `f` can be evaluated by providing the missing argument at call time.
assert f(y=2) == 3

# `g` is not partial since `f` is not partial.
g = bar(f)
assert not pg.is_partial(g)

# Raises: calling `a()` within `bar` will fail since `f` is partially bound.
# However, it's the user's responsibility to ensure a partially bound function
# may be used as an argument.
g(b=foo(1, 2))

On the other hand, a partial function object is a pg.Functor object whose bound arguments contain partial values. For example:

@pg.symbolize
class Foo:

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

    def __call__(self):
        return self.v ** 2


# `f` is now partial since `Foo()` is partial.
f = bar(Foo.partial())

Pure symbolic objects

PyGX introduces the concept of pure symbolic objects for describing a program whose details will be decided later. Leaf pure symbolic objects are instances of pg.PureSymbolic subclasses. Symbolic objects that contain pure symbolic objects as sub-nodes are also pure symbolic:

@pg.symbolize
class Foo:

    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.z = x + y


@pg.symbolize
class Bar:

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

    def __call__(self):
        return self.foo.x * self.foo.y


# `bar1` is a concrete object since all its sub-nodes are concrete.
bar1 = Bar(Foo(1, 2))
assert not pg.is_pure_symbolic(bar1)


class TBD(pg.PureSymbolic):
    pass


# `bar2` is pure symbolic since its `foo` argument is pure symbolic, which
# contains an object of `TBD` (a subclass of `PureSymbolic`).
bar2 = Bar(Foo(TBD(), 2))
assert pg.is_pure_symbolic(bar2)

Delayed evaluation

A pure symbolic object cannot be evaluated until it becomes concrete, meaning that the behavior of calling any non-symbolic method of a pure symbolic object is undetermined. As a result, the __init__ method of a pure symbolic object will also be delayed. For example:

# Raises: `bar2.__init__` has not been evaluated yet since it's pure symbolic.
bar2.foo

# Raises: `bar2.__call__` cannot be called since it's pure symbolic.
bar2()

# Manipulate `bar2` into a concrete object by replacing all `TBD` with integer 1,
# which triggers its `__init__`.
bar2.sym_rebind(lambda k, v, p: 1 if isinstance(v, TBD) else v)

# Okay: `bar2.__init__` is called by the end of `bar2.sym_rebind` since it's then concrete.
assert bar2.sym_init_args['foo'].z == 3

# Okay: `bar2.__call__` can be called now since it's concrete.
assert bar2() == 2

Placeholding targets

A PureSymbolic subclass developer can control what symbolic fields can be placeheld by the current pure symbolic class. For example, the hyper primitive pg.oneof makes sure all candidate values are acceptable to the target field when it is used as a placeholder. This is done by implementing the custom_apply method, which is inherited from the pg.typing.CustomTyping interface.

Caveats

As shown above, for symbolic classes created with pg.symbolize, the __init__ method is delayed until the object becomes concrete. For symbolic classes created by subclassing pg.Object, the on_sym_bound, on_sym_post_init, and on_sym_change events are always triggered when the object is first created or later mutated, even when the object is abstract. For logic that requires a concrete self, override on_sym_ready instead — it fires only once the object is concrete, so every field is guaranteed present:

@pg.members([
    ('x', pg.typing.Int()),
    ('y', pg.typing.Int()),
])
class MyObject:

    def on_sym_ready(self):
        super().on_sym_ready()
        # All symbolic attributes are guaranteed concrete here.
        self._z = self.x + self.y

If you must run logic in on_sym_bound itself (e.g. it has to fire even while the object is partial), guard the concrete-only parts with self.sym_abstract:

    def on_sym_bound(self):
        super().on_sym_bound()
        if not self.sym_abstract:
            self._z = self.x + self.y