Files
improvise/context/code/source.md
T
Claude (dbsp-sources) 88b644ff2d Merge dbsp/model-over-source into dbsp/live-source
# Conflicts:
#	context/code/source.md
2026-10-10 18:58:28 -07:00

7.4 KiB

CellSource: where cell values are read from

crates/improvise-core/src/model/source.rs. ADR 003 asked for a source trait in core with the in-RAM DataStore as one implementation (improvise-qddo). ADR 009 step 7 (improvise-rnak) asks for the two live sources that need it. This file is the reasoning; the evidence for each test is in context/test-evidence/core.md. The ADRs are not amended: what follows are the readings taken where they leave a choice.

What is in the trait, and why

method what the evaluator asks
value_at(key) the value stored at exactly key (an exact read, the filter-mismatch fallback)
summarize(select) the summary of the cells a Select names: the aggregate, not the rows
keys(partial) the keys of the cells containing partial: stems, and the records behind an aggregate
cells(partial) the same with values: records mode and a drill
  1. Aggregation is the interface. ADR 003 clause 1: GridLayout asks for a value, not for the rows behind it, and the aggregates are SQL's. So the one read the evaluator makes in bulk, summarize, returns a Summary and never a list of values. keys and cells exist because some reads are of records, not of a fold (the stems a formula is evaluated at; records mode), and a SQL source answers them with a SELECT DISTINCT or a plain select.
  2. Writes are not in the trait. DataStore::set and remove are inherent methods. A source that cannot be written to has no method to fail in, which is ADR 003's "unrepresentable rather than enforced". The model's own writes (set_cell, clear_cell) go to the owned store.
  3. The trait has no Result. A source that can fail (a connection) decides at its own boundary what a failed read is, and improvise-io is where that decision belongs. AGENTS.md keeps Option and Result at the edges; Option is the one the trait uses, for "no such cell".
  4. Select is spanned_values' arguments. A fold names a partial key, the covered members of each group or omitted category it spans, and whether only an exact-width cell counts. The covered members are decided in RAM from the categories and the view's hidden items, so a source is handed explicit item lists and never a predicate. A hidden item therefore reaches a source only as the absence of an item from a list.

Summary and Numbers

A Summary is what a source reports about the values of a read: a Numbers (count, an exact sum, the least and greatest non-NaN, in total_cmp order so -0.0 is below +0.0) and whether the rest (text, errors) agree on one value. It reduces exactly as aggregate_cell_values did: numbers by the function, anything else by unanimity, else Error("mixed"), none at all absent. Three properties carry the design:

  • It merges. A read of a collapsed group, a total, or several contexts of a formula aggregate is the merge of the summaries of its parts, equal to the summary of the union. That is what lets an engine answer a pooled read without sending rows, and what lets eval_agg pool Numbers instead of concatenating Vec<f64>. The sum is ExactSum, so a merge is the same bits in any order.
  • It is order-free. Unanimity needs the set of non-number values, not their order, so a source that has no insertion order loses nothing by it.
  • The reference is kept. tests::reference is the old reduction, copied into the test module; a_summary_reduces_as_the_values_did compares every sequence of an eight-value pool, up to three long, with Summary over all five functions. characterization_survives_the_move: the existing evaluator tests (423 in the crate) are unchanged and pass.

What a real engine must supply is stated here because the trait cannot enforce it: a Numbers whose sum is exact. SQLite's SUM is a float sum, so an engine that cannot return an exact accumulator either returns the numbers (and the adapter sums them) or accepts a last-bit difference, which context/code/improvise-incremental.md ("Step 6") calls a user-visible change.

Model::source() and Model::over(source)

Model::source() returns the &dyn CellSource that the evaluator, the stems of recompute_formulas, the member records of an aggregate over an expression and the records of GridLayout read through. Model::over(source) points it at a SharedSource (an Arc<dyn CellSource + Send + Sync>) instead of the owned DataStore. That is the whole evaluator hook (improvise-rnak, bw4p.14's prerequisite): there is still one evaluator, Model's, and it computes every formula value, over a source whose cells it never owns.

  1. A model over a source is read-only, in every build. set_cell, clear_cell, remove_category and remove_item assert! that no source is set (a debug_assert first shipped and was lost in release: the write went to the owned store, which nothing reads). A caller who holds a source writes to the source (ADR 003: the trait has no writes). Model::over drops the model's own cells, so no stale copy is left for get_cell, persistence or the harness to read. data is pub, so a caller can still write it directly; it is not read, and nothing prevents that. Structural edits (a category or item removed) are refused too, because the source's cells would still carry them.
  2. A shared source changes behind its readers by interior mutability of its own. The model holds an Arc, so a database connection or a test double behind a lock is the same object the backend feeds from.
  3. What still reads the owned store: get_cell, persistence, the import wizard and the effects in improvise-command. They are the facade of a document that owns its cells; a model over a source has none of those to offer, and routing them would change get_cell's &CellValue return for 129 call sites. Eager::occupancy, Shapes::of and Stems::build in improvise-incremental are routed by the PRs that need them.
  4. Debug shows only whether a source is set. A source is not necessarily printable, and two models over different sources are not told apart by it.

TableSource

A scanned table of rows, behind cfg(any(test, feature = "test-support")). It is the second implementation, written as a query engine with no index would answer, sharing only Summary with DataStore. the_store_and_a_scanned_table_answer_every_select_alike asks both every select over a fixture (every subset of every stored key as a base, with no span, one span of each of six item lists, and two), at both widths and for all five functions: over 5,000 triples.

ChangeSource

CellSource plus drain_changes(&self) -> Vec<CellChange>: a source that reports what changed in it. The contract that matters is that a source's cells are those as of the last drain, so a snapshot read now plus the changes drained later counts no change twice or not at all. Why, and how Incremental uses it: context/code/improvise-incremental.md, step 7. DataStore is not a ChangeSource: its log is &mut and is drained by the owner in the step that made the edit. A SharedFeed is the Arc a backend and a model share.

Building a Summary from an engine's answer

Summary::from_parts(Numbers::from_parts(count, exact_sum, least, greatest), Agreement). Agreement is what the non-number values hold: None, One(v) or Mixed. from_parts drops extremes that contradict the count or are NaN, so a source cannot hand the evaluator a state add could not reach.