Skip to content

run_json: buffer the jailed and fd report text as UTF-8 bytes - #559

Merged
luisleo526 merged 12 commits into
mainfrom
core/report-text-bytes
Oct 27, 2026
Merged

luisleo526 merged 12 commits into
mainfrom
core/report-text-bytes

Conversation

@luisleo526

@luisleo526 luisleo526 commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

What this changes

docker/run_json.py built the report text of the jailed sink-off route and of the fd route in an io.StringIO and then read it back for publication. CPython's StringIO keeps the written text (one byte per ASCII character) and realizes a four-byte-per-character buffer at its first read, so the resident text was about five times the stdout bytes.

The text is now built in io.TextIOWrapper(io.BytesIO(), encoding="utf-8", newline="\n"): the same characters and the same bytes on stdout, one byte of resident text per output byte for the ASCII json.dumps output, and non-ASCII text still encodes as UTF-8. Serialization still completes before the buffer is returned, so a serialization error publishes no report prefix; _write_report is unchanged. The remaining io.StringIO in run_json.py serves the capped failure diagnostics and is not touched.

The change in the code is four lines in _prepare_report_json. The rest of the PR is the rows that prove it and the documentation.

Measured effect

Kernel ru_maxrss, 8,000,000 versus 2,000,000 bars (mean of two slopes), stdout byte-equal in every pair, on build hosts:

module stdout per bar slope before slope after
zero-trade codegen module 63 B 363.0 B/bar 191.0 B/bar
hand-written trading module 143.88 B 767.4 B/bar 289.7 B/bar

The previous slope is exactly the chart (48 B) plus five times the stdout bytes per bar. What remains after this change is not decomposed here and no claim is made that it is explained.

Tests

docker/run_json_stringio_test.py (17 tests, collected by the release harness test stage):

  • allocation rows trace the preparer plus the real _write_report read-back on two documents (one 8 MiB string, one header with 200,000 small equity records), in both profiles, with a ceiling of 2.5 times the encoded document derived from the two mechanisms (the previous code is at least 5 times by construction, measured 5.000; the new code about 1.5 times);
  • a slope row compares an 8 MiB and a 16 MiB document and requires at most 2.5 per added byte (previous code 5.0, new 1.05);
  • a diagnostic row prints the peak of one 1 MiB read-back chunk;
  • stdout bytes equal to the previous code for zero-trade, trading and non-ASCII input; a mid-document serialization error publishes nothing; a mid-write OSError and a short write keep the accepted prefix with a stderr-only reason and status 1; the fd route keeps its bounded summary.

The rows were red on the previous preparer and green on this one, on Python 3.12.3 and inside the release image (Python 3.11.2, image pinned by digest).

Documentation

  • docs/pages/memory-management.md states that the sink-off jailed route holds the report text at about one byte per output byte plus bounded encoding and read-back buffers (O(output)), that --report-sink-fd avoids the full report text buffer, and adds the caller contract for the report-sink descriptor (what the engine does with the fd, which reasons are typed, what a closed reader looks like).
  • docs/design/run-json-report-text-rows.md records the commands, the measured numbers and the history of the allocation rows.

Verification

Preflight, release, docs, debug, sanitizers and kernel legs and the corpus parity and subset legs run on the merge head; a full parity sweep on the same tree is measured against the current codegen main.

luisleo526 and others added 12 commits October 27, 2026 20:50
…it (resident, disk, jailed arena)

The memory-management page said the default sink route keeps the equity
curve out of memory, but not what still grows with the bar count, and that
a jailed run without --report-sink-fd has no sink. It now states, with
sources: the resident equity-report buffers are bounded on the default
unjailed sink route; the curve scratch costs 24 B per script bar on disk
(6 MiB at 262,144 and 48 MiB at 2,097,152 bars) with the E output and the
JSON artifact; in a jailed run the scratch is the RAM-backed arena, so the
same 24 B per script bar are memory there, sized by the hosting wrapper and
failing closed with arena_exhausted; sink-off callers keep the native curve
plus an owning report copy. "O(1) in bars" is not claimed for the jailed
arena or the sink-off path.

