Skip to main content

blazegraph_io_core/
config.rs

1use crate::types::DocumentType;
2use anyhow::Result;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6
7// Default value functions for serde
8fn default_true() -> bool {
9    true
10}
11
12fn default_min_alpha_ratio() -> f32 {
13    0.5
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ParsingConfig {
18    pub document_type: DocumentType,
19    #[serde(default)]
20    pub section_and_hierarchy: SectionAndHierarchyConfig,
21    pub spatial_clustering: SpatialClusteringConfig,
22    pub section_patterns: Vec<String>,
23    /// Include raw Tika XML/HTML output in graph metadata for debugging
24    #[serde(default)]
25    pub include_raw_tika: bool,
26    /// Pipeline configuration - defines which rules to run and in what order
27    #[serde(default)]
28    pub pipeline: PipelineConfig,
29    /// List detection configuration
30    #[serde(default)]
31    pub list_detection: ListDetectionConfig,
32    /// Size enforcement configuration
33    #[serde(default)]
34    pub size_enforcer: SizeEnforcerConfig,
35    /// Minimal parse mode - bypasses all rule processing and returns only base conversion
36    #[serde(default)]
37    pub minimal_parse: bool,
38    /// Configuration for the V2 section detection rule (Block 03).
39    /// Uses `#[serde(default)]` so existing YAML configs without this key still deserialize.
40    #[serde(default)]
41    pub section_detection_v2: SectionDetectionV2Config,
42    /// Configuration for the NodeTypeClustering rule (CR-29; Block 05b — renamed
43    /// from ParagraphClustering once Section started flowing through the same rule).
44    /// Uses `#[serde(default)]` so existing YAML configs without this key still
45    /// deserialize. Also accepts the legacy `paragraph_clustering:` block via the
46    /// migration shim on `NodeTypeClusteringConfig` deserialization (see below).
47    #[serde(default, alias = "paragraph_clustering")]
48    pub node_type_clustering: NodeTypeClusteringConfig,
49    /// Configuration for the graph sanity-check-and-correction pipe (CR-28).
50    /// Runs post-graph-build; defaults are safe (enabled with all invariants
51    /// in check + correct mode).
52    #[serde(default)]
53    pub graph_sanity: GraphSanityConfig,
54    /// When true, the analytics pre-pass writes one JSON file per stat kind to
55    /// `{cache_dir}/stat/<stat_name>/<pdf_hash>.json` after finalization. This is
56    /// a sidecar for offline tooling — not a pipeline cache (output is not read
57    /// back). Default `true` for development; flip off in production where the
58    /// extra writes are unwanted.
59    #[serde(default = "default_true")]
60    pub dump_analytics: bool,
61}
62
63// ─── NodeTypeClustering config (CR-29; was ParagraphClustering) ───────────
64
65/// Per-element-type clustering configuration. Each `ParsedElementType` has its
66/// own self-contained merge config — no shared defaults, no override inheritance.
67/// Adding a new element type to the pipeline (e.g. Table) requires explicitly
68/// adding a block here.
69#[derive(Debug, Clone, Serialize)]
70pub struct NodeTypeClusteringConfig {
71    pub section: NodeTypeMergeConfig,
72    pub paragraph: NodeTypeMergeConfig,
73    pub list: NodeTypeMergeConfig,
74    pub list_item: NodeTypeMergeConfig,
75    pub header: NodeTypeMergeConfig,
76    pub footer: NodeTypeMergeConfig,
77    pub margin: NodeTypeMergeConfig,
78}
79
80impl Default for NodeTypeClusteringConfig {
81    fn default() -> Self {
82        Self {
83            section: NodeTypeMergeConfig::default_section(),
84            paragraph: NodeTypeMergeConfig::default_paragraph(),
85            list: NodeTypeMergeConfig::default_paragraph(),
86            list_item: NodeTypeMergeConfig::default_paragraph(),
87            header: NodeTypeMergeConfig::default_header_footer(),
88            footer: NodeTypeMergeConfig::default_header_footer(),
89            margin: NodeTypeMergeConfig::default_margin(),
90        }
91    }
92}
93
94/// Merge configuration for one element type. Algorithm: partition coarsely at
95/// `(page, element_type)`, walk reading-order-sorted bucket, split into a new
96/// merge group whenever any constraint fails between consecutive elements.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct NodeTypeMergeConfig {
99    // ── Boundary constraints (split when violated between consecutive elements) ──
100    /// Require both elements be in the same Tika line (line_number equality).
101    #[serde(default)]
102    pub same_line: bool,
103
104    /// Require both elements be in the same Tika paragraph (paragraph_number equality).
105    /// `true` is the conservative prose-safe default for body prose; `false` for
106    /// Section (a multi-paragraph bold title in one leaf is still one Section).
107    #[serde(default)]
108    pub same_paragraph: bool,
109
110    /// Drop `region_label` from the equivalence key for this element type.
111    /// `true` collapses all elements of this type within a page into one bucket
112    /// regardless of which Region tree leaf they sit in. Default `false` keeps
113    /// region as a partition dimension; flipped on by Header / Footer defaults
114    /// (per-page running header / footer is one logical unit).
115    #[serde(default)]
116    pub ignore_region_label: bool,
117
118    /// Stopgap for Tika's row-keyed `paragraph_number` in 2-column body layout
119    /// (see [CR-38](docs/P2/core/change-requests/CR-38-bbox-based-paragraph-detection.md)).
120    ///
121    /// When `Some(n)`, a pre-scan counts distinct `paragraph_number`s per
122    /// `(page, element_type, region_label)`. If the count is `>= n` for a
123    /// region, `paragraph_number` is dropped from the key for that region —
124    /// the whole region collapses into one bucket. Catches the gpt2-style
125    /// failure mode where Tika emits one `<p>` per visual row across both
126    /// columns, leaving each column-region with one `paragraph_number` per
127    /// line.
128    ///
129    /// **Tradeoff:** when the threshold fires, real within-column paragraph
130    /// breaks are lost. CR-38 replaces this heuristic with bbox-derived
131    /// paragraph detection (modal body X + indent rule + Y-gap distribution),
132    /// which preserves paragraph structure in 2-column layouts. This knob is
133    /// the Block 10 ship-it pragma; CR-38 is the structurally-correct fix.
134    ///
135    /// `None` disables the fallback (clustering uses `paragraph_number`
136    /// directly).
137    #[serde(default)]
138    pub region_overflow_threshold: Option<u32>,
139
140    // ── Safety constraints ──────────────────────────────────────────────────────
141    /// Require both elements have equal `hierarchy_level`. Prevents a section
142    /// header and a sub-section header on the same page from merging just
143    /// because they share a band-collapsed bucket.
144    #[serde(default)]
145    pub same_depth: bool,
146
147    /// Maximum Y-gap (in points) between `last.bbox.bottom` and `current.bbox.top`.
148    /// `None` disables the proximity check. Geometric distance separates "one
149    /// logical title fragmented across bands" (small gap) from "two unrelated
150    /// nodes on the same page" (large gap).
151    #[serde(default)]
152    pub max_y_gap: Option<f32>,
153
154    // ── Output formatting ───────────────────────────────────────────────────────
155    /// Separator between merged elements crossing line boundaries when the
156    /// element's band has ≤2 columns (prose flows continuously).
157    #[serde(default = "default_prose_separator")]
158    pub prose_line_separator: String,
159
160    /// Separator between merged elements crossing line boundaries when the
161    /// element's band has >2 columns (table-like — preserve rows).
162    #[serde(default = "default_table_separator")]
163    pub table_line_separator: String,
164}
165
166fn default_prose_separator() -> String {
167    " ".to_string()
168}
169fn default_table_separator() -> String {
170    "\n".to_string()
171}
172
173impl NodeTypeMergeConfig {
174    /// Section default: within-region merge with depth equality + proximity. A
175    /// multi-line bold chapter title in one Region tree leaf at one depth is one
176    /// Section regardless of how Tika sliced paragraphs across the bands.
177    pub fn default_section() -> Self {
178        Self {
179            same_line: false,
180            same_paragraph: false,
181            ignore_region_label: false,
182            same_depth: true,
183            max_y_gap: Some(50.0),
184            region_overflow_threshold: None,
185            prose_line_separator: " ".to_string(),
186            table_line_separator: "\n".to_string(),
187        }
188    }
189
190    /// Paragraph (and List / ListItem) default: within-region, with Tika
191    /// paragraph_number as the within-region granularity refinement. This
192    /// solves the small-margin "whole page = one leaf" failure mode where
193    /// region alone is too coarse — paragraph_number gives the within-leaf
194    /// Y-gap clustering Tika has already computed.
195    ///
196    /// `region_overflow_threshold: Some(10)` catches Tika's row-keyed
197    /// paragraph_number in 2-column body layouts (see CR-38). When a
198    /// `(page, region_label)` produces ≥10 distinct paragraph_numbers,
199    /// paragraph_number is dropped and the region collapses to one bucket.
200    pub fn default_paragraph() -> Self {
201        Self {
202            same_line: false,
203            same_paragraph: true,
204            ignore_region_label: false,
205            same_depth: false,
206            max_y_gap: None,
207            region_overflow_threshold: Some(10),
208            prose_line_separator: " ".to_string(),
209            table_line_separator: "\n".to_string(),
210        }
211    }
212
213    /// Header / Footer default: collapse all per-page H-N / F-N labels into
214    /// one bucket per (page, type). Per-page running headers and footers are
215    /// one logical unit — different `H-N` indices are just multiple fragments
216    /// of the same chrome row.
217    pub fn default_header_footer() -> Self {
218        Self {
219            same_line: false,
220            same_paragraph: false,
221            ignore_region_label: true,
222            same_depth: false,
223            max_y_gap: None,
224            region_overflow_threshold: None,
225            prose_line_separator: " ".to_string(),
226            table_line_separator: "\n".to_string(),
227        }
228    }
229
230    /// Margin default: keep `region_label` as a partition dimension. A sidebar
231    /// block and a page-edge marginal note are different logical units even
232    /// though they're both Margin; merging within one Margin region is fine,
233    /// merging across is not.
234    pub fn default_margin() -> Self {
235        Self {
236            same_line: false,
237            same_paragraph: false,
238            ignore_region_label: false,
239            same_depth: false,
240            max_y_gap: None,
241            region_overflow_threshold: None,
242            prose_line_separator: " ".to_string(),
243            table_line_separator: "\n".to_string(),
244        }
245    }
246}
247
248// ── Migration shim ───────────────────────────────────────────────────────────
249//
250// The original `paragraph_clustering:` YAML block used four cascade booleans
251// (merge_segments / merge_lines / merge_columns / merge_bands). When we encounter
252// that shape, translate it into the equivalent constraint set applied uniformly
253// to all four element types (matching pre-CR-29 behaviour exactly).
254
255impl<'de> Deserialize<'de> for NodeTypeClusteringConfig {
256    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
257    where
258        D: serde::Deserializer<'de>,
259    {
260        // Deserialize into a generic Value first so we can inspect the keys
261        // and pick the right shape. `untagged` enums don't work here because
262        // both legacy and new shapes have all-optional fields and would both
263        // match an empty mapping ambiguously.
264        let value = serde_yaml::Value::deserialize(deserializer)?;
265        let mapping = value
266            .as_mapping()
267            .ok_or_else(|| serde::de::Error::custom("node_type_clustering: expected a mapping"))?;
268
269        // If any legacy cascade key is present, route to the migration shim.
270        let legacy_keys = [
271            "merge_segments",
272            "merge_lines",
273            "merge_columns",
274            "merge_bands",
275        ];
276        let is_legacy = legacy_keys
277            .iter()
278            .any(|k| mapping.contains_key(serde_yaml::Value::String((*k).to_string())));
279
280        if is_legacy {
281            // Legacy `paragraph_clustering:` block. The four cascade booleans
282            // (merge_segments / merge_lines / merge_columns / merge_bands) are
283            // translated to the constraint subset that survives Block 06b's
284            // band/column drop: only `same_line` and `same_paragraph` carry
285            // information now. `merge_columns` / `merge_bands` are silently
286            // ignored (no equivalent in the region-aware world).
287            #[derive(Deserialize)]
288            struct LegacyParagraphClustering {
289                #[serde(default = "default_true")]
290                merge_segments: bool,
291                #[serde(default = "default_true")]
292                merge_lines: bool,
293                #[serde(default)]
294                #[allow(dead_code)]
295                merge_columns: bool,
296                #[serde(default)]
297                #[allow(dead_code)]
298                merge_bands: bool,
299                #[serde(default = "default_prose_separator")]
300                prose_line_separator: String,
301                #[serde(default = "default_table_separator")]
302                table_line_separator: String,
303            }
304            let l: LegacyParagraphClustering =
305                serde_yaml::from_value(value).map_err(serde::de::Error::custom)?;
306
307            let unified = NodeTypeMergeConfig {
308                same_line: l.merge_segments && !l.merge_lines,
309                same_paragraph: l.merge_lines,
310                ignore_region_label: false,
311                same_depth: false,
312                max_y_gap: None,
313                region_overflow_threshold: None,
314                prose_line_separator: l.prose_line_separator,
315                table_line_separator: l.table_line_separator,
316            };
317            return Ok(Self {
318                section: unified.clone(),
319                paragraph: unified.clone(),
320                list: unified.clone(),
321                list_item: unified.clone(),
322                header: NodeTypeMergeConfig::default_header_footer(),
323                footer: NodeTypeMergeConfig::default_header_footer(),
324                margin: NodeTypeMergeConfig::default_margin(),
325            });
326        }
327
328        // New shape: per-element-type blocks. Missing blocks fall back to the
329        // type's documented default.
330        #[derive(Deserialize)]
331        struct NewShape {
332            #[serde(default = "NodeTypeMergeConfig::default_section")]
333            section: NodeTypeMergeConfig,
334            #[serde(default = "NodeTypeMergeConfig::default_paragraph")]
335            paragraph: NodeTypeMergeConfig,
336            #[serde(default = "NodeTypeMergeConfig::default_paragraph")]
337            list: NodeTypeMergeConfig,
338            #[serde(default = "NodeTypeMergeConfig::default_paragraph")]
339            list_item: NodeTypeMergeConfig,
340            #[serde(default = "NodeTypeMergeConfig::default_header_footer")]
341            header: NodeTypeMergeConfig,
342            #[serde(default = "NodeTypeMergeConfig::default_header_footer")]
343            footer: NodeTypeMergeConfig,
344            #[serde(default = "NodeTypeMergeConfig::default_margin")]
345            margin: NodeTypeMergeConfig,
346        }
347        let n: NewShape = serde_yaml::from_value(value).map_err(serde::de::Error::custom)?;
348        Ok(Self {
349            section: n.section,
350            paragraph: n.paragraph,
351            list: n.list,
352            list_item: n.list_item,
353            header: n.header,
354            footer: n.footer,
355            margin: n.margin,
356        })
357    }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct PipelineConfig {
362    /// List of rules to run in order
363    pub rules: Vec<RuleConfig>,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct RuleConfig {
368    /// Name of the rule
369    pub name: String,
370    /// Whether this rule is enabled
371    #[serde(default = "default_true")]
372    pub enabled: bool,
373}
374
375impl Default for PipelineConfig {
376    fn default() -> Self {
377        Self {
378            rules: vec![
379                RuleConfig {
380                    name: "SectionDetectionV2".to_string(),
381                    enabled: true,
382                },
383                RuleConfig {
384                    name: "ParagraphClustering".to_string(),
385                    enabled: true,
386                },
387                RuleConfig {
388                    name: "Validation".to_string(),
389                    enabled: true,
390                },
391            ],
392        }
393    }
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct SectionAndHierarchyConfig {
398    /// Font size analysis parameters
399    /// Percentage above median for large headers (0.0-1.0)
400    pub large_header_threshold: f32,
401    /// Percentage above median for medium headers (0.0-1.0)
402    pub medium_header_threshold: f32,
403    /// Percentage above median for small headers (0.0-1.0)
404    pub small_header_threshold: f32,
405    /// Minimum absolute font size to consider for headers
406    pub min_header_size: f32,
407    /// Use bold text as additional header indicator
408    pub use_bold_indicator: bool,
409    /// Require bold text to be larger than typical content to be considered a section
410    /// true = strict (bold AND larger), false = permissive (bold OR larger)  
411    pub bold_size_strict: bool,
412
413    /// Contextual hierarchy parameters
414    /// Maximum hierarchy depth to create
415    pub max_depth: u32,
416    /// Font size difference tolerance for considering sections at same level (points)
417    pub font_size_tolerance: f32,
418    /// Whether to enforce max depth limit (if false, allows unlimited depth)
419    pub enforce_max_depth: bool,
420    /// Starting level for first section (document root is level 0)
421    pub starting_section_level: u32,
422
423    /// Minimum ratio of ASCII alphabetic characters to non-whitespace characters
424    /// for a candidate header. Filters out math symbols/formulas that happen to be
425    /// in larger fonts. 0.0 = disabled, 0.5 = at least half must be a-zA-Z.
426    #[serde(default = "default_min_alpha_ratio")]
427    pub min_alpha_ratio: f32,
428
429    /// Pattern-based section detection configuration
430    pub pattern_detection: PatternDetectionConfig,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct PatternDetectionConfig {
435    /// Whether pattern-based detection is enabled
436    pub enabled: bool,
437    /// Regex patterns to match section headers
438    pub patterns: Vec<String>,
439    /// Whether to respect font size constraints even when pattern matches
440    pub respect_font_constraints: bool,
441}
442
443impl Default for PatternDetectionConfig {
444    fn default() -> Self {
445        Self {
446            enabled: true,
447            patterns: vec![
448                // More restrictive patterns to avoid false positives
449                r"^[A-Z][A-Z\s]{2,}$".to_string(), // ALL CAPS (min 3 chars total)
450                r"^\d+\.\s+[A-Z][a-z]{3,}".to_string(), // "1. Title" (min 4 chars in title)
451                r"^(Chapter|Section|Part|Article)\s+\d+".to_string(), // Explicit structural words
452                r"^[A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,})*:$".to_string(), // "Title Case:" (with colon, min 3 chars per word)
453            ],
454            respect_font_constraints: true,
455        }
456    }
457}
458
459impl Default for SectionAndHierarchyConfig {
460    fn default() -> Self {
461        Self {
462            large_header_threshold: 0.7,
463            medium_header_threshold: 0.3,
464            small_header_threshold: 0.1,
465            min_header_size: 8.5,
466            use_bold_indicator: true,
467            bold_size_strict: true, // Default to strict mode (bold AND larger)
468            max_depth: 5,
469            font_size_tolerance: 0.1,
470            enforce_max_depth: true,
471            starting_section_level: 1,
472            min_alpha_ratio: 0.5,
473            pattern_detection: PatternDetectionConfig::default(),
474        }
475    }
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct SpatialClusteringConfig {
480    /// Enable spatial clustering (if false, falls back to old method)
481    pub enabled: bool,
482    /// Enable paragraph merging based on Tika's paragraph_number detection
483    #[serde(default = "default_true")]
484    pub enable_paragraph_merging: bool,
485    /// Enable spatial adjacency clustering (groups spatially adjacent elements)
486    #[serde(default)]
487    pub enable_spatial_adjacency: bool,
488    /// Minimum line height in points
489    pub min_line_height: f32,
490    /// Multiplier for line height to detect section breaks (e.g., 0.8 = 80% of line height)
491    pub vertical_gap_threshold_multiplier: f32,
492    /// X-coordinate tolerance for text alignment in points
493    pub horizontal_alignment_tolerance: f32,
494    /// Line tolerance as percentage of line height for grouping text lines
495    pub line_grouping_tolerance: f32,
496    /// Configuration for section clustering
497    pub sections: ElementClusteringConfig,
498    /// Configuration for paragraph clustering
499    pub paragraphs: ElementClusteringConfig,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct ElementClusteringConfig {
504    /// Minimum segment size in characters (segments smaller than this get merged)
505    pub min_segment_size: usize,
506    /// Maximum segment size in characters (segments larger than this get split if possible)
507    pub max_segment_size: usize,
508}
509
510// Default value functions for list detection
511fn default_y_tolerance() -> f32 {
512    15.0
513}
514
515fn default_false() -> bool {
516    false
517}
518
519fn default_bullet_patterns() -> Vec<String> {
520    vec![
521        "•".to_string(),
522        "·".to_string(),
523        "●".to_string(),
524        "■".to_string(),
525        "▪".to_string(),
526        "▫".to_string(),
527        "◦".to_string(),
528        "‣".to_string(),
529        "⁃".to_string(),
530        "-".to_string(),
531        "*".to_string(),
532        "→".to_string(),
533        "➤".to_string(),
534        "✓".to_string(),
535        "&bull;".to_string(),
536        "&middot;".to_string(),
537    ]
538}
539
540fn default_numbered_patterns() -> Vec<String> {
541    vec![
542        r"^\d+\.".to_string(),    // 1., 2., 3.
543        r"^\d+\)".to_string(),    // 1), 2), 3)
544        r"^\(\d+\)".to_string(),  // (1), (2), (3)
545        r"^[a-z]\.".to_string(),  // a., b., c.
546        r"^[a-z]\)".to_string(),  // a), b), c)
547        r"^[A-Z]\.".to_string(),  // A., B., C.
548        r"^[A-Z]\)".to_string(),  // A), B), C)
549        r"^[ivx]+\.".to_string(), // i., ii., iii.
550        r"^[IVX]+\.".to_string(), // I., II., III.
551    ]
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct ListDetectionConfig {
556    /// Whether list detection is enabled
557    #[serde(default = "default_true")]
558    pub enabled: bool,
559
560    /// Phase 1: Sequence Detection (NEW)
561    /// How far to look for next marker (in elements)
562    #[serde(default = "default_sequence_lookahead_elements")]
563    pub sequence_lookahead_elements: usize,
564
565    /// Elements past last marker to include in sequence boundary
566    #[serde(default = "default_sequence_boundary_extension")]
567    pub sequence_boundary_extension: usize,
568
569    /// Phase 2: Content Classification
570    /// Y-coordinate tolerance for considering elements on the same line (in points)
571    #[serde(default = "default_y_tolerance")]
572    pub y_tolerance: f32,
573
574    /// List item patterns
575    /// Bullet point patterns to detect
576    #[serde(default = "default_bullet_patterns")]
577    pub bullet_patterns: Vec<String>,
578
579    /// Numbered list patterns (regex)
580    #[serde(default = "default_numbered_patterns")]
581    pub numbered_patterns: Vec<String>,
582
583    /// List grouping behavior
584    /// Whether to create List container nodes for consecutive list items
585    #[serde(default = "default_true")]
586    pub create_list_containers: bool,
587
588    /// Whether to preserve individual ListItem nodes within List containers
589    #[serde(default = "default_false")]
590    pub preserve_list_items: bool,
591
592    /// Maximum number of elements to look ahead for list item continuation
593    #[serde(default = "default_max_lookahead_elements")]
594    pub max_lookahead_elements: usize,
595
596    /// Last list item boundary detection
597    /// Y-gap threshold (in points) for detecting spatial disconnects in last list items
598    /// TODO: OPTIMIZATION_DESIGN phase - fine-tune this value based on document types
599    #[serde(default = "default_last_item_boundary_gap")]
600    pub last_item_boundary_gap: f32,
601
602    /// Phase 2.5: List Validation (NEW)
603    /// Configuration for validating detected lists to eliminate false positives
604    #[serde(default)]
605    pub validation: ListValidationConfig,
606}
607
608fn default_sequence_lookahead_elements() -> usize {
609    10 // Elements to look ahead for next marker in sequence
610}
611
612fn default_sequence_boundary_extension() -> usize {
613    3 // Elements past last marker to include for boundary detection
614}
615
616fn default_max_lookahead_elements() -> usize {
617    25 // Increased from 5 to handle more complex list structures
618}
619
620fn default_last_item_boundary_gap() -> f32 {
621    80.0 // Y-gap threshold for sequence end detection (increased from 20.0)
622}
623
624// List validation default functions
625fn default_validation_enabled() -> bool {
626    true
627}
628
629// Advanced validation rule configurations
630#[derive(Debug, Clone, Serialize, Deserialize)]
631pub struct SequentialNumberingConfig {
632    /// Allow letter sequences (a, b, c) in addition to numbers
633    #[serde(default = "default_true")]
634    pub allow_letter_sequences: bool,
635
636    /// Maximum gap tolerance between numbers (0 = no gaps allowed)
637    #[serde(default = "default_zero")]
638    pub max_gap_tolerance: u32,
639}
640
641impl Default for SequentialNumberingConfig {
642    fn default() -> Self {
643        Self {
644            allow_letter_sequences: true,
645            max_gap_tolerance: 0,
646        }
647    }
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub struct MathematicalContextConfig {
652    /// Mathematical symbols to detect
653    #[serde(default = "default_mathematical_symbols")]
654    pub symbols: Vec<String>,
655
656    /// Mathematical terms that indicate context
657    #[serde(default = "default_mathematical_terms")]
658    pub terms: Vec<String>,
659}
660
661impl Default for MathematicalContextConfig {
662    fn default() -> Self {
663        Self {
664            symbols: default_mathematical_symbols(),
665            terms: default_mathematical_terms(),
666        }
667    }
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize)]
671pub struct HyphenContextConfig {
672    /// Strategy for handling hyphens: "reject", "strict", "context_aware"
673    #[serde(default = "default_hyphen_strategy")]
674    pub strategy: String,
675
676    /// Require space after hyphen for valid lists
677    #[serde(default = "default_true")]
678    pub require_space_after: bool,
679}
680
681impl Default for HyphenContextConfig {
682    fn default() -> Self {
683        Self {
684            strategy: default_hyphen_strategy(),
685            require_space_after: true,
686        }
687    }
688}
689
690// Default value functions for advanced validation
691fn default_zero() -> u32 {
692    0
693}
694
695fn default_mathematical_symbols() -> Vec<String> {
696    vec![
697        "→".to_string(),
698        "←".to_string(),
699        "⇒".to_string(),
700        "⇐".to_string(),
701        "∀".to_string(),
702        "∃".to_string(),
703    ]
704}
705
706fn default_mathematical_terms() -> Vec<String> {
707    vec![
708        "equation".to_string(),
709        "formula".to_string(),
710        "coordinates".to_string(),
711        "system".to_string(),
712        "transform".to_string(),
713    ]
714}
715
716fn default_hyphen_strategy() -> String {
717    "strict".to_string()
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct ListValidationConfig {
722    /// Whether list validation is enabled
723    #[serde(default = "default_validation_enabled")]
724    pub enabled: bool,
725
726    /// Minimum number of items required for a valid list
727    #[serde(default = "default_true")]
728    pub minimum_size_check: bool,
729
730    /// Validate that numbered lists start with "1" (or equivalent first item)
731    #[serde(default = "default_true")]
732    pub first_item_validation: bool,
733
734    /// If using parenthetical numbering (n), must start with (1)
735    #[serde(default = "default_true")]
736    pub parenthetical_context_check: bool,
737
738    // Advanced validation rules (enabled by default)
739    #[serde(default = "default_true")]
740    pub sequential_numbering_check: bool,
741
742    #[serde(default = "default_true")]
743    pub mathematical_context_check: bool,
744
745    #[serde(default = "default_true")]
746    pub hyphen_context_check: bool,
747
748    // Rule-specific configurations
749    #[serde(default)]
750    pub sequential_numbering: SequentialNumberingConfig,
751
752    #[serde(default)]
753    pub mathematical_context: MathematicalContextConfig,
754
755    #[serde(default)]
756    pub hyphen_context: HyphenContextConfig,
757
758    // Future validation rules (disabled by default)
759    #[serde(default = "default_false")]
760    pub sequence_pattern_check: bool,
761
762    #[serde(default = "default_false")]
763    pub content_quality_check: bool,
764
765    #[serde(default = "default_false")]
766    pub spatial_coherence_check: bool,
767}
768
769impl Default for ListValidationConfig {
770    fn default() -> Self {
771        Self {
772            enabled: true,
773            minimum_size_check: true,
774            first_item_validation: true,
775            parenthetical_context_check: true,
776            sequential_numbering_check: true,
777            mathematical_context_check: true,
778            hyphen_context_check: true,
779            sequential_numbering: SequentialNumberingConfig::default(),
780            mathematical_context: MathematicalContextConfig::default(),
781            hyphen_context: HyphenContextConfig::default(),
782            sequence_pattern_check: false,
783            content_quality_check: false,
784            spatial_coherence_check: false,
785        }
786    }
787}
788
789// SizeEnforcerRule default functions
790fn default_max_size() -> usize {
791    800 // characters by default
792}
793
794fn default_size_unit() -> String {
795    "characters".to_string()
796}
797
798fn default_min_split_size_ratio() -> f32 {
799    0.25 // 25% of max_size
800}
801
802fn default_max_iterations() -> usize {
803    10 // safety limit for recursive splitting
804}
805
806fn default_split_direction() -> String {
807    "vertical".to_string() // split chunks stack vertically like separate paragraphs
808}
809
810impl Default for ListDetectionConfig {
811    fn default() -> Self {
812        Self {
813            enabled: true,
814            sequence_lookahead_elements: default_sequence_lookahead_elements(),
815            sequence_boundary_extension: default_sequence_boundary_extension(),
816            y_tolerance: default_y_tolerance(),
817            bullet_patterns: default_bullet_patterns(),
818            numbered_patterns: default_numbered_patterns(),
819            create_list_containers: true,
820            preserve_list_items: false,
821            max_lookahead_elements: default_max_lookahead_elements(),
822            last_item_boundary_gap: default_last_item_boundary_gap(),
823            validation: ListValidationConfig::default(),
824        }
825    }
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize)]
829pub struct SizeEnforcerConfig {
830    /// Whether size enforcement is enabled
831    #[serde(default = "default_true")]
832    pub enabled: bool,
833
834    /// Maximum allowed size for any single node
835    #[serde(default = "default_max_size")]
836    pub max_size: usize,
837
838    /// What to measure: "characters", "words", or "bytes"
839    #[serde(default = "default_size_unit")]
840    pub size_unit: String,
841
842    /// Ensure sentence boundaries are respected when splitting
843    #[serde(default = "default_true")]
844    pub preserve_sentences: bool,
845
846    /// Minimum size of resulting chunks (as ratio of max_size)
847    #[serde(default = "default_min_split_size_ratio")]
848    pub min_split_size_ratio: f32,
849
850    /// Enable recursive splitting until all nodes are compliant
851    #[serde(default = "default_true")]
852    pub recursive: bool,
853
854    /// Safety limit for recursive splitting
855    #[serde(default = "default_max_iterations")]
856    pub max_iterations: usize,
857
858    /// How to split bounding boxes: "horizontal" (side-by-side) or "vertical" (stacked)
859    #[serde(default = "default_split_direction")]
860    pub split_direction: String,
861}
862
863impl Default for SizeEnforcerConfig {
864    fn default() -> Self {
865        Self {
866            enabled: true,
867            max_size: 800,
868            size_unit: "characters".to_string(),
869            preserve_sentences: true,
870            min_split_size_ratio: 0.25,
871            recursive: true,
872            max_iterations: 10,
873            split_direction: "vertical".to_string(),
874        }
875    }
876}
877
878/// Configuration for the V2 section detection rule (V3 algorithm — Block 09).
879///
880/// Three-tier piecewise classifier on `delta = font_size - body_size` plus
881/// pre-gates (rotation, alpha-ratio) and pattern refinement (inclusion /
882/// exclusion regex) as a backup. Isolation is leaf-based, consulting the
883/// `Placement.region_label` set by `analytics::reading_order::tag_and_resort`.
884#[derive(Debug, Clone, Serialize, Deserialize)]
885pub struct SectionDetectionV2Config {
886    /// Y-coordinate tolerance (points) for grouping bboxes onto the same
887    /// visual line. Two elements within `|Δy| < this` in the same Region
888    /// tree leaf are considered to be on the same baseline. Defaults to a
889    /// value smaller than typical inter-line spacing so consecutive lines
890    /// do not merge.
891    pub line_height_tolerance: f32,
892
893    /// Font-size tolerance (points). Defines the symmetric ±tolerance band around body size.
894    ///
895    /// - `delta < -tolerance`             → REJECT (below-body noise).
896    /// - `|delta| ≤ tolerance`            → R3 (at-body band): needs bold AND isolated_in_leaf.
897    /// - `tolerance < delta ≤ structural_size_margin` → R2 (medium): needs bold OR isolated_in_leaf.
898    /// - `delta > structural_size_margin` → R1 (large): auto-promote unconditionally.
899    pub font_size_tolerance: f32,
900
901    /// Size margin (points) above body text at which size alone confirms structural role.
902    /// Region 1 threshold: delta > structural_size_margin → auto-promote.
903    pub structural_size_margin: f32,
904
905    /// Proportional alternative to structural_size_margin. When Some, Region 1 threshold
906    /// is body_size * ratio instead of body_size + margin. Default None (use margin).
907    pub structural_size_ratio: Option<f32>,
908
909    /// Minimum alphabetic character ratio for a candidate to survive
910    /// (inherits semantics from old rule's min_alpha_ratio).
911    pub min_alpha_ratio: f32,
912
913    /// Max hierarchy depth (inherits from old rule).
914    pub max_depth: u32,
915    pub enforce_max_depth: bool,
916    pub starting_section_level: u32,
917
918    /// Regex patterns that promote a weak/rejected candidate to a section
919    /// (escape hatch — e.g., "^\\d+\\.\\d+" for numbered subsections).
920    /// Promotion additionally requires the per-pattern structural gates
921    /// (`require_bold`, `require_isolation`) and a global length cap
922    /// (`inclusion_max_length`). See CR-26 (length cap, isolation) and
923    /// CR-42 (per-pattern bold/isolation gating).
924    pub inclusion_patterns: Vec<InclusionPattern>,
925
926    /// Maximum text length (in characters) for an inclusion-pattern match to
927    /// promote. Real structural labels ("Article 64", "CHAPTER II") are short;
928    /// body wrap-lines that happen to begin with a structural keyword are long.
929    /// This is the synthetic gate Pass 2 needs because, unlike Pass 1, it has
930    /// no bold/rarity confirming signal — pattern + isolation alone admit
931    /// recital wrap-lines on documents like CELEX where font_size is degenerate.
932    pub inclusion_max_length: usize,
933
934    /// Regex patterns that demote a promoted candidate back to non-section
935    /// (escape hatch — e.g., "^Figure\\s" for figure captions).
936    pub exclusion_patterns: Vec<String>,
937
938    /// Ordered list of `(keyword_name, regex)` pairs that identify the structural
939    /// "tier" of a section. Consulted only when the font-size delta vs. the
940    /// previous section is within `font_size_tolerance` (the tie). When the tie
941    /// fires, keyword identity decides whether the new section is a sibling, a
942    /// step-back-up to an earlier tier, or a deeper tier.
943    ///
944    /// Order matters: the first matching pattern wins. Place specific keywords
945    /// before generic ones (e.g. structural words before bare-numbered).
946    pub tiebreaker_keywords: Vec<TiebreakerKeyword>,
947}
948
949/// Named tiebreaker pattern used by the hierarchy stack to classify the tier
950/// of a structural section.
951#[derive(Debug, Clone, Serialize, Deserialize)]
952pub struct TiebreakerKeyword {
953    pub name: String,
954    pub pattern: String,
955}
956
957/// Inclusion pattern with per-pattern structural gates (CR-42).
958///
959/// Each pattern declares whether `is_bold(element)` and/or
960/// `is_isolated_in_leaf(element_idx)` are required for promotion.
961///
962/// Both gates default to `true` — appropriate for the typical structural-label
963/// pattern (Chapter, Article, Section labels in regulations and acts), and the
964/// safe default for any new pattern added without thinking.
965///
966/// CR-42 was filed to close an rfc-quic FP where the `^section\s+\d+`
967/// inclusion pattern was firing on inline hyperlink spans (normal-weight
968/// `<span class="f4" style="color:#2222ee">Section 18</span>`). With
969/// `require_bold: true`, real bold UK-Acts-of-Parliament "Section 12" labels
970/// still promote; the hyperlink span does not.
971#[derive(Debug, Clone, Serialize, Deserialize)]
972pub struct InclusionPattern {
973    pub pattern: String,
974    #[serde(default = "default_true")]
975    pub require_bold: bool,
976    #[serde(default = "default_true")]
977    pub require_isolation: bool,
978}
979
980// ─── CR-28 — Graph Sanity-Check-and-Correction Pipe ──────────────────────────
981
982/// Per-invariant gating: every sanity-check invariant has both a check mode
983/// (always-on diagnostic emission) and a correct mode (config-gated rewrite).
984#[derive(Debug, Clone, Serialize, Deserialize)]
985pub struct InvariantToggle {
986    pub check: bool,
987    pub correct: bool,
988}
989
990impl Default for InvariantToggle {
991    fn default() -> Self {
992        Self {
993            check: true,
994            correct: true,
995        }
996    }
997}
998
999/// Set of invariants the graph sanity pipe enforces.
1000/// Future invariants (childless pruning, repetition filter, etc.) will appear
1001/// here as additional fields.
1002#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1003pub struct GraphSanityInvariants {
1004    /// `node.depth = parent.depth + 1` for every non-root node.
1005    /// Correction strategy: BFS from root, recompute depth.
1006    pub depth_consistency: InvariantToggle,
1007}
1008
1009/// Configuration for the graph sanity-check-and-correction pipe (CR-28).
1010/// Runs after graph build to enforce structural invariants on the assembled
1011/// graph. Each invariant has check + correct gating.
1012#[derive(Debug, Clone, Serialize, Deserialize)]
1013pub struct GraphSanityConfig {
1014    pub enabled: bool,
1015    pub invariants: GraphSanityInvariants,
1016}
1017
1018impl Default for GraphSanityConfig {
1019    fn default() -> Self {
1020        Self {
1021            enabled: true,
1022            invariants: GraphSanityInvariants::default(),
1023        }
1024    }
1025}
1026
1027impl Default for SectionDetectionV2Config {
1028    fn default() -> Self {
1029        Self {
1030            line_height_tolerance: 3.0,
1031            font_size_tolerance: 0.1,
1032            structural_size_margin: 4.0,
1033            structural_size_ratio: None,
1034            min_alpha_ratio: 0.5,
1035            max_depth: 6,
1036            enforce_max_depth: true,
1037            starting_section_level: 1,
1038            inclusion_patterns: vec![
1039                InclusionPattern {
1040                    pattern: r"^\d+\.".to_string(), // "1.", "2.", ...
1041                    require_bold: true,
1042                    require_isolation: true,
1043                },
1044                InclusionPattern {
1045                    pattern: r"^\d+\.\d+".to_string(), // "1.1", "3.2", ...
1046                    require_bold: true,
1047                    require_isolation: true,
1048                },
1049                InclusionPattern {
1050                    pattern: r"^Chapter\s+\d+".to_string(),
1051                    require_bold: true,
1052                    require_isolation: true,
1053                },
1054                InclusionPattern {
1055                    pattern: r"^Appendix\s+[A-Z]".to_string(),
1056                    require_bold: true,
1057                    require_isolation: true,
1058                },
1059            ],
1060            inclusion_max_length: 30,
1061            exclusion_patterns: vec![r"^Figure\s".to_string(), r"^Table\s".to_string()],
1062            tiebreaker_keywords: vec![
1063                TiebreakerKeyword {
1064                    name: "part".into(),
1065                    pattern: r"(?i)^part\s+[IVXLCDM\d]+".into(),
1066                },
1067                TiebreakerKeyword {
1068                    name: "chapter".into(),
1069                    pattern: r"(?i)^chapter\s+[IVXLCDM\d]+".into(),
1070                },
1071                TiebreakerKeyword {
1072                    name: "article".into(),
1073                    pattern: r"(?i)^article\s+\d+[a-z]?".into(),
1074                },
1075                TiebreakerKeyword {
1076                    name: "section".into(),
1077                    pattern: r"(?i)^section\s+\d+[a-z]?".into(),
1078                },
1079                TiebreakerKeyword {
1080                    name: "appendix".into(),
1081                    pattern: r"(?i)^appendix\s+[A-Z\d]+".into(),
1082                },
1083                TiebreakerKeyword {
1084                    name: "schedule".into(),
1085                    pattern: r"(?i)^schedule\s+\d+".into(),
1086                },
1087                TiebreakerKeyword {
1088                    name: "annex".into(),
1089                    pattern: r"(?i)^annex\s+[IVX\d]+".into(),
1090                },
1091                TiebreakerKeyword {
1092                    name: "numbered".into(),
1093                    pattern: r"^\d+\s+[A-Z]".into(),
1094                },
1095            ],
1096        }
1097    }
1098}
1099
1100#[derive(Debug, Clone)]
1101pub struct ConfigManager {
1102    configs: HashMap<DocumentType, ParsingConfig>,
1103    default_config: ParsingConfig,
1104}
1105
1106impl ConfigManager {
1107    pub fn new() -> Result<Self> {
1108        let mut manager = Self {
1109            configs: HashMap::new(),
1110            default_config: Self::create_default_generic_config(),
1111        };
1112
1113        // Load built-in configs
1114        manager.load_builtin_configs()?;
1115
1116        Ok(manager)
1117    }
1118
1119    pub fn get_config(&self, doc_type: &DocumentType) -> &ParsingConfig {
1120        self.configs.get(doc_type).unwrap_or(&self.default_config)
1121    }
1122
1123    pub fn load_config_from_file(&mut self, path: &str) -> Result<()> {
1124        let content = fs::read_to_string(path)?;
1125        let config: ParsingConfig = serde_yaml::from_str(&content)?;
1126        self.configs.insert(config.document_type.clone(), config);
1127        Ok(())
1128    }
1129
1130    fn load_builtin_configs(&mut self) -> Result<()> {
1131        // Generic document config (for our sample PDFs)
1132        let generic_config = Self::create_default_generic_config();
1133        self.configs.insert(DocumentType::Generic, generic_config);
1134
1135        // Academic paper config (more conservative thresholds)
1136        let academic_config = ParsingConfig {
1137            document_type: DocumentType::AcademicPaper,
1138            section_and_hierarchy: SectionAndHierarchyConfig {
1139                large_header_threshold: 0.8, // Higher threshold for academic papers
1140                medium_header_threshold: 0.4,
1141                small_header_threshold: 0.15,
1142                min_header_size: 10.0,
1143                use_bold_indicator: true,
1144                bold_size_strict: true,
1145                max_depth: 4,
1146                font_size_tolerance: 0.1,
1147                enforce_max_depth: true,
1148                starting_section_level: 1,
1149                min_alpha_ratio: 0.5,
1150                pattern_detection: PatternDetectionConfig::default(),
1151            },
1152            spatial_clustering: SpatialClusteringConfig {
1153                enabled: true,
1154                enable_paragraph_merging: true,
1155                enable_spatial_adjacency: false,
1156                min_line_height: 9.0, // Slightly larger for academic papers
1157                vertical_gap_threshold_multiplier: 1.2, // More conservative - bigger gaps needed
1158                horizontal_alignment_tolerance: 8.0, // Tighter alignment for academic formatting
1159                line_grouping_tolerance: 0.25, // Tighter line grouping
1160                sections: ElementClusteringConfig {
1161                    min_segment_size: 50,  // Sections can be short titles
1162                    max_segment_size: 500, // Keep section headers concise
1163                },
1164                paragraphs: ElementClusteringConfig {
1165                    min_segment_size: 200,   // Larger minimum for academic content
1166                    max_segment_size: 12000, // Allow larger segments for detailed methods/results
1167                },
1168            },
1169            section_patterns: vec![
1170                "abstract".to_string(),
1171                "introduction".to_string(),
1172                "methodology".to_string(),
1173                "results".to_string(),
1174                "discussion".to_string(),
1175                "conclusion".to_string(),
1176                "references".to_string(),
1177            ],
1178            include_raw_tika: false, // Default to false for backward compatibility
1179            pipeline: PipelineConfig::default(),
1180            list_detection: ListDetectionConfig::default(),
1181            size_enforcer: SizeEnforcerConfig::default(), // TODO: OPTIMIZATION_DESIGN phase - document type specific tuning
1182            minimal_parse: false,
1183            section_detection_v2: SectionDetectionV2Config::default(),
1184            node_type_clustering: NodeTypeClusteringConfig::default(),
1185            graph_sanity: GraphSanityConfig::default(),
1186            dump_analytics: true,
1187        };
1188        self.configs
1189            .insert(DocumentType::AcademicPaper, academic_config);
1190
1191        // Legal contract config (strict hierarchy)
1192        let legal_config = ParsingConfig {
1193            document_type: DocumentType::LegalContract,
1194            section_and_hierarchy: SectionAndHierarchyConfig {
1195                large_header_threshold: 0.6,
1196                medium_header_threshold: 0.3,
1197                small_header_threshold: 0.1,
1198                min_header_size: 9.0,
1199                use_bold_indicator: true,
1200                bold_size_strict: true,
1201                max_depth: 5,
1202                font_size_tolerance: 0.1,
1203                enforce_max_depth: true,
1204                starting_section_level: 1,
1205                min_alpha_ratio: 0.5,
1206                pattern_detection: PatternDetectionConfig::default(),
1207            },
1208            spatial_clustering: SpatialClusteringConfig {
1209                enabled: true,
1210                enable_paragraph_merging: true,
1211                enable_spatial_adjacency: false,
1212                min_line_height: 8.5,
1213                vertical_gap_threshold_multiplier: 0.6, // Sensitive to small gaps in legal docs
1214                horizontal_alignment_tolerance: 12.0,   // Allow for indented legal clauses
1215                line_grouping_tolerance: 0.2, // Very tight - legal docs have precise formatting
1216                sections: ElementClusteringConfig {
1217                    min_segment_size: 30,  // Very short legal section titles
1218                    max_segment_size: 200, // Keep section headers concise
1219                },
1220                paragraphs: ElementClusteringConfig {
1221                    min_segment_size: 50,   // Smaller minimum - legal clauses can be short
1222                    max_segment_size: 5000, // Moderate maximum - keep clauses digestible
1223                },
1224            },
1225            section_patterns: vec![
1226                "article".to_string(),
1227                "section".to_string(),
1228                "clause".to_string(),
1229                "whereas".to_string(),
1230                "terms".to_string(),
1231                "conditions".to_string(),
1232            ],
1233            include_raw_tika: false, // Default to false for backward compatibility
1234            pipeline: PipelineConfig::default(),
1235            list_detection: ListDetectionConfig::default(),
1236            size_enforcer: SizeEnforcerConfig::default(), // TODO: OPTIMIZATION_DESIGN phase
1237            minimal_parse: false,
1238            section_detection_v2: SectionDetectionV2Config::default(),
1239            node_type_clustering: NodeTypeClusteringConfig::default(),
1240            graph_sanity: GraphSanityConfig::default(),
1241            dump_analytics: true,
1242        };
1243        self.configs
1244            .insert(DocumentType::LegalContract, legal_config);
1245
1246        Ok(())
1247    }
1248
1249    fn create_default_generic_config() -> ParsingConfig {
1250        ParsingConfig {
1251            document_type: DocumentType::Generic,
1252            section_and_hierarchy: SectionAndHierarchyConfig::default(),
1253            spatial_clustering: SpatialClusteringConfig {
1254                enabled: true,                          // Enable spatial clustering by default
1255                enable_paragraph_merging: true,         // Enable paragraph merging by default
1256                enable_spatial_adjacency: false,        // Disable spatial adjacency by default
1257                min_line_height: 8.0,                   // Minimum line height in points
1258                vertical_gap_threshold_multiplier: 0.8, // 80% of line height = section break
1259                horizontal_alignment_tolerance: 10.0,   // 10 points for alignment
1260                line_grouping_tolerance: 0.3,           // 30% of line height for same line
1261                sections: ElementClusteringConfig {
1262                    min_segment_size: 20,  // Short section titles allowed
1263                    max_segment_size: 300, // Keep section headers concise
1264                },
1265                paragraphs: ElementClusteringConfig {
1266                    min_segment_size: 100,  // Minimum 100 chars per segment
1267                    max_segment_size: 8000, // Maximum 8000 chars per segment
1268                },
1269            },
1270            section_patterns: vec![
1271                // Generic patterns that might indicate sections
1272                "chapter".to_string(),
1273                "section".to_string(),
1274                "part".to_string(),
1275                "overview".to_string(),
1276                "summary".to_string(),
1277                "background".to_string(),
1278                "principles".to_string(),
1279                "approach".to_string(),
1280            ],
1281            include_raw_tika: false, // Default to false for backward compatibility
1282            pipeline: PipelineConfig::default(),
1283            list_detection: ListDetectionConfig::default(),
1284            size_enforcer: SizeEnforcerConfig::default(), // TODO: OPTIMIZATION_DESIGN phase
1285            minimal_parse: false,
1286            section_detection_v2: SectionDetectionV2Config::default(),
1287            node_type_clustering: NodeTypeClusteringConfig::default(),
1288            graph_sanity: GraphSanityConfig::default(),
1289            dump_analytics: true,
1290        }
1291    }
1292}
1293
1294impl Default for ConfigManager {
1295    fn default() -> Self {
1296        Self::new().expect("Failed to create default ConfigManager")
1297    }
1298}
1299
1300impl ParsingConfig {
1301    /// Load config from file path (functional approach)
1302    pub fn load_from_file(path: &str) -> Result<Self> {
1303        let content = std::fs::read_to_string(path)?;
1304        let config: ParsingConfig = serde_yaml::from_str(&content)?;
1305        Ok(config)
1306    }
1307
1308    /// Load config with fallback to default
1309    pub fn load_with_fallback(path: Option<&str>) -> Self {
1310        match path {
1311            Some(p) => Self::load_from_file(p).unwrap_or_else(|_| {
1312                eprintln!("⚠️  Failed to load config from {}, using defaults", p);
1313                Self::default()
1314            }),
1315            None => Self::default(),
1316        }
1317    }
1318}
1319
1320impl Default for ParsingConfig {
1321    fn default() -> Self {
1322        // Use the generic config as default
1323        Self {
1324            document_type: DocumentType::Generic,
1325            section_and_hierarchy: SectionAndHierarchyConfig::default(),
1326            spatial_clustering: SpatialClusteringConfig {
1327                enabled: true,
1328                enable_paragraph_merging: true,
1329                enable_spatial_adjacency: false,
1330                min_line_height: 8.0,
1331                vertical_gap_threshold_multiplier: 0.8,
1332                horizontal_alignment_tolerance: 10.0,
1333                line_grouping_tolerance: 0.3,
1334                sections: ElementClusteringConfig {
1335                    min_segment_size: 20,
1336                    max_segment_size: 300,
1337                },
1338                paragraphs: ElementClusteringConfig {
1339                    min_segment_size: 100,
1340                    max_segment_size: 8000,
1341                },
1342            },
1343            section_patterns: vec![],
1344            include_raw_tika: false,
1345            pipeline: PipelineConfig::default(),
1346            list_detection: ListDetectionConfig::default(),
1347            size_enforcer: SizeEnforcerConfig::default(),
1348            minimal_parse: false,
1349            section_detection_v2: SectionDetectionV2Config::default(),
1350            node_type_clustering: NodeTypeClusteringConfig::default(),
1351            graph_sanity: GraphSanityConfig::default(),
1352            dump_analytics: true,
1353        }
1354    }
1355}