1use 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}