Skip to main content

chappi_core/
measure.rs

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
581/// Derives the complete semantic comparison for an already fixed node selection.
582///
583/// The selection is never changed to satisfy a byte limit. The returned byte budget is the exact
584/// size of the resulting canonical content and is therefore a consequence of semantics.
585pub 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(&quote(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
1100/// Decodes only complete records from the v1 controlled-English grammar. A truncated or malformed
1101/// sentence contributes no facts for that record.
1102pub 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
1414/// Decodes only complete canonical compact records. Operational wrapper fields on
1415/// [`ReentryCapsule`] are outside this comparison scope; `canonical_content()` and the
1416/// controlled-English records both cover nodes, enclosed relations, and closed latest focus cues.
1417pub 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
3037// This is a deliberately controlled evaluator, not general-purpose NLP. It recognizes complete
3038// value/alias phrases and qualifier groups inside one clause, then assigns value polarity from
3039// the parity of the finite predicate-negator vocabulary before that value. Sentence punctuation,
3040// commas, and coordinating/subordinate boundaries split clauses. `not only` is the one explicit
3041// non-negating construction. `without` binds to the following matched predicate across only the
3042// small adverb whitelist in `without_reaches`; a comma keeps an introductory phrase such as
3043// `Without hesitation, ...` from negating a later clause.
3044pub 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(&current, &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}