kopitiam_document/segment.rs
1//! Stable, content-derived **segment identifiers** over the [`Document`]
2//! model — the basis for translation memory (Task III-4,
3//! `kopitiam_token_max.md` §13).
4//!
5//! ## The id is derived, never embedded (sidesteps §2.1)
6//!
7//! A segment's id is the FNV-1a content hash of its *normalized text*. It is
8//! **computed on demand and written nowhere**: no anchor, no comment, nothing
9//! is added to the converted Markdown. That is deliberate. `validate()`
10//! measures a recovery ratio by comparing extracted vs. rendered character
11//! counts (§2.1); any in-band id we emitted would inflate `rendered_chars`,
12//! push the ratio above 1.0, and silently mask genuine content loss. Deriving
13//! the id from content the document already contains keeps the ratio honest
14//! while still giving every segment a stable name.
15//!
16//! ## Stable across runs and machines
17//!
18//! FNV-1a is fixed by its constants, so identical text hashes to an identical
19//! id on every run, machine and toolchain — unlike `std::hash::DefaultHasher`
20//! (SipHash), whose output is explicitly not stable across Rust releases. That
21//! stability is the whole point: a translation cache keyed on these ids
22//! (`kopitiam-workspace`) survives between sessions, so a revised document
23//! only re-translates the segments whose text — and therefore id — changed.
24//!
25//! ## Granularity: one block, one segment
26//!
27//! Splitting happens at the already-structured [`Block`] level, never by
28//! re-parsing rendered Markdown text. Each block that carries translatable
29//! text becomes exactly one segment; blocks with no text (e.g. a figure with
30//! no caption) are skipped. A block is a natural translation unit — a
31//! paragraph, a heading, a list, a table — and using the existing structure
32//! keeps the split deterministic and order-stable without a second parser.
33
34use std::fmt;
35use std::ops::Range;
36
37use crate::{Block, Document};
38
39/// FNV-1a 64-bit offset basis and prime. Identical to the constants
40/// `kopitiam_workspace::content_hash` uses (Task II-5), so a [`SegmentId`]
41/// produced here is byte-for-byte the same hex string the workspace
42/// translation-memory store keys on. The tiny algorithm is duplicated rather
43/// than shared to keep the two crates decoupled — no cross-crate dependency
44/// edge, and therefore no `Cargo.lock` churn.
45const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
46const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
47
48/// A stable identifier for a translation segment: the FNV-1a content hash of
49/// the segment's normalized text, as lower-case hex.
50///
51/// See the module docs for why this is derived from content rather than
52/// embedded in the output.
53#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
55pub struct SegmentId(String);
56
57impl SegmentId {
58 /// Computes the id of `text`: normalize it (trim, and collapse every run
59 /// of whitespace to a single space), then hash the result with FNV-1a.
60 ///
61 /// Normalizing before hashing makes the id stable under trivial reflow —
62 /// re-wrapping a paragraph, or a differently line-broken extraction of the
63 /// same prose, keeps the same translation identity. Identical normalized
64 /// text always yields an identical id.
65 pub fn of(text: &str) -> Self {
66 let normalized = normalize(text);
67 let mut hash = FNV_OFFSET_BASIS;
68 for &byte in normalized.as_bytes() {
69 hash ^= byte as u64;
70 hash = hash.wrapping_mul(FNV_PRIME);
71 }
72 SegmentId(format!("{hash:016x}"))
73 }
74
75 /// The id as a hex string — the exact key a translation cache stores it
76 /// under.
77 pub fn as_str(&self) -> &str {
78 &self.0
79 }
80}
81
82impl fmt::Display for SegmentId {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 f.write_str(&self.0)
85 }
86}
87
88/// One translation-sized unit of a [`Document`]: a run of source text with a
89/// content-derived [`SegmentId`], its position in the segment sequence, and
90/// the source block(s) it came from.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub struct Segment {
94 /// 0-based position of this segment in the sequence returned by
95 /// [`segments`]. Contiguous even when source blocks were skipped.
96 pub index: usize,
97
98 /// Stable content hash of [`Self::text`]. See [`SegmentId`].
99 pub id: SegmentId,
100
101 /// The segment's translatable source text, extracted from its block(s).
102 pub text: String,
103
104 /// Half-open range of source-block indices (into [`Document::blocks`])
105 /// this segment covers. With one-block-per-segment granularity this is
106 /// always a single block (`i..i + 1`); the range type leaves room to group
107 /// several blocks into one unit later without changing the shape.
108 pub block_range: Range<usize>,
109}
110
111/// Splits `document` into deterministic, order-stable translation segments.
112///
113/// Walks [`Document::blocks`] in order; each block that carries translatable
114/// text becomes one [`Segment`] whose [`SegmentId`] is the content hash of
115/// that text. Blocks with no text after normalization (e.g. a captionless
116/// figure) are skipped. Called twice on the same document it returns identical
117/// ids in identical order.
118pub fn segments(document: &Document) -> Vec<Segment> {
119 let mut segments = Vec::new();
120 for (block_index, block) in document.blocks.iter().enumerate() {
121 let text = block_text(block);
122 if normalize(&text).is_empty() {
123 // No translatable content (e.g. a figure with no caption): nothing
124 // to name, cache, or translate.
125 continue;
126 }
127 let index = segments.len();
128 segments.push(Segment {
129 index,
130 id: SegmentId::of(&text),
131 text,
132 block_range: block_index..block_index + 1,
133 });
134 }
135 segments
136}
137
138/// Extracts a block's translatable text. Structural blocks (lists, tables) are
139/// flattened deterministically so the same block always yields the same
140/// string, and therefore the same id.
141fn block_text(block: &Block) -> String {
142 match block {
143 Block::Heading(heading) => heading.text.clone(),
144 Block::Paragraph(paragraph) => paragraph.text.clone(),
145 Block::List(list) => list.items.join("\n"),
146 Block::Table(table) => {
147 let mut lines = Vec::with_capacity(table.rows.len() + 1);
148 if !table.headers.is_empty() {
149 lines.push(table.headers.join(" | "));
150 }
151 for row in &table.rows {
152 lines.push(row.join(" | "));
153 }
154 lines.join("\n")
155 }
156 Block::Figure(figure) => figure.caption.clone().unwrap_or_default(),
157 Block::CodeBlock(code) => code.text.clone(),
158 Block::Quote(quote) => quote.text.clone(),
159 }
160}
161
162/// Trims `text` and collapses every run of whitespace to a single space, so
163/// the id is stable under trivial reflow. Uses [`str::split_whitespace`],
164/// which handles Unicode whitespace and needs no dependency.
165fn normalize(text: &str) -> String {
166 text.split_whitespace().collect::<Vec<_>>().join(" ")
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::{Heading, List, Metadata, Paragraph};
173
174 fn doc(blocks: Vec<Block>) -> Document {
175 Document {
176 title: None,
177 metadata: Metadata::default(),
178 blocks,
179 block_pages: Vec::new(),
180 citations: Vec::new(),
181 }
182 }
183
184 fn sample() -> Document {
185 doc(vec![
186 Block::Heading(Heading {
187 level: 1,
188 text: "Digital Twins".to_string(),
189 }),
190 Block::Paragraph(Paragraph {
191 text: "A digital twin mirrors a physical asset.".to_string(),
192 }),
193 Block::List(List::flat(
194 false,
195 vec!["sensor feeds".to_string(), "a model".to_string()],
196 )),
197 ])
198 }
199
200 #[test]
201 fn segment_id_is_deterministic_and_content_sensitive() {
202 assert_eq!(SegmentId::of("hello world"), SegmentId::of("hello world"));
203 assert_ne!(SegmentId::of("hello world"), SegmentId::of("hello worlD"));
204 }
205
206 #[test]
207 fn segment_id_is_stable_under_whitespace_reflow() {
208 // Re-wrapping / re-spacing the same prose keeps the same id.
209 assert_eq!(
210 SegmentId::of("A digital twin mirrors\na physical asset."),
211 SegmentId::of("A digital twin mirrors a physical asset."),
212 );
213 }
214
215 #[test]
216 fn segment_id_known_answer_pins_the_hash() {
217 // Pins the algorithm + normalization so a refactor cannot silently
218 // change persisted ids (which would invalidate every cached
219 // translation). Empty text normalizes to "" -> the FNV-1a basis.
220 assert_eq!(
221 SegmentId::of(" ").as_str(),
222 format!("{:016x}", 0xcbf2_9ce4_8422_2325u64),
223 );
224 // A fixed non-empty answer, computed once and locked in.
225 assert_eq!(SegmentId::of("digital twin").as_str(), "976c60764a911fc7");
226 }
227
228 #[test]
229 fn segments_are_deterministic_and_order_stable() {
230 let document = sample();
231 let a = segments(&document);
232 let b = segments(&document);
233 assert_eq!(a, b);
234 assert_eq!(a.len(), 3);
235 assert_eq!(a[0].index, 0);
236 assert_eq!(a[1].index, 1);
237 assert_eq!(a[2].index, 2);
238 // Ids follow content, in document order.
239 assert_eq!(a[0].id, SegmentId::of("Digital Twins"));
240 assert_eq!(a[2].id, SegmentId::of("sensor feeds\na model"));
241 }
242
243 #[test]
244 fn changing_one_block_changes_exactly_one_segment_id() {
245 let original = segments(&sample());
246
247 // Revise exactly the middle block's text.
248 let mut revised_doc = sample();
249 revised_doc.blocks[1] = Block::Paragraph(Paragraph {
250 text: "A digital twin mirrors a physical asset in real time.".to_string(),
251 });
252 let revised = segments(&revised_doc);
253
254 assert_eq!(revised.len(), original.len());
255 assert_eq!(revised[0].id, original[0].id); // heading unchanged
256 assert_ne!(revised[1].id, original[1].id); // the edited block
257 assert_eq!(revised[2].id, original[2].id); // list unchanged
258 }
259
260 #[test]
261 fn blocks_without_text_are_skipped_but_indices_stay_contiguous() {
262 use crate::Figure;
263 let document = doc(vec![
264 Block::Heading(Heading {
265 level: 2,
266 text: "Method".to_string(),
267 }),
268 Block::Figure(Figure {
269 caption: None,
270 image_path: None,
271 }),
272 Block::Paragraph(Paragraph {
273 text: "We measured throughput.".to_string(),
274 }),
275 ]);
276 let segs = segments(&document);
277 assert_eq!(segs.len(), 2);
278 assert_eq!(segs[0].index, 0);
279 assert_eq!(segs[0].block_range, 0..1);
280 assert_eq!(segs[1].index, 1);
281 // The captionless figure at block 1 was skipped; this segment maps to
282 // block 2.
283 assert_eq!(segs[1].block_range, 2..3);
284 }
285}