Skip to main content

formualizer_eval/formula_plane/
span_store.rs

1//! Passive FormulaPlane run store builder.
2//!
3//! The store is descriptive infrastructure for the FormulaPlane bridge. It does
4//! not route evaluation, dirty propagation, scheduling, dependency graph writes,
5//! or loader behavior.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use super::ids::{FormulaRunId, FormulaTemplateId};
10use super::span_counters::{
11    DEFAULT_CANDIDATE_ROW_BLOCK_SIZE, FormulaPlaneCandidateCell, SpanPartitionCounterOptions,
12    compute_span_partition_counters,
13};
14
15pub const DEFAULT_GAP_SCAN_MAX_PER_AXIS_GROUP: u64 = 1_000_000;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct FormulaTemplateDescriptor {
19    pub id: FormulaTemplateId,
20    pub source_template_id: String,
21    pub formula_cell_count: u64,
22    pub status: TemplateSupportStatus,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub enum TemplateSupportStatus {
27    Supported,
28    ParseError,
29    Unsupported,
30    Dynamic,
31    Volatile,
32    Mixed,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub enum FormulaRunShape {
37    Row,
38    Column,
39    Singleton,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct FormulaRunDescriptor {
44    pub id: FormulaRunId,
45    pub template_id: FormulaTemplateId,
46    pub source_template_id: String,
47    pub sheet: String,
48    pub shape: FormulaRunShape,
49    pub row_start: u32,
50    pub col_start: u32,
51    pub row_end: u32,
52    pub col_end: u32,
53    pub len: u64,
54    pub row_block_start: u32,
55    pub row_block_end: u32,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
59pub struct SpanGapDescriptor {
60    pub template_id: FormulaTemplateId,
61    pub sheet: String,
62    pub row: u32,
63    pub col: u32,
64    pub kind: SpanGapKind,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
68pub enum SpanGapKind {
69    Hole,
70    Exception {
71        other_template_id: FormulaTemplateId,
72    },
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct FormulaRejectedCell {
77    pub sheet: String,
78    pub row: u32,
79    pub col: u32,
80    pub source_template_id: String,
81    pub reason: FormulaRejectReason,
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
85pub enum FormulaRejectReason {
86    ParseError,
87    Unsupported,
88    Dynamic,
89    Volatile,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct FormulaTemplateArena {
94    pub templates: Vec<FormulaTemplateDescriptor>,
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct FormulaRunStoreBuildReport {
99    pub template_count: u64,
100    pub formula_cell_count: u64,
101    pub supported_formula_cell_count: u64,
102    pub rejected_formula_cell_count: u64,
103    pub parse_error_formula_count: u64,
104    pub unsupported_formula_count: u64,
105    pub dynamic_formula_count: u64,
106    pub volatile_formula_count: u64,
107    pub row_run_count: u64,
108    pub column_run_count: u64,
109    pub singleton_run_count: u64,
110    pub formula_cells_represented_by_runs: u64,
111    pub candidate_row_block_partition_count: u64,
112    pub candidate_formula_run_to_partition_edge_estimate: u64,
113    pub max_partitions_touched_by_run: u64,
114    pub hole_count: u64,
115    pub exception_count: u64,
116    pub overlap_dropped_count: u64,
117    pub rectangle_deferred_count: u64,
118    pub gap_scan_truncated_count: u64,
119    pub reconciliation: Fp2aReconciliation,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct Fp2aReconciliation {
124    pub matched: bool,
125    pub deltas: Vec<Fp2aCounterDelta>,
126}
127
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub struct Fp2aCounterDelta {
130    pub field: &'static str,
131    pub fp2a_value: i64,
132    pub span_store_value: i64,
133    pub reason: &'static str,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct FormulaRunStore {
138    pub row_block_size: u32,
139    pub arena: FormulaTemplateArena,
140    pub runs: Vec<FormulaRunDescriptor>,
141    pub gaps: Vec<SpanGapDescriptor>,
142    pub rejected_cells: Vec<FormulaRejectedCell>,
143    pub report: FormulaRunStoreBuildReport,
144}
145
146#[derive(Clone, Copy, Debug, PartialEq, Eq)]
147pub struct FormulaRunStoreBuildOptions {
148    pub row_block_size: u32,
149    pub gap_scan_max_per_axis_group: u64,
150}
151
152impl Default for FormulaRunStoreBuildOptions {
153    fn default() -> Self {
154        Self {
155            row_block_size: DEFAULT_CANDIDATE_ROW_BLOCK_SIZE,
156            gap_scan_max_per_axis_group: DEFAULT_GAP_SCAN_MAX_PER_AXIS_GROUP,
157        }
158    }
159}
160
161impl FormulaRunStoreBuildOptions {
162    fn normalized(self) -> Self {
163        Self {
164            row_block_size: self.row_block_size.max(1),
165            gap_scan_max_per_axis_group: self.gap_scan_max_per_axis_group,
166        }
167    }
168}
169
170impl FormulaRunStore {
171    pub fn build(cells: &[FormulaPlaneCandidateCell]) -> Self {
172        build_formula_run_store(cells, FormulaRunStoreBuildOptions::default())
173    }
174
175    pub fn build_with_options(
176        cells: &[FormulaPlaneCandidateCell],
177        options: FormulaRunStoreBuildOptions,
178    ) -> Self {
179        build_formula_run_store(cells, options)
180    }
181}
182
183pub fn build_formula_run_store(
184    cells: &[FormulaPlaneCandidateCell],
185    options: FormulaRunStoreBuildOptions,
186) -> FormulaRunStore {
187    let options = options.normalized();
188    let template_id_by_source = assign_template_ids(cells);
189    let arena = build_arena(cells, &template_id_by_source);
190    let classified = classify_cells(cells, &template_id_by_source);
191
192    let supported_by_template = group_supported_cells(&classified.supported_cells);
193    let all_cell_templates = build_cell_template_index(&classified.all_cells);
194
195    let candidate_runs = build_candidate_runs(&supported_by_template, options.row_block_size);
196    let rectangle_deferred_count = count_deferred_rectangles(&supported_by_template);
197    let (accepted_runs, overlap_dropped_count, represented_cells) =
198        select_non_overlapping_runs(candidate_runs, options.row_block_size);
199    let mut runs = materialize_runs(
200        accepted_runs,
201        &template_id_by_source,
202        options.row_block_size,
203    );
204
205    add_singleton_runs(
206        &mut runs,
207        &classified.supported_cells,
208        &represented_cells,
209        options.row_block_size,
210    );
211    assign_run_ids(&mut runs);
212
213    let (gaps, gap_scan_truncated_count) = scan_gaps(
214        &supported_by_template,
215        &all_cell_templates,
216        options.gap_scan_max_per_axis_group,
217    );
218
219    let mut rejected_cells = classified.rejected_cells;
220    rejected_cells.sort_by(|a, b| {
221        (&a.sheet, a.row, a.col, &a.source_template_id, a.reason).cmp(&(
222            &b.sheet,
223            b.row,
224            b.col,
225            &b.source_template_id,
226            b.reason,
227        ))
228    });
229
230    let report = build_report(
231        cells,
232        options,
233        &template_id_by_source,
234        &runs,
235        &gaps,
236        rejected_cells.len() as u64,
237        overlap_dropped_count,
238        rectangle_deferred_count,
239        gap_scan_truncated_count,
240    );
241
242    FormulaRunStore {
243        row_block_size: options.row_block_size,
244        arena,
245        runs,
246        gaps,
247        rejected_cells,
248        report,
249    }
250}
251
252#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
253struct CellKey {
254    sheet: String,
255    row: u32,
256    col: u32,
257}
258
259#[derive(Clone, Debug)]
260struct ClassifiedCell {
261    source_template_id: String,
262    template_id: FormulaTemplateId,
263    key: CellKey,
264}
265
266#[derive(Clone, Debug)]
267struct ClassifiedCells {
268    supported_cells: Vec<ClassifiedCell>,
269    rejected_cells: Vec<FormulaRejectedCell>,
270    all_cells: Vec<ClassifiedCell>,
271}
272
273#[derive(Clone, Debug)]
274struct PendingRun {
275    template_id: FormulaTemplateId,
276    source_template_id: String,
277    sheet: String,
278    shape: FormulaRunShape,
279    row_start: u32,
280    col_start: u32,
281    row_end: u32,
282    col_end: u32,
283    len: u64,
284}
285
286impl PendingRun {
287    fn cell_keys(&self) -> Vec<CellKey> {
288        match self.shape {
289            FormulaRunShape::Row => (self.col_start..=self.col_end)
290                .map(|col| CellKey {
291                    sheet: self.sheet.clone(),
292                    row: self.row_start,
293                    col,
294                })
295                .collect(),
296            FormulaRunShape::Column => (self.row_start..=self.row_end)
297                .map(|row| CellKey {
298                    sheet: self.sheet.clone(),
299                    row,
300                    col: self.col_start,
301                })
302                .collect(),
303            FormulaRunShape::Singleton => vec![CellKey {
304                sheet: self.sheet.clone(),
305                row: self.row_start,
306                col: self.col_start,
307            }],
308        }
309    }
310}
311
312fn assign_template_ids(cells: &[FormulaPlaneCandidateCell]) -> BTreeMap<String, FormulaTemplateId> {
313    let sources = cells
314        .iter()
315        .map(|cell| cell.template_id.clone())
316        .collect::<BTreeSet<_>>();
317    sources
318        .into_iter()
319        .enumerate()
320        .map(|(index, source)| (source, FormulaTemplateId(index as u32)))
321        .collect()
322}
323
324fn build_arena(
325    cells: &[FormulaPlaneCandidateCell],
326    template_id_by_source: &BTreeMap<String, FormulaTemplateId>,
327) -> FormulaTemplateArena {
328    let mut stats = template_id_by_source
329        .keys()
330        .map(|source| (source.clone(), TemplateStats::default()))
331        .collect::<BTreeMap<_, _>>();
332
333    for cell in cells {
334        let stat = stats.entry(cell.template_id.clone()).or_default();
335        stat.formula_cell_count += 1;
336        match reject_reason(cell) {
337            Some(reason) => {
338                stat.rejected_reasons.insert(reason);
339            }
340            None => stat.supported_count += 1,
341        }
342    }
343
344    let templates = stats
345        .into_iter()
346        .map(|(source_template_id, stat)| FormulaTemplateDescriptor {
347            id: template_id_by_source[&source_template_id],
348            source_template_id,
349            formula_cell_count: stat.formula_cell_count,
350            status: stat.status(),
351        })
352        .collect();
353
354    FormulaTemplateArena { templates }
355}
356
357#[derive(Clone, Debug, Default)]
358struct TemplateStats {
359    formula_cell_count: u64,
360    supported_count: u64,
361    rejected_reasons: BTreeSet<FormulaRejectReason>,
362}
363
364impl TemplateStats {
365    fn status(&self) -> TemplateSupportStatus {
366        if self.supported_count > 0 && !self.rejected_reasons.is_empty() {
367            return TemplateSupportStatus::Mixed;
368        }
369        if self.supported_count > 0 {
370            return TemplateSupportStatus::Supported;
371        }
372        match self.rejected_reasons.first().copied() {
373            Some(FormulaRejectReason::ParseError) => TemplateSupportStatus::ParseError,
374            Some(FormulaRejectReason::Unsupported) => TemplateSupportStatus::Unsupported,
375            Some(FormulaRejectReason::Dynamic) => TemplateSupportStatus::Dynamic,
376            Some(FormulaRejectReason::Volatile) => TemplateSupportStatus::Volatile,
377            None => TemplateSupportStatus::Supported,
378        }
379    }
380}
381
382fn classify_cells(
383    cells: &[FormulaPlaneCandidateCell],
384    template_id_by_source: &BTreeMap<String, FormulaTemplateId>,
385) -> ClassifiedCells {
386    let mut supported_cells = Vec::new();
387    let mut rejected_cells = Vec::new();
388    let mut all_cells = Vec::new();
389
390    for cell in cells {
391        let template_id = template_id_by_source[&cell.template_id];
392        let key = CellKey {
393            sheet: cell.sheet.clone(),
394            row: cell.row,
395            col: cell.col,
396        };
397        all_cells.push(ClassifiedCell {
398            source_template_id: cell.template_id.clone(),
399            template_id,
400            key: key.clone(),
401        });
402        if let Some(reason) = reject_reason(cell) {
403            rejected_cells.push(FormulaRejectedCell {
404                sheet: cell.sheet.clone(),
405                row: cell.row,
406                col: cell.col,
407                source_template_id: cell.template_id.clone(),
408                reason,
409            });
410        } else {
411            supported_cells.push(ClassifiedCell {
412                source_template_id: cell.template_id.clone(),
413                template_id,
414                key,
415            });
416        }
417    }
418
419    supported_cells.sort_by(|a, b| {
420        (
421            a.template_id,
422            &a.source_template_id,
423            &a.key.sheet,
424            a.key.row,
425            a.key.col,
426        )
427            .cmp(&(
428                b.template_id,
429                &b.source_template_id,
430                &b.key.sheet,
431                b.key.row,
432                b.key.col,
433            ))
434    });
435    all_cells.sort_by(|a, b| {
436        (&a.key.sheet, a.key.row, a.key.col, a.template_id).cmp(&(
437            &b.key.sheet,
438            b.key.row,
439            b.key.col,
440            b.template_id,
441        ))
442    });
443
444    ClassifiedCells {
445        supported_cells,
446        rejected_cells,
447        all_cells,
448    }
449}
450
451fn reject_reason(cell: &FormulaPlaneCandidateCell) -> Option<FormulaRejectReason> {
452    if !cell.parse_ok {
453        Some(FormulaRejectReason::ParseError)
454    } else if cell.unsupported {
455        Some(FormulaRejectReason::Unsupported)
456    } else if cell.dynamic {
457        Some(FormulaRejectReason::Dynamic)
458    } else if cell.volatile {
459        Some(FormulaRejectReason::Volatile)
460    } else {
461        None
462    }
463}
464
465fn group_supported_cells(
466    cells: &[ClassifiedCell],
467) -> BTreeMap<FormulaTemplateId, Vec<ClassifiedCell>> {
468    let mut by_template = BTreeMap::<FormulaTemplateId, Vec<ClassifiedCell>>::new();
469    for cell in cells {
470        by_template
471            .entry(cell.template_id)
472            .or_default()
473            .push(cell.clone());
474    }
475    by_template
476}
477
478fn build_cell_template_index(cells: &[ClassifiedCell]) -> BTreeMap<CellKey, FormulaTemplateId> {
479    let mut out = BTreeMap::new();
480    for cell in cells {
481        out.entry(cell.key.clone()).or_insert(cell.template_id);
482    }
483    out
484}
485
486fn build_candidate_runs(
487    supported_by_template: &BTreeMap<FormulaTemplateId, Vec<ClassifiedCell>>,
488    _row_block_size: u32,
489) -> Vec<PendingRun> {
490    let mut runs = Vec::new();
491    for (template_id, cells) in supported_by_template {
492        build_axis_runs(&mut runs, *template_id, cells, FormulaRunShape::Row);
493        build_axis_runs(&mut runs, *template_id, cells, FormulaRunShape::Column);
494    }
495    runs.sort_by(compare_run_key);
496    runs
497}
498
499fn build_axis_runs(
500    out: &mut Vec<PendingRun>,
501    template_id: FormulaTemplateId,
502    cells: &[ClassifiedCell],
503    shape: FormulaRunShape,
504) {
505    let mut groups: BTreeMap<(String, String, u32), Vec<u32>> = BTreeMap::new();
506    for cell in cells {
507        let fixed = match shape {
508            FormulaRunShape::Row => cell.key.row,
509            FormulaRunShape::Column => cell.key.col,
510            FormulaRunShape::Singleton => unreachable!(),
511        };
512        let value = match shape {
513            FormulaRunShape::Row => cell.key.col,
514            FormulaRunShape::Column => cell.key.row,
515            FormulaRunShape::Singleton => unreachable!(),
516        };
517        groups
518            .entry((
519                cell.source_template_id.clone(),
520                cell.key.sheet.clone(),
521                fixed,
522            ))
523            .or_default()
524            .push(value);
525    }
526
527    for ((source_template_id, sheet, fixed), mut values) in groups {
528        values.sort_unstable();
529        values.dedup();
530        let mut start = None::<u32>;
531        let mut prev = None::<u32>;
532        for value in values {
533            match (start, prev) {
534                (None, _) => {
535                    start = Some(value);
536                    prev = Some(value);
537                }
538                (Some(_), Some(previous)) if value == previous + 1 => prev = Some(value),
539                (Some(run_start), Some(run_end)) => {
540                    push_axis_run(
541                        out,
542                        template_id,
543                        &source_template_id,
544                        &sheet,
545                        shape,
546                        fixed,
547                        run_start,
548                        run_end,
549                    );
550                    start = Some(value);
551                    prev = Some(value);
552                }
553                (Some(_), None) => unreachable!(),
554            }
555        }
556        if let (Some(run_start), Some(run_end)) = (start, prev) {
557            push_axis_run(
558                out,
559                template_id,
560                &source_template_id,
561                &sheet,
562                shape,
563                fixed,
564                run_start,
565                run_end,
566            );
567        }
568    }
569}
570
571fn push_axis_run(
572    out: &mut Vec<PendingRun>,
573    template_id: FormulaTemplateId,
574    source_template_id: &str,
575    sheet: &str,
576    shape: FormulaRunShape,
577    fixed: u32,
578    start: u32,
579    end: u32,
580) {
581    if end <= start {
582        return;
583    }
584    let (row_start, col_start, row_end, col_end) = match shape {
585        FormulaRunShape::Row => (fixed, start, fixed, end),
586        FormulaRunShape::Column => (start, fixed, end, fixed),
587        FormulaRunShape::Singleton => unreachable!(),
588    };
589    out.push(PendingRun {
590        template_id,
591        source_template_id: source_template_id.to_string(),
592        sheet: sheet.to_string(),
593        shape,
594        row_start,
595        col_start,
596        row_end,
597        col_end,
598        len: u64::from(end - start + 1),
599    });
600}
601
602fn select_non_overlapping_runs(
603    mut candidate_runs: Vec<PendingRun>,
604    _row_block_size: u32,
605) -> (Vec<PendingRun>, u64, BTreeSet<CellKey>) {
606    candidate_runs.sort_by(|a, b| {
607        b.len
608            .cmp(&a.len)
609            .then_with(|| shape_order(a.shape).cmp(&shape_order(b.shape)))
610            .then_with(|| compare_run_key(a, b))
611    });
612
613    let mut accepted = Vec::new();
614    let mut represented = BTreeSet::new();
615    let mut overlap_dropped_count = 0;
616
617    for run in candidate_runs {
618        let cells = run.cell_keys();
619        if cells.iter().any(|cell| represented.contains(cell)) {
620            overlap_dropped_count += 1;
621            continue;
622        }
623        for cell in cells {
624            represented.insert(cell);
625        }
626        accepted.push(run);
627    }
628
629    (accepted, overlap_dropped_count, represented)
630}
631
632fn materialize_runs(
633    pending_runs: Vec<PendingRun>,
634    _template_id_by_source: &BTreeMap<String, FormulaTemplateId>,
635    row_block_size: u32,
636) -> Vec<FormulaRunDescriptor> {
637    pending_runs
638        .into_iter()
639        .map(|run| descriptor_from_pending(run, row_block_size))
640        .collect()
641}
642
643fn add_singleton_runs(
644    runs: &mut Vec<FormulaRunDescriptor>,
645    supported_cells: &[ClassifiedCell],
646    represented_cells: &BTreeSet<CellKey>,
647    row_block_size: u32,
648) {
649    let mut seen = BTreeSet::new();
650    for cell in supported_cells {
651        if represented_cells.contains(&cell.key) || !seen.insert(cell.key.clone()) {
652            continue;
653        }
654        let block = row_block_index(cell.key.row, row_block_size);
655        runs.push(FormulaRunDescriptor {
656            id: FormulaRunId(0),
657            template_id: cell.template_id,
658            source_template_id: cell.source_template_id.clone(),
659            sheet: cell.key.sheet.clone(),
660            shape: FormulaRunShape::Singleton,
661            row_start: cell.key.row,
662            col_start: cell.key.col,
663            row_end: cell.key.row,
664            col_end: cell.key.col,
665            len: 1,
666            row_block_start: block,
667            row_block_end: block,
668        });
669    }
670}
671
672fn descriptor_from_pending(run: PendingRun, row_block_size: u32) -> FormulaRunDescriptor {
673    let row_block_start = row_block_index(run.row_start, row_block_size);
674    let row_block_end = row_block_index(run.row_end, row_block_size);
675    FormulaRunDescriptor {
676        id: FormulaRunId(0),
677        template_id: run.template_id,
678        source_template_id: run.source_template_id,
679        sheet: run.sheet,
680        shape: run.shape,
681        row_start: run.row_start,
682        col_start: run.col_start,
683        row_end: run.row_end,
684        col_end: run.col_end,
685        len: run.len,
686        row_block_start,
687        row_block_end,
688    }
689}
690
691fn assign_run_ids(runs: &mut [FormulaRunDescriptor]) {
692    runs.sort_by(|a, b| {
693        (
694            a.template_id,
695            &a.sheet,
696            shape_order(a.shape),
697            a.row_start,
698            a.col_start,
699            a.row_end,
700            a.col_end,
701        )
702            .cmp(&(
703                b.template_id,
704                &b.sheet,
705                shape_order(b.shape),
706                b.row_start,
707                b.col_start,
708                b.row_end,
709                b.col_end,
710            ))
711    });
712    for (index, run) in runs.iter_mut().enumerate() {
713        run.id = FormulaRunId(index as u32);
714    }
715}
716
717fn scan_gaps(
718    supported_by_template: &BTreeMap<FormulaTemplateId, Vec<ClassifiedCell>>,
719    all_cell_templates: &BTreeMap<CellKey, FormulaTemplateId>,
720    gap_scan_max_per_axis_group: u64,
721) -> (Vec<SpanGapDescriptor>, u64) {
722    let mut gaps = BTreeSet::new();
723    let mut truncated = 0;
724    for (template_id, cells) in supported_by_template {
725        truncated += scan_axis_gaps(
726            &mut gaps,
727            *template_id,
728            cells,
729            all_cell_templates,
730            FormulaRunShape::Row,
731            gap_scan_max_per_axis_group,
732        );
733        truncated += scan_axis_gaps(
734            &mut gaps,
735            *template_id,
736            cells,
737            all_cell_templates,
738            FormulaRunShape::Column,
739            gap_scan_max_per_axis_group,
740        );
741    }
742    (gaps.into_iter().collect(), truncated)
743}
744
745fn scan_axis_gaps(
746    gaps: &mut BTreeSet<SpanGapDescriptor>,
747    template_id: FormulaTemplateId,
748    cells: &[ClassifiedCell],
749    all_cell_templates: &BTreeMap<CellKey, FormulaTemplateId>,
750    shape: FormulaRunShape,
751    gap_scan_max_per_axis_group: u64,
752) -> u64 {
753    let mut groups: BTreeMap<(String, u32), Vec<u32>> = BTreeMap::new();
754    for cell in cells {
755        let fixed = match shape {
756            FormulaRunShape::Row => cell.key.row,
757            FormulaRunShape::Column => cell.key.col,
758            FormulaRunShape::Singleton => unreachable!(),
759        };
760        let value = match shape {
761            FormulaRunShape::Row => cell.key.col,
762            FormulaRunShape::Column => cell.key.row,
763            FormulaRunShape::Singleton => unreachable!(),
764        };
765        groups
766            .entry((cell.key.sheet.clone(), fixed))
767            .or_default()
768            .push(value);
769    }
770
771    let mut truncated = 0;
772    for ((sheet, fixed), mut values) in groups {
773        values.sort_unstable();
774        values.dedup();
775        let Some(min) = values.first().copied() else {
776            continue;
777        };
778        let Some(max) = values.last().copied() else {
779            continue;
780        };
781        let width = u64::from(max - min + 1);
782        if width > gap_scan_max_per_axis_group {
783            truncated += 1;
784            continue;
785        }
786        let present = values.into_iter().collect::<BTreeSet<_>>();
787        for value in min..=max {
788            if present.contains(&value) {
789                continue;
790            }
791            let key = match shape {
792                FormulaRunShape::Row => CellKey {
793                    sheet: sheet.clone(),
794                    row: fixed,
795                    col: value,
796                },
797                FormulaRunShape::Column => CellKey {
798                    sheet: sheet.clone(),
799                    row: value,
800                    col: fixed,
801                },
802                FormulaRunShape::Singleton => unreachable!(),
803            };
804            match all_cell_templates.get(&key) {
805                Some(other_template_id) if *other_template_id != template_id => {
806                    gaps.insert(SpanGapDescriptor {
807                        template_id,
808                        sheet: key.sheet,
809                        row: key.row,
810                        col: key.col,
811                        kind: SpanGapKind::Exception {
812                            other_template_id: *other_template_id,
813                        },
814                    });
815                }
816                Some(_) => {}
817                None => {
818                    gaps.insert(SpanGapDescriptor {
819                        template_id,
820                        sheet: key.sheet,
821                        row: key.row,
822                        col: key.col,
823                        kind: SpanGapKind::Hole,
824                    });
825                }
826            }
827        }
828    }
829    truncated
830}
831
832fn count_deferred_rectangles(
833    supported_by_template: &BTreeMap<FormulaTemplateId, Vec<ClassifiedCell>>,
834) -> u64 {
835    let mut count = 0;
836    for cells in supported_by_template.values() {
837        let mut by_sheet_row: BTreeMap<(String, u32), BTreeSet<u32>> = BTreeMap::new();
838        for cell in cells {
839            by_sheet_row
840                .entry((cell.key.sheet.clone(), cell.key.row))
841                .or_default()
842                .insert(cell.key.col);
843        }
844        let mut rows_by_sheet: BTreeMap<String, Vec<BTreeSet<u32>>> = BTreeMap::new();
845        for ((sheet, _row), cols) in by_sheet_row {
846            if cols.len() >= 2 {
847                rows_by_sheet.entry(sheet).or_default().push(cols);
848            }
849        }
850        for rows in rows_by_sheet.values() {
851            let mut found = false;
852            for left_index in 0..rows.len() {
853                for right in rows.iter().skip(left_index + 1) {
854                    if rows[left_index].intersection(right).take(2).count() >= 2 {
855                        count += 1;
856                        found = true;
857                        break;
858                    }
859                }
860                if found {
861                    break;
862                }
863            }
864        }
865    }
866    count
867}
868
869fn build_report(
870    cells: &[FormulaPlaneCandidateCell],
871    options: FormulaRunStoreBuildOptions,
872    template_id_by_source: &BTreeMap<String, FormulaTemplateId>,
873    runs: &[FormulaRunDescriptor],
874    gaps: &[SpanGapDescriptor],
875    rejected_formula_cell_count: u64,
876    overlap_dropped_count: u64,
877    rectangle_deferred_count: u64,
878    gap_scan_truncated_count: u64,
879) -> FormulaRunStoreBuildReport {
880    let row_run_count = runs
881        .iter()
882        .filter(|run| run.shape == FormulaRunShape::Row)
883        .count() as u64;
884    let column_run_count = runs
885        .iter()
886        .filter(|run| run.shape == FormulaRunShape::Column)
887        .count() as u64;
888    let singleton_run_count = runs
889        .iter()
890        .filter(|run| run.shape == FormulaRunShape::Singleton)
891        .count() as u64;
892    let formula_cells_represented_by_runs = runs.iter().map(|run| run.len).sum();
893    let mut partitions = BTreeSet::new();
894    let mut edge_estimate = 0;
895    let mut max_partitions_touched = 0;
896    for run in runs {
897        let touched = u64::from(run.row_block_end - run.row_block_start + 1);
898        edge_estimate += touched;
899        max_partitions_touched = max_partitions_touched.max(touched);
900        for block in run.row_block_start..=run.row_block_end {
901            partitions.insert((run.sheet.clone(), block));
902        }
903    }
904    let hole_count = gaps
905        .iter()
906        .filter(|gap| gap.kind == SpanGapKind::Hole)
907        .count() as u64;
908    let exception_count = gaps
909        .iter()
910        .filter(|gap| matches!(gap.kind, SpanGapKind::Exception { .. }))
911        .count() as u64;
912    let parse_error_formula_count = cells.iter().filter(|cell| !cell.parse_ok).count() as u64;
913    let unsupported_formula_count = cells.iter().filter(|cell| cell.unsupported).count() as u64;
914    let dynamic_formula_count = cells.iter().filter(|cell| cell.dynamic).count() as u64;
915    let volatile_formula_count = cells.iter().filter(|cell| cell.volatile).count() as u64;
916
917    let mut report = FormulaRunStoreBuildReport {
918        template_count: template_id_by_source.len() as u64,
919        formula_cell_count: cells.len() as u64,
920        supported_formula_cell_count: cells.len() as u64 - rejected_formula_cell_count,
921        rejected_formula_cell_count,
922        parse_error_formula_count,
923        unsupported_formula_count,
924        dynamic_formula_count,
925        volatile_formula_count,
926        row_run_count,
927        column_run_count,
928        singleton_run_count,
929        formula_cells_represented_by_runs,
930        candidate_row_block_partition_count: partitions.len() as u64,
931        candidate_formula_run_to_partition_edge_estimate: edge_estimate,
932        max_partitions_touched_by_run: max_partitions_touched,
933        hole_count,
934        exception_count,
935        overlap_dropped_count,
936        rectangle_deferred_count,
937        gap_scan_truncated_count,
938        reconciliation: Fp2aReconciliation {
939            matched: true,
940            deltas: Vec::new(),
941        },
942    };
943    report.reconciliation = reconcile_fp2a(cells, options, &report);
944    report
945}
946
947fn reconcile_fp2a(
948    cells: &[FormulaPlaneCandidateCell],
949    options: FormulaRunStoreBuildOptions,
950    report: &FormulaRunStoreBuildReport,
951) -> Fp2aReconciliation {
952    let fp2a = compute_span_partition_counters(
953        cells,
954        SpanPartitionCounterOptions {
955            row_block_size: options.row_block_size,
956        },
957    );
958    let mut deltas = Vec::new();
959    push_delta(
960        &mut deltas,
961        "template_count",
962        fp2a.template_count,
963        report.template_count,
964        "unexpected_delta",
965    );
966    push_delta(
967        &mut deltas,
968        "formula_cell_count",
969        fp2a.formula_cell_count,
970        report.formula_cell_count,
971        "unexpected_delta",
972    );
973    push_delta(
974        &mut deltas,
975        "parse_error_formula_count",
976        fp2a.parse_error_formula_count,
977        report.parse_error_formula_count,
978        "unexpected_delta",
979    );
980    push_delta(
981        &mut deltas,
982        "unsupported_formula_count",
983        fp2a.unsupported_formula_count,
984        report.unsupported_formula_count,
985        "unexpected_delta",
986    );
987    push_delta(
988        &mut deltas,
989        "dynamic_formula_count",
990        fp2a.dynamic_formula_count,
991        report.dynamic_formula_count,
992        "unexpected_delta",
993    );
994    push_delta(
995        &mut deltas,
996        "volatile_formula_count",
997        fp2a.volatile_formula_count,
998        report.volatile_formula_count,
999        "unexpected_delta",
1000    );
1001    let run_reason = if report.overlap_dropped_count > 0 {
1002        "fp2b_overlap_deduplicates_cells"
1003    } else if report.rejected_formula_cell_count > 0 {
1004        "fp2b_excludes_rejected_cells_from_runs"
1005    } else if report.singleton_run_count > 0 {
1006        "fp2b_stores_supported_singletons_as_runs"
1007    } else {
1008        "unexpected_delta"
1009    };
1010    push_delta(
1011        &mut deltas,
1012        "row_run_count",
1013        fp2a.row_run_count,
1014        report.row_run_count,
1015        run_reason,
1016    );
1017    push_delta(
1018        &mut deltas,
1019        "column_run_count",
1020        fp2a.column_run_count,
1021        report.column_run_count,
1022        run_reason,
1023    );
1024    push_delta(
1025        &mut deltas,
1026        "candidate_formula_run_count",
1027        fp2a.candidate_formula_run_count,
1028        report.row_run_count + report.column_run_count,
1029        run_reason,
1030    );
1031    push_delta(
1032        &mut deltas,
1033        "formula_cells_represented_by_runs",
1034        fp2a.formula_cells_represented_by_runs,
1035        report.formula_cells_represented_by_runs,
1036        run_reason,
1037    );
1038    push_delta(
1039        &mut deltas,
1040        "singleton_formula_count",
1041        fp2a.singleton_formula_count,
1042        report.singleton_run_count,
1043        run_reason,
1044    );
1045    push_delta(
1046        &mut deltas,
1047        "hole_count",
1048        fp2a.hole_count,
1049        report.hole_count,
1050        "fp2a_axis_gaps_vs_fp2b_coordinate_gaps",
1051    );
1052    push_delta(
1053        &mut deltas,
1054        "exception_count",
1055        fp2a.exception_count,
1056        report.exception_count,
1057        "fp2a_axis_gaps_vs_fp2b_coordinate_gaps",
1058    );
1059    push_delta(
1060        &mut deltas,
1061        "candidate_row_block_partition_count",
1062        fp2a.candidate_row_block_partition_count,
1063        report.candidate_row_block_partition_count,
1064        run_reason,
1065    );
1066    push_delta(
1067        &mut deltas,
1068        "candidate_formula_run_to_partition_edge_estimate",
1069        fp2a.candidate_formula_run_to_partition_edge_estimate,
1070        report.candidate_formula_run_to_partition_edge_estimate,
1071        run_reason,
1072    );
1073    push_delta(
1074        &mut deltas,
1075        "max_partitions_touched_by_run",
1076        fp2a.max_partitions_touched_by_run,
1077        report.max_partitions_touched_by_run,
1078        run_reason,
1079    );
1080
1081    Fp2aReconciliation {
1082        matched: deltas.is_empty(),
1083        deltas,
1084    }
1085}
1086
1087fn push_delta(
1088    deltas: &mut Vec<Fp2aCounterDelta>,
1089    field: &'static str,
1090    fp2a_value: u64,
1091    span_store_value: u64,
1092    reason: &'static str,
1093) {
1094    if fp2a_value != span_store_value {
1095        deltas.push(Fp2aCounterDelta {
1096            field,
1097            fp2a_value: fp2a_value as i64,
1098            span_store_value: span_store_value as i64,
1099            reason,
1100        });
1101    }
1102}
1103
1104fn compare_run_key(a: &PendingRun, b: &PendingRun) -> std::cmp::Ordering {
1105    (
1106        a.template_id,
1107        &a.sheet,
1108        shape_order(a.shape),
1109        a.row_start,
1110        a.col_start,
1111        a.row_end,
1112        a.col_end,
1113    )
1114        .cmp(&(
1115            b.template_id,
1116            &b.sheet,
1117            shape_order(b.shape),
1118            b.row_start,
1119            b.col_start,
1120            b.row_end,
1121            b.col_end,
1122        ))
1123}
1124
1125fn shape_order(shape: FormulaRunShape) -> u8 {
1126    match shape {
1127        FormulaRunShape::Row => 0,
1128        FormulaRunShape::Column => 1,
1129        FormulaRunShape::Singleton => 2,
1130    }
1131}
1132
1133fn row_block_index(row: u32, row_block_size: u32) -> u32 {
1134    row.saturating_sub(1) / row_block_size.max(1)
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140
1141    fn cell(sheet: &str, row: u32, col: u32, template_id: &str) -> FormulaPlaneCandidateCell {
1142        FormulaPlaneCandidateCell {
1143            sheet: sheet.to_string(),
1144            row,
1145            col,
1146            template_id: template_id.to_string(),
1147            parse_ok: true,
1148            volatile: false,
1149            dynamic: false,
1150            unsupported: false,
1151        }
1152    }
1153
1154    fn default_cell(row: u32, col: u32, template_id: &str) -> FormulaPlaneCandidateCell {
1155        cell("Sheet1", row, col, template_id)
1156    }
1157
1158    fn rejected(
1159        row: u32,
1160        col: u32,
1161        template_id: &str,
1162        parse_ok: bool,
1163        unsupported: bool,
1164        dynamic: bool,
1165        volatile: bool,
1166    ) -> FormulaPlaneCandidateCell {
1167        FormulaPlaneCandidateCell {
1168            parse_ok,
1169            unsupported,
1170            dynamic,
1171            volatile,
1172            ..default_cell(row, col, template_id)
1173        }
1174    }
1175
1176    fn build(cells: Vec<FormulaPlaneCandidateCell>) -> FormulaRunStore {
1177        FormulaRunStore::build_with_options(
1178            &cells,
1179            FormulaRunStoreBuildOptions {
1180                row_block_size: 4,
1181                ..FormulaRunStoreBuildOptions::default()
1182            },
1183        )
1184    }
1185
1186    fn shuffled(mut cells: Vec<FormulaPlaneCandidateCell>) -> Vec<FormulaPlaneCandidateCell> {
1187        let len = cells.len();
1188        if len <= 1 {
1189            return cells;
1190        }
1191        let mut out = Vec::with_capacity(len);
1192        for index in (1..len).step_by(2) {
1193            out.push(cells[index].clone());
1194        }
1195        for index in (0..len).rev().step_by(2) {
1196            out.push(cells[index].clone());
1197        }
1198        cells.clear();
1199        out
1200    }
1201
1202    #[test]
1203    fn deterministic_template_ids() {
1204        let cells = vec![
1205            default_cell(1, 1, "b"),
1206            default_cell(1, 2, "a"),
1207            default_cell(1, 3, "c"),
1208        ];
1209        let reversed = cells.iter().cloned().rev().collect::<Vec<_>>();
1210        let shuffled = shuffled(cells.clone());
1211
1212        for input in [cells, reversed, shuffled] {
1213            let store = build(input);
1214            let ids = store
1215                .arena
1216                .templates
1217                .iter()
1218                .map(|template| (template.source_template_id.as_str(), template.id.0))
1219                .collect::<Vec<_>>();
1220            assert_eq!(ids, vec![("a", 0), ("b", 1), ("c", 2)]);
1221        }
1222    }
1223
1224    #[test]
1225    fn deterministic_run_ids_for_shuffled_input() {
1226        let cells = vec![
1227            default_cell(1, 1, "a"),
1228            default_cell(2, 1, "a"),
1229            default_cell(3, 1, "a"),
1230            default_cell(5, 2, "b"),
1231            default_cell(5, 3, "b"),
1232            default_cell(5, 4, "b"),
1233            default_cell(8, 8, "c"),
1234        ];
1235        let expected = build(cells.clone());
1236        assert_eq!(expected, build(cells.iter().cloned().rev().collect()));
1237        assert_eq!(expected, build(shuffled(cells)));
1238        assert_eq!(
1239            expected.runs.iter().map(|run| run.id.0).collect::<Vec<_>>(),
1240            vec![0, 1, 2]
1241        );
1242    }
1243
1244    #[test]
1245    fn column_run_basic() {
1246        let store = build((1..=4).map(|row| default_cell(row, 2, "tpl")).collect());
1247        assert_eq!(store.runs.len(), 1);
1248        let run = &store.runs[0];
1249        assert_eq!(run.shape, FormulaRunShape::Column);
1250        assert_eq!(
1251            (run.row_start, run.col_start, run.row_end, run.col_end),
1252            (1, 2, 4, 2)
1253        );
1254        assert_eq!((run.row_block_start, run.row_block_end), (0, 0));
1255        assert!(store.gaps.is_empty());
1256    }
1257
1258    #[test]
1259    fn row_run_basic() {
1260        let store = build((2..=5).map(|col| default_cell(3, col, "tpl")).collect());
1261        assert_eq!(store.runs.len(), 1);
1262        let run = &store.runs[0];
1263        assert_eq!(run.shape, FormulaRunShape::Row);
1264        assert_eq!(
1265            (run.row_start, run.col_start, run.row_end, run.col_end),
1266            (3, 2, 3, 5)
1267        );
1268        assert_eq!((run.row_block_start, run.row_block_end), (0, 0));
1269    }
1270
1271    #[test]
1272    fn singleton_supported_cell() {
1273        let store = build(vec![default_cell(7, 9, "tpl")]);
1274        assert_eq!(store.runs.len(), 1);
1275        assert_eq!(store.runs[0].shape, FormulaRunShape::Singleton);
1276        assert_eq!(store.runs[0].len, 1);
1277        assert!(store.rejected_cells.is_empty());
1278    }
1279
1280    #[test]
1281    fn hole_splits_run() {
1282        let store = build(vec![
1283            default_cell(1, 1, "tpl"),
1284            default_cell(2, 1, "tpl"),
1285            default_cell(4, 1, "tpl"),
1286            default_cell(5, 1, "tpl"),
1287        ]);
1288        assert_eq!(store.runs.len(), 2);
1289        assert!(
1290            store
1291                .runs
1292                .iter()
1293                .all(|run| run.shape == FormulaRunShape::Column)
1294        );
1295        assert_eq!(store.gaps.len(), 1);
1296        assert_eq!(
1297            store.gaps[0],
1298            SpanGapDescriptor {
1299                template_id: FormulaTemplateId(0),
1300                sheet: "Sheet1".to_string(),
1301                row: 3,
1302                col: 1,
1303                kind: SpanGapKind::Hole,
1304            }
1305        );
1306    }
1307
1308    #[test]
1309    fn exception_splits_run() {
1310        let store = build(vec![
1311            default_cell(1, 1, "a"),
1312            default_cell(2, 1, "a"),
1313            default_cell(3, 1, "b"),
1314            default_cell(4, 1, "a"),
1315        ]);
1316        assert_eq!(store.runs.len(), 3);
1317        assert_eq!(store.gaps.len(), 1);
1318        assert_eq!(
1319            store.gaps[0].kind,
1320            SpanGapKind::Exception {
1321                other_template_id: FormulaTemplateId(1)
1322            }
1323        );
1324        assert_eq!((store.gaps[0].row, store.gaps[0].col), (3, 1));
1325    }
1326
1327    #[test]
1328    fn rejected_parse_error() {
1329        let store = build(vec![rejected(1, 1, "tpl", false, false, false, false)]);
1330        assert!(store.runs.is_empty());
1331        assert_eq!(store.rejected_cells.len(), 1);
1332        assert_eq!(
1333            store.rejected_cells[0].reason,
1334            FormulaRejectReason::ParseError
1335        );
1336        assert_eq!(store.report.parse_error_formula_count, 1);
1337    }
1338
1339    #[test]
1340    fn rejected_unsupported_dynamic_volatile_order() {
1341        let store = build(vec![
1342            rejected(1, 1, "parse", false, true, true, true),
1343            rejected(2, 1, "unsupported", true, true, true, true),
1344            rejected(3, 1, "dynamic", true, false, true, true),
1345            rejected(4, 1, "volatile", true, false, false, true),
1346        ]);
1347        let reasons = store
1348            .rejected_cells
1349            .iter()
1350            .map(|cell| cell.reason)
1351            .collect::<Vec<_>>();
1352        assert_eq!(
1353            reasons,
1354            vec![
1355                FormulaRejectReason::ParseError,
1356                FormulaRejectReason::Unsupported,
1357                FormulaRejectReason::Dynamic,
1358                FormulaRejectReason::Volatile,
1359            ]
1360        );
1361    }
1362
1363    #[test]
1364    fn rejected_inside_supported_span() {
1365        let store = build(vec![
1366            default_cell(1, 1, "a"),
1367            rejected(2, 1, "b", false, false, false, false),
1368            default_cell(3, 1, "a"),
1369        ]);
1370        assert_eq!(store.rejected_cells.len(), 1);
1371        assert_eq!(store.gaps.len(), 1);
1372        assert_eq!(
1373            store.gaps[0].kind,
1374            SpanGapKind::Exception {
1375                other_template_id: FormulaTemplateId(1)
1376            }
1377        );
1378        assert_eq!(store.report.hole_count, 0);
1379        assert_eq!(store.report.exception_count, 1);
1380    }
1381
1382    #[test]
1383    fn overlap_dedup_longer_run_wins() {
1384        let store = build(vec![
1385            default_cell(3, 1, "tpl"),
1386            default_cell(3, 2, "tpl"),
1387            default_cell(3, 3, "tpl"),
1388            default_cell(3, 4, "tpl"),
1389            default_cell(1, 2, "tpl"),
1390            default_cell(2, 2, "tpl"),
1391            default_cell(4, 2, "tpl"),
1392        ]);
1393        let row_runs = store
1394            .runs
1395            .iter()
1396            .filter(|run| run.shape == FormulaRunShape::Row)
1397            .count();
1398        assert_eq!(row_runs, 1);
1399        assert!(
1400            store
1401                .runs
1402                .iter()
1403                .any(|run| run.shape == FormulaRunShape::Row && run.len == 4)
1404        );
1405        assert_eq!(store.report.overlap_dropped_count, 1);
1406        assert_eq!(store.runs.iter().map(|run| run.len).sum::<u64>(), 7);
1407    }
1408
1409    #[test]
1410    fn rectangle_deferred() {
1411        let cells = (1..=2)
1412            .flat_map(|row| (1..=3).map(move |col| default_cell(row, col, "tpl")))
1413            .collect::<Vec<_>>();
1414        let store = build(cells);
1415        assert_eq!(store.report.rectangle_deferred_count, 1);
1416        assert_eq!(store.report.row_run_count, 2);
1417        assert_eq!(store.report.column_run_count, 0);
1418        assert!(
1419            store
1420                .runs
1421                .iter()
1422                .all(|run| run.shape != FormulaRunShape::Singleton)
1423        );
1424    }
1425
1426    #[test]
1427    fn fp2a_reconciliation_dense_vertical() {
1428        let cells = (1..=10)
1429            .map(|row| default_cell(row, 2, "tpl"))
1430            .collect::<Vec<_>>();
1431        let store = build(cells);
1432        assert_eq!(store.report.column_run_count, 1);
1433        assert_eq!(store.report.candidate_row_block_partition_count, 3);
1434        assert_eq!(
1435            store
1436                .report
1437                .candidate_formula_run_to_partition_edge_estimate,
1438            3
1439        );
1440        assert_eq!(store.report.max_partitions_touched_by_run, 3);
1441        assert!(store.report.reconciliation.matched);
1442        assert!(store.report.reconciliation.deltas.is_empty());
1443    }
1444
1445    #[test]
1446    fn multi_sheet_determinism() {
1447        let cells = [
1448            cell("Alpha", 1, 1, "tpl"),
1449            cell("Alpha", 2, 1, "tpl"),
1450            cell("Beta", 4, 3, "tpl"),
1451            cell("Beta", 4, 4, "tpl"),
1452            rejected(9, 9, "bad", false, false, false, false),
1453        ];
1454        let mut beta_bad = cells[4].clone();
1455        beta_bad.sheet = "Beta".to_string();
1456        let mut input = cells[..4].to_vec();
1457        input.push(beta_bad);
1458
1459        let expected = build(input.clone());
1460        assert_eq!(expected, build(input.iter().cloned().rev().collect()));
1461        assert_eq!(expected, build(shuffled(input)));
1462        assert_eq!(expected.arena.templates[1].source_template_id, "tpl");
1463        assert_eq!(expected.runs[0].sheet, "Alpha");
1464        assert_eq!(expected.runs[1].sheet, "Beta");
1465    }
1466
1467    #[test]
1468    fn template_status_mixed_for_supported_and_rejected_same_source() {
1469        let store = build(vec![
1470            default_cell(1, 1, "tpl"),
1471            rejected(2, 1, "tpl", false, false, false, false),
1472        ]);
1473        assert_eq!(store.arena.templates.len(), 1);
1474        assert_eq!(
1475            store.arena.templates[0].status,
1476            TemplateSupportStatus::Mixed
1477        );
1478        assert_eq!(store.runs.len(), 1);
1479        assert_eq!(store.rejected_cells.len(), 1);
1480    }
1481
1482    #[test]
1483    fn empty_input() {
1484        let store = build(Vec::new());
1485        assert!(store.arena.templates.is_empty());
1486        assert!(store.runs.is_empty());
1487        assert!(store.gaps.is_empty());
1488        assert!(store.rejected_cells.is_empty());
1489        assert_eq!(store.report.formula_cell_count, 0);
1490        assert!(store.report.reconciliation.matched);
1491    }
1492
1493    #[test]
1494    fn single_unsupported_template_only() {
1495        let store = build(vec![rejected(1, 1, "tpl", true, true, false, false)]);
1496        assert_eq!(store.arena.templates.len(), 1);
1497        assert_eq!(
1498            store.arena.templates[0].status,
1499            TemplateSupportStatus::Unsupported
1500        );
1501        assert!(store.runs.is_empty());
1502        assert_eq!(store.rejected_cells.len(), 1);
1503    }
1504
1505    #[test]
1506    fn row_block_size_normalization() {
1507        let cells = vec![default_cell(1, 1, "tpl"), default_cell(2, 1, "tpl")];
1508        let store = FormulaRunStore::build_with_options(
1509            &cells,
1510            FormulaRunStoreBuildOptions {
1511                row_block_size: 0,
1512                ..FormulaRunStoreBuildOptions::default()
1513            },
1514        );
1515        assert_eq!(store.row_block_size, 1);
1516        assert_eq!(store.runs[0].row_block_start, 0);
1517        assert_eq!(store.runs[0].row_block_end, 1);
1518    }
1519}