build(agent): semicolon#54de0b iteration

This commit is contained in:
agent-54de0bcc6a17828b 2026-04-24 21:07:06 +02:00
parent ec94d234be
commit 478a6db7db
14 changed files with 485 additions and 26 deletions

View File

@ -2,25 +2,27 @@ OpenEnergySphere EnergiBridge MVP
Overview Overview
- EnergiBridge provides a canonical, vendor-agnostic interoperability layer for cross-domain energy planning. It translates domain primitives into a compact IR (Object, SharedVariables, PlanDelta) and enables plug-and-play adapters across DERs, meters, pumps, and buildings while preserving offline-first operation and governance hooks. - EnergiBridge provides a canonical, vendor-agnostic interoperability layer for cross-domain energy planning. It translates domain primitives into a compact IR (Object, SharedVariables, PlanDelta) and enables plug-and-play adapters across DERs, meters, pumps, and buildings while preserving offline-first operation and governance hooks.
- The current codebase includes a signed governance ledger, deterministic delta-sync, a contract template registry, a lightweight marketplace listing flow, and optional SQLite persistence for the registry.
Architecture Architecture
- Core primitives: LocalProblem (per-site optimization), SharedVariables (signals and priors), PlanDelta (incremental actions with provenance), and a Graph-of-Contracts (GoC) registry for adapters and data schemas. - Core primitives: LocalProblem (per-site optimization), SharedVariables (signals and priors), PlanDelta (incremental actions with provenance), DeltaEnvelope (offline sync packaging), and a Graph-of-Contracts registry for adapters, templates, and data schemas.
- Governance: GovernanceLedger produces chained, HMAC-signed audit entries for approvals and policy changes.
- EnergiBridgeMapper (in src/energysphere/energi_bridge.py) maps Energysphere primitives to a canonical IR representation and supports round-tripping LocalProblem objects. - EnergiBridgeMapper (in src/energysphere/energi_bridge.py) maps Energysphere primitives to a canonical IR representation and supports round-tripping LocalProblem objects.
- Registry: In-memory GraphContractRegistry with metadata for auditing and replay protection. LocalProblems get stored with version, timestamp, and nonce metadata. - Registry: GraphContractRegistry supports in-memory operation and optional SQLite-backed persistence. LocalProblems get stored with version, timestamp, and nonce metadata.
- Adapters: Starter adapters implemented under energysphere/adapters/ (SubstationMeterAdapter, DERAggregatorAdapter). - Adapters: Starter adapters implemented under energysphere/adapters/ (SubstationMeterAdapter, DERAggregatorAdapter) and marketplace capability publishing.
- Server API: Lightweight FastAPI app in energysphere/server/main.py exposing contract and adapter endpoints. - Server API: FastAPI app in energysphere/server/main.py exposing contract, template, delta-sync, and governance endpoints.
Roadmap (MVP, 812 weeks) Roadmap
- Phase 0: Protocol skeleton, 2 starter adapters, TLS transport, ADMM-lite local solver, delta-sync with deterministic replay. - Phase 0: Protocol skeleton, 2 starter adapters, TLS transport, delta-sync with deterministic replay.
- Phase 1: Governance ledger scaffold, identity management (DIDs/certs), secure aggregation for SharedVariables. - Phase 1: Governance ledger scaffold, identity management (DIDs/certs), secure aggregation for SharedVariables.
- Phase 2: Cross-domain demo with toy cross-domain objective and EnergiBridge SDK. - Phase 2: Cross-domain demo with toy cross-domain objective and EnergiBridge SDK.
- Phase 3: Hardware-in-the-loop validation and KPI dashboards. - Phase 3: Hardware-in-the-loop validation and KPI dashboards.
Data Contracts Seeds Data Contracts Seeds
- LocalProblem, SharedVariables, DualVariables, PlanDelta, PrivacyBudget, AuditLog; a Graph-of-Contracts registry; minimal contract example and conformance harness for adapters. - LocalProblem, SharedVariables, DualVariables, PlanDelta, DeltaEnvelope, ContractTemplate, GovernanceEvent, PrivacyBudget, AuditLog; a Graph-of-Contracts registry; minimal contract example and conformance harness for adapters.
Security & Governance Security & Governance
- Hardware-backed attestation for adapters; DID/short-lived certs; attestation for plan deltas; optional local DP budgets. - Hardware-backed attestation for adapters; DID/short-lived certs; attestation for plan deltas; optional local DP budgets; append-only governance audit chain.
How to run (current MVP) How to run (current MVP)
- Install dependencies and run tests: - Install dependencies and run tests:
@ -31,3 +33,4 @@ How to run (current MVP)
Notes Notes
- This project uses a pyproject.toml with a proper build-system. Packaging metadata is wired to README.md via readme = "README.md" in pyproject.toml. - This project uses a pyproject.toml with a proper build-system. Packaging metadata is wired to README.md via readme = "README.md" in pyproject.toml.
- The goal is to keep a tight MVP surface while enabling gradual expansion into a production-grade, privacy-preserving federation platform. - The goal is to keep a tight MVP surface while enabling gradual expansion into a production-grade, privacy-preserving federation platform.
- Optional SQLite persistence can be enabled by constructing `GraphContractRegistry(db_path=...)`.

