samkhya_core/feedback.rs
1//! Feedback recorder — captures `(plan, estimate, actual)` triples to a
2//! SQLite sidecar so the residual correction model can learn from real
3//! engine behavior. Inspired by Bao / AutoSteer.
4//!
5//! The store is deliberately minimal: one process, one connection, one
6//! table per concern. The schema is forward-compatible — new optional
7//! columns can be added with `ALTER TABLE` migrations later.
8
9use std::path::Path;
10
11use rusqlite::{Connection, params};
12use serde::{Deserialize, Serialize};
13
14use crate::{Error, Result};
15
16const SCHEMA_V1: &str = r#"
17CREATE TABLE IF NOT EXISTS observations (
18 id INTEGER PRIMARY KEY AUTOINCREMENT,
19 template_hash TEXT NOT NULL,
20 plan_fingerprint TEXT NOT NULL,
21 est_rows INTEGER NOT NULL,
22 actual_rows INTEGER NOT NULL,
23 latency_ms REAL,
24 recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
25);
26CREATE INDEX IF NOT EXISTS idx_obs_template ON observations(template_hash);
27CREATE INDEX IF NOT EXISTS idx_obs_plan ON observations(plan_fingerprint);
28"#;
29
30/// Plan-shape feature columns, added in 1.2.0.
31///
32/// Every column is nullable, so a store written by an older binary reads
33/// back unchanged and rows recorded through [`FeedbackStore::record`]
34/// simply leave them `NULL`. That keeps the addition a migration rather
35/// than a schema break, which is why `SCHEMA_USER_VERSION` does not move.
36///
37/// They exist because a corrector trained without them is trained on a
38/// different feature space than the one it sees at inference time — see
39/// [`PlanObservation`].
40const FEATURE_COLUMNS: &[(&str, &str)] = &[
41 ("left_input_rows", "INTEGER"),
42 ("right_input_rows", "INTEGER"),
43 ("left_distinct", "INTEGER"),
44 ("right_distinct", "INTEGER"),
45 ("predicate_count", "INTEGER"),
46 ("join_depth", "INTEGER"),
47];
48
49/// Schema version stamped into SQLite's `PRAGMA user_version`.
50///
51/// Bumped only when the on-disk schema changes in a backwards-incompatible
52/// way. Stores written by an older binary (with `user_version = 0`,
53/// i.e. unset) are silently upgraded by writing the current value;
54/// stores written by a newer binary (with a strictly larger version)
55/// are rejected so we never silently truncate forward-versioned data.
56/// See `documents/SECURITY-REVIEW-2026-05-17.md` item L3.
57const SCHEMA_USER_VERSION: i32 = 1;
58
59/// A single observation captured at query end.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct Observation {
62 pub template_hash: String,
63 pub plan_fingerprint: String,
64 pub est_rows: u64,
65 pub actual_rows: u64,
66 pub latency_ms: Option<f64>,
67}
68
69impl Observation {
70 /// Multiplicative q-error: `max(actual/est, est/actual)`. Returns `f64::INFINITY` if either is 0.
71 ///
72 /// # Examples
73 ///
74 /// ```
75 /// use samkhya_core::feedback::Observation;
76 ///
77 /// // 10× underestimate: est=10, actual=100 → q-error = 10.
78 /// let obs = Observation {
79 /// template_hash: "t".into(),
80 /// plan_fingerprint: "p".into(),
81 /// est_rows: 10,
82 /// actual_rows: 100,
83 /// latency_ms: None,
84 /// };
85 /// assert!((obs.q_error() - 10.0).abs() < 1e-9);
86 /// ```
87 pub fn q_error(&self) -> f64 {
88 if self.est_rows == 0 || self.actual_rows == 0 {
89 return f64::INFINITY;
90 }
91 let r = self.actual_rows as f64 / self.est_rows as f64;
92 if r >= 1.0 { r } else { 1.0 / r }
93 }
94}
95
96/// An observation captured together with the plan-shape features the
97/// corrector will be handed at inference time.
98///
99/// # Why this exists alongside [`Observation`]
100///
101/// [`Observation`] records only `est_rows` and `actual_rows`. Training from
102/// it forces the trainer to synthesise a feature vector with
103/// `baseline_estimate` set and the other six slots zeroed — while at
104/// inference time an adapter fills all seven. A tree model never splits on
105/// a feature that was constant during training, so six of the seven
106/// features are dead weight and the corrector is effectively
107/// one-dimensional. That is a silent train/serve skew, not a crash, which
108/// is why it survived so long.
109///
110/// `PlanObservation` closes it by recording what the corrector will
111/// actually see. Prefer it for anything that will be trained on.
112///
113/// # Examples
114///
115/// ```
116/// use samkhya_core::feedback::PlanObservation;
117/// use samkhya_core::residual::CorrectionFeatures;
118///
119/// let obs = PlanObservation {
120/// template_hash: "q7".into(),
121/// plan_fingerprint: "hash-join#1".into(),
122/// features: CorrectionFeatures { baseline_estimate: 10, ..Default::default() },
123/// actual_rows: 100,
124/// latency_ms: None,
125/// };
126/// // 10x under-estimate.
127/// assert!((obs.q_error() - 10.0).abs() < 1e-9);
128/// ```
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130pub struct PlanObservation {
131 pub template_hash: String,
132 pub plan_fingerprint: String,
133 /// The feature vector the corrector saw, including `baseline_estimate`.
134 pub features: crate::residual::CorrectionFeatures,
135 pub actual_rows: u64,
136 pub latency_ms: Option<f64>,
137}
138
139impl PlanObservation {
140 /// Multiplicative q-error against the baseline estimate. `f64::INFINITY`
141 /// when either side is zero, matching [`Observation::q_error`].
142 pub fn q_error(&self) -> f64 {
143 if self.features.baseline_estimate == 0 || self.actual_rows == 0 {
144 return f64::INFINITY;
145 }
146 let r = self.actual_rows as f64 / self.features.baseline_estimate as f64;
147 if r >= 1.0 { r } else { 1.0 / r }
148 }
149
150 /// Reduce to the legacy shape, discarding the plan features.
151 pub fn to_observation(&self) -> Observation {
152 Observation {
153 template_hash: self.template_hash.clone(),
154 plan_fingerprint: self.plan_fingerprint.clone(),
155 est_rows: self.features.baseline_estimate,
156 actual_rows: self.actual_rows,
157 latency_ms: self.latency_ms,
158 }
159 }
160}
161
162/// SQLite-backed feedback store.
163pub struct FeedbackStore {
164 conn: Connection,
165}
166
167impl FeedbackStore {
168 /// Open or create a store at `path`.
169 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
170 let path_ref = path.as_ref();
171 let conn = Connection::open(path_ref).map_err(map_sqlite)?;
172 conn.execute_batch(SCHEMA_V1).map_err(map_sqlite)?;
173 check_or_stamp_schema_version(&conn)?;
174 add_feature_columns(&conn)?;
175 // SECURITY-REVIEW-2026-05-17.md (M2): the feedback store records
176 // plan fingerprints which may carry schema details or filter
177 // values. Tighten the file mode to 0o600 (owner-only) so a
178 // shared-system reader cannot snapshot the store. Best-effort:
179 // a failure here (e.g., the file does not exist because we are
180 // running against an in-memory or VFS-special path) is logged
181 // but not promoted to an error — the store is still usable.
182 #[cfg(unix)]
183 {
184 use std::os::unix::fs::PermissionsExt;
185 if let Err(err) =
186 std::fs::set_permissions(path_ref, std::fs::Permissions::from_mode(0o600))
187 {
188 log::debug!(
189 "feedback store: could not tighten perms on {}: {}",
190 path_ref.display(),
191 err
192 );
193 }
194 }
195 Ok(Self { conn })
196 }
197
198 /// Open an in-memory store (test / ephemeral).
199 ///
200 /// # Examples
201 ///
202 /// ```
203 /// use samkhya_core::feedback::FeedbackStore;
204 ///
205 /// let store = FeedbackStore::open_in_memory().unwrap();
206 /// assert_eq!(store.count().unwrap(), 0);
207 /// ```
208 pub fn open_in_memory() -> Result<Self> {
209 let conn = Connection::open_in_memory().map_err(map_sqlite)?;
210 conn.execute_batch(SCHEMA_V1).map_err(map_sqlite)?;
211 check_or_stamp_schema_version(&conn)?;
212 add_feature_columns(&conn)?;
213 Ok(Self { conn })
214 }
215
216 /// Record an observation *with* the plan-shape features the corrector
217 /// will see at inference time.
218 ///
219 /// # Examples
220 ///
221 /// ```
222 /// use samkhya_core::feedback::{FeedbackStore, PlanObservation};
223 /// use samkhya_core::residual::CorrectionFeatures;
224 ///
225 /// let store = FeedbackStore::open_in_memory().unwrap();
226 /// let obs = PlanObservation {
227 /// template_hash: "job-slow".into(),
228 /// plan_fingerprint: "hash-join#7".into(),
229 /// features: CorrectionFeatures {
230 /// baseline_estimate: 1_000,
231 /// left_input_rows: Some(500),
232 /// right_input_rows: Some(2_000),
233 /// predicate_count: 2,
234 /// join_depth: 3,
235 /// ..Default::default()
236 /// },
237 /// actual_rows: 9_500,
238 /// latency_ms: Some(12.5),
239 /// };
240 /// store.record_plan(&obs).unwrap();
241 ///
242 /// let history = store.plan_history("job-slow").unwrap();
243 /// assert_eq!(history.len(), 1);
244 /// assert_eq!(history[0].features.join_depth, 3);
245 /// ```
246 pub fn record_plan(&self, obs: &PlanObservation) -> Result<i64> {
247 let f = &obs.features;
248 self.conn
249 .execute(
250 "INSERT INTO observations (template_hash, plan_fingerprint, est_rows, actual_rows, \
251 latency_ms, left_input_rows, right_input_rows, left_distinct, right_distinct, \
252 predicate_count, join_depth) \
253 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
254 params![
255 obs.template_hash,
256 obs.plan_fingerprint,
257 f.baseline_estimate as i64,
258 obs.actual_rows as i64,
259 obs.latency_ms,
260 f.left_input_rows.map(|v| v as i64),
261 f.right_input_rows.map(|v| v as i64),
262 f.left_distinct.map(|v| v as i64),
263 f.right_distinct.map(|v| v as i64),
264 i64::from(f.predicate_count),
265 i64::from(f.join_depth),
266 ],
267 )
268 .map_err(map_sqlite)?;
269 Ok(self.conn.last_insert_rowid())
270 }
271
272 /// Return every observation for `template_hash` that carries plan
273 /// features, oldest first.
274 ///
275 /// Rows recorded through [`record`](Self::record) have no features and
276 /// are skipped: training on them would silently reintroduce the
277 /// feature-space mismatch this type exists to prevent. The filter is
278 /// `predicate_count IS NOT NULL`, which only
279 /// [`record_plan`](Self::record_plan) sets.
280 pub fn plan_history(&self, template_hash: &str) -> Result<Vec<PlanObservation>> {
281 let mut stmt = self
282 .conn
283 .prepare(
284 "SELECT template_hash, plan_fingerprint, est_rows, actual_rows, latency_ms, \
285 left_input_rows, right_input_rows, left_distinct, right_distinct, \
286 predicate_count, join_depth \
287 FROM observations \
288 WHERE template_hash = ?1 AND predicate_count IS NOT NULL \
289 ORDER BY id ASC",
290 )
291 .map_err(map_sqlite)?;
292 let rows = stmt
293 .query_map(params![template_hash], |row| {
294 let opt_u64 = |v: Option<i64>| v.map(|n| n as u64);
295 Ok(PlanObservation {
296 template_hash: row.get(0)?,
297 plan_fingerprint: row.get(1)?,
298 features: crate::residual::CorrectionFeatures {
299 baseline_estimate: row.get::<_, i64>(2)? as u64,
300 left_input_rows: opt_u64(row.get(5)?),
301 right_input_rows: opt_u64(row.get(6)?),
302 left_distinct: opt_u64(row.get(7)?),
303 right_distinct: opt_u64(row.get(8)?),
304 predicate_count: row.get::<_, i64>(9)? as u32,
305 join_depth: row.get::<_, i64>(10)? as u32,
306 },
307 actual_rows: row.get::<_, i64>(3)? as u64,
308 latency_ms: row.get(4)?,
309 })
310 })
311 .map_err(map_sqlite)?;
312 rows.collect::<std::result::Result<Vec<_>, _>>()
313 .map_err(map_sqlite)
314 }
315
316 /// Record an observation.
317 ///
318 /// # Examples
319 ///
320 /// ```
321 /// use samkhya_core::feedback::{FeedbackStore, Observation};
322 ///
323 /// let store = FeedbackStore::open_in_memory().unwrap();
324 /// let obs = Observation {
325 /// template_hash: "tpch-q1".into(),
326 /// plan_fingerprint: "hash-join#42".into(),
327 /// est_rows: 1000,
328 /// actual_rows: 950,
329 /// latency_ms: Some(12.5),
330 /// };
331 /// let id = store.record(&obs).unwrap();
332 /// assert!(id > 0);
333 /// assert_eq!(store.count().unwrap(), 1);
334 /// ```
335 pub fn record(&self, obs: &Observation) -> Result<i64> {
336 self.conn
337 .execute(
338 "INSERT INTO observations (template_hash, plan_fingerprint, est_rows, actual_rows, latency_ms)
339 VALUES (?1, ?2, ?3, ?4, ?5)",
340 params![
341 obs.template_hash,
342 obs.plan_fingerprint,
343 obs.est_rows as i64,
344 obs.actual_rows as i64,
345 obs.latency_ms,
346 ],
347 )
348 .map_err(map_sqlite)?;
349 Ok(self.conn.last_insert_rowid())
350 }
351
352 /// Return all observations for a given query template, oldest first.
353 pub fn history(&self, template_hash: &str) -> Result<Vec<Observation>> {
354 let mut stmt = self
355 .conn
356 .prepare(
357 "SELECT template_hash, plan_fingerprint, est_rows, actual_rows, latency_ms
358 FROM observations WHERE template_hash = ?1 ORDER BY id ASC",
359 )
360 .map_err(map_sqlite)?;
361 let rows = stmt
362 .query_map(params![template_hash], |row| {
363 Ok(Observation {
364 template_hash: row.get(0)?,
365 plan_fingerprint: row.get(1)?,
366 est_rows: row.get::<_, i64>(2)? as u64,
367 actual_rows: row.get::<_, i64>(3)? as u64,
368 latency_ms: row.get(4)?,
369 })
370 })
371 .map_err(map_sqlite)?;
372 rows.collect::<std::result::Result<Vec<_>, _>>()
373 .map_err(map_sqlite)
374 }
375
376 /// Number of observations stored.
377 pub fn count(&self) -> Result<u64> {
378 self.conn
379 .query_row("SELECT COUNT(*) FROM observations", [], |row| {
380 row.get::<_, i64>(0)
381 })
382 .map(|n| n as u64)
383 .map_err(map_sqlite)
384 }
385}
386
387/// Add the 1.2.0 plan-feature columns to an existing `observations` table.
388///
389/// Idempotent: each column is added only when absent, so opening a store
390/// repeatedly is free and opening one written by an older binary upgrades
391/// it in place. Every column is nullable, so nothing already recorded
392/// becomes invalid and an older binary can still read the file.
393fn add_feature_columns(conn: &Connection) -> Result<()> {
394 let mut existing = std::collections::HashSet::new();
395 {
396 let mut stmt = conn
397 .prepare("PRAGMA table_info(observations)")
398 .map_err(map_sqlite)?;
399 let names = stmt
400 .query_map([], |row| row.get::<_, String>(1))
401 .map_err(map_sqlite)?;
402 for name in names {
403 existing.insert(name.map_err(map_sqlite)?);
404 }
405 }
406 for (column, ty) in FEATURE_COLUMNS {
407 if existing.contains(*column) {
408 continue;
409 }
410 conn.execute_batch(&format!(
411 "ALTER TABLE observations ADD COLUMN {column} {ty}"
412 ))
413 .map_err(map_sqlite)?;
414 }
415 Ok(())
416}
417
418fn map_sqlite(e: rusqlite::Error) -> Error {
419 Error::Feedback(e.to_string())
420}
421
422/// Read the SQLite `user_version` PRAGMA and either stamp it (if unset)
423/// or reject the store (if it carries a strictly larger version).
424///
425/// See `documents/SECURITY-REVIEW-2026-05-17.md` item L3: a previously
426/// malicious or simply newer-schema `.db` opened by an older samkhya
427/// would silently mismatch row shape on read; the new PRAGMA check
428/// makes that visible.
429fn check_or_stamp_schema_version(conn: &Connection) -> Result<()> {
430 let on_disk: i32 = conn
431 .query_row("PRAGMA user_version", [], |row| row.get(0))
432 .map_err(map_sqlite)?;
433 if on_disk == 0 {
434 // Fresh / pre-versioning store. Stamp the current version so
435 // future opens see a match. Using `execute_batch` because
436 // `PRAGMA user_version = N` is not a parameterised statement
437 // (SQLite refuses bind params on PRAGMA writes).
438 conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_USER_VERSION}"))
439 .map_err(map_sqlite)?;
440 return Ok(());
441 }
442 if on_disk > SCHEMA_USER_VERSION {
443 return Err(Error::Feedback(format!(
444 "feedback store schema version {on_disk} is newer than this build supports \
445 ({SCHEMA_USER_VERSION}); refuse to open to avoid data truncation"
446 )));
447 }
448 if on_disk < SCHEMA_USER_VERSION {
449 // Older but compatible. No migrations yet (we are on v1), so
450 // just bump the marker. Future versions will run migration
451 // SQL here before bumping.
452 conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_USER_VERSION}"))
453 .map_err(map_sqlite)?;
454 }
455 Ok(())
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn sample(template: &str, est: u64, actual: u64) -> Observation {
463 Observation {
464 template_hash: template.into(),
465 plan_fingerprint: "plan-abc".into(),
466 est_rows: est,
467 actual_rows: actual,
468 latency_ms: Some(42.0),
469 }
470 }
471
472 #[test]
473 fn record_and_count() {
474 let store = FeedbackStore::open_in_memory().unwrap();
475 assert_eq!(store.count().unwrap(), 0);
476 store.record(&sample("t1", 100, 110)).unwrap();
477 store.record(&sample("t1", 100, 90)).unwrap();
478 store.record(&sample("t2", 50, 200)).unwrap();
479 assert_eq!(store.count().unwrap(), 3);
480 }
481
482 #[test]
483 fn history_filters_by_template() {
484 let store = FeedbackStore::open_in_memory().unwrap();
485 store.record(&sample("t1", 100, 110)).unwrap();
486 store.record(&sample("t2", 50, 200)).unwrap();
487 store.record(&sample("t1", 100, 90)).unwrap();
488 let t1 = store.history("t1").unwrap();
489 assert_eq!(t1.len(), 2);
490 assert!(t1.iter().all(|o| o.template_hash == "t1"));
491 }
492
493 #[test]
494 fn schema_version_stamped_on_fresh_store() {
495 let store = FeedbackStore::open_in_memory().unwrap();
496 let v: i32 = store
497 .conn
498 .query_row("PRAGMA user_version", [], |row| row.get(0))
499 .unwrap();
500 assert_eq!(v, SCHEMA_USER_VERSION);
501 }
502
503 #[test]
504 fn refuses_forward_versioned_store() {
505 // Open once to stamp the schema, then manually bump the
506 // user_version past what this binary supports and re-open.
507 let tmp = std::env::temp_dir().join(format!(
508 "samkhya-feedback-forward-{}.db",
509 std::process::id()
510 ));
511 let _ = std::fs::remove_file(&tmp);
512 {
513 let store = FeedbackStore::open(&tmp).unwrap();
514 store
515 .conn
516 .execute_batch(&format!(
517 "PRAGMA user_version = {}",
518 SCHEMA_USER_VERSION + 99
519 ))
520 .unwrap();
521 }
522 match FeedbackStore::open(&tmp) {
523 Ok(_) => panic!("expected forward-version rejection, got Ok"),
524 Err(Error::Feedback(msg)) => assert!(
525 msg.contains("newer than this build"),
526 "expected forward-version rejection, got: {msg}"
527 ),
528 Err(other) => panic!("expected Error::Feedback, got {other:?}"),
529 }
530 let _ = std::fs::remove_file(&tmp);
531 }
532
533 #[test]
534 fn q_error_computes_correctly() {
535 let obs_over = sample("t1", 10, 100); // 10× underestimate
536 assert!((obs_over.q_error() - 10.0).abs() < 1e-9);
537 let obs_under = sample("t1", 100, 10); // 10× overestimate
538 assert!((obs_under.q_error() - 10.0).abs() < 1e-9);
539 let obs_exact = sample("t1", 100, 100);
540 assert!((obs_exact.q_error() - 1.0).abs() < 1e-9);
541 let obs_zero = sample("t1", 0, 100);
542 assert!(obs_zero.q_error().is_infinite());
543 }
544
545 #[test]
546 fn persists_to_disk() {
547 let tmp = std::env::temp_dir().join(format!("samkhya-test-{}.db", std::process::id()));
548 // ensure clean start
549 let _ = std::fs::remove_file(&tmp);
550 {
551 let store = FeedbackStore::open(&tmp).unwrap();
552 store.record(&sample("t1", 1, 2)).unwrap();
553 }
554 let store2 = FeedbackStore::open(&tmp).unwrap();
555 assert_eq!(store2.count().unwrap(), 1);
556 std::fs::remove_file(&tmp).ok();
557 }
558}