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
149pub 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}