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.
Files changed (65) hide show
  1. xp_libs_analytics/__init__.py +18 -0
  2. xp_libs_analytics/engine/__init__.py +1 -0
  3. xp_libs_analytics/engine/backend.py +379 -0
  4. xp_libs_analytics/engine/planner.py +450 -0
  5. xp_libs_analytics/engine/worker.py +121 -0
  6. xp_libs_analytics/errors/__init__.py +43 -0
  7. xp_libs_analytics/errors/catalog.json +75 -0
  8. xp_libs_analytics/errors/codes.py +81 -0
  9. xp_libs_analytics/ingest/__init__.py +309 -0
  10. xp_libs_analytics/interfaces.py +70 -0
  11. xp_libs_analytics/matching/__init__.py +1 -0
  12. xp_libs_analytics/matching/compiler.py +344 -0
  13. xp_libs_analytics/matching/diagnostics.py +115 -0
  14. xp_libs_analytics/matching/models.py +98 -0
  15. xp_libs_analytics/matching/normalize.py +16 -0
  16. xp_libs_analytics/matching/patterns.py +71 -0
  17. xp_libs_analytics/mcp/__init__.py +1 -0
  18. xp_libs_analytics/mcp/server.py +248 -0
  19. xp_libs_analytics/models/__init__.py +654 -0
  20. xp_libs_analytics/parser/__init__.py +142 -0
  21. xp_libs_analytics/policy/__init__.py +69 -0
  22. xp_libs_analytics/py.typed +0 -0
  23. xp_libs_analytics/registry.py +204 -0
  24. xp_libs_analytics/sdk/__init__.py +259 -0
  25. xp_libs_analytics/skills/generic-analytics/SKILL.md +236 -0
  26. xp_libs_analytics/skills/generic-analytics/examples/README.md +69 -0
  27. xp_libs_analytics/skills/generic-analytics/examples/queries/01_count_all.yaml +8 -0
  28. xp_libs_analytics/skills/generic-analytics/examples/queries/02_filter_and_count.yaml +15 -0
  29. xp_libs_analytics/skills/generic-analytics/examples/queries/03_group_stats_and_topk.yaml +21 -0
  30. xp_libs_analytics/skills/generic-analytics/examples/queries/04_nested_or_predicates.yaml +17 -0
  31. xp_libs_analytics/skills/generic-analytics/examples/queries/05_is_missing.yaml +11 -0
  32. xp_libs_analytics/skills/generic-analytics/examples/queries/06_missing_vs_null.yaml +17 -0
  33. xp_libs_analytics/skills/generic-analytics/examples/queries/07_conditional_aggregation.yaml +19 -0
  34. xp_libs_analytics/skills/generic-analytics/examples/queries/08_ratio_if.yaml +16 -0
  35. xp_libs_analytics/skills/generic-analytics/examples/queries/09_tumbling_window.yaml +23 -0
  36. xp_libs_analytics/skills/generic-analytics/examples/queries/10_sliding_window.yaml +32 -0
  37. xp_libs_analytics/skills/generic-analytics/examples/queries/11_explicit_cast.yaml +16 -0
  38. xp_libs_analytics/skills/generic-analytics/examples/queries/12_text_range_distinct.yaml +23 -0
  39. xp_libs_analytics/skills/generic-analytics/examples/queries/13_summary_statistics.yaml +15 -0
  40. xp_libs_analytics/skills/generic-analytics/examples/queries/14_nested_and_not.yaml +20 -0
  41. xp_libs_analytics/skills/generic-analytics/examples/queries/15_conditional_sum.yaml +13 -0
  42. xp_libs_analytics/skills/generic-analytics/examples/queries/16_mixed_types_cast.yaml +17 -0
  43. xp_libs_analytics/skills/generic-analytics/examples/queries/17_relative_last_hour.yaml +15 -0
  44. xp_libs_analytics/skills/generic-analytics/examples/queries/18_string_exact.yaml +11 -0
  45. xp_libs_analytics/skills/generic-analytics/examples/queries/19_string_normalization.yaml +15 -0
  46. xp_libs_analytics/skills/generic-analytics/examples/queries/20_prefix_suffix.yaml +16 -0
  47. xp_libs_analytics/skills/generic-analytics/examples/queries/21_whole_word.yaml +13 -0
  48. xp_libs_analytics/skills/generic-analytics/examples/queries/22_ilike.yaml +11 -0
  49. xp_libs_analytics/skills/generic-analytics/examples/queries/23_rust_regex.yaml +11 -0
  50. xp_libs_analytics/skills/generic-analytics/examples/queries/24_fuzzy.yaml +17 -0
  51. xp_libs_analytics/skills/generic-analytics/examples/queries/25_multi_field_matching.yaml +24 -0
  52. xp_libs_analytics/skills/generic-analytics/examples/queries/26_conditional_fuzzy.yaml +20 -0
  53. xp_libs_analytics/skills/generic-analytics/examples/queries/27_temporal_fuzzy.yaml +38 -0
  54. xp_libs_analytics/skills/generic-analytics/examples/queries/28_safe_matching_diagnostics.yaml +22 -0
  55. xp_libs_analytics/skills/generic-analytics/references/errors.md +55 -0
  56. xp_libs_analytics/skills/generic-analytics/references/query-language-full.md +306 -0
  57. xp_libs_analytics/skills/generic-analytics/references/query-language.md +39 -0
  58. xp_libs_analytics/skills/generic-analytics/references/string-matching-full.md +122 -0
  59. xp_libs_analytics/skills/generic-analytics/references/string-matching.md +11 -0
  60. xp_libs_analytics-0.4.0.dist-info/METADATA +199 -0
  61. xp_libs_analytics-0.4.0.dist-info/RECORD +65 -0
  62. xp_libs_analytics-0.4.0.dist-info/WHEEL +4 -0
  63. xp_libs_analytics-0.4.0.dist-info/entry_points.txt +2 -0
  64. xp_libs_analytics-0.4.0.dist-info/licenses/LICENSE +22 -0
  65. 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."""