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