chappi-core 0.1.0

Typed cognitive graph, patch, reentry, projection, and measurement engine for Chappi
Documentation
use chappi_core::{
    AffectIntensity, AttentionMode, CognitiveState, Confidence, ConstraintStrength, DesireForce,
    GoalStatus, Lifetime, MetacognitionStatus, NodeKind, QuestionStatus, RelationKind,
    TensionStatus, snapshot_document, snapshot_source,
};
use chappi_syntax::{Atom, FormKind, canonicalize, parse_document};

fn decode(source: &str) -> CognitiveState {
    let document = parse_document(source).expect("fixture syntax should parse");
    CognitiveState::try_from(document).expect("fixture semantics should decode")
}

fn decode_errors(source: &str) -> Vec<chappi_core::Diagnostic> {
    let document = parse_document(source).expect("fixture syntax should parse");
    CognitiveState::try_from(document).expect_err("fixture semantics should be rejected")
}

#[test]
fn decodes_every_v1_node_kind_with_common_fields_and_open_payloads() {
    let state = decode(
        r#"(chappi 1)
(entity self self persistent 100 person (name "Self"))
(entity alex self persistent 90 person (name "Alex"))
(claim c1 self persistent 80 likely (text "Rust is useful"))
(evidence ev1 self working 70 observation "lab notebook" (observed compiler))
(goal g1 self working 85 committed (ship v1))
(constraint co1 self persistent 95 hard (must preserve-memory))
(question q1 self working 60 open (whether compact))
(relationship r1 self persistent 75 alex trusted (knows since-childhood))
(desire d1 self transient 65 strong (learn semantics))
(attention a1 self working 55 foreground (topic validation))
(association as1 self persistent 45 (links syntax semantics))
(tension t1 self working 50 open (tradeoff precision brevity))
(prediction pr1 self transient 40 plausible pending (tests pass))
(perspective p1 self working 70 alex tentative (believes busy))
(affect af1 self transient 35 alex fondness moderate (feels warm))
(metacognition m1 self working 30 monitoring (checking assumptions))
"#,
    );

    assert_eq!(state.version, 1);
    assert_eq!(state.nodes.len(), 16);
    assert!(state.relations.is_empty());

    let entity = &state.nodes[0];
    assert_eq!(entity.id, "self");
    assert_eq!(entity.owner, "self");
    assert_eq!(entity.lifetime, Lifetime::Persistent);
    assert_eq!(entity.salience, 100);
    assert!(matches!(&entity.kind, NodeKind::Entity { entity_kind } if entity_kind == "person"));
    let FormKind::List(payload) = &entity.payload.kind else {
        panic!("payload must remain a syntax form");
    };
    assert!(matches!(&payload[1].kind, FormKind::Atom(Atom::String(value)) if value == "Self"));
    assert_eq!(entity.span.start_line, 2);

    assert!(matches!(
        state.nodes[2].kind,
        NodeKind::Claim {
            confidence: Confidence::Likely
        }
    ));
    assert!(
        matches!(&state.nodes[3].kind, NodeKind::Evidence { evidence_kind, source } if evidence_kind == "observation" && matches!(source, chappi_core::Source::String(value) if value == "lab notebook"))
    );
    assert!(matches!(
        state.nodes[4].kind,
        NodeKind::Goal {
            status: GoalStatus::Committed
        }
    ));
    assert!(matches!(
        state.nodes[5].kind,
        NodeKind::Constraint {
            strength: ConstraintStrength::Hard
        }
    ));
    assert!(matches!(
        state.nodes[6].kind,
        NodeKind::Question {
            status: QuestionStatus::Open
        }
    ));
    assert!(
        matches!(&state.nodes[7].kind, NodeKind::Relationship { target, status } if target == "alex" && status == "trusted")
    );
    assert!(matches!(
        state.nodes[8].kind,
        NodeKind::Desire {
            force: DesireForce::Strong
        }
    ));
    assert!(matches!(
        state.nodes[9].kind,
        NodeKind::Attention {
            mode: AttentionMode::Foreground
        }
    ));
    assert!(matches!(state.nodes[10].kind, NodeKind::Association));
    assert!(matches!(
        state.nodes[11].kind,
        NodeKind::Tension {
            status: TensionStatus::Open
        }
    ));
    assert!(matches!(
        state.nodes[12].kind,
        NodeKind::Prediction {
            confidence: Confidence::Plausible,
            status: chappi_core::PredictionStatus::Pending
        }
    ));
    assert!(
        matches!(&state.nodes[13].kind, NodeKind::Perspective(value) if value.target == "alex" && value.confidence == Confidence::Tentative)
    );
    assert!(
        matches!(&state.nodes[14].kind, NodeKind::Affect { target, affect_kind, intensity: AffectIntensity::Moderate } if target == "alex" && affect_kind == "fondness")
    );
    assert!(matches!(
        state.nodes[15].kind,
        NodeKind::Metacognition {
            status: MetacognitionStatus::Monitoring
        }
    ));
    assert!(
        chappi_core::validate(&state).is_empty(),
        "every decoded v1 node kind should also form a valid graph"
    );
}

