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.
Files changed (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,601 @@
1
+ """Brace-grammar parsing (plan/ux.md "Brace grammar", plan/decisions.md D13).
2
+
3
+ The tokenizer is verb-neutral: it splits a prompt into literal text and brace
4
+ groups. What a brace group *means* differs by verb — in ``map`` the groups name
5
+ output fields; in ``filter``/``reduce`` a single-field group interpolates an
6
+ input value — but both build on these tokens. This module owns only the parse
7
+ and the verb-neutral helpers; per-verb prompt assembly lives alongside.
8
+
9
+ Grammar:
10
+ prompt ::= (text | brace_group | "{{" | "}}")*
11
+ brace_group ::= "{" ws ident (ws "," ws ident)* ws "}"
12
+ ident ::= [A-Za-z_][A-Za-z0-9_]*
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from collections.abc import Mapping
20
+ from dataclasses import dataclass, replace
21
+ from typing import TYPE_CHECKING, Literal
22
+
23
+ from smartpipe.core.errors import ItemError, UsageFault
24
+ from smartpipe.engine.schema import shorthand_to_schema
25
+ from smartpipe.models.base import ( # shared request value types, not behavior
26
+ CompletionRequest,
27
+ ImageData,
28
+ MediaData,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Sequence
33
+
34
+ from smartpipe.io.items import Item
35
+
36
+ __all__ = [
37
+ "FILTER_JUDGE_SYSTEM",
38
+ "IMAGE_ITEM_PREFIX",
39
+ "JOIN_JUDGE_SYSTEM",
40
+ "JUDGE_SCHEMA",
41
+ "MAP_JSON_SYSTEM",
42
+ "MAP_PLAIN_SYSTEM",
43
+ "REDUCE_FINAL_JSON_SYSTEM",
44
+ "REDUCE_FINAL_SYSTEM",
45
+ "REDUCE_INTERMEDIATE_SYSTEM",
46
+ "SCHEMA_DRAFT_SYSTEM",
47
+ "BraceToken",
48
+ "MapPlan",
49
+ "TextToken",
50
+ "Token",
51
+ "brace_fields",
52
+ "brace_notes",
53
+ "brace_props",
54
+ "build_filter_request",
55
+ "build_judge_request",
56
+ "build_map_request",
57
+ "build_reduce_final",
58
+ "build_reduce_intermediate",
59
+ "build_repair_request",
60
+ "build_schema_request",
61
+ "has_brace",
62
+ "interpolate_fields",
63
+ "interpolate_join",
64
+ "parse_join_predicate",
65
+ "parse_prompt",
66
+ "plan_map",
67
+ "reject_comma_groups",
68
+ "render",
69
+ "to_instruction",
70
+ ]
71
+
72
+ MAP_PLAIN_SYSTEM = (
73
+ "You transform text. Reply with ONLY the transformed text for the item — "
74
+ "no preamble, no quotes, no commentary."
75
+ )
76
+ MAP_JSON_SYSTEM = (
77
+ "Extract exactly the requested fields as a single JSON object matching the schema. "
78
+ "Reply with ONLY the JSON object — no preamble, no code fences, no commentary."
79
+ )
80
+ IMAGE_ITEM_PREFIX = "The item is an image. " # stage-07 contract, verbatim
81
+ _PLAIN_MAX_TOKENS = 4096
82
+ _STRUCTURED_MAX_TOKENS = 8192
83
+
84
+ FILTER_JUDGE_SYSTEM = (
85
+ "You judge whether an item satisfies a condition. "
86
+ 'Reply with ONLY JSON: {"match": true} if it satisfies the condition, '
87
+ 'or {"match": false} if it does not. No preamble, no explanation.'
88
+ )
89
+ JUDGE_SCHEMA: dict[str, object] = {
90
+ "type": "object",
91
+ "properties": {"match": {"type": "boolean"}},
92
+ "required": ["match"],
93
+ "additionalProperties": False,
94
+ }
95
+ _JUDGE_MAX_TOKENS = 64
96
+
97
+ REDUCE_FINAL_SYSTEM = (
98
+ "You synthesize many items into a single result. Follow the user's instruction "
99
+ "exactly and reply with only the result — no preamble, no meta-commentary."
100
+ )
101
+ REDUCE_FINAL_JSON_SYSTEM = (
102
+ "You synthesize many items into a single JSON object matching the schema. "
103
+ "Reply with ONLY the JSON object — no preamble, no code fences."
104
+ )
105
+ REDUCE_INTERMEDIATE_SYSTEM = (
106
+ "You are condensing PART of a larger collection. Produce dense notes that "
107
+ "preserve every detail relevant to the stated goal. Do NOT write a conclusion "
108
+ "or a final answer — only notes that a later step will combine with others."
109
+ )
110
+ _REDUCE_MAX_TOKENS = 8192
111
+
112
+ _IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
113
+ _SIDED_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?\Z")
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class TextToken:
118
+ text: str
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class BraceToken:
123
+ fields: tuple[str, ...]
124
+ raw: str # the original "{a, b}" text, kept for error messages and round-trips
125
+ notes: tuple[str | None, ...] = () # rung-2 descriptions, aligned with fields (D22)
126
+ props: tuple[Mapping[str, object] | None, ...] = () # inline types (D37)
127
+
128
+ def note_for(self, position: int) -> str | None:
129
+ return self.notes[position] if position < len(self.notes) else None
130
+
131
+ def prop_for(self, position: int) -> Mapping[str, object] | None:
132
+ return self.props[position] if position < len(self.props) else None
133
+
134
+
135
+ Token = TextToken | BraceToken
136
+
137
+
138
+ def parse_prompt(
139
+ text: str, *, ident: re.Pattern[str] = _IDENT, allow_descriptions: bool = False
140
+ ) -> tuple[Token, ...]:
141
+ tokens: list[Token] = []
142
+ literal: list[str] = []
143
+ index = 0
144
+ length = len(text)
145
+
146
+ def flush() -> None:
147
+ if literal:
148
+ tokens.append(TextToken("".join(literal)))
149
+ literal.clear()
150
+
151
+ while index < length:
152
+ char = text[index]
153
+ pair = text[index : index + 2]
154
+ if pair == "{{":
155
+ literal.append("{")
156
+ index += 2
157
+ elif pair == "}}":
158
+ literal.append("}")
159
+ index += 2
160
+ elif char == "{":
161
+ flush()
162
+ token, index = _parse_group(text, index, ident, allow_descriptions=allow_descriptions)
163
+ tokens.append(token)
164
+ elif char == "}":
165
+ raise UsageFault("unexpected '}' in prompt — use '}}' for a literal brace")
166
+ else:
167
+ literal.append(char)
168
+ index += 1
169
+ flush()
170
+ return tuple(tokens)
171
+
172
+
173
+ def brace_fields(tokens: tuple[Token, ...]) -> tuple[str, ...]:
174
+ """Every field named across all brace groups, deduped, first-seen order."""
175
+ seen: dict[str, None] = {}
176
+ for token in tokens:
177
+ if isinstance(token, BraceToken):
178
+ for field in token.fields:
179
+ seen.setdefault(field, None)
180
+ return tuple(seen)
181
+
182
+
183
+ def _merged_brace_bits(
184
+ tokens: tuple[Token, ...],
185
+ ) -> tuple[list[str], dict[str, str], dict[str, Mapping[str, object]]]:
186
+ """Fields across all groups, deduped in first-seen order; a field typed
187
+ twice DIFFERENTLY is an error (strict mode also rejects duplicate
188
+ ``required`` entries, so the dedupe is correctness, not cosmetics)."""
189
+ fields: list[str] = []
190
+ notes: dict[str, str] = {}
191
+ props: dict[str, Mapping[str, object]] = {}
192
+ for token in tokens:
193
+ if not isinstance(token, BraceToken):
194
+ continue
195
+ for position, name in enumerate(token.fields):
196
+ if name not in fields:
197
+ fields.append(name)
198
+ note = token.note_for(position)
199
+ if note is not None:
200
+ notes.setdefault(name, note)
201
+ prop = token.prop_for(position)
202
+ if prop is not None:
203
+ known = props.get(name)
204
+ if known is not None and known != prop:
205
+ raise UsageFault(
206
+ f"field {name!r} is typed twice differently\n"
207
+ f" first: {_type_words(known)} — then: {_type_words(prop)}\n"
208
+ " Give a field one type; repeats without a type are fine."
209
+ )
210
+ props[name] = prop
211
+ return fields, notes, props
212
+
213
+
214
+ def _type_words(prop: Mapping[str, object]) -> str:
215
+ from smartpipe.core.jsontools import as_items, as_record
216
+
217
+ kind = prop.get("type")
218
+ if isinstance(kind, str):
219
+ items = as_record(prop.get("items"))
220
+ if items is not None:
221
+ inner = items.get("type")
222
+ return f"{inner}[]" if isinstance(inner, str) else "array"
223
+ return kind
224
+ values = as_items(prop.get("enum"))
225
+ if values is not None:
226
+ return "enum(" + ", ".join(str(value) for value in values) + ")"
227
+ return str(dict(prop))
228
+
229
+
230
+ def brace_props(tokens: tuple[Token, ...]) -> dict[str, Mapping[str, object]]:
231
+ """field → inline type (D37), for every typed field across all groups."""
232
+ props: dict[str, Mapping[str, object]] = {}
233
+ for token in tokens:
234
+ if isinstance(token, BraceToken):
235
+ for position, name in enumerate(token.fields):
236
+ typed = token.prop_for(position)
237
+ if typed is not None:
238
+ props[name] = typed
239
+ return props
240
+
241
+
242
+ def brace_notes(tokens: tuple[Token, ...]) -> dict[str, str]:
243
+ """field → rung-2 description, for every described field across all groups."""
244
+ notes: dict[str, str] = {}
245
+ for token in tokens:
246
+ if isinstance(token, BraceToken):
247
+ for position, name in enumerate(token.fields):
248
+ described = token.note_for(position)
249
+ if described is not None:
250
+ notes[name] = described
251
+ return notes
252
+
253
+
254
+ def has_brace(tokens: tuple[Token, ...]) -> bool:
255
+ return any(isinstance(token, BraceToken) for token in tokens)
256
+
257
+
258
+ def render(tokens: tuple[Token, ...]) -> str:
259
+ """Reconstruct a prompt string; ``parse(render(parse(x))) == parse(x)``."""
260
+ parts = [
261
+ token.text.replace("{", "{{").replace("}", "}}")
262
+ if isinstance(token, TextToken)
263
+ else token.raw
264
+ for token in tokens
265
+ ]
266
+ return "".join(parts)
267
+
268
+
269
+ def to_instruction(tokens: tuple[Token, ...]) -> str:
270
+ """The model-facing instruction: literal text as-is, brace groups as their
271
+ comma-joined field names (``Extract {a, b}`` → ``Extract a, b``); a rung-2
272
+ description rides its field as guidance (``vendor (the supplier name)``)."""
273
+
274
+ def group(token: BraceToken) -> str:
275
+ rendered = (
276
+ f"{name} ({note})" if (note := token.note_for(position)) is not None else name
277
+ for position, name in enumerate(token.fields)
278
+ )
279
+ return ", ".join(rendered)
280
+
281
+ parts = [token.text if isinstance(token, TextToken) else group(token) for token in tokens]
282
+ return "".join(parts)
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class MapPlan:
287
+ mode: Literal["plain", "structured"]
288
+ schema: Mapping[str, object] | None
289
+ system: str
290
+
291
+
292
+ def plan_map(tokens: tuple[Token, ...], *, schema: Mapping[str, object] | None) -> MapPlan:
293
+ """Decide plain vs structured: an explicit --schema wins; else braces imply a
294
+ synthesized schema; else plain text (spec §3.1)."""
295
+ if schema is not None:
296
+ return MapPlan("structured", schema, MAP_JSON_SYSTEM)
297
+ if has_brace(tokens):
298
+ fields, notes, props = _merged_brace_bits(tokens)
299
+ synthesized = shorthand_to_schema(fields, descriptions=notes, types=props)
300
+ return MapPlan("structured", synthesized, MAP_JSON_SYSTEM)
301
+ return MapPlan("plain", None, MAP_PLAIN_SYSTEM)
302
+
303
+
304
+ def build_map_request(
305
+ plan: MapPlan,
306
+ instruction: str,
307
+ item_text: str,
308
+ *,
309
+ media: tuple[MediaData, ...] = (),
310
+ ) -> CompletionRequest:
311
+ max_tokens = _STRUCTURED_MAX_TOKENS if plan.mode == "structured" else _PLAIN_MAX_TOKENS
312
+ prefixed = any(isinstance(part, ImageData) for part in media)
313
+ system = f"{IMAGE_ITEM_PREFIX}{plan.system}" if prefixed else plan.system
314
+ return CompletionRequest(
315
+ system=system,
316
+ user=f"{instruction}\n\n{item_text}" if item_text else instruction,
317
+ json_schema=plan.schema,
318
+ max_tokens=max_tokens,
319
+ media=media,
320
+ )
321
+
322
+
323
+ def build_repair_request(
324
+ original: CompletionRequest, *, bad_reply: str, error: str
325
+ ) -> CompletionRequest:
326
+ """Re-ask with the validator's complaint so the model can self-correct once."""
327
+ user = (
328
+ f"{original.user}\n\n"
329
+ f"Your previous reply was:\n{bad_reply}\n\n"
330
+ f"That was invalid: {error}\n"
331
+ "Reply again with ONLY a corrected JSON object."
332
+ )
333
+ return replace(original, user=user)
334
+
335
+
336
+ def reject_comma_groups(tokens: tuple[Token, ...]) -> None:
337
+ """In filter/reduce, ``{field}`` reads one input field — comma-groups are a
338
+ map-only shorthand (plan/decisions.md D13). Fail fast, don't guess."""
339
+ for token in tokens:
340
+ if isinstance(token, BraceToken) and len(token.fields) > 1:
341
+ raise UsageFault(
342
+ f"{token.raw} — comma-separated braces only work in 'map'\n"
343
+ " In map, braces name the output fields. In filter and reduce, {field}\n"
344
+ " inserts a field from each input item, one field per brace group."
345
+ )
346
+
347
+
348
+ def interpolate_fields(tokens: tuple[Token, ...], data: Mapping[str, object] | None) -> str:
349
+ """Substitute each single-field ``{field}`` with the item's value. Raises
350
+ ``ItemError`` (→ skip-and-warn) when the item isn't JSON or lacks the field."""
351
+ parts: list[str] = []
352
+ for token in tokens:
353
+ if isinstance(token, TextToken):
354
+ parts.append(token.text)
355
+ continue
356
+ field = token.fields[0] # comma-groups already rejected
357
+ if data is None:
358
+ raise ItemError(f"no field '{field}' (this item isn't JSON)")
359
+ if field not in data:
360
+ available = ", ".join(data) if data else "no fields"
361
+ raise ItemError(f"no field '{field}'; this item has: {available}")
362
+ parts.append(_render_value(data[field]))
363
+ return "".join(parts)
364
+
365
+
366
+ def build_filter_request(condition: str, item_text: str) -> CompletionRequest:
367
+ return CompletionRequest(
368
+ system=FILTER_JUDGE_SYSTEM,
369
+ user=f"Condition: {condition}\n\nItem:\n{item_text}",
370
+ json_schema=JUDGE_SCHEMA,
371
+ max_tokens=_JUDGE_MAX_TOKENS,
372
+ )
373
+
374
+
375
+ SCHEMA_DRAFT_SYSTEM = (
376
+ "You design JSON Schemas (draft 2020-12). Reply with ONLY the JSON Schema "
377
+ "object — no preamble, no code fences. The top level must be an object schema "
378
+ 'with "type": "object", "properties", "required", and '
379
+ '"additionalProperties": false. Mark a field optional only when the request '
380
+ "implies it. Prefer precise types, enums for closed sets, and short "
381
+ '"description" strings.'
382
+ )
383
+ _SCHEMA_DRAFT_MAX_TOKENS = 2048
384
+
385
+
386
+ def build_schema_request(description: str) -> CompletionRequest:
387
+ """Rung 4 (D22): exactly one drafting call; the reply is meta-validated
388
+ before stdout ever sees a byte."""
389
+ return CompletionRequest(
390
+ system=SCHEMA_DRAFT_SYSTEM,
391
+ user=f"Design a JSON Schema for: {description}",
392
+ max_tokens=_SCHEMA_DRAFT_MAX_TOKENS,
393
+ )
394
+
395
+
396
+ JOIN_JUDGE_SYSTEM = (
397
+ "You judge whether a statement about a pair of items is true. "
398
+ 'Reply with ONLY a JSON object: {"match": true} or {"match": false}.'
399
+ )
400
+
401
+ _JOIN_EXAMPLE = (
402
+ ' Example: smartpipe join "ticket {left.text} concerns {right.name}" --right products.jsonl'
403
+ )
404
+
405
+
406
+ def parse_join_predicate(text: str) -> tuple[Token, ...]:
407
+ """The join grammar (D21): filter-rule braces, side-qualified. Every brace
408
+ names a side; the predicate must read both sides (one-sided predicates match
409
+ everything or nothing — a mistake, refused up front)."""
410
+ tokens = parse_prompt(text, ident=_SIDED_IDENT)
411
+ reject_comma_groups(tokens)
412
+ sides: set[str] = set()
413
+ for token in tokens:
414
+ if not isinstance(token, BraceToken):
415
+ continue
416
+ field = token.fields[0]
417
+ side, dot, _name = field.partition(".")
418
+ if not dot:
419
+ raise UsageFault(
420
+ f"{{{field}}} is ambiguous in join — say {{left.{field}}} or {{right.{field}}}\n"
421
+ " join reads two inputs; each brace names a side's field.\n" + _JOIN_EXAMPLE
422
+ )
423
+ if side not in ("left", "right"):
424
+ raise UsageFault(
425
+ f"{token.raw} — the side must be left or right\n"
426
+ " join reads two inputs; each brace names a side's field.\n" + _JOIN_EXAMPLE
427
+ )
428
+ sides.add(side)
429
+ for missing in ("left", "right"):
430
+ if missing not in sides:
431
+ raise UsageFault(
432
+ f"the predicate never mentions the {missing} side — "
433
+ f"say {{{missing}.field}} somewhere\n"
434
+ " join judges PAIRS; a predicate that reads one side matches everything "
435
+ "or nothing."
436
+ )
437
+ return tokens
438
+
439
+
440
+ def interpolate_join(tokens: tuple[Token, ...], left: Item, right: Item) -> str:
441
+ """Substitute ``{left.x}``/``{right.x}`` from the pair. ``.text`` falls back to
442
+ the item's whole text; any other missing field is an ``ItemError`` (pair-skip)."""
443
+ items = {"left": left, "right": right}
444
+ parts: list[str] = []
445
+ for token in tokens:
446
+ if isinstance(token, TextToken):
447
+ parts.append(token.text)
448
+ continue
449
+ side, _dot, name = token.fields[0].partition(".")
450
+ item = items[side]
451
+ parts.append(_join_value(side, name, item))
452
+ return "".join(parts)
453
+
454
+
455
+ def _join_value(side: str, name: str, item: Item) -> str:
456
+ if item.data is not None and name in item.data:
457
+ return _render_value(item.data[name])
458
+ if name == "text":
459
+ return item.text # the pinned fallback: the whole item as text
460
+ available = ", ".join(item.data) if item.data else "no fields (not JSON)"
461
+ raise ItemError(f"{side} has no field '{name}'; it has: {available}")
462
+
463
+
464
+ def build_judge_request(tokens: tuple[Token, ...], left: Item, right: Item) -> CompletionRequest:
465
+ statement = interpolate_join(tokens, left, right)
466
+ return CompletionRequest(
467
+ system=JOIN_JUDGE_SYSTEM,
468
+ user=f"Statement about a pair of items:\n{statement}\n\nIs the statement true?",
469
+ json_schema=JUDGE_SCHEMA,
470
+ max_tokens=_JUDGE_MAX_TOKENS,
471
+ )
472
+
473
+
474
+ def build_reduce_final(
475
+ instruction: str, texts: Sequence[str], schema: Mapping[str, object] | None
476
+ ) -> CompletionRequest:
477
+ system = REDUCE_FINAL_JSON_SYSTEM if schema is not None else REDUCE_FINAL_SYSTEM
478
+ return CompletionRequest(
479
+ system=system,
480
+ user=f"{instruction}\n\nItems:\n{_numbered(texts)}",
481
+ json_schema=schema,
482
+ max_tokens=_REDUCE_MAX_TOKENS,
483
+ )
484
+
485
+
486
+ def build_reduce_intermediate(goal: str, texts: Sequence[str]) -> CompletionRequest:
487
+ return CompletionRequest(
488
+ system=REDUCE_INTERMEDIATE_SYSTEM,
489
+ user=f"Goal: {goal}\n\nItems:\n{_numbered(texts)}",
490
+ json_schema=None,
491
+ max_tokens=_REDUCE_MAX_TOKENS,
492
+ )
493
+
494
+
495
+ def _numbered(texts: Sequence[str]) -> str:
496
+ return "\n\n---\n\n".join(f"[{index + 1}] {text}" for index, text in enumerate(texts))
497
+
498
+
499
+ def _render_value(value: object) -> str:
500
+ if isinstance(value, str):
501
+ return value
502
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
503
+
504
+
505
+ def _find_close(text: str, start: int) -> int:
506
+ close = text.find("}", start + 1)
507
+ if close == -1:
508
+ raise UsageFault("unclosed '{' in prompt — did you mean '{{' for a literal brace?")
509
+ return close
510
+
511
+
512
+ def _split_top_level(inner: str, raw: str) -> list[str]:
513
+ """Split on commas OUTSIDE parentheses — enum(a, b) survives whole (D37).
514
+
515
+ Unbalanced parens are a loud error: a stray "(" would otherwise swallow
516
+ every following comma (and the fields behind them) into one description."""
517
+ parts: list[str] = []
518
+ depth = 0
519
+ current = ""
520
+ for char in inner:
521
+ if char == "(":
522
+ depth += 1
523
+ elif char == ")":
524
+ depth -= 1
525
+ if depth < 0:
526
+ raise UsageFault(
527
+ f"unbalanced parentheses in field group: {raw}\n"
528
+ " Every '(' needs a ')' — enum(a, b) is the only paren form."
529
+ )
530
+ if char == "," and depth == 0:
531
+ parts.append(current)
532
+ current = ""
533
+ else:
534
+ current += char
535
+ if depth != 0:
536
+ raise UsageFault(
537
+ f"unbalanced parentheses in field group: {raw}\n"
538
+ " Every '(' needs a ')' — enum(a, b) is the only paren form."
539
+ )
540
+ parts.append(current)
541
+ return parts
542
+
543
+
544
+ def _parse_group(
545
+ text: str, start: int, ident: re.Pattern[str], *, allow_descriptions: bool = False
546
+ ) -> tuple[BraceToken, int]:
547
+ close = _find_close(text, start)
548
+ raw = text[start : close + 1]
549
+ inner = text[start + 1 : close]
550
+ parts = tuple(part.strip() for part in _split_top_level(inner, raw))
551
+ names: list[str] = []
552
+ notes: list[str | None] = []
553
+ props: list[Mapping[str, object] | None] = []
554
+ for part in parts:
555
+ head, colon, description = part.partition(":")
556
+ head = head.strip()
557
+ if not allow_descriptions:
558
+ names.append(part)
559
+ notes.append(None)
560
+ props.append(None)
561
+ continue
562
+ name, _space, type_text = head.partition(" ")
563
+ type_text = type_text.strip()
564
+ prop: Mapping[str, object] | None = None
565
+ if type_text:
566
+ # D37: an inline type, straight from the --schema-from vocabulary
567
+ from smartpipe.engine.schema_dsl import TYPE_MENU, type_token
568
+
569
+ prop = type_token(type_text)
570
+ if prop is None:
571
+ if type_text.startswith("enum(") and type_text.endswith(")"):
572
+ raise UsageFault(
573
+ f"{{{part}}} — enum needs at least one value\n"
574
+ " Example: status enum(paid, unpaid)"
575
+ )
576
+ raise UsageFault(
577
+ f"{{{part}}} — {type_text!r} isn't a type\n"
578
+ f" Types: {TYPE_MENU}\n"
579
+ " Constraints (>=, lengths, optional) live in --schema-from or --schema."
580
+ )
581
+ if colon:
582
+ description = description.strip()
583
+ if not description:
584
+ raise UsageFault(
585
+ f"{{{part}}} names field {name!r} with an empty description\n"
586
+ f" Write a description after the colon, or drop the colon: {{{name}}}"
587
+ )
588
+ names.append(name)
589
+ notes.append(description)
590
+ else:
591
+ names.append(name)
592
+ notes.append(None)
593
+ props.append(prop)
594
+ if any(not ident.match(name) for name in names):
595
+ raise UsageFault(
596
+ f"invalid field group: {raw}\n"
597
+ " field names must be identifiers (letters, digits, underscores), comma-separated"
598
+ )
599
+ described = tuple(notes) if any(note is not None for note in notes) else ()
600
+ typed = tuple(props) if any(prop is not None for prop in props) else ()
601
+ return BraceToken(tuple(names), raw, described, typed), close + 1
@@ -0,0 +1,62 @@
1
+ """Similarity ranking for ``top_k`` (plan/architecture.md, spec §3.4) — pure math.
2
+
3
+ Brute-force cosine: no vector database, the embeddings live in the pipe (D16). The
4
+ zero-vector guard matters — a cosine with a zero vector is defined as 0, never NaN,
5
+ so an empty or degenerate embedding can't poison the ranking.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Sequence
15
+
16
+ __all__ = ["board_insert", "cosine", "rank", "select", "unit_score"]
17
+
18
+
19
+ def cosine(a: Sequence[float], b: Sequence[float]) -> float:
20
+ dot = sum(x * y for x, y in zip(a, b, strict=False))
21
+ norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
22
+ return dot / norm if norm else 0.0
23
+
24
+
25
+ def unit_score(cos: float) -> float:
26
+ """Map cosine [-1, 1] to a [0, 1] score (spec §3.4)."""
27
+ return max(0.0, min(1.0, (1.0 + cos) / 2.0))
28
+
29
+
30
+ def rank(
31
+ query: Sequence[float], vectors: Sequence[Sequence[float]]
32
+ ) -> tuple[tuple[int, float], ...]:
33
+ """Score every vector against the query, sorted best-first; ties by input order."""
34
+ scored = [(index, unit_score(cosine(query, vector))) for index, vector in enumerate(vectors)]
35
+ scored.sort(key=lambda pair: (-pair[1], pair[0]))
36
+ return tuple(scored)
37
+
38
+
39
+ def select(
40
+ ranked: Sequence[tuple[int, float]], *, k: int | None, threshold: float | None
41
+ ) -> tuple[tuple[int, float], ...]:
42
+ """Apply a similarity threshold and/or a top-K limit (spec §3.4)."""
43
+ kept = ranked if threshold is None else [p for p in ranked if p[1] >= threshold]
44
+ limited = kept if k is None else kept[:k]
45
+ return tuple(limited)
46
+
47
+
48
+ BoardEntry = tuple[float, int] # (score, arrival) — payloads live with the caller
49
+
50
+
51
+ def board_insert(
52
+ board: tuple[BoardEntry, ...], score: float, arrival: int, k: int
53
+ ) -> tuple[tuple[BoardEntry, ...], bool]:
54
+ """Insert a candidate into a top-K board (stage-08 §4.3, pure).
55
+
56
+ Rank by score descending, ties broken by earlier arrival. Returns the new
57
+ board and whether membership/order changed — the leaderboard repaints or
58
+ emits a snapshot only on change.
59
+ """
60
+ merged = sorted((*board, (score, arrival)), key=lambda e: (-e[0], e[1]))[:k]
61
+ new_board = tuple(merged)
62
+ return new_board, new_board != board