docvortex_core/
geometry_lines.rs1use 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
16fn 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
30fn 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
55pub 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 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 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 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}