rfc: 0006 title: Bench harness — A1 / C1 / C2 thesis-gate measurement status: green author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-05-22 supersedes: — superseded-by: —
RFC 0006 — Bench harness: A1 / C1 / C2 thesis-gate measurement
1. Summary
Pins the contract for ourios-bench: a binary that drives the
shipped ourios-miner + ourios-parquet pipeline against a
corpus on disk, computes the three writer-side thesis-gate
numbers (A1 compression, C1 reconstruction, C2
template-count convergence) per docs/benchmarks.md §2 / §4,
and writes results into docs/benchmarks.md §9 in a
diff-reviewable shape. The RFC fixes the methodology —
what counts as a raw byte, what counts as a Parquet byte, when
plateau is plateau, what equals what in reconstruction — before
any code is written, because the difference between “the thesis
holds” being a real claim and a vibe lives in those
definitions. B1 and B2 (predicate-pushdown and
template-exact query latency) are excluded: they need the
DataFusion querier (ourios-querier, RFC 0007) and therefore
landed in follow-up extensions once the querier was live — both are now
measured authoritatively (docs/benchmarks.md §9.4; RFC 0007
is validated).
2. Motivation
2.1 The honesty contract collapses without measurement
CLAUDE.md §1 declares the project’s central claim and §2
names the three pillars that have to hold for the claim to be
true. docs/benchmarks.md §7’s escalation rule is the
load-bearing consequence: if two thesis-gates fail on any
representative corpus, we pause implementation and revisit the
pillars. That rule is a no-op as long as no thesis-gate has
been measured. The §9 status line as of merge of RFC 0005
reads “no benchmark has been run; all targets are aspirational”
— which is fine for the storage layer’s RFC, but cannot stay
true through the rest of MVP. RFC 0006 is the gate that flips
§9 from aspirational to measured.
2.2 Why bench-first, before the querier
docs/roadmap.md §4 Phase 3 names two crates: ourios-bench
and ourios-querier. Either could go first. Three of the five
thesis-gate goals (A1, C1, C2) need only the bench —
the writer and reader shipped through PR-D…PR-G are everything
those gates require on the storage side. The other two (B1,
B2) need DataFusion plumbing on top. Bench-first means three
thesis-gate signals land before any DataFusion code is written;
querier-first defers all five signals until the querier is
green.
The asymmetric value also runs the other direction: the methodology RFC is the kind of document that tends to surface gaps in the writer / reader contract while the storage code is still fresh, when those gaps are cheaper to fix. Writing it after the querier lands risks discovering A1-affecting writer bugs at the same time we’re debugging predicate pushdown.
2.3 Why an RFC and not just a PR
docs/rfcs/README.md requires an RFC for any new crate, which
covers ourios-bench mechanically. The methodology section is
the deeper reason: A1 in particular has subtle definitional
choices (“what does bytes(raw_corpus) mean for a corpus that
the miner wraps in OTLP envelopes?”, “do we include the
audit-stream files in bytes(parquet)?”, “is zstd-alone the
codec or the codec plus the comparable defaults the Drain
paper uses?”) that are much easier to argue about in markdown
than in code review. Pinning them in an RFC means the resulting
numbers are not only reproducible but meaningful — the §9
Results section that grows out of this work cites the RFC by
section, and changes to the methodology require an amendment.
2.4 Why this is one RFC, not three
A natural split would be RFC 0006 (bench crate shape) /
RFC 0007 (A1 methodology) / RFC 0008 (C1+C2 methodology). All
three are co-designed: the crate shape exists to compute the
measurements, the A1 plain-text-vs-OTLP corpus decision affects
which loader the crate exposes, and C1/C2 share the per-line
ingest loop that A1 also runs to produce its Parquet output.
Splitting them optimises for short documents and loses the
cross-cutting constraints. The querier (and the B1/B2
methodology it carries) is a genuinely separate concern with
no shared code path and lives in RFC 0007 (since shipped and
validated).
3. Proposed design
3.1 Scope and what this RFC pins
This RFC pins:
- The
ourios-benchcrate’s shape: a binary plus a small set of supporting modules (corpus loader, ingest harness, result writer). - The corpus input format for v1 — plain-text
*.txtfiles undertestdata/corpus/, one line per row, UTF-8, the same on-disk shapetestdata/corpus/README.mddocuments and the existing H7.1 property test incrates/ourios-miner/tests/hazards.rsreads. The bench reuses the on-disk format, not the test’sOtlpLogRecordfixture defaults (see §3.3 for the bench-specific tenant / severity / scope envelope). Amendment (PR-K2, 2026-05-28): the OTLP-LogsData migration has landed — the loader also reads*.jsonl/*.jsonfiles in the OTel File Exporter format (oneLogsDataper line). The measurement formulas are unchanged; the §3.3 plain-text envelope defaults still apply to text input. - The A1 / C1 / C2 measurement formulas — what is divided by what, where the byte counts come from, what equality means.
- The “hardware baseline annotation” rule: every result line carries the machine kind the measurement ran on so deltas across hardware classes don’t masquerade as code regressions.
- The output format: a per-run JSON results file under
benchmarks/results/<UTC-RFC3339-ms-colon-free>-<git-sha7>.json(filename colons replaced by-; see §3.6), and a human-readable summary appended todocs/benchmarks.md§9 under a date-stamped sub-heading. - The invocation surface:
cargo run -p ourios-bench --orjust thesis-bench, with CLI flags pinning corpus selection, result-file output, and the optional “annotate-only mode” that runs measurements but does not writebenchmarks.md. The recipe is not namedjust bench— that name is already taken by the criterion-micro-benchmark recipe injustfile; see §3.7.
This RFC does not pin:
B1/B2measurement — both needourios-querier(RFC 0007, where they since landed; authoritative results indocs/benchmarks.md§9.4).The OTLP-LogsData corpus migration (Landed in PR-K2 (2026-05-28). The loader now readsdocs/roadmap.md§4’s “OTLPLogsData(canonical JSON or protobuf)” goal).*.jsonl/*.jsonfiles in the OTel File Exporter format alongside the plain-text*.txtpath. As predicted, the measurement formulas were unchanged; only the loader’s parse step grew. The follow-up that swaps in the protobuf (*.binpb)LogsDatadecode (instead of JSON) remains out of scope for this RFC.criterionmicro-benchmarks for the miner / writer hot paths.criterionis the right tool for sub-measurements (e.g. per-line tokenize cost), but the thesis-gate harness is end-to-end. A follow-up may add acrates/ourios-bench/ benches/directory; this RFC does not specify it.- A
Validated-stage flip for any RFC — this RFC landsgreen(test stubs exist, measurements compile and run on the existing seed corpus), with hardware-and-corpus-specific validation happening in a follow-up benchmarking session.
3.2 Crate shape
crates/ourios-bench/
├── Cargo.toml
└── src/
├── main.rs # CLI entry point, argument parsing
├── lib.rs # public surface for integration tests
├── corpus.rs # *.txt loader (mirrors ourios-miner's
│ # tests/hazards.rs but factored for reuse)
├── harness.rs # ingest loop, per-line measurement
│ # callbacks (lines into miner, records
│ # into Parquet writer, samples C2)
├── a1.rs # A1 compression-ratio computation
├── c1.rs # C1 reconstruction-rate computation
├── c2.rs # C2 template-count-convergence
│ # computation, including plateau detection
└── report.rs # JSON serialisation + benchmarks.md §9
# appender
lib.rs is non-empty so integration tests under tests/ can
drive the bench without going through main.rs argument
parsing. The binary’s main() is thin: parse args, configure
harness, call harness.run(), hand the result to report.
No trait abstraction over Harness or Corpus until a second
consumer exists. The crate is internal to the project; SemVer
applies to it only via the report::ResultsFile shape under
benchmarks/results/<...>.json.
3.3 Corpus format
For v1, the bench reads plain-text *.txt files under
testdata/corpus/ per the format convention in
testdata/corpus/README.md (one log line per row, UTF-8, empty
rows skipped). Each non-empty line becomes one OtlpLogRecord
with Body::String(line), a default tenant (bench-tenant),
severity (9 / INFO), and scope (None / None); the
in-memory shape matches what MinerCluster::ingest expects
for body_kind = String records.
The bench reuses the same corpus files and one-line-per-record
shape as the H7.1 loader in
crates/ourios-miner/tests/hazards.rs, but intentionally
differs on pipeline defaults: H7.1 uses tenant "corpus" and
severity 0 (unspecified), while the bench uses "bench-tenant"
and severity 9 (INFO). The divergence is deliberate —
H7.1 exercises the miner’s body-reconstruction invariant where
tenant/severity are irrelevant, whereas the bench exercises
the full write path where a realistic severity aids coverage of
the Parquet writer’s field encoding. Both loaders produce
Body::String records from the same *.txt files; they are
not code-shared because their purposes and default-filling
strategies differ.
Time stamps for the synthesised records are deterministic:
time_unix_nano starts at a fixed RFC 0005-friendly baseline
(1_775_127_480_000_000_000, i.e. 2026-04-02T10:58:00 UTC,
matching the existing test fixtures) and advances by a fixed
1 ms per line. The advancement is artificial; this RFC accepts
the artificiality because A1 / C1 / C2 are time-insensitive (no
gate measures throughput or query latency against a time
range). The RFC 0007 measurement extension to B1/B2 revisited
time-stamp synthesis as anticipated, since predicate-pushdown
latency depends on the time-range distribution: the B1/B2
real-corpus arms window on the records’ real timestamps.
The default tenant means every record lands in the same partition. This is a simplification — the writer’s atomic publish, row-group rotation, and §3.9 row-vs-path contract have all been exercised on the multi-partition path through PR-E2 / PR-F / PR-G. A1 / C1 / C2 are tenant-distribution neutral. Multi-tenant bench scenarios land with future multi-tenant integration work.
Amendment (PR-K2, 2026-05-28): OTLP-LogsData corpus
support has landed. The loader dispatches on file extension:
*.txt → the plain-text path above; *.jsonl / *.json →
OTLP/JSON Lines (one LogsData per line, the OTel File
Exporter format), parsed via serde_json::from_str against
the opentelemetry-proto types (the with-serde feature
gives the OTLP/JSON spec mapping for free). Each wire
LogRecord maps to one OtlpLogRecord per the RFC 0003 §6.6
shape — severity (clamped to OTLP’s 0..=24), scope,
attributes, resource attributes (copied per record), trace
context (length-validated [u8;16] / [u8;8]), body
(StringValue → Body::String, anything else →
Body::Structured(AnyValue) per RFC 0003 §6.4). Both formats
may coexist in the same corpus directory. Wire timestamps are
honoured for OTLP records (file-static = run-reproducible);
the §3.3 deterministic baseline still drives the text path.
The follow-up that decodes the protobuf (*.binpb)
LogsData form remains out of scope for this RFC.
3.4 Measurement methodology
The load-bearing section of this RFC. The §5 acceptance criteria assert each formula and the §9 status line cites this section by sub-heading.
3.4.1 A1 — Compression ratio
Per docs/benchmarks.md §2 / A1, the formula is:
ourios_ratio = bytes(raw_corpus) / bytes(ourios_output)
zstd_ratio = bytes(raw_corpus) / bytes(zstd_corpus)
A1_delta = ourios_ratio / zstd_ratio
Targets: A1_delta ≥ 3.0 on every corpus in benchmarks.md
§1; ≥ 10.0 on well-templated services.
Pinned definitions:
bytes(raw_corpus): sum ofstd::fs::metadata(p).len()for every corpus file the loader consumed — recursively under the corpus directory. The loader dispatches on extension:*.txt(the §3.3 plain-text path), or*.jsonl/*.json(the §3.1 OTLP/JSON Lines path landed in PR-K2).*.binpbprotobuf-encodedLogsDatais reserved for a future follow-up and not counted today. No transformation: this is the byte count an operator measures withfind testdata/corpus/ \( -iname '*.txt' -o -iname '*.jsonl' -o -iname '*.json' \) -exec stat --printf='%s\n' {} + | awk '{s+=$1}END{print s}'(or the platform equivalent). For OTLP/JSON corpora the byte count includes the envelope (camelCase keys, base64 bytes), so A1 ratios are not directly comparable across formats — a directory holding a mix of*.txtand*.jsonlproduces one aggregate number that conflates both encodings. The §3.6 results JSON’scorpus.directoryfield lets consumers locate the corpus and inspect its contents to interpret the result; cleanly comparable runs need a corpus directory of one encoding only. A per-format byte breakdown on the results JSON is a future enhancement (see §9 open questions).bytes(zstd_corpus)(below) covers the same extension set so the §3.4.1 math invariant — both sides processing the same input — holds across formats.bytes(ourios_output): sum ofstd::fs::metadata(p).len()for every*.parquetfile under the bench’s output bucket directory, including the audit-event file series (audit/...). The audit stream is part of what Ourios stores about the corpus — excluding it would understate the on-disk footprint and inflate the ratio. The pre-rename*.parquet.tmpfiles are skipped (the writer’s atomic-publish contract per RFC 0005 §7 means an open*.parquet.tmpindicates an in-flight write, not a durable artefact).bytes(zstd_corpus): sum ofstd::fs::metadata(p).len()for every*.zstfile produced by runningzstd -19 --no-progressagainst each consumed input file individually — same extension set asbytes(raw_corpus)above (*.txt+*.jsonl+*.json). The two byte counts must cover identical input files; broadening one without the other would break the §3.4.1 math invariant (zerobytes(zstd_corpus)on an OTLP-only corpus would producezstd_ratio = 0and an undefined A1 delta). Level 19 (not 3) matches the Drain paper’s published comparison and is the strictest competent byte codec; using ZSTD-3 would make Ourios’s A1 trivially pass and is dishonest. The--no-progressflag suppresses the progress bar so the bench is deterministic on reinvocation.A1_deltais the ratio of ratios; it has no units. Reported to three significant figures (3.21×,12.4×, etc.) and rounded down to that precision so reported numbers err pessimistic.
The bench logs bytes(raw_corpus), bytes(ourios_output),
bytes(zstd_corpus), ourios_ratio, zstd_ratio, and
A1_delta for each corpus directory it processes. The §9
table summarises by corpus name + hardware kind.
3.4.2 C1 — Bit-identical reconstruction rate
Per docs/benchmarks.md §4 / C1, the formula is:
C1 = count(records WHERE !lossy_flag AND reconstruct == bytes)
/ count(records WHERE !lossy_flag)
Target: C1 = 1.000 (100.000%) on every corpus.
lossy_flag = true rows are excluded from both numerator
and denominator — that’s the definition of “non-lossy
reconstruction rate”. A non-lossy row that reconstructs wrong
is a CLAUDE.md §3.3 violation and a blocker per §4 /
benchmarks.md C1; the bench reports such rows as a hard
failure (non-zero exit code) rather than a degraded gate.
Amendment (PR-K4, 2026-05-29): BodyKind::Structured
rows are also excluded from the C1 denominator. Per RFC
0001 §6.4 / RFC 0003 §6.4, reconstruction for structured
bodies is a storage-layer round-trip (decode the stored
AnyValue bytes) — not a template + params reconstruction —
so the template-based equality C1 measures doesn’t apply to
them. Structured ≠ lossy (the two are independent axes; a
structured record can be high-confidence). The harness
symmetrically skips the templates_for() snapshot lookup for
those records, because RFC 0001 §6.1 assigns them a sentinel
template id outside the Drain tree (no leaf to find).
Pinned definitions:
reconstruct(record, template)is the function exposed byourios_miner::reconstruct::reconstruct, signaturefn reconstruct(record: &MinedRecord, template: &[OwnedToken]) -> Vec<u8>— same function RFC 0001 §6.6 specifies and the H7.1 property test incrates/ourios-miner/tests/hazards.rsalready exercises at unit scale. The function takes the emitted record and the leaf’s template token slice at the record’s emit-time(template_id, template_version); template snapshots have to be captured separately because a later attach can widen the same leaf and rewrite the live template.- Template-snapshot capture mirrors the H7.1 pattern:
after each
MinerCluster::ingest, the harness walkscluster.templates_for(tenant)and records the current template tokens into aHashMap<(template_id, template_version), Vec<OwnedToken>>viaor_insert_with(so the first observation of a(id, v)pair wins and later widenings produce(id, v+1)entries without clobbering). At C1 evaluation time, each record’s(template_id, template_version)looks up its emit-time-active snapshot. A record whose key is not in the map is a contract violation — the harness exits with non-zero before reporting C1. bytesis the original line bytes the loader handedMinerCluster::ingest, captured by the harness alongside eachMinedRecord. The bench MUST capture the input line beforeMinerCluster::ingestborrows or transforms it; the comparison happens against the exact bytes the miner saw.- Equality is byte-for-byte
==betweenreconstruct(record, template)(aVec<u8>) andline.as_bytes(). No trailing-newline normalisation, no case folding, no whitespace trimming. - Reported as a fraction with six decimal places
(
1.000000/0.999998). C1’s100.000%target makes three-decimal precision insufficient — a single failing reconstruction out of 100 000 records is the difference between green and a blocker.
The bench also reports lossy_flag_ratio = count(lossy=true) / count(all) as a quality signal per benchmarks.md C1, with the
≤ 5% / ≤ 20% targets surfaced but not gating.
3.4.3 C2 — Template-count convergence
Per docs/benchmarks.md §4 / C2, the gate is “template count
grows sub-linearly and plateaus within 2× of its steady-state
value by 1 M lines”. The formula needs three things pinned:
when to sample, what counts as plateau, and what
counts as “steady-state value”.
The benchmarks.md C2 phrasing —
“template count grows sub-linearly and plateaus within 2× of
its steady-state value by 1 M lines” — operationalises to:
at the 1 M-line mark, the template count is at least half
of the count the curve eventually converges to. Since
template count is monotonic non-decreasing (the miner does not
unmerge templates), this is the cleanest formulation; if
count(1M) ≥ SS / 2, the curve cannot have more than doubled
between 1 M lines and end-of-corpus, i.e. it is within 2× of
its steady-state value. The phrasing reading where SS is
defined as max(samples) and the comparison is
plateau_value ≤ 2 × max is tautological — plateau_value ≤ max by definition — and was rejected after the first
copilot review of this RFC.
Pinned definitions:
- Sample cadence: every
Nlines, whereN = max(1, ceil(lines_in_corpus / 1024)). The cadence uses ceiling division so the curve never exceeds 1024 samples regardless of corpus size; a 1 M-line corpus samples every 977 lines, a 10 k-line corpus samples every 10 lines. Sampling indices: the curve records template count after processing line indicesN-1, 2N-1, 3N-1, …(i.e. after every N-th line, zero-indexed). The final sample is always taken attotal_lines - 1(the last line), regardless of whether it falls on a cadence boundary. The sample count is thereforeceil(total_lines / N)— at most 1024 entries. - Steady-state value (SS): the template count at the
last sample (line index =
total_lines - 1; always included by the final-sample rule above). Operationally, “where the curve ended up”. Not the running max — see the rationale paragraph above. - Count at 1 M lines: the template count at the sample
whose line index is closest to
999_999(the millionth line, zero-indexed). When two samples are equidistant, the earlier one wins (floor tie-break). Defined only on corpora of≥ 1_000_000lines. - Convergence ratio:
count_at_1m / SS, defined only whenSS > 0. By monotonicity (count_at_1m ≤ SS) it is≤ 1.0; it is0.0when no template has been minted as of the sample nearest the 1 M-line mark (count_at_1m == 0,SS > 0) —count_at_1mis that nearest sample, not the exact millionth line — so the defined range is[0.0, 1.0]. It is undefined (null, paired with anullcount_at_1m) whenSS == 0— a ≥ 1 M corpus that mints no templates at all, a0/0ratio. - Pass condition (gate) — per service (amended for
#444, maintainer-approved 2026-07-10): C2 is defined over
“a corpus from a single stable service”, so on a
multi-service corpus the gate is evaluated per
service.name, not on the whole corpus. Each service’s ratio iscount_at_1m / SSover that service’s lines, withcount_at_1mtaken at that service’s exact millionth line (not the whole-corpus nearest-sample; template creation is a globally-monotonic event attributed to the minting service, so per-service creations partition the whole-corpus template count exactly). A corpus passes iff every service with≥ 1_000_000lines has ratio≥ 0.5— with one exception: a service that mints zero templates over its ≥ 1 M lines (SS == 0, an undefined0/0ratio) passes trivially, since a flat-zero count is the strongest possible convergence (C2’s falsifier is linear growth; an all-NO_TEMPLATEservice is a body-retention / parse-failure concern, caught by §3.1’s counters, not a convergence failure). It fails if any ≥ 1 M service has a defined ratio below0.5; it abstains (c2.pass = null) when no service reaches 1 M lines. A single-service corpus — including the plain-text<unknown>bucket (noservice.name) — is gated on that one service’s ratio, measured at its exact millionth line. That reproduces the pre-amendment whole-corpus verdict for every historical converged corpus (whose ratio sits far from the 0.5 boundary); it is not bit-identical to the whole-corpusconvergence_ratio, which is sampled at the nearest curve point (cadence granularity) and is now only a diagnostic. Only multi-service OTLP corpora change verdict. Rationale: running one whole-corpus ratio over a multi-service capture (e.g. the OTel-Demo) is a category error — it conflates a noisy infra service (a broker emitting high-cardinality offset/path tokens) with clean application services, so the whole-corpus number fails even when every application service converges perfectly (v8 §9.12). The whole-corpusconvergence_ratiois retained as a diagnostic (theby_servicebreakdown is the gate basis). Note: token-level polishing of high-cardinality infra logs is an OTel Collector concern (atransform/redactionprocessor upstream), not the miner’s — consistent with “format parsing is the Collector’s job”. Cardinality cap: the decomposition holds at mostMAX_SERVICES = 1024distinctservice.namebuckets (an O(services) memory guard mirroring §3.2); beyond that, further services fold into one<other>bucket andc2.services_truncatedis set. A real OTLP capture carries tens of services, so the cap is not expected to bind; if it does, the folded<other>bucket mixes services and its per-service ratio is no longer strictly single-service —services_truncatedflags that the run should be re-scoped (the truncation is surfaced, never silent). - Plateau-detection diagnostic (not a gate): the curve
is “plateaued” at the sample where the trailing
K = 64samples all lie within± 5%of the SS. The diagnostic is useful for understanding where the curve actually flattens (often well before 1 M lines), but it does not gate the RFC — the gate is the 2× rule above.
Reported as: template_count_at_1m_lines (integer; null for
corpora < 1 M lines or a ≥ 1 M corpus with SS == 0),
template_count_at_end (integer; this is SS),
convergence_ratio (three-decimal float; null under the same
two conditions). These two form a matched pair — both null or
both set, never mixed — which the report layer relies on
(report.rs errors on a mixed pair). pass (bool or null),
corpus_at_least_1m (bool).
v1 records the convergence curve in the results JSON (as
c2.convergence_curve, an array of {"lines": N, "template_count": M} objects at the sample cadence) but does
not plot it. A future RFC may add a plot artefact so the §9
Results section can include visualisations.
3.5 Hardware baseline and annotation
docs/benchmarks.md §1 pins the hardware baseline: “commodity
cloud VM, 8 vCPU, 32 GiB RAM, gp3-class SSD.” Every bench run
captures the host’s --hardware-kind=<tag> CLI argument
(required; defaults to unknown only when explicitly opted in
via --allow-unknown-hardware) and writes it into the results
JSON. The §9 Results table cites the hardware tag on every
row; a comparison across rows with different tags is a delta
between hardware and code, not code alone.
Hardware tags this RFC pins as known: baseline-8vcpu-32gib
(the §1 reference), dev-laptop, ci-runner. New tags can be
added without an RFC amendment — the value is operator
discipline, not a closed vocabulary — but unknown tags require
the explicit --allow-unknown-hardware opt-in so a forgotten
--hardware-kind doesn’t silently land in §9 as unknown.
3.6 Result format
Each bench invocation writes one results JSON to:
benchmarks/results/<UTC-RFC3339-ms-colon-free>-<git-sha7>[-N].json
The name embeds the run’s millisecond-precision RFC3339
timestamp with the : separators replaced by - (so
2026-05-22T14:30:00.123Z becomes
2026-05-22T14-30-00.123Z). The colon substitution is
required: : is illegal in filenames on Windows and awkward
for shell / tooling elsewhere, so the on-disk name is
colon-free even though the timestamp field inside the
JSON keeps canonical RFC3339 (colons included). Two runs on
the same commit in the same wall-clock second still produce
distinct names via the millisecond component.
Even at millisecond precision two runs can theoretically
collide on a fast machine. The writer creates each candidate
with an atomic create_new (“create iff absent”) open and,
on AlreadyExists, appends a numeric suffix (-1, -2, …)
until it finds a free name — rather than re-deriving the
timestamp. This closes the check-then-write race against a
concurrent run and never clobbers an existing file; if the
suffix budget is exhausted the write fails loudly rather than
overwriting. The directory benchmarks/ will be created at the repo root by
the implementation PR that lands the ourios-bench crate. That
same PR adds a .gitignore entry ignoring benchmarks/results/
except for a .gitkeep and the specific runs the maintainer
chooses to commit (the §9 Results section then cites those by
file path).
The JSON shape is pinned by report::ResultsFile and looks
like:
{
"rfc": "RFC 0006",
"rfc_version": "v1",
"timestamp": "2026-05-22T14:30:00.123Z",
"git_sha": "abc1234",
"hardware_kind": "baseline-8vcpu-32gib",
"corpus": {
"directory": "testdata/corpus/",
"total_lines": 1234567,
"total_files": 2,
"raw_bytes": 98765432
},
"ourios": {
"data_parquet_bytes": 56789,
"audit_parquet_bytes": 1024,
"total_parquet_bytes": 57813
},
"zstd": {
"level": 19,
"compressed_bytes": 312345
},
"a1": {
"ourios_ratio": 13.6,
"zstd_ratio": 3.95,
"delta": 3.44,
"target_delta": 3.0,
"pass": true
},
"c1": {
"non_lossy_total": 12000,
"non_lossy_reconstruct_ok": 12000,
"rate": 1.000000,
"lossy_flag_ratio": 0.0279,
"pass": true
},
"c2": {
"sample_cadence": 1206,
"total_lines": 1234567,
"template_count_at_1m_lines": 142,
"template_count_at_end": 145,
"convergence_ratio": 0.979,
"convergence_curve": [
{"lines": 1206, "template_count": 14},
{"lines": 2412, "template_count": 27}
],
"pass": true,
"corpus_at_least_1m": true
}
}
The temp-directory paths the bench actually uses (the
Writer’s bucket root) are intentionally not in the
JSON. They’re an implementation detail that differs across
runs and would otherwise break the §5 RFC0006.7 reproducibility
scenario. The byte counts are what downstream analysis cares
about; the paths are debug-only and logged to stderr when
--keep-parquet is passed. The field relationship:
total_parquet_bytes = data_parquet_bytes + audit_parquet_bytes,
and total_parquet_bytes is the value §3.4.1 calls
bytes(ourios_output). data_parquet_bytes is the sum of
*.parquet sizes under data/…; audit_parquet_bytes is the
sum under audit/…. The split is recorded for diagnostic
transparency (understanding how much of the footprint is audit
overhead) but the A1 formula operates on the total.
Gate sections are nullable. The a1, c1, and c2 keys
are always present at the top level but their values are
null when the corresponding gate is skipped (via
--gates per §3.7) or abstains (e.g. c2 on a corpus of
< 1 M lines — see §3.4.3). The example above shows all
three populated (the “all gates ran, all gates pass” case);
a --gates c1 run produces "a1": null, "c2": null while
"c1": { ... } carries the populated payload. Downstream
analysis MUST handle the null case (rather than assuming
the object shape) — the §5 RFC0006.6 scenario asserts the
behaviour.
rfc_version is a literal "v1" and tracks RFC 0006
amendments; bumping it requires an RFC amendment, and downstream
analysis tooling refuses unknown versions with a hard error.
This is the bench’s own forward-compatibility policy — the
results JSON is a closed schema, unlike RFC 0005 §3.9’s Parquet
reader which ignores unknown columns and surfaces unknown
ordinals as ParamType::Unknown.
A human-readable summary is appended to docs/benchmarks.md
§9 as a sub-heading per run, with the same numbers in a
markdown table. Repeated bench runs on the same (git-sha, hardware-kind) pair update the existing sub-heading rather
than appending duplicates — the bench reads the §9 section,
finds the matching heading, and rewrites it in place.
3.7 Invocation
The CLI has two output-path concepts and they are spelled differently to avoid the §3.4.1 “output bucket directory” ambiguity:
--results-diris where the JSON results file from §3.6 lands. Default:benchmarks/results/.--bucket-diris thebucket_rootpassed to theourios-parquetwriter — the directory the writer’sdata/andaudit/partition trees grow under, and whose total byte size isbytes(ourios_output)in the §3.4.1 A1 formula. Default: a fresh temp dir understd::env::temp_dir()per invocation, cleaned up on exit unless--keep-parquetis passed.
CLI (crates/ourios-bench/src/main.rs):
ourios-bench [--corpus <path>]
[--results-dir <path>]
[--bucket-dir <path>]
[--keep-parquet]
[--hardware-kind <tag>]
[--allow-unknown-hardware]
[--update-benchmarks-md]
[--gates a1,c1,c2]
Flags:
--corpus <path>(defaulttestdata/corpus/): directory of corpus files the loader walks recursively. Files are dispatched on extension:*.txt(plain-text per §3.3) and*.jsonl/*.json(OTLP/JSON Lines per §3.1 — oneLogsDataper line, the OTel File Exporter format). Both formats may coexist in the same directory; any other extension is silently skipped.--results-dir <path>(defaultbenchmarks/results/): where the §3.6 JSON file lands.--bucket-dir <path>(default: fresh temp dir): the Parquet writer’sbucket_root. Cleaned up on exit unless--keep-parquetis passed.--keep-parquet(off by default): suppress the temp-dir cleanup so the Parquet partition tree is inspectable after the bench exits. Path is logged to stderr.--hardware-kind <tag>(required unless--allow-unknown-hardware): the §3.5 annotation.--update-benchmarks-md(off by default): append / rewrite the §9 sub-heading. CI runs without this flag; maintainers invoke with it to commit numbers.--gates a1,c1,c2(default all): comma-separated subset of gates to compute. Useful when iterating on a single measurement.
Adds a just thesis-bench recipe wrapping cargo run -p ourios-bench --release --. The recipe is not named
just bench — the existing bench recipe in justfile
already runs cargo bench (criterion micro-benchmarks; the
suite is empty today, but the recipe is reserved for the
follow-up that lands crates/ourios-bench/benches/).
thesis-bench makes the gate-vs-microbench distinction
greppable at the recipe level. The --release is normative —
A1 on a debug-mode writer would understate compression
because debug builds disable some arrow / parquet
optimisations the release writer relies on.
CI cadence: not on every PR — too slow for the per-PR loop and
hardware-dependent in ways that would generate noise. The
bench runs on demand (PR comment /bench, future workflow)
and on the nightly schedule that docs/rfcs/0005-parquet- storage.md §7’s open-question on slow-test CI cadence will
formalise. RFC 0006 does not commit to a CI cadence — that’s
the open question’s domain.
4. Alternatives considered
4.1 criterion instead of a custom harness
criterion is the standard Rust micro-benchmarking framework
and CLAUDE.md §6.2 names it for the project’s hot-path
benchmarks. Rejected for the thesis-gate harness: criterion
is statistically tuned for sub-microsecond function-level
measurements (per-iteration noise estimation, warmup loops,
bootstrapped confidence intervals), which is the wrong tool
for “ingest a 1 M-line corpus, write a Parquet partition, then
divide two file-tree sizes.” The bench also runs criterion
benchmarks under crates/ourios-bench/benches/ for the
per-line miner cost and the per-batch writer cost — but that’s
a follow-up PR after the thesis-gate harness lands, not the
v1 shape.
4.2 Bench inside ourios-parquet as an [[example]]
A Cargo [[example]] under crates/ourios-parquet/examples/
could drive the writer + reader without a new crate. Rejected:
the bench needs the miner and the writer plus a custom
result-file writer; living under ourios-parquet would either
add a ourios-miner dependency to the storage crate
(architecturally wrong — storage has no business knowing about
template mining) or grow into a binary that’s not really an
“example” anymore. The dedicated crate matches the
docs/roadmap.md §4 Phase 3 layout.
4.3 Quote A1 against the LogPAI corpora only
The Drain paper measures on LogPAI’s HDFS / BGL / Spark /
Apache / OpenSSH / Windows corpora; we could pin A1 to the
same corpora exclusively and call any other corpus a “tuning”
measurement. Rejected: docs/benchmarks.md §1 already
commits to “every corpus in §1”, including the self-collected
archetypes. Restricting v1 to LogPAI would leave the
self-collected work unmeasured and reintroduce the “we never
ran the bench on the data that matters” gap §1 is designed
to close. v1 measures on whatever corpora are committed; the
seed corpus is the floor, and additions are additive.
4.4 ZSTD level 3 for the reference
ZSTD-3 is the codec the writer itself uses per
RFC 0005 §3.5. Using ZSTD-3 also as the A1 reference would
make ourios_ratio / zstd_ratio an apples-to-apples
codec-vs-codec comparison instead of a structure-vs-codec one
(both sides use the same compressor; Ourios’s win is purely
the template-mining pillar). Rejected because:
- The Drain paper compares against the strongest competent byte codec, and that’s ZSTD-19 / level-max. Using ZSTD-3 understates the codec’s reachable ratio and inflates Ourios’s A1 win.
CLAUDE.md§1’s central claim is “Parquet + template mining + DataFusion collapses [the layers]”; that claim is about the whole stack, not just the template-mining pillar. The reference should be the strongest alternative, not the same codec Ourios uses internally.
The downside — losing the codec-vs-codec isolation — is
captured as an open question (§7). A future RFC may add A1'
(prime, “codec-isolated”) as an additional tuning-goal
measurement alongside the thesis-gate A1.
4.5 Defer the bench to after the corpus migration
The roadmap names “OTLP LogsData corpus” as the Phase 3 goal
and one could argue the bench should not land until the corpus
is in its target shape. Rejected: A1 / C1 / C2 are well-defined
on plain-text input today (the seed corpus is plain text and
the unit-scale H7.1 test already runs against it). Waiting on
the OTLP migration to produce A1 / C1 / C2 numbers couples a
mechanical loader change to a measurement deliverable for no
real reason. The bench’s corpus.rs exposes the loader as an
abstraction so the OTLP migration drops in without touching
the harness or the formulas.
5. Acceptance criteria
Scenario RFC0006.1 — A1 formula is well-defined on the seed corpus
- Given the bench is invoked with
--corpus testdata/ corpus/, the writer ships with the §3.5 / §3.6 RFC 0005 encoding policy, and thezstd_safeRust crate is linked (per the §7 resolution of the ZSTD-integration question)- When the bench runs the A1 measurement
- Then
bytes(raw_corpus)equalssum(std::fs::metadata(f).len())over the consumed corpus files (*.txt,*.jsonl,*.json) in the corpus directory- And
bytes(ourios_output)equals the sum of all*.parquet(not*.parquet.tmp) file sizes under the bench’s output bucket, including theaudit/...partition- And
bytes(zstd_corpus)equals the sum ofstd::fs::metadata(f).len()over the*.zstfiles produced byzstd -19 --no-progresson each consumed input (same extension set asbytes(raw_corpus))- And the reported
deltaequalsourios_ratio / zstd_ratio, rounded down to three significant figures
Scenario RFC0006.2 — C1 = 100% on the seed corpus, mismatch is a hard failure
- Given the bench is invoked with the seed corpus committed under
testdata/corpus/- When the bench runs the C1 measurement
- Then
non_lossy_reconstruct_ok / non_lossy_total = 1.000000(six-decimal precision)- And the results JSON records
c1.pass = true- And if any non-lossy row has
reconstruct(record) != ingested_bytes, the bench writes the failing row’stemplate_id,template_version, expected bytes, and actual reconstruction to stderr and exits with non-zero, and the results JSON recordsc1.pass = false- And the bench writes the results JSON irrespective of
--update-benchmarks-md— the JSON file always lands; only thedocs/benchmarks.md§9 mutation is gated by the flag, so a failure run still leaves a machine-readable record on disk
Scenario RFC0006.3 — C2 gate (“within 2× of SS by 1 M lines”) on a stable corpus
- Given a synthetic stable corpus of
≥ 1_000_000lines whose template alphabet is bounded (constructed by the bench’s integration test; not committed totestdata/corpus/)- When the bench runs the C2 measurement
- Then
c2.corpus_at_least_1m = true- And
template_count_at_1m_linesis the integer template count at the sample whose line index is closest to999_999(zero-indexed; per §3.4.3)- And
template_count_at_endis the integer template count at the final sample (the §3.4.3 SS definition)- And
convergence_ratio = template_count_at_1m_lines / template_count_at_end ≥ 0.5— the “within 2× of SS” gate, made non-tautological by defining SS as the end-of-corpus value rather than the running max- And
c2.pass = true- And the convergence curve in the results JSON has exactly
ceil(total_lines / sample_cadence)samples (the sampling rule pinned in §3.4.3: indicesN-1, 2N-1, 3N-1, …plus a guaranteed final sample attotal_lines - 1)- And on a corpus of
< 1_000_000lines,c2.corpus_at_least_1m = false,c2.pass = null, andc2.template_count_at_1m_lines = null— the gate abstains rather than passing or failing
Scenario RFC0006.4 — Result file shape is stable and the §9 update is reversible
- Given the bench has run and written its results JSON to
benchmarks/results/<...>.json- When a downstream consumer (or a future RFC’s bench) reads the file
- Then the JSON parses against
report::ResultsFilewithrfc_version = "v1"- And the file contains the §3.6 schema’s required keys (
rfc,rfc_version,timestamp,git_sha,hardware_kind,corpus,ourios,zstd,a1,c1,c2)- And when
--update-benchmarks-mdis passed and the §9 section already contains a sub-heading for the same(git_sha, hardware_kind)pair, the bench rewrites that sub-heading in place — running the bench twice on the same commit / hardware does not duplicate §9 rows
Scenario RFC0006.5 — Hardware-kind annotation is required
- Given the bench is invoked without a
--hardware-kindflag and without--allow-unknown-hardware- When the bench parses CLI arguments
- Then the bench exits with a usage error before any measurement runs
- And if
--allow-unknown-hardwareis passed, the resulting JSON carrieshardware_kind = "unknown"and stderr emits a warning naming the §1 baseline tag for reference
Scenario RFC0006.6 —
--gatesflag scopes the measurement
- Given the bench is invoked with
--gates c1- When the bench runs
- Then only the C1 measurement executes; A1 and C2 are skipped
- And the results JSON contains
c1populated anda1,c2set tonull- And the §9 update path (when
--update-benchmarks-mdis passed) leaves the existing A1 / C2 numbers for the(git_sha, hardware_kind)pair untouched
Scenario RFC0006.7 — Bench is reproducible across runs
- Given the bench is invoked twice on the same git checkout and the same corpus, with no code or data changes in between
- When the two runs complete
- Then every measurement field of the results JSON is bit-identical across the two runs — specifically
corpus.raw_bytes,corpus.total_lines,corpus.total_files,ourios.data_parquet_bytes,ourios.audit_parquet_bytes,ourios.total_parquet_bytes,zstd.compressed_bytes,a1.delta,c1.rate,c1.non_lossy_total,c1.non_lossy_reconstruct_ok,c2.template_count_at_end, and (when the corpus is≥ 1 M lines)c2.template_count_at_1m_lines/c2.convergence_ratio- And the only fields that legitimately differ are
timestamp(wall-clock) and the output JSON file’s path (derived fromtimestamp). The temp-dir bucket the writer used is not in the JSON per §3.6, so it cannot contribute to a spurious diff
6. Testing strategy
Per CLAUDE.md §6.2 / docs/verification.md §2:
- RFC0006.1 — integration test in
crates/ourios-bench/tests/a1.rs. Callsourios_bench::runagainst a fixture corpus committed undercrates/ourios-bench/tests/fixtures/, captures the resulting JSON, and asserts each formula leg (raw_bytesfromfs::metadata,total_parquet_bytesfrom inspecting the output bucket,zstd_bytesfrom thezstd_safecrate per the §7 ZSTD-integration resolution). - RFC0006.2 — integration test in
crates/ourios-bench/tests/c1.rs. Drives the bench against the seed corpus; assertsc1.rate == 1.0. A second sub-test injects a synthetic record whosereconstruct()disagrees with the input (built by hand, not by the miner) and asserts the bench exits with a non-zero code and emits the mismatch diagnostics to stderr. - RFC0006.3 — integration test in
crates/ourios-bench/tests/c2.rs. Builds a synthetic corpus in memory (no committedtestdata/) of 1.5 M lines with a known small template alphabet; assertsconvergence_ratio ≥ 0.5and the convergence curve has exactlytotal_lines / sample_cadenceentries (rounded). A second sub-test feeds a non-plateauing corpus (every line introduces a new template structure) and assertsc2.pass = false. - RFC0006.4 — colocated unit test in
crates/ourios-bench/src/report.rs. Serialises a hand-builtResultsFile, parses the JSON back, asserts field-by-field equality. A second sub-test exercises the in-place §9 update via a temp markdown file. - RFC0006.5 — colocated unit test in
crates/ourios-bench/src/main.rs(#[cfg(test)] mod tests) for the CLI parser. Asserts the missing--hardware-kindflag without--allow-unknown-hardwareproduces a usage error beforeHarness::runis invoked. - RFC0006.6 — same test file as RFC0006.5; covers the
--gatesfiltering. - RFC0006.7 —
crates/ourios-bench/tests/ reproducibility.rs. Runs the bench twice against a fixed fixture corpus and asserts the relevant fields bit-equal.
A criterion bench under crates/ourios-bench/benches/ is
deferred to a follow-up PR. The thesis-gate harness this RFC
specifies is correctness-first; per-line miner microbenchmarks
are a separate measurement category.
7. Open questions
-
zstdintegration. Resolved 2026-05-25: the bench links thezstd_safeRust crate. Already in the dep tree viaparquet’szstdfeature, so the marginal build cost is zero. The decision turns on cross-platform reproducibility: shell-out requireszstdon PATH at runtime (not default on macOS or Windows, version varies across Linux distros), and version drift across hosts would mean the same Ourios commit produces different A1 numbers on different machines. With the crate, the compressor version is pinned byCargo.lockand the bundled C library builds on every Tier 1 Rust platform — A1 is reproducible across Linux / macOS / Windows / CI runners without a host-side install step. The Drain-paper apples-to-apples concern is small in practice:zstd_safewraps the same C library at the same compression level, so the resulting bytes are identical to what the CLI binary produces. (RFC0006.1 asserts the byte-count formula directly; if a future observer wants to spot-check against a CLI binary, the JSON results file recordszstd.level = 19so a reproduction pipeline is unambiguous.) - Convergence curve plotting. The results JSON carries the full sample series. Should the §9 sub-heading also render a tiny SVG / ASCII plot of the C2 curve, or is the curve only for downstream analysis? Defer until at least one real run exists.
- CI cadence. When (or whether) the bench runs on a
schedule:trigger is the RFC 0005 §7 open question on slow-test CI cadence. This RFC inherits the question; resolution is the workflow PR that lands the cadence. - Result-file retention policy.
benchmarks/ results/*.jsonwill be gitignored by default (§3.6); specific runs are committed when the maintainer cites them in §9. Open: should there be abenchmarks/results/baseline/sub-directory whose contents are always committed, so regression detection has a stable reference even when the §9 markdown is hand-pruned? - Out-of-tree corpora. A
--corpus <external-path>invocation against, say, a downloaded LogPAI corpus runs but the results JSON points to a path the repo doesn’t carry. Should the JSON record a content hash of the corpus directory (sha256of the concatenated files) so future readers can verify they’re comparing against the same input? Probably yes; defer the mechanics until at least one out-of-tree corpus is actually being measured.
8. References
CLAUDE.md§1 (project charter), §2 (pillars), §3.3 (bit-identical reconstruction), §6.2 (testing discipline), §10 (docs/hazards.mdreading rule).docs/benchmarks.md§1 (corpora + methodology), §2 (A1), §4 (C1, C2), §7 (thesis-gate summary), §9 (Status).docs/roadmap.md§4 Phase 3 (bench + querier scope), §5 (deferred capabilities).docs/rfcs/README.md(RFC process and maturity model).docs/rfcs/0001-template-miner.md§6.6 (reconstruct), §6.4 (audit-event contract that C2’s plateau exercises).docs/rfcs/0005-parquet-storage.md§3.5 (row-group sizing the A1 measurement implicitly depends on), §3.6 (encoding policy that affects compressed bytes), §7 (open question on slow-test CI cadence inherited here).docs/verification.md§2 (scenario-id greppability convention), §3 (maturity-stage gates).