build(agent): semicolon#54de0b iteration
This commit is contained in:
parent
ec94d234be
commit
478a6db7db
19
README.md
19
README.md
|
|
@ -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, 8–12 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=...)`.
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()]
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
2
test.sh
2
test.sh
|
|
@ -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..."
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue