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)]
17#[non_exhaustive]
18pub struct AsOf {
19    /// *What was true.* Bounds a row against its own `valid_from`/`valid_to`.
20    pub valid: Option<String>,
21    /// *What we believed.* Bounds `transaction_log.recorded_at`.
22    pub recorded: Option<String>,
23}
24
25impl AsOf {
26    /// Both axes at the present: live rows, current belief.
27    pub fn now() -> Self {
28        Self::default()
29    }
30
31    /// Fix valid time at `ts`, leaving belief at the present.
32    pub fn valid_at(ts: impl Into<String>) -> Self {
33        Self {
34            valid: Some(ts.into()),
35            recorded: None,
36        }
37    }
38
39    /// Fix belief at `ts`, leaving valid time at the present.
40    pub fn recorded_at(ts: impl Into<String>) -> Self {
41        Self {
42            valid: None,
43            recorded: Some(ts.into()),
44        }
45    }
46
47    /// Fix both — the bitemporal cell.
48    pub fn bitemporal(valid: impl Into<String>, recorded: impl Into<String>) -> Self {
49        Self {
50            valid: Some(valid.into()),
51            recorded: Some(recorded.into()),
52        }
53    }
54}
55
56/// Node attribute payload hydrated from concepts table or transaction_log.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[non_exhaustive]
59pub struct NodeAttributes {
60    pub id: String,
61    pub title: String,
62    pub content: String,
63    pub embedding_model: Option<String>,
64}
65
66impl NodeAttributes {
67    /// A node's attributes, with no embedding model recorded.
68    ///
69    /// The crate hydrates these; the constructor exists because callers
70    /// fabricate them — into a [`MaterializedState`](crate::temporal::MaterializedState)
71    /// bound for `save_snapshot`, or as the expected value of an assertion —
72    /// and 0.15.13's `#[non_exhaustive]` (W15.3, [D-255]) took the literal
73    /// away. `embedding_model` is set through
74    /// [`embedding_model()`](Self::embedding_model) rather than here, on the
75    /// crate's usual split: what a value cannot be without goes in `new`, and
76    /// what it can goes in a setter.
77    ///
78    /// [D-255]: ../../docs/architecture/s13-decision-register.md#d-255
79    pub fn new(
80        id: impl Into<String>,
81        title: impl Into<String>,
82        content: impl Into<String>,
83    ) -> Self {
84        Self {
85            id: id.into(),
86            title: title.into(),
87            content: content.into(),
88            embedding_model: None,
89        }
90    }
91
92    /// Record which model embedded this node — the
93    /// [`embedding_model`](Self::embedding_model) field.
94    pub fn embedding_model(mut self, model: impl Into<String>) -> Self {
95        self.embedding_model = Some(model.into());
96        self
97    }
98}
99
100use crate::util::limits::HYDRATE_CHUNK;
101
102/// Query valid-time graph edges under current belief as of `ts` (§5.2).
103///
104/// Reads the **trunk**, which is what this function has always meant and what
105/// every database without a fork holds. On a forked ledger that is now a
106/// resolution rather than an unfiltered scan: before 0.14.4 it returned every
107/// lineage's rows at once, which is the failure `TraversalBuilder::build_sql`'s
108/// own note describes — extra edges that look entirely ordinary.
109///
110/// Use [`query_as_of_edges_on`] to read another lineage. The signature here is
111/// unchanged rather than gaining a parameter, because a breaking change to the
112/// most-called reader in the crate is not what fixing its default is worth; the
113/// two share one implementation, so neither can drift from the other.
114pub async fn query_as_of_edges(
115    conn: &libsql::Connection,
116    ts: &str,
117) -> Result<Vec<(String, String, String, String, String)>> {
118    query_as_of_edges_on(conn, ts, None).await
119}
120
121/// [`query_as_of_edges`] on a named lineage (§15.3, D-220; the cutoff 0.14.10,
122/// [D-227]).
123///
124/// # The repair this function was left out of
125///
126/// 0.14.4 gave three read paths the same resolution — this one, the traversal,
127/// and `load_subgraph_with` — and 0.14.6 bounded that resolution by the fork
128/// point ([D-223]). **The bound reached two of the three.** The traversal and
129/// the subgraph loader share [`TraversalBuilder`](crate::graph::TraversalBuilder),
130/// which carries the lineage and picks its own source relation, so a repair
131/// written there arrived at both. This function takes the branch as a bare
132/// parameter and spells its own SQL, so it kept 0.14.4's `visible` over
133/// `links_current` and went on absorbing an ancestor's post-fork writes for
134/// four releases.
135///
136/// It was wrong in both directions D-223 names, and the second is the silent
137/// one: a branch was handed a trunk edge recorded after it forked, **and** lost
138/// an inherited edge the moment the trunk retired it — because the retirement
139/// overwrote the projection row the branch was reading through. The reader
140/// returned four edges where the traversal on the same lineage reached five
141/// nodes, and nothing in either answer said they disagreed.
142///
143/// So the resolved form is now the hybrid the traversal emits, produced by
144/// the one lowering in `graph::plan` (since 0.15.1; before that, assembled
145/// from the same functions in `graph::lineage`) rather than a second copy of
146/// it: `links_cut` for what each ancestor may still show, and `visible` to pick
147/// the nearest lineage holding each key. **The trunk's answer is unchanged** —
148/// `main` has no ancestors and no cutoff, so `churned` is empty and `links_cut`
149/// is `links_current` — and an unforked database never reaches this arm at all.
150///
151/// # Errors
152///
153/// [`DbError::UnknownBranch`](crate::DbError::UnknownBranch), naming it, when it
154/// is not registered — refused rather than answered for the trunk, for the
155/// reason `graph::lineage::Lineages::shape` gives.
156///
157/// [D-223]: ../../docs/architecture/s13-decision-register.md#d-223
158/// [D-227]: ../../docs/architecture/s13-decision-register.md#d-227
159pub async fn query_as_of_edges_on(
160    conn: &libsql::Connection,
161    ts: &str,
162    branch: Option<&str>,
163) -> Result<Vec<(String, String, String, String, String)>> {
164    Ok(crate::plan::edges_at(conn, ts, None, branch, None)
165        .await?
166        .into_iter()
167        .map(|e| {
168            (
169                e.source_id,
170                e.target_id,
171                e.edge_type,
172                e.valid_from,
173                e.valid_to,
174            )
175        })
176        .collect())
177}
178
179/// `?n, ?n+1, …` for `count` ids starting at `first`.
180///
181/// The ids are caller data and are bound, never interpolated. Only the
182/// *placeholders* are built by hand, which is the one part of the statement that
183/// carries no caller input at all.
184fn placeholders(first: usize, count: usize) -> String {
185    (first..first + count)
186        .map(|i| format!("?{i}"))
187        .collect::<Vec<_>>()
188        .join(", ")
189}
190
191/// Hydrate attributes for a list of node IDs based on the specified AttributeMode (§5.2).
192///
193/// **Retirement is uniform across the three readers as of Wave 1 (defect AB).**
194/// The rule is the one `AttributeMode::Current` and
195/// [`crate::temporal::reconstruct`]
196/// already followed and `AtTime` did not: *a concept retired as of the instant
197/// being asked about is not returned*. Retirement is the application axis (§4.1)
198/// and a temporal read shows what was visible, so the three modes now disagree
199/// about which text they return and agree about which concepts exist. Before
200/// this, `AtTime` read the payload and never looked at `retired`, so it returned
201/// concepts retired long before `ts` — the one reader that consulted the ledger
202/// most faithfully was also the one that answered the visibility question wrong.
203///
204/// Note what "as of `ts`" means for each: `Current` asks whether the concept is
205/// retired *now* and `AtTime` asks whether it was retired *then*. That is not an
206/// inconsistency, it is the two clocks — and it is why `Current` on a historical
207/// query is worth objecting to. **That objection is no longer made here**
208/// (T3.2, D-085): this function receives the mode as a parameter and has no way
209/// to tell a historical query from a live one, so it does what it is told.
210/// [`crate::graph::TraversalBuilder`] is the layer that knows, and it raises
211/// [`DbError::AttributeModeUnstated`].
212///
213/// Both modes issue **one query per chunk of [`HYDRATE_CHUNK`] ids**, not one
214/// per node (defect AE). Results come back in `node_ids` order regardless of the
215/// order the rows arrive in, because a graph read that permuted its own output
216/// between runs would break the property suite's equality comparisons for a
217/// reason that has nothing to do with the property under test.
218///
219/// # `ts: &str` became `as_of: &AsOf` in 0.13.2 (W7.1, D-174)
220///
221/// The old parameter was one instant read on whichever clock the mode happened
222/// to use — `Current` ignored it, `AtTime` compared it to `recorded_at`, and
223/// neither ever compared it to a concept's own valid interval. So `AtTime`
224/// returned concepts whose validity had ended before the instant asked about,
225/// which is the smaller half of what [§3.1](../../docs/architecture/s0-s3-foundations.md)
226/// names and was recorded in `TraversalBuilder::as_of`'s rustdoc in 0.12.17.
227///
228/// [`AttributeMode::AtTime`] now dispatches on which axes are fixed:
229///
230/// | `as_of` | reads |
231/// |---|---|
232/// | neither | live `concepts`, retired filtered — identical to `Current` |
233/// | `valid` | live `concepts`, bounded by the row's own valid interval |
234/// | `recorded` | the payload believed at that instant |
235/// | both | the payload believed then, bounded by the validity it recorded |
236///
237/// [`AttributeMode::Current`] ignores both axes by definition — it is the
238/// *stated* choice to read live text under a historical topology, which
239/// `TraversalBuilder` makes the caller make rather than fall into (D-085).
240///
241/// # Errors
242///
243/// [`DbError::RecordedInstantUnreachable`] when `as_of.recorded` is set under
244/// [`AttributeMode::AtTime`] and rows have been archived out of the hot log
245/// (0.13.16, W9.1, [D-189](../../docs/architecture/s13-decision-register.md#d-189)).
246/// Only the `recorded` row of the table above can raise it; the other three
247/// read live `concepts` and never the log, so an archive cannot shorten them.
248pub async fn hydrate_attributes(
249    conn: &libsql::Connection,
250    node_ids: &[String],
251    as_of: &AsOf,
252    mode: AttributeMode,
253) -> Result<Vec<NodeAttributes>> {
254    if node_ids.is_empty() {
255        return Ok(Vec::new());
256    }
257
258    let found: HashMap<String, NodeAttributes> = match mode {
259        AttributeMode::Omit => return Ok(Vec::new()),
260        // No warning here any more (T3.2, D-085). This function takes the mode
261        // as a parameter and cannot tell a historical query from a live one, so
262        // the warning fired on *every* `Current` hydrate, which is overwhelmingly
263        // the ordinary live case where it is exactly right. Loud where it did not
264        // matter and, being a log line, silent where it did. The decision now
265        // lives in `TraversalBuilder`, which knows whether an instant was set,
266        // and is a typed error.
267        AttributeMode::Current => hydrate_current(conn, node_ids, None).await?,
268        AttributeMode::AtTime => match as_of.recorded.as_deref() {
269            None => hydrate_current(conn, node_ids, as_of.valid.as_deref()).await?,
270            Some(recorded) => {
271                hydrate_at_time(conn, node_ids, recorded, as_of.valid.as_deref()).await?
272            }
273        },
274    };
275
276    // Caller order, and absences simply dropped — the signature returns a Vec
277    // rather than a per-id Option, so a node with no visible concept is reported
278    // by being missing. That is what both modes did before.
279    let mut out = Vec::with_capacity(found.len());
280    for id in node_ids {
281        if let Some(attrs) = found.get(id) {
282            out.push(attrs.clone());
283        }
284    }
285    Ok(out)
286}
287
288/// Live attributes under current belief, filtered by retirement *now* and — when
289/// `valid` is given — by the row's own valid interval (W7.1).
290///
291/// The valid-time bound is what `AttributeMode::Current` never had and could not
292/// have: `concepts` carries `valid_from`/`valid_to` and nothing read them, so a
293/// concept whose validity had ended still hydrated into a historical traversal.
294/// Passing `None` is the live read, unchanged, and is what `Current` still does —
295/// that mode's whole meaning is *today's text regardless of the instant*.
296async fn hydrate_current(
297    conn: &libsql::Connection,
298    node_ids: &[String],
299    valid: Option<&str>,
300) -> Result<HashMap<String, NodeAttributes>> {
301    let mut found = HashMap::new();
302
303    for chunk in node_ids.chunks(HYDRATE_CHUNK) {
304        // The ids bind from `?1` when there is no instant and from `?2` when
305        // there is, so the instant can lead and the variadic part can trail.
306        let (first, valid_filter) = match valid {
307            Some(_) => (2, " AND valid_from <= ?1 AND ?1 < valid_to"),
308            None => (1, ""),
309        };
310        let sql = format!(
311            "SELECT id, title, content, embedding_model FROM concepts \
312             WHERE retired = 0{valid_filter} AND id IN ({})",
313            placeholders(first, chunk.len())
314        );
315        let mut params: Vec<libsql::Value> = Vec::with_capacity(chunk.len() + 1);
316        if let Some(v) = valid {
317            params.push(libsql::Value::Text(v.to_string()));
318        }
319        params.extend(chunk.iter().map(|id| libsql::Value::Text(id.clone())));
320
321        let mut rows = conn.query(&sql, params).await?;
322        while let Some(row) = rows.next().await? {
323            let id: String = row.get(0)?;
324            found.insert(
325                id.clone(),
326                NodeAttributes {
327                    id,
328                    title: row.get(1)?,
329                    content: row.get(2)?,
330                    embedding_model: row.get(3).ok(),
331                },
332            );
333        }
334    }
335
336    Ok(found)
337}
338
339/// Attributes as recorded at `ts`, filtered by retirement *at* `ts` and — when
340/// `valid` is given — by the validity the payload itself recorded (W7.1).
341///
342/// **The `valid` arm is the bitemporal cell.** The fold picks the row the ledger
343/// held at `ts`; the payload of that row carries the `valid_from`/`valid_to` the
344/// concept had *at that point in the ledger's belief*, so bounding against those
345/// answers *what did we believe at `recorded` about what was true at `valid`*.
346/// Reading the concept's valid interval from the live `concepts` table instead
347/// would answer something else entirely — today's belief about validity, wearing
348/// the past's title — which is the exact conflation W7.1 exists to end.
349///
350/// **It reads the hot log only, and refuses what the hot log cannot answer**
351/// (0.13.16, W9.1). See the guard at the top of the body: this is the same
352/// refusal [`crate::graph::TraversalBuilder::as_of_recorded`] makes, at the
353/// second surface that folds `transaction_log`.
354///
355/// The window partitions on `entity_id` alone and is sound doing so only because
356/// `table_name = 'concepts'` is already in the `WHERE` — the discriminator is
357/// applied by the filter instead of by the partition, so the concept/link
358/// collision that defect W is about cannot arise here. Stated because the four
359/// folds in `replay.rs` now carry the discriminator in the partition and the
360/// difference should not read as an oversight.
361async fn hydrate_at_time(
362    conn: &libsql::Connection,
363    node_ids: &[String],
364    ts: &str,
365    valid: Option<&str>,
366) -> Result<HashMap<String, NodeAttributes>> {
367    // §3.2, closed in 0.13.16 (W9.1, D-189). The fold below reads the *hot*
368    // log, and `archive` physically moves superseded rows out of it -- which is
369    // precisely the rows a past instant asks for. Without this the answer was a
370    // shorter `Vec`, and a missing element here is indistinguishable from
371    // retired and from never having existed.
372    //
373    // Applied where the read is rather than at whichever caller remembered.
374    // `TraversalBuilder::execute_ids` already checks before its own fold, so
375    // `execute` now pays for two, and that is the right way round: the two
376    // folds are separately reachable, and a guard that lives at the caller is
377    // one the next caller does not inherit.
378    if !crate::temporal::replay::hot_log_answers_for(conn, ts).await? {
379        return Err(DbError::RecordedInstantUnreachable { ts: ts.to_string() });
380    }
381
382    let mut found = HashMap::new();
383
384    for chunk in node_ids.chunks(HYDRATE_CHUNK) {
385        // **`entity_id` alone, and unlike the link folds that is correct here.**
386        //
387        // The sweep that widened the four folds in `replay.rs` to carry
388        // `branch_id` (D-216) and the traversal's own fold at 0.14.4 (D-220)
389        // both left this one alone, so the reason is written down rather than
390        // left as an omission that happens to be safe.
391        //
392        // A link's `entity_id` is the edge key and is shared across lineages by
393        // design — that is how a branch corrects an edge it inherited — so a
394        // partition on it alone puts two lineages' beliefs in one group. A
395        // *concept*'s `entity_id` is the concept id, and under Option A there is
396        // exactly one concept row per id across the whole ledger: the guards
397        // refuse a second lineage restating one at all, and `branch_id` on
398        // `concepts` is provenance rather than identity. One row per id means
399        // one `branch_id` per partition, so adding it would change nothing.
400        //
401        // `table_name = 'concepts'` is in the `WHERE` rather than the partition,
402        // which is the same discriminator applied one step earlier.
403        let sql = format!(
404            r#"
405            SELECT entity_id, seq_id, payload FROM (
406                SELECT entity_id, seq_id, payload,
407                       ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY seq_id DESC) as rn
408                FROM transaction_log
409                WHERE table_name = 'concepts'
410                  AND recorded_at <= ?1
411                  AND entity_id IN ({})
412            ) WHERE rn = 1
413            "#,
414            placeholders(2, chunk.len())
415        );
416
417        let mut params: Vec<libsql::Value> = Vec::with_capacity(chunk.len() + 1);
418        params.push(libsql::Value::Text(ts.to_string()));
419        params.extend(chunk.iter().map(|id| libsql::Value::Text(id.clone())));
420
421        let mut rows = conn.query(&sql, params).await?;
422        while let Some(row) = rows.next().await? {
423            let id: String = row.get(0)?;
424            let seq_id: i64 = row.get(1)?;
425            let payload_str: String = row.get(2)?;
426
427            // Raised rather than skipped. A payload that will not parse is the
428            // ledger disagreeing with itself, and the previous version's
429            // `if let Ok(..)` turned that into a node quietly missing from the
430            // answer — the same shape of silence defect W was.
431            let payload: serde_json::Value =
432                serde_json::from_str(&payload_str).map_err(|e| DbError::ReplayCorrupt {
433                    seq: seq_id,
434                    reason: format!("Failed to parse payload JSON: {e}"),
435                })?;
436
437            let v = payload.get("v").and_then(|v| v.as_u64()).unwrap_or(1);
438            if v > PAYLOAD_VERSION as u64 {
439                return Err(DbError::PayloadVersion {
440                    got: v as u8,
441                    max: PAYLOAD_VERSION,
442                });
443            }
444
445            // Retired as of `ts`: not visible, and not an error either.
446            if payload.get("retired").and_then(|r| r.as_i64()).unwrap_or(0) != 0 {
447                continue;
448            }
449
450            // Outside its own valid interval at the instant asked about. Applied
451            // in Rust rather than in the `WHERE` because the interval lives
452            // inside the JSON payload and the fold has already narrowed to one
453            // row per entity — a `json_extract` in the outer filter would read
454            // the same bytes this arm already has in hand.
455            //
456            // A v1 payload carries no `valid_from`/`valid_to` (they arrived with
457            // v2), and an absent bound is treated as unbounded on that side: the
458            // row is from before the crate recorded validity in the log, and
459            // excluding it would report a gap in the ledger that is really a gap
460            // in the payload schema.
461            if let Some(v) = valid {
462                let from = payload.get("valid_from").and_then(|s| s.as_str());
463                let to = payload.get("valid_to").and_then(|s| s.as_str());
464                if from.is_some_and(|f| f > v) || to.is_some_and(|t| t <= v) {
465                    continue;
466                }
467            }
468
469            found.insert(
470                id.clone(),
471                NodeAttributes {
472                    id,
473                    title: payload
474                        .get("title")
475                        .and_then(|s| s.as_str())
476                        .unwrap_or("")
477                        .to_string(),
478                    content: payload
479                        .get("content")
480                        .and_then(|s| s.as_str())
481                        .unwrap_or("")
482                        .to_string(),
483                    // Absent in a v1 payload, which is indistinguishable here
484                    // from present-and-null and correctly so: both mean the
485                    // concept carries no model.
486                    embedding_model: payload
487                        .get("embedding_model")
488                        .and_then(|s| s.as_str())
489                        .map(|s| s.to_string()),
490                },
491            );
492        }
493    }
494
495    Ok(found)
496}