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