2026-09-19 17:54:18 -04:00
//! The catena libraries behind one surface, for calling in process.
//!
//! Every tool in this workspace is a library with a thin command on top, and
//! a pipeline written in another language can have either. A subprocess is
//! the right answer most of the time: it is a 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 is bound here is the **record shapes**, not the internals. Those
//! shapes are documented, versioned, and what every tool already writes to
//! stdout; binding the structs behind them would publish field names that are
//! free to move and leave the two forms disagreeing about the same chunk.
//!
//! Nothing here grows logic of its own. If a binding needs something the
//! libraries cannot do, that is a missing library function.
#[cfg(feature = "python" )]
mod python ;
2026-09-19 18:40:08 -04:00
#[cfg(feature = "capi" )]
pub mod capi ;
// Only ever compiled for a wasm32 target. The feature can be on elsewhere —
// `--all-features` turns everything on — and there it compiles to nothing.
#[cfg(all(feature = "wasm" , target_arch = "wasm32" ))]
pub mod wasm ;
2026-09-19 17:54:18 -04:00
use corpus_core ::{ Chunk , Doc , Hit };
use cuekit ::Cue ;
/// Where chunks are flushed, and how large they may get.
#[derive(Debug, Clone, Copy)]
pub struct ChunkBudgets {
/// Flush a chunk at this many tokens.
pub target : usize ,
/// Carry this much of each chunk into the next one.
pub overlap : usize ,
/// Drop chunks smaller than this.
pub min_tokens : usize ,
/// Never emit a chunk larger than this.
pub hard_max : usize ,
}
impl Default for ChunkBudgets {
fn default () -> Self {
let budgets = chunkmd ::Budgets ::default ();
Self {
target : budgets . target ,
overlap : budgets . overlap ,
min_tokens : budgets . min_tokens ,
hard_max : budgets . hard_max ,
}
}
}
impl From < ChunkBudgets > for chunkmd ::Budgets {
fn from ( budgets : ChunkBudgets ) -> Self {
chunkmd ::Budgets {
target : budgets . target ,
overlap : budgets . overlap ,
min_tokens : budgets . min_tokens ,
hard_max : budgets . hard_max ,
}
}
}
/// How much of a recording a passage covers.
#[derive(Debug, Clone, Copy)]
pub struct CueBudgets {
/// Seconds of audio a passage covers.
pub seconds : f64 ,
/// Tokens a passage may hold, whatever the clock says.
pub max_tokens : usize ,
}
impl Default for CueBudgets {
fn default () -> Self {
let budgets = cuepack ::Budgets ::default ();
Self {
seconds : budgets . seconds ,
max_tokens : budgets . max_tokens ,
}
}
}
/// Cut markdown into retrievable passages.
pub fn chunk_markdown ( markdown : & str , budgets : ChunkBudgets ) -> Vec < Chunk > {
chunkmd ::chunk ( markdown , & budgets . into ())
}
/// Clean converted markdown.
pub fn scrub_markdown ( markdown : & str ) -> String {
mdscrub ::scrub ( markdown )
}
/// Clean converted markdown, normalising page markers as well.
///
/// Separate because it is not idempotent: running it twice renumbers the
/// pages it moved the first time.
pub fn scrub_markdown_pages ( markdown : & str ) -> String {
mdscrub ::pages ::normalize ( & mdscrub ::scrub ( markdown ))
}
/// How many tokens a string is, by the encoding every budget here counts in.
pub fn count_tokens ( text : & str ) -> usize {
textkit ::count ( text )
}
/// Read any of the subtitle formats into cues.
pub fn parse_subtitles ( content : & str ) -> Vec < Cue > {
cuekit ::parse ( content )
}
/// Collapse the runs of repeated cues a live caption track leaves behind.
pub fn merge_repeats ( cues : & [ Cue ]) -> Vec < Cue > {
cuekit ::merge_repeats ( cues )
}
/// Pack cues into passages worth embedding.
pub fn pack_cues ( cues : & [ Cue ], budgets : CueBudgets ) -> Vec < Chunk > {
cuepack ::pack (
cues ,
& cuepack ::Budgets {
seconds : budgets . seconds ,
max_tokens : budgets . max_tokens ,
},
)
}
2026-09-20 09:00:26 -04:00
/// Cut a timed transcript into topic chapters.
///
/// `duration` is how long the recording is, in seconds; it decides how many
/// chapters to aim for. Zero or less means take it from the last cue.
pub fn segment_cues ( cues : & [ Cue ], duration : f64 ) -> Vec < cueseg ::Segment > {
let duration = match duration > 0.0 {
true => duration ,
false => cues . last (). map_or ( 0.0 , | cue | cue . end ),
};
cueseg ::segment ( cues , duration )
}
2026-09-19 17:54:18 -04:00
/// Split a collection into the works inside it.
///
/// `Err` when the document is one work rather than a collection, which is the
/// usual answer and not a failure. The message says what each candidate
/// produced.
pub fn split_collection ( markdown : & str ) -> Result < Vec < mdsplit ::Section > , String > {
mdsplit ::split ( markdown ). map_err ( | why | why . to_string ())
}
/// Fuse ranked lists into one.
///
/// `weights` is read as channel name to weight; a channel not named is worth
/// one. An unknown name is ignored rather than refused, because a caller
/// fusing two channels should not have to know about a third.
pub fn fuse_hits (
hits : & [ Hit ],
k : f64 ,
weights : & [( String , f64 )],
limit : Option < usize > ,
) -> Vec < Hit > {
let mut channels = std ::collections ::HashMap ::new ();
for ( name , weight ) in weights {
if let Some ( channel ) = channel_named ( name ) {
channels . insert ( channel , * weight );
}
}
let fused = rrf ::fuse ( hits , & rrf ::Weights { k , channels });
match limit {
Some ( limit ) => fused . into_iter (). take ( limit ). collect (),
None => fused ,
}
}
/// The channel a name refers to, spelled as the records spell it.
fn channel_named ( name : & str ) -> Option < corpus_core ::Channel > {
match name {
"vec" => Some ( corpus_core ::Channel ::Vector ),
"fts" => Some ( corpus_core ::Channel ::Fts ),
"meta" => Some ( corpus_core ::Channel ::Meta ),
_ => None ,
}
}
/// Say what a file is from its bytes and its name.
pub fn identify ( name : & str , bytes : & [ u8 ]) -> Doc {
booksniff ::sniff ( & bytes , name )
}
2026-09-19 19:19:54 -04:00
/// Read an identifier — a DOI, ISSN, ISBN or arXiv id — out of whatever it
/// arrived wrapped in.
pub fn parse_identifier ( text : & str ) -> Option < idnorm ::Identifier > {
idnorm ::parse ( text )
}
2026-09-19 19:03:47 -04:00
/// The series a title belongs to, and which volume of it this is.
pub fn series_of ( title : & str , author : Option <& str > ) -> Option < seriesof ::Proposal > {
seriesof ::series_of ( title , author )
}
2026-09-19 17:54:18 -04:00
#[cfg(test)]
mod tests {
use super ::* ;
const MARKDOWN : & str = "# Chapter One \n\n <!-- Page 41 --> \n\n The obligation ran both ways, and contemporaries said so plainly whenever they were asked. A lord who could not protect was a lord who could not command. \n " ;
#[test]
fn the_surface_agrees_with_the_library_underneath () {
assert_eq! (
chunk_markdown ( MARKDOWN , ChunkBudgets ::default ()),
chunkmd ::chunk ( MARKDOWN , & chunkmd ::Budgets ::default ())
);
assert_eq! ( scrub_markdown ( MARKDOWN ), mdscrub ::scrub ( MARKDOWN ));
assert_eq! ( count_tokens ( "a passage" ), textkit ::count ( "a passage" ));
}
#[test]
fn the_default_budgets_are_the_tools_defaults () {
let ours = ChunkBudgets ::default ();
let theirs = chunkmd ::Budgets ::default ();
assert_eq! (
( ours . target , ours . overlap , ours . min_tokens , ours . hard_max ),
(
theirs . target ,
theirs . overlap ,
theirs . min_tokens ,
theirs . hard_max
)
);
assert_eq! (
CueBudgets ::default (). seconds ,
cuepack ::Budgets ::default (). seconds
);
}
2026-09-20 09:00:26 -04:00
#[test]
fn a_transcript_comes_back_as_chapters () {
// Long enough to be worth chaptering at all: the segmenter refuses
// to cut a recording into pieces shorter than its minimum.
let cues : Vec < Cue > = ( 0 .. 1200 )
. map ( | i | Cue {
v : corpus_core ::V ,
start : i as f64 * 5.0 ,
end : i as f64 * 5.0 + 5.0 ,
text : match i % 2 {
0 => "the mill stood at the northern end of the valley" . into (),
_ => "parliament dissolved itself again amid objections" . into (),
},
})
. collect ();
let chapters = segment_cues ( & cues , 6000.0 );
assert! ( ! chapters . is_empty ());
assert_eq! ( chapters , cueseg ::segment ( & cues , 6000.0 ));
// The duration can be left to the cues themselves.
assert_eq! ( segment_cues ( & cues , 0.0 ), cueseg ::segment ( & cues , 6000.0 ));
}
2026-09-19 17:54:18 -04:00
#[test]
fn a_document_that_is_one_work_says_so_rather_than_splitting () {
assert! ( split_collection ( MARKDOWN ). is_err ());
}
#[test]
fn subtitles_come_back_as_cues_and_pack_into_passages () {
let srt = "1 \n 00:00:01,000 --> 00:00:04,000 \n the mill stood at the northern end \n\n " ;
let cues = parse_subtitles ( srt );
assert_eq! ( cues . len (), 1 );
let packed = pack_cues ( & cues , CueBudgets ::default ());
assert_eq! ( packed . len (), 1 );
assert_eq! ( packed [ 0 ]. start_time , Some ( 1.0 ));
}
#[test]
fn a_channel_nobody_here_knows_is_ignored_rather_than_refused () {
assert_eq! ( channel_named ( "vec" ), Some ( corpus_core ::Channel ::Vector ));
assert_eq! ( channel_named ( "lexical" ), None );
}
2026-09-19 19:19:54 -04:00
#[test]
fn an_identifier_comes_back_normalised () {
let found = parse_identifier ( "https://doi.org/10.1000/182" ). expect ( "an identifier" );
assert_eq! ( found . id , "10.1000/182" );
assert_eq! ( parse_identifier ( "hello" ), None );
}
2026-09-19 19:03:47 -04:00
#[test]
fn a_series_comes_back_the_same_as_the_library_reads_it () {
let found = series_of ( "The Philokalia Vol 3" , None ). expect ( "a series" );
assert_eq! ( found . series , "The Philokalia" );
assert_eq! ( found . volume , Some ( 3 ));
assert_eq! ( series_of ( "Being and Time" , None ), None );
}
2026-09-19 17:54:18 -04:00
#[test]
fn bytes_are_identified_the_same_way_a_file_is () {
let pdf = b "%PDF-1.4 \n " ;
assert_eq! (
identify ( "book.pdf" , pdf ). kind ,
booksniff ::sniff ( && pdf [ .. ], "book.pdf" ). kind
);
}
}