build(agent): semicolon#54de0b iteration
This commit is contained in:
parent
d4d9a4ab44
commit
f22ae67a53
|
|
@ -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
|
||||
|
|
@ -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`.
|
||||
47
README.md
47
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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")
|
||||
|
|
@ -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]))
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue