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    #[serde(skip_serializing_if = "Option::is_none")]
148    pub clocks: Option<super::MemoryRelationClocks>,
149    pub from: String,
150    pub to: String,
151    pub rel: String,
152    pub class: String,
153    pub why: Option<String>,
154    pub evidence: Option<String>,
155    pub confidence: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub method: Option<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize)]
161pub struct VisualMetric {
162    pub name: String,
163    pub value: f64,
164    pub unit: String,
165    pub scope: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
169pub struct VisualProjectionPage {
170    pub returned: usize,
171    pub total: usize,
172    pub has_more: bool,
173    pub next_cursor: Option<String>,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize)]
177pub struct VisualProjectionResult {
178    pub contract: String,
179    pub about: String,
180    pub axis: TemporalAxisView,
181    pub level_of_detail: VisualLevelOfDetail,
182    pub range: VisualRange,
183    pub bins: Vec<VisualBin>,
184    pub clusters: Vec<VisualCluster>,
185    pub entries: Vec<VisualEntry>,
186    /// Distinct entries by kind across the selected range. Unlike bin and
187    /// cluster aggregates, these totals never count one entry once per lane.
188    pub by_kind: BTreeMap<String, usize>,
189    pub relations: Vec<VisualRelation>,
190    pub metrics: Vec<VisualMetric>,
191    /// Every label the about holds, most used first, each with how many
192    /// entries stand in it across all time and inside this range. Read
193    /// before the read's own filter narrowed the bundle, so an empty label
194    /// is listed as empty rather than left out.
195    pub labels: Vec<VisualLabel>,
196    pub included_dimensions: Vec<String>,
197    pub missing_dimensions: Vec<String>,
198    pub revision: u64,
199    pub content_hash: String,
200    pub page: VisualProjectionPage,
201    pub truncated: bool,
202    pub missing: Vec<String>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
206#[serde(rename_all = "snake_case")]
207pub enum TemporalAxisView {
208    Default,
209    Occurred,
210    Observed,
211    Ingested,
212    Validity,
213}
214
215impl From<TemporalAxis> for TemporalAxisView {
216    fn from(value: TemporalAxis) -> Self {
217        match value {
218            TemporalAxis::Default => Self::Default,
219            TemporalAxis::Occurred => Self::Occurred,
220            TemporalAxis::Observed => Self::Observed,
221            TemporalAxis::Ingested => Self::Ingested,
222            TemporalAxis::Validity => Self::Validity,
223        }
224    }
225}
226
227#[derive(Debug, Clone)]
228struct PositionedEntry {
229    entry: VisualEntry,
230    position: i128,
231    position_text: String,
232    /// The labels the entry stands in, as (key, namespaced value) pairs.
233    labels: BTreeSet<(String, String)>,
234}
235
236/// Bins, clusters and entries of one range, on one clock, one row per
237/// label. `catalogue` is the about's labels as read before the dimension
238/// filter, so the result lists a label that is empty here as empty.
239pub fn build_visual_projection(
240    query: &VisualProjectionQuery,
241    temporal: TemporalMemoryResult,
242    catalogue: Vec<VisualLabel>,
243) -> Result<VisualProjectionResult, ApplicationError> {
244    let from = timestamp_nanos(&query.from).ok_or_else(|| {
245        ApplicationError::Validation("visual projection `from` is not a timestamp".to_string())
246    })?;
247    let to = timestamp_nanos(&query.to).ok_or_else(|| {
248        ApplicationError::Validation("visual projection `to` is not a timestamp".to_string())
249    })?;
250    if to <= from {
251        return Err(ApplicationError::Validation(
252            "visual projection range requires `to` after `from`".to_string(),
253        ));
254    }
255    let bin_count = query.bin_count.clamp(1, MAX_VISUAL_BINS);
256    let page_entries = query.page_entries.clamp(1, MAX_VISUAL_PAGE_ENTRIES);
257    let revision = temporal.source_bundle.metadata().revision;
258    let content_hash = temporal.source_bundle.metadata().content_hash.clone();
259    let selection_hash = selection_hash(query, revision, &content_hash);
260    let offset = cursor_offset(query.cursor.as_deref(), &selection_hash)?;
261
262    let missing_axis_entries = missing_axis_entries(&temporal, query);
263    let mut positioned = temporal
264        .traversal
265        .entries()
266        .iter()
267        .filter_map(|entry| {
268            let (position, position_text) = entry
269                .coordinates()
270                .iter()
271                .filter_map(|coordinate| {
272                    let value = axis_time(coordinate, query.axis)?;
273                    Some((timestamp_nanos(value)?, value.to_string()))
274                })
275                .min_by_key(|(position, _)| *position)?;
276            if position < from || position >= to {
277                return None;
278            }
279            let labels = entry
280                .coordinates()
281                .iter()
282                .map(|coordinate| {
283                    (
284                        coordinate.dimension().to_string(),
285                        coordinate.scope_id().to_string(),
286                    )
287                })
288                .collect();
289            Some(PositionedEntry {
290                entry: VisualEntry {
291                    ref_id: entry.ref_id().to_string(),
292                    kind: entry.kind().to_string(),
293                    text: entry.text().to_string(),
294                    coordinates: entry
295                        .coordinates()
296                        .iter()
297                        .map(TemporalCoordinateView::from)
298                        .collect(),
299                },
300                position,
301                position_text,
302                labels,
303            })
304        })
305        .collect::<Vec<_>>();
306    positioned.sort_by(|left, right| {
307        left.position
308            .cmp(&right.position)
309            .then_with(|| left.entry.ref_id.cmp(&right.entry.ref_id))
310    });
311
312    let by_kind = positioned
313        .iter()
314        .fold(BTreeMap::new(), |mut counts, entry| {
315            *counts.entry(entry.entry.kind.clone()).or_default() += 1;
316            counts
317        });
318    let bins = visual_bins(&positioned, from, to, bin_count);
319    let clusters = if query.level_of_detail == VisualLevelOfDetail::Episode {
320        visual_clusters(&positioned, from, to, bin_count)
321    } else {
322        Vec::new()
323    };
324    let total = positioned.len();
325    let end = offset.saturating_add(page_entries).min(total);
326    let entries = if query.level_of_detail == VisualLevelOfDetail::Moment {
327        positioned[offset.min(total)..end]
328            .iter()
329            .map(|entry| entry.entry.clone())
330            .collect()
331    } else {
332        Vec::new()
333    };
334    let page_returned = if query.level_of_detail == VisualLevelOfDetail::Moment {
335        entries.len()
336    } else {
337        total
338    };
339    let has_more = query.level_of_detail == VisualLevelOfDetail::Moment && end < total;
340    let next_cursor = has_more.then(|| format!("kmp-visual-v1:{selection_hash}:{end}"));
341    let page_refs = entries
342        .iter()
343        .map(|entry| entry.ref_id.clone())
344        .collect::<BTreeSet<_>>();
345    let relations = if query.level_of_detail == VisualLevelOfDetail::Moment {
346        visual_relations(&temporal, &page_refs)
347    } else {
348        Vec::new()
349    };
350    let included_dimensions = positioned
351        .iter()
352        .flat_map(|entry| entry.labels.iter().map(|(dimension, _)| dimension.clone()))
353        .collect::<BTreeSet<_>>()
354        .into_iter()
355        .collect::<Vec<_>>();
356    let labels = VisualLabel::counted(
357        catalogue,
358        positioned.iter().flat_map(|entry| entry.labels.iter()),
359    );
360    let causal = causal_relation_count(&relations);
361    let relation_count = relations.len();
362    let source_truncated = temporal.traversal.page().has_more();
363    let missing = visual_missing(source_truncated, missing_axis_entries);
364
365    Ok(VisualProjectionResult {
366        contract: "kmp.visual.projection.v1".to_string(),
367        about: query.about.clone(),
368        axis: query.axis.into(),
369        level_of_detail: query.level_of_detail,
370        range: VisualRange {
371            from: query.from.clone(),
372            to: query.to.clone(),
373        },
374        bins,
375        clusters,
376        entries,
377        by_kind,
378        relations,
379        labels,
380        metrics: vec![
381            VisualMetric {
382                name: "entry_count".to_string(),
383                value: total as f64,
384                unit: "entries".to_string(),
385                scope: "selected_range".to_string(),
386            },
387            VisualMetric {
388                name: "missing_axis_entries".to_string(),
389                value: missing_axis_entries as f64,
390                unit: "entries".to_string(),
391                scope: "selected_source".to_string(),
392            },
393            VisualMetric {
394                name: "relation_count".to_string(),
395                value: relation_count as f64,
396                unit: "relations".to_string(),
397                scope: "selected_subgraph".to_string(),
398            },
399            VisualMetric {
400                name: "causal_density".to_string(),
401                value: ratio(causal, relation_count),
402                unit: "ratio".to_string(),
403                scope: "selected_subgraph".to_string(),
404            },
405        ],
406        included_dimensions,
407        missing_dimensions: temporal.traversal.missing_dimensions().to_vec(),
408        revision,
409        content_hash,
410        page: VisualProjectionPage {
411            returned: page_returned,
412            total,
413            has_more,
414            next_cursor,
415        },
416        truncated: source_truncated || has_more,
417        missing,
418    })
419}
420
421fn missing_axis_entry(coordinates: &[TemporalCoordinate], axis: TemporalAxis) -> bool {
422    !coordinates
423        .iter()
424        .any(|coordinate| axis_time(coordinate, axis).is_some())
425}
426
427fn missing_axis_entries(temporal: &TemporalMemoryResult, query: &VisualProjectionQuery) -> usize {
428    let mut candidates = BTreeSet::new();
429    let mut coordinates = BTreeMap::<String, Vec<TemporalCoordinate>>::new();
430    for relationship in temporal.source_bundle.relationships() {
431        if relationship.relationship_type() != "contains_entry" {
432            continue;
433        }
434        let explanation = relationship.explanation();
435        if !query.dimensions.includes_coordinate(
436            explanation.dimension().unwrap_or_default(),
437            explanation.scope_id().unwrap_or_default(),
438        ) {
439            continue;
440        }
441        candidates.insert(relationship.target_node_id().to_string());
442        if let Ok(Some(coordinate)) = TemporalCoordinate::from_relation_explanation(explanation) {
443            coordinates
444                .entry(relationship.target_node_id().to_string())
445                .or_default()
446                .push(coordinate);
447        }
448    }
449    candidates
450        .into_iter()
451        .filter(|ref_id| {
452            missing_axis_entry(
453                coordinates.get(ref_id).map_or(&[], Vec::as_slice),
454                query.axis,
455            )
456        })
457        .count()
458}
459
460fn visual_missing(source_truncated: bool, missing_axis_entries: usize) -> Vec<String> {
461    let mut missing = Vec::new();
462    if source_truncated {
463        missing.push("visual_source_entries".to_string());
464    }
465    if missing_axis_entries > 0 {
466        missing.push("temporal_positions".to_string());
467    }
468    missing
469}
470
471fn visual_bins(entries: &[PositionedEntry], from: i128, to: i128, count: usize) -> Vec<VisualBin> {
472    let span = (to - from).max(1);
473    let mut bins = BTreeMap::<(String, String, usize), (usize, BTreeMap<String, usize>)>::new();
474    for entry in entries {
475        let index = (((entry.position - from) * count as i128) / span)
476            .clamp(0, count.saturating_sub(1) as i128) as usize;
477        for (dimension, scope_id) in &entry.labels {
478            let (total, by_kind) = bins
479                .entry((dimension.clone(), scope_id.clone(), index))
480                .or_default();
481            *total += 1;
482            *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
483        }
484    }
485    bins.into_iter()
486        .map(
487            |((dimension, scope_id, index), (total, by_kind))| VisualBin {
488                dimension,
489                scope_id,
490                from: nanos_timestamp(from + span * index as i128 / count as i128),
491                to: nanos_timestamp(from + span * (index + 1) as i128 / count as i128),
492                total,
493                by_kind,
494            },
495        )
496        .collect()
497}
498
499fn visual_clusters(
500    entries: &[PositionedEntry],
501    from: i128,
502    to: i128,
503    count: usize,
504) -> Vec<VisualCluster> {
505    let span = (to - from).max(1);
506    let mut clusters = BTreeMap::<(String, String, usize), Vec<&PositionedEntry>>::new();
507    for entry in entries {
508        let index = (((entry.position - from) * count as i128) / span)
509            .clamp(0, count.saturating_sub(1) as i128) as usize;
510        for (dimension, scope_id) in &entry.labels {
511            clusters
512                .entry((dimension.clone(), scope_id.clone(), index))
513                .or_default()
514                .push(entry);
515        }
516    }
517    clusters
518        .into_iter()
519        .map(|((dimension, scope_id, _), entries)| {
520            let mut by_kind = BTreeMap::new();
521            let mut refs = Vec::new();
522            for entry in &entries {
523                *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
524                refs.push(entry.entry.ref_id.clone());
525            }
526            VisualCluster {
527                dimension,
528                scope_id,
529                from: entries
530                    .first()
531                    .map(|entry| entry.position_text.clone())
532                    .unwrap_or_default(),
533                to: entries
534                    .last()
535                    .map(|entry| entry.position_text.clone())
536                    .unwrap_or_default(),
537                total: entries.len(),
538                refs,
539                by_kind,
540            }
541        })
542        .collect()
543}
544
545fn visual_relations(
546    temporal: &TemporalMemoryResult,
547    refs: &BTreeSet<String>,
548) -> Vec<VisualRelation> {
549    temporal
550        .source_bundle
551        .relationships()
552        .iter()
553        .filter(|relation| {
554            relation.explanation().semantic_class()
555                != &kmp_domain::RelationSemanticClass::Structural
556                && ((refs.contains(relation.source_node_id())
557                    && refs.contains(relation.target_node_id()))
558                    || (relation.relationship_type() == "supports"
559                        && refs.contains(relation.target_node_id())))
560        })
561        .map(visual_relation)
562        .collect()
563}
564
565fn visual_relation(relation: &BundleRelationship) -> VisualRelation {
566    let explanation = relation.explanation();
567    let clocks = super::MemoryRelationClocks {
568        occurred_at: explanation.occurred_at().map(ToString::to_string),
569        observed_at: explanation.observed_at().map(ToString::to_string),
570        ingested_at: explanation.ingested_at().map(ToString::to_string),
571        valid_from: explanation.valid_from().map(ToString::to_string),
572        valid_until: explanation.valid_until().map(ToString::to_string),
573    };
574    VisualRelation {
575        clocks: (clocks != Default::default()).then_some(clocks),
576        from: relation.source_node_id().to_string(),
577        to: relation.target_node_id().to_string(),
578        rel: relation.relationship_type().to_string(),
579        class: relation.explanation().semantic_class().as_str().to_string(),
580        why: relation.explanation().rationale().map(ToString::to_string),
581        evidence: relation.explanation().evidence().map(ToString::to_string),
582        confidence: relation.explanation().confidence().map(ToString::to_string),
583        method: relation.explanation().method().map(ToString::to_string),
584    }
585}
586
587/// Keep the writer's stored declaration before dimension filtering drops
588/// its foreign endpoint. This does not fetch or admit that endpoint.
589pub(super) fn declared_equivalences(bundle: &KmpBundle) -> Vec<VisualRelation> {
590    bundle
591        .relationships()
592        .iter()
593        .filter(|relation| {
594            let proof = relation.explanation();
595            MemoryRelationType::new(relation.relationship_type())
596                .is_ok_and(|kind| kind.may_cross_abouts())
597                && proof.semantic_class() == &RelationSemanticClass::Evidential
598                && proof
599                    .method()
600                    .is_some_and(|method| method.starts_with(DECLARED_FROM_RELATE_METHOD))
601                && proof.rationale().is_some_and(|why| !why.trim().is_empty())
602                && proof
603                    .evidence()
604                    .is_some_and(|evidence| !evidence.trim().is_empty())
605        })
606        .map(visual_relation)
607        .collect()
608}
609
610pub(super) fn include_owned_declarations(
611    projection: &mut VisualProjectionResult,
612    declarations: Vec<VisualRelation>,
613) {
614    let refs: BTreeSet<_> = projection
615        .entries
616        .iter()
617        .map(|entry| entry.ref_id.as_str())
618        .collect();
619    let mut keys: BTreeSet<_> = projection
620        .relations
621        .iter()
622        .map(|edge| (edge.from.clone(), edge.rel.clone(), edge.to.clone()))
623        .collect();
624    for relation in declarations {
625        if refs.contains(relation.from.as_str())
626            && keys.insert((
627                relation.from.clone(),
628                relation.rel.clone(),
629                relation.to.clone(),
630            ))
631        {
632            projection.relations.push(relation);
633        }
634    }
635    for metric in &mut projection.metrics {
636        match metric.name.as_str() {
637            "relation_count" => metric.value = projection.relations.len() as f64,
638            "causal_density" => {
639                metric.value = ratio(
640                    causal_relation_count(&projection.relations),
641                    projection.relations.len(),
642                )
643            }
644            _ => {}
645        }
646    }
647}
648
649fn axis_time(coordinate: &TemporalCoordinate, axis: TemporalAxis) -> Option<&str> {
650    match axis {
651        TemporalAxis::Default => coordinate
652            .occurred_at()
653            .or(coordinate.valid_from())
654            .or(coordinate.observed_at())
655            .or(coordinate.ingested_at()),
656        TemporalAxis::Occurred => coordinate.occurred_at(),
657        TemporalAxis::Observed => coordinate.observed_at(),
658        TemporalAxis::Ingested => coordinate.ingested_at(),
659        TemporalAxis::Validity => coordinate.valid_from().or(coordinate.valid_until()),
660    }
661}
662
663fn selection_hash(query: &VisualProjectionQuery, revision: u64, content_hash: &str) -> String {
664    let mut hasher = Sha256::new();
665    hasher.update(query.about.as_bytes());
666    hasher.update(query.from.as_bytes());
667    hasher.update(query.to.as_bytes());
668    hasher.update(format!("{:?}", query.axis).as_bytes());
669    hasher.update(format!("{:?}", query.dimensions).as_bytes());
670    hasher.update(format!("{:?}", query.level_of_detail).as_bytes());
671    hasher.update(query.bin_count.to_le_bytes());
672    hasher.update(revision.to_le_bytes());
673    hasher.update(content_hash.as_bytes());
674    format!("{:x}", hasher.finalize())[..24].to_string()
675}
676
677fn cursor_offset(cursor: Option<&str>, selection_hash: &str) -> Result<usize, ApplicationError> {
678    let Some(cursor) = cursor else {
679        return Ok(0);
680    };
681    let mut parts = cursor.split(':');
682    let valid = parts.next() == Some("kmp-visual-v1")
683        && parts.next() == Some(selection_hash)
684        && parts.clone().count() == 1;
685    let offset = parts.next().and_then(|value| value.parse::<usize>().ok());
686    if !valid || offset.is_none() {
687        return Err(ApplicationError::Validation(
688            "visual projection cursor is malformed or belongs to another selection".to_string(),
689        ));
690    }
691    Ok(offset.unwrap_or_default())
692}
693
694fn ratio(numerator: usize, denominator: usize) -> f64 {
695    if denominator == 0 {
696        0.0
697    } else {
698        numerator as f64 / denominator as f64
699    }
700}
701
702fn causal_relation_count(relations: &[VisualRelation]) -> usize {
703    relations
704        .iter()
705        .filter(|relation| relation.class == "causal")
706        .count()
707}
708
709fn timestamp_nanos(value: &str) -> Option<i128> {
710    if let Some(value) = value.strip_prefix("unix:") {
711        let (seconds, nanos) = value.split_once(':')?;
712        let seconds = seconds.parse::<i128>().ok()? - 100_000_000_000i128;
713        let nanos = nanos.parse::<i128>().ok()?;
714        return Some(seconds * 1_000_000_000 + nanos);
715    }
716    basic_rfc3339_nanos(value)
717}
718
719fn nanos_timestamp(value: i128) -> String {
720    let seconds = value.div_euclid(1_000_000_000);
721    let nanos = value.rem_euclid(1_000_000_000);
722    format!("unix:{:012}:{:09}", seconds + 100_000_000_000i128, nanos)
723}
724
725fn basic_rfc3339_nanos(value: &str) -> Option<i128> {
726    let value = value.trim();
727    if value.len() < 20 {
728        return None;
729    }
730    let number = |from: usize, to: usize| -> Option<i64> { value.get(from..to)?.parse().ok() };
731    let year = number(0, 4)?;
732    let month = number(5, 7)?;
733    let day = number(8, 10)?;
734    let hour = number(11, 13)?;
735    let minute = number(14, 16)?;
736    let second = number(17, 19)?;
737    if value.get(4..5)? != "-"
738        || value.get(7..8)? != "-"
739        || value.get(10..11)? != "T"
740        || value.get(13..14)? != ":"
741        || value.get(16..17)? != ":"
742    {
743        return None;
744    }
745    let tail = value.get(19..)?;
746    let timezone_start = tail
747        .char_indices()
748        .find_map(|(index, character)| matches!(character, 'Z' | '+' | '-').then_some(index))?;
749    let fraction = tail.get(..timezone_start)?;
750    let timezone = tail.get(timezone_start..)?;
751    let nanos = match fraction.strip_prefix('.') {
752        Some(digits) if !digits.is_empty() && digits.len() <= 9 => {
753            let padded = format!("{digits:0<9}");
754            padded.parse::<i128>().ok()?
755        }
756        None if fraction.is_empty() => 0,
757        _ => return None,
758    };
759    let offset_seconds = match timezone {
760        "Z" => 0,
761        offset if offset.len() == 6 && offset.get(3..4) == Some(":") => {
762            let sign = match offset.get(..1)? {
763                "+" => 1,
764                "-" => -1,
765                _ => return None,
766            };
767            let hours = offset.get(1..3)?.parse::<i64>().ok()?;
768            let minutes = offset.get(4..6)?.parse::<i64>().ok()?;
769            if hours > 23 || minutes > 59 {
770                return None;
771            }
772            sign * (hours * 3_600 + minutes * 60)
773        }
774        _ => return None,
775    };
776    let seconds = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second
777        - offset_seconds;
778    Some(seconds as i128 * 1_000_000_000 + nanos)
779}
780
781fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
782    let year = if month <= 2 { year - 1 } else { year };
783    let era = if year >= 0 { year } else { year - 399 } / 400;
784    let year_of_era = year - era * 400;
785    let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
786    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
787    era * 146_097 + day_of_era - 719_468
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use kmp_domain::{
794        BundleMetadata, BundleNode, BundleRelationship, CaseId, KmpBundle, RelationExplanation,
795        RelationSemanticClass, Role, TemporalMemoryTraversal, TemporalTraversalRequest,
796        TemporalWindow,
797    };
798    use std::collections::BTreeMap;
799
800    #[test]
801    fn sortable_and_rfc3339_timestamps_share_one_coordinate_space() {
802        assert_eq!(
803            timestamp_nanos("2026-08-27T12:00:00Z"),
804            timestamp_nanos("unix:101787832000:000000000")
805        );
806        assert_eq!(
807            timestamp_nanos("2026-08-27T14:00:00.125+02:00"),
808            timestamp_nanos("unix:101787832000:125000000")
809        );
810    }
811
812    #[test]
813    fn projection_cursor_is_bound_to_its_selection() {
814        assert_eq!(cursor_offset(None, "abc").expect("first page"), 0);
815        assert_eq!(
816            cursor_offset(Some("kmp-visual-v1:abc:12"), "abc").expect("same selection"),
817            12
818        );
819        assert!(cursor_offset(Some("kmp-visual-v1:def:12"), "abc").is_err());
820    }
821
822    #[test]
823    fn visual_causal_density_does_not_count_other_explanatory_classes() {
824        let relation = |class: &str| VisualRelation {
825            clocks: None,
826            from: "a".to_string(),
827            to: "b".to_string(),
828            rel: "rel".to_string(),
829            class: class.to_string(),
830            why: None,
831            evidence: None,
832            confidence: None,
833            method: None,
834        };
835        let relations = [
836            relation("causal"),
837            relation("evidential"),
838            relation("motivational"),
839        ];
840
841        assert_eq!(causal_relation_count(&relations), 1);
842    }
843
844    #[test]
845    fn missing_axis_entry_is_reported_without_using_another_clock() {
846        let observed_only =
847            TemporalCoordinate::cursor_time("2026-08-27T12:00:00Z", TemporalAxis::Observed)
848                .expect("coordinate");
849        let occurred =
850            TemporalCoordinate::cursor_time("2026-08-27T12:00:00Z", TemporalAxis::Occurred)
851                .expect("coordinate");
852
853        assert!(missing_axis_entry(
854            std::slice::from_ref(&observed_only),
855            TemporalAxis::Occurred
856        ));
857        assert!(!missing_axis_entry(
858            std::slice::from_ref(&observed_only),
859            TemporalAxis::Observed
860        ));
861        assert!(!missing_axis_entry(
862            &[observed_only, occurred],
863            TemporalAxis::Occurred
864        ));
865    }
866
867    #[test]
868    fn visual_missing_keeps_source_truncation_and_axis_gap_distinct() {
869        assert_eq!(
870            visual_missing(true, 2),
871            vec!["visual_source_entries", "temporal_positions"]
872        );
873        assert_eq!(visual_missing(false, 0), Vec::<String>::new());
874    }
875
876    #[test]
877    fn projection_counts_missing_axis_entries_and_keeps_range_entries() {
878        let node =
879            |id: &str| BundleNode::new(id, "memory", id, id, "ACTIVE", vec![], BTreeMap::new());
880        let edge = |id: &str, occurred: Option<&str>, observed: Option<&str>| {
881            let mut explanation = RelationExplanation::new(RelationSemanticClass::Structural)
882                .with_dimension("lane")
883                .with_scope_id("scope");
884            if let Some(value) = occurred {
885                explanation = explanation.with_occurred_at(value);
886            }
887            if let Some(value) = observed {
888                explanation = explanation.with_observed_at(value);
889            }
890            BundleRelationship::new("scope", id, "contains_entry", explanation)
891        };
892        let bundle = KmpBundle::new(
893            CaseId::new("about").expect("about"),
894            Role::new("memory").expect("role"),
895            node("about"),
896            vec![
897                node("scope"),
898                node("observed"),
899                node("inside"),
900                node("outside"),
901            ],
902            vec![
903                edge("observed", None, Some("2026-08-15T00:00:00Z")),
904                edge("inside", Some("2026-08-15T00:00:00Z"), None),
905                edge("outside", Some("2026-07-15T00:00:00Z"), None),
906            ],
907            vec![],
908            BundleMetadata::initial("visual-test"),
909        )
910        .expect("bundle");
911        let request = TemporalTraversalRequest::new(
912            TemporalDirection::Goto,
913            TemporalCursor::time("2026-09-01T00:00:00Z").expect("cursor"),
914        )
915        .with_axis(TemporalAxis::Occurred)
916        .with_window(TemporalWindow::new(20, 20));
917        let traversal = TemporalMemoryTraversal::traverse(&bundle, &request).expect("traversal");
918        let temporal = TemporalMemoryResult {
919            traversal,
920            source_bundle: bundle,
921            include: TemporalIncludeOptions::default(),
922        };
923        let query = VisualProjectionQuery {
924            about: "about".to_string(),
925            from: "2026-08-01T00:00:00Z".to_string(),
926            to: "2026-09-01T00:00:00Z".to_string(),
927            axis: TemporalAxis::Occurred,
928            dimensions: DimensionSelection::all(),
929            level_of_detail: VisualLevelOfDetail::Moment,
930            bin_count: 4,
931            page_entries: 20,
932            cursor: None,
933            depth: 1,
934        };
935        let result = build_visual_projection(&query, temporal, vec![]).expect("projection");
936
937        assert_eq!(result.entries.len(), 1);
938        assert_eq!(result.entries[0].ref_id, "inside");
939        assert_eq!(result.page.total, 1);
940        assert_eq!(result.missing, vec!["temporal_positions"]);
941        let missing = result
942            .metrics
943            .iter()
944            .find(|metric| metric.name == "missing_axis_entries")
945            .expect("missing metric");
946        assert_eq!(missing.value, 1.0);
947        assert_eq!(missing.scope, "selected_source");
948    }
949}