Calc: iterative calculation for circular references (Maximum Iterations, Maximum Change)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
neilcawse
2026-10-11 16:39:31 -04:00
co-authored by Claude Opus 5.5
parent 4dca02c89b
commit fdb74c27dd
10 changed files with 408 additions and 62 deletions
+154 -45
View File
@@ -6,7 +6,9 @@
//! O(log n + matches) however many ranges share the column. After an edit, the dirty set is the
//! transitive closure of dependents; it is evaluated in topological order, with on-demand
//! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative
//! calculation is on.
//! calculation is on: then a formula read while it is being evaluated gives its previous value,
//! and the formulas that read one are evaluated again until the loop settles
//! (`PassHost::iterate_loops`).
use std::collections::{BTreeSet, VecDeque};
use std::hash::{BuildHasherDefault, Hasher};
@@ -58,6 +60,10 @@ const WIDE: u32 = 64;
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
const PENDING: usize = 32;
/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas
/// evaluated again), however high Maximum Iterations is.
const MAX_ITERATION_WORK: usize = 10_000_000;
/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows
/// `a..=b`?" in O(log n + k).
///
@@ -389,6 +395,44 @@ struct PassHost<'a> {
cycle: bool,
cycles: Vec<Key>,
depth: usize,
/// Iterative calculation: a circular reference reads the cell's previous value instead of
/// giving `#CIRC!`.
iterate: bool,
/// Values from the previous iteration; cells not in it read their stored value.
prev: HashMap<Key, Value>,
/// Formulas being evaluated, innermost last (kept only when iterating).
stack: Vec<Key>,
/// Formulas found on a circular reference (when iterating).
members: HashSet<Key>,
/// Results that read a previous value, directly or through other formulas.
tainted: HashSet<Key>,
/// Whether the formula being evaluated has read a previous value.
taint: bool,
}
impl<'a> PassHost<'a> {
fn new(wb: &'a Workbook, exprs: &'a HashMap<Key, std::sync::Arc<Expr>>, rng: &'a mut u64, now: f64) -> Self {
PassHost {
wb,
exprs,
results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()),
pending: exprs.keys().copied().collect(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng,
now,
cycle: false,
cycles: vec![],
depth: 0,
iterate: false,
prev: HashMap::default(),
stack: Vec::new(),
members: HashSet::default(),
tainted: HashSet::default(),
taint: false,
}
}
}
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
@@ -415,18 +459,29 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
impl PassHost<'_> {
fn compute(&mut self, k: Key) -> Value {
if let Some(v) = self.results.get(&k) {
return v.clone();
let v = v.clone();
if self.iterate && self.tainted.contains(&k) {
self.taint = true;
}
return v;
}
let Some(expr) = self.exprs.get(&k) else {
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
};
if self.in_progress.contains(&k) || self.depth > 2000 {
self.cycle = true;
if self.iterate {
return self.circular_read(k);
}
self.cycles.push(k);
return Value::Error(CellError::Circ);
}
self.in_progress.insert(k);
self.depth += 1;
let outer_taint = std::mem::replace(&mut self.taint, false);
if self.iterate {
self.stack.push(k);
}
let expr = std::sync::Arc::clone(expr);
let v = {
let mut ev = Evaluator::new(self, k.0, k.1);
@@ -441,6 +496,15 @@ impl PassHost<'_> {
self.depth -= 1;
self.in_progress.remove(&k);
self.pending.remove(&k);
if self.iterate {
self.stack.pop();
if self.taint {
self.tainted.insert(k);
} else {
self.tainted.remove(&k);
}
}
self.taint |= outer_taint;
// A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`,
// FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells
// of their own, which stay blank.
@@ -472,6 +536,68 @@ impl PassHost<'_> {
self.results.insert(k, v.clone());
v
}
/// A formula's previous value for iterative calculation: from the last iteration, else the
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
fn previous(&self, k: Key) -> Value {
let v = match self.prev.get(&k) {
Some(v) => v.clone(),
None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(),
};
if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v }
}
/// Reading `k` while it is being evaluated, with iterative calculation on: its previous value.
/// The formulas around the loop are the ones whose changes decide when iteration stops.
fn circular_read(&mut self, k: Key) -> Value {
self.taint = true;
match self.stack.iter().rposition(|s| *s == k) {
Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()),
// Too deep to follow: the formula itself is watched.
None => {
self.members.insert(k);
}
}
self.previous(k)
}
/// Iterative calculation, after a first pass over `order` that met circular references. The
/// formulas that read a previous value (the loops and what depends on them) are evaluated
/// again in the same order, each pass starting from the last one's values and reading what it
/// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or
/// more, or after `max_iterations` passes in all. Returns the number of passes.
fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 {
let again: Vec<Key> = order.iter().copied().filter(|k| self.tainted.contains(k)).collect();
let set: HashSet<Key> = again.iter().copied().collect();
// Bounds the work whatever Maximum Iterations says.
let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1);
let mut passes = 1u32;
while passes < max_iterations.min(max_passes) && !self.converged(max_change) {
self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect();
self.pending = set.clone();
self.spills.retain(|a, _| !set.contains(a));
for k in &again {
self.compute(*k);
}
passes += 1;
}
passes
}
/// Whether every formula on a loop changed by less than `max_change` in the last pass (text,
/// logical and error values: not at all).
fn converged(&self, max_change: f64) -> bool {
self.members.iter().all(|k| {
let Some(new) = self.results.get(k) else { return true };
match (new, &self.previous(*k)) {
(Value::Number(a), Value::Number(b)) => {
let d = (a - b).abs();
d < max_change || d == 0.0
}
(new, old) => new == old,
}
})
}
}
/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as
@@ -532,6 +658,9 @@ impl Host for PassHost<'_> {
if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col {
let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize);
if dr < arr.rows && dc < arr.cols {
if self.iterate && self.tainted.contains(anchor) {
self.taint = true;
}
return arr.get(dr, dc).cloned().unwrap_or_default();
}
}
@@ -590,6 +719,18 @@ impl Host for PassHost<'_> {
v
})
.collect();
// Iterating: an array spilled by a formula that read a previous value.
if self.iterate
&& self.spills.iter().any(|(a, arr)| {
let end = CellRef::new(
a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)),
a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)),
);
a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range)
})
{
self.taint = true;
}
for (i, v) in new {
if let Some(slot) = out.get_mut(i) {
*slot = v;
@@ -825,23 +966,17 @@ impl Calc {
let mut rng = self.rng;
let tp = prof_now();
let (results, spills, cycles) = {
let mut host = PassHost {
wb,
exprs: &exprs,
results: HashMap::with_capacity_and_hasher(dirty.len(), Default::default()),
pending: exprs.keys().copied().collect(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now,
cycle: false,
cycles: vec![],
depth: 0,
};
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
host.iterate = wb.calc.iterative;
for k in &dirty {
host.compute(*k);
}
if host.iterate && !host.members.is_empty() {
let passes = host.iterate_loops(&dirty, wb.calc.max_iterations, wb.calc.max_change);
if std::env::var_os("GRIDCRAFT_PROFILE").is_some() {
eprintln!(" iterative calculation: {} passes over {} formulas", passes, host.tainted.len());
}
}
(host.results, host.spills, host.cycles)
};
self.rng = rng;
@@ -961,20 +1096,7 @@ pub fn evaluate(wb: &Workbook, sheet: usize, at: CellRef, formula: &str) -> Valu
pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> Value {
let exprs = HashMap::default();
let mut rng = 0x1234_5678_9ABC_DEF0u64;
let mut host = PassHost {
wb,
exprs: &exprs,
results: HashMap::default(),
pending: HashSet::default(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now: now_serial(),
cycle: false,
cycles: vec![],
depth: 0,
};
let mut host = PassHost::new(wb, &exprs, &mut rng, now_serial());
let mut ev = Evaluator::new(&mut host, sheet, at);
ev.value(expr)
}
@@ -998,7 +1120,7 @@ pub(crate) mod tests {
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula, Workbook};
use super::{Calc, HashMap, HashSet, Host, Intervals, Key, PassHost};
use super::{Calc, HashMap, Host, Intervals, Key, PassHost};
/// xorshift64 for repeatable pseudo-random cases.
fn rng(state: &mut u64) -> u64 {
@@ -1214,20 +1336,7 @@ pub(crate) mod tests {
// of a real pass partway through; `visit_range` has to cope with the formulas it reads
// being calculated under it.
let mut rng = 1u64;
let mut host = PassHost {
wb,
pending: exprs.keys().copied().collect(),
exprs: &exprs,
results: HashMap::default(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now: 0.0,
cycle: false,
cycles: vec![],
depth: 0,
};
let mut host = PassHost::new(wb, &exprs, &mut rng, 0.0);
let mut seen = Vec::new();
host.visit_range(0, range, &mut |v| {
+99
View File
@@ -120,6 +120,105 @@ fn errors_and_cycles() {
assert_eq!(t.get("C2"), Value::Error(CellError::Name));
}
impl T {
fn iterative(max_iterations: u32, max_change: f64) -> T {
let mut t = T::new();
t.wb.calc.iterative = true;
t.wb.calc.set_max_iterations(max_iterations);
t.wb.calc.set_max_change(max_change);
t
}
}
#[test]
fn iterative_calculation_stops_at_maximum_iterations() {
// A1 =A1+1 never settles: every recalculation adds Maximum Iterations.
let mut t = T::iterative(100, 0.001);
t.set("A1", "=A1+1");
assert_eq!(t.num("A1"), 100.0);
assert!(t.calc.circular.is_empty());
// An edit outside the loop doesn't recalculate it.
t.set("B1", "7");
assert_eq!(t.num("A1"), 100.0);
// Each recalculation starts from the value the cell shows.
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), 200.0);
t.wb.calc.set_max_iterations(5);
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), 205.0);
// Through a range that holds the formula itself.
t.set("C1", "1");
t.set("C2", "2");
t.set("C3", "=SUM(C1:C3)");
assert_eq!(t.num("C3"), 15.0);
// Excel's largest Maximum Iterations.
let mut t = T::iterative(32767, 0.001);
t.set("A1", "=A1+1");
assert_eq!(t.num("A1"), 32767.0);
}
#[test]
fn iterative_calculation_converges_within_maximum_change() {
let mut t = T::iterative(100, 0.001);
t.set("A1", "=(A2+10)/2");
t.set("A2", "=A1");
assert!((t.num("A1") - 10.0).abs() < 0.002, "{}", t.num("A1"));
assert!((t.num("A2") - 10.0).abs() < 0.002, "{}", t.num("A2"));
assert!(t.calc.circular.is_empty());
// A loop fed by a constant, and a formula that reads the loop, follow a change.
t.set("D1", "10");
t.set("A1", "=(A2+D1)/2");
t.set("E1", "=A1*2");
t.set("D1", "30");
assert!((t.num("A1") - 30.0).abs() < 0.002, "{}", t.num("A1"));
assert!((t.num("E1") - 60.0).abs() < 0.004, "{}", t.num("E1"));
// Already settled: recalculating changes it by less than Maximum Change.
let a1 = t.num("A1");
t.calc.recalc_all(&mut t.wb);
assert!((t.num("A1") - a1).abs() < 0.001);
}
#[test]
fn iterative_calculation_stops_once_changes_are_below_maximum_change() {
// A1 halves its distance to 10 each pass, from 5: 7.5, 8.75, 9.375, 9.6875, and A2 trails
// one pass behind. Maximum Change 1 stops once neither moved by 1 or more.
let mut t = T::iterative(100, 1.0);
t.set("A1", "=(A2+10)/2");
assert_eq!(t.num("A1"), 5.0);
t.set("A2", "=A1");
assert_eq!((t.num("A1"), t.num("A2")), (9.6875, 9.375));
// Maximum Iterations 2 stops first.
let mut t = T::iterative(2, 1.0);
t.set("A1", "=(A2+10)/2");
t.set("A2", "=A1");
assert_eq!((t.num("A1"), t.num("A2")), (8.75, 7.5));
}
#[test]
fn iterative_calculation_of_text_and_errors() {
let mut t = T::iterative(100, 0.001);
t.set("A1", "=IF(ISTEXT(A1),A1&\"\",\"go\")");
assert_eq!(t.get("A1"), Value::from("go"));
t.set("B1", "=B1+1/0");
assert_eq!(t.get("B1"), Value::Error(CellError::Div0));
}
#[test]
fn circular_references_without_iterative_calculation() {
let mut t = T::new();
t.set("A1", "=A1+1");
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
assert_eq!(t.calc.circular, vec![(0, c("A1"))]);
// Switched on, the loop is calculated from 0 rather than from the error.
t.wb.calc.iterative = true;
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.num("A1"), 100.0);
assert!(t.calc.circular.is_empty());
t.wb.calc.iterative = false;
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
}
#[test]
fn operators() {
let mut t = T::new();
+10 -5
View File
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Calculation Options",
["Formulas", "Calculation"],
None,
"{mode: automatic|automaticExceptTables|manual, iterative?, maxIterations?, maxChange?}",
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
has_doc,
calc_options
),
@@ -546,6 +546,7 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
_ => None,
};
edit(s, |cx| {
let before = cx.wb.calc.clone();
if let Some(m) = mode {
cx.wb.calc.mode = m;
}
@@ -553,14 +554,18 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
cx.wb.calc.iterative = v;
}
if let Some(v) = u32_param(p, "maxIterations") {
cx.wb.calc.max_iterations = v.clamp(1, 32767);
cx.wb.calc.set_max_iterations(v);
}
if let Some(v) = f64_param(p, "maxChange") {
cx.wb.calc.max_change = v.abs();
cx.wb.calc.set_max_change(v);
}
if mode == Some(CalcMode::Automatic) {
let c = &cx.wb.calc;
// Switching to automatic, or changing how circular references are calculated outside
// manual mode, recalculates the workbook.
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
cx.structural = true;
}
Ok(json!({"mode": format!("{:?}", cx.wb.calc.mode)}))
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
})
}
+38
View File
@@ -1495,6 +1495,44 @@ fn reopen(s: &mut Session) {
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
}
#[test]
fn calculation_options_enable_iterative_calculation() {
let mut s = s();
let near = |s: &Session, a: &str, want: f64| {
let got = v(s, a).as_f64().unwrap_or(f64::NAN);
assert!((got - want).abs() < 0.002, "{a} = {:?}", v(s, a));
};
s.execute("cell.set", json!({"cell": "A1", "input": "=(A2+10)/2"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "=A1"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Circ));
assert!(!s.doc().unwrap().calc.circular.is_empty());
// Switching it on recalculates the loop.
let r = s.execute("formulas.calculationOptions", json!({"iterative": true})).unwrap();
assert_eq!(r, json!({"mode": "Automatic", "iterative": true, "maxIterations": 100, "maxChange": 0.001}));
near(&s, "A1", 10.0);
near(&s, "A2", 10.0);
assert!(s.doc().unwrap().calc.circular.is_empty());
// Empty params change nothing and report the settings.
let r = s.execute("formulas.calculationOptions", json!({})).unwrap();
assert_eq!(r["iterative"], true);
// Limits are kept within Excel's: 1–32767 iterations, a non-negative change.
let r = s.execute("formulas.calculationOptions", json!({"maxIterations": 0, "maxChange": -0.5})).unwrap();
assert_eq!((r["maxIterations"].as_u64(), r["maxChange"].as_f64()), (Some(1), Some(0.5)));
let r = s.execute("formulas.calculationOptions", json!({"maxIterations": 100000, "maxChange": 0.0001})).unwrap();
assert_eq!((r["maxIterations"].as_u64(), r["maxChange"].as_f64()), (Some(32767), Some(0.0001)));
// Saved with the workbook.
reopen(&mut s);
let calc = s.doc().unwrap().wb.calc.clone();
assert_eq!((calc.iterative, calc.max_iterations, calc.max_change), (true, 32767, 0.0001));
near(&s, "A1", 10.0);
// Undo brings the previous settings back.
s.execute("formulas.calculationOptions", json!({"iterative": false})).unwrap();
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Circ));
s.execute("edit.undo", json!({})).unwrap();
assert!(s.doc().unwrap().wb.calc.iterative);
near(&s, "A1", 10.0);
}
fn chart_data(s: &mut Session) {
s.execute(
"range.setValues",
+31 -1
View File
@@ -18,11 +18,16 @@ pub enum CalcMode {
Manual,
}
/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CalcSettings {
pub mode: CalcMode,
/// Enable iterative calculation: circular references are calculated repeatedly instead of
/// reporting an error.
pub iterative: bool,
/// Maximum Iterations, 1 to [`CalcSettings::MAX_ITERATIONS`].
pub max_iterations: u32,
/// Maximum Change: iteration stops once no value in a circular reference changes by more.
pub max_change: f64,
/// Use precision as displayed.
pub precision_as_displayed: bool,
@@ -30,7 +35,32 @@ pub struct CalcSettings {
impl Default for CalcSettings {
fn default() -> Self {
CalcSettings { mode: CalcMode::Automatic, iterative: false, max_iterations: 100, max_change: 0.001, precision_as_displayed: false }
CalcSettings {
mode: CalcMode::Automatic,
iterative: false,
max_iterations: Self::DEFAULT_ITERATIONS,
max_change: Self::DEFAULT_CHANGE,
precision_as_displayed: false,
}
}
}
impl CalcSettings {
pub const DEFAULT_ITERATIONS: u32 = 100;
pub const DEFAULT_CHANGE: f64 = 0.001;
/// The largest Maximum Iterations Excel accepts.
pub const MAX_ITERATIONS: u32 = 32_767;
/// Sets Maximum Iterations, kept within 1 to [`CalcSettings::MAX_ITERATIONS`].
pub fn set_max_iterations(&mut self, n: u32) {
self.max_iterations = n.clamp(1, Self::MAX_ITERATIONS);
}
/// Sets Maximum Change (its magnitude); a value that isn't finite is ignored.
pub fn set_max_change(&mut self, d: f64) {
if d.is_finite() {
self.max_change = d.abs();
}
}
}
+2 -2
View File
@@ -176,10 +176,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
};
wb.calc.iterative = c.flag("iterate", false);
if let Some(n) = c.attr_u32("iterateCount") {
wb.calc.max_iterations = n.clamp(1, 32767);
wb.calc.set_max_iterations(n);
}
if let Some(d) = c.attr_f64("iterateDelta") {
wb.calc.max_change = d.abs();
wb.calc.set_max_change(d);
}
wb.calc.precision_as_displayed = !c.flag("fullPrecision", true);
}
+54
View File
@@ -572,6 +572,60 @@ fn package_structure() {
assert!(texts["xl/workbook.xml"].contains("fullCalcOnLoad"));
}
#[test]
fn iterative_calculation_settings_roundtrip() {
let calc_pr = |wb: &Workbook| {
let bytes = write_xlsx(wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap();
let c = book.child("calcPr").unwrap();
let attrs = ["calcMode", "iterate", "iterateCount", "iterateDelta", "fullPrecision"].map(|a| c.attr(a).map(str::to_string));
(attrs, read_xlsx(&bytes).unwrap().0.calc)
};
let s = |v: &str| Some(v.to_string());
// Defaults: nothing written.
let mut wb = Workbook::new();
assert_eq!(calc_pr(&wb), ([None, None, None, None, None], CalcSettings::default()));
// On: the switch and both limits, beside the other calculation settings.
wb.calc.mode = CalcMode::Manual;
wb.calc.iterative = true;
wb.calc.precision_as_displayed = true;
let (attrs, back) = calc_pr(&wb);
assert_eq!(attrs, [s("manual"), s("1"), s("100"), s("0.001"), s("0")]);
assert_eq!(back, wb.calc);
// Off: limits that differ from the defaults are kept for when it is switched on again.
wb.calc.iterative = false;
wb.calc.set_max_iterations(250);
wb.calc.set_max_change(0.0005);
let (attrs, back) = calc_pr(&wb);
assert_eq!(attrs, [s("manual"), None, s("250"), s("0.0005"), s("0")]);
assert_eq!(back, wb.calc);
// Out-of-range values in a file are kept within Excel's limits.
let xml = |attrs: &str| {
format!(
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="A" sheetId="1" r:id="rId1"/></sheets><calcPr {attrs}/></workbook>"#
)
};
for (attrs, count, change) in [
(r#"iterate="1" iterateCount="0" iterateDelta="-2""#, 1, 2.0),
(r#"iterate="true" iterateCount="99999" iterateDelta="NaN""#, CalcSettings::MAX_ITERATIONS, CalcSettings::DEFAULT_CHANGE),
] {
let wbx = xml(attrs);
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>"#;
let ws = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData/></worksheet>"#;
let bytes = super::make_zip(&[
("[Content_Types].xml", super::CT.as_bytes()),
("_rels/.rels", super::ROOT_RELS.as_bytes()),
("xl/workbook.xml", wbx.as_bytes()),
("xl/_rels/workbook.xml.rels", rels.as_bytes()),
("xl/worksheets/sheet1.xml", ws.as_bytes()),
]);
let calc = read_xlsx(&bytes).unwrap().0.calc;
assert!(calc.iterative, "{attrs}");
assert_eq!((calc.max_iterations, calc.max_change), (count, change), "{attrs}");
}
}
#[test]
fn empty_workbook_roundtrip() {
let wb = Workbook::new();
+12 -3
View File
@@ -5,7 +5,7 @@ use std::fmt::Write as _;
use gridcraft_core::RangeRef;
use gridcraft_formula::quote_sheet;
use gridcraft_model::{CalcMode, Style, Visibility, Workbook};
use gridcraft_model::{CalcMode, CalcSettings, Style, Visibility, Workbook};
use crate::IoError;
use crate::package::ZipOut;
@@ -289,8 +289,17 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
CalcMode::Manual => w.push_str(" calcMode=\"manual\""),
CalcMode::AutomaticExceptTables => w.push_str(" calcMode=\"autoNoTable\""),
}
if wb.calc.iterative {
let _ = write!(w, " iterate=\"1\" iterateCount=\"{}\" iterateDelta=\"{}\"", wb.calc.max_iterations, crate::xml::num(wb.calc.max_change));
// CT_CalcPr defaults: iterate off, 100 iterations, 0.001 maximum change. The limits are kept
// while iteration is off, as Options keeps them.
let calc = &wb.calc;
if calc.iterative {
w.push_str(" iterate=\"1\"");
}
if calc.iterative || calc.max_iterations != CalcSettings::DEFAULT_ITERATIONS {
let _ = write!(w, " iterateCount=\"{}\"", calc.max_iterations);
}
if calc.iterative || calc.max_change != CalcSettings::DEFAULT_CHANGE {
let _ = write!(w, " iterateDelta=\"{}\"", crate::xml::num(calc.max_change));
}
if wb.calc.precision_as_displayed {
w.push_str(" fullPrecision=\"0\"");
+3 -2
View File
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (iterative calculation for circular references) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -80,7 +80,7 @@ PIVOTBY, REGEXTEST…) are all present.
| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) |
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
| Iterative calculation for circular references | cycle detection done; the Enable Iterative Calculation setting is stored and saved to XLSX, but the calc engine does not iterate (circular references stay unresolved) |
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
| 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) |
| External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update |
| Localized function names and `;` separators | **missing** (#45); en-US only |
@@ -103,6 +103,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
| 2026-10-11 | minor | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added: Financial 55 / 55; 505 / 523 by name, 496 working |
| 2026-10-11 | minor | PERCENTOF and EUROCONVERT added: 503 / 523 by name, 494 working |
| 2026-10-11 | minor | BAHTTEXT added: 502 / 523 by name, 493 working |
+5 -4
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§3 narrowed by XLSX pass-through, #330) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§6: iterative calculation landed) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -60,9 +60,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
### 6. Calculation correctness bugs (F)
- **Missing:** 15-significant-digit equality; spill updates after structural edits and cleared
blockers; 3-D span deletion; qualified names after sheet rename; iterative calculation.
- **Evidence:** #57, #133, #139, #140, #134; Enable Iterative Calculation stored but unused;
PRs #148, #155, #156, #157.
blockers; 3-D span deletion; qualified names after sheet rename.
- **Evidence:** #57, #133, #139, #140, #134; PRs #148, #155, #156, #157. Iterative calculation
for circular references landed (no Options dialog yet).
- **Impact:** wrong numbers are worse than missing features.
- **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language)
@@ -159,6 +159,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) |
| 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) |
| 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) |
| 2026-10-11 | minor | §3: XLSX pass-through keeps custom XML, external links, connections, query tables, printer settings, controls and OLE objects (#330, #324) |