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