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,18 @@
1
+ """Bounded snapshot analytics over arbitrary JSON records and restricted YAML."""
2
+
3
+ from xp_libs_analytics.errors import AnalyticsError
4
+ from xp_libs_analytics.models import AnalyticsQuery
5
+ from xp_libs_analytics.policy import Limits, Principal
6
+ from xp_libs_analytics.registry import DatasetInfo, DatasetRegistry
7
+ from xp_libs_analytics.sdk import AnalyticsEngine
8
+
9
+ __all__ = [
10
+ "AnalyticsEngine",
11
+ "AnalyticsError",
12
+ "AnalyticsQuery",
13
+ "DatasetInfo",
14
+ "DatasetRegistry",
15
+ "Limits",
16
+ "Principal",
17
+ ]
18
+ __version__ = "0.4.0"
@@ -0,0 +1 @@
1
+ """Snapshot planner and fixed-expression Polars backend."""
@@ -0,0 +1,379 @@
1
+ """Fixed factories only: no SQL, expressions from text, callbacks or UDFs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from collections.abc import Mapping
8
+ from datetime import UTC, datetime, timedelta
9
+ from functools import reduce
10
+ from typing import Any, cast
11
+
12
+ import polars as pl
13
+
14
+ from xp_libs_analytics.engine.planner import LogicalPlan, timestamp
15
+ from xp_libs_analytics.errors import AnalyticsError
16
+ from xp_libs_analytics.matching.compiler import MatchingCompiler, RuleUse, prepare
17
+ from xp_libs_analytics.matching.diagnostics import matching_evidence
18
+ from xp_libs_analytics.matching.models import is_matching
19
+ from xp_libs_analytics.models import AnalyticsQuery, HavingPredicate, Predicate
20
+ from xp_libs_analytics.policy import Limits
21
+
22
+ DTYPES: dict[str, Any] = {
23
+ "integer": pl.Int64,
24
+ "float": pl.Float64,
25
+ "string": pl.String,
26
+ "boolean": pl.Boolean,
27
+ "datetime": pl.Datetime("us", "UTC"),
28
+ "null": pl.Null,
29
+ "mixed": pl.Null,
30
+ "object": pl.Null,
31
+ "array": pl.Null,
32
+ }
33
+
34
+
35
+ def predicate_expr(
36
+ node: Predicate | HavingPredicate,
37
+ columns: Mapping[str, tuple[str, str | None, str | None]],
38
+ types: dict[str, str],
39
+ matcher: MatchingCompiler | None = None,
40
+ ) -> pl.Expr:
41
+ op = node.op
42
+ if op in ("and", "or"):
43
+ exprs = [predicate_expr(c, columns, types, matcher) for c in getattr(node, "conditions")]
44
+ return reduce(lambda a, b: a & b if op == "and" else a | b, exprs)
45
+ if op == "not":
46
+ return ~predicate_expr(getattr(node, "condition"), columns, types, matcher)
47
+ if matcher is not None and is_matching(node, types):
48
+ return matcher.predicate(node)
49
+ value_col, presence_col, null_col = columns[getattr(node, "field")]
50
+ col = pl.col(value_col)
51
+ if op == "is_missing":
52
+ return ~pl.col(cast(str, presence_col))
53
+ if op == "is_present":
54
+ return pl.col(cast(str, presence_col))
55
+ if op in ("is_null", "is_not_null"):
56
+ expr = (pl.col(null_col) | ~pl.col(cast(str, presence_col))) if null_col else col.is_null()
57
+ return expr if op == "is_null" else ~expr
58
+
59
+ def literal(v: Any) -> Any:
60
+ return timestamp(v) if types[getattr(node, "field")] == "datetime" and v is not None else v
61
+
62
+ if op in ("in", "not_in"):
63
+ # Null in a membership list propagates unknown for an unmatched value.
64
+ values = [literal(v) for v in getattr(node, "values") if v is not None]
65
+ expr = col.is_in(values)
66
+ if None in getattr(node, "values"):
67
+ expr = pl.when(expr).then(True).otherwise(None)
68
+ expr = pl.when(col.is_null()).then(None).otherwise(expr)
69
+ return ~expr if op == "not_in" else expr
70
+ if op == "between":
71
+ return col.is_between(
72
+ literal(getattr(node, "lower")), literal(getattr(node, "upper")), closed="both"
73
+ )
74
+ v = literal(getattr(node, "value"))
75
+ if op == "eq":
76
+ return col == pl.lit(v)
77
+ if op == "ne":
78
+ return col != pl.lit(v)
79
+ if op == "gt":
80
+ return col > pl.lit(v)
81
+ if op == "gte":
82
+ return col >= pl.lit(v)
83
+ if op == "lt":
84
+ return col < pl.lit(v)
85
+ if op == "lte":
86
+ return col <= pl.lit(v)
87
+ # String source predicates are compiled only by the shared matching layer.
88
+ raise AnalyticsError("QUERY_OPERATOR_TYPE")
89
+
90
+
91
+ def aggregate_expr(
92
+ a: Any,
93
+ columns: Mapping[str, tuple[str, str | None, str | None]],
94
+ types: dict[str, str],
95
+ matcher: MatchingCompiler | None = None,
96
+ ) -> pl.Expr:
97
+ op = a.op
98
+ if op == "count":
99
+ return pl.len().cast(pl.Int64)
100
+ if op == "count_if":
101
+ return (
102
+ predicate_expr(a.where, columns, types, matcher).fill_null(False).cast(pl.Int64).sum()
103
+ )
104
+ value_col, present_col, null_col = columns[a.field]
105
+ col = pl.col(value_col)
106
+ if op == "missing_count":
107
+ return (~pl.col(cast(str, present_col))).cast(pl.Int64).sum()
108
+ if op == "null_count":
109
+ return pl.col(cast(str, null_col)).cast(pl.Int64).sum()
110
+ if op == "count_non_null":
111
+ return col.count().cast(pl.Int64)
112
+ if op == "count_distinct":
113
+ return col.drop_nulls().n_unique().cast(pl.Int64)
114
+ if op == "ratio_if":
115
+ numerator = (
116
+ (predicate_expr(a.where, columns, types, matcher).fill_null(False) & col.is_not_null())
117
+ .cast(pl.Int64)
118
+ .sum()
119
+ )
120
+ return pl.when(col.count() > 0).then(numerator / col.count()).otherwise(None)
121
+ if op in ("sum_if", "avg_if"):
122
+ col = col.filter(predicate_expr(a.where, columns, types, matcher).fill_null(False))
123
+ if op in ("sum", "sum_if"):
124
+ numeric = col.cast(pl.Int128) if types[a.field] == "integer" else col.cast(pl.Float64)
125
+ return pl.when(col.count() > 0).then(numeric.sum()).otherwise(None)
126
+ if op in ("avg", "avg_if"):
127
+ return col.cast(pl.Float64).mean()
128
+ if op == "min":
129
+ return col.min()
130
+ if op == "max":
131
+ return col.max()
132
+ if op == "stddev":
133
+ return col.cast(pl.Float64).std(ddof=1)
134
+ if op == "variance":
135
+ return col.cast(pl.Float64).var(ddof=1)
136
+ q = {"median": 0.5, "p50": 0.5, "p90": 0.9, "p95": 0.95, "p99": 0.99}[op]
137
+ return col.cast(pl.Float64).quantile(q, interpolation="linear")
138
+
139
+
140
+ def json_safe(value: Any) -> Any:
141
+ if isinstance(value, datetime):
142
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
143
+ if isinstance(value, dict):
144
+ return {k: json_safe(v) for k, v in value.items()}
145
+ if isinstance(value, list):
146
+ return [json_safe(v) for v in value]
147
+ if type(value) is float and not math.isfinite(value):
148
+ raise AnalyticsError("EXECUTION_BACKEND_ERROR")
149
+ if type(value) is int and not -(2**63) <= value < 2**63:
150
+ raise AnalyticsError("EXECUTION_BACKEND_ERROR")
151
+ return value
152
+
153
+
154
+ def execute_plan(plan: LogicalPlan, limits: Limits) -> dict[str, Any]:
155
+ query = AnalyticsQuery.model_validate_json(plan.query_json)
156
+ frame, columns, types = frame_for(plan)
157
+ if query.time_range:
158
+ frame = frame.filter(
159
+ pl.col(columns[query.time_range.field][0]).is_between(
160
+ plan.start, plan.end, closed="left"
161
+ )
162
+ )
163
+ rules: list[RuleUse] = []
164
+ matcher = None
165
+ if json.loads(plan.matching_cost_json).get("used") or query.options.matching_details.include:
166
+ frame, matcher, rules = prepare(
167
+ query, frame, columns, types, limits, predicate_expr, score=True
168
+ )
169
+ for predicate in query.filters:
170
+ frame = frame.filter(predicate_expr(predicate, columns, types, matcher))
171
+ matching = (
172
+ matching_evidence(
173
+ frame,
174
+ matcher,
175
+ rules,
176
+ include=query.options.matching_details.include,
177
+ top_values=query.options.matching_details.top_values,
178
+ raw=query.options.matching_details.include_raw_values,
179
+ key=plan.diagnostic_key,
180
+ )
181
+ if matcher
182
+ else None
183
+ )
184
+ matched = frame.height
185
+ group_cols = []
186
+ rename = {}
187
+ for i, g in enumerate(query.group_by):
188
+ v, p, _ = columns[g.field]
189
+ frame = frame.with_columns(pl.col(v).alias(f"_g{i}"), pl.col(p).alias(f"_gp{i}"))
190
+ group_cols.extend([f"_g{i}", f"_gp{i}"])
191
+ rename[f"_g{i}"] = g.alias
192
+ if query.window:
193
+ if query.time_range is None:
194
+ raise AnalyticsError("QUERY_WINDOW_INVALID")
195
+ time_col = pl.col(columns[query.time_range.field][0]).dt.epoch("us")
196
+ step = plan.window_step * 1_000_000
197
+ size = plan.window_size * 1_000_000
198
+ aligned = (time_col // step) * step
199
+ frame = frame.with_columns(
200
+ pl.int_ranges(aligned - size + step, aligned + step, step).alias("_ws")
201
+ )
202
+ frame = frame.explode("_ws")
203
+ frame = frame.with_columns(pl.col("_ws").cast(pl.Datetime("us", "UTC")))
204
+ group_cols = ["_ws"] + group_cols
205
+ rename["_ws"] = "window_start"
206
+ agg_exprs = [
207
+ aggregate_expr(a, columns, types, matcher).alias(f"_a{i}")
208
+ for i, a in enumerate(query.aggregations)
209
+ ]
210
+ rename.update({f"_a{i}": a.alias for i, a in enumerate(query.aggregations)})
211
+ if group_cols:
212
+ result = frame.group_by(group_cols).agg(agg_exprs)
213
+ else:
214
+ result = frame.select(agg_exprs)
215
+ if query.options.include_empty_windows:
216
+ if plan.start is None or plan.end is None:
217
+ raise AnalyticsError("QUERY_WINDOW_INVALID")
218
+ step = plan.window_step
219
+ first = math.floor(plan.start.timestamp() / step) * step - plan.window_size + step
220
+ last = math.ceil(plan.end.timestamp() / step) * step
221
+ grid = pl.DataFrame(
222
+ {
223
+ "_ws": pl.Series(
224
+ [datetime.fromtimestamp(s, UTC) for s in range(first, last, step)],
225
+ dtype=pl.Datetime("us", "UTC"),
226
+ )
227
+ }
228
+ )
229
+ result = grid.join(result, on="_ws", how="left")
230
+ counts = [
231
+ f"_a{i}"
232
+ for i, a in enumerate(query.aggregations)
233
+ if a.op.startswith("count") or a.op in ("null_count", "missing_count")
234
+ ]
235
+ if counts:
236
+ result = result.with_columns(pl.col(counts).fill_null(0))
237
+ for i, _ in enumerate(query.aggregations):
238
+ metric_series = result[f"_a{i}"]
239
+ if metric_series.dtype == pl.Int128:
240
+ if any(
241
+ v is not None and not -(2**63) <= cast(int, v) < 2**63
242
+ for v in (metric_series.min(), metric_series.max())
243
+ ):
244
+ raise AnalyticsError("EXECUTION_BACKEND_ERROR")
245
+ if metric_series.dtype.is_float() and not metric_series.is_finite().fill_null(True).all():
246
+ raise AnalyticsError("EXECUTION_BACKEND_ERROR")
247
+ result = result.rename(rename)
248
+ if query.window:
249
+ result = result.with_columns(
250
+ (pl.col("window_start") + timedelta(seconds=plan.window_size)).alias("window_end")
251
+ )
252
+ if result.height > limits.max_groups:
253
+ raise AnalyticsError("RESOURCE_GROUPS_LIMIT")
254
+ result_columns = {name: (name, None, None) for name in result.columns}
255
+ result_types = {
256
+ name: (
257
+ "datetime"
258
+ if dtype.base_type() == pl.Datetime
259
+ else "float"
260
+ if dtype.is_float()
261
+ else "integer"
262
+ if dtype.is_integer()
263
+ else "boolean"
264
+ if dtype == pl.Boolean
265
+ else "string"
266
+ )
267
+ for name, dtype in result.schema.items()
268
+ }
269
+ for h in query.having:
270
+ result = result.filter(predicate_expr(h, result_columns, result_types))
271
+ implicit = (
272
+ (["window_start"] if query.window else [])
273
+ + [g.alias for g in query.group_by]
274
+ + [f"_gp{i}" for i in range(len(query.group_by))]
275
+ )
276
+ if implicit:
277
+ result = result.sort(implicit, nulls_last=True, maintain_order=True)
278
+ for order in reversed(query.order_by):
279
+ result = result.sort(
280
+ order.field,
281
+ descending=order.direction == "desc",
282
+ nulls_last=order.nulls == "last",
283
+ maintain_order=True,
284
+ )
285
+ truncated = result.height > query.limit
286
+ result = result.head(query.limit)
287
+ rows = result.to_dicts()
288
+ for row in rows:
289
+ if query.group_by:
290
+ row["group_key_status"] = {
291
+ g.alias: "missing"
292
+ if not row.pop(f"_gp{i}")
293
+ else "null"
294
+ if row[g.alias] is None
295
+ else "present"
296
+ for i, g in enumerate(query.group_by)
297
+ }
298
+ public_columns = [
299
+ {
300
+ "name": name,
301
+ "type": (
302
+ "int64"
303
+ if dtype.is_integer()
304
+ else "float64"
305
+ if dtype.is_float()
306
+ else "datetime"
307
+ if dtype.base_type() == pl.Datetime
308
+ else "boolean"
309
+ if dtype == pl.Boolean
310
+ else "string"
311
+ if dtype == pl.String
312
+ else "null"
313
+ ),
314
+ }
315
+ for name, dtype in result.schema.items()
316
+ if not name.startswith("_gp") or name in {g.alias for g in query.group_by}
317
+ ]
318
+ quality = json.loads(plan.quality_json)
319
+ partial = bool(
320
+ query.window
321
+ and any(row["window_start"] < plan.start or row["window_end"] > plan.end for row in rows)
322
+ )
323
+ return cast(
324
+ dict[str, Any],
325
+ json_safe(
326
+ {
327
+ "status": "NO_DATA"
328
+ if matched == 0
329
+ else "INSUFFICIENT_DATA"
330
+ if matched < limits.min_samples
331
+ else "OK",
332
+ "columns": public_columns,
333
+ "rows": rows,
334
+ "truncated": truncated,
335
+ "evidence": {
336
+ "input_rows": plan.input_rows,
337
+ "matched_rows": matched,
338
+ "resolved_time_range": {"start": plan.start, "end": plan.end}
339
+ if plan.start
340
+ else None,
341
+ "field_quality": quality,
342
+ "coverage": "UNKNOWN",
343
+ "partial_boundary_windows": partial,
344
+ },
345
+ **({"matching": matching} if matching is not None else {}),
346
+ "warnings": ["DATA_CAST_FAILED"]
347
+ if any(q["cast_errors"] for q in quality.values())
348
+ else [],
349
+ "error": None,
350
+ }
351
+ ),
352
+ )
353
+
354
+
355
+ def frame_for(
356
+ plan: LogicalPlan,
357
+ ) -> tuple[pl.DataFrame, dict[str, tuple[str, str, str]], dict[str, str]]:
358
+ types = dict(plan.types)
359
+ columns = {p: (f"v{i}", f"p{i}", f"n{i}") for i, p in enumerate(plan.fields)}
360
+ series = []
361
+ for name, values in plan.columns:
362
+ dtype = DTYPES[types[plan.fields[int(name[1:])]]] if name[0] == "v" else pl.Boolean
363
+ series.append(pl.Series(name, values, dtype=dtype))
364
+ series.append(pl.Series("_row", range(plan.input_rows), dtype=pl.Int64))
365
+ frame = pl.DataFrame(series)
366
+ return frame, columns, types
367
+
368
+
369
+ def matching_cost(plan: LogicalPlan, limits: Limits) -> dict[str, Any]:
370
+ query = AnalyticsQuery.model_validate_json(plan.query_json)
371
+ frame, columns, types = frame_for(plan)
372
+ if query.time_range:
373
+ frame = frame.filter(
374
+ pl.col(columns[query.time_range.field][0]).is_between(
375
+ plan.start, plan.end, closed="left"
376
+ )
377
+ )
378
+ _, matcher, rules = prepare(query, frame, columns, types, limits, predicate_expr, score=False)
379
+ return matcher.cost(rules)