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}