Skip to main content

weave_graph/
patterns.rs

1//! Conflict-of-interest pattern catalog.
2//!
3//! Patterns are defined as data structures, not hardcoded traversals.
4//! New patterns can be added by appending to the catalog vectors
5//! returned by [`cycle_patterns`], [`path_patterns`], and the hub
6//! threshold constant.
7
8use nulid::Nulid;
9use serde::{Deserialize, Serialize};
10/// How serious a detected conflict is considered.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Severity {
14    Low,
15    Medium,
16    High,
17}
18/// A cycle pattern: a directed cycle whose edge types match a sequence.
19#[derive(Debug, Clone)]
20pub struct CyclePattern {
21    pub id: &'static str,
22    pub name: &'static str,
23    pub severity: Severity,
24    /// Edge types that must appear in order along the cycle.
25    /// The cycle length equals the length of this vector.
26    pub edge_sequence: &'static [&'static str],
27}
28
29/// A single step in a path pattern.
30#[derive(Debug, Clone)]
31pub struct PathStep {
32    pub edge_type: &'static str,
33    /// If `Some`, the target node must have this label.
34    pub target_label: Option<&'static str>,
35}
36
37/// A path pattern: a directed path whose edges and (optionally) node
38/// labels match a template.
39#[derive(Debug, Clone)]
40pub struct PathPattern {
41    pub id: &'static str,
42    pub name: &'static str,
43    pub severity: Severity,
44    /// The label the starting node must have, or `None` for any.
45    pub start_label: Option<&'static str>,
46    pub steps: &'static [PathStep],
47}
48/// A single detected conflict pattern.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ConflictPattern {
51    pub pattern_id: String,
52    pub pattern_name: String,
53    pub severity: Severity,
54    /// NULIDs of all involved nodes.
55    pub nodes: Vec<Nulid>,
56    /// NULIDs of all involved edges.
57    pub edges: Vec<Nulid>,
58    /// Ordered path: alternating node, edge, node, edge, ...
59    pub path: Vec<Nulid>,
60    /// Human-readable summary.
61    pub description: String,
62}
63/// Cycle-based conflict patterns.
64#[must_use]
65pub fn cycle_patterns() -> Vec<CyclePattern> {
66    vec![
67        CyclePattern {
68            id: "COI-001",
69            name: "Payment-Appointment Cycle",
70            severity: Severity::High,
71            edge_sequence: &["PAID_TO", "APPOINTED_BY"],
72        },
73        CyclePattern {
74            id: "COI-002",
75            name: "Contract-Payment Cycle",
76            severity: Severity::High,
77            edge_sequence: &["AWARDED_CONTRACT", "PAID_TO"],
78        },
79        CyclePattern {
80            id: "COI-003",
81            name: "Revolving Door",
82            severity: Severity::High,
83            edge_sequence: &["EMPLOYED_BY", "LOBBIED"],
84        },
85    ]
86}
87
88/// Path-based conflict patterns.
89#[must_use]
90pub fn path_patterns() -> Vec<PathPattern> {
91    vec![
92        PathPattern {
93            id: "COI-004",
94            name: "Family Appointment",
95            severity: Severity::Medium,
96            start_label: Some("Person"),
97            steps: &[
98                PathStep {
99                    edge_type: "FAMILY_OF",
100                    target_label: Some("Person"),
101                },
102                PathStep {
103                    edge_type: "APPOINTED_BY",
104                    target_label: Some("Organization"),
105                },
106            ],
107        },
108        PathPattern {
109            id: "COI-005",
110            name: "Payment Influence Chain",
111            severity: Severity::Medium,
112            start_label: Some("Person"),
113            steps: &[
114                PathStep {
115                    edge_type: "PAID_TO",
116                    target_label: Some("Organization"),
117                },
118                PathStep {
119                    edge_type: "AWARDED_CONTRACT",
120                    target_label: Some("Organization"),
121                },
122            ],
123        },
124    ]
125}
126
127/// Edge types considered "influence" edges for hub concentration.
128pub const HUB_INFLUENCE_TYPES: &[&str] = &[
129    "PAID_TO",
130    "AWARDED_CONTRACT",
131    "APPOINTED_BY",
132    "FUNDED_BY",
133    "OWNS",
134    "LOBBIED",
135];
136
137/// Minimum distinct influence edge types for a hub to be flagged.
138pub const HUB_THRESHOLD: usize = 3;
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn cycle_catalog_has_expected_entries() {
146        let patterns = cycle_patterns();
147        assert_eq!(patterns.len(), 3);
148        assert_eq!(patterns[0].id, "COI-001");
149        assert_eq!(patterns[0].edge_sequence.len(), 2);
150    }
151
152    #[test]
153    fn path_catalog_has_expected_entries() {
154        let patterns = path_patterns();
155        assert_eq!(patterns.len(), 2);
156        assert_eq!(patterns[0].id, "COI-004");
157        assert_eq!(patterns[0].steps.len(), 2);
158    }
159
160    #[test]
161    fn severity_serializes_lowercase() {
162        let json = serde_json::to_string(&Severity::High).unwrap_or_default();
163        assert_eq!(json, "\"high\"");
164    }
165}