Skip to main content

formualizer_eval/engine/graph/
range_deps.rs

1use super::*;
2use crate::engine::used_extent::{ExtentPolicy, OpenRangeBounds, resolve_used_extent};
3use formualizer_common::LiteralValue;
4use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType};
5
6#[derive(Clone, Copy, PartialEq, Eq)]
7enum RangeSelfUse {
8    NoMatch,
9    Excluded,
10    IncludedOrUnknown,
11}
12
13impl RangeSelfUse {
14    fn merge(self, other: Self) -> Self {
15        match (self, other) {
16            (Self::IncludedOrUnknown, _) | (_, Self::IncludedOrUnknown) => Self::IncludedOrUnknown,
17            (Self::Excluded, _) | (_, Self::Excluded) => Self::Excluded,
18            _ => Self::NoMatch,
19        }
20    }
21}
22
23#[derive(Clone, Copy, Debug)]
24pub(crate) enum StructuralEdit {
25    InsertRows { before: u32 },
26    DeleteRows { start: u32, end: u32 },
27    InsertColumns { before: u32 },
28    DeleteColumns { start: u32, end: u32 },
29}
30
31#[derive(Clone, Debug, Default)]
32pub(crate) struct StructuralOccupancy {
33    occupied_rows: Vec<u32>,
34    occupied_columns: Vec<u32>,
35    conservative: bool,
36}
37
38impl StructuralOccupancy {
39    pub(crate) fn conservative() -> Self {
40        Self {
41            conservative: true,
42            ..Self::default()
43        }
44    }
45
46    fn finish(&mut self) {
47        self.occupied_rows.sort_unstable();
48        self.occupied_rows.dedup();
49        self.occupied_columns.sort_unstable();
50        self.occupied_columns.dedup();
51    }
52
53    pub(crate) fn include_arrow_sheet(&mut self, sheet: &crate::arrow_store::ArrowSheet) {
54        let shapes = sheet.shape();
55        for (col, column) in sheet.columns.iter().enumerate() {
56            let shape_occupied = shapes.get(col).is_some_and(|shape| {
57                shape.has_num || shape.has_bool || shape.has_text || shape.has_err
58            });
59            let sparse_meta_occupied = column.sparse_chunks.values().any(|chunk| {
60                chunk.meta.non_null_num > 0
61                    || chunk.meta.non_null_bool > 0
62                    || chunk.meta.non_null_text > 0
63                    || chunk.meta.non_null_err > 0
64            });
65            let overlay_occupied = column
66                .chunks
67                .iter()
68                .chain(column.sparse_chunks.values())
69                .any(|chunk| {
70                    chunk.overlay.iter().next().is_some()
71                        || chunk.computed_overlay.iter().next().is_some()
72                });
73            if shape_occupied || sparse_meta_occupied || overlay_occupied {
74                self.occupied_columns.push(col as u32);
75            }
76        }
77        self.finish();
78    }
79
80    fn intersects(sorted: &[u32], start: u32, end: u32) -> bool {
81        let index = sorted.partition_point(|value| *value < start);
82        sorted.get(index).is_some_and(|value| *value <= end)
83    }
84
85    fn cross_axis_occupied(self_ref: &Self, edit: StructuralEdit, start: u32, end: u32) -> bool {
86        if self_ref.conservative {
87            return true;
88        }
89        match edit {
90            StructuralEdit::InsertRows { .. } | StructuralEdit::DeleteRows { .. } => {
91                Self::intersects(&self_ref.occupied_columns, start, end)
92            }
93            StructuralEdit::InsertColumns { .. } | StructuralEdit::DeleteColumns { .. } => {
94                Self::intersects(&self_ref.occupied_rows, start, end)
95            }
96        }
97    }
98}
99
100impl DependencyGraph {
101    pub(crate) fn has_compressed_range_dependencies(&self) -> bool {
102        !self.formula_to_range_deps.is_empty()
103    }
104
105    pub(crate) fn structural_occupancy(&self, sheet_id: SheetId) -> StructuralOccupancy {
106        let mut occupancy = StructuralOccupancy::default();
107        for (id, coord) in self.grid_vertices_in_sheet(sheet_id) {
108            if self.store.kind(id) != VertexKind::Empty {
109                occupancy.occupied_rows.push(coord.row());
110                occupancy.occupied_columns.push(coord.col());
111            }
112        }
113        occupancy.finish();
114        occupancy
115    }
116
117    pub(crate) fn compressed_range_dependents_for_structural_edit(
118        &self,
119        sheet_id: SheetId,
120        edit: StructuralEdit,
121        occupancy: &StructuralOccupancy,
122    ) -> Vec<VertexId> {
123        self.formula_to_range_deps
124            .iter()
125            .filter_map(|(&dependent, ranges)| {
126                ranges
127                    .iter()
128                    .any(|range| {
129                        // `Current` is the dependent formula's own sheet. An
130                        // unresolvable sheet keeps the candidate conservative.
131                        let range_sheet_id = self
132                            .sheet_reg
133                            .resolve_locator(&range.sheet, self.get_vertex_sheet_id(dependent))
134                            .ok();
135                        if range_sheet_id.is_some_and(|resolved| resolved != sheet_id) {
136                            return false;
137                        }
138                        let start_row = range.start_row.map(|bound| bound.index).unwrap_or(0);
139                        let end_row = range.end_row.map(|bound| bound.index).unwrap_or(u32::MAX);
140                        let start_col = range.start_col.map(|bound| bound.index).unwrap_or(0);
141                        let end_col = range.end_col.map(|bound| bound.index).unwrap_or(u32::MAX);
142                        let axis_matches = match edit {
143                            StructuralEdit::DeleteRows { start, end } => {
144                                start_row <= end && end_row >= start
145                            }
146                            StructuralEdit::InsertRows { before } => {
147                                (range.start_row.is_none() || start_row < before)
148                                    && before <= end_row
149                            }
150                            StructuralEdit::DeleteColumns { start, end } => {
151                                start_col <= end && end_col >= start
152                            }
153                            StructuralEdit::InsertColumns { before } => {
154                                (range.start_col.is_none() || start_col < before)
155                                    && before <= end_col
156                            }
157                        };
158                        let (cross_start, cross_end) = match edit {
159                            StructuralEdit::InsertRows { .. }
160                            | StructuralEdit::DeleteRows { .. } => (start_col, end_col),
161                            StructuralEdit::InsertColumns { .. }
162                            | StructuralEdit::DeleteColumns { .. } => (start_row, end_row),
163                        };
164                        axis_matches
165                            && (range_sheet_id.is_none()
166                                // An unresolvable sheet candidate must remain
167                                // conservative; occupancy from the edited sheet
168                                // cannot prove that candidate empty.
169                                || StructuralOccupancy::cross_axis_occupied(
170                                    occupancy,
171                                    edit,
172                                    cross_start,
173                                    cross_end,
174                                ))
175                    })
176                    .then_some(dependent)
177            })
178            .collect()
179    }
180
181    /// Visit compressed-range formula dependents covering one cell without
182    /// materializing the stripe union used by dirty propagation.
183    ///
184    /// This path is intentionally parallel to
185    /// `collect_range_dependents_for_rect`: scheduling keeps its existing
186    /// behavior, while inspection can stop before a pathological stripe has
187    /// been copied into an unbounded candidate set. Work is charged for every
188    /// stripe candidate and every compressed range exact-check.
189    pub(crate) fn visit_range_dependents_covering_bounded(
190        &self,
191        sheet_id: SheetId,
192        row0: u32,
193        col0: u32,
194        remaining_work: &mut u64,
195        visitor: &mut dyn FnMut(VertexId) -> bool,
196    ) -> bool {
197        if self.stripe_to_dependents.is_empty() {
198            return true;
199        }
200
201        let mut seen = FxHashSet::default();
202        let keys = [
203            StripeKey {
204                sheet_id,
205                stripe_type: StripeType::Column,
206                index: col0,
207            },
208            StripeKey {
209                sheet_id,
210                stripe_type: StripeType::Row,
211                index: row0,
212            },
213            StripeKey {
214                sheet_id,
215                stripe_type: StripeType::Block,
216                index: block_index(row0, col0),
217            },
218        ];
219
220        for key in keys {
221            if key.stripe_type == StripeType::Block && !self.config.enable_block_stripes {
222                continue;
223            }
224            let Some(candidates) = self.stripe_to_dependents.get(&key) else {
225                continue;
226            };
227            for &dependent in candidates {
228                if *remaining_work == 0 {
229                    return false;
230                }
231                *remaining_work -= 1;
232                if !seen.insert(dependent) {
233                    continue;
234                }
235                let Some(ranges) = self.formula_to_range_deps.get(&dependent) else {
236                    continue;
237                };
238                let mut covered = false;
239                for range in ranges {
240                    if *remaining_work == 0 {
241                        return false;
242                    }
243                    *remaining_work -= 1;
244                    // `Current` is the dependent formula's own sheet; an
245                    // unresolvable sheet name is interpreted on the query sheet
246                    // so the dependent is not silently dropped.
247                    let range_sheet = self
248                        .sheet_reg
249                        .resolve_locator(&range.sheet, self.get_vertex_sheet_id(dependent))
250                        .unwrap_or(sheet_id);
251                    if range_sheet != sheet_id {
252                        continue;
253                    }
254                    let start_row = range.start_row.map(|bound| bound.index).unwrap_or(0);
255                    let end_row = range.end_row.map(|bound| bound.index).unwrap_or(u32::MAX);
256                    let start_col = range.start_col.map(|bound| bound.index).unwrap_or(0);
257                    let end_col = range.end_col.map(|bound| bound.index).unwrap_or(u32::MAX);
258                    if start_row <= row0 && row0 <= end_row && start_col <= col0 && col0 <= end_col
259                    {
260                        covered = true;
261                        break;
262                    }
263                }
264                if covered && !visitor(dependent) {
265                    return false;
266                }
267            }
268        }
269        true
270    }
271
272    /// Public wrapper to add range-dependent edges.
273    pub fn add_range_edges(
274        &mut self,
275        dependent: VertexId,
276        ranges: &[SharedRangeRef<'static>],
277        current_sheet_id: SheetId,
278    ) {
279        self.add_range_dependent_edges(dependent, ranges, current_sheet_id);
280    }
281
282    /// Return the compressed range dependencies recorded for a formula vertex, if any.
283    /// These are `SharedRangeRef` entries that were not expanded into explicit
284    /// cell edges due to `range_expansion_limit` or due to infinite/partial bounds.
285    pub fn get_range_dependencies(
286        &self,
287        vertex: VertexId,
288    ) -> Option<&Vec<SharedRangeRef<'static>>> {
289        self.formula_to_range_deps.get(&vertex)
290    }
291
292    #[cfg(test)]
293    pub(crate) fn formula_to_range_deps(
294        &self,
295    ) -> &FxHashMap<VertexId, Vec<SharedRangeRef<'static>>> {
296        &self.formula_to_range_deps
297    }
298
299    #[cfg(test)]
300    pub(crate) fn stripe_to_dependents(&self) -> &FxHashMap<StripeKey, FxHashSet<VertexId>> {
301        &self.stripe_to_dependents
302    }
303
304    /// True when a (possibly open-ended) range region on `sheet_id` covers
305    /// the formula vertex's own cell. Used to record a self-loop for
306    /// stripe-compressed / whole-axis self-inclusion (#120): such references
307    /// never produce explicit cell edges, so the ingest self-reference check
308    /// (which scans expanded cell deps) misses them. `None` bounds mean the
309    /// axis is unbounded (whole column/row), which always covers the cell.
310    fn range_region_contains_self(
311        &self,
312        dependent: VertexId,
313        sheet_id: SheetId,
314        s_row: Option<u32>,
315        e_row: Option<u32>,
316        s_col: Option<u32>,
317        e_col: Option<u32>,
318    ) -> bool {
319        if self.store.sheet_id(dependent) != sheet_id {
320            return false;
321        }
322        // A symbol vertex has no position, so no range region can contain it.
323        let Some(coord) = self.store.grid_addr(dependent) else {
324            return false;
325        };
326        let r0 = coord.row();
327        let c0 = coord.col();
328        s_row.is_none_or(|s| r0 >= s)
329            && e_row.is_none_or(|e| r0 <= e)
330            && s_col.is_none_or(|s| c0 >= s)
331            && e_col.is_none_or(|e| c0 <= e)
332    }
333
334    /// Record a self-loop edge (vertex → itself). The edge store and Tarjan
335    /// both treat self-loops as cycles (`separate_cycles` via `has_self_loop`).
336    fn record_self_loop(&mut self, vertex: VertexId) {
337        if !self.has_self_loop(vertex) {
338            self.edges.add_edge(vertex, vertex);
339        }
340    }
341
342    pub(crate) fn compressed_range_resolved_bounds(
343        &self,
344        sheet: SheetId,
345        range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
346    ) -> Option<(u32, u32, u32, u32)> {
347        let (start_row, end_row, start_col, end_col) = range;
348        let extent = resolve_used_extent(
349            OpenRangeBounds {
350                start_row,
351                start_column: start_col,
352                end_row,
353                end_column: end_col,
354            },
355            ExtentPolicy::GraphCompat {
356                fallback_row: self.config.max_open_ended_rows.saturating_sub(1),
357                fallback_column: self.config.max_open_ended_cols.saturating_sub(1),
358            },
359            |first, last| self.used_row_bounds_for_columns(sheet, first, last),
360            |first, last| self.used_col_bounds_for_rows(sheet, first, last),
361        )?;
362        Some((
363            extent.start_row,
364            extent.end_row,
365            extent.start_column,
366            extent.end_column,
367        ))
368    }
369
370    /// Classify whether every occurrence of one compressed range that covers
371    /// the formula cell is narrowed away from that cell by a statically
372    /// resolvable `INDEX`. The range dependency itself remains conservative so
373    /// used-bound growth still invalidates the formula; only the synthetic #120
374    /// self-loop is omitted when the selected reference cannot contain the
375    /// formula cell.
376    fn compressed_range_self_use(
377        &self,
378        dependent: VertexId,
379        range_sheet: SheetId,
380        range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
381    ) -> RangeSelfUse {
382        let Some(ast) = self.get_formula(dependent) else {
383            return RangeSelfUse::IncludedOrUnknown;
384        };
385
386        fn static_index(node: &ASTNode) -> Option<i64> {
387            match &node.node_type {
388                ASTNodeType::Literal(LiteralValue::Int(value)) => Some(*value),
389                ASTNodeType::Literal(LiteralValue::Number(value)) if value.is_finite() => {
390                    Some(*value as i64)
391                }
392                ASTNodeType::UnaryOp { op, expr } if op == "+" => static_index(expr),
393                ASTNodeType::UnaryOp { op, expr } if op == "-" => static_index(expr)?.checked_neg(),
394                _ => None,
395            }
396        }
397
398        fn matching_range(
399            graph: &DependencyGraph,
400            node: &ASTNode,
401            dependent: VertexId,
402            range_sheet: SheetId,
403            range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
404        ) -> bool {
405            let ASTNodeType::Reference {
406                reference:
407                    ReferenceType::Range {
408                        sheet,
409                        start_row,
410                        start_col,
411                        end_row,
412                        end_col,
413                        ..
414                    },
415                ..
416            } = &node.node_type
417            else {
418                return false;
419            };
420            let sheet_id = match sheet.as_deref() {
421                Some(name) => match graph.sheet_id(name) {
422                    Some(id) => id,
423                    None => return false,
424                },
425                None => graph.get_vertex_sheet_id(dependent),
426            };
427            sheet_id == range_sheet
428                && start_row.map(|index| index.saturating_sub(1)) == range.0
429                && end_row.map(|index| index.saturating_sub(1)) == range.1
430                && start_col.map(|index| index.saturating_sub(1)) == range.2
431                && end_col.map(|index| index.saturating_sub(1)) == range.3
432        }
433
434        fn selected_region_contains_self(
435            graph: &DependencyGraph,
436            dependent: VertexId,
437            range_sheet: SheetId,
438            range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
439            position: i64,
440            explicit_col: Option<i64>,
441        ) -> Option<bool> {
442            let (sr, er, sc, ec) = graph.compressed_range_resolved_bounds(range_sheet, range)?;
443            let (row, col) = match explicit_col {
444                Some(col) => (position, col),
445                None if sr == er => (1, position),
446                None => (position, 1),
447            };
448            if row < 0 || col < 0 {
449                return Some(false);
450            }
451            // A symbol vertex has no position, so no range region can contain it.
452            let coord = graph.store.grid_addr(dependent)?;
453            let contains = if row == 0 && col == 0 {
454                coord.row() >= sr && coord.row() <= er && coord.col() >= sc && coord.col() <= ec
455            } else if col == 0 {
456                let selected_row = sr.checked_add(u32::try_from(row).ok()?.saturating_sub(1))?;
457                selected_row <= er
458                    && coord.row() == selected_row
459                    && coord.col() >= sc
460                    && coord.col() <= ec
461            } else if row == 0 {
462                let selected_col = sc.checked_add(u32::try_from(col).ok()?.saturating_sub(1))?;
463                selected_col <= ec
464                    && coord.col() == selected_col
465                    && coord.row() >= sr
466                    && coord.row() <= er
467            } else {
468                let selected_row = sr.checked_add(u32::try_from(row).ok()?.saturating_sub(1))?;
469                let selected_col = sc.checked_add(u32::try_from(col).ok()?.saturating_sub(1))?;
470                selected_row <= er
471                    && selected_col <= ec
472                    && coord.row() == selected_row
473                    && coord.col() == selected_col
474            };
475            Some(contains)
476        }
477
478        fn visit(
479            graph: &DependencyGraph,
480            node: &ASTNode,
481            dependent: VertexId,
482            range_sheet: SheetId,
483            range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
484            index: Option<(i64, Option<i64>)>,
485        ) -> RangeSelfUse {
486            if matching_range(graph, node, dependent, range_sheet, range) {
487                return match index.and_then(|(row, col)| {
488                    selected_region_contains_self(graph, dependent, range_sheet, range, row, col)
489                }) {
490                    Some(false) => RangeSelfUse::Excluded,
491                    Some(true) | None => RangeSelfUse::IncludedOrUnknown,
492                };
493            }
494            match &node.node_type {
495                ASTNodeType::Function { name, args }
496                    if name.eq_ignore_ascii_case("INDEX") && (2..=3).contains(&args.len()) =>
497                {
498                    let row = static_index(&args[1]);
499                    let col = args.get(2).and_then(static_index);
500                    let selection = row.and_then(|row| {
501                        if args.len() == 2 || col.is_some() {
502                            Some((row, col))
503                        } else {
504                            None
505                        }
506                    });
507                    let mut use_kind =
508                        visit(graph, &args[0], dependent, range_sheet, range, selection);
509                    for arg in &args[1..] {
510                        use_kind =
511                            use_kind.merge(visit(graph, arg, dependent, range_sheet, range, None));
512                    }
513                    use_kind
514                }
515                ASTNodeType::Function { args, .. } => {
516                    args.iter().fold(RangeSelfUse::NoMatch, |kind, arg| {
517                        kind.merge(visit(graph, arg, dependent, range_sheet, range, None))
518                    })
519                }
520                ASTNodeType::UnaryOp { expr, .. } => {
521                    visit(graph, expr, dependent, range_sheet, range, None)
522                }
523                ASTNodeType::BinaryOp { left, right, .. } => visit(
524                    graph,
525                    left,
526                    dependent,
527                    range_sheet,
528                    range,
529                    None,
530                )
531                .merge(visit(graph, right, dependent, range_sheet, range, None)),
532                ASTNodeType::Call { callee, args } => {
533                    let mut kind = visit(graph, callee, dependent, range_sheet, range, None);
534                    for arg in args {
535                        kind = kind.merge(visit(graph, arg, dependent, range_sheet, range, None));
536                    }
537                    kind
538                }
539                ASTNodeType::Array(rows) => {
540                    rows.iter()
541                        .flatten()
542                        .fold(RangeSelfUse::NoMatch, |kind, item| {
543                            kind.merge(visit(graph, item, dependent, range_sheet, range, None))
544                        })
545                }
546                ASTNodeType::Literal(_) | ASTNodeType::Omitted | ASTNodeType::Reference { .. } => {
547                    RangeSelfUse::NoMatch
548                }
549            }
550        }
551
552        visit(self, &ast, dependent, range_sheet, range, None)
553    }
554
555    pub(super) fn add_range_dependent_edges(
556        &mut self,
557        dependent: VertexId,
558        ranges: &[SharedRangeRef<'static>],
559        current_sheet_id: SheetId,
560    ) {
561        if ranges.is_empty() {
562            return;
563        }
564
565        self.formula_to_range_deps
566            .insert(dependent, ranges.to_vec());
567
568        for range in ranges {
569            // `current_sheet_id` is the dependent formula's sheet, which is what
570            // `Current` means. An unresolvable sheet name falls back to it so a
571            // stripe is still registered rather than the edge being dropped.
572            let sheet_id = self
573                .sheet_reg
574                .resolve_locator(&range.sheet, current_sheet_id)
575                .unwrap_or(current_sheet_id);
576
577            let s_row = range.start_row.map(|b| b.index);
578            let e_row = range.end_row.map(|b| b.index);
579            let s_col = range.start_col.map(|b| b.index);
580            let e_col = range.end_col.map(|b| b.index);
581
582            // #120: a compressed range whose region covers this formula's own
583            // cell is a self-reference. Record a self-loop so SCC detection
584            // flags the cycle (the ingest self-ref check only sees expanded
585            // cell edges, which compressed ranges do not produce).
586            if self.range_region_contains_self(dependent, sheet_id, s_row, e_row, s_col, e_col)
587                && self.compressed_range_self_use(dependent, sheet_id, (s_row, e_row, s_col, e_col))
588                    != RangeSelfUse::Excluded
589            {
590                self.record_self_loop(dependent);
591            }
592
593            // #376: an all-unbounded range means "the whole sheet". The stripe
594            // classification below would treat it as both column- and
595            // row-striped, fall through both branches, and collapse it to a
596            // single row-0 stripe, hiding edits anywhere else from this
597            // dependent. Register full column coverage instead; the precision
598            // check against `formula_to_range_deps` already treats the missing
599            // bounds as unbounded.
600            if s_row.is_none() && e_row.is_none() && s_col.is_none() && e_col.is_none() {
601                self.register_whole_sheet_stripes(dependent, sheet_id);
602                continue;
603            }
604
605            let col_stripes = (s_row.is_none() && e_row.is_none())
606                || (s_col.is_some() && e_col.is_some() && (s_row.is_none() || e_row.is_none()));
607            let row_stripes = (s_col.is_none() && e_col.is_none())
608                || (s_row.is_some() && e_row.is_some() && (s_col.is_none() || e_col.is_none()));
609
610            if col_stripes && !row_stripes {
611                let sc = s_col.unwrap_or(0);
612                let ec = e_col.unwrap_or(sc);
613                for col in sc..=ec {
614                    let key = StripeKey {
615                        sheet_id,
616                        stripe_type: StripeType::Column,
617                        index: col,
618                    };
619                    self.stripe_to_dependents
620                        .entry(key.clone())
621                        .or_default()
622                        .insert(dependent);
623                    #[cfg(test)]
624                    {
625                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
626                            && let Ok(mut g) = self.instr.lock()
627                        {
628                            g.stripe_inserts += 1;
629                        }
630                    }
631                }
632                continue;
633            }
634
635            if row_stripes && !col_stripes {
636                let sr = s_row.unwrap_or(0);
637                let er = e_row.unwrap_or(sr);
638                for row in sr..=er {
639                    let key = StripeKey {
640                        sheet_id,
641                        stripe_type: StripeType::Row,
642                        index: row,
643                    };
644                    self.stripe_to_dependents
645                        .entry(key.clone())
646                        .or_default()
647                        .insert(dependent);
648                    #[cfg(test)]
649                    {
650                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
651                            && let Ok(mut g) = self.instr.lock()
652                        {
653                            g.stripe_inserts += 1;
654                        }
655                    }
656                }
657                continue;
658            }
659
660            let start_row = s_row.unwrap_or(0);
661            let start_col = s_col.unwrap_or(0);
662            let end_row = e_row.unwrap_or(start_row);
663            let end_col = e_col.unwrap_or(start_col);
664
665            let height = end_row.saturating_sub(start_row) + 1;
666            let width = end_col.saturating_sub(start_col) + 1;
667
668            if self.config.enable_block_stripes && height > 1 && width > 1 {
669                let start_block_row = start_row / BLOCK_H;
670                let end_block_row = end_row / BLOCK_H;
671                let start_block_col = start_col / BLOCK_W;
672                let end_block_col = end_col / BLOCK_W;
673
674                for block_row in start_block_row..=end_block_row {
675                    for block_col in start_block_col..=end_block_col {
676                        let key = StripeKey {
677                            sheet_id,
678                            stripe_type: StripeType::Block,
679                            index: block_index(block_row * BLOCK_H, block_col * BLOCK_W),
680                        };
681                        self.stripe_to_dependents
682                            .entry(key.clone())
683                            .or_default()
684                            .insert(dependent);
685                        #[cfg(test)]
686                        {
687                            if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
688                                && let Ok(mut g) = self.instr.lock()
689                            {
690                                g.stripe_inserts += 1;
691                            }
692                        }
693                    }
694                }
695            } else if height > width {
696                for col in start_col..=end_col {
697                    let key = StripeKey {
698                        sheet_id,
699                        stripe_type: StripeType::Column,
700                        index: col,
701                    };
702                    self.stripe_to_dependents
703                        .entry(key.clone())
704                        .or_default()
705                        .insert(dependent);
706                    #[cfg(test)]
707                    {
708                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
709                            && let Ok(mut g) = self.instr.lock()
710                        {
711                            g.stripe_inserts += 1;
712                        }
713                    }
714                }
715            } else {
716                for row in start_row..=end_row {
717                    let key = StripeKey {
718                        sheet_id,
719                        stripe_type: StripeType::Row,
720                        index: row,
721                    };
722                    self.stripe_to_dependents
723                        .entry(key.clone())
724                        .or_default()
725                        .insert(dependent);
726                    #[cfg(test)]
727                    {
728                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
729                            && let Ok(mut g) = self.instr.lock()
730                        {
731                            g.stripe_inserts += 1;
732                        }
733                    }
734                }
735            }
736        }
737    }
738
739    /// Register stripes covering every cell of a sheet, for a dependent whose
740    /// range is unbounded on both axes (#376). Dirty-propagation lookups probe
741    /// the column stripe of every edited cell, so covering all columns
742    /// guarantees any edit on the sheet reaches the precision check.
743    fn register_whole_sheet_stripes(&mut self, dependent: VertexId, sheet_id: SheetId) {
744        /// Excel sheet column capacity (column XFD), as a 0-based exclusive bound.
745        const SHEET_MAX_COLS: u32 = 16_384;
746        for col in 0..SHEET_MAX_COLS {
747            let key = StripeKey {
748                sheet_id,
749                stripe_type: StripeType::Column,
750                index: col,
751            };
752            self.stripe_to_dependents
753                .entry(key)
754                .or_default()
755                .insert(dependent);
756        }
757    }
758
759    /// Fast-path: add range dependencies using compact RangeKey.
760    pub fn add_range_deps_from_keys(
761        &mut self,
762        dependent: VertexId,
763        keys: &[crate::engine::plan::RangeKey],
764        current_sheet_id: SheetId,
765    ) {
766        use crate::engine::plan::RangeKey as RK;
767        if keys.is_empty() {
768            return;
769        }
770
771        let mut shared_ranges: Vec<SharedRangeRef<'static>> = Vec::with_capacity(keys.len());
772        for k in keys {
773            let sheet_loc = SharedSheetLocator::Id(match k {
774                RK::Rect { sheet, .. }
775                | RK::WholeRow { sheet, .. }
776                | RK::WholeCol { sheet, .. }
777                | RK::OpenRect { sheet, .. } => *sheet,
778            });
779
780            let mk_axis = |idx0: u32| formualizer_common::AxisBound::new(idx0, false);
781
782            let built = match k {
783                RK::Rect { start, end, .. } => {
784                    let sr = mk_axis(start.row());
785                    let sc = mk_axis(start.col());
786                    let er = mk_axis(end.row());
787                    let ec = mk_axis(end.col());
788                    SharedRangeRef::from_parts(sheet_loc, Some(sr), Some(sc), Some(er), Some(ec))
789                        .ok()
790                }
791                RK::WholeRow { row, .. } => {
792                    let r0 = row.saturating_sub(1);
793                    let b = mk_axis(r0);
794                    SharedRangeRef::from_parts(sheet_loc, Some(b), None, Some(b), None).ok()
795                }
796                RK::WholeCol { col, .. } => {
797                    let c0 = col.saturating_sub(1);
798                    let b = mk_axis(c0);
799                    SharedRangeRef::from_parts(sheet_loc, None, Some(b), None, Some(b)).ok()
800                }
801                RK::OpenRect {
802                    start_row,
803                    start_col,
804                    end_row,
805                    end_col,
806                    ..
807                } => SharedRangeRef::from_parts(
808                    sheet_loc,
809                    start_row.map(mk_axis),
810                    start_col.map(mk_axis),
811                    end_row.map(mk_axis),
812                    end_col.map(mk_axis),
813                )
814                .ok(),
815            };
816
817            if let Some(r) = built {
818                shared_ranges.push(r.into_owned());
819            }
820        }
821
822        if shared_ranges.is_empty() {
823            return;
824        }
825
826        self.formula_to_range_deps
827            .insert(dependent, shared_ranges.clone());
828
829        for range in &shared_ranges {
830            // See add_range_dependent_edges.
831            let sheet_id = self
832                .sheet_reg
833                .resolve_locator(&range.sheet, current_sheet_id)
834                .unwrap_or(current_sheet_id);
835
836            let s_row = range.start_row.map(|b| b.index);
837            let e_row = range.end_row.map(|b| b.index);
838            let s_col = range.start_col.map(|b| b.index);
839            let e_col = range.end_col.map(|b| b.index);
840
841            // #120: see add_range_dependent_edges — compressed range covering
842            // the formula's own cell records a self-loop for SCC detection.
843            if self.range_region_contains_self(dependent, sheet_id, s_row, e_row, s_col, e_col)
844                && self.compressed_range_self_use(dependent, sheet_id, (s_row, e_row, s_col, e_col))
845                    != RangeSelfUse::Excluded
846            {
847                self.record_self_loop(dependent);
848            }
849
850            // #376: an all-unbounded range means "the whole sheet". The stripe
851            // classification below would treat it as both column- and
852            // row-striped, fall through both branches, and collapse it to a
853            // single row-0 stripe, hiding edits anywhere else from this
854            // dependent. Register full column coverage instead; the precision
855            // check against `formula_to_range_deps` already treats the missing
856            // bounds as unbounded.
857            if s_row.is_none() && e_row.is_none() && s_col.is_none() && e_col.is_none() {
858                self.register_whole_sheet_stripes(dependent, sheet_id);
859                continue;
860            }
861
862            let col_stripes = (s_row.is_none() && e_row.is_none())
863                || (s_col.is_some() && e_col.is_some() && (s_row.is_none() || e_row.is_none()));
864            let row_stripes = (s_col.is_none() && e_col.is_none())
865                || (s_row.is_some() && e_row.is_some() && (s_col.is_none() || e_col.is_none()));
866
867            if col_stripes && !row_stripes {
868                let sc = s_col.unwrap_or(0);
869                let ec = e_col.unwrap_or(sc);
870                for col in sc..=ec {
871                    let key = StripeKey {
872                        sheet_id,
873                        stripe_type: StripeType::Column,
874                        index: col,
875                    };
876                    self.stripe_to_dependents
877                        .entry(key)
878                        .or_default()
879                        .insert(dependent);
880                }
881                continue;
882            }
883
884            if row_stripes && !col_stripes {
885                let sr = s_row.unwrap_or(0);
886                let er = e_row.unwrap_or(sr);
887                for row in sr..=er {
888                    let key = StripeKey {
889                        sheet_id,
890                        stripe_type: StripeType::Row,
891                        index: row,
892                    };
893                    self.stripe_to_dependents
894                        .entry(key)
895                        .or_default()
896                        .insert(dependent);
897                }
898                continue;
899            }
900
901            let start_row = s_row.unwrap_or(0);
902            let start_col = s_col.unwrap_or(0);
903            let end_row = e_row.unwrap_or(start_row);
904            let end_col = e_col.unwrap_or(start_col);
905
906            let height = end_row.saturating_sub(start_row) + 1;
907            let width = end_col.saturating_sub(start_col) + 1;
908
909            if self.config.enable_block_stripes && height > 1 && width > 1 {
910                let start_block_row = start_row / BLOCK_H;
911                let end_block_row = end_row / BLOCK_H;
912                let start_block_col = start_col / BLOCK_W;
913                let end_block_col = end_col / BLOCK_W;
914
915                for block_row in start_block_row..=end_block_row {
916                    for block_col in start_block_col..=end_block_col {
917                        let key = StripeKey {
918                            sheet_id,
919                            stripe_type: StripeType::Block,
920                            index: block_index(block_row * BLOCK_H, block_col * BLOCK_W),
921                        };
922                        self.stripe_to_dependents
923                            .entry(key)
924                            .or_default()
925                            .insert(dependent);
926                    }
927                }
928            } else if height > width {
929                for col in start_col..=end_col {
930                    let key = StripeKey {
931                        sheet_id,
932                        stripe_type: StripeType::Column,
933                        index: col,
934                    };
935                    self.stripe_to_dependents
936                        .entry(key)
937                        .or_default()
938                        .insert(dependent);
939                }
940            } else {
941                for row in start_row..=end_row {
942                    let key = StripeKey {
943                        sheet_id,
944                        stripe_type: StripeType::Row,
945                        index: row,
946                    };
947                    self.stripe_to_dependents
948                        .entry(key)
949                        .or_default()
950                        .insert(dependent);
951                }
952            }
953        }
954    }
955}