Skip to main content

newt_data/store/
mod.rs

1//! The [`DataStore`] trait and its result types (Phase 21).
2//!
3//! These types are the engine's public contract — the seam the later MCP
4//! adapter (step 21.2) serializes over JSON-RPC and the PyO3 submodule (step
5//! 21.6) hands to a notebook. Per
6//! [`docs/design/centaur-data-scientist.md`](../../../docs/design/centaur-data-scientist.md)
7//! the trait is the **DuckDB-later seam**: swapping the SQLite backend for a
8//! DuckDB one (step 21.8) must never change a single signature here.
9//!
10//! Every result type derives `serde` `Serialize` / `Deserialize` so the MCP
11//! adapter can return it as a JSON-RPC tool result with no hand mapping. The
12//! types are deliberately honest (Phase 21 — "the gates must be honest"):
13//! [`QueryResult`] carries a `truncated` flag but **never a fabricated total
14//! row count**, and [`NumericDescribe`] follows pandas `.describe()` semantics
15//! exactly (sample std, linear-interpolation quartiles) rather than an
16//! approximation.
17
18pub mod sqlite;
19
20use std::path::Path;
21
22use serde::{Deserialize, Serialize};
23
24use crate::error::Result;
25
26/// The SQLite storage class a column is materialized as.
27///
28/// Inference is "smallest type that fits every non-empty cell" (Phase 21,
29/// [`crate::ingest`]): all-integer → [`ColumnDtype::Integer`], else all-numeric
30/// → [`ColumnDtype::Real`], else [`ColumnDtype::Text`]. The [`Display`] form is
31/// the SQLite declared type used in `CREATE TABLE`.
32///
33/// [`Display`]: std::fmt::Display
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum ColumnDtype {
37    /// 64-bit signed integer (`INTEGER`).
38    Integer,
39    /// 64-bit IEEE-754 float (`REAL`).
40    Real,
41    /// UTF-8 text (`TEXT`).
42    Text,
43}
44
45impl ColumnDtype {
46    /// The SQLite declared type written into `CREATE TABLE` for this dtype.
47    ///
48    /// Identical to the [`Display`](std::fmt::Display) form; provided as an
49    /// explicit, self-documenting name at the SQL call sites.
50    pub fn sqlite_decl_type(self) -> &'static str {
51        match self {
52            Self::Integer => "INTEGER",
53            Self::Real => "REAL",
54            Self::Text => "TEXT",
55        }
56    }
57
58    /// Map a SQLite `PRAGMA table_info` declared type back to a [`ColumnDtype`].
59    ///
60    /// Case-insensitive on the leading token: `INTEGER` → [`ColumnDtype::Integer`],
61    /// `REAL` (or any `REA…`/`FLOA…`/`DOUB…` numeric affinity) → [`ColumnDtype::Real`],
62    /// everything else → [`ColumnDtype::Text`]. Used by [`DataStore::summarize`]
63    /// to recover the dtype of an already-materialized table.
64    pub fn from_decl_type(decl: &str) -> Self {
65        let upper = decl.trim().to_ascii_uppercase();
66        if upper.starts_with("INT") {
67            Self::Integer
68        } else if upper.starts_with("REA") || upper.starts_with("FLOA") || upper.starts_with("DOUB")
69        {
70            Self::Real
71        } else {
72            Self::Text
73        }
74    }
75}
76
77impl std::fmt::Display for ColumnDtype {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.write_str(self.sqlite_decl_type())
80    }
81}
82
83/// Per-column schema fact reported by [`DataStore::ingest_csv`].
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct ColumnInfo {
86    /// The literal column name (taken verbatim from the CSV header — spaces
87    /// and punctuation survive because identifiers are quoted, never rejected).
88    pub name: String,
89    /// The inferred storage dtype.
90    pub dtype: ColumnDtype,
91    /// How many cells in this column were empty (stored as `NULL`).
92    pub null_count: u64,
93}
94
95/// What [`DataStore::ingest_csv`] did: the table it (re)created and the schema
96/// it inferred.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct IngestReport {
99    /// The table name the CSV was loaded into (dropped + recreated).
100    pub table: String,
101    /// Number of data rows inserted (header excluded).
102    pub row_count: u64,
103    /// One [`ColumnInfo`] per CSV column, in header order.
104    pub columns: Vec<ColumnInfo>,
105    /// The source path (`path.display()`), recorded in the `__datasets`
106    /// metadata table; `None` only on stores that never recorded one.
107    pub source: Option<String>,
108}
109
110/// The result of a [`DataStore::query`] — a row set capped at `row_cap`.
111///
112/// **Honesty (Phase 21):** there is no fabricated grand total. `returned` is
113/// exactly how many rows are in `rows`, and `truncated` is `true` **iff** at
114/// least one more row existed past the cap (detected by reading one extra row
115/// and discarding it). Callers learn "there is more" without the engine ever
116/// inventing a number it did not count.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct QueryResult {
119    /// Column names, in SELECT order (empty for a non-row statement).
120    pub columns: Vec<String>,
121    /// The returned rows; each inner vec is one row aligned to `columns`.
122    /// Cell values are JSON: `null`, number, string, or a base64 string for
123    /// `BLOB` columns.
124    pub rows: Vec<Vec<serde_json::Value>>,
125    /// `rows.len()` — the count actually returned.
126    pub returned: usize,
127    /// `true` iff more rows existed beyond `row_cap` (none of which are in
128    /// `rows`).
129    pub truncated: bool,
130}
131
132/// pandas `.describe()` of a numeric column (Phase 21).
133///
134/// Semantics match pandas / numpy defaults exactly so a notebook user can
135/// cross-check: `std` is the **sample** standard deviation (`ddof = 1`, and is
136/// `0.0` when `count < 2`), and the quartiles use **linear interpolation** on
137/// the sorted values (`pos = (n - 1) * q`).
138#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
139pub struct NumericDescribe {
140    /// Number of non-null values.
141    pub count: u64,
142    /// Arithmetic mean.
143    pub mean: f64,
144    /// Sample standard deviation (`ddof = 1`; `0.0` when `count < 2`).
145    pub std: f64,
146    /// Minimum.
147    pub min: f64,
148    /// 25th percentile (linear interpolation).
149    pub q25: f64,
150    /// 50th percentile / median (linear interpolation).
151    pub q50: f64,
152    /// 75th percentile (linear interpolation).
153    pub q75: f64,
154    /// Maximum.
155    pub max: f64,
156}
157
158/// Per-column summary returned inside a [`TableSummary`].
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct ColumnSummary {
161    /// Column name.
162    pub name: String,
163    /// The column's dtype (recovered from its SQLite declared type).
164    pub dtype: ColumnDtype,
165    /// Count of `NULL` cells (`row_count - COUNT(col)`).
166    pub null_count: u64,
167    /// Count of distinct non-null values (`COUNT(DISTINCT col)`).
168    pub distinct_count: u64,
169    /// pandas-style describe for numeric columns; `None` for `TEXT`.
170    pub numeric: Option<NumericDescribe>,
171}
172
173/// A full [`DataStore::summarize`] result: schema + per-column statistics.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct TableSummary {
176    /// The summarized table.
177    pub table: String,
178    /// Total row count.
179    pub row_count: u64,
180    /// One [`ColumnSummary`] per column, in schema order.
181    pub columns: Vec<ColumnSummary>,
182}
183
184/// One entry from [`DataStore::list_tables`].
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub struct TableInfo {
187    /// The (user-facing) table name.
188    pub table: String,
189    /// Current row count.
190    pub row_count: u64,
191    /// The source path recorded at ingest time, if any.
192    pub source: Option<String>,
193}
194
195/// The headless data engine (Phase 21).
196///
197/// The seam every later step builds on: the MCP adapter (21.2) wraps a
198/// `dyn DataStore`, and the DuckDB backend (21.8) implements this same trait so
199/// no tool signature changes. `Send` is required so the trait object can move
200/// across the MCP server's task boundary; methods take `&self` because the
201/// SQLite backend shares one connection behind a mutex.
202///
203/// See [`docs/design/centaur-data-scientist.md`](../../../docs/design/centaur-data-scientist.md).
204pub trait DataStore: Send {
205    /// Load `path` (a CSV) into `table`, dropping and recreating the table.
206    ///
207    /// Headers become column names; per-column dtype is inferred from the data
208    /// (Phase 21 — [`crate::ingest`]). Empty cells are `NULL`. `table` is
209    /// validated as a safe identifier first ([`DataError::InvalidIdentifier`]);
210    /// an empty file is [`DataError::EmptyCsv`].
211    ///
212    /// [`DataError::InvalidIdentifier`]: crate::DataError::InvalidIdentifier
213    /// [`DataError::EmptyCsv`]: crate::DataError::EmptyCsv
214    fn ingest_csv(&self, path: &Path, table: &str) -> Result<IngestReport>;
215
216    /// Run `sql` and return up to `row_cap` rows with an honest `truncated`
217    /// flag (Phase 21 — see [`QueryResult`]). `row_cap == 0` returns no rows
218    /// and `truncated == true` iff the query produced any.
219    fn query(&self, sql: &str, row_cap: usize) -> Result<QueryResult>;
220
221    /// Compute schema + per-column statistics (pandas-style describe for
222    /// numeric columns) for `table` ([`DataError::NoSuchTable`] if absent).
223    ///
224    /// [`DataError::NoSuchTable`]: crate::DataError::NoSuchTable
225    fn summarize(&self, table: &str) -> Result<TableSummary>;
226
227    /// List the user tables (excluding the engine's `__datasets` metadata and
228    /// SQLite's internal tables) with their row counts and source paths.
229    fn list_tables(&self) -> Result<Vec<TableInfo>>;
230
231    /// A short, stable backend identifier (`"sqlite"` today; `"duckdb"` at
232    /// step 21.8) for diagnostics and tool output.
233    fn backend_name(&self) -> &'static str;
234}