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