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:
class Exp(pg.Object):
# `Any`, so the example can nest one `Exp` inside another below.
x: Any
y: Any
def on_sym_ready(self):
# Runs only once every field is present.
super().on_sym_ready()
print('`on_sym_ready` 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, y=2)
assert pg.is_partial(b)
# Fill in the missing part. `y` is what was absent, so rebinding it is what
# makes `a` — and therefore `b`, which holds it — concrete.
# At this point, we see the message "`on_sym_ready` is called" printed.
a.sym_rebind(y=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:
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:
For a partial object, the missing values in the object hierarchy can be queried
via 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 values¶
A pure symbolic value occupies a field for representation, not for
execution. It is a stand-in: something that says what a slot means before
anything has decided what goes in it. pg.oneof(['a', 'b', 'c']) is the
familiar one — it does not evaluate to a letter, it represents the choice
between them until a search algorithm settles it.
This is what separates symbolic OOP from ordinary OOP. An ordinary object must be built from values it can run with; here you can express the shape of an idea first and supply the values later, which decouples stating an idea from implementing it.
The leaves are instances of
pg.PureSymbolic subclasses, and anything
holding one is pure symbolic too — the property propagates up the tree:
@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¶
Because a pure symbolic value is representational, there is nothing to run
yet. For a class built with pg.symbolize, PyGX
enforces that: __init__ is delayed until the object becomes concrete, so
none of the state it would compute exists in the meantime.
# 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
Functors are not guarded this way
The delay above applies to symbolized classes. A functor holding a pure symbolic argument is still callable: its body runs, and receives the placeholder object itself rather than a value. Whether that fails depends on what the body does with it:
@pg.symbolize
def bar(v):
return v * 2
space = bar(pg.oneof([1, 2]))
space() # raises TypeError: unsupported operand type(s)
# for *: 'OneOf' and 'int'
A body that merely passes the argument along will not raise at all — it
just hands back the OneOf, which is rarely what the caller wanted. As
with partial functions, it is the caller's
responsibility not to invoke one before it is concrete. Materialize
first, then call:
(pg.iter yields one concrete bar per candidate, so the body finally
sees an int.)
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.
What a materialized program owns, and what it shares¶
Passing a value into a hyper primitive adopts it: it becomes a node in the search space, at a position inside the candidate list.
class Model(pg.Object, topo=True):
units: int = 8
class Exp(pg.Object, topo=True):
model: Any = None
tok: Any = None
m = Model(units=8)
m.topo_path # '' — free-floating
space = Exp(model=pg.oneof([m, Model(units=16)]))
m.topo_path # 'model.candidates[0]' — now held by the space
Materializing a program therefore cannot hand m straight back: a node has
one position, and the program needs it at model. So a topo=True candidate
is copied on the way out — the materialized program is independent of the
space that described it:
exp = pg.materialize(space, pg.DNA(0))
exp.model is m # False — a copy
pg.eq(exp.model, m) # True — an equal one
exp.model.sym_rebind(units=128) # mutating the program...
m.units # 8 — ...leaves the search space intact
That independence is the point: two programs drawn from the same space must not alias, or tuning one trial would corrupt the others.
Only the parts that are topo=True get copied. The rule is per node,
not per candidate — a value is copied exactly where it is held, i.e. where it
has a tree position. Everything else passes through by reference:
| Candidate shape | In the materialized program |
|---|---|
a topo=True object |
copied |
| a plain Python object | shared |
a topo=False pg.Object |
shared |
a topo=True object inside a plain list |
copied |
a plain object inside a plain list |
shared |
a plain field inside a copied topo=True object |
shared |
A copied holder does not isolate what it holds
The last row is the one that bites. Copying a topo=True candidate says
nothing about the opaque values inside it — every program that selects
that candidate shares them:
class Tokenizer: # a plain, non-symbolic object
def __init__(self, name):
self.name = name
self.calls = 0
shared = Tokenizer('bpe')
space = Exp(tok=pg.oneof([shared, Tokenizer('wp')]))
a = pg.materialize(space, pg.DNA(0))
b = pg.materialize(space, pg.DNA(0))
a.tok is shared # True — same object in every program
a.tok.calls += 1
b.tok.calls # 1 — state bleeds across trials
PyGX shares rather than copies here because it has no safe way to
duplicate an arbitrary Python object — a model, a client, an open
handle — and copying one silently would be worse than sharing it. If a
trial mutates such a value, isolate it deliberately: declare it as a
topo=True pg.Object so the tree owns it, or attach a
pg.field(transform=copy.deepcopy).
Two habits follow. Don't reuse a value after passing it into a search space —
it has been adopted, and belongs to the space now; pass
pg.maybe_clone if you need to keep your own.
And compare materialized values with pg.eq, never is.
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:
class MyObject(pg.Object):
x: int
y: int
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: