From 28ae65a4e7b05a9a126f89fdf0aca6d5e2f6954e Mon Sep 17 00:00:00 2001 From: agent-b883b4bc188823a2 Date: Sat, 25 Apr 2026 21:14:13 +0200 Subject: [PATCH] build(agent): c3po#b883b4 iteration --- .gitignore | 21 ++++ AGENTS.md | 27 +++++ README.md | 35 ++++++- pyproject.toml | 23 +++++ src/medimesh/__init__.py | 25 +++++ src/medimesh/__main__.py | 13 +++ src/medimesh/adapters.py | 39 ++++++++ src/medimesh/contracts.py | 102 +++++++++++++++++++ src/medimesh/governance.py | 157 ++++++++++++++++++++++++++++++ src/medimesh/scenario.py | 77 +++++++++++++++ src/medimesh/solver.py | 91 +++++++++++++++++ src/medimesh/sync.py | 133 +++++++++++++++++++++++++ test.sh | 7 ++ tests/test_contracts.py | 14 +++ tests/test_governance_and_sync.py | 21 ++++ tests/test_solver_and_scenario.py | 24 +++++ 16 files changed, 807 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 pyproject.toml create mode 100644 src/medimesh/__init__.py create mode 100644 src/medimesh/__main__.py create mode 100644 src/medimesh/adapters.py create mode 100644 src/medimesh/contracts.py create mode 100644 src/medimesh/governance.py create mode 100644 src/medimesh/scenario.py create mode 100644 src/medimesh/solver.py create mode 100644 src/medimesh/sync.py create mode 100755 test.sh create mode 100644 tests/test_contracts.py create mode 100644 tests/test_governance_and_sync.py create mode 100644 tests/test_solver_and_scenario.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..4acd2e2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# MediMesh Contributor Guide + +## Architecture +- `src/medimesh/contracts.py`: Graph-of-Contracts registry and schema conformance checks. +- `src/medimesh/solver.py`: Distributed allocator and policy weighting for scarce medical resources. +- `src/medimesh/governance.py`: DID-style identities, attestations, and tamper-evident SQLite ledger. +- `src/medimesh/sync.py`: Offline-first delta storage and deterministic replay. +- `src/medimesh/adapters.py`: Starter hospital inventory and EMS/logistics adapters. +- `src/medimesh/scenario.py`: End-to-end toy scenario for validation and demos. + +## Tech Stack +- Python 3.10+ +- `networkx` for contract graph modeling +- `cryptography` for Ed25519 signing and verification +- SQLite via the standard library for local durable state +- `pytest` for tests + +## Rules +- Keep changes small and deterministic. +- Preserve privacy boundaries: patient data stays local and out of global coordination objects. +- Prefer aggregated signals over site-level reconstruction. +- Maintain hash-chain integrity in sync and governance stores. +- Update tests alongside behavior changes. + +## Commands +- Install and test: `bash test.sh` +- Run the toy scenario: `python -m medimesh` diff --git a/README.md b/README.md index 1e9dd0a..4286e39 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ -# idea150-medimesh-federated-privacy +# MediMesh -Source logic for Idea #150 \ No newline at end of file +MediMesh is a federated coordination platform for disaster-affected health systems. +It keeps site-level data local, exchanges only aggregated signals, and uses a canonical contract registry to coordinate hospitals, clinics, EMS, pharmacies, and supply caches under privacy and governance constraints. + +## What It Provides + +- Graph-of-Contracts registry for schema and adapter governance +- Distributed allocator for scarce resources using policy-weighted coordination +- SQLite-backed tamper-evident governance log with cryptographic attestations +- Offline-first delta storage with deterministic replay for audits +- Starter hospital inventory and EMS/logistics adapters +- End-to-end toy scenario with a shortage shock + +## Package Layout + +- `medimesh.contracts`: canonical contracts and registry +- `medimesh.solver`: local and distributed allocation logic +- `medimesh.governance`: identities, credentials, and ledger +- `medimesh.sync`: offline delta sync and replay +- `medimesh.adapters`: reference controllers +- `medimesh.scenario`: demo orchestration + +## Run + +```bash +bash test.sh +python -m medimesh +``` + +## Packaging + +- Python distribution name: `idea150-medimesh-federated-privacy` +- `readme = "README.md"` is wired into `pyproject.toml` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..26b8819 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea150-medimesh-federated-privacy" +version = "0.1.0" +description = "Federated, privacy-preserving coordination for medical resource allocation in low-connectivity environments" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "networkx>=3.2", + "cryptography>=42.0", +] + +[project.optional-dependencies] +test = ["pytest>=8.0"] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/medimesh/__init__.py b/src/medimesh/__init__.py new file mode 100644 index 0000000..21aa49c --- /dev/null +++ b/src/medimesh/__init__.py @@ -0,0 +1,25 @@ +"""MediMesh federated coordination toolkit.""" + +from .adapters import EMSDispatchLogisticsController, HospitalInventoryController +from .contracts import ContractRegistry, DataContract, GovernancePolicy +from .governance import Credential, GovernanceLedger, Identity, IdentityAuthority +from .scenario import run_toy_scenario +from .solver import DistributedAllocator, HumanitarianPolicy, SiteState +from .sync import OfflineDeltaStore + +__all__ = [ + "ContractRegistry", + "DataContract", + "GovernancePolicy", + "Credential", + "GovernanceLedger", + "Identity", + "IdentityAuthority", + "DistributedAllocator", + "HumanitarianPolicy", + "SiteState", + "OfflineDeltaStore", + "HospitalInventoryController", + "EMSDispatchLogisticsController", + "run_toy_scenario", +] diff --git a/src/medimesh/__main__.py b/src/medimesh/__main__.py new file mode 100644 index 0000000..0da5b98 --- /dev/null +++ b/src/medimesh/__main__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import json + +from .scenario import run_toy_scenario + + +def main() -> None: + print(json.dumps(run_toy_scenario(), indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/src/medimesh/adapters.py b/src/medimesh/adapters.py new file mode 100644 index 0000000..f4fe7c7 --- /dev/null +++ b/src/medimesh/adapters.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +from .solver import AllocationResult, DistributedAllocator, HumanitarianPolicy, SiteState + + +@dataclass(frozen=True) +class HospitalInventoryController: + site_id: str + region: str + on_hand: float + forecast_demand: float + minimum_service: float = 0.0 + + def to_site_state(self) -> SiteState: + return SiteState( + site_id=self.site_id, + region=self.region, + on_hand=self.on_hand, + forecast_demand=self.forecast_demand, + minimum_service=self.minimum_service, + ) + + def recommend(self, allocator: DistributedAllocator, total_available: float, policy: HumanitarianPolicy) -> AllocationResult: + return allocator.allocate([self.to_site_state()], total_available=total_available, policy=policy) + + +@dataclass(frozen=True) +class EMSDispatchLogisticsController: + dispatch_base: str + region: str + + def prioritize_transfers(self, allocations: Mapping[str, float], shortage_signal: float) -> dict[str, float]: + if shortage_signal <= 0: + return {site_id: 0.0 for site_id in allocations} + total = sum(max(0.0, amount) for amount in allocations.values()) or 1.0 + return {site_id: round(shortage_signal * amount / total, 4) for site_id, amount in allocations.items()} diff --git a/src/medimesh/contracts.py b/src/medimesh/contracts.py new file mode 100644 index 0000000..ecbe7ad --- /dev/null +++ b/src/medimesh/contracts.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Mapping, Optional + +import networkx as nx + + +@dataclass(frozen=True) +class DataContract: + name: str + version: str + required_fields: tuple[str, ...] + optional_fields: tuple[str, ...] = () + owner: str = "" + classification: str = "internal" + + @property + def node_id(self) -> str: + return f"{self.name}@{self.version}" + + def validate(self, payload: Mapping[str, Any]) -> list[str]: + missing = [field for field in self.required_fields if field not in payload] + if missing: + raise ValueError(f"payload missing required fields: {', '.join(missing)}") + return sorted(set(payload.keys()) - set(self.required_fields) - set(self.optional_fields)) + + +@dataclass(frozen=True) +class GovernancePolicy: + name: str + version: str + triage_priority: float = 1.0 + equity_weight: float = 1.0 + continuity_weight: float = 1.0 + regional_floor: float = 0.0 + + @property + def node_id(self) -> str: + return f"policy:{self.name}@{self.version}" + + +@dataclass(frozen=True) +class AdapterContractBinding: + adapter_name: str + reads: tuple[str, ...] + writes: tuple[str, ...] + governed_by: tuple[str, ...] = () + + +class ContractRegistry: + def __init__(self) -> None: + self.graph: nx.DiGraph = nx.DiGraph() + + def register_contract(self, contract: DataContract) -> None: + self.graph.add_node( + contract.node_id, + kind="contract", + contract=contract, + ) + + def register_policy(self, policy: GovernancePolicy) -> None: + self.graph.add_node( + policy.node_id, + kind="policy", + policy=policy, + ) + + def register_adapter(self, binding: AdapterContractBinding) -> None: + self.graph.add_node( + binding.adapter_name, + kind="adapter", + binding=binding, + ) + for contract_id in binding.reads: + self.graph.add_edge(contract_id, binding.adapter_name, relation="feeds") + for contract_id in binding.writes: + self.graph.add_edge(binding.adapter_name, contract_id, relation="produces") + for policy_id in binding.governed_by: + self.graph.add_edge(policy_id, binding.adapter_name, relation="governs") + + def register_flow(self, upstream: str, downstream: str, relation: str = "depends_on") -> None: + self.graph.add_edge(upstream, downstream, relation=relation) + + def conformance_check(self, contract_id: str, payload: Mapping[str, Any]) -> list[str]: + contract = self.resolve_contract(contract_id) + return contract.validate(payload) + + def resolve_contract(self, contract_id: str) -> DataContract: + node = self.graph.nodes[contract_id] + if node.get("kind") != "contract": + raise KeyError(f"{contract_id} is not a registered contract") + return node["contract"] + + def lineage(self, node_id: str) -> list[str]: + return list(nx.ancestors(self.graph, node_id)) + + def topology(self) -> dict[str, list[tuple[str, str]]]: + return { + node: sorted((neighbor, data.get("relation", "")) for _, neighbor, data in self.graph.out_edges(node, data=True)) + for node in self.graph.nodes + } diff --git a/src/medimesh/governance.py b/src/medimesh/governance.py new file mode 100644 index 0000000..9c165c6 --- /dev/null +++ b/src/medimesh/governance.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _hash_bytes(*parts: bytes) -> str: + digest = hashlib.sha256() + for part in parts: + digest.update(part) + return digest.hexdigest() + + +@dataclass(frozen=True) +class Identity: + did: str + public_key: bytes + private_key: Ed25519PrivateKey + + def sign(self, payload: bytes) -> bytes: + return self.private_key.sign(payload) + + def verify(self, payload: bytes, signature: bytes) -> None: + Ed25519PublicKey.from_public_bytes(self.public_key).verify(signature, payload) + + +class IdentityAuthority: + @staticmethod + def create(label: str) -> Identity: + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + fingerprint = hashlib.sha256(public_key).hexdigest()[:24] + did = f"did:medimesh:{label}:{fingerprint}" + return Identity(did=did, public_key=public_key, private_key=private_key) + + +@dataclass(frozen=True) +class Credential: + subject_did: str + issuer_did: str + scopes: tuple[str, ...] + issued_at: str + expires_at: str + signature: str + + def is_valid(self, now: datetime | None = None) -> bool: + now = now or datetime.now(timezone.utc) + expires = datetime.fromisoformat(self.expires_at) + return now <= expires + + +class GovernanceLedger: + def __init__(self, db_path: str | Path = ":memory:") -> None: + self.db_path = str(db_path) + self.conn = sqlite3.connect(self.db_path) + self.conn.row_factory = sqlite3.Row + self._ensure_schema() + + def _ensure_schema(self) -> None: + self.conn.execute( + """ + CREATE TABLE IF NOT EXISTS ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + actor_did TEXT NOT NULL, + event_type TEXT NOT NULL, + payload TEXT NOT NULL, + prev_hash TEXT NOT NULL, + entry_hash TEXT NOT NULL, + signature TEXT NOT NULL + ) + """ + ) + self.conn.commit() + + def latest_hash(self) -> str: + row = self.conn.execute("SELECT entry_hash FROM ledger_entries ORDER BY id DESC LIMIT 1").fetchone() + return row["entry_hash"] if row else "GENESIS" + + def append(self, actor: Identity, event_type: str, payload: Mapping[str, Any]) -> dict[str, Any]: + ts = datetime.now(timezone.utc).isoformat() + prev_hash = self.latest_hash() + payload_json = _canonical_json(payload) + entry_hash = _hash_bytes(prev_hash.encode(), ts.encode(), actor.did.encode(), event_type.encode(), payload_json.encode()) + signature = actor.sign(entry_hash.encode()) + record = { + "ts": ts, + "actor_did": actor.did, + "event_type": event_type, + "payload": payload_json, + "prev_hash": prev_hash, + "entry_hash": entry_hash, + "signature": base64.b64encode(signature).decode(), + } + self.conn.execute( + """ + INSERT INTO ledger_entries (ts, actor_did, event_type, payload, prev_hash, entry_hash, signature) + VALUES (:ts, :actor_did, :event_type, :payload, :prev_hash, :entry_hash, :signature) + """, + record, + ) + self.conn.commit() + return record + + def entries(self) -> list[dict[str, Any]]: + rows = self.conn.execute("SELECT * FROM ledger_entries ORDER BY id ASC").fetchall() + return [dict(row) for row in rows] + + def verify_chain(self, identity_lookup: Mapping[str, Identity]) -> bool: + previous = "GENESIS" + for entry in self.entries(): + payload = entry["payload"] + recalculated = _hash_bytes( + previous.encode(), + entry["ts"].encode(), + entry["actor_did"].encode(), + entry["event_type"].encode(), + payload.encode(), + ) + if recalculated != entry["entry_hash"]: + return False + actor = identity_lookup.get(entry["actor_did"]) + if actor is None: + return False + try: + actor.verify(entry["entry_hash"].encode(), base64.b64decode(entry["signature"])) + except InvalidSignature: + return False + previous = entry["entry_hash"] + return True + + def issue_credential(self, issuer: Identity, subject_did: str, scopes: Iterable[str], ttl_seconds: int = 900) -> Credential: + now = datetime.now(timezone.utc) + expires = now.timestamp() + ttl_seconds + body = { + "subject_did": subject_did, + "issuer_did": issuer.did, + "scopes": tuple(scopes), + "issued_at": now.isoformat(), + "expires_at": datetime.fromtimestamp(expires, tz=timezone.utc).isoformat(), + } + signature = base64.b64encode(issuer.sign(_canonical_json(body).encode())).decode() + return Credential(signature=signature, **body) diff --git a/src/medimesh/scenario.py b/src/medimesh/scenario.py new file mode 100644 index 0000000..0eae358 --- /dev/null +++ b/src/medimesh/scenario.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import asdict + +from .adapters import EMSDispatchLogisticsController, HospitalInventoryController +from .contracts import AdapterContractBinding, ContractRegistry, DataContract, GovernancePolicy +from .governance import GovernanceLedger, IdentityAuthority +from .solver import DistributedAllocator, HumanitarianPolicy, SiteState +from .sync import OfflineDeltaStore + + +def run_toy_scenario() -> dict: + registry = ContractRegistry() + inventory_contract = DataContract( + name="inventory_snapshot", + version="1.0", + required_fields=("site_id", "resource", "on_hand", "forecast_demand"), + optional_fields=("minimum_service",), + owner="hospital", + ) + dispatch_contract = DataContract( + name="dispatch_plan", + version="1.0", + required_fields=("site_id", "transfer_priority", "shortage_signal"), + owner="ems", + ) + policy = GovernancePolicy(name="humanitarian", version="1.0", triage_priority=1.5, equity_weight=1.2, continuity_weight=1.1) + registry.register_contract(inventory_contract) + registry.register_contract(dispatch_contract) + registry.register_policy(policy) + registry.register_adapter( + AdapterContractBinding( + adapter_name="hospital-inventory-controller", + reads=(inventory_contract.node_id,), + writes=(dispatch_contract.node_id,), + governed_by=(policy.node_id,), + ) + ) + + authority = IdentityAuthority.create("medimesh") + ledger = GovernanceLedger() + delta_store = OfflineDeltaStore() + allocator = DistributedAllocator(iterations=10) + policy_runtime = HumanitarianPolicy(equity_weight=1.2, continuity_weight=1.1, triage_weight=1.5) + + sites = [ + SiteState("hospital-a", "north", 32, 46, equity_weight=1.4, continuity_weight=1.2, triage_priority=1.5, minimum_service=10), + SiteState("hospital-b", "central", 18, 40, equity_weight=1.1, continuity_weight=1.5, triage_priority=1.2, minimum_service=8), + SiteState("clinic-c", "south", 7, 21, equity_weight=1.8, continuity_weight=0.9, triage_priority=1.4, minimum_service=4), + SiteState("pharmacy-d", "east", 15, 12, equity_weight=0.9, continuity_weight=1.0, triage_priority=0.8, minimum_service=2), + ] + + ledger.append(authority, "scenario.initialized", {"sites": [site.site_id for site in sites]}) + for site in sites: + delta_store.append(site.site_id, "set", {"on_hand": site.on_hand, "forecast_demand": site.forecast_demand}, authority.did) + + shortage_shock = 28.0 + result = allocator.allocate(sites, total_available=52.0 - shortage_shock, policy=policy_runtime) + ems = EMSDispatchLogisticsController(dispatch_base="ems-hub-1", region="central") + transfers = ems.prioritize_transfers(result.allocations, shortage_signal=shortage_shock) + + ledger.append(authority, "allocation.issued", {"allocations": result.allocations, "shortage_shock": shortage_shock}) + for site_id, allocation in result.allocations.items(): + delta_store.append(site_id, "increment", {"allocated": allocation}, authority.did) + + hospital = HospitalInventoryController("hospital-a", "north", 32, 46, minimum_service=10) + hospital_result = hospital.recommend(allocator, total_available=24.0, policy=policy_runtime) + + return { + "registry_nodes": sorted(registry.graph.nodes), + "ledger_verified": ledger.verify_chain({authority.did: authority}), + "delta_replay": {site.site_id: delta_store.replay(site.site_id) for site in sites}, + "allocation": result.allocations, + "unmet": result.unmet_demand, + "transfers": transfers, + "hospital_recommendation": hospital_result.allocations, + } diff --git a/src/medimesh/solver.py b/src/medimesh/solver.py new file mode 100644 index 0000000..4e2c6ad --- /dev/null +++ b/src/medimesh/solver.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Mapping + + +@dataclass(frozen=True) +class SiteState: + site_id: str + region: str + on_hand: float + forecast_demand: float + equity_weight: float = 1.0 + continuity_weight: float = 1.0 + triage_priority: float = 1.0 + minimum_service: float = 0.0 + + +@dataclass(frozen=True) +class HumanitarianPolicy: + equity_weight: float = 1.0 + continuity_weight: float = 1.0 + triage_weight: float = 1.0 + regional_floor: float = 0.0 + + def site_prior(self, site: SiteState) -> float: + return ( + site.equity_weight * self.equity_weight + + site.continuity_weight * self.continuity_weight + + site.triage_priority * self.triage_weight + ) + + +@dataclass(frozen=True) +class AllocationResult: + allocations: dict[str, float] + unmet_demand: dict[str, float] + total_allocated: float + iterations: int + + +class DistributedAllocator: + def __init__(self, iterations: int = 8, step_size: float = 0.5) -> None: + self.iterations = iterations + self.step_size = step_size + + @staticmethod + def _project_to_sum(values: list[float], total: float, lowers: list[float], uppers: list[float]) -> list[float]: + if not values: + return [] + low = min(v - u for v, u in zip(values, uppers)) - abs(total) + high = max(v - l for v, l in zip(values, lowers)) + abs(total) + for _ in range(80): + shift = (low + high) / 2.0 + projected = [min(upper, max(lower, value - shift)) for value, lower, upper in zip(values, lowers, uppers)] + current = sum(projected) + if abs(current - total) < 1e-8: + return projected + if current > total: + low = shift + else: + high = shift + shift = (low + high) / 2.0 + return [min(upper, max(lower, value - shift)) for value, lower, upper in zip(values, lowers, uppers)] + + def allocate(self, sites: Iterable[SiteState], total_available: float, policy: HumanitarianPolicy | None = None) -> AllocationResult: + policy = policy or HumanitarianPolicy() + sites = list(sites) + if not sites: + return AllocationResult({}, {}, 0.0, 0) + + lowers = [max(0.0, site.minimum_service) for site in sites] + uppers = [max(lower, min(site.forecast_demand, site.on_hand + total_available)) for site, lower in zip(sites, lowers)] + weights = [max(0.1, policy.site_prior(site)) for site in sites] + total = min(total_available, sum(uppers)) + x = [min(upper, max(lower, total * weight / sum(weights))) for weight, lower, upper in zip(weights, lowers, uppers)] + duals = [0.0 for _ in sites] + + for _ in range(self.iterations): + proposals = [] + for idx, site in enumerate(sites): + target = min(site.forecast_demand, site.on_hand + total_available) + prior = policy.site_prior(site) + local_update = x[idx] + self.step_size * ((target - x[idx]) / max(1.0, target) + prior - duals[idx]) + proposals.append(local_update) + x = self._project_to_sum(proposals, total, lowers, uppers) + duals = [dual + proposal - alloc for dual, proposal, alloc in zip(duals, proposals, x)] + + allocations = {site.site_id: round(value, 4) for site, value in zip(sites, x)} + unmet = {site.site_id: round(max(0.0, site.forecast_demand - allocations[site.site_id]), 4) for site in sites} + return AllocationResult(allocations=allocations, unmet_demand=unmet, total_allocated=round(sum(x), 4), iterations=self.iterations) diff --git a/src/medimesh/sync.py b/src/medimesh/sync.py new file mode 100644 index 0000000..dc3bd92 --- /dev/null +++ b/src/medimesh/sync.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _hash_chain(*parts: str) -> str: + digest = hashlib.sha256() + for part in parts: + digest.update(part.encode()) + return digest.hexdigest() + + +@dataclass(frozen=True) +class DeltaRecord: + site_id: str + sequence: int + kind: str + payload: dict[str, Any] + author_did: str + prev_hash: str + hash: str + + +class OfflineDeltaStore: + def __init__(self, db_path: str | Path = ":memory:") -> None: + self.db_path = str(db_path) + self.conn = sqlite3.connect(self.db_path) + self.conn.row_factory = sqlite3.Row + self._ensure_schema() + + def _ensure_schema(self) -> None: + self.conn.execute( + """ + CREATE TABLE IF NOT EXISTS deltas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + author_did TEXT NOT NULL, + prev_hash TEXT NOT NULL, + hash TEXT NOT NULL + ) + """ + ) + self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_deltas_site_sequence ON deltas(site_id, sequence)") + self.conn.commit() + + def latest_sequence(self, site_id: str) -> int: + row = self.conn.execute("SELECT MAX(sequence) AS sequence FROM deltas WHERE site_id = ?", (site_id,)).fetchone() + return int(row["sequence"] or 0) + + def latest_hash(self, site_id: str) -> str: + row = self.conn.execute("SELECT hash FROM deltas WHERE site_id = ? ORDER BY sequence DESC LIMIT 1", (site_id,)).fetchone() + return row["hash"] if row else "GENESIS" + + def append(self, site_id: str, kind: str, payload: Mapping[str, Any], author_did: str) -> DeltaRecord: + sequence = self.latest_sequence(site_id) + 1 + prev_hash = self.latest_hash(site_id) + payload_json = _canonical_json(payload) + hash_value = _hash_chain(site_id, str(sequence), kind, payload_json, author_did, prev_hash) + self.conn.execute( + """ + INSERT INTO deltas (site_id, sequence, kind, payload, author_did, prev_hash, hash) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (site_id, sequence, kind, payload_json, author_did, prev_hash, hash_value), + ) + self.conn.commit() + return DeltaRecord(site_id, sequence, kind, dict(payload), author_did, prev_hash, hash_value) + + def records(self, site_id: str) -> list[DeltaRecord]: + rows = self.conn.execute("SELECT * FROM deltas WHERE site_id = ? ORDER BY sequence ASC", (site_id,)).fetchall() + return [ + DeltaRecord( + site_id=row["site_id"], + sequence=row["sequence"], + kind=row["kind"], + payload=json.loads(row["payload"]), + author_did=row["author_did"], + prev_hash=row["prev_hash"], + hash=row["hash"], + ) + for row in rows + ] + + def delta_since(self, site_id: str, sequence: int) -> list[DeltaRecord]: + rows = self.conn.execute( + "SELECT * FROM deltas WHERE site_id = ? AND sequence > ? ORDER BY sequence ASC", + (site_id, sequence), + ).fetchall() + return [ + DeltaRecord( + site_id=row["site_id"], + sequence=row["sequence"], + kind=row["kind"], + payload=json.loads(row["payload"]), + author_did=row["author_did"], + prev_hash=row["prev_hash"], + hash=row["hash"], + ) + for row in rows + ] + + def replay(self, site_id: str) -> dict[str, Any]: + state: dict[str, Any] = {} + for record in self.records(site_id): + if record.kind == "set": + state.update(record.payload) + elif record.kind == "increment": + for key, value in record.payload.items(): + state[key] = state.get(key, 0) + value + else: + state[record.kind] = record.payload + return state + + def verify_chain(self, site_id: str) -> bool: + previous = "GENESIS" + for record in self.records(site_id): + recalculated = _hash_chain(record.site_id, str(record.sequence), record.kind, _canonical_json(record.payload), record.author_did, previous) + if recalculated != record.hash: + return False + previous = record.hash + return True diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..b3e2817 --- /dev/null +++ b/test.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 -m pip install -e .[test] +python3 -m pip install build +pytest +python3 -m build diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..5a867ee --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,14 @@ +from medimesh.contracts import AdapterContractBinding, ContractRegistry, DataContract, GovernancePolicy + + +def test_contract_registry_conformance_and_lineage() -> None: + registry = ContractRegistry() + contract = DataContract("inventory_snapshot", "1.0", ("site_id", "on_hand"), ("forecast_demand",)) + policy = GovernancePolicy("humanitarian", "1.0") + registry.register_contract(contract) + registry.register_policy(policy) + registry.register_adapter(AdapterContractBinding("adapter", (contract.node_id,), (), (policy.node_id,))) + + assert registry.conformance_check(contract.node_id, {"site_id": "a", "on_hand": 10, "forecast_demand": 8}) == [] + assert contract.node_id in registry.lineage("adapter") + assert policy.node_id in registry.lineage("adapter") diff --git a/tests/test_governance_and_sync.py b/tests/test_governance_and_sync.py new file mode 100644 index 0000000..d88e620 --- /dev/null +++ b/tests/test_governance_and_sync.py @@ -0,0 +1,21 @@ +from medimesh.governance import GovernanceLedger, IdentityAuthority +from medimesh.sync import OfflineDeltaStore + + +def test_governance_chain_verifies_and_credentials_are_short_lived() -> None: + authority = IdentityAuthority.create("tester") + ledger = GovernanceLedger() + ledger.append(authority, "site.registered", {"site_id": "hospital-a"}) + cred = ledger.issue_credential(authority, "did:medimesh:subject", ["read:inventory"], ttl_seconds=60) + + assert cred.is_valid() + assert ledger.verify_chain({authority.did: authority}) + + +def test_delta_sync_replay_is_deterministic() -> None: + store = OfflineDeltaStore() + store.append("hospital-a", "set", {"on_hand": 10}, "did:medimesh:test") + store.append("hospital-a", "increment", {"on_hand": -3}, "did:medimesh:test") + + assert store.verify_chain("hospital-a") + assert store.replay("hospital-a")["on_hand"] == 7 diff --git a/tests/test_solver_and_scenario.py b/tests/test_solver_and_scenario.py new file mode 100644 index 0000000..9d3f949 --- /dev/null +++ b/tests/test_solver_and_scenario.py @@ -0,0 +1,24 @@ +from medimesh.scenario import run_toy_scenario +from medimesh.solver import DistributedAllocator, HumanitarianPolicy, SiteState + + +def test_allocator_respects_supply_and_bounds() -> None: + allocator = DistributedAllocator(iterations=6) + sites = [ + SiteState("a", "north", 10, 12, minimum_service=3), + SiteState("b", "south", 5, 8, minimum_service=2), + ] + + result = allocator.allocate(sites, total_available=9.0, policy=HumanitarianPolicy()) + + assert round(result.total_allocated, 4) <= 9.0 + assert result.allocations["a"] >= 3 + assert result.allocations["b"] >= 2 + + +def test_toy_scenario_runs_end_to_end() -> None: + outcome = run_toy_scenario() + + assert outcome["ledger_verified"] is True + assert "hospital-a" in outcome["allocation"] + assert outcome["registry_nodes"]