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,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
|