View File

@ -11,6 +11,7 @@ requires-python = ">=3.10"
dependencies = [ dependencies = [
"fastapi>=0.90.0", "fastapi>=0.90.0",
"httpx>=0.23.0",
"uvicorn[standard]>=0.18.0", "uvicorn[standard]>=0.18.0",
"pydantic>=1.10.2", "pydantic>=1.10.2",
"pytest>=7.0.0" "pytest>=7.0.0"
@ -18,4 +19,3 @@ dependencies = [
[tool.setuptools] [tool.setuptools]
package-dir = { "" = "src" } package-dir = { "" = "src" }

View File

@ -1,9 +1,38 @@
""" Energysphere core package minimal init. """ """EnergiBridge core package."""
__version__ = "0.1.0" __version__ = "0.1.0"
# Re-export common bridge utilities for quick access from .energi_bridge import EnergiBridgeMapper
try: from .governance import GovernanceLedger
from .energi_bridge import EnergiBridgeMapper # type: ignore from .models import (
except Exception: AdapterConformance,
# If optional or during early import stages, fail gracefully. AuditLog,
EnergiBridgeMapper = None # type: ignore ContractTemplate,
DeltaEnvelope,
DualVariables,
GovernanceEvent,
LocalProblem,
PlanDelta,
PrivacyBudget,
SharedVariable,
SharedVariables,
)
from .registry import GraphContractRegistry
from .sync import DeltaSyncEngine
__all__ = [
"AdapterConformance",
"AuditLog",
"ContractTemplate",
"DeltaEnvelope",
"DeltaSyncEngine",
"DualVariables",
"EnergiBridgeMapper",
"GovernanceEvent",
"GovernanceLedger",
"GraphContractRegistry",
"LocalProblem",
"PlanDelta",
"PrivacyBudget",
"SharedVariable",
"SharedVariables",
]

View File

@ -9,12 +9,24 @@ class DERAggregatorAdapter:
def __init__(self, registry: GraphContractRegistry) -> None: def __init__(self, registry: GraphContractRegistry) -> None:
self._registry = registry self._registry = registry
self.supported_domains = ["electricity", "distributed-energy-resources"]
def collect_local_problem(self) -> LocalProblem: def collect_local_problem(self) -> LocalProblem:
lp = LocalProblem( lp = LocalProblem(
site_id="site-der-agg-001", site_id="site-der-agg-001",
objective="min-carbon", objective="min-carbon",
parameters={"der_count": 5, "time_horizon": 24}, parameters={"der_count": 5, "time_horizon": 24},
domain="electricity",
) )
self._registry.register_local_problem("contract-der-aggregator-001", lp) self._registry.register_local_problem("contract-der-aggregator-001", lp)
return lp return lp
def publish_capability(self) -> dict[str, object]:
listing = {
"adapter_id": self.adapter_id,
"name": "DER Aggregator Adapter",
"domains": self.supported_domains,
"contracts": ["contract-der-aggregator-001"],
}
self._registry.register_marketplace_entry(self.adapter_id, listing)
return listing

View File

@ -1,10 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict
from energysphere.models import LocalProblem from energysphere.models import LocalProblem
from energysphere.registry import GraphContractRegistry from energysphere.registry import GraphContractRegistry
from energysphere.models import LocalProblem
class SubstationMeterAdapter: class SubstationMeterAdapter:
@ -14,6 +11,7 @@ class SubstationMeterAdapter:
def __init__(self, registry: GraphContractRegistry) -> None: def __init__(self, registry: GraphContractRegistry) -> None:
self._registry = registry self._registry = registry
self.supported_domains = ["electricity"]
def collect_local_problem(self) -> LocalProblem: def collect_local_problem(self) -> LocalProblem:
# Minimal sample LocalProblem; in a real system this would read from meters. # Minimal sample LocalProblem; in a real system this would read from meters.
@ -21,7 +19,18 @@ class SubstationMeterAdapter:
site_id="site-meter-001", site_id="site-meter-001",
objective="min-cost", objective="min-cost",
parameters={"load": 1.0, "time_horizon": 24}, parameters={"load": 1.0, "time_horizon": 24},
domain="electricity",
) )
# register a reference contract for this adapter's generated problem # register a reference contract for this adapter's generated problem
self._registry.register_local_problem("contract-substation-meter-001", lp) self._registry.register_local_problem("contract-substation-meter-001", lp)
return lp return lp
def publish_capability(self) -> dict[str, object]:
listing = {
"adapter_id": self.adapter_id,
"name": "Substation Meter Adapter",
"domains": self.supported_domains,
"contracts": ["contract-substation-meter-001"],
}
self._registry.register_marketplace_entry(self.adapter_id, listing)
return listing

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from typing import Any
def model_to_dict(model: Any) -> dict[str, Any]:
if hasattr(model, "model_dump"):
return model.model_dump()
return model.dict()

