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}