build(agent): semicolon#54de0b iteration

This commit is contained in:
agent-54de0bcc6a17828b 2026-04-24 18:20:49 +02:00
parent e56595c658
commit 3ffc0f6dd1
13 changed files with 708 additions and 2 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
node_modules/
.npmrc
.env
.env.*
__tests__/
coverage/
.nyc_output/
dist/
build/
.cache/
*.log
.DS_Store
tmp/
.tmp/
__pycache__/
*.pyc
.venv/
venv/
*.egg-info/
.pytest_cache/
READY_TO_PUBLISH

31
AGENTS.md Normal file
View File

@ -0,0 +1,31 @@
# AGENTS.md
## Architecture
This repository contains `idea154-civicmesh-studio-federated`, a Python package for federated urban resilience coordination.
Core modules:
- `contracts.py`: canonical Pydantic contracts for `LocalModel`, `SharedSignals`, `PlanDelta`, and `DualVariable`.
- `registry.py`: Graph-of-Contracts adapter registry plus starter GIS, weather, and pump adapters.
- `sync.py`: deterministic delta log with audit hashes and replay.
- `solver.py`: lightweight ADMM-style coordinator for cross-neighborhood plan updates.
## Tech Stack
- Python 3.11+
- `pydantic` for validation and canonical serialization
- `pytest` for tests
- `setuptools` build backend
## Rules
- Keep contracts canonical and deterministic.
- Prefer small, explicit changes over broad abstractions.
- Preserve reproducible serialization and replay behavior.
- Add or update tests when changing contract shapes, adapter behavior, or solver math.
## Testing
- `bash test.sh`
- `python3 -m pytest`
- `python3 -m build`

View File

@ -1,3 +1,42 @@
# idea154-civicmesh-studio-federated # CivicMesh Studio Federated
Source logic for Idea #154 CivicMesh Studio Federated is a Python package for coordinating neighborhood-scale resilience planning without requiring full data centralization.
It provides three core building blocks:
- Canonical contracts for `LocalModel`, `SharedSignals`, `PlanDelta`, and `DualVariable`
- A Graph-of-Contracts registry for adapter compatibility and versioning
- A deterministic delta-sync log and a lightweight ADMM-style coordination solver
## What it does
The package models each neighborhood as a local optimization problem with its own assets, limits, and signals. Neighborhoods can exchange compact summaries instead of raw operational data, then coordinate incremental plan updates under a shared district constraint.
Included starter adapters:
- `gis-layer`: turns simple GeoJSON-like feature collections into a `LocalModel`
- `weather-forecast`: converts weather feed summaries into `SharedSignals`
- `water-pump-controller`: emits `PlanDelta` updates from pump telemetry
## Repository layout
- `src/idea154_civicmesh_studio_federated/`
- `tests/`
- `test.sh`
- `AGENTS.md`
## How to run
```bash
bash test.sh
```
## Packaging
The published project name is `idea154-civicmesh-studio-federated`.
## Notes for contributors
- Keep serialized outputs deterministic.
- Keep adapter input/output contracts explicit.
- Expand tests when changing solver behavior or contract fields.

25
pyproject.toml Normal file
View File

@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "idea154-civicmesh-studio-federated"
version = "0.1.0"
description = "Federated urban resilience planning primitives for cross-neighborhood coordination."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"pydantic>=2.7,<3",
]
[project.optional-dependencies]
test = [
"build>=1.2",
"pytest>=8",
]
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -0,0 +1,27 @@
"""CivicMesh Studio federated resilience primitives."""
from .contracts import DualVariable, LocalModel, PlanDelta, SharedSignals
from .registry import (
GISLayerAdapter,
GraphOfContractsRegistry,
WaterPumpControllerAdapter,
WeatherForecastAdapter,
)
from .solver import FederatedResilienceSolver, NeighborhoodProblem, SolverResult
from .sync import DeltaSyncLog, SyncRecord
__all__ = [
"DualVariable",
"LocalModel",
"PlanDelta",
"SharedSignals",
"GISLayerAdapter",
"GraphOfContractsRegistry",
"WaterPumpControllerAdapter",
"WeatherForecastAdapter",
"FederatedResilienceSolver",
"NeighborhoodProblem",
"SolverResult",
"DeltaSyncLog",
"SyncRecord",
]

View File

