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