build(agent): semicolon#54de0b iteration

This commit is contained in:
agent-54de0bcc6a17828b 2026-04-24 19:32:39 +02:00
parent d4d9a4ab44
commit f22ae67a53
14 changed files with 755 additions and 2 deletions

21
.gitignore vendored Normal file
View File

@ -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

49
AGENTS.md Normal file
View File

@ -0,0 +1,49 @@
# AGENTS.md
## Architecture
This repository contains a Python SDK for FedCatOpt.
Core modules:
`models.py`
: Typed DSL primitives for `LocalProblem`, `SharedVariables`, `PlanDelta`, `DualVariables`, `PrivacyBudget`, `AuditLog`, and `PolicyBlock`.
`solver.py`
: Deterministic ADMM-lite consensus solver with delta generation and optional privacy budget consumption.
`registry.py`
: Graph-of-Contracts registry for contract nodes and deterministic topology queries.
`adapters.py`
: Starter adapters for `rover_planner` and `drone_controller` into the canonical IR.
`identity.py`
: Governance ledger and short-lived certificate skeleton.
`sdk.py`
: Thin orchestration façade tying the registry, solver, audit log, and policy checks together.
## Tech Stack
- Python 3.11+
- `pydantic` for runtime validation of DSL models
- `numpy` declared for numerical extensibility
- `pytest` for tests
- `python -m build` for packaging verification
## Rules
- Keep contract ordering deterministic.
- Prefer minimal, explicit changes.
- Do not add new dependencies unless the code uses them directly.
- Preserve the canonical IR and DSL model names.
- Do not overwrite user changes elsewhere in the worktree.
## Testing
Run:
`bash test.sh`
That script installs the package in editable mode, runs `pytest`, and then runs `python3 -m build`.

View File

@ -1,3 +1,46 @@
# idea177-fedcatopt-federated-category
# FedCatOpt
Source logic for Idea #177
FedCatOpt is a Python SDK for federated, offline-first coordination of heterogeneous robotic fleets.
It provides:
- Typed DSL primitives for local optimization, shared state, deltas, dual variables, privacy budgets, audit logs, and policy blocks
- A deterministic Graph-of-Contracts registry for contract wiring and islanded execution
- Starter adapters for `rover_planner` and `drone_controller`
- A category-theory-inspired canonical IR that normalizes robotics-specific control payloads
- An ADMM-lite consensus solver with deterministic delta generation and optional privacy budget consumption
- A governance ledger and short-lived certificate skeleton for Phase 1 identity flows
## Project Layout
- `src/idea177_fedcatopt_federated_category/models.py`
- `src/idea177_fedcatopt_federated_category/solver.py`
- `src/idea177_fedcatopt_federated_category/registry.py`
- `src/idea177_fedcatopt_federated_category/adapters.py`
- `src/idea177_fedcatopt_federated_category/identity.py`
- `src/idea177_fedcatopt_federated_category/sdk.py`
## Example
```python
from idea177_fedcatopt_federated_category import (
ADMMliteSolver,
DRONE_CONTROLLER_ADAPTER,
ROVER_PLANNER_ADAPTER,
SharedVariables,
)
rover = ROVER_PLANNER_ADAPTER.to_local_problem({"agent_id": "rover-1", "desired_speed": 3.0, "desired_heading": 1.0})
drone = DRONE_CONTROLLER_ADAPTER.to_local_problem({"agent_id": "drone-1", "desired_thrust": 0.8, "desired_yaw_rate": 0.2})
solver = ADMMliteSolver()
result = solver.solve([rover, drone], SharedVariables(variables={"speed": 0.0, "heading": 0.0, "thrust": 0.0, "yaw_rate": 0.0}))
```
## Testing
`bash test.sh`
## Packaging
The package name is `idea177-fedcatopt-federated-category` and the source distribution metadata points to this README.

23
pyproject.toml Normal file
View File

