Conversation
Add `computation_graph.composers.coloring` + the runner's node-activation primitive: declare which color (opaque token) each node belongs to while BUILDING the graph and derive `run.NodeActivation` from those tags -- instead of reconstructing colors from a post-assembly graph's shape. Tags ride `func.__dict__`, so they survive `duplicate_function` (functools.wraps copies `__dict__`). run.py: `NodeActivation` / `ChangeActiveColors` / `to_callable_with_node_activation` (opt-in per-node skipping; uncolored graphs behave exactly as before) + `to_callable_with_coloring` (derive the activation from tags, then compile). coloring.py: add_colors(colors, subgraph, empty=...) / tag_empty / read_colors / read_empties; mark_observer / observer; pin_core_func; latch(current, present, default); build_node_activation_from_edges(edges) -- the whole NodeActivation from tags: single-color rule (>=2 colors = shared = always-on), a tolerance-aware must-run closure, and combiner-aware boundary defaults from the `empty` tags. memory.py: the observer combinators (accumulate / changed / ever / lag) auto-mark via `coloring.observer`. The engine stays domain-agnostic: colors are opaque tokens; nothing here knows skills / routes / effects. Validated on a 66k-node production voice agent: ~53.5k nodes pruned per turn with zero starvation vs the trusted data-flow baseline, ~12x faster on no-LLM turns, behavior byte-identical on a turn-by-turn e2e. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ghtening
run.py:
- Factor the async runner's "gather pending futures, fold results back, split
skips from real exceptions" into one `_gather_pending` helper shared by the
restart loop and the final harvest; drop the now-dead `_group_by_is_future`
and align both sites on `asyncio.isfuture`.
- Drop `to_callable_with_node_activation`; `to_callable_with_coloring` derives
the activation from tags and compiles via the `_multi` builder directly.
Tests/diagnostics that pass an explicit NodeActivation compile via the
existing `to_callable_with_side_effect` curry.
coloring.py:
- Collapse the four guarded-`setattr` taggers onto one `_tag_func` primitive.
- Mark internal-only helpers private: `_read_empties`, `_mark_observer`,
`_pin_core_func`, `_read_colors`. Public surface is now `add_colors`,
`tag_empty`, `observer`, `latch`, `build_node_activation_from_edges`.
- Fix stale docstrings (the never-existent `observer_nodes` / `pin_core`).
All 88 computation_graph tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… machinery ChangeActiveColors declarations now COMPOSE: the runner activates the union of every declaration observed in a pass (replacing the seed) instead of last-wins, so independent declarers (one per routing context) can no longer silently cancel each other on a multi-skill turn. The sync runner is restructured to full-pass-then-check to match the async one (required to see all declarers), and a union that revisits an earlier active set raises ColorDeclarationsDidNotConverge instead of looping. Adds coloring.pin_core: pin a shared, core-authored subgraph CORE at its authoring site so per-skill duplicates of it are never single-colored by a later sweep (the absorbing tag rides func.__dict__ through duplication). Also adds the CG_SKIP_TRACE env knob (log skipped nodes + colors, substring filtered) used to diagnose the starvation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_next_active_colors (union the pass's ChangeActiveColors declarations, settle or raise ColorDeclarationsDidNotConverge on a revisit) and _drop_color_dependent were duplicated verbatim between _run_graph and _run_graph_async; the loops now read as schedule-pass -> next-active -> restart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kip trace Nothing passes an initial active-colors set anymore (the nlu seed experiment was dropped: pinning covers every validated need), so the reducer is back to (prev, sources) and every run starts from no active colors; a declaration is the only way to activate. Removes the untrusted prune_shared option (its own docs said "test before trusting" -- never trusted), the CG_SKIP_TRACE diagnostic env knob, and stale docstring references (emit_active_colors never existed). Tests that selected colors via the seed now do it the production way -- through a declarer node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Partially reverts the unused-surface cleanup: the reducer's optional active_colors argument (and the initial-colors plumbing) comes back. The nlu seed built on it turned out to be REQUIRED, not a perf nicety: multi-turn flows (appointment PID sub-flows, proactive care-gap, UAT) have mid-flow turns where routing state does not re-declare the engaged skill; without the seed the whole skill prunes and the bot returns no action (28/29 comprehensive ff-on failures). prune_shared, CG_SKIP_TRACE, and the stale docstring references stay removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erez-work
force-pushed
the
CORE-08-graph-coloring
branch
from
July 4, 2026 14:05
a046b5c to
b6add9b
Compare
Variable-exposure transformers are often plain functions (or duplicated graphs) handed to transform_and_expose; pinning them at that authoring site needs a single-callable form of pin_core. Same absorbing-tag semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes surfaced by wiring coloring.latch around a COLORED producer (the variable-exposure state input): * latch: `current` now enters through a tolerant make_first + pinned default fallback -- a pruned producer yields the default (-> previous) instead of starving the pinned carry_forward at an intolerant frontier, which the must-run closure would (correctly) answer by forcing the producer's whole input cone always-on (measured on bon_secours: prunable 50k -> 26k, greeting 257ms -> 6.5s). A terminal `current` (the var-bus consume) was already tolerant; the guard is harmless there. * _is_tolerant_consumer: duplicate_function prefixes __name__ with "duplicate of ", so the exact-name match misclassified every DUPLICATED first_sink as intolerant (24 such frontiers closure-forced 31k nodes). Strip the prefix -- a duplicated first_sink is exactly as tolerant as the original. After both: strict audit clean (0 untagged intolerant frontiers), prunable 50,460, greeting 321ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Master (#82) turned GraphType into a frozen dataclass (.edges frozenset + explicit .sink), moved merge_graphs from base_types to graph (with a mandatory sink_node_or_graph), and dropped graph.get_leaves. Port the coloring machinery: * coloring.py: normalize GraphType-or-edges at every public entry point (_edges helper), local _leaves replaces graph.get_leaves, pin_core / latch recognize GraphType as a graph, latch merges via graph.merge_graphs with its pass-through sink. * run.py: rename the runner param shadowing the graph module (the coloring color_dependent traversal needs graph.traverse_forward). * tests: graph.merge_graphs with explicit sinks; .edges where node enumeration wants raw edges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt optional A produced domain-unknown now passes through the latch like the always-run baseline would deliver it; latching over it resurrected stale values (the computation-side sibling of the consume false latch). The make_first guard yields a private _PRUNED sentinel instead of the caller default, so pruning is detected structurally rather than by value predicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-context ROUTE declarer node is color-dependent, so on a mid-flow route change the newly-selected skill's declarer prunes under the newly-active color and its declaration is dropped on the restart -- the next turn then seeds empty and the just-selected skill's machinery prunes (no DOB question, unfilled route name, etc.). Record the restart loop's settled `active` set under a reserved results key (EFFECTIVE_ACTIVE_COLORS_KEY) in both runners. It survives restarts intact, so a caller that persists state can seed it next turn instead of relying on a declarer node that may have pruned. Domain-agnostic: the engine still only knows colors + the ChangeActiveColors event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Color tags ride the process-global func.__dict__ so they survive duplicate_function, but many funcs are shared BY REFERENCE across bot builds (generic gamla combinators, module-level slot/configurable defs built once at import). A bot built earlier stamped these shared objects and a bot built later inherited the foreign colors -> mis-pruning. In container/API serving this spans real assistants (multiple bots preloaded per process), so it is a production correctness bug, not just a test artifact. Fix: register every func _tag_func stamps in a module set, and add reset_colors() to strip the origin/empty/observer tags off all of them. The consumer calls it once at the START of each bot build (before construction and the color sweep). Untagged funcs default to CORE (always-run), so a reset can only ever under-prune, never mis-prune. Reset at build START (not after compile) deliberately: multi-skill bots compile N subgraphs and clearing between them would leave later compiles uncolored -- the failed earlier attempt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reset_colors() previously wiped color/empty/observer tags off every tagged func's __dict__ at each build start. That is correct but ruinously slow under the test runner (dagz), which instruments every __dict__ mutation for dependency tracking -- tens of thousands of mutations per build blew the suite from ~15m to >60m (job timeout). Move the tags into module-level maps keyed by func. reset_colors() now just drops the maps: O(1), zero per-func mutation, and coloring never touches the shared process-global func objects at all. Provenance still survives duplicate_function: functools.wraps sets inner.__wrapped__ = original, so _resolve walks the __wrapped__ chain to recover a duplicate's tag from the func it was copied from. Core pins and empties resolve the same way. Behavior is unchanged (127 CG tests pass, duplication-survival test green; the poison->victim contamination repro still fixes the victim). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous reset_colors() wiped tags off every func's __dict__ per build. That is correct but ruinous under the test runner (dagz), which instruments __dict__ mutations for dependency tracking -- tens of thousands of mutations per build blew the suite past its 60m timeout. (A prior attempt to move tags into module dicts was no better: dagz's hires_registry_tracking instruments dict registries too, and the run still timed out.) Keep the tags on func.__dict__ (one setattr per tag, cheap -- the same tagging the passing no-reset run used) but stamp each as a (generation, value) PAIR. reset_colors() just increments a module-global build generation; readers ignore any tag from an older generation. So per-build isolation costs ONE int bump and mutates no func. CORE pins are stamped _PERMANENT (core is core in every build -> always-run, safe) so they survive resets. Tags still ride duplicate_function via functools.wraps (no __wrapped__ walk needed). 127 CG tests pass; the poison->victim contamination repro still fixes the victim; build_node_activation is cached per graph (@functools.cache in the nlu graph_wrapper) so the generation check adds no warm per-turn cost. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… plain tags The reset approach was abandoned: the consumer now prevents cross-build color contamination by having every bot (incl. goal bots) color its own skills, so shared building blocks accumulate >=2 colors -> always-run, immune to a foreign single color -- no per-build reset needed. reset_colors() / the (generation, value) tag encoding were already inert (never called). Revert to plain `func.__dict__ = colorset|_CORE` tags: colors accumulate and ride duplicate_function as before. Pure refactor, no behavior change (validated: pipeline stays 17.4m, contamination victims fixed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes the "unknowable false latch" class (PID exits with 'PII not collected' under coloring): an always-run memory/decision cone evaluating its pruned inputs' boundary defaults as domain truth and committing it across turns. * _must_run_closure now STOPS at empty-tagged producers: their boundary default satisfies even a forced consumer, so they (and their input cones) stay prunable. Before, one untagged frontier anywhere downstream forced entire skill channels always-on THROUGH their typed-empty interior nodes. * tag_empty also stamps the declared GraphType.sink -- a sink with a future self-edge (memory/remember nodes) is a source of its own edge, so leaf detection missed it (the pitfall latch already documents). * New pin_core_excluding(subgraph, excluded): pin shared machinery CORE while named per-color channels stay prunable, each behind its typed-empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
active_colors=None now means "no color information yet": the run executes every node exactly like to_callable, ChangeActiveColors declarations never narrow the pass, and their union is recorded under EFFECTIVE_ACTIVE_COLORS_KEY so pruning starts on the caller's NEXT turn. An explicit frozenset() keeps the old prune-all-colored behavior. Root cause this fixes (measured on the appointment-management ff-on family): slot gate-latches (remember over all_true/any_true legs) hold verdicts that are only computable on the conversation's first turn; pruning a skill's first-ever turn leaves them Unknown forever, its ask-channels stay live, and the merged identity output contaminates did_say channels -> false listener captures -> first-match hijacks turns later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Large voice agents compile every skill into one ~66k-node graph and run almost all of it every turn, so even no-LLM turns pay a fixed serial-plumbing floor. This adds authoring-time graph coloring: nodes are tagged with the colors they belong to (the tag rides duplication on the function itself), and the runner skips nodes whose colors aren't active this turn — deriving the whole skip plan from those tags plus a tolerance-aware analysis, with no graph surgery and the bus left intact.
The engine stays domain-agnostic: colors are opaque tokens, so nothing here knows about skills or routing. A consumer opts in by tagging at build time and emitting a change-color event; uncolored graphs behave exactly as before. On a 66k-node production agent this prunes ~53.5k nodes per turn with zero starvation versus the trusted data-flow baseline, and ~12x lower latency on no-LLM turns.
Closes CORE-8