sift-cli 1.0.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.
sift/distill.py ADDED
@@ -0,0 +1,670 @@
1
+ """Choosing which lines to show, without ever writing one.
2
+
3
+ This is the idea the whole tool rests on. The capture is numbered and handed to
4
+ a model with a single question -- *which numbers matter?* -- and the model
5
+ answers with numbers. Whatever else it says is thrown away without being read.
6
+ The view is then printed from the file on disk, line by line, byte for byte.
7
+
8
+ That is why `sift` is not a summariser and cannot be compared to one. A
9
+ summariser writes a sentence about your output and can be wrong about what your
10
+ output said. Nothing here writes a line. Being wrong is still possible -- the
11
+ wrong lines can be chosen -- but the cost of that mistake is a line missing from
12
+ a view, never a line that says something the command never said. And a missing
13
+ line is one `sift peek` away, because the file was never touched.
14
+
15
+ It is also why coverage is not a list. Nothing in this file knows what a stack
16
+ trace looks like in Rust, or what a Japanese error message says, or how a
17
+ language released last week reports a failure. The model knows all of that
18
+ already. There is no table here to be missing an entry.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import os
25
+ import re
26
+ import threading
27
+ from collections.abc import Iterable, Iterator
28
+ from concurrent.futures import ThreadPoolExecutor
29
+ from dataclasses import dataclass
30
+
31
+ from sift import answers, records
32
+ from sift import lines as text_lines
33
+ from sift.capture import Capture
34
+ from sift.model import Answer, Bridge
35
+ from sift.privacy import mask
36
+
37
+ # Every question asked through `select` is answered the same way, because one
38
+ # parser reads every answer. `select` appends it rather than each question ending
39
+ # with it, so a second question cannot quietly arrive with a second format for
40
+ # the reply -- and so it stays last in the prompt, after whatever the question
41
+ # and the budget had to say.
42
+ ANSWER_FORMAT = (
43
+ "\n"
44
+ "Answer with line numbers only: single numbers or ranges, separated by commas.\n"
45
+ "For example: 1, 40-47, 512\n"
46
+ "Write nothing else. Do not explain, do not quote any line, do not repeat the "
47
+ "text. Only numbers."
48
+ )
49
+
50
+ QUESTION = (
51
+ "You are given the numbered output of a command someone just ran.\n"
52
+ "Choose the lines a person debugging this would need to see: what failed, "
53
+ "what warned, what changed, the final result, and the few lines around them "
54
+ "that make those readable.\n"
55
+ "Leave out repetition, progress that only says work happened, and lines that "
56
+ "carry no information on their own.\n"
57
+ )
58
+
59
+ # What to ask when the text was a list rather than a transcript.
60
+ #
61
+ # The shape of the answer does not change -- numbers, and nothing else -- and
62
+ # neither does anything under it. What changes is what the numbers count, and
63
+ # saying so is the whole of the difference: asked "which lines matter" about a
64
+ # list, a model answers about lines that do not exist as units.
65
+ #
66
+ # The last sentence is the one a transcript does not need. A reader of a log
67
+ # wants the exceptional lines and nothing else; a reader of a list of four
68
+ # hundred records also needs to know what the ordinary ones look like, or the
69
+ # view says a list is made of nothing but its outliers.
70
+ RECORDS = (
71
+ "You are given the records of a JSON array, numbered one per record.\n"
72
+ "Choose the records someone would need to understand this list: what failed, "
73
+ "what is unusual, what marks a boundary or a change.\n"
74
+ "Leave out records that say the same thing another one already says, but keep "
75
+ "a few ordinary ones, so that what the rest look like can be seen.\n"
76
+ )
77
+
78
+ # What to ask about the part of a command that has arrived since the last look.
79
+ #
80
+ # The difference from `QUESTION` is not tone, it is what is true. There is no
81
+ # final result yet, and asking for one invites a model to nominate whichever
82
+ # line happens to be nearest the end. And the reader has already been shown
83
+ # everything before these lines, so a line that repeats what they read ten
84
+ # minutes ago is worth less here than the same line would be in a capture read
85
+ # once, at the end, by someone who was not watching.
86
+ FOLLOWING = (
87
+ "You are given the numbered output a command has produced since it was last "
88
+ "looked at. The command is still running: this is the middle of the output, "
89
+ "not the end of it, and there is no final result here to find.\n"
90
+ "Choose the lines that tell someone watching what has happened since they "
91
+ "last looked: what failed, what warned, what finished, what changed.\n"
92
+ "Leave out progress that only says work is still going on. If nothing here "
93
+ "is worth interrupting them for, choose nothing at all.\n"
94
+ )
95
+
96
+ # One ask covers this much of the transcript. Most captures fit in a single one;
97
+ # a long build is split and the answers are added together.
98
+ CHARS_PER_ASK = 120_000
99
+
100
+ # What the second pass adds to whatever question was asked the first time.
101
+ #
102
+ # Measured, and the measurement is the reason this exists. Handing a shortlist
103
+ # back with the original question produced four asks and 359 lines against a
104
+ # budget of 120: asked "which lines matter" about a list of lines that all
105
+ # matter, a model answers "all of them", and it is right. The second pass is not
106
+ # the first pass asked again. It has to say that the list is already the answer
107
+ # and is still too long, which is a different question and gets a different
108
+ # reply.
109
+ NARROWING = (
110
+ "\nThese lines are already an answer to that question, chosen out of a much "
111
+ "longer text, and there are still too many of them to show at once.\n"
112
+ "Choose which of them to keep: prefer a line that carries something none of "
113
+ "the others do over one more example of something already covered.\n"
114
+ )
115
+
116
+ # What a whole view may cost, in lines, however long the capture behind it was.
117
+ #
118
+ # The number matters less than the fact that there is one. Without it a view is
119
+ # a fraction of its capture rather than a size: measured, a 378-line test run
120
+ # came back as 5 lines, and the same tool on a 9,984-line lint run came back as
121
+ # 2,842 -- no rule broken, every line real, and 181 KB of context spent. A tool
122
+ # whose cost grows with the mess it is pointed at is least useful exactly when it
123
+ # is needed most.
124
+ # How many pieces of one capture are asked about at once.
125
+ #
126
+ # Measured: `ruff check --select ALL` over this repository is 633,877 bytes,
127
+ # which is seven questions of 120,000 characters each, and asking them one after
128
+ # another took ten minutes of waiting on a socket with the CPU idle. The pieces
129
+ # are disjoint and their answers are unioned, so asking them together changes
130
+ # how long the caller waits and nothing else.
131
+ WORKERS = 6
132
+
133
+
134
+ def workers() -> int:
135
+ """How many pieces to ask about at once, with `SIFT_WORKERS` overriding."""
136
+ written = os.environ.get("SIFT_WORKERS", "").strip()
137
+ return max(1, int(written)) if written.isdigit() else WORKERS
138
+
139
+
140
+ BUDGET = 120
141
+
142
+ # How many times an over-long answer may be handed back before its length is
143
+ # accepted. Each round costs asks, and a model that has not narrowed after three
144
+ # is not going to; the view then comes back long and says how long, which is
145
+ # worth more than a shorter view nobody can trust.
146
+ NARROW_ROUNDS = 3
147
+
148
+ # Only in the prompt. A line thousands of characters wide is nearly always
149
+ # machine noise, and the model needs its beginning to judge it, not all of it.
150
+ # What gets shown is never shortened -- that comes from the file.
151
+ PROMPT_LINE_CAP = 400
152
+
153
+ # Ranges written the way a model writes them: a hyphen, or one of the dashes a
154
+ # text generator reaches for instead (U+2013, U+2014). Escaped rather than typed
155
+ # so that a dash nobody can see in a diff cannot go missing from here.
156
+ _NUMBERS = re.compile(r"(\d+)\s*[-\u2013\u2014]\s*(\d+)|(\d+)")
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class View:
161
+ """A capture with most of it folded away, and a note of what was folded."""
162
+
163
+ handle: str
164
+ text: str
165
+ kept: int
166
+ total: int
167
+ model: str | None
168
+ asks: int
169
+ # Questions that came back with nothing. Lines they would have kept are not
170
+ # kept by anything else, so a view missing six answers out of seven reads
171
+ # exactly like a view that got all seven -- shorter, and no less confident.
172
+ # Counting them is what lets the footer tell those two apart.
173
+ unanswered: int = 0
174
+ # What `kept` and `total` are counting. A line, unless the text was a JSON
175
+ # array, in which case a line was never a unit and a record is. Carried on
176
+ # the view rather than worked out again by whoever prints it: two places
177
+ # deciding this separately is two places that can disagree about what a
178
+ # number means, and the number is the whole of what a footer says.
179
+ unit: str = "line"
180
+ # What the asking cost, in the endpoint's own tokens, summed over every ask
181
+ # this view took. Zero when no model was reached and zero when the answer
182
+ # was remembered -- both of which are true, and both of which are the point
183
+ # of counting it.
184
+ tokens: int = 0
185
+
186
+ @property
187
+ def folded(self) -> int:
188
+ return self.total - self.kept
189
+
190
+
191
+ def rows(pairs: Iterable[tuple[int, str]], cap: int = PROMPT_LINE_CAP) -> str:
192
+ """Numbered lines as the model sees them: one number, one bar, one line.
193
+
194
+ The numbers are given rather than counted, because the second pass asks
195
+ about a shortlist -- lines 12, 400 and 9,981 of a capture, with nothing
196
+ between them. They have to keep the numbers they had there, or an answer
197
+ about the shortlist would name lines of the capture nobody asked about.
198
+
199
+ This is the only place capture text is turned into something that leaves the
200
+ machine, which is why the masking happens here and nowhere else. What is
201
+ shown to the reader is rendered somewhere else entirely, from the file, and
202
+ is never touched by it.
203
+ """
204
+ out = []
205
+ for number, line in pairs:
206
+ # Masked before it is shortened, not after. Half a secret is still a
207
+ # secret, and a pattern cannot recognise the half it is shown.
208
+ safe = mask(line)
209
+ shown = safe if len(safe) <= cap else safe[:cap] + " …"
210
+ out.append(f"{number}| {shown}")
211
+ return "\n".join(out)
212
+
213
+
214
+ def numbered(lines: list[str], first: int = 1, cap: int = PROMPT_LINE_CAP) -> str:
215
+ """A consecutive run of lines, numbered from `first`."""
216
+ return rows(enumerate(lines, first), cap)
217
+
218
+
219
+ def ceiling(budget: int, asks: int) -> str:
220
+ """One ask's share of what the whole view may cost.
221
+
222
+ Divided, and this is the whole of Faz 8. The budget was a sentence in one
223
+ question before, and a capture split into five asks was five times told to
224
+ keep a hundred lines -- so it kept five hundred, and the tool that promised
225
+ to cost a page cost a chapter. A share holds: five asks for twenty-four lines
226
+ each answer the same size as one ask for a hundred and twenty, and the view
227
+ costs what it costs whether the build printed four hundred lines or ten
228
+ thousand.
229
+
230
+ Written here beside the parser for the reason `ANSWER_FORMAT` is written
231
+ here: two questions must not be able to say this two different ways.
232
+ """
233
+ each = max(1, budget // asks)
234
+ word = "line" if each == 1 else "lines"
235
+ return (
236
+ f"\nKeep the answer to about {each} {word}. If more than that would "
237
+ "qualify, answer with the ones that carry the most and leave the rest "
238
+ "out; nothing is lost by leaving a line out, because the whole text is "
239
+ "on disk and can be asked for by number afterwards.\n"
240
+ )
241
+
242
+
243
+ def read_numbers(answer: str, total: int, first: int = 1) -> set[int]:
244
+ """Every line number in the model's reply, and nothing else from it.
245
+
246
+ Prose around the numbers is ignored rather than rejected: a model that
247
+ explains itself has still answered the question, and the explanation is the
248
+ part that cannot be trusted. Numbers outside the capture are dropped -- a
249
+ line that does not exist cannot be shown, and inventing one is exactly what
250
+ this design exists to prevent.
251
+
252
+ `first` is where the numbering starts, which is 1 for a whole capture and
253
+ the line after the last one already read for a command still running. The
254
+ bound moves with it: a model shown lines 812 to 900 that answers "4" is
255
+ answering about a line nobody showed it.
256
+ """
257
+ chosen: set[int] = set()
258
+ for low, high, single in _NUMBERS.findall(answer):
259
+ if single:
260
+ start = end = int(single)
261
+ else:
262
+ start, end = sorted((int(low), int(high)))
263
+ start = max(start, first)
264
+ end = min(end, first + total - 1)
265
+ chosen.update(range(start, end + 1))
266
+ return chosen
267
+
268
+
269
+ def runs(chosen: set[int]) -> list[tuple[int, int]]:
270
+ """The chosen numbers as consecutive stretches, in order."""
271
+ stretches: list[tuple[int, int]] = []
272
+ for number in sorted(chosen):
273
+ if stretches and number == stretches[-1][1] + 1:
274
+ stretches[-1] = (stretches[-1][0], number)
275
+ else:
276
+ stretches.append((number, number))
277
+ return stretches
278
+
279
+
280
+ def gap(count: int, handle: str, unit: str = "line") -> str:
281
+ """The mark left where units were folded away.
282
+
283
+ It says how many, and how to read them. A view that hid things silently
284
+ would be asking to be trusted; this one can be checked.
285
+
286
+ `peek` is addressed in lines, so the sentence changes with the unit rather
287
+ than pretending records can be asked for by number. What it points at is the
288
+ same capture either way, and that is what the second rule promises: not that
289
+ every unit has an address, but that nothing was thrown away.
290
+ """
291
+ word = unit if count == 1 else unit + "s"
292
+ if unit == "line":
293
+ return f"─ {count:,} {word} not shown · sift peek {handle} for any of them ─"
294
+ return f"─ {count:,} {word} not shown · sift peek {handle} for the text they came from ─"
295
+
296
+
297
+ def render(
298
+ lines: list[str],
299
+ chosen: set[int],
300
+ handle: str,
301
+ first: int = 1,
302
+ unit: str = "line",
303
+ ) -> str:
304
+ """The view: chosen lines exactly as captured, gaps marked with their size.
305
+
306
+ Nothing before `first` is marked as a gap. For a whole capture there is
307
+ nothing before it; for a command still running, what came before was already
308
+ handed to the reader in an earlier look, and marking it "not shown" would be
309
+ telling them they missed something they have already been given.
310
+ """
311
+ pieces: list[str] = []
312
+ previous_end = first - 1
313
+ for start, end in runs(chosen):
314
+ if start > previous_end + 1:
315
+ pieces.append(gap(start - previous_end - 1, handle, unit))
316
+ pieces.extend(lines[start - first : end - first + 1])
317
+ previous_end = end
318
+ last = first + len(lines) - 1
319
+ if previous_end < last:
320
+ pieces.append(gap(last - previous_end, handle, unit))
321
+ return "\n".join(pieces)
322
+
323
+
324
+ def select(
325
+ lines: list[str],
326
+ question: str,
327
+ handle: str,
328
+ bridge: Bridge | None = None,
329
+ budget: int | None = BUDGET,
330
+ first: int = 1,
331
+ keep: str | None = None,
332
+ unit: str = "line",
333
+ ) -> View | None:
334
+ """Ask one question about numbered units, and show the ones it answers with.
335
+
336
+ The question is an argument because nothing underneath it is about failures.
337
+ Numbering the lines, cutting a long text into asks that keep their original
338
+ numbering, reading numbers out of a reply and discarding the rest, printing
339
+ from the source byte for byte -- none of that changes when the question
340
+ changes from *what went wrong here* to *what is declared here*. Only the
341
+ sentence changes, and a second sentence is not a second engine.
342
+
343
+ The prompt is assembled here and not by the caller: the question, then what
344
+ the answer may cost, then how to write it. A question that carried its own
345
+ budget could only state it per ask, and would be right about one ask and
346
+ wrong about the capture.
347
+
348
+ `first` is the number the first of these lines has in the capture it came
349
+ from, which is 1 unless the caller is looking at the part of a running
350
+ command it has not looked at yet. It is a number and not a slice because
351
+ every number that leaves here ends up in `sift peek`: a view whose numbers
352
+ started over at 1 would send the reader to the wrong line of a file that
353
+ disagrees with them, which is the one thing this tool promises cannot
354
+ happen.
355
+
356
+ `keep` is the caller's own say: any line matching it is shown, whatever the
357
+ model chose and whatever the ceiling allows. `_always` explains why a pattern
358
+ is permitted to exist here when `fallback` refuses them outright.
359
+
360
+ Returns nothing when there is no usable judgement -- no key, no model that
361
+ would answer, or an answer with no numbers in it. The caller decides what to
362
+ do with that; falling back is not this function's business, and pretending to
363
+ have judged would be worse than admitting it did not. A `keep` that matched
364
+ something is judgement enough on its own: those lines were asked for by name,
365
+ so they come back even when nobody answered.
366
+ """
367
+ if not lines:
368
+ return View(handle, "", 0, 0, None, 0, unit=unit)
369
+
370
+ always = _always(lines, keep, first) if keep else set()
371
+
372
+ named = answers.key("\n".join(lines), question, budget, first)
373
+ remembered = answers.load(named) if answers.wanted() else None
374
+ if remembered is not None:
375
+ return _seen(lines, remembered, always, handle, first, unit)
376
+
377
+ judge = bridge if bridge is not None else Bridge()
378
+ batches = _batches(list(enumerate(lines, first)))
379
+ asked = prompt(question, budget, len(batches))
380
+ chosen: set[int] = set()
381
+ model: str | None = None
382
+ unanswered = 0
383
+ asks = 0
384
+
385
+ spent = 0
386
+ for answer in _ask_all(judge, asked, batches):
387
+ asks += 1
388
+ if answer is None:
389
+ unanswered += 1
390
+ continue
391
+ model = answer.model
392
+ spent += answer.tokens
393
+ chosen |= read_numbers(answer.text, len(lines), first)
394
+
395
+ if not chosen and not always:
396
+ return None
397
+
398
+ if budget is not None and len(chosen) > budget:
399
+ chosen, rounds, cost = narrow(lines, chosen, question, budget, judge, first)
400
+ asks += rounds
401
+ spent += cost
402
+
403
+ # Written after narrowing and before `keep`, so what is remembered is the
404
+ # model's judgement under this ceiling and nothing the caller added to it.
405
+ # `keep` is applied again on the way out of a hit, because it is an
406
+ # instruction rather than an answer and belongs to the call, not the cache.
407
+ if answers.wanted():
408
+ answers.save(named, chosen, model, unanswered)
409
+
410
+ chosen = chosen | always
411
+
412
+ return View(
413
+ handle=handle,
414
+ text=render(lines, chosen, handle, first, unit),
415
+ kept=len(chosen),
416
+ total=len(lines),
417
+ model=model,
418
+ asks=asks,
419
+ unanswered=unanswered,
420
+ unit=unit,
421
+ tokens=spent,
422
+ )
423
+
424
+
425
+ def _seen(
426
+ lines: list[str],
427
+ remembered: answers.Answered,
428
+ always: set[int],
429
+ handle: str,
430
+ first: int,
431
+ unit: str,
432
+ ) -> View:
433
+ """A view built from an answer already given, rendered against this text.
434
+
435
+ `asks` is zero and that is the whole of what was saved. It is also how a
436
+ reader is told: a view naming a model and costing no questions is one that
437
+ was remembered, and the footer says so rather than implying a model was
438
+ reached just now.
439
+ """
440
+ chosen = remembered.chosen | always
441
+ return View(
442
+ handle=handle,
443
+ text=render(lines, chosen, handle, first, unit),
444
+ kept=len(chosen),
445
+ total=len(lines),
446
+ model=remembered.model,
447
+ asks=0,
448
+ unanswered=remembered.unanswered,
449
+ unit=unit,
450
+ )
451
+
452
+
453
+ def _always(lines: list[str], keep: str, first: int) -> set[int]:
454
+ """The lines the caller named, whatever the model made of them.
455
+
456
+ This is the only pattern anywhere in the judging path, and it is allowed
457
+ because it is not this tool's pattern. A rule invented here about what output
458
+ looks like would be a guess about languages it half knows, wearing the
459
+ clothes of knowledge -- the thing `fallback` refuses in the strongest terms.
460
+ A pattern the caller typed is not a guess: they know what they are looking
461
+ for, and the only job left is to not lose it.
462
+
463
+ A pattern that will not compile is used as plain text. Someone who typed
464
+ `main()` meant those characters, and answering a mistyped group with silence
465
+ would drop the request without ever saying so.
466
+ """
467
+ try:
468
+ found = re.compile(keep)
469
+ except re.error:
470
+ return {n for n, line in enumerate(lines, first) if keep in line}
471
+ return {n for n, line in enumerate(lines, first) if found.search(line)}
472
+
473
+
474
+ def narrow(
475
+ lines: list[str],
476
+ chosen: set[int],
477
+ question: str,
478
+ budget: int,
479
+ judge: Bridge,
480
+ first: int = 1,
481
+ ) -> tuple[set[int], int, int]:
482
+ """Hand an over-long answer back and ask which part of it to keep.
483
+
484
+ The alternative was to cut it here, and cutting means ranking lines with code
485
+ that cannot read them. Every rule available is wrong somewhere: drop the
486
+ short runs and a lone `FAILED` goes, drop the long ones and a stack trace
487
+ goes, drop from the middle and it is a coin toss. The model ranked these
488
+ lines once. Shown a shorter list it can rank them again, the reply is still
489
+ numbers, and the guarantee the whole tool rests on does not move.
490
+
491
+ What is asked is the original question plus `NARROWING`, and not the
492
+ original question on its own -- that was measured, and it comes back with the
493
+ shortlist unchanged, because a list of lines that all matter is a correct
494
+ answer to a question about which lines matter.
495
+
496
+ Only lines already chosen survive a round: a number from outside the
497
+ shortlist is dropped rather than admitted, so narrowing can never widen. A
498
+ round that comes back empty leaves the previous answer standing, because a
499
+ question nobody answered is not a decision to show nothing. And a round that
500
+ changes nothing ends it -- asking a fourth time costs what the first three
501
+ cost and has already been refused three times.
502
+ """
503
+ asks = 0
504
+ spent = 0
505
+ for _ in range(NARROW_ROUNDS):
506
+ if len(chosen) <= budget:
507
+ break
508
+ shortlist = [(number, lines[number - first]) for number in sorted(chosen)]
509
+ batches = _batches(shortlist)
510
+ asked = prompt(question + NARROWING, budget, len(batches))
511
+ kept: set[int] = set()
512
+ for batch in batches:
513
+ answer = ask_one(judge, asked, batch)
514
+ asks += 1
515
+ if answer is not None:
516
+ spent += answer.tokens
517
+ kept |= read_numbers(answer.text, len(lines), first) & chosen
518
+ if not kept or len(kept) >= len(chosen):
519
+ break
520
+ chosen = kept
521
+ return chosen, asks, spent
522
+
523
+
524
+ def prompt(question: str, budget: int | None, asks: int) -> str:
525
+ """The question, what the answer may cost, and how to write it -- in order."""
526
+ share = ceiling(budget, asks) if budget is not None else ""
527
+ return question + share + ANSWER_FORMAT
528
+
529
+
530
+ def distill(
531
+ capture: Capture,
532
+ bridge: Bridge | None = None,
533
+ budget: int | None = BUDGET,
534
+ keep: str | None = None,
535
+ ) -> View | None:
536
+ """Ask which parts of a capture matter, then show those parts from it.
537
+
538
+ The unit is decided here and nowhere else, by asking the text rather than by
539
+ asking what produced it. A command that prints a JSON array is not a
540
+ different kind of command and there is no list here of the ones that do it;
541
+ `records.of` either finds an array or does not, and that answer is a fact
542
+ about the bytes.
543
+ """
544
+ text = capture.text()
545
+ found = records.of(text)
546
+ if found is not None:
547
+ return select(
548
+ found,
549
+ RECORDS,
550
+ capture.handle,
551
+ bridge,
552
+ budget=budget,
553
+ keep=keep,
554
+ unit="record",
555
+ )
556
+ return select(
557
+ text_lines.of(text),
558
+ QUESTION,
559
+ capture.handle,
560
+ bridge,
561
+ budget=budget,
562
+ keep=keep,
563
+ )
564
+
565
+
566
+ def follow(
567
+ lines: list[str],
568
+ handle: str,
569
+ first: int,
570
+ bridge: Bridge | None = None,
571
+ ) -> View | None:
572
+ """Ask which of a running command's newest lines matter, and show those.
573
+
574
+ The same engine as `distill`, with the two things that are actually
575
+ different made different: the question knows the output has not ended, and
576
+ the numbering starts where the reader's last look stopped instead of at 1.
577
+ """
578
+ return select(lines, FOLLOWING, handle, bridge, first=first)
579
+
580
+
581
+ _GATE: dict[int, threading.Semaphore] = {}
582
+ _GATE_LOCK = threading.Lock()
583
+
584
+
585
+ @contextlib.contextmanager
586
+ def in_flight() -> Iterator[None]:
587
+ """One ceiling on asks in the air, wherever in this process they started.
588
+
589
+ `SIFT_WORKERS` is a statement about this machine and this endpoint: how many
590
+ requests may be outstanding at once. It used to be read at two levels -- how
591
+ many samples to measure at a time, and how many pieces of one sample to ask
592
+ about at a time -- with nothing tying them together, so they multiplied. Six
593
+ became thirty-six, the free endpoint refused what it could not take, and the
594
+ refusals were counted as lines this tool had lost. That is written up in
595
+ `notlar/08`, and it cost a day.
596
+
597
+ Phase 15 makes the multiplying easy again: several files, several running
598
+ commands, each of them split into pieces. So the ceiling stopped being
599
+ arithmetic every caller has to redo, and became a gate every ask goes
600
+ through. A number that is enforced in one place cannot be multiplied by a
601
+ caller who did not know about it.
602
+
603
+ The gate is rebuilt when the setting changes, which is what makes it usable
604
+ from a test. Asks already through an older gate are still governed by it --
605
+ they finish under the ceiling they started under, which is the only sense in
606
+ which "changed the limit" can mean anything mid-flight.
607
+ """
608
+ size = workers()
609
+ with _GATE_LOCK:
610
+ gate = _GATE.get(size)
611
+ if gate is None:
612
+ gate = _GATE[size] = threading.Semaphore(size)
613
+ gate.acquire()
614
+ try:
615
+ yield
616
+ finally:
617
+ gate.release()
618
+
619
+
620
+ def ask_one(judge: Bridge, asked: str, batch: list[tuple[int, str]]) -> Answer | None:
621
+ """One question, through the gate. Every ask in this file goes through here."""
622
+ with in_flight():
623
+ return judge.ask(asked, rows(batch), max_tokens=2048)
624
+
625
+
626
+ def _ask_all(
627
+ judge: Bridge, asked: str, batches: list[list[tuple[int, str]]]
628
+ ) -> list[Answer | None]:
629
+ """Every piece of one capture, asked about at the same time.
630
+
631
+ The pieces do not overlap and their answers are added together as a set, so
632
+ the order the replies arrive in cannot change what comes back -- only how
633
+ long it takes. One piece is asked about directly rather than through a pool,
634
+ because a pool for one job is a thread and a queue to do what a call does.
635
+
636
+ `narrow` is deliberately left serial. Each of its rounds is a question about
637
+ the previous round's answer, so there is nothing there to overlap.
638
+
639
+ The bridge is shared across the threads, which is safe for everything it
640
+ holds except `last_error`: two pieces failing at once leave one of the two
641
+ reasons behind rather than both. Both are true, and the caller shows one.
642
+ """
643
+ if len(batches) == 1:
644
+ return [ask_one(judge, asked, batches[0])]
645
+ with ThreadPoolExecutor(max_workers=min(workers(), len(batches))) as pool:
646
+ return list(pool.map(lambda batch: ask_one(judge, asked, batch), batches))
647
+
648
+
649
+ def _batches(pairs: list[tuple[int, str]]) -> list[list[tuple[int, str]]]:
650
+ """Numbered lines in pieces small enough to ask about, keeping their numbers.
651
+
652
+ Splitting never renumbers: a piece that starts at line 4,001 is numbered from
653
+ 4,001, so an answer about it needs no translation and cannot be misread as
654
+ being about the top of the file. The numbers arrive with the lines rather
655
+ than being counted from an offset here, which is what lets the second pass
656
+ reuse this on a shortlist whose lines are nowhere near each other.
657
+ """
658
+ batches: list[list[tuple[int, str]]] = []
659
+ current: list[tuple[int, str]] = []
660
+ size = 0
661
+ for number, line in pairs:
662
+ cost = min(len(line), PROMPT_LINE_CAP) + 12
663
+ if current and size + cost > CHARS_PER_ASK:
664
+ batches.append(current)
665
+ current = []
666
+ size = 0
667
+ current.append((number, line))
668
+ size += cost
669
+ batches.append(current)
670
+ return batches