@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "idea177-fedcatopt-federated-category"
version = "0.1.0"
description = "FedCatOpt: federated category-theoretic optimization primitives for robotic fleets"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"numpy>=1.26",
"pydantic>=2.7",
]
[project.urls]
Homepage = "https://example.com/idea177-fedcatopt-federated-category"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -0,0 +1,43 @@
"""FedCatOpt SDK."""
from .adapters import DRONE_CONTROLLER_ADAPTER, ROVER_PLANNER_ADAPTER, CanonicalAdapter
from .identity import DIDDocument, GovernanceLedger, ShortLivedCertificate
from .models import (
AuditEntry,
AuditLog,
CanonicalIR,
DualVariables,
LocalProblem,
PlanDelta,
PolicyBlock,
PrivacyBudget,
SharedVariables,
)
from .registry import ContractNode, GoCRegistry
from .sdk import FedCatOptSDK
from .solver import ADMMliteSolver, SolveResult
__all__ = [
"ADMMliteSolver",
"AuditEntry",
"AuditLog",
"CanonicalAdapter",
"CanonicalIR",
"ContractNode",
"DIDDocument",
"DRONE_CONTROLLER_ADAPTER",
"DualVariables",
"FedCatOptSDK",
"GoCRegistry",
"GovernanceLedger",
"LocalProblem",
"PlanDelta",
"PolicyBlock",
"PrivacyBudget",
"ROVER_PLANNER_ADAPTER",
"SharedVariables",
"ShortLivedCertificate",
"SolveResult",
]
__version__ = "0.1.0"

View File

@ -0,0 +1,17 @@
from __future__ import annotations
from json import dumps
from .adapters import DRONE_CONTROLLER_ADAPTER, ROVER_PLANNER_ADAPTER
from .sdk import FedCatOptSDK
def main() -> None:
sdk = FedCatOptSDK()
sdk.register_adapter(ROVER_PLANNER_ADAPTER, domain="ground")
sdk.register_adapter(DRONE_CONTROLLER_ADAPTER, domain="air")
print(dumps({"adapters": list(sdk.adapters), "registry": sdk.registry.adjacency()}, indent=2, sort_keys=True))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,70 @@
from __future__ import annotations
from dataclasses import dataclass
from .models import CanonicalIR, LocalProblem, PlanDelta
@dataclass(frozen=True, slots=True)
class CanonicalAdapter:
name: str
source_system: str
def to_ir(self, payload: dict[str, float]) -> CanonicalIR:
raise NotImplementedError
def to_local_problem(self, payload: dict[str, float]) -> LocalProblem:
raise NotImplementedError
def from_plan_delta(self, delta: PlanDelta) -> dict[str, float]:
return dict(delta.changes)
class RoverPlannerAdapter(CanonicalAdapter):
def to_ir(self, payload: dict[str, float]) -> CanonicalIR:
return CanonicalIR(
primitive="rover_planner",
subject=payload.get("agent_id", "rover"),
inputs={"distance_to_goal": payload.get("distance_to_goal", 0.0), "terrain_risk": payload.get("terrain_risk", 0.0)},
outputs={"speed": payload.get("desired_speed", 1.0)},
metadata={"source_system": self.source_system, "adapter": self.name},
)
def to_local_problem(self, payload: dict[str, float]) -> LocalProblem:
return LocalProblem(
problem_id=payload.get("problem_id", "rover_planner_problem"),
agent_id=payload.get("agent_id", "rover"),
target_variables={
"speed": payload.get("desired_speed", 1.0),
"heading": payload.get("desired_heading", 0.0),
},
weights={"speed": 1.0, "heading": 0.5},
metadata={"source_system": self.source_system, "adapter": self.name},
)
class DroneControllerAdapter(CanonicalAdapter):
def to_ir(self, payload: dict[str, float]) -> CanonicalIR:
return CanonicalIR(
primitive="drone_controller",
subject=payload.get("agent_id", "drone"),
inputs={"altitude": payload.get("altitude", 0.0), "battery": payload.get("battery", 1.0)},
outputs={"thrust": payload.get("desired_thrust", 0.5)},
metadata={"source_system": self.source_system, "adapter": self.name},
)
def to_local_problem(self, payload: dict[str, float]) -> LocalProblem:
return LocalProblem(
problem_id=payload.get("problem_id", "drone_controller_problem"),
agent_id=payload.get("agent_id", "drone"),
target_variables={
"thrust": payload.get("desired_thrust", 0.5),
"yaw_rate": payload.get("desired_yaw_rate", 0.0),
},
weights={"thrust": 1.0, "yaw_rate": 0.75},
metadata={"source_system": self.source_system, "adapter": self.name},
)
ROVER_PLANNER_ADAPTER = RoverPlannerAdapter(name="rover_planner", source_system="ROS2")
DRONE_CONTROLLER_ADAPTER = DroneControllerAdapter(name="drone_controller", source_system="PX4")

