Skip to content

fluree rdf convert: the parallel path scales and the memory is attributable - #1578

Open
aaj3f wants to merge 37 commits into
feat/rdf-toolkit-m1from
feat/rdf-perf-wave4
Open

fluree rdf convert: the parallel path scales and the memory is attributable#1578
aaj3f wants to merge 37 commits into
feat/rdf-toolkit-m1from
feat/rdf-perf-wave4

Conversation

@aaj3f

@aaj3f aaj3f commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

fluree rdf convert: the parallel path scales and the memory is attributable

Two workstreams, one branch: the parallel convert path goes from 1.94× at sixteen workers to 8.22× (the single-threaded chunk scan was the entire cap), and peak RSS on the worst cell drops 1091 → 294 MiB (the footprint is now deterministic run-to-run, not just smaller). Thirty-one commits on feat/rdf-toolkit-m1 (#1569): sixteen on the parallel/scan path, fifteen on memory. Every number below is dev-signal from an M-series laptop (host_class darwin-arm64-translated) — nothing here is a publishable claim until the official-locus run; commands and raw data ship with the branch.

Headline measurements

Scaling (G1b-3, 143.5 MiB / 6M-triple Turtle corpus, best-of-3, quiet box, zero loud points):

K wall speedup end-to-end
1 7.013s 1.00× 20.5 MiB/s
4 2.092s 3.35× 68.6 MiB/s
8 1.088s 6.45× 131.9 MiB/s
16 0.853s 8.22× 168.1 MiB/s

The chunk scan that was 2.85s and 43% of wall (K-independent, ~52 MB/s) now runs at ~556 MiB/s and holds flat at ~258 ms across every K. The 8.22× landed inside the band pre-registered from the scan-fix model before measurement (7.7–10.1×). The next cap is named: the scan is 30% of wall at K=16; parallelizing it at K offsets is a recorded follow-up, not part of this PR.

Memory (peak RSS, interleaved A/B, 3 reps/arm, 4M-statement corpora, zero paged runs):

cell before after spread after
nt, serial 1091.0 MiB 293.7 MiB −73.1% 293.7–293.7
ttl, serial 468.7 MiB 326.4 MiB −30.4% 322.5–327.0
nt, 16 workers 656.8 MiB 417.8 MiB −36.4% 410.2–420.2
ttl, 16 workers 403.4 MiB 314.5 MiB −22.0% 313.0–318.0

The before-column's nt-16 cell ranged 651–894 MiB across three identical runs; every after-cell is reproducible to within ~10 MiB. Output is byte-identical before and after on all cells.

Where we stand against the field (Tier-2 4M-statement matrix, correctness-gated — every cell's output verified equivalent across all tools): single-threaded, we are about half serdi's speed (3.79s vs 1.96s on Turtle) and behind riot (2.56s); with the parallel path at defaults we convert the same corpus in 0.63s. That 6.39M stmt/s figure is our own sixteen-worker ceiling measured beside four single-threaded tools — it is not a like-for-like win over anyone. The honest sentence: about half serdi's speed single-threaded, and we scale where they do not. Validation is measured at ≤1.5% of wall (serial validate 3.788s vs nocheck 3.740s).

The scan/parallel line (16 commits)

The G1b-2 finding was that the worker pool was already at 92.5% of ideal width and the single-threaded pre-pass was the whole ceiling. The fixes, each with a failing-before test:

  • The chunk pre-scan lexed the whole document to find a ~35-byte header — a 512 MiB token vector on large inputs and the entire 2.85s Amdahl term. Capped, then made streaming: the header token walk now pulls tokens one at a time and stops at the first non-directive, so a lex error anywhere after the header no longer forces serial fallback, and a remaining header-scan error genuinely means a malformed header. A valid document with a long literal spanning the old 1 MB window now converts parallel, byte-identical to serial.
  • Dispatch-class bugs (input-syntax vs output-syntax confusion): the parallel path parsed every input as Turtle; --continue-on-error re-parsed as Turtle too; --nocheck was ignored at three parse sites (now honored, with p1/p8 byte-identity pinned, because agreeing on the exit code is not enough when the flag turns off a check).
  • The output budget could hang the pool on a parse error (workers parked on a condvar nothing would notify). The pool now has one shutdown signal, BudgetState.stopped, and every writer of it notifies by construction — the second-store-site hang is unrepresentable rather than guarded. The liveness proof is a saturated-budget test that fails at 90.03s when the unconditional stop is removed; the dead-reader matrix structurally cannot see this guarantee, and the comment now says so.
  • The parallel path's memory is bounded and the bound is measured: budget (256 MB at ceiling) + one chunk per worker overshoot + the input held whole — 1620 MiB observed on a 745 MiB input at K=16, and the docs state that arithmetic instead of the budget figure alone.
  • Reporting honesty: Phase::Chunk lane (the scan was 43% of wall charged to "unattributed"); parse errors on the parallel path now relocate to document coordinates (previously a failure on line 90,002 was confidently reported at line 82); the resync note no longer cries wolf on multi-line statements (a candidate span is re-parsed standalone before being reported as swallowed — a fragment cannot parse, a real statement can); all prose on stderr routes through one gate that knows --profile=json means stderr is a document (this had regressed at six call sites with three spellings of the same guard).

The memory line (15 commits)

Three defects, separately attributed by a tracking-allocator instrument (mem_attrib.rs, live-heap with fetch_max-maintained peak, committed as an example):

  1. The guarded input read doubled its way to 2× the documenttake(LIMIT+1) erased the File size specialization. Now reserves from metadata (clamped at the refusal limit; a lying length cannot turn into an allocation failure; oversized inputs are refused from metadata before reading 4 GiB).
  2. The parser pre-reserved term caches from document length (~210 MB of empty table on a 118 MB input). Length is not evidence of distinct-term count; the reservation decision moved to the caller — import passes its chunk-derived estimate, convert passes nothing. Both directions verified: at 2M distinct terms the hint saves 199 MiB; at 1k it would have cost 37.
  3. Writing sinks held one term per occurrence against producers that never reuse ids (814 MiB of term table for a document whose widest statement needs three slots). GraphSink::declare_term_scope lets a producer declare statement-scoped ids; sinks recycle a tagged region at statement boundaries. The default is the existing contract, a sink may ignore a declaration, and a sink may never honor one that was not made.

The tests here are witnesses, not just contracts: the size hint has an efficacy witness that fails if a null implementation accepts-and-ignores it (allocation-ratio based, with a lower bound so a degenerate corpus fails as vacuous); the Arc handover has a slope witness (1.00 clean, 2.00 with the second allocation restored, and the failure names the cause); the statement-scope guard stamps a generation into the id and panics on the dense re-mint shape that previously wrote one subject's data under another's label. The witnesses measure through a process-global allocator, so they serialize behind a mutex — nextest's process-per-test isolation is not assumed (cargo test and nextest are both green, and both figures are quoted below because they will always differ by the doctest count).

Gates at the assembly pin

cargo nextest run  (5 crates)   1399 passed / 0 failed
cargo test         (5 crates)   1413 passed / 0 failed / 5 ignored   (= 1399 + 14 doctests)
fmt clean · clippy clean (--all-targets --no-deps, 5 crates)

Both runners are quoted deliberately: they disagree by a fixed doctest offset that looks like a discrepancy and is not, and cargo test's thread-per-test model is the only runner that can see cross-test contamination of process-global state (it caught exactly one such bug in this branch's own witness, fixed here).

Each line was independently adversarially reviewed before assembly (three passes on the scan line, three on the memory line), with mutation testing on every claimed guard — the review protocol's standing rule, derived the hard way three times in this wave: before citing a test as verifying a property, mutate the property and confirm it goes red. The measurement provenance (quiet-window discipline, per-run load and pageout-rate recording, interleaved A/B arms, pre-registered predictions, correctness gate over every matrix cell) is in riot-analog-pr-plan.md §1b-3.

The harness that produced the matrix rides along

The final six commits are the Tier-2 comparison harness refinements (benchmarks/conversion/) that generated every matrix number above, so the evidence chain is inspectable in the same diff as the claims: per-tool pinned invocations with the flag-breakage notes that motivated them (riot 6.x bare --check, oxigraph 0.5.x --from-file), the cross-agent bench lock (O_EXCL, pid-matched release, PIPE in the trap because piping a run into head strands the lock — found by testing exactly that), worker counts in our row names so a 16-worker cell can never again sit unlabeled beside single-threaded competitors, the BSBM-shape corpus generator (Turtle-only on purpose: generating the NT half with our own writer would benchmark every tool on input shaped by the tool under test), and the correctness gate's isomorphism-skip message now deriving its blank-node sentence from the cells rather than asserting it. The run manifest records available_parallelism, so an auto row's resolved worker count is recoverable from the artifact on any host.

Named follow-ups (recorded, not in this PR)

  • Parallelize the chunk scan at K offsets (the remaining 30%-of-wall term).
  • fluree.rdf.profile.v1 gains an optional parse-failure field, so the last prose site (report_parse_failure, serial invalid-document path) can route through the stderr gate without trading unparseable stderr for an unexplained exit 1. Schema change, own commit, own review.
  • The resync note's "count cannot be repaired" caveat can be retired (the standalone re-parse yields the statement count).
  • Three flaky-test observations logged for filing: it_property_path_batched lane assert, FIR6-root reload race, it_values_type_deleted_repro (contention-correlated, structurally unrelated to this branch).

aaj3f added 30 commits July 30, 2026 13:12
The pre-scan is single-threaded and runs to completion before the first worker
starts, so its cost is the ceiling on parallel conversion — measured at 2.85 s
of a 3.38 s run at 16 threads, which capped the whole parallel path at 2.29x no
matter how many threads it was given.

It was not a slow scanner. `chunk_in_memory` handed the ENTIRE document to
`extract_prefix_block_from_bytes`, which runs `find_safe_header` — last newline
in the whole buffer — and then `tokenize()`, the real Turtle lexer, over all of
it, to recover a 35-byte prefix block. The two file-based callers already read
no more than PREFIX_SCAN_SIZE; the in-memory one never capped, and the cost was
charged to the chunker, so it looked like byte-scanning was slow.

The cap now lives inside `extract_prefix_block_from_bytes`, where no future
caller can forget it. A header longer than the cap behaves exactly as it
already does on the file path: the directives past it are not recognised,
chunking refuses with PrefixAfterData, and the run converts serially. Loud
rather than silently wrong.

The scanner itself gets a fast path, worth a further 2.2x. Most bytes cannot
change anything — literal bodies, IRI interiors, comment text, name characters
— so `skip_len` reports how many of them start a slice in the current state and
`absorb` accounts for the run in one step. `feed` remains the only place scan
semantics live: the fast path can only skip, never decide, and a `[u8; 256]`
table enumerates the bytes each state must stop on. All three call sites use
it, including the streaming one bulk import runs.

Best-of-5 on a 143.5 MiB corpus, via the committed `scan_probe` example:

                     no fast path      fast path
  no cap             49.6 MiB/s        55.5 MiB/s
  1 MB cap          303.2 MiB/s       680.8 MiB/s

13.7x end to end, 2.89 s to 0.211 s. Building the fast path first and measuring
it at 55.5 MiB/s is why the cap was found at all: a 7% result on a hypothesis
that predicted 10x is a sign the hypothesis is wrong, not that the constant
needs tuning.

Three tests pin the fast path to the slow one: a differential over 25 hostile
fixtures, a differential over every prefix of a hostile document — which puts a
buffer cut at every byte — and a table-contract test asserting the stop set
equals the bytes the scanner actually branches on. Dropping `#` from the table
fails four tests, one of them pre-existing.

The scan also gets its own `Phase::Chunk` lane, because it spent a whole bucket
inside `unattributed_ns` where nothing owned it and nobody optimized it. A
parallel run must now report a non-zero chunk phase and a serial run must not
report one at all — a lane that never fires is worth as little as no lane.
The streaming reader runs on its own thread and surfaces scan failures — a
mid-file `@prefix` redefinition, an I/O error part-way through — only through
`StreamingTurtleReader::join`. The channel closes either way, so `Ok(None)`
means "no more chunks", not "the source was read": a caller that stops there
imports a silently truncated graph and reports success.

Documented rather than fixed because the fix is a breaking change to a `pub`
method — `recv_next` takes `&self` and `join` needs `&mut`, so it cannot join
on the caller's behalf without changing its signature — and there are no
callers in this workspace to migrate. The streaming import paths drive the
reader directly and do join at every completion site. The doc says what a
future caller must do and what the safer shape would be.
`the_sampling_error_bound_is_reported_and_grows_with_dispersion` asserts that a
corpus whose statements vary by 200x reports a wider bound than a uniform one.
Both sides were measured once, and a single preemption in the uniform arm makes
a uniform corpus look dispersed — so the test failed on a loaded machine for a
reason that had nothing to do with the code under test.

Each arm is now the MINIMUM of three trials. Contention only ever ADDS
dispersion, since a preempted statement is indistinguishable from a slow one,
so the minimum is each arm's least-corrupted estimate — the same reasoning that
makes best-of-N the right reduction for a wall-clock measurement.
…inished

Supersedes the hazard documented one commit ago. The streaming reader runs on
its own thread and reports scan failures — a mid-file `@prefix` redefinition,
an I/O error part-way through — only through `StreamingTurtleReader::join`. The
channel closes either way, so `Ok(None)` said "no more chunks" when it meant
"the reader died at 10%", and a caller draining to end-of-stream imported the
truncated half and called it a success.

End-of-stream now joins and surfaces what the reader was holding. Joining takes
the thread handle, so a second end-of-stream has nothing to report and a caller
that keeps polling keeps getting `Ok(None)` rather than a spurious error. That
requires `&mut self`, which is a breaking change to a `pub` method with no
callers in this workspace and no external publication — the cheapest moment
this fix will ever have.

Three tests, and the middle one demonstrates the hazard rather than describing
it: a clean stream drains to `Ok(None)`; a document with a mid-file
redefinition reaches the caller as an error naming a failed READ; polling past
the end stays quiet. Removing the join makes the second return `Ok(11)` —
eleven chunks imported, the rest of the document silently discarded, reported
as success.
`ParallelPlan::decide` consulted the OUTPUT syntax and never the input, and the
workers parsed every chunk with the Turtle parser whatever the input actually
was. Two different bugs from one confusion, and the first is silent.

An N-Triples file above the 4 MiB threshold was read by a parser that accepts
directives, prefixed names, bare numbers and long-quoted strings — precisely
what the strict N-Triples reader exists to reject, and what `riot` and every
other tool in the field reject. So `--parallelism 1` refused those documents
and the default path accepted them: the strict-reader parity that `rdf check`
and `rdf convert` promise silently disappeared for any real-sized `.nt` file.
`write_chunk` now dispatches to the same four readers `rdf::parse_into` uses,
and for the same stated reason.

The second is loud but just as wrong: `.trig` and `.jsonld` inputs were chunked
as though they were Turtle. TriG writes fine as concatenated fragments — which
is what the output check answers — but its statements live inside `GRAPH … { … }`
blocks while the boundary scanner cuts at `.`, so a cut lands mid-block and
neither half parses. JSON-LD has no statement terminators at all. `decide` now
takes both syntaxes, because they answer different questions: `target` asks
whether the OUTPUT survives being written in fragments, `source` asks whether
the INPUT survives being cut. Inputs that cannot be cut convert serially, with
the reason in `--profile`.

Line formats keep their parallelism — they are the common case for large files
and they cut trivially — they just get the right reader now.

Tests fail against the old behaviour: an above-threshold `.nt` under a leading
`@prefix` and one using bare numbers must both exit 1 at `--parallelism 1` AND
8 (they exited 1 and 0), and an above-threshold `.trig` must convert whole with
`threads_used: 1` and a reason naming the cut (it was chunked at 8 and failed).
Same bug as the parallel path, third site. `parse_recovering` called the Turtle
parser directly, so recovery on a `.nt` file accepted every Turtle-only
construct the strict N-Triples reader exists to reject.

Worse here than in the workers, because recovery's whole contract is that it
reports what it dropped. A construct that PARSES is never skipped, so nothing
was reported: the run exited 1 for the genuinely bad line, printed one
diagnostic, and silently wrote a bare integer into an N-Triples file as though
it were a term.

Recovery now dispatches to the same four readers as `rdf::parse_into` and
`write_chunk`. The line formats take neither prefixes nor a base, because they
have neither.

Verified by reverting the dispatch: the fixture goes from "2 skipped, 2
written" to "1 skipped, 3 written", with `42` in the output.

The fixture is worth a note for whoever edits it — every line terminates,
including the junk one. Recovery resyncs by scanning to the next terminator, so
a junk line without one swallows the statement after it, which is exactly how
the first draft of this test hid the line it was written to catch.
… chunks

Two independent causes of a 16-worker run sitting near a gigabyte where the
serial path holds ~300 MB, both found by W-mem.

The channel bounds how many CHUNKS are in flight, which is not a bound on
memory: one chunk's N-Triples output is several times its Turtle input, and the
reorder buffer is unbounded besides — one early chunk finishing last parks every
later chunk in `pending` with nothing capping the pile. `OutputBudget` bounds
the BYTES of converted output waiting to be written, at `workers × chunk_bytes
× 4` clamped to [32 MiB, 256 MiB]: enough that every worker can hold one
chunk's output without the pool serializing, and no more.

Workers wait for room BEFORE taking work, never while holding a finished chunk.
That ordering is the whole safety argument: the writer never waits on this
budget, so it always drains, so the bytes a waiting worker is waiting for are
always on their way out. A worker that blocked with output in hand could be
holding the very chunk the writer wants — which is the deadlock this pool has
already had once, and this must not reintroduce it. The wait is also timed
rather than indefinite, so a worker cannot miss the shutdown flag being set
between checking it and sleeping on the condvar.

Separately, `write_chunk` copied `prefix_block + body` into a fresh String for
every chunk. The line formats have no directives, so their prefix block is
empty and the copy achieved nothing — ~80 MB of pointless copying on a 4 million
triple N-Triples corpus, which is also the case where chunks are biggest. It
now borrows the body when there is no prelude to prepend.

Peak-RSS A/B is NOT in this commit: the machine spent the measurement window at
load 289 from an unrelated filesystem event, and a peak-RSS number taken under
memory pressure is understated rather than merely noisy. The numbers follow when
the box is real.
…ould hang

H1, the fifth dispatch instance. `--nocheck` was honoured by `rdf::parse_into`
and hardcoded away everywhere else: `write_chunk`, `parse_recovering` and the
dead `parse_chunk` all built `ParserOptions::conformant()` from nothing. Over
the 4 MiB threshold, `-p1` exited 0 with validation off and `-p8` exited 1 on
the same file. Options now thread through beside `source`, at every site.

Both dispatch matches are exhaustive over `RdfSyntax` — no `_` arm — so a
syntax added later is a compile error rather than a silent inheritance of
Turtle's reader. That earned itself immediately: writing the arm out is what
revealed RdfXml, RdfJson and Jelly were already in the enum and would have been
swallowed by the catch-all.

The condvar in the previous commit was wrong, and its comment was wrong about
why it was right. The shutdown flag was an external `AtomicBool` whose setter
took no lock and sent no notification, so the 50 ms `wait_timeout` was the only
thing preventing a lost wakeup — "notices within one timeout period", not
"cannot miss the flag". Both halves of the predicate now live in one
`Mutex<BudgetState>`, every writer of either notifies, and the timeout is gone.

Removing the timeout exposed a second hang it had been masking. The writer is
the only releaser of budget and it has three exits: all chunks written, a write
failure, and a parse error that cuts the document short with bytes still
charged in `pending`. Only the first two woke anyone. `budget.stop()` is now
unconditional after the reassembly loop — the dead-destination deadlock again,
reached through the memory bound instead of the channel.

Recovery also stopped lying about what it dropped. Resync scans to the next
terminator, so a failing statement with none of its own consumes the statement
after it — unparsed, undiagnosed, uncounted, with stderr byte-identical to the
honest case. The count cannot be repaired (nothing parsed those bytes, so
nothing knows how many statements they held) but the span can be shown, on its
own `note:` line so the honest case stays byte-stable, and suppressed under
`--profile=json` like every other prose line.

Guards, all of which were mutable at the previous pin: the cap had no
regression test (deleting it left the suite green — adopted review-scan's
oversized-header probe, which pins that the in-memory and file paths reach the
SAME verdict); three long-string escape fixtures, because no fixture put an
escape inside a long string and a missed one ends the literal early and cuts a
chunk INSIDE it, on the scanner bulk import runs; and the table contract now
covers all seven masks rather than one.

The peak-RSS A/B for the previous commit's memory bound is OWED, not delivered:
it needs a machine that is not paging, and it is queued for the same lock hold
as the G1b-3 sweep. Until it lands, that commit's claim is unevidenced.
L1 from the review. `recv_next`'s "the channel closed" note is pasted twice
above the same match arm. No behaviour and no reader benefit — the second copy
only makes the arm harder to scan.
L5. A worker parses `prefix block + its chunk`, so the byte offset a failing
chunk reports is an offset into that synthesized document. The reporting path
dropped it instead of translating it, so one document failed with
`file.ttl:90002:13:` at `--parallelism 1` and a bare `file.ttl:` at 8 — a
difference in the diagnostic caused by a flag documented never to be a
correctness decision.

Rendering the offset untranslated would have been worse than dropping it: it
names a line inside the chunk, which on the new fixture is line 82 for a
failure on line 90,002. So the offset moves into the document at the source,
in `write_chunk`, which is the only place the chunk's start is known. The map
is exact both sides of the seam — the prelude is a verbatim copy of the
document's header, so an offset inside it is already a document offset, and
an offset past it is `chunk start + (offset - prelude length)`. Only the parse
result goes through it: a failure to build the writer carries a placeholder
`0` that is not a position and must not be rendered as one. The dead
`parse_chunk` gets the same treatment for the same reason it got the
`--nocheck` fix — `check` and `count` inherit it the day they go parallel.

Both paths now report through one function rather than two near-copies, since
the copy is what drifted. `report_parse_failure` takes a statement count
rather than `WriterStats` so the parallel path, which has no single writer to
ask, can call it.

L3. `Phase::Chunk` documented itself as "it exists only when the run chunks —
a serial run reports zero", and a run that attempts to chunk and falls back
reports a non-zero chunk lane beside `threads_used: 1`. On the oversized-header
fixture that is 166 ms, and on a fallback it is pure overhead: the case where
the lane's number is most worth having. The doc said the pairing was
impossible, which invites reading it as a bug in the profile.
…he budget

Every fix in the previous pin's H1/(b) commit could be reverted with the suite
still green. These are the tests that stop that, verified by reverting each
hunk and watching them go red.

`--nocheck` gets a test per dispatch site rather than per verb. The flag was
honoured by `rdf::parse_into` and built from nothing at the other three, so
the matrix runs a bad-language-tag corpus past the 4 MiB gate at
`--parallelism 1` and 8 and under `--continue-on-error`, with the flag and
without it. It also pins that the two widths produce the same BYTES: agreeing
on the exit code is not enough when the flag turns off a check rather than a
parser, and a parallel path that accepted the document by reading it with
different options would pass the weaker assertion. Reverting either
`write_chunk` or `parse_recovering` to `conformant()` fails it.

The resync swallow-note gets a positive case, a negative control, and a case
pinning WHICH bytes the span names — content after the resume point on the
same line survives and must not be reported lost. The control is why the note
has a line of its own, and it does not go red when the note is removed; that
is what a control is for, and saying so here stops a later reader from
"fixing" it.

The budget's third exit gets the bounded-wait shape the dead-reader matrix
uses. Getting a worker into `wait_for_room` at all is the whole difficulty:
with a fast destination the writer drains faster than the pool fills, the
budget never binds, and a first attempt against `-o FILE` passed with the fix
reverted. So the destination is a pipe drained at ~2 MB/s against a pool that
manages several times that, and the corpus expands ~25x from Turtle to
N-Triples so the few chunks that can be in flight exceed the budget's 32 MiB
floor. Removing the unconditional `budget.stop()` hangs it at the 90 s ceiling.

Two properties of that corpus are load-bearing and easy to lose in an edit.
The bad statement must LEX — the chunker tokenizes the first megabyte looking
for the header, so a lexical error there makes the document unchunkable and
the whole run falls back to serial, testing nothing. And the test asserts the
fallback note is ABSENT, so a fixture that quietly stops being chunked fails
instead of passing on the serial path, where there are no workers to deadlock.
R1. A statement may span lines and still carry its own terminator, so the
resync lands on ITS `.` and eats nothing. The check split the error-to-resume
span at the first newline and reported whatever followed, which on such a
document is the statement's own second line: 24 bytes announced as lost while
every statement converted.

No positional rule can fix it. The honest case and the false positive are the
same shape — error on line N, resume at the end of line N+1:

    junk with no terminator          ex:bad ~~~ "still the same statement"
    ex:c ex:p "3" .                      ex:more "and more" .

Only the left one lost anything, and the only signal separating them by
position is indentation, which Turtle does not have — the splitter carries a
test asserting exactly that. So the question is what the bytes ARE. A
statement the resync consumed is a statement and parses as one against the
prefixes in force; a continuation is a fragment of one and does not.

The obvious second check — a trailing `;` or `,` means the next line is a
continuation — is in the "why this is not here" comment rather than the code,
because it is wrong in the direction that matters. `ex:bad ~~~ ;` followed by
`ex:c ex:p "3" .` has no terminator of its own, so the resync really does eat
`ex:c`; with that check in place the document lost a statement and stderr said
nothing, which is the bug this note exists to end. It buys nothing either: a
`;` continuation is `predicate object` and a `,` continuation is one object,
so neither parses standalone and the parse test already refutes them. The
guard against reinstating it is a test, not a comment.

Re-parsing needs the reader, prefixes, base and options the recovery itself
used, so the dispatch is lifted into one `parse_fragment` rather than copied —
this file has already paid once for a copied dispatch, when `--nocheck` was
honoured at one of five sites.

The prefixes it re-reads are the LIVE ones, not the snapshot taken at the top
of the loop. That snapshot predates the parse that records the directives, so
on the first pass it is empty and a candidate using a prefix declared on line 1
fails to parse — the swallow would go unreported, silently, which is the exact
failure being fixed. Caught by the existing four-line fixture going quiet.
Six prose sites in this file, three different spellings of the same predicate,
and one site that omitted a term. The chunk-fallback note was guarded by
`!quiet` alone, so `convert big.ttl --profile=json 2> run.json` on a document
that turns out not to chunk wrote `note: … converting serially` ahead of the
JSON and left the file unparseable — on exactly the large inputs most likely to
be profiled.

The predicate now lives in one type and the emit goes through it, because the
per-call-site version is a rake that has now been stepped on twice: this is the
same defect the ✓-line guard was added to prevent, reappearing at a site that
did not copy the guard.

Two levels, not one, and collapsing them would be a regression. Courtesy lines
— the ✓ summary, the fallback note — are what `-q` exists to silence.
Diagnostics — every skip, the swallow note, the closing warning — survive `-q`
on purpose, because a script that asked for quiet still must not read a partial
conversion as a whole one. Both go quiet when stderr is carrying a JSON
document. The 2x2 test over (quiet x profile) is what stops a later
simplification folding the levels together; giving `diagnostic` the `courtesy`
body fails it at `-q true: a dropped statement went unreported`.

`report_parse_failure` is the one exception and stays outside, with the reason
written down rather than left to be rediscovered. On the serial path it prints
before the profile is emitted, so it has the same leak — but `ProfileReport`
has no field for a parse failure, so routing it through `diagnostic()` would
trade an unparseable stderr for an unexplained exit 1. The real fix is a
diagnostic field in the profile schema, which is a schema change and not this
commit.
… literals

R2. `every_mask_matches_what_its_state_branches_on` restates the STOP table as
hand-written byte lists, so it re-asserts the table against a copy of itself
and cannot notice `feed` growing a branch the table does not list. The sibling
test directly below it already does the right thing for `STOP_NORMAL` —
`branched_on` is derived from `is_token_delimiter` and `keyword_byte_matches`
rather than restated — so the pattern was there to follow.

The new test puts a scanner in each of the six other table states, feeds one
byte, and asks whether anything but `prev_byte` changed. `prev_byte` is
excluded because it changes on every byte by construction: it records what was
seen, not what was decided.

`STOP_NORMAL` is deliberately not in the list. It is the one state where "a
field changed" is the wrong question, because `advance_state` feeds
`prefix_check` there and `absorb` replicates exactly that for a skipped run —
and it already has a derived test. In the other six neither touches
`prefix_check`, so any change beyond `prev_byte` is a decision the fast path
would have skipped straight past. `InStringEscape` has no mask at all:
`skip_len` returns 0 for it unconditionally.

A fresh scanner is not a simplification, it is the configuration under test.
`skip_len` returns 0 while any lookahead is outstanding, so the masks only ever
describe a state with nothing pending.

The two mutations the review named both bite, and — the reason for the exercise
— both leave the literal test green: an `InIri` that also ends at `\n` fails
with "byte 10 ('\n'): table says stop=false, the scanner branched=true", and an
`InComment` that reacts to `\t` fails the same way at byte 9. Either one forks
the fast and slow paths, and the fast path is what bulk import runs.

The literal test stays. It documents which bytes are *meant* to matter, which
is a different claim from what the code does, and the two disagreeing is the
signal.
…it notifies

R4, whose brief described a state this branch has not been in since 4d6c4c3.
There is no 50 ms timed wait here: it was removed then, `wait_for_room` is an
untimed `room.wait(state)`, and the only `wait_timeout` left in the tree is the
doc comment explaining why it went. The delta reviewer should re-derive this
from the code rather than reconcile it to the original description.

What was actually true: `shutdown` had exactly one store site, the write-failure
path, which merely happened to be followed by `budget.stop()`. Nothing tied
them together. A worker parked in `wait_for_room` is woken by `release` or
`stop` and never by the flag, so pool liveness rested on one call site
remembering to do both, and a second setter — a cancellation path, a deadline —
would have reintroduced the parse-error hang exactly.

`BudgetState.stopped` already means "the run is ending" and lives under the
mutex the condvar waits on, so the flag is retired rather than taught to
notify. One signal, read in one place, impossible to set without waking
everyone. The worker's top-of-loop check goes with it: `wait_for_room` returns
false the moment `stopped` is set, whether or not it ever had to block, and the
next line took that mutex anyway.

The four-way mutation table in the test comment was re-derived rather than
carried over, and it no longer holds — which is why re-deriving it was worth
the hour. The dead-reader matrix passes with the prompt receiver drop, the
write-failure stop AND the unconditional post-loop stop all removed, because
`rx` is a local of the scope closure and drops before `thread::scope` joins.
The old sentence described a receiver shape this file no longer has. Those
mechanisms buy promptness — without them a worker converts most of a 4 GiB file
into a closed pipe before parking in `send` — but the matrix does not measure
that and never did.

The mechanism that IS load-bearing is the unconditional `budget.stop()`, proven
by a different test: `a_parse_error_wakes_workers_waiting_on_the_output_budget`
hangs at the 90 s ceiling without it, verified again here. It needs a saturated
budget, which a dead reader never produces, which is precisely why the matrix
cannot see it. The comment now says that, so nobody deletes the one guarantee
believing the other test covers it.

Also folds in the measured memory bound, since this commit owns that
documentation. The budget caps buffered output and nothing else: peak is the
budget (256 MB at the ceiling) plus one chunk of overshoot per worker plus the
whole input, which `convert` holds in memory. Measured 1620 MiB peak RSS on a
745 MiB input at K=16 defaults. Quoting the budget alone understates a real run
by about the size of its input.
… megabyte

(a). `extract_prefix_block_from_bytes` materialized every token in its 1 MB
window before looking at one, then walked them and stopped at the first
non-directive — usually about forty bytes in. So a lex failure ANYWHERE in that
window sank chunking for the whole document, and the run fell back to serial
with "the input could not be split into chunks", which blames the input.

The input is often blameless. `find_safe_header` backs the window up to a
newline, and for any literal long enough to span a megabyte that lands inside
the string:

    @Prefix ex: <http://example.org/> .
    ex:doc ex:body """
    …a megabyte and a half of prose…
    """ .

is a valid document whose header region, taken alone, is an unterminated
literal. It converted serially and said the document could not be split.

Pulling tokens one at a time fixes the class rather than the instance. The walk
already stopped at the first non-directive token; now the lexer stops there
too, so the literal is never lexed. A lex error that survives the loop is
therefore a real defect IN THE HEADER — which is what `SplitError::Tokenize`
always claimed to mean — and the reason string becomes true without anyone
writing a heuristic to guess.

It also kills the larger class: a typo 300 KB into a 5 MB file used to move the
whole run to the serial path. Harmless for correctness, since the document
fails either way, but it made "unchunkable" report a property of the document's
shape when the cause was a defect, and those want different fixes from a user.

And it deletes a cost centre. Every parallel run lexed up to a megabyte to find
~35 bytes of prelude, on the single-threaded pass that runs before the first
worker starts — the same shape as the whole-document tokenize this bucket
opened with, on the Amdahl term `Phase::Chunk` exists to expose, now ~30% of
wall at K=16.

`StreamingLexer` is not new: it is `pub`, re-exported at the crate root, and
already what `parser.rs` runs on. The cap and `find_safe_header` are both
deliberately left alone — the cap is what an existing test mutates to prove it
is load-bearing, and changing the truncation in the same commit as the
tokenizer is how this function earned its bugs. `StreamingLexer::new` skips the
`check_input_len` that `Lexer::tokenize` performs, which is harmless under a
megabyte and noted in place for whoever lifts the cap.
The competitor matrix put `fluree rdf convert` at ~1 GB of peak RSS on a
118 MB Turtle corpus next to serdi's 1.5 MB, with no way to say where the
bytes were. This is the instrument that answers it: a tracking global
allocator whose peak is maintained with fetch_max on every allocation
rather than sampled, and the convert pipeline taken apart into the three
stages that can each hold memory — the input read, the parse, and the
parse into a real writer.

Reporting live heap rather than RSS is the point. RSS includes whatever
the allocator has not returned and whatever capacity was reserved but
never touched, which is exactly the part that cannot be attributed to a
line of code; the process high-water mark is printed alongside so the two
can be reconciled against an outside measurement.

The counting sink doubles as a corpus census: the parser serves repeated
terms from its own cache, so every term_* call that reaches a sink is a
distinct term, and that count is the multiplier on everything a writer
keeps per term.
…ength

The caches were pre-reserved at input.len()/60 entries, capped at 2M, into
both the IRI map and the prefixed-name map. On a 118 MB document that is
~1.97M entries each, which at hashbrown's load factor is 2 x 4.19M buckets
— about 210 MB of empty table allocated before a single token is read. The
document it was measured against holds 800,005 distinct terms.

The estimate cannot be rescued by tuning the divisor, because a document's
LENGTH is not evidence about its distinct-term COUNT: one subject with ten
million properties and ten million subjects with one each are the same
number of bytes and differ by six orders of magnitude in what they need
here. Reserving a small floor and letting the map grow costs a rehash per
doubling, ~17 of them on the way to 800K entries and O(n) in total.

Measured on the 4M-triple synthetic corpus: parse-only live-heap peak
373.8 -> 223.8 MiB, whole-run peak RSS 521 -> 386 MiB, with parse time
unchanged within run-to-run variance.
read_all_guarded wraps the reader in take(LIMIT+1) so an oversized input is
refused after reading 4 GiB rather than after allocating whatever was
behind it. The cost of that wrapper was invisible: it erases the File
specialization read_to_end uses to size the buffer once from metadata,
leaving the generic doubling path.

On the 280.7 MB N-Triples corpus that is 26 allocations, a 512 MB buffer
for 280.7 MB of document, and a 768.3 MiB live-heap peak reached by
copying the whole thing one last time. Reserving the real length is two
allocations, no copy, and a 281.0 MiB peak.

The length is a hint and never a promise — only a regular file offers one,
it is clamped at the refusal limit so a wrong or sparse length cannot turn
into an allocation failure in place of the error message this function
exists to produce, and a file that grows past its own metadata still reads
correctly because the Vec simply grows for the tail.
…scoped

The TermId contract says ids from term_iri and term_blank live for the
whole session, because the Turtle parser caches them across statements.
Every sink pays for that, including against producers that do no such
thing: the strict line reader mints a fresh id for every term on every
line and caches nothing, so a writing sink was holding one term plus one
Arc<str> per term OCCURRENCE. Measured on a 4M-statement N-Triples
document: 814.2 MiB of writer term table for a document whose widest
statement needs three slots.

GraphSink::declare_term_scope lets the producer say which it is. The
default is the contract as written, so nothing changes for a producer that
stays quiet — and a sink may ignore the declaration entirely, but must
never honor one that was not made, because recycling a slot a producer
still holds an id for is silent data corruption rather than a crash.

In the writers the table splits in two: a session region and a region
reused from the start at every statement boundary. Labelled blank nodes
stay session-scoped under either scope, and not for storage reasons —
BlankLabeler mints a fresh output label per call, so this map is the only
record that two occurrences of _:x are one node. Everything else recycles.
Sound because every writer clones the term out of the table before the
slot can be reused, which each emit path already did.

Debug builds carry a generation stamp per recycled slot, so a producer
that declares statement scope and then caches an id across end_statement
panics naming the id instead of silently writing the wrong term.

Measured on the 4M-statement N-Triples corpus, serial: writer footprint
814.2 -> 0.0 MiB, whole-run live-heap peak 1144.7 -> 280.7 MiB — the
document itself and nothing above it.

Both halves are pinned by tests that fail against a mutation of the fix:
dropping the slot reuse fails the bound, and letting labelled blanks
recycle fails the identity. A differential test converts documents that
are valid under both grammars through both scopes and requires the bytes
to be identical.
CountingSink holds five counters and no heap, so dropping it releases
nothing and only extends a lifetime — clippy says so under --all-targets.
The census it produced is already copied out above. Fold into the
instrument commit at integration.
The reachable half of a mis-declaration — a producer that declares
statement scope and then caches an id anyway — is caught by the writers'
debug generation stamp. The other half is invisible from inside a sink: a
second, caching producer handed the same sink says nothing, so there is
nothing to notice. That one is held structurally (a writer owns one
destination and writes one document) and now says so. Fold into the
declare_term_scope commit at integration.
A caching parser keeps an Arc<str> per distinct IRI because its own cache
needs one as a key. A sink handed only &str has no way to reach that
allocation, so a sink that STORES terms had to make its own copy of the
very bytes the producer was already holding — on the 4M-statement Turtle
corpus, 800,005 allocations and about 37 MB of duplicate strings, with
both copies live at once for the length of the parse.

GraphSink::term_iri_shared is the same event as term_iri with a default
body that forwards to it, so nothing changes for a sink that does not
store terms or does not care. A sink that does override it clones the Arc:
a refcount bump, and the same lifetime it would have given its own copy,
because the bytes now outlive the producer by construction.

The parser builds its cache key first and hands over a reference to it,
which is why the clone is free rather than a race with a copy — the sink
and the producer are the only two owners at receipt, and a test asserts
exactly that count.

Measured on the 4M-statement Turtle corpus: 35,000,070 -> 34,200,065
allocations (800,005 fewer, one per distinct IRI), 1048.8 -> 1012.2 MiB of
churn, and the parse-into-writer live-heap peak 316.4 -> 286.8 MiB. The
steady-state figure does not move, and should not: the bytes still exist
once, they are simply not duplicated while both sides are alive.

Three tests, because none of this is visible in the output bytes: the
parser must take the sharing path and never the copying one (with a
distinct-IRI count, so a cache regression surfaces here too), the writer's
table must store a pointer-equal Arc (a copy compares equal by value, so
ptr_eq is the only witness), and TimingSink must forward the shared form
AS the shared form while counting it identically — a decorator that
forwarded it to term_iri would silently restore the copy, and one that
forgot to count it would empty the IRI line in --profile.
…dy set

The assertion said a term scope may only be declared before the first term
is minted. That is true of a CHANGE and false of a repeat, and the
difference is a shape the protocol has to support: --continue-on-error
re-enters the reader at every resync point, so a recovering line-format run
declares statement scope once per surviving fragment, against a sink
already holding terms from the fragments before it. The second declaration
panicked the debug build.

Re-declaring the scope in force is now a no-op at any point. What stays
refused is a change of scope once terms exist — that one invalidates ids
already handed out, which is the entire hazard this declaration exists to
keep on the producer's side of the line.

Found by rebasing onto the scan branch and running the integrated suite:
neither branch's tests drove it alone, because the writers had no
recovering producer and recovery had no declaring reader until the two met.
The regression test is the recovery shape rather than the report of it —
re-declare, mint, end statement, five times over — and it panics against
the previous assertion.
Sizing the term caches is a choice between two wastes, and which one is
cheaper reverses around a million distinct terms. Below it, reserving is
the waste: a length-derived estimate put ~210 MB of empty table in front of
a document holding 800K distinct terms. Above it, growing is the waste:
hashbrown doubles, the grow path holds the old table and the new one at
once, and the peak runs ~1.5x the final table.

Measured on a 128 MB ingest-shaped corpus, hinting with the old estimate:

  1000 distinct   no-hint 130.0 MiB   hinted 167.2 MiB   hint costs  +37.2
  2M   distinct   no-hint 682.5 MiB   hinted 483.2 MiB   hint costs -199.3

So neither default is right for both, and the previous commit's small floor
was right for convert and wrong for bulk import — whose memory budget
formula (max_inflight x chunk x 2.5) was calibrated with the reservation in
place, and which routinely runs above the flip.

The estimate moves to whoever has one: ParserOptions gains an optional
distinct-terms hint defaulting to None, import passes its chunk-derived
estimate at the historical 60-bytes-per-term ratio, and convert passes
nothing because a document's LENGTH is not evidence about its
distinct-term COUNT. The parser clamps the hint, which is not a second
guess at the right size but a bound on how wrong a hint can be.

The regression test pins the CONTRACT rather than the byte counts, which
are allocator-dependent: the same document must denote the same RDF at both
ends of the flip, with no hint, with an absurd hint, and with a hint past
the clamp — so a future change chasing bytes cannot quietly change the
answer while it does so.
… panics

The guard for a producer that declares statement scope and then caches an
id stamped the SLOT with the statement that last wrote it. That catches the
sparse shape — next statement narrower, cached slot never re-minted, stamp
stale. It does not catch the dense shape, where the next statement is at
least as wide, the slot has already been re-minted with the current stamp,
and the stale id therefore reads as FRESH. The reviewer's probe named the
result exactly: SUBJECT-TWO written where the producer meant SUBJECT-ONE,
no panic, no error, valid-looking output.

The dense shape is the common one. The guard caught the case I imagined and
missed the case that happens.

The stamp now travels in the id — seven bits at 24-30, beside the session
tag at 31, debug builds only — so a stale id carries the statement it was
minted in no matter what has become of its slot. Both shapes panic, and the
per-slot generation vector is gone since the id carries what it recorded.
Seven bits wrap every 128 statements, so an id cached across exactly a
multiple of 128 still slips through: this is a debug-build heuristic against
an API misuse, not a soundness mechanism, and 24 bits of index for the
widest single statement is the more valuable half of the split.

Also asserts what the session tag has been assuming. "No table can hold
2^31 entries" is an argument about the parser's 4 GiB input cap, and this
table serves producers that are not the parser, so push_session now checks
it instead of inheriting it.

Both shapes are pinned by tests that fail against a disabled guard, and a
third test runs 300 correct statements to prove the guard does not fire on
correct use — a guard that rejects valid programs is worse than none.
The three tests that shipped with the handover check it from the sink's
side, and a hostile parser satisfies all three while allocating twice:
hand the sink a reference, then build a SECOND Arc for the cache key. The
sink still receives a shared reference, the owner count at receipt is still
two, the writer still stores a pointer-equal clone of what it was handed —
and 800K duplicate allocations come back with every test green. The only
party that could prove otherwise is the parser's cache, which is dropped
before any test can look at it.

So this measures from outside, and it measures a SLOPE rather than a count.
An absolute allocation count moves with hashmap growth, Vec doubling and
anything else that allocates along the way; the marginal cost of one more
distinct IRI does not, because everything unrelated appears in both arms
and subtracts out.

Absolute IRIs, deliberately: a prefixed name costs the expanded-IRI String,
the Arc built from it, and a second Arc keyed by the span text for the
prefixed-name cache — about four allocations per distinct term, which
buries the one allocation this is about. Measured on the isolated path the
slope is 1.00; with the second allocation restored it is 2.00 and the test
fails naming the cause. A lower bound catches the other failure: a slope
near zero would mean the two corpora are not differing in distinct-IRI
count, which would make the test vacuous rather than passing.

Also corrects the owners-at-receipt comment, which claimed the producer
hands the term over before caching it as though that were a careful choice.
It is forced — the cache maps the IRI to the TermId, and the TermId does
not exist until the call returns. A fact that cannot vary should not be
written as a decision.
The take(LIMIT + 1) cap already guarantees the refusal. What it costs is
4 GiB of reading and 4 GiB of buffer to reach a verdict the file's own
length already implied. A caller pointing at a 10 GB dump should be told
immediately, not after a minute of I/O.

The hint is not trusted for correctness — an input whose metadata lies
small still hits the cap and is refused there — which is why this is a
short-circuit and not the check. Both paths now raise the same error
through one constructor, so they cannot drift into explaining one condition
two different ways, and the boundary case is pinned: a hint exactly AT the
limit reads, matching the cap that backs it.

Also takes clippy's then_some, and bounds the memory claim in the PR body.
"Input size and nothing above it" is true for the corpora measured and
false for a document with millions of distinct LABELLED blank nodes, whose
label map is session-scoped under either term scope because it is the only
record that two occurrences of _:x are one node. That is a gap in the
evidence rather than something the claim covers, and it now says so.
Two `#[cfg]` blocks with early returns tripped needless_return, because in
a debug build the block IS the tail expression. Splitting each into a pair
of cfg'd helper functions says the same thing without the lint, and reads
better: the stamped and unstamped id layouts now sit side by side with a
line each, instead of being interleaved inside the code that uses them.
The contract test pins the safety half — same RDF whatever the reservation
does — and a null implementation satisfies it completely: plumb the hint
through, then reserve the floor anyway. Every assertion passes, the crate
stays green, and the fix is silently reverted. The reviewer demonstrated it
in three lines. It is the same shape as a sink that receives a shared Arc
and copies it regardless, and a test asserting only the second half of its
own name is how both got through.

The witness measures the reservation from outside, in bytes allocated
during a parse of a deliberately tiny document, so the number is
essentially the reservation. Exact byte counts are hashbrown's business and
are not asserted; the RATIO is, and a two-million-entry table sits about
three orders of magnitude from the floor. Against the reviewer's null
implementation the two measurements come out identical — 102520 bytes
either way — and the test says so by name.

A second test pins the clamp, which is dodgeable on its own and whose
failure mode is an allocation the machine cannot serve rather than a wrong
answer.

Also stops a test from reserving 4 GiB to check an inequality. The
at-the-limit case went through the real read, which reserves the hint — fine
under overcommit, an abort inside a memory-capped CI container. The
off-by-one is the whole risk and it now lives in a predicate that can be
tested without allocating anything.
…al counter

term_cache_efficacy.rs was deterministically red under `cargo test` and
green under nextest, and the difference is isolation rather than luck.
Both its tests arm the same process-global counters; `cargo test` runs a
binary's tests on threads of one process, so each measured the other's
allocations. The floor came out at 104,960,128 bytes instead of ~102,520,
inflated by the clamp test's two-million-entry table allocating alongside
it.

Worse than flaky: the assertion that fired blamed the PRODUCT — "the hint
is being accepted and ignored" — for a defect in the measurement. That is
the most expensive kind of false alarm, because it sends the next reader to
audit working code.

It stayed hidden because every green in this bucket came from nextest,
which gives each test its own process and isolates the statics for free. CI
runs nextest, so this gated nothing, while a developer typing `cargo test`
hit it immediately. A test that is green only under the runner CI happens
to use has accidental isolation, not isolation.

The arm/measure/disarm window is now behind a mutex, with poisoning
recovered rather than propagated so a genuine assertion failure does not
turn every sibling into a confusing poison error instead of its own
verdict. term_sharing_witness.rs uses the same pattern and is safe only
because it holds exactly one measuring test; both files now say so, since
the next person to add a second one will not otherwise find out until it
misattributes a failure.

Verified under both runners across the five crates: `cargo test
--no-fail-fast` 1385 passed / 0 failed / 5 ignored, nextest 1371 — which
reconcile, 1371 + 14 executed doctests = 1385.
aaj3f added 6 commits July 31, 2026 00:59
…ape corpus

Harness changes the first real competitor run needed.

Our column gains four rows rather than one. It is the only column with a
CHECKING dimension — validate (the default) pairs with riot --check=true and
nocheck pairs with --check=false, which is the parity H-8 defines — and the
only one that parallelizes Turtle, so --parallelism 8 is carried as its own
labelled row. It is never merged into the serial rows and never presented as a
competitor comparison: no other tool in the matrix parallelizes Turtle, so
there is nothing on the other side of that comparison.

FLUREE_BIN is now overridable, so a matrix can be pointed at a binary built
from a different tree than the harness lives in.

Adds a BSBM-shape Turtle corpus emitter. A second SHAPE, not a second size:
the synthetic corpus is uniform, subject-major and single-prefix, which
flatters a parser, while BSBM's four interlinked entity types with mixed
datatypes exercise a different path. An ordering that survives both shapes is
worth more than one measured twice.

Turtle only, deliberately. The generator has no native N-Triples emitter, and
producing that half by converting with our own writer would benchmark every
tool on input shaped by the tool under test — the same circularity that
removed RiverBench's Turtle column from the corpus set. The manifest carries
N/A and the reason.
The lock's disclosed flags carried the Jena 5.x spelling. Under 6.1.0,
`--check=false` parses as `--check` plus a POSITIONAL argument `false`, which
riot then tries to open as a file:

  ERROR riot :: Not found: file:///.../benchmarks/conversion/false

So all four riot cells failed. That is the harness working: a wrong flag took
the column out of the matrix loudly instead of producing riot numbers measured
under a checking mode nobody had verified. flags_disclosed exists as a lock
field precisely so this is auditable rather than buried in a script.

Corrected to `--check` and `--nocheck` in both the lock and the runner, with
the 5.x-vs-6.x difference recorded in the lock note. Verified: both modes now
convert the smoke corpus to 10000 statements.
…-2 by size

Two more things the first real run found, both of the same shape as the riot
flag: a lock written against an older CLI.

oxigraph 0.5.9 rejects the 0.4.x `--file` spelling outright, so both its cells
failed and the gate EXCLUDED them rather than comparing against a tool that had
not run. Corrected to --from-file in the lock and the runner, with the version
difference recorded in the lock note.

Level 2 of the correctness gate does not scale, and pretending otherwise would
have been the worse failure. Measured at 4M statements: level 1 normalizes a
281MB cell in 3.5s, while rdflib needs ~40s merely to PARSE the same file,
before canonicalization, times every pair of cells. Above
FLUREE_BENCH_MAX_ISOMORPHISM_STATEMENTS (default 500k) level 2 is now skipped
BY NAME, stating the count, the limit, what level 1 still covered, and how to
force it. A run must never look fully verified because its expensive half
quietly did not happen.
Several agents share this machine and each other's CPU, and a timed run started
while another is compiling does not measure the tool. The damaging part is not
that load adds noise — it is that the contamination is ASYMMETRIC. A build
beginning midway through a matrix hits whichever cells run last, so one column
is measured under load the others never saw. A median does not remove that; it
is a systematic bias in favour of whoever ran first.

This is not hypothetical. In the 4M matrix, the four competitor tools recorded
load/core 0.43-0.62 while our own cells — which the runner schedules last — sat
at 1.07-1.65. The comparison understated our own tool by construction, and the
two noisiest cells in the run (19.4% and 11.3% REL_MAD) were the two highest-load
ones. The per-cell load field added in the previous bucket is what made this
diagnosable rather than a mystery.

The filesystem is the only channel these agents share, so the lock is a file.
Advisory — nothing enforces it but cooperation — so it records holder, pid,
start time and load, letting a stale lock be diagnosed instead of guessed at.
Acquisition uses noclobber so two agents racing cannot both win.

The trap covers PIPE and HUP alongside EXIT/INT/TERM. That is not defensive
padding: piping a run into `head` kills the shell before an EXIT trap fires and
leaves a stale lock that blocks the other agent, which is what the first version
of this did when its own self-test was piped into head.
…not serial

`--parallelism` defaults to 0, which the CLI reads as "as many as this host
has". The rows named `validate` and `nocheck` passed no parallelism flag, so
on this 16-core host they were 16-worker cells — and the matrix presented
them opposite serdi, rapper, oxigraph and riot, every one of which is
single-threaded here. The par8 rows were the only ones whose worker count
was stated, which is how a 16-worker row and an 8-worker row came to look
like a serial-vs-parallel comparison.

Every row now states its count: serial-* passes --parallelism 1 and is the
only kind comparable with the competitors, auto-* is the default a user
gets and is ours alone, par8-* stays a fixed count so the row means the
same thing on another host.

Verified against the binary: `rdf convert` on this host reports "16 workers,
24 chunks" with no flag.
…ecord core count

Three one-liners from the harness review, all about the next operator
rather than tonight's numbers:

- check-correctness's isomorphism-skip message asserted 'this corpus has
  none' about blank nodes unconditionally. The sentence is now derived
  from the cells (grep for '_:'), so pointing the gate at a corpus that
  HAS blank nodes states that their structure is unverified instead of
  stating a falsehood inside the one message whose job is honesty.

- bench_lock_acquire installs an EXIT trap, and bash has one EXIT slot
  per shell: today's callers have no trap of their own, but a future
  caller with a no-silent-pass EXIT guard (check-correctness) would have
  it silently deleted. The comment now says so at the install site.

- The run manifest records available_parallelism. An 'auto' row's
  resolved worker count was otherwise not recoverable from the artifact:
  the same matrix on an 8-core host produced indistinguishable JSON.
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