View File

@ -60,6 +60,7 @@ class EnergiBridgeMapper:
"value": getattr(sv, "value", None), "value": getattr(sv, "value", None),
"version": getattr(sv, "version", 1), "version": getattr(sv, "version", 1),
"privacy_bound": getattr(sv, "privacy_bound", None), "privacy_bound": getattr(sv, "privacy_bound", None),
"source_contract_id": getattr(sv, "source_contract_id", None),
} }
def map_plan_delta(self, delta: Any) -> Dict[str, Any]: # be resilient to different shapes def map_plan_delta(self, delta: Any) -> Dict[str, Any]: # be resilient to different shapes
@ -67,10 +68,14 @@ class EnergiBridgeMapper:
return { return {
"type": "PlanDelta", "type": "PlanDelta",
"delta_id": getattr(delta, "delta_id", ""), "delta_id": getattr(delta, "delta_id", ""),
"contract_id": getattr(delta, "contract_id", None),
"changes": getattr(delta, "changes", {}), "changes": getattr(delta, "changes", {}),
"timestamp": getattr(delta, "timestamp", 0.0), "timestamp": getattr(delta, "timestamp", 0.0),
# Prefer an explicit author field if present; otherwise fall back to delta_id # Prefer an explicit author field if present; otherwise fall back to delta_id
"author": getattr(delta, "author", getattr(delta, "delta_id", "")), "author": getattr(delta, "author", getattr(delta, "delta_id", "")),
"provenance": getattr(delta, "provenance", {}),
"privacy_budget": getattr(delta, "privacy_budget", None),
"signature": getattr(delta, "signature", None),
} }

View File

