Skip to content

Commit 84bafbb

Browse files
committed
feat: reusable harness patterns
1 parent 222e66f commit 84bafbb

17 files changed

Lines changed: 379 additions & 61 deletions

File tree

src/graphrefly/extra/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@
135135
with_status,
136136
)
137137
from graphrefly.extra.sources import (
138+
ReactiveCounterBundle,
138139
cached,
139140
empty,
140141
first_value_from,
@@ -146,8 +147,10 @@
146147
from_cron,
147148
from_iter,
148149
from_timer,
150+
keepalive,
149151
never,
150152
of,
153+
reactive_counter,
151154
replay,
152155
share,
153156
share_replay,
@@ -281,9 +284,12 @@
281284
"from_webhook",
282285
"from_iter",
283286
"from_timer",
287+
"keepalive",
284288
"never",
285289
"of",
286290
"parse_prometheus_text",
291+
"ReactiveCounterBundle",
292+
"reactive_counter",
287293
"parse_statsd",
288294
"parse_syslog",
289295
"pubsub",

src/graphrefly/extra/sources.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from graphrefly.core.node import NO_VALUE, Node, NodeActions, node
1919
from graphrefly.core.protocol import Messages, MessageType
20+
from graphrefly.core.sugar import state
2021

2122

2223
def _source_initial_kwargs(source: Node[Any]) -> dict[str, Any]:
@@ -850,12 +851,92 @@ def on_msg(msg: tuple[Any, ...], _dep_index: int, actions: NodeActions) -> bool:
850851
"from_cron",
851852
"from_iter",
852853
"from_timer",
854+
"keepalive",
853855
"never",
854856
"of",
857+
"ReactiveCounterBundle",
858+
"reactive_counter",
855859
"replay",
856860
"share",
857861
"share_replay",
858862
"throw_error",
859863
"to_array",
860864
"to_list",
861865
]
866+
867+
868+
# ---------------------------------------------------------------------------
869+
# keepalive
870+
# ---------------------------------------------------------------------------
871+
872+
873+
def keepalive(n: Node[Any]) -> Any:
874+
"""Activate a compute node's upstream wiring without a real sink.
875+
876+
Derived/effect nodes are lazy — they don't compute until at least one
877+
subscriber exists (COMPOSITION-GUIDE §5). ``keepalive`` subscribes with
878+
an empty sink so the node stays wired for ``.get()`` and upstream
879+
propagation.
880+
881+
Returns the unsubscribe handle. Common usage::
882+
883+
graph.add_disposer(keepalive(node))
884+
"""
885+
return n.subscribe(lambda _msgs: None)
886+
887+
888+
# ---------------------------------------------------------------------------
889+
# reactive_counter
890+
# ---------------------------------------------------------------------------
891+
892+
893+
class ReactiveCounterBundle:
894+
"""Typed bundle returned by :func:`reactive_counter`.
895+
896+
Attributes mirror the TS ``ReactiveCounterBundle`` type for cross-language parity.
897+
"""
898+
899+
__slots__ = ("_node", "_cap")
900+
901+
def __init__(self, counter: Node[Any], cap: int) -> None:
902+
self._node = counter
903+
self._cap = cap
904+
905+
@property
906+
def node(self) -> Node[Any]:
907+
"""Reactive node holding the current count."""
908+
return self._node
909+
910+
def increment(self) -> bool:
911+
"""Increment by 1. Returns ``False`` if cap would be exceeded."""
912+
current = self._node.get()
913+
if current is None:
914+
current = 0
915+
if current >= self._cap:
916+
return False
917+
self._node.down([(MessageType.DIRTY,), (MessageType.DATA, current + 1)])
918+
return True
919+
920+
def get(self) -> int:
921+
"""Current count (synchronous read)."""
922+
v = self._node.get()
923+
return v if v is not None else 0
924+
925+
def at_cap(self) -> bool:
926+
"""Whether the counter has reached its cap."""
927+
v = self._node.get()
928+
return (v if v is not None else 0) >= self._cap
929+
930+
931+
def reactive_counter(cap: int) -> ReactiveCounterBundle:
932+
"""Reactive counter with a cap — the building block for circuit breakers.
933+
934+
Wraps a ``state(0)`` node with ``increment()`` that respects a maximum.
935+
The ``node`` is subscribable and composable like any reactive node. When
936+
the cap is reached, ``increment()`` returns ``False``.
937+
938+
Returns a :class:`ReactiveCounterBundle` with ``node``, ``increment``,
939+
``get``, and ``at_cap`` members.
940+
"""
941+
counter = state(0)
942+
return ReactiveCounterBundle(counter, cap)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Shared internal utilities for the patterns layer.
2+
3+
These are private helpers used across multiple pattern modules. They are NOT
4+
part of the public API.
5+
6+
General-purpose reactive utilities (``keepalive``, ``reactive_counter``) live
7+
in ``extra.sources`` and are re-exported here for convenience.
8+
9+
.. note:: Internal module — do not import from user code.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Any
15+
16+
# Re-export general-purpose utilities from extra (canonical home).
17+
from graphrefly.extra.sources import keepalive, reactive_counter
18+
19+
20+
def domain_meta(
21+
domain: str,
22+
kind: str,
23+
extra: dict[str, Any] | None = None,
24+
) -> dict[str, Any]:
25+
"""Build a domain metadata dict for pattern-layer nodes.
26+
27+
Each domain (orchestration, messaging, reduction, ai, cqrs,
28+
domain_template) follows the same shape::
29+
30+
{ "<domain>": True, "<domain>_type": "<kind>", ...extra }
31+
"""
32+
out: dict[str, Any] = {domain: True, f"{domain}_type": kind}
33+
if extra is not None:
34+
out.update(extra)
35+
return out
36+
37+
38+
def tracking_key(item: Any) -> str:
39+
"""Stable tracking key for an item with retry/reingestion decoration.
40+
41+
Uses ``related_to[0]`` if present (carries the original key forward
42+
through retries and reingestions). Falls back to ``summary`` for
43+
first-time items.
44+
"""
45+
related = (
46+
item.get("related_to") if isinstance(item, dict) else getattr(item, "related_to", None)
47+
)
48+
if related:
49+
first = related[0] if isinstance(related, (list, tuple)) else None
50+
if first is not None:
51+
return str(first)
52+
if isinstance(item, dict):
53+
summary = item.get("summary", str(item))
54+
else:
55+
summary = getattr(item, "summary", str(item))
56+
return str(summary)
57+
58+
59+
__all__ = [
60+
"domain_meta",
61+
"keepalive",
62+
"reactive_counter",
63+
"tracking_key",
64+
]

