oficina / sheets Public

Evaluator, precedent graph, dirty-set recalc, cycle detection

Adds calc.Eval (arithmetic, comparison, concatenation, error
propagation with left-most-wins), calc.CellKey/Env per the pre-flight
rulings, and calc.Graph with SetFormula/Recalc/Value implementing
transitive dirty-set recalculation and colour-marked cycle detection
that resolves to #CIRCULAR! instead of recursing.

Call dispatch is wired to an empty funcTable (unregistered calls
resolve to #NAME?) for task 8's registry.go to populate.
Paul Campbell d0fa42bdcddc8cdbda399405b3c047253c340d8c parent 789ae1e
8 files changed, +726 −0
  • internal/sheets/calc/errors.go +36 −0
  • internal/sheets/calc/eval.go +138 −0
  • internal/sheets/calc/eval_arith.go +183 −0
  • internal/sheets/calc/eval_call.go +47 −0
  • internal/sheets/calc/eval_test.go +64 −0
  • internal/sheets/calc/graph.go +122 −0
  • internal/sheets/calc/graph_recalc.go +84 −0
  • internal/sheets/calc/graph_test.go +52 −0
diff --git a/internal/sheets/calc/errors.go b/internal/sheets/calc/errors.go
new file mode 100644
index 0000000..6b2f448
--- /dev/null
+++ b/internal/sheets/calc/errors.go
@@ -0,0 +1,36 @@
+package calc
+
+// ErrKind is an Excel error value. These are values, not Go errors: they
+// live in cells, propagate through arithmetic, and round-trip through XLSX.
+type ErrKind uint8
+
+const (
+ ErrNone ErrKind = iota
+ ErrDiv0
+ ErrValue
+ ErrRef
+ ErrName
+ ErrNum
+ ErrNA
+ ErrCircular
+)
+
+func (e ErrKind) String() string {
+ switch e {
+ case ErrDiv0:
+ return "#DIV/0!"
+ case ErrValue:
+ return "#VALUE!"
+ case ErrRef:
+ return "#REF!"
+ case ErrName:
+ return "#NAME?"
+ case ErrNum:
+ return "#NUM!"
+ case ErrNA:
+ return "#N/A"
+ case ErrCircular:
+ return "#CIRCULAR!"
+ }
+ return ""
+}
diff --git a/internal/sheets/calc/eval.go b/internal/sheets/calc/eval.go
new file mode 100644
index 0000000..6856457
--- /dev/null
+++ b/internal/sheets/calc/eval.go
@@ -0,0 +1,138 @@
+package calc
+
+import "time"
+
+// ValueKind tags which field of a Value is live. KindNum is the zero
+// value, so a Value nobody has set (e.g. env.CellValue's answer for a
+// cell that has never been written) reads as the number 0 — the same way
+// an empty cell behaves in arithmetic. It renders as "" only where a
+// caller explicitly asks for text (see toStr); the zero Value itself does
+// not carry a distinct "empty" tag, since nothing in this task's tests
+// needs one and adding a fifth kind and its coercion rules everywhere is
+// a decision better made against a real corpus (task 8).
+type ValueKind uint8
+
+const (
+ KindNum ValueKind = iota
+ KindStr
+ KindBool
+ KindErr
+)
+
+// Value is a computed cell value. Cell values are IEEE doubles end to
+// end — Num carries the number verbatim, with no rounding at this
+// boundary; currency and other display formatting are a concern outside
+// this package. Kind selects which field is meaningful: Num for KindNum,
+// Str for KindStr, Bool for KindBool, Err for KindErr.
+type Value struct {
+ Kind ValueKind
+ Num float64
+ Str string
+ Bool bool
+ Err ErrKind
+}
+
+// CellKey identifies a cell by its stable row and column ids. It is the
+// dependency graph's node key and the key Env looks values up by.
+type CellKey struct {
+ RowID, ColID uint64
+}
+
+// Env supplies the two things Eval cannot know by itself: the current
+// value of any cell a formula references, and the instant this
+// recalculation pass is pinned to. One pass sees one instant — Now must
+// return the same value on every call within a single Eval/Recalc walk,
+// which is what makes a volatile function like NOW() or TODAY()
+// deterministic within a pass (task 8 exercises this).
+//
+// Env is an interface, not a concrete type, so the dependency graph can
+// hand Eval a view backed by its own cache and tests can hand it a plain
+// map — and so that nothing in this package needs to import store or
+// sheets to evaluate a formula.
+type Env interface {
+ CellValue(k CellKey) Value
+ Now() time.Time
+}
+
+// refKey turns a parsed reference's position into the id space Env is
+// keyed by. Binding a reference to a stable id that survives insert and
+// delete is task 10's job; until it exists, position IS the id — the AST
+// carries 0-based row/column positions, so the id is simply position+1
+// (1-based, so 0 stays free as a reserved/unbound sentinel elsewhere in
+// the system). Task 10's real binder can diverge ids from position after
+// structural edits; nothing here assumes it never will.
+func refKey(r *Ref) CellKey {
+ return CellKey{RowID: uint64(r.RowPos + 1), ColID: uint64(r.ColPos + 1)}
+}
+
+// rangeCells expands a Range into every CellKey it covers, normalizing a
+// range written back-to-front (e.g. "A5:A1"). Used both to fetch a
+// function call argument's values and to build the dependency graph's
+// precedent edges for a formula that reads a range.
+//
+// This walks every cell in the range, which is fine for the small ranges
+// a spreadsheet mostly uses but not for "the whole column" — a formula
+// over A1:A1048576 would materialize a million keys. Nothing in this
+// task's brief exercises that shape; a sparse or interval-based
+// representation is a follow-up for whichever task first needs one.
+func rangeCells(rng *Range) []CellKey {
+ r0, r1 := rng.From.RowPos, rng.To.RowPos
+ c0, c1 := rng.From.ColPos, rng.To.ColPos
+ if r0 > r1 {
+ r0, r1 = r1, r0
+ }
+ if c0 > c1 {
+ c0, c1 = c1, c0
+ }
+ out := make([]CellKey, 0, (r1-r0+1)*(c1-c0+1))
+ for row := r0; row <= r1; row++ {
+ for col := c0; col <= c1; col++ {
+ out = append(out, CellKey{RowID: uint64(row + 1), ColID: uint64(col + 1)})
+ }
+ }
+ return out
+}
+
+// Eval walks n and returns its value under env. Errors are values, not Go
+// errors: any error operand makes the whole expression that error,
+// left-most operand wins when both sides are errors (see evalBinary).
+func Eval(n Node, env Env) Value {
+ switch t := n.(type) {
+ case *Num:
+ return Value{Kind: KindNum, Num: t.V}
+ case *Str:
+ return Value{Kind: KindStr, Str: t.V}
+ case *Bool:
+ return Value{Kind: KindBool, Bool: t.V}
+ case *Ref:
+ return env.CellValue(refKey(t))
+ case *Range:
+ // A bare range is not a scalar. Excel accepts one outside a
+ // function only via implicit intersection with the evaluating
+ // cell's own row/column, which this engine does not implement;
+ // reject the same way Excel does when intersection finds
+ // nothing.
+ return Value{Kind: KindErr, Err: ErrValue}
+ case *Unary:
+ return evalUnary(t, env)
+ case *Binary:
+ return evalBinary(t, env)
+ case *Call:
+ return evalCall(t, env)
+ }
+ return Value{Kind: KindErr, Err: ErrValue}
+}
+
+// evalUnary handles the only unary operator the parser produces: negation
+// (see parse.go's parseUnary and translate.go's Render doc comment).
+func evalUnary(t *Unary, env Env) Value {
+ x := Eval(t.X, env)
+ if x.Kind == KindErr {
+ return x
+ }
+ num, errKind := toNumber(x)
+ if errKind != ErrNone {
+ return Value{Kind: KindErr, Err: errKind}
+ }
+ return Value{Kind: KindNum, Num: -num}
+}
diff --git a/internal/sheets/calc/eval_arith.go b/internal/sheets/calc/eval_arith.go
new file mode 100644
index 0000000..9c0343f
--- /dev/null
+++ b/internal/sheets/calc/eval_arith.go
@@ -0,0 +1,183 @@
+package calc
+
+import (
+ "math"
+ "strconv"
+ "strings"
+)
+
+// evalBinary evaluates both operands unconditionally (formulas have no
+// side effects to order), then propagates the left operand's error if it
+// is one, else the right's — "left-most wins" when both sides are errors.
+func evalBinary(t *Binary, env Env) Value {
+ l := Eval(t.Left, env)
+ r := Eval(t.Right, env)
+ if l.Kind == KindErr {
+ return l
+ }
+ if r.Kind == KindErr {
+ return r
+ }
+ switch t.Op {
+ case "+", "-", "*", "/", "^":
+ return evalArith(t.Op, l, r)
+ case "&":
+ return evalConcat(l, r)
+ default: // "=", "<>", "<", "<=", ">", ">="
+ return evalCompare(t.Op, l, r)
+ }
+}
+
+// evalArith coerces both operands to numbers and applies op. Coercion
+// failure on the left operand wins over one on the right, keeping
+// "left-most wins" consistent even below the Value level.
+func evalArith(op string, l, r Value) Value {
+ ln, errKind := toNumber(l)
+ if errKind != ErrNone {
+ return Value{Kind: KindErr, Err: errKind}
+ }
+ rn, errKind := toNumber(r)
+ if errKind != ErrNone {
+ return Value{Kind: KindErr, Err: errKind}
+ }
+ switch op {
+ case "+":
+ return Value{Kind: KindNum, Num: ln + rn}
+ case "-":
+ return Value{Kind: KindNum, Num: ln - rn}
+ case "*":
+ return Value{Kind: KindNum, Num: ln * rn}
+ case "/":
+ if rn == 0 {
+ return Value{Kind: KindErr, Err: ErrDiv0}
+ }
+ return Value{Kind: KindNum, Num: ln / rn}
+ default: // "^"
+ return Value{Kind: KindNum, Num: math.Pow(ln, rn)}
+ }
+}
+
+// evalConcat implements "&": both operands render as text (Excel's own
+// coercion — TRUE, FALSE, and a number's plain decimal form) and join.
+func evalConcat(l, r Value) Value {
+ return Value{Kind: KindStr, Str: toStr(l) + toStr(r)}
+}
+
+// evalCompare implements the six comparison operators over compareValues'
+// ordering.
+func evalCompare(op string, l, r Value) Value {
+ c := compareValues(l, r)
+ var result bool
+ switch op {
+ case "=":
+ result = c == 0
+ case "<>":
+ result = c != 0
+ case "<":
+ result = c < 0
+ case "<=":
+ result = c <= 0
+ case ">":
+ result = c > 0
+ default: // ">="
+ result = c >= 0
+ }
+ return Value{Kind: KindBool, Bool: result}
+}
+
+// compareValues orders l against r the way Excel's comparison operators
+// do. Within the same kind: numbers compare numerically, text compares
+// case-insensitively (Excel's default — "abc" = "ABC"), and booleans
+// order false before true. Across kinds, every number sorts below every
+// piece of text, and every piece of text sorts below every boolean,
+// regardless of the text's literal content — the string "9" is still
+// greater than the number 100, because it is text, not because of what it
+// says.
+func compareValues(l, r Value) int {
+ lr, rr := typeRank(l), typeRank(r)
+ if lr != rr {
+ if lr < rr {
+ return -1
+ }
+ return 1
+ }
+ switch l.Kind {
+ case KindStr:
+ return strings.Compare(strings.ToUpper(l.Str), strings.ToUpper(r.Str))
+ case KindBool:
+ switch {
+ case l.Bool == r.Bool:
+ return 0
+ case !l.Bool:
+ return -1
+ default:
+ return 1
+ }
+ default: // KindNum
+ switch {
+ case l.Num < r.Num:
+ return -1
+ case l.Num > r.Num:
+ return 1
+ default:
+ return 0
+ }
+ }
+}
+
+// typeRank orders the value kinds for cross-kind comparison: number,
+// then text, then boolean.
+func typeRank(v Value) int {
+ switch v.Kind {
+ case KindStr:
+ return 1
+ case KindBool:
+ return 2
+ default: // KindNum
+ return 0
+ }
+}
+
+// toNumber coerces v for an arithmetic operator: a number as itself, a
+// boolean as 1/0 (Excel's own arithmetic coercion), and text only if it
+// parses as a number outright (no currency symbols, percent signs or
+// thousands separators — that normalization belongs to whatever task
+// parses user input into a formula, not to evaluation). Never called on a
+// KindErr Value: callers check for an error operand before coercing.
+func toNumber(v Value) (float64, ErrKind) {
+ switch v.Kind {
+ case KindNum:
+ return v.Num, ErrNone
+ case KindBool:
+ if v.Bool {
+ return 1, ErrNone
+ }
+ return 0, ErrNone
+ case KindStr:
+ n, err := strconv.ParseFloat(strings.TrimSpace(v.Str), 64)
+ if err != nil {
+ return 0, ErrValue
+ }
+ return n, ErrNone
+ default:
+ return 0, ErrValue
+ }
+}
+
+// toStr renders v as text for "&" concatenation: a string as itself, a
+// number in its plain decimal form (no thousands separator or currency —
+// display formatting is a concern outside this package), a boolean as
+// TRUE/FALSE.
+func toStr(v Value) string {
+ switch v.Kind {
+ case KindStr:
+ return v.Str
+ case KindBool:
+ if v.Bool {
+ return "TRUE"
+ }
+ return "FALSE"
+ default: // KindNum
+ return strconv.FormatFloat(v.Num, 'g', -1, 64)
+ }
+}
diff --git a/internal/sheets/calc/eval_call.go b/internal/sheets/calc/eval_call.go
new file mode 100644
index 0000000..12de78a
--- /dev/null
+++ b/internal/sheets/calc/eval_call.go
@@ -0,0 +1,47 @@
+package calc
+
+// Func is a built-in spreadsheet function: it receives every argument's
+// value already evaluated, with a range argument flattened to one Value
+// per cell (row-major), and returns the call's result.
+//
+// This task wires the dispatch only; funcTable starts empty. Task 8 owns
+// the actual function bodies (SUM, IF, VLOOKUP, ...) in fn_*.go and
+// registry.go, and populates funcTable from there — same package, so it
+// needs no exported Register from this file. Until then, every call
+// resolves to #NAME?, which is also where a reference-shaped word the
+// lexer treated as a call because '(' followed it (token.go's
+// followedByCallParen — e.g. A1(2) parses as Call{Name:"A1"}) ends up: it
+// fails here, not at parse time.
+type Func func(args []Value) Value
+
+var funcTable = map[string]Func{}
+
+// evalCall dispatches a function call: look up t.Name (already upper-cased
+// by the parser), flatten every argument, and invoke it.
+func evalCall(t *Call, env Env) Value {
+ fn, ok := funcTable[t.Name]
+ if !ok {
+ return Value{Kind: KindErr, Err: ErrName}
+ }
+ var args []Value
+ for _, a := range t.Args {
+ args = append(args, evalCallArg(a, env)...)
+ }
+ return fn(args)
+}
+
+// evalCallArg evaluates one call argument into one or more Values: a
+// range expands to its cells in row-major order, anything else evaluates
+// to exactly one Value.
+func evalCallArg(n Node, env Env) []Value {
+ rng, ok := n.(*Range)
+ if !ok {
+ return []Value{Eval(n, env)}
+ }
+ cells := rangeCells(rng)
+ out := make([]Value, len(cells))
+ for i, k := range cells {
+ out[i] = env.CellValue(k)
+ }
+ return out
+}
diff --git a/internal/sheets/calc/eval_test.go b/internal/sheets/calc/eval_test.go
new file mode 100644
index 0000000..a008c7d
--- /dev/null
+++ b/internal/sheets/calc/eval_test.go
@@ -0,0 +1,64 @@
+package calc
+
+import (
+ "testing"
+ "time"
+)
+
+// mustParse parses src or fails the test. It never puts src into the
+// failure message: formula text is cell content, and this codebase's
+// discipline (see parse.go) is that cell content never appears in a
+// string a person might read off a log or a test report.
+func mustParse(t *testing.T, src string) Node {
+ t.Helper()
+ n, err := Parse(src)
+ if err != nil {
+ t.Fatalf("mustParse: %v", err)
+ }
+ return n
+}
+
+// emptyEnv answers every cell as an untouched, empty cell (KindNum zero)
+// and pins Now to the zero time. Good enough for tests that never read a
+// cell or a clock.
+type emptyEnv struct{}
+
+func (emptyEnv) CellValue(CellKey) Value { return Value{} }
+func (emptyEnv) Now() time.Time { return time.Time{} }
+
+// mapEnv is a plain map standing in for a real cell store: exactly the
+// kind of test double Env exists to make possible without calc importing
+// store. A missing key reads as an untouched empty cell, same as emptyEnv.
+type mapEnv map[CellKey]Value
+
+func (m mapEnv) CellValue(k CellKey) Value {
+ if v, ok := m[k]; ok {
+ return v
+ }
+ return Value{}
+}
+func (m mapEnv) Now() time.Time { return time.Time{} }
+
+func TestEvalArithmetic(t *testing.T) {
+ v := Eval(mustParse(t, "1+2*3"), emptyEnv{})
+ if v.Kind != KindNum || v.Num != 7 {
+ t.Fatalf("1+2*3 = %v, want 7", v)
+ }
+}
+
+func TestDivideByZeroIsAnExcelError(t *testing.T) {
+ v := Eval(mustParse(t, "1/0"), emptyEnv{})
+ if v.Kind != KindErr || v.Err != ErrDiv0 {
+ t.Fatalf("1/0 = %v, want #DIV/0!", v)
+ }
+}
+
+// Errors propagate rather than being swallowed: =A1+1 where A1 is #REF!
+// must be #REF!, not 1. XLSX round-trips these, so they are values.
+func TestErrorsPropagate(t *testing.T) {
+ env := mapEnv{{1, 1}: {Kind: KindErr, Err: ErrRef}}
+ v := Eval(mustParse(t, "A1+1"), env)
+ if v.Kind != KindErr || v.Err != ErrRef {
+ t.Fatalf("propagation = %v, want #REF!", v)
+ }
+}
diff --git a/internal/sheets/calc/graph.go b/internal/sheets/calc/graph.go
new file mode 100644
index 0000000..c8a744e
--- /dev/null
+++ b/internal/sheets/calc/graph.go
@@ -0,0 +1,122 @@
+package calc
+
+// maxDirtyPerPass caps how many cells a single Recalc will touch. One
+// edit to a cell feeding a whole-column formula can transitively dirty
+// thousands of dependents; past the cap Recalc stops without evaluating
+// anything and just reports how many cells it would have touched, so the
+// caller can tell the client to refetch its viewport instead of shipping
+// (and the client diffing) an enormous delta.
+const maxDirtyPerPass = 5000
+
+// Graph holds the precedent/dependent edges between formula cells and a
+// cache of their last computed value. It is what turns "this cell
+// changed" into "recompute exactly the cells that read it, transitively,
+// and nothing else." A Graph is not safe for concurrent use — callers
+// serialize edits and recalculation passes, the same discipline a single
+// pass seeing a single Now() already requires.
+type Graph struct {
+ formulas map[CellKey]Node
+ precedents map[CellKey]map[CellKey]struct{} // cell -> cells its formula reads
+ dependents map[CellKey]map[CellKey]struct{} // cell -> cells that read it
+ cache map[CellKey]Value
+ cached map[CellKey]bool // cache presence, since a cached zero Value is a real answer
+ color map[CellKey]cellColor
+}
+
+// cellColor marks a cell's state during one evaluation walk, the standard
+// three-colour cycle check: white (unvisited), grey (on the current
+// recursion stack), black (finished). A node re-entered while grey is a
+// cycle.
+type cellColor uint8
+
+const (
+ white cellColor = iota
+ grey
+ black
+)
+
+// NewGraph returns an empty graph with no formulas.
+func NewGraph() *Graph {
+ return &Graph{
+ formulas: make(map[CellKey]Node),
+ precedents: make(map[CellKey]map[CellKey]struct{}),
+ dependents: make(map[CellKey]map[CellKey]struct{}),
+ cache: make(map[CellKey]Value),
+ cached: make(map[CellKey]bool),
+ }
+}
+
+// SetFormula installs (or replaces) the formula at key, rewiring
+// precedent and dependent edges to match the references the new formula
+// actually contains. The cell's cached value is dropped; nothing
+// recomputes until Recalc or Value asks for it.
+func (g *Graph) SetFormula(key CellKey, n Node) {
+ if old, ok := g.precedents[key]; ok {
+ for p := range old {
+ delete(g.dependents[p], key)
+ }
+ }
+ refs := collectRefs(n)
+ prec := make(map[CellKey]struct{}, len(refs))
+ for _, r := range refs {
+ prec[r] = struct{}{}
+ if g.dependents[r] == nil {
+ g.dependents[r] = make(map[CellKey]struct{})
+ }
+ g.dependents[r][key] = struct{}{}
+ }
+ g.formulas[key] = n
+ g.precedents[key] = prec
+ delete(g.cache, key)
+ delete(g.cached, key)
+}
+
+// collectRefs walks n and returns the CellKey of every cell it reads —
+// every *Ref directly, every cell a *Range covers, recursively through
+// binary, unary and call nodes. This is what SetFormula uses to build
+// precedent/dependent edges.
+func collectRefs(n Node) []CellKey {
+ var out []CellKey
+ var walk func(Node)
+ walk = func(n Node) {
+ switch t := n.(type) {
+ case *Ref:
+ out = append(out, refKey(t))
+ case *Range:
+ out = append(out, rangeCells(t)...)
+ case *Binary:
+ walk(t.Left)
+ walk(t.Right)
+ case *Unary:
+ walk(t.X)
+ case *Call:
+ for _, a := range t.Args {
+ walk(a)
+ }
+ }
+ }
+ walk(n)
+ return out
+}
+
+// transitiveDependents returns every cell reachable from origin by
+// following dependent edges — the set that could change because origin
+// did. origin itself is never included by this walk (callers that also
+// need to recompute a changed formula cell's own value add it
+// separately; see Recalc).
+func (g *Graph) transitiveDependents(origin CellKey) map[CellKey]struct{} {
+ seen := make(map[CellKey]struct{})
+ queue := []CellKey{origin}
+ for len(queue) > 0 {
+ cur := queue[0]
+ queue = queue[1:]
+ for dep := range g.dependents[cur] {
+ if _, ok := seen[dep]; ok {
+ continue
+ }
+ seen[dep] = struct{}{}
+ queue = append(queue, dep)
+ }
+ }
+ return seen
+}
diff --git a/internal/sheets/calc/graph_recalc.go b/internal/sheets/calc/graph_recalc.go
new file mode 100644
index 0000000..b7f296c
--- /dev/null
+++ b/internal/sheets/calc/graph_recalc.go
@@ -0,0 +1,84 @@
+package calc
+
+import "time"
+
+// Recalc propagates a change at origin to every cell that transitively
+// depends on it, evaluates each of those exactly once, and returns how
+// many cells it touched. origin's own new value is expected to already
+// be in env (e.g. a literal the user just typed) unless origin itself is
+// a formula cell — an edit to a formula's own text — in which case origin
+// is recomputed too.
+//
+// Past maxDirtyPerPass, Recalc evaluates nothing and returns the count it
+// would have touched; see the constant's comment for why.
+func (g *Graph) Recalc(origin CellKey, env Env) int {
+ dirty := g.transitiveDependents(origin)
+ if _, hasFormula := g.formulas[origin]; hasFormula {
+ dirty[origin] = struct{}{}
+ }
+ if len(dirty) > maxDirtyPerPass {
+ return len(dirty)
+ }
+ g.color = make(map[CellKey]cellColor, len(dirty))
+ genv := &graphEnv{g: g, base: env, dirty: dirty}
+ for k := range dirty {
+ genv.CellValue(k)
+ }
+ return len(dirty)
+}
+
+// Value returns key's current value, evaluating it — and, recursively,
+// whatever it depends on — from scratch. Unlike Recalc, it does not trust
+// any existing cache: it is the ad-hoc "what is this cell right now"
+// query (and the one TestCycleDetection uses), not the incremental fast
+// path an edit takes.
+//
+// Cycle detection is the colour mark during this walk: a formula cell
+// re-entered while still grey — still mid-evaluation lower in the same
+// call stack — resolves to #CIRCULAR! instead of recursing forever.
+func (g *Graph) Value(key CellKey, env Env) Value {
+ g.color = make(map[CellKey]cellColor)
+ genv := &graphEnv{g: g, base: env}
+ return genv.CellValue(key)
+}
+
+// graphEnv is the Env Eval sees while the graph drives an evaluation. It
+// intercepts CellValue for any cell the graph itself manages (one with a
+// formula) and defers to base for everything else — a raw input cell, or
+// any cell this graph has never heard of.
+type graphEnv struct {
+ g *Graph
+ base Env
+ // dirty scopes a Recalc pass: a formula cell outside this set is
+ // assumed unaffected by the change and its existing cached value is
+ // reused as-is, so a clean precedent is never recomputed (and never
+ // inflates Recalc's touched count). nil means no such assumption
+ // applies — Value's every-cell-fresh walk.
+ dirty map[CellKey]struct{}
+}
+
+func (e *graphEnv) Now() time.Time { return e.base.Now() }
+
+func (e *graphEnv) CellValue(k CellKey) Value {
+ n, isFormula := e.g.formulas[k]
+ if !isFormula {
+ return e.base.CellValue(k)
+ }
+ if e.dirty != nil {
+ if _, inPass := e.dirty[k]; !inPass && e.g.cached[k] {
+ return e.g.cache[k] // clean precedent: unaffected, last value still holds
+ }
+ }
+ switch e.g.color[k] {
+ case black:
+ return e.g.cache[k]
+ case grey:
+ return Value{Kind: KindErr, Err: ErrCircular}
+ }
+ e.g.color[k] = grey
+ v := Eval(n, e)
+ e.g.color[k] = black
+ e.g.cache[k] = v
+ e.g.cached[k] = true
+ return v
+}
diff --git a/internal/sheets/calc/graph_test.go b/internal/sheets/calc/graph_test.go
new file mode 100644
index 0000000..8b3f95f
--- /dev/null
+++ b/internal/sheets/calc/graph_test.go
@@ -0,0 +1,52 @@
+package calc
+
+import (
+ "testing"
+ "time"
+)
+
+// The graph tests key cells by hand rather than through a real binder
+// (task 10 owns binding). RowID/ColID here are exactly refKey's position-
+// plus-one convention, so a formula's "A1" and the test's cellA1 name the
+// same CellKey.
+var (
+ cellA1 = CellKey{RowID: 1, ColID: 1}
+ cellB1 = CellKey{RowID: 1, ColID: 2}
+ cellC1 = CellKey{RowID: 1, ColID: 3}
+ cellZ9 = CellKey{RowID: 9, ColID: 26}
+)
+
+// env gives A1 a raw input value so B1 (=A1*2) and C1 (=B1+1) have
+// something concrete to compute from; it is shared read-only by every
+// test in this file, none of which mutate it.
+var env = mapEnv{cellA1: {Kind: KindNum, Num: 10}}
+
+func TestDirtySetTouchesOnlyDependents(t *testing.T) {
+ g := NewGraph()
+ g.SetFormula(cellB1, mustParse(t, "A1*2"))
+ g.SetFormula(cellC1, mustParse(t, "B1+1"))
+ g.SetFormula(cellZ9, mustParse(t, "42")) // unrelated
+
+ touched := g.Recalc(cellA1, env)
+ if touched != 2 {
+ t.Fatalf("touched %d cells, want 2 (B1, C1) — Z9 must not recalc", touched)
+ }
+}
+
+// A cycle must return an error value, not hang. This test has a timeout
+// because the failure mode of getting this wrong is a wedged process.
+func TestCycleDetection(t *testing.T) {
+ g := NewGraph()
+ g.SetFormula(cellA1, mustParse(t, "B1"))
+ g.SetFormula(cellB1, mustParse(t, "A1"))
+ done := make(chan Value, 1)
+ go func() { done <- g.Value(cellA1, env) }()
+ select {
+ case v := <-done:
+ if v.Kind != KindErr || v.Err != ErrCircular {
+ t.Fatalf("cycle = %v, want #CIRCULAR!", v)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("cycle detection hung")
+ }
+}