Skip to main content

kmp_application/memory/
visual_projection.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use kmp_domain::{
4    BundleRelationship, DECLARED_FROM_RELATE_METHOD, DimensionSelection, KmpBundle,
5    MemoryRelationType, RelationSemanticClass, TemporalAxis, TemporalCoordinate, TemporalCursor,
6    TemporalDirection, TemporalWindow,
7};
8use serde::Serialize;
9use sha2::{Digest, Sha256};
10
11use crate::ApplicationError;
12
13use super::{TemporalIncludeOptions, TemporalMemoryQuery, TemporalMemoryResult, VisualLabel};
14
15pub const MAX_VISUAL_SOURCE_ENTRIES: usize = 65_536;
16pub const MAX_VISUAL_PAGE_ENTRIES: usize = 2_048;
17pub const MAX_VISUAL_BINS: usize = 512;
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum VisualLevelOfDetail {
22    #[default]
23    Atlas,
24    Episode,
25    Moment,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct VisualProjectionQuery {
30    pub about: String,
31    pub from: String,
32    pub to: String,
33    pub axis: TemporalAxis,
34    pub dimensions: DimensionSelection,
35    pub level_of_detail: VisualLevelOfDetail,
36    pub bin_count: usize,
37    pub page_entries: usize,
38    pub cursor: Option<String>,
39    pub depth: u32,
40}
41
42impl VisualProjectionQuery {
43    pub fn temporal_query(&self) -> Result<TemporalMemoryQuery, ApplicationError> {
44        Ok(TemporalMemoryQuery {
45            about: self.about.clone(),
46            direction: TemporalDirection::Goto,
47            axis: self.axis,
48            cursor: TemporalCursor::time(self.to.clone())?,
49            dimensions: self.dimensions.clone(),
50            window: TemporalWindow::new(0, 0),
51            limit_entries: Some(MAX_VISUAL_SOURCE_ENTRIES),
52            include: TemporalIncludeOptions {
53                evidence: false,
54                relations: true,
55                raw_refs: false,
56            },
57            token_budget: 262_144,
58            depth: self.depth.max(1),
59            max_tier: None,
60        })
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct VisualRange {
66    pub from: String,
67    pub to: String,
68}
69
70/// One bin of one label: the lane is the key, the row inside it the value,
71/// and a bin counts the entries standing in that pair whose position falls
72/// in its span. A renderer that folds a lane sums its rows.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74pub struct VisualBin {
75    pub dimension: String,
76    pub scope_id: String,
77    pub from: String,
78    pub to: String,
79    pub total: usize,
80    pub by_kind: BTreeMap<String, usize>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84pub struct VisualCluster {
85    pub dimension: String,
86    pub scope_id: String,
87    pub from: String,
88    pub to: String,
89    pub total: usize,
90    pub refs: Vec<String>,
91    pub by_kind: BTreeMap<String, usize>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95pub struct VisualEntry {
96    pub ref_id: String,
97    pub kind: String,
98    pub text: String,
99    pub coordinates: Vec<TemporalCoordinateView>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct TemporalCoordinateView {
104    pub dimension: String,
105    pub scope_id: String,
106    pub occurred_at: Option<String>,
107    pub observed_at: Option<String>,
108    pub ingested_at: Option<String>,
109    pub valid_from: Option<String>,
110    pub valid_until: Option<String>,
111    pub sequence: Option<u32>,
112    pub rank: Option<u32>,
113    /// How the label came to stand on the entry, when it was not the write:
114    /// the method (`kmp_relabel`), the why, and who did it when.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub method: Option<String>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub why: Option<String>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub motivation: Option<String>,
121}
122
123impl From<&TemporalCoordinate> for TemporalCoordinateView {
124    fn from(value: &TemporalCoordinate) -> Self {
125        Self {
126            dimension: value.dimension().to_string(),
127            scope_id: value.scope_id().to_string(),
128            occurred_at: value.occurred_at().map(ToString::to_string),
129            observed_at: value.observed_at().map(ToString::to_string),
130            ingested_at: value.ingested_at().map(ToString::to_string),
131            valid_from: value.valid_from().map(ToString::to_string),
132            valid_until: value.valid_until().map(ToString::to_string),
133            sequence: value.sequence(),
134            rank: value.rank(),
135            method: value.origin().method().map(ToString::to_string),
136            why: value.origin().rationale().map(ToString::to_string),
137            motivation: value.origin().motivation().map(ToString::to_string),
138        }
139    }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct VisualRelation {
144    pub from: String,
145    pub to: String,
146    pub rel: String,
147    pub class: String,
148    pub why: Option<String>,
149    pub evidence: Option<String>,
150    pub confidence: Option<String>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub method: Option<String>,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize)]
156pub struct VisualMetric {
157    pub name: String,
158    pub value: f64,
159    pub unit: String,
160    pub scope: String,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
164pub struct VisualProjectionPage {
165    pub returned: usize,
166    pub total: usize,
167    pub has_more: bool,
168    pub next_cursor: Option<String>,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize)]
172pub struct VisualProjectionResult {
173    pub contract: String,
174    pub about: String,
175    pub axis: TemporalAxisView,
176    pub level_of_detail: VisualLevelOfDetail,
177    pub range: VisualRange,
178    pub bins: Vec<VisualBin>,
179    pub clusters: Vec<VisualCluster>,
180    pub entries: Vec<VisualEntry>,
181    /// Distinct entries by kind across the selected range. Unlike bin and
182    /// cluster aggregates, these totals never count one entry once per lane.
183    pub by_kind: BTreeMap<String, usize>,
184    pub relations: Vec<VisualRelation>,
185    pub metrics: Vec<VisualMetric>,
186    /// Every label the about holds, most used first, each with how many
187    /// entries stand in it across all time and inside this range. Read
188    /// before the read's own filter narrowed the bundle, so an empty label
189    /// is listed as empty rather than left out.
190    pub labels: Vec<VisualLabel>,
191    pub included_dimensions: Vec<String>,
192    pub missing_dimensions: Vec<String>,
193    pub revision: u64,
194    pub content_hash: String,
195    pub page: VisualProjectionPage,
196    pub truncated: bool,
197    pub missing: Vec<String>,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
201#[serde(rename_all = "snake_case")]
202pub enum TemporalAxisView {
203    Default,
204    Occurred,
205    Observed,
206    Ingested,
207    Validity,
208}
209
210impl From<TemporalAxis> for TemporalAxisView {
211    fn from(value: TemporalAxis) -> Self {
212        match value {
213            TemporalAxis::Default => Self::Default,
214            TemporalAxis::Occurred => Self::Occurred,
215            TemporalAxis::Observed => Self::Observed,
216            TemporalAxis::Ingested => Self::Ingested,
217            TemporalAxis::Validity => Self::Validity,
218        }
219    }
220}
221
222#[derive(Debug, Clone)]
223struct PositionedEntry {
224    entry: VisualEntry,
225    position: i128,
226    position_text: String,
227    /// The labels the entry stands in, as (key, namespaced value) pairs.
228    labels: BTreeSet<(String, String)>,
229}
230
231/// Bins, clusters and entries of one range, on one clock, one row per
232/// label. `catalogue` is the about's labels as read before the dimension
233/// filter, so the result lists a label that is empty here as empty.
234pub fn build_visual_projection(
235    query: &VisualProjectionQuery,
236    temporal: TemporalMemoryResult,
237    catalogue: Vec<VisualLabel>,
238) -> Result<VisualProjectionResult, ApplicationError> {
239    let from = timestamp_nanos(&query.from).ok_or_else(|| {
240        ApplicationError::Validation("visual projection `from` is not a timestamp".to_string())
241    })?;
242    let to = timestamp_nanos(&query.to).ok_or_else(|| {
243        ApplicationError::Validation("visual projection `to` is not a timestamp".to_string())
244    })?;
245    if to <= from {
246        return Err(ApplicationError::Validation(
247            "visual projection range requires `to` after `from`".to_string(),
248        ));
249    }
250    let bin_count = query.bin_count.clamp(1, MAX_VISUAL_BINS);
251    let page_entries = query.page_entries.clamp(1, MAX_VISUAL_PAGE_ENTRIES);
252    let revision = temporal.source_bundle.metadata().revision;
253    let content_hash = temporal.source_bundle.metadata().content_hash.clone();
254    let selection_hash = selection_hash(query, revision, &content_hash);
255    let offset = cursor_offset(query.cursor.as_deref(), &selection_hash)?;
256
257    let mut positioned = temporal
258        .traversal
259        .entries()
260        .iter()
261        .filter_map(|entry| {
262            let (position, position_text) = entry
263                .coordinates()
264                .iter()
265                .filter_map(|coordinate| {
266                    let value = axis_time(coordinate, query.axis)?;
267                    Some((timestamp_nanos(value)?, value.to_string()))
268                })
269                .min_by_key(|(position, _)| *position)?;
270            if position < from || position >= to {
271                return None;
272            }
273            let labels = entry
274                .coordinates()
275                .iter()
276                .map(|coordinate| {
277                    (
278                        coordinate.dimension().to_string(),
279                        coordinate.scope_id().to_string(),
280                    )
281                })
282                .collect();
283            Some(PositionedEntry {
284                entry: VisualEntry {
285                    ref_id: entry.ref_id().to_string(),
286                    kind: entry.kind().to_string(),
287                    text: entry.text().to_string(),
288                    coordinates: entry
289                        .coordinates()
290                        .iter()
291                        .map(TemporalCoordinateView::from)
292                        .collect(),
293                },
294                position,
295                position_text,
296                labels,
297            })
298        })
299        .collect::<Vec<_>>();
300    positioned.sort_by(|left, right| {
301        left.position
302            .cmp(&right.position)
303            .then_with(|| left.entry.ref_id.cmp(&right.entry.ref_id))
304    });
305
306    let by_kind = positioned
307        .iter()
308        .fold(BTreeMap::new(), |mut counts, entry| {
309            *counts.entry(entry.entry.kind.clone()).or_default() += 1;
310            counts
311        });
312    let bins = visual_bins(&positioned, from, to, bin_count);
313    let clusters = if query.level_of_detail == VisualLevelOfDetail::Episode {
314        visual_clusters(&positioned, from, to, bin_count)
315    } else {
316        Vec::new()
317    };
318    let total = positioned.len();
319    let end = offset.saturating_add(page_entries).min(total);
320    let entries = if query.level_of_detail == VisualLevelOfDetail::Moment {
321        positioned[offset.min(total)..end]
322            .iter()
323            .map(|entry| entry.entry.clone())
324            .collect()
325    } else {
326        Vec::new()
327    };
328    let page_returned = if query.level_of_detail == VisualLevelOfDetail::Moment {
329        entries.len()
330    } else {
331        total
332    };
333    let has_more = query.level_of_detail == VisualLevelOfDetail::Moment && end < total;
334    let next_cursor = has_more.then(|| format!("kmp-visual-v1:{selection_hash}:{end}"));
335    let page_refs = entries
336        .iter()
337        .map(|entry| entry.ref_id.clone())
338        .collect::<BTreeSet<_>>();
339    let relations = if query.level_of_detail == VisualLevelOfDetail::Moment {
340        visual_relations(&temporal, &page_refs)
341    } else {
342        Vec::new()
343    };
344    let included_dimensions = positioned
345        .iter()
346        .flat_map(|entry| entry.labels.iter().map(|(dimension, _)| dimension.clone()))
347        .collect::<BTreeSet<_>>()
348        .into_iter()
349        .collect::<Vec<_>>();
350    let labels = VisualLabel::counted(
351        catalogue,
352        positioned.iter().flat_map(|entry| entry.labels.iter()),
353    );
354    let causal = causal_relation_count(&relations);
355    let relation_count = relations.len();
356    let source_truncated = temporal.traversal.page().has_more();
357    let missing = if source_truncated {
358        vec!["visual_source_entries".to_string()]
359    } else {
360        Vec::new()
361    };
362
363    Ok(VisualProjectionResult {
364        contract: "kmp.visual.projection.v1".to_string(),
365        about: query.about.clone(),
366        axis: query.axis.into(),
367        level_of_detail: query.level_of_detail,
368        range: VisualRange {
369            from: query.from.clone(),
370            to: query.to.clone(),
371        },
372        bins,
373        clusters,
374        entries,
375        by_kind,
376        relations,
377        labels,
378        metrics: vec![
379            VisualMetric {
380                name: "entry_count".to_string(),
381                value: total as f64,
382                unit: "entries".to_string(),
383                scope: "selected_range".to_string(),
384            },
385            VisualMetric {
386                name: "relation_count".to_string(),
387                value: relation_count as f64,
388                unit: "relations".to_string(),
389                scope: "selected_subgraph".to_string(),
390            },
391            VisualMetric {
392                name: "causal_density".to_string(),
393                value: ratio(causal, relation_count),
394                unit: "ratio".to_string(),
395                scope: "selected_subgraph".to_string(),
396            },
397        ],
398        included_dimensions,
399        missing_dimensions: temporal.traversal.missing_dimensions().to_vec(),
400        revision,
401        content_hash,
402        page: VisualProjectionPage {
403            returned: page_returned,
404            total,
405            has_more,
406            next_cursor,
407        },
408        truncated: source_truncated || has_more,
409        missing,
410    })
411}
412
413fn visual_bins(entries: &[PositionedEntry], from: i128, to: i128, count: usize) -> Vec<VisualBin> {
414    let span = (to - from).max(1);
415    let mut bins = BTreeMap::<(String, String, usize), (usize, BTreeMap<String, usize>)>::new();
416    for entry in entries {
417        let index = (((entry.position - from) * count as i128) / span)
418            .clamp(0, count.saturating_sub(1) as i128) as usize;
419        for (dimension, scope_id) in &entry.labels {
420            let (total, by_kind) = bins
421                .entry((dimension.clone(), scope_id.clone(), index))
422                .or_default();
423            *total += 1;
424            *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
425        }
426    }
427    bins.into_iter()
428        .map(
429            |((dimension, scope_id, index), (total, by_kind))| VisualBin {
430                dimension,
431                scope_id,
432                from: nanos_timestamp(from + span * index as i128 / count as i128),
433                to: nanos_timestamp(from + span * (index + 1) as i128 / count as i128),
434                total,
435                by_kind,
436            },
437        )
438        .collect()
439}
440
441fn visual_clusters(
442    entries: &[PositionedEntry],
443    from: i128,
444    to: i128,
445    count: usize,
446) -> Vec<VisualCluster> {
447    let span = (to - from).max(1);
448    let mut clusters = BTreeMap::<(String, String, usize), Vec<&PositionedEntry>>::new();
449    for entry in entries {
450        let index = (((entry.position - from) * count as i128) / span)
451            .clamp(0, count.saturating_sub(1) as i128) as usize;
452        for (dimension, scope_id) in &entry.labels {
453            clusters
454                .entry((dimension.clone(), scope_id.clone(), index))
455                .or_default()
456                .push(entry);
457        }
458    }
459    clusters
460        .into_iter()
461        .map(|((dimension, scope_id, _), entries)| {
462            let mut by_kind = BTreeMap::new();
463            let mut refs = Vec::new();
464            for entry in &entries {
465                *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
466                refs.push(entry.entry.ref_id.clone());
467            }
468            VisualCluster {
469                dimension,
470                scope_id,
471                from: entries
472                    .first()
473                    .map(|entry| entry.position_text.clone())
474                    .unwrap_or_default(),
475                to: entries
476                    .last()
477                    .map(|entry| entry.position_text.clone())
478                    .unwrap_or_default(),
479                total: entries.len(),
480                refs,
481                by_kind,
482            }
483        })
484        .collect()
485}
486
487fn visual_relations(
488    temporal: &TemporalMemoryResult,
489    refs: &BTreeSet<String>,
490) -> Vec<VisualRelation> {
491    temporal
492        .source_bundle
493        .relationships()
494        .iter()
495        .filter(|relation| {
496            relation.explanation().semantic_class()
497                != &kmp_domain::RelationSemanticClass::Structural
498                && ((refs.contains(relation.source_node_id())
499                    && refs.contains(relation.target_node_id()))
500                    || (relation.relationship_type() == "supports"
501                        && refs.contains(relation.target_node_id())))
502        })
503        .map(visual_relation)
504        .collect()
505}
506
507fn visual_relation(relation: &BundleRelationship) -> VisualRelation {
508    VisualRelation {
509        from: relation.source_node_id().to_string(),
510        to: relation.target_node_id().to_string(),
511        rel: relation.relationship_type().to_string(),
512        class: relation.explanation().semantic_class().as_str().to_string(),
513        why: relation.explanation().rationale().map(ToString::to_string),
514        evidence: relation.explanation().evidence().map(ToString::to_string),
515        confidence: relation.explanation().confidence().map(ToString::to_string),
516        method: relation.explanation().method().map(ToString::to_string),
517    }
518}
519
520/// Keep the writer's stored declaration before dimension filtering drops
521/// its foreign endpoint. This does not fetch or admit that endpoint.
522pub(super) fn declared_equivalences(bundle: &KmpBundle) -> Vec<VisualRelation> {
523    bundle
524        .relationships()
525        .iter()
526        .filter(|relation| {
527            let proof = relation.explanation();
528            MemoryRelationType::new(relation.relationship_type())
529                .is_ok_and(|kind| kind.may_cross_abouts())
530                && proof.semantic_class() == &RelationSemanticClass::Evidential
531                && proof
532                    .method()
533                    .is_some_and(|method| method.starts_with(DECLARED_FROM_RELATE_METHOD))
534                && proof.rationale().is_some_and(|why| !why.trim().is_empty())
535                && proof
536                    .evidence()
537                    .is_some_and(|evidence| !evidence.trim().is_empty())
538        })
539        .map(visual_relation)
540        .collect()
541}
542
543pub(super) fn include_owned_declarations(
544    projection: &mut VisualProjectionResult,
545    declarations: Vec<VisualRelation>,
546) {
547    let refs: BTreeSet<_> = projection
548        .entries
549        .iter()
550        .map(|entry| entry.ref_id.as_str())
551        .collect();
552    let mut keys: BTreeSet<_> = projection
553        .relations
554        .iter()
555        .map(|edge| (edge.from.clone(), edge.rel.clone(), edge.to.clone()))
556        .collect();
557    for relation in declarations {
558        if refs.contains(relation.from.as_str())
559            && keys.insert((
560                relation.from.clone(),
561                relation.rel.clone(),
562                relation.to.clone(),
563            ))
564        {
565            projection.relations.push(relation);
566        }
567    }
568    for metric in &mut projection.metrics {
569        match metric.name.as_str() {
570            "relation_count" => metric.value = projection.relations.len() as f64,
571            "causal_density" => {
572                metric.value = ratio(
573                    causal_relation_count(&projection.relations),
574                    projection.relations.len(),
575                )
576            }
577            _ => {}
578        }
579    }
580}
581
582fn axis_time(coordinate: &TemporalCoordinate, axis: TemporalAxis) -> Option<&str> {
583    match axis {
584        TemporalAxis::Default => coordinate
585            .occurred_at()
586            .or(coordinate.valid_from())
587            .or(coordinate.observed_at())
588            .or(coordinate.ingested_at()),
589        TemporalAxis::Occurred => coordinate.occurred_at(),
590        TemporalAxis::Observed => coordinate.observed_at(),
591        TemporalAxis::Ingested => coordinate.ingested_at(),
592        TemporalAxis::Validity => coordinate.valid_from().or(coordinate.valid_until()),
593    }
594}
595
596fn selection_hash(query: &VisualProjectionQuery, revision: u64, content_hash: &str) -> String {
597    let mut hasher = Sha256::new();
598    hasher.update(query.about.as_bytes());
599    hasher.update(query.from.as_bytes());
600    hasher.update(query.to.as_bytes());
601    hasher.update(format!("{:?}", query.axis).as_bytes());
602    hasher.update(format!("{:?}", query.dimensions).as_bytes());
603    hasher.update(format!("{:?}", query.level_of_detail).as_bytes());
604    hasher.update(query.bin_count.to_le_bytes());
605    hasher.update(revision.to_le_bytes());
606    hasher.update(content_hash.as_bytes());
607    format!("{:x}", hasher.finalize())[..24].to_string()
608}
609
610fn cursor_offset(cursor: Option<&str>, selection_hash: &str) -> Result<usize, ApplicationError> {
611    let Some(cursor) = cursor else {
612        return Ok(0);
613    };
614    let mut parts = cursor.split(':');
615    let valid = parts.next() == Some("kmp-visual-v1")
616        && parts.next() == Some(selection_hash)
617        && parts.clone().count() == 1;
618    let offset = parts.next().and_then(|value| value.parse::<usize>().ok());
619    if !valid || offset.is_none() {
620        return Err(ApplicationError::Validation(
621            "visual projection cursor is malformed or belongs to another selection".to_string(),
622        ));
623    }
624    Ok(offset.unwrap_or_default())
625}
626
627fn ratio(numerator: usize, denominator: usize) -> f64 {
628    if denominator == 0 {
629        0.0
630    } else {
631        numerator as f64 / denominator as f64
632    }
633}
634
635fn causal_relation_count(relations: &[VisualRelation]) -> usize {
636    relations
637        .iter()
638        .filter(|relation| relation.class == "causal")
639        .count()
640}
641
642fn timestamp_nanos(value: &str) -> Option<i128> {
643    if let Some(value) = value.strip_prefix("unix:") {
644        let (seconds, nanos) = value.split_once(':')?;
645        let seconds = seconds.parse::<i128>().ok()? - 100_000_000_000i128;
646        let nanos = nanos.parse::<i128>().ok()?;
647        return Some(seconds * 1_000_000_000 + nanos);
648    }
649    basic_rfc3339_nanos(value)
650}
651
652fn nanos_timestamp(value: i128) -> String {
653    let seconds = value.div_euclid(1_000_000_000);
654    let nanos = value.rem_euclid(1_000_000_000);
655    format!("unix:{:012}:{:09}", seconds + 100_000_000_000i128, nanos)
656}
657
658fn basic_rfc3339_nanos(value: &str) -> Option<i128> {
659    let value = value.trim();
660    if value.len() < 20 {
661        return None;
662    }
663    let number = |from: usize, to: usize| -> Option<i64> { value.get(from..to)?.parse().ok() };
664    let year = number(0, 4)?;
665    let month = number(5, 7)?;
666    let day = number(8, 10)?;
667    let hour = number(11, 13)?;
668    let minute = number(14, 16)?;
669    let second = number(17, 19)?;
670    if value.get(4..5)? != "-"
671        || value.get(7..8)? != "-"
672        || value.get(10..11)? != "T"
673        || value.get(13..14)? != ":"
674        || value.get(16..17)? != ":"
675    {
676        return None;
677    }
678    let tail = value.get(19..)?;
679    let timezone_start = tail
680        .char_indices()
681        .find_map(|(index, character)| matches!(character, 'Z' | '+' | '-').then_some(index))?;
682    let fraction = tail.get(..timezone_start)?;
683    let timezone = tail.get(timezone_start..)?;
684    let nanos = match fraction.strip_prefix('.') {
685        Some(digits) if !digits.is_empty() && digits.len() <= 9 => {
686            let padded = format!("{digits:0<9}");
687            padded.parse::<i128>().ok()?
688        }
689        None if fraction.is_empty() => 0,
690        _ => return None,
691    };
692    let offset_seconds = match timezone {
693        "Z" => 0,
694        offset if offset.len() == 6 && offset.get(3..4) == Some(":") => {
695            let sign = match offset.get(..1)? {
696                "+" => 1,
697                "-" => -1,
698                _ => return None,
699            };
700            let hours = offset.get(1..3)?.parse::<i64>().ok()?;
701            let minutes = offset.get(4..6)?.parse::<i64>().ok()?;
702            if hours > 23 || minutes > 59 {
703                return None;
704            }
705            sign * (hours * 3_600 + minutes * 60)
706        }
707        _ => return None,
708    };
709    let seconds = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second
710        - offset_seconds;
711    Some(seconds as i128 * 1_000_000_000 + nanos)
712}
713
714fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
715    let year = if month <= 2 { year - 1 } else { year };
716    let era = if year >= 0 { year } else { year - 399 } / 400;
717    let year_of_era = year - era * 400;
718    let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
719    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
720    era * 146_097 + day_of_era - 719_468
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    #[test]
728    fn sortable_and_rfc3339_timestamps_share_one_coordinate_space() {
729        assert_eq!(
730            timestamp_nanos("2026-08-27T12:00:00Z"),
731            timestamp_nanos("unix:101787832000:000000000")
732        );
733        assert_eq!(
734            timestamp_nanos("2026-08-27T14:00:00.125+02:00"),
735            timestamp_nanos("unix:101787832000:125000000")
736        );
737    }
738
739    #[test]
740    fn projection_cursor_is_bound_to_its_selection() {
741        assert_eq!(cursor_offset(None, "abc").expect("first page"), 0);
742        assert_eq!(
743            cursor_offset(Some("kmp-visual-v1:abc:12"), "abc").expect("same selection"),
744            12
745        );
746        assert!(cursor_offset(Some("kmp-visual-v1:def:12"), "abc").is_err());
747    }
748
749    #[test]
750    fn visual_causal_density_does_not_count_other_explanatory_classes() {
751        let relation = |class: &str| VisualRelation {
752            from: "a".to_string(),
753            to: "b".to_string(),
754            rel: "rel".to_string(),
755            class: class.to_string(),
756            why: None,
757            evidence: None,
758            confidence: None,
759            method: None,
760        };
761        let relations = [
762            relation("causal"),
763            relation("evidential"),
764            relation("motivational"),
765        ];
766
767        assert_eq!(causal_relation_count(&relations), 1);
768    }
769}