Files
catena/crates/chunkmd/src/lib.rs
T
funkpower 8b3c1a0236 chunkmd: a passage is placed where it begins
The chapter a passage carries was resolved at the offset of the page it came
from, not the offset of the passage. A document with no page markers is a
single page beginning at zero, so every chunk in such a book answered with
whichever heading came first in the file — and took the role classification
with it, since that reads the chapter title.

Measured over a corpus of 5,386 books: all 1,232 unpaginated books with
twenty or more chunks carried exactly one chapter title from cover to cover,
as did 73% of the paginated ones. A bibliography chapter was therefore
invisible to anything selecting on the chapter title, which is the first of
the three ways the citation stage finds one.

The packer now hands back each passage with the offset it began at, and the
page's own offset is the true start of its text rather than the position of
the marker before it.
2026-09-21 07:25:18 -04:00

217 lines
7.7 KiB
Rust

//! Cuts markdown into retrievable passages that carry their page, chapter
//! and role.
//!
//! The order of the passes is load-bearing and is not obvious:
//!
//! 1. Split on page markers, so a chunk never spans a page and its page
//! number is exact rather than inferred.
//! 2. Pack paragraphs toward a token target within each page, carrying an
//! overlap forward.
//! 3. Enforce the hard maximum on token boundaries, whatever the structure
//! said.
//! 4. Drop what is too small to be worth an embedding.
//! 5. Flag the note chunks, *before* the structural marker is stripped out
//! of their text.
//! 6. Classify roles across the whole document at once, because smoothing a
//! stray chunk needs to see its neighbours.
pub mod footnote;
pub mod pack;
pub mod pages;
pub mod role;
use corpus_core::{Chunk, chunk::flags};
pub use pack::Budgets;
/// Cut `markdown` into chunks.
pub fn chunk(markdown: &str, budgets: &Budgets) -> Vec<Chunk> {
let pages = pages::split_by_page(markdown);
let chapters = pages::chapters(markdown);
let by_offset = pages::ChapterIndex::new(&chapters);
let mut chunks: Vec<Chunk> = Vec::new();
for page in pages {
for packed in pack::page(page.text, budgets) {
for (piece, tokens) in pack::enforce_hard_max(&packed.text, budgets.hard_max) {
if tokens < budgets.min_tokens {
continue;
}
// A page-based chapter when the document has page markers;
// the byte offset otherwise, which is what carries EPUBs
// that have headings but no page anchors. Page zero counts as
// no page at all, which is what the Python's truth test makes
// of it.
// The passage's own position, not the page's: an
// unpaginated document is a single page beginning at zero,
// so asking at the page put every chunk in the book under
// whichever heading came first in the file.
let chapter = page
.number
.filter(|number| *number != 0)
.and_then(|number| pages::chapter_of_page(&chapters, number))
.or_else(|| by_offset.at(page.offset + packed.offset));
let idx = chunks.len() as u32;
let notes = footnote::looks_like_citations(&piece);
let mut chunk = Chunk::new(idx, footnote::strip_sentinel(&piece), tokens as u32);
chunk.page = page.number;
chunk.chapter = chapter.map(str::to_string);
if notes {
chunk.flag(flags::FOOTNOTE_CITATIONS);
}
chunks.push(chunk);
}
}
}
let mut roles: Vec<_> = chunks
.iter()
.map(|chunk| role::classify(&chunk.text, chunk.chapter.as_deref()))
.collect();
role::smooth(&mut roles);
for (chunk, role) in chunks.iter_mut().zip(roles) {
chunk.role = role;
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
use corpus_core::Role;
fn small() -> Budgets {
Budgets {
target: 20,
overlap: 4,
min_tokens: 1,
hard_max: 40,
}
}
#[test]
fn chunks_are_numbered_from_zero_without_gaps() {
let markdown = "# One\n\nalpha beta\n\n<!-- Page 2 -->\n\ngamma delta\n\nepsilon zeta";
let chunks = chunk(markdown, &small());
for (at, chunk) in chunks.iter().enumerate() {
assert_eq!(chunk.idx, at as u32);
}
}
#[test]
fn a_chunk_carries_the_page_it_came_from() {
let markdown = "<!-- Page 7 -->\n\nalpha beta gamma";
let chunks = chunk(markdown, &small());
assert_eq!(chunks[0].page, Some(7));
}
#[test]
fn a_chunk_carries_its_chapter() {
let markdown = "<!-- Page 1 -->\n\n# The Second Estate\n\nalpha beta gamma";
let chunks = chunk(markdown, &small());
assert_eq!(chunks[0].chapter.as_deref(), Some("The Second Estate"));
}
#[test]
fn every_chunk_answers_with_the_heading_it_sits_under() {
// An unpaginated document is one page beginning at zero, so asking
// the page where a chunk was rather than asking the chunk put the
// whole book under whichever heading came first in the file.
let markdown = "# Chapter One\n\nBody text long enough to be worth \
keeping as a passage of its own.\n\n# Bibliography\n\n\
Bloch, Marc. *Feudal Society*. London: Routledge, 1961.\n\n\
Duby, Georges. *The Three Orders*. Chicago: Chicago, 1980.\n";
let budgets = Budgets {
target: 40,
overlap: 0,
min_tokens: 5,
..Budgets::default()
};
let chunks = chunk(markdown, &budgets);
let under: Vec<&str> = chunks
.iter()
.map(|chunk| chunk.chapter.as_deref().unwrap_or("—"))
.collect();
assert!(under.contains(&"Chapter One"), "{under:?}");
assert!(under.contains(&"Bibliography"), "{under:?}");
}
#[test]
fn a_chapter_is_found_by_offset_when_there_are_no_page_markers() {
let markdown = "# The Second Estate\n\nalpha beta gamma";
let chunks = chunk(markdown, &small());
assert_eq!(chunks[0].page, None);
assert_eq!(chunks[0].chapter.as_deref(), Some("The Second Estate"));
}
#[test]
fn a_chunk_below_the_minimum_is_dropped() {
let budgets = Budgets {
min_tokens: 100,
..small()
};
assert!(chunk("alpha beta", &budgets).is_empty());
}
#[test]
fn nothing_exceeds_the_hard_maximum() {
let markdown = "word ".repeat(500);
let budgets = Budgets {
target: 30,
overlap: 5,
min_tokens: 1,
hard_max: 35,
};
for chunk in chunk(&markdown, &budgets) {
assert!(chunk.tokens <= 35, "{} tokens", chunk.tokens);
}
}
#[test]
fn a_note_chunk_is_flagged_and_its_marker_is_stripped() {
let markdown = format!(
"{}12. Arendt, The Human Condition (Chicago, 1958), 44.\n\n\
13. Bloch, Feudal Society, vol. 2, p. 17.",
footnote::SENTINEL
);
let chunks = chunk(&markdown, &small());
assert!(chunks[0].has_flag(flags::FOOTNOTE_CITATIONS));
assert!(!chunks[0].text.contains(footnote::SENTINEL));
}
#[test]
fn roles_are_smoothed_across_the_whole_document() {
// Two bibliography-shaped pages with an ambiguous one between them.
let entries = "Arendt, Hannah. The Human Condition. Chicago: University Press, 1958.\n\n\
Bloch, Marc. Feudal Society. London: Routledge, 1961.\n\n\
Cassirer, Ernst. The Myth of the State. New Haven: Yale Press, 1946.";
let markdown = format!(
"<!-- Page 1 -->\n\n{entries}\n\n<!-- Page 2 -->\n\nhttps://example.org/a\n\n\
<!-- Page 3 -->\n\n{entries}"
);
let chunks = chunk(
&markdown,
&Budgets {
target: 200,
overlap: 0,
min_tokens: 1,
hard_max: 400,
},
);
assert!(chunks.len() >= 3, "{chunks:#?}");
assert!(
chunks.iter().all(|chunk| chunk.role == Role::Bibliography),
"{:?}",
chunks.iter().map(|c| c.role).collect::<Vec<_>>()
);
}
#[test]
fn empty_input_produces_no_chunks() {
assert!(chunk("", &Budgets::default()).is_empty());
assert!(chunk(" \n\n ", &Budgets::default()).is_empty());
}
}