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,428 @@
1
+ """The ``reduce`` verb: synthesize many items into one (spec §3.5).
2
+
3
+ The headline feature is invisible recursion: when the input exceeds the model's
4
+ context, smartpipe chunks it, summarizes each chunk into dense notes, and recurses on
5
+ the notes — no flags, no strategy to choose. ``--group-by`` runs one reduction per
6
+ group; ``--schema`` shapes the final output; ``--verbose`` shows the chunking tree.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Protocol
14
+
15
+ from smartpipe.core.errors import ExitCode, ItemError, UsageFault
16
+ from smartpipe.engine.chunking import (
17
+ budget_for,
18
+ chunk_indices,
19
+ estimate_tokens,
20
+ fits_in_one,
21
+ halve,
22
+ is_context_overflow,
23
+ )
24
+ from smartpipe.engine.prompts import (
25
+ build_reduce_final,
26
+ build_reduce_intermediate,
27
+ build_repair_request,
28
+ has_brace,
29
+ interpolate_fields,
30
+ parse_prompt,
31
+ reject_comma_groups,
32
+ to_instruction,
33
+ )
34
+ from smartpipe.engine.schema import load_schema, validate_and_coerce
35
+ from smartpipe.engine.windows import Window, WindowBuffer, WindowPolicy
36
+ from smartpipe.io import diagnostics, readers
37
+ from smartpipe.io.inputs import STDIN
38
+ from smartpipe.io.items import describe_source
39
+ from smartpipe.io.writers import OutputFormat
40
+ from smartpipe.verbs.common import (
41
+ ensure_text,
42
+ interrupted_exit_code,
43
+ outcome_exit_code,
44
+ resolve_schema,
45
+ )
46
+ from smartpipe.verbs.convert import make_converter
47
+
48
+ if TYPE_CHECKING:
49
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
50
+ from pathlib import Path
51
+ from typing import TextIO
52
+
53
+ from smartpipe.engine.prompts import Token
54
+ from smartpipe.io.inputs import InputSpec
55
+ from smartpipe.io.items import Item
56
+ from smartpipe.io.writers import ResultWriter
57
+ from smartpipe.models.base import ChatModel, ModelRef
58
+ from smartpipe.models.stt import RemoteTranscriber
59
+
60
+ __all__ = ["ReduceContext", "ReduceRequest", "Reducer", "run_reduce"]
61
+
62
+ _OVERHEAD_TOKENS = 300 # reserve room for the prompt template + response
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class ReduceRequest:
67
+ prompt: str
68
+ schema_path: Path | None
69
+ group_by: str | None
70
+ model_flag: str | None
71
+ concurrency_flag: int | None
72
+ verbose: bool
73
+ input: InputSpec = STDIN
74
+ window: int | None = None # --window N: stream mode, one reduce per window
75
+ every: int | None = None # --every M: sliding stride (default: tumbling)
76
+ fields: tuple[str, ...] | None = None # --fields: project structured output
77
+ schema_dsl: str | None = None # --schema-from (rung 3, D22)
78
+ allow_captions: bool = False # cloud conversions opt-in (D33)
79
+
80
+
81
+ class ReduceContext(Protocol):
82
+ def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
83
+ async def chat_model(self, flag: str | None = None) -> ChatModel: ...
84
+ async def context_window(self, ref: ModelRef) -> int | None: ...
85
+ def concurrency(self, flag: int | None = None) -> int: ...
86
+ def writer(
87
+ self,
88
+ output_flag: OutputFormat,
89
+ *,
90
+ structured: bool,
91
+ stdout: TextIO,
92
+ fields: tuple[str, ...] | None = None,
93
+ ) -> ResultWriter: ...
94
+
95
+
96
+ async def run_reduce(
97
+ request: ReduceRequest,
98
+ context: ReduceContext,
99
+ *,
100
+ stdin: TextIO,
101
+ stdout: TextIO,
102
+ stop: asyncio.Event | None = None,
103
+ ) -> ExitCode:
104
+ tokens = parse_prompt(request.prompt)
105
+ reject_comma_groups(tokens)
106
+ if has_brace(tokens) and request.group_by is None:
107
+ raise UsageFault(
108
+ "field references like {x} in reduce only work with --group-by "
109
+ "(where {field} means the group's value)"
110
+ )
111
+ schema = resolve_schema(request.schema_path, request.schema_dsl, loader=load_schema)
112
+ if request.every is not None and request.window is None:
113
+ raise UsageFault(
114
+ "--every only makes sense with --window\n"
115
+ " --window N summarizes every N lines; --every M makes those windows slide.\n"
116
+ ' Example: tail -f app.log | smartpipe reduce --window 100 --every 20 "error trend?"'
117
+ )
118
+ if request.window is not None:
119
+ return await _run_windowed(request, tokens, schema, context, stdin, stdout, stop)
120
+ items_iter, _total = readers.resolve_items(request.input, stdin)
121
+ collected = [item async for item in items_iter] # whole-set verbs need everything
122
+ items: list[Item] = []
123
+ media_skipped = 0
124
+ log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
125
+ model = await context.chat_model(request.model_flag)
126
+ converter = make_converter(
127
+ model, allow_paid=request.allow_captions, log=log, stt=context.remote_transcriber(model.ref)
128
+ )
129
+ for candidate in collected:
130
+ try:
131
+ items.append(await ensure_text(candidate, log=log, converter=converter))
132
+ except ItemError as exc:
133
+ diagnostics.warn(f"skipped: {describe_source(candidate.source)} ({exc})")
134
+ media_skipped += 1
135
+ concurrency = context.concurrency(request.concurrency_flag)
136
+ structured = schema is not None
137
+ writer = context.writer(
138
+ OutputFormat.AUTO, structured=structured, stdout=stdout, fields=request.fields
139
+ )
140
+
141
+ if not items:
142
+ return ExitCode.OK
143
+
144
+ reducer = Reducer(
145
+ model=model,
146
+ budget=budget_for(model.ref.provider, prompt_overhead=_OVERHEAD_TOKENS),
147
+ concurrency=concurrency,
148
+ verbose=request.verbose,
149
+ window_budget=_window_budget(context, model),
150
+ )
151
+ try:
152
+ if request.group_by is not None:
153
+ await _run_grouped(reducer, request, tokens, schema, items, writer)
154
+ else:
155
+ await _run_single(reducer, tokens, schema, items, writer)
156
+ finally:
157
+ writer.flush()
158
+ log.finish()
159
+ if reducer.produced == 0:
160
+ return ExitCode.ALL_FAILED
161
+ return ExitCode.PARTIAL if (reducer.skipped or media_skipped) else ExitCode.OK
162
+
163
+
164
+ async def _run_single(
165
+ reducer: Reducer,
166
+ tokens: tuple[Token, ...],
167
+ schema: Mapping[str, object] | None,
168
+ items: list[Item],
169
+ writer: ResultWriter,
170
+ ) -> None:
171
+ instruction = to_instruction(tokens)
172
+ try:
173
+ result = await reducer.reduce(instruction, schema, [item.text for item in items])
174
+ except ItemError as exc:
175
+ diagnostics.warn(f"reduce failed: {exc}")
176
+ return
177
+ _emit(writer, structured=schema is not None, result=result)
178
+ reducer.produced += 1
179
+
180
+
181
+ async def _run_windowed(
182
+ request: ReduceRequest,
183
+ tokens: tuple[Token, ...],
184
+ schema: Mapping[str, object] | None,
185
+ context: ReduceContext,
186
+ stdin: TextIO,
187
+ stdout: TextIO,
188
+ stop: asyncio.Event | None,
189
+ ) -> ExitCode:
190
+ """Stream mode (stage-08 §4.2): one reduce per window, emitted as it lands;
191
+ the trailing partial window is flushed so Ctrl+C never discards buffered lines."""
192
+ assert request.window is not None
193
+ if request.group_by is not None:
194
+ raise UsageFault(
195
+ "--window can't combine with --group-by (windows and groups don't compose)"
196
+ )
197
+ if request.input.patterns or request.input.from_files:
198
+ raise UsageFault(
199
+ "reduce --window reads a stream from stdin — it can't combine with --in\n"
200
+ " File inputs are a finite batch. Drop --window, or pipe the stream in."
201
+ )
202
+ try:
203
+ policy = WindowPolicy(size=request.window, every=request.every or request.window)
204
+ except ValueError as exc:
205
+ raise UsageFault(str(exc)) from exc
206
+
207
+ model = await context.chat_model(request.model_flag)
208
+ writer = context.writer(
209
+ OutputFormat.AUTO, structured=True, stdout=stdout, fields=request.fields
210
+ )
211
+ instruction = to_instruction(tokens)
212
+ reducer = Reducer(
213
+ model=model,
214
+ budget=budget_for(model.ref.provider, prompt_overhead=_OVERHEAD_TOKENS),
215
+ concurrency=context.concurrency(request.concurrency_flag),
216
+ verbose=request.verbose,
217
+ window_budget=_window_budget(context, model),
218
+ )
219
+ log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
220
+ converter = make_converter(
221
+ model, allow_paid=request.allow_captions, log=log, stt=context.remote_transcriber(model.ref)
222
+ )
223
+ buffer: WindowBuffer[str] = WindowBuffer(policy)
224
+ produced = 0
225
+ failed = 0
226
+
227
+ async def emit(window: Window[str]) -> None:
228
+ nonlocal produced, failed
229
+ try:
230
+ result = await reducer.reduce(instruction, schema, list(window.items))
231
+ except ItemError as exc:
232
+ diagnostics.warn(f"skipped: window ending at line {window.end_index} ({exc})")
233
+ failed += 1
234
+ return
235
+ record: dict[str, object] = {"window_end": window.end_index, "result": result}
236
+ if window.partial:
237
+ record["partial"] = True
238
+ writer.write_record(record)
239
+ produced += 1
240
+
241
+ items_iter, _total = readers.resolve_items(request.input, stdin, stop=stop)
242
+ try:
243
+ async for item in items_iter:
244
+ if item.media:
245
+ try:
246
+ item = await ensure_text(item, log=log, converter=converter)
247
+ except ItemError as exc:
248
+ diagnostics.warn(f"skipped: {describe_source(item.source)} ({exc})")
249
+ failed += 1
250
+ continue
251
+ window = buffer.push(item.text)
252
+ if window is not None:
253
+ await emit(window)
254
+ tail = buffer.flush()
255
+ if tail is not None:
256
+ await emit(tail)
257
+ finally:
258
+ writer.flush()
259
+ log.finish()
260
+ if stop is not None and stop.is_set():
261
+ diagnostics.interrupted_summary(processed=produced, skipped=failed)
262
+ return interrupted_exit_code(done=produced, skipped=failed)
263
+ return outcome_exit_code(done=produced, skipped=failed)
264
+
265
+
266
+ async def _run_grouped(
267
+ reducer: Reducer,
268
+ request: ReduceRequest,
269
+ tokens: tuple[Token, ...],
270
+ schema: Mapping[str, object] | None,
271
+ items: list[Item],
272
+ writer: ResultWriter,
273
+ ) -> None:
274
+ assert request.group_by is not None
275
+ for value, group_items in _group(items, request.group_by, reducer):
276
+ instruction = interpolate_fields(tokens, {request.group_by: value})
277
+ try:
278
+ result = await reducer.reduce(instruction, schema, [item.text for item in group_items])
279
+ except ItemError as exc:
280
+ diagnostics.warn(f"reduce failed for group {value!r}: {exc}")
281
+ reducer.skipped += 1
282
+ continue
283
+ writer.write_record({"group": value, "result": result})
284
+ reducer.produced += 1
285
+
286
+
287
+ def _group(items: list[Item], field_name: str, reducer: Reducer) -> list[tuple[object, list[Item]]]:
288
+ order: list[str] = []
289
+ groups: dict[str, tuple[object, list[Item]]] = {}
290
+ for item in items:
291
+ if item.data is None or field_name not in item.data:
292
+ diagnostics.warn(f"skipped: {describe_source(item.source)} (no field '{field_name}')")
293
+ reducer.skipped += 1
294
+ continue
295
+ value = item.data[field_name]
296
+ key = value if isinstance(value, str) else repr(value)
297
+ if key not in groups:
298
+ groups[key] = (value, [])
299
+ order.append(key)
300
+ groups[key][1].append(item)
301
+ return [groups[key] for key in order]
302
+
303
+
304
+ def _emit(writer: ResultWriter, *, structured: bool, result: str | Mapping[str, object]) -> None:
305
+ if structured and not isinstance(result, str):
306
+ writer.write_record(result)
307
+ else:
308
+ writer.write_text(result if isinstance(result, str) else str(result))
309
+
310
+
311
+ def _window_budget(context: ReduceContext, model: ChatModel) -> Callable[[], Awaitable[int | None]]:
312
+ async def refreshed() -> int | None:
313
+ window = await context.context_window(model.ref)
314
+ if window is None:
315
+ return None
316
+ return budget_for(model.ref.provider, prompt_overhead=_OVERHEAD_TOKENS, window=window)
317
+
318
+ return refreshed
319
+
320
+
321
+ @dataclass
322
+ class Reducer:
323
+ model: ChatModel
324
+ budget: int
325
+ concurrency: int
326
+ verbose: bool
327
+ skipped: int = 0
328
+ produced: int = 0
329
+ bisection_noted: bool = False
330
+ window_budget: Callable[[], Awaitable[int | None]] | None = None
331
+ probed: bool = False
332
+
333
+ async def reduce(
334
+ self, instruction: str, schema: Mapping[str, object] | None, texts: Sequence[str]
335
+ ) -> str | Mapping[str, object]:
336
+ trace = [len(texts)]
337
+ current = list(texts)
338
+ semaphore = asyncio.Semaphore(self.concurrency)
339
+ await self._widen_if_possible(current)
340
+ while True:
341
+ while not fits_in_one([estimate_tokens(t) for t in current], self.budget):
342
+ chunks = chunk_indices([estimate_tokens(t) for t in current], self.budget)
343
+ trace.append(len(chunks))
344
+ current = await self._reduce_level(instruction, current, chunks, semaphore)
345
+ if not current:
346
+ raise ItemError("every chunk failed to reduce")
347
+ try:
348
+ if len(trace) > 1:
349
+ trace.append(1)
350
+ if self.verbose:
351
+ diagnostics.note(_trace_line(trace))
352
+ return await self._final(instruction, schema, current)
353
+ except ItemError as exc:
354
+ if not (is_context_overflow(str(exc)) and len(current) > 1):
355
+ raise
356
+ # the synthesis call itself overflowed: collapse one more level
357
+ self._note_bisection()
358
+ first, second = halve(tuple(range(len(current))))
359
+ current = await self._reduce_level(instruction, current, (first, second), semaphore)
360
+ if not current:
361
+ raise ItemError("every chunk failed to reduce") from exc
362
+
363
+ async def _reduce_level(
364
+ self,
365
+ goal: str,
366
+ texts: list[str],
367
+ chunks: tuple[tuple[int, ...], ...],
368
+ semaphore: asyncio.Semaphore,
369
+ ) -> list[str]:
370
+ async def reduce_chunk(chunk: tuple[int, ...]) -> list[str]:
371
+ request = build_reduce_intermediate(goal, [texts[i] for i in chunk])
372
+ try:
373
+ async with semaphore: # released before any bisection recursion
374
+ return [await self.model.complete(request)]
375
+ except ItemError as exc:
376
+ if is_context_overflow(str(exc)) and len(chunk) > 1:
377
+ # D26: the wire said this chunk is too big — the estimate
378
+ # lied, so split at item boundaries and retry both halves
379
+ self._note_bisection()
380
+ first, second = halve(chunk)
381
+ return [*await reduce_chunk(first), *await reduce_chunk(second)]
382
+ assert chunk, "chunk_indices never yields an empty chunk"
383
+ diagnostics.warn(
384
+ f"skipped: chunk over items {chunk[0] + 1}-{chunk[-1] + 1} ({exc})"
385
+ )
386
+ self.skipped += 1
387
+ return []
388
+
389
+ tasks = [asyncio.create_task(reduce_chunk(chunk)) for chunk in chunks]
390
+ notes = [note for task in tasks for note in await task] # awaited in order
391
+ return notes
392
+
393
+ async def _widen_if_possible(self, texts: list[str]) -> None:
394
+ """D26 layer 1: the table budget looks too small — ask the provider for
395
+ the real window, once. A bigger true window means fewer (or no) levels."""
396
+ if self.window_budget is None or self.probed:
397
+ return
398
+ if fits_in_one([estimate_tokens(t) for t in texts], self.budget):
399
+ return
400
+ self.probed = True
401
+ refreshed = await self.window_budget()
402
+ if refreshed is not None and refreshed > self.budget:
403
+ self.budget = refreshed
404
+
405
+ def _note_bisection(self) -> None:
406
+ if not self.bisection_noted:
407
+ self.bisection_noted = True
408
+ diagnostics.note(
409
+ "a chunk overflowed the model's window — splitting further and retrying"
410
+ )
411
+
412
+ async def _final(
413
+ self, instruction: str, schema: Mapping[str, object] | None, texts: list[str]
414
+ ) -> str | Mapping[str, object]:
415
+ request = build_reduce_final(instruction, texts, schema)
416
+ reply = await self.model.complete(request)
417
+ if schema is None:
418
+ return reply.strip()
419
+ try:
420
+ return validate_and_coerce(reply, schema)
421
+ except ItemError as first_error:
422
+ repair = build_repair_request(request, bad_reply=reply, error=str(first_error))
423
+ return validate_and_coerce(await self.model.complete(repair), schema)
424
+
425
+
426
+ def _trace_line(trace: list[int]) -> str:
427
+ tail = "".join(f" → {count}" for count in trace[2:])
428
+ return f"reduce: {trace[0]:,} items → {trace[1]} chunks{tail}"
@@ -0,0 +1,52 @@
1
+ """The ``sample`` verb: seeded representative subsets (D38/08, KQL ``sample``).
2
+
3
+ Deterministic BY DEFAULT (seed 0): the same input yields the same sample with
4
+ no flags, so prompt comparisons compare prompts, and P10's methods section
5
+ can cite the sample. Reservoir sampling: one pass, constant memory, free.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ from smartpipe.core.errors import ExitCode, UsageFault
15
+ from smartpipe.io import diagnostics
16
+
17
+ if TYPE_CHECKING:
18
+ from typing import TextIO
19
+
20
+ __all__ = ["SampleRequest", "run_sample"]
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class SampleRequest:
25
+ count: int
26
+ seed: int = 0
27
+
28
+
29
+ def run_sample(request: SampleRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
30
+ if request.count < 1:
31
+ raise UsageFault("sample needs a positive count")
32
+ rng = random.Random(request.seed) # local instance — never the global RNG
33
+ reservoir: list[tuple[int, str]] = []
34
+ seen = 0
35
+ for line in stdin:
36
+ if not line.strip():
37
+ continue
38
+ raw = line.removesuffix("\n").removesuffix("\r")
39
+ if seen < request.count:
40
+ reservoir.append((seen, raw))
41
+ else:
42
+ slot = rng.randint(0, seen)
43
+ if slot < request.count:
44
+ reservoir[slot] = (seen, raw)
45
+ seen += 1
46
+ for _index, raw in sorted(reservoir): # input order preserved
47
+ stdout.write(raw + "\n")
48
+ if seen <= request.count:
49
+ diagnostics.note(f"sample: input had {seen:,} rows ≤ {request.count:,} — all kept")
50
+ else:
51
+ diagnostics.note(f"sample: {len(reservoir):,} of {seen:,} (seed {request.seed})")
52
+ return ExitCode.OK
@@ -0,0 +1,63 @@
1
+ """The ``sort`` verb: order NDJSON by a field (D38/10, KQL ``sort by``).
2
+
3
+ Free, whole-set (inherently), stable, passthrough-verbatim. Missing-field
4
+ rows always land last — in both directions — and are disclosed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING
11
+
12
+ from smartpipe.core.errors import ExitCode
13
+ from smartpipe.io import diagnostics
14
+ from smartpipe.io.items import item_from_line
15
+
16
+ if TYPE_CHECKING:
17
+ from typing import TextIO
18
+
19
+ __all__ = ["SortRequest", "run_sort"]
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class SortRequest:
24
+ by: str
25
+ descending: bool = False
26
+
27
+
28
+ def run_sort(request: SortRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
29
+ keyed: list[tuple[tuple[int, float, str], str]] = []
30
+ missing: list[str] = []
31
+ for index, line in enumerate(stdin):
32
+ if not line.strip():
33
+ continue
34
+ item = item_from_line(line, index)
35
+ value = item.data.get(request.by) if item.data is not None else None
36
+ if value is None:
37
+ missing.append(item.raw)
38
+ continue
39
+ keyed.append((_key(value, descending=request.descending), item.raw))
40
+ keyed.sort(key=lambda pair: pair[0]) # stable: ties keep input order
41
+ for _sort_key, raw in keyed:
42
+ stdout.write(raw + "\n")
43
+ for raw in missing: # always last, regardless of direction
44
+ stdout.write(raw + "\n")
45
+ if missing:
46
+ diagnostics.note(f"sort: {len(missing):,} rows missing '{request.by}' placed last")
47
+ return ExitCode.OK
48
+
49
+
50
+ def _key(value: object, *, descending: bool) -> tuple[int, float, str]:
51
+ """Numbers first (numerically), then strings (lexically), then the rest
52
+ (stringified). Descending flips within each band — bands never mix."""
53
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
54
+ text = value if isinstance(value, str) else str(value)
55
+ band = 1 if isinstance(value, str) else 2
56
+ return (band, 0.0, _flip_text(text) if descending else text)
57
+ number = float(value)
58
+ return (0, -number if descending else number, "")
59
+
60
+
61
+ def _flip_text(text: str) -> str:
62
+ # descending lexicographic via per-character complement (stable, pure)
63
+ return "".join(chr(0x10FFFF - ord(char)) for char in text)