graphforge-knowledge 0.6.0-rc.1

Immutable GraphForge knowledge and epistemic record domains
Documentation
use super::*;
use crate::{
    Assertion, AssertionLedger, AssertionSupersession, AssertionSupersessionLedger, KnowledgeError,
};
use uuid::Uuid;

fn claim() -> (Assertion, ResearchClaimRecord) {
    let id = Uuid::now_v7();
    let provenance = Uuid::now_v7();
    (
        Assertion::new(
            id,
            "Interpretation of the same evidence".into(),
            provenance,
            10,
        )
        .unwrap(),
        ResearchClaimRecord {
            assertion_uuid: id,
            conceptual_uuid: id,
            category: ResearchCategory::Interpretation,
            creator_uuid: Uuid::now_v7(),
            run_uuid: None,
            origin_branch_uuid: None,
            origin_version_uuid: None,
            provenance_uuid: provenance,
            recorded_at: 10,
        },
    )
}
fn relation(source: Uuid, target: Uuid, kind: ClaimRelationKind) -> ClaimRelationRecord {
    ClaimRelationRecord {
        relation_uuid: Uuid::now_v7(),
        source_assertion_uuid: source,
        target_assertion_uuid: target,
        kind,
        creator_uuid: Uuid::now_v7(),
        provenance_uuid: Uuid::now_v7(),
        recorded_at: 20,
    }
}
#[test]
fn alternatives_coexist_without_rewriting_assertions_or_choosing_a_winner() {
    let (a, ca) = claim();
    let (b, cb) = claim();
    let assertions = assertions(vec![a.clone(), b.clone()]);
    let before = assertions.clone();
    let alternatives = relation(
        a.assertion_uuid,
        b.assertion_uuid,
        ClaimRelationKind::AlternativeTo,
    );
    let ledger = ResearchClaimLedger::new(vec![ca, cb], vec![alternatives]).unwrap();
    ledger
        .validate_references(&assertions, &AssertionSupersessionLedger::default())
        .unwrap();
    assert_eq!(assertions, before);
    assert_eq!(ledger.claims().len(), 2);
    assert_eq!(ledger.relations().len(), 1);
    assert_eq!(ledger.merge(&ledger).unwrap(), ledger);
    let mut changed = ledger.claims()[0].clone();
    changed.category = ResearchCategory::Theory;
    let staged = ResearchClaimLedger::new(vec![changed], vec![]).unwrap();
    assert!(matches!(
        ledger.merge(&staged),
        Err(KnowledgeError::Conflict(_))
    ));
}
#[test]
fn revision_preserves_concept_and_requires_existing_supersession_owner() {
    let (a, ca) = claim();
    let (b, mut cb) = claim();
    let assertions = assertions(vec![a.clone(), b.clone()]);
    let successor = relation(
        b.assertion_uuid,
        a.assertion_uuid,
        ClaimRelationKind::Supersedes,
    );
    let ledger =
        ResearchClaimLedger::new(vec![ca.clone(), cb.clone()], vec![successor.clone()]).unwrap();
    assert!(
        ledger
            .validate_references(&assertions, &AssertionSupersessionLedger::default())
            .is_err()
    );
    let owner = AssertionSupersessionLedger::new(vec![
        AssertionSupersession::new(
            Uuid::now_v7(),
            a.assertion_uuid,
            b.assertion_uuid,
            Uuid::now_v7(),
            Uuid::now_v7(),
            Uuid::now_v7(),
            20,
        )
        .unwrap(),
    ])
    .unwrap();
    assert!(ledger.validate_references(&assertions, &owner).is_err());
    cb.conceptual_uuid = ca.conceptual_uuid;
    let valid = ResearchClaimLedger::new(vec![ca, cb.clone()], vec![successor]).unwrap();
    valid.validate_references(&assertions, &owner).unwrap();
    // A legacy unclassified prior still anchors its successor's conceptual identity.
    ResearchClaimLedger::new(vec![cb], vec![])
        .unwrap()
        .validate_references(&assertions, &owner)
        .unwrap();
    assert_eq!(assertions.assertions.len(), 2);
}
#[test]
fn missing_producer_and_forged_origin_fail_closed_but_legacy_is_unclassified() {
    let (a, mut row) = claim();
    let assertions = assertions(vec![a]);
    ResearchClaimLedger::default()
        .validate_references(&assertions, &AssertionSupersessionLedger::default())
        .unwrap();
    row.category = ResearchCategory::MachineExtraction;
    assert!(ResearchClaimLedger::new(vec![row.clone()], vec![]).is_err());
    row.run_uuid = Some(Uuid::now_v7());
    row.provenance_uuid = Uuid::now_v7();
    assert!(
        ResearchClaimLedger::new(vec![row], vec![])
            .unwrap()
            .validate_references(&assertions, &AssertionSupersessionLedger::default())
            .is_err()
    );
}

fn assertions(rows: Vec<Assertion>) -> AssertionLedger {
    let shared_subject = Uuid::now_v7();
    let refs = rows
        .iter()
        .map(|row| {
            crate::AssertionGraphRef::new(
                row.assertion_uuid,
                shared_subject,
                crate::GraphObjectKind::Node,
                crate::AssertionGraphRole::Subject,
                0,
            )
            .unwrap()
        })
        .collect();
    AssertionLedger::new(rows, refs).unwrap()
}

