Skip to main content

smos_application/types/
search_hit.rs

1//! Vector-search hit DTO.
2//!
3//! Mirrors the POC `SearchHit` (`smos/storage.py:36`): id, document, metadata,
4//! distance. Metadata is broken out into a typed sub-struct so downstream
5//! retrieval-planning logic can read confidence / heat / validity without
6//! string-keyed lookups, but the field stays round-trippable as JSON for
7//! adapter convenience.
8
9use serde::{Deserialize, Serialize};
10use smos_domain::{FactId, MemoryKey};
11
12/// One row returned by `FactRepository::search_similar`.
13#[derive(Debug, Clone, PartialEq)]
14pub struct SearchHit {
15    pub id: FactId,
16    pub document: String,
17    pub memory_key: MemoryKey,
18    pub metadata: SearchHitMetadata,
19}
20
21/// Strongly-typed view over the POC's `dict[str, object]` metadata bag.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct SearchHitMetadata {
24    /// `accepted` / `pending` / `rejected`.
25    pub status: String,
26    /// Stored confidence score in `[0.0, 1.0]`.
27    pub confidence: f32,
28    /// ISO-8601 string of the validity tombstone, or `None` if the fact is
29    /// still current. Stored as a string so the row stays self-describing
30    /// across adapters without binding to a specific datetime crate.
31    pub valid_until: Option<String>,
32    /// Heat base value `[0.0, 1.0]`; §7 decay uses this as the seed.
33    pub heat_base: f32,
34    /// Last-access unix timestamp in seconds. The field is typed as `f32` for
35    /// wire compatibility with downstream JSON consumers that emit fractional
36    /// seconds, but the SurrealStore adapter currently truncates to whole
37    /// seconds (`surreal_store::SearchSimilarRow::to_hit` parses an ISO
38    /// datetime and stores `ts.as_unix_secs() as f32`). Treat the value as
39    /// second-precision until the storage layer gains sub-second support.
40    pub last_access_at: f32,
41    /// Cosine distance reported by the vector store. Lower = more similar.
42    /// `None` when the underlying store did not surface a distance.
43    pub distance: Option<f32>,
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    fn sample_metadata() -> SearchHitMetadata {
51        SearchHitMetadata {
52            status: "accepted".into(),
53            confidence: 0.85,
54            valid_until: None,
55            heat_base: 1.0,
56            last_access_at: 1_700_000_000.0,
57            distance: Some(0.12),
58        }
59    }
60
61    #[test]
62    fn metadata_roundtrips_through_serde() {
63        let meta = sample_metadata();
64        let json = serde_json::to_string(&meta).unwrap();
65        let back: SearchHitMetadata = serde_json::from_str(&json).unwrap();
66        assert_eq!(meta, back);
67    }
68
69    #[test]
70    fn metadata_serialises_optional_valid_until_as_null_when_absent() {
71        let meta = sample_metadata();
72        let v: serde_json::Value = serde_json::to_value(&meta).unwrap();
73        assert_eq!(v["valid_until"], serde_json::Value::Null);
74    }
75
76    #[test]
77    fn metadata_serialises_optional_distance_as_number_when_present() {
78        let meta = sample_metadata();
79        let v: serde_json::Value = serde_json::to_value(&meta).unwrap();
80        // f32 → f64 widening can introduce tiny representation drift, so
81        // compare with tolerance rather than strict equality.
82        let got = v["distance"].as_f64().unwrap_or(f64::NAN);
83        assert!((got - 0.12).abs() < 1e-5, "got {got}");
84    }
85
86    #[test]
87    fn metadata_supports_tombstoned_fact() {
88        let meta = SearchHitMetadata {
89            status: "accepted".into(),
90            confidence: 0.9,
91            valid_until: Some("2027-01-01T00:00:00Z".into()),
92            heat_base: 0.4,
93            last_access_at: 1_700_000_050.0,
94            distance: None,
95        };
96        let v: serde_json::Value = serde_json::to_value(&meta).unwrap();
97        assert_eq!(v["valid_until"], "2027-01-01T00:00:00Z");
98        assert_eq!(v["distance"], serde_json::Value::Null);
99    }
100}