normcore 0.1.1

Rust implementation baseline for NormCore normative admissibility evaluator
Documentation
use std::collections::BTreeSet;

#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Modality {
    Assertive,
    Conditional,
    Refusal,
    Descriptive,
}

impl Modality {
    pub fn as_str(&self) -> &'static str {
        match self {
            Modality::Assertive => "assertive",
            Modality::Conditional => "conditional",
            Modality::Refusal => "refusal",
            Modality::Descriptive => "descriptive",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
    Observed,
    Explicit,
    Inferred,
    Repeated,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
    Hypothesis,
    Candidate,
    Confirmed,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scope {
    Factual,
    Contextual,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EvaluationStatus {
    WellFormed,
    IllFormed,
    Unsupported,
    Underdetermined,
    ConditionallyAcceptable,
    ViolatesNorm,
    Acceptable,
    NoNormativeContent,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Statement {
    pub id: String,
    pub subject: String,
    pub predicate: String,
    pub raw_text: String,
    pub modality: Option<Modality>,
    pub conditions: Vec<String>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct KnowledgeNode {
    pub id: String,
    pub source: Source,
    pub status: Status,
    pub confidence: f64,
    pub scope: Scope,
    pub strength: String,
    pub semantic_id: Option<String>,
}

impl KnowledgeNode {
    pub fn new(
        id: String,
        source: Source,
        status: Status,
        confidence: f64,
        scope: Scope,
        strength: String,
        semantic_id: Option<String>,
    ) -> Result<Self, String> {
        if !(0.0..=1.0).contains(&confidence) {
            return Err(format!(
                "Confidence must be in [0.0, 1.0], got {confidence}"
            ));
        }
        if strength != "strong" && strength != "weak" {
            return Err(format!(
                "Strength must be 'strong' or 'weak', got {strength}"
            ));
        }
        Ok(Self {
            id,
            source,
            status,
            confidence,
            scope,
            strength,
            semantic_id,
        })
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct GroundSet {
    pub nodes: Vec<KnowledgeNode>,
}

impl GroundSet {
    pub fn is_empty(&self) -> bool {
        self.nodes.is_empty()
    }

    pub fn has_factual(&self) -> bool {
        self.nodes.iter().any(|k| k.scope == Scope::Factual)
    }

    pub fn has_scope(&self, scope: Scope) -> bool {
        self.nodes.iter().any(|k| k.scope == scope)
    }

    pub fn get_scope_strength(&self, scope: Scope) -> Option<String> {
        let mut found_any = false;
        for n in &self.nodes {
            if n.scope == scope {
                found_any = true;
                if n.strength == "strong" {
                    return Some("strong".to_string());
                }
            }
        }
        if found_any {
            Some("weak".to_string())
        } else {
            None
        }
    }

    pub fn has_strong_in_scope(&self, scope: Scope) -> bool {
        self.nodes
            .iter()
            .any(|k| k.scope == scope && k.strength == "strong")
    }

    pub fn resolve_ground(&self, ground_id: &str) -> Option<KnowledgeNode> {
        for n in &self.nodes {
            if n.id == ground_id {
                return Some(n.clone());
            }
        }
        for n in &self.nodes {
            if n.semantic_id.as_deref() == Some(ground_id) {
                return Some(n.clone());
            }
        }
        None
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct License {
    pub permitted_modalities: BTreeSet<Modality>,
}

impl License {
    pub fn permits(&self, modality: Modality) -> bool {
        self.permitted_modalities.contains(&modality)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AxiomCheckResult {
    pub status: EvaluationStatus,
    pub violated_axiom: Option<String>,
    pub explanation: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct StatementValidationResult {
    pub statement: Statement,
    pub status: EvaluationStatus,
    pub license: License,
    pub ground_set: GroundSet,
    pub violated_axiom: Option<String>,
    pub explanation: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct ValidationResult {
    pub status: EvaluationStatus,
    pub licensed: bool,
    pub can_retry: bool,
    pub feedback_hint: Option<String>,
    pub violated_axioms: Vec<String>,
    pub statement_results: Vec<StatementValidationResult>,
    pub explanation: String,
    pub num_statements: usize,
    pub num_acceptable: usize,
    pub grounds_accepted: usize,
    pub grounds_cited: usize,
}

#[cfg(test)]
mod tests {
    use super::GroundSet;
    use super::KnowledgeNode;
    use super::Modality;
    use super::Scope;
    use super::Source;
    use super::Status;

    fn node(id: &str, scope: Scope, strength: &str, semantic_id: Option<&str>) -> KnowledgeNode {
        KnowledgeNode::new(
            id.to_string(),
            Source::Observed,
            Status::Confirmed,
            1.0,
            scope,
            strength.to_string(),
            semantic_id.map(ToString::to_string),
        )
        .expect("must create node")
    }

    #[test]
    fn knowledge_node_new_rejects_invalid_strength() {
        let result = KnowledgeNode::new(
            "id".to_string(),
            Source::Observed,
            Status::Confirmed,
            1.0,
            Scope::Factual,
            "medium".to_string(),
            None,
        );
        assert!(result.is_err());
    }

    #[test]
    fn ground_set_resolves_by_semantic_id() {
        let ground_set = GroundSet {
            nodes: vec![node(
                "internal_1",
                Scope::Factual,
                "strong",
                Some("issue_123"),
            )],
        };
        let resolved = ground_set.resolve_ground("issue_123");
        assert_eq!(
            resolved.expect("must resolve by semantic id").id,
            "internal_1"
        );
    }

    #[test]
    fn scope_strength_prefers_strong_over_weak() {
        let ground_set = GroundSet {
            nodes: vec![
                node("n1", Scope::Factual, "weak", None),
                node("n2", Scope::Factual, "strong", None),
            ],
        };
        assert_eq!(
            ground_set.get_scope_strength(Scope::Factual),
            Some("strong".to_string())
        );
    }

    #[test]
    fn modality_as_str_maps_all_variants() {
        assert_eq!(Modality::Assertive.as_str(), "assertive");
        assert_eq!(Modality::Conditional.as_str(), "conditional");
        assert_eq!(Modality::Refusal.as_str(), "refusal");
        assert_eq!(Modality::Descriptive.as_str(), "descriptive");
    }

    #[test]
    fn ground_set_scope_helpers_cover_true_false_paths() {
        let empty = GroundSet { nodes: vec![] };
        assert!(empty.is_empty());
        assert!(!empty.has_factual());
        assert!(!empty.has_scope(Scope::Factual));
        assert!(!empty.has_scope(Scope::Contextual));
        assert_eq!(empty.get_scope_strength(Scope::Factual), None);
        assert!(!empty.has_strong_in_scope(Scope::Factual));

        let mixed = GroundSet {
            nodes: vec![
                node("n1", Scope::Factual, "weak", None),
                node("n2", Scope::Contextual, "strong", None),
            ],
        };
        assert!(!mixed.is_empty());
        assert!(mixed.has_factual());
        assert!(mixed.has_scope(Scope::Factual));
        assert!(mixed.has_scope(Scope::Contextual));
        assert_eq!(
            mixed.get_scope_strength(Scope::Factual),
            Some("weak".to_string())
        );
        assert!(!mixed.has_strong_in_scope(Scope::Factual));
        assert!(mixed.has_strong_in_scope(Scope::Contextual));
    }
}