@ -0,0 +1,101 @@
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
import time
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class LedgerEntry(BaseModel):
event_id: str
event_type: str
actor: str
timestamp: float
payload: Dict[str, Any] = Field(default_factory=dict)
previous_hash: Optional[str] = None
entry_hash: str
signature: str
class GovernanceLedger:
"""Append-only governance ledger with chained hashes and HMAC signatures."""
def __init__(self, signing_secret: str | None = None) -> None:
self._signing_secret = signing_secret or secrets.token_hex(32)
self._entries: List[LedgerEntry] = []
@property
def signing_secret(self) -> str:
return self._signing_secret
def append_event(
self,
event_type: str,
actor: str,
payload: Dict[str, Any] | None = None,
*,
timestamp: float | None = None,
event_id: str | None = None,
) -> LedgerEntry:
payload = payload or {}
timestamp = time.time() if timestamp is None else timestamp
event_id = event_id or secrets.token_hex(12)
previous_hash = self._entries[-1].entry_hash if self._entries else None
canonical = {
"event_id": event_id,
"event_type": event_type,
"actor": actor,
"timestamp": timestamp,
"payload": payload,
"previous_hash": previous_hash,
}
entry_hash = self._hash(canonical)
signature = self._sign(entry_hash)
entry = LedgerEntry(
event_id=event_id,
event_type=event_type,
actor=actor,
timestamp=timestamp,
payload=payload,
previous_hash=previous_hash,
entry_hash=entry_hash,
signature=signature,
)
self._entries.append(entry)
return entry
def list_entries(self) -> List[LedgerEntry]:
return list(self._entries)
def verify_chain(self) -> bool:
previous_hash = None
for entry in self._entries:
canonical = {
"event_id": entry.event_id,
"event_type": entry.event_type,
"actor": entry.actor,
"timestamp": entry.timestamp,
"payload": entry.payload,
"previous_hash": previous_hash,
}
expected_hash = self._hash(canonical)
if expected_hash != entry.entry_hash:
return False
if self._sign(expected_hash) != entry.signature:
return False
previous_hash = entry.entry_hash
return True
def _hash(self, payload: Dict[str, Any]) -> str:
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
def _sign(self, entry_hash: str) -> str:
return hmac.new(self._signing_secret.encode("utf-8"), entry_hash.encode("utf-8"), hashlib.sha256).hexdigest()
__all__ = ["GovernanceLedger", "LedgerEntry"]

View File

@ -1,13 +1,16 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from pydantic import BaseModel, Field
class LocalProblem(BaseModel): class LocalProblem(BaseModel):
site_id: str site_id: str
objective: str objective: str
parameters: Dict[str, Any] parameters: Dict[str, Any]
domain: str = "electricity"
contract_id: Optional[str] = None
class SharedVariables(BaseModel): class SharedVariables(BaseModel):
@ -15,22 +18,41 @@ class SharedVariables(BaseModel):
value: Any value: Any
version: int version: int
privacy_bound: Optional[float] = None privacy_bound: Optional[float] = None
source_contract_id: Optional[str] = None
SharedVariable = SharedVariables
class DualVariables(BaseModel): class DualVariables(BaseModel):
primal: float primal: float
dual: float dual: float
step: Optional[int] = None
class PlanDelta(BaseModel): class PlanDelta(BaseModel):
delta_id: str delta_id: str
contract_id: Optional[str] = None
changes: Dict[str, Any] changes: Dict[str, Any]
timestamp: float timestamp: float
author: Optional[str] = None
provenance: Dict[str, Any] = Field(default_factory=dict)
privacy_budget: Optional[float] = None
signature: Optional[str] = None
class DeltaEnvelope(BaseModel):
replica_id: str
sequence: int
plan_delta: PlanDelta
previous_hash: Optional[str] = None
entry_hash: Optional[str] = None
class PrivacyBudget(BaseModel): class PrivacyBudget(BaseModel):
budget: float budget: float
used: float used: float
scope: Optional[str] = None
class AuditLog(BaseModel): class AuditLog(BaseModel):
@ -38,3 +60,32 @@ class AuditLog(BaseModel):
action: str action: str
timestamp: float timestamp: float
actor: str actor: str
result: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
class ContractTemplate(BaseModel):
template_id: str
name: str
domain: str
schema_version: int = 1
description: Optional[str] = None
fields: Dict[str, str] = Field(default_factory=dict)
class GovernanceEvent(BaseModel):
event_id: str
event_type: str
actor: str
timestamp: float
payload: Dict[str, Any] = Field(default_factory=dict)
previous_hash: Optional[str] = None
entry_hash: Optional[str] = None
signature: Optional[str] = None
class AdapterConformance(BaseModel):
adapter_id: str
contract_id: str
status: str
notes: List[str] = Field(default_factory=list)

View File