#[test]
fn rejects_missing_perspective_and_invalid_confidence_or_status() {
    let diagnostics = decode_errors(
        r#"(chappi 1)
(claim no-owner)
(claim bad-confidence self working 40 certain (invalid confidence))
(goal bad-status self working 40 paused (invalid status))
"#,
    );

    assert_eq!(diagnostics.len(), 3);
    assert_eq!(diagnostics[0].code, "missing-perspective");
    assert_eq!(diagnostics[0].semantic_path, ["nodes", "no-owner", "owner"]);
    assert_eq!(diagnostics[1].code, "invalid-confidence");
    assert_eq!(
        diagnostics[1].semantic_path,
        ["nodes", "bad-confidence", "confidence"]
    );
    assert_eq!(diagnostics[2].code, "invalid-goal-status");
    assert_eq!(
        diagnostics[2].semantic_path,
        ["nodes", "bad-status", "status"]
    );
}

#[test]
fn does_not_misdiagnose_an_omitted_payload_as_a_missing_perspective() {
    let diagnostics = decode_errors("(chappi 1)\n(claim c self working 50 likely)\n");
    assert_eq!(diagnostics.len(), 1);
    assert_eq!(diagnostics[0].code, "invalid-node-arity");

    let lifetime_named_owner =
        decode_errors("(chappi 1)\n(claim c persistent working 50 likely)\n");
    assert_eq!(lifetime_named_owner.len(), 1);
    assert_eq!(lifetime_named_owner[0].code, "invalid-node-arity");
}

#[test]
fn semantic_state_equality_ignores_source_location_metadata() {
    let compact = decode(
        "(chappi 1)\n(entity self self persistent 100 person (name \"Self\"))\n(claim c self working 50 likely (text \"same\"))\n(rel about c self)\n",
    );
    let reformatted = decode(
        "; leading comment\n\n(chappi 1)\n  (entity self self persistent 100 person (name \"Self\"))\n\n(claim c self working 50 likely (text \"same\"))\n(rel about c self)\n",
    );

    assert_eq!(compact, reformatted);
    assert_ne!(compact.nodes[0].span, reformatted.nodes[0].span);
    assert_ne!(compact.relations[0].span, reformatted.relations[0].span);
}

#[test]
fn requires_exactly_one_exact_v1_header() {
    let missing = decode_errors("(entity self self persistent 100 person (self))\n");
    assert_eq!(missing[0].code, "missing-header");

    let duplicate = decode_errors("(chappi 1)\n(chappi 1)\n");
    assert_eq!(duplicate[0].code, "duplicate-header");

    let wrong_version = decode_errors("(chappi 01)\n");
    assert_eq!(wrong_version[0].code, "invalid-header");

    let malformed_then_valid = decode_errors("(chappi 2)\n(chappi 1)\n");
    assert_eq!(
        malformed_then_valid
            .iter()
            .map(|item| item.code.as_str())
            .collect::<Vec<_>>(),
        ["invalid-header", "duplicate-header"]
    );
}

