Build: The Manifest and the JSONL Record Log
Maps to: Phase 1 (control-eval). Kind: Build.
Objective
Add the file contract to control-eval: the functions that read a vignette manifest and append response records to the log. This is the boundary where the coordinator meets the rest of Panoptes — a manifest comes in as JSONL, a response log goes out as JSONL, and the log is append-only. By the end you have parse_manifest, load_manifest, and append_records, with a test that pins the property the whole instrument depends on: a second append never overwrites the first.
Scaffold
Create — one new module in the crate you built in the previous chapter:
crates/control-eval/src/contract.rs—parse_manifest,load_manifest,append_records.- In
crates/control-eval/src/lib.rs, addpub mod contract;and re-export the three functions.
No new dependencies. You already have everything this chapter needs:
serde_json— one record per line, in and out.tokio(fs,io) —tokio::fs::read_to_string,tokio::fs::OpenOptions,AsyncWriteExt::write_all. The coordinator is async, so file I/O is async too.control-core—VignetteandResponseRecordare the wire shapes;ControlErroris the failure type.pretty_assertions(dev) — already declared for the crate's tests.
Expected result: cargo test -p control-eval contract → 2 tests pass (parse_manifest_reads_jsonl, append_records_is_append_only).
The spec (givens)
parse_manifest and load_manifest
pub fn parse_manifest(text: &str) -> Result<Vec<Vignette>, ControlError>;
pub async fn load_manifest(path: impl AsRef<Path>) -> Result<Vec<Vignette>, ControlError>;
parse_manifest takes the whole manifest text and returns one Vignette per non-blank line. Each line is a JSON object { "id": ..., "prompt": ... } — serde_json::from_str straight into a Vignette. Skip blank lines (a trailing newline must not become an empty-line parse error). A line that fails to parse is a terminal fault: map the serde error to ControlError::Invalid, not Worker — a malformed manifest fails identically on retry, so there is nothing to retry.
load_manifest is the thin async wrapper: tokio::fs::read_to_string(path).await, then hand the text to parse_manifest. Let the ? on the read turn an I/O error into ControlError via the From impl control-core already provides.
append_records
pub async fn append_records(
path: impl AsRef<Path>,
records: &[ResponseRecord],
) -> Result<usize, ControlError>;
Open the log with tokio::fs::OpenOptions::new().create(true).append(true) — create it if missing, and append, never truncate. Serialize each record to one JSON line (serde_json::to_string, then push a '\n'), write the batch, and return the count of records written. A serialization failure maps to ControlError::Invalid.
The mode flags are the whole point of the chapter. .append(true) is what makes the log the durable dataset of record: a write extends the file and can never overwrite a byte already on disk. .create(true) makes the first-ever write to a fresh path succeed instead of erroring on a missing file.
Concepts exercised
- Append-only file semantics via
OpenOptions—.create(true).append(true), and why anything that truncates is wrong here. - JSONL as a stage-to-stage contract: one JSON object per line, in and out.
- Mapping a parse or serialize failure to the terminal
ControlError::Invalid, not the retryableWorker. - Async file I/O with
tokio::fsandAsyncWriteExt.
The build loop (you drive)
Test 1 — parse_manifest_reads_jsonl (in contract.rs, plain #[test])
- Write the failing test. Build a two-line JSONL string —
{"id":"ca_geo-030","prompt":"decide"}and{"id":"ca_geo-060","prompt":"choose"}, each followed by\n. Callparse_manifest(text).unwrap()and assertvs.len() == 2,vs[0].id == "ca_geo-030",vs[1].prompt == "choose". - Predict:
str::lines()is kind about a trailing\n—"a\nb\n".lines()yields just["a", "b"], no empty tail. But a blank line between records ("a\n\nb\n") does yield an empty""in the middle. If a manifest ever carried such a blank line and you skipped thefilter, what wouldserde_json::from_str("")return —Ok, or anErryou then map toInvalid? That is why the filter is there even though the trailing newline is harmless. - Run — it fails to compile (no
parse_manifestyet). - Implement
parse_manifest: iteratetext.lines(), filter out blank lines, parse each withserde_json::from_str, mapping the error toControlError::Invalid. How you arrange it is yours. - Run green, commit.
Test 2 — append_records_is_append_only (in contract.rs, #[tokio::test])
This is the load-bearing test of the chapter. It exists to prove the log only ever grows.
- Write the failing test. Pick a temp path under
std::env::temp_dir()(join a name that includesstd::process::id()so parallel test runs don't collide), andremove_fileit first so the test starts clean. Then callappend_recordstwice — once with one record, once with two — and afterwards read the file back withtokio::fs::read_to_stringand asserttext.lines().count() == 3. - Predict: this is the whole point of the exercise. Suppose you implemented
append_recordswith.write(true).truncate(true)(orFile::create) instead of.append(true). After the two calls, how many lines does the file hold — 3, or 1? Which call's records survive? Say it out loud before you write a line of the implementation; the test is built to fail loudly on exactly that mistake. - Run — it fails to compile (no
append_recordsyet). - Implement
append_records: open with.create(true).append(true), serialize each record to a line,write_allthe batch, return the count. - Run green, commit. Then, to feel the test working, temporarily swap
.append(true)for.truncate(true).write(true)and watch the assertion fail with2 != 3— then put it back. That failing run is the proof the test is guarding what you think it guards.
File::create and .truncate(true) both zero it, silently, returning Ok. The append-only property cannot live in a comment that says "remember to append"; a tired maintainer will reach for File::create out of habit and shred the dataset of record with no error to show for it. It lives in this test, which appends twice and refuses to pass unless history grew.
responses.jsonl is the thing the repo archives and a replicator re-scores months later. Each response is one immutable line, written once. That is what makes an eval reproducible — re-score the log and you are scoring the same responses, byte for byte, not a mutated copy. A single truncating write breaks that guarantee for the whole run.
Done when
cargo test -p control-eval contract shows 2 passing tests, parse_manifest turns a malformed line into a terminal ControlError::Invalid, and append_records_is_append_only proves the second append left the first append's records exactly where they were.