diff --git a/CHANGELOG.md b/CHANGELOG.md index a99533e71..a1b363450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Added iterative calculation support for circular references. [#1545](https://github.com/handsontable/hyperformula/issues/1545) - Added a new function: IRR. [#1591](https://github.com/handsontable/hyperformula/issues/1591) - Added a new function: N. [#1585](https://github.com/handsontable/hyperformula/issues/1585) - Added a new function: VALUE. [#1592](https://github.com/handsontable/hyperformula/issues/1592) diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 7ec541d84..2ba66b516 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -233,6 +233,7 @@ module.exports = { ['/guide/volatile-functions', 'Volatile functions'], ['/guide/named-expressions', 'Named expressions'], ['/guide/arrays', 'Array formulas'], + ['/guide/iterative-calculation', 'Iterative calculation'], ] }, { diff --git a/docs/guide/iterative-calculation.md b/docs/guide/iterative-calculation.md new file mode 100644 index 000000000..f3eae7a1d --- /dev/null +++ b/docs/guide/iterative-calculation.md @@ -0,0 +1,160 @@ +# Iterative calculation + +Use iterative calculation to resolve circular references in your spreadsheet formulas. + +## What is iterative calculation? + +A **circular reference** occurs when a formula refers back to its own cell, either directly or through a chain of other cells. For example: + +- Direct: `A1` contains `=A1+1` +- Indirect: `A1` contains `=B1+1` and `B1` contains `=A1+1` + +By default, HyperFormula returns a [`#CYCLE!`](types-of-errors.md) error for circular references. However, some spreadsheet models intentionally use circular references for iterative calculations, such as: + +- **Financial modeling**: calculating loan payments where interest depends on the balance +- **Goal seeking**: finding values that satisfy a target condition +- **Numerical methods**: implementing Newton-Raphson or other iterative algorithms +- **Feedback systems**: modeling systems where outputs influence inputs + +When iterative calculation is enabled, HyperFormula repeatedly evaluates the circular formulas until the values stabilize (converge) or a maximum number of iterations is reached. + +## How it works + +HyperFormula uses the **Gauss-Seidel iteration method**: + +1. All cells in the cycle are initialized to a starting value (default: `0`) +2. Cells are evaluated in address order (by sheet, then row, then column) +3. Each cell immediately uses the most recently computed values from other cells +4. After each complete pass, HyperFormula checks if all values have converged +5. Iteration stops when values converge or the maximum iterations are reached + +### Convergence + +A cell is considered **converged** when the change between iterations is below the threshold: + +- **Numeric values**: `|new - old| < threshold` +- **Non-numeric values**: exact equality (strings, booleans, errors) + +All cells in the cycle must converge for iteration to stop early. + +## Enabling iterative calculation + +To enable iterative calculation, set the [`iterativeCalculationEnable`](../api/interfaces/configparams.html#iterativecalculationenable) option to `true`: + +```javascript +const hfInstance = HyperFormula.buildFromArray( + [ + ['=A1+1'], // A1 references itself + ], + { + licenseKey: 'gpl-v3', + iterativeCalculationEnable: true, + } +); + +// With default settings (100 iterations, threshold 0.001, initial value 0): +// A1 will equal 100 after iteration completes +console.log(hfInstance.getCellValue({ sheet: 0, row: 0, col: 0 })); // 100 +``` + +::: tip +Iterative calculation settings are configured at engine initialization and apply to all sheets. +::: + +## Configuration options + +| Option | Type | Default | Description | +|:-------|:-----|:--------|:------------| +| [`iterativeCalculationEnable`](../api/interfaces/configparams.html#iterativecalculationenable) | `boolean` | `false` | Enable iterative calculation for circular references | +| [`iterativeCalculationMaxIterations`](../api/interfaces/configparams.html#iterativecalculationmaxiterations) | `number` | `100` | Maximum number of iterations before stopping | +| [`iterativeCalculationConvergenceThreshold`](../api/interfaces/configparams.html#iterativecalculationconvergencethreshold) | `number` | `0.001` | Values must change by less than this to be considered converged | +| [`iterativeCalculationInitialValue`](../api/interfaces/configparams.html#iterativecalculationinitialvalue) | `number \| string \| boolean \| Date` | `0` | Starting value for cells in circular references | + +## Examples + +### Simple accumulator + +A cell that adds 1 on each iteration: + +```javascript +const hf = HyperFormula.buildFromArray( + [['=A1+1']], + { + licenseKey: 'gpl-v3', + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 50, + } +); + +// A1 = 50 (after 50 iterations starting from 0) +console.log(hf.getCellValue({ sheet: 0, row: 0, col: 0 })); // 50 +``` + +### Converging system + +Two cells that converge to a stable value: + +```javascript +const hf = HyperFormula.buildFromArray( + [ + ['=B1/2 + 1', '=A1/2 + 1'], // A1 and B1 reference each other + ], + { + licenseKey: 'gpl-v3', + iterativeCalculationEnable: true, + iterativeCalculationConvergenceThreshold: 0.0001, + } +); + +// Both cells converge to 2 +console.log(hf.getCellValue({ sheet: 0, row: 0, col: 0 })); // ~2 (A1) +console.log(hf.getCellValue({ sheet: 0, row: 0, col: 1 })); // ~2 (B1) +``` + +### Custom initial value + +Start iteration from a specific value: + +```javascript +const hf = HyperFormula.buildFromArray( + [['=A1*0.5']], // Halves on each iteration + { + licenseKey: 'gpl-v3', + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: 1000, + iterativeCalculationConvergenceThreshold: 0.01, + } +); + +// Converges toward 0, starting from 1000 +console.log(hf.getCellValue({ sheet: 0, row: 0, col: 0 })); // ~0 +``` + +## Behavior notes + +### Non-converging formulas + +::: warning +Some formulas never converge (e.g., `=A1+1` always increases). In these cases, iteration runs until `iterativeCalculationMaxIterations` is reached, and the final value is used. +::: + +### Error handling + +If a formula in the cycle produces an error during iteration (e.g., `#DIV/0!`), the error becomes the cell's final value. Cells depending on error values will propagate the error. + +### Ranges containing cycles + +If a range reference (e.g., `SUM(A1:A10)`) includes cells that are part of a cycle, the range is recalculated on each iteration to reflect the updated values. + +### Recalculation behavior + +When any cell in a circular reference is modified, the entire cycle is re-evaluated from the initial value. Previous converged values are not preserved between recalculations. + +## Compatibility + +This feature is compatible with: + +- **Microsoft Excel**: [Iterative calculation settings](https://support.microsoft.com/en-gb/office/remove-or-allow-a-circular-reference-in-excel-8540bd0f-6e97-4483-bcf7-1b49cd50d123) +- **Google Sheets**: [Iterative calculation settings](https://workspaceupdates.googleblog.com/2016/12/new-iterative-calculation-settings-and.html) + +The default configuration values match Excel's defaults. diff --git a/src/Config.ts b/src/Config.ts index c345c97e9..8f72fb001 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -23,6 +23,7 @@ import {FunctionPluginDefinition} from './interpreter' import {Maybe} from './Maybe' import {ParserConfig} from './parser/ParserConfig' import {ConfigParams, ConfigParamsList} from './ConfigParams' +import { RawCellContent } from '.' const privatePool: WeakMap = new WeakMap() @@ -67,6 +68,10 @@ export class Config implements ConfigParams, ParserConfig { useColumnIndex: false, useStats: false, useArrayArithmetic: false, + iterativeCalculationEnable: false, + iterativeCalculationMaxIterations: 100, + iterativeCalculationConvergenceThreshold: 0.001, + iterativeCalculationInitialValue: 0, } /** @inheritDoc */ @@ -160,6 +165,14 @@ export class Config implements ConfigParams, ParserConfig { public readonly useWildcards: boolean /** @inheritDoc */ public readonly matchWholeCell: boolean + /** @inheritDoc */ + public readonly iterativeCalculationEnable: boolean + /** @inheritDoc */ + public readonly iterativeCalculationMaxIterations: number + /** @inheritDoc */ + public readonly iterativeCalculationConvergenceThreshold: number + /** @inheritDoc */ + public readonly iterativeCalculationInitialValue: RawCellContent constructor(options: Partial = {}, showDeprecatedWarns: boolean = true) { const { @@ -201,6 +214,10 @@ export class Config implements ConfigParams, ParserConfig { useColumnIndex, useRegularExpressions, useWildcards, + iterativeCalculationEnable, + iterativeCalculationMaxIterations, + iterativeCalculationConvergenceThreshold, + iterativeCalculationInitialValue, } = options if (showDeprecatedWarns) { @@ -255,6 +272,14 @@ export class Config implements ConfigParams, ParserConfig { validateNumberToBeAtLeast(this.maxColumns, 'maxColumns', 1) this.context = context + // Iterative calculation config + this.iterativeCalculationEnable = configValueFromParam(iterativeCalculationEnable, 'boolean', 'iterativeCalculationEnable') + this.iterativeCalculationMaxIterations = configValueFromParam(iterativeCalculationMaxIterations, 'number', 'iterativeCalculationMaxIterations') + this.validateIterativeCalculationMaxIterations(this.iterativeCalculationMaxIterations) + this.iterativeCalculationConvergenceThreshold = configValueFromParam(iterativeCalculationConvergenceThreshold, 'number', 'iterativeCalculationConvergenceThreshold') + validateNumberToBeAtLeast(this.iterativeCalculationConvergenceThreshold, 'iterativeCalculationConvergenceThreshold', 0) + this.iterativeCalculationInitialValue = this.validateIterativeCalculationInitialValue(iterativeCalculationInitialValue) + privatePool.set(this, { licenseKeyValidityState: checkLicenseKeyValidity(this.licenseKey) }) @@ -287,6 +312,23 @@ export class Config implements ConfigParams, ParserConfig { return valueAfterCheck as string[] } + private validateIterativeCalculationMaxIterations(value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new ExpectedValueOfTypeError('positive integer', 'iterativeCalculationMaxIterations') + } + } + + private validateIterativeCalculationInitialValue(value: unknown): RawCellContent { + if (value === undefined) { + return Config.defaultConfig.iterativeCalculationInitialValue + } + if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean' || value instanceof Date) { + return value as RawCellContent + } + + throw new ExpectedValueOfTypeError('number, string, boolean or Date object', 'iterativeCalculationInitialValue') + } + /** * Proxied property to its private counterpart. This makes the property * as accessible as the other Config options but without ability to change the value. diff --git a/src/ConfigParams.ts b/src/ConfigParams.ts index e036731c4..0b228593b 100644 --- a/src/ConfigParams.ts +++ b/src/ConfigParams.ts @@ -6,6 +6,7 @@ import {ChooseAddressMapping} from './DependencyGraph/AddressMapping/ChooseAddressMappingPolicy' import {DateTime, SimpleDate, SimpleDateTime, SimpleTime} from './DateTimeHelper' import {Maybe} from './Maybe' +import { RawCellContent } from '.' export interface ConfigParams { /** @@ -391,7 +392,7 @@ export interface ConfigParams { * * Useful for testing and benchmarking. * @default false - * @category Engine + * @internal */ useStats: boolean, /** @@ -414,6 +415,45 @@ export interface ConfigParams { * @category String */ useWildcards: boolean, + /** + * When set to `true`, enables iterative calculation for circular references. + * + * When disabled, circular references result in a #CYCLE! error. + * + * For more information, see the Excel and Google Sheets documentation on iterative calculations. + * @default false + * @category Engine + */ + iterativeCalculationEnable: boolean, + /** + * Sets the maximum number of iterations for iterative calculation. + * + * Must be a positive integer (>= 1). + * + * @default 100 + * @category Engine + */ + iterativeCalculationMaxIterations: number, + /** + * Sets the convergence threshold for iterative calculation. + * + * Iteration stops when the change between successive values is strictly less than this threshold. + * + * Must be >= 0. + * + * @default 0.001 + * @category Engine + */ + iterativeCalculationConvergenceThreshold: number, + /** + * Sets the initial value used for cells in circular references during iterative calculation. + * + * Can be a number, string, boolean or date/time value. + * + * @default 0 + * @category Engine + */ + iterativeCalculationInitialValue: RawCellContent, } export type ConfigParamsList = keyof ConfigParams diff --git a/src/Evaluator.ts b/src/Evaluator.ts index f810bee36..3e648dfa1 100644 --- a/src/Evaluator.ts +++ b/src/Evaluator.ts @@ -3,6 +3,48 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ +/** + * # Evaluator - Formula Evaluation Engine + * + * Responsible for computing cell values based on their formulas and dependencies. + * + * ## Evaluation Workflow + * + * 1. **Topological Sort with SCC Detection** + * - Sorts vertices (cells/ranges) by dependency order + * - Identifies Strongly Connected Components (SCCs) as cycles + * + * 2. **Pre-cycle Evaluation** + * - Computes all vertices that don't depend on cycles + * - These provide stable inputs for cycle resolution + * + * 3. **Cycle Resolution** (if cycles exist) + * - If iterative calculation disabled: sets #CYCLE! error on all cycle members + * - If enabled: uses Gauss-Seidel iteration until convergence or max iterations + * + * 4. **Post-cycle Evaluation** + * - Recomputes vertices that depend on cycle results + * - Uses subgraph traversal for efficiency + * + * ## Iterative Calculation Algorithm + * + * When circular references exist and `iterativeCalculationEnable` is true: + * + * 1. Initialize all cycle cells to `iterativeCalculationInitialValue` + * 2. Repeat up to `iterativeCalculationMaxIterations` times: + * a. Clear caches for ranges containing cycle cells + * b. Store current values + * c. Recompute all cycle cells in address order (Gauss-Seidel style) + * d. Check convergence: |new - old| < `iterativeCalculationConvergenceThreshold` + * 3. If all cells converge, stop early; otherwise continue to max iterations + * + * ## Entry Points + * + * - `run()`: Full evaluation from scratch (initial load or major changes) + * - `partialRun()`: Incremental evaluation starting from changed vertices + * - `runAndForget()`: One-off formula evaluation without side effects + */ + import {AbsoluteCellRange} from './AbsoluteCellRange' import {absolutizeDependencies} from './absolutizeDependencies' import {CellError, ErrorType, SimpleCellAddress} from './Cell' @@ -19,8 +61,21 @@ import {ColumnSearchStrategy} from './Lookup/SearchStrategy' import {Ast, RelativeDependency} from './parser' import {Statistics, StatType} from './statistics' +/** + * Evaluates formulas in the dependency graph, handling both acyclic and cyclic dependencies. + * + * Uses topological sorting for acyclic portions and iterative calculation for cycles. + * Maintains integration with column search index for VLOOKUP/MATCH optimization. + */ export class Evaluator { - + /** + * @param config - Configuration including iterative calculation settings + * @param stats - Statistics collector for performance measurement + * @param interpreter - AST interpreter for formula evaluation + * @param lazilyTransformingAstService - Service for lazy AST transformations (address updates) + * @param dependencyGraph - Graph of cell/range dependencies + * @param columnSearch - Search index for efficient column lookups + */ constructor( private readonly config: Config, private readonly stats: Statistics, @@ -31,6 +86,15 @@ export class Evaluator { ) { } + /** + * Performs full evaluation of all formulas in the dependency graph. + * + * Used for initial spreadsheet load or when dependencies have changed significantly. + * Performs topological sort to determine evaluation order and handles any cycles. + * + * Complexity: O(V + E) for topological sort + O(I × C) for cycles where + * V=vertices, E=edges, I=iterations, C=cycle size + */ public run(): void { this.stats.start(StatType.TOP_SORT) const {sorted, cycled} = this.dependencyGraph.topSortWithScc() @@ -41,18 +105,76 @@ export class Evaluator { }) } + /** + * Performs incremental evaluation starting from a set of changed vertices. + * + * More efficient than `run()` when only a subset of cells have changed. + * Traverses only the subgraph reachable from the changed vertices. + * + * Algorithm: + * 1. Traverse subgraph from starting vertices in topological order + * 2. Collect any cycles encountered during traversal + * 3. Mark vertices that depend on cycles for deferred processing + * 4. Process cycles via iterative calculation + * 5. Cycle dependents are handled inside iterateCircularDependencies + * + * @param vertices - Starting vertices (typically cells that were directly modified) + * @returns Content changes describing all value updates + */ public partialRun(vertices: Vertex[]): ContentChanges { const changes = ContentChanges.empty() + const cycled: Vertex[] = [] + + // Tracks vertices depending on cycles (direct or transitive) + // These are deferred to updateNonCyclicDependents after cycle resolution + const cycleDependentVertices = new Set() this.stats.measure(StatType.EVALUATION, () => { this.dependencyGraph.graph.getTopSortedWithSccSubgraphFrom(vertices, - (vertex: Vertex) => this.recomputeVertex(vertex, changes), - (vertex: Vertex) => this.processVertexOnCycle(vertex, changes), + // onVertex callback: process each vertex in topological order + (vertex: Vertex) => { + if (cycleDependentVertices.has(vertex)) { + // Propagate cycle dependency to all dependents + this.dependencyGraph.graph.adjacentNodes(vertex).forEach(dependent => { + cycleDependentVertices.add(dependent) + }) + return true // Signal potential changes for dependent propagation + } + return this.recomputeVertex(vertex, changes) + }, + // onCycle callback: handle vertices discovered as part of a cycle + (vertex: Vertex) => { + if (vertex instanceof RangeVertex) { + vertex.clearCache() + } else if (vertex instanceof FormulaVertex) { + cycled.push(vertex) + } + // Mark all direct dependents as cycle-dependent + this.dependencyGraph.graph.adjacentNodes(vertex).forEach(dependent => { + cycleDependentVertices.add(dependent) + }) + }, ) }) + + // Resolve cycles and update their dependents + const cycledChanges = this.iterateCircularDependencies(cycled) + changes.addAll(cycledChanges) + return changes } + /** + * Evaluates a formula without persisting the result or modifying the graph. + * + * Used for one-off calculations like conditional formatting or data validation. + * Temporarily creates range vertices if needed, then cleans them up. + * + * @param ast - Parsed formula AST to evaluate + * @param address - Cell address context for relative references + * @param dependencies - Relative dependencies extracted from the formula + * @returns Computed value (number, string, boolean, error, or array) + */ public runAndForget(ast: Ast, address: SimpleCellAddress, dependencies: RelativeDependency[]): InterpreterValue { const tmpRanges: RangeVertex[] = [] for (const dep of absolutizeDependencies(dependencies, address)) { @@ -75,7 +197,16 @@ export class Evaluator { } /** - * Recalculates the value of a single vertex assuming its dependencies have already been recalculated + * Recomputes a single vertex and records any value changes. + * + * Handles different vertex types: + * - FormulaVertex: evaluates formula, updates column search index if changed + * - RangeVertex: clears cached aggregate values + * - Other (ValueVertex): no computation needed, always signals change + * + * @param vertex - The vertex to recompute (dependencies must be current) + * @param changes - Accumulator for tracking all value changes + * @returns True if value changed (used by graph traversal to propagate to dependents) */ private recomputeVertex(vertex: Vertex, changes: ContentChanges): boolean { if (vertex instanceof FormulaVertex) { @@ -97,30 +228,54 @@ export class Evaluator { } /** - * Processes a vertex that is part of a cycle in dependency graph + * Evaluates all formulas in dependency order, handling cycles appropriately. + * + * Algorithm: + * 1. Build set of vertices that depend on cycles (BFS from cycle members) + * 2. Evaluate all non-cycle-dependent vertices in topological order + * 3. Delegate cycle resolution and dependent updates to iterateCircularDependencies + * + * This separation ensures cycle cells have stable inputs before iteration begins. + * + * @param cycled - Vertices identified as part of cycles (from topological sort) + * @param sorted - All vertices in topological order (excludes cycle members) */ - private processVertexOnCycle(vertex: Vertex, changes: ContentChanges): void { - if (vertex instanceof RangeVertex) { - vertex.clearCache() - } else if (vertex instanceof FormulaVertex) { - const address = vertex.getAddress(this.lazilyTransformingAstService) - this.columnSearch.remove(getRawValue(vertex.valueOrUndef()), address) - const error = new CellError(ErrorType.CYCLE, undefined, vertex) - vertex.setCellValue(error) - changes.addChange(error, address) + private recomputeFormulas(cycled: Vertex[], sorted: Vertex[]): void { + const cyclicSet = new Set(cycled) + + // BFS to find all vertices transitively depending on cycles + const dependsOnCycleSet = new Set() + const queue: Vertex[] = [] + + // Seed with direct dependents of cycle members + for (let i = 0; i < cycled.length; i++) { + this.dependencyGraph.graph.adjacentNodes(cycled[i]).forEach(dependent => { + if (!cyclicSet.has(dependent) && !dependsOnCycleSet.has(dependent)) { + dependsOnCycleSet.add(dependent) + queue.push(dependent) + } + }) } - } - /** - * Recalculates formulas in the topological sort order - */ - private recomputeFormulas(cycled: Vertex[], sorted: Vertex[]): void { - cycled.forEach((vertex: Vertex) => { - if (vertex instanceof FormulaVertex) { - vertex.setCellValue(new CellError(ErrorType.CYCLE, undefined, vertex)) + // Propagate: if X depends on cycle, all dependents of X also depend on cycle + let queueIndex = 0 + while (queueIndex < queue.length) { + const vertex = queue[queueIndex++] + this.dependencyGraph.graph.adjacentNodes(vertex).forEach(dependent => { + if (!cyclicSet.has(dependent) && !dependsOnCycleSet.has(dependent)) { + dependsOnCycleSet.add(dependent) + queue.push(dependent) + } + }) + } + + // Evaluate vertices that don't depend on cycles + // (cycle-dependent vertices handled by updateNonCyclicDependents) + for (let i = 0; i < sorted.length; i++) { + const vertex = sorted[i] + if (dependsOnCycleSet.has(vertex)) { + continue } - }) - sorted.forEach((vertex: Vertex) => { if (vertex instanceof FormulaVertex) { const newCellValue = this.recomputeFormulaVertexValue(vertex) const address = vertex.getAddress(this.lazilyTransformingAstService) @@ -128,9 +283,310 @@ export class Evaluator { } else if (vertex instanceof RangeVertex) { vertex.clearCache() } + } + + // Resolve cycles and evaluate their dependents + this.iterateCircularDependencies(cycled) + } + + /** + * Sets #CYCLE! error on all cycle members when iterative calculation is disabled. + * + * Also removes old values from column search index to maintain consistency. + * RangeVertices in cycles have their caches cleared. + * + * @param cycled - Vertices identified as part of circular dependencies + */ + private blockCircularDependencies(cycled: Vertex[]): void { + for (let i = 0; i < cycled.length; i++) { + const vertex = cycled[i] + if (vertex instanceof RangeVertex) { + vertex.clearCache() + } else if (vertex instanceof FormulaVertex) { + const address = vertex.getAddress(this.lazilyTransformingAstService) + this.columnSearch.remove(getRawValue(vertex.valueOrUndef()), address) + const error = new CellError(ErrorType.CYCLE, undefined, vertex) + vertex.setCellValue(error) + } + } + } + + /** + * Resolves circular dependencies using iterative calculation (Gauss-Seidel method). + * + * When iterative calculation is disabled, sets #CYCLE! error on all cycle members. + * When enabled, iterates until convergence or max iterations reached. + * + * ## Gauss-Seidel Iteration + * Each cell immediately uses updated values from cells earlier in the evaluation order + * (within the same iteration). This typically converges faster than Jacobi iteration. + * + * ## Convergence Criteria + * - Numeric values: |new - old| < threshold + * - Non-numeric values: strict equality + * - All cells must satisfy the criteria to stop early + * + * ## Performance Optimizations + * - Addresses cached to avoid repeated getAddress() calls + * - Affected ranges pre-computed once before iteration loop + * - Previous values stored in pre-allocated array + * + * @param cycled - Vertices forming circular dependencies + * @returns Content changes for all cycle members and their dependents + */ + private iterateCircularDependencies(cycled: Vertex[]): ContentChanges { + const changes = ContentChanges.empty() + + // Early return for empty cycles + if (cycled.length === 0) { + return changes + } + + if (!this.config.iterativeCalculationEnable) { + this.blockCircularDependencies(cycled) + // Still need to update dependents (they'll see #CYCLE! errors) + const dependentChanges = this.updateNonCyclicDependents(cycled) + changes.addAll(dependentChanges) + return changes + } + const maxIterations = this.config.iterativeCalculationMaxIterations + const threshold = this.config.iterativeCalculationConvergenceThreshold + const initialValue = this.config.iterativeCalculationInitialValue + + // Extract formula vertices and cache their addresses to avoid repeated getAddress() calls + const formulaVerticesWithAddresses: {vertex: FormulaVertex, address: SimpleCellAddress}[] = [] + for (let i = 0; i < cycled.length; i++) { + const vertex = cycled[i] + if (vertex instanceof FormulaVertex) { + formulaVerticesWithAddresses.push({ + vertex, + address: vertex.getAddress(this.lazilyTransformingAstService) + }) + } + } + + // Sort by address for consistent evaluation order + formulaVerticesWithAddresses.sort((a, b) => { + if (a.address.sheet !== b.address.sheet) return a.address.sheet - b.address.sheet + if (a.address.row !== b.address.row) return a.address.row - b.address.row + return a.address.col - b.address.col + }) + + // Extract vertices and addresses in single pass + const count = formulaVerticesWithAddresses.length + const formulaVertices: FormulaVertex[] = new Array(count) + const cachedAddresses: SimpleCellAddress[] = new Array(count) + for (let i = 0; i < count; i++) { + formulaVertices[i] = formulaVerticesWithAddresses[i].vertex + cachedAddresses[i] = formulaVerticesWithAddresses[i].address + } + + // Pre-compute affected ranges ONCE before iteration loop (performance optimization) + const affectedRanges = this.findAffectedRanges(cachedAddresses) + + // Always initialize cycle vertices to initialValue (restart on recalculation) + // Config validation ensures initialValue is never undefined/null + // Date objects are converted to their numeric representation (days since epoch) + let safeInitialValue: InterpreterValue + if (initialValue instanceof Date) { + safeInitialValue = initialValue.getTime() / 86400000 + 25569 // Convert to Excel serial date + } else { + safeInitialValue = initialValue ?? 0 + } + for (let i = 0; i < formulaVertices.length; i++) { + formulaVertices[i].setCellValue(safeInitialValue) + } + + // Pre-allocate array for previous values to avoid Map overhead + const previousValues: InterpreterValue[] = new Array(formulaVertices.length) + + // Convert Set to Array for faster iteration in the loop + const affectedRangesArray = Array.from(affectedRanges) + + for (let iteration = 0; iteration < maxIterations; iteration++) { + // Clear affected range caches - O(affectedRanges) instead of O(ranges * cells) + for (let i = 0; i < affectedRangesArray.length; i++) { + affectedRangesArray[i].clearCache() + } + + // Store previous values for convergence check + for (let i = 0; i < formulaVertices.length; i++) { + previousValues[i] = formulaVertices[i].getCellValue() + } + + // Recompute all formula vertices in order (Gauss-Seidel style) + for (let i = 0; i < formulaVertices.length; i++) { + this.recomputeFormulaVertexValue(formulaVertices[i]) + } + + // Check for convergence: all changes must be strictly less than threshold + let converged = true + for (let i = 0; i < formulaVertices.length; i++) { + if (!this.isConverged(previousValues[i], formulaVertices[i].getCellValue(), threshold)) { + converged = false + break + } + } + + if (converged) { + break + } + } + + // Record final values in changes and column search (using cached addresses) + for (let i = 0; i < formulaVertices.length; i++) { + const finalValue = formulaVertices[i].getCellValue() + const address = cachedAddresses[i] + this.columnSearch.add(getRawValue(finalValue), address) + changes.addChange(finalValue, address) + } + + const dependentChanges = this.updateNonCyclicDependents(cycled) + changes.addAll(dependentChanges) + + return changes + } + + /** + * Identifies range vertices whose cached values may be affected by cycle cells. + * + * A range is affected if it contains any cell involved in the cycle. + * These ranges need their caches cleared on each iteration since their + * aggregate values (SUM, AVERAGE, etc.) depend on the changing cycle values. + * + * Computed once before the iteration loop to avoid O(ranges × cells) work per iteration. + * + * @param cyclicAddresses - Addresses of formula vertices in the cycle + * @returns Set of RangeVertices requiring cache invalidation each iteration + */ + private findAffectedRanges(cyclicAddresses: SimpleCellAddress[]): Set { + const affectedRanges = new Set() + + // Group addresses by sheet for efficient lookup + const addressesBySheet = new Map() + + for (let i = 0; i < cyclicAddresses.length; i++) { + const address = cyclicAddresses[i] + let cells = addressesBySheet.get(address.sheet) + if (cells === undefined) { + cells = [] + addressesBySheet.set(address.sheet, cells) + } + cells.push(address) + } + + // Check each range in affected sheets + addressesBySheet.forEach((cellsInSheet, sheet) => { + for (const rangeVertex of this.dependencyGraph.rangeMapping.rangesInSheet(sheet)) { + const range = rangeVertex.range + // Check if range contains any cyclic cell + for (let i = 0; i < cellsInSheet.length; i++) { + if (range.addressInRange(cellsInSheet[i])) { + affectedRanges.add(rangeVertex) + break + } + } + } }) + + return affectedRanges + } + + /** + * Determines if a cell value has converged between iterations. + * + * Convergence rules: + * - Numbers: |new - old| < threshold (strict inequality) + * - Strings, booleans, errors: exact equality + * - Undefined old value: not converged (first iteration) + * + * @param oldValue - Value from previous iteration (undefined if first iteration) + * @param newValue - Value from current iteration + * @param threshold - Maximum allowed change for numeric convergence + * @returns True if the cell has stabilized + */ + private isConverged(oldValue: InterpreterValue | undefined, newValue: InterpreterValue, threshold: number): boolean { + if (oldValue === undefined) { + return false + } + + // For numeric values, compare absolute difference + if (typeof oldValue === 'number' && typeof newValue === 'number') { + return Math.abs(newValue - oldValue) < threshold + } + + // For non-numeric values (strings, booleans, errors), check strict equality + return oldValue === newValue } + /** + * Recomputes all vertices that depend on cycle results. + * + * After cycle values stabilize, their dependents need to be updated. + * Uses subgraph traversal starting from direct dependents of cycle members, + * avoiding a full graph topological sort. + * + * Complexity: O(V' + E') where V' and E' are vertices/edges in the dependent subgraph + * + * @param cycled - Cycle member vertices (used to find their dependents) + * @returns Content changes from all dependent updates + */ + private updateNonCyclicDependents(cycled: Vertex[]): ContentChanges { + const changes = ContentChanges.empty() + const cyclicSet = new Set(cycled) + + // Collect unique direct dependents of cycled vertices (use Set to avoid duplicates) + const directDependentsSet = new Set() + for (let i = 0; i < cycled.length; i++) { + this.dependencyGraph.graph.adjacentNodes(cycled[i]).forEach(dependent => { + if (!cyclicSet.has(dependent)) { + directDependentsSet.add(dependent) + } + }) + } + + if (directDependentsSet.size === 0) { + return changes + } + + const directDependents = Array.from(directDependentsSet) + + // Use subgraph traversal starting from direct dependents + // This avoids full graph traversal - only visits reachable vertices + this.dependencyGraph.graph.getTopSortedWithSccSubgraphFrom( + directDependents, + (vertex: Vertex) => { + if (vertex instanceof FormulaVertex) { + const newCellValue = this.recomputeFormulaVertexValue(vertex) + const address = vertex.getAddress(this.lazilyTransformingAstService) + this.columnSearch.add(getRawValue(newCellValue), address) + changes.addChange(newCellValue, address) + } else if (vertex instanceof RangeVertex) { + vertex.clearCache() + } + return true + }, + (vertex: Vertex) => { + // Handle any cycles in dependents (shouldn't normally happen) + if (vertex instanceof RangeVertex) { + vertex.clearCache() + } + }, + ) + + return changes + } + + /** + * Evaluates a formula vertex and stores the result. + * + * Handles special cases: + * - ArrayFormulaVertex: checks for available space, returns #SPILL! if blocked + * - ScalarFormulaVertex: standard formula evaluation + * + * @param vertex - Formula vertex to evaluate + * @returns The computed value (also stored in the vertex) + */ private recomputeFormulaVertexValue(vertex: FormulaVertex): InterpreterValue { const address = vertex.getAddress(this.lazilyTransformingAstService) if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || !this.dependencyGraph.isThereSpaceForArray(vertex))) { @@ -142,6 +598,17 @@ export class Evaluator { } } + /** + * Evaluates an AST and normalizes the result. + * + * Handles special cases: + * - SimpleRangeValue: returned as-is (for array formulas) + * - EmptyValue: converted to 0 if evaluateNullToZero config is set + * + * @param ast - Parsed formula AST + * @param state - Interpreter state with address context and array mode + * @returns Normalized cell value + */ private evaluateAstToCellValue(ast: Ast, state: InterpreterState): InterpreterValue { const interpreterValue = this.interpreter.evaluateAst(ast, state) if (interpreterValue instanceof SimpleRangeValue) { diff --git a/test/unit/evaluator/iterative-calculation.spec.ts b/test/unit/evaluator/iterative-calculation.spec.ts new file mode 100644 index 000000000..43f87da4c --- /dev/null +++ b/test/unit/evaluator/iterative-calculation.spec.ts @@ -0,0 +1,646 @@ +import {HyperFormula} from '../../../src' +import {ErrorType} from '../../../src/Cell' +import {adr, detailedError} from '../testUtils' + +/** + * Iterative Calculation Feature Tests + * + * Config Parameters: + * - iterativeCalculationEnable: boolean (default: false) + * - iterativeCalculationMaxIterations: number (default: 100) + * - iterativeCalculationConvergenceThreshold: number (default: 0.001) + * - iterativeCalculationInitialValue: number (default: 0) + * + * Behavior: + * - Stop condition: change < threshold (strict less than) + * - When maxIterations reached without convergence: return last computed value + */ + +describe('Iterative Calculation', () => { + describe('Iteration Disabled (Default Behavior)', () => { + it('should return #CYCLE! error for direct self-reference', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.CYCLE)) + }) + }) + + describe('Basic Convergence', () => { + it('should converge simple formula (A1+10)/2 to 10', () => { + const engine = HyperFormula.buildFromArray([ + ['=(A1+10)/2'], + ], {iterativeCalculationEnable: true}) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(10, 2) + }) + + it('should converge damped formula A1*0.5+1 to 2', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1*0.5+1'], + ], {iterativeCalculationEnable: true}) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(2, 2) + }) + + it('should handle immediate convergence when already at solution', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1*1'], // Any value is a fixed point + ], { + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: 5, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(5) + }) + + it('should converge (A1+2)/2 formula starting from 0', () => { + const engine = HyperFormula.buildFromArray([ + ['=(A1+2)/2'], + ], {iterativeCalculationEnable: true}) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(2, 2) + }) + }) + + describe('Max Iterations Behavior', () => { + it('should return 100 for =A1+1 with default maxIterations=100', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1'], + ], {iterativeCalculationEnable: true}) + + expect(engine.getCellValue(adr('A1'))).toBe(100) + }) + + it('should return 1 for =A1+1 with maxIterations=1', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 1, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(1) + }) + + it('should stop at maxIterations even when not converged', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1*2'], // Diverging exponentially + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + iterativeCalculationInitialValue: 1, + }) + + // Starting from 1: 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024 + expect(engine.getCellValue(adr('A1'))).toBe(1024) + }) + }) + + describe('Convergence Threshold Behavior', () => { + it('should stop when change < threshold (strict less than)', () => { + // Formula =(A1+2)/2 converges to 2 + // Iter 1: 1 (change=1), Iter 2: 1.5 (change=0.5), Iter 3: 1.75 (change=0.25), Iter 4: 1.875 (change=0.125) + // With threshold=0.25, should stop at iter 4 (when change=0.125 < 0.25) + const engine = HyperFormula.buildFromArray([ + ['=(A1+2)/2'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationConvergenceThreshold: 0.25, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(1.875) + }) + + it('should continue when change equals threshold', () => { + // With threshold=0.125, iter 4 has change=0.125 which equals threshold + // Should continue to iter 5 (change=0.0625 < 0.125) + const engine = HyperFormula.buildFromArray([ + ['=(A1+2)/2'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationConvergenceThreshold: 0.125, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(1.9375) + }) + + it('should use very small threshold for more precision', () => { + const engine = HyperFormula.buildFromArray([ + ['=(A1+2)/2'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationConvergenceThreshold: 1e-6, + }) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(2, 5) + }) + + it('should handle threshold=0 requiring exact match', () => { + // A1*1 is already converged (any value is fixed point) + const engine = HyperFormula.buildFromArray([ + ['=A1*1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationConvergenceThreshold: 0, + iterativeCalculationInitialValue: 5, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(5) + }) + }) + + describe('Initial Value Behavior', () => { + it('should use default initial value of 0', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 1, + }) + + // Starting from 0, after 1 iteration: 0+1 = 1 + expect(engine.getCellValue(adr('A1'))).toBe(1) + }) + + it('should use custom initial value of 5', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 1, + iterativeCalculationInitialValue: 5, + }) + + // Starting from 5, after 1 iteration: 5+1 = 6 + expect(engine.getCellValue(adr('A1'))).toBe(6) + }) + + it('should use negative initial value', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 1, + iterativeCalculationInitialValue: -10, + }) + + // Starting from -10, after 1 iteration: -10+1 = -9 + expect(engine.getCellValue(adr('A1'))).toBe(-9) + }) + + it('should affect convergence target with non-zero initial', () => { + // Formula =(A1+10)/2 converges to 10 regardless of initial value + const engine = HyperFormula.buildFromArray([ + ['=(A1+10)/2'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: 100, + }) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(10, 2) + }) + }) + + describe('Oscillation Behavior', () => { + it('should return 0 for binary oscillation with even maxIterations', () => { + const engine = HyperFormula.buildFromArray([ + ['=IF(A1=0,1,0)'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 100, + }) + + // Even iterations: 0 -> 1 -> 0 -> 1 -> ... -> 0 + expect(engine.getCellValue(adr('A1'))).toBe(0) + }) + + it('should return 1 for binary oscillation with odd maxIterations', () => { + const engine = HyperFormula.buildFromArray([ + ['=IF(A1=0,1,0)'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 101, + }) + + // Odd iterations: 0 -> 1 -> 0 -> 1 -> ... -> 1 + expect(engine.getCellValue(adr('A1'))).toBe(1) + }) + + it('should handle numeric oscillation =-A1+1', () => { + const engine = HyperFormula.buildFromArray([ + ['=-A1+1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 100, + }) + + // 0 -> 1 -> 0 -> 1 -> ... (even iterations = 0) + expect(engine.getCellValue(adr('A1'))).toBe(0) + }) + + it('should handle text oscillation', () => { + const engine = HyperFormula.buildFromArray([ + ['=IF(A1="a","b","a")'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 100, + }) + + // Initial (0) != "a" -> "a" -> "b" -> "a" -> "b" -> ... (even = "b") + expect(engine.getCellValue(adr('A1'))).toBe('b') + }) + }) + + describe('Multi-Cell Circular Dependencies', () => { + it('should compute two-cell mutual dependency correctly', () => { + const engine = HyperFormula.buildFromArray([ + ['=B1+1', '=A1+1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + }) + + // With cells evaluated in order A1 first, then B1: + // Iter 1: A1 = 0+1 = 1, B1 = 1+1 = 2 + // Iter 2: A1 = 2+1 = 3, B1 = 3+1 = 4 + // ... + // After 10 iters: A1 = 19, B1 = 20 + expect(engine.getCellValue(adr('A1'))).toBe(19) + expect(engine.getCellValue(adr('B1'))).toBe(20) + }) + + it('should compute three-cell chain with feedback', () => { + const engine = HyperFormula.buildFromArray([ + ['=C1+1', '=A1+1', '=B1+1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 5, + }) + + // With evaluation order A1, B1, C1 (sorted by column): + // Iter 0: A1=1, B1=2, C1=3 + // Iter 1: A1=4, B1=5, C1=6 + // ... after 5 iterations: A1=13, B1=14, C1=15 + expect(engine.getCellValue(adr('A1'))).toBe(13) + expect(engine.getCellValue(adr('B1'))).toBe(14) + expect(engine.getCellValue(adr('C1'))).toBe(15) + }) + + it('should converge multi-cell system A1=B1/2, B1=A1/2', () => { + const engine = HyperFormula.buildFromArray([ + ['=B1/2', '=A1/2'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: 8, + iterativeCalculationConvergenceThreshold: 1e-6, + }) + + // Both should converge to 0 + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(0, 5) + expect(engine.getCellValue(adr('B1'))).toBeCloseTo(0, 5) + }) + + it('should handle multiple independent cycles', () => { + const engine = HyperFormula.buildFromArray([ + ['=B1+1', '=A1+1', '', '=E1+2', '=D1+2'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + }) + + // Cycle 1: A1 <-> B1 (each adds 1) + // Cycle 2: D1 <-> E1 (each adds 2) + expect(engine.getCellValue(adr('A1'))).toBe(19) + expect(engine.getCellValue(adr('B1'))).toBe(20) + expect(engine.getCellValue(adr('D1'))).toBe(38) + expect(engine.getCellValue(adr('E1'))).toBe(40) + }) + + it('should handle cycle with SUM function', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(B1:D1)', '=A1/4', '=A1/3', 0.1], + ], { + iterativeCalculationEnable: true, + }) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(0.24, 2) + expect(engine.getCellValue(adr('B1'))).toBeCloseTo(0.06, 2) + expect(engine.getCellValue(adr('C1'))).toBeCloseTo(0.08, 2) + }) + }) + + describe('Mixed Dependencies', () => { + it('should handle circular and non-circular cells in same sheet', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1', '=5+3'], // A1 is circular, B1 is not + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(10) + expect(engine.getCellValue(adr('B1'))).toBe(8) + }) + + it('should handle circular cell depending on constant', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+B1', '5'], // A1 = A1 + 5 + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + }) + + // Each iteration adds 5: 5, 10, 15, 20, 25, 30, 35, 40, 45, 50 + expect(engine.getCellValue(adr('A1'))).toBe(50) + }) + + it('should handle non-circular cell depending on circular result', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1', '=A1*2'], // B1 depends on circular A1 + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(10) + expect(engine.getCellValue(adr('B1'))).toBe(20) + }) + + it('should propagate changes to dependent cells after cycle resolution', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1', '=A1*2', '=B1+5'], // A1 is cycle, B1 and C1 depend on it + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + }) + + // A1 resolves to 10, B1 = 10*2 = 20, C1 = 20+5 = 25 + expect(engine.getCellValue(adr('A1'))).toBe(10) + expect(engine.getCellValue(adr('B1'))).toBe(20) + expect(engine.getCellValue(adr('C1'))).toBe(25) + }) + }) + + describe('Conditional Circular References', () => { + it('should not trigger cycle when condition avoids it', () => { + const engine = HyperFormula.buildFromArray([ + ['=IF(B1>0, A1+1, 5)', '0'], // B1=0, so no self-reference + ], {iterativeCalculationEnable: true}) + + expect(engine.getCellValue(adr('A1'))).toBe(5) + }) + + it('should trigger cycle when condition causes it', () => { + const engine = HyperFormula.buildFromArray([ + ['=IF(B1>0, A1+1, 5)', '1'], // B1=1, so self-reference is active + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(10) + }) + }) + + describe('Error Handling', () => { + it('should handle division by zero in loop', () => { + const engine = HyperFormula.buildFromArray([ + ['=1/(A1-1)'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: 1, + }) + + // Starting from 1: 1/(1-1) = 1/0 = #DIV/0! + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.DIV_BY_ZERO)) + }) + + it('should propagate error through cycle when error occurs', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+B1', '=1/0'], // B1 is #DIV/0! + ], {iterativeCalculationEnable: true}) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.DIV_BY_ZERO)) + }) + + it('should handle #VALUE! inside iterative formula', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+"text"'], // Adding number to text + ], {iterativeCalculationEnable: true}) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE)) + }) + }) + + describe('Edge Cases', () => { + it('should handle very large maxIterations', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 1000, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(1000) + }) + + it('should handle very small threshold', () => { + const engine = HyperFormula.buildFromArray([ + ['=(A1+2)/2'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationConvergenceThreshold: 1e-15, + }) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(2, 10) + }) + + it('should handle formula that converges to negative value', () => { + const engine = HyperFormula.buildFromArray([ + ['=(A1-10)/2'], // Converges to -10 + ], {iterativeCalculationEnable: true}) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(-10, 2) + }) + + it('should handle large number growth', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1*2'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 50, + iterativeCalculationInitialValue: 1, + }) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(Math.pow(2, 50), -5) // huge number + }) + + it('should handle self-referencing SUM formula', () => { + const engine = HyperFormula.buildFromArray([ + ['=SUM(A1, 1)'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(10) + }) + }) + + describe('Config Parameter Validation', () => { + describe('iterativeCalculationMaxIterations', () => { + it('should throw error for negative maxIterations', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: -1, + }) + }).toThrow() + }) + + it('should throw error for zero maxIterations', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 0, + }) + }).toThrow() + }) + + it('should throw error for non-integer maxIterations', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10.5, + }) + }).toThrow() + }) + }) + + describe('iterativeCalculationConvergenceThreshold', () => { + it('should throw error for negative convergenceThreshold', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: true, + iterativeCalculationConvergenceThreshold: -0.001, + }) + }).toThrow() + }) + + it('should accept zero convergenceThreshold', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1*1']], { + iterativeCalculationEnable: true, + iterativeCalculationConvergenceThreshold: 0, + }) + }).not.toThrow() + }) + }) + + describe('iterativeCalculationInitialValue', () => { + it('should accept any numeric initialValue including negative', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: -100, + }) + }).not.toThrow() + }) + + it('should accept zero initialValue', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: 0, + }) + }).not.toThrow() + }) + + it('should accept decimal initialValue', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: 3.14159, + }) + }).not.toThrow() + }) + + it('should accept string initialValue', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1']], { + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: 'foo', + }) + }).not.toThrow() + }) + + it('should accept boolean initialValue', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1']], { + iterativeCalculationEnable: true, + iterativeCalculationInitialValue: true, + }) + }).not.toThrow() + }) + }) + + describe('iterativeCalculationEnable', () => { + it('should accept boolean true', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: true, + }) + }).not.toThrow() + }) + + it('should accept boolean false', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: false, + }) + }).not.toThrow() + }) + + it('should accept undefined', () => { + expect(() => { + HyperFormula.buildFromArray([['=A1+1']], { + iterativeCalculationEnable: undefined, + }) + }).not.toThrow() + }) + }) + }) + + describe('Recalculation After Cell Changes', () => { + it('should recalculate circular reference after dependent cell changes', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+B1', '1'], + ], { + iterativeCalculationEnable: true, + iterativeCalculationMaxIterations: 10, + }) + + expect(engine.getCellValue(adr('A1'))).toBe(10) // 10 iterations of adding 1 + + engine.setCellContents(adr('B1'), [[2]]) + + expect(engine.getCellValue(adr('A1'))).toBe(20) // 10 iterations of adding 2 + }) + + it('should switch between cycle and non-cycle behavior when toggling config', () => { + const engine = HyperFormula.buildFromArray([ + ['=A1+1'], + ], {iterativeCalculationEnable: true, iterativeCalculationMaxIterations: 10}) + + expect(engine.getCellValue(adr('A1'))).toBe(10) + + engine.updateConfig({iterativeCalculationEnable: false}) + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.CYCLE)) + + engine.updateConfig({iterativeCalculationEnable: true}) + expect(engine.getCellValue(adr('A1'))).toBe(10) + }) + }) +})