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