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}