View File

@ -0,0 +1,88 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from secrets import token_hex
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
@dataclass(frozen=True, slots=True)
class DIDDocument:
did: str
controller: str
public_key_fingerprint: str
created_at: datetime = field(default_factory=_utc_now)
@dataclass(frozen=True, slots=True)
class ShortLivedCertificate:
issuer_did: str
subject_did: str
issued_at: datetime
expires_at: datetime
serial_number: str
signature_fingerprint: str
@property
def valid(self) -> bool:
now = _utc_now()
return self.issued_at <= now <= self.expires_at
@dataclass(frozen=True, slots=True)
class LedgerEntry:
index: int
timestamp: datetime
actor: str
action: str
previous_hash: str
payload_hash: str
entry_hash: str
class GovernanceLedger:
def __init__(self) -> None:
self._entries: list[LedgerEntry] = []
@staticmethod
def _hash_payload(payload: str) -> str:
return sha256(payload.encode("utf-8")).hexdigest()
def append(self, actor: str, action: str, payload: dict[str, object]) -> LedgerEntry:
previous_hash = self._entries[-1].entry_hash if self._entries else "0" * 64
payload_hash = self._hash_payload(repr(sorted(payload.items())))
timestamp = _utc_now()
entry_hash = self._hash_payload(f"{len(self._entries)}|{timestamp.isoformat()}|{actor}|{action}|{previous_hash}|{payload_hash}")
entry = LedgerEntry(
index=len(self._entries),
timestamp=timestamp,
actor=actor,
action=action,
previous_hash=previous_hash,
payload_hash=payload_hash,
entry_hash=entry_hash,
)
self._entries.append(entry)
return entry
def issue_short_lived_certificate(self, issuer_did: str, subject_did: str, ttl_seconds: int = 300) -> ShortLivedCertificate:
issued_at = _utc_now()
expires_at = issued_at + timedelta(seconds=ttl_seconds)
serial_number = token_hex(8)
signature_fingerprint = self._hash_payload(f"{issuer_did}|{subject_did}|{serial_number}|{expires_at.isoformat()}")
self.append(issuer_did, "issue_certificate", {"subject_did": subject_did, "serial_number": serial_number})
return ShortLivedCertificate(
issuer_did=issuer_did,
subject_did=subject_did,
issued_at=issued_at,
expires_at=expires_at,
serial_number=serial_number,
signature_fingerprint=signature_fingerprint,
)
def entries(self) -> list[LedgerEntry]:
return list(self._entries)

View File

@ -0,0 +1,104 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
class CanonicalIR(BaseModel):
model_config = ConfigDict(frozen=True)
primitive: str
subject: str
inputs: dict[str, float] = Field(default_factory=dict)
outputs: dict[str, float] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class LocalProblem(BaseModel):
model_config = ConfigDict(frozen=True)
problem_id: str
agent_id: str
target_variables: dict[str, float]
weights: dict[str, float] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("target_variables")
@classmethod
def _require_targets(cls, value: dict[str, float]) -> dict[str, float]:
if not value:
raise ValueError("local problems require at least one target variable")
return value
class SharedVariables(BaseModel):
model_config = ConfigDict(frozen=True)
variables: dict[str, float]
version: int = 0
class PlanDelta(BaseModel):
model_config = ConfigDict(frozen=True)
problem_id: str
base_version: int
new_version: int
changes: dict[str, float]
created_at: datetime = Field(default_factory=_utc_now)
class DualVariables(BaseModel):
model_config = ConfigDict(frozen=True)
problem_id: str
multipliers: dict[str, float] = Field(default_factory=dict)
class PrivacyBudget(BaseModel):
model_config = ConfigDict(frozen=True)
epsilon: float
delta: float
spent_epsilon: float = 0.0
spent_delta: float = 0.0
def consume(self, epsilon: float, delta: float = 0.0) -> "PrivacyBudget":
next_epsilon = self.spent_epsilon + epsilon
next_delta = self.spent_delta + delta
if next_epsilon > self.epsilon + 1e-12 or next_delta > self.delta + 1e-12:
raise ValueError("privacy budget exceeded")
return self.model_copy(update={"spent_epsilon": next_epsilon, "spent_delta": next_delta})
class AuditEntry(BaseModel):
model_config = ConfigDict(frozen=True)
timestamp: datetime = Field(default_factory=_utc_now)
actor: str
action: str
payload: dict[str, Any] = Field(default_factory=dict)
class AuditLog(BaseModel):
model_config = ConfigDict(frozen=True)
entries: list[AuditEntry] = Field(default_factory=list)
def append(self, entry: AuditEntry) -> "AuditLog":
return self.model_copy(update={"entries": [*self.entries, entry]})
class PolicyBlock(BaseModel):
model_config = ConfigDict(frozen=True)
policy_id: str
allowed_domains: list[str] = Field(default_factory=list)
require_secure_aggregation: bool = True
min_certificate_ttl_seconds: int = 300

