Skip to main content

oxidize_pdf/pipeline/
chunk_metadata.rs

1//! Chunk-level metadata for RAG output.
2//!
3//! Surfaces data already computed by the partitioner (heading hierarchy, font,
4//! style, confidence) plus new retrieval signals (content-type flags, counts,
5//! stable IDs, language) and optional source-document metadata.
6
7#[cfg(feature = "semantic")]
8use serde::{Deserialize, Serialize};
9#[cfg(feature = "semantic")]
10use std::collections::BTreeMap;
11
12use crate::pipeline::element::{Element, ElementBBox};
13use crate::pipeline::hybrid_chunking::split_into_sentences;
14
15/// Char-weighted aggregates over a chunk's elements.
16pub(crate) struct Aggregates {
17    pub dominant_font: Option<String>,
18    pub dominant_font_size: Option<f64>,
19    pub is_bold: bool,
20    pub is_italic: bool,
21    pub min_confidence: f32,
22}
23
24impl Aggregates {
25    pub(crate) fn from_elements(elements: &[Element]) -> Self {
26        let mut font_weight: Vec<(String, usize)> = Vec::new();
27        let mut size_weight: Vec<(f64, usize)> = Vec::new();
28        let mut bold_chars = 0usize;
29        let mut italic_chars = 0usize;
30        let mut total_chars = 0usize;
31        let mut min_conf = 1.0f32;
32
33        for e in elements {
34            let w = e.text().chars().count();
35            total_chars += w;
36            let meta = e.metadata();
37            if let Some(f) = &meta.font_name {
38                match font_weight.iter_mut().find(|(name, _)| name == f) {
39                    Some((_, c)) => *c += w,
40                    None => font_weight.push((f.clone(), w)),
41                }
42            }
43            if let Some(s) = meta.font_size {
44                match size_weight.iter_mut().find(|(sz, _)| (*sz - s).abs() < 0.1) {
45                    Some((_, c)) => *c += w,
46                    None => size_weight.push((s, w)),
47                }
48            }
49            if meta.is_bold {
50                bold_chars += w;
51            }
52            if meta.is_italic {
53                italic_chars += w;
54            }
55            min_conf = min_conf.min(meta.confidence as f32);
56        }
57
58        let dominant_font = font_weight
59            .into_iter()
60            .max_by_key(|(_, c)| *c)
61            .map(|(name, _)| name);
62        let dominant_font_size = size_weight
63            .into_iter()
64            .max_by_key(|(_, c)| *c)
65            .map(|(sz, _)| sz);
66
67        Self {
68            dominant_font,
69            dominant_font_size,
70            is_bold: total_chars > 0 && bold_chars * 2 > total_chars,
71            is_italic: total_chars > 0 && italic_chars * 2 > total_chars,
72            min_confidence: if elements.is_empty() { 0.0 } else { min_conf },
73        }
74    }
75}
76
77/// Boolean flags describing the kinds of content present in a chunk.
78///
79/// Pipeline output: the chunker derives these from a chunk's element types;
80/// external consumers read them off [`ChunkMetadata::content_types`]. Like the
81/// enclosing [`ChunkMetadata`], this is `#[non_exhaustive]` so future content
82/// flags (e.g. `has_formula`, `has_footnote`) can be added without a breaking
83/// change. To build one outside the crate (e.g. in a test), start from
84/// [`ContentTypeFlags::default`] and set the fields you need.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86#[cfg_attr(feature = "semantic", derive(Serialize, Deserialize))]
87#[non_exhaustive]
88pub struct ContentTypeFlags {
89    /// The chunk contains at least one table element.
90    pub has_table: bool,
91    /// The chunk contains at least one list item.
92    pub has_list: bool,
93    /// The chunk contains at least one code block.
94    pub has_code: bool,
95    /// The chunk is composed solely of heading (title) elements.
96    pub heading_only: bool,
97}
98
99/// Metadata about the source document a chunk came from.
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
101#[cfg_attr(feature = "semantic", derive(Serialize, Deserialize))]
102#[non_exhaustive]
103pub struct DocumentSource {
104    /// Document title from the info dictionary, if present.
105    pub title: Option<String>,
106    /// Document author from the info dictionary, if present.
107    pub author: Option<String>,
108    /// Creation date string from the info dictionary, if present.
109    pub creation_date: Option<String>,
110    /// Originating file name (caller-supplied — the pipeline does not know it).
111    pub filename: Option<String>,
112    /// Stable document hash (caller-supplied; used as the chunk_id prefix).
113    pub doc_hash: Option<String>,
114    /// Total page count of the source document.
115    pub total_pages: Option<u32>,
116}
117
118impl DocumentSource {
119    /// Construct a source from the two caller-supplied fields (`filename`,
120    /// `doc_hash`); the rest (`title`/`author`/`creation_date`/`total_pages`)
121    /// are left `None` for [`rag_chunks_with_source`](crate::parser::PdfDocument::rag_chunks_with_source)
122    /// to auto-fill from the info dictionary. Provided because `DocumentSource`
123    /// is `#[non_exhaustive]`, so external callers cannot use a struct literal.
124    pub fn with_file(filename: Option<String>, doc_hash: Option<String>) -> Self {
125        Self {
126            filename,
127            doc_hash,
128            ..Default::default()
129        }
130    }
131}
132
133/// Citation anchor for a chunk on a single page: the axis-aligned union of all
134/// the chunk's element bounding boxes that fall on that page. Lets a RAG
135/// consumer cite back to an exact region of the source PDF.
136#[derive(Debug, Clone, Copy, PartialEq)]
137#[cfg_attr(feature = "semantic", derive(Serialize, Deserialize))]
138#[non_exhaustive]
139pub struct PageRegion {
140    /// Page the region is on (as stored on the elements).
141    pub page: u32,
142    /// Union bounding box of the chunk's elements on this page.
143    pub bbox: ElementBBox,
144}
145
146/// Per-chunk metadata attached to every [`RagChunk`](crate::pipeline::RagChunk).
147#[derive(Debug, Clone, Default, PartialEq)]
148#[cfg_attr(feature = "semantic", derive(Serialize, Deserialize))]
149#[non_exhaustive]
150pub struct ChunkMetadata {
151    /// Full section breadcrumb, root→leaf (e.g. `["1 Intro", "1.2 Scope"]`).
152    pub heading_path: Vec<String>,
153    /// Dominant font (char-weighted majority across the chunk's elements).
154    pub dominant_font: Option<String>,
155    /// Dominant font size (char-weighted majority).
156    pub dominant_font_size: Option<f64>,
157    /// True if the majority of characters are bold.
158    pub is_bold: bool,
159    /// True if the majority of characters are italic.
160    pub is_italic: bool,
161    /// Lowest classification confidence among the chunk's elements.
162    pub min_confidence: f32,
163    /// Content-type flags derived from element types.
164    pub content_types: ContentTypeFlags,
165    /// Character count of the chunk text.
166    pub char_count: usize,
167    /// Whitespace-separated word count.
168    pub word_count: usize,
169    /// Sentence count (uses the chunker's sentence splitter).
170    pub sentence_count: usize,
171    /// Detected language code (ISO 639-3, via `whatlang`); `None` if the
172    /// `language-detection` feature is off or detection is inconclusive.
173    pub language: Option<String>,
174    /// Detection confidence in `(0, 1]` for [`language`](Self::language);
175    /// `None` when no language was detected.
176    pub language_confidence: Option<f32>,
177    /// Whether `whatlang` considered the [`language`](Self::language)
178    /// detection reliable. Consumers should gate language-based routing on
179    /// this; `None` when no language was detected.
180    pub language_reliable: Option<bool>,
181    /// Deterministic, stable identifier for this chunk.
182    pub chunk_id: String,
183    /// Identifier of the previous chunk in the document, if any.
184    pub prev_chunk_id: Option<String>,
185    /// Identifier of the next chunk in the document, if any.
186    pub next_chunk_id: Option<String>,
187    /// Source-document metadata, if available.
188    pub source: Option<DocumentSource>,
189    /// First and last page the chunk's elements touch (inclusive), or `None`
190    /// when the chunk has no positioned elements.
191    pub page_span: Option<(u32, u32)>,
192    /// Per-page citation regions (union bbox of the chunk's elements on each
193    /// page), sorted ascending by page. Empty when the chunk has no elements.
194    pub page_regions: Vec<PageRegion>,
195    /// Row count of the chunk's largest table (by row count), or `None` if the
196    /// chunk has no table. Lets a consumer filter/route table-bearing chunks.
197    pub table_rows: Option<usize>,
198    /// Column count (widest row) of the same table reported by
199    /// [`table_rows`](Self::table_rows); `None` when the chunk has no table.
200    pub table_cols: Option<usize>,
201    /// Open extension bag for provider-supplied fields (e.g. a closed analyzer
202    /// stamping `legal.clause_number`). Namespacing keys by provider avoids
203    /// collisions. Serializes nested under `"extra"`; omitted when empty.
204    #[cfg(feature = "semantic")]
205    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
206    pub extra: BTreeMap<String, serde_json::Value>,
207}
208
209use sha2::{Digest, Sha256};
210
211impl ChunkMetadata {
212    /// Build chunk metadata from the chunk's elements and text. `full_text` is
213    /// used for the content-hash id; `doc_hash` (when `Some`) overrides it.
214    /// Language is detected here when the `language-detection` feature is on;
215    /// prev/next links are filled by a later pass ([`link_chunks`]).
216    pub(crate) fn from_elements(
217        elements: &[Element],
218        text: &str,
219        full_text: &str,
220        chunk_index: usize,
221        doc_hash: Option<&str>,
222    ) -> Self {
223        let agg = Aggregates::from_elements(elements);
224        let heading_path = elements
225            .first()
226            .map(|e| e.metadata().heading_path.clone())
227            .unwrap_or_default();
228        let (page_span, page_regions) = page_anchor(elements);
229        let (table_rows, table_cols) = table_dims(elements);
230        // Detect once; fill code + confidence + reliability together.
231        #[cfg(feature = "language-detection")]
232        let (language, language_confidence, language_reliable) = match detect_language_full(text) {
233            Some((code, conf, reliable)) => (Some(code), Some(conf), Some(reliable)),
234            None => (None, None, None),
235        };
236        #[cfg(not(feature = "language-detection"))]
237        let (language, language_confidence, language_reliable): (
238            Option<String>,
239            Option<f32>,
240            Option<bool>,
241        ) = (None, None, None);
242        ChunkMetadata {
243            heading_path,
244            dominant_font: agg.dominant_font,
245            dominant_font_size: agg.dominant_font_size,
246            is_bold: agg.is_bold,
247            is_italic: agg.is_italic,
248            min_confidence: agg.min_confidence,
249            content_types: content_type_flags(elements),
250            char_count: char_count(text),
251            word_count: word_count(text),
252            sentence_count: sentence_count(text),
253            language,
254            language_confidence,
255            language_reliable,
256            chunk_id: content_chunk_id(doc_hash, chunk_index, full_text),
257            prev_chunk_id: None,
258            next_chunk_id: None,
259            source: None,
260            page_span,
261            page_regions,
262            table_rows,
263            table_cols,
264            #[cfg(feature = "semantic")]
265            extra: BTreeMap::new(),
266        }
267    }
268}
269
270/// Dimensions of the chunk's largest table (by row count): `(rows, widest row)`.
271/// `(None, None)` when the chunk contains no table element.
272fn table_dims(elements: &[Element]) -> (Option<usize>, Option<usize>) {
273    elements
274        .iter()
275        .filter_map(|e| match e {
276            Element::Table(t) => Some(match &t.structure {
277                Some(st) => (st.num_rows, st.num_cols),
278                None => (
279                    t.rows.len(),
280                    t.rows.iter().map(|r| r.len()).max().unwrap_or(0),
281                ),
282            }),
283            _ => None,
284        })
285        .max_by_key(|(rows, _)| *rows)
286        .map(|(r, c)| (Some(r), Some(c)))
287        .unwrap_or((None, None))
288}
289
290/// Union of two axis-aligned bounding boxes.
291fn union_bbox(a: ElementBBox, b: ElementBBox) -> ElementBBox {
292    let x = a.x.min(b.x);
293    let y = a.y.min(b.y);
294    let right = a.right().max(b.right());
295    let top = a.top().max(b.top());
296    ElementBBox::new(x, y, right - x, top - y)
297}
298
299/// Compute the chunk's citation anchor: `(page_span, page_regions)`. Groups the
300/// elements by page, unions their bboxes per page, and sorts the regions by
301/// page ascending. Returns `(None, vec![])` for an element-less chunk.
302fn page_anchor(elements: &[Element]) -> (Option<(u32, u32)>, Vec<PageRegion>) {
303    let mut by_page: Vec<(u32, ElementBBox)> = Vec::new();
304    for e in elements {
305        let page = e.metadata().page;
306        let bbox = *e.bbox();
307        match by_page.iter_mut().find(|(p, _)| *p == page) {
308            Some(slot) => slot.1 = union_bbox(slot.1, bbox),
309            None => by_page.push((page, bbox)),
310        }
311    }
312    if by_page.is_empty() {
313        return (None, Vec::new());
314    }
315    by_page.sort_by_key(|(p, _)| *p);
316    let span = (by_page.first().unwrap().0, by_page.last().unwrap().0);
317    let regions = by_page
318        .into_iter()
319        .map(|(page, bbox)| PageRegion { page, bbox })
320        .collect();
321    (Some(span), regions)
322}
323
324/// Fill `prev_chunk_id` / `next_chunk_id` on each chunk from its neighbours' ids.
325pub(crate) fn link_chunks(chunks: &mut [crate::pipeline::RagChunk]) {
326    let ids: Vec<String> = chunks.iter().map(|c| c.metadata.chunk_id.clone()).collect();
327    for (i, c) in chunks.iter_mut().enumerate() {
328        c.metadata.prev_chunk_id = if i > 0 {
329            Some(ids[i - 1].clone())
330        } else {
331            None
332        };
333        c.metadata.next_chunk_id = ids.get(i + 1).cloned();
334    }
335}
336
337/// Detect the dominant language of `text` as an ISO 639-3 code (e.g. `"eng"`,
338/// `"spa"`), via `whatlang`. Returns `None` for empty/whitespace-only input or
339/// when `whatlang` produces no detection. The detection is best-effort: on
340/// short or ambiguous text the code may be unreliable.
341///
342/// Requires the `language-detection` feature.
343#[cfg(feature = "language-detection")]
344pub fn detect_language(text: &str) -> Option<String> {
345    detect_language_full(text).map(|(code, _, _)| code)
346}
347
348/// Run `whatlang` once and return `(code, confidence, reliable)`; `None` for
349/// empty/whitespace-only input or when no detection is produced. Single call
350/// site so [`ChunkMetadata`] can fill code + confidence + reliability without
351/// detecting twice.
352#[cfg(feature = "language-detection")]
353pub(crate) fn detect_language_full(text: &str) -> Option<(String, f32, bool)> {
354    if text.trim().is_empty() {
355        return None;
356    }
357    whatlang::detect(text).map(|info| {
358        (
359            info.lang().code().to_string(),
360            info.confidence() as f32,
361            info.is_reliable(),
362        )
363    })
364}
365
366/// Deterministic chunk id: `<doc_id>:<index>` where `doc_id` is the supplied
367/// `doc_hash` or, absent that, the first 8 bytes of SHA-256(full_text) in hex.
368pub(crate) fn content_chunk_id(doc_hash: Option<&str>, index: usize, full_text: &str) -> String {
369    let doc_id = match doc_hash {
370        Some(h) => h.to_string(),
371        None => {
372            let mut hasher = Sha256::new();
373            hasher.update(full_text.as_bytes());
374            let digest = hasher.finalize();
375            digest[..8]
376                .iter()
377                .map(|b| format!("{b:02x}"))
378                .collect::<String>()
379        }
380    };
381    format!("{doc_id}:{index}")
382}
383
384pub(crate) fn content_type_flags(elements: &[Element]) -> ContentTypeFlags {
385    let mut flags = ContentTypeFlags::default();
386    let mut all_titles = !elements.is_empty();
387    for e in elements {
388        match e {
389            Element::Table(_) => flags.has_table = true,
390            Element::ListItem(_) => flags.has_list = true,
391            Element::CodeBlock(_) => flags.has_code = true,
392            _ => {}
393        }
394        if !matches!(e, Element::Title(_)) {
395            all_titles = false;
396        }
397    }
398    flags.heading_only = all_titles;
399    flags
400}
401
402pub(crate) fn char_count(text: &str) -> usize {
403    text.chars().count()
404}
405
406pub(crate) fn word_count(text: &str) -> usize {
407    text.split_whitespace().count()
408}
409
410pub(crate) fn sentence_count(text: &str) -> usize {
411    if text.trim().is_empty() {
412        return 0;
413    }
414    split_into_sentences(text).len()
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::pipeline::element::{Element, ElementData, ElementMetadata, TableStructure};
421
422    fn table_el() -> Element {
423        Element::Table(crate::pipeline::element::TableElementData::new(
424            vec![],
425            crate::pipeline::element::ElementMetadata::default(),
426        ))
427    }
428
429    #[test]
430    fn content_types_and_counts() {
431        let els = vec![
432            para("Hello world. Second sentence!", "F", 10.0, false, 1.0),
433            table_el(),
434        ];
435        let flags = content_type_flags(&els);
436        assert!(flags.has_table);
437        assert!(!flags.has_list);
438        assert!(!flags.heading_only);
439
440        let text = "Hello world. Second sentence!";
441        assert_eq!(char_count(text), text.chars().count());
442        assert_eq!(word_count(text), 4);
443        assert_eq!(sentence_count(text), 2);
444    }
445
446    #[test]
447    fn heading_only_when_all_titles() {
448        let d = crate::pipeline::element::ElementData {
449            text: "Title".to_string(),
450            metadata: crate::pipeline::element::ElementMetadata::default(),
451        };
452        let els = vec![Element::Title(d)];
453        assert!(content_type_flags(&els).heading_only);
454    }
455
456    fn para(text: &str, font: &str, size: f64, bold: bool, conf: f64) -> Element {
457        let metadata = ElementMetadata {
458            font_name: Some(font.to_string()),
459            font_size: Some(size),
460            is_bold: bold,
461            confidence: conf,
462            ..ElementMetadata::default()
463        };
464        Element::Paragraph(ElementData {
465            text: text.to_string(),
466            metadata,
467        })
468    }
469
470    #[test]
471    fn aggregate_picks_char_weighted_dominant_font_and_min_confidence() {
472        // "aaaa" (4 chars) Helvetica bold conf=0.9 ; "bb" (2) Times conf=0.5
473        let els = vec![
474            para("aaaa", "Helvetica", 12.0, true, 0.9),
475            para("bb", "Times", 10.0, false, 0.5),
476        ];
477        let agg = Aggregates::from_elements(&els);
478        assert_eq!(agg.dominant_font.as_deref(), Some("Helvetica"));
479        assert_eq!(agg.dominant_font_size, Some(12.0));
480        assert!(agg.is_bold, "4 bold chars vs 2 non-bold → bold majority");
481        assert!((agg.min_confidence - 0.5).abs() < 1e-6);
482    }
483
484    #[test]
485    fn chunk_id_is_deterministic_and_prefixed() {
486        let a = content_chunk_id(None, 0, "the quick brown fox");
487        let b = content_chunk_id(None, 0, "the quick brown fox");
488        assert_eq!(a, b, "same text + index → same id");
489        assert!(a.ends_with(":0"));
490        // Hashless prefix is exactly 8 bytes of SHA-256 → 16 hex chars; pin the
491        // width so a change to the digest slice can't silently shrink the id.
492        assert_eq!(
493            a.split(':').next().unwrap().len(),
494            16,
495            "hashless chunk_id prefix must be 16 hex chars (8 bytes)"
496        );
497
498        let with_hash = content_chunk_id(Some("dochash123"), 7, "ignored when hash present");
499        assert_eq!(with_hash, "dochash123:7");
500
501        let other = content_chunk_id(None, 0, "different text");
502        assert_ne!(a, other);
503    }
504
505    #[test]
506    fn chunk_metadata_default_is_empty() {
507        let m = ChunkMetadata::default();
508        assert!(m.heading_path.is_empty());
509        assert_eq!(m.dominant_font, None);
510        assert!(!m.is_bold);
511        assert_eq!(m.min_confidence, 0.0);
512        assert!(!m.content_types.has_table);
513        assert_eq!(m.char_count, 0);
514        assert_eq!(m.language, None);
515        assert_eq!(m.language_confidence, None);
516        assert_eq!(m.language_reliable, None);
517        assert_eq!(m.chunk_id, "");
518        assert!(m.source.is_none());
519        assert_eq!(m.page_span, None);
520        assert!(m.page_regions.is_empty());
521        assert_eq!(m.table_rows, None);
522        assert_eq!(m.table_cols, None);
523    }
524
525    #[test]
526    fn document_source_with_file_sets_only_supplied_fields() {
527        let s = DocumentSource::with_file(Some("doc.pdf".to_string()), Some("h7".to_string()));
528        assert_eq!(s.filename.as_deref(), Some("doc.pdf"));
529        assert_eq!(s.doc_hash.as_deref(), Some("h7"));
530        // Everything the caller did not supply stays None for the info-dict
531        // auto-fill pass to populate.
532        assert_eq!(s.title, None);
533        assert_eq!(s.author, None);
534        assert_eq!(s.creation_date, None);
535        assert_eq!(s.total_pages, None);
536
537        let empty = DocumentSource::with_file(None, None);
538        assert_eq!(empty, DocumentSource::default());
539    }
540
541    #[test]
542    fn build_metadata_from_chunk_elements() {
543        let els = vec![
544            para("aaaa", "Helvetica", 12.0, true, 0.8),
545            para("bb. cc.", "Helvetica", 12.0, false, 0.6),
546        ];
547        let text = "aaaa\nbb. cc.";
548        let m = ChunkMetadata::from_elements(&els, text, text, 3, None);
549        assert_eq!(m.dominant_font.as_deref(), Some("Helvetica"));
550        assert!((m.min_confidence - 0.6).abs() < 1e-6);
551        assert_eq!(m.char_count, text.chars().count());
552        assert_eq!(m.chunk_id, content_chunk_id(None, 3, text));
553        assert!(m.source.is_none());
554        // Without the feature the field stays None; with it, detection runs on
555        // the chunk text (the exact code is whatlang's call, not asserted here).
556        #[cfg(not(feature = "language-detection"))]
557        assert_eq!(m.language, None);
558    }
559
560    fn el_at(text: &str, page: u32, x: f64, y: f64, w: f64, h: f64) -> Element {
561        Element::Paragraph(ElementData {
562            text: text.to_string(),
563            metadata: ElementMetadata {
564                page,
565                bbox: crate::pipeline::element::ElementBBox::new(x, y, w, h),
566                ..ElementMetadata::default()
567            },
568        })
569    }
570
571    #[test]
572    fn citation_anchor_page_span_and_per_page_union_bbox() {
573        let els = vec![
574            el_at("a", 1, 10.0, 700.0, 100.0, 20.0), // page1: x[10,110] y[700,720]
575            el_at("b", 1, 50.0, 600.0, 200.0, 10.0), // page1: x[50,250] y[600,610]
576            el_at("c", 2, 30.0, 500.0, 40.0, 40.0),  // page2: x[30,70]  y[500,540]
577        ];
578        let text = "a\nb\nc";
579        let m = ChunkMetadata::from_elements(&els, text, text, 0, None);
580
581        assert_eq!(m.page_span, Some((1, 2)));
582        assert_eq!(m.page_regions.len(), 2);
583        // Sorted ascending by page.
584        assert_eq!(m.page_regions[0].page, 1);
585        assert_eq!(m.page_regions[1].page, 2);
586
587        // Page 1 region = union of its two element bboxes.
588        let p1 = &m.page_regions[0].bbox;
589        assert_eq!(p1.x, 10.0);
590        assert_eq!(p1.y, 600.0);
591        assert_eq!(p1.right(), 250.0);
592        assert_eq!(p1.top(), 720.0);
593
594        // Page 2 region = the single element's bbox.
595        let p2 = &m.page_regions[1].bbox;
596        assert_eq!(p2.x, 30.0);
597        assert_eq!(p2.right(), 70.0);
598        assert_eq!(p2.top(), 540.0);
599    }
600
601    #[test]
602    fn citation_anchor_empty_for_no_elements() {
603        let m = ChunkMetadata::from_elements(&[], "", "", 0, None);
604        assert_eq!(m.page_span, None);
605        assert!(m.page_regions.is_empty());
606    }
607
608    #[cfg(feature = "language-detection")]
609    #[test]
610    fn language_reliability_populated_alongside_code() {
611        let els = vec![para("x", "F", 10.0, false, 1.0)];
612        let text =
613            "The annual report summarizes the financial performance of the company over the year.";
614        let m = ChunkMetadata::from_elements(&els, text, text, 0, None);
615        assert_eq!(m.language.as_deref(), Some("eng"));
616        let conf = m
617            .language_confidence
618            .expect("confidence present when a language is detected");
619        assert!(
620            conf > 0.0 && conf <= 1.0,
621            "confidence must be in (0, 1], got {conf}"
622        );
623        assert_eq!(
624            m.language_reliable,
625            Some(true),
626            "a full English sentence must be a reliable detection"
627        );
628    }
629
630    #[cfg(feature = "language-detection")]
631    #[test]
632    fn language_reliability_none_for_empty_text() {
633        let m = ChunkMetadata::from_elements(&[], "", "", 0, None);
634        assert_eq!(m.language, None);
635        assert_eq!(m.language_confidence, None);
636        assert_eq!(m.language_reliable, None);
637    }
638
639    fn table_with(rows: Vec<Vec<&str>>) -> Element {
640        Element::Table(crate::pipeline::element::TableElementData::new(
641            rows.into_iter()
642                .map(|r| r.into_iter().map(String::from).collect())
643                .collect(),
644            ElementMetadata::default(),
645        ))
646    }
647
648    #[test]
649    fn table_dims_from_largest_table() {
650        let small = table_with(vec![vec!["a", "b"]]); // 1 row x 2 cols
651        let big = table_with(vec![vec!["a"], vec!["b"], vec!["c"]]); // 3 rows x 1 col
652        let els = vec![para("x", "F", 10.0, false, 1.0), small, big];
653        let text = "x";
654        let m = ChunkMetadata::from_elements(&els, text, text, 0, None);
655        // Largest by row count wins.
656        assert_eq!(m.table_rows, Some(3));
657        assert_eq!(m.table_cols, Some(1));
658    }
659
660    #[test]
661    fn table_cols_uses_widest_row() {
662        let ragged = table_with(vec![vec!["a", "b"], vec!["c", "d", "e", "f"]]);
663        let m = ChunkMetadata::from_elements(&[ragged], "t", "t", 0, None);
664        assert_eq!(m.table_rows, Some(2));
665        assert_eq!(m.table_cols, Some(4));
666    }
667
668    #[test]
669    fn table_dims_none_without_table() {
670        let els = vec![para("just prose", "F", 10.0, false, 1.0)];
671        let m = ChunkMetadata::from_elements(&els, "just prose", "just prose", 0, None);
672        assert_eq!(m.table_rows, None);
673        assert_eq!(m.table_cols, None);
674    }
675
676    #[test]
677    fn table_dims_prefers_rich_structure() {
678        // Flat `rows` says 1x1; rich `structure` says 3x4 (e.g. a merged-cell
679        // table where the flat fallback under-counts). table_dims must read
680        // the structure geometry, not the flat rows, when structure is present.
681        // Deliberately construct rows/structure out of sync (1x1 flat rows vs
682        // 3x4 structure) to prove table_dims reads structure geometry, not the
683        // flat rows view. `from_structure` would derive rows FROM structure and
684        // erase this mismatch, so this stays a same-crate struct literal
685        // (unaffected by #[non_exhaustive], which only gates cross-crate
686        // construction).
687        let el = Element::Table(crate::pipeline::element::TableElementData {
688            rows: vec![vec!["x".to_string()]],
689            structure: Some(TableStructure {
690                cells: vec![],
691                num_rows: 3,
692                num_cols: 4,
693                header_rows: 1,
694            }),
695            metadata: ElementMetadata::default(),
696        });
697        assert_eq!(table_dims(&[el]), (Some(3), Some(4)));
698    }
699
700    #[cfg(feature = "semantic")]
701    #[test]
702    fn extra_bag_defaults_empty_and_roundtrips() {
703        let mut m = ChunkMetadata::default();
704        assert!(m.extra.is_empty(), "extra defaults to empty");
705
706        // Empty extra is omitted from the serialized output.
707        let json_empty = serde_json::to_string(&m).unwrap();
708        assert!(
709            !json_empty.contains("\"extra\""),
710            "empty extra must be skipped in JSON"
711        );
712
713        // Populated extra survives a deterministic round-trip.
714        m.extra
715            .insert("legal.clause_number".to_string(), serde_json::json!("3.2"));
716        m.extra.insert(
717            "legal.defined_terms".to_string(),
718            serde_json::json!(["Party", "Agreement"]),
719        );
720        let json = serde_json::to_string(&m).unwrap();
721        assert!(json.contains("\"extra\""));
722        let back: ChunkMetadata = serde_json::from_str(&json).unwrap();
723        assert_eq!(back.extra, m.extra, "extra survives round-trip");
724        assert_eq!(
725            back.extra.get("legal.clause_number").unwrap(),
726            &serde_json::json!("3.2")
727        );
728    }
729}