Skip to content

Commit 8fa6920

Browse files
committed
feat: 9.0
1 parent 100551d commit 8fa6920

9 files changed

Lines changed: 733 additions & 31 deletions

File tree

docs/optimizations.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,16 @@ Union-find over node identity merges components when nodes list dependencies at
159159

160160
- **Cleanup wrapper `{"cleanup": fn, "value"?: v}` (all phases, noted 2026-04-06, resolved 2026-04-06):** Node fn can now return `{"cleanup": callable, "value": v}` to explicitly separate cleanup from data. When returned: `cleanup` is registered; if `"value"` key is present, it's emitted as data. Plain callable returns remain cleanup for backward compat. Exported as `CleanupResult<T>` (TS) / documented dict pattern (PY). Resolves the documented limitation where returning a callable as data was silently consumed as cleanup.
161161

162+
- **`gate` rename → `valve` + new human-approval `gate` (Phase 9.0, noted 2026-04-06):** The boolean-control `gate` operator (extra/tier2, patterns/orchestration) renamed to `valve` across both TS and PY. A new `gate` primitive (queue-based human-in-the-loop approval with `pending`, `count`, `is_open` reactive surfaces and `approve`/`reject`/`modify`/`open`/`close` controls) added to `patterns/orchestration` in both repos. Key decisions: (1) `modify(fn, n?)` signature is `(value, index, pending) → value` (Array.map-style). (2) Each approved/modified value emits as a separate `[(DATA, v)]` — each approval is a separate reactive cycle. (3) Internal state nodes registered via `graph.mount()` subgraph in PY and `_pending`/`_isOpen`/`_count` suffix in TS.
163+
- **`prompt_node` factory (Phase 9.0, noted 2026-04-06, parity aligned 2026-04-07):** Universal LLM transform wrapping prompt template + adapter into a reactive derived node. Both TS and PY now use `switch_map` / `switchMap` internally — new dep values cancel stale in-flight LLM calls. Both create an intermediate `messages` derived node from deps, then `switch_map`/`switchMap` maps each message list to an LLM invocation. Supports `format="json"` (auto-parse with markdown fence stripping), `retries`, `cache` (dict keyed by serialized `(role, content)` tuples). Composes with existing `LLMAdapter` interface.
164+
- **Parity fixes applied (Phase 9.0, 2026-04-07):** (1) PY `valve` `describe_kind` changed from `"valve"` to `"operator"` (spec Appendix B compliance). (2) PY `gate` error messages now include method name (e.g. `gate: approve() called after gate was torn down`), aligned to TS. (3) PY `gate` `_sync_reactive` split into `_sync_pending` / `_sync_open` to avoid unnecessary reactive cycles on `is_open_node`, aligned to TS pattern. (4) PY `GateController` type annotations fixed (`approve`/`reject` now `Callable[..., None]` reflecting optional `count` param). (5) TS `promptNode` now passes `systemPrompt` in adapter invoke opts (was missing). (6) TS `promptNode` now strips markdown fences and uses defensive content extraction (aligned to PY). (7) Both cache keys now use explicit `[role, content]` tuple serialization.
165+
166+
- **Whole-repo `emit``down` audit + `up` / backpressure / `message_tier` sweep (all phases, noted 2026-04-07):** Systematic audit across both TS and PY:
167+
1. **Rename `emit`-named methods, variables, and keys to `down`** everywhere the operation only applies to the downstream direction. The `emit` naming creates a "fire and forget" mental model that obscures the bidirectional protocol and causes AI agents (and humans) to ignore `up()`. Exceptions: names that genuinely apply to both directions (e.g. `emit_with_batch` which orchestrates both phases) may keep their name if renaming is misleading.
168+
2. **Re-examine `up()` handling in every operator, source, and pattern factory.** Verify that backpressure works: PAUSE/RESUME signals pass through and are processed (not swallowed). Verify that `up()` is a no-op or correctly forwarded — not silently dropped. Check for asymmetric treatment where `down()` gets full message handling but `up()` is ignored entirely.
169+
3. **Use `message_tier()` / `messageTier()` for all tier-based gating** — no hardcoded type checks (`== MessageType.COMPLETE or == MessageType.ERROR`). Replace with `message_tier(t) >= 3` for terminal checks, `>= 2` for phase-2 gating, etc.
170+
Pre-1.0, no backward compat concern on any rename.
171+
162172
---
163173

