Skip to main content

khive_runtime/
validation.rs

1//! Validation pipeline types for pack-contributed KG rules.
2//!
3//! Defines the trait surface for `CorpusCheck` (whole-corpus, cross-entity joins)
4//! and `StreamingRule` (per-record) shapes. Both return `Vec<Violation>` aggregated
5//! into a `ValidationReport`.
6
7use std::collections::BTreeMap;
8
9// ── Rule identity ─────────────────────────────────────────────────────────────
10
11/// Stable rule identifier, namespaced by pack: `"<pack>/<rule-id>"`.
12///
13/// Built-in rules use no namespace prefix (e.g. `"min-edge-density"`).
14/// Pack-contributed rules MUST be namespaced (e.g. `"biology/required-taxa-rank"`).
15pub type RuleId = &'static str;
16
17/// Severity of a validation finding.
18///
19/// Intended exit-code semantics (`Error` = exit 1, `Warning` = report-only
20/// unless `--strict`, `Info` = informational) apply to the shipped TOML
21/// RulePass; pack-declared rules are not yet executed by `kkernel kg
22/// validate` (ADR-034 defers CLI runner wiring). See
23/// `crates/khive-runtime/docs/api/validation.md` for the shipped-vs-deferred split.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
25pub enum Severity {
26    Info,
27    Warning,
28    Error,
29}
30
31// ── Corpus snapshot ───────────────────────────────────────────────────────────
32
33/// Opaque snapshot of the KG corpus passed to `CorpusCheck::check`.
34///
35/// v1 exposes the bare field set needed for the built-in rules. Pack authors
36/// that need richer access should open a design review to extend this surface —
37/// do NOT reach through this struct to the storage layer.
38#[non_exhaustive]
39pub struct GraphSnapshot {
40    /// Total entity count in the snapshot.
41    pub entity_count: usize,
42    /// Total edge count in the snapshot.
43    pub edge_count: usize,
44}
45
46/// Context passed to all rule implementations.
47///
48/// Design contract for the deferred pack-rule runner (ADR-034): once wired,
49/// it will carry configuration overrides from `.khive/kg/rules.toml` merged
50/// with pack defaults, read per-rule from `config[rule_id]`. No runtime code
51/// currently constructs a populated `ValidationContext`.
52#[non_exhaustive]
53pub struct ValidationContext<'a> {
54    /// The corpus snapshot for whole-corpus rules.
55    pub snapshot: &'a GraphSnapshot,
56    /// Per-rule config overrides, keyed by rule ID.
57    pub config: &'a BTreeMap<&'static str, serde_json::Value>,
58}
59
60// ── Violation ─────────────────────────────────────────────────────────────────
61
62/// A single rule violation produced by a rule implementation.
63#[non_exhaustive]
64pub struct Violation {
65    /// The rule that produced this violation.
66    pub rule_id: &'static str,
67    /// Violation severity (may differ from rule-level severity for pack rules
68    /// that emit mixed-severity output within one rule).
69    pub severity: Severity,
70    /// Human-readable explanation of the violation.
71    pub message: String,
72    /// Whether the violation is auto-fixable (the `--fix` write path is deferred; ADR-034).
73    pub fixable: bool,
74    /// Optional entity UUID (short-form) that the violation targets.
75    pub entity_id: Option<String>,
76    /// Optional edge UUID (short-form) that the violation targets.
77    pub edge_id: Option<String>,
78}
79
80impl Violation {
81    /// Construct a non-fixable violation without a specific entity/edge target.
82    pub fn new(rule_id: &'static str, severity: Severity, message: impl Into<String>) -> Self {
83        Self {
84            rule_id,
85            severity,
86            message: message.into(),
87            fixable: false,
88            entity_id: None,
89            edge_id: None,
90        }
91    }
92
93    /// Attach an entity identifier to an existing violation.
94    pub fn with_entity(mut self, id: impl Into<String>) -> Self {
95        self.entity_id = Some(id.into());
96        self
97    }
98}
99
100// ── Rule function type ────────────────────────────────────────────────────────
101
102/// Whole-corpus check function type.
103///
104/// Receives the corpus snapshot and config context; returns all violations
105/// produced by the rule in one call.
106pub type RuleFn = fn(&ValidationContext<'_>) -> Vec<Violation>;
107
108/// Optional auto-fix function type.
109///
110/// Receives the context and violations emitted by the corresponding `RuleFn`.
111/// Returns a `GraphPatch` (opaque in v1 — see below). Applying patches is part
112/// of the deferred auto-fix write path (ADR-034) — no validator currently
113/// invokes fix functions or applies patches. Returning `None` leaves the graph
114/// unchanged.
115///
116/// `GraphPatch` is a placeholder type in v1; the auto-fix write path is out of
117/// scope for this cluster.
118pub type FixFn = fn(&ValidationContext<'_>, &[Violation]) -> Option<GraphPatch>;
119
120/// Opaque graph patch produced by a fix function.
121///
122/// v1 carries no fields — the auto-fix machinery is stubbed. The type exists
123/// so pack authors can write `fix: Some(my_fix as FixFn)` without a
124/// compile-time change when the v1 fix path is wired up.
125#[non_exhaustive]
126pub struct GraphPatch;
127
128// ── ValidationRule ────────────────────────────────────────────────────────────
129
130/// A pack-contributed validation rule.
131///
132/// Rule IDs must follow the `<pack>/<rule-id>` namespace convention.
133/// See `docs/api/validation.md` for declaration examples and the
134/// shipped-vs-deferred split.
135pub struct ValidationRule {
136    /// Stable rule identifier in `<pack>/<rule-id>` format.
137    pub id: RuleId,
138    /// Default severity. TOML severity override for pack rules is part of the
139    /// deferred runner wiring (ADR-034); no override mapping exists yet.
140    pub severity: Severity,
141    /// Human-readable description (surfaced once the CLI runner wiring lands; ADR-034).
142    pub description: &'static str,
143    /// Whole-corpus check function.
144    pub check: RuleFn,
145    /// Optional auto-fix function. `None` for unfixable rules.
146    pub fix: Option<FixFn>,
147}
148
149// ── Aggregated report ─────────────────────────────────────────────────────────
150
151/// Aggregated result of running the full rule pipeline.
152#[derive(Default)]
153pub struct ValidationReport {
154    /// Violations grouped by rule ID, sorted canonically by rule ID.
155    pub violations_by_rule: BTreeMap<String, Vec<Violation>>,
156}
157
158impl ValidationReport {
159    /// Add violations for a given rule to the report.
160    pub fn add(&mut self, rule_id: &str, violations: Vec<Violation>) {
161        self.violations_by_rule
162            .entry(rule_id.to_string())
163            .or_default()
164            .extend(violations);
165    }
166
167    /// Total number of violations at `Severity::Error` across all rules.
168    pub fn error_count(&self) -> usize {
169        self.violations_by_rule
170            .values()
171            .flat_map(|vs| vs.iter())
172            .filter(|v| v.severity == Severity::Error)
173            .count()
174    }
175
176    /// Total number of violations at `Severity::Warning` across all rules.
177    pub fn warning_count(&self) -> usize {
178        self.violations_by_rule
179            .values()
180            .flat_map(|vs| vs.iter())
181            .filter(|v| v.severity == Severity::Warning)
182            .count()
183    }
184
185    /// `true` when no errors were found (the standard exit-0 condition).
186    pub fn passed(&self) -> bool {
187        self.error_count() == 0
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn violation_builder() {
197        let v = Violation::new("test/rule", Severity::Warning, "something is off")
198            .with_entity("abc123");
199        assert_eq!(v.rule_id, "test/rule");
200        assert_eq!(v.severity, Severity::Warning);
201        assert!(!v.fixable);
202        assert_eq!(v.entity_id.as_deref(), Some("abc123"));
203    }
204
205    #[test]
206    fn report_error_count() {
207        let mut report = ValidationReport::default();
208        report.add(
209            "test/rule",
210            vec![
211                Violation::new("test/rule", Severity::Error, "bad"),
212                Violation::new("test/rule", Severity::Warning, "meh"),
213            ],
214        );
215        assert_eq!(report.error_count(), 1);
216        assert_eq!(report.warning_count(), 1);
217        assert!(!report.passed());
218    }
219
220    #[test]
221    fn report_passed_when_no_errors() {
222        let mut report = ValidationReport::default();
223        report.add(
224            "test/rule",
225            vec![Violation::new("test/rule", Severity::Warning, "meh")],
226        );
227        assert!(report.passed());
228    }
229
230    #[test]
231    fn graph_patch_is_constructible() {
232        // Ensure the placeholder type can be named and constructed.
233        let _patch = GraphPatch;
234    }
235
236    #[test]
237    fn validation_rule_fields() {
238        fn dummy_check(_ctx: &ValidationContext<'_>) -> Vec<Violation> {
239            vec![]
240        }
241        let rule = ValidationRule {
242            id: "bio/taxa",
243            severity: Severity::Warning,
244            description: "taxa must exist",
245            check: dummy_check,
246            fix: None,
247        };
248        assert_eq!(rule.id, "bio/taxa");
249        assert!(rule.fix.is_none());
250    }
251}