//! Semantic resolution engine: subtype inference + constraint matrix.
//!
//! This module is the core of the typed resolution graph. It runs inside
//! the hot loop on every candidate pair and must be O(1) per pair.
//!
//! Design:
//! - Subtype inference: evaluate compiled rules against entity data.
//!   First matching rule (sorted by priority desc) wins.
//! - Constraint matrix: lookup (subtype_a, subtype_b) -> action.
//!   The pair is normalized alphabetically so (variant, model) and
//!   (model, variant) find the same entry, with direction flipped.

use std::collections::HashMap;
use cannon_common::ir::{
    ConstraintAction,
    IdentityPlan, SubtypeCondition, SubtypeOperator, RelateDirection,
};
use super::types::{NormalizedEntity, SubtypeInference, ResolutionDecision, Decision};

/// A subtype condition with an optional pre-compiled regex for `Matches` operator.
struct CompiledCondition {
    condition: SubtypeCondition,
    /// Pre-compiled regex for `Matches` operator. None for other operators or
    /// if the pattern failed to compile.
    compiled_regex: Option<regex::Regex>,
}

/// A subtype rule with pre-compiled regexes for any `Matches` conditions.
struct PreparedSubtypeRule {
    subtype: String,
    conditions: Vec<CompiledCondition>,
    #[allow(dead_code)]
    priority: u16,
}

/// Semantic engine for typed resolution decisions.
/// Pure logic, no DB, no async.
pub struct SemanticEngine {
    /// Subtype rules sorted by priority descending, with pre-compiled regexes.
    prepared_rules: Vec<PreparedSubtypeRule>,
    /// Constraint matrix keyed by alphabetically-sorted (subtype_a, subtype_b).
    /// The bool indicates whether the pair was swapped from the original definition.
    constraint_matrix: HashMap<(String, String), (ConstraintAction, bool)>,
    /// Minimum confidence for subtype inference to participate in constraints.
    confidence_threshold: f64,
}

impl SemanticEngine {
    /// Create an inactive semantic engine (no subtype rules).
    pub fn inactive() -> Self {
        Self {
            prepared_rules: vec![],
            constraint_matrix: HashMap::new(),
            confidence_threshold: 0.7,
        }
    }

    pub fn new(plan: &IdentityPlan) -> Self {
        let mut subtype_rules = plan.subtype_rules.clone();
        // Sort by priority descending so highest priority is checked first
        subtype_rules.sort_by(|a, b| b.priority.cmp(&a.priority));

        // Pre-compile regex patterns to avoid recompilation in the hot loop
        let prepared_rules: Vec<PreparedSubtypeRule> = subtype_rules.into_iter().map(|rule| {
            let conditions = rule.conditions.into_iter().map(|cond| {
                let compiled_regex = if cond.operator == SubtypeOperator::Matches {
                    cond.value.as_ref().and_then(|pattern| {
                        regex::Regex::new(pattern).ok()
                    })
                } else {
                    None
                };
                CompiledCondition { condition: cond, compiled_regex }
            }).collect();
            PreparedSubtypeRule {
                subtype: rule.subtype,
                conditions,
                priority: rule.priority,
            }
        }).collect();

        let mut constraint_matrix = HashMap::new();
        for constraint in &plan.constraint_matrix {
            // Normalize pair alphabetically
            let (key, swapped) = if constraint.subtype_a <= constraint.subtype_b {
                ((constraint.subtype_a.clone(), constraint.subtype_b.clone()), false)
            } else {
                ((constraint.subtype_b.clone(), constraint.subtype_a.clone()), true)
            };
            constraint_matrix.insert(key, (constraint.action.clone(), swapped));
        }

        Self {
            prepared_rules,
            constraint_matrix,
            confidence_threshold: plan.subtype_confidence_threshold,
        }
    }

    /// Returns true if the semantic engine has subtype rules configured.
    /// When false, evaluate and apply_constraints are no-ops.
    pub fn is_active(&self) -> bool {
        !self.prepared_rules.is_empty()
    }

