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}