Skip to content

Migrating to 0.5

0.5 finishes what 0.4 started: topo=False — the flat, value-semantics posture — is now the default everywhere, and the one error it raises is named after its cause. Two breaking changes, both mechanical to migrate.

1. SymbolicModeError is now TopoNotEnabled

Before After
except pg.SymbolicModeError except pg.TopoNotEnabled

A rename with no alias — a sweep of except clauses is the whole migration.

The exception moves for the same reason the topo_* family did in 0.4: it is raised for exactly one cause — the class was declared topo=False — so it now says so. SymbolicModeError was also a misnomer: a topo=False object is a symbolic object (it keeps its init args, validates, compares by value, serializes, and rebinds); what it lacks is a parent link.

2. pg.functor, pg.symbolize and pg.wrap are now topo=False

The symbolizing decorators no longer join the symbolic tree by default — functions (@pg.functor, pg.symbolize on a function) and classes (pg.symbolize on a class, pg.wrap) alike, aligning them with pg.Object's own default.

The old default made every call adopt its arguments, so ordinary Python call patterns raised:

@pg.functor()
def combine(a, b): ...

shared = Node(1)
combine(shared, shared)   # 0.4: SharedHoldError — held at two keys
combine(shared, 1)        # 0.4: SharedHoldError — held by an earlier call

Both work now. A call is a call; it does not take ownership of what it is passed.

What you lose is upward reach from a decorated value: topo_parent / topo_path / topo_root raise TopoNotEnabled, changes do not climb from it to its holder, and contextual resolution and origin tracking — which walk the parent chain — do not resolve through one. Everything else is unchanged: arguments are still validated, compared by value, serialized, and rebindable at any depth, and partial and incremental binding work exactly as before.

Class wrappers rebuild without the tree. A flat wrapper still reruns the wrapped __init__ on rebind: its raw container members funnel path writes through the wrapper itself, which is notified. What does not fire flat is interior mutation of a symbolic member — the ordinary flat value-bag posture, same as any topo=False pg.Object holding one.

The bases flip with their decorators. pg.Functor and ClassWrapper are topo=False, so class Sum(pg.Functor) matches @pg.functor and a direct ClassWrapper subclass matches pg.wrap. The bases that stay topo=True are exactly the ones that require the tree: pg.Ref, InferredValue and pg.Compound.

If you need the tree, ask for it where the type is made:

@pg.functor(topo=True)
def search_space(a, b): ...

class Sum(pg.Functor, topo=True): ...

That is the right call for a pg.hyper search space, where a where clause filters candidates by topo_path. An explicit topo= or a base_class= you supplied always wins over the default.