src/graphrefly/patterns/ai.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from graphrefly.extra.sources import first_value_from, from_any, from_timer
2727
from graphrefly.extra.tier2 import switch_map
2828
from graphrefly.graph.graph import Graph
29+
from graphrefly.patterns._internal import domain_meta
30+
from graphrefly.patterns._internal import keepalive as _keepalive
2931
from graphrefly.patterns.memory import (
3032
KnowledgeGraph,
3133
VectorIndex,
@@ -181,15 +183,7 @@ class MemoryTiers:
181183

182184

183185
def _ai_meta(kind: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
184-
out: dict[str, Any] = {"ai": True, "ai_type": kind}
185-
if extra:
186-
out.update(extra)
187-
return out
188-
189-
190-
def _keepalive(n: Any) -> Any:
191-
"""Subscribe to keep derived node wired; returns unsubscribe handle."""
192-
return n.subscribe(lambda _msgs: None)
186+
return domain_meta("ai", kind, extra)
193187

194188

195189
_DEFAULT_TIMEOUT = 30.0 # seconds

src/graphrefly/patterns/cqrs.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from graphrefly.core.sugar import derived, state
2424
from graphrefly.extra.data_structures import reactive_log
2525
from graphrefly.graph.graph import Graph
26+
from graphrefly.patterns._internal import domain_meta
27+
from graphrefly.patterns._internal import keepalive as _keepalive_raw
2628

2729
if TYPE_CHECKING:
2830
from collections.abc import Callable, Sequence
@@ -67,10 +69,7 @@ def _build_event_guard(allow: Any, deny: Any) -> None:
6769

6870

6971
def _cqrs_meta(kind: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
70-
out: dict[str, Any] = {"cqrs": True, "cqrs_type": kind}
71-
if extra:
72-
out.update(extra)
73-
return out
72+
return domain_meta("cqrs", kind, extra)
7473

7574

7675
@dataclass(slots=True)
@@ -83,7 +82,7 @@ class _EventLogEntry:
8382

8483
def _keepalive(n: Any) -> Callable[[], None]:
8584
"""Keep dep wiring alive; returns unsubscribe handle for cleanup."""
86-
return cast("Callable[[], None]", n.subscribe(lambda _msgs: None))
85+
return cast("Callable[[], None]", _keepalive_raw(n))
8786

8887

8988
def _tuple_snapshot(raw: Any) -> tuple[Any, ...]:

src/graphrefly/patterns/domain_templates.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from graphrefly.core.sugar import derived, effect, state
2525
from graphrefly.extra.data_structures import reactive_log
2626
from graphrefly.graph.graph import Graph
27+
from graphrefly.patterns._internal import domain_meta
2728
from graphrefly.patterns.reduction import (
2829
StratifyRule,
2930
feedback,
@@ -37,10 +38,7 @@
3738

3839

3940
def _base_meta(kind: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
40-
out: dict[str, Any] = {"domain_template": True, "template_type": kind}
41-
if extra:
42-
out.update(extra)
43-
return out
41+
return domain_meta("domain_template", kind, extra)
4442

4543

4644
def _is_tagged(value: Any, tag: str) -> bool:
@@ -50,10 +48,6 @@ def _is_tagged(value: Any, tag: str) -> bool:
5048
return value.get("type") == tag or value.get("kind") == tag
5149

5250

53-
def _keepalive(n: NodeImpl[Any]) -> Any:
54-
return n.subscribe(lambda _msgs: None)
55-
56-
5751
# ---------------------------------------------------------------------------
5852
# 1. observability_graph
5953
# ---------------------------------------------------------------------------

src/graphrefly/patterns/harness/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
EvalJudgeScore,
1010
EvalResult,
1111
EvalTaskResult,
12+
create_intake_bridge,
1213
eval_intake_bridge,
1314
)
1415
from graphrefly.patterns.harness.loop import HarnessGraph, harness_loop
@@ -76,6 +77,7 @@
7677
"EvalJudgeScore",
7778
"EvalResult",
7879
"EvalTaskResult",
80+
"create_intake_bridge",
7981
"eval_intake_bridge",
8082
# loop
8183
"HarnessGraph",

src/graphrefly/patterns/harness/bridge.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,49 @@
1717

1818
from .types import IntakeItem, Severity
1919

20+
# ---------------------------------------------------------------------------
21+
# Generic intake bridge
22+
# ---------------------------------------------------------------------------
23+
24+
25+
def create_intake_bridge(
26+
source: NodeImpl[Any],
27+
intake_topic: TopicGraph,
28+
parser: Any,
29+
*,
30+
name: str | None = None,
31+
) -> NodeImpl[Any]:
32+
"""Generic source→intake bridge factory.
33+
34+
Watches a source node for new values, passes each through a user-supplied
35+
``parser`` that produces zero or more ``IntakeItem`` instances, and publishes
36+
them to the given intake topic.
37+
38+
This is the generalized pattern behind :func:`eval_intake_bridge`. Use it
39+
for CI results, test failures, Slack messages, monitoring alerts, or any
40+
domain where structured results should flow into a harness loop.
41+
42+
Args:
43+
source: Reactive node emitting domain-specific data.
44+
intake_topic: TopicGraph to publish IntakeItem entries to.
45+
parser: ``(value: T) -> list[IntakeItem]``. Return empty list to skip.
46+
name: Optional name for the effect node.
47+
48+
Returns:
49+
The effect node (for lifecycle management).
50+
"""
51+
52+
def _bridge(deps: list[Any], _actions: Any) -> None:
53+
value = deps[0]
54+
if value is None:
55+
return
56+
items = parser(value)
57+
for item in items:
58+
intake_topic.publish(item)
59+
60+
return effect([source], _bridge, name=name or "intake-bridge")
61+
62+
2063
# ---------------------------------------------------------------------------
2164
# Generic eval result shape
2265
# ---------------------------------------------------------------------------

src/graphrefly/patterns/harness/loop.py

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from graphrefly.core.sugar import effect, state
1616
from graphrefly.extra.tier1 import merge, with_latest_from
1717
from graphrefly.graph.graph import Graph
18+
from graphrefly.patterns._internal import tracking_key as _tracking_key
1819
from graphrefly.patterns.ai import prompt_node
1920
from graphrefly.patterns.messaging import TopicGraph
2021
from graphrefly.patterns.orchestration import gate
@@ -36,26 +37,6 @@
3637
from collections.abc import Callable
3738

3839

39-
def _tracking_key(item: Any) -> str:
40-
"""Stable tracking key for an item.
41-
42-
Uses ``related_to[0]`` if the item is already a retry or reingestion
43-
(carries the original key forward). Falls back to the raw summary.
44-
"""
45-
related = (
46-
item.get("related_to") if isinstance(item, dict) else getattr(item, "related_to", None)
47-
)
48-
if related:
49-
first = related[0] if isinstance(related, (list, tuple)) else None
50-
if first:
51-
return str(first)
52-
if isinstance(item, dict):
53-
summary = item.get("summary", str(item))
54-
else:
55-
summary = getattr(item, "summary", str(item))
56-
return str(summary)
57-
58-
5940
# ---------------------------------------------------------------------------
6041
# Default prompts
6142
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)