1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Component, Path};
4
5use chappi_syntax::parse_document;
6use serde::{Deserialize, Serialize};
7
8use crate::patch::AddValue;
9use crate::{
10 BudgetUnit, CognitiveState, ConstraintStrength, Diagnostic, FocusCue, GoalStatus,
11 LexicalTokenEstimator, Node, NodeKind, Patch, PatchOp, ProjectionFormat, QuestionStatus,
12 ReentryCapsule, Source, TensionStatus, TokenEstimator, apply_patch, project, snapshot_source,
13 state_hash, validate, validate_patch,
14};
15
16const CORPUS_SCHEMA: &str = "chappi.corpus";
17const EPISODE_SCHEMA: &str = "chappi.episode";
18const MEASUREMENT_SCHEMA: &str = "chappi.measurement";
19const VERSION: u32 = 1;
20const FACT_ONTOLOGY_VERSION: u32 = 1;
21const CONTROLLED_ENGLISH_VERSION: u32 = 1;
22const EPISODE_COUNT: usize = 24;
23const KNOWN_DIAGNOSTIC_CODES: &[&str] = &[
24 "cyclic-supersession",
25 "duplicate-focus-reference",
26 "duplicate-focus-stream",
27 "duplicate-header",
28 "duplicate-node-id",
29 "duplicate-patch-header",
30 "duplicate-relation",
31 "duplicate-revision",
32 "empty-focus-stream",
33 "expected-symbol",
34 "focus-stream-mismatch",
35 "invalid-add-arity",
36 "invalid-add-payload",
37 "invalid-add-revision",
38 "invalid-affect-intensity",
39 "invalid-append-focus-arity",
40 "invalid-append-focus-payload",
41 "invalid-attention-mode",
42 "invalid-character",
43 "invalid-confidence",
44 "invalid-constraint-strength",
45 "invalid-desire-force",
46 "invalid-entity-target-kind",
47 "invalid-escape",
48 "invalid-evidence-source",
49 "invalid-expected-revision",
50 "invalid-focus-append",
51 "invalid-focus-arity",
52 "invalid-focus-bucket",
53 "invalid-focus-owner-kind",
54 "invalid-focus-sequence",
55 "invalid-focus-sequence-number",
56 "invalid-focus-start",
57 "invalid-focus-transition",
58 "invalid-goal-status",
59 "invalid-header",
60 "invalid-lifetime",
61 "invalid-metacognition-status",
62 "invalid-node-arity",
63 "invalid-owner-kind",
64 "invalid-patch-base-hash",
65 "invalid-patch-header",
66 "invalid-patch-top-level-form",
67 "invalid-patch-version",
68 "invalid-prediction-status",
69 "invalid-question-status",
70 "invalid-relation-arity",
71 "invalid-relation-endpoints",
72 "invalid-relation-kind",
73 "invalid-retire-arity",
74 "invalid-retire-node-id",
75 "invalid-revise-arity",
76 "invalid-revise-payload",
77 "invalid-revision",
78 "invalid-revision-node",
79 "invalid-salience",
80 "invalid-tension-status",
81 "invalid-top-level-form",
82 "invalid-unicode-escape",
83 "invalid-version",
84 "missing-header",
85 "missing-patch-header",
86 "missing-perspective",
87 "nesting-depth-exceeded",
88 "node-still-referenced",
89 "noncanonical-decimal",
90 "noncanonical-integer",
91 "noncanonical-symbol",
92 "noncanonical-text",
93 "overlapping-focus-reference",
94 "revision-conflict",
95 "revision-overflow",
96 "stale-patch-base",
97 "unclosed-list",
98 "unclosed-string",
99 "undefined-entity-target",
100 "undefined-focus-owner",
101 "undefined-focus-reference",
102 "undefined-owner",
103 "undefined-relation-source",
104 "undefined-relation-target",
105 "unexpected-close-paren",
106 "unexpected-eof",
107 "unknown-node-id",
108 "unknown-patch-operation",
109 "unknown-revision-node",
110 "unknown-top-level-form",
111];
112
113#[derive(Clone, Debug, Deserialize, Serialize)]
114pub struct Corpus {
115 pub schema: String,
116 pub version: u32,
117 pub episodes: Vec<Episode>,
118}
119
120#[derive(Clone, Debug, Deserialize, Serialize)]
121#[serde(deny_unknown_fields)]
122pub struct Episode {
123 pub schema: String,
124 pub version: u32,
125 pub id: String,
126 pub category: EpisodeCategory,
127 pub snapshot_source: String,
128 pub patch_sources: Vec<String>,
129 pub natural_language_baseline: String,
130 pub capsule_budget_bytes: usize,
131 #[serde(default)]
132 pub comparison: ComparisonFixture,
133 pub golden: GoldenAnswers,
134 pub recovery_questions: Vec<RecoveryQuestion>,
135 pub seeded_invalid_sources: Vec<SeededInvalidSource>,
136}
137
138#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
139#[serde(deny_unknown_fields)]
140pub struct ComparisonFixture {
141 pub fact_ontology_version: u32,
142 pub controlled_english_version: u32,
143 pub selected_node_ids: Vec<String>,
144 pub atomic_facts: Vec<AtomicFact>,
145}
146
147#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
148#[serde(rename_all = "kebab-case")]
149pub enum FactFamily {
150 PayloadKind,
151 IdentityLifecycle,
152 Relation,
153 Focus,
154}
155
156impl FactFamily {
157 const ALL: [Self; 4] = [
158 Self::PayloadKind,
159 Self::IdentityLifecycle,
160 Self::Relation,
161 Self::Focus,
162 ];
163}
164
165#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
166#[serde(rename_all = "kebab-case")]
167pub enum NodeFactField {
168 Id,
169 Revision,
170 Owner,
171 Lifetime,
172 Salience,
173 Kind,
174 EntityKind,
175 ClaimConfidence,
176 EvidenceKind,
177 EvidenceSourceType,
178 EvidenceSourceValue,
179 GoalStatus,
180 ConstraintStrength,
181 QuestionStatus,
182 RelationshipTarget,
183 RelationshipStatus,
184 DesireForce,
185 AttentionMode,
186 TensionStatus,
187 PredictionConfidence,
188 PredictionStatus,
189 PerspectiveTarget,
190 PerspectiveConfidence,
191 AffectTarget,
192 AffectKind,
193 AffectIntensity,
194 MetacognitionStatus,
195 Payload,
196}
197
198impl NodeFactField {
199 fn family(self) -> FactFamily {
200 match self {
201 Self::Id | Self::Revision | Self::Owner | Self::Lifetime | Self::Salience => {
202 FactFamily::IdentityLifecycle
203 }
204 _ => FactFamily::PayloadKind,
205 }
206 }
207
208 fn key(self) -> &'static str {
209 match self {
210 Self::Id => "id",
211 Self::Revision => "revision",
212 Self::Owner => "owner",
213 Self::Lifetime => "lifetime",
214 Self::Salience => "salience",
215 Self::Kind => "kind",
216 Self::EntityKind => "entity-kind",
217 Self::ClaimConfidence => "claim-confidence",
218 Self::EvidenceKind => "evidence-kind",
219 Self::EvidenceSourceType => "evidence-source-type",
220 Self::EvidenceSourceValue => "evidence-source-value",
221 Self::GoalStatus => "goal-status",
222 Self::ConstraintStrength => "constraint-strength",
223 Self::QuestionStatus => "question-status",
224 Self::RelationshipTarget => "relationship-target",
225 Self::RelationshipStatus => "relationship-status",
226 Self::DesireForce => "desire-force",
227 Self::AttentionMode => "attention-mode",
228 Self::TensionStatus => "tension-status",
229 Self::PredictionConfidence => "prediction-confidence",
230 Self::PredictionStatus => "prediction-status",
231 Self::PerspectiveTarget => "perspective-target",
232 Self::PerspectiveConfidence => "perspective-confidence",
233 Self::AffectTarget => "affect-target",
234 Self::AffectKind => "affect-kind",
235 Self::AffectIntensity => "affect-intensity",
236 Self::MetacognitionStatus => "metacognition-status",
237 Self::Payload => "payload",
238 }
239 }
240}
241
242#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
243#[serde(rename_all = "kebab-case")]
244pub enum FocusFactField {
245 StreamId,
246 Sequence,
247 Owner,
248 Transition,
249 Trigger,
250}
251
252impl FocusFactField {
253 fn key(self) -> &'static str {
254 match self {
255 Self::StreamId => "stream",
256 Self::Sequence => "sequence",
257 Self::Owner => "owner",
258 Self::Transition => "transition",
259 Self::Trigger => "trigger",
260 }
261 }
262}
263
264#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
265#[serde(rename_all = "kebab-case")]
266pub enum FocusBucket {
267 Foreground,
268 Background,
269 Residue,
270}
271
272impl FocusBucket {
273 fn key(self) -> &'static str {
274 match self {
275 Self::Foreground => "foreground",
276 Self::Background => "background",
277 Self::Residue => "residue",
278 }
279 }
280}
281
282#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
283#[serde(tag = "fact", rename_all = "kebab-case", deny_unknown_fields)]
284pub enum AtomicFact {
285 Node {
286 node_id: String,
287 field: NodeFactField,
288 value: String,
289 },
290 Relation {
291 kind: String,
292 source: String,
293 target: String,
294 },
295 Focus {
296 stream_id: String,
297 sequence: u64,
298 field: FocusFactField,
299 value: String,
300 },
301 FocusBucketMember {
302 stream_id: String,
303 sequence: u64,
304 bucket: FocusBucket,
305 node_id: String,
306 },
307 FocusBucketEmpty {
308 stream_id: String,
309 sequence: u64,
310 bucket: FocusBucket,
311 },
312}
313
314impl AtomicFact {
315 pub fn family(&self) -> FactFamily {
316 match self {
317 Self::Node { field, .. } => field.family(),
318 Self::Relation { .. } => FactFamily::Relation,
319 Self::Focus { .. } | Self::FocusBucketMember { .. } | Self::FocusBucketEmpty { .. } => {
320 FactFamily::Focus
321 }
322 }
323 }
324}
325
326#[derive(Clone, Debug)]
327pub struct DerivedComparison {
328 pub fixture: ComparisonFixture,
329 pub canonical_capsule: String,
330 pub natural_language_baseline: String,
331 pub capsule_budget_bytes: usize,
332}
333
334#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
335#[serde(rename_all = "kebab-case")]
336pub enum EpisodeCategory {
337 Technical,
338 Interpersonal,
339 AffectiveMemory,
340 Tension,
341 PredictionSurprise,
342 InterruptedResumption,
343}
344
345impl EpisodeCategory {
346 const ALL: [Self; 6] = [
347 Self::Technical,
348 Self::Interpersonal,
349 Self::AffectiveMemory,
350 Self::Tension,
351 Self::PredictionSurprise,
352 Self::InterruptedResumption,
353 ];
354}
355
356#[derive(Clone, Debug, Deserialize, Serialize)]
357#[serde(deny_unknown_fields)]
358pub struct GoldenAnswers {
359 pub required_constraint_ids: Vec<String>,
360 pub committed_goal_ids: Vec<String>,
361 pub active_question_ids: Vec<String>,
362 pub active_tension_ids: Vec<String>,
363 pub owner_target_expectations: Vec<OwnerTargetExpectation>,
364}
365
366#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
367#[serde(rename_all = "kebab-case")]
368pub enum AnswerPolarity {
369 Affirmed,
370 Negated,
371}
372
373#[derive(Clone, Debug, Deserialize, Serialize)]
374#[serde(deny_unknown_fields)]
375pub struct SemanticAnswer {
376 pub value: String,
377 #[serde(default)]
378 pub aliases: Vec<String>,
379 pub qualifiers: Vec<Vec<String>>,
380 pub polarity: AnswerPolarity,
381}
382
383#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
384#[serde(rename_all = "kebab-case")]
385pub enum RecoveryFactKind {
386 OwnerContext,
387 RequiredConstraint,
388 CommittedGoal,
389 ActiveQuestion,
390 ActiveTension,
391}
392
393#[derive(Clone, Debug, Deserialize, Serialize)]
394#[serde(deny_unknown_fields)]
395pub struct RecoveryQuestion {
396 pub id: String,
397 pub prompt: String,
398 pub fact_kind: RecoveryFactKind,
399 pub covered_node_id: String,
400 pub expected: SemanticAnswer,
401}
402
403#[derive(Clone, Debug, Deserialize, Serialize)]
404#[serde(deny_unknown_fields)]
405pub struct OwnerTargetExpectation {
406 pub node_id: String,
407 pub owner: String,
408 pub target: Option<String>,
409}
410
411#[derive(Clone, Debug, Deserialize, Serialize)]
412#[serde(deny_unknown_fields)]
413pub struct SeededInvalidSource {
414 pub source: String,
415 pub expected_diagnostic_codes: Vec<String>,
416}
417
418#[derive(Clone, Copy, Debug, Eq, PartialEq)]
419pub enum CorpusErrorKind {
420 Io,
421 Invalid,
422}
423
424#[derive(Clone, Debug)]
425pub struct CorpusError {
426 pub kind: CorpusErrorKind,
427 pub diagnostics: Vec<CorpusDiagnostic>,
428}
429
430#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
431pub struct CorpusDiagnostic {
432 pub code: String,
433 pub path: String,
434 pub message: String,
435}
436
437#[derive(Clone, Debug, Serialize)]
438pub struct MeasurementReport {
439 pub schema: &'static str,
440 pub version: u32,
441 pub episodes: usize,
442 pub semantic: SemanticMetrics,
443 pub continuity: ContinuityMetrics,
444 pub precision: PrecisionMetrics,
445 pub compression: CompressionMetrics,
446 pub gates: Vec<GateResult>,
447 pub passed: bool,
448}
449
450#[derive(Clone, Debug, Serialize)]
451pub struct SemanticMetrics {
452 pub loaded: usize,
453 pub replayed: usize,
454 pub replay_ratio: f64,
455}
456
457#[derive(Clone, Debug, Serialize)]
458pub struct ContinuityMetrics {
459 pub constraints_and_committed_goals: f64,
460 pub active_questions_and_tensions: f64,
461 pub owner_target_expectations: f64,
462}
463
464#[derive(Clone, Debug, Serialize)]
465pub struct PrecisionMetrics {
466 pub seeded_diagnostics: f64,
467 pub chappi_task_accuracy: f64,
468 pub baseline_task_accuracy: f64,
469 pub task_accuracy_advantage: f64,
470 pub fact_families: Vec<FactFamilyMetrics>,
471}
472
473#[derive(Clone, Debug, Serialize)]
474pub struct FactFamilyMetrics {
475 pub family: FactFamily,
476 pub total: usize,
477 pub chappi_hits: usize,
478 pub baseline_hits: usize,
479 pub chappi_accuracy: Option<f64>,
480 pub baseline_accuracy: Option<f64>,
481 pub included_in_macro: bool,
482}
483
484#[derive(Clone, Debug)]
485pub struct FactRecoveryScore {
486 pub families: Vec<FactFamilyMetrics>,
487 pub chappi_macro_accuracy: f64,
488 pub baseline_macro_accuracy: f64,
489}
490
491pub fn score_fact_recovery(
492 expected: &[AtomicFact],
493 chappi: &[AtomicFact],
494 baseline: &[AtomicFact],
495) -> FactRecoveryScore {
496 let expected = expected.iter().cloned().collect::<BTreeSet<_>>();
497 let chappi = chappi.iter().cloned().collect::<BTreeSet<_>>();
498 let baseline = baseline.iter().cloned().collect::<BTreeSet<_>>();
499 let mut families = Vec::new();
500 let mut chappi_sum = 0.0;
501 let mut baseline_sum = 0.0;
502 let mut included = 0usize;
503 for family in FactFamily::ALL {
504 let family_expected = expected
505 .iter()
506 .filter(|fact| fact.family() == family)
507 .collect::<BTreeSet<_>>();
508 let total = family_expected.len();
509 let chappi_hits = family_expected
510 .iter()
511 .filter(|fact| chappi.contains(**fact))
512 .count();
513 let baseline_hits = family_expected
514 .iter()
515 .filter(|fact| baseline.contains(**fact))
516 .count();
517 let chappi_accuracy = (total > 0).then(|| ratio(chappi_hits, total));
518 let baseline_accuracy = (total > 0).then(|| ratio(baseline_hits, total));
519 if let (Some(chappi_accuracy), Some(baseline_accuracy)) =
520 (chappi_accuracy, baseline_accuracy)
521 {
522 chappi_sum += chappi_accuracy;
523 baseline_sum += baseline_accuracy;
524 included += 1;
525 }
526 families.push(FactFamilyMetrics {
527 family,
528 total,
529 chappi_hits,
530 baseline_hits,
531 chappi_accuracy,
532 baseline_accuracy,
533 included_in_macro: total > 0,
534 });
535 }
536 FactRecoveryScore {
537 families,
538 chappi_macro_accuracy: if included == 0 {
539 0.0
540 } else {
541 chappi_sum / included as f64
542 },
543 baseline_macro_accuracy: if included == 0 {
544 0.0
545 } else {
546 baseline_sum / included as f64
547 },
548 }
549}
550
551#[derive(Clone, Debug, Serialize)]
552pub struct CompressionMetrics {
553 pub snapshot_to_json: f64,
554 pub capsule_to_baseline: f64,
555 pub canonical_snapshot_bytes: usize,
556 pub pretty_json_bytes: usize,
557 pub capsule_bytes: usize,
558 pub baseline_bytes: usize,
559 pub canonical_snapshot_tokens: usize,
560 pub pretty_json_tokens: usize,
561 pub capsule_tokens: usize,
562 pub baseline_tokens: usize,
563}
564
565#[derive(Clone, Debug, Serialize)]
566pub struct GateResult {
567 pub name: &'static str,
568 pub passed: bool,
569 pub actual: f64,
570 pub required: f64,
571}
572
573#[derive(Deserialize)]
574#[serde(deny_unknown_fields)]
575struct Manifest {
576 schema: String,
577 version: u32,
578 episodes: Vec<String>,
579}
580
581pub fn derive_semantic_comparison(
586 state: &CognitiveState,
587 selected_node_ids: &[String],
588) -> Result<DerivedComparison, String> {
589 let selected = selected_node_ids.iter().cloned().collect::<BTreeSet<_>>();
590 if selected.len() != selected_node_ids.len() {
591 return Err("selected node IDs must be unique".into());
592 }
593 let known = state
594 .nodes
595 .iter()
596 .map(|node| node.id.as_str())
597 .collect::<BTreeSet<_>>();
598 if let Some(id) = selected.iter().find(|id| !known.contains(id.as_str())) {
599 return Err(format!("selected node `{id}` does not exist"));
600 }
601 for node in state
602 .nodes
603 .iter()
604 .filter(|node| selected.contains(&node.id))
605 {
606 if !selected.contains(&node.owner) {
607 return Err(format!(
608 "selection omits owner `{}` for `{}`",
609 node.owner, node.id
610 ));
611 }
612 if let Some(target) = node_target(node) {
613 if !selected.contains(target) {
614 return Err(format!(
615 "selection omits target `{target}` for `{}`",
616 node.id
617 ));
618 }
619 }
620 }
621
622 let capsule = exact_capsule(state, &selected);
623 let canonical_capsule = capsule.canonical_content();
624 let atomic_facts = facts_from_capsule(&capsule);
625 let natural_language_baseline = render_controlled_english(&atomic_facts)?;
626 Ok(DerivedComparison {
627 fixture: ComparisonFixture {
628 fact_ontology_version: FACT_ONTOLOGY_VERSION,
629 controlled_english_version: CONTROLLED_ENGLISH_VERSION,
630 selected_node_ids: selected.into_iter().collect(),
631 atomic_facts,
632 },
633 capsule_budget_bytes: canonical_capsule.len(),
634 canonical_capsule,
635 natural_language_baseline,
636 })
637}
638
639fn exact_capsule(state: &CognitiveState, selected: &BTreeSet<String>) -> ReentryCapsule {
640 let mut nodes = state
641 .nodes
642 .iter()
643 .filter(|node| selected.contains(&node.id))
644 .cloned()
645 .collect::<Vec<_>>();
646 nodes.sort_by(|left, right| left.id.cmp(&right.id));
647
648 let mut relations = state
649 .relations
650 .iter()
651 .filter(|relation| {
652 selected.contains(&relation.source) && selected.contains(&relation.target)
653 })
654 .cloned()
655 .collect::<Vec<_>>();
656 relations.sort_by(|left, right| {
657 left.kind
658 .as_str()
659 .cmp(right.kind.as_str())
660 .then_with(|| left.source.cmp(&right.source))
661 .then_with(|| left.target.cmp(&right.target))
662 });
663
664 let mut focus_cues = state
665 .focus_streams
666 .iter()
667 .filter_map(|stream| stream.frames.iter().max_by_key(|frame| frame.sequence))
668 .filter(|frame| frame.transition != crate::TransitionKind::Resolved)
669 .filter(|frame| {
670 selected.contains(&frame.owner)
671 && frame
672 .foreground
673 .iter()
674 .chain(&frame.background)
675 .chain(&frame.residue)
676 .all(|id| selected.contains(id))
677 })
678 .map(|frame| FocusCue {
679 stream_id: frame.stream_id.clone(),
680 sequence: frame.sequence,
681 owner: frame.owner.clone(),
682 transition: frame.transition,
683 foreground: sorted_owned(&frame.foreground),
684 background: sorted_owned(&frame.background),
685 residue: sorted_owned(&frame.residue),
686 trigger: crate::resume::canonical_form(&frame.trigger),
687 })
688 .collect::<Vec<_>>();
689 focus_cues.sort_by(|left, right| {
690 left.stream_id
691 .cmp(&right.stream_id)
692 .then_with(|| left.sequence.cmp(&right.sequence))
693 });
694
695 let mut omitted_node_ids = state
696 .nodes
697 .iter()
698 .filter(|node| !selected.contains(&node.id))
699 .map(|node| node.id.clone())
700 .collect::<Vec<_>>();
701 omitted_node_ids.sort();
702 let mut capsule = ReentryCapsule {
703 schema_version: VERSION,
704 source_state_hash: state_hash(state),
705 nodes,
706 relations,
707 focus_cues,
708 omitted_node_ids,
709 used: 0,
710 requested_limit: 0,
711 requested_unit: BudgetUnit::Bytes,
712 over_budget: false,
713 };
714 let used = capsule.canonical_content().len();
715 capsule.used = used;
716 capsule.requested_limit = used;
717 capsule
718}
719
720fn sorted_owned(values: &[String]) -> Vec<String> {
721 let mut values = values.to_vec();
722 values.sort();
723 values
724}
725
726fn facts_from_capsule(capsule: &ReentryCapsule) -> Vec<AtomicFact> {
727 let mut facts = Vec::new();
728 for node in &capsule.nodes {
729 push_node_facts(&mut facts, node);
730 }
731 for relation in &capsule.relations {
732 facts.push(AtomicFact::Relation {
733 kind: relation.kind.as_str().into(),
734 source: relation.source.clone(),
735 target: relation.target.clone(),
736 });
737 }
738 for cue in &capsule.focus_cues {
739 facts.push(focus_scalar(cue, FocusFactField::StreamId, &cue.stream_id));
740 facts.push(focus_scalar(
741 cue,
742 FocusFactField::Sequence,
743 &cue.sequence.to_string(),
744 ));
745 facts.push(focus_scalar(cue, FocusFactField::Owner, &cue.owner));
746 facts.push(focus_scalar(
747 cue,
748 FocusFactField::Transition,
749 cue.transition.as_str(),
750 ));
751 push_bucket_facts(&mut facts, cue, FocusBucket::Foreground, &cue.foreground);
752 push_bucket_facts(&mut facts, cue, FocusBucket::Background, &cue.background);
753 push_bucket_facts(&mut facts, cue, FocusBucket::Residue, &cue.residue);
754 facts.push(focus_scalar(cue, FocusFactField::Trigger, &cue.trigger));
755 }
756 facts
757}
758
759fn push_node_facts(facts: &mut Vec<AtomicFact>, node: &Node) {
760 let mut push = |field, value: String| {
761 facts.push(AtomicFact::Node {
762 node_id: node.id.clone(),
763 field,
764 value,
765 });
766 };
767 push(NodeFactField::Id, node.id.clone());
768 push(NodeFactField::Revision, node.revision.to_string());
769 push(NodeFactField::Owner, node.owner.clone());
770 push(
771 NodeFactField::Lifetime,
772 crate::resume::lifetime_text(node.lifetime).into(),
773 );
774 push(NodeFactField::Salience, node.salience.to_string());
775 push(NodeFactField::Kind, node_kind_name(&node.kind).into());
776 match &node.kind {
777 NodeKind::Entity { entity_kind } => push(NodeFactField::EntityKind, entity_kind.clone()),
778 NodeKind::Claim { confidence } => push(
779 NodeFactField::ClaimConfidence,
780 crate::resume::confidence_text(*confidence).into(),
781 ),
782 NodeKind::Evidence {
783 evidence_kind,
784 source,
785 } => {
786 push(NodeFactField::EvidenceKind, evidence_kind.clone());
787 push(
788 NodeFactField::EvidenceSourceType,
789 match source {
790 Source::Symbol(_) => "symbol",
791 Source::String(_) => "string",
792 }
793 .into(),
794 );
795 push(
796 NodeFactField::EvidenceSourceValue,
797 match source {
798 Source::Symbol(value) | Source::String(value) => value.clone(),
799 },
800 );
801 }
802 NodeKind::Goal { status } => push(
803 NodeFactField::GoalStatus,
804 crate::resume::goal_status(*status).into(),
805 ),
806 NodeKind::Constraint { strength } => push(
807 NodeFactField::ConstraintStrength,
808 crate::resume::constraint_text(*strength).into(),
809 ),
810 NodeKind::Question { status } => push(
811 NodeFactField::QuestionStatus,
812 crate::resume::question_text(*status).into(),
813 ),
814 NodeKind::Relationship { target, status } => {
815 push(NodeFactField::RelationshipTarget, target.clone());
816 push(NodeFactField::RelationshipStatus, status.clone());
817 }
818 NodeKind::Desire { force } => push(
819 NodeFactField::DesireForce,
820 crate::resume::desire_text(*force).into(),
821 ),
822 NodeKind::Attention { mode } => push(
823 NodeFactField::AttentionMode,
824 crate::resume::attention_text(*mode).into(),
825 ),
826 NodeKind::Association => {}
827 NodeKind::Tension { status } => push(
828 NodeFactField::TensionStatus,
829 crate::resume::tension_text(*status).into(),
830 ),
831 NodeKind::Prediction { confidence, status } => {
832 push(
833 NodeFactField::PredictionConfidence,
834 crate::resume::confidence_text(*confidence).into(),
835 );
836 push(
837 NodeFactField::PredictionStatus,
838 crate::resume::prediction_text(*status).into(),
839 );
840 }
841 NodeKind::Perspective(value) => {
842 push(NodeFactField::PerspectiveTarget, value.target.clone());
843 push(
844 NodeFactField::PerspectiveConfidence,
845 crate::resume::confidence_text(value.confidence).into(),
846 );
847 }
848 NodeKind::Affect {
849 target,
850 affect_kind,
851 intensity,
852 } => {
853 push(NodeFactField::AffectTarget, target.clone());
854 push(NodeFactField::AffectKind, affect_kind.clone());
855 push(
856 NodeFactField::AffectIntensity,
857 crate::resume::affect_text(*intensity).into(),
858 );
859 }
860 NodeKind::Metacognition { status } => push(
861 NodeFactField::MetacognitionStatus,
862 crate::resume::metacognition_text(*status).into(),
863 ),
864 }
865 push(
866 NodeFactField::Payload,
867 crate::resume::canonical_form(&node.payload),
868 );
869}
870
871fn focus_scalar(cue: &FocusCue, field: FocusFactField, value: &str) -> AtomicFact {
872 AtomicFact::Focus {
873 stream_id: cue.stream_id.clone(),
874 sequence: cue.sequence,
875 field,
876 value: value.into(),
877 }
878}
879
880fn push_bucket_facts(
881 facts: &mut Vec<AtomicFact>,
882 cue: &FocusCue,
883 bucket: FocusBucket,
884 ids: &[String],
885) {
886 if ids.is_empty() {
887 facts.push(AtomicFact::FocusBucketEmpty {
888 stream_id: cue.stream_id.clone(),
889 sequence: cue.sequence,
890 bucket,
891 });
892 } else {
893 facts.extend(ids.iter().map(|id| AtomicFact::FocusBucketMember {
894 stream_id: cue.stream_id.clone(),
895 sequence: cue.sequence,
896 bucket,
897 node_id: id.clone(),
898 }));
899 }
900}
901
902fn render_controlled_english(facts: &[AtomicFact]) -> Result<String, String> {
903 let mut records = Vec::new();
904 let mut index = 0;
905 while index < facts.len() {
906 match &facts[index] {
907 AtomicFact::Node { node_id, .. } => {
908 let start = index;
909 while index < facts.len()
910 && matches!(&facts[index], AtomicFact::Node { node_id: id, .. } if id == node_id)
911 {
912 index += 1;
913 }
914 records.push(render_node_record(&facts[start..index])?);
915 }
916 AtomicFact::Relation {
917 kind,
918 source,
919 target,
920 } => {
921 records.push(format!(
922 "Relation kind={kind} source={} target={}.",
923 quote(source),
924 quote(target)
925 ));
926 index += 1;
927 }
928 AtomicFact::Focus {
929 stream_id,
930 sequence,
931 ..
932 }
933 | AtomicFact::FocusBucketMember {
934 stream_id,
935 sequence,
936 ..
937 }
938 | AtomicFact::FocusBucketEmpty {
939 stream_id,
940 sequence,
941 ..
942 } => {
943 let start = index;
944 while index < facts.len() && same_focus_record(&facts[index], stream_id, *sequence)
945 {
946 index += 1;
947 }
948 records.push(render_focus_record(&facts[start..index])?);
949 }
950 }
951 }
952 Ok(records.join(" "))
953}
954
955fn render_node_record(facts: &[AtomicFact]) -> Result<String, String> {
956 let mut output = String::from("Node");
957 let mut seen = BTreeSet::new();
958 let mut node_id = None;
959 for fact in facts {
960 let AtomicFact::Node {
961 node_id: id,
962 field,
963 value,
964 } = fact
965 else {
966 return Err("node record contains a non-node fact".into());
967 };
968 if node_id.get_or_insert(id) != &id {
969 return Err("node record mixes node IDs".into());
970 }
971 if !seen.insert(*field) {
972 return Err(format!("node `{id}` duplicates `{}`", field.key()));
973 }
974 output.push(' ');
975 output.push_str(field.key());
976 output.push('=');
977 if node_field_is_quoted(*field) {
978 output.push_str("e(value));
979 } else {
980 output.push_str(value);
981 }
982 }
983 output.push('.');
984 Ok(output)
985}
986
987fn render_focus_record(facts: &[AtomicFact]) -> Result<String, String> {
988 let (stream_id, sequence) = focus_key(
989 facts
990 .first()
991 .ok_or_else(|| "focus record must not be empty".to_string())?,
992 )
993 .ok_or_else(|| "focus record must start with a focus fact".to_string())?;
994 let mut scalars = BTreeMap::new();
995 let mut buckets = BTreeMap::<FocusBucket, Vec<String>>::new();
996 let mut empty = BTreeSet::new();
997 for fact in facts {
998 if focus_key(fact) != Some((stream_id, sequence)) {
999 return Err("focus record mixes keys".into());
1000 }
1001 match fact {
1002 AtomicFact::Focus { field, value, .. } => {
1003 if scalars.insert(*field, value.as_str()).is_some() {
1004 return Err(format!("focus record duplicates `{}`", field.key()));
1005 }
1006 }
1007 AtomicFact::FocusBucketMember {
1008 bucket, node_id, ..
1009 } => buckets.entry(*bucket).or_default().push(node_id.clone()),
1010 AtomicFact::FocusBucketEmpty { bucket, .. } => {
1011 empty.insert(*bucket);
1012 }
1013 AtomicFact::Node { .. } | AtomicFact::Relation { .. } => unreachable!(),
1014 }
1015 }
1016 for bucket in FocusBucket::ALL {
1017 let members = buckets.entry(bucket).or_default();
1018 members.sort();
1019 if members.is_empty() != empty.contains(&bucket) {
1020 return Err(format!(
1021 "focus bucket `{}` must have members or one emptiness fact",
1022 bucket.key()
1023 ));
1024 }
1025 }
1026 let scalar = |field| {
1027 scalars
1028 .get(&field)
1029 .copied()
1030 .ok_or_else(|| format!("focus record omits `{}`", field.key()))
1031 };
1032 if scalar(FocusFactField::StreamId)? != stream_id
1033 || scalar(FocusFactField::Sequence)? != sequence.to_string()
1034 {
1035 return Err("focus key facts do not match their record key".into());
1036 }
1037 Ok(format!(
1038 "Focus stream={} sequence={} owner={} transition={} foreground={} background={} residue={} trigger={}.",
1039 quote(stream_id),
1040 sequence,
1041 quote(scalar(FocusFactField::Owner)?),
1042 scalar(FocusFactField::Transition)?,
1043 serde_json::to_string(&buckets[&FocusBucket::Foreground]).expect("strings serialize"),
1044 serde_json::to_string(&buckets[&FocusBucket::Background]).expect("strings serialize"),
1045 serde_json::to_string(&buckets[&FocusBucket::Residue]).expect("strings serialize"),
1046 quote(scalar(FocusFactField::Trigger)?),
1047 ))
1048}
1049
1050impl FocusBucket {
1051 const ALL: [Self; 3] = [Self::Foreground, Self::Background, Self::Residue];
1052}
1053
1054fn same_focus_record(fact: &AtomicFact, stream_id: &str, sequence: u64) -> bool {
1055 focus_key(fact) == Some((stream_id, sequence))
1056}
1057
1058fn focus_key(fact: &AtomicFact) -> Option<(&str, u64)> {
1059 match fact {
1060 AtomicFact::Focus {
1061 stream_id,
1062 sequence,
1063 ..
1064 }
1065 | AtomicFact::FocusBucketMember {
1066 stream_id,
1067 sequence,
1068 ..
1069 }
1070 | AtomicFact::FocusBucketEmpty {
1071 stream_id,
1072 sequence,
1073 ..
1074 } => Some((stream_id, *sequence)),
1075 AtomicFact::Node { .. } | AtomicFact::Relation { .. } => None,
1076 }
1077}
1078
1079fn node_field_is_quoted(field: NodeFactField) -> bool {
1080 matches!(
1081 field,
1082 NodeFactField::Id
1083 | NodeFactField::Owner
1084 | NodeFactField::EntityKind
1085 | NodeFactField::EvidenceKind
1086 | NodeFactField::EvidenceSourceValue
1087 | NodeFactField::RelationshipTarget
1088 | NodeFactField::RelationshipStatus
1089 | NodeFactField::PerspectiveTarget
1090 | NodeFactField::AffectTarget
1091 | NodeFactField::AffectKind
1092 | NodeFactField::Payload
1093 )
1094}
1095
1096fn quote(value: &str) -> String {
1097 serde_json::to_string(value).expect("strings serialize")
1098}
1099
1100pub fn decode_controlled_english_facts(input: &str) -> Vec<AtomicFact> {
1103 complete_sentences(input)
1104 .into_iter()
1105 .filter_map(decode_controlled_record)
1106 .flatten()
1107 .collect()
1108}
1109
1110fn complete_sentences(input: &str) -> Vec<&str> {
1111 let mut records = Vec::new();
1112 let mut start = 0;
1113 let mut quoted = false;
1114 let mut escaped = false;
1115 for (index, character) in input.char_indices() {
1116 if quoted {
1117 if escaped {
1118 escaped = false;
1119 } else if character == '\\' {
1120 escaped = true;
1121 } else if character == '"' {
1122 quoted = false;
1123 }
1124 } else if character == '"' {
1125 quoted = true;
1126 } else if character == '.' {
1127 records.push(input[start..=index].trim());
1128 start = index + 1;
1129 }
1130 }
1131 records
1132}
1133
1134fn decode_controlled_record(record: &str) -> Option<Vec<AtomicFact>> {
1135 let body = record.strip_suffix('.')?;
1136 let tokens = split_controlled_tokens(body)?;
1137 let (head, fields) = tokens.split_first()?;
1138 match head.as_str() {
1139 "Node" => decode_controlled_node(fields),
1140 "Relation" => decode_controlled_relation(fields),
1141 "Focus" => decode_controlled_focus(fields),
1142 _ => None,
1143 }
1144}
1145
1146fn split_controlled_tokens(input: &str) -> Option<Vec<String>> {
1147 let mut tokens = Vec::new();
1148 let mut start = None;
1149 let mut quoted = false;
1150 let mut escaped = false;
1151 let mut brackets = 0usize;
1152 for (index, character) in input.char_indices() {
1153 if quoted {
1154 if escaped {
1155 escaped = false;
1156 } else if character == '\\' {
1157 escaped = true;
1158 } else if character == '"' {
1159 quoted = false;
1160 }
1161 } else {
1162 match character {
1163 '"' => quoted = true,
1164 '[' => brackets += 1,
1165 ']' => brackets = brackets.checked_sub(1)?,
1166 ' ' if brackets == 0 => {
1167 if let Some(begin) = start.take() {
1168 tokens.push(input[begin..index].to_string());
1169 }
1170 continue;
1171 }
1172 _ => {}
1173 }
1174 }
1175 start.get_or_insert(index);
1176 }
1177 if quoted || brackets != 0 {
1178 return None;
1179 }
1180 if let Some(begin) = start {
1181 tokens.push(input[begin..].to_string());
1182 }
1183 Some(tokens)
1184}
1185
1186fn key_values(fields: &[String]) -> Option<Vec<(&str, &str)>> {
1187 fields.iter().map(|field| field.split_once('=')).collect()
1188}
1189
1190fn decode_controlled_node(fields: &[String]) -> Option<Vec<AtomicFact>> {
1191 let pairs = key_values(fields)?;
1192 let id = decode_quoted(pairs.first().filter(|(key, _)| *key == "id")?.1)?;
1193 let mut facts = Vec::new();
1194 for (key, raw) in pairs {
1195 let field = node_field_from_key(key)?;
1196 let value = if node_field_is_quoted(field) {
1197 decode_quoted(raw)?
1198 } else {
1199 if raw.is_empty()
1200 || !raw
1201 .chars()
1202 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
1203 {
1204 return None;
1205 }
1206 raw.to_string()
1207 };
1208 facts.push(AtomicFact::Node {
1209 node_id: id.clone(),
1210 field,
1211 value,
1212 });
1213 }
1214 valid_node_fact_record(&facts).then_some(facts)
1215}
1216
1217fn decode_controlled_relation(fields: &[String]) -> Option<Vec<AtomicFact>> {
1218 let pairs = key_values(fields)?;
1219 if pairs.len() != 3 || pairs[0].0 != "kind" || pairs[1].0 != "source" || pairs[2].0 != "target"
1220 {
1221 return None;
1222 }
1223 Some(vec![AtomicFact::Relation {
1224 kind: pairs[0].1.to_string(),
1225 source: decode_quoted(pairs[1].1)?,
1226 target: decode_quoted(pairs[2].1)?,
1227 }])
1228}
1229
1230fn decode_controlled_focus(fields: &[String]) -> Option<Vec<AtomicFact>> {
1231 let pairs = key_values(fields)?;
1232 let expected = [
1233 "stream",
1234 "sequence",
1235 "owner",
1236 "transition",
1237 "foreground",
1238 "background",
1239 "residue",
1240 "trigger",
1241 ];
1242 if pairs.len() != expected.len() || pairs.iter().map(|(key, _)| *key).ne(expected) {
1243 return None;
1244 }
1245 let stream_id = decode_quoted(pairs[0].1)?;
1246 let sequence = pairs[1].1.parse::<u64>().ok()?;
1247 if sequence.to_string() != pairs[1].1 {
1248 return None;
1249 }
1250 let owner = decode_quoted(pairs[2].1)?;
1251 let transition = pairs[3].1.to_string();
1252 let mut facts = vec![
1253 AtomicFact::Focus {
1254 stream_id: stream_id.clone(),
1255 sequence,
1256 field: FocusFactField::StreamId,
1257 value: stream_id.clone(),
1258 },
1259 AtomicFact::Focus {
1260 stream_id: stream_id.clone(),
1261 sequence,
1262 field: FocusFactField::Sequence,
1263 value: sequence.to_string(),
1264 },
1265 AtomicFact::Focus {
1266 stream_id: stream_id.clone(),
1267 sequence,
1268 field: FocusFactField::Owner,
1269 value: owner,
1270 },
1271 AtomicFact::Focus {
1272 stream_id: stream_id.clone(),
1273 sequence,
1274 field: FocusFactField::Transition,
1275 value: transition,
1276 },
1277 ];
1278 for (bucket, (_, raw)) in FocusBucket::ALL.into_iter().zip(&pairs[4..7]) {
1279 let members = serde_json::from_str::<Vec<String>>(raw).ok()?;
1280 if members.is_empty() {
1281 facts.push(AtomicFact::FocusBucketEmpty {
1282 stream_id: stream_id.clone(),
1283 sequence,
1284 bucket,
1285 });
1286 } else {
1287 facts.extend(
1288 members
1289 .into_iter()
1290 .map(|node_id| AtomicFact::FocusBucketMember {
1291 stream_id: stream_id.clone(),
1292 sequence,
1293 bucket,
1294 node_id,
1295 }),
1296 );
1297 }
1298 }
1299 facts.push(AtomicFact::Focus {
1300 stream_id,
1301 sequence,
1302 field: FocusFactField::Trigger,
1303 value: decode_quoted(pairs[7].1)?,
1304 });
1305 Some(facts)
1306}
1307
1308fn decode_quoted(raw: &str) -> Option<String> {
1309 serde_json::from_str::<String>(raw).ok()
1310}
1311
1312fn node_field_from_key(key: &str) -> Option<NodeFactField> {
1313 Some(match key {
1314 "id" => NodeFactField::Id,
1315 "revision" => NodeFactField::Revision,
1316 "owner" => NodeFactField::Owner,
1317 "lifetime" => NodeFactField::Lifetime,
1318 "salience" => NodeFactField::Salience,
1319 "kind" => NodeFactField::Kind,
1320 "entity-kind" => NodeFactField::EntityKind,
1321 "claim-confidence" => NodeFactField::ClaimConfidence,
1322 "evidence-kind" => NodeFactField::EvidenceKind,
1323 "evidence-source-type" => NodeFactField::EvidenceSourceType,
1324 "evidence-source-value" => NodeFactField::EvidenceSourceValue,
1325 "goal-status" => NodeFactField::GoalStatus,
1326 "constraint-strength" => NodeFactField::ConstraintStrength,
1327 "question-status" => NodeFactField::QuestionStatus,
1328 "relationship-target" => NodeFactField::RelationshipTarget,
1329 "relationship-status" => NodeFactField::RelationshipStatus,
1330 "desire-force" => NodeFactField::DesireForce,
1331 "attention-mode" => NodeFactField::AttentionMode,
1332 "tension-status" => NodeFactField::TensionStatus,
1333 "prediction-confidence" => NodeFactField::PredictionConfidence,
1334 "prediction-status" => NodeFactField::PredictionStatus,
1335 "perspective-target" => NodeFactField::PerspectiveTarget,
1336 "perspective-confidence" => NodeFactField::PerspectiveConfidence,
1337 "affect-target" => NodeFactField::AffectTarget,
1338 "affect-kind" => NodeFactField::AffectKind,
1339 "affect-intensity" => NodeFactField::AffectIntensity,
1340 "metacognition-status" => NodeFactField::MetacognitionStatus,
1341 "payload" => NodeFactField::Payload,
1342 _ => return None,
1343 })
1344}
1345
1346fn valid_node_fact_record(facts: &[AtomicFact]) -> bool {
1347 let fields = facts
1348 .iter()
1349 .filter_map(|fact| match fact {
1350 AtomicFact::Node { field, .. } => Some(*field),
1351 _ => None,
1352 })
1353 .collect::<Vec<_>>();
1354 if fields.len() != facts.len() || fields.len() < 7 {
1355 return false;
1356 }
1357 let base = [
1358 NodeFactField::Id,
1359 NodeFactField::Revision,
1360 NodeFactField::Owner,
1361 NodeFactField::Lifetime,
1362 NodeFactField::Salience,
1363 NodeFactField::Kind,
1364 ];
1365 if !fields.starts_with(&base) || fields.last() != Some(&NodeFactField::Payload) {
1366 return false;
1367 }
1368 let kind = facts.iter().find_map(|fact| match fact {
1369 AtomicFact::Node {
1370 field: NodeFactField::Kind,
1371 value,
1372 ..
1373 } => Some(value.as_str()),
1374 _ => None,
1375 });
1376 let expected_middle: &[NodeFactField] = match kind {
1377 Some("entity") => &[NodeFactField::EntityKind],
1378 Some("claim") => &[NodeFactField::ClaimConfidence],
1379 Some("evidence") => &[
1380 NodeFactField::EvidenceKind,
1381 NodeFactField::EvidenceSourceType,
1382 NodeFactField::EvidenceSourceValue,
1383 ],
1384 Some("goal") => &[NodeFactField::GoalStatus],
1385 Some("constraint") => &[NodeFactField::ConstraintStrength],
1386 Some("question") => &[NodeFactField::QuestionStatus],
1387 Some("relationship") => &[
1388 NodeFactField::RelationshipTarget,
1389 NodeFactField::RelationshipStatus,
1390 ],
1391 Some("desire") => &[NodeFactField::DesireForce],
1392 Some("attention") => &[NodeFactField::AttentionMode],
1393 Some("association") => &[],
1394 Some("tension") => &[NodeFactField::TensionStatus],
1395 Some("prediction") => &[
1396 NodeFactField::PredictionConfidence,
1397 NodeFactField::PredictionStatus,
1398 ],
1399 Some("perspective") => &[
1400 NodeFactField::PerspectiveTarget,
1401 NodeFactField::PerspectiveConfidence,
1402 ],
1403 Some("affect") => &[
1404 NodeFactField::AffectTarget,
1405 NodeFactField::AffectKind,
1406 NodeFactField::AffectIntensity,
1407 ],
1408 Some("metacognition") => &[NodeFactField::MetacognitionStatus],
1409 _ => return false,
1410 };
1411 fields[base.len()..fields.len() - 1] == *expected_middle
1412}
1413
1414pub fn decode_compact_semantic_facts(input: &str) -> Vec<AtomicFact> {
1418 complete_compact_records(input)
1419 .into_iter()
1420 .filter_map(decode_compact_record)
1421 .flatten()
1422 .collect()
1423}
1424
1425fn complete_compact_records(input: &str) -> Vec<&str> {
1426 let mut records = Vec::new();
1427 let mut start = None;
1428 let mut depth = 0usize;
1429 let mut quoted = false;
1430 let mut escaped = false;
1431 for (index, character) in input.char_indices() {
1432 if quoted {
1433 if escaped {
1434 escaped = false;
1435 } else if character == '\\' {
1436 escaped = true;
1437 } else if character == '"' {
1438 quoted = false;
1439 }
1440 continue;
1441 }
1442 match character {
1443 '"' if depth > 0 => quoted = true,
1444 '(' => {
1445 if depth == 0 {
1446 start = Some(index);
1447 }
1448 depth += 1;
1449 }
1450 ')' if depth > 0 => {
1451 depth -= 1;
1452 if depth == 0 {
1453 if let Some(begin) = start.take() {
1454 records.push(&input[begin..=index]);
1455 }
1456 }
1457 }
1458 _ => {}
1459 }
1460 }
1461 records
1462}
1463
1464fn decode_compact_record(record: &str) -> Option<Vec<AtomicFact>> {
1465 let inner = record.strip_prefix('(')?.strip_suffix(')')?;
1466 if inner.starts_with('R') {
1467 decode_compact_relation(inner)
1468 } else if inner.starts_with('!') {
1469 decode_compact_focus(inner)
1470 } else {
1471 decode_compact_node(inner)
1472 }
1473}
1474
1475fn decode_compact_node(inner: &str) -> Option<Vec<AtomicFact>> {
1476 let (header, payload) = split_once_outside_quotes(inner, ' ')?;
1477 if parse_document(payload).ok()?.forms.len() != 1 {
1478 return None;
1479 }
1480 let (id_raw, rest) = split_once_outside_quotes(header, '~')?;
1481 let (revision_raw, rest) = split_once_outside_quotes(rest, '@')?;
1482 let (owner_raw, rest) = split_once_outside_quotes(rest, '/')?;
1483 let (life_salience, kind_raw) = split_once_outside_quotes(rest, ':')?;
1484 let mut life_chars = life_salience.chars();
1485 let lifetime = match life_chars.next()? {
1486 'p' => "persistent",
1487 'w' => "working",
1488 't' => "transient",
1489 _ => return None,
1490 };
1491 let salience = decode_base36(life_chars.as_str())?;
1492 if salience > u8::MAX as u64 {
1493 return None;
1494 }
1495 let id = decode_compact_atom(id_raw)?;
1496 let owner = decode_compact_atom(owner_raw)?;
1497 let revision = decode_base36(revision_raw)?;
1498 let (kind, attributes) = decode_compact_kind(kind_raw)?;
1499 let mut facts = vec![
1500 node_fact(&id, NodeFactField::Id, &id),
1501 node_fact(&id, NodeFactField::Revision, &revision.to_string()),
1502 node_fact(&id, NodeFactField::Owner, &owner),
1503 node_fact(&id, NodeFactField::Lifetime, lifetime),
1504 node_fact(&id, NodeFactField::Salience, &salience.to_string()),
1505 node_fact(&id, NodeFactField::Kind, kind),
1506 ];
1507 facts.extend(
1508 attributes
1509 .into_iter()
1510 .map(|(field, value)| node_fact(&id, field, &value)),
1511 );
1512 facts.push(node_fact(&id, NodeFactField::Payload, payload));
1513 valid_node_fact_record(&facts).then_some(facts)
1514}
1515
1516fn node_fact(node_id: &str, field: NodeFactField, value: &str) -> AtomicFact {
1517 AtomicFact::Node {
1518 node_id: node_id.into(),
1519 field,
1520 value: value.into(),
1521 }
1522}
1523
1524fn decode_compact_kind(raw: &str) -> Option<(&'static str, Vec<(NodeFactField, String)>)> {
1525 let parts = split_outside_quotes(raw, '/')?;
1526 let head = parts.first()?.as_str();
1527 let result = match head {
1528 "ep" if parts.len() == 1 => ("entity", vec![(NodeFactField::EntityKind, "person".into())]),
1529 "e" if parts.len() == 2 => (
1530 "entity",
1531 vec![(NodeFactField::EntityKind, decode_compact_atom(&parts[1])?)],
1532 ),
1533 value if value.len() == 2 && value.starts_with('c') && parts.len() == 1 => (
1534 "claim",
1535 vec![(
1536 NodeFactField::ClaimConfidence,
1537 decode_confidence(value.chars().nth(1)?)?.into(),
1538 )],
1539 ),
1540 "v" if parts.len() == 4 => (
1541 "evidence",
1542 vec![
1543 (NodeFactField::EvidenceKind, decode_compact_atom(&parts[1])?),
1544 (
1545 NodeFactField::EvidenceSourceType,
1546 match parts[2].as_str() {
1547 "s" => "symbol",
1548 "q" => "string",
1549 _ => return None,
1550 }
1551 .into(),
1552 ),
1553 (
1554 NodeFactField::EvidenceSourceValue,
1555 decode_compact_atom(&parts[3])?,
1556 ),
1557 ],
1558 ),
1559 value if value.len() == 2 && value.starts_with('g') && parts.len() == 1 => (
1560 "goal",
1561 vec![(
1562 NodeFactField::GoalStatus,
1563 match value.chars().nth(1)? {
1564 'n' => "considering",
1565 'a' => "active",
1566 'c' => "committed",
1567 'd' => "done",
1568 'x' => "dropped",
1569 _ => return None,
1570 }
1571 .into(),
1572 )],
1573 ),
1574 value if value.len() == 2 && value.starts_with('k') && parts.len() == 1 => (
1575 "constraint",
1576 vec![(
1577 NodeFactField::ConstraintStrength,
1578 match value.chars().nth(1)? {
1579 's' => "soft",
1580 'h' => "hard",
1581 _ => return None,
1582 }
1583 .into(),
1584 )],
1585 ),
1586 value if value.len() == 2 && value.starts_with('q') && parts.len() == 1 => (
1587 "question",
1588 vec![(
1589 NodeFactField::QuestionStatus,
1590 match value.chars().nth(1)? {
1591 'o' => "open",
1592 'a' => "answered",
1593 'd' => "deferred",
1594 _ => return None,
1595 }
1596 .into(),
1597 )],
1598 ),
1599 "r" if parts.len() == 3 => (
1600 "relationship",
1601 vec![
1602 (
1603 NodeFactField::RelationshipTarget,
1604 decode_compact_atom(&parts[1])?,
1605 ),
1606 (
1607 NodeFactField::RelationshipStatus,
1608 decode_compact_atom(&parts[2])?,
1609 ),
1610 ],
1611 ),
1612 value if value.len() == 2 && value.starts_with('d') && parts.len() == 1 => (
1613 "desire",
1614 vec![(
1615 NodeFactField::DesireForce,
1616 match value.chars().nth(1)? {
1617 'w' => "weak",
1618 'm' => "moderate",
1619 's' => "strong",
1620 _ => return None,
1621 }
1622 .into(),
1623 )],
1624 ),
1625 value if value.len() == 2 && value.starts_with('a') && parts.len() == 1 => (
1626 "attention",
1627 vec![(
1628 NodeFactField::AttentionMode,
1629 match value.chars().nth(1)? {
1630 'f' => "foreground",
1631 'b' => "background",
1632 _ => return None,
1633 }
1634 .into(),
1635 )],
1636 ),
1637 "s" if parts.len() == 1 => ("association", vec![]),
1638 value if value.len() == 2 && value.starts_with('t') && parts.len() == 1 => (
1639 "tension",
1640 vec![(
1641 NodeFactField::TensionStatus,
1642 match value.chars().nth(1)? {
1643 'o' => "open",
1644 'r' => "resolved",
1645 _ => return None,
1646 }
1647 .into(),
1648 )],
1649 ),
1650 value if value.len() == 3 && value.starts_with('p') && parts.len() == 1 => (
1651 "prediction",
1652 vec![
1653 (
1654 NodeFactField::PredictionConfidence,
1655 decode_confidence(value.chars().nth(1)?)?.into(),
1656 ),
1657 (
1658 NodeFactField::PredictionStatus,
1659 match value.chars().nth(2)? {
1660 'p' => "pending",
1661 'm' => "matched",
1662 's' => "surprised",
1663 _ => return None,
1664 }
1665 .into(),
1666 ),
1667 ],
1668 ),
1669 "w" if parts.len() == 3 && parts[2].len() == 1 => (
1670 "perspective",
1671 vec![
1672 (
1673 NodeFactField::PerspectiveTarget,
1674 decode_compact_atom(&parts[1])?,
1675 ),
1676 (
1677 NodeFactField::PerspectiveConfidence,
1678 decode_confidence(parts[2].chars().next()?)?.into(),
1679 ),
1680 ],
1681 ),
1682 "f" if parts.len() == 4 => (
1683 "affect",
1684 vec![
1685 (NodeFactField::AffectTarget, decode_compact_atom(&parts[1])?),
1686 (NodeFactField::AffectKind, decode_compact_atom(&parts[2])?),
1687 (
1688 NodeFactField::AffectIntensity,
1689 match parts[3].as_str() {
1690 "w" => "weak",
1691 "m" => "moderate",
1692 "s" => "strong",
1693 _ => return None,
1694 }
1695 .into(),
1696 ),
1697 ],
1698 ),
1699 value if value.len() == 2 && value.starts_with('m') && parts.len() == 1 => (
1700 "metacognition",
1701 vec![(
1702 NodeFactField::MetacognitionStatus,
1703 match value.chars().nth(1)? {
1704 'u' => "uncertain",
1705 'm' => "monitoring",
1706 'r' => "resolved",
1707 _ => return None,
1708 }
1709 .into(),
1710 )],
1711 ),
1712 _ => return None,
1713 };
1714 Some(result)
1715}
1716
1717fn decode_confidence(code: char) -> Option<&'static str> {
1718 Some(match code {
1719 't' => "tentative",
1720 'p' => "plausible",
1721 'l' => "likely",
1722 's' => "settled",
1723 _ => return None,
1724 })
1725}
1726
1727fn decode_compact_relation(inner: &str) -> Option<Vec<AtomicFact>> {
1728 let (kind_raw, endpoints) = split_once_outside_quotes(inner.strip_prefix('R')?, ':')?;
1729 let (source_raw, target_raw) = split_once_outside_quotes(endpoints, '>')?;
1730 let kind = match kind_raw {
1731 "u" => "supports",
1732 "x" => "contradicts",
1733 "r" => "refines",
1734 "c" => "causes",
1735 "s" => "supersedes",
1736 "a" => "about",
1737 "b" => "blocks",
1738 "o" => "associates",
1739 _ => return None,
1740 };
1741 Some(vec![AtomicFact::Relation {
1742 kind: kind.into(),
1743 source: decode_compact_atom(source_raw)?,
1744 target: decode_compact_atom(target_raw)?,
1745 }])
1746}
1747
1748fn decode_compact_focus(inner: &str) -> Option<Vec<AtomicFact>> {
1749 let (stream_raw, rest) = split_once_outside_quotes(inner.strip_prefix('!')?, '~')?;
1750 let (sequence_raw, rest) = split_once_outside_quotes(rest, '@')?;
1751 let (owner_raw, rest) = split_once_outside_quotes(rest, ':')?;
1752 let (transition_raw, buckets_trigger) = split_once_outside_quotes(rest, '/')?;
1753 let (buckets, trigger) = split_once_outside_quotes(buckets_trigger, '#')?;
1754 let parts = split_outside_quotes(buckets, '/')?;
1755 if parts.len() != 3 {
1756 return None;
1757 }
1758 let residue_raw = &parts[2];
1759 let stream_id = decode_compact_atom(stream_raw)?;
1760 let sequence = decode_base36(sequence_raw)?;
1761 let owner = decode_compact_atom(owner_raw)?;
1762 let transition = match transition_raw {
1763 "e" => "evoked",
1764 "a" => "associated",
1765 "i" => "interrupted",
1766 "r" => "returned",
1767 "f" => "reframed",
1768 "s" => "resolved",
1769 _ => return None,
1770 };
1771 if parse_document(trigger).ok()?.forms.len() != 1 {
1772 return None;
1773 }
1774 let mut facts = vec![
1775 AtomicFact::Focus {
1776 stream_id: stream_id.clone(),
1777 sequence,
1778 field: FocusFactField::StreamId,
1779 value: stream_id.clone(),
1780 },
1781 AtomicFact::Focus {
1782 stream_id: stream_id.clone(),
1783 sequence,
1784 field: FocusFactField::Sequence,
1785 value: sequence.to_string(),
1786 },
1787 AtomicFact::Focus {
1788 stream_id: stream_id.clone(),
1789 sequence,
1790 field: FocusFactField::Owner,
1791 value: owner,
1792 },
1793 AtomicFact::Focus {
1794 stream_id: stream_id.clone(),
1795 sequence,
1796 field: FocusFactField::Transition,
1797 value: transition.into(),
1798 },
1799 ];
1800 for (bucket, raw) in [
1801 (FocusBucket::Foreground, parts[0].as_str()),
1802 (FocusBucket::Background, parts[1].as_str()),
1803 (FocusBucket::Residue, residue_raw),
1804 ] {
1805 let members = decode_compact_bucket(raw, bucket)?;
1806 if members.is_empty() {
1807 facts.push(AtomicFact::FocusBucketEmpty {
1808 stream_id: stream_id.clone(),
1809 sequence,
1810 bucket,
1811 });
1812 } else {
1813 facts.extend(
1814 members
1815 .into_iter()
1816 .map(|node_id| AtomicFact::FocusBucketMember {
1817 stream_id: stream_id.clone(),
1818 sequence,
1819 bucket,
1820 node_id,
1821 }),
1822 );
1823 }
1824 }
1825 facts.push(AtomicFact::Focus {
1826 stream_id,
1827 sequence,
1828 field: FocusFactField::Trigger,
1829 value: trigger.into(),
1830 });
1831 Some(facts)
1832}
1833
1834fn decode_compact_bucket(raw: &str, bucket: FocusBucket) -> Option<Vec<String>> {
1835 let prefix = match bucket {
1836 FocusBucket::Foreground => "f[",
1837 FocusBucket::Background => "b[",
1838 FocusBucket::Residue => "r[",
1839 };
1840 let values = raw.strip_prefix(prefix)?.strip_suffix(']')?;
1841 if values.is_empty() {
1842 return Some(Vec::new());
1843 }
1844 split_outside_quotes(values, ',')?
1845 .into_iter()
1846 .map(|value| decode_compact_atom(&value))
1847 .collect()
1848}
1849
1850fn decode_compact_atom(raw: &str) -> Option<String> {
1851 let document = parse_document(raw).ok()?;
1852 let form = document
1853 .forms
1854 .first()
1855 .filter(|_| document.forms.len() == 1)?;
1856 match &form.kind {
1857 chappi_syntax::FormKind::Atom(chappi_syntax::Atom::Symbol(value))
1858 | chappi_syntax::FormKind::Atom(chappi_syntax::Atom::Integer(value))
1859 | chappi_syntax::FormKind::Atom(chappi_syntax::Atom::Decimal(value))
1860 | chappi_syntax::FormKind::Atom(chappi_syntax::Atom::String(value)) => Some(value.clone()),
1861 chappi_syntax::FormKind::List(_) => None,
1862 }
1863}
1864
1865fn decode_base36(raw: &str) -> Option<u64> {
1866 if raw.is_empty()
1867 || (raw.len() > 1 && raw.starts_with('0'))
1868 || !raw
1869 .chars()
1870 .all(|character| character.is_ascii_digit() || character.is_ascii_lowercase())
1871 {
1872 return None;
1873 }
1874 u64::from_str_radix(raw, 36).ok()
1875}
1876
1877fn split_once_outside_quotes(input: &str, delimiter: char) -> Option<(&str, &str)> {
1878 let mut quoted = false;
1879 let mut escaped = false;
1880 for (index, character) in input.char_indices() {
1881 if quoted {
1882 if escaped {
1883 escaped = false;
1884 } else if character == '\\' {
1885 escaped = true;
1886 } else if character == '"' {
1887 quoted = false;
1888 }
1889 } else if character == '"' {
1890 quoted = true;
1891 } else if character == delimiter {
1892 let after = index + character.len_utf8();
1893 return Some((&input[..index], &input[after..]));
1894 }
1895 }
1896 None
1897}
1898
1899fn split_outside_quotes(input: &str, delimiter: char) -> Option<Vec<String>> {
1900 let mut output = Vec::new();
1901 let mut rest = input;
1902 while let Some((left, right)) = split_once_outside_quotes(rest, delimiter) {
1903 output.push(left.to_string());
1904 rest = right;
1905 }
1906 output.push(rest.to_string());
1907 Some(output)
1908}
1909
1910pub fn load_corpus(root: &Path) -> Result<Corpus, CorpusError> {
1911 let manifest_path = root.join("manifest.json");
1912 let manifest_bytes = read(&manifest_path)?;
1913 let manifest: Manifest = decode_json(&manifest_bytes, "manifest.json")?;
1914 let mut diagnostics = Vec::new();
1915 if manifest.schema != CORPUS_SCHEMA {
1916 push(
1917 &mut diagnostics,
1918 "invalid-corpus-schema",
1919 "manifest.json.schema",
1920 format!("corpus schema must be `{CORPUS_SCHEMA}`"),
1921 );
1922 }
1923 if manifest.version != VERSION {
1924 push(
1925 &mut diagnostics,
1926 "invalid-corpus-version",
1927 "manifest.json.version",
1928 "corpus version must be 1",
1929 );
1930 }
1931 if manifest.episodes.len() != EPISODE_COUNT {
1932 push(
1933 &mut diagnostics,
1934 "invalid-episode-count",
1935 "manifest.json.episodes",
1936 "corpus must contain exactly 24 episodes",
1937 );
1938 }
1939 let mut manifest_paths = BTreeSet::new();
1940 for (index, path) in manifest.episodes.iter().enumerate() {
1941 if !manifest_paths.insert(path.as_str()) {
1942 push(
1943 &mut diagnostics,
1944 "duplicate-episode-path",
1945 format!("manifest.json.episodes.{index}"),
1946 "episode paths must be unique",
1947 );
1948 }
1949 }
1950 for pair in manifest.episodes.windows(2) {
1951 if pair[0] > pair[1] {
1952 push(
1953 &mut diagnostics,
1954 "unsorted-episode-paths",
1955 "manifest.json.episodes",
1956 "episode paths must be sorted",
1957 );
1958 break;
1959 }
1960 }
1961 for (index, path) in manifest.episodes.iter().enumerate() {
1962 if !safe_episode_path(path) {
1963 push(
1964 &mut diagnostics,
1965 "unsafe-episode-path",
1966 format!("manifest.json.episodes.{index}"),
1967 "episode path must be a relative episodes/<id>.json path",
1968 );
1969 }
1970 }
1971 if !diagnostics.is_empty() {
1972 return Err(CorpusError {
1973 kind: CorpusErrorKind::Invalid,
1974 diagnostics,
1975 });
1976 }
1977 let episode_directory = root.join("episodes");
1978 let entries = fs::read_dir(&episode_directory).map_err(|error| CorpusError {
1979 kind: CorpusErrorKind::Io,
1980 diagnostics: vec![CorpusDiagnostic {
1981 code: "corpus-io-error".into(),
1982 path: episode_directory.display().to_string(),
1983 message: error.to_string(),
1984 }],
1985 })?;
1986 let mut disk_paths = Vec::new();
1987 for entry in entries {
1988 let entry = entry.map_err(|error| CorpusError {
1989 kind: CorpusErrorKind::Io,
1990 diagnostics: vec![CorpusDiagnostic {
1991 code: "corpus-io-error".into(),
1992 path: episode_directory.display().to_string(),
1993 message: error.to_string(),
1994 }],
1995 })?;
1996 if entry.path().extension().and_then(|value| value.to_str()) == Some("json") {
1997 disk_paths.push(format!("episodes/{}", entry.file_name().to_string_lossy()));
1998 }
1999 }
2000 disk_paths.sort();
2001 for path in disk_paths {
2002 if !manifest_paths.contains(path.as_str()) {
2003 push(
2004 &mut diagnostics,
2005 "unlisted-episode-file",
2006 path,
2007 "every episode JSON file must be listed by the manifest",
2008 );
2009 }
2010 }
2011 let canonical_root = fs::canonicalize(root).map_err(|error| CorpusError {
2012 kind: CorpusErrorKind::Io,
2013 diagnostics: vec![CorpusDiagnostic {
2014 code: "corpus-io-error".into(),
2015 path: root.display().to_string(),
2016 message: error.to_string(),
2017 }],
2018 })?;
2019
2020 let mut episodes = Vec::new();
2021 let mut ids = BTreeSet::new();
2022 for relative in &manifest.episodes {
2023 let path = root.join(relative);
2024 let resolved = fs::canonicalize(&path).map_err(|error| CorpusError {
2025 kind: CorpusErrorKind::Io,
2026 diagnostics: vec![CorpusDiagnostic {
2027 code: "corpus-io-error".into(),
2028 path: path.display().to_string(),
2029 message: error.to_string(),
2030 }],
2031 })?;
2032 if !resolved.starts_with(&canonical_root) {
2033 push(
2034 &mut diagnostics,
2035 "unsafe-episode-path",
2036 relative,
2037 "resolved episode path must remain below the corpus root",
2038 );
2039 continue;
2040 }
2041 let bytes = read(&resolved)?;
2042 let episode: Episode = match decode_json(&bytes, relative) {
2043 Ok(episode) => episode,
2044 Err(error) => {
2045 diagnostics.extend(error.diagnostics);
2046 continue;
2047 }
2048 };
2049 validate_episode_metadata(&episode, relative, &mut diagnostics);
2050 let expected_id = Path::new(relative)
2051 .file_stem()
2052 .and_then(|value| value.to_str())
2053 .unwrap_or_default();
2054 if episode.id != expected_id {
2055 push(
2056 &mut diagnostics,
2057 "mismatched-episode-id",
2058 format!("{relative}.id"),
2059 format!("episode ID must match `{expected_id}`"),
2060 );
2061 }
2062 if !ids.insert(episode.id.clone()) {
2063 push(
2064 &mut diagnostics,
2065 "duplicate-episode-id",
2066 format!("{relative}.id"),
2067 "episode IDs must be unique",
2068 );
2069 }
2070 validate_episode_content(&episode, relative, &mut diagnostics);
2071 episodes.push(episode);
2072 }
2073
2074 let mut counts = BTreeMap::new();
2075 for episode in &episodes {
2076 *counts.entry(episode.category).or_insert(0usize) += 1;
2077 }
2078 for category in EpisodeCategory::ALL {
2079 if counts.get(&category).copied().unwrap_or_default() != 4 {
2080 push(
2081 &mut diagnostics,
2082 "invalid-category-count",
2083 "manifest.json.episodes",
2084 format!("category {category:?} must contain exactly four episodes"),
2085 );
2086 }
2087 }
2088 validate_corpus_diversity(&episodes, &mut diagnostics);
2089 if diagnostics.is_empty() {
2090 Ok(Corpus {
2091 schema: manifest.schema,
2092 version: manifest.version,
2093 episodes,
2094 })
2095 } else {
2096 Err(CorpusError {
2097 kind: CorpusErrorKind::Invalid,
2098 diagnostics,
2099 })
2100 }
2101}
2102
2103pub fn measure_corpus(corpus: &Corpus) -> MeasurementReport {
2104 let mut replayed = 0usize;
2105 let mut mandatory_hit = 0usize;
2106 let mut mandatory_total = 0usize;
2107 let mut active_hit = 0usize;
2108 let mut active_total = 0usize;
2109 let mut owner_target_hit = 0usize;
2110 let mut owner_target_total = 0usize;
2111 let mut diagnostic_hit = 0usize;
2112 let mut diagnostic_total = 0usize;
2113 let mut fact_counts = FactFamily::ALL
2114 .into_iter()
2115 .map(|family| (family, (0usize, 0usize, 0usize)))
2116 .collect::<BTreeMap<_, _>>();
2117 let mut snapshot_bytes = 0usize;
2118 let mut json_bytes = 0usize;
2119 let mut capsule_bytes = 0usize;
2120 let mut baseline_bytes = 0usize;
2121 let mut snapshot_tokens = 0usize;
2122 let mut json_tokens = 0usize;
2123 let mut capsule_tokens = 0usize;
2124 let mut baseline_tokens = 0usize;
2125 let estimator = LexicalTokenEstimator;
2126
2127 for episode in &corpus.episodes {
2128 let Ok(state) = replay_episode(episode) else {
2129 continue;
2130 };
2131 replayed += 1;
2132 let fixed_selection = derive_required_selection(episode, &state)
2133 .into_iter()
2134 .collect::<Vec<_>>();
2135 let Ok(derived) = derive_semantic_comparison(&state, &fixed_selection) else {
2136 continue;
2137 };
2138 let compact_prefix = utf8_prefix(&derived.canonical_capsule, episode.capsule_budget_bytes);
2139 let baseline_prefix = utf8_prefix(
2140 &episode.natural_language_baseline,
2141 episode.capsule_budget_bytes,
2142 );
2143 let compact_facts = decode_compact_semantic_facts(compact_prefix);
2144 let baseline_facts = decode_controlled_english_facts(baseline_prefix);
2145 let selected = compact_facts
2146 .iter()
2147 .filter_map(|fact| match fact {
2148 AtomicFact::Node {
2149 node_id,
2150 field: NodeFactField::Id,
2151 value,
2152 } if node_id == value => Some(node_id.as_str()),
2153 _ => None,
2154 })
2155 .collect::<BTreeSet<_>>();
2156 for id in episode
2157 .golden
2158 .required_constraint_ids
2159 .iter()
2160 .chain(&episode.golden.committed_goal_ids)
2161 {
2162 mandatory_total += 1;
2163 mandatory_hit += usize::from(selected.contains(id.as_str()));
2164 }
2165 for id in episode
2166 .golden
2167 .active_question_ids
2168 .iter()
2169 .chain(&episode.golden.active_tension_ids)
2170 {
2171 active_total += 1;
2172 active_hit += usize::from(selected.contains(id.as_str()));
2173 }
2174 let recovery_ids = episode
2175 .golden
2176 .required_constraint_ids
2177 .iter()
2178 .chain(&episode.golden.committed_goal_ids)
2179 .chain(&episode.golden.active_question_ids)
2180 .chain(&episode.golden.active_tension_ids)
2181 .collect::<BTreeSet<_>>();
2182 for expectation in &episode.golden.owner_target_expectations {
2183 if !recovery_ids.contains(&expectation.node_id) && expectation.target.is_none() {
2184 continue;
2185 }
2186 owner_target_total += 1;
2187 owner_target_hit += usize::from(
2188 derived
2189 .fixture
2190 .selected_node_ids
2191 .iter()
2192 .filter(|id| selected.contains(id.as_str()))
2193 .filter_map(|id| state.nodes.iter().find(|node| node.id == *id))
2194 .any(|node| expectation_matches(node, expectation)),
2195 );
2196 }
2197 let score = score_fact_recovery(
2198 &episode.comparison.atomic_facts,
2199 &compact_facts,
2200 &baseline_facts,
2201 );
2202 for family in score.families {
2203 let counts = fact_counts
2204 .get_mut(&family.family)
2205 .expect("all families initialized");
2206 counts.0 += family.total;
2207 counts.1 += family.chappi_hits;
2208 counts.2 += family.baseline_hits;
2209 }
2210 for seeded in &episode.seeded_invalid_sources {
2211 let actual = diagnostic_codes(&seeded.source);
2212 let expected = seeded
2213 .expected_diagnostic_codes
2214 .iter()
2215 .cloned()
2216 .collect::<BTreeSet<_>>();
2217 diagnostic_total += 1;
2218 diagnostic_hit += usize::from(actual == expected);
2219 }
2220 let canonical_snapshot = snapshot_source(&state);
2221 let compact_json = project(&state, ProjectionFormat::Json);
2222 let json: serde_json::Value =
2223 serde_json::from_str(&compact_json).expect("projection is JSON");
2224 let pretty_json = serde_json::to_string_pretty(&json).expect("projection serializes");
2225 let canonical_capsule = derived.canonical_capsule;
2226 snapshot_bytes += canonical_snapshot.len();
2227 json_bytes += pretty_json.len();
2228 capsule_bytes += canonical_capsule.len();
2229 baseline_bytes += episode.natural_language_baseline.len();
2230 snapshot_tokens += estimator
2231 .estimate(std::str::from_utf8(&canonical_snapshot).expect("snapshot is UTF-8"));
2232 json_tokens += estimator.estimate(&pretty_json);
2233 capsule_tokens += estimator.estimate(&canonical_capsule);
2234 baseline_tokens += estimator.estimate(&episode.natural_language_baseline);
2235 }
2236
2237 let semantic = SemanticMetrics {
2238 loaded: corpus.episodes.len(),
2239 replayed,
2240 replay_ratio: ratio(replayed.min(EPISODE_COUNT), EPISODE_COUNT),
2241 };
2242 let continuity = ContinuityMetrics {
2243 constraints_and_committed_goals: ratio(mandatory_hit, mandatory_total),
2244 active_questions_and_tensions: ratio(active_hit, active_total),
2245 owner_target_expectations: ratio(owner_target_hit, owner_target_total),
2246 };
2247 let fact_families = FactFamily::ALL
2248 .into_iter()
2249 .map(|family| {
2250 let (total, chappi_hits, baseline_hits) = fact_counts[&family];
2251 FactFamilyMetrics {
2252 family,
2253 total,
2254 chappi_hits,
2255 baseline_hits,
2256 chappi_accuracy: (total > 0).then(|| ratio(chappi_hits, total)),
2257 baseline_accuracy: (total > 0).then(|| ratio(baseline_hits, total)),
2258 included_in_macro: total > 0,
2259 }
2260 })
2261 .collect::<Vec<_>>();
2262 let included = fact_families
2263 .iter()
2264 .filter(|family| family.included_in_macro)
2265 .count();
2266 let chappi_accuracy = if included == 0 {
2267 0.0
2268 } else {
2269 fact_families
2270 .iter()
2271 .filter_map(|family| family.chappi_accuracy)
2272 .sum::<f64>()
2273 / included as f64
2274 };
2275 let baseline_accuracy = if included == 0 {
2276 0.0
2277 } else {
2278 fact_families
2279 .iter()
2280 .filter_map(|family| family.baseline_accuracy)
2281 .sum::<f64>()
2282 / included as f64
2283 };
2284 let precision = PrecisionMetrics {
2285 seeded_diagnostics: ratio(diagnostic_hit, diagnostic_total),
2286 chappi_task_accuracy: chappi_accuracy,
2287 baseline_task_accuracy: baseline_accuracy,
2288 task_accuracy_advantage: chappi_accuracy - baseline_accuracy,
2289 fact_families,
2290 };
2291 let compression = CompressionMetrics {
2292 snapshot_to_json: ratio_usize(snapshot_bytes, json_bytes),
2293 capsule_to_baseline: ratio_usize(capsule_bytes, baseline_bytes),
2294 canonical_snapshot_bytes: snapshot_bytes,
2295 pretty_json_bytes: json_bytes,
2296 capsule_bytes,
2297 baseline_bytes,
2298 canonical_snapshot_tokens: snapshot_tokens,
2299 pretty_json_tokens: json_tokens,
2300 capsule_tokens,
2301 baseline_tokens,
2302 };
2303 let gates = vec![
2304 GateResult {
2305 name: "semantic-replay",
2306 passed: semantic.loaded == EPISODE_COUNT && semantic.replayed == EPISODE_COUNT,
2307 actual: semantic.replay_ratio,
2308 required: 1.0,
2309 },
2310 at_least(
2311 "constraints-and-committed-goals",
2312 continuity.constraints_and_committed_goals,
2313 1.0,
2314 ),
2315 at_least(
2316 "active-questions-and-tensions",
2317 continuity.active_questions_and_tensions,
2318 0.90,
2319 ),
2320 at_least(
2321 "owner-target-expectations",
2322 continuity.owner_target_expectations,
2323 1.0,
2324 ),
2325 at_least("seeded-diagnostics", precision.seeded_diagnostics, 1.0),
2326 at_least(
2327 "task-accuracy-advantage",
2328 precision.task_accuracy_advantage,
2329 0.10,
2330 ),
2331 at_most(
2332 "snapshot-to-json-compression",
2333 compression.snapshot_to_json,
2334 0.70,
2335 ),
2336 at_most(
2337 "capsule-to-baseline-compression",
2338 compression.capsule_to_baseline,
2339 0.60,
2340 ),
2341 ];
2342 let passed = gates.iter().all(|gate| gate.passed);
2343 MeasurementReport {
2344 schema: MEASUREMENT_SCHEMA,
2345 version: VERSION,
2346 episodes: corpus.episodes.len(),
2347 semantic,
2348 continuity,
2349 precision,
2350 compression,
2351 gates,
2352 passed,
2353 }
2354}
2355
2356fn validate_episode_metadata(
2357 episode: &Episode,
2358 path: &str,
2359 diagnostics: &mut Vec<CorpusDiagnostic>,
2360) {
2361 if episode.schema != EPISODE_SCHEMA {
2362 push(
2363 diagnostics,
2364 "invalid-episode-schema",
2365 format!("{path}.schema"),
2366 format!("episode schema must be `{EPISODE_SCHEMA}`"),
2367 );
2368 }
2369 if episode.version != VERSION {
2370 push(
2371 diagnostics,
2372 "invalid-episode-version",
2373 format!("{path}.version"),
2374 "episode version must be 1",
2375 );
2376 }
2377}
2378
2379fn validate_episode_content(
2380 episode: &Episode,
2381 path: &str,
2382 diagnostics: &mut Vec<CorpusDiagnostic>,
2383) {
2384 check_nonempty_unique(
2385 &episode.golden.required_constraint_ids,
2386 &format!("{path}.golden.required_constraint_ids"),
2387 "required_constraint_ids",
2388 diagnostics,
2389 );
2390 check_nonempty_unique(
2391 &episode.golden.committed_goal_ids,
2392 &format!("{path}.golden.committed_goal_ids"),
2393 "committed_goal_ids",
2394 diagnostics,
2395 );
2396 check_nonempty_unique(
2397 &episode.golden.active_question_ids,
2398 &format!("{path}.golden.active_question_ids"),
2399 "active_question_ids",
2400 diagnostics,
2401 );
2402 check_nonempty_unique(
2403 &episode.golden.active_tension_ids,
2404 &format!("{path}.golden.active_tension_ids"),
2405 "active_tension_ids",
2406 diagnostics,
2407 );
2408 if episode.golden.owner_target_expectations.is_empty() {
2409 push(
2410 diagnostics,
2411 "empty-golden-list",
2412 format!("{path}.golden.owner_target_expectations"),
2413 "owner/target expectations must not be empty",
2414 );
2415 }
2416 let mut expectation_ids = BTreeSet::new();
2417 for expectation in &episode.golden.owner_target_expectations {
2418 if !expectation_ids.insert(expectation.node_id.as_str()) {
2419 push(
2420 diagnostics,
2421 "duplicate-golden-id",
2422 format!("{path}.golden.owner_target_expectations"),
2423 format!("duplicate expectation for `{}`", expectation.node_id),
2424 );
2425 }
2426 }
2427 let state = match replay_episode(episode) {
2428 Ok(state) => state,
2429 Err(items) => {
2430 for item in items {
2431 push(
2432 diagnostics,
2433 &item.code,
2434 format!("{path}.sources"),
2435 item.message,
2436 );
2437 }
2438 return;
2439 }
2440 };
2441 check_golden_ids(episode, &state, path, diagnostics);
2442 validate_recovery_questions(episode, &state, path, diagnostics);
2443 validate_comparison_fixture(episode, &state, path, diagnostics);
2444 if episode.seeded_invalid_sources.is_empty() {
2445 push(
2446 diagnostics,
2447 "missing-seeded-invalid-source",
2448 format!("{path}.seeded_invalid_sources"),
2449 "episode must seed at least one invalid source",
2450 );
2451 }
2452 for (index, seeded) in episode.seeded_invalid_sources.iter().enumerate() {
2453 check_nonempty_unique(
2454 &seeded.expected_diagnostic_codes,
2455 &format!("{path}.seeded_invalid_sources.{index}.expected_diagnostic_codes"),
2456 "expected_diagnostic_codes",
2457 diagnostics,
2458 );
2459 if seeded.source.trim().is_empty() {
2460 push(
2461 diagnostics,
2462 "empty-seeded-invalid-source",
2463 format!("{path}.seeded_invalid_sources.{index}.source"),
2464 "seeded source must not be empty",
2465 );
2466 }
2467 for code in &seeded.expected_diagnostic_codes {
2468 if !KNOWN_DIAGNOSTIC_CODES.contains(&code.as_str()) {
2469 push(
2470 diagnostics,
2471 "unknown-expected-diagnostic-code",
2472 format!("{path}.seeded_invalid_sources.{index}.expected_diagnostic_codes"),
2473 format!("`{code}` is not in the v1 diagnostic vocabulary"),
2474 );
2475 }
2476 }
2477 }
2478}
2479
2480fn validate_comparison_fixture(
2481 episode: &Episode,
2482 state: &CognitiveState,
2483 path: &str,
2484 diagnostics: &mut Vec<CorpusDiagnostic>,
2485) {
2486 if episode.comparison.fact_ontology_version != FACT_ONTOLOGY_VERSION {
2487 push(
2488 diagnostics,
2489 "invalid-fact-ontology-version",
2490 format!("{path}.comparison.fact_ontology_version"),
2491 "fact ontology version must be 1",
2492 );
2493 }
2494 if episode.comparison.controlled_english_version != CONTROLLED_ENGLISH_VERSION {
2495 push(
2496 diagnostics,
2497 "invalid-controlled-english-version",
2498 format!("{path}.comparison.controlled_english_version"),
2499 "controlled-English grammar version must be 1",
2500 );
2501 }
2502 let selected = derive_required_selection(episode, state);
2503 let selected_ids = selected.into_iter().collect::<Vec<_>>();
2504 if episode.comparison.selected_node_ids != selected_ids {
2505 push(
2506 diagnostics,
2507 "selected-node-set-mismatch",
2508 format!("{path}.comparison.selected_node_ids"),
2509 format!(
2510 "declared {:?}, derived {selected_ids:?}",
2511 episode.comparison.selected_node_ids
2512 ),
2513 );
2514 }
2515 let Ok(derived) = derive_semantic_comparison(state, &selected_ids) else {
2516 push(
2517 diagnostics,
2518 "invalid-semantic-comparison",
2519 format!("{path}.comparison"),
2520 "derived selected node set is not closed",
2521 );
2522 return;
2523 };
2524 if episode.comparison.atomic_facts != derived.fixture.atomic_facts {
2525 push(
2526 diagnostics,
2527 "atomic-facts-mismatch",
2528 format!("{path}.comparison.atomic_facts"),
2529 "literal facts must exactly match the typed target capsule",
2530 );
2531 }
2532 if episode.capsule_budget_bytes != derived.capsule_budget_bytes {
2533 push(
2534 diagnostics,
2535 "capsule-budget-mismatch",
2536 format!("{path}.capsule_budget_bytes"),
2537 format!(
2538 "budget {} must equal exact canonical size {}",
2539 episode.capsule_budget_bytes, derived.capsule_budget_bytes
2540 ),
2541 );
2542 }
2543 let rendered = render_controlled_english(&episode.comparison.atomic_facts);
2544 match rendered {
2545 Ok(rendered) if rendered == episode.natural_language_baseline => {}
2546 Ok(_) => push(
2547 diagnostics,
2548 "controlled-english-baseline-mismatch",
2549 format!("{path}.natural_language_baseline"),
2550 "stored baseline must equal the v1 renderer byte-for-byte",
2551 ),
2552 Err(message) => push(
2553 diagnostics,
2554 "invalid-atomic-fact-records",
2555 format!("{path}.comparison.atomic_facts"),
2556 message,
2557 ),
2558 }
2559 if decode_compact_semantic_facts(&derived.canonical_capsule) != episode.comparison.atomic_facts
2560 {
2561 push(
2562 diagnostics,
2563 "compact-fact-decode-mismatch",
2564 format!("{path}.comparison.atomic_facts"),
2565 "independent compact decoding must reproduce the literal facts",
2566 );
2567 }
2568 if decode_controlled_english_facts(&episode.natural_language_baseline)
2569 != episode.comparison.atomic_facts
2570 {
2571 push(
2572 diagnostics,
2573 "controlled-english-fact-decode-mismatch",
2574 format!("{path}.natural_language_baseline"),
2575 "independent controlled-English decoding must reproduce the literal facts",
2576 );
2577 }
2578}
2579
2580fn derive_required_selection(episode: &Episode, state: &CognitiveState) -> BTreeSet<String> {
2581 let mut selected = episode
2582 .golden
2583 .required_constraint_ids
2584 .iter()
2585 .chain(&episode.golden.committed_goal_ids)
2586 .chain(&episode.golden.active_question_ids)
2587 .chain(&episode.golden.active_tension_ids)
2588 .cloned()
2589 .collect::<BTreeSet<_>>();
2590 selected.extend(
2591 episode
2592 .golden
2593 .owner_target_expectations
2594 .iter()
2595 .map(|expectation| expectation.node_id.clone()),
2596 );
2597 loop {
2598 let before = selected.len();
2599 let dependencies = state
2600 .nodes
2601 .iter()
2602 .filter(|node| selected.contains(&node.id))
2603 .flat_map(|node| {
2604 std::iter::once(node.owner.clone()).chain(node_target(node).map(ToOwned::to_owned))
2605 })
2606 .collect::<Vec<_>>();
2607 selected.extend(dependencies);
2608 if selected.len() == before {
2609 return selected;
2610 }
2611 }
2612}
2613
2614fn replay_episode(episode: &Episode) -> Result<CognitiveState, Vec<Diagnostic>> {
2615 let mut state = decode_state(&episode.snapshot_source)?;
2616 if episode.patch_sources.is_empty() {
2617 return Err(vec![synthetic_diagnostic(
2618 "missing-patch-source",
2619 "episode must apply at least one patch",
2620 )]);
2621 }
2622 for source in &episode.patch_sources {
2623 let document = parse_document(source)?;
2624 let patch = Patch::try_from(document)?;
2625 if patch.ops.is_empty() {
2626 return Err(vec![synthetic_diagnostic(
2627 "empty-patch-operations",
2628 "every corpus patch must contain at least one operation",
2629 )]);
2630 }
2631 let patch_diagnostics = validate_patch(&patch);
2632 if !patch_diagnostics.is_empty() {
2633 return Err(patch_diagnostics);
2634 }
2635 state = apply_patch(&state, &patch)?;
2636 }
2637 Ok(state)
2638}
2639
2640fn decode_state(source: &str) -> Result<CognitiveState, Vec<Diagnostic>> {
2641 let document = parse_document(source)?;
2642 let state = CognitiveState::try_from(document)?;
2643 let diagnostics = validate(&state);
2644 if diagnostics.is_empty() {
2645 Ok(state)
2646 } else {
2647 Err(diagnostics)
2648 }
2649}
2650
2651fn diagnostic_codes(source: &str) -> BTreeSet<String> {
2652 let diagnostics = match parse_document(source) {
2653 Err(items) => items,
2654 Ok(document) => match CognitiveState::try_from(document) {
2655 Err(items) => items,
2656 Ok(state) => validate(&state),
2657 },
2658 };
2659 diagnostics.into_iter().map(|item| item.code).collect()
2660}
2661
2662fn check_golden_ids(
2663 episode: &Episode,
2664 state: &CognitiveState,
2665 path: &str,
2666 diagnostics: &mut Vec<CorpusDiagnostic>,
2667) {
2668 for id in &episode.golden.required_constraint_ids {
2669 check_id(
2670 state,
2671 id,
2672 path,
2673 diagnostics,
2674 "required_constraint_ids",
2675 |kind| {
2676 matches!(
2677 kind,
2678 NodeKind::Constraint {
2679 strength: ConstraintStrength::Hard
2680 }
2681 )
2682 },
2683 );
2684 }
2685 for id in &episode.golden.committed_goal_ids {
2686 check_id(state, id, path, diagnostics, "committed_goal_ids", |kind| {
2687 matches!(
2688 kind,
2689 NodeKind::Goal {
2690 status: GoalStatus::Committed
2691 }
2692 )
2693 });
2694 }
2695 for id in &episode.golden.active_question_ids {
2696 check_id(
2697 state,
2698 id,
2699 path,
2700 diagnostics,
2701 "active_question_ids",
2702 |kind| {
2703 matches!(
2704 kind,
2705 NodeKind::Question {
2706 status: QuestionStatus::Open
2707 }
2708 )
2709 },
2710 );
2711 }
2712 for id in &episode.golden.active_tension_ids {
2713 check_id(state, id, path, diagnostics, "active_tension_ids", |kind| {
2714 matches!(
2715 kind,
2716 NodeKind::Tension {
2717 status: TensionStatus::Open
2718 }
2719 )
2720 });
2721 }
2722 for expectation in &episode.golden.owner_target_expectations {
2723 if !state
2724 .nodes
2725 .iter()
2726 .any(|node| expectation_matches(node, expectation))
2727 {
2728 push(
2729 diagnostics,
2730 "invalid-owner-target-expectation",
2731 format!("{path}.golden.owner_target_expectations"),
2732 format!(
2733 "expectation for `{}` does not match final state",
2734 expectation.node_id
2735 ),
2736 );
2737 }
2738 }
2739 check_complete_golden_sets(episode, state, path, diagnostics);
2740}
2741
2742fn check_complete_golden_sets(
2743 episode: &Episode,
2744 state: &CognitiveState,
2745 path: &str,
2746 diagnostics: &mut Vec<CorpusDiagnostic>,
2747) {
2748 let exact = [
2749 (
2750 "required_constraint_ids",
2751 episode
2752 .golden
2753 .required_constraint_ids
2754 .iter()
2755 .cloned()
2756 .collect::<BTreeSet<_>>(),
2757 state
2758 .nodes
2759 .iter()
2760 .filter(|node| {
2761 matches!(
2762 node.kind,
2763 NodeKind::Constraint {
2764 strength: ConstraintStrength::Hard
2765 }
2766 )
2767 })
2768 .map(|node| node.id.clone())
2769 .collect::<BTreeSet<_>>(),
2770 ),
2771 (
2772 "committed_goal_ids",
2773 episode
2774 .golden
2775 .committed_goal_ids
2776 .iter()
2777 .cloned()
2778 .collect::<BTreeSet<_>>(),
2779 state
2780 .nodes
2781 .iter()
2782 .filter(|node| {
2783 matches!(
2784 node.kind,
2785 NodeKind::Goal {
2786 status: GoalStatus::Committed
2787 }
2788 )
2789 })
2790 .map(|node| node.id.clone())
2791 .collect(),
2792 ),
2793 (
2794 "active_question_ids",
2795 episode
2796 .golden
2797 .active_question_ids
2798 .iter()
2799 .cloned()
2800 .collect::<BTreeSet<_>>(),
2801 state
2802 .nodes
2803 .iter()
2804 .filter(|node| {
2805 matches!(
2806 node.kind,
2807 NodeKind::Question {
2808 status: QuestionStatus::Open
2809 }
2810 )
2811 })
2812 .map(|node| node.id.clone())
2813 .collect(),
2814 ),
2815 (
2816 "active_tension_ids",
2817 episode
2818 .golden
2819 .active_tension_ids
2820 .iter()
2821 .cloned()
2822 .collect::<BTreeSet<_>>(),
2823 state
2824 .nodes
2825 .iter()
2826 .filter(|node| {
2827 matches!(
2828 node.kind,
2829 NodeKind::Tension {
2830 status: TensionStatus::Open
2831 }
2832 )
2833 })
2834 .map(|node| node.id.clone())
2835 .collect(),
2836 ),
2837 ];
2838 for (field, declared, derived) in exact {
2839 if declared != derived {
2840 push(
2841 diagnostics,
2842 "incomplete-golden-semantic-set",
2843 format!("{path}.golden.{field}"),
2844 format!("declared {declared:?}, derived {derived:?}"),
2845 );
2846 }
2847 }
2848
2849 let declared = episode
2850 .golden
2851 .owner_target_expectations
2852 .iter()
2853 .map(|item| {
2854 (
2855 item.node_id.clone(),
2856 item.owner.clone(),
2857 item.target.clone(),
2858 )
2859 })
2860 .collect::<BTreeSet<_>>();
2861 let derived = state
2862 .nodes
2863 .iter()
2864 .map(|node| {
2865 (
2866 node.id.clone(),
2867 node.owner.clone(),
2868 node_target(node).map(ToOwned::to_owned),
2869 )
2870 })
2871 .collect::<BTreeSet<_>>();
2872 if declared != derived {
2873 push(
2874 diagnostics,
2875 "incomplete-owner-target-golden",
2876 format!("{path}.golden.owner_target_expectations"),
2877 format!("declared {declared:?}, derived {derived:?}"),
2878 );
2879 }
2880}
2881
2882fn validate_recovery_questions(
2883 episode: &Episode,
2884 state: &CognitiveState,
2885 path: &str,
2886 diagnostics: &mut Vec<CorpusDiagnostic>,
2887) {
2888 let mut required = [
2889 (
2890 RecoveryFactKind::RequiredConstraint,
2891 &episode.golden.required_constraint_ids,
2892 ),
2893 (
2894 RecoveryFactKind::CommittedGoal,
2895 &episode.golden.committed_goal_ids,
2896 ),
2897 (
2898 RecoveryFactKind::ActiveQuestion,
2899 &episode.golden.active_question_ids,
2900 ),
2901 (
2902 RecoveryFactKind::ActiveTension,
2903 &episode.golden.active_tension_ids,
2904 ),
2905 ]
2906 .into_iter()
2907 .flat_map(|(kind, ids)| ids.iter().cloned().map(move |id| (kind, id)))
2908 .collect::<BTreeSet<_>>();
2909 let recovery_owners = required
2910 .iter()
2911 .filter_map(|(_, id)| state.nodes.iter().find(|node| node.id == *id))
2912 .map(|node| node.owner.clone())
2913 .collect::<BTreeSet<_>>();
2914 required.extend(
2915 recovery_owners
2916 .into_iter()
2917 .map(|owner| (RecoveryFactKind::OwnerContext, owner)),
2918 );
2919 let declared = episode
2920 .recovery_questions
2921 .iter()
2922 .map(|question| (question.fact_kind, question.covered_node_id.clone()))
2923 .collect::<BTreeSet<_>>();
2924 let unique_ids = episode
2925 .recovery_questions
2926 .iter()
2927 .map(|question| question.id.as_str())
2928 .collect::<BTreeSet<_>>();
2929 if required != declared
2930 || declared.len() != episode.recovery_questions.len()
2931 || unique_ids.len() != episode.recovery_questions.len()
2932 {
2933 push(
2934 diagnostics,
2935 "incomplete-recovery-question-set",
2936 format!("{path}.recovery_questions"),
2937 "questions must uniquely cover every required semantic fact exactly once",
2938 );
2939 }
2940
2941 for (index, question) in episode.recovery_questions.iter().enumerate() {
2942 if question.prompt.trim().is_empty()
2943 || question.expected.value.trim().is_empty()
2944 || question.expected.qualifiers.is_empty()
2945 || question.expected.qualifiers.iter().any(|alternatives| {
2946 alternatives.is_empty() || alternatives.iter().any(|v| v.trim().is_empty())
2947 })
2948 {
2949 push(
2950 diagnostics,
2951 "invalid-semantic-question",
2952 format!("{path}.recovery_questions.{index}"),
2953 "question, answer value, and non-empty qualifier alternatives are required",
2954 );
2955 continue;
2956 }
2957 let Some(node) = state
2958 .nodes
2959 .iter()
2960 .find(|node| node.id == question.covered_node_id)
2961 else {
2962 continue;
2963 };
2964 let payload = crate::resume::canonical_form(&node.payload);
2965 if !exact_lexical_containment(&payload, &question.expected.value) {
2966 push(
2967 diagnostics,
2968 "semantic-value-not-payload-backed",
2969 format!("{path}.recovery_questions.{index}.expected.value"),
2970 "the canonical answer value must be a contiguous phrase in the covered node payload",
2971 );
2972 }
2973 if !semantic_answer_matches(&semantic_evidence_for_node(node, state), &question.expected) {
2974 push(
2975 diagnostics,
2976 "semantic-answer-mismatches-state",
2977 format!("{path}.recovery_questions.{index}.expected"),
2978 "expected answer is not recoverable from the covered node's semantic content",
2979 );
2980 }
2981 }
2982}
2983
2984fn semantic_evidence_for_node(node: &Node, state: &CognitiveState) -> String {
2985 let mut parts = vec![match &node.kind {
2986 NodeKind::Entity { .. } => "entity owner owns responsible".to_string(),
2987 NodeKind::Constraint { strength } => format!(
2988 "constraint {}",
2989 match strength {
2990 ConstraintStrength::Hard => "hard required must",
2991 ConstraintStrength::Soft => "soft preferred",
2992 }
2993 ),
2994 NodeKind::Goal { status } => format!(
2995 "goal {}",
2996 match status {
2997 GoalStatus::Committed => "committed will",
2998 GoalStatus::Active => "active",
2999 GoalStatus::Considering => "considering",
3000 GoalStatus::Done => "done",
3001 GoalStatus::Dropped => "dropped",
3002 }
3003 ),
3004 NodeKind::Question { status } => format!(
3005 "question {}",
3006 match status {
3007 QuestionStatus::Open => "open unresolved",
3008 QuestionStatus::Answered => "answered",
3009 QuestionStatus::Deferred => "deferred",
3010 }
3011 ),
3012 NodeKind::Tension { status } => format!(
3013 "tension tradeoff {}",
3014 match status {
3015 TensionStatus::Open => "open unresolved",
3016 TensionStatus::Resolved => "resolved",
3017 }
3018 ),
3019 _ => "context".to_string(),
3020 }];
3021 parts.push(crate::resume::canonical_form(&node.payload));
3022 if let Some(owner) = state
3023 .nodes
3024 .iter()
3025 .find(|candidate| candidate.id == node.owner)
3026 {
3027 parts.push(crate::resume::canonical_form(&owner.payload));
3028 }
3029 if let Some(target) =
3030 node_target(node).and_then(|id| state.nodes.iter().find(|candidate| candidate.id == id))
3031 {
3032 parts.push(crate::resume::canonical_form(&target.payload));
3033 }
3034 parts.join(" ")
3035}
3036
3037pub fn semantic_answer_matches(text: &str, answer: &SemanticAnswer) -> bool {
3045 semantic_clauses(text)
3046 .iter()
3047 .any(|clause| semantic_answer_matches_clause(clause, answer))
3048}
3049
3050fn semantic_answer_matches_clause(text: &str, answer: &SemanticAnswer) -> bool {
3051 let tokens = normalized_tokens(text);
3052 if !answer.qualifiers.iter().all(|alternatives| {
3053 alternatives.iter().any(|value| {
3054 phrase_positions(&tokens, &normalized_tokens(value))
3055 .next()
3056 .is_some()
3057 })
3058 }) {
3059 return false;
3060 }
3061 let mut values = Vec::with_capacity(answer.aliases.len() + 1);
3062 values.push(answer.value.as_str());
3063 values.extend(answer.aliases.iter().map(String::as_str));
3064 values.into_iter().any(|value| {
3065 phrase_positions(&tokens, &normalized_tokens(value)).any(|position| {
3066 is_negated(&tokens, position) == (answer.polarity == AnswerPolarity::Negated)
3067 })
3068 })
3069}
3070
3071fn normalized_tokens(text: &str) -> Vec<String> {
3072 lexical_tokens(text)
3073 .into_iter()
3074 .map(|token| semantic_stem(&token))
3075 .collect()
3076}
3077
3078fn exact_lexical_containment(text: &str, value: &str) -> bool {
3079 let text = text.to_lowercase();
3080 let value = value.to_lowercase();
3081 if value.is_empty() {
3082 return false;
3083 }
3084 text.match_indices(&value).any(|(start, matched)| {
3085 let end = start + matched.len();
3086 let before = text[..start].chars().next_back();
3087 let after = text[end..].chars().next();
3088 before.is_none_or(|character| !character.is_alphanumeric())
3089 && after.is_none_or(|character| !character.is_alphanumeric())
3090 })
3091}
3092
3093fn lexical_tokens(text: &str) -> Vec<String> {
3094 let mut expanded = text.to_lowercase().replace('’', "'");
3095 for (contraction, replacement) in [
3096 ("can't", "cannot"),
3097 ("won't", "will not"),
3098 ("wouldn't", "would not"),
3099 ("shouldn't", "should not"),
3100 ("couldn't", "could not"),
3101 ("mustn't", "must not"),
3102 ("isn't", "is not"),
3103 ("aren't", "are not"),
3104 ("doesn't", "does not"),
3105 ("don't", "do not"),
3106 ("didn't", "did not"),
3107 ] {
3108 expanded = expanded.replace(contraction, replacement);
3109 }
3110 let mut prepared = String::new();
3111 for character in expanded.chars() {
3112 if character.is_alphanumeric() {
3113 prepared.push(character);
3114 } else {
3115 prepared.push(' ');
3116 }
3117 }
3118 prepared
3119 .split_whitespace()
3120 .filter(|token| !matches!(*token, "a" | "an" | "the" | "to" | "is" | "are"))
3121 .map(ToOwned::to_owned)
3122 .collect()
3123}
3124
3125fn semantic_stem(token: &str) -> String {
3126 let mut value = token.to_string();
3127 if value.len() >= 6 && value.ends_with("ing") {
3128 value.truncate(value.len() - 3);
3129 } else if value.len() > 5 && value.ends_with("ed") {
3130 value.truncate(value.len() - 2);
3131 } else if value.len() > 5 && value.ends_with('s') {
3132 value.truncate(value.len() - 1);
3133 }
3134 if value.len() > 4 && value.ends_with('e') {
3135 value.pop();
3136 }
3137 if value.len() > 3 {
3138 let mut chars = value.chars().rev();
3139 if chars.next() == chars.next() {
3140 value.pop();
3141 }
3142 }
3143 value
3144}
3145
3146fn phrase_positions<'a>(
3147 tokens: &'a [String],
3148 phrase: &'a [String],
3149) -> impl Iterator<Item = usize> + 'a {
3150 tokens
3151 .windows(phrase.len().max(1))
3152 .enumerate()
3153 .filter(move |(_, window)| !phrase.is_empty() && *window == phrase)
3154 .map(|(index, _)| index)
3155}
3156
3157fn is_negated(tokens: &[String], position: usize) -> bool {
3158 tokens[..position]
3159 .iter()
3160 .enumerate()
3161 .filter(|(index, token)| {
3162 if token.as_str() == "not" && tokens.get(index + 1).is_some_and(|next| next == "only") {
3163 return false;
3164 }
3165 if token.as_str() == "without" {
3166 return without_reaches(tokens, *index, position);
3167 }
3168 matches!(
3169 token.as_str(),
3170 "not"
3171 | "cannot"
3172 | "cant"
3173 | "no"
3174 | "never"
3175 | "unabl"
3176 | "avoid"
3177 | "forbid"
3178 | "forbidden"
3179 | "prohibit"
3180 )
3181 })
3182 .count()
3183 % 2
3184 == 1
3185}
3186
3187fn without_reaches(tokens: &[String], index: usize, position: usize) -> bool {
3188 index < position
3189 && tokens[index + 1..position].iter().all(|token| {
3190 matches!(
3191 token.as_str(),
3192 "ever" | "fully" | "adequately" | "properly" | "reliably" | "safely"
3193 )
3194 })
3195}
3196
3197fn semantic_clauses(text: &str) -> Vec<String> {
3198 let expanded = text.to_lowercase().replace('’', "'");
3199 let mut clauses = Vec::new();
3200 let mut current = Vec::new();
3201 for token in expanded.split_whitespace() {
3202 let trimmed = token
3203 .trim_matches(|character: char| matches!(character, ',' | '.' | '!' | '?' | ';' | ':'));
3204 let begins_new_clause =
3205 matches!(trimmed, "but" | "however" | "whereas" | "although" | "yet");
3206 if begins_new_clause && !current.is_empty() {
3207 clauses.push(current.join(" "));
3208 current.clear();
3209 }
3210 if !trimmed.is_empty() && !begins_new_clause {
3211 current.push(trimmed.to_string());
3212 }
3213 if token.ends_with(['.', '!', '?', ';', ':', ',']) && !current.is_empty() {
3214 clauses.push(current.join(" "));
3215 current.clear();
3216 }
3217 }
3218 if !current.is_empty() {
3219 clauses.push(current.join(" "));
3220 }
3221 clauses
3222}
3223
3224fn check_id(
3225 state: &CognitiveState,
3226 id: &str,
3227 path: &str,
3228 diagnostics: &mut Vec<CorpusDiagnostic>,
3229 field: &str,
3230 predicate: impl Fn(&NodeKind) -> bool,
3231) {
3232 match state.nodes.iter().find(|node| node.id == id) {
3233 Some(node) if predicate(&node.kind) => {}
3234 Some(_) => push(
3235 diagnostics,
3236 "golden-id-wrong-kind-or-status",
3237 format!("{path}.golden.{field}"),
3238 format!("golden ID `{id}` has the wrong kind or status"),
3239 ),
3240 None => push(
3241 diagnostics,
3242 "golden-id-absent",
3243 format!("{path}.golden.{field}"),
3244 format!("golden ID `{id}` is absent"),
3245 ),
3246 }
3247}
3248
3249fn expectation_matches(node: &Node, expectation: &OwnerTargetExpectation) -> bool {
3250 node.id == expectation.node_id
3251 && node.owner == expectation.owner
3252 && expectation.target.as_deref() == node_target(node)
3253}
3254
3255fn check_nonempty_unique(
3256 values: &[String],
3257 field_path: &str,
3258 field: &str,
3259 diagnostics: &mut Vec<CorpusDiagnostic>,
3260) {
3261 if values.is_empty() {
3262 push(
3263 diagnostics,
3264 "empty-golden-list",
3265 field_path,
3266 format!("`{field}` must not be empty"),
3267 );
3268 return;
3269 }
3270 let mut unique = BTreeSet::new();
3271 for value in values {
3272 if !unique.insert(value) {
3273 push(
3274 diagnostics,
3275 "duplicate-golden-id",
3276 field_path,
3277 format!("duplicate value `{value}`"),
3278 );
3279 }
3280 }
3281}
3282
3283fn validate_corpus_diversity(episodes: &[Episode], diagnostics: &mut Vec<CorpusDiagnostic>) {
3284 let unique_snapshots = episodes
3285 .iter()
3286 .map(|episode| episode.snapshot_source.as_str())
3287 .collect::<BTreeSet<_>>()
3288 .len();
3289 let mut initial_cardinalities = BTreeSet::new();
3290 let mut final_cardinalities = BTreeSet::new();
3291 let mut patch_counts = BTreeSet::new();
3292 let mut operation_kinds = BTreeSet::new();
3293 let mut revision_episodes = 0usize;
3294 let mut retirement_episodes = 0usize;
3295 let mut transitions = BTreeSet::new();
3296 let mut category_shapes = BTreeMap::<EpisodeCategory, BTreeSet<&'static str>>::new();
3297 let mut causal_signatures = BTreeSet::new();
3298 let mut revised_semantic_kinds = BTreeSet::new();
3299 let mut retired_semantic_kinds = BTreeSet::new();
3300 let mut initial_recovery_episodes = 0usize;
3301 let mut owner_handoff_episodes = 0usize;
3302 let mut relation_history_episodes = 0usize;
3303 let mut residue_history_episodes = 0usize;
3304
3305 for episode in episodes {
3306 let all_sources = std::iter::once(episode.snapshot_source.as_str())
3307 .chain(episode.patch_sources.iter().map(String::as_str))
3308 .collect::<Vec<_>>()
3309 .join("\n");
3310 if [
3311 "(working note ",
3312 "(reviewed question ",
3313 "(next action ",
3314 "(remaining risk ",
3315 "(evidence needed ",
3316 ]
3317 .iter()
3318 .any(|scaffold| all_sources.contains(scaffold))
3319 {
3320 push(
3321 diagnostics,
3322 "formulaic-history-scaffold",
3323 format!("episodes/{}.json.sources", episode.id),
3324 "history contains the rejected d/x0/x1/x2 benchmark scaffold",
3325 );
3326 }
3327 let Ok(initial) = decode_state(&episode.snapshot_source) else {
3328 continue;
3329 };
3330 let Ok(final_state) = replay_episode(episode) else {
3331 continue;
3332 };
3333 initial_cardinalities.insert(initial.nodes.len());
3334 final_cardinalities.insert(final_state.nodes.len());
3335 patch_counts.insert(episode.patch_sources.len());
3336 let mut revised = false;
3337 let mut retired = false;
3338 let mut owner_handoff = false;
3339 let mut relation_history = false;
3340 let mut residue_history = false;
3341 let mut current = initial.clone();
3342 let mut signature = initial
3343 .nodes
3344 .iter()
3345 .map(|node| format!("initial:{}", history_node_label(&node.kind)))
3346 .collect::<Vec<_>>();
3347 signature.sort();
3348 initial_recovery_episodes += usize::from(initial.nodes.iter().any(|node| {
3349 matches!(
3350 node.kind,
3351 NodeKind::Constraint { .. }
3352 | NodeKind::Goal { .. }
3353 | NodeKind::Question { .. }
3354 | NodeKind::Tension { .. }
3355 )
3356 }));
3357 for source in &episode.patch_sources {
3358 let Ok(document) = parse_document(source) else {
3359 continue;
3360 };
3361 let Ok(patch) = Patch::try_from(document) else {
3362 continue;
3363 };
3364 for operation in &patch.ops {
3365 match operation {
3366 PatchOp::Add(add) => {
3367 operation_kinds.insert("add");
3368 match &add.value {
3369 AddValue::Node(node) => {
3370 signature.push(format!("add:{}", history_node_label(&node.kind)))
3371 }
3372 AddValue::Relation(relation) => {
3373 relation_history = true;
3374 signature.push(format!("relation:{}", relation.kind.as_str()));
3375 }
3376 }
3377 }
3378 PatchOp::Revise(revision) => {
3379 operation_kinds.insert("revise");
3380 revised = true;
3381 let before = current
3382 .nodes
3383 .iter()
3384 .find(|node| node.id == revision.node.id);
3385 if let Some(before) = before {
3386 let before_kind = history_node_label(&before.kind);
3387 let after_kind = history_node_label(&revision.node.kind);
3388 signature.push(format!("revise:{before_kind}->{after_kind}"));
3389 if semantic_history_kind(&before.kind) {
3390 revised_semantic_kinds.insert(node_kind_name(&before.kind));
3391 }
3392 if before.owner != revision.node.owner {
3393 owner_handoff = true;
3394 signature.push("owner-handoff".into());
3395 }
3396 }
3397 }
3398 PatchOp::Retire(retirement) => {
3399 operation_kinds.insert("retire");
3400 retired = true;
3401 if let Some(before) = current
3402 .nodes
3403 .iter()
3404 .find(|node| node.id == retirement.node_id)
3405 {
3406 signature.push(format!("retire:{}", history_node_label(&before.kind)));
3407 if semantic_history_kind(&before.kind) {
3408 retired_semantic_kinds.insert(node_kind_name(&before.kind));
3409 }
3410 }
3411 }
3412 PatchOp::AppendFocus(append) => {
3413 operation_kinds.insert("append-focus");
3414 residue_history |= !append.frame.residue.is_empty();
3415 signature.push(format!(
3416 "focus:{}:{}",
3417 append.frame.transition.as_str(),
3418 if append.frame.residue.is_empty() {
3419 "clear"
3420 } else {
3421 "residue"
3422 }
3423 ));
3424 }
3425 }
3426 }
3427 if let Ok(next) = apply_patch(¤t, &patch) {
3428 current = next;
3429 }
3430 }
3431 causal_signatures.insert(signature);
3432 revision_episodes += usize::from(revised);
3433 retirement_episodes += usize::from(retired);
3434 owner_handoff_episodes += usize::from(owner_handoff);
3435 relation_history_episodes += usize::from(relation_history);
3436 residue_history_episodes += usize::from(residue_history);
3437 for frame in final_state
3438 .focus_streams
3439 .iter()
3440 .flat_map(|stream| &stream.frames)
3441 {
3442 transitions.insert(frame.transition.as_str());
3443 }
3444 let shape = category_shapes.entry(episode.category).or_default();
3445 for node in &final_state.nodes {
3446 shape.insert(node_kind_name(&node.kind));
3447 }
3448 }
3449 let distinct_category_shapes = category_shapes
3450 .values()
3451 .map(|shape| shape.iter().copied().collect::<Vec<_>>())
3452 .collect::<BTreeSet<_>>()
3453 .len();
3454 let requirements = [
3455 (
3456 unique_snapshots == EPISODE_COUNT,
3457 format!("{unique_snapshots} unique starting snapshots"),
3458 ),
3459 (
3460 initial_cardinalities.len() >= 3,
3461 format!("{} starting cardinalities", initial_cardinalities.len()),
3462 ),
3463 (
3464 final_cardinalities.len() >= 4,
3465 format!("{} final cardinalities", final_cardinalities.len()),
3466 ),
3467 (
3468 patch_counts.len() >= 3,
3469 format!("{} patch-count shapes", patch_counts.len()),
3470 ),
3471 (
3472 operation_kinds.len() == 4,
3473 format!("operations {operation_kinds:?}"),
3474 ),
3475 (
3476 revision_episodes >= 6,
3477 format!("{revision_episodes} episodes revise nodes"),
3478 ),
3479 (
3480 retirement_episodes >= 6,
3481 format!("{retirement_episodes} episodes retire nodes"),
3482 ),
3483 (
3484 transitions.len() >= 5,
3485 format!("transitions {transitions:?}"),
3486 ),
3487 (
3488 distinct_category_shapes == 6,
3489 format!("{distinct_category_shapes} category shapes"),
3490 ),
3491 ];
3492 for (passed, detail) in requirements {
3493 if !passed {
3494 push(
3495 diagnostics,
3496 "insufficient-corpus-diversity",
3497 "manifest.json.episodes",
3498 detail,
3499 );
3500 }
3501 }
3502 let causal_requirements = [
3503 (
3504 causal_signatures.len() >= 8,
3505 format!("{} structural history signatures", causal_signatures.len()),
3506 ),
3507 (
3508 initial_recovery_episodes >= 12,
3509 format!("{initial_recovery_episodes} histories begin with a recovery fact"),
3510 ),
3511 (
3512 revised_semantic_kinds.len() >= 3,
3513 format!("revised semantic kinds {revised_semantic_kinds:?}"),
3514 ),
3515 (
3516 retired_semantic_kinds.len() >= 3,
3517 format!("retired semantic kinds {retired_semantic_kinds:?}"),
3518 ),
3519 (
3520 owner_handoff_episodes >= 3,
3521 format!("{owner_handoff_episodes} owner handoff histories"),
3522 ),
3523 (
3524 relation_history_episodes >= 3,
3525 format!("{relation_history_episodes} relation-bearing histories"),
3526 ),
3527 (
3528 residue_history_episodes >= 3,
3529 format!("{residue_history_episodes} histories preserve focus residue"),
3530 ),
3531 ];
3532 for (passed, detail) in causal_requirements {
3533 if !passed {
3534 push(
3535 diagnostics,
3536 "insufficient-causal-history-diversity",
3537 "manifest.json.episodes",
3538 detail,
3539 );
3540 }
3541 }
3542}
3543
3544fn semantic_history_kind(kind: &NodeKind) -> bool {
3545 matches!(
3546 kind,
3547 NodeKind::Constraint { .. }
3548 | NodeKind::Goal { .. }
3549 | NodeKind::Question { .. }
3550 | NodeKind::Tension { .. }
3551 )
3552}
3553
3554fn history_node_label(kind: &NodeKind) -> String {
3555 match kind {
3556 NodeKind::Constraint { strength } => format!("constraint:{strength:?}"),
3557 NodeKind::Goal { status } => format!("goal:{status:?}"),
3558 NodeKind::Question { status } => format!("question:{status:?}"),
3559 NodeKind::Tension { status } => format!("tension:{status:?}"),
3560 _ => node_kind_name(kind).to_string(),
3561 }
3562}
3563
3564fn node_kind_name(kind: &NodeKind) -> &'static str {
3565 match kind {
3566 NodeKind::Entity { .. } => "entity",
3567 NodeKind::Claim { .. } => "claim",
3568 NodeKind::Evidence { .. } => "evidence",
3569 NodeKind::Goal { .. } => "goal",
3570 NodeKind::Constraint { .. } => "constraint",
3571 NodeKind::Question { .. } => "question",
3572 NodeKind::Relationship { .. } => "relationship",
3573 NodeKind::Desire { .. } => "desire",
3574 NodeKind::Attention { .. } => "attention",
3575 NodeKind::Association => "association",
3576 NodeKind::Tension { .. } => "tension",
3577 NodeKind::Prediction { .. } => "prediction",
3578 NodeKind::Perspective(_) => "perspective",
3579 NodeKind::Affect { .. } => "affect",
3580 NodeKind::Metacognition { .. } => "metacognition",
3581 }
3582}
3583
3584fn node_target(node: &Node) -> Option<&str> {
3585 match &node.kind {
3586 NodeKind::Relationship { target, .. } | NodeKind::Affect { target, .. } => Some(target),
3587 NodeKind::Perspective(value) => Some(&value.target),
3588 _ => None,
3589 }
3590}
3591
3592fn safe_episode_path(path: &str) -> bool {
3593 let path = Path::new(path);
3594 !path.is_absolute()
3595 && path.extension().and_then(|value| value.to_str()) == Some("json")
3596 && matches!(path.components().next(), Some(Component::Normal(value)) if value == "episodes")
3597 && path
3598 .components()
3599 .all(|component| matches!(component, Component::Normal(_)))
3600 && path.components().count() == 2
3601}
3602
3603fn utf8_prefix(value: &str, limit: usize) -> &str {
3604 let mut end = value.len().min(limit);
3605 while !value.is_char_boundary(end) {
3606 end -= 1;
3607 }
3608 &value[..end]
3609}
3610
3611fn ratio(numerator: usize, denominator: usize) -> f64 {
3612 if denominator == 0 {
3613 0.0
3614 } else {
3615 numerator as f64 / denominator as f64
3616 }
3617}
3618
3619fn ratio_usize(numerator: usize, denominator: usize) -> f64 {
3620 if denominator == 0 {
3621 if numerator == 0 { 0.0 } else { 1.0 }
3622 } else {
3623 numerator as f64 / denominator as f64
3624 }
3625}
3626
3627fn at_least(name: &'static str, actual: f64, required: f64) -> GateResult {
3628 GateResult {
3629 name,
3630 passed: actual >= required,
3631 actual,
3632 required,
3633 }
3634}
3635
3636fn at_most(name: &'static str, actual: f64, required: f64) -> GateResult {
3637 GateResult {
3638 name,
3639 passed: actual <= required,
3640 actual,
3641 required,
3642 }
3643}
3644
3645fn read(path: &Path) -> Result<Vec<u8>, CorpusError> {
3646 fs::read(path).map_err(|error| CorpusError {
3647 kind: CorpusErrorKind::Io,
3648 diagnostics: vec![CorpusDiagnostic {
3649 code: "corpus-io-error".into(),
3650 path: path.display().to_string(),
3651 message: error.to_string(),
3652 }],
3653 })
3654}
3655
3656fn decode_json<T: for<'de> Deserialize<'de>>(bytes: &[u8], path: &str) -> Result<T, CorpusError> {
3657 serde_json::from_slice(bytes).map_err(|error| CorpusError {
3658 kind: CorpusErrorKind::Invalid,
3659 diagnostics: vec![CorpusDiagnostic {
3660 code: "invalid-corpus-json".into(),
3661 path: path.into(),
3662 message: error.to_string(),
3663 }],
3664 })
3665}
3666
3667fn push(
3668 diagnostics: &mut Vec<CorpusDiagnostic>,
3669 code: &str,
3670 path: impl Into<String>,
3671 message: impl Into<String>,
3672) {
3673 diagnostics.push(CorpusDiagnostic {
3674 code: code.into(),
3675 path: path.into(),
3676 message: message.into(),
3677 });
3678}
3679
3680fn synthetic_diagnostic(code: &str, message: &str) -> Diagnostic {
3681 Diagnostic {
3682 code: code.into(),
3683 severity: crate::Severity::Error,
3684 span: crate::Span {
3685 start: 0,
3686 end: 0,
3687 start_line: 1,
3688 start_column: 1,
3689 end_line: 1,
3690 end_column: 1,
3691 },
3692 semantic_path: Vec::new(),
3693 message: message.into(),
3694 hint: None,
3695 }
3696}