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.
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
- The records — the shapes, described.
catena-cli— the rest of what a filter needs.textkit— counting and splitting text.