use std::collections::HashSet;
use chappi_syntax::{Atom, Diagnostic, Document, Form, FormKind, Span};
use crate::diagnostic::error;
use crate::focus::{FocusStream, decode_focus};
pub(crate) type DecodeResult<T> = Result<T, Box<Diagnostic>>;
#[derive(Clone, Debug)]
pub struct CognitiveState {
pub version: u32,
pub nodes: Vec<Node>,
pub relations: Vec<Relation>,
pub focus_streams: Vec<FocusStream>,
}
impl PartialEq for CognitiveState {
fn eq(&self, other: &Self) -> bool {
if self.version != other.version {
return false;
}
let mut left_nodes = self.nodes.iter().collect::<Vec<_>>();
let mut right_nodes = other.nodes.iter().collect::<Vec<_>>();
left_nodes.sort_by(|left, right| left.id.cmp(&right.id));
right_nodes.sort_by(|left, right| left.id.cmp(&right.id));
let mut left_relations = self.relations.iter().collect::<Vec<_>>();
let mut right_relations = other.relations.iter().collect::<Vec<_>>();
let compare_relations = |left: &&Relation, right: &&Relation| {
left.kind
.as_str()
.cmp(right.kind.as_str())
.then_with(|| left.source.cmp(&right.source))
.then_with(|| left.target.cmp(&right.target))
};
left_relations.sort_by(compare_relations);
right_relations.sort_by(compare_relations);
let mut left_focus = self.focus_streams.iter().collect::<Vec<_>>();
let mut right_focus = other.focus_streams.iter().collect::<Vec<_>>();
left_focus.sort_by(|left, right| left.id.cmp(&right.id));
right_focus.sort_by(|left, right| left.id.cmp(&right.id));
left_nodes == right_nodes && left_relations == right_relations && left_focus == right_focus
}
}
impl Eq for CognitiveState {}
#[derive(Clone, Debug)]
pub struct Node {
pub id: String,
pub revision: u64,
pub owner: String,
pub lifetime: Lifetime,
pub salience: u8,
pub kind: NodeKind,
pub payload: Form,
pub span: Span,
}
impl PartialEq for Node {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.revision == other.revision
&& self.owner == other.owner
&& self.lifetime == other.lifetime
&& self.salience == other.salience
&& self.kind == other.kind
&& forms_semantically_equal(&self.payload, &other.payload)
}
}
impl Eq for Node {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum NodeKind {
Entity {
entity_kind: String,
},
Claim {
confidence: Confidence,
},
Evidence {
evidence_kind: String,
source: Source,
},
Goal {
status: GoalStatus,
},
Constraint {
strength: ConstraintStrength,
},
Question {
status: QuestionStatus,
},
Relationship {
target: String,
status: String,
},
Desire {
force: DesireForce,
},
Attention {
mode: AttentionMode,
},
Association,
Tension {
status: TensionStatus,
},
Prediction {
confidence: Confidence,
status: PredictionStatus,
},
Perspective(Perspective),
Affect {
target: String,
affect_kind: String,
intensity: AffectIntensity,
},
Metacognition {
status: MetacognitionStatus,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Perspective {
pub target: String,
pub confidence: Confidence,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Source {
Symbol(String),
String(String),
}
macro_rules! string_enum {
($name:ident { $($text:literal => $variant:ident),+ $(,)? }) => {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum $name {
$($variant),+
}
impl $name {
fn decode(value: &str) -> Option<Self> {
match value {
$($text => Some(Self::$variant),)+
_ => None,
}
}
}
};
}
string_enum!(Lifetime {
"persistent" => Persistent,
"working" => Working,
"transient" => Transient,
});
string_enum!(Confidence {
"tentative" => Tentative,
"plausible" => Plausible,
"likely" => Likely,
"settled" => Settled,
});
string_enum!(GoalStatus {
"considering" => Considering,
"active" => Active,
"committed" => Committed,
"done" => Done,
"dropped" => Dropped,
});
string_enum!(ConstraintStrength { "soft" => Soft, "hard" => Hard });
string_enum!(QuestionStatus { "open" => Open, "answered" => Answered, "deferred" => Deferred });
string_enum!(DesireForce { "weak" => Weak, "moderate" => Moderate, "strong" => Strong });
string_enum!(AttentionMode { "foreground" => Foreground, "background" => Background });
string_enum!(TensionStatus { "open" => Open, "resolved" => Resolved });
string_enum!(PredictionStatus { "pending" => Pending, "matched" => Matched, "surprised" => Surprised });
string_enum!(AffectIntensity { "weak" => Weak, "moderate" => Moderate, "strong" => Strong });
string_enum!(MetacognitionStatus { "uncertain" => Uncertain, "monitoring" => Monitoring, "resolved" => Resolved });
#[derive(Clone, Debug)]
pub struct Relation {
pub kind: RelationKind,
pub source: String,
pub target: String,
pub span: Span,
}
impl PartialEq for Relation {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind && self.source == other.source && self.target == other.target
}
}
impl Eq for Relation {}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RelationKind {
Supports,
Contradicts,
Refines,
Causes,
Supersedes,
About,
Blocks,
Associates,
}
impl RelationKind {
fn decode(value: &str) -> Option<Self> {
match value {
"supports" => Some(Self::Supports),
"contradicts" => Some(Self::Contradicts),
"refines" => Some(Self::Refines),
"causes" => Some(Self::Causes),
"supersedes" => Some(Self::Supersedes),
"about" => Some(Self::About),
"blocks" => Some(Self::Blocks),
"associates" => Some(Self::Associates),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Supports => "supports",
Self::Contradicts => "contradicts",
Self::Refines => "refines",
Self::Causes => "causes",
Self::Supersedes => "supersedes",
Self::About => "about",
Self::Blocks => "blocks",
Self::Associates => "associates",
}
}
}
impl TryFrom<Document> for CognitiveState {
type Error = Vec<Diagnostic>;
fn try_from(document: Document) -> Result<Self, Self::Error> {
let mut version = None;
let mut header_seen = false;
let mut nodes = Vec::new();
let mut relations = Vec::new();
let mut focus_streams: Vec<FocusStream> = Vec::new();
let mut revisions = Vec::new();
let mut diagnostics = Vec::new();
for form in document.forms {
let Some((head, items)) = list_head(&form) else {
diagnostics.push(error(
"invalid-top-level-form",
form.span,
Vec::new(),
"top-level forms must be non-empty lists",
None,
));
continue;
};
if head == "chappi" {
if header_seen {
diagnostics.push(error(
"duplicate-header",
form.span,
vec!["header".into()],
"the `(chappi 1)` header must appear exactly once",
None,
));
} else {
header_seen = true;
if items.len() == 2 && integer(&items[1]) == Some("1") {
version = Some(1);
} else {
diagnostics.push(error(
"invalid-header",
form.span,
vec!["header".into()],
"the header must be exactly `(chappi 1)`",
None,
));
}
}
} else if is_node_head(head) {
match decode_node(head, items, form.span) {
Ok(node) => nodes.push(node),
Err(diagnostic) => diagnostics.push(*diagnostic),
}
} else if head == "rel" {
match decode_relation(items, form.span) {
Ok(relation) => relations.push(relation),
Err(diagnostic) => diagnostics.push(*diagnostic),
}
} else if head == "revision" {
match decode_revision(items, form.span) {
Ok(revision) => revisions.push(revision),
Err(diagnostic) => diagnostics.push(*diagnostic),
}
} else if head == "focus" {
match decode_focus(items, form.span) {
Ok(frame) => {
if let Some(stream) = focus_streams
.iter_mut()
.find(|stream| stream.id == frame.stream_id)
{
stream.frames.push(frame);
} else {
focus_streams.push(FocusStream {
id: frame.stream_id.clone(),
frames: vec![frame],
});
}
}
Err(diagnostic) => diagnostics.push(*diagnostic),
}
} else {
diagnostics.push(error(
"unknown-top-level-form",
form.span,
Vec::new(),
format!("unknown top-level form `{head}`"),
None,
));
}
}
if !header_seen {
diagnostics.push(error(
"missing-header",
empty_span(),
vec!["header".into()],
"the document must contain exactly one `(chappi 1)` header",
Some("add `(chappi 1)` as the first form".into()),
));
}
let mut seen_revisions = HashSet::new();
for (id, revision, span) in revisions {
if !seen_revisions.insert(id.clone()) {
diagnostics.push(error(
"duplicate-revision",
span,
vec!["nodes".into(), id.clone(), "revision".into()],
format!("node `{id}` has more than one revision form"),
None,
));
continue;
}
let Some(node) = nodes.iter_mut().find(|node| node.id == id) else {
diagnostics.push(error(
"unknown-revision-node",
span,
vec!["nodes".into(), id.clone(), "revision".into()],
format!("revision references unknown node `{id}`"),
None,
));
continue;
};
node.revision = revision;
}
if diagnostics.is_empty() {
focus_streams.sort_by(|left, right| left.id.cmp(&right.id));
for stream in &mut focus_streams {
stream.frames.sort_by_key(|frame| frame.sequence);
}
Ok(Self {
version: version.expect("valid state has a header"),
nodes,
relations,
focus_streams,
})
} else {
Err(diagnostics)
}
}
}
fn decode_revision(items: &[Form], span: Span) -> DecodeResult<(String, u64, Span)> {
if items.len() != 3 {
return Err(error(
"invalid-revision",
span,
vec!["nodes".into(), "revision".into()],
"a revision must be `(revision <node-id> <nonzero-revision>)`",
None,
)
.into());
}
let Some(id) = symbol(&items[1]) else {
return Err(error(
"invalid-revision-node",
items[1].span,
vec!["nodes".into(), "revision".into()],
"revision node ID must be a symbol",
None,
)
.into());
};
let revision = match &items[2].kind {
FormKind::Atom(Atom::Integer(value)) => {
value.parse::<u64>().ok().filter(|value| *value > 0)
}
_ => None,
}
.ok_or_else(|| {
Box::new(error(
"invalid-revision",
items[2].span,
vec!["nodes".into(), id.into(), "revision".into()],
"revision must be a nonzero 64-bit integer",
None,
))
})?;
Ok((id.to_owned(), revision, span))
}
pub(crate) fn decode_relation(items: &[Form], span: Span) -> DecodeResult<Relation> {
if items.len() != 4 {
return Err(error(
"invalid-relation-arity",
span,
vec!["relations".into()],
"a relation must be `(rel <kind> <source-id> <target-id>)`",
None,
)
.into());
}
let kind_text = required_symbol(&items[1], "relation kind", &["relations", "kind"])?;
let kind = decode_enum(
RelationKind::decode(&kind_text),
&items[1],
"invalid-relation-kind",
&["relations", "kind"],
"relation kind must be `supports`, `contradicts`, `refines`, `causes`, `supersedes`, `about`, `blocks`, or `associates`",
)?;
let source = required_symbol(&items[2], "relation source ID", &["relations", "source"])?;
let target = required_symbol(&items[3], "relation target ID", &["relations", "target"])?;
Ok(Relation {
kind,
source,
target,
span,
})
}
pub(crate) fn decode_node(head: &str, items: &[Form], span: Span) -> DecodeResult<Node> {
let expected = node_arity(head);
if items.len() <= 2 {
let id = items.get(1).and_then(symbol).unwrap_or("<unknown>");
return Err(error(
"missing-perspective",
span,
vec!["nodes".into(), id.into(), "owner".into()],
format!("`{head}` requires an owner perspective after its node ID"),
Some("add the ID of an entity node as the owner".into()),
)
.into());
}
if items.len() != expected {
return Err(error(
"invalid-node-arity",
span,
vec!["nodes".into()],
format!("`{head}` expects {} fields after its kind", expected - 1),
None,
)
.into());
}
let id = required_symbol(&items[1], "node ID", &["nodes", "id"])?;
let owner = required_symbol(&items[2], "owner", &["nodes", &id, "owner"])?;
let lifetime_text = required_symbol(&items[3], "lifetime", &["nodes", &id, "lifetime"])?;
let lifetime = decode_enum(
Lifetime::decode(&lifetime_text),
&items[3],
"invalid-lifetime",
&["nodes", &id, "lifetime"],
"lifetime must be `persistent`, `working`, or `transient`",
)?;
let salience = decode_salience(&items[4], &id)?;
let path = |field: &'static str| ["nodes", id.as_str(), field];
let (kind, payload_index) = match head {
"entity" => (
NodeKind::Entity {
entity_kind: required_symbol(&items[5], "entity kind", &path("entity_kind"))?,
},
6,
),
"claim" => (
NodeKind::Claim {
confidence: decode_confidence(&items[5], &path("confidence"))?,
},
6,
),
"evidence" => (
NodeKind::Evidence {
evidence_kind: required_symbol(&items[5], "evidence kind", &path("evidence_kind"))?,
source: decode_source(&items[6], &path("source"))?,
},
7,
),
"goal" => (
NodeKind::Goal {
status: decode_named_enum(
&items[5],
GoalStatus::decode,
"invalid-goal-status",
&path("status"),
"goal status must be `considering`, `active`, `committed`, `done`, or `dropped`",
)?,
},
6,
),
"constraint" => (
NodeKind::Constraint {
strength: decode_named_enum(
&items[5],
ConstraintStrength::decode,
"invalid-constraint-strength",
&path("strength"),
"constraint strength must be `soft` or `hard`",
)?,
},
6,
),
"question" => (
NodeKind::Question {
status: decode_named_enum(
&items[5],
QuestionStatus::decode,
"invalid-question-status",
&path("status"),
"question status must be `open`, `answered`, or `deferred`",
)?,
},
6,
),
"relationship" => (
NodeKind::Relationship {
target: required_symbol(&items[5], "target entity ID", &path("target"))?,
status: required_symbol(&items[6], "relationship status", &path("status"))?,
},
7,
),
"desire" => (
NodeKind::Desire {
force: decode_named_enum(
&items[5],
DesireForce::decode,
"invalid-desire-force",
&path("force"),
"desire force must be `weak`, `moderate`, or `strong`",
)?,
},
6,
),
"attention" => (
NodeKind::Attention {
mode: decode_named_enum(
&items[5],
AttentionMode::decode,
"invalid-attention-mode",
&path("mode"),
"attention mode must be `foreground` or `background`",
)?,
},
6,
),
"association" => (NodeKind::Association, 5),
"tension" => (
NodeKind::Tension {
status: decode_named_enum(
&items[5],
TensionStatus::decode,
"invalid-tension-status",
&path("status"),
"tension status must be `open` or `resolved`",
)?,
},
6,
),
"prediction" => (
NodeKind::Prediction {
confidence: decode_confidence(&items[5], &path("confidence"))?,
status: decode_named_enum(
&items[6],
PredictionStatus::decode,
"invalid-prediction-status",
&path("status"),
"prediction status must be `pending`, `matched`, or `surprised`",
)?,
},
7,
),
"perspective" => (
NodeKind::Perspective(Perspective {
target: required_symbol(&items[5], "target entity ID", &path("target"))?,
confidence: decode_confidence(&items[6], &path("confidence"))?,
}),
7,
),
"affect" => (
NodeKind::Affect {
target: required_symbol(&items[5], "target entity ID", &path("target"))?,
affect_kind: required_symbol(&items[6], "affect kind", &path("affect_kind"))?,
intensity: decode_named_enum(
&items[7],
AffectIntensity::decode,
"invalid-affect-intensity",
&path("intensity"),
"affect intensity must be `weak`, `moderate`, or `strong`",
)?,
},
8,
),
"metacognition" => (
NodeKind::Metacognition {
status: decode_named_enum(
&items[5],
MetacognitionStatus::decode,
"invalid-metacognition-status",
&path("status"),
"metacognition status must be `uncertain`, `monitoring`, or `resolved`",
)?,
},
6,
),
_ => unreachable!("caller checked node kind"),
};
Ok(Node {
id,
revision: 0,
owner,
lifetime,
salience,
kind,
payload: items[payload_index].clone(),
span,
})
}
fn decode_salience(form: &Form, id: &str) -> DecodeResult<u8> {
let Some(value) = integer(form) else {
return Err(error(
"invalid-salience",
form.span,
vec!["nodes".into(), id.into(), "salience".into()],
"salience must be an integer from 0 through 100",
None,
)
.into());
};
value
.parse::<u8>()
.ok()
.filter(|value| *value <= 100)
.ok_or_else(|| {
Box::new(error(
"invalid-salience",
form.span,
vec!["nodes".into(), id.into(), "salience".into()],
"salience must be an integer from 0 through 100",
None,
))
})
}
fn decode_confidence(form: &Form, path: &[&str]) -> DecodeResult<Confidence> {
decode_named_enum(
form,
Confidence::decode,
"invalid-confidence",
path,
"confidence must be `tentative`, `plausible`, `likely`, or `settled`",
)
}
fn decode_source(form: &Form, path: &[&str]) -> DecodeResult<Source> {
match &form.kind {
FormKind::Atom(Atom::Symbol(value)) => Ok(Source::Symbol(value.clone())),
FormKind::Atom(Atom::String(value)) => Ok(Source::String(value.clone())),
_ => Err(error(
"invalid-evidence-source",
form.span,
path.iter().map(ToString::to_string).collect(),
"evidence source must be a symbol or string",
None,
)
.into()),
}
}
fn decode_named_enum<T>(
form: &Form,
decode: fn(&str) -> Option<T>,
code: &str,
path: &[&str],
message: &str,
) -> DecodeResult<T> {
let value = symbol(form).unwrap_or("");
decode_enum(decode(value), form, code, path, message)
}
fn decode_enum<T>(
decoded: Option<T>,
form: &Form,
code: &str,
path: &[&str],
message: &str,
) -> DecodeResult<T> {
decoded.ok_or_else(|| {
Box::new(error(
code,
form.span,
path.iter().map(ToString::to_string).collect(),
message,
None,
))
})
}
fn required_symbol(form: &Form, name: &str, path: &[&str]) -> DecodeResult<String> {
symbol(form).map(ToOwned::to_owned).ok_or_else(|| {
Box::new(error(
"expected-symbol",
form.span,
path.iter().map(ToString::to_string).collect(),
format!("{name} must be a symbol"),
None,
))
})
}
pub(crate) fn list_head(form: &Form) -> Option<(&str, &[Form])> {
let FormKind::List(items) = &form.kind else {
return None;
};
let head = items.first().and_then(symbol)?;
Some((head, items))
}
pub(crate) fn symbol(form: &Form) -> Option<&str> {
match &form.kind {
FormKind::Atom(Atom::Symbol(value)) => Some(value),
_ => None,
}
}
pub(crate) fn integer(form: &Form) -> Option<&str> {
match &form.kind {
FormKind::Atom(Atom::Integer(value)) => Some(value),
_ => None,
}
}
pub(crate) fn is_node_head(head: &str) -> bool {
matches!(
head,
"entity"
| "claim"
| "evidence"
| "goal"
| "constraint"
| "question"
| "relationship"
| "desire"
| "attention"
| "association"
| "tension"
| "prediction"
| "perspective"
| "affect"
| "metacognition"
)
}
fn node_arity(head: &str) -> usize {
match head {
"evidence" | "relationship" | "prediction" | "perspective" => 8,
"affect" => 9,
"association" => 6,
_ => 7,
}
}
fn empty_span() -> Span {
Span {
start: 0,
end: 0,
start_line: 1,
start_column: 1,
end_line: 1,
end_column: 1,
}
}
pub(crate) fn forms_semantically_equal(left: &Form, right: &Form) -> bool {
match (&left.kind, &right.kind) {
(FormKind::Atom(left), FormKind::Atom(right)) => left == right,
(FormKind::List(left), FormKind::List(right)) => {
left.len() == right.len()
&& left
.iter()
.zip(right)
.all(|(left, right)| forms_semantically_equal(left, right))
}
_ => false,
}
}