build(agent): semicolon#54de0b iteration
This commit is contained in:
parent
434601d06d
commit
4d5024f8e5
19
README.md
19
README.md
|
|
@ -8,6 +8,8 @@ It coordinates solar, wind, and thermal storage sites through summarized signals
|
|||
- Canonical protocol primitives for local optimization, shared signals, dual variables, and plan deltas
|
||||
- A SQLite-backed graph-of-contracts registry for adapter versioning and schema conformance
|
||||
- An ADMM-lite solver for federated dispatch planning
|
||||
- A federation bridge that packages solver rounds, audit records, and privacy-budget charges
|
||||
- Deterministic starter adapters for solar inverter and thermal storage controllers
|
||||
- Signed message envelopes and DID-style identities for short-lived trust
|
||||
- Audit logging and privacy-budget accounting
|
||||
- Delta-sync journaling for islanded/offline operation
|
||||
|
|
@ -18,6 +20,8 @@ It coordinates solar, wind, and thermal storage sites through summarized signals
|
|||
- `solfuse.identity`: identity generation, signing, and verification
|
||||
- `solfuse.registry`: adapter registry and schema conformance checks
|
||||
- `solfuse.solver`: federated consensus solver
|
||||
- `solfuse.bridge`: round orchestration and governance-aware packaging
|
||||
- `solfuse.adapters`: starter inverter and thermal controller adapters
|
||||
- `solfuse.delta_sync`: incremental plan journal and replay helpers
|
||||
- `solfuse.governance`: audit log and privacy budget ledger
|
||||
- `solfuse.transport`: canonical envelope serialization and TLS context helpers
|
||||
|
|
@ -34,16 +38,15 @@ solfuse demo
|
|||
## Minimal Example
|
||||
|
||||
```python
|
||||
from solfuse.models import LocalProblem
|
||||
from solfuse.solver import ADMMLiteSolver
|
||||
from solfuse.bridge import FederationBridge
|
||||
from solfuse.adapters import SolarInverterAdapter, ThermalStorageAdapter
|
||||
|
||||
solver = ADMMLiteSolver(rho=1.0, tolerance=1e-6, max_iterations=100)
|
||||
result = solver.solve([
|
||||
LocalProblem(site_id="solar-a", preferred_dispatch=[4.0, 4.5], quadratic_weight=2.0),
|
||||
LocalProblem(site_id="storage-b", preferred_dispatch=[1.0, 1.5], quadratic_weight=1.5),
|
||||
])
|
||||
bridge = FederationBridge()
|
||||
solar = SolarInverterAdapter().to_local_problem({"site_id": "solar-a", "forecast_kw": [4.0, 4.5]}, round_id=1)
|
||||
thermal = ThermalStorageAdapter().to_local_problem({"site_id": "storage-b", "cooling_load_kw": [1.0, 1.5], "capacity_kw": 2.0}, round_id=1)
|
||||
result = bridge.solve_round([solar, thermal], round_id=1)
|
||||
|
||||
print(result.consensus)
|
||||
print(result.solver_result.consensus)
|
||||
```
|
||||
|
||||
## Governance Model
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"""SolFuse canonical federation primitives."""
|
||||
|
||||
from .delta_sync import DeltaSyncJournal
|
||||
from .bridge import FederationBridge, RoundSummary
|
||||
from .adapters import AdapterRuntime, SolarInverterAdapter, ThermalStorageAdapter
|
||||
from .governance import AuditLedger, PrivacyBudgetLedger
|
||||
from .identity import AgentIdentity, generate_identity, sign_message, verify_message
|
||||
from .models import (
|
||||
AdapterContract,
|
||||
AuditLogEntry,
|
||||
DualVariable,
|
||||
FederationSnapshot,
|
||||
LocalProblem,
|
||||
PlanAction,
|
||||
PlanDelta,
|
||||
|
|
@ -20,9 +23,12 @@ from .solver import ADMMLiteSolver
|
|||
__all__ = [
|
||||
"ADMMLiteSolver",
|
||||
"AdapterContract",
|
||||
"AdapterRuntime",
|
||||
"AgentIdentity",
|
||||
"AuditLedger",
|
||||
"AuditLogEntry",
|
||||
"FederationBridge",
|
||||
"FederationSnapshot",
|
||||
"DeltaSyncJournal",
|
||||
"DualVariable",
|
||||
"GraphOfContractsRegistry",
|
||||
|
|
@ -31,8 +37,11 @@ __all__ = [
|
|||
"PlanDelta",
|
||||
"PrivacyBudget",
|
||||
"PrivacyBudgetLedger",
|
||||
"RoundSummary",
|
||||
"SharedVariable",
|
||||
"SolverResult",
|
||||
"SolarInverterAdapter",
|
||||
"ThermalStorageAdapter",
|
||||
"generate_identity",
|
||||
"sign_message",
|
||||
"verify_message",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .models import AdapterContract, LocalProblem, PlanAction, PlanDelta, SolfuseModel
|
||||
|
||||
|
||||
class AdapterTelemetry(SolfuseModel):
|
||||
site_id: str
|
||||
round_id: int = Field(default=0, ge=0)
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class AdapterRuntime(ABC):
|
||||
contract: AdapterContract
|
||||
|
||||
@abstractmethod
|
||||
def to_local_problem(self, telemetry: dict[str, Any], *, round_id: int = 0) -> LocalProblem:
|
||||
raise NotImplementedError
|
||||
|
||||
def apply_plan(self, delta: PlanDelta) -> list[dict[str, Any]]:
|
||||
return [action.model_dump(mode="json") for action in delta.actions]
|
||||
|
||||
|
||||
class SolarInverterAdapter(AdapterRuntime):
|
||||
contract = AdapterContract(
|
||||
name="solar-inverter",
|
||||
version="1.0.0",
|
||||
adapter_type="inverter",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {"type": "string"},
|
||||
"forecast_kw": {"type": "array", "items": {"type": "number"}, "minItems": 1},
|
||||
"upper_bound_kw": {"type": "array", "items": {"type": "number"}, "minItems": 1},
|
||||
"lower_bound_kw": {"type": "array", "items": {"type": "number"}, "minItems": 1},
|
||||
},
|
||||
"required": ["site_id", "forecast_kw"],
|
||||
"additionalProperties": True,
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"site_id": {"type": "string"}, "dispatch_kw": {"type": "array", "items": {"type": "number"}}},
|
||||
"required": ["site_id", "dispatch_kw"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
capabilities=["dispatch", "telemetry", "forecasting"],
|
||||
)
|
||||
|
||||
def to_local_problem(self, telemetry: dict[str, Any], *, round_id: int = 0) -> LocalProblem:
|
||||
forecast = [float(value) for value in telemetry["forecast_kw"]]
|
||||
lower = telemetry.get("lower_bound_kw")
|
||||
upper = telemetry.get("upper_bound_kw")
|
||||
return LocalProblem(
|
||||
site_id=str(telemetry["site_id"]),
|
||||
preferred_dispatch=forecast,
|
||||
lower_bounds=None if lower is None else [float(value) for value in lower],
|
||||
upper_bounds=None if upper is None else [float(value) for value in upper],
|
||||
quadratic_weight=float(telemetry.get("quadratic_weight", 1.0)),
|
||||
round_id=round_id,
|
||||
metadata={"adapter": self.contract.name, "source": "solar"},
|
||||
)
|
||||
|
||||
|
||||
class ThermalStorageAdapter(AdapterRuntime):
|
||||
contract = AdapterContract(
|
||||
name="thermal-storage",
|
||||
version="1.0.0",
|
||||
adapter_type="thermal",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {"type": "string"},
|
||||
"cooling_load_kw": {"type": "array", "items": {"type": "number"}, "minItems": 1},
|
||||
"capacity_kw": {"type": "number"},
|
||||
"min_kw": {"type": "number"},
|
||||
"max_kw": {"type": "number"},
|
||||
},
|
||||
"required": ["site_id", "cooling_load_kw", "capacity_kw"],
|
||||
"additionalProperties": True,
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"site_id": {"type": "string"}, "dispatch_kw": {"type": "array", "items": {"type": "number"}}},
|
||||
"required": ["site_id", "dispatch_kw"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
capabilities=["dispatch", "thermal-storage", "demand-shaping"],
|
||||
)
|
||||
|
||||
def to_local_problem(self, telemetry: dict[str, Any], *, round_id: int = 0) -> LocalProblem:
|
||||
load = [float(value) for value in telemetry["cooling_load_kw"]]
|
||||
capacity = float(telemetry["capacity_kw"])
|
||||
min_kw = float(telemetry.get("min_kw", 0.0))
|
||||
max_kw = float(telemetry.get("max_kw", capacity))
|
||||
preferred = [min(max(value, min_kw), max_kw) for value in load]
|
||||
return LocalProblem(
|
||||
site_id=str(telemetry["site_id"]),
|
||||
preferred_dispatch=preferred,
|
||||
lower_bounds=[min_kw] * len(preferred),
|
||||
upper_bounds=[max_kw] * len(preferred),
|
||||
quadratic_weight=float(telemetry.get("quadratic_weight", 1.0)),
|
||||
round_id=round_id,
|
||||
metadata={"adapter": self.contract.name, "source": "thermal"},
|
||||
)
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .governance import AuditLedger, PrivacyBudgetLedger
|
||||
from .identity import AgentIdentity, sign_message
|
||||
from .delta_sync import DeltaSyncJournal
|
||||
from .models import DualVariable, FederationSnapshot, LocalProblem, SharedVariable
|
||||
from .solver import ADMMLiteSolver
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoundSummary:
|
||||
round_id: int
|
||||
consensus: list[float]
|
||||
delta_count: int
|
||||
|
||||
|
||||
class FederationBridge:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
solver: ADMMLiteSolver | None = None,
|
||||
journal: DeltaSyncJournal | None = None,
|
||||
audit_ledger: AuditLedger | None = None,
|
||||
privacy_ledger: PrivacyBudgetLedger | None = None,
|
||||
) -> None:
|
||||
self.solver = solver or ADMMLiteSolver()
|
||||
self.journal = journal
|
||||
self.audit_ledger = audit_ledger
|
||||
self.privacy_ledger = privacy_ledger
|
||||
|
||||
def solve_round(
|
||||
self,
|
||||
problems: list[LocalProblem],
|
||||
*,
|
||||
round_id: int,
|
||||
shared_variables: list[SharedVariable] | None = None,
|
||||
dual_variables: list[DualVariable] | None = None,
|
||||
actor_id: str = "federation",
|
||||
identity: AgentIdentity | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> FederationSnapshot:
|
||||
solver_result = self.solver.solve(
|
||||
problems,
|
||||
shared_variables=shared_variables,
|
||||
dual_variables=dual_variables,
|
||||
)
|
||||
snapshot = FederationSnapshot(
|
||||
round_id=round_id,
|
||||
local_problems=problems,
|
||||
shared_variables=shared_variables or [],
|
||||
dual_variables=dual_variables or [],
|
||||
solver_result=solver_result,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
if self.journal is not None:
|
||||
self.journal.merge(solver_result.deltas)
|
||||
|
||||
if self.privacy_ledger is not None and self.privacy_ledger.get_budget(actor_id) is not None:
|
||||
epsilon = 0.01 * max(1, len(solver_result.deltas))
|
||||
delta = 1e-8 * max(1, sum(len(delta.actions) for delta in solver_result.deltas))
|
||||
self.privacy_ledger.charge(actor_id, epsilon, delta)
|
||||
|
||||
if self.audit_ledger is not None:
|
||||
payload = snapshot.model_dump(mode="json")
|
||||
signature = sign_message(identity, payload) if identity is not None else None
|
||||
self.audit_ledger.append(
|
||||
self._build_audit_entry(
|
||||
actor_id=actor_id,
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
)
|
||||
)
|
||||
|
||||
return snapshot
|
||||
|
||||
def summarize_round(self, snapshot: FederationSnapshot) -> RoundSummary:
|
||||
return RoundSummary(
|
||||
round_id=snapshot.round_id,
|
||||
consensus=snapshot.solver_result.consensus,
|
||||
delta_count=len(snapshot.solver_result.deltas),
|
||||
)
|
||||
|
||||
def _build_audit_entry(self, *, actor_id: str, payload: dict[str, Any], signature: str | None):
|
||||
from .models import AuditLogEntry
|
||||
|
||||
return AuditLogEntry(
|
||||
actor_id=actor_id,
|
||||
event_type="federation_round",
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
)
|
||||
|
|
@ -3,6 +3,8 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
|
||||
from .adapters import SolarInverterAdapter, ThermalStorageAdapter
|
||||
from .bridge import FederationBridge
|
||||
from .models import LocalProblem
|
||||
from .solver import ADMMLiteSolver
|
||||
|
||||
|
|
@ -11,6 +13,7 @@ def main() -> None:
|
|||
parser = argparse.ArgumentParser(prog="solfuse")
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
subcommands.add_parser("demo")
|
||||
subcommands.add_parser("microgrid-demo")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "demo":
|
||||
|
|
@ -22,3 +25,15 @@ def main() -> None:
|
|||
]
|
||||
)
|
||||
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
||||
if args.command == "microgrid-demo":
|
||||
bridge = FederationBridge(solver=ADMMLiteSolver())
|
||||
solar = SolarInverterAdapter().to_local_problem(
|
||||
{"site_id": "solar-a", "forecast_kw": [4.0, 4.5], "upper_bound_kw": [5.0, 5.0]},
|
||||
round_id=1,
|
||||
)
|
||||
thermal = ThermalStorageAdapter().to_local_problem(
|
||||
{"site_id": "thermal-b", "cooling_load_kw": [1.0, 1.5], "capacity_kw": 2.0},
|
||||
round_id=1,
|
||||
)
|
||||
snapshot = bridge.solve_round([solar, thermal], round_id=1)
|
||||
print(json.dumps(snapshot.model_dump(mode="json"), indent=2))
|
||||
|
|
|
|||
|
|
@ -130,3 +130,12 @@ class SolverResult(SolfuseModel):
|
|||
dual_residual: float
|
||||
deltas: list[PlanDelta] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FederationSnapshot(SolfuseModel):
|
||||
round_id: int = Field(ge=0)
|
||||
local_problems: list[LocalProblem]
|
||||
shared_variables: list[SharedVariable] = Field(default_factory=list)
|
||||
dual_variables: list[DualVariable] = Field(default_factory=list)
|
||||
solver_result: SolverResult
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import math
|
|||
|
||||
import numpy as np
|
||||
|
||||
from .models import LocalProblem, PlanAction, PlanDelta, SolverResult
|
||||
from .models import DualVariable, LocalProblem, PlanAction, PlanDelta, SharedVariable, SolverResult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -19,11 +19,19 @@ class ADMMLiteSolver:
|
|||
def __init__(self, *, rho: float = 1.0, tolerance: float = 1e-6, max_iterations: int = 100) -> None:
|
||||
if rho <= 0:
|
||||
raise ValueError("rho must be positive")
|
||||
if max_iterations <= 0:
|
||||
raise ValueError("max_iterations must be positive")
|
||||
self.rho = rho
|
||||
self.tolerance = tolerance
|
||||
self.max_iterations = max_iterations
|
||||
|
||||
def solve(self, problems: list[LocalProblem]) -> SolverResult:
|
||||
def solve(
|
||||
self,
|
||||
problems: list[LocalProblem],
|
||||
*,
|
||||
shared_variables: list[SharedVariable] | None = None,
|
||||
dual_variables: list[DualVariable] | None = None,
|
||||
) -> SolverResult:
|
||||
if not problems:
|
||||
raise ValueError("at least one local problem is required")
|
||||
|
||||
|
|
@ -39,11 +47,21 @@ class ADMMLiteSolver:
|
|||
|
||||
x = preferred.copy()
|
||||
z = preferred.mean(axis=0)
|
||||
if shared_variables:
|
||||
candidate = np.asarray(shared_variables[0].values, dtype=float)
|
||||
if candidate.shape == (horizon,):
|
||||
z = candidate
|
||||
u = np.zeros_like(x)
|
||||
if dual_variables:
|
||||
candidate = np.asarray(dual_variables[0].values, dtype=float)
|
||||
if candidate.shape == (horizon,):
|
||||
u = np.broadcast_to(candidate, x.shape).copy()
|
||||
primal_residual = float("inf")
|
||||
dual_residual = float("inf")
|
||||
last_iteration = 0
|
||||
|
||||
for iteration in range(1, self.max_iterations + 1):
|
||||
for current_iteration in range(1, self.max_iterations + 1):
|
||||
last_iteration = current_iteration
|
||||
previous_z = z.copy()
|
||||
x = np.clip((weights * preferred + self.rho * (z - u)) / (weights + self.rho), lower, upper)
|
||||
z = np.mean(x + u, axis=0)
|
||||
|
|
@ -64,9 +82,9 @@ class ADMMLiteSolver:
|
|||
PlanDelta(
|
||||
site_id=problem.site_id,
|
||||
adapter_id=f"adapter:{problem.site_id}",
|
||||
revision=iteration,
|
||||
revision=last_iteration,
|
||||
round_id=problem.round_id,
|
||||
parent_revision=max(0, iteration - 1),
|
||||
parent_revision=max(0, last_iteration - 1),
|
||||
actions=actions,
|
||||
metadata={"solver": "admm-lite", "round_id": problem.round_id},
|
||||
)
|
||||
|
|
@ -74,7 +92,7 @@ class ADMMLiteSolver:
|
|||
|
||||
return SolverResult(
|
||||
consensus=[float(value) for value in z],
|
||||
iterations=iteration,
|
||||
iterations=last_iteration,
|
||||
primal_residual=primal_residual,
|
||||
dual_residual=dual_residual,
|
||||
deltas=deltas,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from solfuse import (
|
||||
AuditLedger,
|
||||
DeltaSyncJournal,
|
||||
FederationBridge,
|
||||
PrivacyBudget,
|
||||
PrivacyBudgetLedger,
|
||||
DualVariable,
|
||||
SolarInverterAdapter,
|
||||
ThermalStorageAdapter,
|
||||
SharedVariable,
|
||||
generate_identity,
|
||||
)
|
||||
|
||||
|
||||
def test_bridge_records_rounds_and_respects_governance():
|
||||
journal = DeltaSyncJournal()
|
||||
audit = AuditLedger()
|
||||
budgets = PrivacyBudgetLedger()
|
||||
budgets.set_budget("federation", PrivacyBudget(epsilon=1.0, delta=1e-5))
|
||||
bridge = FederationBridge(journal=journal, audit_ledger=audit, privacy_ledger=budgets)
|
||||
|
||||
solar = SolarInverterAdapter().to_local_problem({"site_id": "solar-a", "forecast_kw": [4.0, 4.5]}, round_id=3)
|
||||
thermal = ThermalStorageAdapter().to_local_problem(
|
||||
{"site_id": "thermal-b", "cooling_load_kw": [1.0, 1.5], "capacity_kw": 2.0},
|
||||
round_id=3,
|
||||
)
|
||||
shared = SharedVariable(name="forecast", version="1.0", values=[3.5, 3.5], round_id=3)
|
||||
dual = DualVariable(name="dispatch-dual", version="1.0", values=[0.0, 0.0], round_id=3)
|
||||
|
||||
snapshot = bridge.solve_round(
|
||||
[solar, thermal],
|
||||
round_id=3,
|
||||
shared_variables=[shared],
|
||||
dual_variables=[dual],
|
||||
identity=generate_identity("federation"),
|
||||
)
|
||||
|
||||
assert snapshot.round_id == 3
|
||||
assert snapshot.shared_variables[0].name == "forecast"
|
||||
assert snapshot.dual_variables[0].name == "dispatch-dual"
|
||||
assert snapshot.solver_result.deltas
|
||||
assert journal.load()
|
||||
assert audit.coverage() == 1.0
|
||||
updated_budget = budgets.get_budget("federation")
|
||||
assert updated_budget is not None
|
||||
assert updated_budget.spent_epsilon > 0
|
||||
|
||||
|
||||
def test_starter_adapters_generate_local_problems_and_plan_frames():
|
||||
solar = SolarInverterAdapter()
|
||||
thermal = ThermalStorageAdapter()
|
||||
|
||||
solar_problem = solar.to_local_problem({"site_id": "solar-a", "forecast_kw": [3.0, 3.5], "upper_bound_kw": [4.0, 4.0]})
|
||||
thermal_problem = thermal.to_local_problem({"site_id": "thermal-b", "cooling_load_kw": [1.0, 2.0], "capacity_kw": 2.5})
|
||||
|
||||
assert solar_problem.site_id == "solar-a"
|
||||
assert thermal_problem.upper_bounds == [2.5, 2.5]
|
||||
Loading…
Reference in New Issue