Skip to main content

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}