#[test]
fn rejects_salience_outside_the_closed_zero_to_one_hundred_range() {
    let diagnostics = decode_errors(
        "(chappi 1)\n(entity low low persistent -1 person (low))\n(entity high high persistent 101 person (high))\n",
    );

    assert_eq!(diagnostics.len(), 2);
    assert!(
        diagnostics
            .iter()
            .all(|item| item.code == "invalid-salience")
    );
}

#[test]
fn decodes_each_v1_relation_kind_without_a_relation_id() {
    let state = decode(
        r#"(chappi 1)
(entity self self persistent 100 person (self))
(entity alex self persistent 90 person (alex))
(claim c1 self persistent 80 likely (first))
(claim c2 self persistent 75 settled (second))
(evidence ev1 self working 70 observation notebook (observed))
(goal g1 self working 60 active (goal))
(constraint co1 self persistent 95 hard (constraint))
(association as1 self persistent 30 (association))
(prediction pr1 self transient 40 plausible pending (prediction))
(perspective p1 self working 50 alex likely (perspective))
(affect af1 self transient 35 alex fondness strong (affect))
(rel supports ev1 c1)
(rel contradicts c2 pr1)
(rel refines c2 c1)
(rel causes co1 g1)
(rel supersedes c2 c1)
(rel about p1 alex)
(rel blocks co1 g1)
(rel associates as1 af1)
"#,
    );

    assert_eq!(state.relations.len(), 8);
    assert_eq!(state.relations[0].kind, RelationKind::Supports);
    assert_eq!(state.relations[0].source, "ev1");
    assert_eq!(state.relations[0].target, "c1");
    assert_eq!(state.relations[0].span.start_line, 13);
    assert_eq!(state.relations[7].kind, RelationKind::Associates);
}

#[test]
fn snapshot_round_trip_preserves_revisions_and_focus_frames_losslessly() {
    let mut state = decode(
        r#"(chappi 1)
(entity self self persistent 100 person (name "Self"))
(claim c self working 80 settled (text "quoted \"payload\""))
(rel about c self)
(focus memory 0 self evoked none (fg c self) (bg) (residue) (trigger (noticed "first")))
(focus memory 1 self reframed 0 (fg self) (bg c) (residue) (trigger (insight "second")))
"#,
    );
    state.nodes[1].revision = 7;

    let bytes = snapshot_source(&state);
    let source = std::str::from_utf8(&bytes).expect("snapshot source must be UTF-8");
    assert!(source.contains("(revision c 7)\n"));
    assert!(!source.contains("(revision self 0)"));
    let decoded = decode(source);

    assert_eq!(decoded, state);
    assert_eq!(canonicalize(&snapshot_document(&state)), bytes);
}

#[test]
fn revision_forms_must_be_unique_nonzero_u64s_for_existing_nodes() {
    let fixtures = [
        ("(revision c 0)", "invalid-revision"),
        ("(revision c -1)", "invalid-revision"),
        ("(revision c nope)", "invalid-revision"),
        ("(revision \"c\" 1)", "invalid-revision-node"),
        ("(revision c 1)\n(revision c 2)", "duplicate-revision"),
        ("(revision missing 1)", "unknown-revision-node"),
    ];

    for (revision, expected_code) in fixtures {
        let source = format!(
            "(chappi 1)\n(entity self self persistent 100 person (self))\n(claim c self working 50 likely (claim))\n{revision}\n"
        );
        let diagnostics = decode_errors(&source);
        assert!(
            diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == expected_code),
            "expected {expected_code} for {revision}, got {diagnostics:#?}"
        );
    }
}