164174
## Cross-language parity fixes (2026-04-05)
@@ -313,7 +323,7 @@ Both ports treat “`fn` returned a callable” as a **cleanup** (TS: `typeof ou
313323

314324
| Topic | Python | TypeScript | Rationale |
315325
|-------|--------|------------|-----------|
316-
| `signal` node dedupe | No per-call dedupe (duplicate mount is forbidden, so unnecessary). | Shared `visited` `Set<Node>` across recursion. | TS keeps the dedupe as defense-in-depth. |
326+
| `signal` node dedupe | Shared `visited: set[int]` (`id(n)`) across recursion in `_signal_graph` / `_signal_node_subtree`. | Shared `visited` `Set<Node>` across recursion. | Both use per-call dedupe as defense-in-depth. Aligned. |
317327

318328
**Docs:** This repo’s `docs/roadmap.md` still lists `graph.signal` under Phase 1.4 unchecked while Phase 1.2 marks composition done; `signal` exists — checklist drift only.
319329

@@ -700,7 +710,7 @@ Both ports now align on the following:
700710

701711
- **`SvgBoundsAdapter` regex hardening (Phase 7.1, resolved 2026-04-03):** Strip `<!--...-->` and `<![CDATA[...]]>` from SVG content before viewBox/width/height extraction. Document that input should be a single root SVG element. Additionally, expose a `SvgParser` protocol so users can opt in their own parser for complex SVG inputs. Default: built-in regex parser. Cross-language: TS exposes equivalent `SvgParserAdapter` interface.
702712

703-
- **`sample` + `undefined` as `T` (Tier 2, resolved 2026-04-03 — no action):** Documented limitation. TS-specific edge case (Python does not have the `undefined` ambiguity). No sentinel needed.
713+
- **~~`sample` + `undefined` as `T` (Tier 2, resolved 2026-04-07):~~** Fixed in TS. `sample` now tracks source DATA via local `NO_VALUE` sentinel instead of `source.get()`. Python had no ambiguity.
704714

705715
- **`mergeMap` / `merge_map` + `ERROR` cascading (Tier 2, resolved 2026-04-03 — no action):** Documented limitation. Inner errors do not cascade to siblings. Current behavior (independent inner lifecycles) is more useful for parallel work. Document in docstrings.
706716

@@ -796,14 +806,14 @@ Cross-language notes for `patterns.ai` / `graphrefly.patterns.ai`. **Keep this s
796806
|-------|------------|
797807
| **`agent_loop` / `agentLoop` — LLM adapter output** | `invoke` may return a plain `LLMResponse`, or any `NodeInput` (including `Node`, awaitables, async iterables). Implementations coerce with `fromAny` / `from_any`, prefer a synchronous `get()` when it already holds an `LLMResponse`, then **block until the first settled `DATA`** (`subscribe` + `Promise` in TypeScript; `first_value_from` in Python). Do not unsubscribe immediately after `subscribe` without waiting for emissions. |
798808
| **`toolRegistry` / `tool_registry` — handler output** | Handlers may return plain values, Promise-like values, or reactive `NodeInput`. **TypeScript:** `execute` awaits Promise-likes, then resolves **only** `Node` / `AsyncIterable` via `fromAny` + first `DATA` (do **not** pass arbitrary strings through `fromAny` — it treats strings as iterables and emits per character). **Python:** `execute` uses `from_any` + `first_value_from` only for awaitables, async iterables, or `Node`; plain values return as-is. |
799-
| **`agentMemory` / `agent_memory` — factory scope** | **Resolved (2026-03-31):** Ship as-designed. The full in-factory composition (`knowledgeGraph` + `vectorIndex` + `lightCollection` + `decay` + `autoCheckpoint`, opt-in via options) will be implemented per the resolved design decision at the top of this document. A single `agentMemory(name, { vectorDimensions, embedFn, enableKnowledgeGraph })` / `agent_memory(name, vector_dimensions=, embed_fn=, enable_knowledge_graph=)` call provides batteries-included memory. Implementation to follow. |
809+
| **~~`agentMemory` / `agent_memory` — factory scope (implemented, verified 2026-04-07)~~** | Fully wired in both TS and PY. All five primitives (`knowledgeGraph`/`knowledge_graph`, `vectorIndex`/`vector_index`, `lightCollection`/`light_collection`, `decay`, `autoCheckpoint`/`auto_checkpoint`) are opt-in via options. Retrieval pipeline (4-stage: vector search + KG expansion + scoring + budget packing) and periodic reflection also wired. |
800810

801811
### AI surface (Phase 4.4) — parity follow-ups
802812

803813
| # | Topic | Notes |
804814
|---|--------|-------|
805-
| **3** | **`_invoke_llm` / `_invokeLLM` defensive alignment** | **TypeScript** (`_invokeLLM`): rejects `null`/`undefined` and plain `str` before `fromAny` (strings would iterate per character); accepts sync plain objects with `content` when not a Promise/`Node`. **Python** (`_invoke_llm`): should mirror those guards — reject `None`; reject `str`; do not pass raw `dict`/`Mapping` through `from_any` without normalizing to `LLMResponse` (iterating a `dict` yields keys). **Status:** pending implementation in `graphrefly-py`. |
806-
| **4** | **`LLMInvokeOptions` + cooperative cancellation** | **Resolved (2026-03-31):** Python will use a `CancellationToken` protocol — a small interface with `.is_cancelled` property and `.on_cancel(fn)` callback registration, backed internally by `threading.Event`. This mirrors TS's `AbortSignal` pattern. The token is passed from `AgentLoopGraph` into `adapter.invoke()`. Adapters react to cancellation via `.on_cancel()` callbacks (no polling — respects the reactive invariant). The protocol can be extended to `asyncio.Event` backing later. **TypeScript** retains `AbortSignal` via `LLMInvokeOptions.signal`. |
815+
| **3** | **~~`_invoke_llm` / `_invokeLLM` defensive alignment (resolved 2026-04-07 — already implemented)~~** | Both ports reject `None`/`null`, reject plain `str`, normalize `dict` with `content` key to `LLMResponse`, and route remaining shapes through `from_any` + `first_value_from`. Aligned. |
816+
| **4** | **~~`LLMInvokeOptions` + cooperative cancellation (implemented 2026-04-07)~~** | Both ports now wire cancellation from the agent loop into `adapter.invoke()`. **TS:** `AbortSignal` via `LLMInvokeOptions.signal`. **PY:** `CancellationToken` protocol (`core/cancellation.py`) with `.is_cancelled` + `.on_cancel(fn)`, backed by `threading.Event`; wired from `AgentLoopGraph._cancel_token` into `LLMInvokeOptions.cancellation_token`. |
807817

808818
Normative anti-patterns table: [**Implementation anti-patterns**](#implementation-anti-patterns) (top of this document).
809819

@@ -840,7 +850,7 @@ Applies to `src/extra/operators.ts` and `graphrefly.extra.tier2`. **Keep the tab
840850

841851
| Item | Status | Notes |
842852
|------|--------|-------|
843-
| **`sample` + `undefined` as `T`** | Documented limitation (2026-03-31) | Sampling uses the primary dep’s cached value (`get()`). If `T` allows `undefined`, a cache of `undefined` is indistinguishable from “no snapshot yet”; TypeScript currently emits `RESOLVED` instead of `DATA` in that case (JSDoc `@remarks`). This is a known TS-specific edge case (Python does not have the `undefined` ambiguity). Document in JSDoc; no sentinel needed. |
853+
| **~~`sample` + `undefined` as `T`~~** | Resolved (2026-04-07) | Fixed in TS: `sample` now tracks source DATA via local `NO_VALUE` sentinel. Python had no ambiguity. |
844854
| **`mergeMap` / `merge_map` + `ERROR`** | Documented limitation (2026-03-31) | When the outer stream or one inner emits `ERROR`, other inner subscriptions may keep running until they complete or unsubscribe. Rx-style “first error cancels all sibling inners” is **not** specified or implemented. Current behavior (inner errors don’t cascade) is arguably more useful for parallel work — no change needed. Document in JSDoc/docstrings. |
845855

846856
### TC39 compat read/subscribe terminal semantics (`Signal.get` / `Signal.sub`)

src/graphrefly/extra/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,6 @@
192192
delay,
193193
exhaust_map,
194194
flat_map,
195-
gate,
196195
interval,
197196
merge_map,
198197
pausable,
@@ -203,6 +202,7 @@
203202
throttle,
204203
throttle_time,
205204
timeout,
205+
valve,
206206
window,
207207
window_count,
208208
window_time,
@@ -341,7 +341,7 @@
341341
"find",
342342
"first",
343343
"flat_map",
344-
"gate",
344+
"valve",
345345
"interval",
346346
"last",
347347
"map",

src/graphrefly/extra/tier2.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,10 +1331,10 @@ def cleanup() -> None:
13311331
return _op
13321332

13331333

1334-
def gate(control: Node[Any]) -> PipeOperator:
1334+
def valve(control: Node[Any]) -> PipeOperator:
13351335
"""Forward ``DATA`` only when ``control`` is truthy; otherwise emit ``RESOLVED``.
13361336
1337-
This is a value-level gate using a boolean control signal. See :func:`pausable`
1337+
This is a value-level valve using a boolean control signal. See :func:`pausable`
13381338
for protocol-level ``PAUSE``/``RESUME`` buffering.
13391339
13401340
Args:
@@ -1346,10 +1346,10 @@ def gate(control: Node[Any]) -> PipeOperator:
13461346
Example:
13471347
```python
13481348
from graphrefly import state, pipe
1349-
from graphrefly.extra.tier2 import gate
1349+
from graphrefly.extra.tier2 import valve
13501350
src = state(1)
13511351
ctrl = state(True)
1352-
out = pipe(src, gate(ctrl))
1352+
out = pipe(src, valve(ctrl))
13531353
```
13541354
"""
13551355

@@ -1360,7 +1360,7 @@ def compute(deps: list[Any], actions: NodeActions) -> Any:
13601360
return None
13611361
return deps[0]
13621362

1363-
return node([src, control], compute, describe_kind="gate")
1363+
return node([src, control], compute, describe_kind="operator")
13641364

13651365
return _op
13661366

@@ -1785,7 +1785,7 @@ def cleanup() -> None:
17851785
"delay",
17861786
"exhaust_map",
17871787
"flat_map",
1788-
"gate",
1788+
"valve",
17891789
"interval",
17901790
"merge_map",
17911791
"pausable",

0 commit comments

Comments
 (0)