@ -1,10 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json
import sqlite3
import time import time
import secrets import secrets
from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
from .models import LocalProblem from .compat import model_to_dict
from .models import AdapterConformance, ContractTemplate, LocalProblem, PlanDelta
class GraphContractRegistry: class GraphContractRegistry:
@ -14,8 +18,16 @@ class GraphContractRegistry:
a persistent registry backed by SQLite/PostgreSQL in the future. a persistent registry backed by SQLite/PostgreSQL in the future.
""" """
def __init__(self) -> None: def __init__(self, db_path: str | None = None) -> None:
self._contracts: Dict[str, Dict[str, Any]] = {} self._contracts: Dict[str, Dict[str, Any]] = {}
self._templates: Dict[str, Dict[str, Any]] = {}
self._deltas: Dict[str, Dict[str, Any]] = {}
self._marketplace: Dict[str, Dict[str, Any]] = {}
self._db_path = Path(db_path) if db_path else None
self._conn = sqlite3.connect(self._db_path) if self._db_path else None
if self._conn is not None:
self._conn.row_factory = sqlite3.Row
self._init_db()
def register_contract(self, contract_id: str, contract_spec: Dict[str, Any]) -> None: def register_contract(self, contract_id: str, contract_spec: Dict[str, Any]) -> None:
"""Register a contract specification with optional metadata. """Register a contract specification with optional metadata.
@ -25,6 +37,7 @@ class GraphContractRegistry:
contract_spec as-is for backward-compatibility. contract_spec as-is for backward-compatibility.
""" """
self._contracts[contract_id] = contract_spec self._contracts[contract_id] = contract_spec
self._persist("contracts", contract_id, contract_spec)
def register_contract_with_meta( def register_contract_with_meta(
self, self,
@ -49,6 +62,7 @@ class GraphContractRegistry:
contract_spec = dict(contract_spec) # shallow copy contract_spec = dict(contract_spec) # shallow copy
contract_spec["_meta"] = meta contract_spec["_meta"] = meta
self._contracts[contract_id] = contract_spec self._contracts[contract_id] = contract_spec
self._persist("contracts", contract_id, contract_spec)
def list_contracts(self) -> List[Dict[str, Any]]: def list_contracts(self) -> List[Dict[str, Any]]:
return list(self._contracts.values()) return list(self._contracts.values())
@ -56,13 +70,71 @@ class GraphContractRegistry:
def get_contract(self, contract_id: str) -> Dict[str, Any] | None: def get_contract(self, contract_id: str) -> Dict[str, Any] | None:
return self._contracts.get(contract_id) return self._contracts.get(contract_id)
def register_contract_template(self, template_id: str, template: ContractTemplate | Dict[str, Any]) -> None:
template_dict = model_to_dict(template) if isinstance(template, ContractTemplate) else dict(template)
self._templates[template_id] = template_dict
self._persist("templates", template_id, template_dict)
def list_contract_templates(self) -> List[Dict[str, Any]]:
return list(self._templates.values())
def register_plan_delta(self, delta: PlanDelta) -> None:
delta_dict = model_to_dict(delta)
self._deltas[delta.delta_id] = delta_dict
self._persist("deltas", delta.delta_id, delta_dict)
def list_plan_deltas(self) -> List[Dict[str, Any]]:
return list(self._deltas.values())
def register_marketplace_entry(self, entry_id: str, entry: Dict[str, Any]) -> None:
self._marketplace[entry_id] = dict(entry)
self._persist("marketplace", entry_id, entry)
def list_marketplace_entries(self) -> List[Dict[str, Any]]:
return list(self._marketplace.values())
def conformance_report(self, adapter_id: str, contract_ids: List[str]) -> AdapterConformance:
missing = [contract_id for contract_id in contract_ids if contract_id not in self._contracts]
status = "pass" if not missing else "partial"
notes = [f"missing:{contract_id}" for contract_id in missing]
return AdapterConformance(adapter_id=adapter_id, contract_id=",".join(contract_ids), status=status, notes=notes)
# Lightweight helper to store a LocalProblem as a contract reference # Lightweight helper to store a LocalProblem as a contract reference
def register_local_problem(self, contract_id: str, lp: LocalProblem) -> None: def register_local_problem(self, contract_id: str, lp: LocalProblem) -> None:
"""Register a LocalProblem with default metadata.""" """Register a LocalProblem with default metadata."""
spec = lp.dict() spec = model_to_dict(lp)
spec["_meta"] = { spec["_meta"] = {
"version": 1, "version": 1,
"timestamp": time.time(), "timestamp": time.time(),
"nonce": secrets.token_hex(8), "nonce": secrets.token_hex(8),
} }
self.register_contract(contract_id, spec) self.register_contract(contract_id, spec)
def _init_db(self) -> None:
assert self._conn is not None
self._conn.execute(
"CREATE TABLE IF NOT EXISTS registry_entries (bucket TEXT NOT NULL, entry_id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (bucket, entry_id))"
)
self._conn.commit()
cursor = self._conn.execute("SELECT bucket, entry_id, payload FROM registry_entries")
for row in cursor:
payload = json.loads(row["payload"])
bucket = row["bucket"]
entry_id = row["entry_id"]
if bucket == "contracts":
self._contracts[entry_id] = payload
elif bucket == "templates":
self._templates[entry_id] = payload
elif bucket == "deltas":
self._deltas[entry_id] = payload
elif bucket == "marketplace":
self._marketplace[entry_id] = payload
def _persist(self, bucket: str, entry_id: str, payload: Dict[str, Any]) -> None:
if self._conn is None:
return
self._conn.execute(
"INSERT OR REPLACE INTO registry_entries(bucket, entry_id, payload) VALUES (?, ?, ?)",
(bucket, entry_id, json.dumps(payload, sort_keys=True)),
)
self._conn.commit()

View File

@ -3,14 +3,19 @@ from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from pydantic import BaseModel from pydantic import BaseModel
from energysphere.compat import model_to_dict
from energysphere.governance import GovernanceLedger
from energysphere.models import ContractTemplate, LocalProblem, PlanDelta
from energysphere.registry import GraphContractRegistry from energysphere.registry import GraphContractRegistry
from energysphere.models import LocalProblem from energysphere.sync import DeltaSyncEngine
app = FastAPI(title="EnergiBridge MVP API") app = FastAPI(title="EnergiBridge MVP API")
# Simple in-memory stores # Simple in-memory stores
registry = GraphContractRegistry() registry = GraphContractRegistry()
adapters_registry: dict[str, dict[str, str]] = {} adapters_registry: dict[str, dict[str, str]] = {}
ledger = GovernanceLedger()
sync_engine = DeltaSyncEngine(replica_id="api")
class LocalProblemDTO(BaseModel): class LocalProblemDTO(BaseModel):
@ -41,3 +46,49 @@ class AdapterDTO(BaseModel):
def register_adapter(dto: AdapterDTO): def register_adapter(dto: AdapterDTO):
adapters_registry[dto.adapter_id] = {"description": dto.description or ""} adapters_registry[dto.adapter_id] = {"description": dto.description or ""}
return {"registered": True, "adapter_id": dto.adapter_id} return {"registered": True, "adapter_id": dto.adapter_id}
@app.post("/contracts/templates")
def register_contract_template(template: ContractTemplate):
registry.register_contract_template(template.template_id, template)
return {"registered": True, "template_id": template.template_id}
@app.get("/contracts/templates")
def list_contract_templates():
return registry.list_contract_templates()
@app.post("/deltas")
def register_plan_delta(delta: PlanDelta):
registry.register_plan_delta(delta)
envelope = sync_engine.issue_delta(delta)
return {"registered": True, "delta": model_to_dict(delta), "envelope": model_to_dict(envelope)}
class DeltaBatchDTO(BaseModel):
deltas: list[PlanDelta]
@app.post("/sync/reconcile")
def reconcile_deltas(batch: DeltaBatchDTO):
envelopes = [sync_engine.issue_delta(delta) for delta in batch.deltas]
merged = sync_engine.reconcile(envelopes)
return {"merged": [model_to_dict(entry) for entry in merged]}
class LedgerEventDTO(BaseModel):
event_type: str
actor: str
payload: dict[str, object] | None = None
@app.post("/governance/events")
def append_governance_event(event: LedgerEventDTO):
entry = ledger.append_event(event.event_type, event.actor, event.payload)
return {"entry": model_to_dict(entry), "valid": ledger.verify_chain()}
@app.get("/governance/events")
def list_governance_events():
return [model_to_dict(entry) for entry in ledger.list_entries()]

63
src/energysphere/sync.py Normal file
View File

@ -0,0 +1,63 @@
from __future__ import annotations
import hashlib
import json
from typing import Iterable, List
from .compat import model_to_dict
from .models import DeltaEnvelope, PlanDelta
class DeltaSyncEngine:
"""Deterministic reconciliation for offline-first plan deltas."""
def __init__(self, replica_id: str) -> None:
self.replica_id = replica_id
self._sequence = 0
self._history: list[DeltaEnvelope] = []
def issue_delta(self, plan_delta: PlanDelta, previous_hash: str | None = None) -> DeltaEnvelope:
self._sequence += 1
envelope = DeltaEnvelope(
replica_id=self.replica_id,
sequence=self._sequence,
plan_delta=plan_delta,
previous_hash=previous_hash,
)
envelope.entry_hash = self._hash_envelope(envelope)
self._history.append(envelope)
return envelope
def reconcile(self, envelopes: Iterable[DeltaEnvelope]) -> list[DeltaEnvelope]:
merged: dict[str, DeltaEnvelope] = {}
for envelope in envelopes:
key = envelope.plan_delta.delta_id
existing = merged.get(key)
if existing is None or self._rank(envelope) > self._rank(existing):
if envelope.entry_hash is None:
envelope.entry_hash = self._hash_envelope(envelope)
merged[key] = envelope
return sorted(merged.values(), key=self._rank)
def history(self) -> list[DeltaEnvelope]:
return list(self._history)
def _rank(self, envelope: DeltaEnvelope) -> tuple[int, float, str]:
return (
envelope.sequence,
envelope.plan_delta.timestamp,
envelope.plan_delta.delta_id,
)
def _hash_envelope(self, envelope: DeltaEnvelope) -> str:
payload = {
"replica_id": envelope.replica_id,
"sequence": envelope.sequence,
"plan_delta": model_to_dict(envelope.plan_delta),
"previous_hash": envelope.previous_hash,
}
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
__all__ = ["DeltaSyncEngine"]

View File

@ -3,7 +3,7 @@ set -euo pipefail
export PYTHONPATH=./src export PYTHONPATH=./src
echo "Installing dependencies..." echo "Installing dependencies..."
pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
pip install "pydantic>=1.10.2" "fastapi>=0.90.0" "uvicorn[standard]>=0.18.0" "pytest>=7.0.0" >/dev/null 2>&1 || true pip install "pydantic>=1.10.2" "fastapi>=0.90.0" "httpx>=0.23.0" "uvicorn[standard]>=0.18.0" "pytest>=7.0.0" >/dev/null 2>&1 || true
echo "Running tests..." echo "Running tests..."
pytest -q pytest -q
echo "Running packaging build..." echo "Running packaging build..."

View File

@ -0,0 +1,54 @@
from energysphere.governance import GovernanceLedger
from energysphere.models import ContractTemplate, PlanDelta
from energysphere.registry import GraphContractRegistry
from energysphere.server.main import app
from energysphere.sync import DeltaSyncEngine
from fastapi.testclient import TestClient
def test_governance_ledger_is_chained_and_verified():
ledger = GovernanceLedger(signing_secret="secret")
ledger.append_event("policy.approved", "alice", {"policy_id": "p-1"})
ledger.append_event("policy.updated", "bob", {"policy_id": "p-1"})
assert ledger.verify_chain() is True
assert len(ledger.list_entries()) == 2
def test_delta_sync_deduplicates_by_delta_id():
engine = DeltaSyncEngine(replica_id="site-a")
first = engine.issue_delta(PlanDelta(delta_id="d-1", changes={"a": 1}, timestamp=1.0))
second = engine.issue_delta(PlanDelta(delta_id="d-1", changes={"a": 2}, timestamp=2.0))
merged = engine.reconcile([first, second])
assert len(merged) == 1
assert merged[0].plan_delta.changes == {"a": 2}
def test_registry_and_api_support_templates_and_ledger():
registry = GraphContractRegistry()
template = ContractTemplate(
template_id="tmpl-1",
name="meter-template",
domain="electricity",
fields={"site_id": "string", "load": "number"},
)
registry.register_contract_template(template.template_id, template)
report = registry.conformance_report("adapter-1", ["missing-contract"])
assert registry.list_contract_templates()[0]["template_id"] == "tmpl-1"
assert report.status == "partial"
assert "missing:missing-contract" in report.notes
client = TestClient(app)
response = client.post(
"/governance/events",
json={"event_type": "policy.approved", "actor": "alice", "payload": {"policy_id": "p-2"}},
)
assert response.status_code == 200
body = response.json()
assert body["valid"] is True
assert body["entry"]["event_type"] == "policy.approved"