agileplus-domain 0.3.0

Typed intent graphs for engineering work — convert prompts into validated DAGs of intents, features, tasks, tests, and edges, then store, query, and serve them from Rust, HTTP, or MCP.
Documentation
//! Extended ontology for the intent graph.
//!
//! Adds 4 node types and 3 edge types beyond the core ontology. Each
//! constructor validates the ID format `<Type>#<kebab-case-rest>`, sets
//! `DagStage` per the type's role, and stamps metadata with source
//! `agileplus-ontology-ext`.
//!
//! Mapping to existing [`NodeType`] variants:
//! - `Story`     → `NodeType::Story`     (DagStage::Feature)
//! - `Bug`       → `NodeType::Bug`       (DagStage::Task)
//! - `Metric`    → `NodeType::Artifact`  (DagStage::Intent) — proxy
//! - `Hypothesis`→ `NodeType::Intent`    (DagStage::Intent)
//!
//! Mapping to existing [`RelationshipType`] variants:
//! - `DecomposesInto` → `RelationshipType::TracesTo`   (sentinel)
//! - `Mitigates`      → `RelationshipType::DependsOn`  (sentinel)
//! - `Validates`      → `RelationshipType::Covers`     (sentinel)
//!
//! The original extended relationship is preserved in
//! `Edge.properties["relationship_type_extended"]` so the trace validator
//! can reconstruct intent without an enum bump.

use chrono::Utc;
use regex::Regex;
use serde_json::json;
use std::sync::OnceLock;

use crate::intent_graph::{
    DagStage, Edge, Meta, Node, NodeId, NodeType, RelationshipType, Status,
};

/// Extended node variants beyond the core [`NodeType`] enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExtendedNodeType {
    /// User-facing narrative; subtype of Feature.
    Story,
    /// Defect; actionable work item.
    Bug,
    /// Observable measurement; goal-shaped artifact.
    Metric,
    /// Testable claim; goal-shaped intent.
    Hypothesis,
}

impl ExtendedNodeType {
    /// Canonical type token used in ID prefixes.
    #[must_use]
    pub fn as_token(self) -> &'static str {
        match self {
            Self::Story => "Story",
            Self::Bug => "Bug",
            Self::Metric => "Metric",
            Self::Hypothesis => "Hypothesis",
        }
    }

    /// Underlying [`NodeType`] used in the materialized [`Node`].
    #[must_use]
    pub fn as_core_node_type(self) -> NodeType {
        match self {
            Self::Story => NodeType::Story,
            Self::Bug => NodeType::Bug,
            Self::Metric => NodeType::Artifact,
            Self::Hypothesis => NodeType::Intent,
        }
    }

    /// [`DagStage`] appropriate for this node variant.
    #[must_use]
    pub fn dag_stage(self) -> DagStage {
        match self {
            Self::Story => DagStage::Feature,
            Self::Bug => DagStage::Task,
            Self::Metric | Self::Hypothesis => DagStage::Intent,
        }
    }
}

impl std::fmt::Display for ExtendedNodeType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_token())
    }
}

/// Extended relationship variants beyond the core [`RelationshipType`] enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExtendedRelationshipType {
    /// Parent breaks into N children.
    DecomposesInto,
    /// Source mitigates a risk/bug.
    Mitigates,
    /// Source validates a feature/intent/hypothesis.
    Validates,
}

impl ExtendedRelationshipType {
    /// Property key storing the original extended variant.
    pub const PROP_KEY: &'static str = "relationship_type_extended";

    /// Canonical kebab-case identifier.
    #[must_use]
    pub fn as_kebab(self) -> &'static str {
        match self {
            Self::DecomposesInto => "decomposes-into",
            Self::Mitigates => "mitigates",
            Self::Validates => "validates",
        }
    }

    /// Underlying [`RelationshipType`] used as the materialized edge variant.
    #[must_use]
    pub fn as_core_relationship_type(self) -> RelationshipType {
        match self {
            Self::DecomposesInto => RelationshipType::TracesTo,
            Self::Mitigates => RelationshipType::DependsOn,
            Self::Validates => RelationshipType::Covers,
        }
    }
}

impl std::str::FromStr for ExtendedRelationshipType {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "decomposes-into" => Ok(Self::DecomposesInto),
            "mitigates" => Ok(Self::Mitigates),
            "validates" => Ok(Self::Validates),
            other => Err(format!("unknown extended relationship type: {other}")),
        }
    }
}

/// Lazy-compiled ID regex: `<Type>#<kebab-case-rest>`.
fn id_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^[A-Z][A-Za-z0-9]*#[a-z0-9]+(-[a-z0-9]+)*$").unwrap())
}

/// Validate an extended-node ID: `<Type>#<kebab-case-rest>`.
fn validate_extended_node_id(id: &str, expected_token: &str) -> Result<(), String> {
    let re = id_regex();
    if !re.is_match(id) {
        return Err(format!(
            "id `{id}` does not match `<Type>#<kebab-case-rest>`"
        ));
    }
    let prefix = id.split('#').next().unwrap_or("");
    if prefix != expected_token {
        return Err(format!(
            "id `{id}` has prefix `{prefix}`, expected `{expected_token}#`"
        ));
    }
    Ok(())
}

