Skip to main content

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}