Symbolic Operations¶
Symbolic operations are operations that can be applied to any node in a symbolic tree. They are organized into five categories:
- Topological: Symbolic tree access, traversal, and query.
- Semantical: Common semantics for symbols, such as partial, pure symbolic, etc.
- Replication: Symbolic tree cloning and serialization.
- Mutation: Mutating nodes in a symbolic tree in various ways.
- Tracking: Tracking creation of objects for debugging.
Requires sym=True
The tree-shaped operations on this page — topology access, traversal,
query, path-based mutation — operate on symbolic-tree nodes. pg.Object
subclasses are flat by default; declare tree-participating classes with
sym=True (the mode is inherited by subclasses), e.g.
class Zoo(pg.Object, sym=True): ... — as all the zoo classes below are.
Types created via pg.symbolize / pg.functor, and the pg.Dict /
pg.List containers, are symbolic already. The value-shaped operations
(equality, hashing, diff, clone, serialization, formatting) work in both
modes.
To make it easy to demonstrate symbolic operations, we use a symbolic object
zoo as the operation target:
zoo = Zoo(
name='San Diego Zoo',
city='San Diego',
exhibits=[
Cage(animal=Python('Bob', color='black')),
Pool(animal=Shark('Jack')),
],
)
When we talk about "manipulating the symbolic tree" in the context of a
zoo-based program, it means changing one or more sub-nodes within zoo.
Topological¶
Starting from an arbitrary node in a symbolic tree (e.g., Cage, Python,
Shark from the code above), PyGX allows the user to access any other nodes
in the tree. To do so, PyGX maintains bi-directional links between
containing/contained symbolic objects, allowing update notifications to
propagate through them.
Root¶
Users can access the root of the current symbolic tree via the
sym_root property. For example:
assert zoo.sym_root is zoo
assert zoo.exhibits[0].sym_root is zoo
assert zoo.exhibits[0].animal.sym_root is zoo
Parent node¶
Similarly, users can access the immediate parent (the containing node) of a
symbolic value via the sym_parent
property. For example, the Cage object in zoo has exhibits (a symbolic
list) as its parent:
Tip
PyGX automatically sets the parent of a symbolic object when assignment happens. For example:
Ancestor¶
sym_ancestor can be useful when users
require an ancestor in the containing chain that meets specific criteria
instead of the root or immediate parent. For instance, the following code
illustrates how to retrieve the nearest Zoo object from an Animal object
located in a zoo:
Child nodes¶
Child nodes (a.k.a. symbolic attributes) mean different things for different symbolic types:
-
For symbolic classes, the child nodes are the arguments of the
__init__method, which can be accessed throughsym_init_args. For example: -
For symbolic functions, the child nodes are the bound arguments of the function, which can be directly accessed through its properties as well as
sym_init_args: -
For symbolic lists, the child nodes are the items in the list, which can be directly accessed via the
[]operator with their indices: -
For symbolic dicts, the child nodes are the key/value pairs stored in the dict, which can be accessed via either the
[]operator or as attributes:
The following table illustrates the uniform APIs to test and access symbolic attributes across symbolic types:
| Method | Description |
|---|---|
sym_hasattr |
Test if a child key exists. |
sym_getattr |
Get the value of a child by key. |
sym_keys |
Iterate the child keys. |
sym_values |
Iterate the child values. |
sym_items |
Iterate child key/value pairs. |
For example:
assert list(zoo.sym_keys()) == ['name', 'city', 'exhibits']
assert list(zoo.sym_values())[0] == 'San Diego Zoo'
assert list(zoo.sym_items())[0] == ('name', 'San Diego Zoo')
assert zoo.sym_hasattr('name') is True
assert zoo.sym_getattr('name') == 'San Diego Zoo'
Descendants¶
In addition to accessing immediate child nodes,
sym_descendants is a handy tool to
retrieve all nodes in the sub-tree. Users can also specify a filter function
(using the argument where) and choose whether to include intermediate
nodes, leaves, or both in the returned nodes (using the argument option).
For instance, the following code demonstrates how to select all animals from
a zoo:
assert zoo.sym_descendants(lambda x: isinstance(x, Animal)) == [
Python('Bob', color='black'),
Shark('Jack'),
]
Location¶
Each symbolic object has a unique location within a symbolic tree, represented
as a key path (pg.KeyPath) — a path consisting of the
keys from the root node to the current node.
For example, a.b[0].c is a path with height 4:
- Level 0: a symbolic object or dict as the root node, bearing an empty key.
- Level 1: a symbolic object or dict assigned to attribute
aof the root node. - Level 2: a symbolic list assigned to attribute
bof the level-1 node. - Level 3: a symbolic object or dict assigned to the first item of the level-2 list.
- Level 4: a value assigned to argument
cof the level-3 node.
The property sym_path is the API to access
the symbolic location, which is set when a symbolic object is added into a
symbolic tree, and is updated when the hierarchy of the tree changes.
Relational¶
IS-A and HAS-A are two common relationships among symbolic
representations. Symbolic objects are instances of their symbolic classes,
therefore the IS-A relation can be easily tested through Python's
isinstance operator. For the HAS-A relation,
pg.contains does the job. For example:
@pg.symbolize
def foo(x, y):
pass
@pg.symbolize
def bar(a, b):
pass
f = foo(1, 2)
b = bar(f, 3)
# `f` has an IS-A relation with class `foo`.
assert isinstance(f, foo)
assert isinstance(b, bar)
# `f` has a HAS-A relation with integer 2.
assert pg.contains(f, 2)
# HAS-A is transitive.
assert pg.contains(b, 2)
# HAS-A can be tested on types as well.
# The following code asks whether `b` contains any sub-node of type `foo`.
assert pg.contains(b, type=foo)
Traversal¶
pg.traverse is the API for facilitating symbolic
tree traversal:
- Users provide either a pre-order visitor function, a post-order visitor function, or both to perform the traversal.
- Each visitor function takes a tuple of (
key_path,value,parent) as input and returns an action (seepg.TraverseAction) to indicate whether to continue the traversal, stop, or skip the current branch.
For example:
def print_integers(key_path, value, parent):
if isinstance(value, int) and isinstance(parent, Foo):
print(key_path, value)
return pg.symbolic.TraverseAction.ENTER
# Print all integer arguments of `Foo` objects in the symbolic tree.
pg.traverse(tree, print_integers)
Query¶
pg.query is the helper when the user needs to query a
symbolic tree, which selects nodes from the tree based on user-defined
predicates:
- A regular expression can be provided to perform path-based filtering.
- A value selector can be provided to perform value-based filtering.
- A custom selector can be provided to perform more complex filtering based on a node's path, value, and parent node.
For example:
@pg.members([
('x', pg.typing.Int()),
('y', pg.typing.Int()),
])
class A(pg.Object, sym=True):
pass
value = {
'a1': A(x=0, y=1),
'a2': [A(x=1, y=1), A(x=1, y=2)],
'a3': {
'p': A(x=2, y=1),
'q': A(x=2, y=2),
},
}
# Query by path regex.
print(pg.query(value, r'.*p'))
# {'a3.p': A(x=2, y=1)}
# Query by value.
print(pg.query(value, where=lambda v: v == 2))
# {
# 'a2[1].y': 2,
# 'a3.p.x': 2,
# 'a3.q.x': 2,
# 'a3.q.y': 2,
# }
# Query by path, value, and parent.
print(pg.query(
value, r'.*y',
where=lambda v, p: v > 1 and isinstance(p, A) and p.x == 1))
# {
# 'a2[1].y': 2,
# }
On top of pg.query, every symbolic object has an
inspect method that combines pg.query
with a print, useful in REPLs:
zoo.inspect(where=lambda v: isinstance(v, Animal))
# Prints each Animal node under zoo with its key path.
Formatting¶
A symbolic tree can be presented nicely for human consumption. By default,
all symbolic types override __repr__ and __str__ so that a human-readable
format can be shown during debugging:
__repr__formats a symbolic tree into a single-line string representation, which is usually used in error messages.__str__formats a symbolic tree into a multi-line string representation, which is usually used for debugging.
Both methods are based on pg.format, which provides a
rich set of features for formatting symbolic trees. For example, to exclude
keys with default values from the string representation:
@pg.members([
('x', pg.typing.Int()),
('y', pg.typing.Int(default=2)),
])
class Foo(pg.Object):
pass
foo = Foo(1, 2)
print(foo.format(compact=False))
# Foo(
# x=1,
# y=2
# )
print(foo.format(compact=False, exclude_defaults=True))
# Foo(
# x=1
# )
Semantical¶
In software development, developers often need to work with object representations rather than their states. This poses requirements such as comparing the equality of two representations, hashing objects using their representations, and cloning objects through their representations instead of duplicating their entire state. The APIs necessary for achieving these objectives are discussed in this section.
Equality¶
Symbolic equality is determined by matching types and equal symbolic attributes, regardless of whether the internal states are identical. For example:
@pg.symbolize
class File:
def __init__(self, file_path):
self._file_path = file_path
self._file_handle = None
def read(self, n_bytes):
self._file_handle = open(self._file_path)
# ...
path = 'a.json'
f1 = File(path)
# `f1.read()` triggers the creation of `f1._file_handle`.
f1.read(10)
f2 = File(path)
assert pg.eq(f1, f2)
f1 and f2 are considered equal as they have the same file_path, even
though their _file_handle are different.
Symbolic equality can be tested via pg.eq and
pg.ne:
- For symbolic objects, the member method
sym_eqis called to determine whether they are symbolically equal. - For non-symbolic objects, the comparison is delegated to
object.__eq__andobject.__ne__.
Tip
For symbolic classes that subclass pg.Object,
whether to use symbolic equality as the default
__eq__/__ne__/__hash__ behavior can be customized via the class
variable
use_symbolic_comparison,
which is set to True by default. For classes symbolized via
pg.symbolize, this can be achieved by
specifying the eq argument to pg.symbolize, which is set to
False by default.
Less-than / greater-than¶
Two symbolic objects can be compared not only for equality, but also for
ordering. A symbolic object x is considered less than another symbolic
object y when:
- If
xandyare comparable by their values, the__lt__operator is used for comparison (e.g.,bool,int,float,str). - If
xandyare of the same type and are symbolic containers (e.g.,list,dict,pg.Symbolic), the order is determined by the order of their first differing sub-nodes. For example,['b']is greater than['a', 'b']. - If
xandyare not directly comparable and have different types, they are compared based on their types. The order of different types is:pg.MISSING_VALUE,NoneType,bool,int,float,str,list,tuple,set,dict, functions/classes. If different functions or classes are compared, their order is determined by their qualified name. - Non-symbolic classes can define the method
sym_ltto enable symbolic comparison.
Here are some examples:
assert pg.lt(False, True) == (False < True)
assert pg.lt(0.1, 1) == (0.1 < 1)
assert pg.lt('a', 'ab') == ('a' < 'ab')
assert pg.lt(['a'], ['a', 'b'])
assert pg.lt(['a', 'b', 'c'], ['b'])
assert pg.lt({'x': 1}, {'x': 2})
assert pg.lt({'x': 1}, {'y': 1})
assert pg.lt(A(x=1), A(x=2))
assert pg.lt(pg.MISSING_VALUE, None)
assert pg.lt(None, 1)
assert pg.lt(1, 'abc')
assert pg.lt('abc', [])
assert pg.lt([], {})
assert pg.lt([], A(x=1))
Similarly, pg.gt determines if a symbolic object is
greater than another symbolic object by its representation.
Hashing¶
The semantics of symbolic hashing are aligned with equality: two symbolically equal objects should produce the same symbolic hash value.
In PyGX, the symbolic hash can be computed via pg.hash:
- For symbolic objects, the member method
sym_hashis called to compute the symbolic hash value. - For non-symbolic objects, PyGX falls back to the original hash semantics.
Warning
Always override sym_hash when sym_eq is overridden.
Difference¶
The symbolic differences between two objects can be obtained via
pg.diff. pg.diff is a handy tool for figuring out
which parts of the objects differ.
Special symbolic forms¶
PyGX supports abstract objects through symbolic placeholding (see Placeholding), which allows creating and manipulating symbolic objects that are merely representations. Here is a summary of operations that detect the forms of symbolic objects.
| API | Method | Description |
|---|---|---|
pg.is_abstract |
sym_abstract |
Test whether an object is abstract. |
pg.is_partial |
sym_partial |
Test whether an object is partial. |
pg.is_pure_symbolic |
sym_puresymbolic |
Test whether an object is pure symbolic. |
pg.is_deterministic |
N/A | Test whether an object contains objects of pg.symbolic.NonDeterministic. |
The following APIs offer capabilities to query parts of special interest:
| Method | Description |
|---|---|
sym_missing |
Query the missing values from the object. |
sym_nondefault |
Query the non-default values from the object. |
Replication¶
Symbolic objects can be replicated in process or across processes. In-process replication is achieved by cloning, and inter-process replication is achieved by serialization/deserialization.
Warning
By default, symbolic replication does not deal with replication of
internal states, which means a replicated symbolic object is equivalent
to a freshly constructed object with the same binding parameters. The
user can optionally handle internal state replication by overriding the
_sym_clone and sym_jsonify methods.
Clone¶
Users can clone a symbolic object via the
pg.clone function or call the sym_clone member method
of the symbolic object. The semantics of symbolic clone are as follows:
- For symbolic types,
sym_cloneis called when cloning the object. - For non-symbolic types,
__copy__/__deepcopy__is called when cloning the object. Thedeepargument ofpg.clonedetermines which function to use.
It is common to clone a symbolic object with overrides. This can be done with
the overrides argument, which accepts a dictionary of paths to values to
override in the cloned object.
Serialization¶
The automatic serialization/deserialization capability for symbolic objects
is provided by the member method sym_jsonify and the class method
from_json. sym_jsonify converts the current symbolic object into a Python
dict mapped from strings to basic Python values, while from_json converts
them back.
Based on the two methods, PyGX provides a few helper methods for serialization and deserialization.
| Method | Description |
|---|---|
pg.to_json |
Converts a symbolic object into a plain Python dict. |
pg.from_json |
Converts a plain Python dict into a symbolic object. |
pg.to_json_str |
Converts a symbolic object into a JSON string. |
pg.from_json_str |
Creates a symbolic object from a JSON string. |
pg.save |
Saves a symbolic object to a file. |
pg.load |
Loads a symbolic object from a file. |
Tip
For deserialization to work, the user class definition needs to be imported first.
The save and load hook¶
pg.set_save_handler and
pg.set_load_handler are introduced to
allow users to plug in custom IO operations when calling
pg.save and pg.load. Through
this, users can load/save symbolic objects in cloud-based storage without
changing the client code.
Mutation¶
Symbolic mutation is the core of symbolic programming. PyGX provides a rich set of APIs for mutating symbolic objects.
Location-based mutations¶
Location-based mutation is the most basic form of symbolic mutation, performed
through Symbolic.sym_rebind with a dict of
key paths to new values:
zoo.sym_rebind({
'name': 'San Francisco Zoo',
'exhibits[0].animal.name': 'Alice',
'exhibits[1].animal.name': 'Bruce',
})
The keys are key paths (pg.KeyPath-compatible strings) and the values are
the new values for those nodes. Nodes that are not listed are left untouched.
Each rebind triggers the lifecycle events
(on_sym_change, on_sym_bound / on_sym_ready, parent notifications)
exactly once per affected object, regardless of how many keys in the same
call landed inside that object.
Pattern-based mutations¶
Often, the user mutates a symbolic object by rules. Many of these rules can
be described as patterns. For example: change the name property of all
objects, or change the filters property if the object type is Conv2D.
Built on top of Symbolic.sym_rebind, pygx.patching is a sub-module of PyGX
for pattern-based object patching. Common patterns are supported:
| Method | Description |
|---|---|
pg.patching.patch_on_key |
Replaces objects assigned to certain keys (described by a regular expression) in the tree. |
pg.patching.patch_on_path |
Replaces objects with certain paths (described by a regular expression) in the tree. |
pg.patching.patch_on_value |
Replaces objects whose values match a condition. |
pg.patching.patch_on_type |
Replaces objects of specific types in the tree. |
pg.patching.patch_on_member |
Replaces objects that are members of a given type. |
Rule-based mutations¶
More complex symbolic mutations are achievable by passing a transform function
to Symbolic.sym_rebind as a rebinding rule. The function takes three inputs —
the key_path, value, and parent of each node — and returns the new
value (or the original value to leave it untouched):
The traversal is bottom-up: leaf nodes are visited first, then their parents, so a transform can rely on its children already being in their post-transform form.
Command-based mutations¶
For transformations that are reusable across object trees, PyGX exposes a
declarative pg.patcher decorator that registers a
named patcher with arguments. Patchers can then be applied by name, which
makes them composable and serialisable:
@pg.patcher([('factor', pg.typing.Int(min_value=1))])
def scale_ints(unused_obj, factor):
def transform(k, v, p):
if isinstance(v, int):
return v * factor
return v
return transform
# Apply a patcher by URL-like spec.
pg.patch(zoo, 'scale_ints?factor=3')
See pygx.patching for the full registry / apply / compose
surface.
Sealing an object¶
Symbolic objects can be sealed against further mutation via
Symbolic.sym_seal(), and unsealed by passing
False:
zoo.sym_seal() # equivalent to zoo.sym_seal(True)
zoo.sym_rebind(name='X') # raises WritePermissionError
zoo.sym_seal(False)
zoo.sym_rebind(name='X') # OK
Sealing is useful for objects that should be treated as immutable after a
setup or configuration phase — for instance, a Trainer config that should
not be modified once training has started.
Tracking¶
Since a symbolic object can be created and modified at runtime, sometimes we
want to track the origin of symbolic objects for debugging purposes. PyGX
introduces an Origin class, an instance of which can be associated with a
symbolic object during its creation. The Origin object contains stack
information and the source form of the symbolic object — whether it's a
file path string or an object from which the current object was cloned. The
user can also add origin information to objects using
Symbolic.sym_setorigin and access it using the Symbolic.sym_origin
property.