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/// The instant pair a temporal read is taken at (0.13.2, W7.1, D-174).
9///
10/// One field per axis, because [§3.1](../../docs/architecture/s0-s3-foundations.md)
11/// is what happens when there is one field for both. `None` on either axis means
12/// *the present* on that axis, and the two are independent: a read may fix valid
13/// time and float transaction time, or the reverse, or fix both — which is the
14/// cell Jensen and Snodgrass's BCDM defines a bitemporal database as answering,
15/// and which no surface in this crate could express before W7.1.
16#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17pub struct AsOf {
18    /// *What was true.* Bounds a row against its own `valid_from`/`valid_to`.
19    pub valid: Option<String>,
20    /// *What we believed.* Bounds `transaction_log.recorded_at`.
21    pub recorded: Option<String>,
22}
23
24impl AsOf {
25    /// Both axes at the present: live rows, current belief.
26    pub fn now() -> Self {
27        Self::default()
28    }
29
30    /// Fix valid time at `ts`, leaving belief at the present.
31    pub fn valid_at(ts: impl Into<String>) -> Self {
32        Self {
33            valid: Some(ts.into()),
34            recorded: None,
35        }
36    }
37
38    /// Fix belief at `ts`, leaving valid time at the present.
39    pub fn recorded_at(ts: impl Into<String>) -> Self {
40        Self {
41            valid: None,
42            recorded: Some(ts.into()),
43        }
44    }
45
46    /// Fix both — the bitemporal cell.
47    pub fn bitemporal(valid: impl Into<String>, recorded: impl Into<String>) -> Self {
48        Self {
49            valid: Some(valid.into()),
50            recorded: Some(recorded.into()),
51        }
52    }
53}
54
55/// Node attribute payload hydrated from concepts table or transaction_log.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct NodeAttributes {
58    pub id: String,
59    pub title: String,
60    pub content: String,
61    pub embedding_model: Option<String>,
62}
63
64use crate::util::limits::HYDRATE_CHUNK;
65
66/// Query valid-time graph edges under current belief as of `ts` (§5.2).
67pub async fn query_as_of_edges(
68    conn: &libsql::Connection,
69    ts: &str,
70) -> Result<Vec<(String, String, String, String, String)>> {
71    let sql = r#"
72        SELECT source_id, target_id, edge_type, valid_from, valid_to
73        FROM links_current
74        WHERE valid_from <= ?1 AND ?1 < valid_to
75    "#;
76    let mut rows = conn.query(sql, libsql::params![ts]).await?;
77    let mut edges = Vec::new();
78    while let Some(row) = rows.next().await? {
79        let src: String = row.get(0)?;
80        let tgt: String = row.get(1)?;
81        let edge_type: String = row.get(2)?;
82        let vf: String = row.get(3)?;
83        let vt: String = row.get(4)?;
84        edges.push((src, tgt, edge_type, vf, vt));
85    }
86    Ok(edges)
87}
88
89/// `?n, ?n+1, …` for `count` ids starting at `first`.
90///
91/// The ids are caller data and are bound, never interpolated. Only the
92/// *placeholders* are built by hand, which is the one part of the statement that
93/// carries no caller input at all.
94fn placeholders(first: usize, count: usize) -> String {
95    (first..first + count)
96        .map(|i| format!("?{i}"))
97        .collect::<Vec<_>>()
98        .join(", ")
99}
100
101/// Hydrate attributes for a list of node IDs based on the specified AttributeMode (§5.2).
102///
103/// **Retirement is uniform across the three readers as of Wave 1 (defect AB).**
104/// The rule is the one `AttributeMode::Current` and
105/// [`crate::temporal::reconstruct`]
106/// already followed and `AtTime` did not: *a concept retired as of the instant
107/// being asked about is not returned*. Retirement is the application axis (§4.1)
108/// and a temporal read shows what was visible, so the three modes now disagree
109/// about which text they return and agree about which concepts exist. Before
110/// this, `AtTime` read the payload and never looked at `retired`, so it returned
111/// concepts retired long before `ts` — the one reader that consulted the ledger
112/// most faithfully was also the one that answered the visibility question wrong.
113///
114/// Note what "as of `ts`" means for each: `Current` asks whether the concept is
115/// retired *now* and `AtTime` asks whether it was retired *then*. That is not an
116/// inconsistency, it is the two clocks — and it is why `Current` on a historical
117/// query is worth objecting to. **That objection is no longer made here**
118/// (T3.2, D-085): this function receives the mode as a parameter and has no way
119/// to tell a historical query from a live one, so it does what it is told.
120/// [`crate::graph::TraversalBuilder`] is the layer that knows, and it raises
121/// [`DbError::AttributeModeUnstated`].
122///
123/// Both modes issue **one query per chunk of [`HYDRATE_CHUNK`] ids**, not one
124/// per node (defect AE). Results come back in `node_ids` order regardless of the
125/// order the rows arrive in, because a graph read that permuted its own output
126/// between runs would break the property suite's equality comparisons for a
127/// reason that has nothing to do with the property under test.
128///
129/// # `ts: &str` became `as_of: &AsOf` in 0.13.2 (W7.1, D-174)
130///
131/// The old parameter was one instant read on whichever clock the mode happened
132/// to use — `Current` ignored it, `AtTime` compared it to `recorded_at`, and
133/// neither ever compared it to a concept's own valid interval. So `AtTime`
134/// returned concepts whose validity had ended before the instant asked about,
135/// which is the smaller half of what [§3.1](../../docs/architecture/s0-s3-foundations.md)
136/// names and was recorded in `TraversalBuilder::as_of`'s rustdoc in 0.12.17.
137///
138/// [`AttributeMode::AtTime`] now dispatches on which axes are fixed:
139///
140/// | `as_of` | reads |
141/// |---|---|
142/// | neither | live `concepts`, retired filtered — identical to `Current` |
143/// | `valid` | live `concepts`, bounded by the row's own valid interval |
144/// | `recorded` | the payload believed at that instant |
145/// | both | the payload believed then, bounded by the validity it recorded |
146///
147/// [`AttributeMode::Current`] ignores both axes by definition — it is the
148/// *stated* choice to read live text under a historical topology, which
149/// `TraversalBuilder` makes the caller make rather than fall into (D-085).
150///
151/// # Errors
152///
153/// [`DbError::RecordedInstantUnreachable`] when `as_of.recorded` is set under
154/// [`AttributeMode::AtTime`] and rows have been archived out of the hot log
155/// (0.13.16, W9.1, [D-189](../../docs/architecture/s13-decision-register.md#d-189)).
156/// Only the `recorded` row of the table above can raise it; the other three
157/// read live `concepts` and never the log, so an archive cannot shorten them.
158pub async fn hydrate_attributes(
159    conn: &libsql::Connection,
160    node_ids: &[String],
161    as_of: &AsOf,
162    mode: AttributeMode,
163) -> Result<Vec<NodeAttributes>> {
164    if node_ids.is_empty() {
165        return Ok(Vec::new());
166    }
167
168    let found: HashMap<String, NodeAttributes> = match mode {
169        AttributeMode::Omit => return Ok(Vec::new()),
170        // No warning here any more (T3.2, D-085). This function takes the mode
171        // as a parameter and cannot tell a historical query from a live one, so
172        // the warning fired on *every* `Current` hydrate, which is overwhelmingly
173        // the ordinary live case where it is exactly right. Loud where it did not
174        // matter and, being a log line, silent where it did. The decision now
175        // lives in `TraversalBuilder`, which knows whether an instant was set,
176        // and is a typed error.
177        AttributeMode::Current => hydrate_current(conn, node_ids, None).await?,
178        AttributeMode::AtTime => match as_of.recorded.as_deref() {
179            None => hydrate_current(conn, node_ids, as_of.valid.as_deref()).await?,
180            Some(recorded) => {
181                hydrate_at_time(conn, node_ids, recorded, as_of.valid.as_deref()).await?
182            }
183        },
184    };
185
186    // Caller order, and absences simply dropped — the signature returns a Vec
187    // rather than a per-id Option, so a node with no visible concept is reported
188    // by being missing. That is what both modes did before.
189    let mut out = Vec::with_capacity(found.len());
190    for id in node_ids {
191        if let Some(attrs) = found.get(id) {
192            out.push(attrs.clone());
193        }
194    }
195    Ok(out)
196}
197
198/// Live attributes under current belief, filtered by retirement *now* and — when
199/// `valid` is given — by the row's own valid interval (W7.1).
200///
201/// The valid-time bound is what `AttributeMode::Current` never had and could not
202/// have: `concepts` carries `valid_from`/`valid_to` and nothing read them, so a
203/// concept whose validity had ended still hydrated into a historical traversal.
204/// Passing `None` is the live read, unchanged, and is what `Current` still does —
205/// that mode's whole meaning is *today's text regardless of the instant*.
206async fn hydrate_current(
207    conn: &libsql::Connection,
208    node_ids: &[String],
209    valid: Option<&str>,
210) -> Result<HashMap<String, NodeAttributes>> {
211    let mut found = HashMap::new();
212
213    for chunk in node_ids.chunks(HYDRATE_CHUNK) {
214        // The ids bind from `?1` when there is no instant and from `?2` when
215        // there is, so the instant can lead and the variadic part can trail.
216        let (first, valid_filter) = match valid {
217            Some(_) => (2, " AND valid_from <= ?1 AND ?1 < valid_to"),
218            None => (1, ""),
219        };
220        let sql = format!(
221            "SELECT id, title, content, embedding_model FROM concepts \
222             WHERE retired = 0{valid_filter} AND id IN ({})",
223            placeholders(first, chunk.len())
224        );
225        let mut params: Vec<libsql::Value> = Vec::with_capacity(chunk.len() + 1);
226        if let Some(v) = valid {
227            params.push(libsql::Value::Text(v.to_string()));
228        }
229        params.extend(chunk.iter().map(|id| libsql::Value::Text(id.clone())));
230
231        let mut rows = conn.query(&sql, params).await?;
232        while let Some(row) = rows.next().await? {
233            let id: String = row.get(0)?;
234            found.insert(
235                id.clone(),
236                NodeAttributes {
237                    id,
238                    title: row.get(1)?,
239                    content: row.get(2)?,
240                    embedding_model: row.get(3).ok(),
241                },
242            );
243        }
244    }
245
246    Ok(found)
247}
248
249/// Attributes as recorded at `ts`, filtered by retirement *at* `ts` and — when
250/// `valid` is given — by the validity the payload itself recorded (W7.1).
251///
252/// **The `valid` arm is the bitemporal cell.** The fold picks the row the ledger
253/// held at `ts`; the payload of that row carries the `valid_from`/`valid_to` the
254/// concept had *at that point in the ledger's belief*, so bounding against those
255/// answers *what did we believe at `recorded` about what was true at `valid`*.
256/// Reading the concept's valid interval from the live `concepts` table instead
257/// would answer something else entirely — today's belief about validity, wearing
258/// the past's title — which is the exact conflation W7.1 exists to end.
259///
260/// **It reads the hot log only, and refuses what the hot log cannot answer**
261/// (0.13.16, W9.1). See the guard at the top of the body: this is the same
262/// refusal [`crate::graph::TraversalBuilder::as_of_recorded`] makes, at the
263/// second surface that folds `transaction_log`.
264///
265/// The window partitions on `entity_id` alone and is sound doing so only because
266/// `table_name = 'concepts'` is already in the `WHERE` — the discriminator is
267/// applied by the filter instead of by the partition, so the concept/link
268/// collision that defect W is about cannot arise here. Stated because the four
269/// folds in `replay.rs` now carry the discriminator in the partition and the
270/// difference should not read as an oversight.
271async fn hydrate_at_time(
272    conn: &libsql::Connection,
273    node_ids: &[String],
274    ts: &str,
275    valid: Option<&str>,
276) -> Result<HashMap<String, NodeAttributes>> {
277    // §3.2, closed in 0.13.16 (W9.1, D-189). The fold below reads the *hot*
278    // log, and `archive` physically moves superseded rows out of it -- which is
279    // precisely the rows a past instant asks for. Without this the answer was a
280    // shorter `Vec`, and a missing element here is indistinguishable from
281    // retired and from never having existed.
282    //
283    // Applied where the read is rather than at whichever caller remembered.
284    // `TraversalBuilder::execute_ids` already checks before its own fold, so
285    // `execute` now pays for two, and that is the right way round: the two
286    // folds are separately reachable, and a guard that lives at the caller is
287    // one the next caller does not inherit.
288    if !crate::temporal::replay::hot_log_answers_for(conn, ts).await? {
289        return Err(DbError::RecordedInstantUnreachable { ts: ts.to_string() });
290    }
291
292    let mut found = HashMap::new();
293
294    for chunk in node_ids.chunks(HYDRATE_CHUNK) {
295        let sql = format!(
296            r#"
297            SELECT entity_id, seq_id, payload FROM (
298                SELECT entity_id, seq_id, payload,
299                       ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY seq_id DESC) as rn
300                FROM transaction_log
301                WHERE table_name = 'concepts'
302                  AND recorded_at <= ?1
303                  AND entity_id IN ({})
304            ) WHERE rn = 1
305            "#,
306            placeholders(2, chunk.len())
307        );
308
309        let mut params: Vec<libsql::Value> = Vec::with_capacity(chunk.len() + 1);
310        params.push(libsql::Value::Text(ts.to_string()));
311        params.extend(chunk.iter().map(|id| libsql::Value::Text(id.clone())));
312
313        let mut rows = conn.query(&sql, params).await?;
314        while let Some(row) = rows.next().await? {
315            let id: String = row.get(0)?;
316            let seq_id: i64 = row.get(1)?;
317            let payload_str: String = row.get(2)?;
318
319            // Raised rather than skipped. A payload that will not parse is the
320            // ledger disagreeing with itself, and the previous version's
321            // `if let Ok(..)` turned that into a node quietly missing from the
322            // answer — the same shape of silence defect W was.
323            let payload: serde_json::Value =
324                serde_json::from_str(&payload_str).map_err(|e| DbError::ReplayCorrupt {
325                    seq: seq_id,
326                    reason: format!("Failed to parse payload JSON: {e}"),
327                })?;
328
329            let v = payload.get("v").and_then(|v| v.as_u64()).unwrap_or(1);
330            if v > PAYLOAD_VERSION as u64 {
331                return Err(DbError::PayloadVersion {
332                    got: v as u8,
333                    max: PAYLOAD_VERSION,
334                });
335            }
336
337            // Retired as of `ts`: not visible, and not an error either.
338            if payload.get("retired").and_then(|r| r.as_i64()).unwrap_or(0) != 0 {
339                continue;
340            }
341
342            // Outside its own valid interval at the instant asked about. Applied
343            // in Rust rather than in the `WHERE` because the interval lives
344            // inside the JSON payload and the fold has already narrowed to one
345            // row per entity — a `json_extract` in the outer filter would read
346            // the same bytes this arm already has in hand.
347            //
348            // A v1 payload carries no `valid_from`/`valid_to` (they arrived with
349            // v2), and an absent bound is treated as unbounded on that side: the
350            // row is from before the crate recorded validity in the log, and
351            // excluding it would report a gap in the ledger that is really a gap
352            // in the payload schema.
353            if let Some(v) = valid {
354                let from = payload.get("valid_from").and_then(|s| s.as_str());
355                let to = payload.get("valid_to").and_then(|s| s.as_str());
356                if from.is_some_and(|f| f > v) || to.is_some_and(|t| t <= v) {
357                    continue;
358                }
359            }
360
361            found.insert(
362                id.clone(),
363                NodeAttributes {
364                    id,
365                    title: payload
366                        .get("title")
367                        .and_then(|s| s.as_str())
368                        .unwrap_or("")
369                        .to_string(),
370                    content: payload
371                        .get("content")
372                        .and_then(|s| s.as_str())
373                        .unwrap_or("")
374                        .to_string(),
375                    // Absent in a v1 payload, which is indistinguishable here
376                    // from present-and-null and correctly so: both mean the
377                    // concept carries no model.
378                    embedding_model: payload
379                        .get("embedding_model")
380                        .and_then(|s| s.as_str())
381                        .map(|s| s.to_string()),
382                },
383            );
384        }
385    }
386
387    Ok(found)
388}