Merge gh/main into fix/delivery-swap-livelock

This commit is contained in:
Claude (dbsp-delivery)
2026-10-10 20:25:25 -07:00
committed by Edward Langley
26 changed files with 3286 additions and 137 deletions
+5
View File
@@ -3095,6 +3095,11 @@ rec {
}
];
devDependencies = [
{
name = "improvise-core";
packageId = "improvise-core";
features = [ "test-support" ];
}
{
name = "improvise-io";
packageId = "improvise-io";
+116
View File
@@ -388,3 +388,119 @@ that say otherwise (step 4 items 4 and 10, step 5 item 5). Each is a user-visibl
- **Overflow of the exact sum to infinity** follows `ExactSum::value` (round to nearest, so an overflowing
total is infinite): correct IEEE behaviour.
## Step 7: live sources (improvise-rnak)
ADR 009 clause 2 names two kinds of live source: one that reports its changes, which feeds cell deltas
like `DataStore`, and one that answers aggregate queries but reports none, which implements the derived
value by pushdown. The core half of both is in `context/code/source.md` (`CellSource`, `Model::over`).
The brief ordered this half after the evaluator hook; the hook came first because a source that reports
changes still has its formulas evaluated over it. User ruling 2026-10-10: formulas are computed in RAM
from the source's folds, never pushed down; `Derived` stays in this crate.
### Change-reporting (`Incremental::over`)
1. **`ChangeSource` is `CellSource` plus `drain_changes(&self)`, and a source's cells are those as of the
last drain.** A shape is built lazily, by a pass over the source, at its first read. If a source
showed a write before reporting it, a shape built in between would hold the write and the next
drain would add it again. So the contract is that nothing is visible until it is drained, which is
what a database does inside a read transaction and what `DataStore` does already (its log is
drained in the step that made the edit). `&self` because a live source is shared with the model that
reads it and changes behind it by interior mutability of its own.
2. **`Incremental::over(model, feed, none_cats)`** takes a `SharedFeed`, the alias core exports (a generic
`Arc<S>` could not take it: `S` is `Sized`). The model is put over the feed (`Model::over`), which
drops the cells it came with, so none is left for `get_cell`, persistence or the harness to read. Its categories, formulas and aggregations are the definition; the
source holds the data. Shapes and stems are built from the feed as it stands.
3. **`sync()` drains and maintains; `apply_changes(&[CellChange])` maintains changes the source already
reflects**, for a source whose changes reach the backend by another route (a trigger table, a
channel). `Maintained::apply` on a fed backend takes definition edits (a formula, an aggregation)
and drains: a cell edit is the source's to make, and `apply` refuses it with an `assert!` in every
build (a release build lost it silently when only `Model::set_cell`'s `debug_assert` stood in the way).
**A feed has one drainer**: a drain is destructive, so two backends over one feed split its changes
(`context/code/source.md`, `ChangeSource`).
4. **Freshness is the source's.** ADR 003 left "polled, triggered, or stale until refreshed" open: `sync`
is the poll, and when it is called is the host's decision. Nothing here schedules it.
5. **What a change costs** is what it costs for the owned store: two group updates and two exact-sum
updates per maintained shape, no pass over the source (`the_cost_of_a_reported_change_...` counts the
rows the source serves for snapshots: zero after the build). Formulas are evaluated at the stems the
change reaches, as in step 5.
6. **Three routes into the store were routed through the source**: `Shape::of` (the build) reads
`cells(&[])`, `Stems::build` reads `keys(&[])`, and `Incremental::read` reads `value_at`. `Eager::occupancy`
reads `keys(&[])` too, so an `Eager` over a source would answer it.
`tests/support/live.rs` holds `LiveTable`, the scripted source (an indexed store behind a lock, with
a pending list and a counter of snapshot rows) and `Fed`, an `Incremental` over one that a differential
can drive: a cell edit becomes a write to the table followed by `sync`.
### Query pushdown (`Pushdown`)
For a source that answers aggregate queries and reports nothing. `Pushdown::new(definition, source,
none_cats)` puts the model over the source (`Model::over`) and keeps nothing else: no shapes, no
change log, no stems. These are the readings taken.
1. **`AggregateQuery` is `CellSource`; there are two traits, not three.** The brief's `AggregateQuery`
(`fold`, `count`) is the trait core already has: `summarize` is the fold, `count` the count. `Derived`
stays here (user ruling) and stays a different thing: the values a view draws, which a backend
builds from a source's aggregates and its own state. A source implements `CellSource` and a backend
implements `Derived`.
2. **Folds are queries, formula cells are computed in RAM.** `value`, `raw_fold`, `covered` and `total` are
the model's evaluator over `Model::source()`, so a fold is one `summarize` and the rest is arithmetic
on the answers. The formula cache is the one thing `Pushdown` holds. That is the user's ruling
(2026-10-10), and it makes the evaluator hook the only change to core's evaluation.
3. **Staleness is `refresh()`.** A stored cell or a fold is current at the moment of the read. A formula
cell is the cache's value until `refresh` recomputes it from the source as it now stands, which
costs one `distinct_without` per formula and the folds each formula reads. When to refresh is the
host's (ADR 003's "polled, triggered, or explicitly stale"); nothing here schedules it. A
delivery layer that wants notices from a pushdown-backed shape has no moved keys to name, so it
marks them "correct" (ADR 009 clause 9), which is a bead (below).
4. **Occupancy is one `count`.** A group in the partial becomes a span over its own name and its
members; the view's hidden pairs go as `excluding`. `Eager::occupancy` lists every key to say the
same thing.
5. **`define(edit)` takes a formula or an aggregation edit and refreshes.** A cell edit is the source's:
`define` refuses it with an `assert!` of its own in every build (`a_cell_edit_on_a_pushdown_is_refused`).
6. **What the differential proves, and what it does not.** `value`, `raw_fold`, `covered` and `total` are
the same four functions for `Eager` and `Pushdown` (`reads.rs`; they were copies, and are shared now),
so a Pushdown-against-Eager differential cannot see a mistake in them. `Pushdown::members` repeats
`Eager::group_holds` in a different form (a span list against a predicate), and that repetition
is compared. What the differential proves is the **source's** semantics: that `summarize`, `count`
and `distinct_without` give, for every select the evaluator and the views make, the answers a
`DataStore` gives. The evaluator over a source is proven equal to the evaluator over the owned
store in `improvise-core` (`over_tests`). Nothing else is claimed.
7. **A refresh wants one snapshot.** `refresh` asks `distinct_without` and then folds, so a source that
changes between the queries can serve them from different revisions. The passes heal what they
can: a write between two folds is read again by the next pass, and the cache ends equal to the new
state's values (`writes_between_two_queries_of_a_refresh_are_healed_by_the_fixed_point`). A stem
that appears after the stem list was taken has no cell until the next refresh
(`a_stem_that_appears_between_the_stem_list_and_the_folds_waits_for_the_next_refresh`). A real source
should serve a refresh from one read transaction; nothing here can ask it to.
8. **Known evaluator defect: the result can depend on the order of the stems** (improvise-bw4p.30). The
passes of the fixed point update the cache in place, visiting stems in the order `distinct_without`
lists them, and stop at sixteen. A document whose stems read each other settles in one pass listed
along its dependency chain and takes one pass per link listed against it, so an eighteen-link chain
is `circular` the second way. That is the evaluator's fault, not an obligation on a source: a SQL
source has no order to keep, and bw4p.30 is to make the fixed point independent of it. Until then
the first-seen order (`DataStore`'s insertion order) is what the oracle uses, and
`the_order_stems_are_listed_in_decides_a_result_today` pins today's behaviour: along the chain 18,
sorted `circular`, and a source listing its stems sorted is a divergence. No fixture before it had a
result that depended on the order. When bw4p.30 lands the test's sorted half must change to expect
agreement, and this item goes.
9. **What is and is not proven.** The tests run against `Remote` (`tests/support/remote.rs`), a scanning
in-memory table with counters, that shares no evaluator with `Eager` and answers queries over rows.
Without a real SQL engine only the **contract** is proven: that a source answering these six
queries as specified gives `Eager`'s answers, and what each read costs in queries. Nothing here
shows that SQLite can answer `summarize` exactly (an exact sum is not what its `SUM` returns:
`context/code/source.md`), or what a query costs. The first real source is improvise-rucf, still
deferred.
### What step 7 did not predict
- **The hook came before the change feed**, not after (a fed backend's formulas are evaluated over the
source; see `context/code/source.md`).
- **`TableSource` and `LiveTable` found nothing in `Incremental`**; the corpus and the random edits pass
over a source on the first run. What the tests found was in the fixtures: a rival that read the owned
store for the stored fallback of a filtered formula passed until a cell was stored where the `WHERE`
fails, and a rival that forgot a group's own name as an item passed until a cell was stored under
one.
- **The two-second limit decided the shape of the tests** more than the design did: the corpus
differential over a scanning source is 2.5s untrimmed, so it follows `corpus_eager`'s trimming, and
each random test is its own binary.
+134
View File
@@ -0,0 +1,134 @@
# `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 |
| `count(select, excluding)` | how many cells a select names, leaving out those holding a hidden pair: occupancy |
| `distinct_without(cats)` | the cell keys with `cats` removed, each once, first seen first: the stems formulas are evaluated at |
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 and the formula cells computed from them, 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` (a production caller: `improvise-command/src/ui/effect.rs`,
the `old` value of a rename), `Model::data` (`pub`), 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.
## Pushdown needs two more queries
`count` and `distinct_without` joined the trait for `Pushdown` (improvise-rnak). Both are what a query
engine answers without sending rows (`COUNT(*) ... WHERE`, `SELECT DISTINCT`), and each replaces a
read that would have transferred every key: `Eager::occupancy` lists all keys, and
`recompute_formulas` walked every cell to find its stems. `recompute_formulas` now asks
`distinct_without(target and None categories)` once per formula, and the order it returns is
the order the cells were first seen in, so the stems are inserted in the order they always were.
That order is not a contract a source can be held to: the passes of the fixed point visit stems in
it and a chain of stems can settle or not by it. That is a known evaluator defect, improvise-bw4p.30
(`context/code/improvise-incremental.md`, step 7 item 8), and until it is fixed a source with no order
of its own should list stems in a stable one.
`Select::admits` is the predicate of a select over one key, for a source that must test keys itself
(`DataStore::count`). `TableSource` does not use it: its own `holds` is the independent second reading.
backend and a model share. **A feed has one drainer**: a drain empties the pending list, so two
backends over one feed split the changes between them (measured in review: the same write gave one
backend a Revenue of 128 and the other 38, and the second then disagreed with its own exact reads).
`a_feed_has_one_drainer` pins the behaviour. Kb-style invariant: `invariant(change_source, one_drainer)`.
## 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`. Both constructors return a `Result`
and **refuse** a state no sequence of values could produce (extremes with no numbers, a NaN extreme,
least above greatest, one extreme without the other, a sum with no numbers, no extremes beside a sum
that is not NaN, a number among the "others"), because repairing
it would hide an engine's bug.
+174
View File
@@ -2300,3 +2300,177 @@ key with the whole suite green. It is reached by `reopened_at` (an advance),
`refused_here` (a commit) and `settle_declined_editor`. Rival: the arm passes
`category`; the test fails on all three assertions, and the display is 36 characters
against the key "Region".
### model::source::tests::a_summary_reduces_as_the_values_did
improvise-qddo. The reduction moved from `aggregate_cell_values` to `Summary::reduce`; the test keeps
the old function as `reference` and compares all 585 value sequences of up to three values from an
eight-value pool (NaN, both zeros, infinity, two texts, an error) on all five functions. Rivals, each
failing it by name: unanimity ignored (`Others::One` kept for any second value), a number beside a
unanimous text read as unanimous, and `<` for the order of the zeros (the sign of a zero extreme is
lost). The first two also fail `aggregating_differing_text_values_is_an_explicit_error` and
`aggregating_text_among_numbers_is_an_explicit_error`; the zero rival also fails
`eager_math_tests::min_and_max_of_both_zeros_do_not_depend_on_their_order`.
### model::source::tests::a_merge_of_summaries_is_the_summary_of_the_union
Review of #356 (Opus): the pool gained `1e16`, `1.0`, `-1e16`, `0.1` and `0.2`. A merge rounding each part's
sum before pooling, keeping the zero sign, changed 514 production answers while the workspace passed;
that rival now fails this test, `numbers_pool_what_the_formula_aggregate_pools` (a NaN-free split now,
compared as bits), `summaries_built_from_parts_merge_as_the_union_does` and the fixture test below, by
name: four tests, for each of the three round-then-pool variants.
A read of several parts is a merge. The test merges every ordered pair of sequences of up to two
values and compares with the reference of the concatenation. Rivals: the least not merged, the
greatest merged with the other side's least, the zeros unordered. Each fails it.
### model::source::tests::numbers_pool_what_the_formula_aggregate_pools
`eval_agg` pools `Numbers` now, not a concatenated `Vec<f64>`. Every split of a five-number list
(a NaN and both zeros among them) pooled by merge must equal the whole, by bits for all five
functions and for the total. Rivals: the least not merged, the greatest seeded from the other
side's least. Both fail it.
### model::source::tests::the_store_and_a_scanned_table_answer_every_select_alike
`DataStore` and `TableSource` (a scan, sharing only `Summary`) are asked every select over the
fixture, at both widths. Rivals, each failing it: the store ignores `exact` for a select with no
span, and the store ignores `exact` for a spanned one. The first also fails
`an_exact_select_with_no_span_names_one_cell`.
### model::source::tests::the_store_and_a_scanned_table_list_the_same_cells
`keys` and `cells` under every base of the fixture, and `value_at` for every row and an absent key.
Rival: `DataStore::keys` ignores its partial. It fails here and in seven evaluator tests that read
the member records of an aggregate over an expression
(`route_then_aggregate_tests::a_routing_formula_at_a_record_is_the_rule_itself` among them).
### model::source::tests::an_exact_select_with_no_span_names_one_cell
A select with `exact` and no span is the cell, not the cells containing the pairs. The store's
`spanned_values` is never called for it (the evaluator reads an exact key through `value_at`), so
this is the contract a second source implements. Rival: `exact` ignored when there is no span.
### model::over_tests::a_model_over_a_source_evaluates_as_the_model_that_owns_the_cells
improvise-rnak. A model with its store emptied and `over` a `TableSource` of the same rows is asked
every read the evaluator makes (exact and over-hidden values, covered by a view that hides East,
spanned over none, one and two categories) at every sub-key of every stored key, a group named in
place of a member, and every formula target; the answers must agree to the bit. The fixture has a
ref, an aggregate of a ref, an aggregate of an expression, a filtered formula, a cycle and a
formula over formulas, and 2,000+ answers are compared. Each rival replaces one read with the owned
store, and fails it by name: the member records of an aggregate over an expression, the pooled
formula aggregate's raw read, the fold of a group, the exact read, and the stored cell a filtered
formula falls back to (a `Bonus` cell stored where its `WHERE` does not hold; before that cell
was added to the fixture this rival passed).
### model::over_tests::the_formula_cells_of_a_model_over_a_source_are_the_owned_models
`recompute_formulas` over a source under three None sets: the formula cache must equal the owned
model's at every key, and at least 40 cells are present across the three (a cache that is empty on
both sides would pass the comparison). Rival: the stems read from the owned store, which finds no
stem and fills no cell; it fails here and nowhere else.
### model::over_tests::a_model_over_a_source_does_not_read_its_own_store
A cell is set in the over-model's own store (the only cell it holds) with a value no fold could
mistake. The exact read and the fold of its measure must be the source's. Rivals: a fold or an exact
read of the owned store fail it.
### model::over_tests::the_records_a_drill_freezes_are_read_from_the_source
`drill_snapshot` (records mode's rows) over the empty store and a source. Rival: the unconstrained
branch reads the owned store; it fails here.
### model::over_tests::a_model_over_a_source_refuses_a_write
`set_cell` on a model over a source panics in a debug build, naming the reason. Without the assert the
write goes to the owned store and nothing reads it.
### model::source::tests::the_store_and_a_scanned_table_count_every_select_alike
improvise-rnak. `count(select, excluding)` on the store and on a scanning table, over every select of
the fixture, at both widths, hiding none, each of four items and each pair of them: 31,680 counts, 3,370
of them not zero (a count that was zero on both sides would pass). Rivals, each failing it: the count
ignores the excluded pairs (also fails `hiding_an_item_removes_exactly_the_cells_that_hold_it`), ignores the
spans, and ignores `exact`.
### model::source::tests::hiding_an_item_removes_exactly_the_cells_that_hold_it
The hand-checked half: all rows, all but those holding `R=E`, and a select naming a hidden item is
zero. A count that agreed with a wrong scan on both sides would pass the test above and fail this.
### model::source::tests::the_store_and_a_scanned_table_list_the_same_stems
`distinct_without` on the store and on a scanning table, for every subset of three categories and one
that does not exist, equal and in the same order, each stem once. Rivals, each failing it by name:
the stems not deduplicated, sorted instead of first seen (this is the only test that sees it: the
evaluator's results do not depend on stem order for the fixtures), and the dropped categories kept (also
fails several evaluator tests).
### a_summary_built_from_an_engines_parts_reduces_as_one_built_from_the_values
Review of #356 (Codex): an engine outside core could not build a `Summary` without replaying rows. The
parts are computed in the test without `Summary::of`, for every sequence of up to three values of the
pool. No rival of the first version failed this test alone (its filter was untested); see below.
### summaries_built_from_parts_merge_as_the_union_does
A summary built from parts, merged with one built from values and with one built from parts, equals the
reference of the union, for every pair of sequences of up to two values. This is where a bad extreme
would matter, since merge reads the extremes.
### parts_no_sequence_of_values_could_produce_are_refused
Review of #356 (Opus, Codex): `from_parts` repaired some impossible states and accepted others
(`One(Number)`, least above greatest). It refuses all of them with a `BadParts` naming the reason.
The earlier entry's claim that a NaN-extreme rival failed `parts_that_contradict_themselves_...` was
false: that test passed with the filter removed. Rivals now, each failing this test alone: NaN extreme
allowed, least above greatest allowed, extremes with no numbers allowed, a number accepted among the
others, a sum with no numbers allowed, and no extremes beside a sum that is not NaN allowed.
### the_exact_sum_of_a_pool_of_cancelling_values_is_not_the_sum_of_rounded_parts
An exactness test needs inputs whose float sum is inexact and no NaN. `1e16 + 1.0 - 1e16` rounded part
by part is `0.0`; pooled exactly it is `1.0`. The merge runs in both orders, so whichever part a rival
rounds is the inexact one: rounding only the other part, only self, or both each fail it (the first
version merged one way and a rival rounding only the exact part passed it; Opus caught that).
### keys_of_a_store_are_its_cells_keys_without_the_values
Review of #356 (Codex): `DataStore::keys` went through `cells` and cloned every value. It reads keys
only now; the test pins the keys and their order against `cells`. No clone-count test: a value clone is
not observable from outside, so the equality is all that can be asserted.
### a_model_over_a_source_refuses_a_clear
Review of #357: the `clear_cell` guard had no test. The refusal is an `assert!`, so the test holds in a
release build too (`cargo test --release -p improvise-core --lib over_tests` was run: the earlier
`debug_assert!` failed all four refusal tests there, `a_model_over_a_source_refuses_a_write` among
them, and was the bug). Rival: the guard removed from `clear_cell` fails this test only.
### a_model_over_a_source_refuses_to_remove_a_category
`remove_category` swept the owned store with no guard. Rival: the guard removed.
### a_model_over_a_source_refuses_to_remove_an_item
The same for `remove_item`.
### putting_a_populated_model_over_a_source_drops_its_own_cells
`Model::over` empties the owned store, so `get_cell` on an over-model is `None` for a cell the
source holds, and the evaluator still reads the source's value.
### records_mode_reads_the_records_from_a_source
Review of #357: a records-mode `GridLayout` over a source. `group_records` reading the owned store
left all 563 tests green; with this test it fails (the grid has no rows).
### putting_a_model_with_formula_cells_over_a_source_drops_what_those_cells_computed
Review of #357 (Codex): `Model::over` emptied the store and kept `formula_cache`, which was computed from
those cells; reads consult the cache first, so old formula values survived until a recompute. The model
has a computed Profit, the source holds a different revenue, and the cell must be gone after `over`.
Rival: the `formula_cache.clear()` removed fails this test alone (before the fix it failed here first).
`measure_agg` and the categories are the definition and stay; nothing else is derived from the cells.
@@ -801,3 +801,159 @@ User-visible changes this evidence also covers: a cycle that only flips a zero's
same cycle over nonzero data always was; a cycle fed a NaN (`A = A + 1`, `A = A` with `A` NaN) settles on
NaN instead of `circular`.
### a_live_source_matches_eager_over_the_corpus_and_its_edits
improvise-rnak. `Incremental` over a `LiveTable` (the model's cells moved into the table, the model's
own store emptied) against `Eager` over the shipped documents, their views and `edits_for`
(trimmed to three edits above 40 cells, as `corpus_eager` does). Rivals, each failing it by name: `sync`
drains nothing; the model not put over the feed (formulas read an empty store); a shape built from an
empty store; the stems built from the model's own store.
### changes_named_by_the_caller_are_maintained_as_the_drain_would
`apply_changes` with the changes the table reports, against `Eager` after each of four edits (an
overwrite, a new record under an item the model lacks, a removal, a text), asking every probe of every
step so far. Rivals: `apply_changes` ignores its changes (only this test and no other fails it); the
stems from the owned store.
### a_write_is_invisible_until_it_is_drained
The contract of a `ChangeSource`: a read after a write and before the drain sees the old value, after
`sync` the new one, and a second `sync` changes nothing. Rivals: the table shows a write at once (this
test alone fails it, which is why the contract has a test of its own: the differential cannot see it,
since every shape it asks about was built before the write); `sync` drains nothing.
### the_cost_of_a_reported_change_does_not_depend_on_the_size_of_the_table
One overwrite reported by the table, after the shape is built, at 216 and at 864 cells: two group
updates and two sum updates, no build, equal meters, and zero snapshot rows served by the source
for the change. The build itself serves exactly the table (`n * n * 6` rows), so the zero is a count
that could have been positive. Rival: every sync rebuilds the shapes (`builds` and `built_cells` move,
and the table serves the snapshot again).
### an_overwrite_by_the_same_value_moves_nothing
A table that reports an overwrite of a cell by its own value leaves the fold where it was (23). Rival:
the model not over the feed fails it with the others.
### a_live_source_matches_eager_over_random_formula_and_cell_edits
`Fed` against `Eager` over random formula and cell edits on the formula fixture (no views, so the binary
stays under two seconds). Rivals: `sync` drains nothing, the model not over the feed, a shape built from an
empty store and the stems from the owned store all fail it.
### a_pushdown_matches_eager_over_the_shipped_documents
improvise-rnak. `Pushdown` over `Remote` (a scanning table that counts its queries; the model keeps no
cells) against `Eager`, over the shipped documents, their views and `edits_for` (trimmed as
`corpus_eager` is). Rivals, each failing it by name: the occupancy forgetting the hidden items; the
model not over the source; a raw fold answering for a formula target; a covered read covering every
member; a total covering every member; the engine ignoring `exact`; a refresh that does nothing; a
definition edit that does not refresh.
### a_pushdown_matches_eager_on_hidden_items_and_collapsed_groups_under_every_aggregation
The hidden and the counted fixtures, drawn, under each of five aggregations, with a NaN, a `-0.0` and
a text written to a hidden cell and a cell stored under the group's own name. The last is why the
rival "occupancy forgets the group's own name" is caught: before that edit, it passed everywhere.
Rivals: the occupancy treating a group as an item; the same forgetting the group's name; the occupancy
forgetting the hidden items; covered and total covering everything.
### a_pushdown_matches_eager_on_formulas_groups_and_wide_keys
The ratio fixture (a formula over two measures), the hand model under every aggregation of two
measures, a grouped model and the six-category wide model. Rivals: a refresh that does nothing, a
definition edit that does not refresh, a raw fold of a formula target, the engine ignoring `exact`.
### a_fold_is_one_query_and_sends_no_row
A raw fold, a collapsed group's covered read and a column total are three `summarize` calls between
them and no row sent back by the table (`rows_sent` unchanged); an exact read is one lookup. Rival:
the model not over the source (the reads then never reach the table).
### occupancy_is_one_count_whatever_the_groups_and_hidden_items
The Coastal-by-A occupancy of the hidden fixture (East hidden, West holding A) is 1, in one `count`,
with no row sent. Rivals: the hidden items forgotten, a group treated as an item.
### formula_cells_are_computed_from_the_distinct_stems_not_from_every_cell
Refreshing the hand model's one formula asks for stems once and gets two rows back (Product A and B)
from twelve cells, lists no keys and no cells, and still queries the folds the formula reads.
Rivals: a refresh that does nothing, the model not over the source.
### a_stored_cell_is_current_and_a_formula_cell_waits_for_a_refresh
The staleness contract of `Pushdown`. A write by another client is seen by the next exact read (50) and
not by the cached formula cell (9) until `refresh` (49). Rival: a refresh that does nothing.
### a_source_that_forgets_the_hidden_items_is_caught_as_a_divergence
The check of the checker, the user's ruling about faults: `IgnoresHidden` is a source whose `count`
drops the hidden pairs. The differential must report a `Divergence` and the probe must be an
occupancy; a sound source over the same fixture must pass.
### a_pushdown_matches_eager_over_random_formula_and_cell_edits
`Pushdown` over `Remote` against `Eager` over random formula and cell edits on the formula fixture
(cycles, text, NaN and `-0.0` among the values), in its own binary. Rival: a refresh that does
nothing.
### a_backend_can_be_built_over_the_exported_feed_alias
Review of #358: `over<S>(Arc<S>)` could not take the `SharedFeed` the same PR exports. The test builds
a backend from a `SharedFeed`; it did not compile against the generic signature.
### a_populated_model_put_over_a_feed_keeps_no_cells_of_its_own
Review of #358: `Fed::new` emptied the store before the call, so no test passed a populated model. This
one does, asserts the owned store is empty afterwards and that every harness probe agrees with
`Eager`. Rival: `Model::over` keeping the cells fails it, and only it.
### a_cell_edit_on_a_fed_backend_is_refused
`Maintained::apply` with a `SetCell` panics with the backend's own message in every build. Rival: the
assert removed (the test then sees the model's message, not the backend's, and fails).
### a_cell_clear_on_a_fed_backend_is_refused
The same for `ClearCell`.
### a_feed_has_one_drainer
Review of #358: two backends over one feed; one write; `a.sync(); b.sync()`. The first sees it, the
second does not. This pins the behaviour as documented rather than as a guarantee: a drain is destructive.
### the_order_stems_are_listed_in_decides_a_result_today
Review of #360: stem order was invisible to every differential (a sorted `distinct_without` went
uncaught). It pins today's behaviour of a known evaluator defect (improvise-bw4p.30), not a contract. Eighteen products are stored in chain order with names that sort the other way, and one nested
`IF` formula reads the product before it. Along the chain it settles in one pass (the last link is 18);
sorted it needs one pass per link and the bound of sixteen leaves it `circular`. The test asserts both
values, that a sound source passes the differential and that a source listing the stems sorted is a
divergence. Rival: `DataStore::distinct_without` sorting fails it (the oracle then gives `circular`, so
the first assertion fails). Before it, a source sorting its stems passed every differential; only the core stems test (which compares two stores) saw a sort.
### writes_between_two_queries_of_a_refresh_are_healed_by_the_fixed_point
Review of #360: a source that changes between the queries of one refresh. Another client's writes land
right after the first fold; the cached Profit of Product A ends at 53, the new state's value, and not at
13 (the old revenue less the new cost). The documented guard is one snapshot per refresh. Rival:
none; this pins a behaviour that the fixed point gives and the note says may not be relied on.
### a_stem_that_appears_between_the_stem_list_and_the_folds_waits_for_the_next_refresh
The stem list is taken, then Product C is written: it has no formula cell until the next refresh,
which has it (5).
### a_cell_edit_on_a_pushdown_is_refused
Review of #360: `define` applied a cell edit to the model, whose guard was a `debug_assert`; now an
`assert!` of its own, with its own message. Rival: the assert removed fails it (the model's message
differs).
### a_pushdown_can_be_built_over_the_exported_source_alias
`Pushdown::new` took a generic `Arc<Q>` that could not take a `SharedSource`; the test builds one from
the alias and did not compile against the generic signature.
+4
View File
@@ -2,7 +2,11 @@ pub mod category;
pub mod cell;
pub mod id;
pub mod record;
pub mod source;
pub mod symbol;
pub mod types;
#[cfg(test)]
mod over_tests;
pub use types::{Aggregation, MAX_CATEGORIES, Model};
@@ -0,0 +1,322 @@
//! A model over a source reads the source and nothing else; `context/code/source.md`.
use super::Aggregation;
use super::Model;
use super::cell::{CellKey, CellValue, DataStore};
use super::source::TableSource;
use crate::formula::parse_formula;
use std::sync::Arc;
fn key(region: &str, product: &str, measure: &str) -> CellKey {
CellKey::new(vec![
("Region".into(), region.into()),
("Product".into(), product.into()),
("_Measure".into(), measure.into()),
])
}
const FORMULAS: [&str; 8] = [
"Profit = Revenue - Cost",
"Weighted = SUM(Revenue * Price)",
"Share = Revenue / SUM(Revenue)",
"Bonus = Revenue * 0.1 WHERE Region = East",
"Peak = MAX(Revenue WHERE Product = A)",
"Loop = Loop + 1",
"Both = Profit + Weighted",
"Mean = AVG(Revenue) + Count",
];
/// A group and a loner, text among numbers, and every kind of formula that reads cells.
fn fixture() -> Model {
let mut m = Model::new("over");
for cat in ["Region", "Product"] {
m.add_category(cat).unwrap();
}
let region = m.category_mut("Region").unwrap();
region.add_group(super::category::Group::new("Coastal"));
region.add_item_in_group("East", "Coastal");
region.add_item_in_group("West", "Coastal");
region.add_item("Inland");
for p in ["A", "B"] {
m.category_mut("Product").unwrap().add_item(p);
}
for measure in ["Revenue", "Price", "Cost", "Count", "Note"] {
m.category_mut("_Measure").unwrap().add_item(measure);
}
let regions = ["East", "West", "Inland"];
for (r, region) in regions.iter().enumerate() {
for (p, product) in ["A", "B"].iter().enumerate() {
let n = (r * 2 + p) as f64;
m.set_cell(key(region, product, "Revenue"), CellValue::Number(10.0 + n));
m.set_cell(key(region, product, "Price"), CellValue::Number(1.5 + n));
m.set_cell(key(region, product, "Cost"), CellValue::Number(4.0 - n));
m.set_cell(key(region, product, "Count"), CellValue::Number(n));
}
}
m.set_cell(key("East", "A", "Note"), CellValue::Text("n".into()));
m.set_cell(key("West", "A", "Note"), CellValue::Text("n".into()));
m.set_cell(key("East", "B", "Note"), CellValue::Text("x".into()));
m.set_cell(key("Inland", "A", "Revenue"), CellValue::Number(-0.0));
// Where Bonus's filter does not hold, the stored cell is read in its place.
m.set_cell(key("West", "A", "Bonus"), CellValue::Number(3.0));
for text in FORMULAS {
m.add_formula(parse_formula(text, "_Measure").unwrap());
}
m
}
/// `model`'s cells as the rows of a table, and `model` with no store of its own, over them.
fn lent(model: &Model) -> Model {
let rows = model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect();
let mut bare = model.clone();
bare.data = DataStore::new();
bare.over(Arc::new(TableSource { rows }))
}
/// Every key worth asking: each stored key with any of its coordinates dropped, a group named
/// in place of its member, and each formula's target at each of those.
fn keys(model: &Model) -> Vec<CellKey> {
let mut out: Vec<CellKey> = Vec::new();
for (stored, _) in model.data.iter_cells() {
let grouped = stored.clone().with("Region", "Coastal");
for base in [stored, grouped] {
for mask in 0..(1u32 << base.0.len()) {
let kept = base.0.iter().enumerate();
let kept = kept.filter(|(i, _)| mask >> i & 1 == 1);
out.push(CellKey::new(kept.map(|(_, p)| p.clone()).collect()));
}
}
}
let targets: Vec<CellKey> = model
.formulas()
.iter()
.flat_map(|f| out.iter().map(|k| k.clone().with("_Measure", &f.target)))
.collect();
out.extend(targets);
out.sort();
out.dedup();
out
}
fn same(a: &Option<CellValue>, b: &Option<CellValue>) -> bool {
match (a, b) {
(Some(CellValue::Number(x)), Some(CellValue::Number(y))) => {
x.to_bits() == y.to_bits() || (x.is_nan() && y.is_nan())
}
_ => a == b,
}
}
/// Every read the evaluator makes, asked of both models; how many answers were not absent.
fn compare_reads(owned: &Model, over: &Model) -> (usize, usize) {
let hide_east = |cat: &str, item: &str| !(cat == "Region" && item == "East");
let spans: [&[String]; 3] = [
&[],
&["Region".to_string()],
&["Region".to_string(), "Product".to_string()],
];
let (mut asked, mut present) = (0, 0);
for key in keys(owned) {
let mut pairs = Vec::new();
for agg in [Aggregation::Exact, Aggregation::OverHidden] {
pairs.push((
owned.evaluate_aggregated(&key, agg),
over.evaluate_aggregated(&key, agg),
));
pairs.push((
owned.evaluate_covering(&key, agg, &hide_east),
over.evaluate_covering(&key, agg, &hide_east),
));
for span in spans {
pairs.push((
owned.evaluate_spanning(&key, span, agg, &hide_east),
over.evaluate_spanning(&key, span, agg, &hide_east),
));
}
}
for (a, b) in pairs {
assert!(same(&a, &b), "{key}: owned {a:?} != over a source {b:?}");
asked += 1;
present += usize::from(a.is_some());
}
}
(asked, present)
}
#[test]
fn a_model_over_a_source_evaluates_as_the_model_that_owns_the_cells() {
let owned = fixture();
let over = lent(&owned);
let (asked, present) = compare_reads(&owned, &over);
assert!(asked > 2000, "the enumeration shrank to {asked}");
assert!(
present > asked / 2,
"{present} of {asked} answers were not absent"
);
}
#[test]
fn the_formula_cells_of_a_model_over_a_source_are_the_owned_models() {
let mut total = 0;
for none in [
vec![],
vec!["Product".to_string()],
vec!["Region".to_string(), "Product".to_string()],
] {
let (mut owned, mut over) = (fixture(), lent(&fixture()));
owned.recompute_formulas(&none);
over.recompute_formulas(&none);
let mut cells = 0;
for key in keys(&owned) {
let (a, b) = (
owned.formula_cell(&key).cloned(),
over.formula_cell(&key).cloned(),
);
assert!(
same(&a, &b),
"{none:?} {key}: owned {a:?} != over a source {b:?}"
);
cells += usize::from(a.is_some());
}
assert!(
cells > 5,
"{none:?}: only {cells} formula cells were present"
);
total += cells;
compare_reads(&owned, &over);
}
assert!(total > 40, "only {total} formula cells were compared");
}
#[test]
fn a_model_over_a_source_does_not_read_its_own_store() {
let owned = fixture();
let mut over = lent(&owned);
let poisoned = key("East", "A", "Revenue");
over.data.set(poisoned.clone(), CellValue::Number(1e9));
let read = over.evaluate_aggregated(&poisoned, Aggregation::Exact);
assert_eq!(
read,
owned.evaluate_aggregated(&poisoned, Aggregation::Exact)
);
let revenue = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
let folded = over.evaluate_aggregated(&revenue, Aggregation::OverHidden);
assert_eq!(
folded,
owned.evaluate_aggregated(&revenue, Aggregation::OverHidden)
);
assert_ne!(folded, Some(CellValue::Number(1e9)), "the store was folded");
}
#[test]
#[should_panic(expected = "read-only")]
fn a_model_over_a_source_refuses_a_write() {
let mut over = lent(&fixture());
over.set_cell(key("East", "A", "Revenue"), CellValue::Number(1.0));
}
#[test]
#[should_panic(expected = "read-only")]
fn a_model_over_a_source_refuses_a_clear() {
let mut over = lent(&fixture());
over.clear_cell(&key("East", "A", "Revenue"));
}
#[test]
#[should_panic(expected = "read-only")]
fn a_model_over_a_source_refuses_to_remove_a_category() {
let mut over = lent(&fixture());
over.remove_category("Product");
}
#[test]
#[should_panic(expected = "read-only")]
fn a_model_over_a_source_refuses_to_remove_an_item() {
let mut over = lent(&fixture());
over.remove_item("Product", "A");
}
#[test]
fn putting_a_populated_model_over_a_source_drops_its_own_cells() {
let owned = fixture();
assert!(owned.data.iter_cells().count() > 0);
let rows = owned
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect();
let over = owned.clone().over(Arc::new(TableSource { rows }));
assert_eq!(over.data.iter_cells().count(), 0, "no stale second copy");
let cell = key("East", "A", "Revenue");
assert_eq!(
over.get_cell(&cell),
None,
"the owned store is empty, the source is not"
);
let read = over.evaluate_aggregated(&cell, Aggregation::Exact);
assert_eq!(read, owned.evaluate_aggregated(&cell, Aggregation::Exact));
}
#[test]
fn the_records_a_drill_freezes_are_read_from_the_source() {
use crate::view::layout::drill_snapshot;
let (owned, over) = (fixture(), lent(&fixture()));
let constraints = [
CellKey::new(vec![]),
CellKey::new(vec![("Region".into(), "East".into())]),
CellKey::new(vec![("_Measure".into(), "Revenue".into())]),
CellKey::new(vec![("Region".into(), "Nowhere".into())]),
];
let mut rows = 0;
for constraint in &constraints {
let (a, b) = (
drill_snapshot(&owned, constraint),
drill_snapshot(&over, constraint),
);
assert_eq!(
format!("{a:?}"),
format!("{b:?}"),
"drill into {constraint}"
);
rows += a.len();
}
assert!(rows > 10, "only {rows} records were compared");
}
#[test]
fn putting_a_model_with_formula_cells_over_a_source_drops_what_those_cells_computed() {
let mut owned = fixture();
owned.recompute_formulas(&["Region".to_string()]);
let profit = CellKey::new(vec![
("Product".into(), "A".into()),
("_Measure".into(), "Profit".into()),
]);
let stale = owned.evaluate_aggregated(&profit, Aggregation::OverHidden);
assert!(
matches!(stale, Some(CellValue::Number(_))),
"the cache holds Profit"
);
let revenue = key("East", "A", "Revenue");
let mut rows: Vec<_> = owned
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect();
rows.iter_mut()
.filter(|(k, _)| *k == revenue)
.for_each(|(_, v)| *v = CellValue::Number(1000.0));
let over = owned.clone().over(Arc::new(TableSource { rows }));
assert_eq!(
over.formula_cell(&profit),
None,
"computed from cells that are gone"
);
assert_ne!(
over.evaluate_aggregated(&profit, Aggregation::OverHidden),
stale
);
}
+900
View File
@@ -0,0 +1,900 @@
//! Where cell values are read from: a read-only trait, and the aggregate it answers in.
//! Reasoning: `context/code/source.md` (ADR 003, ADR 009 clause 2; improvise-qddo).
use super::cell::{CellChange, CellKey, CellValue, DataStore};
use crate::formula::AggFunc;
use numtower::ExactSum;
/// The stored cells one read asks about: those that contain every pair of `base`, and, for each
/// span, one of its listed items. `exact` admits only a cell with no other coordinate.
#[derive(Debug, Clone, Copy)]
pub struct Select<'a> {
pub base: &'a [(String, String)],
pub spans: &'a [(&'a str, Vec<&'a str>)],
pub exact: bool,
}
impl<'a> Select<'a> {
/// Every cell containing `base`.
pub fn matching(base: &'a [(String, String)]) -> Self {
Select {
base,
spans: &[],
exact: false,
}
}
/// Whether the cell at `key` is one this select names.
pub fn admits(&self, key: &CellKey) -> bool {
let spanned = |(cat, items): &(&str, Vec<&str>)| {
key.get(cat).is_some_and(|held| items.contains(&held))
};
key.matches_partial(self.base)
&& self.spans.iter().all(spanned)
&& (!self.exact || key.0.len() == self.base.len() + self.spans.len())
}
/// Every cell containing `base` and one listed item of each span.
pub fn spanning(
base: &'a [(String, String)],
spans: &'a [(&'a str, Vec<&'a str>)],
exact: bool,
) -> Self {
Select { base, spans, exact }
}
}
/// What a source reports of the numbers in a read: count, exact sum, extremes. It merges.
/// Why it is enough: `context/code/source.md`.
#[derive(Debug, Clone, Default)]
pub struct Numbers {
count: u64,
sum: ExactSum,
least: Option<f64>,
greatest: Option<f64>,
}
impl Numbers {
pub fn of(values: impl IntoIterator<Item = f64>) -> Self {
let mut numbers = Numbers::default();
values.into_iter().for_each(|x| numbers.add(x));
numbers
}
/// Numbers from what an engine computed: how many, their exact sum and the least and
/// greatest that are not NaN. A state `add` cannot reach is refused, not repaired.
pub fn from_parts(
count: u64,
sum: ExactSum,
least: Option<f64>,
greatest: Option<f64>,
) -> Result<Self, BadParts> {
let bad = |why| Err(BadParts(why));
if count == 0 && sum != ExactSum::new() {
return bad("a sum with no numbers");
}
match (least, greatest) {
(None, None) if count > 0 && !sum.value().is_nan() => {
return bad("no extremes but a sum that is not NaN");
}
(None, None) => {}
(Some(_), Some(_)) if count == 0 => return bad("extremes with no numbers"),
(Some(l), Some(g)) if l.is_nan() || g.is_nan() => return bad("a NaN extreme"),
(Some(l), Some(g)) if l.total_cmp(&g).is_gt() => return bad("least above greatest"),
(Some(_), Some(_)) => {}
_ => return bad("one extreme without the other"),
}
Ok(Numbers {
count,
sum,
least,
greatest,
})
}
pub fn add(&mut self, x: f64) {
self.count += 1;
self.sum.add(x, 1);
if !x.is_nan() {
self.least = Some(self.least.map_or(x, |l| order_min(l, x)));
self.greatest = Some(self.greatest.map_or(x, |g| order_max(g, x)));
}
}
pub fn merge(&mut self, other: &Numbers) {
self.count += other.count;
self.sum.absorb(&other.sum);
self.least = merged(self.least, other.least, order_min);
self.greatest = merged(self.greatest, other.greatest, order_max);
}
pub fn count(&self) -> u64 {
self.count
}
/// The sum correctly rounded once, signed as IEEE signs a zero total; `+0.0` over none.
pub fn total(&self) -> f64 {
self.sum.value()
}
/// `None` over no numbers: an empty read is absent, not zero. `Min` and `Max` skip NaN, and
/// are NaN when nothing else is left; zeros order by sign.
pub fn reduce(&self, func: &AggFunc) -> Option<f64> {
(self.count > 0).then(|| match func {
AggFunc::Sum => self.sum.value(),
AggFunc::Avg => self.sum.mean(self.count),
AggFunc::Min => self.least.unwrap_or(f64::NAN),
AggFunc::Max => self.greatest.unwrap_or(f64::NAN),
AggFunc::Count => self.count as f64,
})
}
}
fn order_min(a: f64, b: f64) -> f64 {
if b.total_cmp(&a).is_lt() { b } else { a }
}
fn order_max(a: f64, b: f64) -> f64 {
if b.total_cmp(&a).is_gt() { b } else { a }
}
fn merged(a: Option<f64>, b: Option<f64>, pick: fn(f64, f64) -> f64) -> Option<f64> {
match (a, b) {
(Some(x), Some(y)) => Some(pick(x, y)),
(x, y) => x.or(y),
}
}
/// Parts of a summary that no sequence of values could have produced.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BadParts(pub &'static str);
/// What the values that are not numbers agree on: nothing held, one value, or disagreement.
#[derive(Debug, Clone, Default, PartialEq)]
pub enum Agreement {
#[default]
None,
One(CellValue),
Mixed,
}
/// A read of cells as a source reports it: the numbers, and whether the rest agree on one
/// value. It reduces as raw values do (core.md §2); none at all is absent.
#[derive(Debug, Clone, Default)]
pub struct Summary {
numbers: Numbers,
others: Agreement,
}
impl Summary {
pub fn of<'a>(values: impl IntoIterator<Item = &'a CellValue>) -> Self {
let mut summary = Summary::default();
values.into_iter().for_each(|v| summary.add(v));
summary
}
/// A summary from what an engine computed: the numbers, and what the rest agree on.
pub fn from_parts(numbers: Numbers, others: Agreement) -> Result<Self, BadParts> {
match &others {
Agreement::One(v) if v.as_f64().is_some() => Err(BadParts("a number among the others")),
_ => Ok(Summary { numbers, others }),
}
}
pub fn add(&mut self, value: &CellValue) {
match value.as_f64() {
Some(x) => self.numbers.add(x),
None => self.add_other(value),
}
}
fn add_other(&mut self, value: &CellValue) {
self.others = match std::mem::take(&mut self.others) {
Agreement::None => Agreement::One(value.clone()),
Agreement::One(held) if held == *value => Agreement::One(held),
_ => Agreement::Mixed,
};
}
pub fn merge(&mut self, other: &Summary) {
self.numbers.merge(&other.numbers);
match &other.others {
Agreement::None => {}
Agreement::One(v) => self.add_other(v),
Agreement::Mixed => self.others = Agreement::Mixed,
}
}
/// Only the numbers: what a formula-level aggregate pools.
pub fn numbers(&self) -> &Numbers {
&self.numbers
}
pub fn reduce(&self, func: &AggFunc) -> Option<CellValue> {
match (&self.others, self.numbers.count()) {
(Agreement::None, _) => self.numbers.reduce(func).map(CellValue::Number),
(Agreement::One(only), 0) => Some(only.clone()),
_ => Some(CellValue::Error("mixed".to_string())),
}
}
}
/// A source a model can be over and a backend can hold at once. A source that changes behind
/// its readers (a database) does so through interior mutability of its own.
pub type SharedSource = std::sync::Arc<dyn CellSource + Send + Sync>;
/// A source that reports its changes; its cells are the state as of its last drain.
/// A drain is destructive: a feed has one drainer (`context/code/source.md`).
pub trait ChangeSource: CellSource {
/// The changes since the last drain, oldest first; reads see them from now on.
fn drain_changes(&self) -> Vec<CellChange>;
}
/// A change-reporting source a backend and a model share.
pub type SharedFeed = std::sync::Arc<dyn ChangeSource + Send + Sync>;
/// The read side of cell storage (ADR 003). Nothing here writes, so a source that cannot be
/// written to is not one that fails at runtime: it has no method to fail in.
pub trait CellSource {
/// The value stored at exactly `key`.
fn value_at(&self, key: &CellKey) -> Option<CellValue>;
/// The summary of the values `select` names.
fn summarize(&self, select: &Select) -> Summary;
/// The keys of the cells containing every pair of `partial`.
fn keys<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = CellKey> + 'a>;
/// The cells containing every pair of `partial`, with their values.
fn cells<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = (CellKey, CellValue)> + 'a>;
/// How many cells `select` names, leaving out those holding any of the `excluding` pairs.
fn count(&self, select: &Select, excluding: &[(String, String)]) -> usize;
/// The key of every cell with the categories in `without` removed, each once, in the order
/// first seen: the stems a formula is evaluated at, as `SELECT DISTINCT` gives them.
fn distinct_without(&self, without: &[String]) -> Vec<CellKey>;
}
/// Whether the cell at `key` holds any of the `pairs`.
fn holds_any(key: &CellKey, pairs: &[(String, String)]) -> bool {
pairs
.iter()
.any(|(cat, item)| key.get(cat) == Some(item.as_str()))
}
impl CellSource for DataStore {
fn value_at(&self, key: &CellKey) -> Option<CellValue> {
self.get(key).cloned()
}
fn summarize(&self, select: &Select) -> Summary {
let values = match (select.spans.is_empty(), select.exact) {
(true, true) => Vec::from_iter(self.get(&CellKey::new(select.base.to_vec()))),
(true, false) => self.matching_values(select.base),
(false, _) => self.spanned_values(select.base, select.spans, select.exact),
};
Summary::of(values)
}
fn keys<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = CellKey> + 'a> {
match partial.is_empty() {
true => Box::new(self.iter_cells().map(|(key, _)| key)),
false => Box::new(self.matching_cells(partial).into_iter().map(|(key, _)| key)),
}
}
fn cells<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = (CellKey, CellValue)> + 'a> {
match partial.is_empty() {
true => Box::new(self.iter_cells().map(|(k, v)| (k, v.clone()))),
false => Box::new(
self.matching_cells(partial)
.into_iter()
.map(|(k, v)| (k, v.clone())),
),
}
}
fn count(&self, select: &Select, excluding: &[(String, String)]) -> usize {
let named = self.keys(select.base).filter(|key| select.admits(key));
named.filter(|key| !holds_any(key, excluding)).count()
}
fn distinct_without(&self, without: &[String]) -> Vec<CellKey> {
let mut seen = std::collections::HashSet::new();
let stems = self
.iter_cells()
.map(|(key, _)| without.iter().fold(key, |k, cat| k.without(cat)));
stems.filter(|stem| seen.insert(stem.clone())).collect()
}
}
/// A table of rows read by scanning, the way a query engine with no index would. A second
/// implementation of [`CellSource`] that shares no code with [`DataStore`] but the summary.
#[cfg(any(test, feature = "test-support"))]
#[derive(Debug, Clone, Default)]
pub struct TableSource {
pub rows: Vec<(CellKey, CellValue)>,
}
#[cfg(any(test, feature = "test-support"))]
impl TableSource {
fn holds(key: &CellKey, select: &Select) -> bool {
let has = |(cat, item): &(String, String)| key.get(cat) == Some(item.as_str());
let spanned = |(cat, items): &(&str, Vec<&str>)| {
key.get(cat).is_some_and(|held| items.contains(&held))
};
select.base.iter().all(has)
&& select.spans.iter().all(spanned)
&& (!select.exact || key.0.len() == select.base.len() + select.spans.len())
}
}
#[cfg(any(test, feature = "test-support"))]
impl CellSource for TableSource {
fn value_at(&self, key: &CellKey) -> Option<CellValue> {
self.rows
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
}
fn summarize(&self, select: &Select) -> Summary {
let held = self.rows.iter().filter(|(key, _)| Self::holds(key, select));
Summary::of(held.map(|(_, v)| v))
}
fn keys<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = CellKey> + 'a> {
Box::new(self.cells(partial).map(|(key, _)| key))
}
fn cells<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = (CellKey, CellValue)> + 'a> {
let select = Select::matching(partial);
let held = self.rows.iter();
Box::new(
held.filter(move |(key, _)| Self::holds(key, &select))
.cloned(),
)
}
fn count(&self, select: &Select, excluding: &[(String, String)]) -> usize {
let has = |key: &CellKey, (cat, item): &(String, String)| {
key.0.iter().any(|(c, i)| c == cat && i == item)
};
let named = self.rows.iter().filter(|(key, _)| Self::holds(key, select));
named
.filter(|(key, _)| !excluding.iter().any(|pair| has(key, pair)))
.count()
}
fn distinct_without(&self, without: &[String]) -> Vec<CellKey> {
let mut stems: Vec<CellKey> = Vec::new();
for (key, _) in &self.rows {
let kept = key.0.iter().filter(|(c, _)| !without.contains(c));
let stem = CellKey::new(kept.cloned().collect());
if !stems.contains(&stem) {
stems.push(stem);
}
}
stems
}
}
#[cfg(test)]
mod tests {
use super::*;
const FUNCS: [AggFunc; 5] = [
AggFunc::Sum,
AggFunc::Avg,
AggFunc::Min,
AggFunc::Max,
AggFunc::Count,
];
fn pool() -> Vec<CellValue> {
let text = |s: &str| CellValue::Text(s.into());
vec![
CellValue::Number(1.5),
CellValue::Number(-0.0),
CellValue::Number(0.0),
CellValue::Number(f64::NAN),
CellValue::Number(f64::INFINITY),
CellValue::Number(1e16),
CellValue::Number(1.0),
CellValue::Number(-1e16),
CellValue::Number(0.1),
CellValue::Number(0.2),
text("a"),
text("b"),
CellValue::Error("e".into()),
]
}
/// Every sequence of the pool up to `len` values.
fn sequences(len: usize) -> Vec<Vec<CellValue>> {
let mut all: Vec<Vec<CellValue>> = vec![vec![]];
let mut last = all.clone();
for _ in 0..len {
let extend = |s: &Vec<CellValue>| {
pool()
.into_iter()
.map(|v| [s.clone(), vec![v]].concat())
.collect::<Vec<_>>()
};
last = last.iter().flat_map(extend).collect();
all.extend(last.clone());
}
all
}
/// The reduction as it was before the source moved it, kept to compare against.
fn reference(values: &[&CellValue], func: &AggFunc) -> Option<CellValue> {
let numbers: Vec<f64> = values.iter().filter_map(|v| v.as_f64()).collect();
values.split_first().map(|(first, rest)| {
if numbers.len() == values.len() {
let mut sum = ExactSum::new();
numbers.iter().for_each(|v| sum.add(*v, 1));
let extreme = |order: fn(&f64, &f64) -> std::cmp::Ordering| {
let finite = numbers.iter().copied().filter(|v| !v.is_nan());
finite.min_by(order).unwrap_or(f64::NAN)
};
CellValue::Number(match func {
AggFunc::Sum => sum.value(),
AggFunc::Avg => sum.mean(numbers.len() as u64),
AggFunc::Min => extreme(f64::total_cmp),
AggFunc::Max => extreme(|a, b| b.total_cmp(a)),
AggFunc::Count => numbers.len() as f64,
})
} else if rest.iter().all(|v| v == first) {
(*first).clone()
} else {
CellValue::Error("mixed".to_string())
}
})
}
fn same(a: &Option<CellValue>, b: &Option<CellValue>) -> bool {
match (a, b) {
(Some(CellValue::Number(x)), Some(CellValue::Number(y))) => {
x.to_bits() == y.to_bits() || (x.is_nan() && y.is_nan())
}
_ => a == b,
}
}
#[test]
fn a_summary_reduces_as_the_values_did() {
for values in sequences(3) {
let refs: Vec<&CellValue> = values.iter().collect();
for func in &FUNCS {
let want = reference(&refs, func);
let got = Summary::of(&values).reduce(func);
assert!(
same(&want, &got),
"{func:?} of {values:?}: {want:?} != {got:?}"
);
}
}
}
#[test]
fn a_merge_of_summaries_is_the_summary_of_the_union() {
let all = sequences(2);
for (a, b) in all.iter().flat_map(|a| all.iter().map(move |b| (a, b))) {
let mut merged = Summary::of(a);
merged.merge(&Summary::of(b));
let union: Vec<&CellValue> = a.iter().chain(b).collect();
for func in &FUNCS {
let (want, got) = (reference(&union, func), merged.reduce(func));
assert!(
same(&want, &got),
"{func:?} of {a:?} + {b:?}: {want:?} != {got:?}"
);
}
}
}
#[test]
fn numbers_pool_what_the_formula_aggregate_pools() {
let xs = [1e16, 1.0, -1e16, 0.1, 0.2, -0.0, 0.0, 3.0];
for split in 0..=xs.len() {
let mut pooled = Numbers::of(xs[..split].iter().copied());
pooled.merge(&Numbers::of(xs[split..].iter().copied()));
let whole = Numbers::of(xs);
for func in &FUNCS {
let (a, b) = (pooled.reduce(func), whole.reduce(func));
assert_eq!(
a.map(f64::to_bits),
b.map(f64::to_bits),
"{func:?} split at {split}"
);
}
assert_eq!(
pooled.total().to_bits(),
whole.total().to_bits(),
"split at {split}"
);
}
assert_eq!(Numbers::default().total().to_bits(), 0.0f64.to_bits());
assert_eq!(Numbers::default().reduce(&AggFunc::Sum), None);
}
#[test]
fn the_exact_sum_of_a_pool_of_cancelling_values_is_not_the_sum_of_rounded_parts() {
let rounded = [1e16, 1.0].iter().sum::<f64>() + [-1e16].iter().sum::<f64>();
assert_eq!(
rounded, 0.0,
"the fixture is inexact: rounded parts lose the 1"
);
for (first, second) in [([1e16, 1.0], [-1e16, 0.0]), ([-1e16, 0.0], [1e16, 1.0])] {
let mut pooled = Numbers::of(first);
pooled.merge(&Numbers::of(second));
assert_eq!(pooled.total(), 1.0, "{first:?} then {second:?}");
}
}
fn pair(c: &str, i: &str) -> (String, String) {
(c.to_string(), i.to_string())
}
/// Rows of several key shapes over three categories, with text among the numbers.
fn rows() -> Vec<(CellKey, CellValue)> {
let mut rows = Vec::new();
let records = [("E", "A"), ("E", "B"), ("W", "A"), ("W", "B")];
for (n, (r, p)) in records.iter().enumerate() {
for (m, v) in [("Rev", n as f64 + 1.0), ("Cost", 0.5 * n as f64)] {
let key = CellKey::new(vec![pair("R", r), pair("P", p), pair("_Measure", m)]);
rows.push((key, CellValue::Number(v)));
}
}
let note = CellKey::new(vec![pair("R", "E"), pair("_Measure", "Note")]);
rows.push((note, CellValue::Text("x".into())));
let lone = CellKey::new(vec![pair("R", "W"), pair("_Measure", "Rev")]);
rows.push((lone, CellValue::Number(100.0)));
rows
}
fn store_of(rows: &[(CellKey, CellValue)]) -> DataStore {
let mut store = DataStore::new();
rows.iter()
.for_each(|(k, v)| store.set(k.clone(), v.clone()));
store
}
type Spans = Vec<(String, Vec<String>)>;
/// Every subset of every stored key's pairs as a base, each with no span, with one
/// category spanned over each of several item lists, and with two spanned.
fn selects(rows: &[(CellKey, CellValue)]) -> Vec<(Vec<(String, String)>, Spans)> {
let lists: Vec<Vec<String>> = [
vec!["E"],
vec!["E", "W"],
vec!["A", "B"],
vec!["Rev", "Note"],
vec!["Cost", "Rev", "Note"],
vec!["none"],
]
.iter()
.map(|l| l.iter().map(|s| s.to_string()).collect())
.collect();
let mut out = Vec::new();
for (key, _) in rows {
for mask in 0..(1u32 << key.0.len()) {
let keep = |(i, _): &(usize, &(String, String))| mask >> i & 1 == 1;
let base: Vec<_> = key.0.iter().enumerate().filter(keep).collect();
let base: Vec<_> = base.into_iter().map(|(_, p)| p.clone()).collect();
out.push((base.clone(), vec![]));
let all = ["R", "P", "_Measure"].map(String::from);
let free: Vec<_> = all
.iter()
.filter(|c| base.iter().all(|(b, _)| b != *c))
.collect();
for cat in free.iter().copied() {
for list in &lists {
out.push((base.clone(), vec![(cat.clone(), list.clone())]));
for other in free.iter().copied().filter(|o| *o != cat) {
let both = vec![
(cat.clone(), list.clone()),
(other.clone(), lists[1].clone()),
];
out.push((base.clone(), both));
}
}
}
}
}
out
}
#[test]
fn the_store_and_a_scanned_table_answer_every_select_alike() {
let rows = rows();
let (store, table) = (store_of(&rows), TableSource { rows: rows.clone() });
let mut asked = 0;
for (base, spans) in selects(&rows) {
let spans: Vec<(&str, Vec<&str>)> = spans
.iter()
.map(|(c, i)| (c.as_str(), i.iter().map(String::as_str).collect()))
.collect();
for exact in [false, true] {
let select = Select::spanning(&base, &spans, exact);
for func in &FUNCS {
let a = store.summarize(&select).reduce(func);
let b = table.summarize(&select).reduce(func);
assert!(
same(&a, &b),
"{func:?} {base:?} {spans:?} exact={exact}: {a:?} != {b:?}"
);
asked += 1;
}
}
}
assert!(asked > 5000, "the enumeration shrank to {asked}");
}
#[test]
fn the_store_and_a_scanned_table_list_the_same_cells() {
let rows = rows();
let (store, table) = (store_of(&rows), TableSource { rows: rows.clone() });
for (base, _) in selects(&rows) {
let sorted = |source: &dyn CellSource| {
let mut cells: Vec<_> = source.cells(&base).collect();
cells.sort_by(|a, b| a.0.cmp(&b.0));
cells
};
assert_eq!(sorted(&store), sorted(&table), "cells under {base:?}");
let mut keys: Vec<_> = store.keys(&base).collect();
keys.sort();
let listed: Vec<_> = sorted(&table).into_iter().map(|c| c.0).collect();
assert_eq!(keys, listed, "keys under {base:?}");
}
for (key, value) in &rows {
assert_eq!(store.value_at(key).as_ref(), Some(value));
assert_eq!(table.value_at(key).as_ref(), Some(value));
}
let absent = CellKey::new(vec![pair("R", "nowhere")]);
assert_eq!(
(store.value_at(&absent), table.value_at(&absent)),
(None, None)
);
}
#[test]
fn an_exact_select_with_no_span_names_one_cell() {
let store = store_of(&rows());
let note = vec![pair("R", "E"), pair("_Measure", "Note")];
let one = store.summarize(&Select::spanning(&note, &[], true));
assert_eq!(one.reduce(&AggFunc::Sum), Some(CellValue::Text("x".into())));
let wide = vec![pair("R", "E")];
let loose = store.summarize(&Select::matching(&wide));
let mixed = Some(CellValue::Error("mixed".into()));
assert_eq!(
loose.reduce(&AggFunc::Sum),
mixed,
"E holds text and numbers"
);
let exact = store.summarize(&Select::spanning(&wide, &[], true));
assert_eq!(
exact.reduce(&AggFunc::Sum),
None,
"no cell is keyed by R=E alone"
);
}
/// Every way to hide items of the fixture: none, each item of each category, and two at once.
fn hidden_sets() -> Vec<Vec<(String, String)>> {
let items = [("R", "E"), ("R", "W"), ("P", "A"), ("_Measure", "Note")];
let mut sets = vec![vec![]];
for (i, a) in items.iter().enumerate() {
sets.push(vec![pair(a.0, a.1)]);
for b in &items[i + 1..] {
sets.push(vec![pair(a.0, a.1), pair(b.0, b.1)]);
}
}
sets
}
#[test]
fn the_store_and_a_scanned_table_count_every_select_alike() {
let rows = rows();
let (store, table) = (store_of(&rows), TableSource { rows: rows.clone() });
let (mut asked, mut nonzero) = (0, 0);
for (base, spans) in selects(&rows) {
let spans: Vec<(&str, Vec<&str>)> = spans
.iter()
.map(|(c, i)| (c.as_str(), i.iter().map(String::as_str).collect()))
.collect();
for exact in [false, true] {
let select = Select::spanning(&base, &spans, exact);
for hidden in hidden_sets() {
let (a, b) = (store.count(&select, &hidden), table.count(&select, &hidden));
assert_eq!(a, b, "{base:?} {spans:?} exact={exact} hiding {hidden:?}");
asked += 1;
nonzero += usize::from(a > 0);
}
}
}
assert!(asked > 20_000, "the enumeration shrank to {asked}");
assert!(nonzero > 2_000, "{nonzero} of {asked} counts were not zero");
}
#[test]
fn hiding_an_item_removes_exactly_the_cells_that_hold_it() {
let rows = rows();
let store = store_of(&rows);
let none = Select::matching(&[]);
assert_eq!(store.count(&none, &[]), rows.len());
let east = [pair("R", "E")];
let held = rows.iter().filter(|(k, _)| k.get("R") == Some("E")).count();
assert_eq!(store.count(&none, &east), rows.len() - held);
assert_eq!(
store.count(&Select::matching(&east), &east),
0,
"a named hidden item matches nothing"
);
}
#[test]
fn the_store_and_a_scanned_table_list_the_same_stems() {
let rows = rows();
let (store, table) = (store_of(&rows), TableSource { rows: rows.clone() });
let cats = ["R", "P", "_Measure", "Nowhere"].map(String::from);
let mut asked = 0;
for mask in 0..(1u32 << cats.len()) {
let without: Vec<String> = cats
.iter()
.enumerate()
.filter(|(i, _)| mask >> i & 1 == 1)
.map(|(_, c)| c.clone())
.collect();
let (a, b) = (
store.distinct_without(&without),
table.distinct_without(&without),
);
assert_eq!(a, b, "without {without:?}, in first-seen order");
let mut sorted = a.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), a.len(), "each stem once");
asked += a.len();
}
assert!(asked > 40, "only {asked} stems were compared");
}
/// The parts an engine would compute for `values`, found without `Summary::of`.
fn parts_of(values: &[CellValue]) -> Summary {
let numbers: Vec<f64> = values.iter().filter_map(|v| v.as_f64()).collect();
let mut sum = ExactSum::new();
numbers.iter().for_each(|x| sum.add(*x, 1));
let finite = || numbers.iter().copied().filter(|x| !x.is_nan());
let least = finite().min_by(f64::total_cmp);
let greatest = finite().max_by(f64::total_cmp);
let rest: Vec<&CellValue> = values.iter().filter(|v| v.as_f64().is_none()).collect();
let agreement = match rest.split_first() {
None => Agreement::None,
Some((first, others)) if others.iter().all(|v| v == first) => {
Agreement::One((*first).clone())
}
Some(_) => Agreement::Mixed,
};
let parts = Numbers::from_parts(numbers.len() as u64, sum, least, greatest).unwrap();
Summary::from_parts(parts, agreement).unwrap()
}
#[test]
fn a_summary_built_from_an_engines_parts_reduces_as_one_built_from_the_values() {
for values in sequences(3) {
let refs: Vec<&CellValue> = values.iter().collect();
for func in &FUNCS {
let want = reference(&refs, func);
let got = parts_of(&values).reduce(func);
assert!(
same(&want, &got),
"{func:?} of {values:?}: {want:?} != {got:?}"
);
}
}
}
#[test]
fn summaries_built_from_parts_merge_as_the_union_does() {
let all = sequences(2);
for (a, b) in all.iter().flat_map(|a| all.iter().map(move |b| (a, b))) {
let mut merged = parts_of(a);
merged.merge(&Summary::of(b));
let mut both = parts_of(a);
both.merge(&parts_of(b));
let union: Vec<&CellValue> = a.iter().chain(b).collect();
for func in &FUNCS {
let want = reference(&union, func);
assert!(
same(&want, &merged.reduce(func)),
"{func:?} of {a:?} + {b:?}"
);
assert!(
same(&want, &both.reduce(func)),
"{func:?} of {a:?} + {b:?}, both from parts"
);
}
}
}
#[test]
fn parts_no_sequence_of_values_could_produce_are_refused() {
let sum = ExactSum::new;
let refused = |r: Result<Numbers, BadParts>| r.expect_err("must be refused").0;
assert_eq!(
refused(Numbers::from_parts(0, sum(), Some(1.0), Some(1.0))),
"extremes with no numbers"
);
assert_eq!(
refused(Numbers::from_parts(2, sum(), Some(f64::NAN), Some(1.0))),
"a NaN extreme"
);
assert_eq!(
refused(Numbers::from_parts(2, sum(), Some(2.0), Some(1.0))),
"least above greatest"
);
assert_eq!(
refused(Numbers::from_parts(2, sum(), Some(0.0), Some(-0.0))),
"least above greatest"
);
assert_eq!(
refused(Numbers::from_parts(2, sum(), Some(1.0), None)),
"one extreme without the other"
);
let mut one = ExactSum::new();
one.add(1.0, 1);
assert_eq!(
refused(Numbers::from_parts(0, one.clone(), None, None)),
"a sum with no numbers"
);
assert_eq!(
refused(Numbers::from_parts(2, one, None, None)),
"no extremes but a sum that is not NaN"
);
let mut nan = ExactSum::new();
nan.add(f64::NAN, 1);
let ok = Numbers::from_parts(2, nan, None, None).unwrap();
assert!(
ok.reduce(&AggFunc::Min).unwrap().is_nan(),
"all NaN: no extremes, a count"
);
let number = Agreement::One(CellValue::Number(1.0));
let bad = Summary::from_parts(ok.clone(), number).expect_err("a number is not an other");
assert_eq!(bad.0, "a number among the others");
assert!(Summary::from_parts(ok, Agreement::One(CellValue::Text("t".into()))).is_ok());
}
#[test]
fn keys_of_a_store_are_its_cells_keys_without_the_values() {
let rows = rows();
let store = store_of(&rows);
let all: Vec<CellKey> = store.keys(&[]).collect();
assert_eq!(all, rows.iter().map(|(k, _)| k.clone()).collect::<Vec<_>>());
let east = vec![pair("R", "E")];
let under: Vec<CellKey> = store.keys(&east).collect();
let want: Vec<CellKey> = store.cells(&east).map(|c| c.0).collect();
assert_eq!(under, want);
assert!(!under.is_empty());
}
}
+68 -80
View File
@@ -5,8 +5,11 @@ use indexmap::{IndexMap, IndexSet};
use super::category::{Category, CategoryId, CategoryKind, MEASURE_CATEGORY};
use super::cell::{CellKey, CellValue, DataStore};
use super::source::{CellSource, Numbers, Select, SharedSource};
use crate::formula::{AggFunc, Expr, Filter, Formula, FormulaId};
const READ_ONLY: &str = "a model over a source is read-only: the owned store is not read";
/// Regular categories a model may hold; labels and virtuals do not count.
/// Why 24 and what it costs: context/code/core.md §9 (improvise-felo.1).
pub const MAX_CATEGORIES: usize = 24;
@@ -15,30 +18,6 @@ pub const MAX_CATEGORIES: usize = 24;
/// They must agree; why, and why 1e-10: context/code/core.md §4.
const FLOAT_EQ_EPSILON: f64 = 1e-10;
/// The sum correctly rounded once, whatever the order (ADR 009 clause 8), signed as IEEE
/// signs a zero total.
fn exact_sum(values: &[f64]) -> f64 {
exact(values).value()
}
/// The mean correctly rounded once: the exact total over the count, not a rounded sum divided.
fn exact_mean(values: &[f64]) -> f64 {
exact(values).mean(values.len() as u64)
}
fn exact(values: &[f64]) -> numtower::ExactSum {
let mut sum = numtower::ExactSum::new();
values.iter().for_each(|v| sum.add(*v, 1));
sum
}
/// The least of `values` under `order`, skipping NaN; NaN if nothing else is left. Zeros order
/// by sign (`-0.0` below `+0.0`), so the answer does not depend on the order or the platform.
fn extreme(values: &[f64], order: impl Fn(&f64, &f64) -> std::cmp::Ordering) -> f64 {
let numbers = values.iter().copied().filter(|v| !v.is_nan());
numbers.min_by(order).unwrap_or(f64::NAN)
}
/// Whether two cached cells are the same value for a fixed point: numbers by bits, so a zero
/// that changes sign is a change, except that every NaN is one value, so a NaN can settle.
pub fn settled(a: Option<&CellValue>, b: Option<&CellValue>) -> bool {
@@ -52,36 +31,16 @@ pub fn settled(a: Option<&CellValue>, b: Option<&CellValue>) -> bool {
/// Reduce raw values. `None` on empty: an empty cell reads as **absent**, not
/// as zero. Both aggregation paths share it (improvise-kdp, core.md §2).
#[cfg(test)]
fn apply_agg(values: &[f64], func: &AggFunc) -> Option<f64> {
(!values.is_empty()).then(|| match func {
AggFunc::Sum => exact_sum(values),
AggFunc::Avg => exact_mean(values),
AggFunc::Min => extreme(values, f64::total_cmp),
AggFunc::Max => extreme(values, |a, b| b.total_cmp(a)),
AggFunc::Count => values.len() as f64,
})
}
/// Reduce a cell's raw values. Numbers through [`apply_agg`]; anything else
/// by **unanimity**, else `Error("mixed")`. Branch order matters — §2.
fn aggregate_cell_values(values: &[&CellValue], func: &AggFunc) -> Option<CellValue> {
let numbers: Vec<f64> = values.iter().filter_map(|v| v.as_f64()).collect();
values.split_first().and_then(|(first, rest)| {
if numbers.len() == values.len() {
apply_agg(&numbers, func).map(CellValue::Number)
} else if rest.iter().all(|v| v == first) {
Some((*first).clone())
} else {
Some(CellValue::Error("mixed".to_string()))
}
})
Numbers::of(values.iter().copied()).reduce(func)
}
/// The member records behind `partial`, in `DataStore` insertion order and
/// without repeats — the coordinate tuples minus `_Measure` (§13).
fn member_records(model: &Model, partial: &CellKey) -> Vec<CellKey> {
let mut seen: IndexSet<CellKey> = IndexSet::new();
for (key, _) in model.data.matching_cells(&partial.0) {
for key in model.source().keys(&partial.0) {
seen.insert(key.without(MEASURE_CATEGORY));
}
seen.into_iter().collect()
@@ -107,16 +66,16 @@ fn eval_agg(
None => c.clone(),
})
.collect();
let values = distinct
.iter()
.map(|c| agg_values(model, c, target_category, inner, agg_filter, eval_at))
.collect::<Result<Vec<_>, String>>()?
.concat();
let mut pooled = Numbers::default();
for context in &distinct {
let found = agg_values(model, context, target_category, inner, agg_filter, eval_at)?;
pooled.merge(&found);
}
match func {
AggFunc::Sum => Ok(exact_sum(&values)),
AggFunc::Count => Ok(values.len() as f64),
AggFunc::Sum => Ok(pooled.total()),
AggFunc::Count => Ok(pooled.count() as f64),
AggFunc::Avg | AggFunc::Min | AggFunc::Max => {
apply_agg(&values, func).ok_or_else(|| "empty".to_string())
pooled.reduce(func).ok_or_else(|| "empty".to_string())
}
}
}
@@ -130,7 +89,7 @@ fn agg_values(
inner: &Expr,
agg_filter: Option<&Filter>,
eval_at: &dyn Fn(&Expr, &CellKey) -> Result<f64, String>,
) -> Result<Vec<f64>, String> {
) -> Result<Numbers, String> {
let mut partial = context.without(target_category);
// The fast path is the fold specialised to a ref that names a STORED
// cell; where it does not, the premise fails — §13, s08t, s2w3.
@@ -156,11 +115,10 @@ fn agg_values(
// A bare ref asks for the values that exist; anything else asks the
// expression of each member record and folds the answers — §13.
true => Ok(model
.data
.matching_values(&partial.0)
.into_iter()
.filter_map(|v| v.as_f64())
.collect()),
.source()
.summarize(&Select::matching(&partial.0))
.numbers()
.clone()),
false => member_records(model, &partial)
.into_iter()
.map(|record| {
@@ -170,7 +128,8 @@ fn agg_values(
.fold(partial.clone(), |k, (cat, item)| k.with(cat, item));
eval_at(inner, &at)
})
.collect(),
.collect::<Result<Vec<_>, String>>()
.map(Numbers::of),
}
}
@@ -405,6 +364,8 @@ pub struct Model {
pub measure_agg: HashMap<String, AggFunc>,
/// Cached formula evaluation results. Recomputed by `recompute_formulas`.
formula_cache: HashMap<CellKey, CellValue>,
/// Where raw cells are read from instead of `data`, for a model over a live source.
external: Option<SharedSource>,
}
/// Every field, with the two `HashMap`s in key order: a recompute refills the
@@ -420,6 +381,7 @@ impl std::fmt::Debug for Model {
next_category_id,
measure_agg,
formula_cache,
external,
} = self;
let aggs: std::collections::BTreeMap<_, _> = measure_agg.iter().collect();
let mut cache: Vec<_> = formula_cache.iter().collect();
@@ -432,6 +394,7 @@ impl std::fmt::Debug for Model {
.field("next_category_id", next_category_id)
.field("measure_agg", &aggs)
.field("formula_cache", &cache)
.field("external", &external.is_some())
.finish()
}
}
@@ -463,9 +426,24 @@ impl Model {
next_category_id: 3,
measure_agg: HashMap::new(),
formula_cache: HashMap::new(),
external: None,
}
}
/// This model, reading its raw cells from `source` and not `data`: read-only. Its own
/// cells are dropped, and the formula cells computed from them, so no stale copy is left to read. Why: `context/code/source.md`.
pub fn over(mut self, source: SharedSource) -> Self {
self.data = DataStore::new();
self.formula_cache.clear();
self.external = Some(source);
self
}
/// Refuses, in every build, a write the source would never see.
fn writable(&self) {
assert!(self.external.is_none(), "{READ_ONLY}");
}
/// Add a pivot category, enforcing `MAX_CATEGORIES` over regular ones.
/// The caller registers it on every view — normally `Workbook`.
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
@@ -509,6 +487,7 @@ impl Model {
/// Remove a category, its cells and the formulas targeting it. The caller
/// removes it from the views — normally `Workbook`.
pub fn remove_category(&mut self, name: &str) {
self.writable();
if !self.categories.contains_key(name) || CategoryKind::of_name(name).is_virtual() {
return;
}
@@ -531,6 +510,7 @@ impl Model {
/// An item, the formulas targeting it and the cells naming it. The sweep
/// is `remove_category`'s one level down — `context/code/core.md` §9.
pub fn remove_item(&mut self, cat_name: &str, item_name: &str) {
self.writable();
if let Some(cat) = self.categories.get_mut(cat_name) {
cat.remove_item(item_name);
}
@@ -581,10 +561,12 @@ impl Model {
}
pub fn set_cell(&mut self, key: CellKey, value: CellValue) {
self.writable();
self.data.set(key, value);
}
pub fn clear_cell(&mut self, key: &CellKey) {
self.writable();
self.data.remove(key);
}
@@ -765,10 +747,24 @@ impl Model {
}
match self.formula_for(key) {
Some(formula) => self.eval_formula_depth(formula, key, depth - 1),
None => self.data.get(key).cloned(),
None => self.source().value_at(key),
}
}
/// Where raw cell values are read from: the source the model is over, else the owned store.
pub fn source(&self) -> &dyn CellSource {
match &self.external {
Some(source) => &**source,
None => &self.data,
}
}
/// `select`'s cells reduced by `key`'s measure aggregation: numbers by the function, anything
/// else by unanimity, else `Error("mixed")` (core.md §2).
fn read(&self, select: &Select, key: &CellKey) -> Option<CellValue> {
self.source().summarize(select).reduce(self.agg_for(key))
}
/// The aggregation to apply to a cell's raw values, from the measure
/// coordinate's entry in `measure_agg`. Defaults to `Sum`.
fn agg_for(&self, key: &CellKey) -> &AggFunc {
@@ -898,7 +894,7 @@ impl Model {
if let Some(cached) = self.formula_cache.get(key) {
return Some(cached.clone());
}
aggregate_cell_values(&self.data.matching_values(&key.0), self.agg_for(key))
self.read(&Select::matching(&key.0), key)
}
/// Each category `key` folds, with its covered members: a collapsed GROUP it
@@ -979,10 +975,8 @@ impl Model {
agg: Aggregation,
) -> Option<CellValue> {
let exact = agg == Aggregation::Exact;
let values = self
.data
.spanned_values(&expansion.base.0, &expansion.spans, exact);
aggregate_cell_values(&values, self.agg_for(key))
let select = Select::spanning(&expansion.base.0, &expansion.spans, exact);
self.read(&select, key)
}
/// A formula at a group key, its refs read through the fold — the value the
@@ -1042,15 +1036,9 @@ impl Model {
// (improvise-6os replaced an O(n^2) Vec::contains).
let mut stems: IndexSet<CellKey> = IndexSet::new();
for (target_cat, _) in &formula_cats {
for (key, _) in self.data.iter_cells() {
let stem = key.without(target_cat);
// Strip none_cats too: the grid queries without them.
let mut stripped = stem;
for nc in none_cats {
stripped = stripped.without(nc);
}
stems.insert(stripped);
}
// The target and the none_cats go: the grid queries without them.
let without = [std::slice::from_ref(target_cat), none_cats].concat();
stems.extend(self.source().distinct_without(&without));
}
// Fixed-point iteration.
@@ -1262,9 +1250,9 @@ impl Model {
/// Aggregate raw data (never formulas) over hidden dimensions.
fn aggregate_raw(&self, key: &CellKey, none_cats: &[String]) -> Option<CellValue> {
if none_cats.is_empty() {
return self.data.get(key).cloned();
return self.source().value_at(key);
}
aggregate_cell_values(&self.data.matching_values(&key.0), self.agg_for(key))
self.read(&Select::matching(&key.0), key)
}
fn eval_formula_depth(
@@ -1280,7 +1268,7 @@ impl Model {
.map(|v| v == filter.item.as_str())
.unwrap_or(false);
if !matches {
return self.data.get(context).cloned();
return self.source().value_at(context);
}
}
+28 -21
View File
@@ -91,20 +91,7 @@ pub fn drill_constraint(model: &Model, key: &CellKey) -> CellKey {
/// The frozen record list a drill into `constraint` shows. **One home**: a
/// second derivation would freeze a different order — core.md §8, gotcha 18.
pub fn drill_snapshot(model: &Model, constraint: &CellKey) -> Vec<RecordRow> {
let cells: Vec<(CellKey, CellValue)> = match constraint.0.is_empty() {
true => model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect(),
false => model
.data
.matching_cells(&constraint.0)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect(),
};
RecordRow::group(cells)
RecordRow::group(model.source().cells(&constraint.0))
}
/// The records row `record` is, as the records grid groups it; empty when no
@@ -729,13 +716,7 @@ impl GridLayout {
}
fn group_records(model: &Model, page_coords: &[(String, String)]) -> Vec<RecordRow> {
RecordRow::group(
model
.data
.matching_cells(page_coords)
.into_iter()
.map(|(k, v)| (k, v.clone())),
)
RecordRow::group(model.source().cells(page_coords))
}
/// `[coordinates…, +cat, measures…, +]`. Formula targets are not columns:
@@ -1367,6 +1348,32 @@ mod tests {
assert_eq!(layout.col_label(0), "X");
}
/// The records a records-mode grid draws come from the source the model is over.
#[test]
fn records_mode_reads_the_records_from_a_source() {
use crate::model::source::TableSource;
let mut wb = records_workbook();
let v = wb.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
let rows = wb.model.data.iter_cells().map(|(k, v)| (k, v.clone()));
let over = wb.model.clone().over(std::sync::Arc::new(TableSource {
rows: rows.collect(),
}));
let (owned, lent) = (
GridLayout::new(&wb.model, wb.active_view()),
GridLayout::new(&over, wb.active_view()),
);
assert!(lent.is_records_mode());
assert_eq!(lent.row_count(), 1, "the source holds the records");
for col in 0..owned.col_count() {
assert_eq!(lent.records_display(0, col), owned.records_display(0, col));
}
let revenue = (0..lent.col_count()).find(|&c| lent.col_label(c) == "Revenue");
let revenue = revenue.unwrap();
assert_eq!(lent.records_display(0, revenue).as_deref(), Some("100"));
}
#[test]
fn records_mode_activated_when_index_and_dim_on_axes() {
let mut wb = records_workbook();
+2
View File
@@ -13,5 +13,7 @@ numtower = { path = "../numtower" }
zset = { path = "../zset" }
[dev-dependencies]
# `TableSource`, the scanning source the pushdown tests are built on.
improvise-core = { path = "../improvise-core", features = ["test-support"] }
improvise-io = { path = "../improvise-io" }
proptest = "1"
+7 -15
View File
@@ -1,6 +1,6 @@
//! Today's evaluator behind the trait. Temporary (ADR 009 clause 2).
use crate::{Derived, Edit, Maintained, Reading};
use crate::{Derived, Edit, Maintained, Reading, reads};
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::{Aggregation, Model};
@@ -36,27 +36,19 @@ impl Eager {
impl Derived for Eager {
fn value(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
self.model.evaluate_aggregated(key, agg)
reads::value(&self.model, key, agg)
}
fn raw_fold(&self, partial: &CellKey) -> Option<CellValue> {
match self.model.formula_for(partial) {
Some(_) => None,
None => self
.model
.evaluate_aggregated(partial, Aggregation::OverHidden),
}
reads::raw_fold(&self.model, partial)
}
fn covered(&self, key: &CellKey, reading: &Reading) -> Option<CellValue> {
let covers = |cat: &str, item: &str| reading.covers(cat, item);
self.model.evaluate_covering(key, reading.agg, &covers)
reads::covered(&self.model, key, reading)
}
fn total(&self, key: &CellKey, over: &[String], reading: &Reading) -> Option<CellValue> {
let covers = |cat: &str, item: &str| reading.covers(cat, item);
self.model
.evaluate_spanning(key, over, reading.agg, &covers)
reads::total(&self.model, key, over, reading)
}
fn occupancy(&self, partial: &CellKey, reading: &Reading) -> usize {
@@ -67,9 +59,9 @@ impl Derived for Eager {
held == Some(item.as_str()) || self.group_holds(cat, item, held)
})
};
let cells = self.model.data.iter_cells();
let cells = self.model.source().keys(&[]);
cells
.filter(|(k, _)| named_by(k) && k.0.iter().all(drawn))
.filter(|k| named_by(k) && k.0.iter().all(drawn))
.count()
}
}
+1 -1
View File
@@ -240,7 +240,7 @@ impl Stems {
order: Order::of(model),
wide,
};
let cells: Vec<CellKey> = model.data.iter_cells().map(|(k, _)| k).collect();
let cells: Vec<CellKey> = model.source().keys(&[]).collect();
let presence: Vec<(&CellKey, i64)> = cells.iter().map(|k| (k, 1)).collect();
stems.count(&presence);
stems
+59 -11
View File
@@ -9,6 +9,7 @@ use crate::{Derived, Edit, Maintained, Reading, cell_delta};
use improvise_core::formula::ast::AggFunc;
use improvise_core::model::category::MEASURE_CATEGORY;
use improvise_core::model::cell::{CellChange, CellKey, CellValue};
use improvise_core::model::source::{SharedFeed, SharedSource};
use improvise_core::model::types::{Covers, settled};
use improvise_core::model::{Aggregation, Model};
use std::collections::BTreeMap;
@@ -31,6 +32,8 @@ pub struct Incremental {
fingerprint: Vec<String>,
evaluation: Evaluation,
work: Meter,
/// The source the model is over, when its cells are not the model's own.
feed: Option<SharedFeed>,
}
static SUM: AggFunc = AggFunc::Sum;
@@ -38,6 +41,18 @@ static SUM: AggFunc = AggFunc::Sum;
impl Incremental {
pub fn new(mut model: Model, none_cats: Vec<String>) -> Self {
model.data.record_changes();
Self::start(model, none_cats, None)
}
/// A backend over `feed`, which holds the cells and reports their changes; `model`'s own
/// store is not read. Reasoning: `context/code/improvise-incremental.md`, step 7.
pub fn over(model: Model, feed: SharedFeed, none_cats: Vec<String>) -> Self {
let source: SharedSource = feed.clone();
let model = model.over(source);
Self::start(model, none_cats, Some(feed))
}
fn start(model: Model, none_cats: Vec<String>, feed: Option<SharedFeed>) -> Self {
let mut backend = Incremental {
model,
none_cats,
@@ -46,12 +61,37 @@ impl Incremental {
fingerprint: Vec::new(),
evaluation: Evaluation::default(),
work: Meter::default(),
feed,
};
backend.rebuild_formulas();
backend.work = Meter::default();
backend
}
/// Maintains what the feed has reported since the last drain.
pub fn sync(&mut self) {
let changes = self.drain();
self.absorb(None, &changes);
}
/// Maintains `changes`, which the source already reflects. For a source whose changes reach the
/// backend by another route than the drain.
pub fn apply_changes(&mut self, changes: &[CellChange]) {
self.absorb(None, changes);
}
fn drain(&mut self) -> Vec<CellChange> {
match &self.feed {
Some(feed) => feed.drain_changes(),
None => self.model.data.drain_changes(),
}
}
fn absorb(&mut self, edit: Option<&Edit>, changes: &[CellChange]) {
self.shapes.apply(&cell_delta(changes), changes);
self.refresh_formulas(edit, changes);
}
pub fn with_evaluation(mut self, evaluation: Evaluation) -> Self {
self.evaluation = evaluation;
self.rebuild_formulas();
@@ -100,20 +140,24 @@ impl Incremental {
let func = Reduce::of(self.func_for(key));
let expansion = Expansion::of(&self.model, key, covers, over);
match (expansion.spans.is_empty(), agg) {
(true, Aggregation::Exact) => self.model.data.get(key).cloned(),
(true, Aggregation::Exact) => self.model.source().value_at(key),
(true, Aggregation::OverHidden) => self.fold(std::slice::from_ref(key), func),
(false, Aggregation::Exact) => {
let members = expansion.members();
let cells = members.iter().filter_map(|m| self.model.data.get(m));
fold_groups(&[&Group::of(cells)], func)
let cells: Vec<CellValue> = members
.iter()
.filter_map(|m| self.model.source().value_at(m))
.collect();
fold_groups(&[&Group::of(&cells)], func)
}
(false, Aggregation::OverHidden) => self.fold(&expansion.members(), func),
}
}
fn fold(&self, keys: &[CellKey], func: Reduce) -> Option<CellValue> {
self.shapes
.read(&self.model.data, keys, |groups| fold_groups(groups, func))
self.shapes.read(self.model.source(), keys, |groups| {
fold_groups(groups, func)
})
}
/// The items a stored cell may hold in `cat` to be named by `item`: that item, or a member
@@ -134,7 +178,7 @@ impl Incremental {
fn cells_at(&self, at: &CellKey) -> i64 {
let keys = std::slice::from_ref(at);
let cells = |groups: &[&Group]| groups.first().map_or(0, |g| g.cells());
self.shapes.read(&self.model.data, keys, cells)
self.shapes.read(self.model.source(), keys, cells)
}
/// Cells at or under `at` that draw no hidden coordinate: all of them, less those with a
@@ -190,7 +234,7 @@ impl Incremental {
}
/// Brings the formula cells up to date with an edit and the store changes it made.
fn refresh_formulas(&mut self, edit: &Edit, changes: &[CellChange]) {
fn refresh_formulas(&mut self, edit: Option<&Edit>, changes: &[CellChange]) {
if self.fingerprint != self.fingerprint() {
return self.rebuild_formulas();
}
@@ -203,7 +247,7 @@ impl Incremental {
self.clear_stem(stem);
}
let mut dirty = moved.dirty;
if let Edit::SetMeasureAgg { measure, .. } = edit {
if let Some(Edit::SetMeasureAgg { measure, .. }) = edit {
dirty.extend(self.stems.carrying(measure));
if self.stems.reads_name(measure) {
dirty.extend(self.stems.live().cloned());
@@ -342,9 +386,13 @@ impl Derived for Incremental {
impl Maintained for Incremental {
fn apply(&mut self, edit: &Edit) {
let refused = self.feed.is_some() && edit.key().is_some();
assert!(
!refused,
"a fed backend is read-only: cell edits go through the source"
);
edit.apply(&mut self.model);
let changes = self.model.data.drain_changes();
self.shapes.apply(&cell_delta(&changes), &changes);
self.refresh_formulas(edit, &changes);
let changes = self.drain();
self.absorb(Some(edit), &changes);
}
}
+3
View File
@@ -9,7 +9,9 @@ mod formulas;
mod group;
mod harness;
mod incremental;
mod pushdown;
mod reading;
mod reads;
mod shapes;
pub use delta::{CellDelta, DeltaValue, cell_delta, moved_records};
@@ -20,6 +22,7 @@ pub use harness::{
drawn_probes, edit_probes, probes, same_value,
};
pub use incremental::{Evaluation, Incremental};
pub use pushdown::Pushdown;
pub use reading::Reading;
pub use shapes::Meter;
@@ -0,0 +1,98 @@
//! Derived values over a source that answers aggregate queries and reports no changes.
//! Reasoning: `context/code/improvise-incremental.md`, step 7.
use crate::{Derived, Edit, Reading, reads};
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::source::{Select, SharedSource};
use improvise_core::model::{Aggregation, Model};
/// A model whose cells are in `source`: a fold is a query, the formula cells are computed in RAM
/// by the model's own evaluator from those folds, and nothing is kept per shape or per change.
pub struct Pushdown {
model: Model,
none_cats: Vec<String>,
}
impl Pushdown {
/// `definition` supplies the categories, formulas and aggregations; its own store is not read.
pub fn new(definition: Model, source: SharedSource, none_cats: Vec<String>) -> Self {
let mut backend = Pushdown {
model: definition.over(source),
none_cats,
};
backend.refresh();
backend
}
pub fn model(&self) -> &Model {
&self.model
}
/// Computes the formula cells again from the source as it now stands; only they are held.
/// The source should serve a refresh from one snapshot: improvise-incremental.md, step 7.
pub fn refresh(&mut self) {
self.model.recompute_formulas(&self.none_cats);
}
/// A change to the definition: a formula or an aggregation. Cells belong to the source.
pub fn define(&mut self, edit: &Edit) {
let refused = edit.key().is_some();
assert!(
!refused,
"a pushdown is read-only: cell edits go through the source"
);
edit.apply(&mut self.model);
self.refresh();
}
/// The items a stored cell may hold in `cat` to be named by `item`, when `item` names a group.
fn members(&self, cat: &str, item: &str) -> Vec<String> {
let group = self
.model
.category(cat)
.filter(|c| !c.items.contains_key(item));
let members = group.map(|c| c.items_in_group(item)).unwrap_or_default();
match members.is_empty() {
true => Vec::new(),
false => std::iter::once(item)
.chain(members)
.map(String::from)
.collect(),
}
}
}
impl Derived for Pushdown {
fn value(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
reads::value(&self.model, key, agg)
}
fn raw_fold(&self, partial: &CellKey) -> Option<CellValue> {
reads::raw_fold(&self.model, partial)
}
fn covered(&self, key: &CellKey, reading: &Reading) -> Option<CellValue> {
reads::covered(&self.model, key, reading)
}
fn total(&self, key: &CellKey, over: &[String], reading: &Reading) -> Option<CellValue> {
reads::total(&self.model, key, over, reading)
}
fn occupancy(&self, partial: &CellKey, reading: &Reading) -> usize {
let (mut base, mut held) = (Vec::new(), Vec::new());
for (cat, item) in &partial.0 {
match self.members(cat, item) {
members if members.is_empty() => base.push((cat.clone(), item.clone())),
members => held.push((cat.clone(), members)),
}
}
let spans: Vec<(&str, Vec<&str>)> = held
.iter()
.map(|(cat, items)| (cat.as_str(), items.iter().map(String::as_str).collect()))
.collect();
let hidden: Vec<(String, String)> = reading.hidden().cloned().collect();
let select = Select::spanning(&base, &spans, false);
self.model.source().count(&select, &hidden)
}
}
+33
View File
@@ -0,0 +1,33 @@
//! The reads a model answers for a view, shared by every backend that leaves them to the model.
//! Reasoning: `context/code/improvise-incremental.md`, step 7.
use crate::Reading;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::{Aggregation, Model};
pub fn value(model: &Model, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
model.evaluate_aggregated(key, agg)
}
/// Never a formula: `None` for a partial naming a formula target.
pub fn raw_fold(model: &Model, partial: &CellKey) -> Option<CellValue> {
match model.formula_for(partial) {
Some(_) => None,
None => value(model, partial, Aggregation::OverHidden),
}
}
pub fn covered(model: &Model, key: &CellKey, reading: &Reading) -> Option<CellValue> {
let covers = |cat: &str, item: &str| reading.covers(cat, item);
model.evaluate_covering(key, reading.agg, &covers)
}
pub fn total(
model: &Model,
key: &CellKey,
over: &[String],
reading: &Reading,
) -> Option<CellValue> {
let covers = |cat: &str, item: &str| reading.covers(cat, item);
model.evaluate_spanning(key, over, reading.agg, &covers)
}
+10 -9
View File
@@ -3,7 +3,8 @@
use crate::group::Group;
use crate::{CellDelta, DeltaValue};
use improvise_core::model::cell::{CellChange, CellKey, CellValue, DataStore};
use improvise_core::model::cell::{CellChange, CellKey, CellValue};
use improvise_core::model::source::CellSource;
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
@@ -111,28 +112,28 @@ impl Shape {
(updates, sums)
}
/// The shape of `store`'s cells, one pass.
fn of(store: &DataStore, cats: &[String]) -> (Shape, u64) {
/// The shape of `source`'s cells, one pass.
fn of(source: &dyn CellSource, cats: &[String]) -> (Shape, u64) {
let mut shape = Shape {
groups: BTreeMap::new(),
};
let mut visited = 0;
for (key, value) in store.iter_cells() {
for (key, value) in source.cells(&[]) {
visited += 1;
let Some(at) = project(&key, cats) else {
continue;
};
let group = shape.groups.entry(at).or_default();
group.apply(&DeltaValue::new(value), 1);
group.apply(&DeltaValue::new(&value), 1);
if let CellValue::Number(x) = value {
group.add_number(*x, 1);
group.add_number(x, 1);
}
}
(shape, visited)
}
}
/// Every shape a read has asked for. A shape is built from the store on its first read (a
/// Every shape a read has asked for. A shape is built from the source on its first read (a
/// full pass) and fed every delta after.
#[derive(Default)]
pub struct Shapes {
@@ -177,7 +178,7 @@ impl Shapes {
/// shape first if no read has asked for it. A key with no group is left out.
pub fn read<R>(
&self,
store: &DataStore,
source: &dyn CellSource,
keys: &[CellKey],
read: impl FnOnce(&[&Group]) -> R,
) -> R {
@@ -192,7 +193,7 @@ impl Shapes {
);
let mut built = self.built.borrow_mut();
let shape = built.entry(cats.clone()).or_insert_with(|| {
let (shape, visited) = Shape::of(store, &cats);
let (shape, visited) = Shape::of(source, &cats);
self.bump(|m| {
m.builds += 1;
m.built_cells += visited;
@@ -0,0 +1,284 @@
//! `Incremental` over a source that reports its own changes (ADR 009 step 7, improvise-rnak).
//! Evidence: `context/test-evidence/improvise-incremental.md`. Counts, never time.
mod support;
use improvise_core::formula::ast::AggFunc;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::source::{ChangeSource, SharedFeed};
use improvise_core::model::{Aggregation, Model};
use improvise_incremental::{
Derived, Eager, Edit, Incremental, Maintained, Meter, differential_in, edit_probes, probes,
same_value,
};
use std::sync::Arc;
use support::live::{Fed, LiveTable};
use support::{corpus, edits_for, hand_model, key};
#[test]
fn a_live_source_matches_eager_over_the_corpus_and_its_edits() {
let (mut compared, mut files_done) = (0, Vec::<String>::new());
for (name, model, none, views) in corpus() {
let file = name.split(':').next().unwrap().to_string();
if files_done.contains(&file) {
continue;
}
files_done.push(file);
let mut edits = edits_for(&model);
if model.data.iter_cells().count() > 40 {
let picked = [0, 2, 4].iter().filter_map(|&i| edits.get(i).cloned());
edits = picked.collect();
}
let candidate = Fed::new(&model, none.clone());
let ran = differential_in(&model, &views, &none, candidate, &edits);
let report = ran.unwrap_or_else(|d| panic!("{name}: {d}"));
assert_eq!(report.steps, edits.len() + 1, "{name}");
compared += report.probes;
}
assert!(compared > 10_000, "only {compared} answers were compared");
}
#[test]
fn changes_named_by_the_caller_are_maintained_as_the_drain_would() {
let (model, none) = (hand_model(), vec!["Region".to_string()]);
let mut eager = Eager::new(model.clone(), none.clone());
let mut fed = Fed::new(&model, none);
let edits = [
Edit::SetCell(key("East", "A", "Revenue"), CellValue::Number(99.0)),
Edit::SetCell(key("North", "A", "Revenue"), CellValue::Number(1.0)),
Edit::ClearCell(key("West", "B", "Cost")),
Edit::SetCell(key("East", "A", "Note"), CellValue::Text("z".into())),
];
let mut questions = probes(eager.model());
let mut asked = 0;
for edit in &edits {
eager.apply(edit);
let (cell, value) = match edit {
Edit::SetCell(cell, value) => (cell, Some(value.clone())),
Edit::ClearCell(cell) => (cell, None),
_ => unreachable!("cell edits only"),
};
fed.live.write(cell.clone(), value);
let changes = fed.live.drain_changes();
assert_eq!(changes.len(), 1);
fed.inc.apply_changes(&changes);
questions.extend(probes(eager.model()));
questions.extend(edit_probes(edit));
for probe in &questions {
let (want, got) = (probe.ask(&eager), probe.ask(&fed));
assert!(
same_value(&want, &got),
"{probe:?}: eager {want:?} != fed {got:?}"
);
asked += 1;
}
}
assert!(asked > 500, "only {asked} answers were compared");
}
#[test]
fn a_write_is_invisible_until_it_is_drained() {
let model = hand_model();
let mut fed = Fed::new(&model, vec![]);
let cell = key("East", "A", "Revenue");
let before = fed.inc.value(&cell, Aggregation::Exact);
assert_eq!(before, Some(CellValue::Number(10.0)));
fed.live.write(cell.clone(), Some(CellValue::Number(50.0)));
assert_eq!(
fed.inc.value(&cell, Aggregation::Exact),
before,
"not yet drained"
);
fed.inc.sync();
assert_eq!(
fed.inc.value(&cell, Aggregation::Exact),
Some(CellValue::Number(50.0))
);
fed.inc.sync();
assert_eq!(
fed.inc.value(&cell, Aggregation::Exact),
Some(CellValue::Number(50.0))
);
}
fn row(r: usize, p: usize, m: usize) -> CellKey {
CellKey::new(vec![
("Region".into(), format!("r{r}")),
("Product".into(), format!("p{p}")),
("Month".into(), format!("m{m}")),
("_Measure".into(), "Revenue".into()),
])
}
/// `n` regions by `n` products by 6 months of one measure, with no formula.
fn table(n: usize) -> Model {
let mut model = Model::new("table");
for (cat, prefix, count) in [("Region", "r", n), ("Product", "p", n), ("Month", "m", 6)] {
model.add_category(cat).unwrap();
for i in 0..count {
model
.category_mut(cat)
.unwrap()
.add_item(format!("{prefix}{i}"));
}
}
model.category_mut("_Measure").unwrap().add_item("Revenue");
model.measure_agg.insert("Revenue".into(), AggFunc::Max);
let mut seed = 1u64;
for (r, p, m) in (0..n).flat_map(|r| (0..n).flat_map(move |p| (0..6).map(move |m| (r, p, m)))) {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
model.set_cell(
row(r, p, m),
CellValue::Number(((seed >> 33) % 1000) as f64),
);
}
model
}
fn since(before: Meter, after: Meter) -> Meter {
Meter {
updates: after.updates - before.updates,
sum_updates: after.sum_updates - before.sum_updates,
reads: after.reads - before.reads,
builds: after.builds - before.builds,
built_cells: after.built_cells - before.built_cells,
stems: after.stems - before.stems,
formula_cells: after.formula_cells - before.formula_cells,
rebuilds: after.rebuilds - before.rebuilds,
recomputes: after.recomputes - before.recomputes,
}
}
fn region_partial() -> CellKey {
CellKey::new(vec![
("Region".into(), "r1".into()),
("_Measure".into(), "Revenue".into()),
])
}
/// What one reported change costs once a shape is built, and what the source served for it.
fn change_cost(n: usize) -> (Meter, u64) {
let mut fed = Fed::new(&table(n), vec![]);
fed.inc.raw_fold(&region_partial());
let (before, served) = (fed.inc.meter(), fed.live.snapshot_rows());
assert_eq!(
served,
(n * n * 6) as u64,
"the build is one pass over the table"
);
fed.live
.write(row(1, 2, 3), Some(CellValue::Number(2000.0)));
fed.inc.sync();
let cost = since(before, fed.inc.meter());
assert_eq!(
fed.inc.raw_fold(&region_partial()),
Some(CellValue::Number(2000.0)),
"the new maximum comes from the change"
);
(cost, fed.live.snapshot_rows() - served)
}
#[test]
fn the_cost_of_a_reported_change_does_not_depend_on_the_size_of_the_table() {
let (small, small_rows) = change_cost(6);
let (large, large_rows) = change_cost(12);
assert_eq!(
(small.updates, small.sum_updates),
(2, 2),
"retract and insert"
);
assert_eq!(small, large, "216 cells and 864 cells cost the same");
assert_eq!(
(small.builds, small.built_cells),
(0, 0),
"no shape is rebuilt"
);
assert_eq!(
(small_rows, large_rows),
(0, 0),
"the source serves no snapshot for a change"
);
}
#[test]
fn an_overwrite_by_the_same_value_moves_nothing() {
let mut fed = Fed::new(&hand_model(), vec![]);
let cell = key("East", "A", "Revenue");
let revenue = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
let before = fed.inc.raw_fold(&revenue);
fed.live.write(cell, Some(CellValue::Number(10.0)));
fed.inc.sync();
assert_eq!(fed.inc.raw_fold(&revenue), before);
assert_eq!(before, Some(CellValue::Number(23.0)));
}
#[test]
fn a_backend_can_be_built_over_the_exported_feed_alias() {
let model = hand_model();
let rows = model.data.iter_cells().map(|(k, v)| (k, v.clone()));
let feed: SharedFeed = Arc::new(LiveTable::of(rows));
let mut inc = Incremental::over(model.clone(), feed, vec![]);
let revenue = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
assert_eq!(inc.raw_fold(&revenue), Some(CellValue::Number(23.0)));
inc.sync();
}
#[test]
fn a_populated_model_put_over_a_feed_keeps_no_cells_of_its_own() {
let (model, none) = (hand_model(), vec!["Region".to_string()]);
assert!(
model.data.iter_cells().count() > 0,
"the input is populated"
);
let rows = model.data.iter_cells().map(|(k, v)| (k, v.clone()));
let live = Arc::new(LiveTable::of(rows));
let inc = Incremental::over(model.clone(), live, none.clone());
assert_eq!(inc.model().data.iter_cells().count(), 0, "no stale copy");
assert_eq!(inc.model().get_cell(&key("East", "A", "Revenue")), None);
let eager = Eager::new(model, none);
for probe in probes(eager.model()) {
let (want, got) = (probe.ask(&eager), probe.ask(&inc));
assert!(
same_value(&want, &got),
"{probe:?}: eager {want:?} != fed {got:?}"
);
}
}
#[test]
#[should_panic(expected = "cell edits go through the source")]
fn a_cell_edit_on_a_fed_backend_is_refused() {
let mut fed = Fed::new(&hand_model(), vec![]);
let edit = Edit::SetCell(key("East", "A", "Revenue"), CellValue::Number(1.0));
fed.inc.apply(&edit);
}
#[test]
#[should_panic(expected = "cell edits go through the source")]
fn a_cell_clear_on_a_fed_backend_is_refused() {
let mut fed = Fed::new(&hand_model(), vec![]);
fed.inc.apply(&Edit::ClearCell(key("East", "A", "Revenue")));
}
#[test]
fn a_feed_has_one_drainer() {
let model = hand_model();
let rows = model.data.iter_cells().map(|(k, v)| (k, v.clone()));
let live = Arc::new(LiveTable::of(rows));
let mut a = Incremental::over(model.clone(), live.clone(), vec![]);
let mut b = Incremental::over(model, live.clone(), vec![]);
let revenue = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
let (before_a, before_b) = (a.raw_fold(&revenue), b.raw_fold(&revenue));
assert_eq!(before_a, before_b);
live.write(key("East", "A", "Revenue"), Some(CellValue::Number(100.0)));
a.sync();
b.sync();
let (after_a, after_b) = (a.raw_fold(&revenue), b.raw_fold(&revenue));
assert_ne!(after_a, before_a, "the first drainer saw the write");
assert_eq!(
after_b, before_b,
"the second saw nothing: a drain is destructive"
);
}
@@ -0,0 +1,355 @@
//! `Pushdown` over a remote table against `Eager`: values, hidden items, groups, formulas.
//! Without a real SQL engine only the contract is proven: `context/code/improvise-incremental.md`.
mod support;
use improvise_core::formula::ast::AggFunc;
use improvise_core::formula::parse_formula;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::source::SharedSource;
use improvise_core::model::{Aggregation, Model};
use improvise_core::view::View;
use improvise_incremental::{Derived, Edit, Probe, Reading, differential_in};
use improvise_incremental::{Eager, Pushdown};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use support::remote::{
ChangesAfterAFold, ChangesAfterStems, Hooked, IgnoresHidden, Pushed, Remote, SortsStems,
};
use support::{
corpus, edits_for, grouped_model, hand_model, hidden_counted_workbook, hidden_workbook, key,
none_of, ratio_workbook, wide_workbook,
};
fn run(model: &Model, views: &[View], none: &[String], edits: &[Edit]) -> usize {
let candidate = Pushed::new(model, none.to_vec());
match differential_in(model, views, none, candidate, edits) {
Ok(report) => report.probes,
Err(d) => panic!("{d}"),
}
}
fn agg_edits(measure: &str) -> Vec<Edit> {
let all = [
AggFunc::Count,
AggFunc::Min,
AggFunc::Max,
AggFunc::Avg,
AggFunc::Sum,
];
all.into_iter()
.map(|func| Edit::SetMeasureAgg {
measure: measure.into(),
func,
})
.collect()
}
#[test]
fn a_pushdown_matches_eager_over_the_shipped_documents() {
let (mut compared, mut files_done) = (0, Vec::<String>::new());
for (name, model, none, views) in corpus() {
let file = name.split(':').next().unwrap().to_string();
if files_done.contains(&file) {
continue;
}
files_done.push(file);
let mut edits = edits_for(&model);
if model.data.iter_cells().count() > 40 {
let picked = [0, 2, 4].iter().filter_map(|&i| edits.get(i).cloned());
edits = picked.collect();
}
compared += run(&model, &views, &none, &edits);
}
assert!(compared > 10_000, "only {compared} answers were compared");
}
#[test]
fn a_pushdown_matches_eager_on_hidden_items_and_collapsed_groups_under_every_aggregation() {
for wb in [hidden_workbook(), hidden_counted_workbook()] {
let views: Vec<View> = wb.views.values().cloned().collect();
let none = none_of(&wb);
let mut edits = edits_for(&wb.model);
edits.extend(agg_edits("Revenue"));
let hidden = key("East", "A", "Revenue");
edits.push(Edit::SetCell(hidden.clone(), CellValue::Number(f64::NAN)));
edits.push(Edit::SetCell(hidden.clone(), CellValue::Number(-0.0)));
edits.push(Edit::SetCell(hidden, CellValue::Text("t".into())));
// A cell stored under the group's own name is a cell its fold and its count both see.
let named = key("Coastal", "B", "Revenue");
edits.push(Edit::SetCell(named, CellValue::Number(4.0)));
assert!(run(&wb.model, &views, &none, &edits) > 1_000);
}
}
#[test]
fn a_pushdown_matches_eager_on_formulas_groups_and_wide_keys() {
let ratio = ratio_workbook();
let views: Vec<View> = ratio.views.values().cloned().collect();
let mut edits = edits_for(&ratio.model);
edits.extend(agg_edits("Revenue"));
run(&ratio.model, &views, &none_of(&ratio), &edits);
let hand = hand_model();
let mut edits = agg_edits("Revenue");
edits.extend(agg_edits("Note"));
run(&hand, &[], &["Region".to_string()], &edits);
run(&grouped_model(), &[], &[], &edits_for(&grouped_model()));
let wide = wide_workbook();
let views: Vec<View> = wide.views.values().cloned().collect();
run(
&wide.model,
&views,
&none_of(&wide),
&edits_for(&wide.model),
);
}
#[test]
fn a_fold_is_one_query_and_sends_no_row() {
let wb = hidden_counted_workbook();
let none = none_of(&wb);
let pushed = Pushed::new(&wb.model, none);
let at = |name: &str| key("Inland", name, "Revenue");
let before = pushed.remote.asked();
pushed.pd.raw_fold(&CellKey::new(vec![
("Product".into(), "A".into()),
("_Measure".into(), "Revenue".into()),
]));
let reading = Reading::of_view(wb.active_view(), &none_of(&wb));
let coastal = key("Coastal", "A", "Revenue");
pushed.pd.covered(&coastal, &reading);
let total = key("Inland", "A", "Revenue").without("Region");
pushed.pd.total(&total, &["Region".to_string()], &reading);
let spent = pushed.remote.asked();
assert_eq!(spent.summaries - before.summaries, 3, "one query per fold");
assert_eq!(spent.rows_sent, before.rows_sent, "and no row sent back");
let one = pushed.pd.value(&at("A"), Aggregation::Exact);
assert_eq!(one, Some(CellValue::Number(2.0)));
assert_eq!(pushed.remote.asked().lookups - spent.lookups, 1);
}
#[test]
fn occupancy_is_one_count_whatever_the_groups_and_hidden_items() {
let wb = hidden_workbook();
let pushed = Pushed::new(&wb.model, none_of(&wb));
let reading = Reading::of_view(wb.active_view(), &none_of(&wb));
let before = pushed.remote.asked();
let coastal = CellKey::new(vec![
("Region".into(), "Coastal".into()),
("Product".into(), "A".into()),
]);
let seen = pushed.pd.occupancy(&coastal, &reading);
let spent = pushed.remote.asked();
assert_eq!(seen, 1, "East is hidden, West holds A");
assert_eq!(spent.counts - before.counts, 1);
assert_eq!(spent.rows_sent, before.rows_sent);
}
#[test]
fn formula_cells_are_computed_from_the_distinct_stems_not_from_every_cell() {
let model = hand_model();
let cells = model.data.iter_cells().count() as u64;
let pushed = Pushed::new(&model, vec!["Region".to_string()]);
let asked = pushed.remote.asked();
assert_eq!(asked.stem_lists, 1, "one list for the one formula");
assert_eq!(
asked.rows_sent, 2,
"Product A and Product B, of {cells} cells"
);
assert_eq!((asked.key_lists, asked.cell_lists), (0, 0));
assert!(
asked.summaries > 0,
"and the folds the formula reads are queries"
);
}
#[test]
fn a_stored_cell_is_current_and_a_formula_cell_waits_for_a_refresh() {
let mut pushed = Pushed::new(&hand_model(), vec!["Region".to_string()]);
let revenue = key("East", "A", "Revenue");
let profit = CellKey::new(vec![
("Product".into(), "A".into()),
("_Measure".into(), "Profit".into()),
]);
let fold = |p: &Pushed| p.pd.value(&profit, Aggregation::OverHidden);
assert_eq!(fold(&pushed), Some(CellValue::Number(9.0)));
pushed
.remote
.write(revenue.clone(), Some(CellValue::Number(50.0)));
let now = pushed.pd.value(&revenue, Aggregation::Exact);
assert_eq!(now, Some(CellValue::Number(50.0)), "a read is a query");
assert_eq!(
fold(&pushed),
Some(CellValue::Number(9.0)),
"the cache is stale"
);
pushed.pd.refresh();
assert_eq!(fold(&pushed), Some(CellValue::Number(49.0)));
}
#[test]
fn a_source_that_forgets_the_hidden_items_is_caught_as_a_divergence() {
let wb = hidden_workbook();
let views: Vec<View> = wb.views.values().cloned().collect();
let none = none_of(&wb);
let faulty = Pushed::through(&wb.model, none.clone(), IgnoresHidden);
let ran = differential_in(&wb.model, &views, &none, faulty, &[]);
let divergence = ran.expect_err("a wrong hidden filter must not pass");
assert!(
matches!(*divergence.probe, Probe::Occupancy(..)),
"{divergence}"
);
let sound = Pushed::new(&wb.model, none.clone());
assert!(differential_in(&wb.model, &views, &none, sound, &[]).is_ok());
}
/// Eighteen products stored in chain order and named to sort the other way, and a formula that
/// reads the product before it: one pass along the chain, one per link against it.
fn chain() -> Model {
let name = |k: usize| format!("n{}", 100 - k);
let mut model = Model::new("chain");
model.add_category("Product").unwrap();
model.category_mut("_Measure").unwrap().add_item("Revenue");
for k in 1..=18 {
model.category_mut("Product").unwrap().add_item(name(k));
let cell = CellKey::new(vec![
("Product".into(), name(k)),
("_Measure".into(), "Revenue".into()),
]);
model.set_cell(cell, CellValue::Number(k as f64));
}
let mut text = "0".to_string();
for k in (2..=18).rev() {
text = format!(
"IF((Product IS {}) > 0, {} + 1, {text})",
name(k),
name(k - 1)
);
}
text = format!("IF((Product IS {}) > 0, 1, {text})", name(1));
model.add_formula(parse_formula(&format!("Chain = {text}"), "_Measure").unwrap());
model
}
#[test]
fn the_order_stems_are_listed_in_decides_a_result_today() {
let model = chain();
let last = CellKey::new(vec![
("Product".into(), "n82".into()),
("_Measure".into(), "Chain".into()),
]);
let eager = Eager::new(model.clone(), vec![]);
let want = eager.value(&last, Aggregation::OverHidden);
assert_eq!(
want,
Some(CellValue::Number(18.0)),
"along the chain it settles"
);
let sound = Pushed::new(&model, vec![]);
assert_eq!(sound.pd.value(&last, Aggregation::OverHidden), want);
let ran = differential_in(&model, &[], &[], sound, &[]);
assert!(ran.is_ok(), "{}", ran.unwrap_err());
let sorted = Pushed::through(&model, vec![], |remote| Hooked(remote, SortsStems));
let got = sorted.pd.value(&last, Aggregation::OverHidden);
assert_eq!(
got,
Some(CellValue::Error("circular".into())),
"against it, the bound of 16 passes"
);
let ran = differential_in(&model, &[], &[], sorted, &[]);
assert!(ran.is_err(), "a sorted stem list must be a divergence");
}
type Change = Box<dyn Fn(&Remote) + Send + Sync>;
/// Another client's writes: Product A's revenue and cost move, and Product C appears.
fn another_clients_writes() -> Change {
Box::new(|remote| {
remote.write(key("East", "A", "Revenue"), Some(CellValue::Number(50.0)));
remote.write(key("East", "A", "Cost"), Some(CellValue::Number(0.0)));
remote.write(key("East", "C", "Revenue"), Some(CellValue::Number(7.0)));
remote.write(key("East", "C", "Cost"), Some(CellValue::Number(2.0)));
})
}
fn profit_of(product: &str) -> CellKey {
CellKey::new(vec![
("Product".into(), product.into()),
("_Measure".into(), "Profit".into()),
])
}
#[test]
fn writes_between_two_queries_of_a_refresh_are_healed_by_the_fixed_point() {
let none = vec!["Region".to_string()];
let armed = Arc::new(AtomicBool::new(false));
let hook = ChangesAfterAFold {
armed: armed.clone(),
change: another_clients_writes(),
};
let none_cats = vec!["Region".to_string()];
let mut pushed = Pushed::through(&hand_model(), none_cats, |r| Hooked(r, hook));
assert_eq!(
pushed.pd.value(&profit_of("A"), Aggregation::OverHidden),
Some(CellValue::Number(9.0))
);
armed.store(true, Ordering::SeqCst);
pushed.pd.refresh();
assert!(
!armed.load(Ordering::SeqCst),
"the change happened mid-refresh"
);
let mut after = hand_model();
after.set_cell(key("East", "A", "Revenue"), CellValue::Number(50.0));
after.set_cell(key("East", "A", "Cost"), CellValue::Number(0.0));
let truth = Eager::new(after, none);
let want = truth.value(&profit_of("A"), Aggregation::OverHidden);
assert_eq!(want, Some(CellValue::Number(53.0)));
let got = pushed.pd.value(&profit_of("A"), Aggregation::OverHidden);
assert_eq!(
got, want,
"the last pass read the new state: 55 - 2, and not 13"
);
}
#[test]
fn a_stem_that_appears_between_the_stem_list_and_the_folds_waits_for_the_next_refresh() {
let armed = Arc::new(AtomicBool::new(false));
let hook = ChangesAfterStems {
armed: armed.clone(),
change: another_clients_writes(),
};
let none_cats = vec!["Region".to_string()];
let mut pushed = Pushed::through(&hand_model(), none_cats, |r| Hooked(r, hook));
armed.store(true, Ordering::SeqCst);
pushed.pd.refresh();
assert_eq!(
pushed.pd.value(&profit_of("C"), Aggregation::OverHidden),
None,
"the stem list was taken before Product C had cells"
);
pushed.pd.refresh();
assert_eq!(
pushed.pd.value(&profit_of("C"), Aggregation::OverHidden),
Some(CellValue::Number(5.0)),
"and the next refresh has it"
);
}
#[test]
#[should_panic(expected = "cell edits go through the source")]
fn a_cell_edit_on_a_pushdown_is_refused() {
let mut pushed = Pushed::new(&hand_model(), vec![]);
let edit = Edit::SetCell(key("East", "A", "Revenue"), CellValue::Number(1.0));
pushed.pd.define(&edit);
}
#[test]
fn a_pushdown_can_be_built_over_the_exported_source_alias() {
let model = hand_model();
let rows = model.data.iter_cells().map(|(k, v)| (k, v.clone()));
let source: SharedSource = Arc::new(Remote::of(rows));
let pd = Pushdown::new(model, source, vec![]);
let revenue = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
assert_eq!(pd.raw_fold(&revenue), Some(CellValue::Number(23.0)));
}
@@ -0,0 +1,22 @@
//! `Incremental` over a live source against `Eager` over random formula and cell edits.
//! Evidence: `context/test-evidence/improvise-incremental.md`.
mod support;
use improvise_incremental::differential_in;
use proptest::prelude::*;
use support::live::Fed;
use support::{any_formula_edit, formula_workbook};
proptest! {
#[test]
fn a_live_source_matches_eager_over_random_formula_and_cell_edits(
edits in prop::collection::vec(any_formula_edit(), 0..3),
) {
let wb = formula_workbook();
let none = vec!["Region".to_string()];
let candidate = Fed::new(&wb.model, none.clone());
let ran = differential_in(&wb.model, &[], &none, candidate, &edits);
prop_assert!(ran.is_ok(), "{}", ran.unwrap_err());
}
}
@@ -0,0 +1,22 @@
//! `Pushdown` over a remote table against `Eager` over random formula and cell edits (NaN,
//! `-0.0`, cycles and text among them). Evidence: `context/test-evidence/improvise-incremental.md`.
mod support;
use improvise_incremental::differential_in;
use proptest::prelude::*;
use support::remote::Pushed;
use support::{any_formula_edit, formula_workbook};
proptest! {
#[test]
fn a_pushdown_matches_eager_over_random_formula_and_cell_edits(
edits in prop::collection::vec(any_formula_edit(), 0..3),
) {
let wb = formula_workbook();
let none = vec!["Region".to_string()];
let candidate = Pushed::new(&wb.model, none.clone());
let ran = differential_in(&wb.model, &[], &none, candidate, &edits);
prop_assert!(ran.is_ok(), "{}", ran.unwrap_err());
}
}
@@ -0,0 +1,164 @@
//! A live table: a scripted source that reports its changes, the way a database would.
//! Its rows are those as of the last drain; a write is pending until then.
#![allow(dead_code)]
use improvise_core::model::cell::{CellChange, CellKey, CellValue, DataStore};
use improvise_core::model::source::{CellSource, ChangeSource, Select, Summary};
use improvise_core::model::{Aggregation, Model};
use improvise_incremental::{Derived, Edit, Incremental, Maintained, Reading};
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct State {
/// What a read sees: the rows as of the last drain.
visible: DataStore,
/// The rows with every pending write applied, to say what a write changed.
latest: DataStore,
pending: Vec<CellChange>,
/// Rows handed out by a snapshot read (`cells` with no partial).
snapshot_rows: u64,
}
#[derive(Default)]
pub struct LiveTable {
state: Mutex<State>,
}
impl LiveTable {
/// A table already holding `rows`, with nothing pending.
pub fn of(rows: impl IntoIterator<Item = (CellKey, CellValue)>) -> Self {
let mut state = State::default();
for (key, value) in rows {
state.visible.set(key.clone(), value.clone());
state.latest.set(key, value);
}
LiveTable {
state: Mutex::new(state),
}
}
/// Writes `value` at `key` (or removes it), as a database user would: visible after the next drain.
pub fn write(&self, key: CellKey, value: Option<CellValue>) {
let mut state = self.state.lock().unwrap();
let old = state.latest.get(&key).cloned();
match &value {
Some(v) => state.latest.set(key.clone(), v.clone()),
None if old.is_none() => return,
None => state.latest.remove(&key),
}
state.pending.push(CellChange {
key,
old,
new: value,
});
}
/// How many rows snapshot reads have handed out so far.
pub fn snapshot_rows(&self) -> u64 {
self.state.lock().unwrap().snapshot_rows
}
}
impl CellSource for LiveTable {
fn value_at(&self, key: &CellKey) -> Option<CellValue> {
self.state.lock().unwrap().visible.value_at(key)
}
fn summarize(&self, select: &Select) -> Summary {
self.state.lock().unwrap().visible.summarize(select)
}
fn keys<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = CellKey> + 'a> {
let state = self.state.lock().unwrap();
let keys: Vec<CellKey> = state.visible.keys(partial).collect();
Box::new(keys.into_iter())
}
fn cells<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = (CellKey, CellValue)> + 'a> {
let mut state = self.state.lock().unwrap();
let cells: Vec<_> = state.visible.cells(partial).collect();
if partial.is_empty() {
state.snapshot_rows += cells.len() as u64;
}
Box::new(cells.into_iter())
}
fn count(&self, select: &Select, excluding: &[(String, String)]) -> usize {
self.state.lock().unwrap().visible.count(select, excluding)
}
fn distinct_without(&self, without: &[String]) -> Vec<CellKey> {
self.state.lock().unwrap().visible.distinct_without(without)
}
}
impl ChangeSource for LiveTable {
fn drain_changes(&self) -> Vec<CellChange> {
let mut state = self.state.lock().unwrap();
let changes = std::mem::take(&mut state.pending);
for change in &changes {
match &change.new {
Some(value) => state.visible.set(change.key.clone(), value.clone()),
None => state.visible.remove(&change.key),
}
}
changes
}
}
/// An `Incremental` over a live table that holds the model's cells; the model keeps no store.
/// A cell edit is a write to the table, the way another client's would be, followed by a drain.
pub struct Fed {
pub inc: Incremental,
pub live: Arc<LiveTable>,
}
impl Fed {
pub fn new(model: &Model, none_cats: Vec<String>) -> Fed {
let rows = model.data.iter_cells().map(|(k, v)| (k, v.clone()));
let live = Arc::new(LiveTable::of(rows));
let mut bare = model.clone();
bare.data = DataStore::new();
let inc = Incremental::over(bare, live.clone(), none_cats);
Fed { inc, live }
}
}
impl Derived for Fed {
fn value(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
self.inc.value(key, agg)
}
fn raw_fold(&self, partial: &CellKey) -> Option<CellValue> {
self.inc.raw_fold(partial)
}
fn covered(&self, key: &CellKey, reading: &Reading) -> Option<CellValue> {
self.inc.covered(key, reading)
}
fn total(&self, key: &CellKey, over: &[String], reading: &Reading) -> Option<CellValue> {
self.inc.total(key, over, reading)
}
fn occupancy(&self, partial: &CellKey, reading: &Reading) -> usize {
self.inc.occupancy(partial, reading)
}
}
impl Maintained for Fed {
fn apply(&mut self, edit: &Edit) {
match edit {
Edit::SetCell(key, value) => self.live.write(key.clone(), Some(value.clone())),
Edit::ClearCell(key) => self.live.write(key.clone(), None),
_ => return self.inc.apply(edit),
}
self.inc.sync();
}
}
@@ -1,6 +1,9 @@
//! Fixtures the integration tests share: the shipped corpus and the hand-built models.
#![allow(dead_code)]
pub mod live;
pub mod remote;
use improvise_core::formula::ast::AggFunc;
use improvise_core::formula::parse_formula;
use improvise_core::model::Aggregation;
@@ -0,0 +1,315 @@
//! A remote table: an in-memory stand-in for a query engine, read by scanning and counted.
//! It is not `Eager` and shares no evaluator with it; it answers queries over rows.
#![allow(dead_code)]
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::source::{CellSource, Select, Summary, TableSource};
use improvise_core::model::{Aggregation, Model};
use improvise_incremental::{Derived, Edit, Maintained, Pushdown, Reading};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
/// What the engine was asked, and how many rows it sent back.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Asked {
pub lookups: u64,
pub summaries: u64,
pub counts: u64,
pub stem_lists: u64,
pub key_lists: u64,
pub cell_lists: u64,
/// Keys, cells and stems sent back by `keys`, `cells` and `distinct_without`: rows, not aggregates.
pub rows_sent: u64,
}
#[derive(Default)]
pub struct Remote {
table: Mutex<TableSource>,
asked: Mutex<Asked>,
}
impl Remote {
pub fn of(rows: impl IntoIterator<Item = (CellKey, CellValue)>) -> Self {
let table = TableSource {
rows: rows.into_iter().collect(),
};
Remote {
table: Mutex::new(table),
asked: Mutex::default(),
}
}
/// Writes as another client of the database would: in place, or at the end, or removed.
pub fn write(&self, key: CellKey, value: Option<CellValue>) {
let mut table = self.table.lock().unwrap();
let at = table.rows.iter().position(|(k, _)| *k == key);
match (at, value) {
(Some(i), Some(v)) => table.rows[i].1 = v,
(None, Some(v)) => table.rows.push((key, v)),
(Some(i), None) => {
table.rows.remove(i);
}
(None, None) => {}
}
}
pub fn asked(&self) -> Asked {
*self.asked.lock().unwrap()
}
fn ask(&self, f: impl FnOnce(&mut Asked)) {
f(&mut self.asked.lock().unwrap());
}
fn table(&self) -> std::sync::MutexGuard<'_, TableSource> {
self.table.lock().unwrap()
}
}
impl CellSource for Remote {
fn value_at(&self, key: &CellKey) -> Option<CellValue> {
self.ask(|a| a.lookups += 1);
self.table().value_at(key)
}
fn summarize(&self, select: &Select) -> Summary {
self.ask(|a| a.summaries += 1);
self.table().summarize(select)
}
fn keys<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = CellKey> + 'a> {
let keys: Vec<CellKey> = self.table().keys(partial).collect();
self.ask(|a| {
a.key_lists += 1;
a.rows_sent += keys.len() as u64;
});
Box::new(keys.into_iter())
}
fn cells<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = (CellKey, CellValue)> + 'a> {
let cells: Vec<_> = self.table().cells(partial).collect();
self.ask(|a| {
a.cell_lists += 1;
a.rows_sent += cells.len() as u64;
});
Box::new(cells.into_iter())
}
fn count(&self, select: &Select, excluding: &[(String, String)]) -> usize {
self.ask(|a| a.counts += 1);
self.table().count(select, excluding)
}
fn distinct_without(&self, without: &[String]) -> Vec<CellKey> {
let stems = self.table().distinct_without(without);
self.ask(|a| {
a.stem_lists += 1;
a.rows_sent += stems.len() as u64;
});
stems
}
}
/// A source with a fault, for checking that a differential can see it: it forgets the hidden
/// items a count leaves out.
pub struct IgnoresHidden(pub Arc<Remote>);
impl CellSource for IgnoresHidden {
fn value_at(&self, key: &CellKey) -> Option<CellValue> {
self.0.value_at(key)
}
fn summarize(&self, select: &Select) -> Summary {
self.0.summarize(select)
}
fn keys<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = CellKey> + 'a> {
self.0.keys(partial)
}
fn cells<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = (CellKey, CellValue)> + 'a> {
self.0.cells(partial)
}
fn count(&self, select: &Select, _excluding: &[(String, String)]) -> usize {
self.0.count(select, &[])
}
fn distinct_without(&self, without: &[String]) -> Vec<CellKey> {
self.0.distinct_without(without)
}
}
/// What a source does besides answering: reorders the stems it lists, or acts after a query.
pub trait Hook: Send + Sync {
fn stems(&self, stems: Vec<CellKey>, _remote: &Remote) -> Vec<CellKey> {
stems
}
fn after_summary(&self, _remote: &Remote) {}
}
/// A source that answers from `remote` and consults `hook`.
pub struct Hooked<H>(pub Arc<Remote>, pub H);
impl<H: Hook> CellSource for Hooked<H> {
fn value_at(&self, key: &CellKey) -> Option<CellValue> {
self.0.value_at(key)
}
fn summarize(&self, select: &Select) -> Summary {
let summary = self.0.summarize(select);
self.1.after_summary(&self.0);
summary
}
fn keys<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = CellKey> + 'a> {
self.0.keys(partial)
}
fn cells<'a>(
&'a self,
partial: &'a [(String, String)],
) -> Box<dyn Iterator<Item = (CellKey, CellValue)> + 'a> {
self.0.cells(partial)
}
fn count(&self, select: &Select, excluding: &[(String, String)]) -> usize {
self.0.count(select, excluding)
}
fn distinct_without(&self, without: &[String]) -> Vec<CellKey> {
let stems = self.0.distinct_without(without);
self.1.stems(stems, &self.0)
}
}
/// Lists the stems sorted, not in the order the cells were first seen.
pub struct SortsStems;
impl Hook for SortsStems {
fn stems(&self, mut stems: Vec<CellKey>, _remote: &Remote) -> Vec<CellKey> {
stems.sort();
stems
}
}
type Change = Box<dyn Fn(&Remote) + Send + Sync>;
/// Changes the table once, when `armed`, right after the stem list is sent.
pub struct ChangesAfterStems {
pub armed: Arc<AtomicBool>,
pub change: Change,
}
impl Hook for ChangesAfterStems {
fn stems(&self, stems: Vec<CellKey>, remote: &Remote) -> Vec<CellKey> {
if self.armed.swap(false, Ordering::SeqCst) {
(self.change)(remote);
}
stems
}
}
/// Changes the table once, when `armed`, right after the first fold is answered.
pub struct ChangesAfterAFold {
pub armed: Arc<AtomicBool>,
pub change: Change,
}
impl Hook for ChangesAfterAFold {
fn after_summary(&self, remote: &Remote) {
if self.armed.swap(false, Ordering::SeqCst) {
(self.change)(remote);
}
}
}
/// A `Pushdown` over a remote table that holds the model's cells; the model keeps no store.
/// A cell edit is a write to the table, as another client's would be, then a refresh.
pub struct Pushed {
pub pd: Pushdown,
pub remote: Arc<Remote>,
}
impl Pushed {
pub fn new(model: &Model, none_cats: Vec<String>) -> Pushed {
let remote = Arc::new(Remote::of(rows_of(model)));
let pd = Pushdown::new(bare(model), remote.clone(), none_cats);
Pushed { pd, remote }
}
/// The same, reading the table through `wrap`.
pub fn through<Q: CellSource + Send + Sync + 'static>(
model: &Model,
none_cats: Vec<String>,
wrap: impl FnOnce(Arc<Remote>) -> Q,
) -> Pushed {
let remote = Arc::new(Remote::of(rows_of(model)));
let pd = Pushdown::new(bare(model), Arc::new(wrap(remote.clone())), none_cats);
Pushed { pd, remote }
}
}
fn rows_of(model: &Model) -> Vec<(CellKey, CellValue)> {
model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect()
}
/// `model` with no cells of its own.
fn bare(model: &Model) -> Model {
let mut bare = model.clone();
bare.data = improvise_core::model::cell::DataStore::new();
bare
}
impl Derived for Pushed {
fn value(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
self.pd.value(key, agg)
}
fn raw_fold(&self, partial: &CellKey) -> Option<CellValue> {
self.pd.raw_fold(partial)
}
fn covered(&self, key: &CellKey, reading: &Reading) -> Option<CellValue> {
self.pd.covered(key, reading)
}
fn total(&self, key: &CellKey, over: &[String], reading: &Reading) -> Option<CellValue> {
self.pd.total(key, over, reading)
}
fn occupancy(&self, partial: &CellKey, reading: &Reading) -> usize {
self.pd.occupancy(partial, reading)
}
}
impl Maintained for Pushed {
fn apply(&mut self, edit: &Edit) {
match edit {
Edit::SetCell(key, value) => self.remote.write(key.clone(), Some(value.clone())),
Edit::ClearCell(key) => self.remote.write(key.clone(), None),
_ => return self.pd.define(edit),
}
self.pd.refresh();
}
}
+1
View File
@@ -72,6 +72,7 @@ const SECTIONS = [
["context/code/zset-ffi.md", "code/zset-ffi", "zset-ffi, the C ABI"],
["context/code/delivery.md", "code/delivery", "delivery, notices and corrections"],
["context/code/improvise-incremental.md", "code/improvise-incremental", "improvise-incremental, the plug"],
["context/code/source.md", "code/source", "CellSource, where cells are read from"],
["context/code/kb-tool.md", "code/kb-tool", "The kb tool"],
["context/code/tooling.md", "code/tooling", "The repository's own checkers"],
],