Skip to main content

newt_data/
lib.rs

1//! # newt-data — the Phase 21 Centaur data-science engine
2//!
3//! A **headless** Rust library: the SQLite data engine behind the
4//! [Centaur Data Scientist](../../../docs/design/centaur-data-scientist.md)
5//! capability. No MCP, no JSON-RPC, no PyO3, no Jupyter kernel — those arrive
6//! in later steps (21.2 MCP server, 21.3+ kernel, 21.6 PyO3 submodule). This
7//! crate is *only* the engine where ~all the coverage is earned: pure
8//! functions over an in-memory or on-disk SQLite database.
9//!
10//! ## The shape
11//!
12//! - [`DataStore`] is the trait the whole capability is built on — the
13//!   **DuckDB-later seam** (step 21.8 swaps the backend without touching a
14//!   signature). It exposes CSV ingest, ad-hoc SQL query, schema/statistics
15//!   summary, and table listing.
16//! - [`SqliteBackend`] is the first implementation: bundled `rusqlite 0.31`
17//!   (MSRV 1.75), WAL-with-DELETE-fallback like the conversation store, and a
18//!   separate database at `<workspace>/.newt-data/data.db`.
19//!
20//! ## Design contracts honored here (Phase 21)
21//!
22//! - **Identifier safety** — every table/column name reaches SQL only through
23//!   [`ingest::quote_ident`]; table names are additionally validated. A CSV
24//!   header like `weird "name` round-trips as a literal column name; it cannot
25//!   inject SQL.
26//! - **Honest query results** — [`QueryResult::truncated`] is set by reading
27//!   one row past the cap, never by fabricating a total.
28//! - **pandas-faithful statistics** — [`NumericDescribe`] reproduces
29//!   `.describe()` exactly: sample std (`ddof = 1`), linear-interpolation
30//!   quartiles.
31//!
32//! ## Example
33//!
34//! ```no_run
35//! use std::path::Path;
36//! use newt_data::{DataStore, SqliteBackend};
37//!
38//! # fn main() -> anyhow::Result<()> {
39//! let store = SqliteBackend::open_in_memory()?;
40//! let report = store.ingest_csv(Path::new("sales.csv"), "sales")?;
41//! println!("loaded {} rows into {}", report.row_count, report.table);
42//!
43//! let result = store.query("SELECT * FROM sales", 100)?;
44//! if result.truncated {
45//!     println!("(showing first {} rows; more exist)", result.returned);
46//! }
47//!
48//! let summary = store.summarize("sales")?;
49//! for col in &summary.columns {
50//!     println!("{}: {} ({} nulls)", col.name, col.dtype, col.null_count);
51//! }
52//! # Ok(())
53//! # }
54//! ```
55
56pub mod error;
57pub mod ingest;
58/// nbformat `.ipynb` notebook persistence (Phase 21.4): pure `serde_json`
59/// read/insert/persist over the human's reviewable, git-diffable notebook
60/// artifact. A **normal** module (no kernel/HTTP deps) — it manipulates JSON, it
61/// does not run cells. `run_cell(persist_to=…)` calls [`notebook::persist_cell`]
62/// to append each executed cell. See
63/// [`docs/design/centaur-data-scientist.md`](../../../docs/design/centaur-data-scientist.md)
64/// §4.1, the notebook-artifact bullet.
65pub mod notebook;
66pub mod store;
67pub mod summary;
68
69/// Live-kernel co-pilot transport (Phase 21.3): the Jupyter [`KernelClient`]
70/// trait, the pure iopub accumulator, and the REST + websocket client. Gated on
71/// the `kernel` feature so the default engine build and the PyO3 wheel stay lean
72/// (no TLS/websocket/HTTP stack). See
73/// [`docs/design/centaur-data-scientist.md`](../../../docs/design/centaur-data-scientist.md).
74///
75/// [`KernelClient`]: kernel::KernelClient
76#[cfg(feature = "kernel")]
77pub mod kernel;
78
79#[cfg(feature = "pyo3")]
80pub mod pyo3_module;
81
82pub use error::{DataError, Result};
83pub use store::sqlite::SqliteBackend;
84pub use store::{
85    ColumnDtype, ColumnInfo, ColumnSummary, DataStore, IngestReport, NumericDescribe, QueryResult,
86    TableInfo, TableSummary,
87};
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use std::io::Write;
93    use tempfile::NamedTempFile;
94
95    const EPS: f64 = 1e-9;
96
97    /// Fixture: id (Integer), label (Text), score (Real). The score column
98    /// holds 1.0..=4.0 over four rows plus one fifth row with an EMPTY score
99    /// (the null test). The non-null scores [1,2,3,4] give the checkable
100    /// pandas describe in [`super::summary`].
101    const FIXTURE_CSV: &str = "id,label,score\n\
102        1,alpha,1.0\n\
103        2,bravo,2.0\n\
104        3,charlie,3.0\n\
105        4,delta,4.0\n\
106        5,echo,\n";
107
108    /// Write `contents` to a NamedTempFile and return it (kept alive by the
109    /// caller so the path stays valid).
110    fn csv_file(contents: &str) -> NamedTempFile {
111        let mut f = NamedTempFile::new().unwrap();
112        f.write_all(contents.as_bytes()).unwrap();
113        f.flush().unwrap();
114        f
115    }
116
117    fn ingested() -> (SqliteBackend, NamedTempFile, IngestReport) {
118        let store = SqliteBackend::open_in_memory().unwrap();
119        let f = csv_file(FIXTURE_CSV);
120        let report = store.ingest_csv(f.path(), "metrics").unwrap();
121        (store, f, report)
122    }
123
124    #[test]
125    fn ingest_infers_dtypes_and_counts_nulls() {
126        let (_store, _f, report) = ingested();
127        assert_eq!(report.table, "metrics");
128        assert_eq!(report.row_count, 5);
129        assert_eq!(report.columns.len(), 3);
130
131        let id = &report.columns[0];
132        assert_eq!(id.name, "id");
133        assert_eq!(id.dtype, ColumnDtype::Integer);
134        assert_eq!(id.null_count, 0);
135
136        let label = &report.columns[1];
137        assert_eq!(label.name, "label");
138        assert_eq!(label.dtype, ColumnDtype::Text);
139        assert_eq!(label.null_count, 0);
140
141        let score = &report.columns[2];
142        assert_eq!(score.name, "score");
143        assert_eq!(score.dtype, ColumnDtype::Real);
144        // Exactly one empty score cell (row 5).
145        assert_eq!(score.null_count, 1);
146
147        assert!(report.source.is_some());
148    }
149
150    #[test]
151    fn summarize_describe_matches_pandas() {
152        let (store, _f, _report) = ingested();
153        let summary = store.summarize("metrics").unwrap();
154        assert_eq!(summary.table, "metrics");
155        assert_eq!(summary.row_count, 5);
156
157        let score = summary.columns.iter().find(|c| c.name == "score").unwrap();
158        assert_eq!(score.dtype, ColumnDtype::Real);
159        assert_eq!(score.null_count, 1);
160        // 4 distinct non-null scores.
161        assert_eq!(score.distinct_count, 4);
162
163        let d = score.numeric.expect("score is numeric");
164        // pandas .describe() of [1,2,3,4].
165        assert_eq!(d.count, 4);
166        assert!((d.mean - 2.5).abs() < EPS);
167        assert!((d.std - 1.290_994_448_735_805_6).abs() < EPS);
168        assert!((d.min - 1.0).abs() < EPS);
169        assert!((d.q25 - 1.75).abs() < EPS);
170        assert!((d.q50 - 2.5).abs() < EPS);
171        assert!((d.q75 - 3.25).abs() < EPS);
172        assert!((d.max - 4.0).abs() < EPS);
173
174        // The id column is also numeric; the label column is not.
175        let id = summary.columns.iter().find(|c| c.name == "id").unwrap();
176        assert!(id.numeric.is_some());
177        let label = summary.columns.iter().find(|c| c.name == "label").unwrap();
178        assert!(label.numeric.is_none());
179        assert_eq!(label.distinct_count, 5);
180    }
181
182    #[test]
183    fn summarize_missing_table_errors() {
184        let store = SqliteBackend::open_in_memory().unwrap();
185        assert!(matches!(
186            store.summarize("nope"),
187            Err(DataError::NoSuchTable(_))
188        ));
189    }
190
191    #[test]
192    fn query_truncation_is_honest() {
193        let (store, _f, _report) = ingested();
194        // 5 rows; cap of 2 → returns 2, truncated true.
195        let capped = store.query("SELECT * FROM metrics", 2).unwrap();
196        assert_eq!(capped.returned, 2);
197        assert_eq!(capped.rows.len(), 2);
198        assert!(capped.truncated);
199        assert_eq!(capped.columns, vec!["id", "label", "score"]);
200
201        // A cap larger than the table → no truncation.
202        let full = store.query("SELECT * FROM metrics", 100).unwrap();
203        assert_eq!(full.returned, 5);
204        assert!(!full.truncated);
205
206        // cap 0 → 0 rows, but truncated true (rows exist).
207        let zero = store.query("SELECT * FROM metrics", 0).unwrap();
208        assert_eq!(zero.returned, 0);
209        assert!(zero.truncated);
210
211        // cap 0 over an empty result → not truncated.
212        let empty = store
213            .query("SELECT * FROM metrics WHERE id < 0", 0)
214            .unwrap();
215        assert_eq!(empty.returned, 0);
216        assert!(!empty.truncated);
217    }
218
219    #[test]
220    fn query_maps_cell_types_to_json() {
221        let (store, _f, _report) = ingested();
222        let r = store
223            .query("SELECT id, label, score FROM metrics ORDER BY id", 10)
224            .unwrap();
225        // Row 1: integer, text, real.
226        assert_eq!(r.rows[0][0], serde_json::json!(1));
227        assert_eq!(r.rows[0][1], serde_json::json!("alpha"));
228        assert_eq!(r.rows[0][2], serde_json::json!(1.0));
229        // Row 5 has a NULL score.
230        assert_eq!(r.rows[4][2], serde_json::Value::Null);
231    }
232
233    /// End-to-end regression for the lossy non-finite boundary (Phase 21 —
234    /// see [`store::sqlite`]'s `value_ref_to_json` contract). A CSV cell that is
235    /// literally `inf` / `-inf` / `nan` parses as `f64`, so the column infers
236    /// [`ColumnDtype::Real`] and the value is stored as `REAL`. JSON has no
237    /// representation for a non-finite float, so on query each such cell comes
238    /// back as `Null` — the original token is *not* preserved. This pins the
239    /// behavior so a future change (e.g. emitting the strings `"Infinity"` /
240    /// `"NaN"`) is a conscious, test-updating decision rather than a silent one.
241    #[test]
242    fn query_non_finite_real_cell_is_null() {
243        // Mixed finite + non-finite so the column still infers Real (every
244        // non-empty cell parses as f64). `1e9999` overflows f64 to +inf.
245        let csv = "v\n\
246            1.0\n\
247            inf\n\
248            -inf\n\
249            nan\n\
250            1e9999\n";
251        let store = SqliteBackend::open_in_memory().unwrap();
252        let f = csv_file(csv);
253        let report = store.ingest_csv(f.path(), "edge").unwrap();
254        assert_eq!(report.row_count, 5);
255        // The whole column is inferred Real, not Text — these tokens parse.
256        assert_eq!(report.columns[0].dtype, ColumnDtype::Real);
257        assert_eq!(report.columns[0].null_count, 0);
258
259        let r = store
260            .query("SELECT v FROM edge ORDER BY rowid", 10)
261            .unwrap();
262        // The one finite value survives as a JSON number.
263        assert_eq!(r.rows[0][0], serde_json::json!(1.0));
264        // inf, -inf, nan, and the overflowed 1e9999 all degrade to JSON null.
265        for row in &r.rows[1..] {
266            assert_eq!(
267                row[0],
268                serde_json::Value::Null,
269                "non-finite real cell should query back as JSON null"
270            );
271        }
272    }
273
274    #[test]
275    fn query_non_row_statement_returns_empty() {
276        let (store, _f, _report) = ingested();
277        let r = store.query("DELETE FROM metrics WHERE id = 5", 10).unwrap();
278        assert_eq!(r.returned, 0);
279        assert!(!r.truncated);
280        assert!(r.rows.is_empty());
281        // The delete really happened.
282        let after = store.query("SELECT * FROM metrics", 100).unwrap();
283        assert_eq!(after.returned, 4);
284    }
285
286    #[test]
287    fn identifier_safety_round_trips_weird_headers() {
288        // A header with an embedded double-quote and a space-containing column.
289        let csv = "id,weird \"name,has space\n\
290            1,x,y\n\
291            2,z,w\n";
292        let store = SqliteBackend::open_in_memory().unwrap();
293        let f = csv_file(csv);
294        let report = store.ingest_csv(f.path(), "quirky").unwrap();
295        assert_eq!(report.row_count, 2);
296
297        let names: Vec<&str> = report.columns.iter().map(|c| c.name.as_str()).collect();
298        assert_eq!(names, vec!["id", "weird \"name", "has space"]);
299
300        // Those literal column names are queryable through the same quoting.
301        let r = store
302            .query(
303                "SELECT \"weird \"\"name\", \"has space\" FROM quirky ORDER BY id",
304                10,
305            )
306            .unwrap();
307        assert_eq!(r.returned, 2);
308        assert_eq!(r.rows[0][0], serde_json::json!("x"));
309        assert_eq!(r.rows[0][1], serde_json::json!("y"));
310
311        // summarize survives the weird names too.
312        let summary = store.summarize("quirky").unwrap();
313        let weird = summary
314            .columns
315            .iter()
316            .find(|c| c.name == "weird \"name")
317            .unwrap();
318        assert_eq!(weird.dtype, ColumnDtype::Text);
319    }
320
321    #[test]
322    fn ingest_rejects_table_name_with_double_quote() {
323        let store = SqliteBackend::open_in_memory().unwrap();
324        let f = csv_file(FIXTURE_CSV);
325        let err = store.ingest_csv(f.path(), "bad\"name").unwrap_err();
326        assert!(matches!(err, DataError::InvalidIdentifier(_)));
327    }
328
329    #[test]
330    fn ingest_empty_csv_errors() {
331        let store = SqliteBackend::open_in_memory().unwrap();
332        // No header row at all.
333        let f = csv_file("");
334        let err = store.ingest_csv(f.path(), "empty").unwrap_err();
335        assert!(matches!(err, DataError::EmptyCsv));
336    }
337
338    #[test]
339    fn list_tables_reports_rows_and_source() {
340        let (store, f, _report) = ingested();
341        let tables = store.list_tables().unwrap();
342        assert_eq!(tables.len(), 1);
343        let t = &tables[0];
344        assert_eq!(t.table, "metrics");
345        assert_eq!(t.row_count, 5);
346        // The recorded source is the fixture's path.
347        assert_eq!(
348            t.source.as_deref(),
349            Some(f.path().display().to_string().as_str())
350        );
351    }
352
353    #[test]
354    fn list_tables_excludes_metadata_table() {
355        let store = SqliteBackend::open_in_memory().unwrap();
356        // __datasets must never show up as a user table.
357        assert!(store.list_tables().unwrap().is_empty());
358    }
359
360    #[test]
361    fn reingest_replaces_table() {
362        let store = SqliteBackend::open_in_memory().unwrap();
363        let f1 = csv_file(FIXTURE_CSV);
364        store.ingest_csv(f1.path(), "metrics").unwrap();
365
366        // Re-ingest a smaller CSV into the same table name.
367        let f2 = csv_file("id\n1\n2\n");
368        let report = store.ingest_csv(f2.path(), "metrics").unwrap();
369        assert_eq!(report.row_count, 2);
370        assert_eq!(report.columns.len(), 1);
371
372        let tables = store.list_tables().unwrap();
373        assert_eq!(tables.len(), 1);
374        assert_eq!(tables[0].row_count, 2);
375    }
376
377    #[test]
378    fn backend_name_is_sqlite() {
379        let store = SqliteBackend::open_in_memory().unwrap();
380        assert_eq!(store.backend_name(), "sqlite");
381    }
382
383    #[test]
384    fn datastore_is_object_safe() {
385        let backend = SqliteBackend::open_in_memory().unwrap();
386        let _: &dyn DataStore = &backend;
387    }
388
389    #[test]
390    fn open_on_disk_round_trips() {
391        let dir = tempfile::tempdir().unwrap();
392        let path = SqliteBackend::default_db_path(dir.path());
393        let store = SqliteBackend::open(&path).unwrap();
394        let f = csv_file(FIXTURE_CSV);
395        store.ingest_csv(f.path(), "metrics").unwrap();
396        drop(store);
397
398        // Reopen the same file: the data persisted.
399        let reopened = SqliteBackend::open(&path).unwrap();
400        let tables = reopened.list_tables().unwrap();
401        assert_eq!(tables.len(), 1);
402        assert_eq!(tables[0].row_count, 5);
403    }
404
405    #[test]
406    fn types_serialize_round_trip() {
407        // The public types are serde-stable (the MCP adapter relies on this).
408        let (store, _f, report) = ingested();
409        let json = serde_json::to_string(&report).unwrap();
410        let back: IngestReport = serde_json::from_str(&json).unwrap();
411        assert_eq!(report, back);
412
413        let summary = store.summarize("metrics").unwrap();
414        let json = serde_json::to_string(&summary).unwrap();
415        let back: TableSummary = serde_json::from_str(&json).unwrap();
416        assert_eq!(summary, back);
417    }
418}