docs: a page for every tool and every library, with real output

This commit is contained in:
funkpower
2026-09-19 13:09:20 -04:00
parent b0743d5bf7
commit 2e2988b1b1
20 changed files with 2162 additions and 66 deletions
+79
View File
@@ -0,0 +1,79 @@
# catena
Small programs that each do one part of turning a pile of files into a
searchable corpus, and hand the next one text on stdout.
Every one of them is a filter: bytes in, records out, diagnostics on stderr,
and an exit code that means something. None of them needs a database, a GPU, a
queue or a config file.
## The tools
### Working out what you have
| | |
|---|---|
| [`booksniff`](booksniff.md) | What a file actually is, whatever its name claims |
| [`pdftriage`](pdftriage.md) | Whether a PDF's text layer is worth reading, or whether it has to be OCRed |
| [`bibclean`](bibclean.md) | Catalogue records audited and repaired, with a reason for every change |
### Turning documents into passages
| | |
|---|---|
| [`mdscrub`](mdscrub.md) | Converted markdown in, clean markdown out |
| [`chunkmd`](chunkmd.md) | Markdown into passages that carry their page, chapter and role |
### Turning recordings into passages
| | |
|---|---|
| [`cuenorm`](cuenorm.md) | Any subtitle format in, one normalised cue stream out |
| [`cuepack`](cuepack.md) | Cues into passages worth embedding, keeping the times they were said at |
| [`cueseg`](cueseg.md) | A timed transcript into topic chapters |
### Searching
| | |
|---|---|
| [`emb`](emb.md) | Lines into vectors, against an embedding server |
| [`rrf`](rrf.md) | Several ranked lists into one, without the database |
## The libraries underneath
Every tool is a library crate with a thin command on top, so anything a
command does can be done in process instead.
| | |
|---|---|
| [`corpus-core`](corpus-core.md) | The record types and the stream plumbing everything shares |
| [`textkit`](textkit.md) | Token counting, sentence and paragraph splitting |
| [`cuekit`](cuekit.md) | The cue model, and the three subtitle formats it is read from |
| [`catena-cli`](catena-cli.md) | The parts of a filter every command does the same way |
## Reference
- [The records](records.md) — the shapes that travel between the tools.
- [The contract](contract.md) — what every one of these programs promises.
## A worked pipeline
A book, from a converted chapter to passages an index can hold:
```
$ mdscrub chapter.md | chunkmd --min-tokens 20
{"v":1,"idx":0,"text":"The obligation ran both ways, and contemporaries said so plainly…","tokens":56,"page":41,"chapter":"III. The Second Estate","role":"body","start_time":null,"end_time":null,"flags":[]}
{"v":1,"idx":1,"text":"What the rolls do not say, and what has to be inferred…","tokens":143,"page":42,"chapter":"III. The Second Estate","role":"body","start_time":null,"end_time":null,"flags":[]}
```
A recording, through the same shape:
```
$ cuenorm episode.vtt | cuepack --seconds 10
{"v":1,"idx":0,"text":"The mill stood at the northern end of the valley, where the fall of the river was steepest and the ice came latest.","tokens":27,"page":null,"chapter":null,"role":"body","start_time":1.0,"end_time":8.0,"flags":[]}
{"v":1,"idx":1,"text":"Its wheel was undershot, which was wasteful,","tokens":11,"page":null,"chapter":null,"role":"body","start_time":11.0,"end_time":19.0,"flags":[]}
```
Both produce the same record, so one index answers across a book and a lecture
and returns both with a location you can cite — a page number for one, a
timestamp for the other.
+154
View File
@@ -0,0 +1,154 @@
# bibclean
Catalogue records audited and repaired, with a reason for every change.
Back to [the index](README.md).
## What it does
Reads catalogue records, names everything wrong with each one, weighs it, and
makes the repairs that need no lookup.
Nothing is rewritten silently. Every change comes back with the fault that
prompted it, so a run can be read before it is agreed to.
## When you would use it
- **Before trusting a catalogue you inherited.** Metadata rots in recognisable
ways, and the ways are worth naming: a title that is really a filename, an
author field holding a publisher, a scanner's name where the work's name
should be.
- **Finding the worst records first.** Every fault carries a weight, so a
thousand records sort by how much attention they deserve and a limited pass
spends its time where it matters.
- **Recovering identifiers you already have.** A file is often named exactly
its ISBN, which is a free and certain identifier for a record that has none
— certain because the check digit says so.
- **As a gate in a pipeline.** It exits `1` when anything needed attention,
so a nightly job can stop and ask.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena bibclean
```
## Example
Five records, four of which have something wrong with them:
```
$ bibclean --flagged books.ndjson
{"v":1,"title":"The Human Condition","author":"Arendt, Hannah","isbn":"9780226586601","path":"/library/9780226586601.pdf","reasons":["allcaps_title","missing_isbn"],"severity":2,"changes":[{"field":"title","from":"THE HUMAN CONDITION","to":"The Human Condition","why":"allcaps_title"},{"field":"isbn","from":"","to":"9780226586601","why":"missing_isbn"}]}
{"v":1,"title":"Untitled","author":"Arguing About War","isbn":null,"path":null,"reasons":["placeholder_title","title_in_author","missing_isbn"],"severity":7,"changes":[]}
{"v":1,"title":"Microsoft Word - ch3.doc","author":"Oxford University Press","isbn":null,"path":null,"reasons":["filename_title","junk_title","publisher_as_author","missing_isbn"],"severity":9,"changes":[]}
{"v":1,"title":"The Fall: And the Rise","author":"Camus, Albert","isbn":null,"path":null,"shelf":"upstairs","reasons":["underscore_title","missing_isbn"],"severity":1,"changes":[{"field":"title","from":"The_Fall_ And the Rise_txt","to":"The Fall: And the Rise","why":"underscore_title"}]}
```
Three things to notice: the ISBN was recovered from the filename and the
check digit confirmed it; the second record was not touched, because what it
needs is a lookup and guessing would be worse; and `"shelf": "upstairs"` came
through untouched, because fields this does not know about are carried.
The worst ten records, in order:
```sh
bibclean --flagged books.ndjson | jq -s 'sort_by(-.severity) | .[:10] | .[]' -c
```
Just the changes, for review:
```sh
bibclean books.ndjson | jq -r '.changes[] | "\(.why)\t\(.from)\t→\t\(.to)"'
```
## Options
| Option | Effect |
|---|---|
| `--flagged` | Write only the records that need attention |
A missing ISBN alone does not count as needing attention: nearly every book
lacks one.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Nothing needed attention |
| `1` | Something did |
| `64` | The command line was wrong |
| `65` | A record could not be read |
| `74` | The file could not be read |
## Input
One JSON record per line. `title`, `author`, `isbn` and `path` are read;
everything else is carried through untouched, so a record can go through this
and back into whatever it came from.
```json
{"title":"THE HUMAN CONDITION","author":"Arendt, Hannah","path":"/library/9780226586601.pdf"}
```
## The faults it names
| Reason | Weight | What it means |
|---|---|---|
| `placeholder_title` | 4 | The whole title is "Untitled", "Document", "N/A" |
| `placeholder_author` | 4 | The same, in the author field |
| `path_title` | 4 | A file path sitting in the title |
| `numeric_author` | 4 | An author field opening with a bare year or series number |
| `unknown_author` | 3 | "Unknown", "Anonymous", empty |
| `filename_title` | 3 | A bare filename, extension and all |
| `junk_title` | 3 | The name of the tool that made the file, not the work |
| `publisher_as_author` | 3 | An imprint in the author field |
| `title_in_author` | 3 | The title is a placeholder and the real title is in the author field |
| `allcaps_title` | 2 | Shouting |
| `lowercase_title` | 2 | A filename stub: "heidegger areopagite" |
| `missing_apostrophe` | 2 | "Lossky s Argument" |
| `possible_swap` | 2 | Title and author look to be the wrong way round |
| `underscore_title` | 1 | Underscores standing in for spaces and colons |
| `missing_isbn` | 0 | Reported, but not a reason to do anything |
Each test is deliberately conservative. A real book called "The Document" or
"A Test of Faith" must not be swept up, so the placeholder tests only fire
when the *entire* field is the placeholder; and "Oxford University Press" in
an author field is wrong while "Oxford, Michael" is a person.
## The repairs it makes
| Fault | Repair |
|---|---|
| `underscore_title` | A trailing format name comes off, an underscore before a subtitle becomes a colon, the rest become spaces |
| `allcaps_title` | Title-cased, keeping small words small, hyphenated parts cased on both sides, and accented first letters capitalised rather than skipped |
| `missing_isbn` | Filled from the filename, if a checksum-valid ISBN is in it |
Everything else is named and left alone, because the fix needs a lookup and a
wrong guess in a catalogue is harder to find than a missing value.
## As a library
```rust
let cleaned = bibclean::clean(&book);
if bibclean::reasons::worth_fixing(&cleaned.reasons) {
for change in &cleaned.changes {
println!("{}: {} → {} ({})", change.field, change.from, change.to, change.why);
}
}
```
The pieces are usable on their own: `bibclean::isbn::valid`,
`bibclean::isbn::from_filename`, `bibclean::fix::title_case`,
`bibclean::matching::same_work`.
`same_work` is worth knowing about separately. It compares vocabulary rather
than characters, because character similarity is not usable for titles: "The
Cambridge Companion to Aquinas" and "The Cambridge Companion to Hume" are
eighty-six per cent the same string, and believing that attaches one book's
description to another.
## See also
- [`booksniff`](booksniff.md) — what the file behind the record actually is.
- [The records](records.md) — the shapes the rest of the suite uses.
+118
View File
@@ -0,0 +1,118 @@
# booksniff
What a file actually is, whatever its name claims.
Back to [the index](README.md).
## What it does
Reads the first few bytes of a file, and an archive's directory at the back,
and says what format it really is. It never reads the whole file, so a
directory of nine-hundred-megabyte scans costs the same as a directory of
anything else.
Writes one [`doc` record](records.md#doc--what-a-file-is) per file, with a
note for anything worth remarking on: a name that disagrees with the bytes, a
file too small to be a book, a name that says "sample".
## When you would use it
- **Before a conversion step**, so a converter is never handed a format it
cannot read. Most conversion failures are this, and the error a converter
gives for it is rarely the error you want.
- **Auditing a library you did not build**, where files have been renamed,
re-saved, and moved between machines that disagreed about extensions.
- **Finding the files that are not books** — the samples, the readmes, the
cover images that ended up in a folder of PDFs.
A library accumulates files that lie: a DjVu named `.pdf`, a Word document
named `.epub`, an EPUB saved as `.zip`, a single PDF that somebody zipped and
renamed back to `.pdf`.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena booksniff
```
## Example
Three files, one of which is not what it says it is:
```
$ booksniff --plain book.pdf atlas.pdf chapter.md
pdf book.pdf
djvu atlas.pdf
named .pdf, but the bytes say DjVu
txt chapter.md
925 bytes, which is too small to be a book
```
The record form, which is what a pipeline wants:
```
$ booksniff book.pdf
{"v":1,"path":"book.pdf","kind":"pdf","declared_ext":"pdf","bytes":17595,"pages":null,"language":null,"text_layer":null,"needs_ocr":null,"notes":[]}
```
Finding everything in a folder that is not really a PDF:
```sh
booksniff library/*.pdf | jq -r 'select(.kind != "pdf") | "\(.kind)\t\(.path)"'
```
## Options
| Option | Effect |
|---|---|
| `--plain` | Print a line a person can read instead of the record |
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Every file was identified |
| `1` | At least one was not |
| `64` | The command line was wrong |
| `74` | A file could not be read |
A file that cannot be read is reported on stderr and the rest still run, so
one bad file does not stop a sweep.
## What it recognises
| Kind | How |
|---|---|
| `pdf` | `%PDF-` |
| `djvu` | The IFF container tag |
| `ole2` | The compound-file header every binary Word document starts with |
| `epub` | A zip holding `mimetype` and `META-INF/container.xml` |
| `zip-wrapped-pdf` | A zip whose only real member is one PDF |
| `azw3`, `mobi` | A Palm database tagged `BOOKMOBI`, told apart by the format version inside the first record |
| `html` | Markup at the front, after any byte-order mark |
| `txt` | Text that is none of the above |
| `unknown` | Anything else, with a note saying what was seen |
An archive holding several PDFs is a collection, not a wrapped book: choosing
among them would be a guess, and naming one chapter as the whole work is worse
than refusing.
## As a library
```rust
use booksniff::bytes::File;
let file = File::open(std::path::Path::new("atlas.pdf"))?;
let doc = booksniff::sniff(&file, "atlas.pdf");
assert_eq!(doc.kind, corpus_core::Kind::Djvu);
```
`sniff` works over anything implementing `booksniff::bytes::Bytes`, which is
implemented for `&[u8]` as well as for a file — so a caller that already has
the bytes need not write them to disk first.
## See also
- [`pdftriage`](pdftriage.md) — once you know it is a PDF, whether its text is
worth reading.
- [The records](records.md) — the shape this writes.
+121
View File
@@ -0,0 +1,121 @@
# catena-cli
The parts of a filter every command does the same way.
Back to [the index](README.md).
## What it is
A library. Not a framework — a handful of functions, each of which exists
because getting it wrong is easy and the consequence is a pipeline that
misreports why it stopped.
Every command in this workspace is built on it, which is why they all behave
the same at the edges.
## When you would use it
- **Writing a filter that joins this pipeline**, and wanting it to behave like
the others without rereading [the contract](contract.md).
- **Anywhere a small Unix filter is wanted**, in or out of this workspace.
## Parsing a command line
```rust
fn main() -> std::process::ExitCode {
let args: Args = match catena_cli::parse() {
Ok(args) => args,
Err(code) => return code,
};
// …
}
```
`--help` and `--version` are not errors and leave with `0`; anything else
wrong with the arguments leaves with `64`, which is what the rest of the suite
means by "the command line was wrong". Argument parsers tend to pick their own
number for this, and a pipeline that routes on exit codes then has two
different meanings for the same one.
## Reading the input
```rust
let text = match catena_cli::read_text(args.path.as_deref()) {
Ok(text) => text,
Err((code, why)) => {
eprintln!("mytool: {why}");
return ExitCode::from(code);
}
};
```
`read_text` tells a file that cannot be read from one that is not text: the
first is an I/O failure (`74`) and the second is bad input (`65`). A caller
routing on the exit code needs to tell those apart — one is worth retrying and
the other never is.
`read_lossy` is the same but replaces anything that is not UTF-8, for formats
that routinely arrive in another encoding. A subtitle file downloaded from
anywhere is as likely to be Latin-1, and a cue with one wrong character still
has its timing and most of its words.
Both read stdin when given no path.
## Writing the output
```rust
catena_cli::stream("mytool", &records) // NDJSON, one per line, flushed
catena_cli::text("mytool", &cleaned) // plain text
```
Both return the exit code to leave with. Both treat a consumer that closed the
pipe as the end of the job rather than as a failure — `… | head -1` exits `0`,
like every other filter — and both flush as they go, so a slow stage
downstream sees a record as soon as it exists.
For a loop that needs to do something between records:
```rust
let mut out = std::io::BufWriter::new(std::io::stdout().lock());
for record in records {
// …
}
catena_cli::finish("mytool", out)
```
## A whole filter
```rust
use std::process::ExitCode;
use clap::Parser;
#[derive(Parser)]
#[command(version, about)]
struct Args {
path: Option<std::path::PathBuf>,
}
fn main() -> ExitCode {
let args: Args = match catena_cli::parse() {
Ok(args) => args,
Err(code) => return code,
};
let text = match catena_cli::read_text(args.path.as_deref()) {
Ok(text) => text,
Err((code, why)) => {
eprintln!("mytool: {why}");
return ExitCode::from(code);
}
};
catena_cli::stream("mytool", &do_the_work(&text))
}
```
That is the whole shape. Everything a tool here actually *does* lives in its
library crate; the command is argument parsing, input, output and an exit
code, and should stay under a hundred and fifty lines.
## See also
- [The contract](contract.md) — what these functions are enforcing.
- [`corpus-core`](corpus-core.md) — the records and the exit-code constants.
+131
View File
@@ -0,0 +1,131 @@
# chunkmd
Markdown into passages that carry their page, chapter and role.
Back to [the index](README.md).
## What it does
Cuts a document into pieces of about the size an embedding model wants, and
puts on each one the page it came from, the chapter it is in, and what part of
the work it belongs to.
Writes [`chunk` records](records.md#chunk--a-retrievable-passage), one per
line.
## When you would use it
- **Before embedding anything.** A whole chapter is too big to embed usefully
and a sentence is too small to mean anything; this is the step in between.
- **When a result has to be citable.** A passage a reader cannot locate in the
book is a passage they cannot check, so the page and chapter are carried
from the first stage rather than reconstructed later.
- **To keep the apparatus out of search results.** An index, a table of
contents and a bibliography all match a query beautifully and answer
nothing — they are lists of the words somebody is searching for. Marking
them lets a search exclude them while keeping them addressable for the
consumers that want them.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena chunkmd
```
## Example
```
$ mdscrub chapter.md | chunkmd --min-tokens 20
{"v":1,"idx":0,"text":"The obligation ran both ways, and contemporaries said so plainly\nwhenever they were asked.4 A lord who could not\nprotect was a lord who could not command, and the men who owed him service\nsaid as much in language that has survived in the court rolls.","tokens":56,"page":41,"chapter":"III. The Second Estate","role":"body","start_time":null,"end_time":null,"flags":[]}
{"v":1,"idx":1,"text":"What the rolls do not say, and what has to be inferred, is how often the\nobligation was actually tested…","tokens":143,"page":42,"chapter":"III. The Second Estate","role":"body","start_time":null,"end_time":null,"flags":[]}
```
Just the text, for an embedder:
```sh
mdscrub chapter.md | chunkmd | jq -r .text | emb --model bge-m3 --dim 1024
```
Only the body, leaving the bibliography and the index behind:
```sh
chunkmd book.md | jq -c 'select(.role == "body")'
```
## Options
| Option | Default | Effect |
|---|---|---|
| `--target <TOKENS>` | `512` | Flush a chunk at this many tokens |
| `--overlap <TOKENS>` | `64` | Carry this much of each chunk into the next |
| `--min-tokens <TOKENS>` | `50` | Drop chunks smaller than this |
| `--hard-max <TOKENS>` | `768` | Never emit a chunk larger than this |
The defaults suit a model with a 512-token comfortable window. A larger model
wants roughly double everything:
```sh
chunkmd --target 1024 --overlap 128 --min-tokens 80 --hard-max 1280
```
Budgets that cannot produce anything sensible — an overlap at or above the
target, a hard maximum below the target — are refused before any input is
read.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Done |
| `64` | The command line was wrong |
| `65` | The input was not text |
| `74` | The file could not be read |
## How it cuts
Page first, paragraph second, sentence only when a paragraph is itself too
big.
1. **Split on page markers**, so a chunk never spans a page and its page
number is exact rather than inferred. Text before the first marker is kept,
with no page number — it is still the book.
2. **Pack paragraphs** toward the token target within each page, carrying an
overlap forward so a sentence that answers a question is not orphaned from
the sentence that asked it.
3. **Enforce the hard maximum** on token boundaries, whatever the structure
said.
4. **Drop what is too small** to be worth an embedding.
5. **Classify roles** across the whole document at once, because deciding a
stray chunk needs to see its neighbours: a short run of body chunks
sandwiched between two bibliography chunks is part of the bibliography.
A chunk whose text is a page of numbered notes is flagged `footnote_citations`,
because footnotes are citations in disguise — they read as body text, rank as
body text, and answer a query with a publisher and a page number.
## The roles
| Role | What it is |
|---|---|
| `body` | Running text |
| `bibliography` | A works-cited or references list |
| `index` | A back-of-book index |
| `toc` | A table of contents |
| `frontmatter` | Title page, copyright page, dedication |
## As a library
```rust
use chunkmd::Budgets;
for chunk in chunkmd::chunk(&markdown, &Budgets::default()) {
println!("{} tokens on page {:?}", chunk.tokens, chunk.page);
}
```
## See also
- [`mdscrub`](mdscrub.md) — what to run before this.
- [`cuepack`](cuepack.md) — the same record, from a recording.
- [`emb`](emb.md) — turning the text into vectors.
- [The records](records.md) — the shape this writes.
+78
View File
@@ -0,0 +1,78 @@
# The contract
Every program here obeys all of this, or it does not ship. It is worth reading
once, because it is what lets these be used together without reading any of
them.
Back to [the index](README.md).
## One job
A tool that needs a `--mode` flag to change what it *is* is two tools.
## stdin to stdout
Input on stdin or a path argument, result on stdout, diagnostics on stderr
only — never content and chatter on the same stream. A tool that takes a path
instead of a stream says why in its `--help`; [`pdftriage`](pdftriage.md) does,
because it reads two specific page ranges rather than the whole file.
## Streams, not documents
Anything list-shaped is NDJSON: one [record](records.md) per line, flushed as
it is produced, so a slow stage never blocks the pipe behind it.
The reader is strict on purpose. It refuses a record whose version it does not
know, it stops at the first bad line rather than reading past a disagreement
about the format, and it bounds how much one line may cost — a filter pointed
at a binary file should fail, not allocate the file.
## Predicates answer with exit codes
| Code | Meaning |
|---|---|
| `0` | Yes, or done |
| `1` | No |
| `2` | Could not tell |
| `64` | The command line was wrong |
| `65` | The input was not what the tool can work on |
| `69` | A program it needs is not installed |
| `70` | The tool's own fault |
| `74` | Reading or writing failed |
The failure codes start where `sysexits.h` puts them so that "no" and "broke"
are never the same number. That is what makes this work:
```sh
for f in *.pdf; do pdftriage --quiet "$f" && ocrmypdf "$f" "ocr/$f"; done
```
A consumer closing the pipe is not a failure. `… | head -1` exits `0`, like
every other filter.
## No network, no database, no config file, no state
Two exceptions, both declared in `--help`:
- [`emb`](emb.md), whose whole job is an HTTP call.
- [`pdftriage`](pdftriage.md), which shells out to `pdftotext` and `pdfinfo`
and exits `69` when either is missing.
## No ambient knowledge
No hostnames, no local paths, no private vocabularies compiled in. General
scheme data — ISBN check digits, the HTML character references — may be;
anything local is a `--rules` file.
## Deterministic
Same bytes in, same bytes out, across runs and machines. No hash-order
dependence, no clock, no locale reads. Floats that JSON cannot carry are
refused where they are produced rather than written as `null` and lost.
## `--json` everywhere
The human-readable default is for a person at a terminal; `--json` is the
contract, and it does not change shape without a major version. Tools whose
only sensible output is a stream of records write that stream and have no
`--json` to add.
+142
View File
@@ -0,0 +1,142 @@
# corpus-core
The record types and the stream plumbing everything shares.
Back to [the index](README.md).
## What it is
A library, not a command — though it carries a small one for printing
schemas. It holds the three [record types](records.md), 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`](records.md#doc--what-a-file-is),
[`Chunk`](records.md#chunk--a-retrievable-passage) and
[`Hit`](records.md#hit--one-scored-candidate). Each derives `Serialize`,
`Deserialize` and `JsonSchema`, and implements `Record`, which is how a reader
knows what version it is looking at.
```rust
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
```rust
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:
```rust
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:
```rust
#[derive(Serialize, Deserialize)]
struct Mine {
#[serde(with = "corpus_core::finite")]
score: f64,
#[serde(default, with = "corpus_core::finite::option")]
when: Option<f64>,
}
```
## Exit codes
```rust
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:
```rust
use corpus_core::exit::Verdict;
let answer = Verdict::from(needs_ocr);
std::process::ExitCode::from(answer)
```
See [the contract](contract.md#predicates-answer-with-exit-codes) 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](records.md) — the shapes, described.
- [`catena-cli`](catena-cli.md) — the rest of what a filter needs.
- [`textkit`](textkit.md) — counting and splitting text.
+104
View File
@@ -0,0 +1,104 @@
# cuekit
The cue model, and the three subtitle formats it is read from.
Back to [the index](README.md).
## What it is
A library. Three formats go in — SubRip, WebVTT and SubStation Alpha — and
one [`cue`](records.md#cue--one-line-of-a-transcript) stream comes out.
Everything downstream works on cues and never has to know which of the three a
recording arrived with.
[`cuenorm`](cuenorm.md) is a command over this.
## When you would use it
- **Reading subtitles in your own program**, without shelling out.
- **Adding a fourth format**, or handling one of these three differently.
- **Anywhere timed text is the input** and the format is whatever somebody
happened to have.
## Reading a file
```rust
let cues = cuekit::parse(&content);
```
The format is worked out from the content, not from the name, because a
subtitle file is named for the format somebody expected it to be in. When it
is already known:
```rust
use cuekit::Format;
let cues = cuekit::parse_as(&content, Format::WebVtt);
assert_eq!(cuekit::detect(&content), Format::WebVtt);
```
The parsers are also available directly — `cuekit::srt::parse` (which reads
WebVTT too) and `cuekit::ass::parse`.
## The cue
```rust
use cuekit::Cue;
let cue = Cue::new(12.5, 15.0, "the mill stood at the northern end");
assert_eq!(cue.seconds(), 2.5);
```
Deliberately smaller than a [chunk](records.md#chunk--a-retrievable-passage).
A cue is what a subtitle file holds — a few seconds, often half a sentence —
and turning cues into passages worth retrieving is a later stage's job.
`seconds()` is negative if the file says a cue ends before it starts, which
some do.
## Merging the repeats
```rust
let merged = cuekit::merge_repeats(&cues);
```
Consecutive cues with identical text become one, keeping the earliest start
and the latest end.
Automatic captioning emits a rolling window: the same line appears in cue
after cue as the window slides, so a ten-minute stretch of speech can arrive
as three times as many cues as there are sentences. Indexed as they come,
every one of those is a separate hit for the same words at almost the same
timestamp.
## Cleaning a cue's text
```rust
assert_eq!(cuekit::text::clean("<v Host>[Music] The mill stood"), "The mill stood");
```
Styling and speaker tags go; bracketed sound cues — `[Music]`, `[Applause]`,
`[inaudible]` — go with them, because they are not speech and a search that
matches them matches every recording with a theme tune.
## Timestamps
```rust
assert_eq!(cuekit::srt::timestamp("00:01:23,456"), 83.456);
assert_eq!(cuekit::ass::timestamp("0:01:23.45"), 83.45);
```
Both return `NaN` rather than an error for something that is not a timestamp,
which is what lets a malformed block be dropped whole instead of guessed at.
`cuekit::jsnum` holds the number parsing these use. It reproduces the two
JavaScript conversions the original parser was written against, which disagree
about almost everything an ill-formed timestamp can contain — an empty string
is zero to one of them and not a number to the other. Reproducing both is what
makes a malformed file drop the same cues here as there.
## See also
- [`cuenorm`](cuenorm.md) — the command over this.
- [`cuepack`](cuepack.md) and [`cueseg`](cueseg.md) — what reads cues.
- [The records](records.md) — the cue shape on the wire.
+132
View File
@@ -0,0 +1,132 @@
# cuenorm
Any subtitle format in, one normalised cue stream out.
Back to [the index](README.md).
## What it does
Reads a subtitle file — SubRip, WebVTT or SubStation Alpha — and writes one
[`cue` record](records.md#cue--one-line-of-a-transcript) per line: a start, an
end, and what was said.
The format is worked out from the content, not from the name, because a
subtitle file is named for the format somebody expected it to be in.
## When you would use it
- **At the front of anything that reads recordings.** Everything downstream
works on cues and never has to know which of the three formats a recording
arrived with.
- **Cleaning up automatic captioning.** It repeats each line across
overlapping windows, so the same sentence arrives three or four times over;
indexed as they come, every one is a separate hit for the same words at
almost the same timestamp, which buries everything else in a result list.
- **Getting markup and sound cues out of the way.** `<v Speaker>` tags,
styling, `[Music]`, `[Applause]` — none of it is speech, and a search that
matches `[Music]` matches every recording with a theme tune.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena cuenorm
```
## Example
A WebVTT file with a speaker tag, a sound cue, and a repeated line:
```
$ cat episode.vtt
WEBVTT
00:00:01.000 --> 00:00:04.500
<v Host>The mill stood at the northern end of the valley,
00:00:04.500 --> 00:00:08.000
where the fall of the river was steepest and the ice came latest.
00:00:08.000 --> 00:00:11.000
[Music]
00:00:11.000 --> 00:00:15.500
Its wheel was undershot, which was wasteful,
00:00:15.000 --> 00:00:19.000
Its wheel was undershot, which was wasteful,
00:00:19.000 --> 00:00:24.000
and nobody replaced it for two hundred years.
```
```
$ cuenorm episode.vtt
{"v":1,"start":1.0,"end":4.5,"text":"The mill stood at the northern end of the valley,"}
{"v":1,"start":4.5,"end":8.0,"text":"where the fall of the river was steepest and the ice came latest."}
{"v":1,"start":11.0,"end":19.0,"text":"Its wheel was undershot, which was wasteful,"}
{"v":1,"start":19.0,"end":24.0,"text":"and nobody replaced it for two hundred years."}
```
The speaker tag is gone, `[Music]` left no cue at all, and the repeated line
became one cue spanning both — `11.0` to `19.0`, the earliest start and the
latest end.
In a pipeline:
```sh
cuenorm episode.vtt | cuepack --seconds 75 > passages.ndjson
cuenorm episode.srt | cueseg --duration 7245 > chapters.ndjson
```
## Options
| Option | Effect |
|---|---|
| `--keep-repeats` | Keep consecutive repeats instead of merging them |
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Done |
| `64` | The command line was wrong |
| `74` | The file could not be read |
Bytes that are not UTF-8 are replaced rather than refused. A subtitle file
downloaded from anywhere is as likely to be Latin-1, and a cue with one wrong
character still has its timing and most of its words.
## The formats
| Format | Recognised by |
|---|---|
| WebVTT | The `WEBVTT` line at the front |
| SubStation Alpha | A `[Script Info]` or `[Events]` section |
| SubRip | Neither of the above |
SubStation Alpha events are rows under a `Format:` line that names the
columns, so the column order is read rather than assumed. Override blocks
(`{\an8}`), line breaks (`\N`) and drawing commands are handled: a drawing
command is a shape, not speech, and reads as a string of coordinates if it is
kept.
A WebVTT cue line carries positioning settings after the end time, which are
ignored — that is also what lets an old SubRip file with screen coordinates
after its timestamps read at all.
## As a library
```rust
let cues = cuekit::merge_repeats(&cuekit::parse(&content));
for cue in &cues {
println!("{:.1}–{:.1} {}", cue.start, cue.end, cue.text);
}
```
See [`cuekit`](cuekit.md) for the parsers one at a time.
## See also
- [`cuepack`](cuepack.md) — cues into passages worth embedding.
- [`cueseg`](cueseg.md) — cues into topic chapters.
- [`cuekit`](cuekit.md) — the library this is a command over.
+105
View File
@@ -0,0 +1,105 @@
# cuepack
Cues into passages worth embedding, keeping the times they were said at.
Back to [the index](README.md).
## What it does
Packs consecutive cues into passages of about a minute of continuous speech,
and writes each one as a [`chunk`
record](records.md#chunk--a-retrievable-passage) carrying the start of its
first cue and the end of its last.
## When you would use it
- **Before embedding a transcript.** A cue is a few seconds of speech and
often half a sentence; an embedding of one says almost nothing, and a search
over them returns fragments.
- **When a result has to point somewhere.** Because the passage keeps the
first cue's start and the last cue's end, a hit can be cited to the second
and a player can jump to it.
- **To index recordings and books together.** The record is the same one
[`chunkmd`](chunkmd.md) writes, so one index answers across both and returns
each with a location: a page number for one, a timestamp for the other.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena cuepack
```
## Example
```
$ cuenorm episode.vtt | cuepack --seconds 10
{"v":1,"idx":0,"text":"The mill stood at the northern end of the valley, where the fall of the river was steepest and the ice came latest.","tokens":27,"page":null,"chapter":null,"role":"body","start_time":1.0,"end_time":8.0,"flags":[]}
{"v":1,"idx":1,"text":"Its wheel was undershot, which was wasteful,","tokens":11,"page":null,"chapter":null,"role":"body","start_time":11.0,"end_time":19.0,"flags":[]}
{"v":1,"idx":2,"text":"and nobody replaced it for two hundred years.","tokens":9,"page":null,"chapter":null,"role":"body","start_time":19.0,"end_time":24.0,"flags":[]}
```
Straight to an embedder:
```sh
cuenorm episode.vtt | cuepack | jq -r .text | emb --model bge-m3 --dim 1024
```
It also reads a subtitle file directly, so the middle step can be skipped when
nothing else needs the cues:
```sh
cuepack episode.srt > passages.ndjson
```
## Options
| Option | Default | Effect |
|---|---|---|
| `--seconds <SECONDS>` | `75` | Flush a passage once it covers this much audio |
| `--max-tokens <TOKENS>` | `512` | Never let a passage exceed this many tokens |
Seventy-five seconds is about a paragraph of speech: long enough to hold an
argument, short enough that a result points at a place in the recording rather
than at a region of it.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Done |
| `64` | The command line was wrong |
| `65` | The cue stream could not be read |
| `74` | The file could not be read |
## How it packs
A passage is flushed when the window from its first cue's start to the current
cue's end reaches the seconds budget, or when adding the current cue would
take it past the token budget. The cue that trips the budget starts the next
passage rather than finishing this one.
Immediately repeated lines are dropped rather than packed. Automatic
captioning repeats each line across overlapping windows, and a passage padded
with its own echo embeds as something nobody said.
## Input
Either the cue stream [`cuenorm`](cuenorm.md) writes, or a subtitle file. A
cue stream begins with a JSON object and a subtitle file does not, which is
enough to tell them apart.
## As a library
```rust
use cuepack::Budgets;
for passage in cuepack::pack(&cues, &Budgets::default()) {
println!("{:?}–{:?}: {}", passage.start_time, passage.end_time, passage.text);
}
```
## See also
- [`cuenorm`](cuenorm.md) — what to run before this.
- [`cueseg`](cueseg.md) — the other thing to do with a cue stream.
- [`chunkmd`](chunkmd.md) — the same record, from a book.
+119
View File
@@ -0,0 +1,119 @@
# cueseg
A timed transcript into topic chapters.
Back to [the index](README.md).
## What it does
Finds where a recording changes subject, and writes one [`segment`
record](records.md#segment--one-topic-chapter) per chapter: a start, an end, a
title taken from the chapter's own first sentence, and an excerpt.
No model and no network. The text is hashed into a small lexical vector a
window at a time, and a topic boundary is where two consecutive windows stop
resembling each other.
## When you would use it
- **Chapter marks for a player.** A three-hour recording with no marks is
unnavigable; the boundaries this finds are exactly the marks a scrubber
needs.
- **Feeding a summariser one subject at a time.** A whole transcript is too
much context and a paragraph is not enough; a chapter is the unit that gives
a useful answer.
- **Navigation in a search result.** Knowing which chapter a hit falls in is
often more useful than the timestamp alone.
- **Titles before anybody has written any.** The extracted titles are meant to
be replaced by something better later, and to be usable until they are.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena cueseg
```
## Example
A two-hour recording of two subjects:
```
$ cueseg --duration 7200 transcript.ndjson
{"v":1,"start":0.0,"end":1190.6666666666667,"title":"The Mill Stood At The Northern End Of","excerpt":"the mill stood at the northern end of the valley where the fall of the river was steepest and the ice came latest…"}
{"v":1,"start":1190.6666666666667,"end":2381.3333333333335,"title":"On One Household And The Benefit Was Spread","excerpt":"on one household and the benefit was spread across four hundred families…"}
{"v":1,"start":2381.3333333333335,"end":3572.0,"title":"Stood At The Northern End Of The Valley","excerpt":"stood at the northern end of the valley where the fall of the river was steepest…"}
{"v":1,"start":3572.0,"end":4479.0,"title":"Steepest And The Ice Came Latest Its Wheel","excerpt":"steepest and the ice came latest its wheel was undershot which was wasteful…"}
{"v":1,"start":4479.0,"end":5386.0,"title":"It From The Journals Of Either House Parliament","excerpt":"it from the journals of either house parliament dissolved itself again amid furious objections…"}
{"v":1,"start":5386.0,"end":6293.0,"title":"At All Which Left The Committee With No","excerpt":"at all which left the committee with no record of the vote…"}
{"v":1,"start":6293.0,"end":7200.0,"title":"House Parliament Dissolved Itself Again Amid Furious Objections","excerpt":"house parliament dissolved itself again amid furious objections from every bench…"}
```
Straight from a subtitle file:
```sh
cuenorm episode.vtt | cueseg --duration 7245 > chapters.ndjson
```
As chapter marks for a player:
```sh
cueseg --duration 7245 transcript.ndjson | jq -r '"\(.start | floor)\t\(.title)"'
```
## Options
| Option | Effect |
|---|---|
| `--duration <SECONDS>` | How long the recording is |
Without it, the duration is taken from the end of the last cue. That is
earlier than the end of the file whenever a recording ends in music or
silence, and a chapter that claims to run to the end of the transcript rather
than the end of the file is a mark that lands in the wrong place.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Done |
| `64` | The command line was wrong, or the duration was not a length |
| `65` | The cue stream could not be read |
| `74` | The file could not be read |
## How it finds a boundary
1. **Windows.** Cues are gathered into stretches of about seventy-five
seconds — long enough to have a subject, short enough that a boundary
lands within a minute of the real one.
2. **Vectors.** Each window's content words are hashed into a small vector of
counts. Filler, discourse markers and the vague nouns that carry a
conversation are dropped: leaving them in makes every window resemble every
other.
3. **Valleys.** A boundary is where two consecutive windows resemble each
other less than their neighbours do, and less than the recording's own
average by half a standard deviation. Valleys are taken deepest first, each
at least a chapter's length from the ones already taken.
4. **A floor and a ceiling.** Between four and twenty chapters, targeting one
every fifteen minutes. A chapter mark every two minutes is not navigation,
it is noise; and a chapter that runs too long is no more navigable than no
chapter at all, so an over-long span is divided evenly whether or not the
text suggested a break there.
5. **A fallback.** A recording where nothing stands out is cut evenly, which
is better than one chapter of three hours.
## Input
Either the cue stream [`cuenorm`](cuenorm.md) writes, or a subtitle file.
## As a library
```rust
for chapter in cueseg::segment(&cues, 7245.0) {
println!("{:.0}s {}", chapter.start, chapter.title);
}
```
## See also
- [`cuenorm`](cuenorm.md) — what to run before this.
- [`cuepack`](cuepack.md) — the other thing to do with a cue stream.
+129
View File
@@ -0,0 +1,129 @@
# emb
Lines into vectors, against an embedding server.
Back to [the index](README.md).
## What it does
Sends each line of its input to an HTTP embedding endpoint and writes the
vector that comes back, one JSON array per line, in the order the lines
arrived.
It never loads a model. An embedding model is half a gigabyte of weights and a
GPU, which belongs behind a server where it can be shared — what this owns is
everything around the call: batching, a defensive truncation, a retry, and the
assertion that the vectors are the width the index expects.
## When you would use it
- **At the end of an ingest pipeline**, where passages become rows.
- **Re-embedding an existing corpus** after changing model, where the thing
that matters is that every vector is the same width as the last.
- **Anywhere a GPU is somebody else's problem.** One server, many machines
running this.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena emb
```
## Example
```
$ emb --host http://127.0.0.1:11434 --model bge-m3 --dim 4 lines.txt
[0.0123,-0.0456,0.0789,0.0012]
[0.0123,-0.0456,0.0789,0.0012]
```
Asking for a width the server does not produce is refused, rather than
half-filling an index:
```
$ emb --host http://127.0.0.1:11434 --model bge-m3 --dim 1024 lines.txt
emb: vectors are 4 wide, expected 1024
$ echo $?
65
```
At the end of a pipeline:
```sh
mdscrub chapter.md | chunkmd | jq -r .text \
| emb --model bge-m3 --dim 1024 > vectors.ndjson
```
Because the output lines up with the input line for line, the two can be put
back together:
```sh
paste -d'\t' <(chunkmd book.md | jq -c .) vectors.ndjson
```
## Options
| Option | Default | Effect |
|---|---|---|
| `--host <URL>` | `http://localhost:11434` | The server to ask |
| `--model <NAME>` | required | The model to ask for |
| `--dim <WIDTH>` | — | Refuse vectors that are not this wide |
| `--batch <COUNT>` | `32` | Texts per request |
| `--max-input-tokens <TOKENS>` | `1800` | Cut each text to this many tokens before sending it; `0` sends them whole |
| `--timeout <SECONDS>` | `600` | How long to wait for one request |
| `--attempts <COUNT>` | `3` | How many times to try a failing request |
The input cap is a defensive one, not a model limit. A server returns an error
for an input past its context length, and that error fails the whole batch —
so one oversized passage costs thirty-one good ones.
The timeout is generous because a cold server loading a model onto a GPU can
take minutes before it answers at all.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Every line was embedded |
| `64` | The command line was wrong |
| `65` | The server answered something that was not an embedding, or was the wrong width |
| `69` | The server could not be reached |
| `74` | The input could not be read |
A width mismatch and a missing vector are refused immediately and not retried:
the server answered, it just did not answer the question.
## The request
`POST {host}/api/embed`, with:
```json
{"model": "bge-m3", "input": ["the mill stood", "at the northern end"]}
```
and expecting:
```json
{"embeddings": [[0.0123, -0.0456, …], [0.0021, 0.0310, …]]}
```
which is the shape Ollama uses and several other servers speak.
## Empty lines
Sent as they are. Dropping them would make the output stop lining up with the
input, which is the one property that makes this composable.
## As a library
```rust
let endpoint = emb::Endpoint { dim: Some(1024), ..emb::Endpoint::new(host, "bge-m3") };
let agent = ureq::Agent::new_with_defaults();
let vectors = emb::embed_batch(&agent, &endpoint, &texts)?;
```
## See also
- [`chunkmd`](chunkmd.md) and [`cuepack`](cuepack.md) — where the text comes
from.
- [`rrf`](rrf.md) — what to do with the results of searching those vectors.
+144
View File
@@ -0,0 +1,144 @@
# mdscrub
Converted markdown in, clean markdown out.
Back to [the index](README.md).
## What it does
Takes the markdown that falls out of an EPUB or a PDF converter and removes
everything that means nothing: styling tags that survived, control characters
a text column will reject, headings wrapped in links to their own anchors,
page anchors that should be page markers.
Safe to run twice — the second run changes nothing.
## When you would use it
- **Between a converter and a chunker.** Converted markdown is readable but
not clean, and every piece of leftover markup ends up in an embedding, in a
full-text index, and in every result a reader sees.
- **Backfilling text you already stored.** Running it over text that was
ingested before a cleaning step existed is safe, because it is idempotent.
- **Getting rid of the things that break a database write.** A null byte in
extracted text is rejected outright by most text columns; the rest of the
C0 controls are storable and are extraction garbage.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena mdscrub
```
## Example
A chapter as a converter leaves it:
```
$ cat chapter.md
# [<span id="page41"></span>III. The Second Estate](#ch3) {.unnumbered}
The obligation ran <em>both ways</em>, and contemporaries said so plainly
whenever they were asked.<a href="#n4"><sup>4</sup></a> A lord who could not
protect was a lord who could not command…
<span id="page42"></span>
…
## [Notes]{.section-title}
5. Maitland, *Domesday Book and Beyond* (Cambridge, 1897), 220&ndash;24.
```
and after:
```
$ mdscrub chapter.md
# III. The Second Estate
<!-- Page 41 -->
The obligation ran both ways, and contemporaries said so plainly
whenever they were asked.4 A lord who could not
protect was a lord who could not command…
<!-- Page 42 -->
…
## Notes
5. Maitland, *Domesday Book and Beyond* (Cambridge, 1897), 220–24.
```
The heading is readable, its page anchor became a marker on its own line, the
superscript note reference became the digit it was, and `&ndash;` became the
dash it names.
In a pipeline:
```sh
pandoc -t commonmark chapter.xhtml | mdscrub | chunkmd > chunks.ndjson
```
## Options
| Option | Effect |
|---|---|
| `--pages` | First turn page separators and form feeds into page markers |
`--pages` is for text straight out of a PDF or DjVu extractor, which marks its
page boundaries with a form feed or a rule of dashes. It is off by default
because only the program that just read a PDF knows that a form feed in the
text is a page break rather than a stray control character — in ordinary
markdown it is a control character, and in a code listing it is part of the
listing.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Done |
| `64` | The command line was wrong |
| `65` | The input was not text |
| `74` | The file could not be read |
## What it does, pass by pass
1. **Headings.** A heading wrapped in a link to its own anchor, an attribute
block, a bracketed span, bold markers around a title set in capitals — all
removed, leaving the title. A page anchor inside a heading is moved to a
marker on the line after it, so the heading keeps its position relative to
the body text.
2. **Page anchors.** `<span id="page42">` in the body becomes
`<!-- Page 42 -->`, with the number exactly as it was written.
3. **Character references.** `&lt;sup>107` is decoded first, because a tag
escaped twice is invisible to any pattern looking for a tag — which is how
markup survives into text.
4. **Tags.** Styling tags go and their text stays. A tag that stands for a
line or block boundary — `<br>`, `<p>`, `<li>`, a table cell — becomes a
space, because deleting it welds the words either side together:
`APPENDICES<br>OF<br>LIST` would otherwise come out as `APPENDICESOFLIST`,
which is a word no reader can search for. Genuinely inline tags go to
nothing, so `claim<sup>16</sup>` stays `claim16`.
5. **Fenced code.** Left alone, so a book that quotes markup keeps its
examples. A decorative rule of tildes is not a fence.
6. **Control characters.** Removed, tab and newline excepted.
7. **Blank lines.** Runs collapse to one.
HTML comments survive every pass, which is what lets page markers through.
## As a library
```rust
let cleaned = mdscrub::scrub(&markdown);
// And, for text straight out of an extractor:
let cleaned = mdscrub::scrub(&mdscrub::pages::normalize(&raw));
```
The passes are also available one at a time — `mdscrub::html::strip_tags`,
`mdscrub::heading::clean`, `mdscrub::unescape::unescape` — for a caller that
wants only one of them.
## See also
- [`chunkmd`](chunkmd.md) — what to do with the markdown once it is clean.
- [`pdftriage`](pdftriage.md) — whether the text was worth extracting at all.
+136
View File
@@ -0,0 +1,136 @@
# pdftriage
Whether a PDF's text layer is worth reading, or whether it has to be OCRed.
Back to [the index](README.md).
## What it does
Extracts a sample of text from the middle of a PDF and judges it: healthy,
garbled, sparse, reflected-font, or absent — and therefore whether OCR is
worth its cost.
It never runs OCR, never writes to the file, and reads two page ranges rather
than the whole document.
## When you would use it
- **Deciding what to OCR.** OCR is the expensive step in any ingest: minutes
of GPU time a page, and a result that is worse than a text layer the file
already had. This is the question worth answering carefully before spending
that.
- **Finding books that are already broken in an index.** A garbled layer
extracts as characters, not words, so it indexes silently and returns
nothing anybody searched for.
- **Recovering a layer that only looks dead.** Some PDFs store every character
code reflected about a fixed point, so the text extracts as punctuation
soup — `ײ¬®±¼«½¬·±²` is "Introduction". That is recoverable in full, which
is strictly better than an OCR pass that could only approximate it.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena pdftriage
```
Needs `pdftotext` and `pdfinfo` on `PATH`. Both ship with poppler. Without
either, the tool exits `69` and says so rather than guessing.
## Example
A book whose text is fine:
```
$ pdftriage book.pdf
healthy, no OCR needed
1805 characters on the opening pages
1805 characters over 5 sample pages, ascii text score 1.00
letters 0.98, latin-1 0.00
361 characters a page
english function words 0.44
$ echo $?
1
```
A scan with no text at all:
```
$ pdftriage scan.pdf
absent, needs OCR
0 characters on the opening pages
$ echo $?
0
```
The exit code is the answer, so a shell can act on it directly:
```sh
for f in *.pdf; do pdftriage --quiet "$f" && ocrmypdf "$f" "ocr/$f"; done
```
The record form:
```
$ pdftriage --json book.pdf
{"v":1,"path":"book.pdf","kind":"pdf","declared_ext":"pdf","bytes":17595,"pages":20,"language":null,"text_layer":"healthy","needs_ocr":false,"notes":["1805 characters on the opening pages","1805 characters over 5 sample pages, ascii text score 1.00","letters 0.98, latin-1 0.00","361 characters a page","english function words 0.44"]}
```
## Options
| Option | Effect |
|---|---|
| `--json` | Emit a [`doc` record](records.md#doc--what-a-file-is) instead of a readable verdict |
| `-q`, `--quiet` | Write no verdict; answer with the exit code alone. Diagnostics still go to stderr, because a silent failure is worse than a quiet one |
## Exit codes
| Code | Meaning |
|---|---|
| `0` | The layer needs OCR |
| `1` | It does not |
| `2` | Could not tell — the extractor could not read the file |
| `64` | The command line was wrong |
| `69` | `pdftotext` or `pdfinfo` is not installed |
| `74` | The file could not be read |
## The verdicts
| Verdict | What it means | OCR? |
|---|---|---|
| `healthy` | Extractable and readable as it stands | no |
| `reflected-font` | Permuted by a non-standard font encoding, and recoverable without OCR once the permutation is undone | no |
| `garbled` | Present but mojibake — extraction yields characters, not words | yes |
| `sparse` | Present but too thin to be the whole page: a scan whose layer holds only the page numbers and the running heads | yes |
| `absent` | No text at all | yes |
The sample comes from the middle of the document, not the front. Front matter
is often a bare scan in a book whose body carries good text, and often cleanly
typeset boilerplate in a book whose body is damaged — judging a book by its
title page gets both wrong.
The thresholds are measurements rather than guesses. Each one sits in the
middle of a gap observed across a few thousand real PDFs, and the reason is
recorded beside it in the source.
## As a library
The judging is a pure function over already-extracted text, so it can be
tested and used without a PDF anywhere in sight:
```rust
use pdftriage::{Limits, Sample};
let found = pdftriage::triage(
&Sample { opening, middle, middle_pages: 5, pages: Some(412) },
&Limits::default(),
);
println!("{:?} needs_ocr={}", found.layer, found.needs_ocr);
```
Running `pdftotext` is the command's job, not the library's.
## See also
- [`booksniff`](booksniff.md) — whether it is a PDF at all.
- [`mdscrub`](mdscrub.md) — cleaning up the text once it has been extracted.
- [The records](records.md) — the shape `--json` writes.
+116
View File
@@ -0,0 +1,116 @@
# The records
Five shapes travel between these tools. Each carries a version field `v`, so a
reader can refuse a stream it does not understand rather than parsing it into
whatever happens to line up.
Every list-shaped stream is [NDJSON](contract.md#streams-not-documents): one
record per line, flushed as it is produced.
Back to [the index](README.md).
## `doc` — what a file is
```json
{"v":1,"path":"scan.pdf","kind":"pdf","declared_ext":"pdf","bytes":9261184,
"pages":412,"language":"eng","text_layer":"garbled","needs_ocr":true,"notes":[]}
```
| Field | Meaning |
|---|---|
| `path` | Where the file was read from, as given |
| `kind` | `pdf`, `epub`, `djvu`, `azw3`, `mobi`, `txt`, `html`, `ole2`, `zip-wrapped-pdf`, `unknown` |
| `declared_ext` | The extension the name claimed, lowercased, without the dot |
| `bytes` | Size on disk |
| `pages` | Page count, for paginated formats |
| `language` | Language code, when one was detected |
| `text_layer` | `healthy`, `garbled`, `sparse`, `reflected-font`, `absent` |
| `needs_ocr` | Whether the file has to be OCRed to be searchable |
| `notes` | Observations, in the order they were made |
A tool fills what it can and leaves the rest null: a format sniffer fills
`kind` and leaves `text_layer` empty, a triage tool does the reverse.
Written by [`booksniff`](booksniff.md) and [`pdftriage`](pdftriage.md).
## `chunk` — a retrievable passage
```json
{"v":1,"idx":7,"text":"…","tokens":512,"page":37,"chapter":"III. The Second Estate",
"role":"body","start_time":null,"end_time":null,"flags":[]}
```
| Field | Meaning |
|---|---|
| `idx` | Position in the stream this chunk was cut from, from zero |
| `text` | The passage |
| `tokens` | Token count of `text` |
| `page` | Page the passage starts on, for paginated sources |
| `chapter` | Nearest enclosing heading, as written |
| `role` | `body`, `bibliography`, `index`, `toc`, `frontmatter` |
| `start_time`, `end_time` | Seconds from the start, for recordings |
| `flags` | Set tags, sorted and deduplicated |
`page` and `chapter` for documents, `start_time` and `end_time` for
recordings. One shape either way, which is what lets a single index serve a
book and a lecture and cite both.
Written by [`chunkmd`](chunkmd.md) and [`cuepack`](cuepack.md).
## `hit` — one scored candidate
```json
{"v":1,"id":"c81a","channel":"vec","rank":1,"score":0.83,"payload":{}}
```
| Field | Meaning |
|---|---|
| `id` | Identifier of the thing retrieved, opaque to fusion |
| `channel` | `vec`, `fts` or `meta` |
| `rank` | Position within that channel's own ranking, from one |
| `score` | The channel's raw score, on whatever scale it uses |
| `payload` | Anything the producer wants carried through untouched |
`rank` is what fusion consumes. `score` is carried along because it is useful
to a person reading a trace and useless for comparing across channels.
Read and written by [`rrf`](rrf.md).
## `cue` — one line of a transcript
```json
{"v":1,"start":12.5,"end":15.0,"text":"the mill stood at the northern end"}
```
Smaller than a chunk on purpose: a cue is what a subtitle file holds, a few
seconds and often half a sentence.
Written by [`cuenorm`](cuenorm.md), read by [`cuepack`](cuepack.md) and
[`cueseg`](cueseg.md).
## `segment` — one topic chapter
```json
{"v":1,"start":0.0,"end":1190.7,"title":"The Mill Stood At The Northern End",
"excerpt":"the mill stood at the northern end of the valley where…"}
```
The marks a scrubber needs, and the spans a summariser should be given one at
a time.
Written by [`cueseg`](cueseg.md).
## Getting the schemas
The JSON Schema for the first three is printed by the record library itself:
```
$ cargo run -p corpus-core -- schema chunk
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Chunk",
…
}
```
See [`corpus-core`](corpus-core.md).
+114
View File
@@ -0,0 +1,114 @@
# rrf
Several ranked lists into one, without the database.
Back to [the index](README.md).
## What it does
Reciprocal rank fusion. Reads [`hit`
records](records.md#hit--one-scored-candidate) from any number of retrieval
channels and writes one ranked list, best first.
## When you would use it
- **Hybrid search, without adopting anything.** Vector search and full-text
search disagree about everything except what matters, and their scores are
not comparable — a cosine distance and a text rank are different quantities
on different scales. Fusion sidesteps that by throwing the scores away and
keeping only the ranks: a result both channels put near the top beats one
that either channel loves alone.
- **Making a metadata match count for more.** When somebody searches for a
book by its title, the book rarely repeats its own title in its body, so the
channel that matched the title is the one telling the truth. That is what
the weights are for.
- **Merging results across corpora**, or across shards, or across any two
systems that both return ranked lists.
This is the one tool here worth calling in process rather than as a
subprocess: by the time a hit reaches it the data is already yours, so the
isolation a separate process buys is not worth a spawn on an interactive
search path.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena rrf
```
## Example
Three channels, overlapping:
```
$ cat vector.ndjson fts.ndjson meta.ndjson | rrf
{"v":1,"id":"e901","channel":"meta","rank":1,"score":0.040719196192490745,"payload":{"title":"The Human Condition"}}
{"v":1,"id":"c81a","channel":"vec","rank":2,"score":0.032266458495966696,"payload":{}}
{"v":1,"id":"b455","channel":"fts","rank":3,"score":0.032266458495966696,"payload":{}}
{"v":1,"id":"7f20","channel":"vec","rank":4,"score":0.016129032258064516,"payload":{}}
```
`e901` was third in full-text search and first in metadata, and the metadata
weight carries it to the top. `c81a` and `b455` tie, and the tie is broken by
which was seen first, so the same input always gives the same output.
The top ten:
```sh
cat *.ndjson | rrf --limit 10
```
Trusting full-text matching more than usual:
```sh
cat *.ndjson | rrf --weight fts=2 --weight meta=1
```
## Options
| Option | Default | Effect |
|---|---|---|
| `-n`, `--limit <COUNT>` | all | How many results to keep |
| `--rrf-k <K>` | `60` | The rank constant |
| `--weight <CHANNEL=WEIGHT>` | `vec=1 fts=1 meta=1.5` | What a channel's rank is worth. Repeatable |
The rank constant flattens the difference between rank one and rank two. Sixty
is what the method's own paper uses and what every other implementation uses,
which makes fused scores comparable across systems — worth more than tuning
it.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Done |
| `64` | The command line was wrong |
| `65` | A hit could not be read |
| `74` | The input could not be read |
## Input and output
One [`hit`](records.md#hit--one-scored-candidate) per line, in any order and
interleaved if you like — each carries the rank its own channel gave it, so
the stream does not have to be grouped.
The record that comes back for an id is the one that ranked it best, carrying
its payload, with `rank` and `score` replaced by its place and score in the
fused list. That makes `channel` on the output informative: it names which
channel liked this result most.
## The arithmetic
Each hit contributes `weight / (k + rank)` to its id's score. Nothing else is
used — not the channel's own score, which is exactly what is not comparable.
## As a library
```rust
let fused = rrf::fuse(&hits, &rrf::Weights::default());
```
## See also
- [`emb`](emb.md) — producing the vectors one of the channels searches.
- [The records](records.md) — the shape this reads and writes.
+121
View File
@@ -0,0 +1,121 @@
# textkit
Token counting, sentence and paragraph splitting.
Back to [the index](README.md).
## What it is
A library. Two jobs, both of which have to give the same answer everywhere or
chunk boundaries move: counting tokens the way an embedding stack counts them,
and cutting text into sentences and paragraphs.
## When you would use it
- **Deciding how much text fits** in a model's context, before sending it.
- **Writing your own chunker** that has to agree with
[`chunkmd`](chunkmd.md) about where a boundary is.
- **Porting anything from Python** where the exact behaviour of `\s`, `\w` or
`\b` matters more than it looks like it should.
## Counting tokens
```rust
let tokens = textkit::count("the mill stood at the northern end");
let ids = textkit::encode("…");
let text = textkit::decode_lossy(&ids[..100]);
```
`cl100k_base`, loaded from data compiled into the binary — no download, no
cache directory, nothing to go stale. Counting against a different tokeniser
than the one an index was built with silently moves every chunk boundary,
which is why this is one function and not a parameter.
Special-token spellings are treated as ordinary text. Text out of a PDF can
contain anything, including the literal string `<|endoftext|>`, and encoding
it as a control token would be both wrong and a way for a document to reach
into a model's input.
Cutting a list of tokens at an arbitrary index can split a character in half,
so decoding a slice is lossy rather than fallible:
```rust
// The last resort of a chunker: guarantees a maximum, respects nothing else.
for piece in textkit::split_at_token_limit(&text, 512) {
assert!(textkit::count(&piece) <= 512);
}
```
## Splitting text
```rust
for paragraph in textkit::paragraphs(&markdown) {
for sentence in textkit::sentences(paragraph) {
// …
}
}
```
Both return borrowed slices, trimmed, with the empty pieces dropped.
A paragraph break is a newline, any run of whitespace, and a newline — so one
blank line and four of them separate equally, and trailing spaces on an
otherwise blank line do not stop it being blank.
A sentence break is whitespace following `.`, `!` or `?`. Crude on purpose: it
does not know that `Mr.` or `p. 41` are not sentence ends. It is the fallback
path for a paragraph too large to chunk whole, where a wrong cut costs a
little context and a clever cut that disagrees with an existing index costs a
re-embedding.
## Whitespace
```rust
let trimmed = text.trim_matches(textkit::is_space);
```
`is_space` is whitespace by Python's definition: the Unicode `White_Space`
property *and* the four ASCII information separators `U+001C`–`U+001F`.
Trimming with the narrower definition leaves a separator on the front of a
piece, which changes its token count, which moves a chunk boundary.
## Numbers
```rust
assert_eq!(textkit::decimal("<!-- Page 412 -->"), 412);
assert_eq!(textkit::decimal("٤٢"), 42);
```
Every decimal digit, not only the ASCII ones. Every Unicode decimal block runs
zero to nine in consecutive code points, which is what makes this possible
without a lookup table.
## Patterns
Character classes that mean in Rust what they mean in Python:
```rust
use textkit::pattern::{SPACE, WORD, WORD_START, WORD_END};
let year = fancy_regex::Regex::new(&format!(r"{WORD_START}(19|20)\d{{2}}{WORD_END}"))?;
let heading = regex::Regex::new(&format!(r"(?m)^(#{{1,3}}){SPACE}+(.+)$"))?;
```
The two regex engines disagree, and every disagreement is a heuristic that
fires on one side and not the other:
- Python's `\s` also matches `U+001C`–`U+001F`.
- Python's `\w` is the letters, the numbers and the underscore. Rust's adds
every combining mark and every connector punctuation mark, and leaves out
the letter-like and other numbers — so `Gödel` written in decomposed form is
one word to Rust and two to Python, and `²` is a word character to Python
and not to Rust.
- `\b` is defined in terms of `\w`, so it inherits all of that.
The boundary forms are lookarounds, so a pattern using them needs an engine
that has them.
## See also
- [`corpus-core`](corpus-core.md) — the records these help produce.
- [`chunkmd`](chunkmd.md) — the chunker built on this.