Skip to main content

ipfrs_tensorlogic/
rule_conflict_v2.rs

1//! Extended Rule Conflict Resolution V2
2//!
3//! This module provides comprehensive conflict detection and resolution for
4//! logic rule sets with support for multi-party conflicts, priority chains,
5//! and detailed conflict reports.
6//!
7//! # Conflict Types
8//!
9//! | Variant | Meaning |
10//! |---------|---------|
11//! | [`ConflictType::HeadOverlap`] | Two rules prove the same goal pattern (prefix match). |
12//! | [`ConflictType::CycleDetected`] | Rules form a dependency cycle. |
13//! | [`ConflictType::PriorityTie`] | Same priority value; resolution is ambiguous. |
14//! | [`ConflictType::ContradictoryConstraints`] | Rules impose contradictory head conditions. |
15//!
16//! # Resolution Strategies
17//!
18//! | Variant | Meaning |
19//! |---------|---------|
20//! | [`ResolutionStrategy::HigherPriority`] | Prefer rule with higher priority value. |
21//! | [`ResolutionStrategy::LaterTimestamp`] | Prefer more recently created rule. |
22//! | [`ResolutionStrategy::Alphabetical`] | Prefer lexicographically smaller rule_id. |
23//! | [`ResolutionStrategy::FirstRegistered`] | Prefer rule registered first (index order). |
24//!
25//! # Examples
26//!
27//! ```
28//! use ipfrs_tensorlogic::rule_conflict_v2::{
29//!     ResolutionStrategy, RuleConflictResolverV2, RuleV2,
30//! };
31//!
32//! let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
33//!
34//! resolver.register(RuleV2 {
35//!     rule_id: "r1".to_string(),
36//!     head: "parent".to_string(),
37//!     body: vec![],
38//!     priority: 10,
39//!     author: "alice".to_string(),
40//!     created_at_secs: 1000,
41//! });
42//!
43//! resolver.register(RuleV2 {
44//!     rule_id: "r2".to_string(),
45//!     head: "parent_of".to_string(),
46//!     body: vec![],
47//!     priority: 5,
48//!     author: "bob".to_string(),
49//!     created_at_secs: 2000,
50//! });
51//!
52//! let reports = resolver.resolve_all();
53//! // "parent" is a prefix of "parent_of" → HeadOverlap, r1 wins (higher priority)
54//! assert_eq!(reports.len(), 1);
55//! assert_eq!(reports[0].winner, Some("r1".to_string()));
56//! ```
57
58use std::collections::HashMap;
59
60// ─── ConflictType ─────────────────────────────────────────────────────────────
61
62/// Categorises the nature of a detected conflict between rules.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ConflictType {
65    /// Two rules prove the same goal pattern (head prefix overlap).
66    HeadOverlap,
67    /// Rules form a dependency cycle.
68    CycleDetected,
69    /// Two or more rules share the same priority value; the winner cannot be
70    /// determined unambiguously by priority alone.
71    PriorityTie,
72    /// Rules impose contradictory conditions on the same head predicate.
73    ContradictoryConstraints,
74}
75
76// ─── RuleV2 ───────────────────────────────────────────────────────────────────
77
78/// A versioned logic rule with conflict-resolution metadata.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct RuleV2 {
81    /// Unique identifier for this rule.
82    pub rule_id: String,
83    /// Head predicate pattern (what the rule proves).
84    pub head: String,
85    /// Body predicates (what the rule depends on).
86    pub body: Vec<String>,
87    /// Priority value — higher is preferred.
88    pub priority: i32,
89    /// Author / owner of this rule.
90    pub author: String,
91    /// Unix timestamp (seconds) of rule creation.
92    pub created_at_secs: u64,
93}
94
95// ─── ConflictReport ──────────────────────────────────────────────────────────
96
97/// A structured report describing a single detected conflict.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ConflictReport {
100    /// The kind of conflict detected.
101    pub conflict_type: ConflictType,
102    /// All rule IDs involved in the conflict.
103    pub rule_ids: Vec<String>,
104    /// The rule ID that wins (if it can be determined by the strategy).
105    pub winner: Option<String>,
106    /// Human-readable explanation of the conflict and its resolution.
107    pub resolution: String,
108}
109
110// ─── ResolutionStrategy ──────────────────────────────────────────────────────
111
112/// Strategy used to select the winning rule when a conflict is detected.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum ResolutionStrategy {
115    /// Prefer the rule with the higher `priority` value.
116    HigherPriority,
117    /// Prefer the more recently created rule (largest `created_at_secs`).
118    LaterTimestamp,
119    /// Prefer the rule with the lexicographically smaller `rule_id` (deterministic).
120    Alphabetical,
121    /// Prefer the rule that was registered first (smallest index in `rules` vec).
122    FirstRegistered,
123}
124
125// ─── RuleConflictResolverV2 ───────────────────────────────────────────────────
126
127/// Extended conflict resolver for logic rule sets.
128///
129/// Supports multi-party conflict detection, priority chains, cycle detection,
130/// and detailed conflict reporting.
131#[derive(Debug, Clone)]
132pub struct RuleConflictResolverV2 {
133    /// All registered rules, in registration order.
134    pub rules: Vec<RuleV2>,
135    /// Strategy used to choose the winner in a pairwise conflict.
136    pub strategy: ResolutionStrategy,
137}
138
139impl RuleConflictResolverV2 {
140    /// Create a new resolver with the given resolution strategy.
141    pub fn new(strategy: ResolutionStrategy) -> Self {
142        Self {
143            rules: Vec::new(),
144            strategy,
145        }
146    }
147
148    /// Register a rule with the resolver.
149    ///
150    /// Rules are appended in registration order; this order is used by
151    /// [`ResolutionStrategy::FirstRegistered`].
152    pub fn register(&mut self, rule: RuleV2) {
153        self.rules.push(rule);
154    }
155
156    /// Determine the winner between two rules (identified by their slice
157    /// indices) using the configured strategy.
158    ///
159    /// Returns `None` when the strategy cannot break the tie (e.g. equal
160    /// priority under [`ResolutionStrategy::HigherPriority`]).
161    fn pick_winner_indices(&self, idx_a: usize, idx_b: usize) -> Option<usize> {
162        let a = &self.rules[idx_a];
163        let b = &self.rules[idx_b];
164        match self.strategy {
165            ResolutionStrategy::HigherPriority => {
166                if a.priority > b.priority {
167                    Some(idx_a)
168                } else if b.priority > a.priority {
169                    Some(idx_b)
170                } else {
171                    None // tie
172                }
173            }
174            ResolutionStrategy::LaterTimestamp => {
175                if a.created_at_secs > b.created_at_secs {
176                    Some(idx_a)
177                } else if b.created_at_secs > a.created_at_secs {
178                    Some(idx_b)
179                } else {
180                    None
181                }
182            }
183            ResolutionStrategy::Alphabetical => {
184                if a.rule_id <= b.rule_id {
185                    Some(idx_a)
186                } else {
187                    Some(idx_b)
188                }
189            }
190            ResolutionStrategy::FirstRegistered => {
191                // idx_a < idx_b by construction in detect_head_overlaps, but
192                // guard anyway.
193                if idx_a <= idx_b {
194                    Some(idx_a)
195                } else {
196                    Some(idx_b)
197                }
198            }
199        }
200    }
201
202    /// Detect pairs of rules whose heads share a prefix (one is a prefix of
203    /// the other, or they are equal).
204    ///
205    /// For each such pair, a [`ConflictReport`] is produced.  When the
206    /// strategy resolves the tie the `winner` field is set; when the
207    /// priorities are equal under [`ResolutionStrategy::HigherPriority`] the
208    /// conflict type is [`ConflictType::PriorityTie`].
209    pub fn detect_head_overlaps(&self) -> Vec<ConflictReport> {
210        let mut reports = Vec::new();
211
212        for i in 0..self.rules.len() {
213            for j in (i + 1)..self.rules.len() {
214                let head_a = &self.rules[i].head;
215                let head_b = &self.rules[j].head;
216
217                let overlaps =
218                    head_a.starts_with(head_b.as_str()) || head_b.starts_with(head_a.as_str());
219                if !overlaps {
220                    continue;
221                }
222
223                // Determine conflict type and winner.
224                let (conflict_type, winner_id, resolution) =
225                    if matches!(self.strategy, ResolutionStrategy::HigherPriority)
226                        && self.rules[i].priority == self.rules[j].priority
227                    {
228                        let res = format!(
229                            "Rules '{}' and '{}' have overlapping heads ('{}' / '{}') \
230                             and equal priority {}; cannot determine winner.",
231                            self.rules[i].rule_id,
232                            self.rules[j].rule_id,
233                            head_a,
234                            head_b,
235                            self.rules[i].priority
236                        );
237                        (ConflictType::PriorityTie, None, res)
238                    } else {
239                        let winner_idx = self.pick_winner_indices(i, j);
240                        let winner_id = winner_idx.map(|idx| self.rules[idx].rule_id.clone());
241                        let res = match &winner_id {
242                            Some(w) => format!(
243                                "Rules '{}' and '{}' have overlapping heads ('{}' / '{}'). \
244                                 Winner: '{}' selected by {:?} strategy.",
245                                self.rules[i].rule_id,
246                                self.rules[j].rule_id,
247                                head_a,
248                                head_b,
249                                w,
250                                self.strategy
251                            ),
252                            None => format!(
253                                "Rules '{}' and '{}' have overlapping heads ('{}' / '{}'). \
254                                 Strategy {:?} could not break the tie.",
255                                self.rules[i].rule_id,
256                                self.rules[j].rule_id,
257                                head_a,
258                                head_b,
259                                self.strategy
260                            ),
261                        };
262                        (ConflictType::HeadOverlap, winner_id, res)
263                    };
264
265                reports.push(ConflictReport {
266                    conflict_type,
267                    rule_ids: vec![self.rules[i].rule_id.clone(), self.rules[j].rule_id.clone()],
268                    winner: winner_id,
269                    resolution,
270                });
271            }
272        }
273
274        reports
275    }
276
277    /// Detect dependency cycles among the registered rules.
278    ///
279    /// A dependency edge A → B exists when any predicate in A's body matches
280    /// B's head (exact match: `body_pred == B.head`).
281    ///
282    /// Cycle detection uses iterative DFS with 3-colour marking:
283    /// - `0` = unvisited
284    /// - `1` = in the current DFS stack (grey)
285    /// - `2` = fully processed (black)
286    ///
287    /// Each distinct cycle is reported once.
288    pub fn detect_cycles(&self) -> Vec<ConflictReport> {
289        // Build: head → list of rule indices that have this head.
290        let mut head_to_indices: HashMap<&str, Vec<usize>> = HashMap::new();
291        for (idx, rule) in self.rules.iter().enumerate() {
292            head_to_indices
293                .entry(rule.head.as_str())
294                .or_default()
295                .push(idx);
296        }
297
298        // Build adjacency list: rule index → set of rule indices it depends on.
299        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); self.rules.len()];
300        for (src_idx, rule) in self.rules.iter().enumerate() {
301            for body_pred in &rule.body {
302                if let Some(targets) = head_to_indices.get(body_pred.as_str()) {
303                    for &tgt_idx in targets {
304                        if tgt_idx != src_idx {
305                            adj[src_idx].push(tgt_idx);
306                        }
307                    }
308                }
309            }
310        }
311
312        let n = self.rules.len();
313        // 0 = unvisited, 1 = in-progress, 2 = done
314        let mut color: Vec<u8> = vec![0u8; n];
315        // parent[i] = index of node that discovered i in DFS
316        let mut parent: Vec<Option<usize>> = vec![None; n];
317        let mut reports: Vec<ConflictReport> = Vec::new();
318        // Track cycle signatures already reported to deduplicate.
319        let mut reported_cycles: Vec<Vec<usize>> = Vec::new();
320
321        for start in 0..n {
322            if color[start] != 0 {
323                continue;
324            }
325            // Iterative DFS using an explicit stack.
326            // Stack items: (node_index, iterator_position_into_adj[node])
327            let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
328            color[start] = 1;
329
330            'dfs: while let Some((node, child_pos)) = stack.last_mut() {
331                let node = *node;
332                let adj_list = &adj[node];
333
334                if *child_pos < adj_list.len() {
335                    let child = adj_list[*child_pos];
336                    *child_pos += 1;
337
338                    if color[child] == 1 {
339                        // Back edge found: reconstruct cycle path.
340                        let mut cycle_indices: Vec<usize> = Vec::new();
341                        cycle_indices.push(child);
342                        // Walk the stack backwards until we reach `child` again.
343                        let mut found = false;
344                        for &(frame_node, _) in stack.iter().rev() {
345                            if found {
346                                break;
347                            }
348                            cycle_indices.push(frame_node);
349                            if frame_node == child {
350                                found = true;
351                            }
352                        }
353                        cycle_indices.dedup();
354
355                        // Normalise the cycle for deduplication: rotate so that
356                        // the minimum index is first, then check set equality.
357                        let mut cycle_sorted = cycle_indices.clone();
358                        cycle_sorted.sort_unstable();
359                        cycle_sorted.dedup();
360
361                        let already = reported_cycles.contains(&cycle_sorted);
362
363                        if !already {
364                            reported_cycles.push(cycle_sorted);
365                            let rule_ids: Vec<String> = cycle_indices
366                                .iter()
367                                .map(|&idx| self.rules[idx].rule_id.clone())
368                                .collect();
369                            let resolution = format!(
370                                "Dependency cycle detected among rules: {}.",
371                                rule_ids.join(" → ")
372                            );
373                            reports.push(ConflictReport {
374                                conflict_type: ConflictType::CycleDetected,
375                                rule_ids,
376                                winner: None,
377                                resolution,
378                            });
379                        }
380                    } else if color[child] == 0 {
381                        color[child] = 1;
382                        parent[child] = Some(node);
383                        stack.push((child, 0));
384                    }
385                    // color[child] == 2: already fully processed, skip.
386                } else {
387                    // All children of `node` have been visited.
388                    color[node] = 2;
389                    stack.pop();
390                    let _ = &adj; // satisfy borrow checker
391                    continue 'dfs;
392                }
393            }
394        }
395
396        // Suppress the unused variable warning for `parent` (it's tracked for
397        // potential future use in detailed path reconstruction).
398        let _ = parent;
399
400        reports
401    }
402
403    /// Run all conflict detectors and return a deduplicated list of reports.
404    ///
405    /// Combines results from [`detect_head_overlaps`][Self::detect_head_overlaps]
406    /// and [`detect_cycles`][Self::detect_cycles].
407    pub fn resolve_all(&self) -> Vec<ConflictReport> {
408        let mut reports = self.detect_head_overlaps();
409        let cycles = self.detect_cycles();
410        for cycle in cycles {
411            // Deduplicate by rule_ids set.
412            let already = reports
413                .iter()
414                .any(|r| r.conflict_type == cycle.conflict_type && r.rule_ids == cycle.rule_ids);
415            if !already {
416                reports.push(cycle);
417            }
418        }
419        reports
420    }
421
422    /// Find the winning rule for a given goal string.
423    ///
424    /// Candidates are all rules whose head is a prefix of `goal` (i.e.
425    /// `goal.starts_with(&rule.head)`).  The configured strategy is then
426    /// applied to select the single best candidate.
427    ///
428    /// Returns `None` when no rule matches or after degenerate ties that
429    /// cannot be broken.
430    pub fn winner_for_goal(&self, goal: &str) -> Option<&RuleV2> {
431        // Collect (index, rule) pairs whose head matches.
432        let candidates: Vec<usize> = self
433            .rules
434            .iter()
435            .enumerate()
436            .filter(|(_, r)| goal.starts_with(r.head.as_str()))
437            .map(|(idx, _)| idx)
438            .collect();
439
440        if candidates.is_empty() {
441            return None;
442        }
443
444        // Fold over candidates to pick the best one.
445        let best_idx = candidates.iter().copied().reduce(|acc, idx| {
446            match self.pick_winner_indices(acc, idx) {
447                Some(winner) => winner,
448                // Tie-break: keep the earlier index for stability.
449                None => acc,
450            }
451        })?;
452
453        self.rules.get(best_idx)
454    }
455
456    /// Return all rules registered by a specific author.
457    pub fn rules_for_author(&self, author: &str) -> Vec<&RuleV2> {
458        self.rules.iter().filter(|r| r.author == author).collect()
459    }
460
461    /// Return all rules sorted by descending priority.
462    ///
463    /// Rules with the same priority retain their original registration order
464    /// (stable sort).
465    pub fn sorted_by_priority(&self) -> Vec<&RuleV2> {
466        let mut sorted: Vec<&RuleV2> = self.rules.iter().collect();
467        sorted.sort_by_key(|r| std::cmp::Reverse(r.priority)); // descending
468        sorted
469    }
470}
471
472// ─── Tests ────────────────────────────────────────────────────────────────────
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    // ── helpers ──────────────────────────────────────────────────────────────
479
480    fn make_rule(
481        id: &str,
482        head: &str,
483        body: Vec<&str>,
484        priority: i32,
485        author: &str,
486        ts: u64,
487    ) -> RuleV2 {
488        RuleV2 {
489            rule_id: id.to_string(),
490            head: head.to_string(),
491            body: body.iter().map(|s| s.to_string()).collect(),
492            priority,
493            author: author.to_string(),
494            created_at_secs: ts,
495        }
496    }
497
498    // ── 1: register adds rule ─────────────────────────────────────────────────
499
500    #[test]
501    fn test_register_adds_rule() {
502        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
503        assert_eq!(resolver.rules.len(), 0);
504        resolver.register(make_rule("r1", "parent", vec![], 5, "alice", 1000));
505        assert_eq!(resolver.rules.len(), 1);
506        resolver.register(make_rule("r2", "child", vec![], 3, "bob", 2000));
507        assert_eq!(resolver.rules.len(), 2);
508    }
509
510    // ── 2: no conflicts for non-overlapping rules ─────────────────────────────
511
512    #[test]
513    fn test_no_conflicts_non_overlapping() {
514        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
515        resolver.register(make_rule("r1", "ancestor", vec![], 5, "alice", 1000));
516        resolver.register(make_rule("r2", "sibling", vec![], 3, "bob", 2000));
517        let reports = resolver.detect_head_overlaps();
518        assert!(reports.is_empty());
519    }
520
521    // ── 3: head overlap detected for prefix match ─────────────────────────────
522
523    #[test]
524    fn test_head_overlap_prefix_match() {
525        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
526        resolver.register(make_rule("r1", "parent", vec![], 10, "alice", 1000));
527        resolver.register(make_rule("r2", "parent_of", vec![], 5, "bob", 2000));
528        let reports = resolver.detect_head_overlaps();
529        assert_eq!(reports.len(), 1);
530        assert!(matches!(
531            reports[0].conflict_type,
532            ConflictType::HeadOverlap
533        ));
534        assert!(reports[0].rule_ids.contains(&"r1".to_string()));
535        assert!(reports[0].rule_ids.contains(&"r2".to_string()));
536    }
537
538    // ── 4: higher priority wins with HigherPriority strategy ──────────────────
539
540    #[test]
541    fn test_higher_priority_wins() {
542        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
543        resolver.register(make_rule("r1", "parent", vec![], 10, "alice", 1000));
544        resolver.register(make_rule("r2", "parent_of", vec![], 5, "bob", 2000));
545        let reports = resolver.detect_head_overlaps();
546        assert_eq!(reports.len(), 1);
547        assert_eq!(reports[0].winner, Some("r1".to_string()));
548    }
549
550    // ── 5: priority tie generates PriorityTie report ──────────────────────────
551
552    #[test]
553    fn test_priority_tie_generates_report() {
554        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
555        resolver.register(make_rule("r1", "parent", vec![], 5, "alice", 1000));
556        resolver.register(make_rule("r2", "parent_of", vec![], 5, "bob", 2000));
557        let reports = resolver.detect_head_overlaps();
558        assert_eq!(reports.len(), 1);
559        assert!(matches!(
560            reports[0].conflict_type,
561            ConflictType::PriorityTie
562        ));
563        assert_eq!(reports[0].winner, None);
564    }
565
566    // ── 6: LaterTimestamp strategy picks newer rule ───────────────────────────
567
568    #[test]
569    fn test_later_timestamp_strategy() {
570        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::LaterTimestamp);
571        resolver.register(make_rule("r1", "parent", vec![], 5, "alice", 1000));
572        resolver.register(make_rule("r2", "parent_of", vec![], 5, "bob", 9999));
573        let reports = resolver.detect_head_overlaps();
574        assert_eq!(reports.len(), 1);
575        assert_eq!(reports[0].winner, Some("r2".to_string()));
576    }
577
578    // ── 7: Alphabetical strategy picks lexicographically first ────────────────
579
580    #[test]
581    fn test_alphabetical_strategy() {
582        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::Alphabetical);
583        resolver.register(make_rule("zebra_rule", "parent", vec![], 5, "alice", 1000));
584        resolver.register(make_rule("alpha_rule", "parent_of", vec![], 5, "bob", 2000));
585        let reports = resolver.detect_head_overlaps();
586        assert_eq!(reports.len(), 1);
587        assert_eq!(reports[0].winner, Some("alpha_rule".to_string()));
588    }
589
590    // ── 8: FirstRegistered strategy picks first by index ─────────────────────
591
592    #[test]
593    fn test_first_registered_strategy() {
594        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::FirstRegistered);
595        resolver.register(make_rule("r1", "parent", vec![], 5, "alice", 1000));
596        resolver.register(make_rule("r2", "parent_of", vec![], 5, "bob", 2000));
597        let reports = resolver.detect_head_overlaps();
598        assert_eq!(reports.len(), 1);
599        assert_eq!(reports[0].winner, Some("r1".to_string()));
600    }
601
602    // ── 9: cycle detection A→B→A ─────────────────────────────────────────────
603
604    #[test]
605    fn test_cycle_ab_ba() {
606        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
607        // A's body contains B's head; B's body contains A's head.
608        resolver.register(make_rule("A", "head_a", vec!["head_b"], 5, "alice", 1000));
609        resolver.register(make_rule("B", "head_b", vec!["head_a"], 5, "alice", 2000));
610        let reports = resolver.detect_cycles();
611        assert!(!reports.is_empty());
612        assert!(matches!(
613            reports[0].conflict_type,
614            ConflictType::CycleDetected
615        ));
616        // Both rules should be present.
617        let ids: Vec<&str> = reports[0].rule_ids.iter().map(|s| s.as_str()).collect();
618        assert!(ids.contains(&"A"));
619        assert!(ids.contains(&"B"));
620    }
621
622    // ── 10: cycle detection A→B→C→A ──────────────────────────────────────────
623
624    #[test]
625    fn test_cycle_abc() {
626        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
627        resolver.register(make_rule("A", "head_a", vec!["head_b"], 5, "alice", 1000));
628        resolver.register(make_rule("B", "head_b", vec!["head_c"], 5, "alice", 2000));
629        resolver.register(make_rule("C", "head_c", vec!["head_a"], 5, "alice", 3000));
630        let reports = resolver.detect_cycles();
631        assert!(!reports.is_empty());
632        let ids: Vec<&str> = reports[0].rule_ids.iter().map(|s| s.as_str()).collect();
633        assert!(ids.contains(&"A"));
634        assert!(ids.contains(&"B"));
635        assert!(ids.contains(&"C"));
636    }
637
638    // ── 11: no cycle for chain A→B→C (no back edge) ──────────────────────────
639
640    #[test]
641    fn test_no_cycle_chain_abc() {
642        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
643        resolver.register(make_rule("A", "head_a", vec!["head_b"], 5, "alice", 1000));
644        resolver.register(make_rule("B", "head_b", vec!["head_c"], 5, "alice", 2000));
645        resolver.register(make_rule("C", "head_c", vec![], 5, "alice", 3000));
646        let reports = resolver.detect_cycles();
647        assert!(reports.is_empty());
648    }
649
650    // ── 12: resolve_all returns both overlaps and cycles ─────────────────────
651
652    #[test]
653    fn test_resolve_all_combines_reports() {
654        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
655        // Head overlap: r1 / r2
656        resolver.register(make_rule("r1", "parent", vec!["head_b"], 10, "alice", 1000));
657        resolver.register(make_rule("r2", "parent_of", vec!["head_a"], 5, "bob", 2000));
658        // Cycle: r1 → r2 (r1's body has "head_b" which is r2's ... actually let's make a separate cycle)
659        // Additional dedicated cycle rules.
660        resolver.register(make_rule(
661            "cA",
662            "cycle_a",
663            vec!["cycle_b"],
664            1,
665            "carol",
666            3000,
667        ));
668        resolver.register(make_rule(
669            "cB",
670            "cycle_b",
671            vec!["cycle_a"],
672            1,
673            "carol",
674            4000,
675        ));
676        let reports = resolver.resolve_all();
677        let has_overlap = reports.iter().any(|r| {
678            r.conflict_type == ConflictType::HeadOverlap
679                || r.conflict_type == ConflictType::PriorityTie
680        });
681        let has_cycle = reports
682            .iter()
683            .any(|r| r.conflict_type == ConflictType::CycleDetected);
684        assert!(
685            has_overlap,
686            "Expected at least one HeadOverlap or PriorityTie report"
687        );
688        assert!(has_cycle, "Expected at least one CycleDetected report");
689    }
690
691    // ── 13: winner_for_goal returns highest priority matching rule ─────────────
692
693    #[test]
694    fn test_winner_for_goal_highest_priority() {
695        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
696        resolver.register(make_rule("r1", "parent", vec![], 5, "alice", 1000));
697        resolver.register(make_rule("r2", "parent", vec![], 10, "bob", 2000));
698        resolver.register(make_rule("r3", "parent_extra", vec![], 3, "carol", 3000));
699        let winner = resolver.winner_for_goal("parent(X,Y)");
700        assert!(winner.is_some());
701        assert_eq!(winner.map(|r| r.rule_id.as_str()), Some("r2"));
702    }
703
704    // ── 14: winner_for_goal returns None for unknown goal ─────────────────────
705
706    #[test]
707    fn test_winner_for_goal_unknown() {
708        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
709        resolver.register(make_rule("r1", "parent", vec![], 5, "alice", 1000));
710        let winner = resolver.winner_for_goal("ancestor(X,Y)");
711        assert!(winner.is_none());
712    }
713
714    // ── 15: rules_for_author filters correctly ────────────────────────────────
715
716    #[test]
717    fn test_rules_for_author() {
718        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
719        resolver.register(make_rule("r1", "parent", vec![], 5, "alice", 1000));
720        resolver.register(make_rule("r2", "child", vec![], 3, "bob", 2000));
721        resolver.register(make_rule("r3", "sibling", vec![], 4, "alice", 3000));
722        let alice_rules = resolver.rules_for_author("alice");
723        assert_eq!(alice_rules.len(), 2);
724        let ids: Vec<&str> = alice_rules.iter().map(|r| r.rule_id.as_str()).collect();
725        assert!(ids.contains(&"r1"));
726        assert!(ids.contains(&"r3"));
727        let bob_rules = resolver.rules_for_author("bob");
728        assert_eq!(bob_rules.len(), 1);
729    }
730
731    // ── 16: sorted_by_priority descending ────────────────────────────────────
732
733    #[test]
734    fn test_sorted_by_priority_descending() {
735        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
736        resolver.register(make_rule("r1", "a", vec![], 3, "alice", 1000));
737        resolver.register(make_rule("r2", "b", vec![], 10, "bob", 2000));
738        resolver.register(make_rule("r3", "c", vec![], 7, "carol", 3000));
739        let sorted = resolver.sorted_by_priority();
740        assert_eq!(sorted[0].priority, 10);
741        assert_eq!(sorted[1].priority, 7);
742        assert_eq!(sorted[2].priority, 3);
743    }
744
745    // ── 17: ConflictReport has winner field set correctly ─────────────────────
746
747    #[test]
748    fn test_conflict_report_winner_field() {
749        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::LaterTimestamp);
750        resolver.register(make_rule("old_rule", "fact", vec![], 5, "alice", 100));
751        resolver.register(make_rule(
752            "new_rule",
753            "fact_detail",
754            vec![],
755            5,
756            "alice",
757            9999,
758        ));
759        let reports = resolver.detect_head_overlaps();
760        assert_eq!(reports.len(), 1);
761        assert_eq!(reports[0].winner, Some("new_rule".to_string()));
762        assert!(!reports[0].resolution.is_empty());
763    }
764
765    // ── 18: head overlap with same head (exact match) ─────────────────────────
766
767    #[test]
768    fn test_head_overlap_exact_match() {
769        let mut resolver = RuleConflictResolverV2::new(ResolutionStrategy::HigherPriority);
770        resolver.register(make_rule("r1", "same_head", vec![], 5, "alice", 1000));
771        resolver.register(make_rule("r2", "same_head", vec![], 8, "bob", 2000));
772        let reports = resolver.detect_head_overlaps();
773        assert_eq!(reports.len(), 1);
774        // "same_head".starts_with("same_head") is true, so this is an overlap.
775        assert!(matches!(
776            reports[0].conflict_type,
777            ConflictType::HeadOverlap | ConflictType::PriorityTie
778        ));
779        // r2 has higher priority.
780        assert_eq!(reports[0].winner, Some("r2".to_string()));
781    }
782}