use std::collections::{HashMap, HashSet};
use chappi_syntax::{
Atom, Form, FormKind, Span, is_canonical_decimal, is_canonical_integer, is_canonical_symbol,
is_canonical_text,
};
use crate::diagnostic::error;
use crate::focus::validate_focus_streams;
use crate::patch::{AddValue, Patch, PatchOp};
use crate::{
CognitiveState, Diagnostic, FocusFrame, Node, NodeKind, Relation, RelationKind, Source,
};
pub fn validate(state: &CognitiveState) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let mut nodes_by_id = HashMap::new();
validate_canonical_domain(state, &mut diagnostics);
for node in &state.nodes {
if node.salience > 100 {
diagnostics.push(error(
"invalid-salience",
node.span,
vec!["nodes".into(), node.id.clone(), "salience".into()],
"salience must be an integer from 0 through 100",
None,
));
}
if nodes_by_id.contains_key(node.id.as_str()) {
diagnostics.push(error(
"duplicate-node-id",
node.span,
vec!["nodes".into(), node.id.clone(), "id".into()],
format!("node ID `{}` is defined more than once", node.id),
Some("use a unique stable ID for every node".into()),
));
} else {
nodes_by_id.insert(node.id.as_str(), node);
}
}
for node in &state.nodes {
validate_entity_reference(
&nodes_by_id,
node,
&node.owner,
"owner",
"undefined-owner",
"invalid-owner-kind",
&mut diagnostics,
);
match &node.kind {
NodeKind::Relationship { target, .. }
| NodeKind::Affect { target, .. }
| NodeKind::Perspective(crate::Perspective { target, .. }) => {
validate_entity_reference(
&nodes_by_id,
node,
target,
"target",
"undefined-entity-target",
"invalid-entity-target-kind",
&mut diagnostics,
);
}
_ => {}
}
}
let mut seen_relations = HashSet::new();
for relation in &state.relations {
let key = (
relation.kind,
relation.source.as_str(),
relation.target.as_str(),
);
if !seen_relations.insert(key) {
diagnostics.push(error(
"duplicate-relation",
relation.span,
relation_path(relation),
format!(
"relation `({} {} {})` is defined more than once",
relation.kind.as_str(),
relation.source,
relation.target
),
Some("remove the duplicate relation".into()),
));
}
let source = nodes_by_id.get(relation.source.as_str()).copied();
let target = nodes_by_id.get(relation.target.as_str()).copied();
if source.is_none() {
let mut path = relation_path(relation);
path.push("source".into());
diagnostics.push(error(
"undefined-relation-source",
relation.span,
path,
format!(
"relation source `{}` does not name an existing node",
relation.source
),
None,
));
}
if target.is_none() {
let mut path = relation_path(relation);
path.push("target".into());
diagnostics.push(error(
"undefined-relation-target",
relation.span,
path,
format!(
"relation target `{}` does not name an existing node",
relation.target
),
None,
));
}
let (Some(source), Some(target)) = (source, target) else {
continue;
};
if !relation_endpoints_allowed(relation.kind, &source.kind, &target.kind) {
diagnostics.push(error(
"invalid-relation-endpoints",
relation.span,
relation_path(relation),
format!(
"relation kind `{}` does not permit {} -> {}",
relation.kind.as_str(),
node_kind_name(&source.kind),
node_kind_name(&target.kind)
),
Some("choose endpoint node kinds allowed by the v1 relation matrix".into()),
));
}
}
validate_supersession_cycles(state, &nodes_by_id, &mut diagnostics);
validate_focus_streams(state, &nodes_by_id, &mut diagnostics);
diagnostics
}
pub fn validate_patch(patch: &Patch) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
if patch.version != 1 {
diagnostics.push(error(
"invalid-patch-version",
patch.span,
vec!["header".into(), "version".into()],
format!("patch version must be 1, not {}", patch.version),
None,
));
}
for (index, op) in patch.ops.iter().enumerate() {
let root = vec!["ops".into(), index.to_string()];
match op {
PatchOp::Add(op) => match &op.value {
AddValue::Node(node) => {
validate_node_canonical(node, child(&root, "add"), &mut diagnostics)
}
AddValue::Relation(relation) => {
validate_relation_canonical(relation, child(&root, "add"), &mut diagnostics)
}
},
PatchOp::Revise(op) => {
validate_node_canonical(&op.node, child(&root, "revise"), &mut diagnostics)
}
PatchOp::Retire(op) => {
let retire_root = child(&root, "retire");
validate_symbol_value(
&op.node_id,
op.span,
child(&retire_root, "node_id"),
&mut diagnostics,
);
validate_form(&op.reason, child(&retire_root, "reason"), &mut diagnostics);
}
PatchOp::AppendFocus(op) => validate_focus_frame_canonical(
&op.frame,
child(&root, "append_focus"),
&mut diagnostics,
),
}
}
diagnostics
}
fn validate_canonical_domain(state: &CognitiveState, diagnostics: &mut Vec<Diagnostic>) {
if state.version != 1 {
diagnostics.push(error(
"invalid-version",
empty_span(),
vec!["version".into()],
format!("state version must be 1, not {}", state.version),
None,
));
}
for node in &state.nodes {
let root = vec!["nodes".into(), node.id.clone()];
validate_node_canonical(node, root, diagnostics);
}
for relation in &state.relations {
let root = relation_path(relation);
validate_relation_canonical(relation, root, diagnostics);
}
for stream in &state.focus_streams {
let stream_root = vec!["focus".into(), stream.id.clone()];
let stream_span = stream
.frames
.first()
.map_or_else(empty_span, |frame| frame.span);
validate_symbol_value(
&stream.id,
stream_span,
child(&stream_root, "id"),
diagnostics,
);
for frame in &stream.frames {
let frame_root = vec![
"focus".into(),
stream.id.clone(),
frame.sequence.to_string(),
];
validate_focus_frame_canonical(frame, frame_root, diagnostics);
}
}
}
fn validate_node_canonical(node: &Node, root: Vec<String>, diagnostics: &mut Vec<Diagnostic>) {
validate_symbol_value(&node.id, node.span, child(&root, "id"), diagnostics);
validate_symbol_value(&node.owner, node.span, child(&root, "owner"), diagnostics);
match &node.kind {
NodeKind::Entity { entity_kind } => validate_symbol_value(
entity_kind,
node.span,
child(&root, "entity_kind"),
diagnostics,
),
NodeKind::Evidence {
evidence_kind,
source,
} => {
validate_symbol_value(
evidence_kind,
node.span,
child(&root, "evidence_kind"),
diagnostics,
);
match source {
Source::Symbol(value) => {
validate_symbol_value(value, node.span, child(&root, "source"), diagnostics)
}
Source::String(value) => {
validate_text_value(value, node.span, child(&root, "source"), diagnostics)
}
}
}
NodeKind::Relationship { target, status } => {
validate_symbol_value(target, node.span, child(&root, "target"), diagnostics);
validate_symbol_value(status, node.span, child(&root, "status"), diagnostics);
}
NodeKind::Perspective(value) => validate_symbol_value(
&value.target,
node.span,
child(&root, "target"),
diagnostics,
),
NodeKind::Affect {
target,
affect_kind,
..
} => {
validate_symbol_value(target, node.span, child(&root, "target"), diagnostics);
validate_symbol_value(
affect_kind,
node.span,
child(&root, "affect_kind"),
diagnostics,
);
}
NodeKind::Claim { .. }
| NodeKind::Goal { .. }
| NodeKind::Constraint { .. }
| NodeKind::Question { .. }
| NodeKind::Desire { .. }
| NodeKind::Attention { .. }
| NodeKind::Association
| NodeKind::Tension { .. }
| NodeKind::Prediction { .. }
| NodeKind::Metacognition { .. } => {}
}
validate_form(&node.payload, child(&root, "payload"), diagnostics);
}
fn validate_relation_canonical(
relation: &Relation,
root: Vec<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
validate_symbol_value(
&relation.source,
relation.span,
child(&root, "source"),
diagnostics,
);
validate_symbol_value(
&relation.target,
relation.span,
child(&root, "target"),
diagnostics,
);
}
fn validate_focus_frame_canonical(
frame: &FocusFrame,
root: Vec<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
validate_symbol_value(
&frame.stream_id,
frame.span,
child(&root, "stream_id"),
diagnostics,
);
validate_symbol_value(&frame.owner, frame.span, child(&root, "owner"), diagnostics);
for (bucket_name, bucket) in [
("fg", &frame.foreground),
("bg", &frame.background),
("residue", &frame.residue),
] {
for (index, reference) in bucket.iter().enumerate() {
let mut path = child(&root, bucket_name);
path.push(index.to_string());
validate_symbol_value(reference, frame.span, path, diagnostics);
}
}
validate_form(&frame.trigger, child(&root, "trigger"), diagnostics);
}
fn validate_form(form: &Form, path: Vec<String>, diagnostics: &mut Vec<Diagnostic>) {
match &form.kind {
FormKind::Atom(Atom::Symbol(value)) => {
validate_symbol_value(value, form.span, path, diagnostics)
}
FormKind::Atom(Atom::String(value)) => {
validate_text_value(value, form.span, path, diagnostics)
}
FormKind::Atom(Atom::Integer(value)) if !is_canonical_integer(value) => {
diagnostics.push(error(
"noncanonical-integer",
form.span,
path,
format!("integer `{value}` does not have canonical spelling"),
Some("write the integer without redundant leading zeros".into()),
));
}
FormKind::Atom(Atom::Decimal(value)) if !is_canonical_decimal(value) => {
diagnostics.push(error(
"noncanonical-decimal",
form.span,
path,
format!("decimal `{value}` does not have canonical spelling"),
Some("remove redundant leading and trailing zeros".into()),
));
}
FormKind::List(items) => {
for (index, item) in items.iter().enumerate() {
let mut item_path = path.clone();
item_path.push(index.to_string());
validate_form(item, item_path, diagnostics);
}
}
FormKind::Atom(Atom::Integer(_) | Atom::Decimal(_)) => {}
}
}
fn validate_symbol_value(
value: &str,
span: Span,
path: Vec<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
if !is_canonical_symbol(value) {
diagnostics.push(error(
"noncanonical-symbol",
span,
path,
format!("symbol `{value}` is not round-trip-safe canonical syntax"),
Some("use an NFC symbol that is neither numeric nor delimiter-containing".into()),
));
}
}
fn validate_text_value(
value: &str,
span: Span,
path: Vec<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
if !is_canonical_text(value) {
diagnostics.push(error(
"noncanonical-text",
span,
path,
"text is not NFC-normalized",
Some("normalize text to Unicode NFC before encoding".into()),
));
}
}
fn child(root: &[String], field: &str) -> Vec<String> {
let mut path = root.to_vec();
path.push(field.into());
path
}
fn empty_span() -> Span {
Span {
start: 0,
end: 0,
start_line: 1,
start_column: 1,
end_line: 1,
end_column: 1,
}
}
fn validate_entity_reference(
nodes_by_id: &HashMap<&str, &Node>,
node: &Node,
reference: &str,
field: &str,
undefined_code: &str,
wrong_kind_code: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
let path = vec!["nodes".into(), node.id.clone(), field.into()];
match nodes_by_id.get(reference) {
None => diagnostics.push(error(
undefined_code,
node.span,
path,
format!("{field} `{reference}` does not name an existing entity node"),
None,
)),
Some(target) if !matches!(target.kind, NodeKind::Entity { .. }) => {
diagnostics.push(error(
wrong_kind_code,
node.span,
path,
format!("{field} `{reference}` names a non-entity node"),
Some("refer to the stable ID of an entity node".into()),
));
}
Some(_) => {}
}
}
fn relation_endpoints_allowed(kind: RelationKind, source: &NodeKind, target: &NodeKind) -> bool {
match kind {
RelationKind::Supports | RelationKind::Contradicts => {
matches!(source, NodeKind::Evidence { .. } | NodeKind::Claim { .. })
&& matches!(target, NodeKind::Claim { .. } | NodeKind::Prediction { .. })
}
RelationKind::Refines | RelationKind::Supersedes => {
std::mem::discriminant(source) == std::mem::discriminant(target)
}
RelationKind::Causes => matches!(
target,
NodeKind::Claim { .. }
| NodeKind::Goal { .. }
| NodeKind::Desire { .. }
| NodeKind::Affect { .. }
| NodeKind::Prediction { .. }
| NodeKind::Tension { .. }
),
RelationKind::About => matches!(target, NodeKind::Entity { .. }),
RelationKind::Blocks => {
matches!(
source,
NodeKind::Constraint { .. } | NodeKind::Tension { .. } | NodeKind::Question { .. }
) && matches!(target, NodeKind::Goal { .. } | NodeKind::Desire { .. })
}
RelationKind::Associates => true,
}
}
fn validate_supersession_cycles(
state: &CognitiveState,
nodes_by_id: &HashMap<&str, &Node>,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
for relation in &state.relations {
if relation.kind != RelationKind::Supersedes {
continue;
}
let (Some(source), Some(target)) = (
nodes_by_id.get(relation.source.as_str()),
nodes_by_id.get(relation.target.as_str()),
) else {
continue;
};
if !relation_endpoints_allowed(relation.kind, &source.kind, &target.kind) {
continue;
}
if reachable(
&adjacency,
relation.target.as_str(),
relation.source.as_str(),
) {
diagnostics.push(error(
"cyclic-supersession",
relation.span,
relation_path(relation),
format!(
"supersession from `{}` to `{}` closes a cycle",
relation.source, relation.target
),
Some("supersedes relations must form an acyclic graph".into()),
));
} else {
adjacency
.entry(relation.source.as_str())
.or_default()
.push(relation.target.as_str());
}
}
}
fn reachable(adjacency: &HashMap<&str, Vec<&str>>, start: &str, target: &str) -> bool {
if start == target {
return true;
}
let mut pending = vec![start];
let mut visited = HashSet::new();
while let Some(current) = pending.pop() {
if !visited.insert(current) {
continue;
}
if let Some(neighbors) = adjacency.get(current) {
for neighbor in neighbors {
if *neighbor == target {
return true;
}
pending.push(neighbor);
}
}
}
false
}
fn relation_path(relation: &Relation) -> Vec<String> {
vec![
"relations".into(),
relation.kind.as_str().into(),
relation.source.clone(),
relation.target.clone(),
]
}
fn node_kind_name(kind: &NodeKind) -> &'static str {
match kind {
NodeKind::Entity { .. } => "entity",
NodeKind::Claim { .. } => "claim",
NodeKind::Evidence { .. } => "evidence",
NodeKind::Goal { .. } => "goal",
NodeKind::Constraint { .. } => "constraint",
NodeKind::Question { .. } => "question",
NodeKind::Relationship { .. } => "relationship",
NodeKind::Desire { .. } => "desire",
NodeKind::Attention { .. } => "attention",
NodeKind::Association => "association",
NodeKind::Tension { .. } => "tension",
NodeKind::Prediction { .. } => "prediction",
NodeKind::Perspective(_) => "perspective",
NodeKind::Affect { .. } => "affect",
NodeKind::Metacognition { .. } => "metacognition",
}
}