activegraph 1.0.0rc2__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.
- activegraph/__init__.py +222 -0
- activegraph/__main__.py +5 -0
- activegraph/behaviors/__init__.py +1 -0
- activegraph/behaviors/base.py +153 -0
- activegraph/behaviors/decorators.py +218 -0
- activegraph/cli/__init__.py +17 -0
- activegraph/cli/main.py +793 -0
- activegraph/cli/quickstart.py +556 -0
- activegraph/core/__init__.py +1 -0
- activegraph/core/clock.py +46 -0
- activegraph/core/event.py +33 -0
- activegraph/core/graph.py +846 -0
- activegraph/core/ids.py +135 -0
- activegraph/core/patch.py +47 -0
- activegraph/core/view.py +59 -0
- activegraph/errors.py +342 -0
- activegraph/frame.py +15 -0
- activegraph/llm/__init__.py +51 -0
- activegraph/llm/anthropic.py +339 -0
- activegraph/llm/cache.py +180 -0
- activegraph/llm/errors.py +257 -0
- activegraph/llm/prompt.py +420 -0
- activegraph/llm/provider.py +66 -0
- activegraph/llm/recorded.py +270 -0
- activegraph/llm/types.py +130 -0
- activegraph/observability/__init__.py +61 -0
- activegraph/observability/logging.py +205 -0
- activegraph/observability/metrics.py +219 -0
- activegraph/observability/migration.py +404 -0
- activegraph/observability/prometheus.py +101 -0
- activegraph/observability/status.py +83 -0
- activegraph/packs/__init__.py +999 -0
- activegraph/packs/diligence/__init__.py +86 -0
- activegraph/packs/diligence/behaviors.py +447 -0
- activegraph/packs/diligence/fixtures/__init__.py +364 -0
- activegraph/packs/diligence/fixtures/companies.py +511 -0
- activegraph/packs/diligence/object_types.py +163 -0
- activegraph/packs/diligence/settings.py +60 -0
- activegraph/packs/diligence/tools.py +124 -0
- activegraph/packs/loader.py +709 -0
- activegraph/packs/scaffold.py +317 -0
- activegraph/policy.py +20 -0
- activegraph/runtime/__init__.py +1 -0
- activegraph/runtime/behavior_graph.py +151 -0
- activegraph/runtime/budget.py +120 -0
- activegraph/runtime/config_errors.py +124 -0
- activegraph/runtime/diff.py +145 -0
- activegraph/runtime/errors.py +216 -0
- activegraph/runtime/exec_errors.py +232 -0
- activegraph/runtime/patterns.py +946 -0
- activegraph/runtime/queue.py +27 -0
- activegraph/runtime/registration_errors.py +291 -0
- activegraph/runtime/registry.py +111 -0
- activegraph/runtime/runtime.py +2441 -0
- activegraph/runtime/scheduler.py +206 -0
- activegraph/runtime/view_builder.py +65 -0
- activegraph/store/__init__.py +41 -0
- activegraph/store/base.py +66 -0
- activegraph/store/conformance.py +161 -0
- activegraph/store/errors.py +84 -0
- activegraph/store/memory.py +118 -0
- activegraph/store/postgres.py +609 -0
- activegraph/store/serde.py +202 -0
- activegraph/store/sqlite.py +446 -0
- activegraph/store/url.py +230 -0
- activegraph/tools/__init__.py +64 -0
- activegraph/tools/base.py +57 -0
- activegraph/tools/cache.py +157 -0
- activegraph/tools/context.py +48 -0
- activegraph/tools/decorators.py +70 -0
- activegraph/tools/errors.py +320 -0
- activegraph/tools/graph_query.py +94 -0
- activegraph/tools/recorded.py +205 -0
- activegraph/tools/web_fetch.py +80 -0
- activegraph/trace/__init__.py +1 -0
- activegraph/trace/causal.py +123 -0
- activegraph/trace/printer.py +495 -0
- activegraph-1.0.0rc2.dist-info/METADATA +228 -0
- activegraph-1.0.0rc2.dist-info/RECORD +82 -0
- activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
- activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
- activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
"""Object, Relation, Graph + projector.
|
|
2
|
+
|
|
3
|
+
CONTRACT #2 (strict): Graph state is materialized from the event log.
|
|
4
|
+
`emit(event)` is the only mutator. Convenience methods (`add_object`,
|
|
5
|
+
`add_relation`, `patch_object`, `propose_patch`, `apply_patch`, etc.) all
|
|
6
|
+
build an Event and call emit.
|
|
7
|
+
|
|
8
|
+
CONTRACT v0.5 #15: the projector is `apply_event(graph, event)`, a
|
|
9
|
+
module-level function — the ONLY thing that mutates graph state. It is
|
|
10
|
+
called from two paths:
|
|
11
|
+
- `Graph.emit` for live events (also persists + notifies listeners)
|
|
12
|
+
- `Graph._replay_event` for replay (silent: no persist, no notify)
|
|
13
|
+
Two callers, one code path.
|
|
14
|
+
|
|
15
|
+
CONTRACT #5 (provenance): every object/relation/patch carries a provenance
|
|
16
|
+
dict written here, never by the behavior. Behaviors pass `data`; we strip
|
|
17
|
+
any `provenance` key they sneak in.
|
|
18
|
+
|
|
19
|
+
CONTRACT #4 (versioning): every object has a monotonic `version` int that
|
|
20
|
+
bumps on every patch.applied. Patches record `expected_version`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import copy
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Callable, Optional
|
|
28
|
+
|
|
29
|
+
from activegraph.core.clock import Clock
|
|
30
|
+
from activegraph.core.event import Event
|
|
31
|
+
from activegraph.core.ids import IDGen
|
|
32
|
+
from activegraph.core.patch import Patch
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------- handles ----------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Object:
|
|
40
|
+
id: str
|
|
41
|
+
type: str
|
|
42
|
+
data: dict[str, Any]
|
|
43
|
+
version: int
|
|
44
|
+
provenance: dict[str, Any]
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"id": self.id,
|
|
49
|
+
"type": self.type,
|
|
50
|
+
"data": copy.deepcopy(self.data),
|
|
51
|
+
"version": self.version,
|
|
52
|
+
"provenance": copy.deepcopy(self.provenance),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Relation:
|
|
58
|
+
id: str
|
|
59
|
+
source: str
|
|
60
|
+
target: str
|
|
61
|
+
type: str
|
|
62
|
+
data: dict[str, Any]
|
|
63
|
+
provenance: dict[str, Any]
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"id": self.id,
|
|
68
|
+
"source": self.source,
|
|
69
|
+
"target": self.target,
|
|
70
|
+
"type": self.type,
|
|
71
|
+
"data": copy.deepcopy(self.data),
|
|
72
|
+
"provenance": copy.deepcopy(self.provenance),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------- graph ----------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _strip_provenance(data: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
"""Behaviors do not get to set provenance (CONTRACT #5)."""
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
return data
|
|
83
|
+
return {k: v for k, v in data.items() if k != "provenance"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _diff(old: dict[str, Any], updates: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
87
|
+
"""Return per-field {old, new} for fields that actually change."""
|
|
88
|
+
out: dict[str, dict[str, Any]] = {}
|
|
89
|
+
for k, new_v in updates.items():
|
|
90
|
+
old_v = old.get(k, _MISSING)
|
|
91
|
+
if old_v != new_v:
|
|
92
|
+
out[k] = {"old": None if old_v is _MISSING else old_v, "new": new_v}
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_MISSING = object()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Graph:
|
|
100
|
+
"""Event-sourced graph. The log is truth; objects/relations are projection."""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
ids: Optional[IDGen] = None,
|
|
105
|
+
clock: Optional[Clock] = None,
|
|
106
|
+
run_id: Optional[str] = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.ids = ids or IDGen()
|
|
109
|
+
self.clock = clock or Clock()
|
|
110
|
+
# CONTRACT v0.5 #6: every graph has a run_id.
|
|
111
|
+
self.run_id: str = run_id or self.ids.run()
|
|
112
|
+
|
|
113
|
+
# projected state — touched ONLY by apply_event (CONTRACT v0.5 #15)
|
|
114
|
+
self._objects: dict[str, Object] = {}
|
|
115
|
+
self._relations: dict[str, Relation] = {}
|
|
116
|
+
self._patches: dict[str, Patch] = {}
|
|
117
|
+
|
|
118
|
+
# the log
|
|
119
|
+
self._events: list[Event] = []
|
|
120
|
+
|
|
121
|
+
# listeners (the runtime queue subscribes here)
|
|
122
|
+
self._listeners: list[Callable[[Event], None]] = []
|
|
123
|
+
|
|
124
|
+
# CONTRACT v0.5 #14: track which events were replayed (not live).
|
|
125
|
+
# The trace printer renders them with a [replay.event] prefix.
|
|
126
|
+
self._replayed_ids: set[str] = set()
|
|
127
|
+
|
|
128
|
+
# Optional persistence sink (attached by Runtime when persist_to=...).
|
|
129
|
+
self._store = None # type: ignore[assignment]
|
|
130
|
+
|
|
131
|
+
# v0.9: optional schema validators attached by `runtime.load_pack`.
|
|
132
|
+
# `_pack_object_validator(type, data) -> validated_data` is called
|
|
133
|
+
# from `add_object`. `_pack_relation_validator(type, src_type,
|
|
134
|
+
# tgt_type) -> None` is called from `add_relation`. When None
|
|
135
|
+
# (default), behavior is unchanged from v0.8.
|
|
136
|
+
self._pack_object_validator = None
|
|
137
|
+
self._pack_relation_validator = None
|
|
138
|
+
|
|
139
|
+
# ---------- read API ----------
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def events(self) -> list[Event]:
|
|
143
|
+
return list(self._events)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def replayed_ids(self) -> frozenset[str]:
|
|
147
|
+
return frozenset(self._replayed_ids)
|
|
148
|
+
|
|
149
|
+
def get_object(self, id_: str) -> Optional[Object]:
|
|
150
|
+
return self._objects.get(id_)
|
|
151
|
+
|
|
152
|
+
def get_relation(self, id_: str) -> Optional[Relation]:
|
|
153
|
+
return self._relations.get(id_)
|
|
154
|
+
|
|
155
|
+
def get_patch(self, id_: str) -> Optional[Patch]:
|
|
156
|
+
return self._patches.get(id_)
|
|
157
|
+
|
|
158
|
+
def all_objects(self) -> list[Object]:
|
|
159
|
+
return list(self._objects.values())
|
|
160
|
+
|
|
161
|
+
def all_relations(self) -> list[Relation]:
|
|
162
|
+
return list(self._relations.values())
|
|
163
|
+
|
|
164
|
+
def get_relations(
|
|
165
|
+
self,
|
|
166
|
+
object_id: Optional[str] = None,
|
|
167
|
+
type: Optional[str] = None,
|
|
168
|
+
direction: str = "both",
|
|
169
|
+
) -> list[Relation]:
|
|
170
|
+
out: list[Relation] = []
|
|
171
|
+
for r in self._relations.values():
|
|
172
|
+
if type is not None and r.type != type:
|
|
173
|
+
continue
|
|
174
|
+
if object_id is not None:
|
|
175
|
+
if direction == "outgoing" and r.source != object_id:
|
|
176
|
+
continue
|
|
177
|
+
if direction == "incoming" and r.target != object_id:
|
|
178
|
+
continue
|
|
179
|
+
if direction == "both" and object_id not in (r.source, r.target):
|
|
180
|
+
continue
|
|
181
|
+
out.append(r)
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
def neighborhood(self, object_id: str, depth: int = 1) -> tuple[list[Object], list[Relation]]:
|
|
185
|
+
if object_id not in self._objects:
|
|
186
|
+
return ([], [])
|
|
187
|
+
seen_objs = {object_id}
|
|
188
|
+
frontier = {object_id}
|
|
189
|
+
seen_rels: set[str] = set()
|
|
190
|
+
for _ in range(depth):
|
|
191
|
+
next_frontier: set[str] = set()
|
|
192
|
+
for r in self._relations.values():
|
|
193
|
+
if r.source in frontier or r.target in frontier:
|
|
194
|
+
seen_rels.add(r.id)
|
|
195
|
+
if r.source not in seen_objs:
|
|
196
|
+
next_frontier.add(r.source)
|
|
197
|
+
if r.target not in seen_objs:
|
|
198
|
+
next_frontier.add(r.target)
|
|
199
|
+
seen_objs |= next_frontier
|
|
200
|
+
frontier = next_frontier
|
|
201
|
+
if not frontier:
|
|
202
|
+
break
|
|
203
|
+
return (
|
|
204
|
+
[self._objects[i] for i in seen_objs if i in self._objects],
|
|
205
|
+
[self._relations[i] for i in seen_rels if i in self._relations],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def query(
|
|
209
|
+
self,
|
|
210
|
+
object_type: Optional[str] = None,
|
|
211
|
+
where: Optional[dict[str, Any]] = None,
|
|
212
|
+
) -> list[Object]:
|
|
213
|
+
out: list[Object] = []
|
|
214
|
+
for o in self._objects.values():
|
|
215
|
+
if object_type is not None and o.type != object_type:
|
|
216
|
+
continue
|
|
217
|
+
if where and not _eval_where_on_object(where, o):
|
|
218
|
+
continue
|
|
219
|
+
out.append(o)
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
def has_object_of_type(self, type_: str) -> bool:
|
|
223
|
+
return any(o.type == type_ for o in self._objects.values())
|
|
224
|
+
|
|
225
|
+
# ---------- listener API (runtime hooks here) ----------
|
|
226
|
+
|
|
227
|
+
def add_listener(self, fn: Callable[[Event], None]) -> None:
|
|
228
|
+
self._listeners.append(fn)
|
|
229
|
+
|
|
230
|
+
# ---------- store attachment (Runtime sets this) ----------
|
|
231
|
+
|
|
232
|
+
def attach_store(self, store) -> None:
|
|
233
|
+
"""Wire an EventStore as the durability sink. Idempotent on the same
|
|
234
|
+
store. Calling with a *different* store after events exist is an error
|
|
235
|
+
— events would be persisted in two places and you'd lose history.
|
|
236
|
+
"""
|
|
237
|
+
if self._store is store:
|
|
238
|
+
return
|
|
239
|
+
if self._store is not None:
|
|
240
|
+
from activegraph.runtime.config_errors import IncompatibleRuntimeState
|
|
241
|
+
raise IncompatibleRuntimeState(
|
|
242
|
+
"graph already has a store attached",
|
|
243
|
+
what_failed=(
|
|
244
|
+
"Graph.attach_store() was called, but this graph already "
|
|
245
|
+
"has a store. Stores attach at most once per graph "
|
|
246
|
+
"lifetime."
|
|
247
|
+
),
|
|
248
|
+
why=(
|
|
249
|
+
"A graph's store is the durability target for every "
|
|
250
|
+
"event it emits. Re-attaching a second store would "
|
|
251
|
+
"either (a) split the event log across two stores, "
|
|
252
|
+
"with subsequent events going to the new one and "
|
|
253
|
+
"earlier events stuck in the old, or (b) try to copy "
|
|
254
|
+
"the old log to the new store, which is a migration, "
|
|
255
|
+
"not an attach. The framework refuses re-attach so "
|
|
256
|
+
"neither failure mode is reachable silently."
|
|
257
|
+
),
|
|
258
|
+
how_to_fix=(
|
|
259
|
+
"If you want to copy the graph's run to a new store, "
|
|
260
|
+
"use the migration primitive on the existing store's "
|
|
261
|
+
"URL after the run completes:\n"
|
|
262
|
+
" activegraph migrate --from <old-url> --to <new-url>\n"
|
|
263
|
+
"\n"
|
|
264
|
+
"If the graph is fresh and the existing store is a "
|
|
265
|
+
"placeholder (e.g., from a test fixture), construct a "
|
|
266
|
+
"new Graph rather than re-attaching."
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
self._store = store
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def store(self):
|
|
273
|
+
return self._store
|
|
274
|
+
|
|
275
|
+
# ---------- the only mutator (live path) ----------
|
|
276
|
+
|
|
277
|
+
def emit(self, event: Event) -> Event:
|
|
278
|
+
"""Append to log, project, persist (if attached), notify. CONTRACT #2."""
|
|
279
|
+
# Fail-fast serialization check at emit time so bad payloads never
|
|
280
|
+
# land in the in-memory log either (CONTRACT v0.5 #4).
|
|
281
|
+
if self._store is not None:
|
|
282
|
+
from activegraph.store.serde import validate_event
|
|
283
|
+
|
|
284
|
+
validate_event(event)
|
|
285
|
+
self._events.append(event)
|
|
286
|
+
apply_event(self, event)
|
|
287
|
+
if self._store is not None:
|
|
288
|
+
self._store.append(event)
|
|
289
|
+
for listener in self._listeners:
|
|
290
|
+
listener(event)
|
|
291
|
+
return event
|
|
292
|
+
|
|
293
|
+
# ---------- the only mutator (replay path) ----------
|
|
294
|
+
|
|
295
|
+
def _replay_event(self, event: Event) -> None:
|
|
296
|
+
"""Apply a recorded event WITHOUT persisting or firing listeners.
|
|
297
|
+
|
|
298
|
+
Used only by `Runtime.load` and `Runtime.fork`. CONTRACT v0.5 #14:
|
|
299
|
+
replay rebuilds graph state; it does NOT fire behaviors.
|
|
300
|
+
"""
|
|
301
|
+
self._events.append(event)
|
|
302
|
+
apply_event(self, event)
|
|
303
|
+
self._replayed_ids.add(event.id)
|
|
304
|
+
|
|
305
|
+
# ---------- convenience builders (each builds an Event and emits) ----------
|
|
306
|
+
|
|
307
|
+
def add_object(
|
|
308
|
+
self,
|
|
309
|
+
type: str,
|
|
310
|
+
data: dict[str, Any],
|
|
311
|
+
*,
|
|
312
|
+
actor: str = "system",
|
|
313
|
+
caused_by: Optional[str] = None,
|
|
314
|
+
frame_id: Optional[str] = None,
|
|
315
|
+
evidence: Optional[list[str]] = None,
|
|
316
|
+
llm_request_event_id: Optional[str] = None,
|
|
317
|
+
tool_request_event_ids: Optional[list[str]] = None,
|
|
318
|
+
) -> Object:
|
|
319
|
+
obj_id = self.ids.object(type)
|
|
320
|
+
clean = _strip_provenance(copy.deepcopy(data))
|
|
321
|
+
# v0.9: schema validation against loaded pack object types.
|
|
322
|
+
# Validator is set by runtime.load_pack and is None when no
|
|
323
|
+
# typed pack contributes this object type — preserving v0.8
|
|
324
|
+
# untyped semantics (CONTRACT v0.9 #5 / #21).
|
|
325
|
+
if self._pack_object_validator is not None:
|
|
326
|
+
clean = self._pack_object_validator(type, clean)
|
|
327
|
+
provenance = self._provenance(
|
|
328
|
+
actor,
|
|
329
|
+
caused_by,
|
|
330
|
+
frame_id,
|
|
331
|
+
evidence,
|
|
332
|
+
llm_request_event_id,
|
|
333
|
+
tool_request_event_ids,
|
|
334
|
+
)
|
|
335
|
+
payload = {
|
|
336
|
+
"object": {
|
|
337
|
+
"id": obj_id,
|
|
338
|
+
"type": type,
|
|
339
|
+
"data": clean,
|
|
340
|
+
"version": 1,
|
|
341
|
+
"provenance": provenance,
|
|
342
|
+
},
|
|
343
|
+
"id": obj_id,
|
|
344
|
+
}
|
|
345
|
+
event = Event(
|
|
346
|
+
id=self.ids.event(),
|
|
347
|
+
type="object.created",
|
|
348
|
+
payload=payload,
|
|
349
|
+
actor=actor,
|
|
350
|
+
frame_id=frame_id,
|
|
351
|
+
caused_by=caused_by,
|
|
352
|
+
timestamp=self.clock.now(),
|
|
353
|
+
)
|
|
354
|
+
self.emit(event)
|
|
355
|
+
return self._objects[obj_id]
|
|
356
|
+
|
|
357
|
+
def add_relation(
|
|
358
|
+
self,
|
|
359
|
+
source: str,
|
|
360
|
+
target: str,
|
|
361
|
+
type: str,
|
|
362
|
+
data: Optional[dict[str, Any]] = None,
|
|
363
|
+
*,
|
|
364
|
+
actor: str = "system",
|
|
365
|
+
caused_by: Optional[str] = None,
|
|
366
|
+
frame_id: Optional[str] = None,
|
|
367
|
+
llm_request_event_id: Optional[str] = None,
|
|
368
|
+
tool_request_event_ids: Optional[list[str]] = None,
|
|
369
|
+
) -> Relation:
|
|
370
|
+
rel_id = self.ids.relation()
|
|
371
|
+
clean = _strip_provenance(copy.deepcopy(data or {}))
|
|
372
|
+
# v0.9: relation type validation (source/target type rules).
|
|
373
|
+
if self._pack_relation_validator is not None:
|
|
374
|
+
src_obj = self._objects.get(source)
|
|
375
|
+
tgt_obj = self._objects.get(target)
|
|
376
|
+
self._pack_relation_validator(
|
|
377
|
+
type,
|
|
378
|
+
src_obj.type if src_obj else None,
|
|
379
|
+
tgt_obj.type if tgt_obj else None,
|
|
380
|
+
)
|
|
381
|
+
provenance = self._provenance(
|
|
382
|
+
actor,
|
|
383
|
+
caused_by,
|
|
384
|
+
frame_id,
|
|
385
|
+
[],
|
|
386
|
+
llm_request_event_id,
|
|
387
|
+
tool_request_event_ids,
|
|
388
|
+
)
|
|
389
|
+
payload = {
|
|
390
|
+
"relation": {
|
|
391
|
+
"id": rel_id,
|
|
392
|
+
"source": source,
|
|
393
|
+
"target": target,
|
|
394
|
+
"type": type,
|
|
395
|
+
"data": clean,
|
|
396
|
+
"provenance": provenance,
|
|
397
|
+
},
|
|
398
|
+
"id": rel_id,
|
|
399
|
+
"source": source,
|
|
400
|
+
"target": target,
|
|
401
|
+
}
|
|
402
|
+
event = Event(
|
|
403
|
+
id=self.ids.event(),
|
|
404
|
+
type="relation.created",
|
|
405
|
+
payload=payload,
|
|
406
|
+
actor=actor,
|
|
407
|
+
frame_id=frame_id,
|
|
408
|
+
caused_by=caused_by,
|
|
409
|
+
timestamp=self.clock.now(),
|
|
410
|
+
)
|
|
411
|
+
self.emit(event)
|
|
412
|
+
return self._relations[rel_id]
|
|
413
|
+
|
|
414
|
+
def remove_relation(
|
|
415
|
+
self,
|
|
416
|
+
relation_id: str,
|
|
417
|
+
*,
|
|
418
|
+
actor: str = "system",
|
|
419
|
+
caused_by: Optional[str] = None,
|
|
420
|
+
frame_id: Optional[str] = None,
|
|
421
|
+
) -> None:
|
|
422
|
+
if relation_id not in self._relations:
|
|
423
|
+
return
|
|
424
|
+
event = Event(
|
|
425
|
+
id=self.ids.event(),
|
|
426
|
+
type="relation.removed",
|
|
427
|
+
payload={"id": relation_id},
|
|
428
|
+
actor=actor,
|
|
429
|
+
frame_id=frame_id,
|
|
430
|
+
caused_by=caused_by,
|
|
431
|
+
timestamp=self.clock.now(),
|
|
432
|
+
)
|
|
433
|
+
self.emit(event)
|
|
434
|
+
|
|
435
|
+
def remove_object(
|
|
436
|
+
self,
|
|
437
|
+
object_id: str,
|
|
438
|
+
*,
|
|
439
|
+
actor: str = "system",
|
|
440
|
+
caused_by: Optional[str] = None,
|
|
441
|
+
frame_id: Optional[str] = None,
|
|
442
|
+
) -> None:
|
|
443
|
+
if object_id not in self._objects:
|
|
444
|
+
return
|
|
445
|
+
event = Event(
|
|
446
|
+
id=self.ids.event(),
|
|
447
|
+
type="object.removed",
|
|
448
|
+
payload={"id": object_id},
|
|
449
|
+
actor=actor,
|
|
450
|
+
frame_id=frame_id,
|
|
451
|
+
caused_by=caused_by,
|
|
452
|
+
timestamp=self.clock.now(),
|
|
453
|
+
)
|
|
454
|
+
self.emit(event)
|
|
455
|
+
|
|
456
|
+
def patch_object(
|
|
457
|
+
self,
|
|
458
|
+
target: str,
|
|
459
|
+
updates: dict[str, Any],
|
|
460
|
+
*,
|
|
461
|
+
actor: str = "system",
|
|
462
|
+
caused_by: Optional[str] = None,
|
|
463
|
+
frame_id: Optional[str] = None,
|
|
464
|
+
rationale: Optional[str] = None,
|
|
465
|
+
evidence: Optional[list[str]] = None,
|
|
466
|
+
llm_request_event_id: Optional[str] = None,
|
|
467
|
+
tool_request_event_ids: Optional[list[str]] = None,
|
|
468
|
+
) -> Patch:
|
|
469
|
+
"""Auto-apply shortcut: build patch, version-check, emit applied/rejected."""
|
|
470
|
+
obj = self._objects.get(target)
|
|
471
|
+
if obj is None:
|
|
472
|
+
raise KeyError(f"unknown object: {target}")
|
|
473
|
+
clean = _strip_provenance(copy.deepcopy(updates))
|
|
474
|
+
patch = Patch(
|
|
475
|
+
id=self.ids.patch(),
|
|
476
|
+
target=target,
|
|
477
|
+
op="update",
|
|
478
|
+
value=clean,
|
|
479
|
+
expected_version=obj.version,
|
|
480
|
+
proposed_by=actor,
|
|
481
|
+
rationale=rationale,
|
|
482
|
+
evidence=list(evidence or []),
|
|
483
|
+
status="applied",
|
|
484
|
+
provenance=self._provenance(
|
|
485
|
+
actor,
|
|
486
|
+
caused_by,
|
|
487
|
+
frame_id,
|
|
488
|
+
evidence,
|
|
489
|
+
llm_request_event_id,
|
|
490
|
+
tool_request_event_ids,
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
diff = _diff(obj.data, clean)
|
|
494
|
+
event = Event(
|
|
495
|
+
id=self.ids.event(),
|
|
496
|
+
type="patch.applied",
|
|
497
|
+
payload={
|
|
498
|
+
"patch": patch.to_dict(),
|
|
499
|
+
"target": target,
|
|
500
|
+
"diff": diff,
|
|
501
|
+
},
|
|
502
|
+
actor=actor,
|
|
503
|
+
frame_id=frame_id,
|
|
504
|
+
caused_by=caused_by,
|
|
505
|
+
timestamp=self.clock.now(),
|
|
506
|
+
)
|
|
507
|
+
self.emit(event)
|
|
508
|
+
return self._patches[patch.id]
|
|
509
|
+
|
|
510
|
+
def propose_patch(
|
|
511
|
+
self,
|
|
512
|
+
target: str,
|
|
513
|
+
op: str,
|
|
514
|
+
value: dict[str, Any],
|
|
515
|
+
*,
|
|
516
|
+
proposed_by: str,
|
|
517
|
+
rationale: Optional[str] = None,
|
|
518
|
+
evidence: Optional[list[str]] = None,
|
|
519
|
+
caused_by: Optional[str] = None,
|
|
520
|
+
frame_id: Optional[str] = None,
|
|
521
|
+
llm_request_event_id: Optional[str] = None,
|
|
522
|
+
tool_request_event_ids: Optional[list[str]] = None,
|
|
523
|
+
) -> Patch:
|
|
524
|
+
# Strip "object:" / "relation:" prefix if present (README sugar).
|
|
525
|
+
normalized = target.split(":", 1)[1] if ":" in target else target
|
|
526
|
+
obj = self._objects.get(normalized)
|
|
527
|
+
expected_version = obj.version if obj else 0
|
|
528
|
+
clean = _strip_provenance(copy.deepcopy(value))
|
|
529
|
+
patch = Patch(
|
|
530
|
+
id=self.ids.patch(),
|
|
531
|
+
target=normalized,
|
|
532
|
+
op=op,
|
|
533
|
+
value=clean,
|
|
534
|
+
expected_version=expected_version,
|
|
535
|
+
proposed_by=proposed_by,
|
|
536
|
+
rationale=rationale,
|
|
537
|
+
evidence=list(evidence or []),
|
|
538
|
+
status="proposed",
|
|
539
|
+
provenance=self._provenance(
|
|
540
|
+
proposed_by,
|
|
541
|
+
caused_by,
|
|
542
|
+
frame_id,
|
|
543
|
+
evidence,
|
|
544
|
+
llm_request_event_id,
|
|
545
|
+
tool_request_event_ids,
|
|
546
|
+
),
|
|
547
|
+
)
|
|
548
|
+
event = Event(
|
|
549
|
+
id=self.ids.event(),
|
|
550
|
+
type="patch.proposed",
|
|
551
|
+
payload={"patch": patch.to_dict()},
|
|
552
|
+
actor=proposed_by,
|
|
553
|
+
frame_id=frame_id,
|
|
554
|
+
caused_by=caused_by,
|
|
555
|
+
timestamp=self.clock.now(),
|
|
556
|
+
)
|
|
557
|
+
self.emit(event)
|
|
558
|
+
return self._patches[patch.id]
|
|
559
|
+
|
|
560
|
+
def apply_patch(
|
|
561
|
+
self,
|
|
562
|
+
patch_id: str,
|
|
563
|
+
*,
|
|
564
|
+
approved_by: str = "system",
|
|
565
|
+
caused_by: Optional[str] = None,
|
|
566
|
+
frame_id: Optional[str] = None,
|
|
567
|
+
) -> Event:
|
|
568
|
+
patch = self._patches.get(patch_id)
|
|
569
|
+
if patch is None:
|
|
570
|
+
raise KeyError(f"unknown patch: {patch_id}")
|
|
571
|
+
if patch.status != "proposed":
|
|
572
|
+
from activegraph.runtime.exec_errors import InvalidPatchLifecycleState
|
|
573
|
+
raise InvalidPatchLifecycleState(
|
|
574
|
+
patch_id=patch_id, current_status=patch.status,
|
|
575
|
+
)
|
|
576
|
+
target_obj = self._objects.get(patch.target)
|
|
577
|
+
current_version = target_obj.version if target_obj else 0
|
|
578
|
+
if current_version != patch.expected_version:
|
|
579
|
+
return self._reject(
|
|
580
|
+
patch_id,
|
|
581
|
+
reason=f"version mismatch: expected {patch.expected_version}, got {current_version}",
|
|
582
|
+
actor=approved_by,
|
|
583
|
+
caused_by=caused_by,
|
|
584
|
+
frame_id=frame_id,
|
|
585
|
+
)
|
|
586
|
+
diff = _diff(target_obj.data if target_obj else {}, patch.value)
|
|
587
|
+
event = Event(
|
|
588
|
+
id=self.ids.event(),
|
|
589
|
+
type="patch.applied",
|
|
590
|
+
payload={
|
|
591
|
+
"patch": {**patch.to_dict(), "status": "applied"},
|
|
592
|
+
"target": patch.target,
|
|
593
|
+
"diff": diff,
|
|
594
|
+
"approved_by": approved_by,
|
|
595
|
+
},
|
|
596
|
+
actor=approved_by,
|
|
597
|
+
frame_id=frame_id,
|
|
598
|
+
caused_by=caused_by,
|
|
599
|
+
timestamp=self.clock.now(),
|
|
600
|
+
)
|
|
601
|
+
return self.emit(event)
|
|
602
|
+
|
|
603
|
+
def reject_patch(
|
|
604
|
+
self,
|
|
605
|
+
patch_id: str,
|
|
606
|
+
reason: str,
|
|
607
|
+
*,
|
|
608
|
+
actor: str = "system",
|
|
609
|
+
caused_by: Optional[str] = None,
|
|
610
|
+
frame_id: Optional[str] = None,
|
|
611
|
+
) -> Event:
|
|
612
|
+
return self._reject(patch_id, reason, actor=actor, caused_by=caused_by, frame_id=frame_id)
|
|
613
|
+
|
|
614
|
+
def _reject(
|
|
615
|
+
self,
|
|
616
|
+
patch_id: str,
|
|
617
|
+
reason: str,
|
|
618
|
+
*,
|
|
619
|
+
actor: str,
|
|
620
|
+
caused_by: Optional[str],
|
|
621
|
+
frame_id: Optional[str],
|
|
622
|
+
) -> Event:
|
|
623
|
+
patch = self._patches[patch_id]
|
|
624
|
+
current = self._objects.get(patch.target)
|
|
625
|
+
event = Event(
|
|
626
|
+
id=self.ids.event(),
|
|
627
|
+
type="patch.rejected",
|
|
628
|
+
payload={
|
|
629
|
+
"patch_id": patch_id,
|
|
630
|
+
"target": patch.target,
|
|
631
|
+
"reason": reason,
|
|
632
|
+
"current_version": current.version if current else 0,
|
|
633
|
+
},
|
|
634
|
+
actor=actor,
|
|
635
|
+
frame_id=frame_id,
|
|
636
|
+
caused_by=caused_by,
|
|
637
|
+
timestamp=self.clock.now(),
|
|
638
|
+
)
|
|
639
|
+
return self.emit(event)
|
|
640
|
+
|
|
641
|
+
# ---------- provenance helper (CONTRACT v0.5 #13: includes run_id) ----------
|
|
642
|
+
|
|
643
|
+
def _provenance(
|
|
644
|
+
self,
|
|
645
|
+
actor: str,
|
|
646
|
+
caused_by: Optional[str],
|
|
647
|
+
frame_id: Optional[str],
|
|
648
|
+
evidence: Optional[list[str]],
|
|
649
|
+
llm_request_event_id: Optional[str] = None,
|
|
650
|
+
tool_request_event_ids: Optional[list[str]] = None,
|
|
651
|
+
) -> dict[str, Any]:
|
|
652
|
+
p: dict[str, Any] = {
|
|
653
|
+
"created_by": actor,
|
|
654
|
+
"caused_by_event": caused_by,
|
|
655
|
+
"frame_id": frame_id,
|
|
656
|
+
"timestamp": self.clock.now(),
|
|
657
|
+
"evidence": list(evidence or []),
|
|
658
|
+
"run_id": self.run_id,
|
|
659
|
+
}
|
|
660
|
+
# CONTRACT v0.6 #15: objects/relations/patches created inside an
|
|
661
|
+
# @llm_behavior handler carry the llm.requested event id so causal
|
|
662
|
+
# chain walks can cross the LLM boundary.
|
|
663
|
+
if llm_request_event_id is not None:
|
|
664
|
+
p["llm_request_event_id"] = llm_request_event_id
|
|
665
|
+
# CONTRACT v0.7 #19: when the LLM behavior's turn loop invoked
|
|
666
|
+
# tools, all contributing tool.requested event ids are stamped
|
|
667
|
+
# here so causal-chain walks can enumerate every tool call.
|
|
668
|
+
if tool_request_event_ids:
|
|
669
|
+
p["tool_request_event_ids"] = list(tool_request_event_ids)
|
|
670
|
+
return p
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ---------- the projector — module-level, single mutation code path ----------
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def apply_event(graph: Graph, event: Event) -> None:
|
|
677
|
+
"""Project an event onto the graph's in-memory state.
|
|
678
|
+
|
|
679
|
+
The ONLY function that mutates `_objects` / `_relations` / `_patches`.
|
|
680
|
+
Called from `Graph.emit` (live) and `Graph._replay_event` (replay).
|
|
681
|
+
Pure projection — no I/O, no listener calls, no event log mutation.
|
|
682
|
+
"""
|
|
683
|
+
t = event.type
|
|
684
|
+
p = event.payload
|
|
685
|
+
|
|
686
|
+
if t == "object.created":
|
|
687
|
+
o = p["object"]
|
|
688
|
+
graph._objects[o["id"]] = Object(
|
|
689
|
+
id=o["id"],
|
|
690
|
+
type=o["type"],
|
|
691
|
+
data=copy.deepcopy(o["data"]),
|
|
692
|
+
version=o["version"],
|
|
693
|
+
provenance=copy.deepcopy(o["provenance"]),
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
elif t == "object.removed":
|
|
697
|
+
graph._objects.pop(p["id"], None)
|
|
698
|
+
# cascade: drop relations touching it
|
|
699
|
+
for rid in [
|
|
700
|
+
r.id for r in graph._relations.values() if p["id"] in (r.source, r.target)
|
|
701
|
+
]:
|
|
702
|
+
graph._relations.pop(rid, None)
|
|
703
|
+
|
|
704
|
+
elif t == "relation.created":
|
|
705
|
+
r = p["relation"]
|
|
706
|
+
graph._relations[r["id"]] = Relation(
|
|
707
|
+
id=r["id"],
|
|
708
|
+
source=r["source"],
|
|
709
|
+
target=r["target"],
|
|
710
|
+
type=r["type"],
|
|
711
|
+
data=copy.deepcopy(r["data"]),
|
|
712
|
+
provenance=copy.deepcopy(r["provenance"]),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
elif t == "relation.removed":
|
|
716
|
+
graph._relations.pop(p["id"], None)
|
|
717
|
+
|
|
718
|
+
elif t == "patch.proposed":
|
|
719
|
+
patch_dict = p["patch"]
|
|
720
|
+
graph._patches[patch_dict["id"]] = _patch_from_dict(patch_dict)
|
|
721
|
+
|
|
722
|
+
elif t == "patch.applied":
|
|
723
|
+
patch_dict = p["patch"]
|
|
724
|
+
patch = _patch_from_dict({**patch_dict, "status": "applied"})
|
|
725
|
+
graph._patches[patch.id] = patch
|
|
726
|
+
obj = graph._objects.get(patch.target)
|
|
727
|
+
if obj is not None:
|
|
728
|
+
if patch.op == "update":
|
|
729
|
+
obj.data.update(patch.value)
|
|
730
|
+
elif patch.op == "replace":
|
|
731
|
+
obj.data = copy.deepcopy(patch.value)
|
|
732
|
+
obj.version += 1
|
|
733
|
+
|
|
734
|
+
elif t == "patch.rejected":
|
|
735
|
+
existing = graph._patches.get(p["patch_id"])
|
|
736
|
+
if existing is not None:
|
|
737
|
+
existing.status = "rejected"
|
|
738
|
+
existing.rejection_reason = p["reason"]
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _patch_from_dict(d: dict[str, Any]) -> Patch:
|
|
742
|
+
return Patch(
|
|
743
|
+
id=d["id"],
|
|
744
|
+
target=d["target"],
|
|
745
|
+
op=d["op"],
|
|
746
|
+
value=copy.deepcopy(d["value"]),
|
|
747
|
+
expected_version=d["expected_version"],
|
|
748
|
+
proposed_by=d["proposed_by"],
|
|
749
|
+
rationale=d.get("rationale"),
|
|
750
|
+
evidence=list(d.get("evidence", [])),
|
|
751
|
+
status=d.get("status", "proposed"),
|
|
752
|
+
rejection_reason=d.get("rejection_reason"),
|
|
753
|
+
provenance=copy.deepcopy(d.get("provenance", {})),
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# ---------- where evaluator (used by query and matchers) ----------
|
|
758
|
+
|
|
759
|
+
_OPS = {
|
|
760
|
+
">": lambda a, b: a is not None and a > b,
|
|
761
|
+
"<": lambda a, b: a is not None and a < b,
|
|
762
|
+
">=": lambda a, b: a is not None and a >= b,
|
|
763
|
+
"<=": lambda a, b: a is not None and a <= b,
|
|
764
|
+
"==": lambda a, b: a == b,
|
|
765
|
+
"!=": lambda a, b: a != b,
|
|
766
|
+
"in": lambda a, b: a in b,
|
|
767
|
+
"not in": lambda a, b: a not in b,
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _resolve_path(root: Any, path: list[str]) -> Any:
|
|
772
|
+
cur = root
|
|
773
|
+
for p in path:
|
|
774
|
+
if isinstance(cur, dict):
|
|
775
|
+
cur = cur.get(p)
|
|
776
|
+
elif hasattr(cur, p):
|
|
777
|
+
cur = getattr(cur, p)
|
|
778
|
+
else:
|
|
779
|
+
return None
|
|
780
|
+
if cur is None:
|
|
781
|
+
return None
|
|
782
|
+
return cur
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def evaluate_where(where: dict[str, Any], root: Any) -> bool:
|
|
786
|
+
"""Evaluate a where dict against a root (event payload, object, etc.).
|
|
787
|
+
|
|
788
|
+
Keys are dotted paths. Values are either literals (equality) or
|
|
789
|
+
`{"op": value}` dicts for comparisons.
|
|
790
|
+
"""
|
|
791
|
+
for key, expected in where.items():
|
|
792
|
+
actual = _resolve_path(root, key.split("."))
|
|
793
|
+
if isinstance(expected, dict):
|
|
794
|
+
for op, value in expected.items():
|
|
795
|
+
fn = _OPS.get(op)
|
|
796
|
+
if fn is None:
|
|
797
|
+
from activegraph.errors import internal_bug_fields
|
|
798
|
+
from activegraph.runtime.exec_errors import (
|
|
799
|
+
InternalEvaluatorError,
|
|
800
|
+
)
|
|
801
|
+
_fields = internal_bug_fields(
|
|
802
|
+
summary=f"unknown where operator: {op!r}",
|
|
803
|
+
what_happened=(
|
|
804
|
+
f"The view-filter evaluator in graph.py received "
|
|
805
|
+
f"comparison operator {op!r}, but the operator "
|
|
806
|
+
f"table (_OPS in this module) has no handler for it."
|
|
807
|
+
),
|
|
808
|
+
why_invariant=(
|
|
809
|
+
"The operator table is the source of truth for "
|
|
810
|
+
"which comparison operators view filters accept. "
|
|
811
|
+
"An unknown operator means either the filter was "
|
|
812
|
+
"constructed by code that bypassed the parser, or "
|
|
813
|
+
"the operator table drifted from the parser. "
|
|
814
|
+
"Either way, evaluating the filter would silently "
|
|
815
|
+
"produce wrong results — refuse instead."
|
|
816
|
+
),
|
|
817
|
+
location="activegraph/core/graph.py:evaluate_where",
|
|
818
|
+
extra_context={"operator": op},
|
|
819
|
+
)
|
|
820
|
+
raise InternalEvaluatorError(
|
|
821
|
+
_fields["summary"],
|
|
822
|
+
what_failed=_fields["what_failed"],
|
|
823
|
+
why=_fields["why"],
|
|
824
|
+
how_to_fix=_fields["how_to_fix"],
|
|
825
|
+
context=_fields["context"],
|
|
826
|
+
)
|
|
827
|
+
if not fn(actual, value):
|
|
828
|
+
return False
|
|
829
|
+
else:
|
|
830
|
+
if actual != expected:
|
|
831
|
+
return False
|
|
832
|
+
return True
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _eval_where_on_object(where: dict[str, Any], obj: Object) -> bool:
|
|
836
|
+
"""Where on a bare Object — keys are paths under data unless they start with one of the object fields."""
|
|
837
|
+
root = {
|
|
838
|
+
"id": obj.id,
|
|
839
|
+
"type": obj.type,
|
|
840
|
+
"data": obj.data,
|
|
841
|
+
"version": obj.version,
|
|
842
|
+
"provenance": obj.provenance,
|
|
843
|
+
# also expose data fields at top level for convenience
|
|
844
|
+
**obj.data,
|
|
845
|
+
}
|
|
846
|
+
return evaluate_where(where, root)
|