Removed text, named. The page text below was introduced by 6d2bf95 (docs:
add the memory-management page, #531) and 30e9f9a (docs: memory-management
page re-read against main after the 1.8.0 changes, #546):
- the section "Use the report sink for bounded memory" is replaced by
  "Report memory: resident buffers, disk and the jailed arena" (its line
  citations were re-read and corrected);
- the phrase "a bounded default route is planned, with no release date" and
  the sentence "Install a report sink; no release date for the default
  route" are removed from the report-memory table (the page documents the
  route as it is);
- the two sink rows of that table are reworded to separate resident buffers
  from disk and arena growth; the sentences about the heap bound of the
  equity curve and the ledger, the sink file growth, and the legacy FULL sink
  route are rewritten into the new section, and the release container's
  default profile sentence of 30e9f9a is replaced by the route table.

Tests: a two-size resident proof for the report scratch curve (N = 262,144
and 8N script bars, RSS growth within 1 MiB, scratch growth exactly 24 B per
added bar, metric block bit-equal to the sink-off result); a corpus
projection test that the temporary-sink route and the sink-off route give
bit-identical metrics and diagnostics on real modules (it replaces the
named-sink summary comparison on corpus modules that this test did before,
and still skips when its manifest variable is unset). Both report-memory
rows are pinned as slow rows (61 pinned names) and registered before the
CR-150 include block. The binary-route peak-RSS row is left out of this
change: its 9-minute wall exceeds the CI leg budget.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…62 pinned names)

Both the report-memory change and the paged array store added one pinned slow
row each on top of 60, and git merged the two identical edits of the count
into 61. The pinned set now holds 62 names: 43 from the PINEFORGE_PR_SLOW_TESTS
loop and 19 direct CMake LABELS assignments.