    /// Infer subtype from entity data. First matching rule (by priority) wins.
    /// Returns None if no rule matches.
    pub fn infer_subtype(&self, entity: &NormalizedEntity) -> Option<SubtypeInference> {
        for rule in &self.prepared_rules {
            let mut all_match = true;
            let mut evidence = Vec::new();
            let mut min_confidence = 1.0_f64;

            for compiled_cond in &rule.conditions {
                let condition = &compiled_cond.condition;
                let field_val = entity.data.get(&condition.field);
                let matched = match condition.operator {
                    SubtypeOperator::Present => {
                        field_val.map_or(false, |v| !v.is_empty())
                    }
                    SubtypeOperator::Equals => {
                        if let (Some(val), Some(target)) = (field_val, &condition.value) {
                            val.to_lowercase() == target.to_lowercase()
                        } else {
                            false
                        }
                    }
                    SubtypeOperator::Contains => {
                        if let (Some(val), Some(target)) = (field_val, &condition.value) {
                            val.to_lowercase().contains(&target.to_lowercase())
                        } else {
                            false
                        }
                    }
                    SubtypeOperator::Matches => {
                        if let (Some(val), Some(ref re)) = (field_val, &compiled_cond.compiled_regex) {
                            re.is_match(val)
                        } else {
                            false
                        }
                    }
                };

                if !matched {
                    all_match = false;
                    break;
                }

                // Build evidence and compute confidence based on field presence/quality
                let val_str = field_val.map(|v| v.as_str()).unwrap_or("");
                evidence.push(format!("{}:{}", condition.field, val_str));

                // Confidence heuristic: present gives 0.9, exact match gives 1.0,
                // contains/matches gives 0.85
                let cond_confidence = match condition.operator {
                    SubtypeOperator::Present => 0.9,
                    SubtypeOperator::Equals => 1.0,
                    SubtypeOperator::Contains => 0.85,
                    SubtypeOperator::Matches => 0.85,
                };
                min_confidence = min_confidence.min(cond_confidence);
            }

            if all_match {
                return Some(SubtypeInference {
                    subtype: rule.subtype.clone(),
                    confidence: min_confidence,
                    evidence,
                });
            }
        }

        None
    }

    /// Try to resolve a pair deterministically via constraints BEFORE statistical thresholding.
    ///
    /// Returns Some(resolution) if constraints can decide, None if statistical thresholding needed.
    /// This is Layer 2 of the decision cascade: hard negative > semantic constraints > statistics.
    ///
    /// Rules:
    /// - Block -> always NoMatch (regardless of score)
    /// - Relate -> Relate if score > 0 (some evidence of similarity exists)
    /// - MergeAllowed or no constraint -> None (let statistics decide)
    pub fn try_constraint_decision(
        &self,
        score: f64,
        subtype_a: &Option<SubtypeInference>,
        subtype_b: &Option<SubtypeInference>,
    ) -> Option<ResolutionDecision> {
        let (st_a, st_b) = match (subtype_a, subtype_b) {
            (Some(a), Some(b)) => (a, b),
            _ => return None, // can't resolve without subtypes
        };

        if st_a.confidence < self.confidence_threshold || st_b.confidence < self.confidence_threshold {
            return None; // low confidence - let statistics decide
        }

        let (key, swapped) = if st_a.subtype <= st_b.subtype {
            ((st_a.subtype.clone(), st_b.subtype.clone()), false)
        } else {
            ((st_b.subtype.clone(), st_a.subtype.clone()), true)
        };

        let (action, was_swapped) = match self.constraint_matrix.get(&key) {
            Some((action, was_swapped)) => (action.clone(), *was_swapped),
            None => return None, // open world - let statistics decide
        };

        let effective_swap = swapped ^ was_swapped;

        match action {
            ConstraintAction::Block => Some(ResolutionDecision::NoMatch),
            ConstraintAction::Relate { ref link_type, ref direction } => {
                // Only produce Relate if there's SOME evidence of similarity
                // (score > 0 means at least one field had partial overlap)
                if score > 0.0 {
                    let effective_direction = if effective_swap {
                        match direction {
                            RelateDirection::AToB => RelateDirection::BToA,
                            RelateDirection::BToA => RelateDirection::AToB,
                            RelateDirection::Symmetric => RelateDirection::Symmetric,
                        }
                    } else {
                        direction.clone()
                    };
                    Some(ResolutionDecision::Relate {
                        link_type: link_type.clone(),
                        direction: effective_direction,
                    })
                } else {
                    Some(ResolutionDecision::NoMatch) // different types AND no similarity
                }
            }
            ConstraintAction::MergeAllowed => None, // let statistics decide
        }
    }

