Skip to main content

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}