#[test]
fn typed_arrow_round_trip_preserves_optional_origin_and_closed_categories() {
    let (_, mut first) = claim();
    let (_, second) = claim();
    first.category = ResearchCategory::MachineExtraction;
    first.run_uuid = Some(Uuid::now_v7());
    first.origin_branch_uuid = Some(Uuid::now_v7());
    first.origin_version_uuid = Some(Uuid::now_v7());
    let relation = relation(
        first.assertion_uuid,
        second.assertion_uuid,
        ClaimRelationKind::Disputes,
    );
    let ledger = ResearchClaimLedger::new(vec![first, second], vec![relation]).unwrap();
    let batch = ledger.claim_batch().unwrap();
    assert_eq!(
        ResearchClaimLedger::from_batches(
            std::slice::from_ref(&batch),
            &[ledger.relation_batch().unwrap()]
        )
        .unwrap(),
        ledger
    );
    let mut columns = batch.columns().to_vec();
    let category = batch.schema().index_of("category").unwrap();
    columns[category] = std::sync::Arc::new(arrow::array::StringArray::from(vec![
        "invented_truth";
        batch.num_rows()
    ]));
    let corrupt = arrow::record_batch::RecordBatch::try_new(batch.schema(), columns).unwrap();
    assert!(ResearchClaimLedger::from_batches(&[corrupt], &[]).is_err());
    let duplicates = [batch.clone(), batch];
    assert!(matches!(
        ResearchClaimLedger::from_batches(&duplicates, &[]),
        Err(KnowledgeError::Duplicate(_))
    ));
}

#[test]
fn decision_arrow_round_trip_preserves_order_and_context() {
    let event = ResearchDecisionRecord {
        sequence: 1,
        decision_uuid: Uuid::now_v7(),
        operation_uuid: Uuid::now_v7(),
        request_sha256: [7; 32],
        authority: ResearchAuthority {
            project_uuid: Uuid::now_v7(),
            community_uuid: Some(Uuid::now_v7()),
            context_uuid: Uuid::now_v7(),
        },
        subject_kind: ResearchSubjectKind::Node,
        subject_uuid: Uuid::now_v7(),
        kind: ResearchDecisionKind::Promote,
        creator_uuid: Uuid::now_v7(),
        source_version_uuid: Some(Uuid::now_v7()),
        recorded_at: -100,
    };
    let ledger = ResearchDecisionLedger::new(vec![event]).unwrap();
    assert_eq!(
        ResearchDecisionLedger::from_batches(&[ledger.batch().unwrap()]).unwrap(),
        ledger
    );
    let empty = ResearchDecisionLedger::default();
    assert_eq!(
        ResearchDecisionLedger::from_batches(&[empty.batch().unwrap()]).unwrap(),
        empty
    );
}

#[test]
fn conceptual_origin_crosses_unclassified_intermediate_revisions() {
    let (a, mut ca) = claim();
    let (b, _) = claim();
    let (c, mut cc) = claim();
    let assertions = assertions(vec![a.clone(), b.clone(), c.clone()]);
    let edges = [
        (a.assertion_uuid, b.assertion_uuid),
        (b.assertion_uuid, c.assertion_uuid),
    ];
    let owner = AssertionSupersessionLedger::new(
        edges
            .into_iter()
            .map(|(prior, next)| {
                AssertionSupersession::new(
                    Uuid::now_v7(),
                    prior,
                    next,
                    Uuid::now_v7(),
                    Uuid::now_v7(),
                    Uuid::now_v7(),
                    20,
                )
                .unwrap()
            })
            .collect(),
    )
    .unwrap();
    cc.conceptual_uuid = a.assertion_uuid;
    ResearchClaimLedger::new(vec![cc.clone()], vec![])
        .unwrap()
        .validate_references(&assertions, &owner)
        .unwrap();
    cc.conceptual_uuid = b.assertion_uuid;
    assert!(
        ResearchClaimLedger::new(vec![cc.clone()], vec![])
            .unwrap()
            .validate_references(&assertions, &owner)
            .is_err()
    );
    ca.conceptual_uuid = Uuid::now_v7();
    cc.conceptual_uuid = ca.conceptual_uuid;
    ResearchClaimLedger::new(vec![ca.clone(), cc.clone()], vec![])
        .unwrap()
        .validate_references(&assertions, &owner)
        .unwrap();
    cc.conceptual_uuid = b.assertion_uuid;
    assert!(
        ResearchClaimLedger::new(vec![ca, cc], vec![])
            .unwrap()
            .validate_references(&assertions, &owner)
            .is_err()
    );
}

#[test]
fn suppression_is_scoped_immutable_and_round_trips_without_touching_assertions() {
    use std::collections::HashSet;
    let assertion = Uuid::now_v7();
    let branch = Uuid::now_v7();
    let event = ResearchSuppressionRecord {
        suppression_uuid: Uuid::now_v7(),
        assertion_uuid: assertion,
        context_uuid: branch,
        creator_uuid: Uuid::now_v7(),
        provenance_uuid: Uuid::now_v7(),
        recorded_at: 12,
    };
    let ledger = ResearchSuppressionLedger::new(vec![event.clone()]).unwrap();
    assert!(ledger.is_suppressed(assertion, &HashSet::from([branch])));
    assert!(!ledger.is_suppressed(assertion, &HashSet::from([Uuid::now_v7()])));
    assert_eq!(
        ResearchSuppressionLedger::from_batches(&[ledger.batch().unwrap()]).unwrap(),
        ledger
    );
    assert_eq!(ledger.merge(&ledger).unwrap(), ledger);
    let mut changed = event;
    changed.context_uuid = Uuid::now_v7();
    assert!(
        ledger
            .merge(&ResearchSuppressionLedger::new(vec![changed]).unwrap())
            .is_err()
    );
}