xp-libs-analytics 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xp_libs_analytics/__init__.py +18 -0
- xp_libs_analytics/engine/__init__.py +1 -0
- xp_libs_analytics/engine/backend.py +379 -0
- xp_libs_analytics/engine/planner.py +450 -0
- xp_libs_analytics/engine/worker.py +121 -0
- xp_libs_analytics/errors/__init__.py +43 -0
- xp_libs_analytics/errors/catalog.json +75 -0
- xp_libs_analytics/errors/codes.py +81 -0
- xp_libs_analytics/ingest/__init__.py +309 -0
- xp_libs_analytics/interfaces.py +70 -0
- xp_libs_analytics/matching/__init__.py +1 -0
- xp_libs_analytics/matching/compiler.py +344 -0
- xp_libs_analytics/matching/diagnostics.py +115 -0
- xp_libs_analytics/matching/models.py +98 -0
- xp_libs_analytics/matching/normalize.py +16 -0
- xp_libs_analytics/matching/patterns.py +71 -0
- xp_libs_analytics/mcp/__init__.py +1 -0
- xp_libs_analytics/mcp/server.py +248 -0
- xp_libs_analytics/models/__init__.py +654 -0
- xp_libs_analytics/parser/__init__.py +142 -0
- xp_libs_analytics/policy/__init__.py +69 -0
- xp_libs_analytics/py.typed +0 -0
- xp_libs_analytics/registry.py +204 -0
- xp_libs_analytics/sdk/__init__.py +259 -0
- xp_libs_analytics/skills/generic-analytics/SKILL.md +236 -0
- xp_libs_analytics/skills/generic-analytics/examples/README.md +69 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/01_count_all.yaml +8 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/02_filter_and_count.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/03_group_stats_and_topk.yaml +21 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/04_nested_or_predicates.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/05_is_missing.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/06_missing_vs_null.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/07_conditional_aggregation.yaml +19 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/08_ratio_if.yaml +16 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/09_tumbling_window.yaml +23 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/10_sliding_window.yaml +32 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/11_explicit_cast.yaml +16 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/12_text_range_distinct.yaml +23 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/13_summary_statistics.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/14_nested_and_not.yaml +20 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/15_conditional_sum.yaml +13 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/16_mixed_types_cast.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/17_relative_last_hour.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/18_string_exact.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/19_string_normalization.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/20_prefix_suffix.yaml +16 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/21_whole_word.yaml +13 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/22_ilike.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/23_rust_regex.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/24_fuzzy.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/25_multi_field_matching.yaml +24 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/26_conditional_fuzzy.yaml +20 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/27_temporal_fuzzy.yaml +38 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/28_safe_matching_diagnostics.yaml +22 -0
- xp_libs_analytics/skills/generic-analytics/references/errors.md +55 -0
- xp_libs_analytics/skills/generic-analytics/references/query-language-full.md +306 -0
- xp_libs_analytics/skills/generic-analytics/references/query-language.md +39 -0
- xp_libs_analytics/skills/generic-analytics/references/string-matching-full.md +122 -0
- xp_libs_analytics/skills/generic-analytics/references/string-matching.md +11 -0
- xp_libs_analytics-0.4.0.dist-info/METADATA +199 -0
- xp_libs_analytics-0.4.0.dist-info/RECORD +65 -0
- xp_libs_analytics-0.4.0.dist-info/WHEEL +4 -0
- xp_libs_analytics-0.4.0.dist-info/entry_points.txt +2 -0
- xp_libs_analytics-0.4.0.dist-info/licenses/LICENSE +22 -0
- xp_libs_analytics-0.4.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +175 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Per-query matching compiler: native expressions, exhaustive distinct fuzzy scoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import reduce
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
from rapidfuzz import fuzz
|
|
14
|
+
|
|
15
|
+
from xp_libs_analytics.errors import AnalyticsError
|
|
16
|
+
from xp_libs_analytics.policy import Limits
|
|
17
|
+
|
|
18
|
+
from .models import MatchFieldsPredicate, Normalization, is_matching
|
|
19
|
+
from .normalize import normalize
|
|
20
|
+
from .patterns import like_regex, regex_flags, validate_regex
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RuleUse:
|
|
25
|
+
field: str
|
|
26
|
+
op: str
|
|
27
|
+
value: str
|
|
28
|
+
normalization: Normalization
|
|
29
|
+
algorithm: str | None = None
|
|
30
|
+
min_score: float | None = None
|
|
31
|
+
|
|
32
|
+
def key(self) -> str:
|
|
33
|
+
return json.dumps(
|
|
34
|
+
[
|
|
35
|
+
self.field,
|
|
36
|
+
self.op,
|
|
37
|
+
self.value,
|
|
38
|
+
self.normalization.model_dump(),
|
|
39
|
+
self.algorithm,
|
|
40
|
+
self.min_score,
|
|
41
|
+
],
|
|
42
|
+
sort_keys=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def leaves(node: Any) -> Iterator[Any]:
|
|
47
|
+
if node.op in {"and", "or"}:
|
|
48
|
+
for child in node.conditions:
|
|
49
|
+
yield from leaves(child)
|
|
50
|
+
elif node.op == "not":
|
|
51
|
+
yield from leaves(node.condition)
|
|
52
|
+
else:
|
|
53
|
+
yield node
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def uses(node: Any, types: dict[str, str] | None = None) -> list[RuleUse]:
|
|
57
|
+
if isinstance(node, MatchFieldsPredicate):
|
|
58
|
+
return [
|
|
59
|
+
RuleUse(
|
|
60
|
+
field,
|
|
61
|
+
rule.op,
|
|
62
|
+
rule.value,
|
|
63
|
+
node.match.normalization,
|
|
64
|
+
getattr(rule, "algorithm", None),
|
|
65
|
+
getattr(rule, "min_score", None),
|
|
66
|
+
)
|
|
67
|
+
for field in node.match.fields
|
|
68
|
+
for rule in node.match.patterns
|
|
69
|
+
]
|
|
70
|
+
if not is_matching(node, types):
|
|
71
|
+
return []
|
|
72
|
+
if not isinstance(node.value, str):
|
|
73
|
+
raise AnalyticsError(
|
|
74
|
+
"MATCH_NORMALIZATION_INVALID"
|
|
75
|
+
if getattr(node, "normalization", None) is not None
|
|
76
|
+
else "QUERY_INVALID_LITERAL"
|
|
77
|
+
)
|
|
78
|
+
return [
|
|
79
|
+
RuleUse(
|
|
80
|
+
node.field,
|
|
81
|
+
node.op,
|
|
82
|
+
node.value,
|
|
83
|
+
node.normalization or Normalization(),
|
|
84
|
+
getattr(node, "algorithm", None),
|
|
85
|
+
getattr(node, "min_score", None),
|
|
86
|
+
)
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def rule_config(rule: RuleUse) -> Normalization:
|
|
91
|
+
config = rule.normalization
|
|
92
|
+
if rule.op == "ilike":
|
|
93
|
+
if config.case_sensitive and "case_sensitive" in config.model_fields_set:
|
|
94
|
+
raise AnalyticsError("MATCH_NORMALIZATION_INVALID")
|
|
95
|
+
config = config.model_copy(update={"case_sensitive": False})
|
|
96
|
+
return config
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pattern(rule: RuleUse, limits: Limits) -> str:
|
|
100
|
+
config = rule_config(rule)
|
|
101
|
+
if rule.op == "regex":
|
|
102
|
+
if len(rule.value.encode("utf-8")) > limits.max_regex_bytes:
|
|
103
|
+
raise AnalyticsError("MATCH_REGEX_POLICY")
|
|
104
|
+
if config.unicode != "none" or config.trim:
|
|
105
|
+
raise AnalyticsError("MATCH_NORMALIZATION_INVALID")
|
|
106
|
+
if (
|
|
107
|
+
config.case_sensitive
|
|
108
|
+
and "case_sensitive" in config.model_fields_set
|
|
109
|
+
and "i" in regex_flags(rule.value)
|
|
110
|
+
):
|
|
111
|
+
raise AnalyticsError("MATCH_NORMALIZATION_INVALID")
|
|
112
|
+
value = rule.value if config.case_sensitive else "(?i)" + rule.value
|
|
113
|
+
validate_regex(value, limits, wrapper_bytes=0 if config.case_sensitive else 4)
|
|
114
|
+
return value
|
|
115
|
+
value = normalize(rule.value, config)
|
|
116
|
+
if rule.op in {"like", "ilike"}:
|
|
117
|
+
value = like_regex(value)
|
|
118
|
+
else:
|
|
119
|
+
value = r"\b" + re.escape(value) + r"\b"
|
|
120
|
+
validate_regex(value, limits, translated=True)
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate_rules(nodes: list[Any], types: dict[str, str], limits: Limits) -> list[RuleUse]:
|
|
125
|
+
result = []
|
|
126
|
+
work = 0
|
|
127
|
+
for node in nodes:
|
|
128
|
+
if isinstance(node, MatchFieldsPredicate):
|
|
129
|
+
body = node.match
|
|
130
|
+
if len(set(body.fields)) != len(body.fields):
|
|
131
|
+
raise AnalyticsError("QUERY_INVALID_LITERAL")
|
|
132
|
+
if (
|
|
133
|
+
len(body.fields) > limits.max_match_fields
|
|
134
|
+
or len(body.patterns) > limits.max_match_patterns
|
|
135
|
+
):
|
|
136
|
+
raise AnalyticsError("MATCH_COMPLEXITY_BUDGET_EXCEEDED")
|
|
137
|
+
rules = uses(node, types)
|
|
138
|
+
work += len(rules)
|
|
139
|
+
if work > limits.max_match_work:
|
|
140
|
+
raise AnalyticsError("MATCH_COMPLEXITY_BUDGET_EXCEEDED")
|
|
141
|
+
for rule in rules:
|
|
142
|
+
if rule.field not in types:
|
|
143
|
+
raise AnalyticsError("DATA_FIELD_UNKNOWN")
|
|
144
|
+
field_type = types[rule.field]
|
|
145
|
+
if field_type == "mixed":
|
|
146
|
+
raise AnalyticsError("DATA_FIELD_TYPE_CONFLICT")
|
|
147
|
+
if field_type in {"array", "object"}:
|
|
148
|
+
raise AnalyticsError("DATA_FIELD_NOT_SCALAR")
|
|
149
|
+
if field_type not in {"string", "null"}:
|
|
150
|
+
raise AnalyticsError(
|
|
151
|
+
"MATCH_NORMALIZATION_INVALID"
|
|
152
|
+
if rule.op in {"eq", "ne"}
|
|
153
|
+
else "QUERY_OPERATOR_TYPE"
|
|
154
|
+
)
|
|
155
|
+
if len(rule.value) > limits.max_match_literal_chars or (
|
|
156
|
+
not rule.value and rule.op not in {"eq", "ne"}
|
|
157
|
+
):
|
|
158
|
+
raise AnalyticsError("MATCH_PATTERN_INVALID")
|
|
159
|
+
config = rule_config(rule)
|
|
160
|
+
value = normalize(rule.value, config) if rule.op != "regex" else rule.value
|
|
161
|
+
if len(value) > limits.max_match_literal_chars:
|
|
162
|
+
raise AnalyticsError("MATCH_PATTERN_INVALID")
|
|
163
|
+
if rule.op not in {"eq", "ne"} and not value:
|
|
164
|
+
raise AnalyticsError("MATCH_PATTERN_INVALID")
|
|
165
|
+
if rule.op == "fuzzy":
|
|
166
|
+
if rule.min_score is None or rule.min_score < limits.min_fuzzy_score:
|
|
167
|
+
raise AnalyticsError("MATCH_FUZZY_THRESHOLD_INVALID")
|
|
168
|
+
if len(value) < limits.min_fuzzy_query_chars:
|
|
169
|
+
raise AnalyticsError("MATCH_FUZZY_QUERY_TOO_SHORT")
|
|
170
|
+
if rule.op in {"word", "like", "ilike", "regex"}:
|
|
171
|
+
pattern(rule, limits)
|
|
172
|
+
result.append(rule)
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def conjuncts(node: Any) -> list[Any]:
|
|
177
|
+
return (
|
|
178
|
+
[c for child in node.conditions for c in conjuncts(child)] if node.op == "and" else [node]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class MatchingCompiler:
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
frame: pl.DataFrame,
|
|
186
|
+
columns: Mapping[str, tuple[str, str | None, str | None]],
|
|
187
|
+
limits: Limits,
|
|
188
|
+
*,
|
|
189
|
+
score: bool,
|
|
190
|
+
) -> None:
|
|
191
|
+
self.frame, self.columns, self.limits, self.score = frame, columns, limits, score
|
|
192
|
+
self.expressions: dict[str, pl.Expr] = {}
|
|
193
|
+
self.mappings: dict[tuple[str, str], dict[str, str]] = {}
|
|
194
|
+
self.scores: dict[str, dict[str, float]] = {}
|
|
195
|
+
self.checked: set[str] = set()
|
|
196
|
+
self.comparisons = 0
|
|
197
|
+
self.normalized_bytes = 0
|
|
198
|
+
self.candidates = 0
|
|
199
|
+
|
|
200
|
+
def column(self, field: str) -> pl.Expr:
|
|
201
|
+
return pl.col(self.columns[field][0]).cast(pl.String)
|
|
202
|
+
|
|
203
|
+
def check_values(self, field: str) -> None:
|
|
204
|
+
if field not in self.checked:
|
|
205
|
+
values = self.frame.get_column(self.columns[field][0]).cast(pl.String)
|
|
206
|
+
if cast(int, values.str.len_chars().max() or 0) > self.limits.max_match_value_chars:
|
|
207
|
+
raise AnalyticsError("MATCH_CANDIDATE_BUDGET_EXCEEDED")
|
|
208
|
+
self.checked.add(field)
|
|
209
|
+
|
|
210
|
+
def mapping(self, field: str, config: Normalization) -> dict[str, str]:
|
|
211
|
+
key = (field, config.model_dump_json())
|
|
212
|
+
if key not in self.mappings:
|
|
213
|
+
self.check_values(field)
|
|
214
|
+
values = self.frame.get_column(self.columns[field][0]).drop_nulls().unique().to_list()
|
|
215
|
+
mapped = {}
|
|
216
|
+
for value in sorted(values):
|
|
217
|
+
normalized = normalize(value, config)
|
|
218
|
+
self.normalized_bytes += len(normalized.encode("utf-8"))
|
|
219
|
+
if (
|
|
220
|
+
len(normalized) > self.limits.max_match_value_chars
|
|
221
|
+
or self.normalized_bytes > self.limits.max_match_normalized_bytes
|
|
222
|
+
):
|
|
223
|
+
raise AnalyticsError("MATCH_CANDIDATE_BUDGET_EXCEEDED")
|
|
224
|
+
mapped[value] = normalized
|
|
225
|
+
self.mappings[key] = mapped
|
|
226
|
+
return self.mappings[key]
|
|
227
|
+
|
|
228
|
+
def normalized_column(self, field: str, config: Normalization) -> pl.Expr:
|
|
229
|
+
self.check_values(field)
|
|
230
|
+
col = self.column(field)
|
|
231
|
+
if config == Normalization():
|
|
232
|
+
return col
|
|
233
|
+
mapped = self.mapping(field, config)
|
|
234
|
+
return col.replace_strict(
|
|
235
|
+
list(mapped), list(mapped.values()), default=None, return_dtype=pl.String
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def expression(self, rule: RuleUse) -> pl.Expr:
|
|
239
|
+
key = rule.key()
|
|
240
|
+
if key in self.expressions:
|
|
241
|
+
return self.expressions[key]
|
|
242
|
+
config = rule_config(rule)
|
|
243
|
+
col = (
|
|
244
|
+
self.normalized_column(rule.field, config)
|
|
245
|
+
if rule.op != "regex"
|
|
246
|
+
else self.column(rule.field)
|
|
247
|
+
)
|
|
248
|
+
self.check_values(rule.field)
|
|
249
|
+
value = rule.value if rule.op == "regex" else normalize(rule.value, config)
|
|
250
|
+
if rule.op == "fuzzy":
|
|
251
|
+
mapped = self.mapping(rule.field, config)
|
|
252
|
+
distinct = sorted(set(mapped.values()))
|
|
253
|
+
if len(distinct) > self.limits.max_fuzzy_candidates:
|
|
254
|
+
raise AnalyticsError("MATCH_CANDIDATE_BUDGET_EXCEEDED")
|
|
255
|
+
self.comparisons += len(distinct)
|
|
256
|
+
self.candidates += len(distinct)
|
|
257
|
+
if self.comparisons > self.limits.max_fuzzy_comparisons:
|
|
258
|
+
raise AnalyticsError("MATCH_COMPLEXITY_BUDGET_EXCEEDED")
|
|
259
|
+
scorer = {
|
|
260
|
+
"ratio": fuzz.ratio,
|
|
261
|
+
"wratio": fuzz.WRatio,
|
|
262
|
+
"token_sort": fuzz.token_sort_ratio,
|
|
263
|
+
}[rule.algorithm or "ratio"]
|
|
264
|
+
scores = (
|
|
265
|
+
{
|
|
266
|
+
candidate: float(
|
|
267
|
+
scorer(value, candidate, processor=None, score_cutoff=rule.min_score)
|
|
268
|
+
)
|
|
269
|
+
for candidate in distinct
|
|
270
|
+
}
|
|
271
|
+
if self.score
|
|
272
|
+
else {}
|
|
273
|
+
)
|
|
274
|
+
qualified = {
|
|
275
|
+
candidate: score
|
|
276
|
+
for candidate, score in scores.items()
|
|
277
|
+
if score >= (rule.min_score or 0)
|
|
278
|
+
}
|
|
279
|
+
self.scores[key] = qualified
|
|
280
|
+
expr = pl.when(col.is_null()).then(None).otherwise(col.is_in(list(qualified)))
|
|
281
|
+
elif rule.op in {"word", "like", "ilike", "regex"}:
|
|
282
|
+
expr = col.str.contains(pattern(rule, self.limits), literal=False, strict=True)
|
|
283
|
+
elif rule.op in {"eq", "ne"}:
|
|
284
|
+
expr = col == pl.lit(value) if rule.op == "eq" else col != pl.lit(value)
|
|
285
|
+
elif rule.op in {"contains", "not_contains"}:
|
|
286
|
+
expr = col.str.contains(value, literal=True)
|
|
287
|
+
if rule.op == "not_contains":
|
|
288
|
+
expr = ~expr
|
|
289
|
+
elif rule.op == "starts_with":
|
|
290
|
+
expr = col.str.starts_with(value)
|
|
291
|
+
else:
|
|
292
|
+
expr = col.str.ends_with(value)
|
|
293
|
+
self.expressions[key] = expr
|
|
294
|
+
return expr
|
|
295
|
+
|
|
296
|
+
def predicate(self, node: Any) -> pl.Expr:
|
|
297
|
+
return reduce(lambda a, b: a | b, [self.expression(rule) for rule in uses(node)])
|
|
298
|
+
|
|
299
|
+
def cost(self, rules: list[RuleUse]) -> dict[str, Any]:
|
|
300
|
+
return {
|
|
301
|
+
"used": bool(rules),
|
|
302
|
+
"work_units": len(rules),
|
|
303
|
+
"comparisons": self.comparisons,
|
|
304
|
+
"distinct_candidates_examined": self.candidates,
|
|
305
|
+
"normalized_bytes": self.normalized_bytes,
|
|
306
|
+
"requires_isolation": any(r.op in {"fuzzy", "regex"} for r in rules),
|
|
307
|
+
"candidate_scope": "time_and_independent_nonfuzzy_conjuncts",
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def prepare(
|
|
312
|
+
query: Any,
|
|
313
|
+
frame: pl.DataFrame,
|
|
314
|
+
columns: Mapping[str, tuple[str, str | None, str | None]],
|
|
315
|
+
types: dict[str, str],
|
|
316
|
+
limits: Limits,
|
|
317
|
+
ordinary: Callable[..., pl.Expr],
|
|
318
|
+
*,
|
|
319
|
+
score: bool,
|
|
320
|
+
) -> tuple[pl.DataFrame, MatchingCompiler, list[RuleUse]]:
|
|
321
|
+
nodes = [
|
|
322
|
+
node
|
|
323
|
+
for root in list(query.filters)
|
|
324
|
+
+ [a.where for a in query.aggregations if hasattr(a, "where")]
|
|
325
|
+
for node in leaves(root)
|
|
326
|
+
]
|
|
327
|
+
rules = validate_rules(nodes, types, limits)
|
|
328
|
+
compiler = MatchingCompiler(frame, columns, limits, score=score)
|
|
329
|
+
if rules and limits.match_pushdown:
|
|
330
|
+
safe = [
|
|
331
|
+
c
|
|
332
|
+
for root in query.filters
|
|
333
|
+
for c in conjuncts(root)
|
|
334
|
+
if not any(r.op == "fuzzy" for n in leaves(c) for r in uses(n, types))
|
|
335
|
+
]
|
|
336
|
+
# Apply ordinary typed restrictions first; never push an OR/NOT child independently.
|
|
337
|
+
safe.sort(key=lambda c: any(is_matching(n, types) for n in leaves(c)))
|
|
338
|
+
for c in safe:
|
|
339
|
+
frame = frame.filter(ordinary(c, columns, types, compiler))
|
|
340
|
+
compiler.frame = frame
|
|
341
|
+
compiler = MatchingCompiler(frame, columns, limits, score=score)
|
|
342
|
+
for rule in rules:
|
|
343
|
+
compiler.expression(rule)
|
|
344
|
+
return frame, compiler, rules
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Policy-cleared, deterministic matching evidence; never part of record selection."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
import rapidfuzz
|
|
9
|
+
|
|
10
|
+
from xp_libs_analytics.errors import AnalyticsError
|
|
11
|
+
|
|
12
|
+
from .compiler import MatchingCompiler, RuleUse, rule_config
|
|
13
|
+
from .normalize import VERSION, normalize
|
|
14
|
+
|
|
15
|
+
PRECEDENCE = {
|
|
16
|
+
"eq": 0,
|
|
17
|
+
"ne": 0,
|
|
18
|
+
"word": 1,
|
|
19
|
+
"starts_with": 2,
|
|
20
|
+
"ends_with": 2,
|
|
21
|
+
"contains": 3,
|
|
22
|
+
"not_contains": 3,
|
|
23
|
+
"like": 4,
|
|
24
|
+
"ilike": 4,
|
|
25
|
+
"regex": 5,
|
|
26
|
+
"fuzzy": 6,
|
|
27
|
+
}
|
|
28
|
+
NAMES = {"eq": "exact", "ne": "not_exact", "starts_with": "prefix", "ends_with": "suffix"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def matching_evidence(
|
|
32
|
+
frame: pl.DataFrame,
|
|
33
|
+
compiler: MatchingCompiler,
|
|
34
|
+
rules: list[RuleUse],
|
|
35
|
+
*,
|
|
36
|
+
include: bool,
|
|
37
|
+
top_values: int,
|
|
38
|
+
raw: bool,
|
|
39
|
+
key: bytes,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
result: dict[str, Any] = {
|
|
42
|
+
**compiler.cost(rules),
|
|
43
|
+
"predicate_complete": True,
|
|
44
|
+
"algorithm_versions": {
|
|
45
|
+
"rapidfuzz": rapidfuzz.__version__,
|
|
46
|
+
"polars": pl.__version__,
|
|
47
|
+
"normalization": VERSION,
|
|
48
|
+
},
|
|
49
|
+
"diagnostic_scope": "matching_leaf_hits_within_post_all_filters_pre_aggregation",
|
|
50
|
+
}
|
|
51
|
+
if not include:
|
|
52
|
+
return result
|
|
53
|
+
result["requires_isolation"] = True
|
|
54
|
+
unique = {rule.key(): rule for rule in rules}
|
|
55
|
+
hits: dict[tuple[str, str], dict[str, Any]] = {}
|
|
56
|
+
for rule in unique.values():
|
|
57
|
+
mask = frame.select(compiler.expression(rule).alias("matched")).get_column("matched")
|
|
58
|
+
values = frame.get_column(compiler.columns[rule.field][0]).to_list()
|
|
59
|
+
for row, (value, matched) in enumerate(zip(values, mask, strict=True)):
|
|
60
|
+
if matched is not True:
|
|
61
|
+
continue
|
|
62
|
+
if (rule.field, value) not in hits and len(
|
|
63
|
+
hits
|
|
64
|
+
) >= compiler.limits.max_match_diagnostic_candidates:
|
|
65
|
+
raise AnalyticsError("MATCH_CANDIDATE_BUDGET_EXCEEDED")
|
|
66
|
+
hit = hits.setdefault((rule.field, value), {"rows": set(), "rules": {}})
|
|
67
|
+
hit["rows"].add(row)
|
|
68
|
+
score = compiler.scores.get(rule.key(), {}).get(normalize(value, rule_config(rule)))
|
|
69
|
+
hit["rules"][rule.key()] = (rule, score)
|
|
70
|
+
ranked = []
|
|
71
|
+
for (field, value), hit in hits.items():
|
|
72
|
+
matched_rules = sorted(
|
|
73
|
+
hit["rules"].values(),
|
|
74
|
+
key=lambda item: (PRECEDENCE[item[0].op], -(item[1] or 0), item[0].key()),
|
|
75
|
+
)
|
|
76
|
+
primary, score = matched_rules[0]
|
|
77
|
+
evidence = [
|
|
78
|
+
{
|
|
79
|
+
"match_type": NAMES.get(r.op, r.op),
|
|
80
|
+
"normalization": rule_config(r).model_dump(),
|
|
81
|
+
**({"algorithm": r.algorithm, "score": round(s, 6)} if s is not None else {}),
|
|
82
|
+
}
|
|
83
|
+
for r, s in matched_rules
|
|
84
|
+
]
|
|
85
|
+
entry = {
|
|
86
|
+
"field": field,
|
|
87
|
+
"value": value if raw else None,
|
|
88
|
+
"value_ref": hmac.new(key, (field + "\0" + value).encode(), hashlib.sha256).hexdigest(),
|
|
89
|
+
"frequency": len(hit["rows"]),
|
|
90
|
+
"match_type": NAMES.get(primary.op, primary.op),
|
|
91
|
+
"normalization": rule_config(primary).model_dump(),
|
|
92
|
+
"matched_rules": evidence,
|
|
93
|
+
**(
|
|
94
|
+
{"fuzzy": {"algorithm": primary.algorithm}, "score": round(score, 6)}
|
|
95
|
+
if score is not None
|
|
96
|
+
else {}
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
order = (
|
|
100
|
+
PRECEDENCE[primary.op],
|
|
101
|
+
-(score or 0),
|
|
102
|
+
-entry["frequency"],
|
|
103
|
+
normalize(value, rule_config(primary)),
|
|
104
|
+
field,
|
|
105
|
+
value,
|
|
106
|
+
)
|
|
107
|
+
ranked.append((order, entry))
|
|
108
|
+
ranked.sort(key=lambda item: item[0])
|
|
109
|
+
truncated = len(ranked) > top_values
|
|
110
|
+
result.update(
|
|
111
|
+
top_values=[{**entry, "truncated": truncated} for _, entry in ranked[:top_values]],
|
|
112
|
+
diagnostics_truncated=truncated,
|
|
113
|
+
complete=not truncated,
|
|
114
|
+
)
|
|
115
|
+
return result
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Strict matching rules shared by scalar and multi-field source predicates."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MatchModel(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Normalization(MatchModel):
|
|
13
|
+
case_sensitive: bool = True
|
|
14
|
+
unicode: Literal["none", "nfc", "nfkc"] = "none"
|
|
15
|
+
trim: bool = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LiteralRule(MatchModel):
|
|
19
|
+
op: Literal[
|
|
20
|
+
"eq",
|
|
21
|
+
"contains",
|
|
22
|
+
"not_contains",
|
|
23
|
+
"starts_with",
|
|
24
|
+
"ends_with",
|
|
25
|
+
"word",
|
|
26
|
+
"like",
|
|
27
|
+
"ilike",
|
|
28
|
+
"regex",
|
|
29
|
+
]
|
|
30
|
+
value: str = Field(min_length=1, max_length=1024)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FuzzyRule(MatchModel):
|
|
34
|
+
op: Literal["fuzzy"]
|
|
35
|
+
value: str = Field(min_length=1, max_length=1024)
|
|
36
|
+
algorithm: Literal["ratio", "wratio", "token_sort"]
|
|
37
|
+
min_score: float = Field(ge=0, le=100)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
StringRule = Annotated[LiteralRule | FuzzyRule, Field(discriminator="op")]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MatchFields(MatchModel):
|
|
44
|
+
fields: list[str] = Field(min_length=1, max_length=10)
|
|
45
|
+
patterns: list[StringRule] = Field(min_length=1, max_length=10)
|
|
46
|
+
normalization: Normalization = Field(default_factory=Normalization)
|
|
47
|
+
mode: Literal["any"] = "any"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MatchFieldsPredicate(MatchModel):
|
|
51
|
+
match: MatchFields
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def op(self) -> Literal["match"]:
|
|
55
|
+
return "match"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class StringPredicate(MatchModel):
|
|
59
|
+
op: Literal["not_contains", "word", "like", "ilike", "regex"]
|
|
60
|
+
field: str
|
|
61
|
+
value: str = Field(min_length=1, max_length=1024)
|
|
62
|
+
normalization: Normalization | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FuzzyPredicate(FuzzyRule):
|
|
66
|
+
field: str
|
|
67
|
+
normalization: Normalization | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MatchingDetails(MatchModel):
|
|
71
|
+
include: bool = False
|
|
72
|
+
top_values: int = Field(default=10, ge=1, le=20)
|
|
73
|
+
include_raw_values: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
TEXT_OPS = {
|
|
77
|
+
"contains",
|
|
78
|
+
"not_contains",
|
|
79
|
+
"starts_with",
|
|
80
|
+
"ends_with",
|
|
81
|
+
"word",
|
|
82
|
+
"like",
|
|
83
|
+
"ilike",
|
|
84
|
+
"regex",
|
|
85
|
+
"fuzzy",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_matching(node: Any, types: dict[str, str] | None = None) -> bool:
|
|
90
|
+
if node.op == "match" or node.op in TEXT_OPS:
|
|
91
|
+
return True
|
|
92
|
+
if node.op in {"eq", "ne"}:
|
|
93
|
+
if node.normalization is not None:
|
|
94
|
+
return True
|
|
95
|
+
return isinstance(node.value, str) and (
|
|
96
|
+
types is None or types.get(node.field) in {None, "string", "null"}
|
|
97
|
+
)
|
|
98
|
+
return False
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Versioned Unicode preprocessing over bounded distinct strings, never row UDFs."""
|
|
2
|
+
|
|
3
|
+
import unicodedata
|
|
4
|
+
from typing import Literal, cast
|
|
5
|
+
|
|
6
|
+
from .models import Normalization
|
|
7
|
+
|
|
8
|
+
VERSION = "unicode-" + unicodedata.unidata_version + ":normalize-trim-casefold/v1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize(value: str, config: Normalization) -> str:
|
|
12
|
+
if config.unicode != "none":
|
|
13
|
+
value = unicodedata.normalize(cast(Literal["NFC", "NFKC"], config.unicode.upper()), value)
|
|
14
|
+
if config.trim:
|
|
15
|
+
value = value.strip()
|
|
16
|
+
return value if config.case_sensitive else value.casefold()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Bounded LIKE translation and validation by the actual Rust/Polars engine."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
from xp_libs_analytics.errors import AnalyticsError
|
|
8
|
+
from xp_libs_analytics.policy import Limits
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def like_regex(value: str) -> str:
|
|
12
|
+
result, escaped = [], False
|
|
13
|
+
for char in value:
|
|
14
|
+
if escaped:
|
|
15
|
+
if char not in "%_\\":
|
|
16
|
+
raise AnalyticsError("MATCH_PATTERN_INVALID")
|
|
17
|
+
result.append(re.escape(char))
|
|
18
|
+
escaped = False
|
|
19
|
+
elif char == "\\":
|
|
20
|
+
escaped = True
|
|
21
|
+
else:
|
|
22
|
+
result.append(".*" if char == "%" else "." if char == "_" else re.escape(char))
|
|
23
|
+
if escaped:
|
|
24
|
+
raise AnalyticsError("MATCH_PATTERN_INVALID")
|
|
25
|
+
return r"(?s)\A" + "".join(result) + r"\z"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def regex_flags(pattern: str) -> list[str]:
|
|
29
|
+
"""Inspect flag groups, respecting escaped characters and bracket classes."""
|
|
30
|
+
flags, escaped, depth = [], False, 0
|
|
31
|
+
for index, char in enumerate(pattern):
|
|
32
|
+
if escaped:
|
|
33
|
+
escaped = False
|
|
34
|
+
elif char == "\\":
|
|
35
|
+
escaped = True
|
|
36
|
+
elif char == "[":
|
|
37
|
+
depth += 1
|
|
38
|
+
elif char == "]" and depth:
|
|
39
|
+
depth -= 1
|
|
40
|
+
elif char == "(" and not depth and pattern[index : index + 2] == "(?":
|
|
41
|
+
end = index + 2
|
|
42
|
+
while end < len(pattern) and (
|
|
43
|
+
pattern[end].isascii() and pattern[end].isalpha() or pattern[end] == "-"
|
|
44
|
+
):
|
|
45
|
+
end += 1
|
|
46
|
+
if end > index + 2 and end < len(pattern) and pattern[end] in ":)":
|
|
47
|
+
flags.append(pattern[index + 2 : end])
|
|
48
|
+
return flags
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_regex(
|
|
52
|
+
pattern: str, limits: Limits, *, translated: bool = False, wrapper_bytes: int = 0
|
|
53
|
+
) -> None:
|
|
54
|
+
budget = (
|
|
55
|
+
limits.max_match_literal_chars * 8 + 32
|
|
56
|
+
if translated
|
|
57
|
+
else limits.max_regex_bytes + wrapper_bytes
|
|
58
|
+
)
|
|
59
|
+
if len(pattern.encode("utf-8")) > budget:
|
|
60
|
+
raise AnalyticsError("MATCH_REGEX_POLICY")
|
|
61
|
+
if not translated:
|
|
62
|
+
# This checks only a bounded policy syntax; it never executes user regex in Python.
|
|
63
|
+
if any(flag != "i" for flag in regex_flags(pattern)):
|
|
64
|
+
raise AnalyticsError("MATCH_REGEX_POLICY")
|
|
65
|
+
try:
|
|
66
|
+
pl.Series([""]).str.contains(pattern, literal=False, strict=True)
|
|
67
|
+
except pl.exceptions.ComputeError:
|
|
68
|
+
raise AnalyticsError("MATCH_REGEX_INVALID") from None
|
|
69
|
+
if not limits.allow_match_catch_all:
|
|
70
|
+
if pl.Series([""]).str.contains(pattern, literal=False, strict=True)[0]:
|
|
71
|
+
raise AnalyticsError("MATCH_REGEX_POLICY")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional standalone FastMCP adapter. Core imports do not load this module."""
|