generflow-core 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ """Rewind/replay: buffer SSE events as they arrive, allow scrubbing
2
+ through them at any speed, and export for debugging.
3
+
4
+ Storage is a flat list of events with monotonic timestamps. The
5
+ scrubber reconstructs the spec tree at any point in time.
6
+
7
+ Key uses:
8
+ - User hits "rewind" → events replay at 4× speed
9
+ - Developer debugs "what went wrong here?" → scrub to the moment
10
+ - Eval harness reproduces deterministic spec states
11
+ - The playground lets users see the LLM "thinking"
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from collections import defaultdict
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Iterator
20
+
21
+ from ..spec import GFLangParser, Assignment, Component
22
+
23
+
24
+ @dataclass
25
+ class BufferedEvent:
26
+ seq: int # monotonic sequence number
27
+ ts: float # wall-clock time
28
+ type: str # session.start, spec.line, data.fill, update, etc.
29
+ data: dict = field(default_factory=dict)
30
+
31
+
32
+ class EventBuffer:
33
+ """In-memory buffer of SSE events for one session.
34
+
35
+ Supports:
36
+ - append(events) as they stream in
37
+ - iter() to walk all events
38
+ - replay(rate=4.0) to walk events at speed
39
+ - scrub(seq) to get the cumulative state at that point
40
+ """
41
+
42
+ def __init__(self, max_size: int = 100_000) -> None:
43
+ self._events: list[BufferedEvent] = []
44
+ self._seq = 0
45
+ self._max_size = max_size
46
+
47
+ def append(self, event_type: str, data: dict | None = None) -> BufferedEvent:
48
+ self._seq += 1
49
+ ev = BufferedEvent(seq=self._seq, ts=time.time(), type=event_type, data=data or {})
50
+ self._events.append(ev)
51
+ if len(self._events) > self._max_size:
52
+ self._events.pop(0)
53
+ return ev
54
+
55
+ def __len__(self) -> int:
56
+ return len(self._events)
57
+
58
+ def __iter__(self) -> Iterator[BufferedEvent]:
59
+ return iter(self._events)
60
+
61
+ def slice(self, start: int = 0, end: int | None = None) -> list[BufferedEvent]:
62
+ return self._events[start:end]
63
+
64
+ def at(self, seq: int) -> list[BufferedEvent]:
65
+ """All events up to and including seq."""
66
+ return [e for e in self._events if e.seq <= seq]
67
+
68
+ async def replay(self, rate: float = 1.0) -> Iterator[BufferedEvent]:
69
+ """Yield events at `rate`× real-time speed.
70
+
71
+ rate=4.0 → 4× faster than original; rate=0.5 → half-speed.
72
+ """
73
+ if not self._events:
74
+ return
75
+ start = self._events[0].ts
76
+ for i, ev in enumerate(self._events):
77
+ if i > 0:
78
+ original_delta = ev.ts - self._events[i - 1].ts
79
+ replay_delta = original_delta / rate
80
+ # cap at 100ms to avoid freezing on big gaps
81
+ if replay_delta > 0.1:
82
+ replay_delta = 0.1
83
+ # busy-wait sleep — caller should be async
84
+ time.sleep(replay_delta)
85
+ yield ev
86
+
87
+ def scrub_state(self, seq: int) -> dict:
88
+ """Reconstruct the spec state at a given sequence point.
89
+
90
+ Returns:
91
+ {
92
+ "spec_lines": N,
93
+ "data_fills": M,
94
+ "updates": K,
95
+ "spec_text": "<reconstructed GF-Lang>",
96
+ "tree": { ... Component dict ... }
97
+ }
98
+ """
99
+ events = self.at(seq)
100
+ spec_text = ""
101
+ data_fills: dict[str, Any] = {}
102
+ updates: list[dict] = []
103
+ for ev in events:
104
+ if ev.type == "spec.line":
105
+ node = ev.data.get("node", {})
106
+ spec_text += _node_to_gflang(node) + "\n"
107
+ elif ev.type == "update":
108
+ updates.append(ev.data)
109
+ # apply the update (best-effort, no validation)
110
+ path = ev.data.get("path", "")
111
+ value = ev.data.get("value")
112
+ if path and value is not None:
113
+ _apply_inline_update(spec_text, path, value)
114
+ elif ev.type == "data.fill":
115
+ ref = ev.data.get("ref")
116
+ if ref:
117
+ data_fills[ref] = ev.data.get("value")
118
+ # parse reconstructed text
119
+ parser = GFLangParser()
120
+ nodes = parser.feed_chunk(spec_text)
121
+ return {
122
+ "seq": seq,
123
+ "spec_lines": sum(1 for e in events if e.type == "spec.line"),
124
+ "data_fills": len(data_fills),
125
+ "updates": len(updates),
126
+ "data_by_ref": data_fills,
127
+ "spec_text": spec_text,
128
+ "parsed_nodes": [n.to_dict() for n in nodes],
129
+ }
130
+
131
+ def export(self) -> dict:
132
+ """Export the full buffer as JSON (for debugging / sharing)."""
133
+ return {
134
+ "version": 1,
135
+ "event_count": len(self._events),
136
+ "first_ts": self._events[0].ts if self._events else None,
137
+ "last_ts": self._events[-1].ts if self._events else None,
138
+ "events": [{"seq": e.seq, "ts": e.ts, "type": e.type, "data": e.data} for e in self._events],
139
+ }
140
+
141
+
142
+ def _node_to_gflang(node: dict) -> str:
143
+ """Reconstruct GF-Lang text from a serialized Component dict."""
144
+ name = node.get("name", "Text")
145
+ kwargs = node.get("kwargs", {})
146
+ args = node.get("args", [])
147
+ children = node.get("children", [])
148
+ parts = []
149
+ for k, v in kwargs.items():
150
+ parts.append(f"{k}={_render_gflang_value(v)}")
151
+ children_str = ""
152
+ if children:
153
+ kids = [_node_to_gflang(c) for c in children]
154
+ children_str = "[\n " + ",\n ".join(kids) + ",\n]"
155
+ if children_str:
156
+ if parts:
157
+ return f"{name}({', '.join(parts)},\n{children_str})"
158
+ return f"{name}({children_str})"
159
+ if parts:
160
+ return f"{name}({', '.join(parts)})"
161
+ return f"{name}()"
162
+
163
+
164
+ def _render_gflang_value(v: Any) -> str:
165
+ if v is None:
166
+ return "null"
167
+ if isinstance(v, bool):
168
+ return "true" if v else "false"
169
+ if isinstance(v, (int, float)):
170
+ return str(v)
171
+ if isinstance(v, str):
172
+ if any(c in v for c in " \t\"\\'"):
173
+ return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
174
+ return v
175
+ if isinstance(v, dict):
176
+ # looks like a serialized Component
177
+ if "name" in v and ("kwargs" in v or "args" in v):
178
+ return _node_to_gflang(v)
179
+ return json.dumps(v)
180
+ if isinstance(v, list):
181
+ return "[" + ", ".join(_render_gflang_value(x) for x in v) + "]"
182
+ return str(v)
183
+
184
+
185
+ def _apply_inline_update(text: str, path: str, value: Any) -> None:
186
+ """Best-effort: mutate `text` by replacing the value at `path`."""
187
+ # not implemented in v1 (use spec.update.apply_update on the parsed tree)
188
+ # this is a placeholder for future expansion
189
+ pass
@@ -0,0 +1,21 @@
1
+ """GF-Lang spec module — public API."""
2
+ from .ast import Assignment, Component, Literal, Ref
3
+ from .diff import DiffEntry, diff_specs, summarize_diff
4
+ from .parser import GFLangParser, ParseError, count_tokens
5
+ from .update import Update, apply_update, parse_update
6
+
7
+ __all__ = [
8
+ "Assignment",
9
+ "Component",
10
+ "DiffEntry",
11
+ "Literal",
12
+ "Ref",
13
+ "Update",
14
+ "GFLangParser",
15
+ "ParseError",
16
+ "apply_update",
17
+ "count_tokens",
18
+ "diff_specs",
19
+ "parse_update",
20
+ "summarize_diff",
21
+ ]
@@ -0,0 +1,61 @@
1
+ """GF-Lang AST node types."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Union
6
+
7
+
8
+ @dataclass
9
+ class Literal:
10
+ value: Union[str, int, float, bool, list, None]
11
+
12
+ def to_dict(self) -> Any:
13
+ return self.value
14
+
15
+
16
+ @dataclass
17
+ class Component:
18
+ name: str
19
+ args: list = field(default_factory=list)
20
+ kwargs: dict = field(default_factory=dict)
21
+ children: list = field(default_factory=list) # child Components (not positional args)
22
+
23
+ def to_dict(self) -> dict:
24
+ return {
25
+ "type": "component",
26
+ "name": self.name,
27
+ "args": [_to_dict(a) for a in self.args],
28
+ "kwargs": {k: _to_dict(v) for k, v in self.kwargs.items()},
29
+ "children": [_to_dict(c) for c in self.children],
30
+ }
31
+
32
+
33
+ @dataclass
34
+ class Ref:
35
+ name: str # the part after $
36
+
37
+ def to_dict(self) -> dict:
38
+ return {"type": "ref", "name": self.name}
39
+
40
+
41
+ @dataclass
42
+ class Assignment:
43
+ identifier: str
44
+ value: Any # Component | Literal | Ref
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "type": "assignment",
49
+ "id": self.identifier,
50
+ "value": _to_dict(self.value),
51
+ }
52
+
53
+
54
+ def _to_dict(node: Any) -> Any:
55
+ if hasattr(node, "to_dict"):
56
+ return node.to_dict()
57
+ if isinstance(node, dict):
58
+ return {k: _to_dict(v) for k, v in node.items()}
59
+ if isinstance(node, list):
60
+ return [_to_dict(x) for x in node]
61
+ return node
@@ -0,0 +1,177 @@
1
+ """Spec diffing: compare two GF-Lang specs and produce a structural diff.
2
+
3
+ Useful for:
4
+ - Regression testing ("did the prompt change break this UI?")
5
+ - Prompt evaluation ("what changed between version A and B?")
6
+ - Showing users "the AI just changed this field"
7
+
8
+ Diff output format: list of {op, path, before, after} entries.
9
+ Ops: "added", "removed", "changed", "moved".
10
+
11
+ Inspired by RFC 6902 (JSON Patch) but adapted for the spec tree.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, asdict
16
+ from typing import Any
17
+
18
+ from .ast import Assignment, Component, Literal, Ref
19
+
20
+
21
+ @dataclass
22
+ class DiffEntry:
23
+ op: str # "added" | "removed" | "changed" | "moved"
24
+ path: str # e.g. "root.children[2].kwargs.value"
25
+ before: Any = None
26
+ after: Any = None
27
+ note: str = ""
28
+
29
+ def to_dict(self) -> dict:
30
+ return {
31
+ "op": self.op,
32
+ "path": self.path,
33
+ "before": _serialize(self.before),
34
+ "after": _serialize(self.after),
35
+ "note": self.note,
36
+ }
37
+
38
+
39
+ def _serialize(v: Any) -> Any:
40
+ """Make AST nodes JSON-friendly."""
41
+ if v is None or isinstance(v, (str, int, float, bool)):
42
+ return v
43
+ if isinstance(v, (Component, Assignment, Literal, Ref)):
44
+ return v.to_dict()
45
+ if isinstance(v, dict):
46
+ return {k: _serialize(val) for k, val in v.items()}
47
+ if isinstance(v, list):
48
+ return [_serialize(x) for x in v]
49
+ return str(v)
50
+
51
+
52
+ def diff_specs(before_text: str, after_text: str) -> list[dict]:
53
+ """Compare two GF-Lang spec strings and return a diff.
54
+
55
+ Parses both specs, then walks in lockstep producing entries for each
56
+ structural difference. If a spec fails to parse, the whole thing
57
+ is reported as one entry.
58
+ """
59
+ from .parser import GFLangParser
60
+
61
+ p = GFLangParser()
62
+ a = p.feed_chunk(before_text)
63
+ b = p.feed_chunk(after_text)
64
+
65
+ # unwrap Assignment(root = ...) → root Component
66
+ a_root = _root(a)
67
+ b_root = _root(b)
68
+ if a_root is None or b_root is None:
69
+ return [DiffEntry(
70
+ op="error",
71
+ path="$",
72
+ before=before_text[:80] + "..." if len(before_text) > 80 else before_text,
73
+ after=after_text[:80] + "..." if len(after_text) > 80 else after_text,
74
+ note="could not parse one or both specs",
75
+ ).to_dict()]
76
+ out: list[DiffEntry] = []
77
+ _walk(a_root, b_root, "root", out)
78
+ return [e.to_dict() for e in out]
79
+
80
+
81
+ def _root(nodes: list) -> Component | None:
82
+ if not nodes:
83
+ return None
84
+ n = nodes[0]
85
+ if isinstance(n, Assignment):
86
+ n = n.value
87
+ return n if isinstance(n, Component) else None
88
+
89
+
90
+ def _walk(a: Any, b: Any, path: str, out: list[DiffEntry]) -> None:
91
+ """Recursively compare two AST subtrees."""
92
+ if type(a) != type(b):
93
+ out.append(DiffEntry(op="changed", path=path, before=a, after=b, note=f"type changed {type(a).__name__}→{type(b).__name__}"))
94
+ return
95
+ if isinstance(a, Component):
96
+ if a.name != b.name:
97
+ out.append(DiffEntry(op="changed", path=f"{path}.name", before=a.name, after=b.name, note="component renamed"))
98
+ # compare kwargs
99
+ a_keys = set(a.kwargs.keys())
100
+ b_keys = set(b.kwargs.keys())
101
+ for k in sorted(a_keys - b_keys):
102
+ out.append(DiffEntry(op="removed", path=f"{path}.kwargs.{k}", before=_serialize(a.kwargs[k])))
103
+ for k in sorted(b_keys - a_keys):
104
+ out.append(DiffEntry(op="added", path=f"{path}.kwargs.{k}", after=_serialize(b.kwargs[k])))
105
+ for k in sorted(a_keys & b_keys):
106
+ _walk(a.kwargs[k], b.kwargs[k], f"{path}.kwargs.{k}", out)
107
+ # compare children
108
+ _walk_children(a, b, path, out)
109
+ elif isinstance(a, Literal):
110
+ if a.value != b.value:
111
+ out.append(DiffEntry(op="changed", path=path, before=_serialize(a.value), after=_serialize(b.value)))
112
+ elif isinstance(a, Ref):
113
+ if a.name != b.name:
114
+ out.append(DiffEntry(op="changed", path=path, before=a.name, after=b.name, note="ref renamed"))
115
+ elif isinstance(a, dict):
116
+ a_keys = set(a.keys())
117
+ b_keys = set(b.keys())
118
+ for k in sorted(a_keys - b_keys):
119
+ out.append(DiffEntry(op="removed", path=f"{path}.{k}", before=_serialize(a[k])))
120
+ for k in sorted(b_keys - a_keys):
121
+ out.append(DiffEntry(op="added", path=f"{path}.{k}", after=_serialize(b[k])))
122
+ for k in sorted(a_keys & b_keys):
123
+ _walk(a[k], b[k], f"{path}.{k}", out)
124
+
125
+
126
+ def _walk_children(a: Component, b: Component, path: str, out: list[DiffEntry]) -> None:
127
+ """Compare children arrays (positional + list-of-children)."""
128
+ # gather child lists: each Component has children as the last positional arg
129
+ a_children = _children(a)
130
+ b_children = _children(b)
131
+ if len(a_children) != len(b_children):
132
+ out.append(DiffEntry(
133
+ op="changed", path=f"{path}.children",
134
+ before=f"{len(a_children)} children",
135
+ after=f"{len(b_children)} children",
136
+ note="child count changed",
137
+ ))
138
+ n = min(len(a_children), len(b_children))
139
+ for i in range(n):
140
+ _walk(a_children[i], b_children[i], f"{path}.children[{i}]", out)
141
+ if len(a_children) > n:
142
+ for i in range(n, len(a_children)):
143
+ out.append(DiffEntry(op="removed", path=f"{path}.children[{i}]", before=_serialize(a_children[i])))
144
+ if len(b_children) > n:
145
+ for i in range(n, len(b_children)):
146
+ out.append(DiffEntry(op="added", path=f"{path}.children[{i}]", after=_serialize(b_children[i])))
147
+
148
+
149
+ def _children(comp: Component) -> list:
150
+ """Extract the list of child Components.
151
+
152
+ Prefers the new `.children` field on Component. Falls back to the
153
+ legacy `args[-1]` Literal-wrapped list for backward compatibility
154
+ with code that hasn't been updated yet.
155
+ """
156
+ if hasattr(comp, "children") and comp.children:
157
+ return comp.children
158
+ if not comp.args:
159
+ return []
160
+ last = comp.args[-1]
161
+ if isinstance(last, Literal) and isinstance(last.value, list):
162
+ return last.value
163
+ return []
164
+
165
+
166
+ def summarize_diff(entries: list[dict]) -> dict:
167
+ """Produce a one-line summary of a diff: counts + the most-impacted path."""
168
+ if not entries:
169
+ return {"count": 0, "added": 0, "removed": 0, "changed": 0, "top_path": None}
170
+ counts = {"added": 0, "removed": 0, "changed": 0, "moved": 0, "error": 0}
171
+ for e in entries:
172
+ op = e["op"]
173
+ counts[op] = counts.get(op, 0) + 1
174
+ # pick the shallowest changed/added path with a non-trivial value
175
+ interesting = [e for e in entries if e["op"] in ("changed", "added", "removed")]
176
+ top = interesting[0]["path"] if interesting else None
177
+ return {"count": len(entries), **counts, "top_path": top}