231 lines
9.6 KiB
Python
231 lines
9.6 KiB
Python
"""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)
|