View File

@ -0,0 +1,65 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable
@dataclass(frozen=True, slots=True)
class ContractNode:
contract_id: str
primitive: str
domain: str
inputs: tuple[str, ...] = ()
outputs: tuple[str, ...] = ()
metadata: dict[str, object] = field(default_factory=dict)
class GoCRegistry:
def __init__(self) -> None:
self._nodes: dict[str, ContractNode] = {}
self._edges: dict[str, set[str]] = {}
def register(self, node: ContractNode) -> None:
self._nodes[node.contract_id] = node
self._edges.setdefault(node.contract_id, set())
def connect(self, source_contract_id: str, target_contract_id: str) -> None:
if source_contract_id not in self._nodes or target_contract_id not in self._nodes:
raise KeyError("both contracts must be registered before connecting them")
self._edges.setdefault(source_contract_id, set()).add(target_contract_id)
def get(self, contract_id: str) -> ContractNode:
return self._nodes[contract_id]
def contracts(self) -> list[ContractNode]:
return [self._nodes[contract_id] for contract_id in sorted(self._nodes)]
def adjacency(self) -> dict[str, tuple[str, ...]]:
return {contract_id: tuple(sorted(targets)) for contract_id, targets in sorted(self._edges.items())}
def topological_order(self) -> list[str]:
incoming: dict[str, int] = {contract_id: 0 for contract_id in self._nodes}
for targets in self._edges.values():
for target in targets:
incoming[target] += 1
ready = sorted(contract_id for contract_id, count in incoming.items() if count == 0)
order: list[str] = []
edges = {source: set(targets) for source, targets in self._edges.items()}
while ready:
current = ready.pop(0)
order.append(current)
for target in sorted(edges.get(current, ())):
incoming[target] -= 1
if incoming[target] == 0:
ready.append(target)
ready.sort()
if len(order) != len(self._nodes):
raise ValueError("graph of contracts contains a cycle")
return order
def island(self, domains: Iterable[str]) -> list[ContractNode]:
selected = set(domains)
return [node for node in self.contracts() if node.domain in selected]

View File

@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass, field
from .adapters import CanonicalAdapter
from .identity import GovernanceLedger
from .models import AuditEntry, AuditLog, LocalProblem, PolicyBlock, PrivacyBudget, SharedVariables
from .registry import ContractNode, GoCRegistry
from .solver import ADMMliteSolver, SolveResult
@dataclass(slots=True)
class FedCatOptSDK:
registry: GoCRegistry = field(default_factory=GoCRegistry)
ledger: GovernanceLedger = field(default_factory=GovernanceLedger)
audit_log: AuditLog = field(default_factory=AuditLog)
solver: ADMMliteSolver = field(default_factory=ADMMliteSolver)
adapters: dict[str, CanonicalAdapter] = field(default_factory=dict)
def register_adapter(self, adapter: CanonicalAdapter, domain: str) -> None:
self.adapters[adapter.name] = adapter
self.registry.register(
ContractNode(
contract_id=adapter.name,
primitive=adapter.name,
domain=domain,
metadata={"source_system": adapter.source_system},
)
)
self.audit_log = self.audit_log.append(
AuditEntry(actor="sdk", action="register_adapter", payload={"adapter": adapter.name, "domain": domain})
)
def solve(
self,
local_problems: list[LocalProblem],
shared_variables: SharedVariables,
privacy_budgets: dict[str, PrivacyBudget] | None = None,
rounds: int = 10,
) -> SolveResult:
self.audit_log = self.audit_log.append(
AuditEntry(actor="sdk", action="solve", payload={"problems": [problem.problem_id for problem in local_problems], "rounds": rounds})
)
return self.solver.solve(local_problems, shared_variables, rounds=rounds, privacy_budgets=privacy_budgets)
def enforce_policy(self, policy: PolicyBlock, domain: str, secure_aggregation: bool) -> None:
if policy.allowed_domains and domain not in policy.allowed_domains:
raise PermissionError(f"domain {domain!r} is not allowed by policy {policy.policy_id!r}")
if policy.require_secure_aggregation and not secure_aggregation:
raise PermissionError("policy requires secure aggregation")

