Skip to main content

docvortex_core/
geometry_lines.rs

1//! 在自有样本上连续计算行字形、主基线与最终 Y 风险,避免 Python 字符往返。
2use crate::{
3    geometry::Box4, geometry_risk::quantile, geometry_style::Prepared, median,
4    statistics::ordered_clusters,
5};
6use std::collections::HashMap;
7
8pub struct World {
9    pub tight: Box4,
10    pub canonical: bool,
11    pub y_eligible: bool,
12}
13
14pub type Summary = (Vec<((i64, i64), Box4)>, Vec<((i64, i64), f64)>, bool);
15
16/// 保持 Python min/max 首见值,包括相等坐标及有符号零。
17fn union(boxes: impl Iterator<Item = Box4>) -> Option<Box4> {
18    let mut boxes = boxes;
19    let mut result = boxes.next()?;
20    for bbox in boxes {
21        for axis in 0..4 {
22            if (axis < 2 && bbox[axis] < result[axis]) || (axis >= 2 && bbox[axis] > result[axis]) {
23                result[axis] = bbox[axis];
24            }
25        }
26    }
27    Some(result)
28}
29
30/// 模拟 CPython 3.12 起的补偿求和;旧版本仍按原顺序普通相加。
31fn width_sum(values: impl Iterator<Item = f64>, compensated: bool) -> Option<f64> {
32    let (mut hi, mut lo) = (0.0_f64, 0.0_f64);
33    for value in values {
34        let next = hi + value;
35        if compensated {
36            lo += if hi.abs() >= value.abs() {
37                (hi - next) + value
38            } else {
39                (value - next) + hi
40            };
41        }
42        hi = next;
43        if !hi.is_finite() || !lo.is_finite() {
44            return None;
45        }
46    }
47    let total = if compensated && lo != 0.0 {
48        hi + lo
49    } else {
50        hi
51    };
52    total.is_finite().then_some(total)
53}
54
55/// 共享基线聚类,但 canonical 按宽度优先、Y 风险按成员数优先,保留不同裁决语义。
56pub fn summarize(prepared: &Prepared, world: &[World], compensated: bool) -> Option<Summary> {
57    if world.len() != prepared.samples.len()
58        || world.iter().any(|w| w.tight.iter().any(|v| !v.is_finite()))
59    {
60        return None;
61    }
62    let samples = &prepared.samples;
63    let mut inks = Vec::new();
64    let mut baselines = Vec::new();
65    let mut ratios = Vec::new();
66    let mut extremes = HashMap::<usize, usize>::new();
67    let mut y_risk = false;
68    for (key, line) in prepared.line_keys.iter().zip(&prepared.lines) {
69        let Some(&first) = line.first() else {
70            continue;
71        };
72        inks.push((*key, union(line.iter().map(|i| world[*i].tight))?));
73        let anchors: Vec<_> = line
74            .iter()
75            .copied()
76            .filter(|i| samples[*i].anchor)
77            .collect();
78        let canonical = world[first].canonical && anchors.len() >= 2;
79        let risk = !y_risk && world[first].y_eligible && anchors.len() >= 4;
80        if !canonical && !risk {
81            continue;
82        }
83        let heights: Vec<_> = anchors
84            .iter()
85            .map(|i| samples[*i].tight[3] - samples[*i].tight[1])
86            .collect();
87        let tolerance = 0.5_f64.max(0.25 * quantile(heights, 0.75));
88        let groups = ordered_clusters(
89            anchors.iter().map(|i| samples[*i].origin[1]).collect(),
90            tolerance,
91            0.0,
92            false,
93        )?;
94        if canonical {
95            let mut best = None;
96            for group in &groups {
97                let width = width_sum(
98                    group.iter().map(|i| {
99                        let b = samples[anchors[*i]].tight;
100                        b[2] - b[0]
101                    }),
102                    compensated,
103                )?;
104                let score = (width, group.len());
105                if best.as_ref().is_none_or(|(previous, _)| score > *previous) {
106                    best = Some((score, group));
107                }
108            }
109            if let Some((_, dominant)) = best {
110                if dominant.len() as f64 / anchors.len() as f64 >= 0.5 {
111                    baselines.push((
112                        *key,
113                        median(
114                            dominant
115                                .iter()
116                                .map(|i| samples[anchors[*i]].origin[1])
117                                .collect(),
118                        ),
119                    ));
120                }
121            }
122        }
123        if !risk {
124            continue;
125        }
126        if groups
127            .iter()
128            .filter(|g| g.len() >= 3 && g.len() as f64 / anchors.len() as f64 >= 0.20)
129            .count()
130            >= 2
131        {
132            y_risk = true;
133            continue;
134        }
135        // max_by_key 会选最后一个并列项;显式比较以保持 Python max 的首见顺序。
136        let mut dominant = &groups[0];
137        for group in &groups[1..] {
138            if group.len() > dominant.len() {
139                dominant = group;
140            }
141        }
142        if dominant.len() as f64 / (anchors.len() as f64) < 0.80 {
143            continue;
144        }
145        let source = union(dominant.iter().map(|i| samples[anchors[*i]].source))?;
146        let tight = union(dominant.iter().map(|i| samples[anchors[*i]].tight))?;
147        let height = tight[3] - tight[1];
148        if height <= 0.0 {
149            continue;
150        }
151        let ratio = (source[3] - source[1]) / height;
152        if !ratio.is_finite() {
153            return None;
154        }
155        ratios.push(ratio);
156        if ratio >= 3.0 {
157            let mut counts = HashMap::<usize, usize>::new();
158            for i in dominant {
159                *counts.entry(samples[anchors[*i]].run).or_default() += 1;
160            }
161            let mut run = samples[anchors[dominant[0]]].run;
162            for i in dominant {
163                let candidate = samples[anchors[*i]].run;
164                if counts[&candidate] > counts[&run] {
165                    run = candidate;
166                }
167            }
168            *extremes.entry(run).or_default() += 1;
169        }
170    }
171    y_risk |= !ratios.is_empty()
172        && (quantile(ratios, 0.95) >= 2.20 || extremes.values().any(|n| *n >= 3));
173    Some((inks, baselines, y_risk))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    /// 保留 Python 3.11 与 3.12+ 的不同求和结果,避免并列主基线改变。
182    fn width_sum_matches_python_versions() {
183        assert_eq!(width_sum([1e16, 1.0, 1.0].into_iter(), false), Some(1e16));
184        assert_eq!(
185            width_sum([1e16, 1.0, 1.0].into_iter(), true),
186            Some(1e16 + 2.0)
187        );
188        assert_eq!(width_sum([f64::MAX, f64::MAX].into_iter(), true), None);
189    }
190
191    #[test]
192    /// 并集保留首次出现的有符号零,不用浮点 min/max 改写坐标位模式。
193    fn union_preserves_first_equal_coordinate() {
194        let result = union([[0.0, -0.0, 1.0, 2.0], [-0.0, 0.0, 1.0, 2.0]].into_iter()).unwrap();
195        assert_eq!(result[0].to_bits(), 0.0_f64.to_bits());
196        assert_eq!(result[1].to_bits(), (-0.0_f64).to_bits());
197    }
198}