@ -0,0 +1,90 @@
"""Canonical data contracts for CivicMesh Studio."""
from __future__ import annotations
from hashlib import sha256
import json
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
def _canonical_json(model: BaseModel) -> str:
return json.dumps(model.model_dump(mode="json"), sort_keys=True, separators=(",", ":"), ensure_ascii=True)
class Asset(BaseModel):
model_config = ConfigDict(frozen=True)
asset_id: str
asset_type: str
neighborhood_id: str
capacity_kw: float = 0.0
capacity_m3: float = 0.0
service_priority: float = 1.0
metadata: dict[str, Any] = Field(default_factory=dict)
class LocalModel(BaseModel):
model_config = ConfigDict(frozen=True)
neighborhood_id: str
version: str = "1.0"
assets: list[Asset] = Field(default_factory=list)
energy_limit_kw: float = 0.0
water_limit_m3: float = 0.0
objective_weights: dict[str, float] = Field(default_factory=lambda: {"service_disruption": 1.0, "energy_use": 1.0})
def canonical_json(self) -> str:
return _canonical_json(self)
def content_hash(self) -> str:
return sha256(self.canonical_json().encode("utf-8")).hexdigest()
class SharedSignals(BaseModel):
model_config = ConfigDict(frozen=True)
neighborhood_id: str
source: str
horizon_hours: int = 1
forecast_demand_kw: float = 0.0
forecast_water_m3: float = 0.0
available_capacity_kw: float = 0.0
available_capacity_m3: float = 0.0
weather_risk: float = 0.0
delay_steps: int = 0
def canonical_json(self) -> str:
return _canonical_json(self)
def content_hash(self) -> str:
return sha256(self.canonical_json().encode("utf-8")).hexdigest()
class PlanDelta(BaseModel):
model_config = ConfigDict(frozen=True)
neighborhood_id: str
iteration: int = 0
actions: dict[str, float] = Field(default_factory=dict)
notes: str = ""
affected_assets: list[str] = Field(default_factory=list)
def canonical_json(self) -> str:
return _canonical_json(self)
def content_hash(self) -> str:
return sha256(self.canonical_json().encode("utf-8")).hexdigest()
class DualVariable(BaseModel):
model_config = ConfigDict(frozen=True)
neighborhood_id: str
value: float = 0.0
version: int = 0
def bump(self, delta: float) -> "DualVariable":
return self.model_copy(update={"value": self.value + delta, "version": self.version + 1})

View File

