Current section

Files

Jump to
rbt_weave crates weave-graph src patterns.rs
Raw

crates/weave-graph/src/patterns.rs

//! Conflict-of-interest pattern catalog.
//!
//! Patterns are defined as data structures, not hardcoded traversals.
//! New patterns can be added by appending to the catalog vectors
//! returned by [`cycle_patterns`], [`path_patterns`], and the hub
//! threshold constant.
use nulid::Nulid;
use serde::{Deserialize, Serialize};
/// How serious a detected conflict is considered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Low,
Medium,
High,
}
/// A cycle pattern: a directed cycle whose edge types match a sequence.
#[derive(Debug, Clone)]
pub struct CyclePattern {
pub id: &'static str,
pub name: &'static str,
pub severity: Severity,
/// Edge types that must appear in order along the cycle.
/// The cycle length equals the length of this vector.
pub edge_sequence: &'static [&'static str],
}
/// A single step in a path pattern.
#[derive(Debug, Clone)]
pub struct PathStep {
pub edge_type: &'static str,
/// If `Some`, the target node must have this label.
pub target_label: Option<&'static str>,
}
/// A path pattern: a directed path whose edges and (optionally) node
/// labels match a template.
#[derive(Debug, Clone)]
pub struct PathPattern {
pub id: &'static str,
pub name: &'static str,
pub severity: Severity,
/// The label the starting node must have, or `None` for any.
pub start_label: Option<&'static str>,
pub steps: &'static [PathStep],
}
/// A single detected conflict pattern.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictPattern {
pub pattern_id: String,
pub pattern_name: String,
pub severity: Severity,
/// NULIDs of all involved nodes.
pub nodes: Vec<Nulid>,
/// NULIDs of all involved edges.
pub edges: Vec<Nulid>,
/// Ordered path: alternating node, edge, node, edge, ...
pub path: Vec<Nulid>,
/// Human-readable summary.
pub description: String,
}
/// Cycle-based conflict patterns.
#[must_use]
pub fn cycle_patterns() -> Vec<CyclePattern> {
vec![
CyclePattern {
id: "COI-001",
name: "Payment-Appointment Cycle",
severity: Severity::High,
edge_sequence: &["PAID_TO", "APPOINTED_BY"],
},
CyclePattern {
id: "COI-002",
name: "Contract-Payment Cycle",
severity: Severity::High,
edge_sequence: &["AWARDED_CONTRACT", "PAID_TO"],
},
CyclePattern {
id: "COI-003",
name: "Revolving Door",
severity: Severity::High,
edge_sequence: &["EMPLOYED_BY", "LOBBIED"],
},
]
}
/// Path-based conflict patterns.
#[must_use]
pub fn path_patterns() -> Vec<PathPattern> {
vec![
PathPattern {
id: "COI-004",
name: "Family Appointment",
severity: Severity::Medium,
start_label: Some("Person"),
steps: &[
PathStep {
edge_type: "FAMILY_OF",
target_label: Some("Person"),
},
PathStep {
edge_type: "APPOINTED_BY",
target_label: Some("Organization"),
},
],
},
PathPattern {
id: "COI-005",
name: "Payment Influence Chain",
severity: Severity::Medium,
start_label: Some("Person"),
steps: &[
PathStep {
edge_type: "PAID_TO",
target_label: Some("Organization"),
},
PathStep {
edge_type: "AWARDED_CONTRACT",
target_label: Some("Organization"),
},
],
},
]
}
/// Edge types considered "influence" edges for hub concentration.
pub const HUB_INFLUENCE_TYPES: &[&str] = &[
"PAID_TO",
"AWARDED_CONTRACT",
"APPOINTED_BY",
"FUNDED_BY",
"OWNS",
"LOBBIED",
];
/// Minimum distinct influence edge types for a hub to be flagged.
pub const HUB_THRESHOLD: usize = 3;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cycle_catalog_has_expected_entries() {
let patterns = cycle_patterns();
assert_eq!(patterns.len(), 3);
assert_eq!(patterns[0].id, "COI-001");
assert_eq!(patterns[0].edge_sequence.len(), 2);
}
#[test]
fn path_catalog_has_expected_entries() {
let patterns = path_patterns();
assert_eq!(patterns.len(), 2);
assert_eq!(patterns[0].id, "COI-004");
assert_eq!(patterns[0].steps.len(), 2);
}
#[test]
fn severity_serializes_lowercase() {
let json = serde_json::to_string(&Severity::High).unwrap_or_default();
assert_eq!(json, "\"high\"");
}
}