diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5590b --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +.npmrc +.env +.env.* +__tests__/ +coverage/ +.nyc_output/ +dist/ +build/ +.cache/ +*.log +.DS_Store +tmp/ +.tmp/ +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +READY_TO_PUBLISH diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..13ad725 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# AGENTS.md + +## Architecture +- Python package: `catopt_swarm` +- Core modules: + - `models.py`: validated IR and audit objects + - `registry.py`: graph-of-contracts registry and adapter conformance checks + - `adapters.py`: drone and rover functor-style adapters into the IR + - `solver.py`: deterministic ADMM-lite distributed solver with delta sync + - `verification.py`: invariant and safety verification helpers + - `cli.py`: demo entrypoint + +## Tech Stack +- Python 3.10+ +- Pydantic for runtime validation +- NetworkX for the contract graph +- Pytest for tests + +## Testing +- `bash test.sh` +- `python3 -m pytest` +- `python3 -m build` + +## Contribution Rules +- Keep changes minimal and deterministic. +- Prefer validated models over ad-hoc dicts. +- Preserve the top-level compatibility shims unless there is a strong reason to remove them. +- Update `README.md` and this file if the architecture changes. diff --git a/README.md b/README.md index f585ccb..afdacd7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,62 @@ -# idea174-catopt-swarm +# CatOpt-Swarm -Safe, verifiable distributed optimization for robotic swarms \ No newline at end of file +Safe, verifiable distributed optimization for robotic swarms. + +CatOpt-Swarm models swarm coordination as a small validated IR: + +- `LocalProblem` captures each robot's local objective and state. +- `PlanDelta` carries deterministic, contract-tagged updates. +- `SharedVariables` stores consensus state and versioning. +- `SafetyPolicy` enforces travel, separation, and energy limits. +- `ContractRegistry` records adapter and mission contracts as a graph. + +The solver is a deterministic ADMM-lite loop with: + +- bounded-step local updates +- delta reconciliation with bounded staleness +- separation projection for collision avoidance +- audit logs and convergence certificates + +## Package Layout + +- `catopt_swarm.models` +- `catopt_swarm.registry` +- `catopt_swarm.adapters` +- `catopt_swarm.solver` +- `catopt_swarm.verification` +- `catopt_swarm.cli` + +## Install + +```bash +python3 -m pip install -e . +``` + +## Run + +```bash +python3 -m catopt_swarm.cli +``` + +Or use the compatibility script: + +```bash +python3 admm_solver.py +``` + +## Test + +```bash +bash test.sh +``` + +## What the demo covers + +- Two-robot consensus planning +- Adapter conformance checks for aerial and ground controllers +- Graph-based contract registration +- Safety verification after solve + +## Notes + +This repository currently focuses on the core planning and verification substrate. The next extension point is integrating ROS/Gazebo-backed mission replay and richer platform adapters. diff --git a/admm_solver.py b/admm_solver.py index 74dadf9..6b748dc 100644 --- a/admm_solver.py +++ b/admm_solver.py @@ -1,62 +1,17 @@ -import sys -from primitives import LocalProblem, SharedVariables, SafetyPolicy +from catopt_swarm import LocalProblem, SafetyPolicy +from catopt_swarm.solver import ADMMSolver + + +def main() -> None: + robots = [ + LocalProblem(robot_id="drone-1", target_pos=0.0, current_pos=0.0), + LocalProblem(robot_id="drone-2", target_pos=10.0, current_pos=10.0), + ] + policy = SafetyPolicy(max_travel_distance=15.0, min_separation=1.0, energy_budget=20.0) + solver = ADMMSolver(rho=1.5, max_iter=40, epsilon=1e-4) + solution = solver.solve(robots, policy) + print(f"Final agreed rendezvous point: {solution.consensus:.3f}") -class ADMMSolver: - def __init__(self, rho=1.0, max_iter=50, epsilon=1e-3): - self.rho = rho - self.max_iter = max_iter - self.epsilon = epsilon - - def run(self, robots, safety_policy): - z = 0.0 # Initial global consensus - shared_vars = SharedVariables(version=0, global_consensus=z) - - print("Starting CatOpt-Swarm ADMM-lite consensus rendezvous...") - - for iteration in range(self.max_iter): - old_z = shared_vars.global_consensus - - # 1. Local Problem Updates (x_i update) - # x_i = (2*p_i + rho*(z - y_i)) / (2 + rho) - for r in robots: - new_x = (2 * r.target_pos + self.rho * (shared_vars.global_consensus - r.dual_var)) / (2 + self.rho) - if not safety_policy.verify(r.target_pos, new_x): - print(f"Safety violation for {r.robot_id}: travel bound exceeded.") - sys.exit(1) - r.current_pos = new_x - - # 2. Shared Variables Update (z update) - # z = 1/N * sum(x_i + y_i) - sum_x_y = sum([r.current_pos + r.dual_var for r in robots]) - new_z = sum_x_y / len(robots) - shared_vars.global_consensus = new_z - shared_vars.version += 1 - - # 3. Dual Variables Update (y_i update) - primal_residual = 0.0 - for r in robots: - r.dual_var += (r.current_pos - shared_vars.global_consensus) - primal_residual += abs(r.current_pos - shared_vars.global_consensus) - - dual_residual = abs(shared_vars.global_consensus - old_z) * self.rho - - print(f"Iter {iteration:02d}: Z={shared_vars.global_consensus:.3f} | PrimalRes={primal_residual:.4f} | DualRes={dual_residual:.4f}") - - if primal_residual < self.epsilon and dual_residual < self.epsilon: - print("Convergence achieved!") - return shared_vars.global_consensus - - print("Failed to converge within max iterations.") - sys.exit(1) if __name__ == "__main__": - robots = [ - LocalProblem(robot_id="drone-1", target_pos=0.0), - LocalProblem(robot_id="drone-2", target_pos=10.0) - ] - policy = SafetyPolicy(max_travel_distance=15.0) - - solver = ADMMSolver(rho=1.5) - final_z = solver.run(robots, policy) - print(f"Final agreed rendezvous point: {final_z:.3f}") - assert abs(final_z - 5.0) < 0.01, "Rendezvous should be exactly in the middle!" + main() diff --git a/catopt_swarm/__init__.py b/catopt_swarm/__init__.py new file mode 100644 index 0000000..8c58d6d --- /dev/null +++ b/catopt_swarm/__init__.py @@ -0,0 +1,32 @@ +from .adapters import DroneControllerAdapter, GroundRoverControllerAdapter +from .models import ( + AuditLogEntry, + ConvergenceCertificate, + DualVariables, + LocalProblem, + PlanDelta, + SafetyPolicy, + SharedVariables, + SwarmSolution, +) +from .registry import ContractRegistry, ContractSpec +from .solver import ADMMSolver +from .verification import VerificationReport, verify_swarm_solution + +__all__ = [ + "ADMMSolver", + "AuditLogEntry", + "ConvergenceCertificate", + "ContractRegistry", + "ContractSpec", + "DroneControllerAdapter", + "DualVariables", + "GroundRoverControllerAdapter", + "LocalProblem", + "PlanDelta", + "SafetyPolicy", + "SharedVariables", + "SwarmSolution", + "VerificationReport", + "verify_swarm_solution", +] diff --git a/catopt_swarm/adapters.py b/catopt_swarm/adapters.py new file mode 100644 index 0000000..d9c7fd6 --- /dev/null +++ b/catopt_swarm/adapters.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .models import LocalProblem, PlanDelta + + +@dataclass(frozen=True) +class _AdapterDefaults: + max_step: float + energy_budget: float + + +class DroneControllerAdapter: + name = "drone-controller" + domain = "aerial" + defaults = _AdapterDefaults(max_step=12.0, energy_budget=80.0) + + def to_local_problem(self, robot_id: str, mission: dict[str, Any]) -> LocalProblem: + return LocalProblem( + robot_id=robot_id, + target_pos=float(mission["target_pos"]), + initial_pos=float(mission.get("initial_pos", 0.0)), + max_step=float(mission.get("max_step", self.defaults.max_step)), + energy_budget=float(mission.get("energy_budget", self.defaults.energy_budget)), + metadata={"domain": self.domain, **mission.get("metadata", {})}, + ) + + def to_plan_delta( + self, + robot_id: str, + contract_id: str, + updates: dict[str, Any], + sequence: int, + author: str = "drone-controller", + ) -> PlanDelta: + return PlanDelta( + author=author, + contract_id=contract_id, + target_robot_id=robot_id, + sequence=sequence, + updates=updates, + ) + + +class GroundRoverControllerAdapter: + name = "ground-rover-controller" + domain = "ground" + defaults = _AdapterDefaults(max_step=8.0, energy_budget=120.0) + + def to_local_problem(self, robot_id: str, mission: dict[str, Any]) -> LocalProblem: + return LocalProblem( + robot_id=robot_id, + target_pos=float(mission["target_pos"]), + initial_pos=float(mission.get("initial_pos", 0.0)), + max_step=float(mission.get("max_step", self.defaults.max_step)), + energy_budget=float(mission.get("energy_budget", self.defaults.energy_budget)), + metadata={"domain": self.domain, **mission.get("metadata", {})}, + ) + + def to_plan_delta( + self, + robot_id: str, + contract_id: str, + updates: dict[str, Any], + sequence: int, + author: str = "ground-rover-controller", + ) -> PlanDelta: + return PlanDelta( + author=author, + contract_id=contract_id, + target_robot_id=robot_id, + sequence=sequence, + updates=updates, + ) diff --git a/catopt_swarm/cli.py b/catopt_swarm/cli.py new file mode 100644 index 0000000..4cd63ed --- /dev/null +++ b/catopt_swarm/cli.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from .solver import reference_scenario + + +def main() -> None: + solution = reference_scenario() + print(f"Consensus: {solution.consensus:.3f}") + print(f"Iterations: {solution.certificate.iterations}") + for robot in solution.robots: + print(f"{robot.robot_id}: pos={robot.current_pos:.3f} travel={robot.travel_distance:.3f}") + + +if __name__ == "__main__": + main() diff --git a/catopt_swarm/models.py b/catopt_swarm/models.py new file mode 100644 index 0000000..94fc879 --- /dev/null +++ b/catopt_swarm/models.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from pydantic import BaseModel, Field, field_validator, model_validator + + +class LocalProblem(BaseModel): + robot_id: str + target_pos: float + initial_pos: float = 0.0 + current_pos: float | None = None + dual_var: float = 0.0 + max_step: float = 15.0 + energy_budget: float = 100.0 + weight: float = 1.0 + version: int = 0 + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("max_step", "energy_budget", "weight") + @classmethod + def _positive(cls, value: float) -> float: + if value <= 0: + raise ValueError("must be positive") + return value + + @model_validator(mode="after") + def _default_current_pos(self) -> "LocalProblem": + if self.current_pos is None: + self.current_pos = self.initial_pos + return self + + @property + def travel_distance(self) -> float: + current_pos = self.current_pos if self.current_pos is not None else self.initial_pos + return abs(current_pos - self.initial_pos) + + def apply_updates(self, updates: dict[str, Any]) -> None: + for key, value in updates.items(): + if hasattr(self, key): + setattr(self, key, value) + + +class SharedVariables(BaseModel): + version: int = 0 + global_consensus: float = 0.0 + delta_clock: int = 0 + + +class DualVariables(BaseModel): + values: dict[str, float] = Field(default_factory=dict) + + +class PlanDelta(BaseModel): + author: str + contract_id: str + sequence: int + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + target_robot_id: str | None = None + updates: dict[str, Any] = Field(default_factory=dict) + + @field_validator("sequence") + @classmethod + def _non_negative(cls, value: int) -> int: + if value < 0: + raise ValueError("sequence must be non-negative") + return value + + +class SafetyPolicy(BaseModel): + max_travel_distance: float = 15.0 + min_separation: float = 1.0 + energy_budget: float = 100.0 + bounded_staleness: int = 2 + + @field_validator("max_travel_distance", "min_separation", "energy_budget") + @classmethod + def _non_negative(cls, value: float) -> float: + if value < 0: + raise ValueError("must be non-negative") + return value + + @field_validator("bounded_staleness") + @classmethod + def _bounded_staleness(cls, value: int) -> int: + if value < 0: + raise ValueError("must be non-negative") + return value + + def verify(self, start_pos: float, new_pos: float) -> bool: + return abs(new_pos - start_pos) <= self.max_travel_distance + + +class AuditLogEntry(BaseModel): + iteration: int + shared_version: int + consensus: float + primal_residual: float + dual_residual: float + applied_deltas: list[str] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class ConvergenceCertificate(BaseModel): + converged: bool + iterations: int + epsilon: float + final_primal_residual: float + final_dual_residual: float + + +class SwarmSolution(BaseModel): + consensus: float + robots: list[LocalProblem] + shared: SharedVariables + audit_log: list[AuditLogEntry] + certificate: ConvergenceCertificate diff --git a/catopt_swarm/registry.py b/catopt_swarm/registry.py new file mode 100644 index 0000000..d551b20 --- /dev/null +++ b/catopt_swarm/registry.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any + +import networkx as nx +from pydantic import BaseModel, Field + + +class ContractSpec(BaseModel): + contract_id: str + adapter_name: str + domain: str + version: str + invariants: tuple[str, ...] = () + preconditions: tuple[str, ...] = () + postconditions: tuple[str, ...] = () + metadata: dict[str, Any] = Field(default_factory=dict) + + +class AdapterConformanceReport(BaseModel): + adapter_name: str + contract_id: str + passed: bool + issues: list[str] = Field(default_factory=list) + + +class ContractRegistry: + def __init__(self) -> None: + self._graph = nx.DiGraph() + + def register(self, spec: ContractSpec) -> None: + self._graph.add_node(spec.contract_id, spec=spec) + + def link(self, parent_contract_id: str, child_contract_id: str, relation: str = "extends") -> None: + self._graph.add_edge(parent_contract_id, child_contract_id, relation=relation) + + def get(self, contract_id: str) -> ContractSpec: + return self._graph.nodes[contract_id]["spec"] + + def has_contract(self, contract_id: str) -> bool: + return contract_id in self._graph + + def summary(self) -> dict[str, Any]: + return { + "contracts": list(self._graph.nodes), + "links": [ + {"from": source, "to": target, **data} + for source, target, data in self._graph.edges(data=True) + ], + } + + +def check_adapter_conformance(adapter: Any, spec: ContractSpec) -> AdapterConformanceReport: + issues: list[str] = [] + if getattr(adapter, "name", None) != spec.adapter_name: + issues.append(f"adapter name mismatch: expected {spec.adapter_name}") + if getattr(adapter, "domain", None) != spec.domain: + issues.append(f"domain mismatch: expected {spec.domain}") + return AdapterConformanceReport( + adapter_name=getattr(adapter, "name", adapter.__class__.__name__), + contract_id=spec.contract_id, + passed=not issues, + issues=issues, + ) diff --git a/catopt_swarm/solver.py b/catopt_swarm/solver.py new file mode 100644 index 0000000..7f02425 --- /dev/null +++ b/catopt_swarm/solver.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from math import fsum +from typing import Iterable, Sequence + +from .models import ( + AuditLogEntry, + ConvergenceCertificate, + LocalProblem, + PlanDelta, + SafetyPolicy, + SharedVariables, + SwarmSolution, +) +from .verification import verify_swarm_solution +class ADMMSolver: + def __init__(self, rho: float = 1.0, max_iter: int = 50, epsilon: float = 1e-3, staleness_bound: int = 2): + self.rho = rho + self.max_iter = max_iter + self.epsilon = epsilon + self.staleness_bound = staleness_bound + + def _ordered_deltas(self, deltas: Iterable[PlanDelta]) -> list[PlanDelta]: + return sorted( + deltas, + key=lambda delta: (delta.timestamp, delta.author, delta.contract_id, delta.sequence), + ) + + def _apply_deltas( + self, + robots: Sequence[LocalProblem], + shared: SharedVariables, + deltas: Iterable[PlanDelta], + ) -> list[str]: + applied: list[str] = [] + indexed = {robot.robot_id: robot for robot in robots} + for delta in self._ordered_deltas(deltas): + if shared.version - delta.sequence > self.staleness_bound: + continue + if delta.target_robot_id is None or delta.target_robot_id not in indexed: + continue + indexed[delta.target_robot_id].apply_updates(delta.updates) + indexed[delta.target_robot_id].version = max(indexed[delta.target_robot_id].version, delta.sequence) + applied.append(f"{delta.contract_id}:{delta.author}:{delta.sequence}") + if applied: + shared.delta_clock += len(applied) + return applied + + def _project_for_separation(self, candidates: list[float], minimum: float) -> list[float]: + if minimum <= 0 or len(candidates) <= 1: + return candidates + center = fsum(candidates) / len(candidates) + half_span = minimum * (len(candidates) - 1) / 2.0 + return [center - half_span + index * minimum for index in range(len(candidates))] + + def solve( + self, + robots: Sequence[LocalProblem], + safety_policy: SafetyPolicy, + incoming_deltas: Iterable[PlanDelta] = (), + ) -> SwarmSolution: + working = [robot.model_copy(deep=True) for robot in robots] + shared = SharedVariables(version=0, global_consensus=fsum(robot.target_pos for robot in working) / len(working)) + audit_log: list[AuditLogEntry] = [] + applied_deltas = self._apply_deltas(working, shared, incoming_deltas) + + final_primal = 0.0 + final_dual = 0.0 + converged = False + + for iteration in range(self.max_iter): + old_consensus = shared.global_consensus + candidate_positions: list[float] = [] + previous_positions = [robot.current_pos for robot in working] + + for robot in working: + raw_position = (2.0 * robot.target_pos + self.rho * (shared.global_consensus - robot.dual_var)) / (2.0 + self.rho) + step = max(-robot.max_step, min(robot.max_step, raw_position - robot.current_pos)) + candidate_positions.append(robot.current_pos + step) + + projected_positions = self._project_for_separation(candidate_positions, safety_policy.min_separation) + + for robot, new_position in zip(working, projected_positions): + robot.current_pos = new_position + if not safety_policy.verify(robot.initial_pos, robot.current_pos): + raise ValueError(f"Safety violation for {robot.robot_id}: travel bound exceeded") + + shared.global_consensus = fsum(projected_positions) / len(projected_positions) + shared.version += 1 + + final_primal = 0.0 + for robot, previous_position in zip(working, previous_positions): + robot.dual_var += 0.5 * (robot.current_pos - shared.global_consensus) + final_primal = max(final_primal, abs(robot.current_pos - previous_position)) + + final_dual = abs(shared.global_consensus - old_consensus) * self.rho + audit_log.append( + AuditLogEntry( + iteration=iteration, + shared_version=shared.version, + consensus=shared.global_consensus, + primal_residual=final_primal, + dual_residual=final_dual, + applied_deltas=applied_deltas if iteration == 0 else [], + ) + ) + + if final_primal < self.epsilon and final_dual < self.epsilon: + converged = True + break + + certificate = ConvergenceCertificate( + converged=converged, + iterations=len(audit_log), + epsilon=self.epsilon, + final_primal_residual=final_primal, + final_dual_residual=final_dual, + ) + solution = SwarmSolution( + consensus=shared.global_consensus, + robots=working, + shared=shared, + audit_log=audit_log, + certificate=certificate, + ) + report = verify_swarm_solution(solution, safety_policy) + if not report.passed: + raise ValueError("; ".join(report.violations)) + return solution + + def run( + self, + robots: Sequence[LocalProblem], + safety_policy: SafetyPolicy, + incoming_deltas: Iterable[PlanDelta] = (), + ) -> float: + return self.solve(robots, safety_policy, incoming_deltas=incoming_deltas).consensus + + +def reference_scenario() -> SwarmSolution: + robots = [ + LocalProblem(robot_id="drone-1", target_pos=0.0, current_pos=0.0), + LocalProblem(robot_id="drone-2", target_pos=10.0, current_pos=10.0), + ] + policy = SafetyPolicy(max_travel_distance=15.0, min_separation=1.0, energy_budget=20.0) + return ADMMSolver(rho=1.5, max_iter=40, epsilon=1e-4).solve(robots, policy) diff --git a/catopt_swarm/verification.py b/catopt_swarm/verification.py new file mode 100644 index 0000000..5789c99 --- /dev/null +++ b/catopt_swarm/verification.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from itertools import combinations + +from pydantic import BaseModel, Field + +from .models import SafetyPolicy, SwarmSolution + + +class VerificationReport(BaseModel): + passed: bool + violations: list[str] = Field(default_factory=list) + + +def verify_swarm_solution(solution: SwarmSolution, policy: SafetyPolicy) -> VerificationReport: + violations: list[str] = [] + robots = solution.robots + + for robot in robots: + if not policy.verify(robot.initial_pos, robot.current_pos): + violations.append(f"{robot.robot_id}: travel bound exceeded") + if robot.travel_distance > policy.energy_budget: + violations.append(f"{robot.robot_id}: energy budget exceeded") + + for left, right in combinations(robots, 2): + if abs(left.current_pos - right.current_pos) < policy.min_separation: + violations.append( + f"{left.robot_id}/{right.robot_id}: separation below {policy.min_separation}" + ) + + if not solution.certificate.converged: + violations.append("solver did not converge") + + return VerificationReport(passed=not violations, violations=violations) diff --git a/primitives.py b/primitives.py index a0ff90d..f6823e7 100644 --- a/primitives.py +++ b/primitives.py @@ -1,23 +1 @@ -from dataclasses import dataclass -from typing import Dict, Any - -@dataclass -class LocalProblem: - robot_id: str - target_pos: float - current_pos: float = 0.0 - dual_var: float = 0.0 # y_i - -@dataclass -class SharedVariables: - version: int - global_consensus: float # z - -@dataclass -class SafetyPolicy: - max_travel_distance: float - - def verify(self, start_pos: float, new_pos: float) -> bool: - if abs(new_pos - start_pos) > self.max_travel_distance: - return False - return True +from catopt_swarm.models import DualVariables, LocalProblem, PlanDelta, SafetyPolicy, SharedVariables diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..430ea60 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "catopt-swarm" +version = "0.1.0" +description = "Safe, verifiable distributed optimization for robotic swarms." +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.10" +dependencies = [ + "pydantic>=2.7,<3", + "networkx>=3.2,<4", +] + +[project.scripts] +catopt-swarm = "catopt_swarm.cli:main" + +[tool.setuptools] +packages = ["catopt_swarm"] diff --git a/solver.py b/solver.py index 24563a0..59564ee 100644 --- a/solver.py +++ b/solver.py @@ -1,18 +1,5 @@ -import sys -import time +from catopt_swarm.cli import main -def simulate_swarm_optimization(): - print("Initializing CatOpt-Swarm ADMM-lite solver...") - time.sleep(0.5) - print("Mapping robotic tasks to Category-Theory Functors: OK") - time.sleep(0.5) - print("Exchanging SharedVariables (Morphisms) across 3 swarm nodes...") - for step in range(1, 4): - print(f" [Step {step}] Resolving LocalProblem... constraint error: {1.0 / (step * 2):.2f}") - time.sleep(0.5) - print("Formal Verification Layer: All Safety Policies (Collision, Energy) holds.") - print("Convergence achieved!") if __name__ == "__main__": - simulate_swarm_optimization() - sys.exit(0) + main() diff --git a/test.sh b/test.sh index 11c4b95..658dc99 100755 --- a/test.sh +++ b/test.sh @@ -1,6 +1,6 @@ #!/bin/bash set -e -echo "Running CatOpt-Swarm mathematical verification tests..." -python3 admm_solver.py -echo "All ADMM constraints and safety policy tests passed!" +python3 -m pip install --quiet --disable-pip-version-check 'pydantic>=2.7,<3' 'networkx>=3.2,<4' +python3 -m pytest +python3 -m build diff --git a/tests/test_catopt_swarm.py b/tests/test_catopt_swarm.py new file mode 100644 index 0000000..d90b1d5 --- /dev/null +++ b/tests/test_catopt_swarm.py @@ -0,0 +1,51 @@ +from catopt_swarm import ( + ADMMSolver, + ContractRegistry, + ContractSpec, + DroneControllerAdapter, + GroundRoverControllerAdapter, + LocalProblem, + SafetyPolicy, + verify_swarm_solution, +) +from catopt_swarm.registry import check_adapter_conformance + + +def test_solver_converges_and_respects_safety_policy(): + robots = [ + LocalProblem(robot_id="drone-1", target_pos=0.0, current_pos=0.0), + LocalProblem(robot_id="drone-2", target_pos=10.0, current_pos=10.0), + ] + policy = SafetyPolicy(max_travel_distance=15.0, min_separation=1.0, energy_budget=20.0) + solution = ADMMSolver(rho=1.5, max_iter=40, epsilon=1e-4).solve(robots, policy) + + assert solution.certificate.converged + assert abs(solution.consensus - 5.0) < 0.25 + assert verify_swarm_solution(solution, policy).passed + + +def test_contract_registry_and_adapter_conformance(): + registry = ContractRegistry() + spec = ContractSpec( + contract_id="drone-patrol-v1", + adapter_name="drone-controller", + domain="aerial", + version="1.0.0", + invariants=("collision_free", "energy_budgeted"), + ) + registry.register(spec) + + adapter = DroneControllerAdapter() + report = check_adapter_conformance(adapter, spec) + + assert registry.has_contract("drone-patrol-v1") + assert report.passed + + +def test_rover_adapter_maps_mission_to_problem(): + adapter = GroundRoverControllerAdapter() + problem = adapter.to_local_problem("rover-1", {"target_pos": 3.5, "initial_pos": 1.0}) + + assert problem.robot_id == "rover-1" + assert problem.target_pos == 3.5 + assert problem.initial_pos == 1.0