Skip to content

Commit 36f5932

Browse files
feat(drift): implement Temporal drift detector + wire verisimiser drift (#98)
Closes #49. First drift category to ship per ADR-0003. Temporal drift = max pairwise `|v_a - v_b| / max(v_a, v_b, 1)` across `table_name` rows for the same `entity_id` in `verisimdb_temporal_versions`. Two public functions in `tier1::drift`: - `temporal_drift_score(versions: &[i64]) -> f64`: pure kernel, unit-testable without a database. Returns the max pairwise drift, clamped to `[0, 1]`. - `detect_temporal_drift(conn, entity_id) -> Result<Option<DriftReport>>`: queries `MAX(version) GROUP BY table_name`, computes the kernel, packs into a `DriftReport` with one `(Temporal, score)` entry. Returns `None` for entities recorded under fewer than two modalities (nothing to compare). CLI wiring: - `verisimiser drift` now (a) opens the SQLite sidecar via rusqlite, (b) scans every distinct `entity_id` in `verisimdb_temporal_versions`, (c) calls `detect_temporal_drift` per entity, (d) prints entities whose score ≥ `--threshold`, (e) summarises count scanned vs above threshold. - Non-SQLite sidecar storage fails with a typed error (same pattern as `verisimiser gc` in V-L2-P1, #50). Tests in `tier1::drift::temporal_drift_tests` (no external DB needed — uses `Connection::open_in_memory()`): - `identical_versions_score_zero` - `one_off_high_version_score_point_one` — ADR-0003 worked example `(10, 9) → 0.1` - `drifted_score_above_threshold` — `(5, 4) → 0.2` - `one_zero_score_one` — `(10, 0) → 1.0` - `score_symmetric` — order-independence - `score_is_max_pairwise` — not the mean - `score_clamped_to_unit_interval` — property test across `[1]`, `[0,0]`, `[1000,1]`, `[i64::MAX,1]`, `[1..=10]` - `detect_returns_none_below_two_modalities` — single modality short-circuits - `detect_produces_drift_report_for_two_modalities` — full end-to-end with two `table_name` rows The remaining seven categories (Structural, Semantic, Statistical, Referential, Provenance, Spatial, Embedding) each get their own V-L1-E* follow-up; the report shape, threshold flag, and the sidecar-only constraint are the same template. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 83f0849 commit 36f5932

2 files changed

Lines changed: 222 additions & 4 deletions

File tree

src/main.rs

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
use anyhow::Result;
1919
use clap::{Parser, Subcommand};
20-
use verisimiser::{abi, codegen, doctor, gc, manifest};
20+
use verisimiser::{abi, codegen, doctor, gc, manifest, tier1};
2121

2222
/// Long version string: `<crate-version> (<git-describe>, built <date>)`.
2323
const LONG_VERSION: &str = concat!(
@@ -212,9 +212,42 @@ fn main() -> Result<()> {
212212
manifest,
213213
threshold,
214214
} => {
215-
let _m = manifest::load_manifest(&manifest)?;
216-
println!("Checking cross-modal drift (threshold: {})...", threshold);
217-
// TODO: query drift index
215+
let m = manifest::load_manifest(&manifest)?;
216+
if m.sidecar.storage != "sqlite" {
217+
anyhow::bail!(
218+
"verisimiser drift currently only supports the SQLite \
219+
sidecar backend; [sidecar].storage is {:?}",
220+
m.sidecar.storage
221+
);
222+
}
223+
let conn = rusqlite::Connection::open(&m.sidecar.path)?;
224+
// Distinct entity_ids that have at least one row in temporal_versions.
225+
let mut stmt = conn
226+
.prepare("SELECT DISTINCT entity_id FROM verisimdb_temporal_versions")?;
227+
let entities: Vec<String> = stmt
228+
.query_map([], |r| r.get::<_, String>(0))?
229+
.collect::<rusqlite::Result<_>>()?;
230+
231+
println!("Checking Temporal drift (threshold: {})...", threshold);
232+
let mut reported = 0usize;
233+
for entity in &entities {
234+
let Some(report) = tier1::drift::detect_temporal_drift(&conn, entity)? else {
235+
continue;
236+
};
237+
if report.overall_score >= threshold {
238+
println!(
239+
" {} drift={:.3}",
240+
report.entity_id, report.overall_score
241+
);
242+
reported += 1;
243+
}
244+
}
245+
println!(
246+
"Scanned {} entit{}; {} above threshold.",
247+
entities.len(),
248+
if entities.len() == 1 { "y" } else { "ies" },
249+
reported
250+
);
218251
Ok(())
219252
}
220253

src/tier1/drift.rs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,188 @@ pub struct DriftReport {
4040
/// Timestamp of measurement.
4141
pub measured_at: chrono::DateTime<chrono::Utc>,
4242
}
43+
44+
/// Compute the Temporal drift score for one entity per ADR-0003 §3.1.
45+
///
46+
/// Reads the latest version per `table_name` for `entity_id` from
47+
/// `verisimdb_temporal_versions`. Score is the max pairwise drift:
48+
/// `|v_a - v_b| / max(v_a, v_b, 1)`, where `v_*` is the latest
49+
/// version recorded under each modality (`table_name`).
50+
///
51+
/// Returns `Ok(None)` if the entity is recorded under fewer than two
52+
/// `table_name`s — Temporal drift requires at least two modalities
53+
/// to compare.
54+
///
55+
/// Closes #49. The function is intentionally narrow: one entity, one
56+
/// category. A higher-level pass over all entities is the
57+
/// responsibility of `verisimiser drift`.
58+
pub fn detect_temporal_drift(
59+
conn: &rusqlite::Connection,
60+
entity_id: &str,
61+
) -> rusqlite::Result<Option<DriftReport>> {
62+
let mut stmt = conn.prepare(
63+
"SELECT MAX(version) FROM verisimdb_temporal_versions \
64+
WHERE entity_id = ?1 GROUP BY table_name",
65+
)?;
66+
let versions: Vec<i64> = stmt
67+
.query_map([entity_id], |row| row.get::<_, i64>(0))?
68+
.collect::<rusqlite::Result<Vec<_>>>()?;
69+
70+
if versions.len() < 2 {
71+
return Ok(None);
72+
}
73+
74+
let score = temporal_drift_score(&versions);
75+
Ok(Some(DriftReport {
76+
entity_id: entity_id.to_string(),
77+
overall_score: score,
78+
categories: vec![(DriftCategory::Temporal, score)],
79+
measured_at: chrono::Utc::now(),
80+
}))
81+
}
82+
83+
/// Pure-Rust kernel of the Temporal drift score — extracted so unit
84+
/// tests can exercise it without touching SQLite.
85+
///
86+
/// `versions` is the latest version per modality for a single entity.
87+
/// Returns the max pairwise `|v_a - v_b| / max(v_a, v_b, 1)` over the
88+
/// list, clamped to `[0.0, 1.0]`. With fewer than two versions the
89+
/// function returns `0.0` (caller should generally short-circuit
90+
/// before this — see [`detect_temporal_drift`]).
91+
pub fn temporal_drift_score(versions: &[i64]) -> f64 {
92+
let mut max_score: f64 = 0.0;
93+
for i in 0..versions.len() {
94+
for j in (i + 1)..versions.len() {
95+
let a = versions[i];
96+
let b = versions[j];
97+
let diff = (a - b).abs() as f64;
98+
let denom = a.max(b).max(1) as f64;
99+
let s = (diff / denom).clamp(0.0, 1.0);
100+
if s > max_score {
101+
max_score = s;
102+
}
103+
}
104+
}
105+
max_score
106+
}
107+
108+
#[cfg(test)]
109+
mod temporal_drift_tests {
110+
use super::{detect_temporal_drift, temporal_drift_score, DriftCategory};
111+
use rusqlite::Connection;
112+
113+
/// Identical versions → score 0.0.
114+
#[test]
115+
fn identical_versions_score_zero() {
116+
assert_eq!(temporal_drift_score(&[5, 5, 5]), 0.0);
117+
}
118+
119+
/// `|10-9| / 10 = 0.1` — at threshold per ADR-0003.
120+
#[test]
121+
fn one_off_high_version_score_point_one() {
122+
assert!((temporal_drift_score(&[10, 9]) - 0.1).abs() < 1e-12);
123+
}
124+
125+
/// `|5-4| / 5 = 0.2` — above threshold.
126+
#[test]
127+
fn drifted_score_above_threshold() {
128+
assert!((temporal_drift_score(&[5, 4]) - 0.2).abs() < 1e-12);
129+
}
130+
131+
/// Maximally drifted: `|10-0| / 10 = 1.0`.
132+
#[test]
133+
fn one_zero_score_one() {
134+
assert_eq!(temporal_drift_score(&[10, 0]), 1.0);
135+
}
136+
137+
/// Order doesn't matter.
138+
#[test]
139+
fn score_symmetric() {
140+
let a = temporal_drift_score(&[10, 4, 7]);
141+
let b = temporal_drift_score(&[7, 4, 10]);
142+
let c = temporal_drift_score(&[4, 7, 10]);
143+
assert_eq!(a, b);
144+
assert_eq!(b, c);
145+
}
146+
147+
/// Score is the *max* pairwise drift, not the mean.
148+
#[test]
149+
fn score_is_max_pairwise() {
150+
// (10, 9): 0.1; (10, 0): 1.0; (9, 0): 1.0 — answer 1.0
151+
let score = temporal_drift_score(&[10, 9, 0]);
152+
assert!((score - 1.0).abs() < 1e-12);
153+
}
154+
155+
/// Property: every output stays in `[0, 1]` regardless of input.
156+
#[test]
157+
fn score_clamped_to_unit_interval() {
158+
for case in [
159+
vec![1i64],
160+
vec![0, 0],
161+
vec![1000, 1],
162+
vec![i64::MAX, 1],
163+
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
164+
] {
165+
let s = temporal_drift_score(&case);
166+
assert!(
167+
(0.0..=1.0).contains(&s),
168+
"score {s} out of [0,1] for {case:?}"
169+
);
170+
}
171+
}
172+
173+
/// `detect_temporal_drift` returns `None` for an entity recorded
174+
/// under fewer than two modalities — there is nothing to compare.
175+
#[test]
176+
fn detect_returns_none_below_two_modalities() {
177+
let conn = Connection::open_in_memory().unwrap();
178+
conn.execute_batch(
179+
"CREATE TABLE verisimdb_temporal_versions (
180+
entity_id TEXT NOT NULL,
181+
table_name TEXT NOT NULL,
182+
version INTEGER NOT NULL,
183+
valid_from TEXT NOT NULL,
184+
valid_to TEXT,
185+
snapshot TEXT NOT NULL,
186+
operation TEXT NOT NULL,
187+
PRIMARY KEY (entity_id, table_name, version)
188+
);
189+
INSERT INTO verisimdb_temporal_versions
190+
VALUES ('e1','posts',1,'2026-01-01','2026-02-01','{}','insert'),
191+
('e1','posts',2,'2026-02-01',NULL,'{}','update');",
192+
)
193+
.unwrap();
194+
let report = detect_temporal_drift(&conn, "e1").unwrap();
195+
assert!(report.is_none(), "single-modality entity → None");
196+
}
197+
198+
/// Worked example end-to-end: two modalities at versions 5 and 4
199+
/// → score 0.2, populated into a DriftReport whose categories list
200+
/// is exactly one `(Temporal, 0.2)` pair.
201+
#[test]
202+
fn detect_produces_drift_report_for_two_modalities() {
203+
let conn = Connection::open_in_memory().unwrap();
204+
conn.execute_batch(
205+
"CREATE TABLE verisimdb_temporal_versions (
206+
entity_id TEXT NOT NULL,
207+
table_name TEXT NOT NULL,
208+
version INTEGER NOT NULL,
209+
valid_from TEXT NOT NULL,
210+
valid_to TEXT,
211+
snapshot TEXT NOT NULL,
212+
operation TEXT NOT NULL,
213+
PRIMARY KEY (entity_id, table_name, version)
214+
);
215+
INSERT INTO verisimdb_temporal_versions
216+
VALUES ('e1','posts',5,'2026-05-01',NULL,'{}','update'),
217+
('e1','posts_graph',4,'2026-04-01',NULL,'{}','update');",
218+
)
219+
.unwrap();
220+
let report = detect_temporal_drift(&conn, "e1").unwrap().unwrap();
221+
assert_eq!(report.entity_id, "e1");
222+
assert!((report.overall_score - 0.2).abs() < 1e-12);
223+
assert_eq!(report.categories.len(), 1);
224+
assert_eq!(report.categories[0].0, DriftCategory::Temporal);
225+
assert!((report.categories[0].1 - 0.2).abs() < 1e-12);
226+
}
227+
}

0 commit comments

Comments
 (0)