Symbolic Placeholding¶
A regular Python object serves as a program state after its creation. Therefore, it must be constructed with all required arguments fully specified and meet the type definition. On the other hand, symbolic objects 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 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.x == 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)
# Making partial objects concrete.
# Till this point, we shall see message "`__init__` is called" printed out.
a.rebind(x=2)
assert not pg.is_partial(a)
assert not pg.is_partial(b)
More on Partial Objects 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, it requires every containing
class to call parital
explicitly. This prevents human errors, but is also inconvenient.
PyGlove offers context manager pg.allow_partial
to address
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 of the 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 make sure
# a partially bound function may be used as an argument.
g(b=foo(1, 2))
On the other hand, a partial function object is 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¶
PyGlove introduces the concept of pure symbolic objects, for describing a program whose details will be decided later.
Leaf pure symbolic objects are the instances of pg.PureSymbolic
subclasses.
Symbolic objects that contain pure symbolic objects as its 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` which is 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.rebind(lambda k, v, p: 1 if isinstance(v, TBD) else v)
# Okay: `bar2.__init__` is called by the end of `bar2.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¶
Besides, the PureSymbolic
subclass developer can control what symbolic fields can be
placeheld by the current pure symbolic class. For example, hyper primitive pg.oneof
will make sure all candidate values are acceptable to the target field when it is used
as a placeholder. This can be done via implementing the pg.PureSymbolic
method, which is inherited from the pg.typing.CustomTyping
interface.
Caveats¶
As we have shown above, for symbolic classes created with pg.symbolize
,
the __init__
method will be delayed until the object becomes concrete. For symbolic classes created by
subclassing pg.Object
, special care needs to be taken care of when handling
_on_bound, _on_init and _on_change events. These methods are always triggered when the
object is first created or later mutated, even when the object is abstract. So always check self.sym_abstract
to carry on the logic that requires a concrete self. For example:
@pg.members([
('x', pg.typing.Int()),
('y', pg.typing.Int())
])
class MyObject:
def _on_bound(self):
super()._on_bound()
# This check ensures all symbolic attributes are concrete.
if not self.sym_abstract:
self._z = self.x + self.y