Formualizer Docs
Guides

Circular References and Runtime Cycle Detection

Resolve guarded and routing-switch cycles to values with runtime cycle detection, while real cycles still surface

By default, Formualizer treats every statically circular group of cells as an error: it stamps each member #CIRC! the moment the dependency graph closes a loop. That is correct for genuine circular references, but it is too aggressive for a common class of spreadsheet that is only structurally circular — the live data path never actually loops.

Runtime cycle detection (RFC #112) keeps the static cycle as a candidate, then watches the edges that actually fire during evaluation. If the live edges are acyclic, the cells get ordinary values; only cells that participate in a real, live cycle receive #CIRC!.

The problem: guarded and routing-switch cycles

Consider the classic guarded pair from discussion #99:

A1 = TRUE              (a boolean guard)
A2 = IF(A1, 555, A3)
A3 = IF(A1, A2, 999)

A2 references A3 and A3 references A2, so the static graph has a cycle. But for any concrete value of the guard, only one branch of each IF is live:

  • A1 = TRUE → A2 takes 555, A3 takes A2 → both resolve to 555.
  • A1 = FALSE → A2 takes A3, A3 takes 999 → both resolve to 999.

The same shape shows up in routing switches (a CHOOSE/SWITCH that selects one of several mutually-referencing branches) and in many financial models. Under static detection these all collapse to #CIRC!, even though no value ever depends on itself.

Static vs Runtime detection

CycleDetection has two modes:

ModeBehaviorDefault
StaticEvery static SCC is stamped #CIRC! on sight. No live-edge machinery runs.Yes
RuntimeStatic SCCs are candidates; members evaluate with live-edge recording, and only live cycles get the policy verdict.Opt-in

Static remains the default, byte-for-byte compatible with prior releases. Runtime is strictly opt-in.

Under Runtime, a static SCC whose live edges turn out to be acyclic is called a phantom cycle. Phantom cycles produce ordinary values; they are not errors and do not count toward cycle telemetry.

Try it

Toggle the guard cell and watch the values flip. Switch the detection mode to Static to see the same workbook collapse to #CIRC!.

CellValueNotes
A1 (guard)TRUEboolean input
A2555=IF(A1,555,A3)
A3555=IF(A1,A2,999)

Under runtime detection, only the live edges count — the guarded pair is a phantom cycle and resolves to a value. Flip to static detection (today's default) and the same SCC is stamped #CIRC! on sight.

Configuration

Runtime detection is set on the engine's cycle config. The policy for live cycles stays Error here — we are only changing how candidates are confirmed, not what happens to real cycles.

Enable runtime cycle detection
use formualizer_eval::engine::{CycleConfig, CycleDetection, CyclePolicy, EvalConfig};use formualizer_workbook::{Workbook, WorkbookConfig};let mut cfg = WorkbookConfig::interactive();cfg.eval = cfg.eval.with_cycle(CycleConfig {  detection: CycleDetection::Runtime,  policy: CyclePolicy::Error, // live cycles still produce #CIRC!});let mut wb = Workbook::new_with_config(cfg);wb.add_sheet("Sheet1")?;wb.set_value("Sheet1", 1, 1, true.into())?;            // A1 guardwb.set_formula("Sheet1", 2, 1, "=IF(A1,555,A3)")?;     // A2wb.set_formula("Sheet1", 3, 1, "=IF(A1,A2,999)")?;     // A3wb.evaluate_all()?;assert_eq!(wb.evaluate_cell("Sheet1", 2, 1)?, 555.0.into());

In the WASM binding, cycle config is accepted only through the load entry points (fromJsonWithOptions, fromXlsxBytesWithOptions). The plain new Workbook() constructor always uses the default (Static / Error). Build a JSON or XLSX workbook and load it with options to opt into runtime detection.

What changes (and what does not)

  • Phantom cycles produce values. A static SCC whose live edges are acyclic resolves to ordinary values.
  • Real cycles still get #CIRC!. A genuinely live cycle (for example A1 = B1 + 1, B1 = A1 + 1) is confined to its live-cycle members and stamped #CIRC!.
  • Blast radius is the live cycle, not the static SCC. Only cells on the live loop are stamped. A member of the static SCC that is not on the live loop keeps its value.
  • Direct self-reference is still rejected at edit time. Writing =A1+1 into A1 is refused when the formula is set ("Self-reference detected") in both modes; runtime detection does not relax that ingest rule. (Iterative calculation is the exception — see the iterative calculation guide.)
  • Determinism is preserved. Members are evaluated in a stable order, so a given workbook + guard state always produces the same values.

Telemetry

Under runtime detection the engine records per-recalc cycle counters. The Python binding exposes them via Workbook.last_cycle_telemetry():

t = wb.last_cycle_telemetry()
print(t.static_sccs)          # static SCCs considered this recalc
print(t.phantom_sccs)         # SCCs that turned out acyclic at runtime
print(t.live_cycles_witnessed)
print(t.circ_cells_stamped)   # cells stamped #CIRC!

The WASM binding does not expose telemetry today; its cycle surface is config-only.

On this page