Skip to main content

macrame/temporal/
as_of.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use crate::error::{DbError, Result};
5use crate::graph::builder::AttributeMode;
6use crate::temporal::replay::PAYLOAD_VERSION;
7
8/// Node attribute payload hydrated from concepts table or transaction_log.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct NodeAttributes {
11    pub id: String,
12    pub title: String,
13    pub content: String,
14    pub embedding_model: Option<String>,
15}
16
17use crate::util::limits::HYDRATE_CHUNK;
18
19/// Query valid-time graph edges under current belief as of `ts` (§5.2).
20pub async fn query_as_of_edges(
21    conn: &libsql::Connection,
22    ts: &str,
23) -> Result<Vec<(String, String, String, String, String)>> {
24    let sql = r#"
25        SELECT source_id, target_id, edge_type, valid_from, valid_to
26        FROM links_current
27        WHERE valid_from <= ?1 AND ?1 < valid_to
28    "#;
29    let mut rows = conn.query(sql, libsql::params![ts]).await?;
30    let mut edges = Vec::new();
31    while let Some(row) = rows.next().await? {
32        let src: String = row.get(0)?;
33        let tgt: String = row.get(1)?;
34        let edge_type: String = row.get(2)?;
35        let vf: String = row.get(3)?;
36        let vt: String = row.get(4)?;
37        edges.push((src, tgt, edge_type, vf, vt));
38    }
39    Ok(edges)
40}
41
42/// `?n, ?n+1, …` for `count` ids starting at `first`.
43///
44/// The ids are caller data and are bound, never interpolated. Only the
45/// *placeholders* are built by hand, which is the one part of the statement that
46/// carries no caller input at all.
47fn placeholders(first: usize, count: usize) -> String {
48    (first..first + count)
49        .map(|i| format!("?{i}"))
50        .collect::<Vec<_>>()
51        .join(", ")
52}
53
54/// Hydrate attributes for a list of node IDs based on the specified AttributeMode (§5.2).
55///
56/// **Retirement is uniform across the three readers as of Wave 1 (defect AB).**
57/// The rule is the one `AttributeMode::Current` and [`crate::reconstruct`]
58/// already followed and `AtTime` did not: *a concept retired as of the instant
59/// being asked about is not returned*. Retirement is the application axis (§4.1)
60/// and a temporal read shows what was visible, so the three modes now disagree
61/// about which text they return and agree about which concepts exist. Before
62/// this, `AtTime` read the payload and never looked at `retired`, so it returned
63/// concepts retired long before `ts` — the one reader that consulted the ledger
64/// most faithfully was also the one that answered the visibility question wrong.
65///
66/// Note what "as of `ts`" means for each: `Current` asks whether the concept is
67/// retired *now* and `AtTime` asks whether it was retired *then*. That is not an
68/// inconsistency, it is the two clocks — and it is why `Current` on a historical
69/// query is worth objecting to. **That objection is no longer made here**
70/// (T3.2, D-085): this function receives the mode as a parameter and has no way
71/// to tell a historical query from a live one, so it does what it is told.
72/// [`crate::graph::TraversalBuilder`] is the layer that knows, and it raises
73/// [`DbError::AttributeModeUnstated`].
74///
75/// Both modes issue **one query per chunk of [`HYDRATE_CHUNK`] ids**, not one
76/// per node (defect AE). Results come back in `node_ids` order regardless of the
77/// order the rows arrive in, because a graph read that permuted its own output
78/// between runs would break the property suite's equality comparisons for a
79/// reason that has nothing to do with the property under test.
80pub async fn hydrate_attributes(
81    conn: &libsql::Connection,
82    node_ids: &[String],
83    ts: &str,
84    mode: AttributeMode,
85) -> Result<Vec<NodeAttributes>> {
86    if node_ids.is_empty() {
87        return Ok(Vec::new());
88    }
89
90    let found: HashMap<String, NodeAttributes> = match mode {
91        AttributeMode::Omit => return Ok(Vec::new()),
92        // No warning here any more (T3.2, D-085). This function takes the mode
93        // as a parameter and cannot tell a historical query from a live one —
94        // `ts` is just an instant — so the warning fired on *every* `Current`
95        // hydrate, which is overwhelmingly the ordinary live case where it is
96        // exactly right. Loud where it did not matter and, being a log line,
97        // silent where it did. The decision now lives in `TraversalBuilder`,
98        // which knows whether `as_of` was set, and is a typed error.
99        AttributeMode::Current => hydrate_current(conn, node_ids).await?,
100        AttributeMode::AtTime => hydrate_at_time(conn, node_ids, ts).await?,
101    };
102
103    // Caller order, and absences simply dropped — the signature returns a Vec
104    // rather than a per-id Option, so a node with no visible concept is reported
105    // by being missing. That is what both modes did before.
106    let mut out = Vec::with_capacity(found.len());
107    for id in node_ids {
108        if let Some(attrs) = found.get(id) {
109            out.push(attrs.clone());
110        }
111    }
112    Ok(out)
113}
114
115/// Live attributes, filtered by retirement *now*.
116async fn hydrate_current(
117    conn: &libsql::Connection,
118    node_ids: &[String],
119) -> Result<HashMap<String, NodeAttributes>> {
120    let mut found = HashMap::new();
121
122    for chunk in node_ids.chunks(HYDRATE_CHUNK) {
123        let sql = format!(
124            "SELECT id, title, content, embedding_model FROM concepts \
125             WHERE retired = 0 AND id IN ({})",
126            placeholders(1, chunk.len())
127        );
128        let params: Vec<libsql::Value> = chunk
129            .iter()
130            .map(|id| libsql::Value::Text(id.clone()))
131            .collect();
132
133        let mut rows = conn.query(&sql, params).await?;
134        while let Some(row) = rows.next().await? {
135            let id: String = row.get(0)?;
136            found.insert(
137                id.clone(),
138                NodeAttributes {
139                    id,
140                    title: row.get(1)?,
141                    content: row.get(2)?,
142                    embedding_model: row.get(3).ok(),
143                },
144            );
145        }
146    }
147
148    Ok(found)
149}
150
151/// Attributes as recorded at `ts`, filtered by retirement *at* `ts`.
152///
153/// The window partitions on `entity_id` alone and is sound doing so only because
154/// `table_name = 'concepts'` is already in the `WHERE` — the discriminator is
155/// applied by the filter instead of by the partition, so the concept/link
156/// collision that defect W is about cannot arise here. Stated because the four
157/// folds in `replay.rs` now carry the discriminator in the partition and the
158/// difference should not read as an oversight.
159async fn hydrate_at_time(
160    conn: &libsql::Connection,
161    node_ids: &[String],
162    ts: &str,
163) -> Result<HashMap<String, NodeAttributes>> {
164    let mut found = HashMap::new();
165
166    for chunk in node_ids.chunks(HYDRATE_CHUNK) {
167        let sql = format!(
168            r#"
169            SELECT entity_id, seq_id, payload FROM (
170                SELECT entity_id, seq_id, payload,
171                       ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY seq_id DESC) as rn
172                FROM transaction_log
173                WHERE table_name = 'concepts'
174                  AND recorded_at <= ?1
175                  AND entity_id IN ({})
176            ) WHERE rn = 1
177            "#,
178            placeholders(2, chunk.len())
179        );
180
181        let mut params: Vec<libsql::Value> = Vec::with_capacity(chunk.len() + 1);
182        params.push(libsql::Value::Text(ts.to_string()));
183        params.extend(chunk.iter().map(|id| libsql::Value::Text(id.clone())));
184
185        let mut rows = conn.query(&sql, params).await?;
186        while let Some(row) = rows.next().await? {
187            let id: String = row.get(0)?;
188            let seq_id: i64 = row.get(1)?;
189            let payload_str: String = row.get(2)?;
190
191            // Raised rather than skipped. A payload that will not parse is the
192            // ledger disagreeing with itself, and the previous version's
193            // `if let Ok(..)` turned that into a node quietly missing from the
194            // answer — the same shape of silence defect W was.
195            let payload: serde_json::Value =
196                serde_json::from_str(&payload_str).map_err(|e| DbError::ReplayCorrupt {
197                    seq: seq_id,
198                    reason: format!("Failed to parse payload JSON: {e}"),
199                })?;
200
201            let v = payload.get("v").and_then(|v| v.as_u64()).unwrap_or(1);
202            if v > PAYLOAD_VERSION as u64 {
203                return Err(DbError::PayloadVersion {
204                    got: v as u8,
205                    max: PAYLOAD_VERSION,
206                });
207            }
208
209            // Retired as of `ts`: not visible, and not an error either.
210            if payload.get("retired").and_then(|r| r.as_i64()).unwrap_or(0) != 0 {
211                continue;
212            }
213
214            found.insert(
215                id.clone(),
216                NodeAttributes {
217                    id,
218                    title: payload
219                        .get("title")
220                        .and_then(|s| s.as_str())
221                        .unwrap_or("")
222                        .to_string(),
223                    content: payload
224                        .get("content")
225                        .and_then(|s| s.as_str())
226                        .unwrap_or("")
227                        .to_string(),
228                    // Absent in a v1 payload, which is indistinguishable here
229                    // from present-and-null and correctly so: both mean the
230                    // concept carries no model.
231                    embedding_model: payload
232                        .get("embedding_model")
233                        .and_then(|s| s.as_str())
234                        .map(|s| s.to_string()),
235                },
236            );
237        }
238    }
239
240    Ok(found)
241}