From a155f6a0ce2918ed450d222820d6ec1db4cad70f Mon Sep 17 00:00:00 2001 From: agent-b883b4bc188823a2 Date: Sun, 26 Apr 2026 22:17:03 +0200 Subject: [PATCH] build(agent): c3po#b883b4 iteration --- .gitignore | 21 ++ AGENTS.md | 30 +++ README.md | 47 +++- pyproject.toml | 22 ++ .../__init__.py | 28 +++ .../__main__.py | 13 ++ .../canonical.py | 44 ++++ .../demo.py | 42 ++++ .../ledger.py | 201 ++++++++++++++++++ .../models.py | 105 +++++++++ .../solver.py | 103 +++++++++ .../utils.py | 32 +++ test.sh | 7 + tests/test_agrimesh.py | 96 +++++++++ 14 files changed, 789 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 pyproject.toml create mode 100644 src/idea170_agrimesh_federated_privacy/__init__.py create mode 100644 src/idea170_agrimesh_federated_privacy/__main__.py create mode 100644 src/idea170_agrimesh_federated_privacy/canonical.py create mode 100644 src/idea170_agrimesh_federated_privacy/demo.py create mode 100644 src/idea170_agrimesh_federated_privacy/ledger.py create mode 100644 src/idea170_agrimesh_federated_privacy/models.py create mode 100644 src/idea170_agrimesh_federated_privacy/solver.py create mode 100644 src/idea170_agrimesh_federated_privacy/utils.py create mode 100644 test.sh create mode 100644 tests/test_agrimesh.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5590b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a59870e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# AGENTS.md + +## Architecture + +- Python 3.11 package under `src/idea170_agrimesh_federated_privacy`. +- SQLite-backed governance ledger for farms, adapters, contracts, deltas, and sync state. +- Pydantic models validate local problems, shared signals, plan deltas, and adapter contracts. +- `solver.py` contains the ADMM-lite allocator for cross-farm irrigation planning. +- `canonical.py` maps AgriMesh primitives into a CatOpt-style intermediate representation. +- `demo.py` assembles a drought scenario for 2 to 3 farms. + +## Tech Stack + +- Standard library: `sqlite3`, `json`, `hashlib`, `hmac`, `math`, `random`, `dataclasses`, `pathlib`. +- External dependency: `pydantic` for strict data validation. +- Tests: `pytest`. + +## Rules + +- Keep changes minimal and deterministic. +- Preserve SQLite schema compatibility unless a migration is explicitly added. +- Any new public data model should have validation and at least one test. +- Preserve canonical JSON ordering when hashing or signing payloads. +- Do not bypass the solver or ledger abstractions with ad hoc state. + +## Testing + +- `bash test.sh` +- `pytest -q` +- `python3 -m build` diff --git a/README.md b/README.md index 60ce892..0ed3ecc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,46 @@ -# idea170-agrimesh-federated-privacy +# AgriMesh Federated Privacy -Source logic for Idea #170 \ No newline at end of file +AgriMesh is a Python package for privacy-preserving coordination across neighboring farms. It models local irrigation and resource constraints, exchanges aggregated signals, records tamper-evident plan deltas in SQLite, and produces coordinated irrigation plans for drought conditions. + +## What It Includes + +- Pydantic models for farms, local problems, shared signals, regional constraints, adapters, and plan deltas. +- A SQLite governance ledger for farms, contracts, adapters, events, and offline delta sync. +- An ADMM-lite irrigation allocator that respects farm budgets and regional water and energy caps. +- A canonical mapping layer that converts AgriMesh inputs into a CatOpt-style intermediate representation. +- A drought demo for 2 to 3 farms. + +## Package Layout + +- `src/idea170_agrimesh_federated_privacy/models.py` - validated data models. +- `src/idea170_agrimesh_federated_privacy/ledger.py` - SQLite governance ledger. +- `src/idea170_agrimesh_federated_privacy/solver.py` - cross-farm allocator. +- `src/idea170_agrimesh_federated_privacy/canonical.py` - CatOpt mapping. +- `src/idea170_agrimesh_federated_privacy/demo.py` - drought scenario builder. + +## Quick Start + +```python +from idea170_agrimesh_federated_privacy import build_drought_demo + +result = build_drought_demo() +for delta in result.deltas: + print(delta.farm_id, delta.irrigation_liters) +``` + +```bash +bash test.sh +python3 -m idea170_agrimesh_federated_privacy +``` + +## Testing + +The repository is wired for: + +- `bash test.sh` +- `pytest -q` +- `python3 -m build` + +## Packaging + +This project is published as `idea170-agrimesh-federated-privacy` and uses `README.md` as its package description source. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7a840cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea170-agrimesh-federated-privacy" +version = "0.1.0" +description = "AgriMesh federated, privacy-preserving cross-farm resource optimization platform" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "pydantic>=2.7,<3", +] + +[project.optional-dependencies] +test = ["pytest>=8,<9"] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/idea170_agrimesh_federated_privacy/__init__.py b/src/idea170_agrimesh_federated_privacy/__init__.py new file mode 100644 index 0000000..c1184da --- /dev/null +++ b/src/idea170_agrimesh_federated_privacy/__init__.py @@ -0,0 +1,28 @@ +"""AgriMesh federated optimization primitives.""" + +from .canonical import AgriMeshCanonicalMapper, CatOptProblem +from .demo import build_drought_demo +from .ledger import GovernanceLedger +from .models import ( + AdapterSpec, + FarmIdentity, + LocalProblem, + PlanDelta, + RegionalConstraint, + SharedSignals, +) +from .solver import solve_cross_farm_plan + +__all__ = [ + "AgriMeshCanonicalMapper", + "AdapterSpec", + "CatOptProblem", + "FarmIdentity", + "GovernanceLedger", + "LocalProblem", + "PlanDelta", + "RegionalConstraint", + "SharedSignals", + "build_drought_demo", + "solve_cross_farm_plan", +] diff --git a/src/idea170_agrimesh_federated_privacy/__main__.py b/src/idea170_agrimesh_federated_privacy/__main__.py new file mode 100644 index 0000000..9906686 --- /dev/null +++ b/src/idea170_agrimesh_federated_privacy/__main__.py @@ -0,0 +1,13 @@ +from .demo import build_drought_demo + + +def main() -> None: + result = build_drought_demo() + for delta in result.deltas: + print(delta.model_dump_json()) + print(f"total_irrigation_liters={result.total_irrigation_liters}") + print(f"total_energy_kwh={result.total_energy_kwh}") + + +if __name__ == "__main__": + main() diff --git a/src/idea170_agrimesh_federated_privacy/canonical.py b/src/idea170_agrimesh_federated_privacy/canonical.py new file mode 100644 index 0000000..91c0d85 --- /dev/null +++ b/src/idea170_agrimesh_federated_privacy/canonical.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from .models import CatOptConstraint, CatOptProblem, CatOptVariable, LocalProblem, PlanDelta, RegionalConstraint + + +class AgriMeshCanonicalMapper: + def to_catopt(self, local_problem: LocalProblem, regional_constraint: RegionalConstraint) -> CatOptProblem: + need = max(local_problem.target_soil_moisture_pct - local_problem.current_soil_moisture_pct, 0.0) + return CatOptProblem( + problem_id=f"{local_problem.farm_id}:{regional_constraint.regional_water_quota_id}", + variables=[ + CatOptVariable( + name="irrigation_liters", + lower=0.0, + upper=local_problem.irrigation_budget_liters, + target=min(local_problem.irrigation_budget_liters, need * local_problem.plot_area_hectares * 10.0), + ) + ], + constraints=[ + CatOptConstraint( + name="water_quota", + expression=f"irrigation_liters <= {regional_constraint.water_quota_liters}", + ), + CatOptConstraint( + name="energy_cap", + expression=f"energy_kwh <= {regional_constraint.energy_cap_kwh}", + ), + ], + objective="minimize squared deviation from target soil moisture", + metadata={ + "farm_id": local_problem.farm_id, + "drought_target_moisture_pct": regional_constraint.drought_target_moisture_pct, + "plot_area_hectares": local_problem.plot_area_hectares, + }, + ) + + def from_plan_delta(self, delta: PlanDelta) -> dict[str, float | str]: + return { + "farm_id": delta.farm_id, + "irrigation_liters": delta.irrigation_liters, + "fertilizer_kg": delta.fertilizer_kg, + "energy_kwh": delta.energy_kwh, + "tag": delta.tag, + } diff --git a/src/idea170_agrimesh_federated_privacy/demo.py b/src/idea170_agrimesh_federated_privacy/demo.py new file mode 100644 index 0000000..19688bf --- /dev/null +++ b/src/idea170_agrimesh_federated_privacy/demo.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from .models import LocalProblem, RegionalConstraint, SharedSignals +from .solver import SolveResult, solve_cross_farm_plan + + +def build_drought_demo() -> SolveResult: + farms = [ + LocalProblem( + farm_id="farm-a", + current_soil_moisture_pct=18.0, + target_soil_moisture_pct=31.0, + irrigation_budget_liters=3200.0, + energy_budget_kwh=90.0, + plot_area_hectares=18.0, + crop_priority=1.0, + fertilizer_cap_kg=30.0, + ), + LocalProblem( + farm_id="farm-b", + current_soil_moisture_pct=22.0, + target_soil_moisture_pct=30.0, + irrigation_budget_liters=2800.0, + energy_budget_kwh=80.0, + plot_area_hectares=14.0, + crop_priority=0.9, + fertilizer_cap_kg=25.0, + ), + LocalProblem( + farm_id="farm-c", + current_soil_moisture_pct=26.0, + target_soil_moisture_pct=32.0, + irrigation_budget_liters=2400.0, + energy_budget_kwh=75.0, + plot_area_hectares=12.0, + crop_priority=0.8, + fertilizer_cap_kg=20.0, + ), + ] + regional = RegionalConstraint(water_quota_liters=5200.0, drought_target_moisture_pct=30.0, energy_cap_kwh=170.0) + signals = SharedSignals(mean_soil_moisture_pct=22.0, drought_pressure=0.78, forecast_rain_mm=1.5, local_dp_epsilon=1.0) + return solve_cross_farm_plan(farms, regional, signals) diff --git a/src/idea170_agrimesh_federated_privacy/ledger.py b/src/idea170_agrimesh_federated_privacy/ledger.py new file mode 100644 index 0000000..45767ff --- /dev/null +++ b/src/idea170_agrimesh_federated_privacy/ledger.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from typing import Any, Iterable + +from .models import AdapterSpec, FarmIdentity, GovernanceEvent, PlanDelta +from .utils import canonical_json, sha256_hex + + +class GovernanceLedger: + def __init__(self, path: str | Path = ":memory:") -> None: + self.path = str(path) + self._conn = sqlite3.connect(self.path) + self._conn.row_factory = sqlite3.Row + self._ensure_schema() + + def close(self) -> None: + self._conn.close() + + def _ensure_schema(self) -> None: + self._conn.executescript( + """ + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS farms ( + farm_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + public_key TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS adapters ( + adapter_id TEXT PRIMARY KEY, + farm_id TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL, + endpoint TEXT NOT NULL, + capabilities_json TEXT NOT NULL, + metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(farm_id) REFERENCES farms(farm_id) + ); + CREATE TABLE IF NOT EXISTS events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS plan_deltas ( + delta_id TEXT PRIMARY KEY, + farm_id TEXT NOT NULL, + step_index INTEGER NOT NULL, + payload_json TEXT NOT NULL, + previous_tag TEXT NOT NULL, + tag TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(farm_id) REFERENCES farms(farm_id) + ); + CREATE TABLE IF NOT EXISTS sync_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + delta_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + delivered_at TEXT, + FOREIGN KEY(delta_id) REFERENCES plan_deltas(delta_id) + ); + CREATE TABLE IF NOT EXISTS contracts ( + contract_id TEXT PRIMARY KEY, + farm_id TEXT NOT NULL, + adapter_id TEXT NOT NULL, + contract_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + content_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(farm_id) REFERENCES farms(farm_id), + FOREIGN KEY(adapter_id) REFERENCES adapters(adapter_id) + ); + """ + ) + self._conn.commit() + + def register_farm(self, identity: FarmIdentity) -> None: + self._conn.execute( + "INSERT OR REPLACE INTO farms (farm_id, name, public_key, created_at) VALUES (?, ?, ?, ?)", + (identity.farm_id, identity.name, identity.public_key, identity.created_at.isoformat()), + ) + self._conn.commit() + + def upsert_adapter(self, adapter: AdapterSpec) -> None: + self._conn.execute( + """ + INSERT OR REPLACE INTO adapters + (adapter_id, farm_id, name, kind, endpoint, capabilities_json, metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + adapter.adapter_id, + adapter.farm_id, + adapter.name, + adapter.kind, + adapter.endpoint, + canonical_json(adapter.capabilities), + canonical_json(adapter.metadata), + adapter.created_at.isoformat(), + adapter.updated_at.isoformat(), + ), + ) + self._conn.commit() + + def record_event(self, event: GovernanceEvent) -> None: + self._conn.execute( + "INSERT OR REPLACE INTO events (event_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?)", + (str(event.event_id), event.event_type, canonical_json(event.payload), event.created_at.isoformat()), + ) + self._conn.commit() + + def record_plan_delta(self, delta: PlanDelta, secret: str) -> PlanDelta: + payload = delta.model_dump(mode="json", exclude={"tag"}) + tag = delta.tag or self._compute_tag(delta.previous_tag, payload, secret) + tagged = delta.model_copy(update={"tag": tag}) + self._conn.execute( + """ + INSERT OR REPLACE INTO plan_deltas + (delta_id, farm_id, step_index, payload_json, previous_tag, tag, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + str(tagged.delta_id), + tagged.farm_id, + tagged.step_index, + canonical_json(tagged.model_dump(mode="json")), + tagged.previous_tag, + tagged.tag, + tagged.created_at.isoformat(), + ), + ) + self._conn.execute( + "INSERT INTO sync_outbox (delta_id, payload_json, created_at) VALUES (?, ?, ?)", + (str(tagged.delta_id), canonical_json(tagged.model_dump(mode="json")), tagged.created_at.isoformat()), + ) + self._conn.commit() + return tagged + + def queue_delta(self, delta: PlanDelta) -> None: + self._conn.execute( + "INSERT INTO sync_outbox (delta_id, payload_json, created_at) VALUES (?, ?, ?)", + (str(delta.delta_id), canonical_json(delta.model_dump(mode="json")), delta.created_at.isoformat()), + ) + self._conn.commit() + + def pending_deltas(self) -> list[dict[str, Any]]: + rows = self._conn.execute( + "SELECT delta_id, payload_json, created_at FROM sync_outbox WHERE delivered_at IS NULL ORDER BY id ASC" + ).fetchall() + return [dict(row) for row in rows] + + def mark_delivered(self, delta_id: str) -> None: + self._conn.execute( + "UPDATE sync_outbox SET delivered_at = CURRENT_TIMESTAMP WHERE delta_id = ? AND delivered_at IS NULL", + (delta_id,), + ) + self._conn.commit() + + def record_contract(self, farm_id: str, adapter_id: str, contract_type: str, payload: dict[str, Any]) -> str: + contract_id = sha256_hex({"farm_id": farm_id, "adapter_id": adapter_id, "type": contract_type, "payload": payload}) + self._conn.execute( + """ + INSERT OR REPLACE INTO contracts + (contract_id, farm_id, adapter_id, contract_type, payload_json, content_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + (contract_id, farm_id, adapter_id, contract_type, canonical_json(payload), contract_id), + ) + self._conn.commit() + return contract_id + + def list_contracts(self) -> list[dict[str, Any]]: + rows = self._conn.execute("SELECT * FROM contracts ORDER BY created_at ASC").fetchall() + return [dict(row) for row in rows] + + def chain_is_valid(self, farm_id: str, secret: str) -> bool: + rows = self._conn.execute( + "SELECT payload_json, previous_tag, tag FROM plan_deltas WHERE farm_id = ? ORDER BY created_at ASC", (farm_id,) + ).fetchall() + previous = "" + for row in rows: + payload = dict(__import__("json").loads(row["payload_json"])) + payload.pop("tag", None) + expected = self._compute_tag(previous, payload, secret) + if row["previous_tag"] != previous or row["tag"] != expected: + return False + previous = row["tag"] + return True + + def snapshot(self) -> dict[str, int]: + tables = ["farms", "adapters", "events", "plan_deltas", "sync_outbox", "contracts"] + return {name: int(self._conn.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0]) for name in tables} + + @staticmethod + def _compute_tag(previous_tag: str, payload: dict[str, Any], secret: str) -> str: + return sha256_hex({"previous_tag": previous_tag, "payload": payload, "secret": secret}) diff --git a/src/idea170_agrimesh_federated_privacy/models.py b/src/idea170_agrimesh_federated_privacy/models.py new file mode 100644 index 0000000..47d5885 --- /dev/null +++ b/src/idea170_agrimesh_federated_privacy/models.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Literal +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field, field_validator + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class FarmIdentity(BaseModel): + farm_id: str = Field(min_length=1) + name: str = Field(min_length=1) + public_key: str = Field(min_length=8) + created_at: datetime = Field(default_factory=utc_now) + + +class LocalProblem(BaseModel): + farm_id: str = Field(min_length=1) + current_soil_moisture_pct: float = Field(ge=0.0, le=100.0) + target_soil_moisture_pct: float = Field(ge=0.0, le=100.0) + irrigation_budget_liters: float = Field(gt=0.0) + energy_budget_kwh: float = Field(ge=0.0) + plot_area_hectares: float = Field(gt=0.0) + crop_priority: float = Field(default=1.0, ge=0.0) + fertilizer_cap_kg: float = Field(default=0.0, ge=0.0) + + @field_validator("target_soil_moisture_pct") + @classmethod + def validate_target(cls, value: float, info): + current = info.data.get("current_soil_moisture_pct") + if current is not None and value < current * 0.5: + raise ValueError("target moisture is implausibly low relative to current moisture") + return value + + +class SharedSignals(BaseModel): + timestamp: datetime = Field(default_factory=utc_now) + mean_soil_moisture_pct: float = Field(ge=0.0, le=100.0) + drought_pressure: float = Field(ge=0.0, le=1.0) + forecast_rain_mm: float = Field(ge=0.0) + secure_aggregation_version: Literal["v1"] = "v1" + local_dp_epsilon: float | None = Field(default=None, gt=0.0) + + +class RegionalConstraint(BaseModel): + water_quota_liters: float = Field(gt=0.0) + drought_target_moisture_pct: float = Field(ge=0.0, le=100.0) + energy_cap_kwh: float = Field(ge=0.0) + regional_water_quota_id: str = Field(default_factory=lambda: str(uuid4())) + + +class PlanDelta(BaseModel): + delta_id: UUID = Field(default_factory=uuid4) + farm_id: str = Field(min_length=1) + step_index: int = Field(ge=0) + irrigation_liters: float = Field(ge=0.0) + fertilizer_kg: float = Field(ge=0.0) + energy_kwh: float = Field(ge=0.0) + rationale: str = Field(min_length=1) + previous_tag: str = Field(default="") + tag: str = Field(default="") + created_at: datetime = Field(default_factory=utc_now) + + +class AdapterSpec(BaseModel): + adapter_id: str = Field(min_length=1) + farm_id: str = Field(min_length=1) + name: str = Field(min_length=1) + kind: Literal["irrigation_controller", "soil_sensor_gateway", "weather_feed", "farm_management_system"] + endpoint: str = Field(min_length=1) + capabilities: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + + +class GovernanceEvent(BaseModel): + event_id: UUID = Field(default_factory=uuid4) + event_type: str = Field(min_length=1) + payload: dict[str, Any] = Field(default_factory=dict) + created_at: datetime = Field(default_factory=utc_now) + + +class CatOptVariable(BaseModel): + name: str + lower: float + upper: float + target: float + + +class CatOptConstraint(BaseModel): + name: str + expression: str + + +class CatOptProblem(BaseModel): + problem_id: str + variables: list[CatOptVariable] + constraints: list[CatOptConstraint] + objective: str + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/src/idea170_agrimesh_federated_privacy/solver.py b/src/idea170_agrimesh_federated_privacy/solver.py new file mode 100644 index 0000000..92e1eca --- /dev/null +++ b/src/idea170_agrimesh_federated_privacy/solver.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + +from .models import LocalProblem, PlanDelta, RegionalConstraint, SharedSignals +from .utils import clamp, laplace_noise + + +@dataclass(frozen=True) +class SolveResult: + deltas: list[PlanDelta] + total_irrigation_liters: float + total_energy_kwh: float + + +def _project_capped_simplex(values: Sequence[float], quota: float, caps: Sequence[float]) -> list[float]: + clipped = [clamp(v, 0.0, cap) for v, cap in zip(values, caps, strict=True)] + total = sum(clipped) + if total <= quota: + return clipped + + lo = min(v - cap for v, cap in zip(clipped, caps, strict=True)) - quota + hi = max(clipped) + for _ in range(80): + mid = (lo + hi) / 2.0 + projected = [clamp(v - mid, 0.0, cap) for v, cap in zip(clipped, caps, strict=True)] + current = sum(projected) + if current > quota: + lo = mid + else: + hi = mid + return [clamp(v - hi, 0.0, cap) for v, cap in zip(clipped, caps, strict=True)] + + +def solve_cross_farm_plan( + local_problems: Sequence[LocalProblem], + regional_constraint: RegionalConstraint, + shared_signals: SharedSignals, + *, + farm_secret: str = "agrimesh-secret", + iterations: int = 12, +) -> SolveResult: + if not local_problems: + return SolveResult([], 0.0, 0.0) + + needs = [] + caps = [] + for problem in local_problems: + moisture_gap = max(problem.target_soil_moisture_pct - problem.current_soil_moisture_pct, 0.0) + weather_adjustment = max(0.2, 1.0 - shared_signals.forecast_rain_mm / 50.0) + drought_adjustment = 1.0 + shared_signals.drought_pressure * 0.35 + need = moisture_gap * problem.plot_area_hectares * 10.0 * weather_adjustment * drought_adjustment + needs.append(need) + caps.append(min(problem.irrigation_budget_liters, regional_constraint.water_quota_liters)) + + rho = 1.2 + x = [0.0 for _ in local_problems] + z = [0.0 for _ in local_problems] + u = [0.0 for _ in local_problems] + + for _ in range(iterations): + x = [clamp((need + rho * (zi - ui)) / (1.0 + rho), 0.0, cap) for need, zi, ui, cap in zip(needs, z, u, caps, strict=True)] + z = _project_capped_simplex([xi + ui for xi, ui in zip(x, u, strict=True)], regional_constraint.water_quota_liters, caps) + u = [ui + xi - zi for xi, zi, ui in zip(x, z, u, strict=True)] + + total_irrigation = sum(z) + if shared_signals.local_dp_epsilon is not None: + total_irrigation = max(0.0, total_irrigation + laplace_noise(scale=1.0 / shared_signals.local_dp_epsilon, seed=7)) + + deltas: list[PlanDelta] = [] + total_energy = 0.0 + for index, (problem, irrigation) in enumerate(zip(local_problems, z, strict=True)): + fertilizer = min(problem.fertilizer_cap_kg, irrigation / 200.0) + energy = min(problem.energy_budget_kwh, irrigation * 0.02) + total_energy += energy + deltas.append( + PlanDelta( + farm_id=problem.farm_id, + step_index=index, + irrigation_liters=round(irrigation, 3), + fertilizer_kg=round(fertilizer, 3), + energy_kwh=round(energy, 3), + rationale=f"Consensus allocation under water quota {regional_constraint.water_quota_liters:.1f}", + ) + ) + + if total_energy > regional_constraint.energy_cap_kwh and total_energy > 0: + scale = regional_constraint.energy_cap_kwh / total_energy + deltas = [ + delta.model_copy( + update={ + "irrigation_liters": round(delta.irrigation_liters * scale, 3), + "fertilizer_kg": round(delta.fertilizer_kg * scale, 3), + "energy_kwh": round(delta.energy_kwh * scale, 3), + } + ) + for delta in deltas + ] + total_energy = regional_constraint.energy_cap_kwh + total_irrigation = sum(delta.irrigation_liters for delta in deltas) + + return SolveResult(deltas=deltas, total_irrigation_liters=round(total_irrigation, 3), total_energy_kwh=round(total_energy, 3)) diff --git a/src/idea170_agrimesh_federated_privacy/utils.py b/src/idea170_agrimesh_federated_privacy/utils.py new file mode 100644 index 0000000..5b701bf --- /dev/null +++ b/src/idea170_agrimesh_federated_privacy/utils.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import math +import random +from typing import Any + + +def canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) + + +def sha256_hex(value: Any) -> str: + return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() + + +def hmac_hex(secret: str, value: Any) -> str: + return hmac.new(secret.encode("utf-8"), canonical_json(value).encode("utf-8"), hashlib.sha256).hexdigest() + + +def laplace_noise(scale: float, seed: int | None = None) -> float: + if scale <= 0: + return 0.0 + rng = random.Random(seed) + u = rng.random() - 0.5 + return -scale * math.copysign(math.log1p(-2 * abs(u)), u) + + +def clamp(value: float, lower: float, upper: float) -> float: + return min(max(value, lower), upper) diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..d355127 --- /dev/null +++ b/test.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 -m pip install --upgrade pip >/dev/null +python3 -m pip install -e .[test] build >/dev/null +pytest -q +python3 -m build diff --git a/tests/test_agrimesh.py b/tests/test_agrimesh.py new file mode 100644 index 0000000..673c018 --- /dev/null +++ b/tests/test_agrimesh.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from pathlib import Path + +from idea170_agrimesh_federated_privacy import ( + AgriMeshCanonicalMapper, + AdapterSpec, + FarmIdentity, + GovernanceLedger, + LocalProblem, + RegionalConstraint, + SharedSignals, + build_drought_demo, + solve_cross_farm_plan, +) + + +def test_solver_respects_quota_and_energy_caps() -> None: + farms = [ + LocalProblem( + farm_id="farm-1", + current_soil_moisture_pct=15.0, + target_soil_moisture_pct=30.0, + irrigation_budget_liters=3000.0, + energy_budget_kwh=50.0, + plot_area_hectares=10.0, + fertilizer_cap_kg=10.0, + ), + LocalProblem( + farm_id="farm-2", + current_soil_moisture_pct=20.0, + target_soil_moisture_pct=29.0, + irrigation_budget_liters=1800.0, + energy_budget_kwh=45.0, + plot_area_hectares=8.0, + fertilizer_cap_kg=9.0, + ), + ] + result = solve_cross_farm_plan( + farms, + RegionalConstraint(water_quota_liters=2500.0, drought_target_moisture_pct=28.0, energy_cap_kwh=40.0), + SharedSignals(mean_soil_moisture_pct=18.0, drought_pressure=0.9, forecast_rain_mm=0.0), + ) + + assert len(result.deltas) == 2 + assert result.total_irrigation_liters <= 2500.0 + 1e-6 + assert result.total_energy_kwh <= 40.0 + 1e-6 + assert all(delta.irrigation_liters <= farm.irrigation_budget_liters for delta, farm in zip(result.deltas, farms, strict=True)) + + +def test_ledger_persists_and_validates_chain(tmp_path: Path) -> None: + ledger = GovernanceLedger(tmp_path / "ledger.sqlite") + identity = FarmIdentity(farm_id="farm-a", name="Alpha Farm", public_key="public-key-123") + ledger.register_farm(identity) + + adapter = AdapterSpec( + adapter_id="adapter-1", + farm_id="farm-a", + name="Drip Controller", + kind="irrigation_controller", + endpoint="mqtt://controller.local", + capabilities=["set_flow_rate"], + ) + ledger.upsert_adapter(adapter) + ledger.record_contract("farm-a", "adapter-1", "controller-binding", {"protocol": "mqtt", "topic": "irrigation/set"}) + + delta = ledger.record_plan_delta( + build_drought_demo().deltas[0].model_copy(update={"farm_id": "farm-a", "previous_tag": ""}), + secret="shared-secret", + ) + + assert ledger.snapshot()["farms"] == 1 + assert ledger.snapshot()["adapters"] == 1 + assert ledger.snapshot()["contracts"] == 1 + assert ledger.snapshot()["plan_deltas"] == 1 + assert ledger.chain_is_valid("farm-a", secret="shared-secret") + assert delta.tag + ledger.close() + + +def test_canonical_mapper_and_demo_are_available() -> None: + mapper = AgriMeshCanonicalMapper() + local = LocalProblem( + farm_id="farm-x", + current_soil_moisture_pct=16.0, + target_soil_moisture_pct=30.0, + irrigation_budget_liters=1500.0, + energy_budget_kwh=25.0, + plot_area_hectares=7.0, + ) + regional = RegionalConstraint(water_quota_liters=1200.0, drought_target_moisture_pct=29.0, energy_cap_kwh=20.0) + problem = mapper.to_catopt(local, regional) + + assert problem.variables[0].name == "irrigation_liters" + assert problem.constraints[0].name == "water_quota" + assert build_drought_demo().deltas