For advanced callers of pg.typing.Schema.apply: respect_raw_storage is now a tri-state, mirroring the native signature. None (the new default) means no per-field gating — the old behavior of the former False default. An explicit respect_raw_storage=False now means "gate const fields, unauthored specs resolve raw" (a flat owner's axis) rather than "no gating"; spell the old intent as None, or just omit the argument.

3. Raw-member path writes now notify

Not breaking, but observable: a sym_rebind whose path lands in a raw dict / list member used to change the value silently. It now notifies the nearest symbolic holder (on_sym_change fires with the full path) and participates in model-validation rollback, so the same change reports identically whichever spelling delivers it:

f.sym_rebind({'x.y': 2})   # 0.4: silent   0.5: on_sym_change(['x.y'])
f.sym_rebind(x={'y': 2})   # notified in both

Direct mutation (f.x['y'] = 2) stays invisible — PyGX is not involved in it. If an on_sym_change override filtered on top-level keys only, it will now also see dotted paths into raw members.

4. A no-op sym_rebind no longer raises (0.5.2)

sym_rebind's raise_on_no_change now defaults to False: a call that carries no updates — an empty mapping / no kwargs, or a rebinder that returns everything unchanged — returns self silently instead of raising ValueError.

d = pg.Dict(a=1)
d.sym_rebind(lambda k, v, p: v)              # 0.5.1: ValueError   now: no-op
d.sym_rebind({})                             # 0.5.1: ValueError   now: no-op
d.sym_rebind({}, raise_on_no_change=True)    # opt back in: ValueError

Assigning a value equal to the current one was never a "no change" — it is an update that happens to keep the value — so it did not raise before and still does not.

If code relied on the raise to detect an empty rebinder result, pass raise_on_no_change=True explicitly.

5. Renames without aliases (0.5.2)

Three misspelled public names are renamed outright — imports of the old spellings fail, and there are no compatibility aliases:

Before After
pg.algo.early_stopping.EarlyStopingPolicyBase pg.algo.early_stopping.EarlyStoppingPolicyBase
pg.algo.scalars.Substraction pg.algo.scalars.Subtraction
pg.algo.mutfun.Substract pg.algo.mutfun.Subtract

Wire type names follow the classes: JSON serialized before 0.5.2 that contains one of the old _type names no longer loads. Re-serialize such artifacts with 0.5.2, or rewrite the _type strings in place.

6. Behavior fixes in 0.5.2

Parameters that were silently inert now do what their docs say, and a few inconsistencies are gone. Adjust call sites that (knowingly or not) depended on the old behavior:

  • pg.patching.patch_on_*: calling without value or value_fn now raises (it used to silently patch matched fields to None); an explicit value=None patches TO None. Spell the old no-arg behavior as value=None.
  • pg.KeyPathSet.union(other) now merges into the receiver and returns it; pass copy=True for the previous always-copy behavior.
  • pg.List: every shrinking op honors the value spec's min_sizepop() and del l[i] used to slip below it silently. remove() now works under accessor_writable=False, like the other mutating methods.
  • pg.materialize(..., use_literal_values=...): the flag works again (integer decisions read as literal values when True) and its dead default flips True→False, so default calls keep index interpretation.
  • pg.concurrent.thread_local_value_scope: a key absent on entry is restored to initial_value on exit instead of being deleted.
  • pg.coding permissions: ASSIGN now gates augmented (x += y) and annotated (x: int = y) assignments; LOOP gates plain with blocks.
  • pg.typing.ListKey: max_value=0 is a real (empty) bound; unbounded is spelled None or -1.
  • pg.coding.run / sandbox_call: a child process that dies without reporting (typically the spawn re-import of an unguarded __main__) raises ChildProcessError immediately instead of hanging until timeout.
  • Tuning: a trial's final measurement is the one at the largest step (previously last-added); Trial.completed_time is now populated.
  • Plus fixes with no migration impact: pg.from_json no longer consumes its input payload; pg.notify_once works for topo=False objects; native List.extend/+=/* clone held elements like the oracle; List.sym_hash is class-blind like its eq; pg.typing.is_subclass accepts union types; patcher validators no longer run against a replacement object they never saw.

7. Behavior fixes in 0.5.3

  • Bound methods render by name. repr(), str() and pg.to_html() used to blow the stack on any value holding a bound method of an object that (directly or transitively) held it back — CPython renders a bound method as <bound method C.m of {self!r}>, embedding repr(__self__), and that nested repr re-entered formatting through Python rather than through format(), so the existing cycle guard never saw it. A bound method now renders as <bound method C.m>, without expanding its owner. A method is behavior, not data: naming it is both safer and more useful. Affects the rendered text of any object holding a bound method, cycle or not.

This fix spans both cores, so the native half ships in pygx-core 0.5.2; pygx 0.5.3 pins it. Upgrading pygx pulls the matching core.

8. Behavior fixes in 0.5.4

  • A hyper primitive reused at several locations is ONE decision. A pg.oneof / pg.floatv / pg.manyof value bound to a name and placed at two or more locations in a search space used to be treated as that many independent decisions; it is now a single decision whose choice applies at every location. This shrinks the search space of any program that did this — pg.dna_spec(...) reports fewer decision points, and a search visits fewer candidates. To keep independent decisions, create a separate primitive per location (call pg.oneof(...) once per site). To tie two locations together explicitly, pg.hyper.reference remains the spelled-out form. (#821)

  • pg.Ref in a topo=False holder now resolves. A flat holder only refuses the inferential values that need to walk the tree (Inferential.needs_topo() — e.g. pg.ContextualGetter); a pg.Ref reads as its referent instead of raising TopoNotEnabled. Code that caught that error as a signal no longer sees it. (#823)

  • A subclass that changes topo re-bakes its field accessors. Reading an inferential field on a topo=False subclass of a topo=True class (or the reverse) used to go through the accessor baked for the base's mode and leak the inferential's raw AttributeError; it now behaves as the subclass's mode dictates. Reads on such subclasses are slower until the native-core route in #827 lands; no class in pygx itself hits this. (#825)

These fixes span both cores, so the native half ships in pygx-core 0.5.3; pygx 0.5.4 pins it. Upgrading pygx pulls the matching core.

9. Behavior fixes in 0.5.5

  • A topo=False holder refuses a tree-walking inferential where it lands. Constructing, assigning or sym_rebind-ing a contextual value (ValueFromParentChain, pg.contextual(...), any Inferential whose needs_topo() is true) into a topo=False object now raises TopoNotEnabled at that write, naming the field — previously the write succeeded and the error surfaced at the first read. A class whose field defaults to such a value can no longer be constructed without supplying that field; declare it topo=True (or give the inferential a needs_topo() that returns False if its infer() reads no tree state). pg.Ref and other self-contained inferentials are unaffected. (#829)

This fix spans both cores, so the native half ships in pygx-core 0.5.4; pygx 0.5.5 pins it. Upgrading pygx pulls the matching core.

10. Why this changed

0.4 made pg.Object flat by default on the argument that most objects are values, not tree nodes. The decorators stayed tree-mode — and the seam showed: one object passed to two functor arguments raised SharedHoldError, the same value reused across two calls raised, and the workarounds leaked (pg.Ref handed the callee a Ref; assign='ref' was not reachable from the decorator).

The features that once pinned the decorators to the tree stopped needing it: partial binding works flat, and a flat wrapper's reset-on-rebind fires from holder notification on every schema shape. What remained of topo=True in the decorators was the cost — call-time adoption — without the benefit, so the default moved to where pg.Object's already was, and topo=True became what it is on classes: the explicit opt-in for values that live in a tree.