"""Dependency contradiction detection engine.""" from __future__ import annotations from dataclasses import dataclass, field from physcom.models.combination import Combination from physcom.models.domain import DomainConstraint from physcom.models.entity import Dependency # Mutual exclusion registry: for a given key, which value-sets contradict. # Values within the same set are compatible; values in different sets contradict. MUTEX_VALUES: dict[str, list[set[str]]] = { "atmosphere": [{"vacuum", "vacuum_or_thin"}, {"dense", "standard"}], "medium": [{"ground"}, {"water"}, {"air"}, {"space"}], } # Conditions assumed always available (don't need an explicit provides) AMBIENT_CONDITIONS: set[tuple[str, str]] = { ("ground_surface", "true"), ("gravity", "true"), ("star_proximity", "true"), } # Per-category behavior for unmet requirements: # "block" = hard violation, "warn" = conditional warning, "skip" = ignore CATEGORY_SEVERITY: dict[str, str] = { "energy": "block", "infrastructure": "skip", } # For provides-vs-range_min: deficit > this ratio = hard block, else warning DEFICIT_THRESHOLD: float = 0.25 @dataclass class ConstraintResult: """Outcome of constraint resolution for a combination.""" status: str = "valid" # valid, blocked, conditional violations: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) class ConstraintResolver: """Checks a Combination's entities for dependency contradictions.""" def __init__( self, mutex_registry=None, ambient_conditions=None, category_severity=None, deficit_threshold=None, ) -> None: self.mutex = mutex_registry or MUTEX_VALUES self.ambient = ambient_conditions or AMBIENT_CONDITIONS self.category_severity = category_severity or CATEGORY_SEVERITY self.deficit_threshold = ( deficit_threshold if deficit_threshold is not None else DEFICIT_THRESHOLD ) def resolve(self, combination: Combination) -> ConstraintResult: result = ConstraintResult() all_deps: list[tuple[str, Dependency]] = [] for entity in combination.entities: for dep in entity.dependencies: all_deps.append((entity.name, dep)) self._check_requires_vs_excludes(all_deps, result) self._check_mutual_exclusion(all_deps, result) self._check_range_incompatibility(all_deps, result) self._check_provides_vs_range(combination, result) self._check_unmet_requirements(all_deps, result) if result.violations: result.status = "p1_fail" elif result.warnings: result.status = "conditional" return result def _check_requires_vs_excludes( self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult ) -> None: """Rule 1: If A requires key=X and B excludes key=X → BLOCKED.""" requires = [(name, d) for name, d in all_deps if d.constraint_type == "requires"] excludes = [(name, d) for name, d in all_deps if d.constraint_type == "excludes"] for req_name, req in requires: for exc_name, exc in excludes: if req_name == exc_name: continue if req.key == exc.key and req.value == exc.value: result.violations.append( f"{req_name} requires {req.key}={req.value} " f"but {exc_name} excludes it" ) def _check_mutual_exclusion( self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult ) -> None: """Rule 2: If A requires key=X and B requires key=Y where X,Y are mutex → BLOCKED.""" requires = [(name, d) for name, d in all_deps if d.constraint_type == "requires"] for i, (name_a, dep_a) in enumerate(requires): for name_b, dep_b in requires[i + 1:]: if name_a == name_b: continue if dep_a.key != dep_b.key: continue if dep_a.value == dep_b.value: continue # Check if values are in different mutex sets if dep_a.key in self.mutex: set_a = self._find_mutex_set(dep_a.key, dep_a.value) set_b = self._find_mutex_set(dep_b.key, dep_b.value) if set_a is not None and set_b is not None and set_a is not set_b: result.violations.append( f"{name_a} requires {dep_a.key}={dep_a.value} " f"but {name_b} requires {dep_b.key}={dep_b.value} " f"(mutually exclusive)" ) def _find_mutex_set(self, key: str, value: str) -> set[str] | None: """Find which mutex set a value belongs to, or None.""" for value_set in self.mutex.get(key, []): if value in value_set: return value_set return None def _check_range_incompatibility( self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult ) -> None: """Rule 3: If A range_min > B range_max for the same key → BLOCKED.""" range_mins: dict[str, list[tuple[str, float]]] = {} range_maxs: dict[str, list[tuple[str, float]]] = {} for name, dep in all_deps: if dep.constraint_type == "range_min": range_mins.setdefault(dep.key, []).append((name, float(dep.value))) elif dep.constraint_type == "range_max": range_maxs.setdefault(dep.key, []).append((name, float(dep.value))) for key in set(range_mins) & set(range_maxs): for min_name, min_val in range_mins[key]: for max_name, max_val in range_maxs[key]: if min_name == max_name: continue if min_val > max_val: result.violations.append( f"{min_name} requires {key} >= {min_val} " f"but {max_name} limits {key} <= {max_val}" ) def _check_provides_vs_range( self, combination: Combination, result: ConstraintResult ) -> None: """Generic: provides(key, N) < range_min(key, M) → block/warn.""" provided: dict[str, list[tuple[str, float]]] = {} required: dict[str, list[tuple[str, float]]] = {} for entity in combination.entities: for dep in entity.dependencies: try: val = float(dep.value) except (ValueError, TypeError): continue if dep.constraint_type == "provides": provided.setdefault(dep.key, []).append((entity.name, val)) elif dep.constraint_type == "range_min": required.setdefault(dep.key, []).append((entity.name, val)) for key in set(provided) & set(required): for req_name, req_val in required[key]: for prov_name, prov_val in provided[key]: if prov_val < req_val * self.deficit_threshold: result.violations.append( f"{prov_name} provides {key}={prov_val:.0f} but " f"{req_name} requires {key}>={req_val:.0f} " f"(deficit > {int(1 / self.deficit_threshold)}x)" ) elif prov_val < req_val: result.warnings.append( f"{prov_name} provides {key}={prov_val:.0f} but " f"{req_name} requires {key}>={req_val:.0f} " f"(under-provision)" ) def check_domain_constraints( self, combination: Combination, constraints: list[DomainConstraint] ) -> ConstraintResult: """Check if a combo's entity requirements fall within domain-allowed values.""" result = ConstraintResult() for dc in constraints: allowed = set(dc.allowed_values) for entity in combination.entities: for dep in entity.dependencies: if dep.key == dc.key and dep.constraint_type == "requires": if dep.value not in allowed: result.violations.append( f"{entity.name} requires {dc.key}={dep.value} " f"but domain only allows {dc.allowed_values}" ) if result.violations: result.status = "p1_fail" return result def _check_unmet_requirements( self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult ) -> None: """Rule 5: Required condition not provided by any entity → conditional.""" provides = {(d.key, d.value) for _, d in all_deps if d.constraint_type == "provides"} for name, dep in all_deps: if dep.constraint_type != "requires": continue severity = self.category_severity.get(dep.category, "warn") if severity == "skip": continue key_val = (dep.key, dep.value) if key_val not in provides and key_val not in self.ambient: msg = ( f"{name} requires {dep.key}={dep.value} " f"but no entity in this combination provides it" ) if severity == "block": result.violations.append(msg) else: result.warnings.append(msg)