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