    /// Apply constraints to a scored pair (legacy path, used by realtime_scoring).
    ///
    /// Logic:
    /// 1. Both subtypes None -> fall through (Decision maps directly)
    /// 2. Either subtype below confidence_threshold -> fall through
    /// 3. Lookup (subtype_a, subtype_b) in constraint_matrix (normalized pair order)
    /// 4. MergeAllowed -> original decision; Relate -> Relate; Block -> NoMatch
    /// 5. Pair not in matrix -> default MergeAllowed (open world)
    pub fn apply_constraints(
        &self,
        decision: &Decision,
        subtype_a: &Option<SubtypeInference>,
        subtype_b: &Option<SubtypeInference>,
    ) -> ResolutionDecision {
        // Step 1: Both subtypes None -> fall through
        let (st_a, st_b) = match (subtype_a, subtype_b) {
            (Some(a), Some(b)) => (a, b),
            _ => return decision_to_resolution(decision),
        };

        // Step 2: Either below confidence threshold -> fall through
        if st_a.confidence < self.confidence_threshold || st_b.confidence < self.confidence_threshold {
            return decision_to_resolution(decision);
        }

        // Step 3: Lookup in constraint matrix (normalized pair order)
        let (key, swapped) = if st_a.subtype <= st_b.subtype {
            ((st_a.subtype.clone(), st_b.subtype.clone()), false)
        } else {
            ((st_b.subtype.clone(), st_a.subtype.clone()), true)
        };

        let action = match self.constraint_matrix.get(&key) {
            Some((action, was_swapped)) => {
                // If the original constraint was already swapped during matrix building,
                // XOR with our current swap to determine effective direction
                let effective_swap = swapped ^ was_swapped;
                (action.clone(), effective_swap)
            }
            // Step 5: Pair not in matrix -> default MergeAllowed
            None => return decision_to_resolution(decision),
        };

        // Step 4: Apply action
        match action.0 {
            ConstraintAction::MergeAllowed => decision_to_resolution(decision),
            ConstraintAction::Relate { ref link_type, ref direction } => {
                // Only produce Relate if the original decision was Merge or Review
                match decision {
                    Decision::Merge | Decision::Review => {
                        // Flip direction if the pair was swapped relative to the
                        // original constraint definition
                        let effective_direction = if action.1 {
                            match direction {
                                RelateDirection::AToB => RelateDirection::BToA,
                                RelateDirection::BToA => RelateDirection::AToB,
                                RelateDirection::Symmetric => RelateDirection::Symmetric,
                            }
                        } else {
                            direction.clone()
                        };
                        ResolutionDecision::Relate {
                            link_type: link_type.clone(),
                            direction: effective_direction,
                        }
                    }
                    Decision::NoMerge => ResolutionDecision::NoMatch,
                }
            }
            ConstraintAction::Block => ResolutionDecision::NoMatch,
        }
    }
}

