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/__init__.py +18 -0
- sift/answers.py +175 -0
- sift/background.py +444 -0
- sift/capture.py +240 -0
- sift/cli.py +820 -0
- sift/digest.py +101 -0
- sift/distill.py +670 -0
- sift/fallback.py +98 -0
- sift/hook.py +275 -0
- sift/lines.py +37 -0
- sift/many.py +51 -0
- sift/memory.py +94 -0
- sift/model.py +433 -0
- sift/outline.py +117 -0
- sift/peek.py +161 -0
- sift/privacy.py +145 -0
- sift/records.py +95 -0
- sift/server.py +552 -0
- sift/store.py +499 -0
- sift/tools.py +76 -0
- sift/view.py +317 -0
- sift/watch.py +166 -0
- sift_cli-1.0.0.dist-info/METADATA +326 -0
- sift_cli-1.0.0.dist-info/RECORD +27 -0
- sift_cli-1.0.0.dist-info/WHEEL +4 -0
- sift_cli-1.0.0.dist-info/entry_points.txt +3 -0
- sift_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|