@ -0,0 +1,145 @@
"""Graph-of-Contracts adapter registry and starter adapters."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from pydantic import BaseModel, ConfigDict, Field
from .contracts import Asset, LocalModel, PlanDelta, SharedSignals
class AdapterContext(BaseModel):
model_config = ConfigDict(frozen=True)
neighborhood_id: str
metadata: dict[str, Any] = Field(default_factory=dict)
@dataclass(frozen=True)
class ContractSpec:
name: str
version: str
input_contract: str
output_contract: str
description: str
class ContractAdapter(Protocol):
spec: ContractSpec
def adapt(self, payload: dict[str, Any], context: AdapterContext) -> BaseModel: ...
class GraphOfContractsRegistry:
def __init__(self) -> None:
self._adapters: dict[str, ContractAdapter] = {}
def register(self, adapter: ContractAdapter) -> None:
if adapter.spec.name in self._adapters:
raise ValueError(f"adapter already registered: {adapter.spec.name}")
self._adapters[adapter.spec.name] = adapter
def get(self, name: str) -> ContractAdapter:
return self._adapters[name]
def route(self, name: str, payload: dict[str, Any], context: AdapterContext) -> BaseModel:
adapter = self.get(name)
return adapter.adapt(payload, context)
def compatibility_report(self) -> list[dict[str, str]]:
return [
{
"adapter": adapter.spec.name,
"input_contract": adapter.spec.input_contract,
"output_contract": adapter.spec.output_contract,
"version": adapter.spec.version,
}
for adapter in self._adapters.values()
]
class GISLayerAdapter:
spec = ContractSpec(
name="gis-layer",
version="1.0",
input_contract="GeoJSONFeatureCollection",
output_contract="LocalModel",
description="Translate a GIS feature collection into a local neighborhood asset model.",
)
def adapt(self, payload: dict[str, Any], context: AdapterContext) -> LocalModel:
features = payload.get("features", [])
assets = []
for index, feature in enumerate(features):
properties = feature.get("properties", {})
geometry = feature.get("geometry", {})
asset_type = properties.get("asset_type", "unknown")
assets.append(
Asset(
asset_id=properties.get("id", f"{context.neighborhood_id}-asset-{index}"),
asset_type=asset_type,
neighborhood_id=context.neighborhood_id,
capacity_kw=float(properties.get("capacity_kw", 0.0)),
capacity_m3=float(properties.get("capacity_m3", 0.0)),
service_priority=float(properties.get("service_priority", 1.0)),
metadata={"geometry": geometry, "source": "gis"},
)
)
return LocalModel(
neighborhood_id=context.neighborhood_id,
assets=assets,
energy_limit_kw=float(payload.get("energy_limit_kw", 0.0)),
water_limit_m3=float(payload.get("water_limit_m3", 0.0)),
)
class WeatherForecastAdapter:
spec = ContractSpec(
name="weather-forecast",
version="1.0",
input_contract="WeatherForecast",
output_contract="SharedSignals",
description="Convert weather feed summaries into shared resilience signals.",
)
def adapt(self, payload: dict[str, Any], context: AdapterContext) -> SharedSignals:
severity = float(payload.get("precipitation_mm", 0.0)) + abs(float(payload.get("temperature_c", 0.0)) - 20.0) * 0.1
return SharedSignals(
neighborhood_id=context.neighborhood_id,
source=str(payload.get("source", "weather")),
horizon_hours=int(payload.get("horizon_hours", 1)),
forecast_demand_kw=float(payload.get("forecast_demand_kw", 0.0)),
forecast_water_m3=float(payload.get("forecast_water_m3", 0.0)),
available_capacity_kw=float(payload.get("available_capacity_kw", 0.0)),
available_capacity_m3=float(payload.get("available_capacity_m3", 0.0)),
weather_risk=severity,
delay_steps=int(payload.get("delay_steps", 0)),
)
class WaterPumpControllerAdapter:
spec = ContractSpec(
name="water-pump-controller",
version="1.0",
input_contract="PumpTelemetry",
output_contract="PlanDelta",
description="Create incremental pump dispatch actions from telemetry.",
)
def adapt(self, payload: dict[str, Any], context: AdapterContext) -> PlanDelta:
pressure = float(payload.get("pressure_bar", 0.0))
level = float(payload.get("reservoir_level_m", 0.0))
demand = float(payload.get("demand_m3", 0.0))
action = max(0.0, demand - level)
if pressure < 2.0:
action *= 1.25
return PlanDelta(
neighborhood_id=context.neighborhood_id,
iteration=int(payload.get("iteration", 0)),
actions={"pump_dispatch_m3": round(action, 3), "pressure_support_kw": round(max(0.0, 2.0 - pressure), 3)},
notes="Generated from pump telemetry",
affected_assets=[str(payload.get("asset_id", "pump-0"))],
)

View File

@ -0,0 +1,124 @@
"""Lightweight ADMM-style federated coordination solver."""
from __future__ import annotations
from dataclasses import dataclass
from math import sqrt
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from .contracts import DualVariable, LocalModel, PlanDelta, SharedSignals
class NeighborhoodProblem(BaseModel):
model_config = ConfigDict(frozen=True)
local_model: LocalModel
signals: SharedSignals
dual: DualVariable = Field(default_factory=lambda: DualVariable(neighborhood_id="unknown"))
class SolverResult(BaseModel):
model_config = ConfigDict(frozen=True)
iterations: int
primal_residual: float
dual_residual: float
objective_value: float
deltas: list[PlanDelta]
history: list[dict[str, Any]]
def _project_nonnegative_simplex(values: list[float], limit: float) -> list[float]:
clipped = [max(0.0, value) for value in values]
total = sum(clipped)
if total <= limit or total == 0.0:
return clipped
scale = limit / total
return [value * scale for value in clipped]
def _norm(values: list[float]) -> float:
return sqrt(sum(value * value for value in values))
def _local_target(problem: NeighborhoodProblem) -> float:
demand_gap = max(0.0, problem.signals.forecast_demand_kw - problem.signals.available_capacity_kw)
weather_penalty = problem.signals.weather_risk * 0.5
return demand_gap + weather_penalty
@dataclass
class FederatedResilienceSolver:
problems: list[NeighborhoodProblem]
district_energy_limit_kw: float
rho: float = 1.0
max_iterations: int = 25
tolerance: float = 1e-4
delay_steps: int = 0
def solve(self) -> SolverResult:
if not self.problems:
raise ValueError("at least one neighborhood problem is required")
targets = [_local_target(problem) for problem in self.problems]
local = [0.0 for _ in self.problems]
consensus = [0.0 for _ in self.problems]
duals = [problem.dual.value for problem in self.problems]
history: list[dict[str, Any]] = []
for iteration in range(1, self.max_iterations + 1):
stale_index = max(0, iteration - 1 - self.delay_steps)
stale_consensus = history[stale_index]["consensus"] if history and stale_index < len(history) else consensus
previous_consensus = consensus
for index, problem in enumerate(self.problems):
weight = max(1.0, problem.local_model.objective_weights.get("service_disruption", 1.0))
numerator = weight * targets[index] + self.rho * (stale_consensus[index] - duals[index])
denominator = weight + self.rho
upper_bound = max(problem.local_model.energy_limit_kw, 0.0) or max(targets[index], 1.0)
local[index] = min(upper_bound, max(0.0, numerator / denominator))
consensus = _project_nonnegative_simplex([value + dual for value, dual in zip(local, duals)], self.district_energy_limit_kw)
for index in range(len(duals)):
duals[index] += local[index] - consensus[index]
primal_residual = _norm([local_value - consensus_value for local_value, consensus_value in zip(local, consensus)])
dual_residual = _norm([current - previous for current, previous in zip(consensus, previous_consensus)]) if history else _norm(consensus)
objective_value = sum(0.5 * (value - target) ** 2 for value, target in zip(consensus, targets))
history.append(
{
"iteration": iteration,
"local": list(local),
"consensus": list(consensus),
"duals": list(duals),
"primal_residual": primal_residual,
"dual_residual": dual_residual,
"objective_value": objective_value,
}
)
if primal_residual <= self.tolerance and dual_residual <= self.tolerance:
break
deltas = []
for index, problem in enumerate(self.problems):
action = consensus[index]
deltas.append(
PlanDelta(
neighborhood_id=problem.local_model.neighborhood_id,
iteration=len(history),
actions={"load_shift_kw": round(action, 6), "energy_budget_kw": round(max(0.0, problem.local_model.energy_limit_kw - action), 6)},
notes="Consensus plan from federated solver",
affected_assets=[asset.asset_id for asset in problem.local_model.assets],
)
)
return SolverResult(
iterations=len(history),
primal_residual=history[-1]["primal_residual"],
dual_residual=history[-1]["dual_residual"],
objective_value=history[-1]["objective_value"],
deltas=deltas,
history=history,
)

View File

@ -0,0 +1,82 @@
"""Deterministic delta sync with replay and auditability."""
from __future__ import annotations
from hashlib import sha256
import json
from typing import Any, Callable
from pydantic import BaseModel, ConfigDict
from .contracts import PlanDelta
class SyncRecord(BaseModel):
model_config = ConfigDict(frozen=True)
sequence: int
payload_type: str
payload: dict[str, Any]
payload_hash: str
previous_hash: str
record_hash: str
def _canonical_payload(payload: dict[str, Any]) -> str:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
class DeltaSyncLog:
def __init__(self) -> None:
self._records: list[SyncRecord] = []
def append(self, delta: PlanDelta) -> SyncRecord:
payload = delta.model_dump(mode="json")
payload_hash = sha256(_canonical_payload(payload).encode("utf-8")).hexdigest()
previous_hash = self._records[-1].record_hash if self._records else "0" * 64
sequence = len(self._records) + 1
record_hash = sha256(f"{sequence}:{payload_hash}:{previous_hash}".encode("utf-8")).hexdigest()
record = SyncRecord(
sequence=sequence,
payload_type=delta.__class__.__name__,
payload=payload,
payload_hash=payload_hash,
previous_hash=previous_hash,
record_hash=record_hash,
)
self._records.append(record)
return record
def export_since(self, sequence: int) -> list[SyncRecord]:
return [record for record in self._records if record.sequence > sequence]
def verify(self) -> bool:
previous_hash = "0" * 64
for expected_sequence, record in enumerate(self._records, start=1):
if record.sequence != expected_sequence:
return False
payload_hash = sha256(_canonical_payload(record.payload).encode("utf-8")).hexdigest()
if payload_hash != record.payload_hash:
return False
if previous_hash != record.previous_hash:
return False
candidate_hash = sha256(f"{record.sequence}:{record.payload_hash}:{record.previous_hash}".encode("utf-8")).hexdigest()
if candidate_hash != record.record_hash:
return False
previous_hash = record.record_hash
return True
def replay(self, applier: Callable[[PlanDelta], Any] | None = None) -> list[PlanDelta]:
if not self.verify():
raise ValueError("sync log failed verification")
replayed: list[PlanDelta] = []
for record in self._records:
delta = PlanDelta.model_validate(record.payload)
replayed.append(delta)
if applier is not None:
applier(delta)
return replayed
def snapshot(self) -> list[SyncRecord]:
return list(self._records)

6
test.sh Normal file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
python3 -m pip install -e ".[test]" build
python3 -m pytest
python3 -m build

27
tests/test_contracts.py Normal file
View File

@ -0,0 +1,27 @@
from idea154_civicmesh_studio_federated.contracts import Asset, LocalModel, PlanDelta, SharedSignals
def test_contract_hashes_are_stable():
model = LocalModel(
neighborhood_id="north",
assets=[Asset(asset_id="light-1", asset_type="street-light", neighborhood_id="north", capacity_kw=1.2)],
energy_limit_kw=10.0,
)
same_model = LocalModel(
neighborhood_id="north",
assets=[Asset(asset_id="light-1", asset_type="street-light", neighborhood_id="north", capacity_kw=1.2)],
energy_limit_kw=10.0,
)
assert model.content_hash() == same_model.content_hash()
def test_plan_delta_serializes_canonically():
delta = PlanDelta(neighborhood_id="south", iteration=2, actions={"pump_dispatch_m3": 4.5}, notes="ok")
assert '"pump_dispatch_m3":4.5' in delta.canonical_json()
def test_shared_signals_round_trip():
signals = SharedSignals(neighborhood_id="east", source="weather", forecast_demand_kw=5.0, available_capacity_kw=3.0)
assert signals.model_dump()["neighborhood_id"] == "east"

36
tests/test_registry.py Normal file
View File

@ -0,0 +1,36 @@
from idea154_civicmesh_studio_federated.registry import (
AdapterContext,
GISLayerAdapter,
GraphOfContractsRegistry,
WaterPumpControllerAdapter,
WeatherForecastAdapter,
)
def test_registry_routes_adapters():
registry = GraphOfContractsRegistry()
registry.register(GISLayerAdapter())
registry.register(WeatherForecastAdapter())
registry.register(WaterPumpControllerAdapter())
local_model = registry.route(
"gis-layer",
{
"energy_limit_kw": 12.0,
"features": [
{"properties": {"id": "light-1", "asset_type": "street-light", "capacity_kw": 1.0}},
{"properties": {"id": "pump-2", "asset_type": "pump", "capacity_m3": 3.5}},
],
},
AdapterContext(neighborhood_id="n-1"),
)
assert local_model.neighborhood_id == "n-1"
assert len(local_model.assets) == 2
def test_registry_compatibility_report_contains_versions():
registry = GraphOfContractsRegistry()
registry.register(GISLayerAdapter())
report = registry.compatibility_report()
assert report[0]["version"] == "1.0"

View File

@ -0,0 +1,53 @@
from idea154_civicmesh_studio_federated.contracts import Asset, DualVariable, LocalModel, PlanDelta, SharedSignals
from idea154_civicmesh_studio_federated.solver import FederatedResilienceSolver, NeighborhoodProblem
from idea154_civicmesh_studio_federated.sync import DeltaSyncLog
def _problem(neighborhood_id: str, demand: float, capacity: float, energy_limit: float) -> NeighborhoodProblem:
return NeighborhoodProblem(
local_model=LocalModel(
neighborhood_id=neighborhood_id,
assets=[Asset(asset_id=f"{neighborhood_id}-asset", asset_type="microgrid", neighborhood_id=neighborhood_id, capacity_kw=capacity)],
energy_limit_kw=energy_limit,
),
signals=SharedSignals(
neighborhood_id=neighborhood_id,
source="weather",
forecast_demand_kw=demand,
available_capacity_kw=capacity,
weather_risk=2.0,
),
dual=DualVariable(neighborhood_id=neighborhood_id),
)
def test_delta_sync_replays_deterministically():
log = DeltaSyncLog()
first = log.append(PlanDelta(neighborhood_id="a", iteration=1, actions={"load_shift_kw": 2.5}, notes="alpha"))
second = log.append(PlanDelta(neighborhood_id="b", iteration=1, actions={"load_shift_kw": 1.5}, notes="beta"))
assert first.sequence == 1
assert second.sequence == 2
assert log.verify()
replayed = log.replay()
assert len(replayed) == 2
assert replayed[0].neighborhood_id == "a"
def test_solver_converges_under_district_limit():
solver = FederatedResilienceSolver(
problems=[
_problem("north", 8.0, 2.0, 6.0),
_problem("south", 7.0, 1.0, 5.0),
],
district_energy_limit_kw=6.0,
max_iterations=50,
tolerance=1e-5,
delay_steps=1,
)
result = solver.solve()
assert result.iterations >= 1
assert sum(delta.actions["load_shift_kw"] for delta in result.deltas) <= 6.000001
assert result.primal_residual < 1.0