Skip to content

CORE-08: authoring-time graph coloring (per-turn node pruning) - #83

Open
erez-work wants to merge 19 commits into
masterfrom
CORE-08-graph-coloring
Open

erez-work wants to merge 19 commits into
masterfrom
CORE-08-graph-coloring

Conversation

@erez-work

Copy link
Copy Markdown

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

erez-work and others added 6 commits June 28, 2026 20:56
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
erez-work force-pushed the CORE-08-graph-coloring branch from a046b5c to b6add9b Compare July 4, 2026 14:05
erez-work and others added 13 commits July 4, 2026 19:48
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant