Skip to main content

chappi_core/
model.rs

1use std::collections::HashSet;
2
3use chappi_syntax::{Atom, Diagnostic, Document, Form, FormKind, Span};
4
5use crate::diagnostic::error;
6use crate::focus::{FocusStream, decode_focus};
7
8pub(crate) type DecodeResult<T> = Result<T, Box<Diagnostic>>;
9
10#[derive(Clone, Debug)]
11pub struct CognitiveState {
12    pub version: u32,
13    pub nodes: Vec<Node>,
14    pub relations: Vec<Relation>,
15    pub focus_streams: Vec<FocusStream>,
16}
17
18impl PartialEq for CognitiveState {
19    fn eq(&self, other: &Self) -> bool {
20        if self.version != other.version {
21            return false;
22        }
23        let mut left_nodes = self.nodes.iter().collect::<Vec<_>>();
24        let mut right_nodes = other.nodes.iter().collect::<Vec<_>>();
25        left_nodes.sort_by(|left, right| left.id.cmp(&right.id));
26        right_nodes.sort_by(|left, right| left.id.cmp(&right.id));
27
28        let mut left_relations = self.relations.iter().collect::<Vec<_>>();
29        let mut right_relations = other.relations.iter().collect::<Vec<_>>();
30        let compare_relations = |left: &&Relation, right: &&Relation| {
31            left.kind
32                .as_str()
33                .cmp(right.kind.as_str())
34                .then_with(|| left.source.cmp(&right.source))
35                .then_with(|| left.target.cmp(&right.target))
36        };
37        left_relations.sort_by(compare_relations);
38        right_relations.sort_by(compare_relations);
39
40        let mut left_focus = self.focus_streams.iter().collect::<Vec<_>>();
41        let mut right_focus = other.focus_streams.iter().collect::<Vec<_>>();
42        left_focus.sort_by(|left, right| left.id.cmp(&right.id));
43        right_focus.sort_by(|left, right| left.id.cmp(&right.id));
44
45        left_nodes == right_nodes && left_relations == right_relations && left_focus == right_focus
46    }
47}
48
49impl Eq for CognitiveState {}
50
51#[derive(Clone, Debug)]
52pub struct Node {
53    pub id: String,
54    pub revision: u64,
55    pub owner: String,
56    pub lifetime: Lifetime,
57    pub salience: u8,
58    pub kind: NodeKind,
59    pub payload: Form,
60    pub span: Span,
61}
62
63impl PartialEq for Node {
64    fn eq(&self, other: &Self) -> bool {
65        self.id == other.id
66            && self.revision == other.revision
67            && self.owner == other.owner
68            && self.lifetime == other.lifetime
69            && self.salience == other.salience
70            && self.kind == other.kind
71            && forms_semantically_equal(&self.payload, &other.payload)
72    }
73}
74
75impl Eq for Node {}
76
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub enum NodeKind {
79    Entity {
80        entity_kind: String,
81    },
82    Claim {
83        confidence: Confidence,
84    },
85    Evidence {
86        evidence_kind: String,
87        source: Source,
88    },
89    Goal {
90        status: GoalStatus,
91    },
92    Constraint {
93        strength: ConstraintStrength,
94    },
95    Question {
96        status: QuestionStatus,
97    },
98    Relationship {
99        target: String,
100        status: String,
101    },
102    Desire {
103        force: DesireForce,
104    },
105    Attention {
106        mode: AttentionMode,
107    },
108    Association,
109    Tension {
110        status: TensionStatus,
111    },
112    Prediction {
113        confidence: Confidence,
114        status: PredictionStatus,
115    },
116    Perspective(Perspective),
117    Affect {
118        target: String,
119        affect_kind: String,
120        intensity: AffectIntensity,
121    },
122    Metacognition {
123        status: MetacognitionStatus,
124    },
125}
126
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct Perspective {
129    pub target: String,
130    pub confidence: Confidence,
131}
132
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub enum Source {
135    Symbol(String),
136    String(String),
137}
138
139macro_rules! string_enum {
140    ($name:ident { $($text:literal => $variant:ident),+ $(,)? }) => {
141        #[derive(Clone, Copy, Debug, Eq, PartialEq)]
142        pub enum $name {
143            $($variant),+
144        }
145
146        impl $name {
147            fn decode(value: &str) -> Option<Self> {
148                match value {
149                    $($text => Some(Self::$variant),)+
150                    _ => None,
151                }
152            }
153        }
154    };
155}
156
157string_enum!(Lifetime {
158    "persistent" => Persistent,
159    "working" => Working,
160    "transient" => Transient,
161});
162
163string_enum!(Confidence {
164    "tentative" => Tentative,
165    "plausible" => Plausible,
166    "likely" => Likely,
167    "settled" => Settled,
168});
169
170string_enum!(GoalStatus {
171    "considering" => Considering,
172    "active" => Active,
173    "committed" => Committed,
174    "done" => Done,
175    "dropped" => Dropped,
176});
177
178string_enum!(ConstraintStrength { "soft" => Soft, "hard" => Hard });
179string_enum!(QuestionStatus { "open" => Open, "answered" => Answered, "deferred" => Deferred });
180string_enum!(DesireForce { "weak" => Weak, "moderate" => Moderate, "strong" => Strong });
181string_enum!(AttentionMode { "foreground" => Foreground, "background" => Background });
182string_enum!(TensionStatus { "open" => Open, "resolved" => Resolved });
183string_enum!(PredictionStatus { "pending" => Pending, "matched" => Matched, "surprised" => Surprised });
184string_enum!(AffectIntensity { "weak" => Weak, "moderate" => Moderate, "strong" => Strong });
185string_enum!(MetacognitionStatus { "uncertain" => Uncertain, "monitoring" => Monitoring, "resolved" => Resolved });
186
187#[derive(Clone, Debug)]
188pub struct Relation {
189    pub kind: RelationKind,
190    pub source: String,
191    pub target: String,
192    pub span: Span,
193}
194
195impl PartialEq for Relation {
196    fn eq(&self, other: &Self) -> bool {
197        self.kind == other.kind && self.source == other.source && self.target == other.target
198    }
199}
200
201impl Eq for Relation {}
202
203#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
204pub enum RelationKind {
205    Supports,
206    Contradicts,
207    Refines,
208    Causes,
209    Supersedes,
210    About,
211    Blocks,
212    Associates,
213}
214
215impl RelationKind {
216    fn decode(value: &str) -> Option<Self> {
217        match value {
218            "supports" => Some(Self::Supports),
219            "contradicts" => Some(Self::Contradicts),
220            "refines" => Some(Self::Refines),
221            "causes" => Some(Self::Causes),
222            "supersedes" => Some(Self::Supersedes),
223            "about" => Some(Self::About),
224            "blocks" => Some(Self::Blocks),
225            "associates" => Some(Self::Associates),
226            _ => None,
227        }
228    }
229
230    pub fn as_str(self) -> &'static str {
231        match self {
232            Self::Supports => "supports",
233            Self::Contradicts => "contradicts",
234            Self::Refines => "refines",
235            Self::Causes => "causes",
236            Self::Supersedes => "supersedes",
237            Self::About => "about",
238            Self::Blocks => "blocks",
239            Self::Associates => "associates",
240        }
241    }
242}
243
244impl TryFrom<Document> for CognitiveState {
245    type Error = Vec<Diagnostic>;
246
247    fn try_from(document: Document) -> Result<Self, Self::Error> {
248        let mut version = None;
249        let mut header_seen = false;
250        let mut nodes = Vec::new();
251        let mut relations = Vec::new();
252        let mut focus_streams: Vec<FocusStream> = Vec::new();
253        let mut revisions = Vec::new();
254        let mut diagnostics = Vec::new();
255
256        for form in document.forms {
257            let Some((head, items)) = list_head(&form) else {
258                diagnostics.push(error(
259                    "invalid-top-level-form",
260                    form.span,
261                    Vec::new(),
262                    "top-level forms must be non-empty lists",
263                    None,
264                ));
265                continue;
266            };
267
268            if head == "chappi" {
269                if header_seen {
270                    diagnostics.push(error(
271                        "duplicate-header",
272                        form.span,
273                        vec!["header".into()],
274                        "the `(chappi 1)` header must appear exactly once",
275                        None,
276                    ));
277                } else {
278                    header_seen = true;
279                    if items.len() == 2 && integer(&items[1]) == Some("1") {
280                        version = Some(1);
281                    } else {
282                        diagnostics.push(error(
283                            "invalid-header",
284                            form.span,
285                            vec!["header".into()],
286                            "the header must be exactly `(chappi 1)`",
287                            None,
288                        ));
289                    }
290                }
291            } else if is_node_head(head) {
292                match decode_node(head, items, form.span) {
293                    Ok(node) => nodes.push(node),
294                    Err(diagnostic) => diagnostics.push(*diagnostic),
295                }
296            } else if head == "rel" {
297                match decode_relation(items, form.span) {
298                    Ok(relation) => relations.push(relation),
299                    Err(diagnostic) => diagnostics.push(*diagnostic),
300                }
301            } else if head == "revision" {
302                match decode_revision(items, form.span) {
303                    Ok(revision) => revisions.push(revision),
304                    Err(diagnostic) => diagnostics.push(*diagnostic),
305                }
306            } else if head == "focus" {
307                match decode_focus(items, form.span) {
308                    Ok(frame) => {
309                        if let Some(stream) = focus_streams
310                            .iter_mut()
311                            .find(|stream| stream.id == frame.stream_id)
312                        {
313                            stream.frames.push(frame);
314                        } else {
315                            focus_streams.push(FocusStream {
316                                id: frame.stream_id.clone(),
317                                frames: vec![frame],
318                            });
319                        }
320                    }
321                    Err(diagnostic) => diagnostics.push(*diagnostic),
322                }
323            } else {
324                diagnostics.push(error(
325                    "unknown-top-level-form",
326                    form.span,
327                    Vec::new(),
328                    format!("unknown top-level form `{head}`"),
329                    None,
330                ));
331            }
332        }
333
334        if !header_seen {
335            diagnostics.push(error(
336                "missing-header",
337                empty_span(),
338                vec!["header".into()],
339                "the document must contain exactly one `(chappi 1)` header",
340                Some("add `(chappi 1)` as the first form".into()),
341            ));
342        }
343
344        let mut seen_revisions = HashSet::new();
345        for (id, revision, span) in revisions {
346            if !seen_revisions.insert(id.clone()) {
347                diagnostics.push(error(
348                    "duplicate-revision",
349                    span,
350                    vec!["nodes".into(), id.clone(), "revision".into()],
351                    format!("node `{id}` has more than one revision form"),
352                    None,
353                ));
354                continue;
355            }
356            let Some(node) = nodes.iter_mut().find(|node| node.id == id) else {
357                diagnostics.push(error(
358                    "unknown-revision-node",
359                    span,
360                    vec!["nodes".into(), id.clone(), "revision".into()],
361                    format!("revision references unknown node `{id}`"),
362                    None,
363                ));
364                continue;
365            };
366            node.revision = revision;
367        }
368
369        if diagnostics.is_empty() {
370            focus_streams.sort_by(|left, right| left.id.cmp(&right.id));
371            for stream in &mut focus_streams {
372                stream.frames.sort_by_key(|frame| frame.sequence);
373            }
374            Ok(Self {
375                version: version.expect("valid state has a header"),
376                nodes,
377                relations,
378                focus_streams,
379            })
380        } else {
381            Err(diagnostics)
382        }
383    }
384}
385
386fn decode_revision(items: &[Form], span: Span) -> DecodeResult<(String, u64, Span)> {
387    if items.len() != 3 {
388        return Err(error(
389            "invalid-revision",
390            span,
391            vec!["nodes".into(), "revision".into()],
392            "a revision must be `(revision <node-id> <nonzero-revision>)`",
393            None,
394        )
395        .into());
396    }
397    let Some(id) = symbol(&items[1]) else {
398        return Err(error(
399            "invalid-revision-node",
400            items[1].span,
401            vec!["nodes".into(), "revision".into()],
402            "revision node ID must be a symbol",
403            None,
404        )
405        .into());
406    };
407    let revision = match &items[2].kind {
408        FormKind::Atom(Atom::Integer(value)) => {
409            value.parse::<u64>().ok().filter(|value| *value > 0)
410        }
411        _ => None,
412    }
413    .ok_or_else(|| {
414        Box::new(error(
415            "invalid-revision",
416            items[2].span,
417            vec!["nodes".into(), id.into(), "revision".into()],
418            "revision must be a nonzero 64-bit integer",
419            None,
420        ))
421    })?;
422    Ok((id.to_owned(), revision, span))
423}
424
425pub(crate) fn decode_relation(items: &[Form], span: Span) -> DecodeResult<Relation> {
426    if items.len() != 4 {
427        return Err(error(
428            "invalid-relation-arity",
429            span,
430            vec!["relations".into()],
431            "a relation must be `(rel <kind> <source-id> <target-id>)`",
432            None,
433        )
434        .into());
435    }
436
437    let kind_text = required_symbol(&items[1], "relation kind", &["relations", "kind"])?;
438    let kind = decode_enum(
439        RelationKind::decode(&kind_text),
440        &items[1],
441        "invalid-relation-kind",
442        &["relations", "kind"],
443        "relation kind must be `supports`, `contradicts`, `refines`, `causes`, `supersedes`, `about`, `blocks`, or `associates`",
444    )?;
445    let source = required_symbol(&items[2], "relation source ID", &["relations", "source"])?;
446    let target = required_symbol(&items[3], "relation target ID", &["relations", "target"])?;
447
448    Ok(Relation {
449        kind,
450        source,
451        target,
452        span,
453    })
454}
455
456pub(crate) fn decode_node(head: &str, items: &[Form], span: Span) -> DecodeResult<Node> {
457    let expected = node_arity(head);
458    if items.len() <= 2 {
459        let id = items.get(1).and_then(symbol).unwrap_or("<unknown>");
460        return Err(error(
461            "missing-perspective",
462            span,
463            vec!["nodes".into(), id.into(), "owner".into()],
464            format!("`{head}` requires an owner perspective after its node ID"),
465            Some("add the ID of an entity node as the owner".into()),
466        )
467        .into());
468    }
469    if items.len() != expected {
470        return Err(error(
471            "invalid-node-arity",
472            span,
473            vec!["nodes".into()],
474            format!("`{head}` expects {} fields after its kind", expected - 1),
475            None,
476        )
477        .into());
478    }
479
480    let id = required_symbol(&items[1], "node ID", &["nodes", "id"])?;
481    let owner = required_symbol(&items[2], "owner", &["nodes", &id, "owner"])?;
482    let lifetime_text = required_symbol(&items[3], "lifetime", &["nodes", &id, "lifetime"])?;
483    let lifetime = decode_enum(
484        Lifetime::decode(&lifetime_text),
485        &items[3],
486        "invalid-lifetime",
487        &["nodes", &id, "lifetime"],
488        "lifetime must be `persistent`, `working`, or `transient`",
489    )?;
490    let salience = decode_salience(&items[4], &id)?;
491    let path = |field: &'static str| ["nodes", id.as_str(), field];
492
493    let (kind, payload_index) = match head {
494        "entity" => (
495            NodeKind::Entity {
496                entity_kind: required_symbol(&items[5], "entity kind", &path("entity_kind"))?,
497            },
498            6,
499        ),
500        "claim" => (
501            NodeKind::Claim {
502                confidence: decode_confidence(&items[5], &path("confidence"))?,
503            },
504            6,
505        ),
506        "evidence" => (
507            NodeKind::Evidence {
508                evidence_kind: required_symbol(&items[5], "evidence kind", &path("evidence_kind"))?,
509                source: decode_source(&items[6], &path("source"))?,
510            },
511            7,
512        ),
513        "goal" => (
514            NodeKind::Goal {
515                status: decode_named_enum(
516                    &items[5],
517                    GoalStatus::decode,
518                    "invalid-goal-status",
519                    &path("status"),
520                    "goal status must be `considering`, `active`, `committed`, `done`, or `dropped`",
521                )?,
522            },
523            6,
524        ),
525        "constraint" => (
526            NodeKind::Constraint {
527                strength: decode_named_enum(
528                    &items[5],
529                    ConstraintStrength::decode,
530                    "invalid-constraint-strength",
531                    &path("strength"),
532                    "constraint strength must be `soft` or `hard`",
533                )?,
534            },
535            6,
536        ),
537        "question" => (
538            NodeKind::Question {
539                status: decode_named_enum(
540                    &items[5],
541                    QuestionStatus::decode,
542                    "invalid-question-status",
543                    &path("status"),
544                    "question status must be `open`, `answered`, or `deferred`",
545                )?,
546            },
547            6,
548        ),
549        "relationship" => (
550            NodeKind::Relationship {
551                target: required_symbol(&items[5], "target entity ID", &path("target"))?,
552                status: required_symbol(&items[6], "relationship status", &path("status"))?,
553            },
554            7,
555        ),
556        "desire" => (
557            NodeKind::Desire {
558                force: decode_named_enum(
559                    &items[5],
560                    DesireForce::decode,
561                    "invalid-desire-force",
562                    &path("force"),
563                    "desire force must be `weak`, `moderate`, or `strong`",
564                )?,
565            },
566            6,
567        ),
568        "attention" => (
569            NodeKind::Attention {
570                mode: decode_named_enum(
571                    &items[5],
572                    AttentionMode::decode,
573                    "invalid-attention-mode",
574                    &path("mode"),
575                    "attention mode must be `foreground` or `background`",
576                )?,
577            },
578            6,
579        ),
580        "association" => (NodeKind::Association, 5),
581        "tension" => (
582            NodeKind::Tension {
583                status: decode_named_enum(
584                    &items[5],
585                    TensionStatus::decode,
586                    "invalid-tension-status",
587                    &path("status"),
588                    "tension status must be `open` or `resolved`",
589                )?,
590            },
591            6,
592        ),
593        "prediction" => (
594            NodeKind::Prediction {
595                confidence: decode_confidence(&items[5], &path("confidence"))?,
596                status: decode_named_enum(
597                    &items[6],
598                    PredictionStatus::decode,
599                    "invalid-prediction-status",
600                    &path("status"),
601                    "prediction status must be `pending`, `matched`, or `surprised`",
602                )?,
603            },
604            7,
605        ),
606        "perspective" => (
607            NodeKind::Perspective(Perspective {
608                target: required_symbol(&items[5], "target entity ID", &path("target"))?,
609                confidence: decode_confidence(&items[6], &path("confidence"))?,
610            }),
611            7,
612        ),
613        "affect" => (
614            NodeKind::Affect {
615                target: required_symbol(&items[5], "target entity ID", &path("target"))?,
616                affect_kind: required_symbol(&items[6], "affect kind", &path("affect_kind"))?,
617                intensity: decode_named_enum(
618                    &items[7],
619                    AffectIntensity::decode,
620                    "invalid-affect-intensity",
621                    &path("intensity"),
622                    "affect intensity must be `weak`, `moderate`, or `strong`",
623                )?,
624            },
625            8,
626        ),
627        "metacognition" => (
628            NodeKind::Metacognition {
629                status: decode_named_enum(
630                    &items[5],
631                    MetacognitionStatus::decode,
632                    "invalid-metacognition-status",
633                    &path("status"),
634                    "metacognition status must be `uncertain`, `monitoring`, or `resolved`",
635                )?,
636            },
637            6,
638        ),
639        _ => unreachable!("caller checked node kind"),
640    };
641
642    Ok(Node {
643        id,
644        revision: 0,
645        owner,
646        lifetime,
647        salience,
648        kind,
649        payload: items[payload_index].clone(),
650        span,
651    })
652}
653
654fn decode_salience(form: &Form, id: &str) -> DecodeResult<u8> {
655    let Some(value) = integer(form) else {
656        return Err(error(
657            "invalid-salience",
658            form.span,
659            vec!["nodes".into(), id.into(), "salience".into()],
660            "salience must be an integer from 0 through 100",
661            None,
662        )
663        .into());
664    };
665    value
666        .parse::<u8>()
667        .ok()
668        .filter(|value| *value <= 100)
669        .ok_or_else(|| {
670            Box::new(error(
671                "invalid-salience",
672                form.span,
673                vec!["nodes".into(), id.into(), "salience".into()],
674                "salience must be an integer from 0 through 100",
675                None,
676            ))
677        })
678}
679
680fn decode_confidence(form: &Form, path: &[&str]) -> DecodeResult<Confidence> {
681    decode_named_enum(
682        form,
683        Confidence::decode,
684        "invalid-confidence",
685        path,
686        "confidence must be `tentative`, `plausible`, `likely`, or `settled`",
687    )
688}
689
690fn decode_source(form: &Form, path: &[&str]) -> DecodeResult<Source> {
691    match &form.kind {
692        FormKind::Atom(Atom::Symbol(value)) => Ok(Source::Symbol(value.clone())),
693        FormKind::Atom(Atom::String(value)) => Ok(Source::String(value.clone())),
694        _ => Err(error(
695            "invalid-evidence-source",
696            form.span,
697            path.iter().map(ToString::to_string).collect(),
698            "evidence source must be a symbol or string",
699            None,
700        )
701        .into()),
702    }
703}
704
705fn decode_named_enum<T>(
706    form: &Form,
707    decode: fn(&str) -> Option<T>,
708    code: &str,
709    path: &[&str],
710    message: &str,
711) -> DecodeResult<T> {
712    let value = symbol(form).unwrap_or("");
713    decode_enum(decode(value), form, code, path, message)
714}
715
716fn decode_enum<T>(
717    decoded: Option<T>,
718    form: &Form,
719    code: &str,
720    path: &[&str],
721    message: &str,
722) -> DecodeResult<T> {
723    decoded.ok_or_else(|| {
724        Box::new(error(
725            code,
726            form.span,
727            path.iter().map(ToString::to_string).collect(),
728            message,
729            None,
730        ))
731    })
732}
733
734fn required_symbol(form: &Form, name: &str, path: &[&str]) -> DecodeResult<String> {
735    symbol(form).map(ToOwned::to_owned).ok_or_else(|| {
736        Box::new(error(
737            "expected-symbol",
738            form.span,
739            path.iter().map(ToString::to_string).collect(),
740            format!("{name} must be a symbol"),
741            None,
742        ))
743    })
744}
745
746pub(crate) fn list_head(form: &Form) -> Option<(&str, &[Form])> {
747    let FormKind::List(items) = &form.kind else {
748        return None;
749    };
750    let head = items.first().and_then(symbol)?;
751    Some((head, items))
752}
753
754pub(crate) fn symbol(form: &Form) -> Option<&str> {
755    match &form.kind {
756        FormKind::Atom(Atom::Symbol(value)) => Some(value),
757        _ => None,
758    }
759}
760
761pub(crate) fn integer(form: &Form) -> Option<&str> {
762    match &form.kind {
763        FormKind::Atom(Atom::Integer(value)) => Some(value),
764        _ => None,
765    }
766}
767
768pub(crate) fn is_node_head(head: &str) -> bool {
769    matches!(
770        head,
771        "entity"
772            | "claim"
773            | "evidence"
774            | "goal"
775            | "constraint"
776            | "question"
777            | "relationship"
778            | "desire"
779            | "attention"
780            | "association"
781            | "tension"
782            | "prediction"
783            | "perspective"
784            | "affect"
785            | "metacognition"
786    )
787}
788
789fn node_arity(head: &str) -> usize {
790    match head {
791        "evidence" | "relationship" | "prediction" | "perspective" => 8,
792        "affect" => 9,
793        "association" => 6,
794        _ => 7,
795    }
796}
797
798fn empty_span() -> Span {
799    Span {
800        start: 0,
801        end: 0,
802        start_line: 1,
803        start_column: 1,
804        end_line: 1,
805        end_column: 1,
806    }
807}
808
809pub(crate) fn forms_semantically_equal(left: &Form, right: &Form) -> bool {
810    match (&left.kind, &right.kind) {
811        (FormKind::Atom(left), FormKind::Atom(right)) => left == right,
812        (FormKind::List(left), FormKind::List(right)) => {
813            left.len() == right.len()
814                && left
815                    .iter()
816                    .zip(right)
817                    .all(|(left, right)| forms_semantically_equal(left, right))
818        }
819        _ => false,
820    }
821}