Skip to content

Commit a2a48c2

Browse files
author
Zac
committed
feat: add capsule replay + repro minimizer flow\n\n- add capsule artifact replay path and reproducible invocation metadata\n- introduce repro minimization helpers and new regression coverage\n- tighten witness query/filter handling and refusal guidance\n- update docs and bump crate version to 0.3.0
1 parent 7d11033 commit a2a48c2

File tree

23 files changed

+1375
-130
lines changed

23 files changed

+1375
-130
lines changed

.beads/issues.jsonl

Lines changed: 11 additions & 7 deletions
Large diffs are not rendered by default.

Cargo.toml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rvl"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2024"
55
authors = ["CMD+RVL <engineering@cmdrvl.com>"]
66
description = "Reveal the smallest set of numeric changes that explain what actually changed."
@@ -20,10 +20,6 @@ csv = "1"
2020
serde = { version = "1", features = ["derive"] }
2121
serde_json = "1"
2222
blake3 = "1"
23-
simd-csv = { version = "0.10.3", optional = true }
24-
25-
[features]
26-
simd_csv = ["simd-csv"]
2723

2824
[dev-dependencies]
2925
arrow-csv = "57.2.0"

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ rvl <old.csv> <new.csv> [OPTIONS]
281281
| `--threshold <float>` | float | `0.95` | Coverage target (0 < x ≤ 1.0). The minimum fraction of total numeric change that the top contributors must explain. |
282282
| `--tolerance <float>` | float | `1e-9` | Per-cell noise floor (x ≥ 0). Absolute deltas ≤ this value are treated as zero. |
283283
| `--delimiter <delim>` | string | *(auto-detect)* | Force CSV delimiter for both files. See [Delimiter](#delimiter). |
284+
| `--capsule-out <dir>` | string | *(disabled)* | Write deterministic replay capsule artifacts (`manifest.json`, `old.csv`, `new.csv`, `output.txt`, `replay.sh`) to `<dir>/capsule-<id>/`. |
284285
| `--json` | flag | `false` | Emit a single JSON object on stdout instead of human-readable output. |
285286

286287
Invalid `--threshold` or `--tolerance` values are CLI argument errors (exit 2).
@@ -364,6 +365,31 @@ fi
364365
- **Refusals have next steps** — an agent can read `.refusal.code` and decide whether to retry with different flags or escalate
365366
- **`shape --describe`** — prints the tool's `operator.json` contract so an agent can discover invocation, flags, and exit codes without reading docs
366367

368+
### Capsule replay workflow (agent swarms)
369+
370+
Use capsules when you need a deterministic handoff between agents, CI jobs, or debugging sessions:
371+
372+
```bash
373+
# 1. Produce the normal verdict and write a replay capsule sidecar
374+
rvl old.csv new.csv --key id --json --capsule-out ./capsules > run.json
375+
376+
# 2. Inspect generated capsule
377+
ls ./capsules/capsule-*/
378+
# manifest.json old.csv new.csv output.txt replay.sh
379+
380+
# 3. Re-run exactly from the capsule payload
381+
cd ./capsules/capsule-<id>
382+
./replay.sh > replay.json
383+
```
384+
385+
`manifest.json` includes:
386+
- original invocation args (`key`, `threshold`, `tolerance`, `delimiter`, `json`)
387+
- outcome and refusal code (if any)
388+
- contributor summary for REAL_CHANGE
389+
- replay command plus artifact hashes for integrity checks
390+
391+
For troubleshooting, compare `run.json` vs `replay.json` outcome/refusal code first; if they differ, the environment or binary changed.
392+
367393
---
368394

369395
## Scripting Examples

benches/bakeoff.rs

Lines changed: 98 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ struct Case {
2323
path: PathBuf,
2424
}
2525

26+
#[derive(Debug, Clone, Copy)]
27+
struct CaseDialect {
28+
delimiter: u8,
29+
escape: EscapeMode,
30+
skip_sep: bool,
31+
}
32+
2633
#[derive(Debug, Clone, Copy)]
2734
enum ParserKind {
2835
Csv,
@@ -80,17 +87,18 @@ fn run_case(
8087
) -> Option<f64> {
8188
let bytes = std::fs::read(&case.path).ok()?;
8289
let input = guard_input_bytes(&bytes).ok()?;
90+
let dialect = choose_dialect(input, forced_delimiter)?;
8391

8492
let mut row_count = None;
8593
for _ in 0..warmup {
86-
row_count = parse_count(input, &case.path, forced_delimiter, parser);
94+
row_count = parse_count(input, &case.path, dialect, parser);
8795
row_count?;
8896
}
8997

9098
let mut total = Duration::ZERO;
9199
for _ in 0..iterations {
92100
let start = Instant::now();
93-
row_count = parse_count(input, &case.path, forced_delimiter, parser);
101+
row_count = parse_count(input, &case.path, dialect, parser);
94102
row_count?;
95103
total += start.elapsed();
96104
}
@@ -118,26 +126,75 @@ fn run_case(
118126
Some(avg_ms)
119127
}
120128

121-
fn parse_count(
129+
fn parse_count(input: &[u8], path: &Path, dialect: CaseDialect, parser: ParserKind) -> Option<u64> {
130+
let rows = pipeline_data_row_count(input, dialect.delimiter, dialect.escape, dialect.skip_sep)?;
131+
132+
match parser {
133+
ParserKind::Csv => {
134+
parse_only_csv(input, dialect.delimiter, dialect.escape, dialect.skip_sep)
135+
}
136+
ParserKind::SimdCsv => {
137+
parse_only_simd(input, dialect.delimiter, dialect.escape, dialect.skip_sep)
138+
}
139+
ParserKind::Arrow => {
140+
parse_only_arrow(input, dialect.delimiter, dialect.escape, dialect.skip_sep)
141+
}
142+
ParserKind::Polars => parse_only_polars(
143+
input,
144+
path,
145+
dialect.delimiter,
146+
dialect.escape,
147+
dialect.skip_sep,
148+
),
149+
}?;
150+
151+
Some(rows)
152+
}
153+
154+
fn pipeline_data_row_count(
122155
input: &[u8],
123-
path: &Path,
124-
forced_delimiter: Option<u8>,
125-
parser: ParserKind,
156+
delimiter: u8,
157+
escape: EscapeMode,
158+
skip_sep: bool,
126159
) -> Option<u64> {
127-
let (delimiter, escape, skip_sep) = choose_dialect(input, forced_delimiter)?;
160+
let mut reader = build_reader(Cursor::new(input), delimiter, escape);
161+
let mut record = CsvByteRecord::new();
162+
let mut rows = 0u64;
163+
let mut skipped_sep = !skip_sep;
164+
let mut header_seen = false;
128165

129-
match parser {
130-
ParserKind::Csv => parse_count_csv(input, delimiter, escape, skip_sep),
131-
ParserKind::SimdCsv => parse_count_simd(input, delimiter, escape, skip_sep),
132-
ParserKind::Arrow => parse_count_arrow(input, delimiter, escape, skip_sep),
133-
ParserKind::Polars => parse_count_polars(input, path, delimiter, escape, skip_sep),
166+
loop {
167+
match reader.read_byte_record(&mut record) {
168+
Ok(true) => {
169+
if !header_seen {
170+
if record.len() == 1 && is_blank_record(&record) {
171+
continue;
172+
}
173+
if !skipped_sep {
174+
skipped_sep = true;
175+
continue;
176+
}
177+
header_seen = true;
178+
continue;
179+
} else if is_blank_record(&record) {
180+
continue;
181+
}
182+
rows += 1;
183+
}
184+
Ok(false) => break,
185+
Err(_) => return None,
186+
}
134187
}
188+
189+
if !header_seen {
190+
return None;
191+
}
192+
Some(rows)
135193
}
136194

137-
fn parse_count_csv(input: &[u8], delimiter: u8, escape: EscapeMode, skip_sep: bool) -> Option<u64> {
195+
fn parse_only_csv(input: &[u8], delimiter: u8, escape: EscapeMode, skip_sep: bool) -> Option<()> {
138196
let mut reader = build_reader(Cursor::new(input), delimiter, escape);
139197
let mut record = CsvByteRecord::new();
140-
let mut count = 0u64;
141198
let mut skipped_sep = !skip_sep;
142199
let mut pre_header = true;
143200

@@ -153,25 +210,17 @@ fn parse_count_csv(input: &[u8], delimiter: u8, escape: EscapeMode, skip_sep: bo
153210
continue;
154211
}
155212
pre_header = false;
156-
} else if is_blank_record(&record) {
157-
continue;
158213
}
159-
count += 1;
160214
}
161215
Ok(false) => break,
162216
Err(_) => return None,
163217
}
164218
}
165219

166-
Some(count)
220+
Some(())
167221
}
168222

169-
fn parse_count_simd(
170-
input: &[u8],
171-
delimiter: u8,
172-
escape: EscapeMode,
173-
skip_sep: bool,
174-
) -> Option<u64> {
223+
fn parse_only_simd(input: &[u8], delimiter: u8, escape: EscapeMode, skip_sep: bool) -> Option<()> {
175224
if matches!(escape, EscapeMode::Backslash) {
176225
return None;
177226
}
@@ -183,7 +232,6 @@ fn parse_count_simd(
183232
.has_headers(false)
184233
.from_reader(Cursor::new(input));
185234
let mut record = SimdByteRecord::new();
186-
let mut count = 0u64;
187235
let mut skipped_sep = !skip_sep;
188236
let mut pre_header = true;
189237

@@ -199,25 +247,17 @@ fn parse_count_simd(
199247
continue;
200248
}
201249
pre_header = false;
202-
} else if is_blank_record_simd(&record) {
203-
continue;
204250
}
205-
count += 1;
206251
}
207252
Ok(false) => break,
208253
Err(_) => return None,
209254
}
210255
}
211256

212-
Some(count)
257+
Some(())
213258
}
214259

