2026-09-19 17:54:18 -04:00
|
|
|
# catena (bindings)
|
|
|
|
|
|
|
|
|
|
The catena libraries behind one surface, for calling in process rather than as
|
|
|
|
|
a subprocess.
|
|
|
|
|
|
|
|
|
|
Part of [catena](../../README.md).
|
|
|
|
|
|
|
|
|
|
## What it does
|
|
|
|
|
|
|
|
|
|
Every tool in this workspace is a library with a thin command on top. A
|
|
|
|
|
pipeline written in another language can have either.
|
|
|
|
|
|
|
|
|
|
A subprocess is the right answer most of the time: it is a real process
|
|
|
|
|
boundary, it composes with a shell, and it costs a few milliseconds. In
|
|
|
|
|
process is the right answer when the caller is already holding the text — a
|
|
|
|
|
stage that would otherwise write a file so that it can read it straight back.
|
|
|
|
|
|
|
|
|
|
What comes back is the same **record shapes** the commands write to stdout,
|
|
|
|
|
as dictionaries rather than lines of JSON. Those shapes are documented and
|
|
|
|
|
versioned; the structs behind them are free to move.
|
|
|
|
|
|
2026-09-19 18:40:08 -04:00
|
|
|
Three of them: Python, JavaScript through WebAssembly, and a C ABI. All three
|
|
|
|
|
call the same Rust and hand back the same records.
|
|
|
|
|
|
2026-09-19 17:54:18 -04:00
|
|
|
## Python
|
|
|
|
|
|
|
|
|
|
```sh
|
2026-09-21 08:15:26 -04:00
|
|
|
pip install catena-corpus
|
2026-09-19 17:54:18 -04:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
import catena
|
|
|
|
|
|
|
|
|
|
chunks = catena.chunk_markdown(markdown, target=512, overlap=64)
|
|
|
|
|
chunks[0]["page"] # 41
|
|
|
|
|
chunks[0]["chapter"] # 'III. The Second Estate'
|
|
|
|
|
|
|
|
|
|
clean = catena.scrub(raw_markdown, pages=True)
|
|
|
|
|
catena.count_tokens(clean)
|
|
|
|
|
|
|
|
|
|
cues = catena.parse_subtitles(open("episode.srt").read())
|
|
|
|
|
passages = catena.pack_cues(cues, seconds=75)
|
|
|
|
|
|
|
|
|
|
catena.identify("atlas.pdf", open("atlas.pdf", "rb").read(1024))["kind"]
|
2026-09-20 09:12:04 -04:00
|
|
|
|
|
|
|
|
# Chapters, compared by the words a window repeats …
|
|
|
|
|
chapters = catena.segment_cues(cues, duration=7200)
|
|
|
|
|
|
|
|
|
|
# … or by vectors you made, which know that ships and vessels are one subject.
|
|
|
|
|
vectors = embed(catena.cue_windows(cues))
|
|
|
|
|
chapters = catena.segment_cues(cues, duration=7200, embeddings=vectors)
|
2026-09-19 17:54:18 -04:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`split_collection` raises `ValueError` when the document is one work rather
|
|
|
|
|
than a collection — the usual answer, with the reason each candidate was
|
|
|
|
|
rejected:
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
try:
|
|
|
|
|
works = catena.split_collection(markdown)
|
|
|
|
|
except ValueError as why:
|
|
|
|
|
works = [{"idx": 0, "title": title, "text": markdown}]
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### What is bound
|
|
|
|
|
|
|
|
|
|
| Function | The tool it is |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `chunk_markdown` | [`chunkmd`](../chunkmd/README.md) |
|
|
|
|
|
| `scrub` | [`mdscrub`](../mdscrub/README.md) |
|
|
|
|
|
| `count_tokens` | [`textkit`](../textkit/README.md) |
|
|
|
|
|
| `parse_subtitles` | [`cuenorm`](../cuenorm/README.md) |
|
|
|
|
|
| `pack_cues` | [`cuepack`](../cuepack/README.md) |
|
2026-09-20 09:00:26 -04:00
|
|
|
| `segment_cues` | [`cueseg`](../cueseg/README.md) |
|
2026-09-20 09:12:04 -04:00
|
|
|
| `cue_windows` | the windows `segment_cues` measures, to embed |
|
2026-09-19 17:54:18 -04:00
|
|
|
| `split_collection` | [`mdsplit`](../mdsplit/README.md) |
|
|
|
|
|
| `fuse_hits` | [`rrf`](../rrf/README.md) |
|
|
|
|
|
| `identify` | [`booksniff`](../booksniff/README.md) |
|
2026-09-19 19:03:47 -04:00
|
|
|
| `series_of` | [`seriesof`](../seriesof/README.md) |
|
2026-09-19 19:19:54 -04:00
|
|
|
| `parse_identifier` | [`idnorm`](../idnorm/README.md) |
|
2026-09-20 10:38:48 -04:00
|
|
|
| `find_citations` | [`citefind`](../citefind/README.md) |
|
2026-09-20 14:44:09 -04:00
|
|
|
| `looks_like_footnote_citations` | whether a body chunk carries notes worth reading |
|
2026-09-20 15:10:35 -04:00
|
|
|
| `suggest_author` | [`bibclean`](../bibclean/README.md)'s author recovery |
|
2026-09-20 15:15:48 -04:00
|
|
|
| `canonical_subject` | what a closed subject scheme calls a heading |
|
|
|
|
|
| `compose_subjects` | the subjects a book should carry under one |
|
|
|
|
|
| `needs_subject` | whether it carries a heading the scheme knows |
|
2026-09-20 15:22:06 -04:00
|
|
|
| `clean_record` | a whole catalogue record audited and repaired |
|
|
|
|
|
| `record_problems`, `record_severity` | what is wrong with one, and how much |
|
|
|
|
|
| `title_in_author`, `looks_like_person` | the two commonest catalogue faults |
|
|
|
|
|
| `is_shouting`, `title_case`, `clean_underscores` | the repairs that need no lookup |
|
|
|
|
|
| `valid_isbn`, `isbn_from_text`, `isbn_from_filename` | ISBNs, checked and found |
|
|
|
|
|
| `same_work` | whether two titles name the same book |
|
2026-09-20 10:38:48 -04:00
|
|
|
| `citation_prompt` | the question `citefind --prompt` writes |
|
|
|
|
|
| `merge_citation_reply` | what `citefind --merge` does with the answer |
|
2026-09-19 17:54:18 -04:00
|
|
|
|
|
|
|
|
The tools that are not here are the ones a binding would gain nothing over a
|
|
|
|
|
subprocess for: they read files, shell out to another program, or talk to a
|
|
|
|
|
server, and none of that is cheaper in process.
|
|
|
|
|
|
2026-09-19 18:40:08 -04:00
|
|
|
## JavaScript
|
|
|
|
|
|
|
|
|
|
```sh
|
2026-09-21 08:15:26 -04:00
|
|
|
npm install catena-corpus
|
2026-09-19 18:40:08 -04:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
```js
|
2026-09-21 08:15:26 -04:00
|
|
|
import { chunkMarkdown, scrub, countTokens, fuseHits } from "catena-corpus";
|
2026-09-19 18:40:08 -04:00
|
|
|
|
|
|
|
|
const chunks = chunkMarkdown(markdown, 512, 64);
|
|
|
|
|
chunks[0].page; // 41
|
|
|
|
|
chunks[0].chapter; // 'III. The Second Estate'
|
|
|
|
|
|
|
|
|
|
fuseHits([...vectorHits, ...textHits], undefined, { vec: 2 }, 20);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Records come back as plain objects, not handles: nothing to free,
|
|
|
|
|
`JSON.stringify` works, and an array of chunks spreads into state without
|
|
|
|
|
ceremony. The declarations are hand-written, so a caller gets `Chunk[]` rather
|
|
|
|
|
than `any`.
|
|
|
|
|
|
|
|
|
|
`splitCollection` throws when the document is one work rather than a
|
|
|
|
|
collection, which is the usual answer.
|
|
|
|
|
|
|
|
|
|
Two builds, because the loaders differ — node reads the `.wasm` off disk at
|
|
|
|
|
import, the web build takes a URL a bundler rewrites:
|
|
|
|
|
|
|
|
|
|
```js
|
2026-09-21 08:15:26 -04:00
|
|
|
import { chunkMarkdown } from "catena-corpus"; // resolves per environment
|
2026-09-19 18:40:08 -04:00
|
|
|
import { chunkMarkdown } from "catena/node"; // or pick one
|
|
|
|
|
import { chunkMarkdown } from "catena/web";
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## C
|
|
|
|
|
|
|
|
|
|
```c
|
|
|
|
|
#include <catena.h>
|
|
|
|
|
|
|
|
|
|
char *chunks = catena_chunk_markdown(markdown, 512, 64, 50, 768);
|
|
|
|
|
if (!chunks) fprintf(stderr, "%s\n", catena_last_error());
|
|
|
|
|
/* … a JSON array of chunk records … */
|
|
|
|
|
catena_string_free(chunks);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Text in as NUL-terminated UTF-8, records out as NUL-terminated JSON — the same
|
|
|
|
|
document the command line writes. Nothing here is an opaque handle with
|
|
|
|
|
accessors to keep in step: the records already have a documented shape, and
|
|
|
|
|
every language with an FFI has a JSON parser.
|
|
|
|
|
|
|
|
|
|
Every returned string is yours and must go back through `catena_string_free`.
|
|
|
|
|
A call that fails returns NULL and leaves a message in `catena_last_error`,
|
|
|
|
|
which is thread-local. `catena_abi_version` is bumped when anything here
|
|
|
|
|
changes meaning; additions leave it alone.
|
|
|
|
|
|
|
|
|
|
A budget of zero means that budget's default, so a caller that cares about one
|
|
|
|
|
of them can pass zero for the rest.
|
|
|
|
|
|
|
|
|
|
## Building
|
2026-09-19 17:54:18 -04:00
|
|
|
|
|
|
|
|
```sh
|
2026-09-19 18:40:08 -04:00
|
|
|
cd crates/bindings
|
|
|
|
|
maturin build --profile wheel # the Python wheel
|
|
|
|
|
npm/build.sh # the npm package
|
|
|
|
|
cargo build --features capi # the C library and its header
|
2026-09-19 17:54:18 -04:00
|
|
|
```
|
|
|
|
|
|
2026-09-19 18:40:08 -04:00
|
|
|
Three things worth knowing, each of which fails confusingly otherwise:
|
|
|
|
|
|
|
|
|
|
**`cargo build --features python` does not work and is not supposed to.**
|
|
|
|
|
pyo3's `extension-module` leaves the Python symbols to be resolved by the
|
|
|
|
|
interpreter at load time, so there is nothing to link against until an
|
|
|
|
|
interpreter loads it. `cargo clippy --features python` type-checks it without
|
|
|
|
|
linking, which is what the gate runs.
|
|
|
|
|
|
|
|
|
|
**The wheel needs `--profile wheel`, not `--release`.** The release profile
|
|
|
|
|
strips, and a stripped extension module is one macOS refuses to load at all —
|
|
|
|
|
"mis-aligned LINKEDIT string pool". The `wheel` profile is the release profile
|
|
|
|
|
without it. `[tool.maturin] profile` is ignored by maturin 1.15, so it has to
|
|
|
|
|
be the flag.
|
|
|
|
|
|
|
|
|
|
**The WebAssembly build is only compiled for wasm32.** On any other target the
|
|
|
|
|
module compiles to nothing, so `--all-features` on a host build does not reach
|
|
|
|
|
it; the gate checks it separately against `wasm32-unknown-unknown`.
|
2026-09-19 17:54:18 -04:00
|
|
|
|
2026-09-19 20:40:15 -04:00
|
|
|
## Swapping a stage over
|
|
|
|
|
|
|
|
|
|
A binding that returns *nearly* the same answer as the code it replaces is
|
|
|
|
|
worse than no binding: the pipeline keeps running and the corpus quietly
|
2026-09-19 20:43:16 -04:00
|
|
|
changes underneath it.
|
2026-09-19 20:40:15 -04:00
|
|
|
|
2026-09-19 20:43:16 -04:00
|
|
|
So before swapping anything, run both implementations over a directory of real
|
|
|
|
|
files and compare the answers field by field — not a sample, and not the test
|
|
|
|
|
fixtures, which are the inputs somebody already thought of. The things that
|
|
|
|
|
find divergences are the documents nobody chose.
|
2026-09-19 20:40:15 -04:00
|
|
|
|
2026-09-19 20:43:16 -04:00
|
|
|
Two details worth the trouble:
|
2026-09-19 20:40:15 -04:00
|
|
|
|
2026-09-19 20:43:16 -04:00
|
|
|
- **Compare against the code you are actually replacing.** A checkout that has
|
|
|
|
|
moved on since the port measures the drift rather than the port, and reports
|
|
|
|
|
failures that are nobody's bug.
|
|
|
|
|
- **Check that the comparison can fail.** Feed one side a deliberately wrong
|
|
|
|
|
setting and confirm it is reported. A check that cannot fail proves nothing,
|
|
|
|
|
and one that silently compares nothing looks exactly like success.
|
2026-09-19 20:40:15 -04:00
|
|
|
|
2026-09-19 17:54:18 -04:00
|
|
|
## As a Rust library
|
|
|
|
|
|
|
|
|
|
The same surface, without a binding in the way:
|
|
|
|
|
|
|
|
|
|
```rust
|
|
|
|
|
let chunks = catena::chunk_markdown(&markdown, catena::ChunkBudgets::default());
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Nothing here grows logic of its own. If a binding needs something the
|
|
|
|
|
libraries cannot do, that is a missing library function rather than a binding
|
|
|
|
|
feature.
|
|
|
|
|
|
|
|
|
|
## See also
|
|
|
|
|
|
|
|
|
|
- [The records](../../docs/records.md) — the shapes these hand back.
|
|
|
|
|
- [Recipes](../../docs/recipes.md) — the same pipelines from a shell.
|