Skip to content

Commit 7e2d824

Browse files
authored
Merge pull request #990 from taras/agent/repl-render-capacity-opus-20261025
Keep long REPL output readable
2 parents 3834f19 + 6e9fff8 commit 7e2d824

3 files changed

Lines changed: 183 additions & 7 deletions

File tree

‎architecture.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7085,6 +7085,19 @@ Batch-local ids keep that set as small as one batch. This bounds the request,
70857085
not the document: nothing is truncated to make an engine accept it, and the
70867086
answers come back one per candidate in the order they were asked for.
70877087

7088+
**An engine bounds the text one render measures as well as its elements.** Every
7089+
space-separated part of every text in a render is kept in a cache the engine
7090+
refuses from, and that cache holds the same number of them however few elements
7091+
carry them — so a batch well inside the element budget still overruns it when
7092+
its rows are prose, and one line of a retained reading can hold more parts than
7093+
any render measures whatever a batch size is. A batch is therefore bounded by
7094+
both, and a line is measured as far as it could still fit its region rather than
7095+
to its end: every part after the first is preceded by a space, so a prefix of
7096+
more parts than the region has columns is already wider than the region, and
7097+
text does not narrow when more of it is appended. The cut that prefix bounds is
7098+
the reader's own line, measured as the row it became — a bounded question, not a
7099+
shortened document.
7100+
70887101
An engine that is asked for too much at once does not say so usefully. It
70897102
reports unbalanced structure for a list whose opens and closes are exactly
70907103
paired, which is not a capacity refusal — so it is not reclassified as one

‎packages/cli/src/repl/fitting.ts‎

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,61 @@ const SEPARATOR = " ";
306306
*/
307307
const WIDTHS_PER_RENDER = 1024;
308308

