Skip to main content

chappi_core/
validate.rs

1use std::collections::{HashMap, HashSet};
2
3use chappi_syntax::{
4    Atom, Form, FormKind, Span, is_canonical_decimal, is_canonical_integer, is_canonical_symbol,
5    is_canonical_text,
6};
7
8use crate::diagnostic::error;
9use crate::focus::validate_focus_streams;
10use crate::patch::{AddValue, Patch, PatchOp};
11use crate::{
12    CognitiveState, Diagnostic, FocusFrame, Node, NodeKind, Relation, RelationKind, Source,
13};
14
15pub fn validate(state: &CognitiveState) -> Vec<Diagnostic> {
16    let mut diagnostics = Vec::new();
17    let mut nodes_by_id = HashMap::new();
18
19    validate_canonical_domain(state, &mut diagnostics);
20
21    for node in &state.nodes {
22        if node.salience > 100 {
23            diagnostics.push(error(
24                "invalid-salience",
25                node.span,
26                vec!["nodes".into(), node.id.clone(), "salience".into()],
27                "salience must be an integer from 0 through 100",
28                None,
29            ));
30        }
31        if nodes_by_id.contains_key(node.id.as_str()) {
32            diagnostics.push(error(
33                "duplicate-node-id",
34                node.span,
35                vec!["nodes".into(), node.id.clone(), "id".into()],
36                format!("node ID `{}` is defined more than once", node.id),
37                Some("use a unique stable ID for every node".into()),
38            ));
39        } else {
40            nodes_by_id.insert(node.id.as_str(), node);
41        }
42    }
43
44    for node in &state.nodes {
45        validate_entity_reference(
46            &nodes_by_id,
47            node,
48            &node.owner,
49            "owner",
50            "undefined-owner",
51            "invalid-owner-kind",
52            &mut diagnostics,
53        );
54
55        match &node.kind {
56            NodeKind::Relationship { target, .. }
57            | NodeKind::Affect { target, .. }
58            | NodeKind::Perspective(crate::Perspective { target, .. }) => {
59                validate_entity_reference(
60                    &nodes_by_id,
61                    node,
62                    target,
63                    "target",
64                    "undefined-entity-target",
65                    "invalid-entity-target-kind",
66                    &mut diagnostics,
67                );
68            }
69            _ => {}
70        }
71    }
72
73    let mut seen_relations = HashSet::new();
74    for relation in &state.relations {
75        let key = (
76            relation.kind,
77            relation.source.as_str(),
78            relation.target.as_str(),
79        );
80        if !seen_relations.insert(key) {
81            diagnostics.push(error(
82                "duplicate-relation",
83                relation.span,
84                relation_path(relation),
85                format!(
86                    "relation `({} {} {})` is defined more than once",
87                    relation.kind.as_str(),
88                    relation.source,
89                    relation.target
90                ),
91                Some("remove the duplicate relation".into()),
92            ));
93        }
94
95        let source = nodes_by_id.get(relation.source.as_str()).copied();
96        let target = nodes_by_id.get(relation.target.as_str()).copied();
97        if source.is_none() {
98            let mut path = relation_path(relation);
99            path.push("source".into());
100            diagnostics.push(error(
101                "undefined-relation-source",
102                relation.span,
103                path,
104                format!(
105                    "relation source `{}` does not name an existing node",
106                    relation.source
107                ),
108                None,
109            ));
110        }
111        if target.is_none() {
112            let mut path = relation_path(relation);
113            path.push("target".into());
114            diagnostics.push(error(
115                "undefined-relation-target",
116                relation.span,
117                path,
118                format!(
119                    "relation target `{}` does not name an existing node",
120                    relation.target
121                ),
122                None,
123            ));
124        }
125        let (Some(source), Some(target)) = (source, target) else {
126            continue;
127        };
128        if !relation_endpoints_allowed(relation.kind, &source.kind, &target.kind) {
129            diagnostics.push(error(
130                "invalid-relation-endpoints",
131                relation.span,
132                relation_path(relation),
133                format!(
134                    "relation kind `{}` does not permit {} -> {}",
135                    relation.kind.as_str(),
136                    node_kind_name(&source.kind),
137                    node_kind_name(&target.kind)
138                ),
139                Some("choose endpoint node kinds allowed by the v1 relation matrix".into()),
140            ));
141        }
142    }
143
144    validate_supersession_cycles(state, &nodes_by_id, &mut diagnostics);
145    validate_focus_streams(state, &nodes_by_id, &mut diagnostics);
146    diagnostics
147}
148
149/// Validates that every value retained by a typed patch can be encoded and
150/// decoded without changing its meaning.
151pub fn validate_patch(patch: &Patch) -> Vec<Diagnostic> {
152    let mut diagnostics = Vec::new();
153    if patch.version != 1 {
154        diagnostics.push(error(
155            "invalid-patch-version",
156            patch.span,
157            vec!["header".into(), "version".into()],
158            format!("patch version must be 1, not {}", patch.version),
159            None,
160        ));
161    }
162
163    for (index, op) in patch.ops.iter().enumerate() {
164        let root = vec!["ops".into(), index.to_string()];
165        match op {
166            PatchOp::Add(op) => match &op.value {
167                AddValue::Node(node) => {
168                    validate_node_canonical(node, child(&root, "add"), &mut diagnostics)
169                }
170                AddValue::Relation(relation) => {
171                    validate_relation_canonical(relation, child(&root, "add"), &mut diagnostics)
172                }
173            },
174            PatchOp::Revise(op) => {
175                validate_node_canonical(&op.node, child(&root, "revise"), &mut diagnostics)
176            }
177            PatchOp::Retire(op) => {
178                let retire_root = child(&root, "retire");
179                validate_symbol_value(
180                    &op.node_id,
181                    op.span,
182                    child(&retire_root, "node_id"),
183                    &mut diagnostics,
184                );
185                validate_form(&op.reason, child(&retire_root, "reason"), &mut diagnostics);
186            }
187            PatchOp::AppendFocus(op) => validate_focus_frame_canonical(
188                &op.frame,
189                child(&root, "append_focus"),
190                &mut diagnostics,
191            ),
192        }
193    }
194    diagnostics
195}
196
197fn validate_canonical_domain(state: &CognitiveState, diagnostics: &mut Vec<Diagnostic>) {
198    if state.version != 1 {
199        diagnostics.push(error(
200            "invalid-version",
201            empty_span(),
202            vec!["version".into()],
203            format!("state version must be 1, not {}", state.version),
204            None,
205        ));
206    }
207
208    for node in &state.nodes {
209        let root = vec!["nodes".into(), node.id.clone()];
210        validate_node_canonical(node, root, diagnostics);
211    }
212
213    for relation in &state.relations {
214        let root = relation_path(relation);
215        validate_relation_canonical(relation, root, diagnostics);
216    }
217
218    for stream in &state.focus_streams {
219        let stream_root = vec!["focus".into(), stream.id.clone()];
220        let stream_span = stream
221            .frames
222            .first()
223            .map_or_else(empty_span, |frame| frame.span);
224        validate_symbol_value(
225            &stream.id,
226            stream_span,
227            child(&stream_root, "id"),
228            diagnostics,
229        );
230        for frame in &stream.frames {
231            let frame_root = vec![
232                "focus".into(),
233                stream.id.clone(),
234                frame.sequence.to_string(),
235            ];
236            validate_focus_frame_canonical(frame, frame_root, diagnostics);
237        }
238    }
239}
240
241fn validate_node_canonical(node: &Node, root: Vec<String>, diagnostics: &mut Vec<Diagnostic>) {
242    validate_symbol_value(&node.id, node.span, child(&root, "id"), diagnostics);
243    validate_symbol_value(&node.owner, node.span, child(&root, "owner"), diagnostics);
244    match &node.kind {
245        NodeKind::Entity { entity_kind } => validate_symbol_value(
246            entity_kind,
247            node.span,
248            child(&root, "entity_kind"),
249            diagnostics,
250        ),
251        NodeKind::Evidence {
252            evidence_kind,
253            source,
254        } => {
255            validate_symbol_value(
256                evidence_kind,
257                node.span,
258                child(&root, "evidence_kind"),
259                diagnostics,
260            );
261            match source {
262                Source::Symbol(value) => {
263                    validate_symbol_value(value, node.span, child(&root, "source"), diagnostics)
264                }
265                Source::String(value) => {
266                    validate_text_value(value, node.span, child(&root, "source"), diagnostics)
267                }
268            }
269        }
270        NodeKind::Relationship { target, status } => {
271            validate_symbol_value(target, node.span, child(&root, "target"), diagnostics);
272            validate_symbol_value(status, node.span, child(&root, "status"), diagnostics);
273        }
274        NodeKind::Perspective(value) => validate_symbol_value(
275            &value.target,
276            node.span,
277            child(&root, "target"),
278            diagnostics,
279        ),
280        NodeKind::Affect {
281            target,
282            affect_kind,
283            ..
284        } => {
285            validate_symbol_value(target, node.span, child(&root, "target"), diagnostics);
286            validate_symbol_value(
287                affect_kind,
288                node.span,
289                child(&root, "affect_kind"),
290                diagnostics,
291            );
292        }
293        NodeKind::Claim { .. }
294        | NodeKind::Goal { .. }
295        | NodeKind::Constraint { .. }
296        | NodeKind::Question { .. }
297        | NodeKind::Desire { .. }
298        | NodeKind::Attention { .. }
299        | NodeKind::Association
300        | NodeKind::Tension { .. }
301        | NodeKind::Prediction { .. }
302        | NodeKind::Metacognition { .. } => {}
303    }
304    validate_form(&node.payload, child(&root, "payload"), diagnostics);
305}
306
307fn validate_relation_canonical(
308    relation: &Relation,
309    root: Vec<String>,
310    diagnostics: &mut Vec<Diagnostic>,
311) {
312    validate_symbol_value(
313        &relation.source,
314        relation.span,
315        child(&root, "source"),
316        diagnostics,
317    );
318    validate_symbol_value(
319        &relation.target,
320        relation.span,
321        child(&root, "target"),
322        diagnostics,
323    );
324}
325
326fn validate_focus_frame_canonical(
327    frame: &FocusFrame,
328    root: Vec<String>,
329    diagnostics: &mut Vec<Diagnostic>,
330) {
331    validate_symbol_value(
332        &frame.stream_id,
333        frame.span,
334        child(&root, "stream_id"),
335        diagnostics,
336    );
337    validate_symbol_value(&frame.owner, frame.span, child(&root, "owner"), diagnostics);
338    for (bucket_name, bucket) in [
339        ("fg", &frame.foreground),
340        ("bg", &frame.background),
341        ("residue", &frame.residue),
342    ] {
343        for (index, reference) in bucket.iter().enumerate() {
344            let mut path = child(&root, bucket_name);
345            path.push(index.to_string());
346            validate_symbol_value(reference, frame.span, path, diagnostics);
347        }
348    }
349    validate_form(&frame.trigger, child(&root, "trigger"), diagnostics);
350}
351
352fn validate_form(form: &Form, path: Vec<String>, diagnostics: &mut Vec<Diagnostic>) {
353    match &form.kind {
354        FormKind::Atom(Atom::Symbol(value)) => {
355            validate_symbol_value(value, form.span, path, diagnostics)
356        }
357        FormKind::Atom(Atom::String(value)) => {
358            validate_text_value(value, form.span, path, diagnostics)
359        }
360        FormKind::Atom(Atom::Integer(value)) if !is_canonical_integer(value) => {
361            diagnostics.push(error(
362                "noncanonical-integer",
363                form.span,
364                path,
365                format!("integer `{value}` does not have canonical spelling"),
366                Some("write the integer without redundant leading zeros".into()),
367            ));
368        }
369        FormKind::Atom(Atom::Decimal(value)) if !is_canonical_decimal(value) => {
370            diagnostics.push(error(
371                "noncanonical-decimal",
372                form.span,
373                path,
374                format!("decimal `{value}` does not have canonical spelling"),
375                Some("remove redundant leading and trailing zeros".into()),
376            ));
377        }
378        FormKind::List(items) => {
379            for (index, item) in items.iter().enumerate() {
380                let mut item_path = path.clone();
381                item_path.push(index.to_string());
382                validate_form(item, item_path, diagnostics);
383            }
384        }
385        FormKind::Atom(Atom::Integer(_) | Atom::Decimal(_)) => {}
386    }
387}
388
389fn validate_symbol_value(
390    value: &str,
391    span: Span,
392    path: Vec<String>,
393    diagnostics: &mut Vec<Diagnostic>,
394) {
395    if !is_canonical_symbol(value) {
396        diagnostics.push(error(
397            "noncanonical-symbol",
398            span,
399            path,
400            format!("symbol `{value}` is not round-trip-safe canonical syntax"),
401            Some("use an NFC symbol that is neither numeric nor delimiter-containing".into()),
402        ));
403    }
404}
405
406fn validate_text_value(
407    value: &str,
408    span: Span,
409    path: Vec<String>,
410    diagnostics: &mut Vec<Diagnostic>,
411) {
412    if !is_canonical_text(value) {
413        diagnostics.push(error(
414            "noncanonical-text",
415            span,
416            path,
417            "text is not NFC-normalized",
418            Some("normalize text to Unicode NFC before encoding".into()),
419        ));
420    }
421}
422
423fn child(root: &[String], field: &str) -> Vec<String> {
424    let mut path = root.to_vec();
425    path.push(field.into());
426    path
427}
428
429fn empty_span() -> Span {
430    Span {
431        start: 0,
432        end: 0,
433        start_line: 1,
434        start_column: 1,
435        end_line: 1,
436        end_column: 1,
437    }
438}
439
440fn validate_entity_reference(
441    nodes_by_id: &HashMap<&str, &Node>,
442    node: &Node,
443    reference: &str,
444    field: &str,
445    undefined_code: &str,
446    wrong_kind_code: &str,
447    diagnostics: &mut Vec<Diagnostic>,
448) {
449    let path = vec!["nodes".into(), node.id.clone(), field.into()];
450    match nodes_by_id.get(reference) {
451        None => diagnostics.push(error(
452            undefined_code,
453            node.span,
454            path,
455            format!("{field} `{reference}` does not name an existing entity node"),
456            None,
457        )),
458        Some(target) if !matches!(target.kind, NodeKind::Entity { .. }) => {
459            diagnostics.push(error(
460                wrong_kind_code,
461                node.span,
462                path,
463                format!("{field} `{reference}` names a non-entity node"),
464                Some("refer to the stable ID of an entity node".into()),
465            ));
466        }
467        Some(_) => {}
468    }
469}
470
471fn relation_endpoints_allowed(kind: RelationKind, source: &NodeKind, target: &NodeKind) -> bool {
472    match kind {
473        RelationKind::Supports | RelationKind::Contradicts => {
474            matches!(source, NodeKind::Evidence { .. } | NodeKind::Claim { .. })
475                && matches!(target, NodeKind::Claim { .. } | NodeKind::Prediction { .. })
476        }
477        RelationKind::Refines | RelationKind::Supersedes => {
478            std::mem::discriminant(source) == std::mem::discriminant(target)
479        }
480        RelationKind::Causes => matches!(
481            target,
482            NodeKind::Claim { .. }
483                | NodeKind::Goal { .. }
484                | NodeKind::Desire { .. }
485                | NodeKind::Affect { .. }
486                | NodeKind::Prediction { .. }
487                | NodeKind::Tension { .. }
488        ),
489        RelationKind::About => matches!(target, NodeKind::Entity { .. }),
490        RelationKind::Blocks => {
491            matches!(
492                source,
493                NodeKind::Constraint { .. } | NodeKind::Tension { .. } | NodeKind::Question { .. }
494            ) && matches!(target, NodeKind::Goal { .. } | NodeKind::Desire { .. })
495        }
496        RelationKind::Associates => true,
497    }
498}
499
500fn validate_supersession_cycles(
501    state: &CognitiveState,
502    nodes_by_id: &HashMap<&str, &Node>,
503    diagnostics: &mut Vec<Diagnostic>,
504) {
505    let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
506
507    for relation in &state.relations {
508        if relation.kind != RelationKind::Supersedes {
509            continue;
510        }
511        let (Some(source), Some(target)) = (
512            nodes_by_id.get(relation.source.as_str()),
513            nodes_by_id.get(relation.target.as_str()),
514        ) else {
515            continue;
516        };
517        if !relation_endpoints_allowed(relation.kind, &source.kind, &target.kind) {
518            continue;
519        }
520
521        if reachable(
522            &adjacency,
523            relation.target.as_str(),
524            relation.source.as_str(),
525        ) {
526            diagnostics.push(error(
527                "cyclic-supersession",
528                relation.span,
529                relation_path(relation),
530                format!(
531                    "supersession from `{}` to `{}` closes a cycle",
532                    relation.source, relation.target
533                ),
534                Some("supersedes relations must form an acyclic graph".into()),
535            ));
536        } else {
537            adjacency
538                .entry(relation.source.as_str())
539                .or_default()
540                .push(relation.target.as_str());
541        }
542    }
543}
544
545fn reachable(adjacency: &HashMap<&str, Vec<&str>>, start: &str, target: &str) -> bool {
546    if start == target {
547        return true;
548    }
549    let mut pending = vec![start];
550    let mut visited = HashSet::new();
551    while let Some(current) = pending.pop() {
552        if !visited.insert(current) {
553            continue;
554        }
555        if let Some(neighbors) = adjacency.get(current) {
556            for neighbor in neighbors {
557                if *neighbor == target {
558                    return true;
559                }
560                pending.push(neighbor);
561            }
562        }
563    }
564    false
565}
566
567fn relation_path(relation: &Relation) -> Vec<String> {
568    vec![
569        "relations".into(),
570        relation.kind.as_str().into(),
571        relation.source.clone(),
572        relation.target.clone(),
573    ]
574}
575
576fn node_kind_name(kind: &NodeKind) -> &'static str {
577    match kind {
578        NodeKind::Entity { .. } => "entity",
579        NodeKind::Claim { .. } => "claim",
580        NodeKind::Evidence { .. } => "evidence",
581        NodeKind::Goal { .. } => "goal",
582        NodeKind::Constraint { .. } => "constraint",
583        NodeKind::Question { .. } => "question",
584        NodeKind::Relationship { .. } => "relationship",
585        NodeKind::Desire { .. } => "desire",
586        NodeKind::Attention { .. } => "attention",
587        NodeKind::Association => "association",
588        NodeKind::Tension { .. } => "tension",
589        NodeKind::Prediction { .. } => "prediction",
590        NodeKind::Perspective(_) => "perspective",
591        NodeKind::Affect { .. } => "affect",
592        NodeKind::Metacognition { .. } => "metacognition",
593    }
594}