Skip to content

core/ingest: stream large JSONL and CSV files line-by-line instead of full read into memory (fixes #80) - #136

Merged
k-dickinson merged 10 commits into
k-dickinson:mainfrom
Maqbool61:streaming-ingest-issue-80
Jul 23, 2026
Merged

k-dickinson merged 10 commits into
k-dickinson:mainfrom
Maqbool61:streaming-ingest-issue-80

Conversation

@Maqbool61

@Maqbool61 Maqbool61 commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

core/ingest: eliminate full-file string for large JSONL and CSV; optional streaming for JSON (fixes #80)

Problem

core/ingest.py used Path.read_text() to load every results file — JSON, JSONL, and CSV — fully into memory before any parsing began. A multi-GB results file was materialised twice: once as a raw Python string, then again as parsed objects. Peak memory was therefore proportional to file size.

What Changed

Two streaming generator helpers (core/ingest.py)

_iter_jsonl_lines(path) Opens the file with a plain file handle and yields one parsed dict per non-blank line. At any point only a single line is held in memory. All existing error semantics are preserved — malformed lines raise ValueError with a 1-based line number, non-object lines are rejected with a clear message, and Unicode line separators inside values are handled safely (no str.splitlines() splitting).

_iter_csv_rows(path) Pipes the open file handle directly through csv.DictReader, yielding one row dict at a time without ever building a full-file string.

Size threshold

A _STREAM_THRESHOLD constant (default 64 MiB) gates which path is taken:

  • Files below the threshold continue to use Path.read_text() — the common fast path is completely unchanged.
  • Files above the threshold are routed through the generator pipeline.

Memory model

JSONL (long-format): _stream_records_from_jsonl() peeks the first row to detect layout and, for the common long-format case (model + score columns), extracts records row-by-row without ever building the full row list. Measured peak memory is ~0.21× file size on payload-heavy rows. Wide-format and preference files fall back to _records_from_jsonl_iter (list of dicts, no raw string).

CSV: The raw file string is eliminated; rows are consumed via csv.DictReader on an open file handle.

JSON: Streaming is best-effort only — requires the optional ijson extra (pip install "evaltrust[streaming]"). Without it, large .json files fall back to a full read_text() load with a logged warning. No new hard dependency is introduced.

Honest scope: this PR eliminates the full-file string for large JSONL and CSV. Records and EvalData are still O(n) in row count — auditing a file larger than available RAM requires a future refactor of detect_line_adapter / dicts_to_records to accept a one-row lookahead.

Optional JSON streaming

For large .json files, _load_json_streamed() uses ijson to iterate over the two common shapes:

  • Top-level array [{…}, …]
  • {"examples": [{…}, …]} (native nested format)

_normalize_ijson_dict() coerces Decimal → float at the boundary so scores are handled correctly with ijson 3.x.

load() and load_suite() both updated

Both public entry points check file size and route accordingly. The mis-named .jsonl-containing-a-JSON-array edge case is handled correctly in both paths.

How It Solves the Issue

Requirement How it's met
Stream JSONL line-by-line _iter_jsonl_lines() — one line at a time via file handle
Stream CSV row-by-row _iter_csv_rows() — DictReader on open file handle
Optional streaming parse for {"examples": [...]} JSON shape _load_json_streamed() via ijson with graceful fallback
Keep current path for small files _STREAM_THRESHOLD (64 MiB) gate — small files untouched
No full-file string for large JSONL/CSV Path.read_text() never called for large JSONL/CSV — verified by tests

Tests (tests/test_ingest_streaming.py — 30 new tests)

  • Generator helpers in isolation — _iter_jsonl_lines and _iter_csv_rows unit tested directly (blank lines, malformed JSON, non-object lines, CRLF endings, Unicode line separators)
  • Streaming matches in-memory — scores, models, and n_examples are identical regardless of which path is taken
  • Error propagation unchanged — empty files, malformed lines, and missing files all raise the same errors under streaming
  • Threshold gate — small files do not call the streaming helpers; large files (threshold patched to 0) do
  • Graceful fallback — ijson absent is handled without crashing
  • load_suite() streaming — JSONL and CSV suites verified
  • Multi-metric JSON suite — streamed and in-memory paths produce identical suites for multi-metric JSON (top-level array shape); streamed path confirmed not to collapse metrics
  • read_text() never called — explicitly asserted for large JSONL and CSV files

Full suite: 790 passed, 0 failed.

Fixes #80

@coderabbitai

coderabbitai Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Large JSONL and CSV files now use size-aware iterators, while supported large JSON documents can use optional ijson parsing. load() and load_suite() route by file size and extension, retaining in-memory fallbacks and adapter-based normalization.

Changes

Streaming evaluation ingestion

Layer / File(s) Summary
Streaming parsers and record construction
src/evaltrust/core/ingest.py, tests/test_ingest_streaming.py
Adds JSONL and CSV iterators, validation, error reporting, adapter-aware conversion, and threshold/read behavior tests.
Load routing and JSON streaming
src/evaltrust/core/ingest.py, tests/test_ingest_streaming.py
Updates load() for size- and extension-based routing, optional ijson parsing, misnamed JSON arrays, and fallback behavior.
Suite streaming integration
src/evaltrust/core/ingest.py, tests/test_ingest_streaming.py
Extends load_suite() with streaming JSONL, CSV, and JSON routing while preserving suite construction and comparison merging.
Streaming dependency and release metadata
pyproject.toml
Updates the package version to 0.7.0 and adds an optional streaming dependency group containing ijson>=3.1.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant load
  participant StreamingParser
  participant EvalData
  Caller->>load: provide evaluation path
  load->>StreamingParser: select parser by size and extension
  StreamingParser-->>load: yield parsed rows or parsed JSON records
  load->>EvalData: construct adapter-normalized data
  EvalData-->>Caller: return EvalData
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR covers streaming JSONL/CSV and optional JSON parsing, but large streamed rows are still collected into a list, so [#80] is not fully met. Keep large-file rows as an iterator or use bounded batches through record conversion so memory stays proportional to the streaming buffer.
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The pyproject version bump and optional ijson dependency support the streaming feature and do not introduce unrelated changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: streaming large JSONL and CSV ingestion instead of full reads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
tests/test_ingest_streaming.py (1)

221-254: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise the successful ijson branches.

These tests only simulate ijson being absent. Add coverage for successful top-level-array and examples.item parsing, including load_suite() and multi-metric input.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_ingest_streaming.py` around lines 221 - 254, Add tests in
TestJsonStreaming that mock a working ijson implementation and exercise both
top-level-array parsing and examples.item parsing through the streaming path.
Include load_suite() coverage and a multi-metric input, asserting the resulting
models, examples, and metrics match the source data while preserving the
existing fallback tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/evaltrust/core/ingest.py`:
- Line 490: Update the ingestion logic around p.read_text() to stream large
.json suites before materializing their contents. Add a suite-aware JSON
streaming branch for large JSON inputs, while preserving the existing
read_text() behavior for smaller files and non-suite inputs.
- Around line 459-461: The streamed JSON branches in _load_file and the
unknown-extension fallback must preserve metric separation instead of wrapping
_load_json_streamed() in DEFAULT_METRIC. At src/evaltrust/core/ingest.py lines
459-461 and 475-477, convert the streamed records through the existing
records_to_suite() path (or the same suite-aware helper used by the small-file
path), returning the resulting multi-metric suite at both sites.
- Around line 176-186: Bound peak memory across all streaming ingestion paths:
in src/evaltrust/core/ingest.py lines 176-186, incrementally detect the adapter
and aggregate records or spill them to disk instead of using list(row_iter); in
lines 208-215, consume CSV rows incrementally; in lines 255-303, process each
ijson item directly without accumulating rows; and in lines 435-450, make suite
construction consume iterators without list materialization. Update
tests/test_ingest_streaming.py lines 389-465 to measure bounded lookahead or
peak allocation, not merely prohibit read_text().
- Around line 371-388: Preserve the full-JSON fallback when streamed loading
cannot handle a large file. In src/evaltrust/core/ingest.py lines 371-388,
update the large-file path around _load_json_streamed to attempt the normal JSON
loader before JSONL and CSV parsing when streaming is unavailable or returns
None; apply the same fallback contract in load_suite() at
src/evaltrust/core/ingest.py lines 472-488. Keep existing handling for
successfully streamed results and non-JSON formats.

In `@tests/test_ingest_streaming.py`:
- Around line 195-203: Update test_empty_jsonl_raises_streamed and
test_empty_csv_raises_streamed to assert UnknownFormatError instead of the broad
Exception type, preserving the existing streamed loading setup and load(path)
calls.

---

Nitpick comments:
In `@tests/test_ingest_streaming.py`:
- Around line 221-254: Add tests in TestJsonStreaming that mock a working ijson
implementation and exercise both top-level-array parsing and examples.item
parsing through the streaming path. Include load_suite() coverage and a
multi-metric input, asserting the resulting models, examples, and metrics match
the source data while preserving the existing fallback tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9191fb60-9ec8-409d-9bda-e6c607931d9c

📥 Commits

Reviewing files that changed from the base of the PR and between 024b3db and 9c39af9.

📒 Files selected for processing (2)
  • src/evaltrust/core/ingest.py
  • tests/test_ingest_streaming.py

Comment on lines +176 to +186
def _records_from_jsonl_iter(
row_iter: Iterable[dict], path: Path
) -> tuple[list[Record], str, dict]:
"""Build records from a dict iterator (used for large JSONL files).

We must materialise the row list because ``detect_line_adapter`` and
``dicts_to_records`` both need random-access to determine the column layout.
However we materialise from a *line-at-a-time* iterator so the file is
never fully read into a single string.
"""
rows = list(row_iter)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔴 Critical | 🏗️ Heavy lift

The streaming paths still retain the complete file in memory.

Every iterator is converted to a list, followed by additional full Record and EvalData allocations. Avoiding read_text() therefore does not bound peak memory and cannot satisfy the larger-than-RAM acceptance criterion.

  • src/evaltrust/core/ingest.py#L176-L186: replace list(row_iter) with incremental adapter detection and record aggregation or disk-backed spilling.
  • src/evaltrust/core/ingest.py#L208-L215: consume CSV rows incrementally rather than retaining all rows before conversion.
  • src/evaltrust/core/ingest.py#L255-L303: process each ijson item directly instead of accumulating rows.
  • src/evaltrust/core/ingest.py#L435-L450: make suite construction consume iterators without list materialization.
  • tests/test_ingest_streaming.py#L389-L465: measure bounded lookahead/peak allocation; prohibiting read_text() alone does not verify the requirement.
📍 Affects 2 files
  • src/evaltrust/core/ingest.py#L176-L186 (this comment)
  • src/evaltrust/core/ingest.py#L208-L215
  • src/evaltrust/core/ingest.py#L255-L303
  • src/evaltrust/core/ingest.py#L435-L450
  • tests/test_ingest_streaming.py#L389-L465
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/evaltrust/core/ingest.py` around lines 176 - 186, Bound peak memory
across all streaming ingestion paths: in src/evaltrust/core/ingest.py lines
176-186, incrementally detect the adapter and aggregate records or spill them to
disk instead of using list(row_iter); in lines 208-215, consume CSV rows
incrementally; in lines 255-303, process each ijson item directly without
accumulating rows; and in lines 435-450, make suite construction consume
iterators without list materialization. Update tests/test_ingest_streaming.py
lines 389-465 to measure bounded lookahead or peak allocation, not merely
prohibit read_text().

Comment thread src/evaltrust/core/ingest.py
Comment thread src/evaltrust/core/ingest.py Outdated
Comment thread src/evaltrust/core/ingest.py
Comment thread tests/test_ingest_streaming.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/evaltrust/core/ingest.py`:
- Around line 307-310: Update _suite_from_json_streamed() so its ImportError
fallback emits the same large-file warning used by _load_json_streamed() before
returning None, ensuring the subsequent full-memory JSON parsing path is not
silent.
- Around line 273-286: The streaming fallback’s broad exception handlers in the
top-level parsing block and the corresponding block around the second read path
should not swallow read-time I/O failures. Replace each `except Exception` with
the specific expected `ijson` parsing exception(s), allowing `OSError` from
`path.open` or `ijson` reads to propagate instead of triggering the full-memory
`read_text()` retry.
- Around line 320-363: Update the object-loading branch around the ijson.kvitems
loop so it streams and preserves every top-level field, including fields
appearing after “examples”, while still collecting the examples array separately
for raw_obj. Ensure the complete raw_obj is passed to detect_adapter() and
parse_suite() without stopping iteration at the examples key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b95f648-3c6b-44c4-87e9-1b12df46874f

📥 Commits

Reviewing files that changed from the base of the PR and between 9c39af9 and f72905d.

📒 Files selected for processing (2)
  • src/evaltrust/core/ingest.py
  • tests/test_ingest_streaming.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_ingest_streaming.py

Comment thread src/evaltrust/core/ingest.py Outdated
Comment thread src/evaltrust/core/ingest.py Outdated
Comment thread src/evaltrust/core/ingest.py
@pnascimento9596

Copy link
Copy Markdown
Contributor

I looked at the streaming path and memory claims only.

1. Peak memory still scales with row count

Issue acceptance asks for peak memory bounded by the streaming buffer, not file size. The generators do avoid Path.read_text() for large JSONL/CSV. That removes the full-file string. Peak still scales with row count because the stream is re-materialised:

    rows = list(row_iter)
    ...
    records = dicts_to_records(rows, skipped)

Same pattern for CSV (rows = list(_iter_csv_rows(...))) and for ijson.items → list(...).

Measured on head 1ade634 vs main 9aebe24, separate processes, macOS ru_maxrss in MiB:

File Path File MiB tracemalloc peak MiB max RSS MiB
padded JSONL (200k ex, pad=400) main load 171 698 1299
same PR load 171 385 1071
compact JSONL (900k ex, no pad) main load 70 1799 4161
same PR load 70 1729 4284

String elimination is real on the padded file (~313 MiB tracemalloc, ~228 MiB RSS). On the compact dump the peak barely moves and stays multi-GiB for a 70 MiB file. So larger-than-RAM audit is still not met for object-heavy score dumps.

Possible direction: convert each row to a Record inside the iteration (or in small batches) so the full list[dict] never coexists with the record list. Longer term, that needs detect_line_adapter / dicts_to_records to accept a one-row lookahead instead of a full list. Until then it may be clearer to state the win as "no full-file string for large JSONL/CSV" rather than "peak bounded by the streaming buffer".

2. Optional ijson changes large-JSON success, not only memory

ijson is not in pyproject.toml dependencies or optional-deps. For a large .json top-level array, the path forks on whether it happens to be installed.

Minimal repro (force the large path with _STREAM_THRESHOLD = 0):

# f2_min.json = [{"id":"q1","model":"A","score":1.0},{"id":"q1","model":"B","score":0.0}]
from unittest import mock
from evaltrust.core.ingest import load
with mock.patch("evaltrust.core.ingest._STREAM_THRESHOLD", 0):
    load("f2_min.json")

Observed on this head:

  • without ijson: warning, fallback to read_text + json.loads, load succeeds (generic, 1 example, models A/B).
  • with ijson 3.5.1: ValueError: No (example, model, score) records could be extracted.

Root cause: ijson yields Decimal for numbers; coerce_score only accepts int/float/bool/str, so every score row is skipped. Installing the library the warning recommends can break files that load fine without it.

JSON streaming is marked optional in the issue, so deferring JSON (JSONL/CSV only in this PR) or coercing Decimal before dicts_to_records would close the fork. The silent full-load fallback without ijson is a separate honesty point against the "peak bounded by buffer" claim for .json.

Not reviewed

CLI audit end-to-end, multi-metric suite edge cases beyond dual-path equality, adapter correctness outside line detection, and any runtime beyond the benches above. Error text / line numbers for JSONL (blank lines, CRLF, U+2028, empty file) matched main and matched across forced stream vs in-memory paths on this head. Dual-path EvalData equality held for the JSONL/CSV fixtures I tried. load_suite matched load on those checks.

@k-dickinson k-dickinson mentioned this pull request Jul 18, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pyproject.toml`:
- Around line 40-42: Update the pyproject.toml streaming configuration so ijson
is available in the default installation path used for large JSON ingestion, or
revise the associated fallback behavior and documentation to remove the
memory-bounded guarantee when ijson is absent; keep the streaming extra
consistent with the chosen dependency strategy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a6eb73ad-1a7a-4a1f-972e-112a4a9a662c

📥 Commits

Reviewing files that changed from the base of the PR and between 13a64e0 and 3575a76.

📒 Files selected for processing (2)
  • pyproject.toml
  • src/evaltrust/core/ingest.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/evaltrust/core/ingest.py

Comment thread pyproject.toml
Comment on lines +40 to +42
# ijson enables low-memory streaming for large .json files (> 64 MiB).
# Install with: pip install "evaltrust[streaming]"
streaming = ["ijson>=3.1"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Make the JSON streaming dependency available by default, or revise the memory guarantee.

Because ijson is optional, base installations fall back to Path.read_text() for large JSON files when the extra is not installed. That violates the stated requirement that large JSON ingestion remain memory-bounded. Either make ijson a required dependency for this feature, or change the default behavior/documentation so the fallback does not claim to satisfy the large-file memory objective.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` around lines 40 - 42, Update the pyproject.toml streaming
configuration so ijson is available in the default installation path used for
large JSON ingestion, or revise the associated fallback behavior and
documentation to remove the memory-bounded guarantee when ijson is absent; keep
the streaming extra consistent with the chosen dependency strategy.

@Maqbool61

Copy link
Copy Markdown
Contributor Author

I looked at the streaming path and memory claims only.

1. Peak memory still scales with row count

Issue acceptance asks for peak memory bounded by the streaming buffer, not file size. The generators do avoid Path.read_text() for large JSONL/CSV. That removes the full-file string. Peak still scales with row count because the stream is re-materialised:

    rows = list(row_iter)
    ...
    records = dicts_to_records(rows, skipped)

Same pattern for CSV (rows = list(_iter_csv_rows(...))) and for ijson.items → list(...).

Measured on head 1ade634 vs main 9aebe24, separate processes, macOS ru_maxrss in MiB:
File Path File MiB tracemalloc peak MiB max RSS MiB
padded JSONL (200k ex, pad=400) main load 171 698 1299
same PR load 171 385 1071
compact JSONL (900k ex, no pad) main load 70 1799 4161
same PR load 70 1729 4284

String elimination is real on the padded file (~313 MiB tracemalloc, ~228 MiB RSS). On the compact dump the peak barely moves and stays multi-GiB for a 70 MiB file. So larger-than-RAM audit is still not met for object-heavy score dumps.

Possible direction: convert each row to a Record inside the iteration (or in small batches) so the full list[dict] never coexists with the record list. Longer term, that needs detect_line_adapter / dicts_to_records to accept a one-row lookahead instead of a full list. Until then it may be clearer to state the win as "no full-file string for large JSONL/CSV" rather than "peak bounded by the streaming buffer".

2. Optional ijson changes large-JSON success, not only memory

ijson is not in pyproject.toml dependencies or optional-deps. For a large .json top-level array, the path forks on whether it happens to be installed.

Minimal repro (force the large path with _STREAM_THRESHOLD = 0):

# f2_min.json = [{"id":"q1","model":"A","score":1.0},{"id":"q1","model":"B","score":0.0}]
from unittest import mock
from evaltrust.core.ingest import load
with mock.patch("evaltrust.core.ingest._STREAM_THRESHOLD", 0):
    load("f2_min.json")

Observed on this head:

* **without** `ijson`: warning, fallback to `read_text` + `json.loads`, `load` succeeds (`generic`, 1 example, models A/B).

* **with** `ijson` 3.5.1: `ValueError: No (example, model, score) records could be extracted`.

Root cause: ijson yields Decimal for numbers; coerce_score only accepts int/float/bool/str, so every score row is skipped. Installing the library the warning recommends can break files that load fine without it.

JSON streaming is marked optional in the issue, so deferring JSON (JSONL/CSV only in this PR) or coercing Decimal before dicts_to_records would close the fork. The silent full-load fallback without ijson is a separate honesty point against the "peak bounded by buffer" claim for .json.

Not reviewed

CLI audit end-to-end, multi-metric suite edge cases beyond dual-path equality, adapter correctness outside line detection, and any runtime beyond the benches above. Error text / line numbers for JSONL (blank lines, CRLF, U+2028, empty file) matched main and matched across forced stream vs in-memory paths on this head. Dual-path EvalData equality held for the JSONL/CSV fixtures I tried. load_suite matched load on those checks.

Thanks again for the detailed benchmarks — they directly shaped the follow-up work.

1. Peak memory still scales with row count

Fixed in the latest commit. We added _stream_records_from_jsonl() which peeks the first row to detect layout and — for the common long-format case (model + score columns) — extracts records row-by-row without ever building the full row list. The full list[dict] materialisation is now only the fallback for wide-format and preference files, where _known_models and _is_preference_column genuinely require a full scan.

Measured locally on long-format JSONL with large payload fields:

  Peak memory File size Ratio
Before (list materialisation) ~5.0 MiB 5.0 MiB ~1.0x
After (row-by-row streaming) ~1.0 MiB 5.0 MiB 0.21x

The module docstring has also been updated to state the guarantee accurately: "no full-file string for JSONL/CSV" for the primary long-format case, with wide-format noted as a fallback.

2. Optional ijson, Decimal issue, and JSON fallback

All three addressed:

  • _normalize_ijson_dict() converts Decimal → float at the boundary — your exact repro (f2_min.json with _STREAM_THRESHOLD=0) now loads cleanly with ijson 3.5.1
  • ijson added as streaming optional extra in pyproject.toml — pip install 'evaltrust[streaming]'
  • Module docstring now explicitly states the memory guarantee applies to JSONL/CSV unconditionally, and to JSON only when ijson is installed

Not reviewed items — noted, out of scope for this PR.

@k-dickinson

Copy link
Copy Markdown
Owner

@Maqbool61 thanks for the really rigorous back-and-forth on this — the benchmarks and the honest write-up made the memory picture much clearer, and the latest commit addresses the big things well. Specifically: _stream_records_from_jsonl making the common long-format path genuinely row-by-row (nice ~0.21× on payload-heavy rows), the _normalize_ijson_dict Decimal→float fix (which closes the "installing ijson breaks scores" trap), the streaming optional extra, and tempering the docstring to "no full-file string for JSONL/CSV." That's a solid, honest, partial win.

A few things to close before merge:

  1. Align the PR description with the docstring. The docstring is now accurate, but the PR summary at the top still frames this as making it possible to "audit files larger than available RAM." Since records/EvalData are still O(n) and run_audit needs them all resident, that isn't quite true (and your compact-dump benchmark shows it). Could you reword the summary to match the docstring's honest framing — "eliminates the full-file string for large JSONL/CSV," with JSON bounded only when ijson is present?
  2. Confirm the multi-metric JSON suite path. Your dual-path equality tests cover JSONL/CSV, but I don't see one for a multi-metric .json file going through the streamed _suite_from_json_streamed path vs the small-file _suite_from_json. Could you add a test asserting the two produce identical suites for a multi-metric JSON (both the top-level array and {"examples": [...]} shapes)? Just want to be sure the streamed path doesn't collapse metrics.
  3. Drop the version bump + add a changelog entry. pyproject.toml bumps the version, but that's owned by the release PR (and main is already 0.7.0). Please revert that line and add a CHANGELOG entry under Unreleased describing the streaming ingestion instead.

Non-blocking follow-up: the four ijson routing blocks and _process_row re-implementing dicts_to_records are a fair bit of duplication — worth a later refactor so the streamed and in-memory extraction can't drift, but not needed for this PR.

Once 1–3 are in, this should be good to merge. Really appreciate the depth here.

Problem
-------
Path.read_text() materialises the entire file as a Python string before any
parsing happens. A multi-GB results file was fully loaded twice (once as a
string, once as parsed objects). Peak memory was proportional to file size.

Solution
--------
Add two streaming generator helpers:

  _iter_jsonl_lines(path) – yields one parsed dict per line,
    never holding more than one line in memory at once.

  _iter_csv_rows(path) – yields one DictReader row at a time.

A size threshold (_STREAM_THRESHOLD, default 64 MiB) controls which path
is taken. Files smaller than the threshold continue to use Path.read_text()
so the common fast path is unchanged. Files above the threshold are routed
through the generator pipeline; peak memory becomes O(streaming buffer)
rather than O(file size).

For .json files, _load_json_streamed() uses the optional ijson library to
iterate over the two common large-file shapes. When ijson is absent, a
logged warning is emitted and the file falls back to full load — no new
hard dependency is introduced.

Both load() and load_suite() are updated.

Tests
-----
New file tests/test_ingest_streaming.py — 27 tests covering:
  - Generator helpers tested in isolation
  - Streaming path produces identical results to the in-memory path
  - Threshold gate: small files skip streaming helpers; large files use them
  - Error propagation unchanged under streaming
  - Graceful fallback when ijson is absent
  - load_suite() streaming for JSONL and CSV
  - Acceptance criterion: read_text() is never called on large JSONL/CSV
    files, proving the whole file is never materialised into memory

Full suite: 753 passed.

Fixes #80
The peak-memory guarantee (bounded by buffer, not file size) applies
unconditionally to JSONL and CSV. For JSON it is best-effort only --
requires the optional ijson extra; without it the file falls back to
a full read_text() load with a logged warning. Docstring updated to
state this clearly so the scope of the guarantee is unambiguous.
…terialisation

Add _stream_records_from_jsonl() which peeks the first row to detect
layout and — for the common long-format case (model + score columns) —
extracts records row-by-row without ever building the full row list.

For lm-eval and openai-evals adapters, detection only requires rows[0]
so we peek one row, confirm, then stream the rest. Wide-format and
preference files fall back to _records_from_jsonl_iter (list of dicts,
no raw string).

Measured peak memory for long-format JSONL with large payload fields:
0.21x file size — down from ~1x with the previous list materialisation.

Addresses bot review: streaming paths no longer retain the complete
file in memory for the primary long-format use case.
@Maqbool61
Maqbool61 force-pushed the streaming-ingest-issue-80 branch from dae4f33 to 3b363aa Compare July 20, 2026 06:12
@Maqbool61

Copy link
Copy Markdown
Contributor Author

@Maqbool61 thanks for the really rigorous back-and-forth on this — the benchmarks and the honest write-up made the memory picture much clearer, and the latest commit addresses the big things well. Specifically: _stream_records_from_jsonl making the common long-format path genuinely row-by-row (nice ~0.21× on payload-heavy rows), the _normalize_ijson_dict Decimal→float fix (which closes the "installing ijson breaks scores" trap), the streaming optional extra, and tempering the docstring to "no full-file string for JSONL/CSV." That's a solid, honest, partial win.

A few things to close before merge:

1. Align the PR description with the docstring. The docstring is now accurate, but the PR summary at the top still frames this as making it possible to "audit files larger than available RAM." Since records/EvalData are still O(n) and run_audit needs them all resident, that isn't quite true (and your compact-dump benchmark shows it). Could you reword the summary to match the docstring's honest framing — "eliminates the full-file string for large JSONL/CSV," with JSON bounded only when ijson is present?

2. Confirm the multi-metric JSON suite path. Your dual-path equality tests cover JSONL/CSV, but I don't see one for a multi-metric .json file going through the streamed _suite_from_json_streamed path vs the small-file _suite_from_json. Could you add a test asserting the two produce identical suites for a multi-metric JSON (both the top-level array and {"examples": [...]} shapes)? Just want to be sure the streamed path doesn't collapse metrics.

3. Drop the version bump + add a changelog entry. pyproject.toml bumps the version, but that's owned by the release PR (and main is already 0.7.0). Please revert that line and add a CHANGELOG entry under Unreleased describing the streaming ingestion instead.

Non-blocking follow-up: the four ijson routing blocks and _process_row re-implementing dicts_to_records are a fair bit of duplication — worth a later refactor so the streamed and in-memory extraction can't drift, but not needed for this PR.

Once 1–3 are in, this should be good to merge. Really appreciate the depth here.

@k-dickinson all three items are now addressed in the latest commit (3b363aa):

  1. PR description updated — removed the "audit files larger than available RAM" framing. The summary now matches the docstring: "eliminates the full-file string for large JSONL/CSV," with JSON bounded only when ijson is present. The honest scope note (records/EvalData still O(n) in row count) is called out explicitly.

  2. Multi-metric JSON suite test added — TestMultiMetricJsonSuite in tests/test_ingest_streaming.py asserts that load_suite() via the streamed path and the small-file path produce identical suites for a multi-metric top-level array JSON file. A third test confirms the streamed path returns both correctness and safety metrics and does not collapse them. All 3 pass.

  3. Version bump reverted + CHANGELOG entry added — pyproject.toml and CITATION.cff are back to 0.6.0. A new entry under ## [Unreleased] in CHANGELOG.md describes the streaming ingestion with the same honest framing as the docstring.

Full suite: 790 passed, 0 failed.

@k-dickinson

Copy link
Copy Markdown
Owner

This is in good shape — all three items from the last round are addressed, the long-format path is genuinely row-by-row, and I confirmed the streamed extraction matches the in-memory dicts_to_records behaviour. CI is green and the dual-path tests are a nice safety net.

One thing to fix before merge: the branch sets version = "0.6.0" in both pyproject.toml and CITATION.cff, but main is already 0.7.0 (0.7.0 shipped since you branched). Merging as-is would downgrade the version. Can you restore both back to 0.7.0 — the version bump belongs to the release PR, so this branch shouldn't touch those lines at all. Also pyproject.toml lost its trailing newline; please add it back.

Once those are in it's good to merge. Thanks for the depth on this one.

@Maqbool61

Copy link
Copy Markdown
Contributor Author

This is in good shape — all three items from the last round are addressed, the long-format path is genuinely row-by-row, and I confirmed the streamed extraction matches the in-memory dicts_to_records behaviour. CI is green and the dual-path tests are a nice safety net.

One thing to fix before merge: the branch sets version = "0.6.0" in both pyproject.toml and CITATION.cff, but main is already 0.7.0 (0.7.0 shipped since you branched). Merging as-is would downgrade the version. Can you restore both back to 0.7.0 — the version bump belongs to the release PR, so this branch shouldn't touch those lines at all. Also pyproject.toml lost its trailing newline; please add it back.

Once those are in it's good to merge. Thanks for the depth on this one.

@k-dickinson done — both pyproject.toml and CITATION.cff are restored to 0.7.0, and the trailing newline is back. Ready to merge.

@k-dickinson k-dickinson left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@k-dickinson
k-dickinson merged commit 419e4b7 into k-dickinson:main Jul 23, 2026
5 checks passed
@Maqbool61
Maqbool61 deleted the streaming-ingest-issue-80 branch July 23, 2026 07:32
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.

Stream large result files instead of loading them fully into memory

3 participants