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.
Files changed (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,946 @@
1
+ """Cypher subset parser + matcher. CONTRACT v0.7 #8 / #9 / #11 / #12,
2
+ + CONTRACT v1.0 PR-B (error format migration).
3
+
4
+ A strict subset of Cypher. Anything outside the subset raises
5
+ `UnsupportedPatternError` pointing at the offending token. A clean
6
+ subset is more useful than a fuzzy superset.
7
+
8
+ Supported (the LOCKED subset):
9
+ * Node patterns: (var:type {prop: value, ...})
10
+ * Relationship patterns: (a)-[var:rel_type]->(b)
11
+ (a)<-[var:rel_type]-(b)
12
+ * Multi-hop: (a)-[:r1]->(b)-[:r2]->(c)
13
+ * WHERE clauses: WHERE expr
14
+ - comparisons: a.confidence > 0.7
15
+ - AND (NO OR — see CONTRACT v0.7 #8)
16
+ - NOT NOT a.confidence > 0.5
17
+ - NOT EXISTS { ... } negation over a sub-pattern
18
+ * Node `{prop: value}` is EQUALITY ONLY. Comparisons go in WHERE.
19
+ * Identifiers: ASCII letters/digits/underscore, leading letter.
20
+
21
+ Refused (will raise UnsupportedPatternError, pointing at the token):
22
+ * RETURN — patterns don't return; bindings come out via ctx.matches
23
+ * OPTIONAL MATCH
24
+ * Variable-length paths -[*1..3]-
25
+ * Aggregation (count, sum, ...)
26
+ * WITH / pipeline composition
27
+ * Subqueries beyond NOT EXISTS
28
+ * OR in WHERE
29
+ * UNION / UNWIND / CREATE / MERGE / SET / DELETE / DETACH
30
+
31
+ Parser produces a `Pattern` AST; the runtime calls
32
+ `Pattern.compile().matches(event, graph) -> list[Match]`. Compilation
33
+ happens once, at behavior registration (CONTRACT v0.7 #9).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from dataclasses import dataclass, field
40
+ from typing import Any, Iterator, Optional
41
+
42
+ from activegraph.errors import DOCS_BASE_URL, PatternError
43
+
44
+
45
+ # CONTRACT v0.7 #8 voice notes for v1.0 PR-B:
46
+ # The pattern subset is small on purpose. Every refusal in this module
47
+ # protects two invariants — (a) patterns are exhaustively testable, so
48
+ # a behavior either fires or does not based on a small finite spec; and
49
+ # (b) the trace's audit trail stays meaningful, because a fuzzy match
50
+ # would let a behavior appear to fire on input it did not actually
51
+ # match. Recovery prose almost always points at "register two behaviors"
52
+ # (for OR-like semantics) or "do it in the behavior body" (for mutation,
53
+ # pipelines, etc.).
54
+
55
+
56
+ class UnsupportedPatternError(PatternError, SyntaxError):
57
+ """Pattern uses syntax outside the v0.7 Cypher subset.
58
+
59
+ Multi-inherits :class:`SyntaxError` so existing user code that
60
+ catches ``SyntaxError`` around pattern compilation keeps working.
61
+ The v1.0 structured-format superclass is :class:`PatternError`,
62
+ which is itself an :class:`ActiveGraphError`.
63
+
64
+ Construct via :meth:`refused_feature` or :meth:`syntax_error` —
65
+ the factory class methods produce the canonical voice for the two
66
+ failure modes (a refused-but-recognized Cypher feature vs. a
67
+ parser-level syntax error). Direct construction with the structured
68
+ fields is supported for one-off cases.
69
+ """
70
+
71
+ _doc_slug = "unsupported-pattern-error"
72
+
73
+ def __init__(
74
+ self,
75
+ summary: str,
76
+ *,
77
+ what_failed: str,
78
+ why: str,
79
+ how_to_fix: str,
80
+ at: Optional[str] = None,
81
+ context: Optional[dict[str, Any]] = None,
82
+ ) -> None:
83
+ ctx: dict[str, Any] = dict(context) if context else {}
84
+ if at is not None:
85
+ ctx["at"] = at
86
+ self.at = at
87
+ super().__init__(
88
+ summary,
89
+ what_failed=what_failed,
90
+ why=why,
91
+ how_to_fix=how_to_fix,
92
+ context=ctx,
93
+ )
94
+
95
+ @classmethod
96
+ def refused_feature(
97
+ cls,
98
+ *,
99
+ feature: str,
100
+ workaround: str,
101
+ at: Optional[str] = None,
102
+ why: Optional[str] = None,
103
+ ) -> "UnsupportedPatternError":
104
+ """The canonical case: a recognized Cypher feature that the v0.7
105
+ subset deliberately refuses, with a documented workaround.
106
+
107
+ Use for OR, OPTIONAL MATCH, variable-length paths, undirected
108
+ relationships, WITH, RETURN, CREATE, MERGE, etc. The ``feature``
109
+ argument is included in the summary verbatim, so the substring
110
+ is the same string an operator would search a log for.
111
+ """
112
+ return cls(
113
+ f"{feature} is not supported in the v0.7 Cypher subset",
114
+ what_failed=(
115
+ f"The pattern uses {feature}. The v0.7 subset refuses this "
116
+ f"feature at registration time — long before any match runs."
117
+ ),
118
+ why=(
119
+ why
120
+ if why
121
+ else (
122
+ "The pattern subset is deliberately small and exhaustively "
123
+ "testable. A fuzzy superset of Cypher would let patterns "
124
+ "appear to match input they did not actually match, which "
125
+ "would break the audit trail that pattern-driven behaviors "
126
+ "preserve. See CONTRACT v0.7 #8 for the locked subset."
127
+ )
128
+ ),
129
+ how_to_fix=workaround,
130
+ at=at,
131
+ )
132
+
133
+ @classmethod
134
+ def syntax_error(
135
+ cls,
136
+ *,
137
+ what: str,
138
+ at: Optional[str] = None,
139
+ expected: Optional[str] = None,
140
+ got: Optional[str] = None,
141
+ ) -> "UnsupportedPatternError":
142
+ """Parser-level error: the pattern does not parse at all (vs.
143
+ parses-but-uses-refused-feature). Recovery points the developer
144
+ at the offending token / position.
145
+ """
146
+ if expected is not None and got is not None:
147
+ summary = f"pattern does not parse: expected {expected}, got {got}"
148
+ body_top = (
149
+ f"While parsing the pattern, the parser expected {expected} "
150
+ f"but found {got}."
151
+ )
152
+ else:
153
+ summary = f"pattern does not parse: {what}"
154
+ body_top = f"While parsing the pattern: {what}."
155
+ if at:
156
+ body_top += f"\n at: {at!r}"
157
+ return cls(
158
+ summary,
159
+ what_failed=body_top,
160
+ why=(
161
+ "Behaviors register their pattern subscriptions at startup, "
162
+ "so the parser refuses ambiguous syntax now rather than risk "
163
+ "matching a pattern the developer did not actually write. An "
164
+ "unparseable pattern is a configuration bug; matching is the "
165
+ "next concern."
166
+ ),
167
+ how_to_fix=(
168
+ f"Fix the syntax. The supported subset is documented in CONTRACT "
169
+ f"v0.7 #8 and at\n"
170
+ f" {DOCS_BASE_URL}/concepts/patterns\n"
171
+ f"If the syntax looks right, check for unbalanced brackets, a "
172
+ f"missing relationship type, or a missing arrow direction."
173
+ ),
174
+ at=at,
175
+ )
176
+
177
+
178
+ # Per-keyword recovery prose. CONTRACT v0.7 #8 enumerates the refused
179
+ # keywords; each one has a specific "do this in the behavior body
180
+ # instead" answer.
181
+ _KEYWORD_WORKAROUNDS: dict[str, str] = {
182
+ "RETURN": (
183
+ "Pattern subscriptions do not return values. Bindings reach the\n"
184
+ "behavior body via `ctx.matches` — read them there."
185
+ ),
186
+ "OPTIONAL": (
187
+ "OPTIONAL MATCH expresses 'match if present, else null.' The runtime\n"
188
+ "does not have a null binding. Register a second behavior whose\n"
189
+ "pattern is the optional sub-pattern."
190
+ ),
191
+ "WITH": (
192
+ "WITH composes a pipeline of matches. The runtime evaluates each\n"
193
+ "pattern as a flat match; pipelines are expressed as multiple\n"
194
+ "behaviors chained through emitted events."
195
+ ),
196
+ "MATCH": (
197
+ "Multiple MATCH clauses compose a pipeline. Flatten the pattern\n"
198
+ "or register one behavior per clause and chain them through\n"
199
+ "emitted events."
200
+ ),
201
+ "UNWIND": (
202
+ "UNWIND iterates a collection. Iterate in the behavior body\n"
203
+ "instead — `for row in ctx.matches: ...` — and express the source\n"
204
+ "collection as a sub-pattern."
205
+ ),
206
+ "UNION": (
207
+ "UNION takes the union of two queries. Register two behaviors,\n"
208
+ "one per branch, and let both fire."
209
+ ),
210
+ "CREATE": (
211
+ "Patterns observe the graph; they do not mutate it. Mutations go\n"
212
+ "in the behavior body via `graph.add_object` / `graph.add_relation`."
213
+ ),
214
+ "MERGE": (
215
+ "Same as CREATE — patterns do not mutate. Use\n"
216
+ "`graph.add_object` (with idempotency handled by the behavior) in\n"
217
+ "the body instead."
218
+ ),
219
+ "SET": (
220
+ "Same as CREATE — patterns do not mutate. Use\n"
221
+ "`graph.patch_object` in the behavior body."
222
+ ),
223
+ "DELETE": (
224
+ "Same as CREATE — patterns do not mutate. Use\n"
225
+ "`graph.remove_object` / `graph.remove_relation` in the behavior\n"
226
+ "body."
227
+ ),
228
+ "DETACH": (
229
+ "Same as DELETE — patterns do not mutate."
230
+ ),
231
+ "REMOVE": (
232
+ "Same as DELETE — patterns do not mutate."
233
+ ),
234
+ "FOREACH": (
235
+ "FOREACH iterates inside the pattern. Iterate in the behavior\n"
236
+ "body instead."
237
+ ),
238
+ "CALL": (
239
+ "CALL invokes a procedure. The runtime has no procedure registry;\n"
240
+ "call your function from the behavior body."
241
+ ),
242
+ "LIMIT": (
243
+ "LIMIT caps result count. Apply the cap in the behavior body by\n"
244
+ "slicing `ctx.matches`."
245
+ ),
246
+ "SKIP": (
247
+ "SKIP offsets results. Apply the offset in the behavior body."
248
+ ),
249
+ "ORDER": (
250
+ "ORDER BY sorts results. Sort in the behavior body."
251
+ ),
252
+ }
253
+
254
+
255
+ # ---------- AST -------------------------------------------------------------
256
+
257
+
258
+ @dataclass
259
+ class NodePat:
260
+ var: Optional[str]
261
+ type: Optional[str]
262
+ properties: dict[str, Any] = field(default_factory=dict)
263
+
264
+
265
+ @dataclass
266
+ class RelPat:
267
+ var: Optional[str]
268
+ type: str
269
+ # 'right' = (a)-[]->(b); 'left' = (a)<-[]-(b). Undirected not supported.
270
+ direction: str
271
+
272
+
273
+ @dataclass
274
+ class MatchClause:
275
+ """Linear chain of nodes connected by relationships.
276
+
277
+ `rels[i]` connects `nodes[i]` to `nodes[i+1]`. v0.7 patterns are
278
+ always linear chains — branching is "register two patterns" (same
279
+ spirit as "no OR in WHERE: register two behaviors").
280
+ """
281
+
282
+ nodes: list[NodePat]
283
+ rels: list[RelPat]
284
+
285
+
286
+ @dataclass
287
+ class Comparison:
288
+ """Either path-vs-literal or path-vs-path."""
289
+
290
+ left_path: list[str]
291
+ op: str
292
+ right_path: Optional[list[str]] = None # one of right_path / right_value
293
+ right_value: Any = None
294
+
295
+
296
+ @dataclass
297
+ class NotExpr:
298
+ inner: "BoolExpr"
299
+
300
+
301
+ @dataclass
302
+ class NotExists:
303
+ sub_match: MatchClause
304
+
305
+
306
+ @dataclass
307
+ class AndExpr:
308
+ parts: list["BoolExpr"] = field(default_factory=list)
309
+
310
+
311
+ BoolExpr = Any # Union[Comparison, NotExpr, NotExists, AndExpr]
312
+
313
+
314
+ @dataclass
315
+ class Pattern:
316
+ match: MatchClause
317
+ where: Optional[BoolExpr]
318
+ source: str # original pattern string, for error messages and tracing
319
+
320
+ def compile(self) -> "PatternMatcher":
321
+ return PatternMatcher(self)
322
+
323
+
324
+ # ---------- Lexer -----------------------------------------------------------
325
+
326
+
327
+ _TOKEN_RE = re.compile(
328
+ r"""
329
+ \s+ # whitespace (skipped)
330
+ | (?P<STRING>"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')
331
+ | (?P<NUMBER>-?\d+\.\d+|-?\d+)
332
+ # Arrows BEFORE OP so '<-' tokenizes as ARROW_L, not OP('<') + DASH.
333
+ | (?P<ARROW_R>->)
334
+ | (?P<ARROW_L><-)
335
+ | (?P<OP><=|>=|<>|!=|=|<|>)
336
+ | (?P<DASH>-)
337
+ | (?P<STAR>\*)
338
+ | (?P<LPAREN>\()
339
+ | (?P<RPAREN>\))
340
+ | (?P<LBRACK>\[)
341
+ | (?P<RBRACK>\])
342
+ | (?P<LBRACE>\{)
343
+ | (?P<RBRACE>\})
344
+ | (?P<COMMA>,)
345
+ | (?P<COLON>:)
346
+ | (?P<DOT>\.)
347
+ | (?P<IDENT>[A-Za-z_][A-Za-z0-9_]*)
348
+ """,
349
+ re.VERBOSE,
350
+ )
351
+
352
+
353
+ # Tokens that the parser inspects by .kind. Keywords are recognized
354
+ # at parse time from IDENT (case-insensitive).
355
+ @dataclass(frozen=True)
356
+ class Tok:
357
+ kind: str
358
+ text: str
359
+ pos: int
360
+
361
+
362
+ _KEYWORDS = {"WHERE", "AND", "OR", "NOT", "EXISTS", "TRUE", "FALSE", "NULL"}
363
+ _FORBIDDEN_KEYWORDS = {
364
+ "RETURN", "OPTIONAL", "WITH", "MATCH", "UNWIND", "UNION", "CREATE",
365
+ "MERGE", "SET", "DELETE", "DETACH", "REMOVE", "FOREACH", "CALL",
366
+ "LIMIT", "SKIP", "ORDER",
367
+ }
368
+
369
+
370
+ def _tokenize(s: str) -> list[Tok]:
371
+ out: list[Tok] = []
372
+ pos = 0
373
+ while pos < len(s):
374
+ m = _TOKEN_RE.match(s, pos)
375
+ if not m:
376
+ raise UnsupportedPatternError.syntax_error(
377
+ what=f"unexpected character at position {pos}",
378
+ at=s[pos : pos + 8],
379
+ )
380
+ if m.lastgroup is None:
381
+ pos = m.end()
382
+ continue
383
+ kind = m.lastgroup
384
+ text = m.group(kind)
385
+ if kind == "IDENT":
386
+ upper = text.upper()
387
+ if upper in _FORBIDDEN_KEYWORDS:
388
+ workaround = _KEYWORD_WORKAROUNDS.get(
389
+ upper,
390
+ f"Remove the {upper} keyword. The v0.7 subset refuses it; "
391
+ f"no equivalent in-subset expression exists for this case.",
392
+ )
393
+ raise UnsupportedPatternError.refused_feature(
394
+ feature=f"the {upper} keyword",
395
+ workaround=workaround,
396
+ at=text,
397
+ )
398
+ if upper in _KEYWORDS:
399
+ kind = "KW_" + upper
400
+ text = upper
401
+ out.append(Tok(kind=kind, text=text, pos=pos))
402
+ pos = m.end()
403
+ out.append(Tok(kind="EOF", text="", pos=pos))
404
+ return out
405
+
406
+
407
+ # ---------- Parser ----------------------------------------------------------
408
+
409
+
410
+ class _Parser:
411
+ def __init__(self, tokens: list[Tok], source: str) -> None:
412
+ self.tokens = tokens
413
+ self.source = source
414
+ self.i = 0
415
+
416
+ def peek(self, offset: int = 0) -> Tok:
417
+ return self.tokens[min(self.i + offset, len(self.tokens) - 1)]
418
+
419
+ def eat(self, kind: str, text: Optional[str] = None) -> Tok:
420
+ t = self.peek()
421
+ if t.kind != kind or (text is not None and t.text != text):
422
+ expected = kind + (f" {text!r}" if text else "")
423
+ raise UnsupportedPatternError.syntax_error(
424
+ what=f"expected {expected}, got {t.kind} {t.text!r}",
425
+ expected=expected,
426
+ got=f"{t.kind} {t.text!r}",
427
+ at=t.text or self.source[t.pos : t.pos + 8],
428
+ )
429
+ self.i += 1
430
+ return t
431
+
432
+ def consume_if(self, kind: str, text: Optional[str] = None) -> Optional[Tok]:
433
+ t = self.peek()
434
+ if t.kind == kind and (text is None or t.text == text):
435
+ self.i += 1
436
+ return t
437
+ return None
438
+
439
+ # ---- top level ----
440
+
441
+ def parse_pattern(self) -> Pattern:
442
+ # Optional leading MATCH keyword is not in our grammar (forbidden
443
+ # by lexer), so we just go straight into nodes/rels.
444
+ match_clause = self.parse_match()
445
+ where: Optional[BoolExpr] = None
446
+ if self.consume_if("KW_WHERE"):
447
+ where = self.parse_bool_expr()
448
+ # Anything after WHERE (or after the match if no WHERE) is junk.
449
+ if self.peek().kind != "EOF":
450
+ t = self.peek()
451
+ raise UnsupportedPatternError.syntax_error(
452
+ what=f"unexpected trailing tokens after pattern: {t.kind} {t.text!r}",
453
+ at=t.text,
454
+ )
455
+ return Pattern(match=match_clause, where=where, source=self.source)
456
+
457
+ # ---- match (linear chain) ----
458
+
459
+ def parse_match(self) -> MatchClause:
460
+ first = self.parse_node()
461
+ nodes: list[NodePat] = [first]
462
+ rels: list[RelPat] = []
463
+ while self.peek().kind in ("DASH", "ARROW_L"):
464
+ rel = self.parse_rel()
465
+ nxt = self.parse_node()
466
+ rels.append(rel)
467
+ nodes.append(nxt)
468
+ return MatchClause(nodes=nodes, rels=rels)
469
+
470
+ def parse_node(self) -> NodePat:
471
+ self.eat("LPAREN")
472
+ var: Optional[str] = None
473
+ type_: Optional[str] = None
474
+ if self.peek().kind == "IDENT":
475
+ var = self.eat("IDENT").text
476
+ if self.consume_if("COLON"):
477
+ type_ = self.eat("IDENT").text
478
+ props: dict[str, Any] = {}
479
+ if self.consume_if("LBRACE"):
480
+ props = self.parse_props()
481
+ self.eat("RBRACE")
482
+ self.eat("RPAREN")
483
+ return NodePat(var=var, type=type_, properties=props)
484
+
485
+ def parse_rel(self) -> RelPat:
486
+ t = self.peek()
487
+ if t.kind == "DASH":
488
+ # -[...]-> or -[...]<- — the second is illegal (we don't
489
+ # support undirected); the first is `direction=right`.
490
+ self.eat("DASH")
491
+ rel_var, rel_type = self._parse_edge_brackets()
492
+ # Now expect ARROW_R (->); DASH (undirected -) is refused.
493
+ arrow = self.peek()
494
+ if arrow.kind == "ARROW_R":
495
+ self.eat("ARROW_R")
496
+ return RelPat(var=rel_var, type=rel_type, direction="right")
497
+ if arrow.kind == "DASH":
498
+ raise UnsupportedPatternError.refused_feature(
499
+ feature="undirected-relationship syntax",
500
+ workaround=(
501
+ "Pick a direction. Use `(a)-[:rel]->(b)` for source→target\n"
502
+ "or `(a)<-[:rel]-(b)` for target→source. The pattern\n"
503
+ "matcher needs the direction so the audit trail knows which\n"
504
+ "endpoint produced the binding."
505
+ ),
506
+ at="-",
507
+ )
508
+ raise UnsupportedPatternError.syntax_error(
509
+ what=f"expected '->' after relationship, got {arrow.kind} {arrow.text!r}",
510
+ expected="'->'",
511
+ got=f"{arrow.kind} {arrow.text!r}",
512
+ at=arrow.text,
513
+ )
514
+ if t.kind == "ARROW_L":
515
+ self.eat("ARROW_L")
516
+ rel_var, rel_type = self._parse_edge_brackets()
517
+ self.eat("DASH")
518
+ return RelPat(var=rel_var, type=rel_type, direction="left")
519
+ raise UnsupportedPatternError.syntax_error(
520
+ what=f"expected relationship between nodes, got {t.kind} {t.text!r}",
521
+ expected="a relationship",
522
+ got=f"{t.kind} {t.text!r}",
523
+ at=t.text,
524
+ )
525
+
526
+ def _parse_edge_brackets(self) -> tuple[Optional[str], str]:
527
+ self.eat("LBRACK")
528
+ # Reject variable-length path syntax explicitly so the error
529
+ # message points at the right place.
530
+ if self.consume_if("STAR") is not None:
531
+ raise UnsupportedPatternError.refused_feature(
532
+ feature="variable-length path syntax (-[*]-)",
533
+ workaround=(
534
+ "Express the path as N separate one-hop patterns and register\n"
535
+ "one behavior per length you care about. If the path length is\n"
536
+ "unbounded, the matcher would have unbounded cost — that's\n"
537
+ "the reason for the refusal, not just policy."
538
+ ),
539
+ at="*",
540
+ )
541
+ var: Optional[str] = None
542
+ if self.peek().kind == "IDENT":
543
+ var = self.eat("IDENT").text
544
+ # Type is required: per the locked subset, relationships always
545
+ # have an explicit type.
546
+ if not self.consume_if("COLON"):
547
+ t = self.peek()
548
+ raise UnsupportedPatternError.syntax_error(
549
+ what="relationship type required (e.g. [:supports] or [r:supports])",
550
+ at=t.text,
551
+ )
552
+ type_tok = self.eat("IDENT")
553
+ self.eat("RBRACK")
554
+ return var, type_tok.text
555
+
556
+ def parse_props(self) -> dict[str, Any]:
557
+ # `{key: literal, key: literal, ...}` — equality only.
558
+ out: dict[str, Any] = {}
559
+ if self.peek().kind == "RBRACE":
560
+ return out
561
+ while True:
562
+ key = self.eat("IDENT").text
563
+ self.eat("COLON")
564
+ out[key] = self.parse_literal()
565
+ if not self.consume_if("COMMA"):
566
+ break
567
+ return out
568
+
569
+ # ---- where ----
570
+
571
+ def parse_bool_expr(self) -> BoolExpr:
572
+ parts: list[BoolExpr] = [self.parse_unary()]
573
+ while self.consume_if("KW_AND"):
574
+ parts.append(self.parse_unary())
575
+ if self.consume_if("KW_OR"):
576
+ raise UnsupportedPatternError.refused_feature(
577
+ feature="OR",
578
+ workaround=(
579
+ "Register two behaviors, one per branch of the disjunction.\n"
580
+ "Both fire independently; if both branches are true for the\n"
581
+ "same event, both behaviors fire (which is usually what you\n"
582
+ "want — OR-then-dedup is not).\n"
583
+ "\n"
584
+ "Example:\n"
585
+ " Instead of: WHERE c.confidence > 0.7 OR c.severity = 'high'\n"
586
+ " Register: one behavior with WHERE c.confidence > 0.7\n"
587
+ " one behavior with WHERE c.severity = 'high'"
588
+ ),
589
+ why=(
590
+ "OR in WHERE clauses can produce match-set ambiguity at the "
591
+ "trace level: it's hard to tell, after the fact, which branch "
592
+ "of the OR actually triggered. Registering two behaviors keeps "
593
+ "every fire attributable to a specific pattern in the audit "
594
+ "trail. See CONTRACT v0.7 #8."
595
+ ),
596
+ at="OR",
597
+ )
598
+ if len(parts) == 1:
599
+ return parts[0]
600
+ return AndExpr(parts=parts)
601
+
602
+ def parse_unary(self) -> BoolExpr:
603
+ if self.consume_if("KW_NOT"):
604
+ # NOT EXISTS { sub-pattern }
605
+ if self.consume_if("KW_EXISTS"):
606
+ self.eat("LBRACE")
607
+ sub = self.parse_match()
608
+ self.eat("RBRACE")
609
+ return NotExists(sub_match=sub)
610
+ return NotExpr(inner=self.parse_unary())
611
+ if self.consume_if("LPAREN"):
612
+ inner = self.parse_bool_expr()
613
+ self.eat("RPAREN")
614
+ return inner
615
+ return self.parse_comparison()
616
+
617
+ def parse_comparison(self) -> Comparison:
618
+ left = self.parse_path()
619
+ t = self.peek()
620
+ if t.kind != "OP":
621
+ raise UnsupportedPatternError.syntax_error(
622
+ what=f"expected comparison operator, got {t.kind} {t.text!r}",
623
+ expected="a comparison operator (=, <>, <, <=, >, >=)",
624
+ got=f"{t.kind} {t.text!r}",
625
+ at=t.text,
626
+ )
627
+ op = self.eat("OP").text
628
+ # Right side: literal or another path
629
+ nxt = self.peek()
630
+ if nxt.kind in ("NUMBER", "STRING", "KW_TRUE", "KW_FALSE", "KW_NULL"):
631
+ value = self.parse_literal()
632
+ return Comparison(left_path=left, op=op, right_value=value)
633
+ if nxt.kind == "IDENT":
634
+ right_path = self.parse_path()
635
+ return Comparison(left_path=left, op=op, right_path=right_path)
636
+ raise UnsupportedPatternError.syntax_error(
637
+ what=f"expected literal or path on rhs of comparison, got {nxt.kind} {nxt.text!r}",
638
+ expected="a literal (number, string, true/false/null) or a binding path (a.field)",
639
+ got=f"{nxt.kind} {nxt.text!r}",
640
+ at=nxt.text,
641
+ )
642
+
643
+ def parse_path(self) -> list[str]:
644
+ first = self.eat("IDENT").text
645
+ parts = [first]
646
+ while self.consume_if("DOT"):
647
+ parts.append(self.eat("IDENT").text)
648
+ return parts
649
+
650
+ def parse_literal(self) -> Any:
651
+ t = self.peek()
652
+ if t.kind == "NUMBER":
653
+ self.i += 1
654
+ text = t.text
655
+ return float(text) if ("." in text) else int(text)
656
+ if t.kind == "STRING":
657
+ self.i += 1
658
+ # Strip surrounding quotes; minimal escape support.
659
+ inner = t.text[1:-1]
660
+ return inner.encode("utf-8").decode("unicode_escape")
661
+ if t.kind == "KW_TRUE":
662
+ self.i += 1
663
+ return True
664
+ if t.kind == "KW_FALSE":
665
+ self.i += 1
666
+ return False
667
+ if t.kind == "KW_NULL":
668
+ self.i += 1
669
+ return None
670
+ raise UnsupportedPatternError.syntax_error(
671
+ what=f"expected literal, got {t.kind} {t.text!r}",
672
+ expected="a literal (number, string, true, false, null)",
673
+ got=f"{t.kind} {t.text!r}",
674
+ at=t.text,
675
+ )
676
+
677
+
678
+ def parse(pattern: str) -> Pattern:
679
+ """Public entry point. Raises UnsupportedPatternError on any
680
+ parse failure with the offending token."""
681
+ tokens = _tokenize(pattern)
682
+ return _Parser(tokens, pattern).parse_pattern()
683
+
684
+
685
+ # ---------- Matcher ---------------------------------------------------------
686
+
687
+
688
+ @dataclass
689
+ class Match:
690
+ """A single pattern binding.
691
+
692
+ Maps variable names to object_ids (for nodes) or relation_ids (for
693
+ rels). Variables for unbound nodes/rels (e.g. `(:claim)`, `[:supports]`)
694
+ are absent. Handlers iterate `ctx.matches` to consume; the runtime
695
+ fires the behavior once per event, not once per match
696
+ (CONTRACT v0.7 #12).
697
+ """
698
+
699
+ bindings: dict[str, str]
700
+
701
+ def __getitem__(self, key: str) -> str:
702
+ return self.bindings[key]
703
+
704
+ def get(self, key: str, default=None) -> Any:
705
+ return self.bindings.get(key, default)
706
+
707
+
708
+ class PatternMatcher:
709
+ """Pre-compiled matcher. Apply to (event, graph) → list[Match]."""
710
+
711
+ def __init__(self, pattern: Pattern) -> None:
712
+ self.pattern = pattern
713
+
714
+ def matches(self, event, graph) -> list[Match]:
715
+ # `event` is currently unused — patterns evaluate against the
716
+ # post-event graph state. Future extensions may bind event
717
+ # properties (e.g. `$event.payload.x`). v0.7 spec keeps it
718
+ # graph-only.
719
+ return list(self._enumerate_matches(self.pattern.match, graph))
720
+
721
+ def _enumerate_matches(self, match_clause: MatchClause, graph):
722
+ if not match_clause.nodes:
723
+ return
724
+ # Linear-chain match: enumerate bindings for nodes[0], then for
725
+ # each subsequent node by following rels[i].
726
+ candidates_0 = _candidate_objects(graph, match_clause.nodes[0])
727
+ for obj0 in candidates_0:
728
+ bindings: dict[str, str] = {}
729
+ if match_clause.nodes[0].var:
730
+ bindings[match_clause.nodes[0].var] = obj0.id
731
+ yield from self._extend_chain(
732
+ match_clause, graph, bindings, obj_chain=[obj0]
733
+ )
734
+
735
+ def _extend_chain(self, match_clause, graph, bindings, obj_chain):
736
+ i = len(obj_chain) - 1
737
+ # Done extending? Apply WHERE.
738
+ if i == len(match_clause.rels):
739
+ # Filter by WHERE.
740
+ if self.pattern.where is None or _eval_where(
741
+ self.pattern.where, bindings, graph
742
+ ):
743
+ yield Match(bindings=dict(bindings))
744
+ return
745
+ rel_pat = match_clause.rels[i]
746
+ next_node_pat = match_clause.nodes[i + 1]
747
+ src = obj_chain[-1]
748
+ for rel, neighbor in _follow_relation(graph, src, rel_pat):
749
+ if not _node_matches(neighbor, next_node_pat):
750
+ continue
751
+ # Forbid binding the same var to two different objects.
752
+ new_bindings = dict(bindings)
753
+ if rel_pat.var:
754
+ if rel_pat.var in new_bindings and new_bindings[rel_pat.var] != rel.id:
755
+ continue
756
+ new_bindings[rel_pat.var] = rel.id
757
+ if next_node_pat.var:
758
+ if (
759
+ next_node_pat.var in new_bindings
760
+ and new_bindings[next_node_pat.var] != neighbor.id
761
+ ):
762
+ continue
763
+ new_bindings[next_node_pat.var] = neighbor.id
764
+ yield from self._extend_chain(
765
+ match_clause, graph, new_bindings, obj_chain + [neighbor]
766
+ )
767
+
768
+
769
+ def _candidate_objects(graph, node_pat: NodePat):
770
+ """All objects that could fill `node_pat` ignoring relationships."""
771
+ out = []
772
+ for o in graph.all_objects():
773
+ if _node_matches(o, node_pat):
774
+ out.append(o)
775
+ return out
776
+
777
+
778
+ def _node_matches(obj, node_pat: NodePat) -> bool:
779
+ if node_pat.type is not None and obj.type != node_pat.type:
780
+ return False
781
+ for k, v in node_pat.properties.items():
782
+ if obj.data.get(k) != v:
783
+ return False
784
+ return True
785
+
786
+
787
+ def _follow_relation(graph, src, rel_pat: RelPat) -> Iterator[tuple[Any, Any]]:
788
+ """Yield (relation, neighbor_object) for every edge of the right type
789
+ in the right direction leaving src.
790
+ """
791
+ for r in graph.all_relations():
792
+ if r.type != rel_pat.type:
793
+ continue
794
+ if rel_pat.direction == "right":
795
+ if r.source != src.id:
796
+ continue
797
+ neighbor = graph.get_object(r.target)
798
+ else: # 'left' — (src)<-[:r]-(neighbor) means r.target == src.id
799
+ if r.target != src.id:
800
+ continue
801
+ neighbor = graph.get_object(r.source)
802
+ if neighbor is None:
803
+ continue
804
+ yield r, neighbor
805
+
806
+
807
+ # ---------- WHERE evaluator -------------------------------------------------
808
+
809
+
810
+ _OPS = {
811
+ "=": lambda a, b: a == b,
812
+ "==": lambda a, b: a == b,
813
+ "!=": lambda a, b: a != b,
814
+ "<>": lambda a, b: a != b,
815
+ ">": lambda a, b: a is not None and b is not None and a > b,
816
+ "<": lambda a, b: a is not None and b is not None and a < b,
817
+ ">=": lambda a, b: a is not None and b is not None and a >= b,
818
+ "<=": lambda a, b: a is not None and b is not None and a <= b,
819
+ }
820
+
821
+
822
+ def _eval_where(expr: BoolExpr, bindings: dict[str, str], graph) -> bool:
823
+ if isinstance(expr, Comparison):
824
+ left = _resolve_path(expr.left_path, bindings, graph)
825
+ if expr.right_path is not None:
826
+ right = _resolve_path(expr.right_path, bindings, graph)
827
+ else:
828
+ right = expr.right_value
829
+ fn = _OPS.get(expr.op)
830
+ if fn is None:
831
+ # Internal: the parser accepted an operator the evaluator does
832
+ # not recognize. Either the parser drifted from _OPS or the
833
+ # AST was constructed externally. PR-G normalization: uses the
834
+ # shared internal_bug_fields helper for uniform prose.
835
+ from activegraph.errors import internal_bug_fields
836
+ fields = internal_bug_fields(
837
+ summary=f"unknown comparison operator {expr.op!r}",
838
+ what_happened=(
839
+ f"The WHERE evaluator received a comparison with operator "
840
+ f"{expr.op!r}, but the operator table has no handler for it."
841
+ ),
842
+ why_invariant=(
843
+ "The operator table (_OPS in this module) is the source of "
844
+ "truth for which comparison operators the runtime accepts. "
845
+ "If the parser produces an operator the evaluator does not "
846
+ "know about, the audit trail would silently mis-evaluate "
847
+ "the pattern — refuse instead."
848
+ ),
849
+ location="activegraph/runtime/patterns.py:_eval_where (unknown comparison operator)",
850
+ extra_context={"operator": expr.op},
851
+ )
852
+ raise UnsupportedPatternError(
853
+ fields["summary"],
854
+ what_failed=fields["what_failed"],
855
+ why=fields["why"],
856
+ how_to_fix=fields["how_to_fix"],
857
+ context=fields["context"],
858
+ )
859
+ return fn(left, right)
860
+ if isinstance(expr, NotExpr):
861
+ return not _eval_where(expr.inner, bindings, graph)
862
+ if isinstance(expr, AndExpr):
863
+ return all(_eval_where(p, bindings, graph) for p in expr.parts)
864
+ if isinstance(expr, NotExists):
865
+ # NOT EXISTS { sub_match } — sub_match shares variable bindings
866
+ # with the outer match: any variable used in both refers to the
867
+ # same object id. If any binding of the sub_match exists that's
868
+ # consistent with the outer bindings, NOT EXISTS is False.
869
+ sub_matcher = PatternMatcher(
870
+ Pattern(match=expr.sub_match, where=None, source="<sub>")
871
+ )
872
+ for m in sub_matcher.matches(event=None, graph=graph):
873
+ # Outer bindings constrain sub-match bindings (shared vars).
874
+ consistent = True
875
+ for k, v in bindings.items():
876
+ if k in m.bindings and m.bindings[k] != v:
877
+ consistent = False
878
+ break
879
+ if consistent:
880
+ return False
881
+ return True
882
+ # Internal: an AST node the evaluator does not recognize. Should not
883
+ # happen given the parser produces a closed set of node types. PR-G
884
+ # normalization: uses the shared internal_bug_fields helper.
885
+ from activegraph.errors import internal_bug_fields
886
+ _fields = internal_bug_fields(
887
+ summary=f"unrecognized WHERE AST node {type(expr).__name__}",
888
+ what_happened=(
889
+ f"The WHERE evaluator received an AST node of type "
890
+ f"{type(expr).__name__}, but the evaluator only handles "
891
+ f"Comparison, NotExpr, AndExpr, and NotExists."
892
+ ),
893
+ why_invariant=(
894
+ "The AST node set is closed and produced by this module's parser. "
895
+ "An unrecognized node means either the parser drifted from the "
896
+ "evaluator or the AST was constructed externally — both would "
897
+ "produce silent mis-evaluation, so the runtime refuses."
898
+ ),
899
+ location="activegraph/runtime/patterns.py:_eval_where (unrecognized AST node)",
900
+ extra_context={"ast_node_type": type(expr).__name__},
901
+ )
902
+ raise UnsupportedPatternError(
903
+ _fields["summary"],
904
+ what_failed=_fields["what_failed"],
905
+ why=_fields["why"],
906
+ how_to_fix=_fields["how_to_fix"],
907
+ context=_fields["context"],
908
+ )
909
+
910
+
911
+ def _resolve_path(path: list[str], bindings: dict[str, str], graph) -> Any:
912
+ """Resolve `a.confidence` etc. against bindings + graph."""
913
+ if not path:
914
+ return None
915
+ head, *rest = path
916
+ obj_id = bindings.get(head)
917
+ if obj_id is None:
918
+ # Could be a relation binding (NOT supported for property access
919
+ # in v0.7 — relations have no .data attribute lookup syntax).
920
+ return None
921
+ obj = graph.get_object(obj_id)
922
+ if obj is None:
923
+ return None
924
+ if not rest:
925
+ # `a` alone → just return the object id; useful for equality.
926
+ return obj.id
927
+ # Walk: a.data.x.y or a.confidence (shorthand for a.data.confidence)
928
+ cur: Any = obj.data
929
+ # Allow `a.type` and `a.id` as direct attribute access.
930
+ first = rest[0]
931
+ if first in ("id", "type", "version"):
932
+ cur = getattr(obj, first, None)
933
+ rest = rest[1:]
934
+ elif first == "data":
935
+ cur = obj.data
936
+ rest = rest[1:]
937
+ # Anything else is treated as `a.<field>` → `a.data.<field>` (the
938
+ # common case: `c.confidence` rather than `c.data.confidence`).
939
+ for p in rest:
940
+ if isinstance(cur, dict):
941
+ cur = cur.get(p)
942
+ else:
943
+ return None
944
+ if cur is None:
945
+ return None
946
+ return cur