Skip to main content

improv_storage_mentat/
lib.rs

1//! Persist an Improv `Model` to the embedded (SQLite-backed) Mentat store.
2//!
3//! The model is stored as datoms using the schema from IMPROV.txt
4//! ("Mentat schema (steering version)"): categories, items, measures, and
5//! input cells are first-class entities so the model is *queryable*, not an
6//! opaque blob.
7//!
8//! Formulas and coordinates are stored as JSON strings (the AST/coordinate are
9//! serialized with serde); everything else maps to native datom value types.
10#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
11// The attribute is only used inside #[cfg(test)] modules, so non-test
12// coverage builds would otherwise warn that the feature is unused.
13#![cfg_attr(coverage_nightly, allow(unused_features))]
14
15use improv_core_model::{
16    Category, CategoryId, Coordinate, Formula, Item, ItemId, Measure, MeasureId, MeasureKind,
17    Model, Name, Value, ValueType,
18};
19use mentat::{InProgress, Store, TypedValue};
20use std::collections::HashMap;
21
22mod convert;
23mod schema;
24
25#[derive(Debug, thiserror::Error)]
26pub enum StoreError {
27    #[error("mentat error: {0}")]
28    Mentat(String),
29    #[error("serialization error: {0}")]
30    Serde(#[from] serde_json::Error),
31    #[error("data integrity: {0}")]
32    Integrity(String),
33    /// Another writer committed to the store after this handle's model was
34    /// loaded; saving would clobber (and retract) its changes.
35    #[error("conflict: {0}")]
36    Conflict(String),
37}
38
39/// The store's high-water mark: the newest transaction that changed a datom
40/// other than `:db/txInstant` (entid 3, a Mentat bootstrap constant), read
41/// from the raw transaction log. Read from SQLite rather than
42/// `Store::last_tx_id`, whose cached partition map never sees another
43/// process's commits. Skipping `txInstant` ignores the no-op schema transact
44/// every [`ModelStore::open`] performs, so a reader merely opening the store
45/// does not count as a writer. Scans the log backwards by rowid, so it touches
46/// only the latest transaction's rows.
47const TX_HIGH_WATER_SQL: &str = "SELECT coalesce((SELECT tx FROM timelined_transactions \
48     WHERE timeline = 0 AND a <> 3 ORDER BY rowid DESC LIMIT 1), 0)";
49
50const CONFLICT_MSG: &str =
51    "the store was changed by another writer since it was loaded; reload and retry";
52
53fn high_water(ip: &InProgress) -> Result<i64> {
54    ip.transaction
55        .query_row(TX_HIGH_WATER_SQL, [], |r| r.get(0))
56        .map_err(|e| StoreError::Mentat(e.to_string()))
57}
58
59impl From<mentat::errors::MentatError> for StoreError {
60    fn from(e: mentat::errors::MentatError) -> Self {
61        StoreError::Mentat(e.to_string())
62    }
63}
64
65pub type Result<T> = std::result::Result<T, StoreError>;
66
67/// Upper bound on the number of datoms Improv puts in a single
68/// `InProgress::transact` call.
69///
70/// Mentat's `insert_non_fts_searches` (`../mentat/db/src/db.rs`) splits one
71/// transact's datoms into chunks of `SQLITE_LIMIT_VARIABLE_NUMBER /
72/// bindings_per_statement` = `32766 / 6` = 5461, then asserts
73/// `bindings_per_statement * count < max_vars`. For a *full* chunk that reads
74/// `6 * 5461 < 32766`, which is false — so any transact carrying 5461 or more
75/// datoms aborts the process with `Too many values: 6 * 5461 >= 32766`. It is a
76/// hard `assert!`, not a `Result`, so it cannot be caught or retried; the only
77/// fix on our side is to never hand Mentat that many datoms at once.
78///
79/// 2000 leaves ~2.7x headroom under 5461. The headroom is deliberate: a re-save
80/// of an existing entity turns into a retraction plus an assertion for each
81/// changed cardinality-one attribute, so the datom count Mentat actually sees
82/// can exceed the count we emit.
83const MAX_DATOMS_PER_TRANSACT: usize = 2000;
84
85/// The datom count at which one transact aborts the process (see
86/// `MAX_DATOMS_PER_TRANSACT`): `32766 / 6`.
87///
88/// Chunking keeps *groups* of entities under this, but it cannot help when ONE
89/// entity is this wide by itself — an entity map is indivisible, so the widest
90/// single entity is the hard floor on what a transact must carry. Improv has
91/// exactly one entity kind whose width scales with model data:
92/// `:measure/categories` is `:db.cardinality/many`, so a measure with N
93/// categories emits N ref datoms (plus ~7 fixed attributes). `save_model`
94/// therefore rejects an over-wide measure with `StoreError::Integrity` instead
95/// of handing Mentat a transact that kills the process.
96///
97/// The ceiling applies *per attribute queue*, not per transact: Mentat's
98/// transactor splits a transact's datoms into cardinality-many
99/// (`SearchType::Exact`) and cardinality-one (`Inexact`) queues and calls
100/// `insert_non_fts_searches` once per queue, each with its own chunking and its
101/// own `assert!`. That is why a measure with 5460 categories saves even though
102/// it emits 5467 datoms: 5460 land in the many-queue and 7 in the one-queue.
103/// The binding constraint is the category count alone, so the guard checks that
104/// and not the padded `datoms_per_entity` bound (checking the padded bound would
105/// reject the 5454..=5460 category range, which is measurably fine today).
106const MAX_DATOMS_PER_TRANSACT_QUEUE: usize = 32766 / 6;
107
108/// Most `:measure/categories` refs one measure can carry and still be savable
109/// (5460 — verified as the last good value by
110/// `measure_at_the_single_entity_ceiling_round_trips`).
111const MAX_CATEGORIES_PER_MEASURE: usize = MAX_DATOMS_PER_TRANSACT_QUEUE - 1;
112
113/// Transact `entities` through `ip` in EDN vectors of at most
114/// `MAX_DATOMS_PER_TRANSACT / datoms_per_entity` entities each, sharing the
115/// underlying SQLite transaction with every other call made on the same
116/// `InProgress` (a no-op if `entities` is empty).
117///
118/// `datoms_per_entity` is an *upper bound* on the attributes one element of
119/// `entities` asserts (see `MAX_DATOMS_PER_TRANSACT` for why the bound matters).
120///
121/// Chunking happens *inside* the caller's `InProgress`: many `transact` calls,
122/// still exactly one `commit()`, so `save_model` stays all-or-nothing — a
123/// failure in chunk 7 of 24 rolls back chunks 1..7 along with every other step
124/// of the save.
125fn transact_group(
126    ip: &mut InProgress<'_, '_>,
127    datoms_per_entity: usize,
128    entities: impl IntoIterator<Item = String>,
129) -> Result<()> {
130    let parts: Vec<String> = entities.into_iter().collect();
131    // `.max(1)` only bites for an entity so wide it exceeds the whole budget by
132    // itself; a single entity is indivisible, so one-per-transact is the best we
133    // can do. That is NOT automatically under Mentat's real ceiling — an entity
134    // wide enough to exceed MAX_DATOMS_PER_TRANSACT_QUEUE on its own still
135    // aborts, which is why `save_model` guards the one entity kind whose width
136    // is unbounded (`:measure/categories`) before it gets here.
137    let per_chunk = (MAX_DATOMS_PER_TRANSACT / datoms_per_entity.max(1)).max(1);
138    for batch in parts.chunks(per_chunk) {
139        ip.transact(format!("[{}]", batch.join("\n")))?;
140    }
141    Ok(())
142}
143
144const VALUE_NUMBER: &str = "value-number";
145const VALUE_BOOLEAN: &str = "value-boolean";
146const VALUE_TEXT: &str = "value-text";
147
148/// The `:cell/<attr>` a value of type `vt` is stored under (see
149/// `convert::cell_edn`).
150fn value_attr(vt: ValueType) -> &'static str {
151    match vt {
152        ValueType::Number | ValueType::Enum => VALUE_NUMBER,
153        ValueType::Boolean => VALUE_BOOLEAN,
154        ValueType::Text | ValueType::DateTime => VALUE_TEXT,
155    }
156}
157
158/// A model store backed by embedded Mentat (a single SQLite file, or `""` for
159/// in-memory).
160pub struct ModelStore {
161    store: Store,
162    /// The high-water as of this handle's last load or save; `save_model`
163    /// refuses to commit if the store has moved past it. `None`: unchecked.
164    loaded_tx: Option<i64>,
165    /// The measures whose cells the last load read: `None` after a full load
166    /// (or none), the dependency closure after [`ModelStore::load_partial`].
167    /// `save_model` only retracts stored cells of these measures, so saving a
168    /// partial model leaves every other measure's cells alone.
169    loaded_scope: Option<std::collections::HashSet<MeasureId>>,
170    /// Test-only: how many `(measure, coord)` rows the last `load_cells` query
171    /// *returned* (not how many survived a Rust-side filter). Lets a test prove
172    /// the enumeration itself is bounded by `load_partial`'s closure rather
173    /// than by the size of the whole store.
174    #[cfg(test)]
175    cell_rows_returned: usize,
176}
177
178impl ModelStore {
179    /// Open (or create) a store at `path`. Use `""` for an in-memory database.
180    pub fn open(path: &str) -> Result<Self> {
181        let mut store = Store::open(path)?;
182        store.transact(schema::SCHEMA_EDN)?;
183        Ok(ModelStore {
184            store,
185            loaded_tx: None,
186            loaded_scope: None,
187            #[cfg(test)]
188            cell_rows_returned: 0,
189        })
190    }
191
192    /// Persist the entire model, atomically: either every category, item,
193    /// measure, cell, view and meta-blob is durably saved, or (on any error)
194    /// none of them are.
195    ///
196    /// Idempotent for identity-unique entities (categories/items/measures
197    /// keyed by their id; cells keyed by measure+coord), so re-saving updates
198    /// in place.
199    ///
200    /// Cells are diffed against the store, which ends up holding exactly
201    /// `model.inputs`: unchanged cells add no datoms, and a stored cell the
202    /// model lacks is retracted. After [`ModelStore::load_partial`] only the
203    /// loaded closure's cells are diffed that way; cells of measures the load
204    /// skipped are left as they are.
205    ///
206    /// Transacted in dependency order (categories, then items, then measures,
207    /// then cells, then views, then meta) as several `InProgress::transact`
208    /// calls sharing ONE underlying SQLite transaction (via
209    /// `Store::begin_transaction`), so later lookup-refs resolve against
210    /// earlier (still-uncommitted) writes in the same save. A single
211    /// `ip.commit()` at the end makes the whole sequence all-or-nothing: if
212    /// any step's EDN fails to transact, `?` returns before `commit()` runs
213    /// and the dropped `InProgress` rolls back every prior step of this save.
214    /// See `.agent/steering/AGENT_DATABASE_CONNECTIVITY.md` "Crash safety".
215    ///
216    /// Each step is further split into several `transact` calls of bounded
217    /// datom count (see `MAX_DATOMS_PER_TRANSACT`), because Mentat aborts the
218    /// process on a transact of 5461+ datoms. The extra calls are still inside
219    /// the same single `InProgress`/`commit()`, so atomicity is unaffected.
220    ///
221    /// Optimistic concurrency: if this handle loaded (or saved) the model, the
222    /// save fails with [`StoreError::Conflict`], writing nothing, when another
223    /// writer has committed since. A handle that never loaded saves
224    /// unconditionally.
225    ///
226    /// Chunking cannot rescue a *single* entity wider than that ceiling, so a
227    /// measure with more than `MAX_CATEGORIES_PER_MEASURE` (5460) categories is
228    /// rejected up front with `StoreError::Integrity` — a recoverable error
229    /// instead of a killed process.
230    pub fn save_model(&mut self, model: &Model) -> Result<()> {
231        // IMMEDIATE: no other writer can commit between this check and ours.
232        let mut ip = self.store.begin_transaction()?;
233        if let Some(loaded) = self.loaded_tx {
234            if high_water(&ip)? != loaded {
235                return Err(StoreError::Conflict(CONFLICT_MSG.into()));
236            }
237        }
238
239        // `datoms_per_entity` arguments below are upper bounds on the
240        // attributes each `convert::*_edn` helper can emit for one entity.
241        // :category/id + :category/name.
242        transact_group(
243            &mut ip,
244            2,
245            model.categories.values().map(convert::category_edn),
246        )?;
247        // :item/id + :item/name + :item/category.
248        transact_group(&mut ip, 3, model.items.values().map(convert::item_edn))?;
249
250        let mut measures = Vec::new();
251        for m in model.measures.values() {
252            measures.push(convert::measure_edn(m, model.sql_sources.get(&m.id))?);
253        }
254        // A measure is one indivisible entity, and `:measure/categories` is
255        // cardinality-many, so its width is the only model-data-dependent width
256        // in the schema. Chunking cannot split it: past the per-queue ceiling
257        // Mentat aborts the process, so refuse the save with a recoverable error
258        // instead (`ip` is dropped un-committed here, so nothing persists).
259        //
260        // NOTE (interacts with the missing-retraction bug): `save_model` never
261        // retracts stale `:measure/categories` refs, so re-saving a measure whose
262        // category set *changed* leaves the union on disk. A later `load_model`
263        // hands back that union, which is how a model can grow past this limit
264        // without any single in-memory save ever being that wide. The guard is
265        // still sound, because Mentat's `assert!` counts only the datoms in the
266        // transact being applied — on-disk accumulation for a cardinality-many
267        // attribute adds no rows to the search tables (proven by
268        // `churned_resave_reports_error_not_abort`). The consequence of the
269        // retraction bug is that the *error* can appear on a re-save of a loaded
270        // model whose author never built a measure that wide; fixing retraction
271        // removes that surprise, not this guard.
272        let widest_categories = model
273            .measures
274            .values()
275            .map(|m| m.categories.len())
276            .max()
277            .unwrap_or(0);
278        if widest_categories > MAX_CATEGORIES_PER_MEASURE {
279            let worst = model
280                .measures
281                .values()
282                .max_by_key(|m| m.categories.len())
283                .expect("non-empty: widest_categories > 0");
284            return Err(StoreError::Integrity(format!(
285                "measure {} (\"{}\") has {} categories; a measure is a single \
286                 indivisible entity and the store aborts on a transact carrying \
287                 {} or more datoms for one attribute, so at most {} categories \
288                 per measure can be saved",
289                worst.id.0,
290                worst.name.0,
291                widest_categories,
292                MAX_DATOMS_PER_TRANSACT_QUEUE,
293                MAX_CATEGORIES_PER_MEASURE,
294            )));
295        }
296        // id, name, value-type, kind, formula, description, sql-source, plus
297        // one :measure/categories datom per category (cardinality-many); take
298        // the widest measure in this model rather than guessing.
299        transact_group(&mut ip, 7 + widest_categories, measures)?;
300
301        // Diff the cells against what is stored, so the store's cells end up
302        // exactly `model.inputs`: an unchanged cell is not re-asserted, a value
303        // left under another type's attribute is retracted, and a stored cell
304        // the model no longer has is retracted whole (it used to linger on
305        // disk and come back on the next load).
306        let mut stored = stored_cells(&ip)?;
307        let mut cells = Vec::new();
308        let mut retracts = Vec::new();
309        for ((mid, coord), val) in model.inputs.iter() {
310            let coord_json = serde_json::to_string(coord)?;
311            let key = convert::cell_key(*mid, &coord_json);
312            let (attr, v) = convert::cell_value_edn(val)?;
313            if let Some(old) = stored.remove(&key) {
314                let mut unchanged = false;
315                for (a, ov) in &old.values {
316                    if *a == attr {
317                        // Cardinality-one: asserting the new value replaces it.
318                        unchanged = *ov == v;
319                    } else {
320                        retracts.push(format!("[:db/retract {} :cell/{a} {ov}]", old.e));
321                    }
322                }
323                if unchanged {
324                    continue;
325                }
326            }
327            cells.push(convert::cell_edn(*mid, &coord_json, &key, attr, &v));
328        }
329        for (key, old) in stored {
330            // A partial load never saw this measure's cells: not ours to drop.
331            if let Some(scope) = &self.loaded_scope {
332                let mid = key.split_once("::").and_then(|(m, _)| m.parse().ok());
333                if !mid.is_some_and(|m| scope.contains(&MeasureId(m))) {
334                    continue;
335                }
336            }
337            let e = old.e;
338            retracts.push(format!(
339                "[:db/retract {e} :cell/key {}]",
340                convert::edn_str_pub(&key)
341            ));
342            retracts.push(format!("[:db/retract {e} :cell/measure {}]", old.measure));
343            retracts.push(format!(
344                "[:db/retract {e} :cell/coord {}]",
345                convert::edn_str_pub(&old.coord)
346            ));
347            for (a, ov) in &old.values {
348                retracts.push(format!("[:db/retract {e} :cell/{a} {ov}]"));
349            }
350        }
351        // One datom per retraction.
352        transact_group(&mut ip, 1, retracts)?;
353        // :cell/key + :cell/measure + :cell/coord + one typed value attribute.
354        transact_group(&mut ip, 4, cells)?;
355
356        let mut views = Vec::new();
357        for v in model.views.values() {
358            let json = serde_json::to_string(v)?;
359            views.push(format!(
360                "{{:view/id {} :view/json {}}}",
361                v.id.0,
362                convert::edn_str_pub(&json)
363            ));
364        }
365        // :view/id + :view/json.
366        transact_group(&mut ip, 2, views)?;
367
368        // Singleton meta: external function defs + external-call measures +
369        // what-if scenarios, each as a JSON blob on one entity (only if any
370        // are non-empty).
371        if !model.external_fns.is_empty()
372            || !model.external_calls.is_empty()
373            || !model.scenarios.is_empty()
374            || !model.lambdas.is_empty()
375        {
376            let fns_json = serde_json::to_string(&model.external_fns)?;
377            let calls_json = serde_json::to_string(&model.external_calls)?;
378            let scen_json = serde_json::to_string(&model.scenarios)?;
379            let lambdas_json = serde_json::to_string(&model.lambdas)?;
380            let edn = format!(
381                "[{{:meta/singleton 1 :meta/external-fns {} :meta/external-calls {} :meta/scenarios {} :meta/lambdas {}}}]",
382                convert::edn_str_pub(&fns_json),
383                convert::edn_str_pub(&calls_json),
384                convert::edn_str_pub(&scen_json),
385                convert::edn_str_pub(&lambdas_json),
386            );
387            ip.transact(edn)?;
388        }
389
390        // Read before commit, under our write lock, so a writer racing in right
391        // after the commit is never mistaken for our own transaction.
392        let new_tx = high_water(&ip)?;
393        ip.commit()?;
394        self.loaded_tx = Some(new_tx);
395        Ok(())
396    }
397
398    /// The store's latest transaction id, read live from SQLite (so it sees
399    /// other processes' commits).
400    pub fn tx_high_water(&mut self) -> Result<i64> {
401        high_water(&self.store.begin_read()?.in_progress)
402    }
403
404    /// The high-water this handle last loaded or saved at (`None` if it has
405    /// done neither). Callers that reopen the store for each save carry it
406    /// across handles with [`ModelStore::set_loaded_tx`].
407    pub fn loaded_tx(&self) -> Option<i64> {
408        self.loaded_tx
409    }
410
411    /// Adopt `tx` (an earlier handle's [`ModelStore::loaded_tx`]) as the base
412    /// `save_model` checks against. `None` saves unconditionally.
413    pub fn set_loaded_tx(&mut self, tx: Option<i64>) {
414        self.loaded_tx = tx;
415    }
416
417    /// Re-read the model after a [`StoreError::Conflict`]; same as
418    /// [`ModelStore::load_model`].
419    pub fn reload_model(&mut self) -> Result<Model> {
420        self.load_model()
421    }
422
423    /// Reconstruct the model by querying the store.
424    pub fn load_model(&mut self) -> Result<Model> {
425        let tx = self.tx_high_water()?;
426        let mut model = Model::new();
427        self.load_categories(&mut model)?;
428        self.load_items(&mut model)?;
429        self.load_measures(&mut model)?;
430        self.load_cells(&mut model, None)?;
431        self.load_views(&mut model)?;
432        self.load_meta(&mut model)?;
433        self.loaded_tx = Some(tx);
434        self.loaded_scope = None;
435        Ok(model)
436    }
437
438    /// Load a model containing only the categories/items/measures/cells that
439    /// an operation over `measure_ids` actually needs: `measure_ids` plus
440    /// their transitive dependency closure (`Model::measure_dependency_closure`
441    /// — every measure reachable via a `Derived` formula or an external-call's
442    /// `arg_measures`). Views/scenarios/external-fn defs/meta are still loaded
443    /// in full (they're small, bounded by model *shape* not data volume, not
444    /// data-scale-sensitive like cells are).
445    ///
446    /// This is the "windowed" load recommended in
447    /// `.agent/steering/AGENT_OUT_OF_CORE_DESIGN.md` §4: `engine::dataflow::
448    /// evaluate`/`engine::session::Engine::new` need ZERO changes, because they
449    /// already only touch whatever is in the `Model` they're handed — the gap
450    /// was purely that `load_model` always loaded every cell of every measure
451    /// regardless of what the caller needed. Categories/items/measures are
452    /// still loaded in full too (their volume scales with model *shape*, which
453    /// is the cheap part; only `load_cells`, whose volume scales with *data*,
454    /// is filtered). A single operation whose formula-dependency closure is a
455    /// bounded subset of a huge model can now avoid paying for every other
456    /// measure's cells — it does NOT help an operation that touches the whole
457    /// model by definition (e.g. a grand total over everything).
458    ///
459    /// The cell filter is applied *in the store*: `load_cells` binds `?mid` to
460    /// exactly the closure's measure ids with `ground`, so the query returns
461    /// rows only for those measures — no row is materialized or returned for a
462    /// cell outside the closure. See `load_cells` for the one residual cost
463    /// this does NOT remove (SQLite still scans the cell datom slices inside
464    /// the query; only the returned row set is closure-bounded).
465    ///
466    /// Saving the result with [`ModelStore::save_model`] writes the closure's
467    /// cells and leaves every other measure's cells alone.
468    pub fn load_partial(&mut self, measure_ids: &[MeasureId]) -> Result<Model> {
469        let tx = self.tx_high_water()?;
470        let mut model = Model::new();
471        self.load_categories(&mut model)?;
472        self.load_items(&mut model)?;
473        self.load_measures(&mut model)?;
474        self.load_views(&mut model)?;
475        self.load_meta(&mut model)?; // external_calls populated before the closure walk
476        let closure = model.measure_dependency_closure(measure_ids);
477        self.load_cells(&mut model, Some(&closure))?;
478        self.loaded_tx = Some(tx);
479        self.loaded_scope = Some(closure);
480        Ok(model)
481    }
482
483    // --- load: query each entity kind ---
484
485    fn load_categories(&mut self, model: &mut Model) -> Result<()> {
486        let q = "[:find ?id ?name :where [?e :category/id ?id] [?e :category/name ?name]]";
487        for row in self.rel(q)? {
488            let id = CategoryId(convert::as_u32(&row[0])?);
489            let name = convert::as_string(&row[1])?;
490            model.categories.insert(
491                id,
492                Category {
493                    id,
494                    name: Name(name),
495                    items: Vec::new(),
496                },
497            );
498        }
499        Ok(())
500    }
501
502    fn load_items(&mut self, model: &mut Model) -> Result<()> {
503        let q = "[:find ?id ?cat ?name :where \
504                  [?e :item/id ?id] \
505                  [?e :item/category ?c] [?c :category/id ?cat] \
506                  [?e :item/name ?name]]";
507        for row in self.rel(q)? {
508            let id = ItemId(convert::as_u32(&row[0])?);
509            let category = CategoryId(convert::as_u32(&row[1])?);
510            let name = convert::as_string(&row[2])?;
511            model.items.insert(
512                id,
513                Item {
514                    id,
515                    category,
516                    name: Name(name),
517                },
518            );
519            if let Some(c) = model.categories.get_mut(&category) {
520                c.items.push(id);
521            }
522        }
523        Ok(())
524    }
525
526    fn load_measures(&mut self, model: &mut Model) -> Result<()> {
527        // Required fields only; optional formula/description fetched per measure.
528        let q = "[:find ?id ?name ?vt ?kind :where \
529                  [?e :measure/id ?id] \
530                  [?e :measure/name ?name] \
531                  [?e :measure/value-type ?vt] \
532                  [?e :measure/kind ?kind]]";
533        for row in self.rel(q)? {
534            let id = MeasureId(convert::as_u32(&row[0])?);
535            let name = convert::as_string(&row[1])?;
536            let value_type = convert::value_type_from_kw(&convert::as_string(&row[2])?)?;
537            let kind_kw = convert::as_string(&row[3])?;
538
539            let kind = match kind_kw.as_str() {
540                "input" => MeasureKind::Input,
541                "derived" => {
542                    let formula_json = self
543                        .scalar_string(&format!(
544                            "[:find ?f . :where [?e :measure/id {}] [?e :measure/formula ?f]]",
545                            id.0
546                        ))?
547                        .ok_or_else(|| {
548                            StoreError::Integrity(format!(
549                                "derived measure {} has no formula",
550                                id.0
551                            ))
552                        })?;
553                    let f: Formula = serde_json::from_str(&formula_json)?;
554                    MeasureKind::Derived(f)
555                }
556                other => {
557                    return Err(StoreError::Integrity(format!(
558                        "unknown measure kind {other}"
559                    )))
560                }
561            };
562
563            let description = self.scalar_string(&format!(
564                "[:find ?d . :where [?e :measure/id {}] [?e :measure/description ?d]]",
565                id.0
566            ))?;
567
568            let categories = self.load_measure_categories(id)?;
569
570            // SQL-source metadata (Phase 7), if this measure is SQL-backed.
571            if let Some(json) = self.scalar_string(&format!(
572                "[:find ?s . :where [?e :measure/id {}] [?e :measure/sql-source ?s]]",
573                id.0
574            ))? {
575                let src: improv_core_model::SqlSource = serde_json::from_str(&json)?;
576                model.sql_sources.insert(id, src);
577            }
578
579            model.measures.insert(
580                id,
581                Measure {
582                    id,
583                    name: Name(name),
584                    value_type,
585                    categories,
586                    kind,
587                    description,
588                },
589            );
590        }
591        Ok(())
592    }
593
594    fn load_measure_categories(&mut self, measure: MeasureId) -> Result<Vec<CategoryId>> {
595        let q = format!(
596            "[:find ?cat :where \
597             [?e :measure/id {}] [?e :measure/categories ?c] [?c :category/id ?cat]]",
598            measure.0
599        );
600        let mut cats = Vec::new();
601        for row in self.rel(&q)? {
602            cats.push(CategoryId(convert::as_u32(&row[0])?));
603        }
604        // `:measure/categories` is a set, so its query order is arbitrary.
605        // Restore the saved order; a store written before
606        // `:measure/category-order` existed falls back to id order, and an id
607        // the order does not list (a stale ref) sorts after those it does.
608        let order: Vec<u32> = match self.scalar_string(&format!(
609            "[:find ?o . :where [?e :measure/id {}] [?e :measure/category-order ?o]]",
610            measure.0
611        ))? {
612            Some(json) => serde_json::from_str(&json)?,
613            None => Vec::new(),
614        };
615        cats.sort_by_key(|c| {
616            (
617                order.iter().position(|&o| o == c.0).unwrap_or(usize::MAX),
618                c.0,
619            )
620        });
621        Ok(cats)
622    }
623
624    fn load_cells(
625        &mut self,
626        model: &mut Model,
627        filter: Option<&std::collections::HashSet<MeasureId>>,
628    ) -> Result<()> {
629        // One query per typed value attribute (three in all), each returning
630        // (measure id, coord, value) rows, so the load is O(value types)
631        // queries, not O(cells). It used to be one query for (mid, coord) and
632        // then one `q_once` per cell for its value: 100k cells took ~290 s.
633        //
634        // The measure filter is applied IN the query, not in this loop: with a
635        // `filter`, `?mid` is `ground`-bound to exactly the closure's measure
636        // ids, so the store returns rows only for those measures. Nothing
637        // proportional to the rest of the model's cells is materialized or
638        // returned.
639        //
640        // Residual cost, stated plainly: the returned row set and all per-row
641        // work are bounded by the closure, but Mentat plans either shape as a
642        // scan of the `:cell/measure` / `:cell/coord` datom slices (`SEARCH ...
643        // USING COVERING INDEX idx_datoms_aevt (a=?)`, verified with
644        // `Store::q_explain`), so inside SQLite the query still walks every
645        // cell datom. Making the scan itself seek-bounded needs a Mentat/schema
646        // change (an AVET-indexed `:cell/measure` usable as a leading index
647        // column), not a query rewrite.
648        let ground = match filter {
649            None => String::new(),
650            Some(keep) if keep.is_empty() => {
651                // `ground` rejects an empty collection, and an empty closure
652                // needs no cells: skip the queries entirely.
653                #[cfg(test)]
654                {
655                    self.cell_rows_returned = 0;
656                }
657                return Ok(());
658            }
659            Some(keep) => {
660                // Sorted so the query text is stable/reproducible.
661                let mut ids: Vec<u32> = keep.iter().map(|m| m.0).collect();
662                ids.sort_unstable();
663                let ids: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
664                format!("[(ground [{}]) [?mid ...]] ", ids.join(" "))
665            }
666        };
667        #[cfg(test)]
668        {
669            self.cell_rows_returned = 0;
670        }
671        for attr in [VALUE_NUMBER, VALUE_BOOLEAN, VALUE_TEXT] {
672            let q = format!(
673                "[:find ?mid ?coord ?v :where {ground}\
674                 [?m :measure/id ?mid] [?e :cell/measure ?m] \
675                 [?e :cell/coord ?coord] [?e :cell/{attr} ?v]]"
676            );
677            let rows = self.rel(&q)?;
678            #[cfg(test)]
679            {
680                self.cell_rows_returned += rows.len();
681            }
682            for row in rows {
683                let mid = MeasureId(convert::as_u32(&row[0])?);
684                let vt = model
685                    .measures
686                    .get(&mid)
687                    .map(|m| m.value_type)
688                    .ok_or_else(|| {
689                        StoreError::Integrity(format!("cell for unknown measure {mid:?}"))
690                    })?;
691                let coord: Coordinate = serde_json::from_str(&convert::as_string(&row[1])?)?;
692                let value = match (attr, vt) {
693                    (VALUE_NUMBER, ValueType::Enum) => {
694                        Value::Enum(convert::as_f64(&row[2])? as u32)
695                    }
696                    (VALUE_NUMBER, _) => Value::Number(convert::as_f64(&row[2])?),
697                    (VALUE_BOOLEAN, _) => Value::Boolean(convert::as_bool(&row[2])?),
698                    (_, ValueType::DateTime) => {
699                        let text = convert::as_string(&row[2])?;
700                        match chrono::DateTime::parse_from_rfc3339(&text) {
701                            Ok(dt) => Value::DateTime(dt.with_timezone(&chrono::Utc)),
702                            Err(_) => Value::Text(text),
703                        }
704                    }
705                    _ => Value::Text(convert::as_string(&row[2])?),
706                };
707                // A cell carries one value attribute, but a store written
708                // before re-saves retracted the old one may hold several: the
709                // attribute matching the measure's declared type wins.
710                let key = (mid, coord);
711                if attr == value_attr(vt) || !model.inputs.contains_key(&key) {
712                    model.inputs.insert(key, value);
713                }
714            }
715        }
716        Ok(())
717    }
718
719    fn load_views(&mut self, model: &mut Model) -> Result<()> {
720        let q = "[:find ?json :where [?e :view/id _] [?e :view/json ?json]]";
721        for row in self.rel(q)? {
722            let json = convert::as_string(&row[0])?;
723            let v: improv_core_model::View = serde_json::from_str(&json)?;
724            model.views.insert(v.id, v);
725        }
726        Ok(())
727    }
728
729    fn load_meta(&mut self, model: &mut Model) -> Result<()> {
730        if let Some(json) = self.scalar_string(
731            "[:find ?j . :where [?e :meta/singleton 1] [?e :meta/external-fns ?j]]",
732        )? {
733            model.external_fns = serde_json::from_str(&json)?;
734        }
735        if let Some(json) = self.scalar_string(
736            "[:find ?j . :where [?e :meta/singleton 1] [?e :meta/external-calls ?j]]",
737        )? {
738            model.external_calls = serde_json::from_str(&json)?;
739        }
740        if let Some(json) = self
741            .scalar_string("[:find ?j . :where [?e :meta/singleton 1] [?e :meta/scenarios ?j]]")?
742        {
743            model.scenarios = serde_json::from_str(&json)?;
744        }
745        if let Some(json) =
746            self.scalar_string("[:find ?j . :where [?e :meta/singleton 1] [?e :meta/lambdas ?j]]")?
747        {
748            model.lambdas = serde_json::from_str(&json)?;
749        }
750        Ok(())
751    }
752
753    fn scalar(&self, query: &str) -> Result<Option<TypedValue>> {
754        use mentat::Queryable;
755        let out = self.store.q_once(query, None)?;
756        let s = out
757            .results
758            .into_scalar()
759            .map_err(|e| StoreError::Mentat(e.to_string()))?;
760        Ok(s.and_then(|b| b.into_scalar()))
761    }
762
763    fn scalar_string(&self, query: &str) -> Result<Option<String>> {
764        match self.scalar(query)? {
765            Some(tv) => Ok(Some(convert::as_string(&tv)?)),
766            None => Ok(None),
767        }
768    }
769
770    fn rel(&self, query: &str) -> Result<Vec<Vec<TypedValue>>> {
771        rel_on(&self.store, query)
772    }
773}
774
775/// A stored cell entity, as `save_model` needs it to diff or retract it.
776struct StoredCell {
777    e: i64,
778    /// The `:cell/measure` ref's entity id.
779    measure: i64,
780    coord: String,
781    /// (value attribute, EDN literal) for each typed value the entity holds.
782    values: Vec<(&'static str, String)>,
783}
784
785/// Every stored cell, by `:cell/key`. Four queries, whatever the cell count.
786fn stored_cells(q: &impl mentat::Queryable) -> Result<HashMap<String, StoredCell>> {
787    let mut out = HashMap::new();
788    let rows = rel_on(
789        q,
790        "[:find ?e ?key ?m ?coord :where \
791         [?e :cell/key ?key] [?e :cell/measure ?m] [?e :cell/coord ?coord]]",
792    )?;
793    for row in rows {
794        out.insert(
795            convert::as_string(&row[1])?,
796            StoredCell {
797                e: convert::as_entid(&row[0])?,
798                measure: convert::as_entid(&row[2])?,
799                coord: convert::as_string(&row[3])?,
800                values: Vec::new(),
801            },
802        );
803    }
804    for attr in [VALUE_NUMBER, VALUE_BOOLEAN, VALUE_TEXT] {
805        let q_attr = format!("[:find ?key ?v :where [?e :cell/key ?key] [?e :cell/{attr} ?v]]");
806        for row in rel_on(q, &q_attr)? {
807            if let Some(c) = out.get_mut(&convert::as_string(&row[0])?) {
808                c.values.push((attr, convert::stored_value_edn(&row[1])?));
809            }
810        }
811    }
812    Ok(out)
813}
814
815fn rel_on(q: &impl mentat::Queryable, query: &str) -> Result<Vec<Vec<TypedValue>>> {
816    let out = q.q_once(query, None)?;
817    let rel = out
818        .results
819        .into_rel()
820        .map_err(|e| StoreError::Mentat(e.to_string()))?;
821    Ok(rel
822        .into_iter()
823        .map(|row| {
824            row.into_iter()
825                .map(|b| b.into_scalar().expect("scalar binding"))
826                .collect()
827        })
828        .collect())
829}
830
831// Silence unused-import warnings for HashMap in case the module trims; it's used
832// transitively by Model. (Kept explicit for readability of the load path.)
833#[allow(unused_imports)]
834use HashMap as _HashMap;
835
836#[cfg(test)]
837#[cfg_attr(coverage_nightly, coverage(off))]
838mod tests {
839    use super::*;
840    use improv_core_model::{BinaryOp, DimensionSpec, Expr, ItemId};
841
842    fn sample_model() -> Model {
843        let mut m = Model::new();
844        let (time, product) = (CategoryId(1), CategoryId(2));
845        m.add_category(time, "Time");
846        m.add_category(product, "Product");
847        m.add_item(ItemId(10), time, "2025");
848        m.add_item(ItemId(20), product, "Widget A");
849
850        m.add_measure(Measure {
851            id: MeasureId(100),
852            name: Name("Price".into()),
853            value_type: ValueType::Number,
854            categories: vec![product],
855            kind: MeasureKind::Input,
856            description: Some("Unit price".into()),
857        });
858        m.add_measure(Measure {
859            id: MeasureId(102),
860            name: Name("Revenue".into()),
861            value_type: ValueType::Number,
862            categories: vec![time, product],
863            kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
864                BinaryOp::Mul,
865                Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
866                Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
867            ))),
868            description: None,
869        });
870        m.set_input(
871            MeasureId(100),
872            Coordinate::from_pairs([(product, ItemId(20))]),
873            Value::Number(10.0),
874        );
875        m.add_view(improv_core_model::View {
876            id: improv_core_model::ViewId(1),
877            name: Name("Prices by product".into()),
878            measure: MeasureId(100),
879            axis_order: vec![CategoryId(2)],
880            n_rows: 1,
881            n_cols: 1,
882            page_items: vec![],
883            filters: vec![improv_core_model::Filter {
884                category: CategoryId(2),
885                items: vec![ItemId(20)],
886            }],
887            rect: Default::default(),
888            placements: vec![],
889        });
890
891        // A named LAMBDA rides in the same blob.
892        m.lambdas.insert(
893            3,
894            improv_core_model::NamedLambda {
895                name: "Half".into(),
896                params: vec!["x".into()],
897                body: Expr::BinaryOp(
898                    BinaryOp::Div,
899                    Box::new(Expr::Ref(
900                        improv_core_model::functions::lambda_param(0),
901                        Default::default(),
902                    )),
903                    Box::new(Expr::Literal(Value::Number(2.0))),
904                ),
905            },
906        );
907        // Meta blob: an external function def, an external-call measure that
908        // uses it, and a what-if scenario -- so `save_model`'s :meta/* step
909        // (the last, easiest-to-miss step of the multi-step save) is non-empty
910        // and covered by the round-trip.
911        m.external_fns.insert(
912            "double".into(),
913            improv_core_model::ExternalFn {
914                name: "double".into(),
915                language: improv_core_model::Language::Pure,
916                body: "return args[0] * 2".into(),
917                arg_types: vec![ValueType::Number],
918                return_type: ValueType::Number,
919                pure: true,
920            },
921        );
922        m.external_calls.insert(
923            MeasureId(102),
924            improv_core_model::ExternalCall {
925                func: "double".into(),
926                arg_measures: vec![MeasureId(100)],
927                refresh_policy: improv_core_model::RefreshPolicy::Manual,
928            },
929        );
930        m.add_scenario(improv_core_model::Scenario {
931            id: improv_core_model::ScenarioId(1),
932            name: Name("High price".into()),
933            overrides: [(
934                (
935                    MeasureId(100),
936                    Coordinate::from_pairs([(product, ItemId(20))]),
937                ),
938                Value::Number(99.0),
939            )]
940            .into_iter()
941            .collect(),
942        });
943        m
944    }
945
946    #[test]
947    fn save_then_load_round_trips() {
948        let mut store = ModelStore::open("").expect("open in-memory");
949        let original = sample_model();
950        store.save_model(&original).expect("save");
951        let loaded = store.load_model().expect("load");
952
953        assert_eq!(loaded.categories.len(), 2);
954        assert_eq!(loaded.items.len(), 2);
955        assert_eq!(loaded.measures.len(), 2);
956        assert_eq!(loaded.inputs.len(), 1);
957
958        // Derived measure formula survived the JSON-in-datom round trip.
959        let rev = loaded.measure_by_name("Revenue").expect("revenue");
960        assert!(rev.is_derived());
961        assert_eq!(rev.categories.len(), 2);
962
963        // Input cell value survived.
964        let coord = Coordinate::from_pairs([(CategoryId(2), ItemId(20))]);
965        assert_eq!(
966            loaded.input(MeasureId(100), &coord),
967            Some(&Value::Number(10.0))
968        );
969
970        // The saved view (layout + filter) survived the round trip.
971        let v = loaded.view_by_name("Prices by product").expect("view");
972        assert_eq!(v.measure, MeasureId(100));
973        assert_eq!(v.axis_order, vec![CategoryId(2)]);
974        assert!(v.allows(CategoryId(2), ItemId(20)));
975        assert!(!v.allows(CategoryId(2), ItemId(21)));
976
977        // The :meta/* blob (external fns/calls + scenarios) survived too --
978        // this is the LAST step of `save_model` and the one most exposed by
979        // the old multi-transact-call bug this fix closes (see the atomicity
980        // test below).
981        assert_eq!(loaded.external_fns.len(), 1);
982        assert_eq!(loaded.lambdas, original.lambdas);
983        assert_eq!(
984            loaded.external_fns["double"],
985            original.external_fns["double"]
986        );
987        assert_eq!(loaded.external_calls.len(), 1);
988        assert_eq!(
989            loaded.external_calls[&MeasureId(102)],
990            original.external_calls[&MeasureId(102)]
991        );
992        assert_eq!(loaded.scenarios.len(), 1);
993        let scenario = loaded.scenario_by_name("High price").expect("scenario");
994        assert_eq!(
995            scenario.overrides.get(&(MeasureId(100), coord.clone())),
996            Some(&Value::Number(99.0))
997        );
998
999        // Full-model equality, modulo `measure.categories` / `category.items`
1000        // vector order (cardinality-many refs come back from the store in
1001        // query order, not necessarily insertion order): normalize those
1002        // before comparing so the assertion isn't flaky while still checking
1003        // every field of every entity.
1004        assert_eq!(sort_vecs(loaded), sort_vecs(original));
1005    }
1006
1007    /// A three-category model whose measure lists its categories out of id
1008    /// order, `[3, 1, 2]`.
1009    fn unordered_categories_model() -> Model {
1010        let mut m = Model::new();
1011        for (id, name) in [(1, "Year"), (2, "Region"), (3, "Product")] {
1012            m.add_category(CategoryId(id), name);
1013        }
1014        m.add_measure(Measure {
1015            id: MeasureId(400),
1016            name: Name("Sales".into()),
1017            value_type: ValueType::Number,
1018            categories: vec![CategoryId(3), CategoryId(1), CategoryId(2)],
1019            kind: MeasureKind::Input,
1020            description: None,
1021        });
1022        m
1023    }
1024
1025    /// `:measure/categories` is a set; the order positional data (SQL/CSV
1026    /// columns, coordinates) is mapped by must survive save/load and
1027    /// load_partial anyway.
1028    #[test]
1029    fn measure_category_order_round_trips() {
1030        let want = vec![CategoryId(3), CategoryId(1), CategoryId(2)];
1031        let mut store = ModelStore::open("").expect("open in-memory");
1032        store
1033            .save_model(&unordered_categories_model())
1034            .expect("save");
1035        let loaded = store.load_model().expect("load");
1036        assert_eq!(loaded.measures[&MeasureId(400)].categories, want);
1037        let partial = store.load_partial(&[MeasureId(400)]).expect("partial");
1038        assert_eq!(partial.measures[&MeasureId(400)].categories, want);
1039
1040        // A re-save with a new order replaces the old one.
1041        let mut m = loaded;
1042        m.measures
1043            .get_mut(&MeasureId(400))
1044            .unwrap()
1045            .categories
1046            .reverse();
1047        store.save_model(&m).expect("re-save");
1048        assert_eq!(
1049            store.load_model().expect("re-load").measures[&MeasureId(400)].categories,
1050            vec![CategoryId(2), CategoryId(1), CategoryId(3)]
1051        );
1052    }
1053
1054    /// A store written before `:measure/category-order` existed (old schema,
1055    /// measure without the attribute) still opens -- installing the new
1056    /// schema over it is idempotent -- and loads its categories in id order.
1057    #[test]
1058    fn store_without_category_order_still_loads() {
1059        let dir = std::env::temp_dir().join(format!("improv-catorder-{}", std::process::id()));
1060        let _ = std::fs::remove_dir_all(&dir);
1061        std::fs::create_dir_all(&dir).unwrap();
1062        let path = dir.join("old.db");
1063        let path = path.to_str().unwrap();
1064
1065        let start = schema::SCHEMA_EDN
1066            .find(" ;; The order of :measure/categories")
1067            .expect("category-order block");
1068        let tail = ":db.cardinality/one}";
1069        let end = start + schema::SCHEMA_EDN[start..].find(tail).unwrap() + tail.len();
1070        let old_schema = format!(
1071            "{}{}",
1072            &schema::SCHEMA_EDN[..start],
1073            &schema::SCHEMA_EDN[end..]
1074        );
1075        assert!(!old_schema.contains("category-order"));
1076        {
1077            let mut old = Store::open(path).expect("open old");
1078            old.transact(&old_schema).expect("old schema");
1079            old.transact(
1080                r#"[{:category/id 1 :category/name "Year"}
1081                    {:category/id 2 :category/name "Region"}
1082                    {:category/id 3 :category/name "Product"}]"#,
1083            )
1084            .expect("old categories");
1085            old.transact(
1086                r#"[{:measure/id 400 :measure/name "Sales"
1087                     :measure/value-type "number" :measure/kind "input"
1088                     :measure/categories [(lookup-ref :category/id 3)
1089                                          (lookup-ref :category/id 1)
1090                                          (lookup-ref :category/id 2)]}]"#,
1091            )
1092            .expect("old measure");
1093        }
1094
1095        // Opening twice installs the schema twice: both must succeed.
1096        drop(ModelStore::open(path).expect("open old store with new schema"));
1097        let mut store = ModelStore::open(path).expect("re-open");
1098        let loaded = store.load_model().expect("load old store");
1099        assert_eq!(
1100            loaded.measures[&MeasureId(400)].categories,
1101            vec![CategoryId(1), CategoryId(2), CategoryId(3)]
1102        );
1103        // Re-saving writes the order, after which it is preserved.
1104        let mut m = loaded;
1105        m.measures.get_mut(&MeasureId(400)).unwrap().categories =
1106            vec![CategoryId(3), CategoryId(1), CategoryId(2)];
1107        store.save_model(&m).expect("upgrade save");
1108        assert_eq!(
1109            store.load_model().expect("load").measures[&MeasureId(400)].categories,
1110            vec![CategoryId(3), CategoryId(1), CategoryId(2)]
1111        );
1112        drop(store);
1113        let _ = std::fs::remove_dir_all(&dir);
1114    }
1115
1116    /// A view saved by a PRE-CANVAS build of Improv must still load. Those
1117    /// blobs are on users' disks; the only thing that ever read them is
1118    /// `serde_json::from_str::<View>` in `load_views`, so this drives the real
1119    /// store path with the real legacy bytes (transacted straight as a
1120    /// `:view/json` datom -- constructing it via today's `View` would test the
1121    /// new type against itself and prove nothing about old data).
1122    #[test]
1123    fn legacy_flat_view_blob_loads_from_the_store() {
1124        let mut store = ModelStore::open("").expect("open in-memory");
1125        // Structure first, so the loaded view's measure/categories exist.
1126        let mut m = sample_model();
1127        m.views.clear();
1128        store.save_model(&m).expect("save");
1129
1130        // Byte-for-byte the shape `View` serialized to before canvases: no
1131        // `placements`, no `rect`.
1132        let legacy = r#"{"id":9,"name":"Legacy layout","measure":100,"axis_order":[2,1],"n_rows":1,"n_cols":1,"page_items":[],"filters":[{"category":2,"items":[20]}]}"#;
1133        store
1134            .store
1135            .transact(&format!(
1136                "[{{:view/id 9 :view/json {}}}]",
1137                convert::edn_str_pub(legacy)
1138            ))
1139            .expect("transact legacy view blob");
1140
1141        let loaded = store.load_model().expect("load");
1142        let v = loaded.view_by_name("Legacy layout").expect("legacy view");
1143        assert_eq!(v.id, improv_core_model::ViewId(9));
1144        assert_eq!(v.measure, MeasureId(100));
1145        assert_eq!(v.axis_order, vec![CategoryId(2), CategoryId(1)]);
1146        assert_eq!((v.n_rows, v.n_cols), (1, 1));
1147        assert!(v.page_items.is_empty());
1148        // Behaves identically: filters still filter.
1149        assert!(v.allows(CategoryId(2), ItemId(20)));
1150        assert!(!v.allows(CategoryId(2), ItemId(21)));
1151        // ...as a one-matrix canvas with defaulted geometry.
1152        assert!(v.placements.is_empty());
1153        let ms = v.matrices();
1154        assert_eq!(ms.len(), 1);
1155        assert_eq!(ms[0].measure, MeasureId(100));
1156        assert_eq!(ms[0].filters, v.filters);
1157        assert_eq!(ms[0].rect, improv_core_model::CanvasRect::default());
1158
1159        // Re-saving the loaded model rewrites it in the NEW shape, and that
1160        // still loads to the same thing (migration is idempotent, not lossy).
1161        store.save_model(&loaded).expect("re-save");
1162        let again = store.load_model().expect("re-load");
1163        assert_eq!(again.views, loaded.views);
1164    }
1165
1166    /// A multi-matrix canvas view survives a real store round trip, geometry
1167    /// included.
1168    ///
1169    /// Also confirms the change does NOT interact with `save_model`'s two datom
1170    /// limits: a view is `:view/id` + `:view/json` = 2 datoms no matter how many
1171    /// placements the JSON holds (placements make the *string* longer, never the
1172    /// datom count), and placements are not `:measure/categories`, so the
1173    /// 5460-categories-per-measure guard is untouched. The 24 placements here
1174    /// would blow a per-datom budget if either were false.
1175    #[test]
1176    fn multi_placement_view_round_trips_through_the_store() {
1177        use improv_core_model::{CanvasRect, MatrixPlacement, View, ViewId};
1178
1179        let mut store = ModelStore::open("").expect("open in-memory");
1180        let mut m = sample_model();
1181        m.views.clear();
1182
1183        let mut primary = MatrixPlacement::new(MeasureId(100));
1184        primary.axis_order = vec![CategoryId(2)];
1185        primary.rect = CanvasRect {
1186            x: 12.5,
1187            y: 24.0,
1188            w: 400.0,
1189            h: 180.0,
1190        };
1191        primary.filters = vec![improv_core_model::Filter {
1192            category: CategoryId(2),
1193            items: vec![ItemId(20)],
1194        }];
1195
1196        let mut second = MatrixPlacement::new(MeasureId(102));
1197        second.axis_order = vec![CategoryId(1), CategoryId(2)];
1198        second.n_rows = 2;
1199        second.n_cols = 0;
1200        second.page_items = vec![(CategoryId(1), ItemId(10))];
1201        second.rect = CanvasRect {
1202            x: 440.0,
1203            y: 24.0,
1204            w: 320.25,
1205            h: 240.0,
1206        };
1207
1208        let extras: Vec<MatrixPlacement> = std::iter::once(second.clone())
1209            .chain((0u8..23).map(|i| {
1210                let mut p = MatrixPlacement::new(MeasureId(100));
1211                p.rect = CanvasRect {
1212                    x: f32::from(i) * 10.0,
1213                    y: 600.0,
1214                    w: 100.0,
1215                    h: 80.0,
1216                };
1217                p
1218            }))
1219            .collect();
1220        let view = View::from_matrices(
1221            ViewId(5),
1222            Name("Canvas".into()),
1223            primary.clone(),
1224            extras.clone(),
1225        );
1226        m.add_view(view.clone());
1227
1228        store.save_model(&m).expect("save multi-placement view");
1229        let loaded = store.load_model().expect("load");
1230        let v = loaded.view_by_name("Canvas").expect("canvas view");
1231
1232        assert_eq!(v, &view, "whole view survived the store round trip");
1233        assert_eq!(v.matrices().len(), 1 + extras.len());
1234        // Geometry, to the float.
1235        assert_eq!(v.rect, primary.rect);
1236        assert_eq!(v.placements[0].rect, second.rect);
1237        assert_eq!(v.placements[0].rect.w, 320.25);
1238        // Per-matrix pivot and filters.
1239        assert_eq!((v.placements[0].n_rows, v.placements[0].n_cols), (2, 0));
1240        assert_eq!(v.placements[0].page_items, second.page_items);
1241        assert!(v.allows(CategoryId(2), ItemId(20)));
1242        assert!(!v.allows(CategoryId(2), ItemId(21)));
1243        assert!(
1244            v.placements[0].allows(CategoryId(2), ItemId(21)),
1245            "matrix 2 is unfiltered -- filters are per matrix"
1246        );
1247    }
1248
1249    /// Prove `save_model`'s atomicity: a save that fails partway through must
1250    /// not leave a mixed old/new state.
1251    ///
1252    /// Before this fix, `save_model` issued one `Store::transact` call per
1253    /// step (categories, items, measures, cells, views, meta): six separate
1254    /// SQLite transactions, each committing independently (confirmed by
1255    /// reading `Store::transact` in `../mentat/src/store.rs`, which opens and
1256    /// commits its own `InProgress` per call -- see also
1257    /// `AGENT_DATABASE_CONNECTIVITY.md` "Crash safety"). A crash or error
1258    /// between two of those steps left the earlier steps durably committed
1259    /// and the later ones missing or stale: a real "atomic saves" gap per
1260    /// IMPROV.txt.
1261    ///
1262    /// After the fix, all six steps run through ONE `InProgress` (one open
1263    /// SQLite transaction, via `Store::begin_transaction`) and a single
1264    /// trailing `ip.commit()`. If any step's `?` returns early, the
1265    /// `InProgress` (and its inner `rusqlite::Transaction`) is dropped
1266    /// without being committed; `rusqlite::Transaction`'s `Drop` rolls the
1267    /// whole SQLite transaction back (default `DropBehavior::Rollback`), so
1268    /// nothing from a failed save -- not even its earlier steps -- reaches
1269    /// disk/the in-memory DB.
1270    ///
1271    /// To manufacture a real, publicly-reachable transact failure we set an
1272    /// input cell to `Value::Number(f64::NAN)`: Rust's `f64` `Display` prints
1273    /// `NaN` as the bare (unquoted) EDN token `NaN`, which Mentat's
1274    /// `:db.type/double` typechecking rejects with `BadValuePair` (verified
1275    /// directly against `../mentat`: `store.transact("[{:cell/value-number
1276    /// NaN}]")` fails). This is a real, reachable failure -- e.g. a formula
1277    /// producing `0.0/0.0` fed back in as an input -- not a contrived path.
1278    #[test]
1279    fn save_partway_failure_does_not_leave_a_partial_write() {
1280        let mut store = ModelStore::open("").expect("open in-memory");
1281        let m1 = sample_model();
1282        store.save_model(&m1).expect("save m1");
1283
1284        // M2: a modification of M1 that touches every step BEFORE the failing
1285        // `cells` step (a new category+item, a renamed measure) plus the
1286        // failing cell itself, and would ALSO touch steps after `cells` (a
1287        // new view, a new scenario) were the transact ever to get that far.
1288        // If atomicity holds, NONE of these changes appear after the failed
1289        // save -- not even the ones from steps that would have run first.
1290        let mut m2 = m1.clone();
1291        let extra_cat = CategoryId(3);
1292        m2.add_category(extra_cat, "Region");
1293        m2.add_item(ItemId(30), extra_cat, "EMEA");
1294        m2.measures.get_mut(&MeasureId(100)).unwrap().name = Name("Price (changed)".into());
1295        m2.set_input(
1296            MeasureId(100),
1297            Coordinate::from_pairs([(CategoryId(2), ItemId(20))]),
1298            Value::Number(f64::NAN),
1299        );
1300        m2.add_view(improv_core_model::View {
1301            id: improv_core_model::ViewId(2),
1302            name: Name("Should never persist".into()),
1303            measure: MeasureId(100),
1304            axis_order: vec![],
1305            n_rows: 1,
1306            n_cols: 1,
1307            page_items: vec![],
1308            filters: vec![],
1309            rect: Default::default(),
1310            placements: vec![],
1311        });
1312        m2.add_scenario(improv_core_model::Scenario {
1313            id: improv_core_model::ScenarioId(2),
1314            name: Name("Should also never persist".into()),
1315            overrides: HashMap::new(),
1316        });
1317
1318        let err = store
1319            .save_model(&m2)
1320            .expect_err("NaN cell must fail to transact");
1321        assert!(
1322            matches!(err, StoreError::Mentat(_)),
1323            "expected a Mentat transact error, got {err:?}"
1324        );
1325
1326        // The store must be exactly M1 -- none of M2's changes, including the
1327        // ones from steps that ran before the failing `cells` step, survived.
1328        let after = store.load_model().expect("load after failed save");
1329        assert_eq!(sort_vecs(after), sort_vecs(m1));
1330    }
1331
1332    /// Sort the order-insensitive `Vec` fields (`measure.categories`,
1333    /// `category.items`) so two models differing only in that ordering
1334    /// compare equal.
1335    fn temp_store_path(tag: &str) -> String {
1336        let p = std::env::temp_dir().join(format!("improv-lock-{tag}-{}.db", std::process::id()));
1337        let _ = std::fs::remove_file(&p);
1338        p.to_str().expect("utf-8 temp path").to_owned()
1339    }
1340
1341    /// Two handles on one file stand in for two processes. B's save lands
1342    /// after A's load, so A's save must refuse rather than retract B's cell.
1343    #[test]
1344    fn stale_save_is_refused_and_keeps_the_other_writers_cell() {
1345        let path = temp_store_path("stale");
1346        ModelStore::open(&path)
1347            .and_then(|mut s| s.save_model(&sample_model()))
1348            .expect("seed");
1349        let mut a = ModelStore::open(&path).expect("open a");
1350        let mut b = ModelStore::open(&path).expect("open b");
1351        let model_a = a.load_model().expect("a loads");
1352        let mut model_b = b.load_model().expect("b loads");
1353        let b_coord = Coordinate::from_pairs([(CategoryId(2), ItemId(21))]);
1354        model_b.add_item(ItemId(21), CategoryId(2), "Widget B");
1355        model_b.set_input(MeasureId(100), b_coord.clone(), Value::Number(7.0));
1356        b.save_model(&model_b).expect("b saves");
1357
1358        let err = a.save_model(&model_a).expect_err("a's save is stale");
1359        assert!(matches!(err, StoreError::Conflict(_)), "got {err:?}");
1360
1361        let after = ModelStore::open(&path)
1362            .and_then(|mut s| s.load_model())
1363            .expect("reopen");
1364        assert_eq!(
1365            after.input(MeasureId(100), &b_coord),
1366            Some(&Value::Number(7.0))
1367        );
1368        let _ = std::fs::remove_file(&path);
1369    }
1370
1371    /// A handle's own commit advances its base, so it can keep saving; and a
1372    /// handle that merely opened the store (a no-op schema transact) does not
1373    /// count as a writer.
1374    #[test]
1375    fn own_saves_and_bare_opens_do_not_conflict() {
1376        let path = temp_store_path("own");
1377        ModelStore::open(&path)
1378            .and_then(|mut s| s.save_model(&sample_model()))
1379            .expect("seed");
1380        let mut a = ModelStore::open(&path).expect("open a");
1381        let mut m = a.load_model().expect("load");
1382        a.save_model(&m).expect("first save");
1383        let _reader = ModelStore::open(&path).expect("another process opens");
1384        m.set_input(
1385            MeasureId(100),
1386            Coordinate::from_pairs([(CategoryId(2), ItemId(20))]),
1387            Value::Number(11.0),
1388        );
1389        a.save_model(&m).expect("second save");
1390        let _ = std::fs::remove_file(&path);
1391    }
1392
1393    #[test]
1394    fn fresh_store_saves_without_a_prior_load() {
1395        let path = temp_store_path("fresh");
1396        let mut s = ModelStore::open(&path).expect("open");
1397        assert_eq!(s.loaded_tx(), None);
1398        s.save_model(&sample_model()).expect("unconditional save");
1399        assert_eq!(s.loaded_tx(), Some(s.tx_high_water().expect("high water")));
1400        let _ = std::fs::remove_file(&path);
1401    }
1402
1403    fn sort_vecs(mut m: Model) -> Model {
1404        for c in m.categories.values_mut() {
1405            c.items.sort();
1406        }
1407        for measure in m.measures.values_mut() {
1408            measure.categories.sort();
1409        }
1410        m
1411    }
1412
1413    #[test]
1414    fn load_partial_loads_only_the_dependency_closures_cells() {
1415        // Time x Product; Price (input), Quantity (input), Revenue = Price *
1416        // Quantity (derived) -- Revenue's closure is {Revenue, Price, Quantity}.
1417        // Unrelated (input) is a measure with its own cell that must NOT load.
1418        let mut store = ModelStore::open("").expect("open in-memory");
1419        let mut m = Model::new();
1420        let (time, product) = (CategoryId(1), CategoryId(2));
1421        m.add_category(time, "Time");
1422        m.add_category(product, "Product");
1423        m.add_item(ItemId(10), time, "2025");
1424        m.add_item(ItemId(20), product, "WidgetA");
1425
1426        m.add_measure(Measure {
1427            id: MeasureId(100),
1428            name: Name("Price".into()),
1429            value_type: ValueType::Number,
1430            categories: vec![product],
1431            kind: MeasureKind::Input,
1432            description: None,
1433        });
1434        m.add_measure(Measure {
1435            id: MeasureId(101),
1436            name: Name("Quantity".into()),
1437            value_type: ValueType::Number,
1438            categories: vec![time, product],
1439            kind: MeasureKind::Input,
1440            description: None,
1441        });
1442        m.add_measure(Measure {
1443            id: MeasureId(102),
1444            name: Name("Revenue".into()),
1445            value_type: ValueType::Number,
1446            categories: vec![time, product],
1447            kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
1448                BinaryOp::Mul,
1449                Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
1450                Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
1451            ))),
1452            description: None,
1453        });
1454        m.add_measure(Measure {
1455            id: MeasureId(200),
1456            name: Name("Unrelated".into()),
1457            value_type: ValueType::Number,
1458            categories: vec![],
1459            kind: MeasureKind::Input,
1460            description: None,
1461        });
1462
1463        let price_coord = Coordinate::from_pairs([(product, ItemId(20))]);
1464        let qty_coord = Coordinate::from_pairs([(time, ItemId(10)), (product, ItemId(20))]);
1465        m.set_input(MeasureId(100), price_coord.clone(), Value::Number(10.0));
1466        m.set_input(MeasureId(101), qty_coord.clone(), Value::Number(5.0));
1467        m.set_input(MeasureId(200), Coordinate::new(), Value::Number(999.0));
1468
1469        store.save_model(&m).expect("save");
1470
1471        // load_partial(&[Revenue]) must load Price/Quantity's cells but NOT
1472        // Unrelated's, even though Unrelated's MEASURE metadata is still
1473        // present (categories/items/measures are always loaded in full --
1474        // only cell volume is filtered).
1475        let partial = store.load_partial(&[MeasureId(102)]).expect("load_partial");
1476        assert!(
1477            partial.measures.contains_key(&MeasureId(200)),
1478            "measure shape still loads"
1479        );
1480        assert_eq!(
1481            partial.input(MeasureId(100), &price_coord),
1482            Some(&Value::Number(10.0))
1483        );
1484        assert_eq!(
1485            partial.input(MeasureId(101), &qty_coord),
1486            Some(&Value::Number(5.0))
1487        );
1488        assert_eq!(
1489            partial.input(MeasureId(200), &Coordinate::new()),
1490            None,
1491            "Unrelated's cell must NOT be loaded -- it's outside Revenue's closure"
1492        );
1493
1494        // The engine gets zero changes: evaluate() over the SAME partial model
1495        // (which is an ordinary Model) produces the same Revenue as a full load.
1496        let full = store.load_model().expect("load_model");
1497        let out_full =
1498            improv_engine::dataflow::evaluate(&full, &[MeasureId(102)]).expect("eval full");
1499        let out_partial =
1500            improv_engine::dataflow::evaluate(&partial, &[MeasureId(102)]).expect("eval partial");
1501        assert_eq!(out_full, out_partial);
1502
1503        // Saving a partial model keeps the cells it never loaded, and still
1504        // diffs the closure's: an edit lands, a removed closure cell goes.
1505        let mut partial = store
1506            .load_partial(&[MeasureId(102)])
1507            .expect("reload partial");
1508        partial.set_input(MeasureId(100), price_coord.clone(), Value::Number(11.0));
1509        partial.inputs.remove(&(MeasureId(101), qty_coord.clone()));
1510        store.save_model(&partial).expect("save partial");
1511        let after = store.load_model().expect("load after");
1512        assert_eq!(
1513            after.input(MeasureId(200), &Coordinate::new()),
1514            Some(&Value::Number(999.0)),
1515            "a cell outside the closure survives a partial save"
1516        );
1517        assert_eq!(
1518            after.input(MeasureId(100), &price_coord),
1519            Some(&Value::Number(11.0))
1520        );
1521        assert_eq!(after.input(MeasureId(101), &qty_coord), None);
1522        // A full load lifts the scope again: dropping Unrelated now retracts it.
1523        let mut full = after;
1524        full.inputs.remove(&(MeasureId(200), Coordinate::new()));
1525        store.save_model(&full).expect("save full");
1526        let last = store.load_model().expect("load last");
1527        assert_eq!(last.input(MeasureId(200), &Coordinate::new()), None);
1528    }
1529
1530    /// `load_partial`'s cell *enumeration* must be bounded by the closure, not
1531    /// by the size of the store: a measure outside the closure with hundreds of
1532    /// cells must not produce a single returned row.
1533    ///
1534    /// Checked via `ModelStore::cell_rows_returned` (test-only), which records
1535    /// how many rows the `load_cells` query RETURNED — before this fix the
1536    /// query was always the unconstrained "every (measure, coord) pair" one and
1537    /// non-closure measures were dropped by a `continue` in the Rust loop, so
1538    /// this counter would have read 402 (all cells) instead of 2, and both
1539    /// assertions below would fail.
1540    #[test]
1541    fn load_partial_enumeration_is_bounded_by_the_closure() {
1542        let mut store = ModelStore::open("").expect("open in-memory");
1543        let mut m = Model::new();
1544        let cat = CategoryId(1);
1545        m.add_category(cat, "Thing");
1546        const BIG: u32 = 400;
1547        for i in 0..BIG {
1548            m.add_item(ItemId(1000 + i), cat, format!("item{i}"));
1549        }
1550
1551        // Closure: Derived = Small * Small (so {Derived, Small}), 1 input cell.
1552        m.add_measure(Measure {
1553            id: MeasureId(1),
1554            name: Name("Small".into()),
1555            value_type: ValueType::Number,
1556            categories: vec![cat],
1557            kind: MeasureKind::Input,
1558            description: None,
1559        });
1560        m.add_measure(Measure {
1561            id: MeasureId(2),
1562            name: Name("Derived".into()),
1563            value_type: ValueType::Number,
1564            categories: vec![cat],
1565            kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
1566                BinaryOp::Mul,
1567                Box::new(Expr::Ref(MeasureId(1), DimensionSpec::default())),
1568                Box::new(Expr::Ref(MeasureId(1), DimensionSpec::default())),
1569            ))),
1570            description: None,
1571        });
1572        // Outside the closure, and much bigger than it.
1573        m.add_measure(Measure {
1574            id: MeasureId(3),
1575            name: Name("Huge".into()),
1576            value_type: ValueType::Number,
1577            categories: vec![cat],
1578            kind: MeasureKind::Input,
1579            description: None,
1580        });
1581
1582        let small_coord = Coordinate::from_pairs([(cat, ItemId(1000))]);
1583        m.set_input(MeasureId(1), small_coord.clone(), Value::Number(3.0));
1584        m.set_input(MeasureId(2), small_coord.clone(), Value::Number(9.0));
1585        for i in 0..BIG {
1586            m.set_input(
1587                MeasureId(3),
1588                Coordinate::from_pairs([(cat, ItemId(1000 + i))]),
1589                Value::Number(f64::from(i)),
1590            );
1591        }
1592        store.save_model(&m).expect("save");
1593
1594        let partial = store.load_partial(&[MeasureId(2)]).expect("load_partial");
1595        // The closure has 2 cells (Small's and Derived's own stored cell); the
1596        // store holds 2 + BIG. The query must have returned only the former.
1597        assert_eq!(
1598            store.cell_rows_returned,
1599            2,
1600            "load_partial must not enumerate out-of-closure rows (store holds {} cells)",
1601            2 + BIG
1602        );
1603        assert_eq!(partial.inputs.len(), 2);
1604        assert_eq!(
1605            partial.input(MeasureId(1), &small_coord),
1606            Some(&Value::Number(3.0))
1607        );
1608
1609        // Empty closure: no cells, no query (a bare `ground []` is a Mentat
1610        // parse error, so this branch must short-circuit, not build a query).
1611        let none = store.load_partial(&[]).expect("load_partial(&[])");
1612        assert!(none.inputs.is_empty());
1613        assert_eq!(store.cell_rows_returned, 0);
1614
1615        // load_model still loads EVERYTHING -- no accidental filtering.
1616        let full = store.load_model().expect("load_model");
1617        assert_eq!(store.cell_rows_returned as u32, 2 + BIG);
1618        assert_eq!(full.inputs.len() as u32, 2 + BIG);
1619        assert_eq!(
1620            full.input(
1621                MeasureId(3),
1622                &Coordinate::from_pairs([(cat, ItemId(1000 + BIG - 1))])
1623            ),
1624            Some(&Value::Number(f64::from(BIG - 1)))
1625        );
1626    }
1627
1628    #[test]
1629    fn load_partial_with_no_dependencies_loads_only_its_own_cells() {
1630        // A root with no formula/arg_measures (an ordinary input measure) has a
1631        // closure of just itself.
1632        let mut store = ModelStore::open("").expect("open in-memory");
1633        let mut m = Model::new();
1634        m.add_measure(Measure {
1635            id: MeasureId(1),
1636            name: Name("A".into()),
1637            value_type: ValueType::Number,
1638            categories: vec![],
1639            kind: MeasureKind::Input,
1640            description: None,
1641        });
1642        m.add_measure(Measure {
1643            id: MeasureId(2),
1644            name: Name("B".into()),
1645            value_type: ValueType::Number,
1646            categories: vec![],
1647            kind: MeasureKind::Input,
1648            description: None,
1649        });
1650        m.set_input(MeasureId(1), Coordinate::new(), Value::Number(1.0));
1651        m.set_input(MeasureId(2), Coordinate::new(), Value::Number(2.0));
1652        store.save_model(&m).expect("save");
1653
1654        let partial = store.load_partial(&[MeasureId(1)]).expect("load_partial");
1655        assert_eq!(
1656            partial.input(MeasureId(1), &Coordinate::new()),
1657            Some(&Value::Number(1.0))
1658        );
1659        assert_eq!(partial.input(MeasureId(2), &Coordinate::new()), None);
1660    }
1661
1662    const WIDE_CAT: CategoryId = CategoryId(1);
1663
1664    /// A model with `n_cells` input cells over `n_cells` items of one category,
1665    /// so BOTH the items group and the cells group exceed a single safe
1666    /// transact.
1667    fn wide_model(n_cells: u32) -> Model {
1668        let mut m = Model::new();
1669        m.add_category(WIDE_CAT, "Thing");
1670        m.add_measure(Measure {
1671            id: MeasureId(1),
1672            name: Name("Amount".into()),
1673            value_type: ValueType::Number,
1674            categories: vec![WIDE_CAT],
1675            kind: MeasureKind::Input,
1676            description: None,
1677        });
1678        for i in 0..n_cells {
1679            m.add_item(ItemId(1000 + i), WIDE_CAT, format!("item{i}"));
1680            m.set_input(
1681                MeasureId(1),
1682                Coordinate::from_pairs([(WIDE_CAT, ItemId(1000 + i))]),
1683                Value::Number(f64::from(i) * 1.5),
1684            );
1685        }
1686        m
1687    }
1688
1689    fn assert_wide_round_trip(n_cells: u32) {
1690        let mut store = ModelStore::open("").expect("open in-memory");
1691        store.save_model(&wide_model(n_cells)).expect("save large");
1692
1693        let loaded = store.load_model().expect("load large");
1694        assert_eq!(loaded.items.len() as u32, n_cells);
1695        assert_eq!(loaded.inputs.len() as u32, n_cells);
1696        // Spot-check the first, a middle, and the last cell's value.
1697        for i in [0, n_cells / 2, n_cells - 1] {
1698            assert_eq!(
1699                loaded.input(
1700                    MeasureId(1),
1701                    &Coordinate::from_pairs([(WIDE_CAT, ItemId(1000 + i))])
1702                ),
1703                Some(&Value::Number(f64::from(i) * 1.5)),
1704                "cell {i} of {n_cells}"
1705            );
1706        }
1707    }
1708
1709    /// A model past Mentat's hard transact ceiling must save and round-trip.
1710    ///
1711    /// Mentat's `insert_non_fts_searches` `assert!`s that a single transact
1712    /// carries fewer than `32766 / 6` = 5461 datoms, and *aborts the process*
1713    /// otherwise (`Too many values: 6 * 5461 >= 32766`). Before the chunking
1714    /// fix `save_model` put every entity of a kind in ONE transact, so this
1715    /// test panicked rather than failed: 6000 cells (and 6000 items) are each
1716    /// well past that limit.
1717    #[test]
1718    fn save_and_load_model_past_mentats_transact_limit() {
1719        assert_wide_round_trip(6_000);
1720    }
1721
1722    /// Same, but far enough past the boundary to prove *many* chunks work and
1723    /// not just the first one.
1724    #[test]
1725    fn save_and_load_model_spanning_many_chunks() {
1726        assert_wide_round_trip(12_000);
1727    }
1728
1729    /// Chunking must not weaken atomicity: a failure in a *later* chunk of the
1730    /// cells group rolls back the earlier chunks of that same group, not just
1731    /// the earlier groups.
1732    #[test]
1733    fn a_failure_in_a_later_chunk_rolls_back_earlier_chunks() {
1734        let mut store = ModelStore::open("").expect("open in-memory");
1735        let mut m = wide_model(6_000);
1736        // NaN prints as the bare EDN token `NaN`, which Mentat's
1737        // `:db.type/double` typecheck rejects -- same mechanism as
1738        // `save_partway_failure_does_not_leave_a_partial_write`. Which chunk it
1739        // lands in depends on HashMap iteration order, which is the point:
1740        // whichever chunk fails, everything before it must vanish.
1741        m.set_input(
1742            MeasureId(1),
1743            Coordinate::from_pairs([(WIDE_CAT, ItemId(1000))]),
1744            Value::Number(f64::NAN),
1745        );
1746        let err = store.save_model(&m).expect_err("NaN cell must fail");
1747        assert!(matches!(err, StoreError::Mentat(_)), "got {err:?}");
1748
1749        // Nothing from the failed save survived: not the categories/items
1750        // transacted before the cells group, and not the cell chunks that
1751        // succeeded before the failing one.
1752        let after = store.load_model().expect("load after failed save");
1753        assert!(
1754            after.inputs.is_empty(),
1755            "{} cells leaked",
1756            after.inputs.len()
1757        );
1758        assert!(after.items.is_empty(), "{} items leaked", after.items.len());
1759        assert!(after.categories.is_empty());
1760    }
1761
1762    /// A measure carrying `n` categories, numbered from `first_id`.
1763    fn measure_with_n_categories_from(n: usize, first_id: u32) -> Model {
1764        let mut m = Model::new();
1765        let mut cats = Vec::with_capacity(n);
1766        for i in 0..n {
1767            let c = CategoryId(first_id + i as u32);
1768            m.add_category(c, format!("cat{i}"));
1769            cats.push(c);
1770        }
1771        m.add_measure(Measure {
1772            id: MeasureId(7),
1773            name: Name("Very Wide".into()),
1774            value_type: ValueType::Number,
1775            categories: cats,
1776            kind: MeasureKind::Input,
1777            description: None,
1778        });
1779        m
1780    }
1781
1782    fn measure_with_n_categories(n: usize) -> Model {
1783        measure_with_n_categories_from(n, 1)
1784    }
1785
1786    /// One entity wider than the store's per-transact ceiling must come back as
1787    /// an error, not kill the process.
1788    ///
1789    /// Chunking (`MAX_DATOMS_PER_TRANSACT`) fixed groups of entities, but a
1790    /// measure is indivisible: `:measure/categories` is cardinality-many, so
1791    /// 5461+ categories on one measure put 5461+ datoms in a single
1792    /// `insert_non_fts_searches` call, which `assert!`s and *aborts*. Against
1793    /// the pre-guard code this exact input printed
1794    /// `Too many values: 6 * 5461 >= 32766` from `../mentat/db/src/db.rs:949`.
1795    #[test]
1796    fn measure_past_the_single_entity_ceiling_errors_instead_of_aborting() {
1797        let mut store = ModelStore::open("").expect("open in-memory");
1798        let m = measure_with_n_categories(MAX_CATEGORIES_PER_MEASURE + 1);
1799        let err = store
1800            .save_model(&m)
1801            .expect_err("over-wide measure must fail");
1802
1803        let msg = err.to_string();
1804        assert!(matches!(err, StoreError::Integrity(_)), "got {err:?}");
1805        // The diagnostic names the offending entity and the real limit.
1806        assert!(msg.contains("measure 7"), "{msg}");
1807        assert!(msg.contains("Very Wide"), "{msg}");
1808        assert!(msg.contains("5461"), "{msg}");
1809        assert!(
1810            msg.contains(&MAX_CATEGORIES_PER_MEASURE.to_string()),
1811            "{msg}"
1812        );
1813
1814        // The refused save left nothing behind (the `InProgress` is dropped
1815        // before `commit`), so a later smaller save is unaffected.
1816        assert!(store.load_model().expect("load").measures.is_empty());
1817    }
1818
1819    /// The last savable width still saves and round-trips — the guard must not
1820    /// be off by one. 5460 is measured, not assumed: 5461 aborts (see above).
1821    #[test]
1822    fn measure_at_the_single_entity_ceiling_round_trips() {
1823        assert_eq!(MAX_CATEGORIES_PER_MEASURE, 5460);
1824        let mut store = ModelStore::open("").expect("open in-memory");
1825        let m = measure_with_n_categories(MAX_CATEGORIES_PER_MEASURE);
1826        store.save_model(&m).expect("5460 categories must save");
1827
1828        let loaded = store.load_model().expect("load");
1829        assert_eq!(
1830            loaded.measures[&MeasureId(7)].categories.len(),
1831            MAX_CATEGORIES_PER_MEASURE
1832        );
1833    }
1834
1835    /// The guard stays sound despite the (separate, unfixed) missing-retraction
1836    /// bug: `:measure/categories` is cardinality-many and `save_model` never
1837    /// retracts stale refs, so re-saving a measure with a *different* category
1838    /// set accumulates the union on disk. Two 3000-category saves of disjoint
1839    /// sets each pass the guard, yet the store then holds 6000 refs — past the
1840    /// ceiling. Re-saving *that loaded model* must be a clean error, and a
1841    /// normal-width save over the accumulated entity must still succeed (proving
1842    /// the abort counts only the datoms of the transact being applied, so the
1843    /// in-memory budget the guard checks is the right thing to check).
1844    #[test]
1845    fn churned_resave_reports_error_not_abort() {
1846        let mut store = ModelStore::open("").expect("open in-memory");
1847        store
1848            .save_model(&measure_with_n_categories_from(3_000, 1))
1849            .expect("first save");
1850        store
1851            .save_model(&measure_with_n_categories_from(3_000, 10_000))
1852            .expect("churned re-save (both halves under the guard)");
1853
1854        // Finding #8: the union accumulated, so the on-disk width now exceeds
1855        // what either in-memory save emitted.
1856        let loaded = store.load_model().expect("load");
1857        assert_eq!(loaded.measures[&MeasureId(7)].categories.len(), 6_000);
1858
1859        // Re-saving the accumulated model is a recoverable error, not an abort.
1860        let err = store
1861            .save_model(&loaded)
1862            .expect_err("6000-ref re-save must fail");
1863        assert!(matches!(err, StoreError::Integrity(_)), "got {err:?}");
1864
1865        // And a normal-width save over the 6000-ref on-disk entity still works:
1866        // the ceiling is per-transact, not cumulative.
1867        store
1868            .save_model(&measure_with_n_categories_from(3_000, 1))
1869            .expect("narrow save over a wide on-disk entity");
1870    }
1871    /// Strings containing newlines, tabs or CRs must survive save/reload.
1872    /// `edn_str` used to emit `\\n`/`\\t`/`\\r`, but Mentat's EDN reader drops the
1873    /// backslash rather than interpreting it, so `"a\\nb"` reloaded as `"anb"` --
1874    /// silent corruption of any description, name, or Text cell with whitespace
1875    /// control characters in it.
1876    #[test]
1877    fn control_characters_survive_a_round_trip() {
1878        let mut store = ModelStore::open("").expect("open in-memory");
1879        let mut m = Model::new();
1880        let cat = CategoryId(1);
1881        m.add_category(cat, "Line\nBreak");
1882        m.add_item(ItemId(10), cat, "Tab\tItem");
1883        m.add_measure(Measure {
1884            id: MeasureId(1),
1885            name: Name("Multi\nLine".into()),
1886            value_type: ValueType::Text,
1887            categories: vec![cat],
1888            kind: MeasureKind::Input,
1889            description: Some("first\nsecond\tthird\rfourth".into()),
1890        });
1891        m.set_input(
1892            MeasureId(1),
1893            Coordinate::from_pairs([(cat, ItemId(10))]),
1894            Value::Text("cell\nwith\ttabs".into()),
1895        );
1896        store.save_model(&m).expect("save");
1897        let back = store.load_model().expect("load");
1898
1899        assert_eq!(back.categories[&cat].name.0, "Line\nBreak");
1900        assert_eq!(back.items[&ItemId(10)].name.0, "Tab\tItem");
1901        let got = &back.measures[&MeasureId(1)];
1902        assert_eq!(got.name.0, "Multi\nLine");
1903        assert_eq!(
1904            got.description.as_deref(),
1905            Some("first\nsecond\tthird\rfourth")
1906        );
1907        assert_eq!(
1908            back.input(MeasureId(1), &Coordinate::from_pairs([(cat, ItemId(10))])),
1909            Some(&Value::Text("cell\nwith\ttabs".into()))
1910        );
1911    }
1912
1913    /// One measure per value type, two cells each: what `load_model` hands
1914    /// back must equal what was saved, type for type (P1: the per-type value
1915    /// queries must not mix attributes up or lose dates to text).
1916    fn mixed_type_model() -> Model {
1917        let mut m = Model::new();
1918        let cat = CategoryId(1);
1919        m.add_category(cat, "Thing");
1920        m.add_item(ItemId(10), cat, "a");
1921        m.add_item(ItemId(11), cat, "b");
1922        let dt = |s: &str| {
1923            Value::DateTime(
1924                chrono::DateTime::parse_from_rfc3339(s)
1925                    .unwrap()
1926                    .with_timezone(&chrono::Utc),
1927            )
1928        };
1929        let cols = [
1930            (ValueType::Number, Value::Number(1.5), Value::Number(-2.0)),
1931            (
1932                ValueType::Text,
1933                Value::Text("hi".into()),
1934                Value::Text(String::new()),
1935            ),
1936            (
1937                ValueType::Boolean,
1938                Value::Boolean(true),
1939                Value::Boolean(false),
1940            ),
1941            (
1942                ValueType::DateTime,
1943                dt("2026-01-02T03:04:05Z"),
1944                dt("1999-12-31T23:59:59.5Z"),
1945            ),
1946            (ValueType::Enum, Value::Enum(3), Value::Enum(0)),
1947        ];
1948        for (i, (vt, a, b)) in cols.into_iter().enumerate() {
1949            let mid = MeasureId(100 + i as u32);
1950            m.add_measure(Measure {
1951                id: mid,
1952                name: Name(format!("M{i}")),
1953                value_type: vt,
1954                categories: vec![cat],
1955                kind: MeasureKind::Input,
1956                description: None,
1957            });
1958            m.set_input(mid, Coordinate::from_pairs([(cat, ItemId(10))]), a);
1959            m.set_input(mid, Coordinate::from_pairs([(cat, ItemId(11))]), b);
1960        }
1961        m
1962    }
1963
1964    #[test]
1965    fn load_model_returns_identical_cells_for_every_value_type() {
1966        let mut store = ModelStore::open("").expect("open in-memory");
1967        let m = mixed_type_model();
1968        store.save_model(&m).expect("save");
1969        let loaded = store.load_model().expect("load");
1970        assert_eq!(loaded.inputs, m.inputs);
1971        let partial = store.load_partial(&[MeasureId(103)]).expect("partial");
1972        assert_eq!(partial.inputs.len(), 2);
1973        assert_eq!(
1974            partial.input(
1975                MeasureId(103),
1976                &Coordinate::from_pairs([(CategoryId(1), ItemId(10))])
1977            ),
1978            m.input(
1979                MeasureId(103),
1980                &Coordinate::from_pairs([(CategoryId(1), ItemId(10))])
1981            )
1982        );
1983    }
1984
1985    /// P1 stress: a 100k-cell load must take seconds, not minutes (it was
1986    /// ~290 s with one query per cell). Release mode recommended:
1987    /// `cargo test --release -p improv_storage_mentat -- --ignored`.
1988    #[test]
1989    #[ignore = "stress: 100k cells; run with --ignored"]
1990    fn load_100k_cells_is_fast() {
1991        let mut store = ModelStore::open("").expect("open in-memory");
1992        store.save_model(&wide_model(100_000)).expect("save");
1993        let t = std::time::Instant::now();
1994        let loaded = store.load_model().expect("load");
1995        let took = t.elapsed();
1996        assert_eq!(loaded.inputs.len(), 100_000);
1997        // Target is 5 s in release; debug builds get headroom.
1998        let limit = if cfg!(debug_assertions) { 60 } else { 5 };
1999        eprintln!("100k-cell load_model: {took:?}");
2000        assert!(took.as_secs() < limit, "100k-cell load took {took:?}");
2001    }
2002
2003    /// How many cell value datoms the store holds, across all three typed
2004    /// value attributes, and how many cell entities.
2005    fn cell_datom_counts(store: &ModelStore) -> (usize, usize) {
2006        let values = [VALUE_NUMBER, VALUE_BOOLEAN, VALUE_TEXT]
2007            .iter()
2008            .map(|a| {
2009                store
2010                    .rel(&format!("[:find ?e ?v :where [?e :cell/{a} ?v]]"))
2011                    .unwrap()
2012                    .len()
2013            })
2014            .sum();
2015        let entities = store
2016            .rel("[:find ?e :where [?e :cell/key _]]")
2017            .unwrap()
2018            .len();
2019        (values, entities)
2020    }
2021
2022    /// P2: re-saving an unchanged model adds no cell datoms, a changed value
2023    /// replaces the old one, and a cleared cell is retracted instead of
2024    /// lingering on disk (and coming back on the next load).
2025    #[test]
2026    fn resave_keeps_cell_datoms_flat_and_retracts_stale_ones() {
2027        let mut store = ModelStore::open("").expect("open in-memory");
2028        let mut m = mixed_type_model();
2029        let n = m.inputs.len();
2030        for _ in 0..3 {
2031            store.save_model(&m).expect("save");
2032            assert_eq!(cell_datom_counts(&store), (n, n));
2033        }
2034
2035        // A changed value replaces the old one (cardinality-one).
2036        let a = Coordinate::from_pairs([(CategoryId(1), ItemId(10))]);
2037        m.set_input(MeasureId(100), a.clone(), Value::Number(42.0));
2038        store.save_model(&m).expect("save changed");
2039        assert_eq!(cell_datom_counts(&store), (n, n));
2040        assert_eq!(store.load_model().unwrap().inputs, m.inputs);
2041
2042        // A cleared cell is gone from the store, not just from the model.
2043        m.inputs.remove(&(MeasureId(101), a.clone()));
2044        store.save_model(&m).expect("save cleared");
2045        assert_eq!(cell_datom_counts(&store), (n - 1, n - 1));
2046        assert_eq!(store.load_model().unwrap().inputs, m.inputs);
2047
2048        // A cell whose measure changed type keeps one value attribute, the new
2049        // one: the old typed value is retracted.
2050        let mut loaded = store.load_model().unwrap();
2051        loaded.measures.get_mut(&MeasureId(100)).unwrap().value_type = ValueType::Text;
2052        loaded.set_input(MeasureId(100), a.clone(), Value::Text("now text".into()));
2053        let b = Coordinate::from_pairs([(CategoryId(1), ItemId(11))]);
2054        loaded.set_input(MeasureId(100), b, Value::Text("also".into()));
2055        store.save_model(&loaded).expect("save retyped");
2056        assert_eq!(cell_datom_counts(&store), (n - 1, n - 1));
2057        assert_eq!(store.load_model().unwrap().inputs, loaded.inputs);
2058    }
2059
2060    /// Paths the main fixture skips: a SQL-sourced measure, a CALL blob with
2061    /// no function defs, a cell re-saved under another value type (the old
2062    /// attribute is retracted; a value whose type differs from the measure's
2063    /// still loads), `reload_model`, and an empty `load_partial` closure.
2064    #[test]
2065    fn sql_source_call_blob_and_retyped_cell_round_trip() {
2066        let mut store = ModelStore::open("").expect("open in-memory");
2067        let product = CategoryId(2);
2068        let mut m = Model::new();
2069        m.add_category(product, "Product");
2070        m.add_item(ItemId(20), product, "Widget A");
2071        m.add_measure(Measure {
2072            id: MeasureId(100),
2073            name: Name("Price".into()),
2074            value_type: ValueType::Number,
2075            categories: vec![product],
2076            kind: MeasureKind::Input,
2077            description: None,
2078        });
2079        m.sql_sources.insert(
2080            MeasureId(100),
2081            improv_core_model::SqlSource {
2082                query: "SELECT p, v FROM t".into(),
2083                dimension_columns: vec!["p".into()],
2084                value_column: "v".into(),
2085                refresh_policy: Default::default(),
2086            },
2087        );
2088        m.external_calls.insert(
2089            MeasureId(100),
2090            improv_core_model::ExternalCall {
2091                func: "f".into(),
2092                arg_measures: vec![],
2093                refresh_policy: Default::default(),
2094            },
2095        );
2096        let coord = Coordinate::from_pairs([(product, ItemId(20))]);
2097        m.set_input(MeasureId(100), coord.clone(), Value::Number(1.0));
2098        store.save_model(&m).expect("save");
2099        m.set_input(MeasureId(100), coord.clone(), Value::Text("n/a".into()));
2100        store.save_model(&m).expect("re-save");
2101
2102        let loaded = store.reload_model().expect("reload");
2103        assert_eq!(loaded.sql_sources, m.sql_sources);
2104        assert_eq!(loaded.external_calls, m.external_calls);
2105        assert_eq!(loaded.inputs, m.inputs);
2106
2107        let partial = store.load_partial(&[]).expect("partial");
2108        assert!(partial.inputs.is_empty());
2109        assert_eq!(partial.measures.len(), 1);
2110    }
2111
2112    /// A meta blob carrying only scenarios survives a round trip. A non-finite
2113    /// number has no EDN double spelling, so its save is a recoverable error
2114    /// (not an abort) that leaves the store untouched.
2115    #[test]
2116    fn scenarios_only_meta_round_trips_and_infinite_cell_is_refused() {
2117        let mut store = ModelStore::open("").expect("open in-memory");
2118        let mut m = Model::new();
2119        m.add_category(CategoryId(1), "Time");
2120        m.add_item(ItemId(10), CategoryId(1), "2025");
2121        m.add_measure(Measure {
2122            id: MeasureId(1),
2123            name: Name("X".into()),
2124            value_type: ValueType::Number,
2125            categories: vec![CategoryId(1)],
2126            kind: MeasureKind::Input,
2127            description: None,
2128        });
2129        let coord = Coordinate::from_pairs([(CategoryId(1), ItemId(10))]);
2130        m.set_input(MeasureId(1), coord.clone(), Value::Number(2.5));
2131        m.add_scenario(improv_core_model::Scenario {
2132            id: improv_core_model::ScenarioId(1),
2133            name: Name("Up".into()),
2134            overrides: [((MeasureId(1), coord.clone()), Value::Number(2.0))]
2135                .into_iter()
2136                .collect(),
2137        });
2138        store.save_model(&m).expect("save");
2139        let loaded = store.load_model().expect("load");
2140        assert_eq!(loaded.scenarios, m.scenarios);
2141        assert_eq!(loaded.inputs, m.inputs);
2142
2143        let mut inf = m.clone();
2144        inf.set_input(MeasureId(1), coord, Value::Number(f64::INFINITY));
2145        let err = store.save_model(&inf).expect_err("inf has no EDN form");
2146        assert!(matches!(err, StoreError::Mentat(_)), "got {err:?}");
2147        assert_eq!(store.load_model().expect("reload").inputs, m.inputs);
2148    }
2149}