The test assertion on len(PINNED_SLOW_TEST_ROWS) and the four sentences in
docs/ci.md that state the count and its split (introduced by 3ea5a0d, ci:
split the long test rows, shard the full CI legs and run them on the larger
hosted runners, #544, and rewritten by the report-memory change) are updated
to 62 and 43 + 19. No other text is removed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
… and 19

test_documented_slow_row_counts_match_literal_population pins the three
numbers that docs/ci.md states for the pinned slow rows (all, from the
PINEFORGE_PR_SLOW_TESTS loop, direct LABELS assignments) and two sentences that
repeat them. The previous commit moved the documented count from 61 to 62
(43 + 19) but left this test at 61, 43 and 18, so test_ci_preflight_slice_6
failed on every profile. The test now states 62, 43 and 19. Only those literals
change.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
… copy

The bulk path-sink equity copy added code above the report-sink helpers in
docker/run_json.py, so the three line ranges the memory-management page cites
for _ReportSink, _build_sink_report and _prepare_stream_report no longer
contained their symbols (doc-anchors: SYMMISS). The ranges now read 6183-6220,
7102-7148 and 6983-7003. Citation numbers only: no sentence is added, removed
or reworded.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…StringIO

The jailed sink-off route and the fd route built the report text in an
io.StringIO and then read it back for publication. CPython's StringIO keeps the
written text (one byte per ASCII character) and realizes a four-byte-per-
character buffer at its first read, so the resident text was about five times
the stdout bytes. The text is now built in
io.TextIOWrapper(io.BytesIO(), encoding="utf-8", newline="\n"): the same
characters and the same bytes on stdout, one byte of resident text per output
byte for the ASCII json.dumps output, and non-ASCII text still encodes as
UTF-8. Serialization still completes before the buffer is returned, so a
serialization error publishes no report prefix; _write_report is unchanged. The
remaining io.StringIO in run_json.py serves the capped failure diagnostics and
is not touched. The memory-management page states that the sink-off jailed
route is O(output) and that --report-sink-fd avoids the full report text buffer.

Measured on build hosts (kernel ru_maxrss, stdout byte-equal in every pair):
the zero-trade codegen module (63 B/bar of stdout) has a slope of 363.0 B/bar
for the previous code (five runs on two hosts) and 191.0 B/bar after this
change; the trading module (143.88 B/bar of stdout) 767.4 and 289.7. The
previous slope is exactly 48 + 5 x stdout bytes per bar (the chart plus one
byte per character plus the four-byte-per-character buffer). What remains after
this change is not decomposed here.

Tests: docker/run_json_stringio_test.py. The allocation rows trace prepare plus
the real _write_report read-back on two documents (one 8 MiB string, and a
header with 200,000 small equity records), both profiles: ceiling 2.5 times the
encoded document, derived from the two mechanisms (the previous code is at least
5 times by construction, measured 5.000; the new code about 1.5 times). A slope
row compares an 8 MiB and a 16 MiB document: at most 2.5 per added byte (previous
code 5.0, new 1.05). A diagnostic row prints the peak of one 1 MiB read. Further
rows: stdout bytes equal to the previous code for zero-trade, trading and
non-ASCII input, a mid-document serialization error publishes nothing, mid-write
OSError and short write keep the accepted prefix with a stderr-only reason and
status 1, and the fd route keeps the bounded summary. The rows were red on the
previous preparer and green on this one on a Linux box with Python 3.12.3 and
inside the release image (Python 3.11.2, image pinned by digest
sha256:8a81b6f43944634081044fc7dfff0d18de9ee3d60b476ca785406609dbdb3484).
docs/design/run-json-report-text-rows.md records the commands, the measured numbers and the
history of the allocation rows (the first version did not fail on the previous
code; the ceilings tried after the first box runs were replaced by the
structural one).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Adds a section to the memory-management page for a host that starts the release
container (or docker/run_json.py) and reads the report from an inherited
descriptor instead of a file: which image forwards PINEFORGE_REPORT_SINK_FD (the
entrypoint reads it from 1.8.0 on; a 1.7.0 image ignores it), what descriptor
numbers a caller may pass, the stream's framing and its verified trailer as the
completion marker, what stdout carries, and what a closed reader produces, as
measured on a FIFO and on a stream socket. A statement the tree does not settle
says so.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The bulk path-sink equity copy added code above _prepare_stream_report and
_prepare_report_json, so the two ranges the memory-management page cites for
them (the scratch_io raise and the _prepare_report_json definition) moved. The
ranges now read 6995-6998 and 7006. Citation numbers only: no sentence is
added, removed or reworded.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…the page now says everywhere

The report-memory text of the memory-management page added by the report-memory
change (memory: state the report-memory bound exactly and add the proofs for it
(resident, disk, jailed arena)) still said that the jailed sink-off route holds
the report text at about five bytes per stdout byte (one byte of text plus a
four-byte-per-character buffer, 363 B and 767 B per bar), in the sink-off
paragraph and in the report-memory table row. That is the behaviour before
the UTF-8 byte buffer. Both places now say: about one byte of resident text per
output byte plus bounded encoding and read-back buffers, measured at 191.0 and
289.7 B per bar (down from 363.0 and 767.4), the remainder not decomposed. The
design document keeps the base figures as the measured baseline.

Removed text, named: the "about five bytes per stdout byte" sentence and the
363 B / 767 B per bar example in the sink-off paragraph, and the "about five
bytes per stdout byte" clause of the sink-off table row, both introduced by the
report-memory change named above.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
@luisleo526
luisleo526 changed the base branch from core/report-memory-bound-2 to main October 27, 2026 18:16
@luisleo526
luisleo526 marked this pull request as ready for review October 27, 2026 19:02
@luisleo526
luisleo526 merged commit 4dc08f4 into main Oct 27, 2026
8 of 10 checks passed
@luisleo526

Copy link
Copy Markdown
Collaborator Author

Merged on the required checks: pineforge/verify (8/8 steps on tree 51abf4eb) and pineforge/parity (full sweep, 7989 probes, no coverage lost, no hard regressions). The build and changes PR checks show "PR changed since this event; rerun CI": they are stale runs from before the base branch was retargeted to main, and they are advisory, not required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant