From f22ae67a5332a71cac0487a94ca5b44419a80460 Mon Sep 17 00:00:00 2001 From: agent-54de0bcc6a17828b Date: Fri, 24 Apr 2026 19:32:39 +0200 Subject: [PATCH] build(agent): semicolon#54de0b iteration --- .gitignore | 21 +++ AGENTS.md | 49 +++++++ README.md | 47 ++++++- pyproject.toml | 23 +++ .../__init__.py | 43 ++++++ .../__main__.py | 17 +++ .../adapters.py | 70 ++++++++++ .../identity.py | 88 ++++++++++++ .../models.py | 104 ++++++++++++++ .../registry.py | 65 +++++++++ .../sdk.py | 50 +++++++ .../solver.py | 132 ++++++++++++++++++ test.sh | 7 + tests/test_core.py | 41 ++++++ 14 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 pyproject.toml create mode 100644 src/idea177_fedcatopt_federated_category/__init__.py create mode 100644 src/idea177_fedcatopt_federated_category/__main__.py create mode 100644 src/idea177_fedcatopt_federated_category/adapters.py create mode 100644 src/idea177_fedcatopt_federated_category/identity.py create mode 100644 src/idea177_fedcatopt_federated_category/models.py create mode 100644 src/idea177_fedcatopt_federated_category/registry.py create mode 100644 src/idea177_fedcatopt_federated_category/sdk.py create mode 100644 src/idea177_fedcatopt_federated_category/solver.py create mode 100644 test.sh create mode 100644 tests/test_core.py 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..e364484 --- /dev/null +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md index ac622b1..51c28ff 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,46 @@ -# idea177-fedcatopt-federated-category +# FedCatOpt -Source logic for Idea #177 \ No newline at end of file +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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..93a3473 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/idea177_fedcatopt_federated_category/__init__.py b/src/idea177_fedcatopt_federated_category/__init__.py new file mode 100644 index 0000000..1349775 --- /dev/null +++ b/src/idea177_fedcatopt_federated_category/__init__.py @@ -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" diff --git a/src/idea177_fedcatopt_federated_category/__main__.py b/src/idea177_fedcatopt_federated_category/__main__.py new file mode 100644 index 0000000..6aa7938 --- /dev/null +++ b/src/idea177_fedcatopt_federated_category/__main__.py @@ -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() diff --git a/src/idea177_fedcatopt_federated_category/adapters.py b/src/idea177_fedcatopt_federated_category/adapters.py new file mode 100644 index 0000000..f6f6e3d --- /dev/null +++ b/src/idea177_fedcatopt_federated_category/adapters.py @@ -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") diff --git a/src/idea177_fedcatopt_federated_category/identity.py b/src/idea177_fedcatopt_federated_category/identity.py new file mode 100644 index 0000000..1cae3bd --- /dev/null +++ b/src/idea177_fedcatopt_federated_category/identity.py @@ -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) diff --git a/src/idea177_fedcatopt_federated_category/models.py b/src/idea177_fedcatopt_federated_category/models.py new file mode 100644 index 0000000..51591d1 --- /dev/null +++ b/src/idea177_fedcatopt_federated_category/models.py @@ -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 diff --git a/src/idea177_fedcatopt_federated_category/registry.py b/src/idea177_fedcatopt_federated_category/registry.py new file mode 100644 index 0000000..b5659a9 --- /dev/null +++ b/src/idea177_fedcatopt_federated_category/registry.py @@ -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] diff --git a/src/idea177_fedcatopt_federated_category/sdk.py b/src/idea177_fedcatopt_federated_category/sdk.py new file mode 100644 index 0000000..7a3c650 --- /dev/null +++ b/src/idea177_fedcatopt_federated_category/sdk.py @@ -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") diff --git a/src/idea177_fedcatopt_federated_category/solver.py b/src/idea177_fedcatopt_federated_category/solver.py new file mode 100644 index 0000000..c6073b6 --- /dev/null +++ b/src/idea177_fedcatopt_federated_category/solver.py @@ -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])) diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..b782c27 --- /dev/null +++ b/test.sh @@ -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 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..57f5d0b --- /dev/null +++ b/tests/test_core.py @@ -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