/// Convert a raw Decision to a ResolutionDecision (no semantic layer).
pub fn decision_to_resolution(decision: &Decision) -> ResolutionDecision {
    match decision {
        Decision::Merge => ResolutionDecision::Merge,
        Decision::Review => ResolutionDecision::Review,
        Decision::NoMerge => ResolutionDecision::NoMatch,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use cannon_common::ir::*;
    use uuid::Uuid;

    fn make_entity(data: Vec<(&str, &str)>) -> NormalizedEntity {
        NormalizedEntity {
            id: Uuid::new_v4(),
            tenant_id: Uuid::new_v4(),
            external_id: "test".to_string(),
            entity_type: "product".to_string(),
            data: data.into_iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
            source_name: "test".to_string(),
            valid_from: None,
            valid_to: None,
            last_updated: chrono::Utc::now(),
        }
    }

    fn make_plan_with_subtypes(
        rules: Vec<CompiledSubtypeRule>,
        constraints: Vec<CompiledConstraint>,
    ) -> IdentityPlan {
        IdentityPlan {
            entity: "product".to_string(),
            plan_hash: "test".to_string(),
            identity_version: "test_v1".to_string(),
            sources: vec![],
            blocking: CompiledBlocking {
                strategy: BlockingStrategy::Composite,
                keys: vec![],
                fallback_sample_rate: None,
                lsh_bands: None,
                lsh_rows: None,
                neighborhood_window: None,
                sort_fields: vec![],
            },
            match_graph: MatchGraph { rules: vec![], leaf_count: 0 },
            survivorship: SurvivorshipPolicy::default(),
            decision: DecisionConfig {
                match_threshold: 0.8,
                review_threshold: None,
                reject_threshold: None,
                conflict_strategy: ConflictStrategy::default(),
                review_webhook: None,
                scoring_method: ScoringMethod::default(),
                ml_ensemble_config: None,
                custom_scoring_config: None,
                fellegi_sunter: None,
                tie_breaking: vec![],
                clustering: None,
                min_total_weight: 0.0,
                allow_single_field: vec![],
            },
            reference_identifiers: vec![],
            relations: vec![],
            exclusions: vec![],
            compliance: None,
            hierarchy: None,
            audit: None,
            metadata: None,
            governance: None,
            subtype_rules: rules,
            constraint_matrix: constraints,
            subtype_confidence_threshold: 0.7,
        }
    }

    #[test]
    fn test_inactive_when_no_rules() {
        let plan = make_plan_with_subtypes(vec![], vec![]);
        let engine = SemanticEngine::new(&plan);
        assert!(!engine.is_active());
    }

    #[test]
    fn test_active_when_rules_exist() {
        let rules = vec![CompiledSubtypeRule {
            subtype: "model".to_string(),
            conditions: vec![SubtypeCondition {
                field: "brand".to_string(),
                operator: SubtypeOperator::Present,
                value: None,
            }],
            priority: 1,
        }];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);
        assert!(engine.is_active());
    }

    #[test]
    fn test_infer_subtype_first_match_wins() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "sellable_sku".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "barcode".to_string(), operator: SubtypeOperator::Present, value: None },
                    SubtypeCondition { field: "mpn".to_string(), operator: SubtypeOperator::Present, value: None },
                ],
                priority: 10,
            },
            CompiledSubtypeRule {
                subtype: "product_model".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "brand".to_string(), operator: SubtypeOperator::Present, value: None },
                ],
                priority: 1,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        // Entity has barcode + mpn + brand -> should match sellable_sku (higher priority)
        let entity = make_entity(vec![("barcode", "1234567890"), ("mpn", "ABC-123"), ("brand", "Samsung")]);
        let result = engine.infer_subtype(&entity);
        assert!(result.is_some());
        assert_eq!(result.unwrap().subtype, "sellable_sku");
    }

    #[test]
    fn test_infer_subtype_no_match_returns_none() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "sellable_sku".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "barcode".to_string(), operator: SubtypeOperator::Present, value: None },
                ],
                priority: 10,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        let entity = make_entity(vec![("brand", "Samsung")]);
        let result = engine.infer_subtype(&entity);
        assert!(result.is_none());
    }

    #[test]
    fn test_infer_subtype_operators() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "present_type".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "brand".to_string(), operator: SubtypeOperator::Present, value: None },
                ],
                priority: 1,
            },
            CompiledSubtypeRule {
                subtype: "equals_type".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "category".to_string(), operator: SubtypeOperator::Equals, value: Some("electronics".to_string()) },
                ],
                priority: 2,
            },
            CompiledSubtypeRule {
                subtype: "contains_type".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "name".to_string(), operator: SubtypeOperator::Contains, value: Some("phone".to_string()) },
                ],
                priority: 3,
            },
            CompiledSubtypeRule {
                subtype: "matches_type".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "sku".to_string(), operator: SubtypeOperator::Matches, value: Some(r"^SKU-\d+$".to_string()) },
                ],
                priority: 4,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        // Test Present
        let e = make_entity(vec![("brand", "Samsung")]);
        assert_eq!(engine.infer_subtype(&e).unwrap().subtype, "present_type");

        // Test Equals (case-insensitive)
        let e = make_entity(vec![("category", "Electronics")]);
        assert_eq!(engine.infer_subtype(&e).unwrap().subtype, "equals_type");

        // Test Contains
        let e = make_entity(vec![("name", "Samsung Phone Pro")]);
        assert_eq!(engine.infer_subtype(&e).unwrap().subtype, "contains_type");

        // Test Matches (regex)
        let e = make_entity(vec![("sku", "SKU-12345")]);
        assert_eq!(engine.infer_subtype(&e).unwrap().subtype, "matches_type");

        // Matches should fail on non-matching
        let e = make_entity(vec![("sku", "PROD-999")]);
        assert!(engine.infer_subtype(&e).is_none());
    }

    #[test]
    fn test_constraint_merge_allowed() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "variant".to_string(),
                conditions: vec![SubtypeCondition { field: "color".to_string(), operator: SubtypeOperator::Present, value: None }],
                priority: 1,
            },
        ];
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "variant".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::MergeAllowed,
            },
        ];
        let plan = make_plan_with_subtypes(rules, constraints);
        let engine = SemanticEngine::new(&plan);

        let st = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.9, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::Merge, &st, &st);
        assert_eq!(result, ResolutionDecision::Merge);
    }

    #[test]
    fn test_constraint_relate_produces_link_type() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "variant_of".to_string(),
                    direction: RelateDirection::BToA,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_model = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });
        let st_variant = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.88, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::Merge, &st_model, &st_variant);
        assert!(matches!(
            result,
            ResolutionDecision::Relate { ref link_type, ref direction }
                if link_type == "variant_of" && *direction == RelateDirection::BToA
        ));
    }

    #[test]
    fn test_constraint_block_produces_nomatch() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "alpha".to_string(),
                subtype_b: "beta".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_a = Some(SubtypeInference { subtype: "alpha".to_string(), confidence: 0.95, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "beta".to_string(), confidence: 0.95, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::Merge, &st_a, &st_b);
        assert_eq!(result, ResolutionDecision::NoMatch);
    }

    #[test]
    fn test_constraint_below_threshold_falls_through() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let mut plan = make_plan_with_subtypes(vec![], constraints);
        plan.subtype_confidence_threshold = 0.9;
        let engine = SemanticEngine::new(&plan);

        // Confidence 0.5 < threshold 0.9 -> falls through to standard Decision
        let st_a = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.5, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.95, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::Merge, &st_a, &st_b);
        assert_eq!(result, ResolutionDecision::Merge);
    }

    #[test]
    fn test_constraint_both_none_falls_through() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let result = engine.apply_constraints(&Decision::Merge, &None, &None);
        assert_eq!(result, ResolutionDecision::Merge);
    }

    #[test]
    fn test_constraint_pair_not_in_matrix_defaults_merge() {
        let plan = make_plan_with_subtypes(vec![], vec![]);
        let engine = SemanticEngine::new(&plan);

        let st_a = Some(SubtypeInference { subtype: "unknown_a".to_string(), confidence: 0.95, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "unknown_b".to_string(), confidence: 0.95, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::Merge, &st_a, &st_b);
        assert_eq!(result, ResolutionDecision::Merge);
    }

    #[test]
    fn test_constraint_asymmetric_direction() {
        // Define constraint as (model, variant) -> Relate b_to_a
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "variant_of".to_string(),
                    direction: RelateDirection::BToA,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        // Pass in reversed order (variant, model) - should still find the constraint
        // but direction should be flipped: original BToA becomes AToB because
        // the pair is swapped relative to the constraint definition
        let st_variant = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.95, evidence: vec![] });
        let st_model = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::Merge, &st_variant, &st_model);
        assert!(matches!(
            result,
            ResolutionDecision::Relate { ref link_type, ref direction }
                if link_type == "variant_of" && *direction == RelateDirection::AToB
        ));
    }

    #[test]
    fn test_constraint_dominates_high_score() {
        // Even with high score (Decision::Merge), Block should produce NoMatch
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_a = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.99, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.99, evidence: vec![] });
        // Score = 0.99 but Block -> NoMatch
        let result = engine.apply_constraints(&Decision::Merge, &st_a, &st_b);
        assert_eq!(result, ResolutionDecision::NoMatch);
    }

    #[test]
    fn test_relate_on_nomerge_produces_nomatch() {
        // If scoring says NoMerge, even a Relate constraint should produce NoMatch
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "variant_of".to_string(),
                    direction: RelateDirection::BToA,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_a = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.95, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::NoMerge, &st_a, &st_b);
        assert_eq!(result, ResolutionDecision::NoMatch);
    }

    #[test]
    fn test_review_falls_through_to_review() {
        let plan = make_plan_with_subtypes(vec![], vec![]);
        let engine = SemanticEngine::new(&plan);

        let result = engine.apply_constraints(&Decision::Review, &None, &None);
        assert_eq!(result, ResolutionDecision::Review);
    }

    #[test]
    fn test_one_subtype_none_falls_through() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_a = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });
        // One is None -> falls through
        let result = engine.apply_constraints(&Decision::Merge, &st_a, &None);
        assert_eq!(result, ResolutionDecision::Merge);
    }

    #[test]
    fn test_infer_subtype_empty_field_not_present() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "sku".to_string(),
                conditions: vec![SubtypeCondition {
                    field: "barcode".to_string(),
                    operator: SubtypeOperator::Present,
                    value: None,
                }],
                priority: 1,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        // Empty string should NOT count as present
        let entity = make_entity(vec![("barcode", "")]);
        assert!(engine.infer_subtype(&entity).is_none());
    }

    #[test]
    fn test_infer_subtype_fallback_to_lower_priority() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "sellable_sku".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "barcode".to_string(), operator: SubtypeOperator::Present, value: None },
                ],
                priority: 10,
            },
            CompiledSubtypeRule {
                subtype: "product_model".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "brand".to_string(), operator: SubtypeOperator::Present, value: None },
                ],
                priority: 1,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        // Entity has brand but not barcode -> falls through to product_model
        let entity = make_entity(vec![("brand", "Samsung")]);
        let result = engine.infer_subtype(&entity);
        assert!(result.is_some());
        assert_eq!(result.unwrap().subtype, "product_model");
    }

    // ---------------------------------------------------------------
    // Direction flip: XOR logic in apply_constraints
    // ---------------------------------------------------------------

    #[test]
    fn test_direction_xor_both_swapped_cancels_out() {
        // Constraint defined as (variant, model) -> BToA.
        // Matrix stores alphabetically: (model, variant), was_swapped=true.
        // Query with (model, variant): swapped=false.
        // effective_swap = false XOR true = true -> direction flipped: BToA -> AToB.
        //
        // Query with (variant, model): swapped=true.
        // effective_swap = true XOR true = false -> direction NOT flipped: BToA stays.
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "variant".to_string(),
                subtype_b: "model".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "variant_of".to_string(),
                    direction: RelateDirection::BToA,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        // Query in SAME order as definition (variant, model):
        // Both constraint storage and query are swapped -> XOR cancels -> direction preserved
        let st_variant = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.95, evidence: vec![] });
        let st_model = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::Merge, &st_variant, &st_model);
        assert!(
            matches!(result, ResolutionDecision::Relate { ref direction, .. } if *direction == RelateDirection::BToA),
            "Expected BToA (no flip), got {:?}", result
        );

        // Query in OPPOSITE order (model, variant):
        // Storage was swapped, query is not -> XOR = true -> direction flipped
        let result2 = engine.apply_constraints(&Decision::Merge, &st_model, &st_variant);
        assert!(
            matches!(result2, ResolutionDecision::Relate { ref direction, .. } if *direction == RelateDirection::AToB),
            "Expected AToB (flipped), got {:?}", result2
        );
    }

    #[test]
    fn test_direction_all_four_combinations() {
        // Test all (direction, swap) combinations to ensure XOR logic is correct.
        // Constraint: (alpha, beta) -> Relate(AToB)
        // alpha < beta alphabetically, so stored as (alpha, beta) with was_swapped=false.

        let constraints = vec![
            CompiledConstraint {
                subtype_a: "alpha".to_string(),
                subtype_b: "beta".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "test_link".to_string(),
                    direction: RelateDirection::AToB,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_alpha = Some(SubtypeInference { subtype: "alpha".to_string(), confidence: 0.95, evidence: vec![] });
        let st_beta = Some(SubtypeInference { subtype: "beta".to_string(), confidence: 0.95, evidence: vec![] });

        // Case 1: Query (alpha, beta) - same order as stored. swapped=false, was_swapped=false.
        // effective_swap = false. Direction stays AToB.
        let r1 = engine.apply_constraints(&Decision::Merge, &st_alpha, &st_beta);
        assert!(matches!(r1, ResolutionDecision::Relate { ref direction, .. } if *direction == RelateDirection::AToB));

        // Case 2: Query (beta, alpha) - reversed. swapped=true, was_swapped=false.
        // effective_swap = true. Direction flips AToB -> BToA.
        let r2 = engine.apply_constraints(&Decision::Merge, &st_beta, &st_alpha);
        assert!(matches!(r2, ResolutionDecision::Relate { ref direction, .. } if *direction == RelateDirection::BToA));
    }

    #[test]
    fn test_symmetric_direction_unaffected_by_swap() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "alpha".to_string(),
                subtype_b: "beta".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "sibling".to_string(),
                    direction: RelateDirection::Symmetric,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_alpha = Some(SubtypeInference { subtype: "alpha".to_string(), confidence: 0.95, evidence: vec![] });
        let st_beta = Some(SubtypeInference { subtype: "beta".to_string(), confidence: 0.95, evidence: vec![] });

        // Both orderings should produce Symmetric
        let r1 = engine.apply_constraints(&Decision::Merge, &st_alpha, &st_beta);
        assert!(matches!(r1, ResolutionDecision::Relate { ref direction, .. } if *direction == RelateDirection::Symmetric));

        let r2 = engine.apply_constraints(&Decision::Merge, &st_beta, &st_alpha);
        assert!(matches!(r2, ResolutionDecision::Relate { ref direction, .. } if *direction == RelateDirection::Symmetric));
    }

    // ---------------------------------------------------------------
    // Confidence threshold edge cases
    // ---------------------------------------------------------------

    #[test]
    fn test_confidence_exactly_at_threshold_applies_constraints() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "alpha".to_string(),
                subtype_b: "beta".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let mut plan = make_plan_with_subtypes(vec![], constraints);
        plan.subtype_confidence_threshold = 0.7;
        let engine = SemanticEngine::new(&plan);

        // Both at exactly the threshold (0.7 >= 0.7 is true, should NOT fall through)
        let st_a = Some(SubtypeInference { subtype: "alpha".to_string(), confidence: 0.7, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "beta".to_string(), confidence: 0.7, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::Merge, &st_a, &st_b);
        // The >= comparison means 0.7 >= 0.7 passes, so Block should apply
        // But the code uses `<` not `>=`:
        // st_a.confidence < self.confidence_threshold (0.7 < 0.7 = false) -> does NOT fall through
        assert_eq!(result, ResolutionDecision::NoMatch, "Confidence exactly at threshold should apply constraints");
    }

    #[test]
    fn test_confidence_just_below_threshold_falls_through() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "alpha".to_string(),
                subtype_b: "beta".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let mut plan = make_plan_with_subtypes(vec![], constraints);
        plan.subtype_confidence_threshold = 0.7;
        let engine = SemanticEngine::new(&plan);

        // One subtype just below threshold
        let st_a = Some(SubtypeInference { subtype: "alpha".to_string(), confidence: 0.699, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "beta".to_string(), confidence: 0.95, evidence: vec![] });
        let result = engine.apply_constraints(&Decision::Merge, &st_a, &st_b);
        // 0.699 < 0.7 = true -> falls through to original decision
        assert_eq!(result, ResolutionDecision::Merge, "Below threshold should fall through");
    }

    // ---------------------------------------------------------------
    // Invalid regex in Matches operator: graceful degradation
    // ---------------------------------------------------------------

    #[test]
    fn test_invalid_regex_pattern_does_not_crash() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "regex_type".to_string(),
                conditions: vec![SubtypeCondition {
                    field: "sku".to_string(),
                    operator: SubtypeOperator::Matches,
                    value: Some("[unclosed".to_string()), // invalid regex
                }],
                priority: 1,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        // Should not crash; compiled_regex is None, so condition fails
        let entity = make_entity(vec![("sku", "SKU-123")]);
        let result = engine.infer_subtype(&entity);
        assert!(result.is_none(), "Invalid regex should not match any entity");
    }

    #[test]
    fn test_matches_operator_with_no_value_does_not_crash() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "broken_type".to_string(),
                conditions: vec![SubtypeCondition {
                    field: "sku".to_string(),
                    operator: SubtypeOperator::Matches,
                    value: None, // no pattern at all
                }],
                priority: 1,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        let entity = make_entity(vec![("sku", "SKU-123")]);
        let result = engine.infer_subtype(&entity);
        assert!(result.is_none());
    }

    // ---------------------------------------------------------------
    // Confidence computation in infer_subtype
    // ---------------------------------------------------------------

    #[test]
    fn test_confidence_min_across_operators() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "mixed".to_string(),
                conditions: vec![
                    // Present -> 0.9 confidence
                    SubtypeCondition { field: "brand".to_string(), operator: SubtypeOperator::Present, value: None },
                    // Equals -> 1.0 confidence
                    SubtypeCondition { field: "category".to_string(), operator: SubtypeOperator::Equals, value: Some("electronics".to_string()) },
                    // Contains -> 0.85 confidence
                    SubtypeCondition { field: "name".to_string(), operator: SubtypeOperator::Contains, value: Some("phone".to_string()) },
                ],
                priority: 1,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        let entity = make_entity(vec![
            ("brand", "Samsung"),
            ("category", "electronics"),
            ("name", "Samsung Phone Pro"),
        ]);
        let result = engine.infer_subtype(&entity).unwrap();
        assert_eq!(result.subtype, "mixed");
        // Minimum of 0.9, 1.0, 0.85 = 0.85
        assert!((result.confidence - 0.85).abs() < f64::EPSILON,
            "Expected 0.85, got {}", result.confidence);
    }

    #[test]
    fn test_confidence_single_present_is_0_9() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "basic".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "brand".to_string(), operator: SubtypeOperator::Present, value: None },
                ],
                priority: 1,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        let entity = make_entity(vec![("brand", "Samsung")]);
        let result = engine.infer_subtype(&entity).unwrap();
        assert!((result.confidence - 0.9).abs() < f64::EPSILON);
    }

    #[test]
    fn test_confidence_single_equals_is_1_0() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "exact".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "type".to_string(), operator: SubtypeOperator::Equals, value: Some("premium".to_string()) },
                ],
                priority: 1,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        let entity = make_entity(vec![("type", "premium")]);
        let result = engine.infer_subtype(&entity).unwrap();
        assert!((result.confidence - 1.0).abs() < f64::EPSILON);
    }

    // ---------------------------------------------------------------
    // Evidence tracking
    // ---------------------------------------------------------------

    #[test]
    fn test_evidence_records_field_values() {
        let rules = vec![
            CompiledSubtypeRule {
                subtype: "tracked".to_string(),
                conditions: vec![
                    SubtypeCondition { field: "brand".to_string(), operator: SubtypeOperator::Present, value: None },
                    SubtypeCondition { field: "color".to_string(), operator: SubtypeOperator::Present, value: None },
                ],
                priority: 1,
            },
        ];
        let plan = make_plan_with_subtypes(rules, vec![]);
        let engine = SemanticEngine::new(&plan);

        let entity = make_entity(vec![("brand", "Samsung"), ("color", "Blue")]);
        let result = engine.infer_subtype(&entity).unwrap();
        assert_eq!(result.evidence.len(), 2);
        assert!(result.evidence.contains(&"brand:Samsung".to_string()));
        assert!(result.evidence.contains(&"color:Blue".to_string()));
    }

    // ---------------------------------------------------------------
    // Review decision with Relate constraint
    // ---------------------------------------------------------------

    #[test]
    fn test_review_decision_with_relate_constraint_produces_relate() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "variant_of".to_string(),
                    direction: RelateDirection::BToA,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_model = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });
        let st_variant = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.95, evidence: vec![] });

        // Review + Relate constraint -> should produce Relate (not fall through to Review)
        let result = engine.apply_constraints(&Decision::Review, &st_model, &st_variant);
        assert!(matches!(result, ResolutionDecision::Relate { .. }),
            "Review with Relate constraint should produce Relate, got {:?}", result);
    }

    // ---------------------------------------------------------------
    // try_constraint_decision tests (Layer 2 of decision cascade)
    // ---------------------------------------------------------------

    #[test]
    fn test_try_constraint_block_always_rejects() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "alpha".to_string(),
                subtype_b: "beta".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_a = Some(SubtypeInference { subtype: "alpha".to_string(), confidence: 0.95, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "beta".to_string(), confidence: 0.95, evidence: vec![] });

        // Block -> NoMatch regardless of score
        assert_eq!(engine.try_constraint_decision(0.99, &st_a, &st_b), Some(ResolutionDecision::NoMatch));
        assert_eq!(engine.try_constraint_decision(0.0, &st_a, &st_b), Some(ResolutionDecision::NoMatch));
        assert_eq!(engine.try_constraint_decision(-5.0, &st_a, &st_b), Some(ResolutionDecision::NoMatch));
    }

    #[test]
    fn test_try_constraint_relate_with_similarity() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "variant_of".to_string(),
                    direction: RelateDirection::BToA,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_model = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });
        let st_variant = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.95, evidence: vec![] });

        // Positive score + Relate -> Relate
        let result = engine.try_constraint_decision(5.0, &st_model, &st_variant);
        assert!(matches!(
            result,
            Some(ResolutionDecision::Relate { ref link_type, ref direction })
                if link_type == "variant_of" && *direction == RelateDirection::BToA
        ));
    }

    #[test]
    fn test_try_constraint_relate_no_similarity() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "variant_of".to_string(),
                    direction: RelateDirection::BToA,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_model = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });
        let st_variant = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.95, evidence: vec![] });

        // Zero score + Relate -> NoMatch (different types, no similarity)
        assert_eq!(engine.try_constraint_decision(0.0, &st_model, &st_variant), Some(ResolutionDecision::NoMatch));
    }

    #[test]
    fn test_try_constraint_merge_allowed_defers() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "variant".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::MergeAllowed,
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.9, evidence: vec![] });

        // MergeAllowed -> None (let statistics decide)
        assert_eq!(engine.try_constraint_decision(5.0, &st, &st), None);
    }

    #[test]
    fn test_try_constraint_no_subtypes_defers() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "alpha".to_string(),
                subtype_b: "beta".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        // No subtypes -> None (can't resolve without type info)
        assert_eq!(engine.try_constraint_decision(5.0, &None, &None), None);

        let st_a = Some(SubtypeInference { subtype: "alpha".to_string(), confidence: 0.95, evidence: vec![] });
        assert_eq!(engine.try_constraint_decision(5.0, &st_a, &None), None);
        assert_eq!(engine.try_constraint_decision(5.0, &None, &st_a), None);
    }

    #[test]
    fn test_try_constraint_low_confidence_defers() {
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "alpha".to_string(),
                subtype_b: "beta".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let mut plan = make_plan_with_subtypes(vec![], constraints);
        plan.subtype_confidence_threshold = 0.9;
        let engine = SemanticEngine::new(&plan);

        // Below confidence threshold -> None
        let st_a = Some(SubtypeInference { subtype: "alpha".to_string(), confidence: 0.5, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "beta".to_string(), confidence: 0.95, evidence: vec![] });
        assert_eq!(engine.try_constraint_decision(5.0, &st_a, &st_b), None);
    }

    #[test]
    fn test_try_constraint_relate_direction_swap() {
        // Constraint is defined as (model, variant) with direction BToA.
        // When we pass subtypes in reversed order (variant, model), the direction
        // should be flipped via the XOR swap logic.
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "variant_of".to_string(),
                    direction: RelateDirection::BToA,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        // Pass in reversed order: variant first, model second
        let st_variant = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.95, evidence: vec![] });
        let st_model = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });

        let result = engine.try_constraint_decision(5.0, &st_variant, &st_model);
        // Direction should be flipped: BToA becomes AToB because arguments were swapped
        assert!(matches!(
            result,
            Some(ResolutionDecision::Relate { ref link_type, ref direction })
                if link_type == "variant_of" && *direction == RelateDirection::AToB
        ), "Reversed subtype order should flip Relate direction, got {:?}", result);
    }

    #[test]
    fn test_try_constraint_relate_negative_score() {
        // Negative score should be treated the same as zero - no similarity evidence.
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "model".to_string(),
                subtype_b: "variant".to_string(),
                action: ConstraintAction::Relate {
                    link_type: "variant_of".to_string(),
                    direction: RelateDirection::Symmetric,
                },
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_a = Some(SubtypeInference { subtype: "model".to_string(), confidence: 0.95, evidence: vec![] });
        let st_b = Some(SubtypeInference { subtype: "variant".to_string(), confidence: 0.95, evidence: vec![] });

        // Negative score -> NoMatch (no similarity evidence)
        assert_eq!(engine.try_constraint_decision(-1.0, &st_a, &st_b), Some(ResolutionDecision::NoMatch));
        // Tiny positive score -> Relate (minimal evidence is still evidence)
        assert!(matches!(
            engine.try_constraint_decision(0.001, &st_a, &st_b),
            Some(ResolutionDecision::Relate { ref direction, .. }) if *direction == RelateDirection::Symmetric
        ));
    }

    #[test]
    fn test_try_constraint_unknown_pair_defers() {
        // Constraint matrix only has (alpha, beta). An (alpha, gamma) pair
        // should return None (open world assumption).
        let constraints = vec![
            CompiledConstraint {
                subtype_a: "alpha".to_string(),
                subtype_b: "beta".to_string(),
                action: ConstraintAction::Block,
            },
        ];
        let plan = make_plan_with_subtypes(vec![], constraints);
        let engine = SemanticEngine::new(&plan);

        let st_a = Some(SubtypeInference { subtype: "alpha".to_string(), confidence: 0.95, evidence: vec![] });
        let st_gamma = Some(SubtypeInference { subtype: "gamma".to_string(), confidence: 0.95, evidence: vec![] });

        // Unknown pair -> None (defer to statistics)
        assert_eq!(engine.try_constraint_decision(5.0, &st_a, &st_gamma), None);
    }
}