/// Build a default [`Meta`] for an ontology-ext constructed node/edge.
fn default_meta() -> Meta {
    Meta {
        source: "agileplus-ontology-ext".to_string(),
        timestamp: Utc::now().to_rfc3339(),
        confidence: None,
        agent_id: None,
    }
}

/// Construct a [`Node`] of an extended type with the given ID and title.
///
/// The ID must match `<Type>#<kebab-case-rest>` where `<Type>` is the
/// canonical token for `variant`.
#[allow(clippy::too_many_arguments)]
pub fn build_extended_node(
    variant: ExtendedNodeType,
    id: impl Into<String>,
    title: impl Into<String>,
) -> Result<Node, String> {
    let id = id.into();
    let title = title.into();
    validate_extended_node_id(&id, variant.as_token())?;
    Ok(Node {
        id: NodeId(id),
        title,
        node_type: variant.as_core_node_type(),
        dag_stage: variant.dag_stage(),
        status: Status::Active,
        meta: default_meta(),
        description: None,
        tags: Vec::new(),
        properties: None,
        table_ref: None,
        table_id: None,
        created_at: None,
        updated_at: None,
    })
}

/// Construct an [`Edge`] of an extended relationship type.
///
/// The original extended variant is preserved in
/// `Edge.properties["relationship_type_extended"]`.
pub fn build_extended_edge(
    variant: ExtendedRelationshipType,
    source: impl Into<String>,
    target: impl Into<String>,
) -> Result<Edge, String> {
    let source = source.into();
    let target = target.into();
    if source.is_empty() || target.is_empty() {
        return Err("source and target must be non-empty".to_string());
    }
    if source == target {
        return Err("source and target must differ".to_string());
    }
    Ok(Edge {
        id: format!("edge-{}", uuid::Uuid::new_v4()),
        source: NodeId(source),
        target: NodeId(target),
        relationship_type: variant.as_core_relationship_type(),
        canonical_map: None,
        meta: default_meta(),
        properties: Some(json!({
            ExtendedRelationshipType::PROP_KEY: variant.as_kebab(),
        })),
        created_at: None,
    })
}

// ---------------------------------------------------------------------------
// Convenience constructors
// ---------------------------------------------------------------------------

/// Build a `Story` node.
pub fn story(id: impl Into<String>, title: impl Into<String>) -> Result<Node, String> {
    build_extended_node(ExtendedNodeType::Story, id, title)
}

/// Build a `Bug` node.
pub fn bug(id: impl Into<String>, title: impl Into<String>) -> Result<Node, String> {
    build_extended_node(ExtendedNodeType::Bug, id, title)
}

/// Build a `Metric` node.
pub fn metric(id: impl Into<String>, title: impl Into<String>) -> Result<Node, String> {
    build_extended_node(ExtendedNodeType::Metric, id, title)
}

/// Build a `Hypothesis` node.
pub fn hypothesis(id: impl Into<String>, title: impl Into<String>) -> Result<Node, String> {
    build_extended_node(ExtendedNodeType::Hypothesis, id, title)
}

/// Build a `DecomposesInto` edge.
pub fn decomposes_into(
    source: impl Into<String>,
    target: impl Into<String>,
) -> Result<Edge, String> {
    build_extended_edge(ExtendedRelationshipType::DecomposesInto, source, target)
}

/// Build a `Mitigates` edge.
pub fn mitigates(source: impl Into<String>, target: impl Into<String>) -> Result<Edge, String> {
    build_extended_edge(ExtendedRelationshipType::Mitigates, source, target)
}

/// Build a `Validates` edge.
pub fn validates(source: impl Into<String>, target: impl Into<String>) -> Result<Edge, String> {
    build_extended_edge(ExtendedRelationshipType::Validates, source, target)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn story_constructor_validates_id_prefix() {
        let ok = story("Story#user-auth", "User auth").unwrap();
        assert_eq!(ok.id.0, "Story#user-auth");
        assert_eq!(ok.node_type, NodeType::Story);
        assert_eq!(ok.dag_stage, DagStage::Feature);
        assert_eq!(ok.status, Status::Active);

        let bad = story("Feature#user-auth", "x");
        assert!(bad.is_err());

        let bad2 = story("Story#User-Auth", "x");
        assert!(bad2.is_err(), "uppercase kebab not allowed");
    }

    #[test]
    fn metric_and_hypothesis_use_intent_stage() {
        let m = metric("Metric#latency-p99", "p99 latency").unwrap();
        assert_eq!(m.dag_stage, DagStage::Intent);

        let h = hypothesis("Hypothesis#cache-helps", "Cache helps p99").unwrap();
        assert_eq!(h.dag_stage, DagStage::Intent);
    }

    #[test]
    fn edge_constructor_round_trips_extended_type() {
        let e = decomposes_into("Story#a", "Task#b").unwrap();
        let prop = e
            .properties
            .as_ref()
            .and_then(|p| p.get(ExtendedRelationshipType::PROP_KEY))
            .and_then(|v| v.as_str())
            .unwrap();
        let parsed: ExtendedRelationshipType = prop.parse().unwrap();
        assert_eq!(parsed, ExtendedRelationshipType::DecomposesInto);
    }

    #[test]
    fn edge_constructor_rejects_self_loop() {
        let err = mitigates("Story#x", "Story#x").unwrap_err();
        assert!(err.contains("differ"));
    }
}