build(agent): semicolon#54de0b iteration
This commit is contained in:
parent
b6376c833b
commit
3ede4a4ab8
40
README.md
40
README.md
|
|
@ -1,23 +1,26 @@
|
||||||
# CatOpt-Swarm
|
# CatOpt-Swarm
|
||||||
|
|
||||||
Safe, verifiable distributed optimization for robotic swarms.
|
CatOpt-Swarm is a validated Python package for safe distributed swarm optimization.
|
||||||
|
It models robot tasks, contract-tagged deltas, and mission policies in a canonical IR,
|
||||||
|
then runs a deterministic ADMM-lite coordinator with verification hooks.
|
||||||
|
|
||||||
CatOpt-Swarm models swarm coordination as a small validated IR:
|
## Core Concepts
|
||||||
|
|
||||||
- `LocalProblem` captures each robot's local objective and state.
|
- `LocalProblem` captures each robot's objective, state, and local limits.
|
||||||
- `PlanDelta` carries deterministic, contract-tagged updates.
|
- `PlanDelta` carries contract-tagged updates with sequence and base-version replay guards.
|
||||||
- `SharedVariables` stores consensus state and versioning.
|
- `SharedVariables` tracks consensus, versioning, and applied sequence state.
|
||||||
- `SafetyPolicy` enforces travel, separation, and energy limits.
|
- `SafetyPolicy` enforces travel distance, separation, and energy limits.
|
||||||
- `ContractRegistry` records adapter and mission contracts as a graph.
|
- `ContractRegistry` stores adapter contracts in a graph and supports conformance checks.
|
||||||
|
|
||||||
The solver is a deterministic ADMM-lite loop with:
|
## Behavior
|
||||||
|
|
||||||
- bounded-step local updates
|
- Deterministic delta ordering and bounded-staleness replay control
|
||||||
- delta reconciliation with bounded staleness
|
- Separation projection to keep robots apart during updates
|
||||||
- separation projection for collision avoidance
|
- Audit logs and convergence certificates per solve
|
||||||
- audit logs and convergence certificates
|
- Verification of consensus consistency, energy budgets, and trace continuity
|
||||||
|
- Adapter shims for aerial and ground controllers
|
||||||
|
|
||||||
## Package Layout
|
## Layout
|
||||||
|
|
||||||
- `catopt_swarm.models`
|
- `catopt_swarm.models`
|
||||||
- `catopt_swarm.registry`
|
- `catopt_swarm.registry`
|
||||||
|
|
@ -38,10 +41,11 @@ python3 -m pip install -e .
|
||||||
python3 -m catopt_swarm.cli
|
python3 -m catopt_swarm.cli
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the compatibility script:
|
Compatibility entrypoints:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 admm_solver.py
|
python3 admm_solver.py
|
||||||
|
python3 solver.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
@ -50,13 +54,15 @@ python3 admm_solver.py
|
||||||
bash test.sh
|
bash test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## What the demo covers
|
## Demo Coverage
|
||||||
|
|
||||||
- Two-robot consensus planning
|
- Two-robot consensus planning
|
||||||
- Adapter conformance checks for aerial and ground controllers
|
- Adapter conformance checks for aerial and ground controllers
|
||||||
- Graph-based contract registration
|
- Graph-based contract registration
|
||||||
- Safety verification after solve
|
- Safety verification after solve
|
||||||
|
- Delta replay and version tracking
|
||||||
|
|
||||||
## Notes
|
## Status
|
||||||
|
|
||||||
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.
|
This repository now covers the core planning, contract, and verification substrate.
|
||||||
|
ROS/Gazebo integration and richer mission templates remain natural next steps.
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,15 @@ class DroneControllerAdapter:
|
||||||
defaults = _AdapterDefaults(max_step=12.0, energy_budget=80.0)
|
defaults = _AdapterDefaults(max_step=12.0, energy_budget=80.0)
|
||||||
|
|
||||||
def to_local_problem(self, robot_id: str, mission: dict[str, Any]) -> LocalProblem:
|
def to_local_problem(self, robot_id: str, mission: dict[str, Any]) -> LocalProblem:
|
||||||
|
metadata = dict(mission.get("metadata", {}))
|
||||||
|
metadata["domain"] = self.domain
|
||||||
return LocalProblem(
|
return LocalProblem(
|
||||||
robot_id=robot_id,
|
robot_id=robot_id,
|
||||||
target_pos=float(mission["target_pos"]),
|
target_pos=float(mission["target_pos"]),
|
||||||
initial_pos=float(mission.get("initial_pos", 0.0)),
|
initial_pos=float(mission.get("initial_pos", 0.0)),
|
||||||
max_step=float(mission.get("max_step", self.defaults.max_step)),
|
max_step=float(mission.get("max_step", self.defaults.max_step)),
|
||||||
energy_budget=float(mission.get("energy_budget", self.defaults.energy_budget)),
|
energy_budget=float(mission.get("energy_budget", self.defaults.energy_budget)),
|
||||||
metadata={"domain": self.domain, **mission.get("metadata", {})},
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_plan_delta(
|
def to_plan_delta(
|
||||||
|
|
@ -50,13 +52,15 @@ class GroundRoverControllerAdapter:
|
||||||
defaults = _AdapterDefaults(max_step=8.0, energy_budget=120.0)
|
defaults = _AdapterDefaults(max_step=8.0, energy_budget=120.0)
|
||||||
|
|
||||||
def to_local_problem(self, robot_id: str, mission: dict[str, Any]) -> LocalProblem:
|
def to_local_problem(self, robot_id: str, mission: dict[str, Any]) -> LocalProblem:
|
||||||
|
metadata = dict(mission.get("metadata", {}))
|
||||||
|
metadata["domain"] = self.domain
|
||||||
return LocalProblem(
|
return LocalProblem(
|
||||||
robot_id=robot_id,
|
robot_id=robot_id,
|
||||||
target_pos=float(mission["target_pos"]),
|
target_pos=float(mission["target_pos"]),
|
||||||
initial_pos=float(mission.get("initial_pos", 0.0)),
|
initial_pos=float(mission.get("initial_pos", 0.0)),
|
||||||
max_step=float(mission.get("max_step", self.defaults.max_step)),
|
max_step=float(mission.get("max_step", self.defaults.max_step)),
|
||||||
energy_budget=float(mission.get("energy_budget", self.defaults.energy_budget)),
|
energy_budget=float(mission.get("energy_budget", self.defaults.energy_budget)),
|
||||||
metadata={"domain": self.domain, **mission.get("metadata", {})},
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_plan_delta(
|
def to_plan_delta(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class SharedVariables(BaseModel):
|
||||||
version: int = 0
|
version: int = 0
|
||||||
global_consensus: float = 0.0
|
global_consensus: float = 0.0
|
||||||
delta_clock: int = 0
|
delta_clock: int = 0
|
||||||
|
last_applied_sequences: dict[str, int] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class DualVariables(BaseModel):
|
class DualVariables(BaseModel):
|
||||||
|
|
@ -56,6 +57,7 @@ class PlanDelta(BaseModel):
|
||||||
author: str
|
author: str
|
||||||
contract_id: str
|
contract_id: str
|
||||||
sequence: int
|
sequence: int
|
||||||
|
base_version: int = 0
|
||||||
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
target_robot_id: str | None = None
|
target_robot_id: str | None = None
|
||||||
updates: dict[str, Any] = Field(default_factory=dict)
|
updates: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
@ -67,6 +69,13 @@ class PlanDelta(BaseModel):
|
||||||
raise ValueError("sequence must be non-negative")
|
raise ValueError("sequence must be non-negative")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@field_validator("base_version")
|
||||||
|
@classmethod
|
||||||
|
def _base_version_non_negative(cls, value: int) -> int:
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError("base_version must be non-negative")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SafetyPolicy(BaseModel):
|
class SafetyPolicy(BaseModel):
|
||||||
max_travel_distance: float = 15.0
|
max_travel_distance: float = 15.0
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ class ContractRegistry:
|
||||||
self._graph = nx.DiGraph()
|
self._graph = nx.DiGraph()
|
||||||
|
|
||||||
def register(self, spec: ContractSpec) -> None:
|
def register(self, spec: ContractSpec) -> None:
|
||||||
|
if self.has_contract(spec.contract_id):
|
||||||
|
raise ValueError(f"contract already registered: {spec.contract_id}")
|
||||||
self._graph.add_node(spec.contract_id, spec=spec)
|
self._graph.add_node(spec.contract_id, spec=spec)
|
||||||
|
|
||||||
def link(self, parent_contract_id: str, child_contract_id: str, relation: str = "extends") -> None:
|
def link(self, parent_contract_id: str, child_contract_id: str, relation: str = "extends") -> None:
|
||||||
|
|
@ -42,7 +44,7 @@ class ContractRegistry:
|
||||||
|
|
||||||
def summary(self) -> dict[str, Any]:
|
def summary(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"contracts": list(self._graph.nodes),
|
"contracts": [self.get(contract_id).model_dump() for contract_id in self._graph.nodes],
|
||||||
"links": [
|
"links": [
|
||||||
{"from": source, "to": target, **data}
|
{"from": source, "to": target, **data}
|
||||||
for source, target, data in self._graph.edges(data=True)
|
for source, target, data in self._graph.edges(data=True)
|
||||||
|
|
@ -56,6 +58,9 @@ def check_adapter_conformance(adapter: Any, spec: ContractSpec) -> AdapterConfor
|
||||||
issues.append(f"adapter name mismatch: expected {spec.adapter_name}")
|
issues.append(f"adapter name mismatch: expected {spec.adapter_name}")
|
||||||
if getattr(adapter, "domain", None) != spec.domain:
|
if getattr(adapter, "domain", None) != spec.domain:
|
||||||
issues.append(f"domain mismatch: expected {spec.domain}")
|
issues.append(f"domain mismatch: expected {spec.domain}")
|
||||||
|
for method_name in ("to_local_problem", "to_plan_delta"):
|
||||||
|
if not callable(getattr(adapter, method_name, None)):
|
||||||
|
issues.append(f"missing adapter method: {method_name}")
|
||||||
return AdapterConformanceReport(
|
return AdapterConformanceReport(
|
||||||
adapter_name=getattr(adapter, "name", adapter.__class__.__name__),
|
adapter_name=getattr(adapter, "name", adapter.__class__.__name__),
|
||||||
contract_id=spec.contract_id,
|
contract_id=spec.contract_id,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ from .models import (
|
||||||
SwarmSolution,
|
SwarmSolution,
|
||||||
)
|
)
|
||||||
from .verification import verify_swarm_solution
|
from .verification import verify_swarm_solution
|
||||||
|
|
||||||
|
|
||||||
class ADMMSolver:
|
class ADMMSolver:
|
||||||
def __init__(self, rho: float = 1.0, max_iter: int = 50, epsilon: float = 1e-3, staleness_bound: int = 2):
|
def __init__(self, rho: float = 1.0, max_iter: int = 50, epsilon: float = 1e-3, staleness_bound: int = 2):
|
||||||
self.rho = rho
|
self.rho = rho
|
||||||
|
|
@ -35,12 +37,19 @@ class ADMMSolver:
|
||||||
applied: list[str] = []
|
applied: list[str] = []
|
||||||
indexed = {robot.robot_id: robot for robot in robots}
|
indexed = {robot.robot_id: robot for robot in robots}
|
||||||
for delta in self._ordered_deltas(deltas):
|
for delta in self._ordered_deltas(deltas):
|
||||||
if shared.version - delta.sequence > self.staleness_bound:
|
if delta.base_version > shared.version:
|
||||||
|
continue
|
||||||
|
if shared.version - delta.base_version > self.staleness_bound:
|
||||||
continue
|
continue
|
||||||
if delta.target_robot_id is None or delta.target_robot_id not in indexed:
|
if delta.target_robot_id is None or delta.target_robot_id not in indexed:
|
||||||
continue
|
continue
|
||||||
|
last_sequence = shared.last_applied_sequences.get(delta.target_robot_id, -1)
|
||||||
|
if delta.sequence <= last_sequence:
|
||||||
|
continue
|
||||||
indexed[delta.target_robot_id].apply_updates(delta.updates)
|
indexed[delta.target_robot_id].apply_updates(delta.updates)
|
||||||
indexed[delta.target_robot_id].version = max(indexed[delta.target_robot_id].version, delta.sequence)
|
indexed[delta.target_robot_id].version = max(indexed[delta.target_robot_id].version, delta.sequence)
|
||||||
|
shared.last_applied_sequences[delta.target_robot_id] = delta.sequence
|
||||||
|
shared.version = max(shared.version, delta.base_version, delta.sequence)
|
||||||
applied.append(f"{delta.contract_id}:{delta.author}:{delta.sequence}")
|
applied.append(f"{delta.contract_id}:{delta.author}:{delta.sequence}")
|
||||||
if applied:
|
if applied:
|
||||||
shared.delta_clock += len(applied)
|
shared.delta_clock += len(applied)
|
||||||
|
|
@ -53,14 +62,26 @@ class ADMMSolver:
|
||||||
half_span = minimum * (len(candidates) - 1) / 2.0
|
half_span = minimum * (len(candidates) - 1) / 2.0
|
||||||
return [center - half_span + index * minimum for index in range(len(candidates))]
|
return [center - half_span + index * minimum for index in range(len(candidates))]
|
||||||
|
|
||||||
|
def _current_position(self, robot: LocalProblem) -> float:
|
||||||
|
current_pos = robot.current_pos
|
||||||
|
if current_pos is None:
|
||||||
|
current_pos = robot.initial_pos
|
||||||
|
return float(current_pos)
|
||||||
|
|
||||||
def solve(
|
def solve(
|
||||||
self,
|
self,
|
||||||
robots: Sequence[LocalProblem],
|
robots: Sequence[LocalProblem],
|
||||||
safety_policy: SafetyPolicy,
|
safety_policy: SafetyPolicy,
|
||||||
incoming_deltas: Iterable[PlanDelta] = (),
|
incoming_deltas: Iterable[PlanDelta] = (),
|
||||||
) -> SwarmSolution:
|
) -> SwarmSolution:
|
||||||
|
if not robots:
|
||||||
|
raise ValueError("at least one robot is required")
|
||||||
|
|
||||||
working = [robot.model_copy(deep=True) for robot in robots]
|
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))
|
shared = SharedVariables(
|
||||||
|
version=max(robot.version for robot in working),
|
||||||
|
global_consensus=fsum(self._current_position(robot) for robot in working) / len(working),
|
||||||
|
)
|
||||||
audit_log: list[AuditLogEntry] = []
|
audit_log: list[AuditLogEntry] = []
|
||||||
applied_deltas = self._apply_deltas(working, shared, incoming_deltas)
|
applied_deltas = self._apply_deltas(working, shared, incoming_deltas)
|
||||||
|
|
||||||
|
|
@ -71,18 +92,22 @@ class ADMMSolver:
|
||||||
for iteration in range(self.max_iter):
|
for iteration in range(self.max_iter):
|
||||||
old_consensus = shared.global_consensus
|
old_consensus = shared.global_consensus
|
||||||
candidate_positions: list[float] = []
|
candidate_positions: list[float] = []
|
||||||
previous_positions = [robot.current_pos for robot in working]
|
previous_positions: list[float] = []
|
||||||
|
|
||||||
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)
|
current_pos = self._current_position(robot)
|
||||||
step = max(-robot.max_step, min(robot.max_step, raw_position - robot.current_pos))
|
previous_positions.append(current_pos)
|
||||||
candidate_positions.append(robot.current_pos + step)
|
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 - current_pos))
|
||||||
|
candidate_positions.append(current_pos + step)
|
||||||
|
|
||||||
projected_positions = self._project_for_separation(candidate_positions, safety_policy.min_separation)
|
projected_positions = self._project_for_separation(candidate_positions, safety_policy.min_separation)
|
||||||
|
|
||||||
for robot, new_position in zip(working, projected_positions):
|
for robot, new_position in zip(working, projected_positions):
|
||||||
robot.current_pos = new_position
|
robot.current_pos = new_position
|
||||||
if not safety_policy.verify(robot.initial_pos, robot.current_pos):
|
if not safety_policy.verify(robot.initial_pos, new_position):
|
||||||
raise ValueError(f"Safety violation for {robot.robot_id}: travel bound exceeded")
|
raise ValueError(f"Safety violation for {robot.robot_id}: travel bound exceeded")
|
||||||
|
|
||||||
shared.global_consensus = fsum(projected_positions) / len(projected_positions)
|
shared.global_consensus = fsum(projected_positions) / len(projected_positions)
|
||||||
|
|
@ -90,8 +115,9 @@ class ADMMSolver:
|
||||||
|
|
||||||
final_primal = 0.0
|
final_primal = 0.0
|
||||||
for robot, previous_position in zip(working, previous_positions):
|
for robot, previous_position in zip(working, previous_positions):
|
||||||
robot.dual_var += 0.5 * (robot.current_pos - shared.global_consensus)
|
current_pos = self._current_position(robot)
|
||||||
final_primal = max(final_primal, abs(robot.current_pos - previous_position))
|
robot.dual_var += 0.5 * (current_pos - shared.global_consensus)
|
||||||
|
final_primal = max(final_primal, abs(current_pos - previous_position))
|
||||||
|
|
||||||
final_dual = abs(shared.global_consensus - old_consensus) * self.rho
|
final_dual = abs(shared.global_consensus - old_consensus) * self.rho
|
||||||
audit_log.append(
|
audit_log.append(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from itertools import combinations
|
from itertools import combinations
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
@ -12,18 +13,45 @@ class VerificationReport(BaseModel):
|
||||||
violations: list[str] = Field(default_factory=list)
|
violations: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_position(robot: Any) -> float:
|
||||||
|
current_pos = robot.current_pos
|
||||||
|
if current_pos is None:
|
||||||
|
current_pos = robot.initial_pos
|
||||||
|
assert current_pos is not None
|
||||||
|
return float(current_pos)
|
||||||
|
|
||||||
|
|
||||||
def verify_swarm_solution(solution: SwarmSolution, policy: SafetyPolicy) -> VerificationReport:
|
def verify_swarm_solution(solution: SwarmSolution, policy: SafetyPolicy) -> VerificationReport:
|
||||||
violations: list[str] = []
|
violations: list[str] = []
|
||||||
robots = solution.robots
|
robots = solution.robots
|
||||||
|
|
||||||
|
if solution.certificate.iterations != len(solution.audit_log):
|
||||||
|
violations.append("certificate iteration count does not match audit log")
|
||||||
|
|
||||||
|
expected_version = solution.shared.version
|
||||||
|
if not solution.audit_log:
|
||||||
|
violations.append("audit log is empty")
|
||||||
|
else:
|
||||||
|
for expected_iteration, entry in enumerate(solution.audit_log):
|
||||||
|
if entry.iteration != expected_iteration:
|
||||||
|
violations.append("audit log iterations are not contiguous")
|
||||||
|
break
|
||||||
|
if solution.audit_log[-1].shared_version != expected_version:
|
||||||
|
violations.append("final shared version does not match audit log")
|
||||||
|
|
||||||
|
average_position = sum(_current_position(robot) for robot in robots) / len(robots) if robots else 0.0
|
||||||
|
if abs(solution.consensus - average_position) > policy.max_travel_distance:
|
||||||
|
violations.append("consensus diverged from robot state")
|
||||||
|
|
||||||
for robot in robots:
|
for robot in robots:
|
||||||
if not policy.verify(robot.initial_pos, robot.current_pos):
|
current_pos = _current_position(robot)
|
||||||
|
if not policy.verify(robot.initial_pos, current_pos):
|
||||||
violations.append(f"{robot.robot_id}: travel bound exceeded")
|
violations.append(f"{robot.robot_id}: travel bound exceeded")
|
||||||
if robot.travel_distance > policy.energy_budget:
|
if robot.travel_distance > policy.energy_budget:
|
||||||
violations.append(f"{robot.robot_id}: energy budget exceeded")
|
violations.append(f"{robot.robot_id}: energy budget exceeded")
|
||||||
|
|
||||||
for left, right in combinations(robots, 2):
|
for left, right in combinations(robots, 2):
|
||||||
if abs(left.current_pos - right.current_pos) < policy.min_separation:
|
if abs(_current_position(left) - _current_position(right)) < policy.min_separation:
|
||||||
violations.append(
|
violations.append(
|
||||||
f"{left.robot_id}/{right.robot_id}: separation below {policy.min_separation}"
|
f"{left.robot_id}/{right.robot_id}: separation below {policy.min_separation}"
|
||||||
)
|
)
|
||||||
|
|
@ -31,4 +59,7 @@ def verify_swarm_solution(solution: SwarmSolution, policy: SafetyPolicy) -> Veri
|
||||||
if not solution.certificate.converged:
|
if not solution.certificate.converged:
|
||||||
violations.append("solver did not converge")
|
violations.append("solver did not converge")
|
||||||
|
|
||||||
|
if solution.audit_log and solution.shared.delta_clock < len(solution.audit_log[0].applied_deltas):
|
||||||
|
violations.append("delta clock regressed")
|
||||||
|
|
||||||
return VerificationReport(passed=not violations, violations=violations)
|
return VerificationReport(passed=not violations, violations=violations)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from catopt_swarm import (
|
||||||
DroneControllerAdapter,
|
DroneControllerAdapter,
|
||||||
GroundRoverControllerAdapter,
|
GroundRoverControllerAdapter,
|
||||||
LocalProblem,
|
LocalProblem,
|
||||||
|
PlanDelta,
|
||||||
SafetyPolicy,
|
SafetyPolicy,
|
||||||
verify_swarm_solution,
|
verify_swarm_solution,
|
||||||
)
|
)
|
||||||
|
|
@ -49,3 +50,64 @@ def test_rover_adapter_maps_mission_to_problem():
|
||||||
assert problem.robot_id == "rover-1"
|
assert problem.robot_id == "rover-1"
|
||||||
assert problem.target_pos == 3.5
|
assert problem.target_pos == 3.5
|
||||||
assert problem.initial_pos == 1.0
|
assert problem.initial_pos == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_solver_applies_delta_and_tracks_replay_state():
|
||||||
|
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)
|
||||||
|
delta = PlanDelta(
|
||||||
|
author="mission-orchestrator",
|
||||||
|
contract_id="patrol-update",
|
||||||
|
sequence=1,
|
||||||
|
base_version=0,
|
||||||
|
target_robot_id="drone-2",
|
||||||
|
updates={"target_pos": 8.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
solution = ADMMSolver(rho=1.5, max_iter=40, epsilon=1e-4).solve(robots, policy, [delta])
|
||||||
|
|
||||||
|
assert solution.audit_log[0].applied_deltas == ["patrol-update:mission-orchestrator:1"]
|
||||||
|
assert solution.shared.last_applied_sequences["drone-2"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_rejects_duplicate_contracts():
|
||||||
|
registry = ContractRegistry()
|
||||||
|
spec = ContractSpec(
|
||||||
|
contract_id="drone-patrol-v1",
|
||||||
|
adapter_name="drone-controller",
|
||||||
|
domain="aerial",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register(spec)
|
||||||
|
|
||||||
|
try:
|
||||||
|
registry.register(spec)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "already registered" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("duplicate contract registration should fail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_conformance_flags_missing_methods():
|
||||||
|
spec = ContractSpec(
|
||||||
|
contract_id="drone-patrol-v1",
|
||||||
|
adapter_name="drone-controller",
|
||||||
|
domain="aerial",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
class IncompleteAdapter:
|
||||||
|
name = "drone-controller"
|
||||||
|
domain = "aerial"
|
||||||
|
|
||||||
|
def to_local_problem(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
report = check_adapter_conformance(IncompleteAdapter(), spec)
|
||||||
|
|
||||||
|
assert not report.passed
|
||||||
|
assert any("missing adapter method" in issue for issue in report.issues)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue