Add Flask web UI, Docker Compose, core engine + tests

- physcom core: CLI, 5-pass pipeline, SQLite repo, 37 tests
- physcom_web: Flask app with HTMX for entity/domain/pipeline/results CRUD
- Docker Compose: web + cli services sharing a named volume for the DB
- Clean up local settings to use wildcard permissions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Simonson, Andrew
2026-02-18 13:59:53 -06:00
parent 6e0f82835a
commit 8118a62242
54 changed files with 3505 additions and 1 deletions

View File

@@ -0,0 +1,180 @@
"""Dependency contradiction detection engine."""
from __future__ import annotations
from dataclasses import dataclass, field
from physcom.models.combination import Combination
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"}],
}
@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: dict[str, list[set[str]]] | None = None) -> None:
self.mutex = mutex_registry or MUTEX_VALUES
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_force_scale(combination, result)
self._check_unmet_requirements(all_deps, result)
if result.violations:
result.status = "blocked"
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_force_scale(
self, combination: Combination, result: ConstraintResult
) -> None:
"""Rule 4: If power source output << platform requirement → warn/block."""
force_provided: list[tuple[str, float]] = []
force_required: list[tuple[str, float]] = []
for entity in combination.entities:
for dep in entity.dependencies:
if dep.key == "force_output_watts" and dep.constraint_type == "provides":
force_provided.append((entity.name, float(dep.value)))
elif dep.key == "force_required_watts" and dep.constraint_type == "range_min":
force_required.append((entity.name, float(dep.value)))
for req_name, req_watts in force_required:
for prov_name, prov_watts in force_provided:
if prov_watts < req_watts * 0.01:
# Off by more than 100x — hard block
result.violations.append(
f"{prov_name} provides {prov_watts}W but "
f"{req_name} requires {req_watts}W "
f"(force deficit > 100x)"
)
elif prov_watts < req_watts:
# Under-powered but not impossibly so — warn
result.warnings.append(
f"{prov_name} provides {prov_watts}W but "
f"{req_name} requires {req_watts}W "
f"(under-powered)"
)
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"}
# Ambient conditions that don't need to be explicitly provided
ambient = {
("ground_surface", "true"),
("gravity", "true"),
("star_proximity", "true"),
}
for name, dep in all_deps:
if dep.constraint_type != "requires":
continue
if dep.category == "infrastructure":
continue # Infrastructure is external, not checked here
key_val = (dep.key, dep.value)
if key_val not in provides and key_val not in ambient:
result.warnings.append(
f"{name} requires {dep.key}={dep.value} "
f"but no entity in this combination provides it"
)