From 509e27ca2537f8cda7aa52b96837bbbaa01fd262 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 12 Oct 2026 12:25:28 +0100 Subject: [PATCH 1/3] Learn incrementally when no other writer has changed the snapshot Each learn cloned the full baseline and reloaded every personal count, about 190 ms per sentence on the production baseline. When PRAGMA data_version shows no other connection has committed since the snapshot, add only the new counts with the existing weight. Otherwise reload and validate as before. Predictions and scores stay bit-identical to a full reload. Median cost is now about 9 ms. Closes #27 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- src/lib.rs | 34 ++++++++++++++-- tests/prediction.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eb6252f..14dce0e 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,9 @@ are rejected. Writes are transactional, including validation of the new in-memor state before commit. Multiple writers serialize through SQLite with a five-second busy timeout. Each predictor uses a snapshot; call `refresh_personal()` to see another instance's updates. Successful writes and duplicate imports refresh the -calling instance. Errors preserve its previous snapshot. +calling instance. When no other connection has written since its snapshot, a write +adds only its own counts, which costs milliseconds rather than a full reload. +Otherwise it reloads and validates everything. Errors preserve its previous snapshot. Personal n-grams can reveal sensitive content and are stored unencrypted. Keep these files in an access-controlled application data directory, outside source diff --git a/src/lib.rs b/src/lib.rs index d1ae9b3..50969ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -349,6 +349,13 @@ pub struct Predictor { baseline: Counts, combined: Counts, personal: Option, + /// The personal database's `data_version` when `combined` last matched it. + /// It changes only when another connection commits. + seen: Option, +} + +fn data_version(db: &Connection) -> Result { + Ok(db.query_row("PRAGMA data_version", [], |r| r.get(0))?) } impl Predictor { pub fn open(baseline: &Path, personal: Option<&Path>) -> Result { @@ -392,6 +399,7 @@ impl Predictor { baseline: counts.clone(), combined: counts, personal, + seen: None, }; result.refresh_personal()?; Ok(result) @@ -406,7 +414,9 @@ impl Predictor { return Err(Error::Invalid("expected personal database".into())); } combined.merge(&load_checked(&tx, "personal")?, 5); + let version = data_version(&tx)?; tx.commit()?; + self.seen = Some(version); } self.combined = combined; Ok(()) @@ -479,10 +489,26 @@ impl Predictor { if imported { delta.write(&tx)?; } - let mut combined = self.baseline.clone(); - combined.merge(&load_checked(&tx, "personal")?, 5); + // While no other connection has written since the snapshot, adding the + // new counts to it equals reloading, without copying the baseline. + // Otherwise reload and validate everything, including other writes. + // A connection's own commits leave its data_version unchanged. + let version = data_version(&tx)?; + let current = self.seen == Some(version); + let combined = if current { + None + } else { + let mut combined = self.baseline.clone(); + combined.merge(&load_checked(&tx, "personal")?, 5); + Some(combined) + }; tx.commit()?; - self.combined = combined; + self.seen = Some(version); + match combined { + Some(combined) => self.combined = combined, + None if imported => self.combined.merge(&delta, 5), + None => {} + } Ok(imported) } pub fn learn(&mut self, completed_text: &str) -> Result<()> { @@ -513,7 +539,9 @@ impl Predictor { "personal reset did not clear imports".into(), )); } + let version = data_version(&tx)?; tx.commit()?; + self.seen = Some(version); self.combined = combined; Ok(()) } diff --git a/tests/prediction.rs b/tests/prediction.rs index 9d5a2e9..04c7ea9 100644 --- a/tests/prediction.rs +++ b/tests/prediction.rs @@ -425,3 +425,98 @@ fn simultaneous_personal_creation_and_writes_preserve_all_segments() { validate(&personal).unwrap(); assert_eq!(digest(&fs::read(&base).unwrap()), baseline_hash); } + +fn ranked(p: &Predictor, context: &str, prefix: &str) -> Vec<(String, u64)> { + p.predict( + context, + prefix, + Options { + limit: 20, + min_chars: 0, + unigram_only: false, + }, + ) + .into_iter() + .map(|s| (s.word, s.score.to_bits())) + .collect() +} + +fn assert_matches_reload(p: &Predictor, base: &Path, personal: &Path) { + let reloaded = Predictor::open(base, Some(personal)).unwrap(); + for (context, prefix) in [ + ("", ""), + ("", "w"), + ("I drink", ""), + ("I drink", "wa"), + ("we walk", "h"), + ("Siobhan said", ""), + ("the physio", "a"), + ] { + assert_eq!( + ranked(p, context, prefix), + ranked(&reloaded, context, prefix), + "{context:?} {prefix:?}" + ); + } +} + +#[test] +fn incremental_learning_matches_a_full_reload() { + let (tmp, base) = fixture(); + let personal = tmp.path().join("personal.sqlite"); + let mut p = Predictor::open(&base, Some(&personal)).unwrap(); + for text in [ + "I drink walnut milk.", + "Siobhan said the physio is at Ballymena. I drink water!", + "We walk home with Siobhan", + "the physio appointment", + ] { + p.learn(text).unwrap(); + assert_matches_reload(&p, &base, &personal); + } + assert_eq!(words(&p, "Siobhan said the", "p"), ["physio"]); +} + +#[test] +fn learning_after_another_writer_includes_its_counts() { + let (tmp, base) = fixture(); + let personal = tmp.path().join("personal.sqlite"); + let mut first = Predictor::open(&base, Some(&personal)).unwrap(); + let mut second = Predictor::open(&base, Some(&personal)).unwrap(); + first + .learn("I drink walnut milk. I drink walnut milk.") + .unwrap(); + second.learn("I drink walrus milk.").unwrap(); + assert!(words(&second, "I drink", "wal").starts_with(&["walnut".into(), "walrus".into()])); + assert_matches_reload(&second, &base, &personal); + second.learn("We walk home.").unwrap(); + assert_matches_reload(&second, &base, &personal); + first.reset_personal().unwrap(); + second.learn("apple pie").unwrap(); + assert_eq!(words(&second, "I drink", "wal"), ["walk"]); + assert_matches_reload(&second, &base, &personal); +} + +/// Run with SWITCHIFY_BASELINE pointing at the production english.sqlite. +#[test] +#[ignore = "requires explicit SWITCHIFY_BASELINE"] +fn learning_cost_on_production_baseline() { + let base = std::path::PathBuf::from(std::env::var_os("SWITCHIFY_BASELINE").unwrap()); + let tmp = tempfile::tempdir().unwrap(); + let personal = tmp.path().join("personal.sqlite"); + let started = std::time::Instant::now(); + let mut p = Predictor::open(&base, Some(&personal)).unwrap(); + println!("open: {:?}", started.elapsed()); + let mut times = Vec::new(); + for i in 0..50 { + let started = std::time::Instant::now(); + p.learn(&format!( + "I need to call Siobhan about the physio appointment number {i}." + )) + .unwrap(); + times.push(started.elapsed()); + } + times.sort(); + println!("learn median {:?}, max {:?}", times[25], times[49]); + assert_eq!(words(&p, "I need to call", "si")[0], "siobhan"); +} From 9dbe57c404bf3ce4c317d72b707c472615b15159 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 12 Oct 2026 12:31:55 +0100 Subject: [PATCH 2/3] Reload when triggers exist and after an uncertain commit A trigger can give the connection's own write side effects that only a full reload validates, so learning takes the reload path whenever one exists, as before. A failed commit clears the recorded data_version, so the next write reloads. Unit tests show the fast path runs, and when it does not. Refs #27 Co-Authored-By: Claude Opus 5.5 --- src/lib.rs | 77 ++++++++++++++++++++++++++++++++++++++++++--- tests/prediction.rs | 32 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 50969ae..1ac1c74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -357,6 +357,15 @@ pub struct Predictor { fn data_version(db: &Connection) -> Result { Ok(db.query_row("PRAGMA data_version", [], |r| r.get(0))?) } + +/// Triggers can give a write side effects that only a full reload sees. +fn has_triggers(db: &Connection) -> Result { + Ok(db.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='trigger')", + [], + |r| r.get(0), + )?) +} impl Predictor { pub fn open(baseline: &Path, personal: Option<&Path>) -> Result { let mut connection = readonly(baseline)?; @@ -415,7 +424,11 @@ impl Predictor { } combined.merge(&load_checked(&tx, "personal")?, 5); let version = data_version(&tx)?; - tx.commit()?; + if let Err(error) = tx.commit() { + // The outcome is unknown, so the next write reloads. + self.seen = None; + return Err(error.into()); + } self.seen = Some(version); } self.combined = combined; @@ -494,7 +507,7 @@ impl Predictor { // Otherwise reload and validate everything, including other writes. // A connection's own commits leave its data_version unchanged. let version = data_version(&tx)?; - let current = self.seen == Some(version); + let current = self.seen == Some(version) && !has_triggers(&tx)?; let combined = if current { None } else { @@ -502,7 +515,11 @@ impl Predictor { combined.merge(&load_checked(&tx, "personal")?, 5); Some(combined) }; - tx.commit()?; + if let Err(error) = tx.commit() { + // The outcome is unknown, so the next write reloads. + self.seen = None; + return Err(error.into()); + } self.seen = Some(version); match combined { Some(combined) => self.combined = combined, @@ -540,7 +557,11 @@ impl Predictor { )); } let version = data_version(&tx)?; - tx.commit()?; + if let Err(error) = tx.commit() { + // The outcome is unknown, so the next write reloads. + self.seen = None; + return Err(error.into()); + } self.seen = Some(version); self.combined = combined; Ok(()) @@ -562,3 +583,51 @@ impl Predictor { .sum() } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Adds a word to the baseline that only a full reload copies into the + /// snapshot. + fn mark(p: &mut Predictor, word: &str) { + p.baseline + .0 + .entry(vec![]) + .or_default() + .insert(word.into(), 1); + } + fn reloaded(p: &Predictor, word: &str) -> bool { + p.combined.0[&Vec::new()].contains_key(word) + } + + #[test] + fn own_learning_reloads_only_after_other_writes_or_with_triggers() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path().join("base.sqlite"); + let personal = dir.path().join("personal.sqlite"); + build(&base, "hello water", "fixture").unwrap(); + let mut p = Predictor::open(&base, Some(&personal)).unwrap(); + mark(&mut p, "first"); + p.learn("hello walnut").unwrap(); + p.learn("").unwrap(); + assert!(!reloaded(&p, "first"), "own learning adds counts in place"); + let mut other = Predictor::open(&base, Some(&personal)).unwrap(); + other.learn("hello walrus").unwrap(); + p.learn("hello wallaby").unwrap(); + assert!(reloaded(&p, "first"), "another writer forces a reload"); + let db = Connection::open(&personal).unwrap(); + db.execute_batch("CREATE TRIGGER t AFTER INSERT ON counts BEGIN SELECT 1; END;") + .unwrap(); + p.learn("hello").unwrap(); + mark(&mut p, "second"); + p.learn("hello wombat").unwrap(); + assert!(reloaded(&p, "second"), "a trigger forces a reload"); + db.execute_batch("DROP TRIGGER t").unwrap(); + p.learn("hello").unwrap(); + p.reset_personal().unwrap(); + mark(&mut p, "third"); + p.learn("hello walnut").unwrap(); + assert!(!reloaded(&p, "third"), "own reset keeps the fast path"); + } +} diff --git a/tests/prediction.rs b/tests/prediction.rs index 04c7ea9..3e44de4 100644 --- a/tests/prediction.rs +++ b/tests/prediction.rs @@ -520,3 +520,35 @@ fn learning_cost_on_production_baseline() { println!("learn median {:?}, max {:?}", times[25], times[49]); assert_eq!(words(&p, "I need to call", "si")[0], "siobhan"); } + +#[test] +fn a_trigger_side_effect_is_still_validated_before_commit() { + let (tmp, base) = fixture(); + let personal = tmp.path().join("personal.sqlite"); + let mut p = Predictor::open(&base, Some(&personal)).unwrap(); + let db = rusqlite::Connection::open(&personal).unwrap(); + db.execute_batch( + "CREATE TRIGGER ghost AFTER INSERT ON counts WHEN NEW.word='zebra' AND NEW.context='[]' \ + BEGIN INSERT OR IGNORE INTO counts VALUES('[\"ghost\"]','zebra',1); END;", + ) + .unwrap(); + p.learn("hello there").unwrap(); + assert!(p.learn("zebra").is_err()); + db.execute_batch("DROP TRIGGER ghost").unwrap(); + assert!(Predictor::open(&base, Some(&personal)).is_ok()); + assert_matches_reload(&p, &base, &personal); +} + +#[test] +fn duplicate_imports_and_empty_text_match_a_full_reload() { + let (tmp, base) = fixture(); + let personal = tmp.path().join("personal.sqlite"); + let input = tmp.path().join("text.txt"); + fs::write(&input, "Siobhan said the physio is late.").unwrap(); + let mut p = Predictor::open(&base, Some(&personal)).unwrap(); + assert!(p.import(&input).unwrap()); + assert!(!p.import(&input).unwrap()); + p.learn("").unwrap(); + p.learn("... !!").unwrap(); + assert_matches_reload(&p, &base, &personal); +} From d237ef6c80427a9f35c9bdf84bdbe8a436187b71 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 12 Oct 2026 12:37:02 +0100 Subject: [PATCH 3/3] Take the fast path only with the standard personal schema Constraints such as UNIQUE ... ON CONFLICT REPLACE, foreign-key actions or a missing CHECK can also give the connection's own write effects beyond its counts. Learning adds counts in place only when the personal database has exactly the schema initialize creates; anything else reloads and validates. Refs #27 Co-Authored-By: Claude Opus 5.5 --- src/lib.rs | 28 ++++++++++++++++++++-------- tests/prediction.rs | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 1ac1c74..1fdae8e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -358,13 +358,25 @@ fn data_version(db: &Connection) -> Result { Ok(db.query_row("PRAGMA data_version", [], |r| r.get(0))?) } -/// Triggers can give a write side effects that only a full reload sees. -fn has_triggers(db: &Connection) -> Result { - Ok(db.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='trigger')", - [], - |r| r.get(0), - )?) +type Schema = Vec<(String, String, String, Option)>; +fn schema(db: &Connection) -> Result { + let mut stmt = + db.prepare("SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY type, name")?; + Ok(stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))? + .collect::>()?) +} +/// Whether the database has exactly the schema `initialize` creates. Other +/// objects, such as triggers or replacing constraints, can give a write side +/// effects that only a full reload validates. +fn standard_schema(db: &Connection) -> Result { + static STANDARD: std::sync::OnceLock> = std::sync::OnceLock::new(); + let standard = STANDARD.get_or_init(|| { + let db = Connection::open_in_memory().ok()?; + initialize(&db, "personal", "schema").ok()?; + schema(&db).ok() + }); + Ok(standard.as_ref() == Some(&schema(db)?)) } impl Predictor { pub fn open(baseline: &Path, personal: Option<&Path>) -> Result { @@ -507,7 +519,7 @@ impl Predictor { // Otherwise reload and validate everything, including other writes. // A connection's own commits leave its data_version unchanged. let version = data_version(&tx)?; - let current = self.seen == Some(version) && !has_triggers(&tx)?; + let current = self.seen == Some(version) && standard_schema(&tx)?; let combined = if current { None } else { diff --git a/tests/prediction.rs b/tests/prediction.rs index 3e44de4..8832528 100644 --- a/tests/prediction.rs +++ b/tests/prediction.rs @@ -552,3 +552,21 @@ fn duplicate_imports_and_empty_text_match_a_full_reload() { p.learn("... !!").unwrap(); assert_matches_reload(&p, &base, &personal); } + +#[test] +fn a_replacing_constraint_is_still_validated_before_commit() { + let (tmp, base) = fixture(); + let personal = tmp.path().join("personal.sqlite"); + let mut p = Predictor::open(&base, Some(&personal)).unwrap(); + let db = rusqlite::Connection::open(&personal).unwrap(); + db.execute_batch( + "PRAGMA foreign_keys=OFF; DROP INDEX counts_word; ALTER TABLE counts RENAME TO old; \ + CREATE TABLE counts(context TEXT NOT NULL,word TEXT NOT NULL REFERENCES vocabulary(word),\ + count INTEGER NOT NULL,PRIMARY KEY(context,word),UNIQUE(word) ON CONFLICT REPLACE) WITHOUT ROWID; \ + CREATE INDEX counts_word ON counts(word); DROP TABLE old;", + ) + .unwrap(); + p.learn("hello").unwrap(); + assert!(p.learn("say hello").is_err()); + assert!(Predictor::open(&base, Some(&personal)).is_ok()); +}