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,413 @@
1
+ """The ``join`` verb (D21): match stdin against a second input, semantically.
2
+
3
+ Embed → block → judge: the right side (``--right``) is finite — read whole,
4
+ embedded once (chunked), held as an in-memory index; each left item embeds,
5
+ blocks to its top-K nearest candidates, and only those pairs reach the chat
6
+ model with the filter-style verdict schema. N·K calls, never N·M.
7
+
8
+ Fail-before-spend order (D18): flags → grammar → right file exists/parses/
9
+ non-empty → right side fully embedded → cost preview → the first judge call.
10
+ A bad right side costs zero chat calls.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from dataclasses import dataclass, replace
17
+ from typing import TYPE_CHECKING, Protocol
18
+
19
+ from smartpipe.core.errors import ExitCode, ItemError, TooManyFailures, UsageFault
20
+ from smartpipe.engine.blocking import RightIndex, build_index, candidates
21
+ from smartpipe.engine.chunking import estimate_tokens, mean_pool, split_text
22
+ from smartpipe.engine.prompts import (
23
+ JUDGE_SCHEMA,
24
+ build_judge_request,
25
+ build_repair_request,
26
+ parse_join_predicate,
27
+ )
28
+ from smartpipe.engine.ranking import rank
29
+ from smartpipe.engine.runner import (
30
+ Done,
31
+ FailurePolicy,
32
+ run_ordered,
33
+ should_halt,
34
+ should_halt_consecutive,
35
+ )
36
+ from smartpipe.engine.schema import validate_and_coerce
37
+ from smartpipe.io import diagnostics, readers
38
+ from smartpipe.io.inputs import STDIN
39
+ from smartpipe.io.items import ItemSource, describe_source, item_from_line
40
+ from smartpipe.io.progress import make_stderr_spinner
41
+ from smartpipe.verbs.common import (
42
+ embed_budget,
43
+ embed_in_batches,
44
+ ensure_text,
45
+ interrupted_exit_code,
46
+ outcome_exit_code,
47
+ )
48
+ from smartpipe.verbs.convert import Converter, make_converter
49
+
50
+ if TYPE_CHECKING:
51
+ from pathlib import Path
52
+ from typing import TextIO
53
+
54
+ from smartpipe.engine.prompts import Token
55
+ from smartpipe.io.inputs import InputSpec
56
+ from smartpipe.io.items import Item
57
+ from smartpipe.io.writers import OutputFormat, ResultWriter
58
+ from smartpipe.models.base import ChatModel, EmbeddingModel, ModelRef
59
+ from smartpipe.models.stt import RemoteTranscriber
60
+
61
+ __all__ = ["JoinContext", "JoinRequest", "PairBook", "run_join"]
62
+
63
+ _PREVIEW_THRESHOLD = 200 # estimated judge calls before the cost line appears (D21)
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class JoinRequest:
68
+ predicate: str
69
+ right: Path
70
+ k: int
71
+ threshold: float | None
72
+ model_flag: str | None
73
+ embed_model_flag: str | None
74
+ concurrency_flag: int | None
75
+ output: OutputFormat
76
+ input: InputSpec = STDIN
77
+ fields: tuple[str, ...] | None = None
78
+ unmatched: Path | None = None # write zero-match left items here, verbatim
79
+ allow_captions: bool = False # cloud conversions opt-in (D33)
80
+ kind: str = "inner" # inner | leftouter | anti (D38/11)
81
+
82
+
83
+ class JoinContext(Protocol):
84
+ def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
85
+
86
+ """The first verb that needs BOTH models — the container already has both."""
87
+
88
+ async def chat_model(self, flag: str | None = None) -> ChatModel: ...
89
+ async def embedding_model(self, flag: str | None = None) -> EmbeddingModel: ...
90
+ def concurrency(self, flag: int | None = None) -> int: ...
91
+ def writer(
92
+ self,
93
+ output_flag: OutputFormat,
94
+ *,
95
+ structured: bool,
96
+ stdout: TextIO,
97
+ fields: tuple[str, ...] | None = None,
98
+ ) -> ResultWriter: ...
99
+
100
+
101
+ @dataclass(slots=True)
102
+ class PairBook:
103
+ """Judge-call accounting (D21: the halt policies count judge calls)."""
104
+
105
+ policy: FailurePolicy
106
+ right_name: str
107
+ judged: int = 0
108
+ skipped: int = 0
109
+ consecutive: int = 0
110
+ succeeded: bool = False
111
+
112
+ def ok(self) -> None:
113
+ self.judged += 1
114
+ self.consecutive = 0
115
+ self.succeeded = True
116
+
117
+ def skip(self, left: Item, right_position: int, reason: str) -> None:
118
+ self.judged += 1
119
+ self.skipped += 1
120
+ self.consecutive += 1
121
+ diagnostics.warn(
122
+ f"skipped: {describe_source(left.source)} × " # noqa: RUF001 — pinned pair mark
123
+ f"{self.right_name} line {right_position + 1} ({reason})"
124
+ )
125
+ if should_halt(self.policy, total=self.judged, skipped=self.skipped):
126
+ raise TooManyFailures(self.skipped, self.judged, reason)
127
+ if should_halt_consecutive(
128
+ self.policy, succeeded=self.succeeded, consecutive=self.consecutive
129
+ ):
130
+ raise TooManyFailures(self.skipped, self.judged, reason)
131
+
132
+
133
+ async def run_join(
134
+ request: JoinRequest,
135
+ context: JoinContext,
136
+ *,
137
+ stdin: TextIO,
138
+ stdout: TextIO,
139
+ stop: asyncio.Event | None = None,
140
+ ) -> ExitCode:
141
+ tokens = parse_join_predicate(request.predicate) # UsageFault on bad grammar
142
+ if request.k < 1:
143
+ raise UsageFault(f"--k must be >= 1, got {request.k}")
144
+ right_items = _load_right(request.right)
145
+ embed_model = await context.embedding_model(request.embed_model_flag)
146
+ log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
147
+ kept_right, index, right_chunks = await _index_right(
148
+ embed_model, right_items, request.right.name, log
149
+ )
150
+ chat = await context.chat_model(request.model_flag)
151
+ concurrency = context.concurrency(request.concurrency_flag)
152
+ if request.kind not in ("inner", "leftouter", "anti"):
153
+ raise UsageFault("--kind takes inner, leftouter, or anti")
154
+ if request.kind == "anti" and request.unmatched is not None:
155
+ raise UsageFault(
156
+ "--unmatched with --kind anti is redundant — anti already puts unmatched rows on stdout"
157
+ )
158
+ writer = context.writer(
159
+ request.output,
160
+ structured=request.kind != "anti", # anti emits left rows verbatim
161
+ stdout=stdout,
162
+ fields=request.fields,
163
+ )
164
+ items_iter, total = readers.resolve_items(request.input, stdin, stop=stop)
165
+ preview_cost(total, request.k, len(index))
166
+
167
+ converter = make_converter(
168
+ chat, allow_paid=request.allow_captions, log=log, stt=context.remote_transcriber(chat.ref)
169
+ )
170
+ book = PairBook(policy=FailurePolicy(), right_name=request.right.name)
171
+ spinner = make_stderr_spinner()
172
+ spinner.start(total=total)
173
+
174
+ async def worker(item: Item) -> tuple[Item, tuple[tuple[int, float], ...]]:
175
+ matches = await _join_one(
176
+ item,
177
+ log=log,
178
+ converter=converter,
179
+ embed_model=embed_model,
180
+ chat=chat,
181
+ tokens=tokens,
182
+ index=index,
183
+ kept_right=kept_right,
184
+ right_chunks=right_chunks,
185
+ request=request,
186
+ book=book,
187
+ stop=stop,
188
+ )
189
+ return item, matches
190
+
191
+ done = 0
192
+ skipped = 0
193
+ outcomes = run_ordered(
194
+ items_iter, worker, concurrency=concurrency, failure_policy=FailurePolicy(), stop=stop
195
+ )
196
+ matched_pairs = 0
197
+ unmatched_count = 0
198
+ unmatched_sink = (
199
+ request.unmatched.open("w", encoding="utf-8") if request.unmatched is not None else None
200
+ )
201
+ try:
202
+ async for outcome in outcomes:
203
+ if isinstance(outcome, Done):
204
+ left, matches = outcome.value
205
+ if request.kind != "anti":
206
+ for position, score in matches:
207
+ writer.write_record(
208
+ {
209
+ "left": _payload(left),
210
+ "right": _payload(kept_right[position]),
211
+ "_score": round(score, 4),
212
+ }
213
+ )
214
+ matched_pairs += len(matches)
215
+ if not matches:
216
+ unmatched_count += 1
217
+ match request.kind:
218
+ case "anti": # the unmatched row IS the finding — verbatim
219
+ writer.write_text(left.raw)
220
+ case "leftouter": # every left row, match or not
221
+ writer.write_record({"left": _payload(left), "right": None})
222
+ case _:
223
+ if unmatched_sink is not None:
224
+ unmatched_sink.write(left.raw + "\n")
225
+ done += 1
226
+ else: # Skipped — the left item itself failed (image, embed error, …)
227
+ diagnostics.warn(f"skipped: {describe_source(outcome.source)} ({outcome.reason})")
228
+ skipped += 1
229
+ spinner.advance()
230
+ finally:
231
+ spinner.finish()
232
+ writer.flush()
233
+ log.finish()
234
+ if unmatched_sink is not None:
235
+ unmatched_sink.close()
236
+ if request.unmatched is not None:
237
+ diagnostics.note(
238
+ f"join: {matched_pairs} matched · {unmatched_count} unmatched → "
239
+ f"{request.unmatched.name}"
240
+ )
241
+ elif request.kind != "inner":
242
+ diagnostics.note(f"join: {matched_pairs} matched · {unmatched_count} unmatched")
243
+ if stop is not None and stop.is_set():
244
+ diagnostics.interrupted_summary(processed=done, skipped=skipped + book.skipped)
245
+ return interrupted_exit_code(done=done, skipped=skipped + book.skipped)
246
+ return outcome_exit_code(done=done, skipped=skipped + book.skipped)
247
+
248
+
249
+ async def _join_one(
250
+ item: Item,
251
+ *,
252
+ log: diagnostics.DegradationLog,
253
+ converter: Converter,
254
+ embed_model: EmbeddingModel,
255
+ chat: ChatModel,
256
+ tokens: tuple[Token, ...],
257
+ index: RightIndex,
258
+ kept_right: list[Item],
259
+ right_chunks: dict[int, ChunkedSide],
260
+ request: JoinRequest,
261
+ book: PairBook,
262
+ stop: asyncio.Event | None,
263
+ ) -> tuple[tuple[int, float], ...]:
264
+ item = await ensure_text(item, log=log, converter=converter) # D33 ladder
265
+ budget = embed_budget(embed_model.ref.provider)
266
+ left_side: ChunkedSide | None = None
267
+ if estimate_tokens(item.text) > budget:
268
+ vector, left_side = await _chunked_side(embed_model, item.text, budget)
269
+ log.note(
270
+ describe_source(item.source),
271
+ "oversized → best-chunk judge",
272
+ f"{len(left_side.chunks)} chunks pooled for blocking",
273
+ )
274
+ else:
275
+ vector = (await embed_model.embed([item.text]))[0]
276
+ matches: list[tuple[int, float]] = []
277
+ for position, score in candidates(vector, index, k=request.k, threshold=request.threshold):
278
+ if stop is not None and stop.is_set():
279
+ break
280
+ right_item = kept_right[position]
281
+ left_for_judge = item
282
+ if left_side is not None:
283
+ left_for_judge = replace(item, text=left_side.best_text(index.vectors[position]))
284
+ chunked_right = right_chunks.get(position)
285
+ if chunked_right is not None:
286
+ right_item = replace(right_item, text=chunked_right.best_text(vector))
287
+ try:
288
+ verdict = await _judge(chat, tokens, left_for_judge, right_item)
289
+ except ItemError as exc:
290
+ book.skip(item, position, str(exc))
291
+ continue
292
+ book.ok()
293
+ if verdict:
294
+ matches.append((position, score))
295
+ return tuple(matches)
296
+
297
+
298
+ def _payload(item: Item) -> dict[str, object]:
299
+ return dict(item.data) if item.data is not None else {"text": item.text}
300
+
301
+
302
+ async def _judge(chat: ChatModel, tokens: tuple[Token, ...], left: Item, right: Item) -> bool:
303
+ """One verdict with the standard single repair; ItemError = the pair skips."""
304
+ request = build_judge_request(tokens, left, right)
305
+ reply = await chat.complete(request)
306
+ try:
307
+ verdict = validate_and_coerce(reply, JUDGE_SCHEMA)
308
+ except ItemError as first_error:
309
+ repair = build_repair_request(request, bad_reply=reply, error=str(first_error))
310
+ verdict = validate_and_coerce(await chat.complete(repair), JUDGE_SCHEMA)
311
+ return verdict.get("match") is True
312
+
313
+
314
+ def _load_right(path: Path) -> list[Item]:
315
+ if str(path) == "-":
316
+ raise UsageFault(
317
+ "--right - reads nothing — stdin is join's left side\n"
318
+ " The right side is a finite file smartpipe indexes up front.\n"
319
+ ' Example: cat stream.jsonl | smartpipe join "…" --right catalog.jsonl'
320
+ )
321
+ if not path.exists():
322
+ raise UsageFault(
323
+ f"no such file: {path}\n --right needs a JSONL or plain-lines file to index."
324
+ )
325
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
326
+ items = [
327
+ replace(
328
+ item_from_line(line, index),
329
+ source=ItemSource("file", path.name, index),
330
+ )
331
+ for index, line in enumerate(lines)
332
+ ]
333
+ if not items:
334
+ raise UsageFault(
335
+ f"{path} is empty — a join against nothing is a mistake\n"
336
+ ' join needs a right side to match against. If you meant "keep nothing", '
337
+ "that's filter."
338
+ )
339
+ return items
340
+
341
+
342
+ @dataclass(frozen=True, slots=True)
343
+ class ChunkedSide:
344
+ """An oversized side's chunks + their vectors — the judge sees the best one."""
345
+
346
+ chunks: tuple[str, ...]
347
+ vectors: tuple[tuple[float, ...], ...]
348
+
349
+ def best_text(self, other_vector: tuple[float, ...]) -> str:
350
+ position, _score = rank(other_vector, self.vectors)[0]
351
+ return self.chunks[position]
352
+
353
+
354
+ async def _chunked_side(
355
+ model: EmbeddingModel, text: str, budget: int
356
+ ) -> tuple[tuple[float, ...], ChunkedSide]:
357
+ """Chunk-embed one oversized text: pooled vector for blocking, chunks kept
358
+ so the judge reads the most-relevant one (D26/W3 — no more skipping)."""
359
+ chunks = split_text(text, budget)
360
+ vectors = await model.embed(list(chunks))
361
+ return mean_pool(vectors), ChunkedSide(tuple(chunks), tuple(vectors))
362
+
363
+
364
+ async def _index_right(
365
+ model: EmbeddingModel,
366
+ items: list[Item],
367
+ right_name: str,
368
+ log: diagnostics.DegradationLog,
369
+ ) -> tuple[list[Item], RightIndex, dict[int, ChunkedSide]]:
370
+ """The build side, fully embedded before any chat spend (the preflight)."""
371
+ budget = embed_budget(model.ref.provider)
372
+ normal = [item for item in items if estimate_tokens(item.text) <= budget]
373
+ oversized = [item for item in items if estimate_tokens(item.text) > budget]
374
+ kept: list[Item] = []
375
+ vectors: list[tuple[float, ...]] = []
376
+ chunked: dict[int, ChunkedSide] = {}
377
+ async for outcome in embed_in_batches(model, normal, failure_policy=FailurePolicy()):
378
+ if isinstance(outcome, Done):
379
+ item, vector = outcome.value
380
+ kept.append(item)
381
+ vectors.append(vector)
382
+ else:
383
+ diagnostics.warn(f"skipped: {right_name} line {outcome.index + 1} ({outcome.reason})")
384
+ for item in oversized:
385
+ try:
386
+ pooled, side = await _chunked_side(model, item.text, budget)
387
+ except ItemError as exc:
388
+ diagnostics.warn(f"skipped: {right_name} line {item.source.index + 1} ({exc})")
389
+ continue
390
+ chunked[len(kept)] = side
391
+ kept.append(item)
392
+ vectors.append(pooled)
393
+ log.note(
394
+ f"{right_name} line {item.source.index + 1}",
395
+ "oversized → best-chunk judge",
396
+ f"{len(side.chunks)} chunks pooled for blocking",
397
+ )
398
+ return kept, build_index(vectors), chunked
399
+
400
+
401
+ def preview_cost(total: int | None, k: int, index_size: int) -> None:
402
+ per_item = min(k, index_size)
403
+ if total is None:
404
+ diagnostics.preview(
405
+ f"join: up to {per_item} model calls per input line (cap with --max-calls)"
406
+ )
407
+ return
408
+ estimate = total * per_item
409
+ if estimate > _PREVIEW_THRESHOLD:
410
+ diagnostics.preview(
411
+ f"join: {total:,} left items · up to {per_item} candidates each = "
412
+ f"at most {estimate:,} model calls (cap with --max-calls)"
413
+ )