smartpipe-cli 1.3.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.
- smartpipe/__init__.py +6 -0
- smartpipe/__main__.py +8 -0
- smartpipe/assets/probe.png +0 -0
- smartpipe/assets/probe.txt +1 -0
- smartpipe/assets/probe.wav +0 -0
- smartpipe/cli/__init__.py +5 -0
- smartpipe/cli/auth_cmd.py +78 -0
- smartpipe/cli/cache_cmd.py +60 -0
- smartpipe/cli/chart_cmd.py +60 -0
- smartpipe/cli/cite_cmd.py +26 -0
- smartpipe/cli/cluster_cmd.py +102 -0
- smartpipe/cli/completions.py +91 -0
- smartpipe/cli/config_cmd.py +234 -0
- smartpipe/cli/diff_cmd.py +100 -0
- smartpipe/cli/distinct_cmd.py +94 -0
- smartpipe/cli/doctor_cmd.py +207 -0
- smartpipe/cli/echo_cmd.py +44 -0
- smartpipe/cli/embed_cmd.py +80 -0
- smartpipe/cli/extend_cmd.py +138 -0
- smartpipe/cli/filter_cmd.py +87 -0
- smartpipe/cli/getschema_cmd.py +31 -0
- smartpipe/cli/input_options.py +113 -0
- smartpipe/cli/interrupts.py +92 -0
- smartpipe/cli/join_cmd.py +162 -0
- smartpipe/cli/map_cmd.py +150 -0
- smartpipe/cli/outliers_cmd.py +82 -0
- smartpipe/cli/probe_cmd.py +223 -0
- smartpipe/cli/reduce_cmd.py +129 -0
- smartpipe/cli/root.py +281 -0
- smartpipe/cli/run_cmd.py +136 -0
- smartpipe/cli/sample_cmd.py +35 -0
- smartpipe/cli/schema_cmd.py +75 -0
- smartpipe/cli/screens.py +231 -0
- smartpipe/cli/sem_file.py +453 -0
- smartpipe/cli/sort_cmd.py +33 -0
- smartpipe/cli/split_cmd.py +76 -0
- smartpipe/cli/summarize_cmd.py +37 -0
- smartpipe/cli/top_k_cmd.py +97 -0
- smartpipe/cli/usage_cmd.py +66 -0
- smartpipe/cli/where_cmd.py +36 -0
- smartpipe/config/__init__.py +5 -0
- smartpipe/config/credentials.py +118 -0
- smartpipe/config/display.py +70 -0
- smartpipe/config/doctor.py +58 -0
- smartpipe/config/paths.py +38 -0
- smartpipe/config/store.py +252 -0
- smartpipe/container.py +439 -0
- smartpipe/core/__init__.py +5 -0
- smartpipe/core/errors.py +57 -0
- smartpipe/core/jsontools.py +56 -0
- smartpipe/engine/__init__.py +9 -0
- smartpipe/engine/aggregate.py +234 -0
- smartpipe/engine/blocking.py +44 -0
- smartpipe/engine/chart.py +143 -0
- smartpipe/engine/chunking.py +161 -0
- smartpipe/engine/clustering.py +94 -0
- smartpipe/engine/predicate.py +330 -0
- smartpipe/engine/prompts.py +601 -0
- smartpipe/engine/ranking.py +62 -0
- smartpipe/engine/runner.py +175 -0
- smartpipe/engine/schema.py +208 -0
- smartpipe/engine/schema_dsl.py +144 -0
- smartpipe/engine/tally.py +53 -0
- smartpipe/engine/timebin.py +67 -0
- smartpipe/engine/units.py +43 -0
- smartpipe/engine/windows.py +78 -0
- smartpipe/io/__init__.py +9 -0
- smartpipe/io/diagnostics.py +148 -0
- smartpipe/io/inputs.py +44 -0
- smartpipe/io/items.py +149 -0
- smartpipe/io/leaderboard.py +52 -0
- smartpipe/io/metering.py +180 -0
- smartpipe/io/progress.py +140 -0
- smartpipe/io/readers.py +455 -0
- smartpipe/io/text.py +40 -0
- smartpipe/io/tty.py +88 -0
- smartpipe/io/usage.py +214 -0
- smartpipe/io/writers.py +340 -0
- smartpipe/models/__init__.py +5 -0
- smartpipe/models/anthropic_adapter.py +149 -0
- smartpipe/models/base.py +170 -0
- smartpipe/models/budget.py +94 -0
- smartpipe/models/cache.py +132 -0
- smartpipe/models/gemini_native.py +196 -0
- smartpipe/models/http_support.py +77 -0
- smartpipe/models/jina.py +98 -0
- smartpipe/models/local_embed.py +76 -0
- smartpipe/models/ollama.py +204 -0
- smartpipe/models/openai_codex.py +237 -0
- smartpipe/models/openai_compat.py +328 -0
- smartpipe/models/openai_oauth.py +366 -0
- smartpipe/models/resolve.py +78 -0
- smartpipe/models/retry.py +69 -0
- smartpipe/models/stt.py +80 -0
- smartpipe/models/windows.py +116 -0
- smartpipe/parsing/__init__.py +10 -0
- smartpipe/parsing/detect.py +178 -0
- smartpipe/parsing/extract.py +582 -0
- smartpipe/py.typed +0 -0
- smartpipe/verbs/__init__.py +5 -0
- smartpipe/verbs/chart.py +153 -0
- smartpipe/verbs/cluster.py +220 -0
- smartpipe/verbs/common.py +468 -0
- smartpipe/verbs/convert.py +251 -0
- smartpipe/verbs/diff.py +206 -0
- smartpipe/verbs/distinct.py +164 -0
- smartpipe/verbs/embed.py +166 -0
- smartpipe/verbs/extend.py +180 -0
- smartpipe/verbs/filter.py +191 -0
- smartpipe/verbs/getschema.py +135 -0
- smartpipe/verbs/join.py +413 -0
- smartpipe/verbs/map.py +315 -0
- smartpipe/verbs/outliers.py +119 -0
- smartpipe/verbs/reduce.py +428 -0
- smartpipe/verbs/sample.py +52 -0
- smartpipe/verbs/sortverb.py +63 -0
- smartpipe/verbs/split.py +333 -0
- smartpipe/verbs/summarize.py +60 -0
- smartpipe/verbs/top_k.py +318 -0
- smartpipe/verbs/where.py +47 -0
- smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
- smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
- smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
- smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
- smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
- smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
smartpipe/core/errors.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Exit codes and the error taxonomy.
|
|
2
|
+
|
|
3
|
+
Contract: plan/decisions.md D12 and plan/architecture.md "Error taxonomy".
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ExitCode",
|
|
12
|
+
"ItemError",
|
|
13
|
+
"SempipeError",
|
|
14
|
+
"SetupFault",
|
|
15
|
+
"TooManyFailures",
|
|
16
|
+
"UsageFault",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExitCode(IntEnum):
|
|
21
|
+
OK = 0
|
|
22
|
+
PARTIAL = 1
|
|
23
|
+
SETUP = 2
|
|
24
|
+
ALL_FAILED = 3
|
|
25
|
+
USAGE = 64
|
|
26
|
+
BUG = 70
|
|
27
|
+
INTERRUPTED = 130
|
|
28
|
+
PIPE_CLOSED = 141 # 128 + SIGPIPE: downstream closed the pipe (| head) — die silently
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SempipeError(Exception):
|
|
32
|
+
"""Base of all *expected* smartpipe failures. Never raised directly."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UsageFault(SempipeError):
|
|
36
|
+
"""Bad flags/arguments/grammar misuse → exit 64."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SetupFault(SempipeError):
|
|
40
|
+
"""No model, unreachable endpoint, missing key/extra, broken config → exit 2.
|
|
41
|
+
|
|
42
|
+
The message may be a full multi-line screen from plan/ux.md.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ItemError(SempipeError):
|
|
47
|
+
"""One item failed; the runner turns this into a skip-and-warn, never a crash."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TooManyFailures(SempipeError):
|
|
51
|
+
"""The failure policy tripped (>50 % of items failed) → exit 3."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, failed: int, total: int, last_reason: str) -> None:
|
|
54
|
+
self.failed = failed
|
|
55
|
+
self.total = total
|
|
56
|
+
self.last_reason = last_reason
|
|
57
|
+
super().__init__(f"stopping — {failed} of {total} items failed the same way")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Typed access into untrusted JSON (provider replies), without ``cast``.
|
|
2
|
+
|
|
3
|
+
Each helper narrows ``object`` to a concrete shape or returns ``None`` — the
|
|
4
|
+
caller decides whether a wrong shape is an ``ItemError`` or a bug.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, TypeGuard
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
__all__ = ["as_float_vector", "as_items", "as_record", "as_str", "record_at"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_record(value: object) -> TypeGuard[Mapping[str, object]]:
|
|
18
|
+
return isinstance(value, dict) # json.loads keys are str by contract
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_items(value: object) -> TypeGuard[Sequence[object]]:
|
|
22
|
+
return isinstance(value, list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def as_record(value: object) -> Mapping[str, object] | None:
|
|
26
|
+
return value if _is_record(value) else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def as_items(value: object) -> Sequence[object] | None:
|
|
30
|
+
return value if _is_items(value) else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def as_str(value: object) -> str | None:
|
|
34
|
+
return value if isinstance(value, str) else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def as_float_vector(value: object) -> tuple[float, ...] | None:
|
|
38
|
+
items = as_items(value)
|
|
39
|
+
if items is None:
|
|
40
|
+
return None
|
|
41
|
+
vector: list[float] = []
|
|
42
|
+
for element in items:
|
|
43
|
+
if isinstance(element, bool) or not isinstance(element, int | float):
|
|
44
|
+
return None
|
|
45
|
+
vector.append(float(element))
|
|
46
|
+
return tuple(vector)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def record_at(value: object, *keys: str) -> Mapping[str, object] | None:
|
|
50
|
+
"""Walk nested objects: ``record_at(data, "message")`` → the message record."""
|
|
51
|
+
current = as_record(value)
|
|
52
|
+
for key in keys:
|
|
53
|
+
if current is None:
|
|
54
|
+
return None
|
|
55
|
+
current = as_record(current.get(key))
|
|
56
|
+
return current
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The pure functional core: prompts, schema, chunking, ranking, ordering.
|
|
2
|
+
|
|
3
|
+
Nothing in this package does I/O, reads the clock, or touches the environment —
|
|
4
|
+
every input is a parameter. That is what makes it exhaustively testable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The ``summarize`` grammar and fold (D38/07) — KQL's micro-syntax, pure.
|
|
2
|
+
|
|
3
|
+
`count(), avg(total), p95(total) by region` parses once into a plan; records
|
|
4
|
+
fold into per-group state in one pass. Percentile aggregations keep each
|
|
5
|
+
group's numeric values (the one memory note); everything else streams.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, Literal, assert_never
|
|
12
|
+
|
|
13
|
+
from smartpipe.core.errors import UsageFault
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"SUMMARIZE_MENU",
|
|
20
|
+
"Aggregation",
|
|
21
|
+
"BinKey",
|
|
22
|
+
"GroupState",
|
|
23
|
+
"SummarizePlan",
|
|
24
|
+
"finish",
|
|
25
|
+
"fold",
|
|
26
|
+
"group_key",
|
|
27
|
+
"parse_summarize",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
SUMMARIZE_MENU = (
|
|
31
|
+
"error: can't parse the summarize expression\n"
|
|
32
|
+
" Shape: AGG[, AGG…] [by FIELD[, FIELD…]]\n"
|
|
33
|
+
" Aggregations: count() · sum(f) · avg(f) · min(f) · max(f)\n"
|
|
34
|
+
" p50(f) · p90(f) · p95(f) · p99(f) · dcount(f)\n"
|
|
35
|
+
" Example: smartpipe summarize 'count(), avg(total), p95(total) by region'"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_FUNCTIONS = ("count", "sum", "avg", "min", "max", "p50", "p90", "p95", "p99", "dcount")
|
|
39
|
+
_Fn = Literal["count", "sum", "avg", "min", "max", "p50", "p90", "p95", "p99", "dcount"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Aggregation:
|
|
44
|
+
fn: _Fn
|
|
45
|
+
field: str | None # None only for count()
|
|
46
|
+
name: str # KQL's output naming: count, avg_total, p95_total, dcount_user
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class BinKey:
|
|
51
|
+
field: str
|
|
52
|
+
bucket_seconds: int
|
|
53
|
+
name: str # "<field>_bin"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class SummarizePlan:
|
|
58
|
+
aggregations: tuple[Aggregation, ...]
|
|
59
|
+
by: tuple[str | BinKey, ...]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def by_names(self) -> tuple[str, ...]:
|
|
63
|
+
return tuple(key if isinstance(key, str) else key.name for key in self.by)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_summarize(text: str) -> SummarizePlan:
|
|
67
|
+
head, _, tail = text.partition(" by ")
|
|
68
|
+
by = (
|
|
69
|
+
tuple(_parse_by(name.strip()) for name in _split_bins(tail) if name.strip()) if tail else ()
|
|
70
|
+
)
|
|
71
|
+
if tail and not by:
|
|
72
|
+
raise UsageFault(SUMMARIZE_MENU + "\n ('by' needs at least one field)")
|
|
73
|
+
aggregations = tuple(_parse_agg(part.strip()) for part in head.split(",") if part.strip())
|
|
74
|
+
if not aggregations:
|
|
75
|
+
raise UsageFault(SUMMARIZE_MENU + "\n (name at least one aggregation)")
|
|
76
|
+
return SummarizePlan(aggregations, by)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _split_bins(tail: str) -> list[str]:
|
|
80
|
+
"""Split by-keys on commas outside parens — bin(ts, 1h) survives whole."""
|
|
81
|
+
parts: list[str] = []
|
|
82
|
+
depth = 0
|
|
83
|
+
current = ""
|
|
84
|
+
for char in tail:
|
|
85
|
+
if char == "(":
|
|
86
|
+
depth += 1
|
|
87
|
+
elif char == ")":
|
|
88
|
+
depth -= 1
|
|
89
|
+
if char == "," and depth == 0:
|
|
90
|
+
parts.append(current)
|
|
91
|
+
current = ""
|
|
92
|
+
else:
|
|
93
|
+
current += char
|
|
94
|
+
parts.append(current)
|
|
95
|
+
return parts
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_by(token: str) -> str | BinKey:
|
|
99
|
+
if not token.startswith("bin(") or not token.endswith(")"):
|
|
100
|
+
return token
|
|
101
|
+
inner = token[4:-1]
|
|
102
|
+
field_name, comma, bucket_text = inner.partition(",")
|
|
103
|
+
field_name = field_name.strip()
|
|
104
|
+
if not comma or not field_name:
|
|
105
|
+
raise UsageFault(SUMMARIZE_MENU + "\n (bin needs bin(field, bucket) — e.g. bin(ts, 1h))")
|
|
106
|
+
from smartpipe.engine.timebin import parse_bucket
|
|
107
|
+
|
|
108
|
+
return BinKey(field_name, parse_bucket(bucket_text.strip()), f"{field_name}_bin")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_agg(token: str) -> Aggregation:
|
|
112
|
+
name, paren, rest = token.partition("(")
|
|
113
|
+
name = name.strip()
|
|
114
|
+
if not paren or not rest.endswith(")"):
|
|
115
|
+
raise UsageFault(SUMMARIZE_MENU + f"\n (stuck at: {token!r})")
|
|
116
|
+
if name not in _FUNCTIONS:
|
|
117
|
+
raise UsageFault(SUMMARIZE_MENU + f"\n ({name!r} isn't an aggregation)")
|
|
118
|
+
inner = rest[:-1].strip()
|
|
119
|
+
if name == "count":
|
|
120
|
+
if inner:
|
|
121
|
+
raise UsageFault(SUMMARIZE_MENU + "\n (count() takes no field)")
|
|
122
|
+
return Aggregation("count", None, "count")
|
|
123
|
+
if not inner:
|
|
124
|
+
raise UsageFault(SUMMARIZE_MENU + f"\n ({name}() needs a field)")
|
|
125
|
+
fn: _Fn = name # type: ignore[assignment] # narrowed by the membership check
|
|
126
|
+
return Aggregation(fn, inner, f"{name}_{inner}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(slots=True)
|
|
130
|
+
class GroupState:
|
|
131
|
+
count: int = 0
|
|
132
|
+
sums: dict[str, float] = field(default_factory=dict[str, float])
|
|
133
|
+
mins: dict[str, float] = field(default_factory=dict[str, float])
|
|
134
|
+
maxes: dict[str, float] = field(default_factory=dict[str, float])
|
|
135
|
+
numeric_counts: dict[str, int] = field(default_factory=dict[str, int])
|
|
136
|
+
values: dict[str, list[float]] = field(default_factory=dict[str, "list[float]"])
|
|
137
|
+
distinct: dict[str, set[str]] = field(default_factory=dict[str, "set[str]"])
|
|
138
|
+
skipped_non_numeric: dict[str, int] = field(default_factory=dict[str, int])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def fold(plan: SummarizePlan, state: GroupState, record: Mapping[str, object]) -> None:
|
|
142
|
+
state.count += 1
|
|
143
|
+
for aggregation in plan.aggregations:
|
|
144
|
+
if aggregation.field is None:
|
|
145
|
+
continue
|
|
146
|
+
value = record.get(aggregation.field)
|
|
147
|
+
if aggregation.fn == "dcount":
|
|
148
|
+
if value is not None:
|
|
149
|
+
state.distinct.setdefault(aggregation.field, set()).add(str(value))
|
|
150
|
+
continue
|
|
151
|
+
number = _numeric(value)
|
|
152
|
+
if number is None:
|
|
153
|
+
if value is not None:
|
|
154
|
+
state.skipped_non_numeric[aggregation.field] = (
|
|
155
|
+
state.skipped_non_numeric.get(aggregation.field, 0) + 1
|
|
156
|
+
)
|
|
157
|
+
continue
|
|
158
|
+
state.numeric_counts[aggregation.field] = state.numeric_counts.get(aggregation.field, 0) + 1
|
|
159
|
+
state.sums[aggregation.field] = state.sums.get(aggregation.field, 0.0) + number
|
|
160
|
+
held = state.mins.get(aggregation.field)
|
|
161
|
+
state.mins[aggregation.field] = number if held is None else min(held, number)
|
|
162
|
+
held = state.maxes.get(aggregation.field)
|
|
163
|
+
state.maxes[aggregation.field] = number if held is None else max(held, number)
|
|
164
|
+
if aggregation.fn in ("p50", "p90", "p95", "p99"):
|
|
165
|
+
state.values.setdefault(aggregation.field, []).append(number)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def finish(plan: SummarizePlan, key: tuple[object, ...], state: GroupState) -> dict[str, object]:
|
|
169
|
+
row: dict[str, object] = dict(zip(plan.by_names, key, strict=True))
|
|
170
|
+
for aggregation in plan.aggregations:
|
|
171
|
+
row[aggregation.name] = _value(aggregation, state)
|
|
172
|
+
return row
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _value(aggregation: Aggregation, state: GroupState) -> object:
|
|
176
|
+
field_name = aggregation.field
|
|
177
|
+
match aggregation.fn:
|
|
178
|
+
case "count":
|
|
179
|
+
return state.count
|
|
180
|
+
case "dcount":
|
|
181
|
+
assert field_name is not None
|
|
182
|
+
return len(state.distinct.get(field_name, set()))
|
|
183
|
+
case "sum":
|
|
184
|
+
assert field_name is not None
|
|
185
|
+
return _round(state.sums.get(field_name)) if field_name in state.sums else None
|
|
186
|
+
case "avg":
|
|
187
|
+
assert field_name is not None
|
|
188
|
+
seen = state.numeric_counts.get(field_name, 0)
|
|
189
|
+
return _round(state.sums[field_name] / seen) if seen else None
|
|
190
|
+
case "min":
|
|
191
|
+
assert field_name is not None
|
|
192
|
+
return _round(state.mins.get(field_name))
|
|
193
|
+
case "max":
|
|
194
|
+
assert field_name is not None
|
|
195
|
+
return _round(state.maxes.get(field_name))
|
|
196
|
+
case "p50" | "p90" | "p95" | "p99":
|
|
197
|
+
assert field_name is not None
|
|
198
|
+
values = sorted(state.values.get(field_name, ()))
|
|
199
|
+
if not values:
|
|
200
|
+
return None
|
|
201
|
+
quantile = int(aggregation.fn[1:]) / 100
|
|
202
|
+
rank = min(len(values) - 1, max(0, round(quantile * (len(values) - 1))))
|
|
203
|
+
return _round(values[rank])
|
|
204
|
+
case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
|
|
205
|
+
assert_never(unreachable)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _round(value: float | None) -> float | None:
|
|
209
|
+
if value is None:
|
|
210
|
+
return None
|
|
211
|
+
return round(value, 4)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _numeric(value: object) -> float | None:
|
|
215
|
+
if isinstance(value, bool):
|
|
216
|
+
return None
|
|
217
|
+
if isinstance(value, (int, float)):
|
|
218
|
+
return float(value)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def group_key(plan: SummarizePlan, record: Mapping[str, object]) -> tuple[object, ...]:
|
|
223
|
+
"""The record's group key: plain fields pass through; bin() keys become
|
|
224
|
+
UTC bucket labels (unparseable timestamps group under null, visibly)."""
|
|
225
|
+
from smartpipe.engine.timebin import bucket_label, parse_timestamp
|
|
226
|
+
|
|
227
|
+
parts: list[object] = []
|
|
228
|
+
for key in plan.by:
|
|
229
|
+
if isinstance(key, str):
|
|
230
|
+
parts.append(record.get(key))
|
|
231
|
+
continue
|
|
232
|
+
epoch = parse_timestamp(record.get(key.field))
|
|
233
|
+
parts.append(None if epoch is None else bucket_label(epoch, key.bucket_seconds))
|
|
234
|
+
return tuple(parts)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Candidate selection for ``join`` (D21) — the *block* of embed-block-judge.
|
|
2
|
+
|
|
3
|
+
Pure math over an in-memory index: brute-force cosine, same envelope as
|
|
4
|
+
``top_k`` (no vector database, no ANN — recorded non-goals; the corpus lives in
|
|
5
|
+
the pipe). ``candidates`` is the recall knob's implementation: everything the
|
|
6
|
+
judge never sees is a match the user never gets, so ties and ordering are pinned
|
|
7
|
+
and property-tested.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from smartpipe.engine.ranking import rank, select
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
|
|
20
|
+
__all__ = ["RightIndex", "build_index", "candidates"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class RightIndex:
|
|
25
|
+
"""The build side, embedded once: position ↔ the caller's right-row payloads."""
|
|
26
|
+
|
|
27
|
+
vectors: tuple[tuple[float, ...], ...]
|
|
28
|
+
|
|
29
|
+
def __len__(self) -> int:
|
|
30
|
+
return len(self.vectors)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_index(vectors: Sequence[Sequence[float]]) -> RightIndex:
|
|
34
|
+
return RightIndex(vectors=tuple(tuple(vector) for vector in vectors))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def candidates(
|
|
38
|
+
query: Sequence[float], index: RightIndex, *, k: int, threshold: float | None
|
|
39
|
+
) -> tuple[tuple[int, float], ...]:
|
|
40
|
+
"""The top-``k`` right positions for one left vector, best-first, ties by
|
|
41
|
+
position; ``threshold`` filters before ``k`` (a floor, then a width)."""
|
|
42
|
+
if k < 1:
|
|
43
|
+
raise ValueError(f"k must be >= 1, got {k}")
|
|
44
|
+
return select(rank(query, index.vectors), k=k, threshold=threshold)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Bar-chart rendering for ``smartpipe chart`` — pure, no plotting dependency.
|
|
2
|
+
|
|
3
|
+
Terminal bars from block characters; ``--save`` writes a hand-rolled SVG (text,
|
|
4
|
+
so it costs no dependency and converts to anything). Counts in, pictures out.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
__all__ = ["CHARTS_EXTRA_SCREEN", "render_bars", "render_svg", "render_svg_panels"]
|
|
15
|
+
|
|
16
|
+
_BLOCK = "▇"
|
|
17
|
+
_BAR_WIDTH = 40 # cells for the longest bar; the rest scale
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render_bars(counts: Sequence[tuple[str, int]], *, width: int = _BAR_WIDTH) -> str:
|
|
21
|
+
"""Horizontal unicode bars, widest value = ``width`` cells, labels aligned."""
|
|
22
|
+
if not counts:
|
|
23
|
+
return "(nothing to chart)"
|
|
24
|
+
label_width = max(len(label) for label, _ in counts)
|
|
25
|
+
top = max(count for _, count in counts) or 1
|
|
26
|
+
lines: list[str] = []
|
|
27
|
+
for label, count in counts:
|
|
28
|
+
cells = max(1, round(count / top * width)) if count else 0
|
|
29
|
+
lines.append(f"{label.ljust(label_width)} {_BLOCK * cells} {count:,}")
|
|
30
|
+
return "\n".join(lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_ROW_HEIGHT = 28
|
|
34
|
+
_LABEL_SPACE = 180
|
|
35
|
+
_CHART_WIDTH = 640
|
|
36
|
+
|
|
37
|
+
CHARTS_EXTRA_SCREEN = (
|
|
38
|
+
"error: saving charts needs an optional dependency\n"
|
|
39
|
+
" svgwrite ships with smartpipe — reinstall smartpipe"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_svg(counts: Sequence[tuple[str, int]], *, title: str | None = None) -> str:
|
|
44
|
+
"""An SVG bar chart via svgwrite (the ``[charts]`` extra) — a library, not
|
|
45
|
+
bespoke markup (owner's call: no hand-rolled SVG to maintain)."""
|
|
46
|
+
try:
|
|
47
|
+
import svgwrite
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
from smartpipe.core.errors import SetupFault
|
|
50
|
+
|
|
51
|
+
raise SetupFault(CHARTS_EXTRA_SCREEN) from exc
|
|
52
|
+
|
|
53
|
+
rows = list(counts) or [("(nothing)", 0)]
|
|
54
|
+
top = max(count for _, count in rows) or 1
|
|
55
|
+
header = 36 if title else 8
|
|
56
|
+
height = header + len(rows) * _ROW_HEIGHT + 8
|
|
57
|
+
drawing = svgwrite.Drawing(size=(_CHART_WIDTH, height))
|
|
58
|
+
drawing.add(drawing.rect(insert=(0, 0), size=(_CHART_WIDTH, height), fill="white"))
|
|
59
|
+
text_style = {"font_family": "system-ui, sans-serif", "font_size": "13px"}
|
|
60
|
+
if title:
|
|
61
|
+
drawing.add(
|
|
62
|
+
drawing.text(
|
|
63
|
+
title,
|
|
64
|
+
insert=(12, 24),
|
|
65
|
+
font_weight="bold",
|
|
66
|
+
font_size="16px",
|
|
67
|
+
font_family="system-ui, sans-serif",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
bar_space = _CHART_WIDTH - _LABEL_SPACE - 80
|
|
71
|
+
for row, (label, count) in enumerate(rows):
|
|
72
|
+
y = header + row * _ROW_HEIGHT
|
|
73
|
+
bar = max(2, round(count / top * bar_space)) if count else 0
|
|
74
|
+
drawing.add(
|
|
75
|
+
drawing.text(label, insert=(_LABEL_SPACE - 8, y + 18), text_anchor="end", **text_style)
|
|
76
|
+
)
|
|
77
|
+
drawing.add(
|
|
78
|
+
drawing.rect(insert=(_LABEL_SPACE, y + 6), size=(bar, 16), fill="#4477aa", rx=2)
|
|
79
|
+
)
|
|
80
|
+
drawing.add(
|
|
81
|
+
drawing.text(f"{count:,}", insert=(_LABEL_SPACE + bar + 6, y + 18), **text_style)
|
|
82
|
+
)
|
|
83
|
+
return drawing.tostring()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def render_svg_panels(
|
|
87
|
+
panels: Sequence[tuple[str, Sequence[tuple[str, int]]]], *, title: str | None = None
|
|
88
|
+
) -> str:
|
|
89
|
+
"""Several bar panels stacked in one SVG (chart --facet). Same dependency
|
|
90
|
+
rules as render_svg: svgwrite behind the [charts] extra."""
|
|
91
|
+
try:
|
|
92
|
+
import svgwrite
|
|
93
|
+
except ImportError as exc:
|
|
94
|
+
from smartpipe.core.errors import SetupFault
|
|
95
|
+
|
|
96
|
+
raise SetupFault(CHARTS_EXTRA_SCREEN) from exc
|
|
97
|
+
|
|
98
|
+
header = 36 if title else 8
|
|
99
|
+
heights = [24 + max(1, len(counts)) * _ROW_HEIGHT + 8 for _name, counts in panels]
|
|
100
|
+
total_height = header + sum(heights)
|
|
101
|
+
drawing = svgwrite.Drawing(size=(_CHART_WIDTH, total_height))
|
|
102
|
+
drawing.add(drawing.rect(insert=(0, 0), size=(_CHART_WIDTH, total_height), fill="white"))
|
|
103
|
+
text_style = {"font_family": "system-ui, sans-serif", "font_size": "13px"}
|
|
104
|
+
if title:
|
|
105
|
+
drawing.add(
|
|
106
|
+
drawing.text(
|
|
107
|
+
title,
|
|
108
|
+
insert=(12, 24),
|
|
109
|
+
font_weight="bold",
|
|
110
|
+
font_size="16px",
|
|
111
|
+
font_family="system-ui, sans-serif",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
offset = header
|
|
115
|
+
bar_space = _CHART_WIDTH - _LABEL_SPACE - 80
|
|
116
|
+
for (name, counts), panel_height in zip(panels, heights, strict=True):
|
|
117
|
+
drawing.add(
|
|
118
|
+
drawing.text(
|
|
119
|
+
name,
|
|
120
|
+
insert=(12, offset + 16),
|
|
121
|
+
font_weight="bold",
|
|
122
|
+
font_size="13px",
|
|
123
|
+
font_family="system-ui, sans-serif",
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
rows = list(counts) or [("(nothing)", 0)]
|
|
127
|
+
top = max(count for _label, count in rows) or 1
|
|
128
|
+
for row, (label, count) in enumerate(rows):
|
|
129
|
+
y = offset + 24 + row * _ROW_HEIGHT
|
|
130
|
+
bar = max(2, round(count / top * bar_space)) if count else 0
|
|
131
|
+
drawing.add(
|
|
132
|
+
drawing.text(
|
|
133
|
+
label, insert=(_LABEL_SPACE - 8, y + 18), text_anchor="end", **text_style
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
drawing.add(
|
|
137
|
+
drawing.rect(insert=(_LABEL_SPACE, y + 6), size=(bar, 16), fill="#4477aa", rx=2)
|
|
138
|
+
)
|
|
139
|
+
drawing.add(
|
|
140
|
+
drawing.text(f"{count:,}", insert=(_LABEL_SPACE + bar + 6, y + 18), **text_style)
|
|
141
|
+
)
|
|
142
|
+
offset += panel_height
|
|
143
|
+
return drawing.tostring()
|