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
23impl DependencyGraph {
24    pub(crate) fn compressed_range_dependents_intersecting_deleted_rows(
25        &self,
26        sheet_id: SheetId,
27        start_row: u32,
28        end_row: u32,
29    ) -> Vec<VertexId> {
30        self.formula_to_range_deps
31            .iter()
32            .filter_map(|(&dependent, ranges)| {
33                ranges
34                    .iter()
35                    .any(|range| {
36                        let range_sheet_id = match range.sheet {
37                            SharedSheetLocator::Id(id) => id,
38                            // Formula analysis normalizes ingested locators to Id, so this
39                            // fallback is unreachable today; match the sibling query semantics.
40                            _ => sheet_id,
41                        };
42                        let range_start = range.start_row.map(|bound| bound.index).unwrap_or(0);
43                        let range_end = range.end_row.map(|bound| bound.index).unwrap_or(u32::MAX);
44                        range_sheet_id == sheet_id
45                            && range_start <= end_row
46                            && range_end >= start_row
47                    })
48                    .then_some(dependent)
49            })
50            .collect()
51    }
52
53    pub(crate) fn compressed_range_dependents_intersecting_deleted_columns(
54        &self,
55        sheet_id: SheetId,
56        start_col: u32,
57        end_col: u32,
58    ) -> Vec<VertexId> {
59        self.formula_to_range_deps
60            .iter()
61            .filter_map(|(&dependent, ranges)| {
62                ranges
63                    .iter()
64                    .any(|range| {
65                        let range_sheet_id = match range.sheet {
66                            SharedSheetLocator::Id(id) => id,
67                            // Formula analysis normalizes ingested locators to Id, so this
68                            // fallback is unreachable today; match the sibling query semantics.
69                            _ => sheet_id,
70                        };
71                        let range_start = range.start_col.map(|bound| bound.index).unwrap_or(0);
72                        let range_end = range.end_col.map(|bound| bound.index).unwrap_or(u32::MAX);
73                        range_sheet_id == sheet_id
74                            && range_start <= end_col
75                            && range_end >= start_col
76                    })
77                    .then_some(dependent)
78            })
79            .collect()
80    }
81
82    /// Visit compressed-range formula dependents covering one cell without
83    /// materializing the stripe union used by dirty propagation.
84    ///
85    /// This path is intentionally parallel to
86    /// `collect_range_dependents_for_rect`: scheduling keeps its existing
87    /// behavior, while inspection can stop before a pathological stripe has
88    /// been copied into an unbounded candidate set. Work is charged for every
89    /// stripe candidate and every compressed range exact-check.
90    pub(crate) fn visit_range_dependents_covering_bounded(
91        &self,
92        sheet_id: SheetId,
93        row0: u32,
94        col0: u32,
95        remaining_work: &mut u64,
96        visitor: &mut dyn FnMut(VertexId) -> bool,
97    ) -> bool {
98        if self.stripe_to_dependents.is_empty() {
99            return true;
100        }
101
102        let mut seen = FxHashSet::default();
103        let keys = [
104            StripeKey {
105                sheet_id,
106                stripe_type: StripeType::Column,
107                index: col0,
108            },
109            StripeKey {
110                sheet_id,
111                stripe_type: StripeType::Row,
112                index: row0,
113            },
114            StripeKey {
115                sheet_id,
116                stripe_type: StripeType::Block,
117                index: block_index(row0, col0),
118            },
119        ];
120
121        for key in keys {
122            if key.stripe_type == StripeType::Block && !self.config.enable_block_stripes {
123                continue;
124            }
125            let Some(candidates) = self.stripe_to_dependents.get(&key) else {
126                continue;
127            };
128            for &dependent in candidates {
129                if *remaining_work == 0 {
130                    return false;
131                }
132                *remaining_work -= 1;
133                if !seen.insert(dependent) {
134                    continue;
135                }
136                let Some(ranges) = self.formula_to_range_deps.get(&dependent) else {
137                    continue;
138                };
139                let mut covered = false;
140                for range in ranges {
141                    if *remaining_work == 0 {
142                        return false;
143                    }
144                    *remaining_work -= 1;
145                    // Match collect_range_dependents_for_rect: unresolved
146                    // non-Id locators are interpreted on the query sheet.
147                    let range_sheet = match range.sheet {
148                        SharedSheetLocator::Id(id) => id,
149                        _ => sheet_id,
150                    };
151                    if range_sheet != sheet_id {
152                        continue;
153                    }
154                    let start_row = range.start_row.map(|bound| bound.index).unwrap_or(0);
155                    let end_row = range.end_row.map(|bound| bound.index).unwrap_or(u32::MAX);
156                    let start_col = range.start_col.map(|bound| bound.index).unwrap_or(0);
157                    let end_col = range.end_col.map(|bound| bound.index).unwrap_or(u32::MAX);
158                    if start_row <= row0 && row0 <= end_row && start_col <= col0 && col0 <= end_col
159                    {
160                        covered = true;
161                        break;
162                    }
163                }
164                if covered && !visitor(dependent) {
165                    return false;
166                }
167            }
168        }
169        true
170    }
171
172    /// Public wrapper to add range-dependent edges.
173    pub fn add_range_edges(
174        &mut self,
175        dependent: VertexId,
176        ranges: &[SharedRangeRef<'static>],
177        current_sheet_id: SheetId,
178    ) {
179        self.add_range_dependent_edges(dependent, ranges, current_sheet_id);
180    }
181
182    /// Return the compressed range dependencies recorded for a formula vertex, if any.
183    /// These are `SharedRangeRef` entries that were not expanded into explicit
184    /// cell edges due to `range_expansion_limit` or due to infinite/partial bounds.
185    pub fn get_range_dependencies(
186        &self,
187        vertex: VertexId,
188    ) -> Option<&Vec<SharedRangeRef<'static>>> {
189        self.formula_to_range_deps.get(&vertex)
190    }
191
192    #[cfg(test)]
193    pub(crate) fn formula_to_range_deps(
194        &self,
195    ) -> &FxHashMap<VertexId, Vec<SharedRangeRef<'static>>> {
196        &self.formula_to_range_deps
197    }
198
199    #[cfg(test)]
200    pub(crate) fn stripe_to_dependents(&self) -> &FxHashMap<StripeKey, FxHashSet<VertexId>> {
201        &self.stripe_to_dependents
202    }
203
204    /// True when a (possibly open-ended) range region on `sheet_id` covers
205    /// the formula vertex's own cell. Used to record a self-loop for
206    /// stripe-compressed / whole-axis self-inclusion (#120): such references
207    /// never produce explicit cell edges, so the ingest self-reference check
208    /// (which scans expanded cell deps) misses them. `None` bounds mean the
209    /// axis is unbounded (whole column/row), which always covers the cell.
210    fn range_region_contains_self(
211        &self,
212        dependent: VertexId,
213        sheet_id: SheetId,
214        s_row: Option<u32>,
215        e_row: Option<u32>,
216        s_col: Option<u32>,
217        e_col: Option<u32>,
218    ) -> bool {
219        if self.store.sheet_id(dependent) != sheet_id {
220            return false;
221        }
222        let coord = self.store.coord(dependent);
223        let r0 = coord.row();
224        let c0 = coord.col();
225        s_row.is_none_or(|s| r0 >= s)
226            && e_row.is_none_or(|e| r0 <= e)
227            && s_col.is_none_or(|s| c0 >= s)
228            && e_col.is_none_or(|e| c0 <= e)
229    }
230
231    /// Record a self-loop edge (vertex → itself). The edge store and Tarjan
232    /// both treat self-loops as cycles (`separate_cycles` via `has_self_loop`).
233    fn record_self_loop(&mut self, vertex: VertexId) {
234        if !self.has_self_loop(vertex) {
235            self.edges.add_edge(vertex, vertex);
236        }
237    }
238
239    pub(crate) fn compressed_range_resolved_bounds(
240        &self,
241        sheet: SheetId,
242        range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
243    ) -> Option<(u32, u32, u32, u32)> {
244        let (start_row, end_row, start_col, end_col) = range;
245        let extent = resolve_used_extent(
246            OpenRangeBounds {
247                start_row,
248                start_column: start_col,
249                end_row,
250                end_column: end_col,
251            },
252            ExtentPolicy::GraphCompat {
253                fallback_row: self.config.max_open_ended_rows.saturating_sub(1),
254                fallback_column: self.config.max_open_ended_cols.saturating_sub(1),
255            },
256            |first, last| self.used_row_bounds_for_columns(sheet, first, last),
257            |first, last| self.used_col_bounds_for_rows(sheet, first, last),
258        )?;
259        Some((
260            extent.start_row,
261            extent.end_row,
262            extent.start_column,
263            extent.end_column,
264        ))
265    }
266
267    /// Classify whether every occurrence of one compressed range that covers
268    /// the formula cell is narrowed away from that cell by a statically
269    /// resolvable `INDEX`. The range dependency itself remains conservative so
270    /// used-bound growth still invalidates the formula; only the synthetic #120
271    /// self-loop is omitted when the selected reference cannot contain the
272    /// formula cell.
273    fn compressed_range_self_use(
274        &self,
275        dependent: VertexId,
276        range_sheet: SheetId,
277        range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
278    ) -> RangeSelfUse {
279        let Some(ast) = self.get_formula(dependent) else {
280            return RangeSelfUse::IncludedOrUnknown;
281        };
282
283        fn static_index(node: &ASTNode) -> Option<i64> {
284            match &node.node_type {
285                ASTNodeType::Literal(LiteralValue::Int(value)) => Some(*value),
286                ASTNodeType::Literal(LiteralValue::Number(value)) if value.is_finite() => {
287                    Some(*value as i64)
288                }
289                ASTNodeType::UnaryOp { op, expr } if op == "+" => static_index(expr),
290                ASTNodeType::UnaryOp { op, expr } if op == "-" => static_index(expr)?.checked_neg(),
291                _ => None,
292            }
293        }
294
295        fn matching_range(
296            graph: &DependencyGraph,
297            node: &ASTNode,
298            dependent: VertexId,
299            range_sheet: SheetId,
300            range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
301        ) -> bool {
302            let ASTNodeType::Reference {
303                reference:
304                    ReferenceType::Range {
305                        sheet,
306                        start_row,
307                        start_col,
308                        end_row,
309                        end_col,
310                        ..
311                    },
312                ..
313            } = &node.node_type
314            else {
315                return false;
316            };
317            let sheet_id = match sheet.as_deref() {
318                Some(name) => match graph.sheet_id(name) {
319                    Some(id) => id,
320                    None => return false,
321                },
322                None => graph.get_vertex_sheet_id(dependent),
323            };
324            sheet_id == range_sheet
325                && start_row.map(|index| index.saturating_sub(1)) == range.0
326                && end_row.map(|index| index.saturating_sub(1)) == range.1
327                && start_col.map(|index| index.saturating_sub(1)) == range.2
328                && end_col.map(|index| index.saturating_sub(1)) == range.3
329        }
330
331        fn selected_region_contains_self(
332            graph: &DependencyGraph,
333            dependent: VertexId,
334            range_sheet: SheetId,
335            range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
336            position: i64,
337            explicit_col: Option<i64>,
338        ) -> Option<bool> {
339            let (sr, er, sc, ec) = graph.compressed_range_resolved_bounds(range_sheet, range)?;
340            let (row, col) = match explicit_col {
341                Some(col) => (position, col),
342                None if sr == er => (1, position),
343                None => (position, 1),
344            };
345            if row < 0 || col < 0 {
346                return Some(false);
347            }
348            let coord = graph.store.coord(dependent);
349            let contains = if row == 0 && col == 0 {
350                coord.row() >= sr && coord.row() <= er && coord.col() >= sc && coord.col() <= ec
351            } else if col == 0 {
352                let selected_row = sr.checked_add(u32::try_from(row).ok()?.saturating_sub(1))?;
353                selected_row <= er
354                    && coord.row() == selected_row
355                    && coord.col() >= sc
356                    && coord.col() <= ec
357            } else if row == 0 {
358                let selected_col = sc.checked_add(u32::try_from(col).ok()?.saturating_sub(1))?;
359                selected_col <= ec
360                    && coord.col() == selected_col
361                    && coord.row() >= sr
362                    && coord.row() <= er
363            } else {
364                let selected_row = sr.checked_add(u32::try_from(row).ok()?.saturating_sub(1))?;
365                let selected_col = sc.checked_add(u32::try_from(col).ok()?.saturating_sub(1))?;
366                selected_row <= er
367                    && selected_col <= ec
368                    && coord.row() == selected_row
369                    && coord.col() == selected_col
370            };
371            Some(contains)
372        }
373
374        fn visit(
375            graph: &DependencyGraph,
376            node: &ASTNode,
377            dependent: VertexId,
378            range_sheet: SheetId,
379            range: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
380            index: Option<(i64, Option<i64>)>,
381        ) -> RangeSelfUse {
382            if matching_range(graph, node, dependent, range_sheet, range) {
383                return match index.and_then(|(row, col)| {
384                    selected_region_contains_self(graph, dependent, range_sheet, range, row, col)
385                }) {
386                    Some(false) => RangeSelfUse::Excluded,
387                    Some(true) | None => RangeSelfUse::IncludedOrUnknown,
388                };
389            }
390            match &node.node_type {
391                ASTNodeType::Function { name, args }
392                    if name.eq_ignore_ascii_case("INDEX") && (2..=3).contains(&args.len()) =>
393                {
394                    let row = static_index(&args[1]);
395                    let col = args.get(2).and_then(static_index);
396                    let selection = row.and_then(|row| {
397                        if args.len() == 2 || col.is_some() {
398                            Some((row, col))
399                        } else {
400                            None
401                        }
402                    });
403                    let mut use_kind =
404                        visit(graph, &args[0], dependent, range_sheet, range, selection);
405                    for arg in &args[1..] {
406                        use_kind =
407                            use_kind.merge(visit(graph, arg, dependent, range_sheet, range, None));
408                    }
409                    use_kind
410                }
411                ASTNodeType::Function { args, .. } => {
412                    args.iter().fold(RangeSelfUse::NoMatch, |kind, arg| {
413                        kind.merge(visit(graph, arg, dependent, range_sheet, range, None))
414                    })
415                }
416                ASTNodeType::UnaryOp { expr, .. } => {
417                    visit(graph, expr, dependent, range_sheet, range, None)
418                }
419                ASTNodeType::BinaryOp { left, right, .. } => visit(
420                    graph,
421                    left,
422                    dependent,
423                    range_sheet,
424                    range,
425                    None,
426                )
427                .merge(visit(graph, right, dependent, range_sheet, range, None)),
428                ASTNodeType::Call { callee, args } => {
429                    let mut kind = visit(graph, callee, dependent, range_sheet, range, None);
430                    for arg in args {
431                        kind = kind.merge(visit(graph, arg, dependent, range_sheet, range, None));
432                    }
433                    kind
434                }
435                ASTNodeType::Array(rows) => {
436                    rows.iter()
437                        .flatten()
438                        .fold(RangeSelfUse::NoMatch, |kind, item| {
439                            kind.merge(visit(graph, item, dependent, range_sheet, range, None))
440                        })
441                }
442                ASTNodeType::Literal(_) | ASTNodeType::Omitted | ASTNodeType::Reference { .. } => {
443                    RangeSelfUse::NoMatch
444                }
445            }
446        }
447
448        visit(self, &ast, dependent, range_sheet, range, None)
449    }
450
451    pub(super) fn add_range_dependent_edges(
452        &mut self,
453        dependent: VertexId,
454        ranges: &[SharedRangeRef<'static>],
455        current_sheet_id: SheetId,
456    ) {
457        if ranges.is_empty() {
458            return;
459        }
460
461        self.formula_to_range_deps
462            .insert(dependent, ranges.to_vec());
463
464        for range in ranges {
465            let sheet_id = match range.sheet {
466                SharedSheetLocator::Id(id) => id,
467                _ => current_sheet_id,
468            };
469
470            let s_row = range.start_row.map(|b| b.index);
471            let e_row = range.end_row.map(|b| b.index);
472            let s_col = range.start_col.map(|b| b.index);
473            let e_col = range.end_col.map(|b| b.index);
474
475            // #120: a compressed range whose region covers this formula's own
476            // cell is a self-reference. Record a self-loop so SCC detection
477            // flags the cycle (the ingest self-ref check only sees expanded
478            // cell edges, which compressed ranges do not produce).
479            if self.range_region_contains_self(dependent, sheet_id, s_row, e_row, s_col, e_col)
480                && self.compressed_range_self_use(dependent, sheet_id, (s_row, e_row, s_col, e_col))
481                    != RangeSelfUse::Excluded
482            {
483                self.record_self_loop(dependent);
484            }
485
486            let col_stripes = (s_row.is_none() && e_row.is_none())
487                || (s_col.is_some() && e_col.is_some() && (s_row.is_none() || e_row.is_none()));
488            let row_stripes = (s_col.is_none() && e_col.is_none())
489                || (s_row.is_some() && e_row.is_some() && (s_col.is_none() || e_col.is_none()));
490
491            if col_stripes && !row_stripes {
492                let sc = s_col.unwrap_or(0);
493                let ec = e_col.unwrap_or(sc);
494                for col in sc..=ec {
495                    let key = StripeKey {
496                        sheet_id,
497                        stripe_type: StripeType::Column,
498                        index: col,
499                    };
500                    self.stripe_to_dependents
501                        .entry(key.clone())
502                        .or_default()
503                        .insert(dependent);
504                    #[cfg(test)]
505                    {
506                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
507                            && let Ok(mut g) = self.instr.lock()
508                        {
509                            g.stripe_inserts += 1;
510                        }
511                    }
512                }
513                continue;
514            }
515
516            if row_stripes && !col_stripes {
517                let sr = s_row.unwrap_or(0);
518                let er = e_row.unwrap_or(sr);
519                for row in sr..=er {
520                    let key = StripeKey {
521                        sheet_id,
522                        stripe_type: StripeType::Row,
523                        index: row,
524                    };
525                    self.stripe_to_dependents
526                        .entry(key.clone())
527                        .or_default()
528                        .insert(dependent);
529                    #[cfg(test)]
530                    {
531                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
532                            && let Ok(mut g) = self.instr.lock()
533                        {
534                            g.stripe_inserts += 1;
535                        }
536                    }
537                }
538                continue;
539            }
540
541            let start_row = s_row.unwrap_or(0);
542            let start_col = s_col.unwrap_or(0);
543            let end_row = e_row.unwrap_or(start_row);
544            let end_col = e_col.unwrap_or(start_col);
545
546            let height = end_row.saturating_sub(start_row) + 1;
547            let width = end_col.saturating_sub(start_col) + 1;
548
549            if self.config.enable_block_stripes && height > 1 && width > 1 {
550                let start_block_row = start_row / BLOCK_H;
551                let end_block_row = end_row / BLOCK_H;
552                let start_block_col = start_col / BLOCK_W;
553                let end_block_col = end_col / BLOCK_W;
554
555                for block_row in start_block_row..=end_block_row {
556                    for block_col in start_block_col..=end_block_col {
557                        let key = StripeKey {
558                            sheet_id,
559                            stripe_type: StripeType::Block,
560                            index: block_index(block_row * BLOCK_H, block_col * BLOCK_W),
561                        };
562                        self.stripe_to_dependents
563                            .entry(key.clone())
564                            .or_default()
565                            .insert(dependent);
566                        #[cfg(test)]
567                        {
568                            if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
569                                && let Ok(mut g) = self.instr.lock()
570                            {
571                                g.stripe_inserts += 1;
572                            }
573                        }
574                    }
575                }
576            } else if height > width {
577                for col in start_col..=end_col {
578                    let key = StripeKey {
579                        sheet_id,
580                        stripe_type: StripeType::Column,
581                        index: col,
582                    };
583                    self.stripe_to_dependents
584                        .entry(key.clone())
585                        .or_default()
586                        .insert(dependent);
587                    #[cfg(test)]
588                    {
589                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
590                            && let Ok(mut g) = self.instr.lock()
591                        {
592                            g.stripe_inserts += 1;
593                        }
594                    }
595                }
596            } else {
597                for row in start_row..=end_row {
598                    let key = StripeKey {
599                        sheet_id,
600                        stripe_type: StripeType::Row,
601                        index: row,
602                    };
603                    self.stripe_to_dependents
604                        .entry(key.clone())
605                        .or_default()
606                        .insert(dependent);
607                    #[cfg(test)]
608                    {
609                        if self.stripe_to_dependents.get(&key).map(|s| s.len()) == Some(1)
610                            && let Ok(mut g) = self.instr.lock()
611                        {
612                            g.stripe_inserts += 1;
613                        }
614                    }
615                }
616            }
617        }
618    }
619
620    /// Fast-path: add range dependencies using compact RangeKey.
621    pub fn add_range_deps_from_keys(
622        &mut self,
623        dependent: VertexId,
624        keys: &[crate::engine::plan::RangeKey],
625        current_sheet_id: SheetId,
626    ) {
627        use crate::engine::plan::RangeKey as RK;
628        if keys.is_empty() {
629            return;
630        }
631
632        let mut shared_ranges: Vec<SharedRangeRef<'static>> = Vec::with_capacity(keys.len());
633        for k in keys {
634            let sheet_loc = SharedSheetLocator::Id(match k {
635                RK::Rect { sheet, .. }
636                | RK::WholeRow { sheet, .. }
637                | RK::WholeCol { sheet, .. }
638                | RK::OpenRect { sheet, .. } => *sheet,
639            });
640
641            let mk_axis = |idx0: u32| formualizer_common::AxisBound::new(idx0, false);
642
643            let built = match k {
644                RK::Rect { start, end, .. } => {
645                    let sr = mk_axis(start.row());
646                    let sc = mk_axis(start.col());
647                    let er = mk_axis(end.row());
648                    let ec = mk_axis(end.col());
649                    SharedRangeRef::from_parts(sheet_loc, Some(sr), Some(sc), Some(er), Some(ec))
650                        .ok()
651                }
652                RK::WholeRow { row, .. } => {
653                    let r0 = row.saturating_sub(1);
654                    let b = mk_axis(r0);
655                    SharedRangeRef::from_parts(sheet_loc, Some(b), None, Some(b), None).ok()
656                }
657                RK::WholeCol { col, .. } => {
658                    let c0 = col.saturating_sub(1);
659                    let b = mk_axis(c0);
660                    SharedRangeRef::from_parts(sheet_loc, None, Some(b), None, Some(b)).ok()
661                }
662                RK::OpenRect { start, end, .. } => {
663                    let (sr, sc) = match start {
664                        Some(p) => (Some(mk_axis(p.row())), Some(mk_axis(p.col()))),
665                        None => (None, None),
666                    };
667                    let (er, ec) = match end {
668                        Some(p) => (Some(mk_axis(p.row())), Some(mk_axis(p.col()))),
669                        None => (None, None),
670                    };
671                    SharedRangeRef::from_parts(sheet_loc, sr, sc, er, ec).ok()
672                }
673            };
674
675            if let Some(r) = built {
676                shared_ranges.push(r.into_owned());
677            }
678        }
679
680        if shared_ranges.is_empty() {
681            return;
682        }
683
684        self.formula_to_range_deps
685            .insert(dependent, shared_ranges.clone());
686
687        for range in &shared_ranges {
688            let sheet_id = match range.sheet {
689                SharedSheetLocator::Id(id) => id,
690                _ => current_sheet_id,
691            };
692
693            let s_row = range.start_row.map(|b| b.index);
694            let e_row = range.end_row.map(|b| b.index);
695            let s_col = range.start_col.map(|b| b.index);
696            let e_col = range.end_col.map(|b| b.index);
697
698            // #120: see add_range_dependent_edges — compressed range covering
699            // the formula's own cell records a self-loop for SCC detection.
700            if self.range_region_contains_self(dependent, sheet_id, s_row, e_row, s_col, e_col)
701                && self.compressed_range_self_use(dependent, sheet_id, (s_row, e_row, s_col, e_col))
702                    != RangeSelfUse::Excluded
703            {
704                self.record_self_loop(dependent);
705            }
706
707            let col_stripes = (s_row.is_none() && e_row.is_none())
708                || (s_col.is_some() && e_col.is_some() && (s_row.is_none() || e_row.is_none()));
709            let row_stripes = (s_col.is_none() && e_col.is_none())
710                || (s_row.is_some() && e_row.is_some() && (s_col.is_none() || e_col.is_none()));
711
712            if col_stripes && !row_stripes {
713                let sc = s_col.unwrap_or(0);
714                let ec = e_col.unwrap_or(sc);
715                for col in sc..=ec {
716                    let key = StripeKey {
717                        sheet_id,
718                        stripe_type: StripeType::Column,
719                        index: col,
720                    };
721                    self.stripe_to_dependents
722                        .entry(key)
723                        .or_default()
724                        .insert(dependent);
725                }
726                continue;
727            }
728
729            if row_stripes && !col_stripes {
730                let sr = s_row.unwrap_or(0);
731                let er = e_row.unwrap_or(sr);
732                for row in sr..=er {
733                    let key = StripeKey {
734                        sheet_id,
735                        stripe_type: StripeType::Row,
736                        index: row,
737                    };
738                    self.stripe_to_dependents
739                        .entry(key)
740                        .or_default()
741                        .insert(dependent);
742                }
743                continue;
744            }
745
746            let start_row = s_row.unwrap_or(0);
747            let start_col = s_col.unwrap_or(0);
748            let end_row = e_row.unwrap_or(start_row);
749            let end_col = e_col.unwrap_or(start_col);
750
751            let height = end_row.saturating_sub(start_row) + 1;
752            let width = end_col.saturating_sub(start_col) + 1;
753
754            if self.config.enable_block_stripes && height > 1 && width > 1 {
755                let start_block_row = start_row / BLOCK_H;
756                let end_block_row = end_row / BLOCK_H;
757                let start_block_col = start_col / BLOCK_W;
758                let end_block_col = end_col / BLOCK_W;
759
760                for block_row in start_block_row..=end_block_row {
761                    for block_col in start_block_col..=end_block_col {
762                        let key = StripeKey {
763                            sheet_id,
764                            stripe_type: StripeType::Block,
765                            index: block_index(block_row * BLOCK_H, block_col * BLOCK_W),
766                        };
767                        self.stripe_to_dependents
768                            .entry(key)
769                            .or_default()
770                            .insert(dependent);
771                    }
772                }
773            } else if height > width {
774                for col in start_col..=end_col {
775                    let key = StripeKey {
776                        sheet_id,
777                        stripe_type: StripeType::Column,
778                        index: col,
779                    };
780                    self.stripe_to_dependents
781                        .entry(key)
782                        .or_default()
783                        .insert(dependent);
784                }
785            } else {
786                for row in start_row..=end_row {
787                    let key = StripeKey {
788                        sheet_id,
789                        stripe_type: StripeType::Row,
790                        index: row,
791                    };
792                    self.stripe_to_dependents
793                        .entry(key)
794                        .or_default()
795                        .insert(dependent);
796                }
797            }
798        }
799    }
800}