View File

@ -0,0 +1,132 @@
from __future__ import annotations
from dataclasses import dataclass, field
from hashlib import sha256
from math import copysign
from random import Random
from .models import DualVariables, LocalProblem, PlanDelta, PrivacyBudget, SharedVariables
@dataclass(frozen=True, slots=True)
class SolveResult:
shared_variables: SharedVariables
deltas: tuple[PlanDelta, ...]
dual_variables: tuple[DualVariables, ...]
residual: float
rounds: int
@dataclass(slots=True)
class _State:
shared: dict[str, float]
duals: dict[str, dict[str, float]] = field(default_factory=dict)
class ADMMliteSolver:
def __init__(self, rho: float = 1.0, tolerance: float = 1e-6, secure_aggregation: bool = True) -> None:
self.rho = rho
self.tolerance = tolerance
self.secure_aggregation = secure_aggregation
def solve(
self,
local_problems: list[LocalProblem],
shared_variables: SharedVariables,
rounds: int = 10,
privacy_budgets: dict[str, PrivacyBudget] | None = None,
) -> SolveResult:
state = _State(shared=dict(shared_variables.variables))
ordered_problems = sorted(local_problems, key=lambda problem: problem.problem_id)
for problem in ordered_problems:
state.duals[problem.problem_id] = {name: 0.0 for name in problem.target_variables}
deltas: list[PlanDelta] = []
for round_index in range(rounds):
proposals: dict[str, list[float]] = {name: [] for name in state.shared}
for problem in ordered_problems:
proposal = self._local_step(problem, state.shared, state.duals[problem.problem_id], round_index, privacy_budgets)
for name, value in proposal.items():
proposals.setdefault(name, []).append(value)
next_shared = self._aggregate(state.shared, proposals)
residual = max((abs(next_shared[name] - state.shared.get(name, 0.0)) for name in next_shared), default=0.0)
version = shared_variables.version + round_index + 1
for problem in ordered_problems:
changes = {
name: next_shared[name] - state.shared.get(name, 0.0)
for name in problem.target_variables
if name in next_shared
}
deltas.append(
PlanDelta(
problem_id=problem.problem_id,
base_version=shared_variables.version + round_index,
new_version=version,
changes=changes,
)
)
for problem in ordered_problems:
for name in problem.target_variables:
state.duals[problem.problem_id][name] += state.shared.get(name, 0.0) - next_shared.get(name, 0.0)
state.shared = next_shared
if residual <= self.tolerance:
shared_variables = SharedVariables(variables=state.shared, version=version)
return SolveResult(
shared_variables=shared_variables,
deltas=tuple(deltas),
dual_variables=tuple(
DualVariables(problem_id=problem.problem_id, multipliers=dict(state.duals[problem.problem_id]))
for problem in ordered_problems
),
residual=residual,
rounds=round_index + 1,
)
shared_variables = SharedVariables(variables=state.shared, version=shared_variables.version + rounds)
residual = max((abs(state.shared[name] - shared_variables.variables[name]) for name in shared_variables.variables), default=0.0)
return SolveResult(
shared_variables=shared_variables,
deltas=tuple(deltas),
dual_variables=tuple(
DualVariables(problem_id=problem.problem_id, multipliers=dict(state.duals[problem.problem_id]))
for problem in ordered_problems
),
residual=residual,
rounds=rounds,
)
def _local_step(
self,
problem: LocalProblem,
shared: dict[str, float],
duals: dict[str, float],
round_index: int,
privacy_budgets: dict[str, PrivacyBudget] | None,
) -> dict[str, float]:
proposal: dict[str, float] = {}
for name, target in problem.target_variables.items():
weight = problem.weights.get(name, 1.0)
current = shared.get(name, target)
dual = duals.get(name, 0.0)
value = (weight * target + self.rho * current - dual) / (weight + self.rho)
if privacy_budgets and problem.problem_id in privacy_budgets:
privacy_budgets[problem.problem_id] = privacy_budgets[problem.problem_id].consume(0.01, 0.0)
value += self._deterministic_noise(problem.problem_id, name, round_index, privacy_budgets[problem.problem_id].epsilon)
proposal[name] = value
return proposal
def _aggregate(self, current: dict[str, float], proposals: dict[str, list[float]]) -> dict[str, float]:
next_shared = dict(current)
for name, values in proposals.items():
if values:
ordered = values if self.secure_aggregation else list(values)
next_shared[name] = sum(ordered) / len(ordered)
return next_shared
@staticmethod
def _deterministic_noise(problem_id: str, name: str, round_index: int, epsilon: float) -> float:
seed = int(sha256(f"{problem_id}|{name}|{round_index}".encode("utf-8")).hexdigest(), 16)
rng = Random(seed)
scale = max(1e-6, 1.0 / max(epsilon, 1e-6))
return copysign(rng.random() * scale * 0.01, rng.choice([-1, 1]))

