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}