build(agent): semicolon#54de0b iteration

This commit is contained in:
agent-54de0bcc6a17828b 2026-04-24 18:45:55 +02:00
parent 434601d06d
commit 4d5024f8e5
8 changed files with 330 additions and 14 deletions

View File

@ -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 - 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 - A SQLite-backed graph-of-contracts registry for adapter versioning and schema conformance
- An ADMM-lite solver for federated dispatch planning - 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 - Signed message envelopes and DID-style identities for short-lived trust
- Audit logging and privacy-budget accounting - Audit logging and privacy-budget accounting
- Delta-sync journaling for islanded/offline operation - 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.identity`: identity generation, signing, and verification
- `solfuse.registry`: adapter registry and schema conformance checks - `solfuse.registry`: adapter registry and schema conformance checks
- `solfuse.solver`: federated consensus solver - `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.delta_sync`: incremental plan journal and replay helpers
- `solfuse.governance`: audit log and privacy budget ledger - `solfuse.governance`: audit log and privacy budget ledger
- `solfuse.transport`: canonical envelope serialization and TLS context helpers - `solfuse.transport`: canonical envelope serialization and TLS context helpers
@ -34,16 +38,15 @@ solfuse demo
## Minimal Example ## Minimal Example
```python ```python
from solfuse.models import LocalProblem from solfuse.bridge import FederationBridge
from solfuse.solver import ADMMLiteSolver from solfuse.adapters import SolarInverterAdapter, ThermalStorageAdapter
solver = ADMMLiteSolver(rho=1.0, tolerance=1e-6, max_iterations=100) bridge = FederationBridge()
result = solver.solve([ solar = SolarInverterAdapter().to_local_problem({"site_id": "solar-a", "forecast_kw": [4.0, 4.5]}, round_id=1)
LocalProblem(site_id="solar-a", preferred_dispatch=[4.0, 4.5], quadratic_weight=2.0), thermal = ThermalStorageAdapter().to_local_problem({"site_id": "storage-b", "cooling_load_kw": [1.0, 1.5], "capacity_kw": 2.0}, round_id=1)
LocalProblem(site_id="storage-b", preferred_dispatch=[1.0, 1.5], quadratic_weight=1.5), result = bridge.solve_round([solar, thermal], round_id=1)
])
print(result.consensus) print(result.solver_result.consensus)
``` ```
## Governance Model ## Governance Model

View File

@ -1,12 +1,15 @@
"""SolFuse canonical federation primitives.""" """SolFuse canonical federation primitives."""
from .delta_sync import DeltaSyncJournal from .delta_sync import DeltaSyncJournal
from .bridge import FederationBridge, RoundSummary
from .adapters import AdapterRuntime, SolarInverterAdapter, ThermalStorageAdapter
from .governance import AuditLedger, PrivacyBudgetLedger from .governance import AuditLedger, PrivacyBudgetLedger
from .identity import AgentIdentity, generate_identity, sign_message, verify_message from .identity import AgentIdentity, generate_identity, sign_message, verify_message
from .models import ( from .models import (
AdapterContract, AdapterContract,
AuditLogEntry, AuditLogEntry,
DualVariable, DualVariable,
FederationSnapshot,
LocalProblem, LocalProblem,
PlanAction, PlanAction,
PlanDelta, PlanDelta,
@ -20,9 +23,12 @@ from .solver import ADMMLiteSolver
__all__ = [ __all__ = [
"ADMMLiteSolver", "ADMMLiteSolver",
"AdapterContract", "AdapterContract",
"AdapterRuntime",
"AgentIdentity", "AgentIdentity",
"AuditLedger", "AuditLedger",
"AuditLogEntry", "AuditLogEntry",
"FederationBridge",
"FederationSnapshot",
"DeltaSyncJournal", "DeltaSyncJournal",
"DualVariable", "DualVariable",
"GraphOfContractsRegistry", "GraphOfContractsRegistry",
@ -31,8 +37,11 @@ __all__ = [
"PlanDelta", "PlanDelta",
"PrivacyBudget", "PrivacyBudget",
"PrivacyBudgetLedger", "PrivacyBudgetLedger",
"RoundSummary",
"SharedVariable", "SharedVariable",
"SolverResult", "SolverResult",
"SolarInverterAdapter",
"ThermalStorageAdapter",
"generate_identity", "generate_identity",
"sign_message", "sign_message",
"verify_message", "verify_message",

108
src/solfuse/adapters.py Normal file
View File

@ -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"},
)

95
src/solfuse/bridge.py Normal file
View File

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

View File

@ -3,6 +3,8 @@ from __future__ import annotations
import argparse import argparse
import json import json
from .adapters import SolarInverterAdapter, ThermalStorageAdapter
from .bridge import FederationBridge
from .models import LocalProblem from .models import LocalProblem
from .solver import ADMMLiteSolver from .solver import ADMMLiteSolver
@ -11,6 +13,7 @@ def main() -> None:
parser = argparse.ArgumentParser(prog="solfuse") parser = argparse.ArgumentParser(prog="solfuse")
subcommands = parser.add_subparsers(dest="command", required=True) subcommands = parser.add_subparsers(dest="command", required=True)
subcommands.add_parser("demo") subcommands.add_parser("demo")
subcommands.add_parser("microgrid-demo")
args = parser.parse_args() args = parser.parse_args()
if args.command == "demo": if args.command == "demo":
@ -22,3 +25,15 @@ def main() -> None:
] ]
) )
print(json.dumps(result.model_dump(mode="json"), indent=2)) 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))

View File

@ -130,3 +130,12 @@ class SolverResult(SolfuseModel):
dual_residual: float dual_residual: float
deltas: list[PlanDelta] = Field(default_factory=list) deltas: list[PlanDelta] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict) 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)

View File

@ -5,7 +5,7 @@ import math
import numpy as np import numpy as np
from .models import LocalProblem, PlanAction, PlanDelta, SolverResult from .models import DualVariable, LocalProblem, PlanAction, PlanDelta, SharedVariable, SolverResult
@dataclass(frozen=True) @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: def __init__(self, *, rho: float = 1.0, tolerance: float = 1e-6, max_iterations: int = 100) -> None:
if rho <= 0: if rho <= 0:
raise ValueError("rho must be positive") raise ValueError("rho must be positive")
if max_iterations <= 0:
raise ValueError("max_iterations must be positive")
self.rho = rho self.rho = rho
self.tolerance = tolerance self.tolerance = tolerance
self.max_iterations = max_iterations 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: if not problems:
raise ValueError("at least one local problem is required") raise ValueError("at least one local problem is required")
@ -39,11 +47,21 @@ class ADMMLiteSolver:
x = preferred.copy() x = preferred.copy()
z = preferred.mean(axis=0) 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) 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") primal_residual = float("inf")
dual_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() previous_z = z.copy()
x = np.clip((weights * preferred + self.rho * (z - u)) / (weights + self.rho), lower, upper) x = np.clip((weights * preferred + self.rho * (z - u)) / (weights + self.rho), lower, upper)
z = np.mean(x + u, axis=0) z = np.mean(x + u, axis=0)
@ -64,9 +82,9 @@ class ADMMLiteSolver:
PlanDelta( PlanDelta(
site_id=problem.site_id, site_id=problem.site_id,
adapter_id=f"adapter:{problem.site_id}", adapter_id=f"adapter:{problem.site_id}",
revision=iteration, revision=last_iteration,
round_id=problem.round_id, round_id=problem.round_id,
parent_revision=max(0, iteration - 1), parent_revision=max(0, last_iteration - 1),
actions=actions, actions=actions,
metadata={"solver": "admm-lite", "round_id": problem.round_id}, metadata={"solver": "admm-lite", "round_id": problem.round_id},
) )
@ -74,7 +92,7 @@ class ADMMLiteSolver:
return SolverResult( return SolverResult(
consensus=[float(value) for value in z], consensus=[float(value) for value in z],
iterations=iteration, iterations=last_iteration,
primal_residual=primal_residual, primal_residual=primal_residual,
dual_residual=dual_residual, dual_residual=dual_residual,
deltas=deltas, deltas=deltas,

View File

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