215-
fn parse_count_arrow(
216-
input: &[u8],
217-
delimiter: u8,
218-
escape: EscapeMode,
219-
skip_sep: bool,
220-
) -> Option<u64> {
260+
fn parse_only_arrow(input: &[u8], delimiter: u8, escape: EscapeMode, skip_sep: bool) -> Option<()> {
221261
let input = slice_after_preface(input, skip_sep);
222262
let header = read_header_record(input, delimiter, escape)?;
223263
let schema = schema_from_header(&header);
@@ -231,26 +271,24 @@ fn parse_count_arrow(
231271
builder = builder.with_escape(escape_byte);
232272
}
233273
let reader = builder.build(Cursor::new(input)).ok()?;
234-
let mut count = 0u64;
235274
for batch in reader {
236-
let batch = batch.ok()?;
237-
count += batch.num_rows() as u64;
275+
batch.ok()?;
238276
}
239-
Some(count + 1)
277+
Some(())
240278
}
241279

242-
fn parse_count_polars(
280+
fn parse_only_polars(
243281
input: &[u8],
244282
path: &Path,
245283
delimiter: u8,
246284
escape: EscapeMode,
247285
skip_sep: bool,
248-
) -> Option<u64> {
286+
) -> Option<()> {
249287
if matches!(escape, EscapeMode::Backslash) {
250288
return None;
251289
}
252290

253-
let header = read_header_record(slice_after_preface(input, skip_sep), delimiter, escape)?;
291+
read_header_record(slice_after_preface(input, skip_sep), delimiter, escape)?;
254292
let skip_lines = count_skip_lines(input, skip_sep);
255293
let parse_options = CsvParseOptions::default()
256294
.with_separator(delimiter)
@@ -262,11 +300,11 @@ fn parse_count_polars(
262300
.with_parse_options(parse_options)
263301
.try_into_reader_with_file_path(Some(path.to_path_buf()))
264302
.ok()?;
265-
let df = reader.finish().ok()?;
266-
Some(df.height() as u64 + header_count(&header))
303+
reader.finish().ok()?;
304+
Some(())
267305
}
268306

269-
fn choose_dialect(input: &[u8], forced_delimiter: Option<u8>) -> Option<(u8, EscapeMode, bool)> {
307+
fn choose_dialect(input: &[u8], forced_delimiter: Option<u8>) -> Option<CaseDialect> {
270308
let mut skip_sep = false;
271309
let mut sep_delimiter = None;
272310
match scan_first_non_blank_line(input.split(|byte| *byte == b'\n')) {
@@ -280,17 +318,29 @@ fn choose_dialect(input: &[u8], forced_delimiter: Option<u8>) -> Option<(u8, Esc
280318
if let Some(forced) = forced_delimiter {
281319
let mut cursor = Cursor::new(input);
282320
let escape = detect_escape_mode(&mut cursor, forced).ok()?;
283-
return Some((forced, escape, skip_sep));
321+
return Some(CaseDialect {
322+
delimiter: forced,
323+
escape,
324+
skip_sep,
325+
});
284326
}
285327

286328
if let Some(delimiter) = sep_delimiter {
287329
let mut cursor = Cursor::new(input);
288330
let escape = detect_escape_mode(&mut cursor, delimiter).ok()?;
289-
return Some((delimiter, escape, skip_sep));
331+
return Some(CaseDialect {
332+
delimiter,
333+
escape,
334+
skip_sep,
335+
});
290336
}
291337

292338
let dialect = auto_detect(input).ok()?;
293-
Some((dialect.delimiter, dialect.escape, false))
339+
Some(CaseDialect {
340+
delimiter: dialect.delimiter,
341+
escape: dialect.escape,
342+
skip_sep: false,
343+
})
294344
}
295345

296346
fn is_blank_record_simd(record: &SimdByteRecord) -> bool {
@@ -382,10 +432,6 @@ fn schema_from_header(header: &[Vec<u8>]) -> SchemaRef {
382432
Arc::new(Schema::new(fields))
383433
}
384434

385-
fn header_count(header: &[Vec<u8>]) -> u64 {
386-
if header.is_empty() { 0 } else { 1 }
387-
}
388-
389435
fn default_inputs() -> Vec<PathBuf> {
390436
vec![
391437
PathBuf::from("tests/fixtures/corpus/basic_old.csv"),

benches/runtime.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ fn run_case(case: &Case, iterations: u64, warmup: u64) -> f64 {
8181
threshold: 0.95,
8282
tolerance: 1e-9,
8383
delimiter: None,
84+
capsule_out: None,
8485
json: false,
8586
no_witness: true,
8687
command: None,

docs/perf/bakeoff.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,31 @@ Other knobs:
8181
| --- | --- | --- | --- |
8282
| csv (baseline) | Pass (`cargo test --test corpus_parse`) | 0 | Corpus parse/REFUSAL expectations matched. |
8383
| simd-csv 0.10.3 | Partial | 1 | Fails `backslash_escape.csv` (no backslash-escape support). |
84-
| arrow-csv 57.2.0 | TBD | TBD | TBD |
85-
| polars 0.52.0 | TBD | TBD | TBD |
84+
| arrow-csv 57.2.0 | Fail | 5 | Parse-ok regressions: `extra_fields_empty.csv`, `extra_trailing_empty_fields.csv`, `ragged_rows_long_empty.csv`, `wide_row_extra_empty.csv`; refusal mismatch: `duplicate_headers.csv` parsed successfully. |
85+
| polars 0.52.0 | Fail | 6 | Same 5 Arrow mismatches plus `backslash_escape.csv` parse failure (no backslash-escape support in harness path). |
8686
| candidate B | TBD | TBD | TBD |
8787

88+
### Compatibility Sweep (2026-02-24)
89+
Corpus run command (single-iteration compatibility sweep):
90+
91+
```bash
92+
inputs=$(printf "%s," tests/fixtures/corpus/*.csv | sed 's/,$//')
93+
RVL_BAKEOFF_PARSER=arrow RVL_BAKEOFF_INPUTS="$inputs" RVL_BAKEOFF_ITERS=1 RVL_BAKEOFF_WARMUP=0 cargo bench --bench bakeoff > /tmp/rvl_bakeoff_arrow_corpus.txt
94+
RVL_BAKEOFF_PARSER=polars RVL_BAKEOFF_INPUTS="$inputs" RVL_BAKEOFF_ITERS=1 RVL_BAKEOFF_WARMUP=0 cargo bench --bench bakeoff > /tmp/rvl_bakeoff_polars_corpus.txt
95+
```
96+
97+
Compared against expected parse/refusal sets in `tests/corpus_parse.rs`:
98+
- **Arrow mismatches (5):**
99+
- Expected parse-ok but skipped: `extra_fields_empty.csv`, `extra_trailing_empty_fields.csv`, `ragged_rows_long_empty.csv`, `wide_row_extra_empty.csv`
100+
- Expected refusal but parsed: `duplicate_headers.csv`
101+
- **Polars mismatches (6):**
102+
- Expected parse-ok but skipped: `backslash_escape.csv`, `extra_fields_empty.csv`, `extra_trailing_empty_fields.csv`, `ragged_rows_long_empty.csv`, `wide_row_extra_empty.csv`
103+
- Expected refusal but parsed: `duplicate_headers.csv`
104+
105+
Note on forced-delimiter fixtures:
106+
- `control_byte_header.csv` and `delim_0x1f.csv` are parse-ok fixtures that require forced delimiter in the corpus spec.
107+
- Targeted runs with `RVL_BAKEOFF_DELIMITER=0x01` and `RVL_BAKEOFF_DELIMITER=0x1f` succeed for both Arrow and Polars, so these were excluded from mismatch counts.
108+
88109
### Throughput / Memory
89110
| Parser | Rows/sec | MB/sec | Peak RSS | Notes |
90111
| --- | --- | --- | --- | --- |
@@ -108,7 +129,8 @@ Note: the bakeoff harness is in-memory and does not include disk I/O.
108129
Baseline Rust `csv` passes the corpus (0 mismatches). simd-csv is ~18.9% faster
109130
in the parser-only bakeoff but skips backslash-escape cases in the harness and
110131
does not meet the >=25% throughput gate. Arrow and Polars are both slower than
111-
the baseline on the same large inputs. Keep Rust `csv` for v0.
132+
the baseline on the same large inputs and fail corpus compatibility checks.
133+
Keep Rust `csv` for v0.
112134

113135
## Next Steps
114136
- If needed, evaluate Arrow/Polars CSV readers and record results.

0 commit comments

Comments
 (0)