Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.rsparse_manifest, load_manifest, append_records.
  • In crates/control-eval/src/lib.rs, add pub 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-coreVignette and ResponseRecord are the wire shapes; ControlError is the failure type.
  • pretty_assertions (dev) — already declared for the crate's tests.

Expected result: cargo test -p control-eval contract2 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.

→ Answer key

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.

→ Answer key

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 retryable Worker.
  • Async file I/O with tokio::fs and AsyncWriteExt.

The build loop (you drive)

Test 1 — parse_manifest_reads_jsonl (in contract.rs, plain #[test])

  1. 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. Call parse_manifest(text).unwrap() and assert vs.len() == 2, vs[0].id == "ca_geo-030", vs[1].prompt == "choose".
  2. 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 the filter, what would serde_json::from_str("") return — Ok, or an Err you then map to Invalid? That is why the filter is there even though the trailing newline is harmless.
  3. Run — it fails to compile (no parse_manifest yet).
  4. Implement parse_manifest: iterate text.lines(), filter out blank lines, parse each with serde_json::from_str, mapping the error to ControlError::Invalid. How you arrange it is yours.
  5. 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.

  1. Write the failing test. Pick a temp path under std::env::temp_dir() (join a name that includes std::process::id() so parallel test runs don't collide), and remove_file it first so the test starts clean. Then call append_records twice — once with one record, once with two — and afterwards read the file back with tokio::fs::read_to_string and assert text.lines().count() == 3.
  2. Predict: this is the whole point of the exercise. Suppose you implemented append_records with .write(true).truncate(true) (or File::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.
  3. Run — it fails to compile (no append_records yet).
  4. Implement append_records: open with .create(true).append(true), serialize each record to a line, write_all the batch, return the count.
  5. Run green, commit. Then, to feel the test working, temporarily swap .append(true) for .truncate(true).write(true) and watch the assertion fail with 2 != 3 — then put it back. That failing run is the proof the test is guarding what you think it guards.
The trap this test exists to catch Nothing about opening a file for writing forces you to preserve its contents — 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.
Why append-only is sacred here 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.