Skip to main content

brain_edge/dto/
statement.rs

1//! Statement-graph request/response DTOs — the public JSON contract for the
2//! `/v1/statements` verbs (fetch by id, list by filters).
3//!
4//! Deliberately identical to the Arc cloud gateway's contract. Ids are
5//! canonical UUID strings. A statement's object is a tagged union
6//! (`kind: entity | value | memory | statement`); a scalar value is itself a
7//! tagged union (`type: text | integer | float | bool | unix_nanos | blob`).
8
9// The DTOs below mirror the HTTP contract one-for-one: the JSON field names are
10// the API, and `tools/http_manifest.py` emits every one of them — with its type
11// and serde attributes — into `contract/http-routes.json`, which the three SDK
12// clients are checked against. A doc comment on each of ~312 fields would
13// restate the field name; the ones that carry meaning beyond their name have
14// one written below.
15#![allow(missing_docs)]
16
17use brain_db_sdk::wire::types::{
18    StatementGetRequest, StatementKindWire, StatementListRequest, StatementObjectWire,
19    StatementValueWire, StatementView,
20};
21use serde::{Deserialize, Serialize};
22
23use super::{parse_uuid, uuid_string};
24
25/// Default page size for `GET /v1/statements` when the client omits `limit`.
26const DEFAULT_LIST_LIMIT: u32 = 100;
27/// Hard ceiling on the page size (mirrors Brain's `1..=1000`).
28const MAX_LIST_LIMIT: u32 = 1000;
29
30/// Whether a statement read follows supersession by default (the dashboard
31/// wants the current head unless told otherwise).
32const fn default_follow_supersession() -> bool {
33    true
34}
35
36/// The snake_case label for a wire statement kind.
37fn statement_kind_str(kind: StatementKindWire) -> String {
38    match kind {
39        StatementKindWire::Fact => "fact".to_owned(),
40        StatementKindWire::Preference => "preference".to_owned(),
41        StatementKindWire::Event => "event".to_owned(),
42        StatementKindWire::Attribute => "attribute".to_owned(),
43        StatementKindWire::Relation => "relation".to_owned(),
44        StatementKindWire::Directive => "directive".to_owned(),
45        StatementKindWire::Custom(_) => "custom".to_owned(),
46    }
47}
48
49/// The 1-based LIST filter byte for a kind name (`0` = "no filter"). Mirrors the
50/// storage byte + 1 (`fact = 1`, `preference = 2`, …).
51fn kind_list_filter_byte(raw: &str) -> Result<u8, String> {
52    Ok(match raw.trim().to_ascii_lowercase().as_str() {
53        "fact" => 1,
54        "preference" => 2,
55        "event" => 3,
56        "attribute" => 4,
57        "relation" => 5,
58        "directive" => 6,
59        other => {
60            return Err(format!(
61                "kind must be one of: fact, preference, event, attribute, relation, directive (got `{other}`)"
62            ));
63        }
64    })
65}
66
67/// A scalar object value in the public JSON contract.
68#[derive(Debug, Serialize)]
69#[serde(tag = "type", rename_all = "snake_case")]
70pub enum StatementValueDto {
71    /// Free text.
72    Text { value: String },
73    /// Signed integer.
74    Integer { value: i64 },
75    /// Floating-point number.
76    Float { value: f64 },
77    /// Boolean.
78    Bool { value: bool },
79    /// A unix-nanos timestamp value.
80    UnixNanos { value: u64 },
81    /// Opaque bytes (JSON array of byte values).
82    Blob { value: Vec<u8> },
83}
84
85impl From<StatementValueWire> for StatementValueDto {
86    fn from(v: StatementValueWire) -> Self {
87        match v {
88            StatementValueWire::Text(s) => Self::Text { value: s },
89            StatementValueWire::Integer(i) => Self::Integer { value: i },
90            StatementValueWire::Float(f) => Self::Float { value: f },
91            StatementValueWire::Bool(b) => Self::Bool { value: b },
92            StatementValueWire::UnixNanos(n) => Self::UnixNanos { value: n },
93            StatementValueWire::Blob(b) => Self::Blob { value: b },
94        }
95    }
96}
97
98/// A statement object in the public JSON contract. Ids are UUID strings.
99#[derive(Debug, Serialize)]
100#[serde(tag = "kind", rename_all = "snake_case")]
101pub enum StatementObjectDto {
102    /// Points at an entity.
103    Entity { id: String },
104    /// A literal scalar value.
105    Value { value: StatementValueDto },
106    /// Points at a memory.
107    Memory { id: String },
108    /// Points at another statement.
109    Statement { id: String },
110}
111
112impl From<StatementObjectWire> for StatementObjectDto {
113    fn from(o: StatementObjectWire) -> Self {
114        match o {
115            StatementObjectWire::EntityRef(id) => Self::Entity {
116                id: uuid_string(&id),
117            },
118            StatementObjectWire::Value(v) => Self::Value {
119                value: StatementValueDto::from(v),
120            },
121            StatementObjectWire::MemoryRef(id) => Self::Memory {
122                id: uuid_string(&id),
123            },
124            StatementObjectWire::StatementRef(id) => Self::Statement {
125                id: uuid_string(&id),
126            },
127        }
128    }
129}
130
131/// A read-side statement view in the public JSON contract (dashboard subset).
132#[derive(Debug, Serialize)]
133pub struct StatementDetailDto {
134    /// The statement id (UUID).
135    pub statement_id: String,
136    /// Its kind (`fact`, `preference`, `event`, …).
137    pub kind: String,
138    /// The subject entity (UUID).
139    pub subject: String,
140    /// The predicate.
141    pub predicate: String,
142    /// The object.
143    pub object: StatementObjectDto,
144    /// Confidence in `[0,1]`.
145    pub confidence: f32,
146    /// When the content happened (unix nanos; `0` = unset).
147    pub event_at_unix_nanos: u64,
148    /// Bi-temporal validity start (unix nanos).
149    pub valid_from_unix_nanos: u64,
150    /// Bi-temporal validity end (`0` = still valid).
151    pub valid_to_unix_nanos: u64,
152    /// Whether the statement is tombstoned.
153    pub tombstoned: bool,
154}
155
156impl From<StatementView> for StatementDetailDto {
157    fn from(v: StatementView) -> Self {
158        Self {
159            statement_id: uuid_string(&v.statement_id),
160            kind: statement_kind_str(v.kind),
161            subject: uuid_string(&v.subject),
162            predicate: v.predicate,
163            object: StatementObjectDto::from(v.object),
164            confidence: v.confidence,
165            event_at_unix_nanos: v.event_at_unix_nanos,
166            valid_from_unix_nanos: v.valid_from_unix_nanos,
167            valid_to_unix_nanos: v.valid_to_unix_nanos,
168            tombstoned: v.tombstoned,
169        }
170    }
171}
172
173/// `GET /v1/statements/{id}` query parameters.
174#[derive(Debug, Deserialize)]
175pub struct GetStatementQuery {
176    /// Follow supersession to the current chain head (default `true`).
177    #[serde(default = "default_follow_supersession")]
178    pub follow_supersession: bool,
179}
180
181/// Build a wire `STATEMENT_GET` request from the path id and query.
182pub fn get_request_from(
183    id: &str,
184    query: &GetStatementQuery,
185) -> Result<StatementGetRequest, String> {
186    Ok(StatementGetRequest {
187        statement_id: parse_uuid(id)?,
188        follow_supersession: query.follow_supersession,
189        act_as: None,
190    })
191}
192
193/// `GET /v1/statements` query parameters. All optional.
194#[derive(Debug, Deserialize)]
195pub struct ListStatementsQuery {
196    /// Subject entity id (UUID) filter; omitted → any subject.
197    #[serde(default)]
198    pub subject: Option<String>,
199    /// Predicate filter; omitted → any predicate.
200    #[serde(default)]
201    pub predicate: String,
202    /// Kind filter (`fact`, `preference`, …); omitted → any kind.
203    #[serde(default)]
204    pub kind: Option<String>,
205    /// Minimum confidence (`0.0` = no floor).
206    #[serde(default)]
207    pub min_confidence: f32,
208    /// Only current (non-superseded) statements (default `true`).
209    #[serde(default = "default_follow_supersession")]
210    pub only_current: bool,
211    /// Include tombstoned statements (default `false`).
212    #[serde(default)]
213    pub include_tombstoned: bool,
214    /// Page size; omitted → 100, clamped to `1..=1000`.
215    pub limit: Option<u32>,
216}
217
218impl ListStatementsQuery {
219    /// Build the wire request. `None` filters map to the wire's "no filter"
220    /// sentinels (`[0;16]` subject, `0` kind byte); v1 has no cursor pagination.
221    pub fn to_request(&self) -> Result<StatementListRequest, String> {
222        let subject = match &self.subject {
223            Some(s) if !s.trim().is_empty() => parse_uuid(s)?,
224            _ => [0u8; 16],
225        };
226        let kind = match &self.kind {
227            Some(k) if !k.trim().is_empty() => kind_list_filter_byte(k)?,
228            _ => 0,
229        };
230        let limit = self
231            .limit
232            .unwrap_or(DEFAULT_LIST_LIMIT)
233            .clamp(1, MAX_LIST_LIMIT);
234        Ok(StatementListRequest {
235            subject,
236            predicate: self.predicate.clone(),
237            kind,
238            min_confidence: self.min_confidence,
239            time_range_start_unix_nanos: 0,
240            time_range_end_unix_nanos: 0,
241            only_current: self.only_current,
242            include_tombstoned: self.include_tombstoned,
243            limit,
244            cursor: Vec::new(),
245            act_as: None,
246        })
247    }
248}
249
250/// `GET /v1/statements` response: a page of statements plus its count.
251#[derive(Debug, Serialize)]
252pub struct ListStatementsResponseDto {
253    /// The statements in this page.
254    pub statements: Vec<StatementDetailDto>,
255    /// Number of statements returned.
256    pub count: usize,
257}
258
259impl ListStatementsResponseDto {
260    /// Assemble from the flattened `STATEMENT_LIST` views.
261    pub fn from_views(views: Vec<StatementView>) -> Self {
262        let statements: Vec<StatementDetailDto> =
263            views.into_iter().map(StatementDetailDto::from).collect();
264        let count = statements.len();
265        Self { statements, count }
266    }
267}
268
269#[cfg(test)]
270mod statement_tests {
271    use super::*;
272
273    #[test]
274    fn list_query_maps_filters_and_sentinels() {
275        let none = ListStatementsQuery {
276            subject: None,
277            predicate: String::new(),
278            kind: None,
279            min_confidence: 0.0,
280            only_current: true,
281            include_tombstoned: false,
282            limit: None,
283        };
284        let req = none.to_request().unwrap();
285        assert_eq!(req.subject, [0u8; 16]);
286        assert_eq!(req.kind, 0);
287        assert_eq!(req.limit, DEFAULT_LIST_LIMIT);
288        assert!(req.only_current);
289
290        let with = ListStatementsQuery {
291            subject: Some("00000000-0000-0000-0000-000000000007".into()),
292            predicate: "brain:likes".into(),
293            kind: Some("preference".into()),
294            min_confidence: 0.5,
295            only_current: false,
296            include_tombstoned: true,
297            limit: Some(9999),
298        };
299        let req = with.to_request().unwrap();
300        assert_eq!(req.subject[15], 7);
301        assert_eq!(req.kind, 2); // preference = storage 1 + 1
302        assert_eq!(req.limit, MAX_LIST_LIMIT);
303        assert!(!req.only_current);
304        assert!(req.include_tombstoned);
305    }
306
307    #[test]
308    fn list_query_rejects_bad_kind_and_subject() {
309        assert!(
310            ListStatementsQuery {
311                subject: None,
312                predicate: String::new(),
313                kind: Some("nonsense".into()),
314                min_confidence: 0.0,
315                only_current: true,
316                include_tombstoned: false,
317                limit: None,
318            }
319            .to_request()
320            .is_err()
321        );
322        assert!(
323            ListStatementsQuery {
324                subject: Some("nope".into()),
325                predicate: String::new(),
326                kind: None,
327                min_confidence: 0.0,
328                only_current: true,
329                include_tombstoned: false,
330                limit: None,
331            }
332            .to_request()
333            .is_err()
334        );
335    }
336
337    #[test]
338    fn get_request_defaults_follow_supersession() {
339        let q = GetStatementQuery {
340            follow_supersession: true,
341        };
342        let req = get_request_from("00000000-0000-0000-0000-000000000001", &q).unwrap();
343        assert!(req.follow_supersession);
344        assert_eq!(req.statement_id[15], 1);
345        assert!(get_request_from("bad", &q).is_err());
346    }
347
348    #[test]
349    fn object_value_serializes_tagged() {
350        let entity = StatementObjectDto::from(StatementObjectWire::EntityRef([0x01u8; 16]));
351        let json = serde_json::to_value(&entity).unwrap();
352        assert_eq!(json["kind"], "entity");
353        assert!(json["id"].as_str().unwrap().contains('-'));
354
355        let value =
356            StatementObjectDto::from(StatementObjectWire::Value(StatementValueWire::Integer(42)));
357        let json = serde_json::to_value(&value).unwrap();
358        assert_eq!(json["kind"], "value");
359        assert_eq!(json["value"]["type"], "integer");
360        assert_eq!(json["value"]["value"], 42);
361    }
362}