Files
catena/crates/corpus-core
funkpower de23a7abad bookmeta, bibfetch, bindings: what a caller needs to trust a cover
bookmeta: Cover::guessed says the file left the choice open — a Kindle
book's first image record, or an EPUB image picked because its name says
cover — so a caller with a register to ask can prefer that. A cover the
file names is never a guess. Cover::extension matches bibfetch's.

bibfetch: Open Library answers 403 to more than a hundred covers by ISBN
in five minutes from one address. That is now Error::Limited, not
Unreachable: the other form of the ISBN is not asked, the cover id still
is, since those are not limited, and the CLI exits 75. Covers asked by
ISBN are kept three seconds apart across the process (Covers::pace), which
stays under the limit however long a run goes. A deadline too long to add
to the clock no longer panics; it is no deadline.

bindings: fetch_cover and search_title for Python, and catena.Limited,
which fetch_identifier raises too when a register refuses for being asked
too often.

corpus-core: exit 75, a service refused for now.
2026-11-13 15:44:50 -05:00
..
2026-09-19 11:12:23 -04:00

corpus-core

The record types and the stream plumbing everything shares.

Part of catena.

What it is

A library, not a command — though it carries a small one for printing schemas. It holds the three record types, the NDJSON reader and writer, and the exit-code conventions.

Everything else in the workspace depends on it, which is what makes the tools composable: they agree about the records before they agree about anything else.

When you would use it

  • Reading or writing these records in your own program, without shelling out to any of the tools.
  • Generating bindings in another language, from the JSON Schema.
  • Writing a new filter that joins the pipeline.

The records

Doc, Chunk and Hit. Each derives Serialize, Deserialize and JsonSchema, and implements Record, which is how a reader knows what version it is looking at.

use corpus_core::{Chunk, Kind, Doc};

let mut chunk = Chunk::new(0, "the mill stood at the northern end", 8);
chunk.page = Some(41);
chunk.chapter = Some("III. The Second Estate".into());
chunk.flag(corpus_core::chunk::flags::FOOTNOTE_CITATIONS);

let doc = Doc::new("scan.pdf", Kind::Pdf, 9_261_184);

Every field is emitted, including the empty ones, so a consumer can rely on the shape rather than on what a particular producer happened to fill in.

Reading and writing a stream

use corpus_core::ndjson;

for record in ndjson::read::<Chunk, _>(std::io::stdin().lock()) {
    let chunk = record?;
    println!("{} tokens", chunk.tokens);
}

ndjson::write_line(&mut std::io::stdout(), &chunk)?;

The reader is strict, and each strictness is there because the alternative is a silent failure:

  • A record from a version it does not know is refused. A reader that parses a newer record into whatever lines up will process truncated records rather than failing.
  • It stops at the first bad line. A bad line means the producer and the consumer disagree about the format, and reading on buries that.
  • Lines are bounded. A filter pointed at a binary file should fail, not allocate the file. The limit is sixteen megabytes and can be changed with read_with_limit.
  • Blank lines are skipped and a byte-order mark is ignored, so a stream that was concatenated or written on Windows still reads.
  • Every record is flushed as it is written, so a slow stage downstream sees a record as soon as it exists.

An error knows whether it was a closed pipe:

match ndjson::write_line(&mut out, &chunk) {
    Err(e) if e.is_broken_pipe() => return Ok(()),  // `| head` is normal
    other => other?,
}

Floats JSON cannot carry

JSON has no spelling for NaN or infinity, and the usual behaviour is to write them as null — which is a silent corruption in both directions: a score written as null fails to read back and kills the stream at the consumer, and a timestamp written as null reads back as "no timestamp" with nothing to say it was ever anything else.

So a non-finite number is refused where it is produced, which is the only place anyone can still fix it. Use the same guard on your own records:

#[derive(Serialize, Deserialize)]
struct Mine {
    #[serde(with = "corpus_core::finite")]
    score: f64,
    #[serde(default, with = "corpus_core::finite::option")]
    when: Option<f64>,
}

Exit codes

use corpus_core::exit;

std::process::ExitCode::from(exit::OK)             // 0
std::process::ExitCode::from(exit::NO)             // 1
std::process::ExitCode::from(exit::INDETERMINATE)  // 2
std::process::ExitCode::from(exit::USAGE)          // 64

Verdict is the three-answer form for a predicate:

use corpus_core::exit::Verdict;

let answer = Verdict::from(needs_ocr);
std::process::ExitCode::from(answer)

See the contract for the whole table.

The schemas

$ cargo run -p corpus-core -- schema chunk > chunk.schema.json
$ cargo run -p corpus-core -- schema all

doc, chunk, hit or all. The output is byte-identical across runs, so it can be committed and diffed.

See also