309+
/**
310+
* How many space-separated parts one measuring render may carry.
311+
*
312+
* The element budget is not the engine's only one. Every part of every text a
313+
* render measures is kept in a cache the engine refuses from, and that cache
314+
* holds the same number of them however few elements carry them: measured on a
315+
* freshly built engine, one text of 16,384 parts and 1,366 texts of twelve each
316+
* refuse, while 16,383 parts go through either way. A tab and a newline are not
317+
* separators there, a run of spaces produces an empty part that costs what a
318+
* word costs, and two texts spelled the same share one entry.
319+
*
320+
* This is the budget whose refusal the renderer's recovery cannot absorb: a
321+
* replacement instance is given the same cache, so a render that overran it
322+
* overruns the replacement too and the refusal reaches the screen as the error
323+
* that ends it. Half the engine's own figure, which leaves the parts an
324+
* instance is still holding from the render before this one somewhere to sit.
325+
*/
326+
const WORDS_PER_RENDER = 8192;
327+
328+
/** What one text costs that budget, counted the way the engine counts it. */
329+
function wordsIn(text: string): number {
330+
let parts = 1;
331+
for (let at = 0; at < text.length; at += 1) {
332+
if (text[at] === " ") {
333+
parts += 1;
334+
}
335+
}
336+
return parts;
337+
}
338+
339+
/**
340+
* How much of one line could still fit a region, as a length in code units.
341+
*
342+
* A single text is bounded by no batch size, and one line of somebody's output
343+
* can hold more parts than a render measures — a journal of this product holds
344+
* lines of 19,999 — so a line is measured as far as it could still fit and
345+
* never further. That prefix settles the question it is asked: every part after
346+
* the first is preceded by a space, so `room + 2` of them are already wider
347+
* than `room` columns, and text does not narrow when more of it is appended.
348+
* The prefix ends at a space, which is a grapheme boundary of its own.
349+
*/
350+
function windowOf(text: string, room: number): number {
351+
const parts = Math.min(room + 2, WORDS_PER_RENDER);
352+
let seen = 0;
353+
for (let at = 0; at < text.length; at += 1) {
354+
if (text[at] === " ") {
355+
seen += 1;
356+
if (seen === parts) {
357+
return at;
358+
}
359+
}
360+
}
361+
return text.length;
362+
}
363+
309364
/**
310365
* Ask the engine how wide each of these texts is.
311366
*
@@ -324,12 +379,26 @@ function* widths(
324379
return Ok([]);
325380
}
326381
const answers: number[] = [];
327-
for (let from = 0; from < texts.length; from += WIDTHS_PER_RENDER) {
328-
const batch = yield* batchWidths(renderer, size, texts.slice(from, from + WIDTHS_PER_RENDER));
382+
let from = 0;
383+
while (from < texts.length) {
384+
let to = from;
385+
let words = 0;
386+
while (to < texts.length && to - from < WIDTHS_PER_RENDER) {
387+
const cost = wordsIn(texts[to]);
388+
// A text that outgrows the budget on its own still goes, alone, so the
389+
// engine's refusal names that text rather than this loop never advancing.
390+
if (to > from && words + cost > WORDS_PER_RENDER) {
391+
break;
392+
}
393+
words += cost;
394+
to += 1;
395+
}
396+
const batch = yield* batchWidths(renderer, size, texts.slice(from, to));
329397
if (!batch.ok) {
330398
return batch;
331399
}
332400
answers.push(...batch.value);
401+
from = to;
333402
}
334403
return Ok(answers);
335404
}
@@ -679,11 +748,15 @@ function* fitLine(
679748
if (line.text.length === 0) {
680749
return Ok([piece(0, 0, false, 0)]);
681750
}
682-
const whole = yield* widths(renderer, size, [line.text]);
683-
if (!whole.ok) {
684-
return whole;
751+
// Asked of as much of the line as the region could still hold. One row is a
752+
// line the engine measured as fitting, and a line held back from the question
753+
// is longer than the answer it would have given, so it is cut.
754+
const reach = windowOf(line.text, room);
755+
const reaching = yield* widths(renderer, size, [line.text.slice(0, reach)]);
756+
if (!reaching.ok) {
757+
return reaching;
685758
}
686-
if (whole.value[0] <= room) {
759+
if (reach === line.text.length && reaching.value[0] <= room) {
687760
return Ok([piece(0, line.text.length, false, 0)]);
688761
}
689762
// Where this line begins, so its continuations begin there too. Bounded well
@@ -728,7 +801,10 @@ function* cutAt(
728801
rest: string,
729802
room: number,
730803
): Operation<Result<number>> {
731-
const stops = graphemeStops(rest);
804+
// Over the boundaries of the prefix that could still fit, which is what keeps
805+
// every candidate inside one render's budget and keeps this from segmenting
806+
// the whole remainder once per row of a very long line.
807+
const stops = graphemeStops(rest.slice(0, windowOf(rest, room)));
732808
// Narrow by halves over grapheme boundaries, asking the engine for one batch
733809
// of candidates per round rather than one render per candidate.
734810
let low = 0;

‎packages/cli/tests/repl-source-reading.test.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,3 +1176,90 @@ describe("S6 a reading longer than one measuring render is still measured whole"
11761176
}));
11771177
}
11781178
});
1179+
1180+
/**
1181+
* S7 a reading with more words than one measuring render.
1182+
*
1183+
* The engine's element budget is not its only one. It keeps every
1184+
* space-separated part of every text a render measures in a cache it refuses
1185+
* from, and that cache holds 16,384 of them however few elements carry them —
1186+
* measured on a freshly built engine, 1,366 texts of twelve parts and one text
1187+
* of 16,384 both refuse. A refusal a bigger engine cannot fix is the one the
1188+
* renderer's recovery cannot absorb, so it reached the screen as the error that
1189+
* ends it.
1190+
*
1191+
* Both arms are the shape the reported crash had. An entry whose recorded
1192+
* output holds one line of 20,000 parts is the real one — a journal of this
1193+
* product holds lines of 19,999 — and it refused inside the first measurement
1194+
* of that single line, which no batch size bounds. A reading of 1,400 ordinary
1195+
* lines is the other half: each line measures on its own, and the pass that
1196+
* measures every finished row at once asked for more parts than the cache holds
1197+
* even though it asked for fewer elements than the element budget.
1198+
*/
1199+
/** One line of `count` space-separated parts, which is what the engine counts. */
1200+
function parts(count: number): string {
1201+
return Array.from({ length: count }, (_, at) => `${at % 10}`).join(" ");
1202+
}
1203+
1204+
describe("S7 a reading with more words than one measuring render is still measured whole", () => {
1205+
/** One recorded output line, as the reading an entry is shown as. */
1206+
function outputLinesOf(output: string): readonly ReplReadingLine[] {
1207+
return entryReading({
1208+
entry: entryOf('<Json value={{ a: 1 }} as="a" />', {
1209+
terminal: settled("ok", output),
1210+
settled: true,
1211+
}),
1212+
lifecycle: NO_LIFECYCLE,
1213+
live: "",
1214+
inspected: false,
1215+
}).output.lines;
1216+
}
1217+
1218+
it("recovers one line of 20,000 parts, exactly and in order", () =>
1219+
withRenderer(function* (renderer) {
1220+
const line = parts(20000);
1221+
const lines = outputLinesOf(line);
1222+
const prepared = yield* prepareReading(renderer, WIDE, lines, 100);
1223+
if (!prepared.ok) {
1224+
throw prepared.error;
1225+
}
1226+
1227+
// One line of somebody's output, whole: it was wrapped into many rows and
1228+
// nothing was shortened to make the engine accept it.
1229+
const whole = recovered(prepared.value);
1230+
expect(whole.size).toBe(lines.length);
1231+
for (const one of lines) {
1232+
expect([one.key, whole.get(one.key)]).toEqual([one.key, one.text]);
1233+
}
1234+
const rows = prepared.value.rows.filter((one) => one.line === lines[lines.length - 1].key);
1235+
expect(rows.length).toBeGreaterThan(1);
1236+
1237+
// And every row still fits the region it was cut for, which is the claim a
1238+
// width answered for a bounded prefix could break.
1239+
const { reservation } = prepared.value;
1240+
for (const row of prepared.value.rows) {
1241+
expect(row.width).toBeLessThanOrEqual(reservation.content - row.indent);
1242+
expect(runText(row.runs)).toBe(row.text);
1243+
}
1244+
}));
1245+
1246+
it("recovers 1,400 ordinary lines whose parts outgrow one render", () =>
1247+
withRenderer(function* (renderer) {
1248+
// Distinct lines, because the engine caches one entry per exact text: a
1249+
// thousand copies of one line cost it the parts of a single line and
1250+
// would prove nothing about what a batch of them asks for.
1251+
const lines = outputLinesOf(
1252+
Array.from({ length: 1400 }, (_, at) => `line ${at} ${parts(20)}`).join("\n"),
1253+
);
1254+
const prepared = yield* prepareReading(renderer, WIDE, lines, 100);
1255+
if (!prepared.ok) {
1256+
throw prepared.error;
1257+
}
1258+
1259+
const whole = recovered(prepared.value);
1260+
expect(whole.size).toBe(lines.length);
1261+
for (const one of lines) {
1262+
expect([one.key, whole.get(one.key)]).toEqual([one.key, one.text]);
1263+
}
1264+
}));
1265+
});

0 commit comments

Comments
 (0)