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