qualia_core_db/modalities/logic/rules.rs
1//! Rules Module — the RuleEngine bridge between N3 text and the live Webizen VM.
2//!
3//! This module is the public API surface for loading N3 rules, evaluating them
4//! against live Quins through the real `SlgArena` → `execute_vm_frame` pipeline,
5//! and emitting WAL audit events for every evaluation.
6//!
7//! ## Pipeline
8//! ```text
9//! N3 text
10//! -> n3_parser::parse_all (parse rules)
11//! -> RuleEngine::load_n3 (register into internal SlgArena)
12//! -> SlgArena::fire_registered_rules (compile to norms + bytecode, execute)
13//! -> RuleEngine::evaluate(quin) (match quin against fired conclusions)
14//! -> wal::log_rule_evaluation (durable audit event)
15//! -> Vec<RuleResult> (public result)
16//! ```
17
18use crate::modalities::logic::n3_parser::{N3Event, N3Parser};
19use crate::wal;
20use crate::{q_hash, NQuin};
21
22/// GuardianShip ruleset identifier
23pub const GUARDIANSHIP_RULESET: &str = "guardianship_rules";
24
25/// WAL predicate hash for rule-evaluation audit events.
26pub const RULE_EVAL_PREDICATE: u64 = q_hash("q42:ruleEvaluation");
27
28/// Rule engine for evaluating rule-based constraints against live Quins.
29///
30/// Internally owns a `SlgArena` (the 42MB Webizen VM) into which parsed N3 rules
31/// are registered and fired. The `evaluate` method matches an input Quin against
32/// the conclusions asserted by fired rules, returning real pass/fail verdicts.
33pub struct RuleEngine {
34 /// Named rulesets — each holds the raw N3 source text for traceability.
35 rulesets: Vec<RuleSet>,
36 /// The live Webizen VM arena. Rules are registered + fired here on load.
37 arena: crate::governance::webizen::SlgArena,
38 /// Contract graph hash used when compiling rules to norms.
39 contract_hash: u64,
40}
41
42/// A set of rules that can be applied to Quin data.
43///
44/// Stores the raw N3 source text so the ruleset can be re-parsed and re-fired
45/// if the engine is reset or the contract context changes.
46pub struct RuleSet {
47 pub name: String,
48 pub rules: Vec<Rule>,
49 /// The raw N3 source text this ruleset was loaded from (for audit/replay).
50 pub n3_source: String,
51}
52
53/// Individual rule definition (metadata — the actual logic lives in the VM).
54pub struct Rule {
55 pub name: String,
56 pub condition: String,
57 pub action: String,
58}
59
60/// Result of evaluating a single rule against a Quin.
61#[derive(Debug, Clone)]
62pub struct RuleResult {
63 /// The name of the ruleset this result comes from.
64 pub ruleset_name: String,
65 /// The rule name (or N3 rule id if annotated).
66 pub rule_name: String,
67 /// `true` when the input Quin matches the rule's conclusion pattern.
68 pub passed: bool,
69 /// Human-readable detail: match, no-match, or error.
70 pub message: String,
71}
72
73impl RuleEngine {
74 /// Create a new rule engine with an empty arena.
75 pub fn new() -> Self {
76 Self {
77 rulesets: Vec::new(),
78 arena: crate::governance::webizen::SlgArena::new(),
79 contract_hash: 0,
80 }
81 }
82
83 /// Create a new rule engine bound to a specific contract graph hash.
84 ///
85 /// The contract hash is used when compiling N3 rules to deontic norms —
86 /// it becomes the `context` field of the compiled norm Quin.
87 pub fn with_contract(contract_hash: u64) -> Self {
88 Self {
89 rulesets: Vec::new(),
90 arena: crate::governance::webizen::SlgArena::new(),
91 contract_hash,
92 }
93 }
94
95 /// Add a pre-built ruleset to the engine and fire its rules in the VM.
96 pub fn add_ruleset(&mut self, ruleset: RuleSet) {
97 self.parse_register_and_fire(&ruleset.n3_source);
98 self.rulesets.push(ruleset);
99 }
100
101 /// Load an N3 source string as a new ruleset, parse it, register and fire
102 /// the rules in the internal VM arena.
103 ///
104 /// Returns the number of rules parsed and fired.
105 pub fn load_n3(&mut self, name: &str, n3_source: &str) -> usize {
106 // Parse and register in one step — we can't return borrowed N3Rule<'a>
107 // and also mutate self.rulesets, so we extract metadata here.
108 let rule_metas = self.parse_register_and_fire(n3_source);
109 let ruleset = RuleSet {
110 name: name.to_string(),
111 rules: rule_metas
112 .into_iter()
113 .map(|(id, premise_debug, conclusion_debug)| Rule {
114 name: id,
115 condition: premise_debug,
116 action: conclusion_debug,
117 })
118 .collect(),
119 n3_source: n3_source.to_string(),
120 };
121 let count = ruleset.rules.len();
122 self.rulesets.push(ruleset);
123 count
124 }
125
126 /// Parse N3 text, register all logic rules into the arena, and fire them.
127 /// Returns metadata tuples (id, premise_debug, conclusion_debug) for each rule.
128 fn parse_register_and_fire(&mut self, n3_source: &str) -> Vec<(String, String, String)> {
129 let mut parser = N3Parser::new(n3_source);
130 let mut rules = Vec::new();
131 parser
132 .parse_all(|event| {
133 if let N3Event::LogicRule(rule) = event {
134 rules.push(rule);
135 }
136 Ok(())
137 })
138 .expect("N3 source must parse cleanly for RuleEngine::load_n3");
139
140 // Extract metadata before registering (registration doesn't consume the rule).
141 let metas: Vec<(String, String, String)> = rules
142 .iter()
143 .map(|r| {
144 (
145 r.id.unwrap_or("unnamed").to_string(),
146 format!("{:?}", r.premise),
147 format!("{:?}", r.conclusion),
148 )
149 })
150 .collect();
151
152 for rule in &rules {
153 self.arena.register_rule(rule);
154 }
155 self.arena.fire_registered_rules(self.contract_hash);
156 metas
157 }
158
159 /// Get a ruleset by name.
160 pub fn get_ruleset(&self, name: &str) -> Option<&RuleSet> {
161 self.rulesets.iter().find(|r| r.name == name)
162 }
163
164 /// Evaluate all rulesets against a Quin by checking whether the Quin
165 /// matches the premise pattern of any fired rule (norm) in the arena.
166 ///
167 /// After `fire_registered_rules`, the arena contains deontic norms derived
168 /// from rule premises. Each norm's predicate encodes the property path
169 /// (shifted left 8 bits) and a deontic opcode in the low byte. This method
170 /// extracts the property path and checks whether the input Quin's
171 /// (subject, predicate, object) matches any norm's premise pattern.
172 ///
173 /// Each evaluation emits a `q42:ruleEvaluation` WAL audit event.
174 /// Returns one `RuleResult` per rule across all rulesets.
175 pub fn evaluate(&self, quin: &NQuin) -> Vec<RuleResult> {
176 let results = self.evaluate_silent(quin);
177 // Emit a WAL audit event for this evaluation.
178 let _ = wal::log_rule_evaluation(quin, &results, self.contract_hash);
179 results
180 }
181
182 /// Evaluate without WAL logging (for hot paths or testing).
183 ///
184 /// Collects active norms from the arena, extracts each norm's premise
185 /// pattern (subject, property_path, object), and checks whether the input
186 /// Quin matches. A rule "passes" when its norm's premise pattern matches
187 /// the input Quin — meaning the rule fires for this input.
188 pub fn evaluate_silent(&self, quin: &NQuin) -> Vec<RuleResult> {
189 // Collect active quins (norms) from the arena.
190 let mut active = [NQuin::default(); 512];
191 let live_count = self.arena.collect_active_quins(&mut active);
192 let live_quins = &active[..live_count];
193
194 let mut results = Vec::new();
195 for ruleset in &self.rulesets {
196 for rule in &ruleset.rules {
197 // Check if the input Quin matches any norm's premise pattern.
198 // A norm's predicate is (property_path_hash << 8) | opcode,
199 // so we extract the property path by shifting right 8 bits.
200 // The shift loses the top 8 bits of the original hash, so we
201 // mask the input quin's predicate to the same 56-bit width.
202 let matched = live_quins.iter().any(|norm| {
203 let opcode = (norm.predicate & 0xFF) as u8;
204 // Only deontic norms (opcode 0x10-0x12) are rule-derived.
205 if opcode < 0x10 || opcode > 0x12 {
206 return false;
207 }
208 let norm_property_path = norm.predicate >> 8;
209 // The deontic encoding stores the path in bits [8..62] (55 bits)
210 // and clears bit 63 (DEFEATER_BIT). After >> 8, the path occupies
211 // bits [0..54]. Mask the input to the same 55-bit width.
212 let quin_property_path = quin.predicate & 0x007F_FFFF_FFFF_FFFF;
213 norm.subject == quin.subject
214 && norm_property_path == quin_property_path
215 && norm.object == quin.object
216 });
217
218 results.push(RuleResult {
219 ruleset_name: ruleset.name.clone(),
220 rule_name: rule.name.clone(),
221 passed: matched,
222 message: if matched {
223 "quin matches rule premise (rule fires)".to_string()
224 } else {
225 "no match".to_string()
226 },
227 });
228 }
229 }
230 results
231 }
232
233 /// Number of rulesets loaded.
234 pub fn ruleset_count(&self) -> usize {
235 self.rulesets.len()
236 }
237
238 /// Total number of rules across all rulesets.
239 pub fn rule_count(&self) -> usize {
240 self.rulesets.iter().map(|rs| rs.rules.len()).sum()
241 }
242
243 /// Number of rules currently registered in the VM arena.
244 pub fn arena_rule_count(&self) -> usize {
245 self.arena.rule_count()
246 }
247}
248
249impl Default for RuleEngine {
250 fn default() -> Self {
251 Self::new()
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn test_guardianship_ruleset_constant() {
261 assert_eq!(GUARDIANSHIP_RULESET, "guardianship_rules");
262 }
263
264 #[test]
265 fn test_rule_engine_creation() {
266 let engine = RuleEngine::new();
267 assert_eq!(engine.ruleset_count(), 0);
268 }
269
270 #[test]
271 fn test_add_ruleset() {
272 let mut engine = RuleEngine::new();
273 let ruleset = RuleSet {
274 name: "test_ruleset".to_string(),
275 rules: vec![],
276 n3_source: String::new(),
277 };
278 engine.add_ruleset(ruleset);
279 assert_eq!(engine.ruleset_count(), 1);
280 }
281
282 #[test]
283 fn test_get_ruleset() {
284 let mut engine = RuleEngine::new();
285 let ruleset = RuleSet {
286 name: "test_ruleset".to_string(),
287 rules: vec![],
288 n3_source: String::new(),
289 };
290 engine.add_ruleset(ruleset);
291
292 let retrieved = engine.get_ruleset("test_ruleset");
293 assert!(retrieved.is_some());
294 assert_eq!(retrieved.unwrap().name, "test_ruleset");
295 }
296
297 #[test]
298 fn test_load_n3_and_evaluate_match() {
299 // A ground Strict rule: if AcmeCorp forbids the dignity right,
300 // then AcmeCorp triggers a personhood error.
301 // After firing, the arena contains a NORM from the PREMISE:
302 // (AcmeCorp, forbids, DignityRight) with deontic opcode packed in.
303 let n3 = "{ ex:AcmeCorp q42:forbids ex:DignityRight } => \
304 { ex:AcmeCorp q42:triggers ex:PersonhoodError } .\n";
305 let mut engine = RuleEngine::with_contract(q_hash("did:webizen:test:contract"));
306 let count = engine.load_n3("guardianship", n3);
307 assert_eq!(count, 1, "one rule must parse and register");
308
309 // The PREMISE quin: (AcmeCorp, forbids, DignityRight) — matches the norm.
310 let premise_quin = NQuin {
311 subject: q_hash("ex:AcmeCorp"),
312 predicate: q_hash("q42:forbids"),
313 object: q_hash("ex:DignityRight"),
314 context: q_hash("did:webizen:test:contract"),
315 metadata: 0,
316 parity: 0,
317 };
318
319 let results = engine.evaluate_silent(&premise_quin);
320 assert_eq!(results.len(), 1);
321 // The premise quin must match the norm's premise pattern.
322 assert!(
323 results[0].passed,
324 "the premise quin must match the norm after fire_registered_rules; got: {:?}",
325 results[0]
326 );
327 }
328
329 #[test]
330 fn test_load_n3_and_evaluate_no_match() {
331 let n3 = "{ ex:AcmeCorp q42:forbids ex:DignityRight } => \
332 { ex:AcmeCorp q42:triggers ex:PersonhoodError } .\n";
333 let mut engine = RuleEngine::with_contract(q_hash("did:webizen:test:contract"));
334 engine.load_n3("guardianship", n3);
335
336 // An unrelated quin that does NOT match the premise.
337 let unrelated = NQuin {
338 subject: q_hash("ex:SomeOtherCorp"),
339 predicate: q_hash("q42:unrelated"),
340 object: q_hash("ex:SomethingElse"),
341 context: q_hash("did:webizen:test:contract"),
342 metadata: 0,
343 parity: 0,
344 };
345
346 let results = engine.evaluate_silent(&unrelated);
347 assert_eq!(results.len(), 1);
348 assert!(
349 !results[0].passed,
350 "an unrelated quin must not match the rule premise"
351 );
352 }
353
354 #[test]
355 fn test_empty_engine_evaluate() {
356 let engine = RuleEngine::new();
357 let quin = NQuin::default();
358 let results = engine.evaluate_silent(&quin);
359 assert_eq!(results.len(), 0, "empty engine returns zero results");
360 }
361}