Skip to main content

kmp_application/memory/
visual_projection.rs

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