7
test.sh Normal file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
python3 -m pip install --quiet --upgrade pip
python3 -m pip install --quiet -e . pytest build
pytest
python3 -m build

41
tests/test_core.py Normal file
View File

@ -0,0 +1,41 @@
from idea177_fedcatopt_federated_category.adapters import DRONE_CONTROLLER_ADAPTER, ROVER_PLANNER_ADAPTER
from idea177_fedcatopt_federated_category.models import LocalProblem, PrivacyBudget, SharedVariables
from idea177_fedcatopt_federated_category.registry import ContractNode, GoCRegistry
from idea177_fedcatopt_federated_category.sdk import FedCatOptSDK
from idea177_fedcatopt_federated_category.solver import ADMMliteSolver
def test_registry_topological_order_and_island():
registry = GoCRegistry()
registry.register(ContractNode(contract_id="a", primitive="alpha", domain="ground"))
registry.register(ContractNode(contract_id="b", primitive="beta", domain="air"))
registry.connect("a", "b")
assert registry.topological_order() == ["a", "b"]
assert [node.contract_id for node in registry.island(["air"])] == ["b"]
def test_adapters_and_solver_produce_stable_deltas():
rover_problem = ROVER_PLANNER_ADAPTER.to_local_problem({"agent_id": "rover-1", "desired_speed": 3.0, "desired_heading": 1.5})
drone_problem = DRONE_CONTROLLER_ADAPTER.to_local_problem({"agent_id": "drone-7", "desired_thrust": 0.7, "desired_yaw_rate": 0.1})
solver = ADMMliteSolver(tolerance=0.0)
result = solver.solve(
[rover_problem, drone_problem],
SharedVariables(variables={"speed": 0.0, "heading": 0.0, "thrust": 0.0, "yaw_rate": 0.0}),
rounds=2,
privacy_budgets={"rover_planner_problem": PrivacyBudget(epsilon=1.0, delta=1e-6)},
)
assert result.rounds == 2
assert result.shared_variables.version == 2
assert set(result.shared_variables.variables) == {"speed", "heading", "thrust", "yaw_rate"}
assert any(delta.problem_id == rover_problem.problem_id for delta in result.deltas)
def test_sdk_registers_adapters_and_applies_policy():
sdk = FedCatOptSDK()
sdk.register_adapter(ROVER_PLANNER_ADAPTER, domain="ground")
sdk.register_adapter(DRONE_CONTROLLER_ADAPTER, domain="air")
assert sdk.registry.adjacency() == {"drone_controller": (), "rover_planner": ()}
assert len(sdk.audit_log.entries) == 2