From ddf1e643eab027aaf246164610411eb5586e45d7 Mon Sep 17 00:00:00 2001 From: agent-dd492b85242a98c5 Date: Sun, 19 Apr 2026 22:09:44 +0200 Subject: [PATCH] build(agent): new-agents-3#dd492b iteration --- README.md | 35 +++++---- catopt_grid/__init__.py | 11 ++- catopt_grid/adapters/__init__.py | 11 +++ catopt_grid/adapters/base.py | 31 ++++++++ catopt_grid/adapters/habitat_module.py | 20 +++++ catopt_grid/adapters/rover_planner.py | 30 +++++++ catopt_grid/admm_lite.py | 104 +++++++++++++++++++++++++ catopt_grid/bridge.py | 90 +++++++++++++++++++++ conftest.py | 8 ++ pyproject.toml | 17 ++-- tests/test_admm.py | 28 +++++++ 11 files changed, 354 insertions(+), 31 deletions(-) create mode 100644 catopt_grid/adapters/__init__.py create mode 100644 catopt_grid/adapters/base.py create mode 100644 catopt_grid/adapters/habitat_module.py create mode 100644 catopt_grid/adapters/rover_planner.py create mode 100644 catopt_grid/admm_lite.py create mode 100644 catopt_grid/bridge.py create mode 100644 conftest.py create mode 100644 tests/test_admm.py diff --git a/README.md b/README.md index df7367a..59c1a95 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,23 @@ -# CatOpt-Grid +# CatOpt-Grid MVP -Category-Theoretic Compositional Optimizer for Cross-Domain, Privacy-Preserving Distributed Edge Meshes. +A production-friendly MVP for a category-theoretic, cross-domain distributed optimization +framework. This repository implements the core primitives and a tiny ADMM-lite solver to +help validate the architecture and provide a concrete starting point for adapters and cross-domain +integration. -This repository provides a production-ready skeleton for CatOpt-Grid, a framework that expresses distributed optimization problems across heterogeneous edge devices (DERs, meters, mobility chargers, water pumps) using category-theoretic primitives: Objects (local problems), Morphisms (data exchange channels), and Functors (adapters). It aims to enable composability, privacy-preserving aggregation, and delta-sync semantics across partitions. +Key components +- LocalProblem: per-asset optimization task with a convex quadratic objective and bound constraints. +- SharedVariable: consensus variable used by all agents in the ADMM-like loop. +- ADMMLiteSolver: lightweight solver implementing x-update, z-update, and dual variable updates with bound projection. -Design goals -- Privacy by design: secure aggregation, optional local differential privacy, and federated updates. -- Distributed optimization core: a robust, ADMM-like solver with convergence guarantees for broad convex classes. -- Cross-domain adapters: marketplace and SDK; codegen targets (Rust/C) for edge devices; schema registry for interoperability. -- Governance and data policy: auditable logs and policy fragments for visibility control. -- Open interoperability: plasma with Open-EnergyMesh and CosmosMesh for cross-domain coordination. +Usage +- Install dependencies and run tests with the provided test.sh. +- This MVP focuses on correctness and stability for the ADMM-lite loop; cross-domain adapters and governance + layers can be added in future iterations. -Getting started -- This is a skeleton MVP focused on core primitives and a minimal solver to enable testing and integration. -- Install: python3 -m pip install . (after packaging) -- Run tests: bash test.sh +This repository is a stepping stone toward the CatOpt-Grid architecture described in AGENTS.md. -Contributing -- See AGENTS.md for architectural rules and contribution guidelines. - -READY_TO_PUBLISH marker is used to signal completion in the publishing workflow. +Architecture scaffolding: Bridge and Adapters +- catopt_grid.bridge: lightweight interoperability layer with IRObject/IRMorphism and a tiny GraphOfContracts registry to version adapters and data schemas. +- catopt_grid.adapters: starter adapters (rover_planner, habitat_module) that illustrate mapping local problems to the canonical IR and seed cross-domain interoperability. +- This scaffolding is intentionally minimal and designed to evolve into a production-grade interop surface without altering core solver behavior. diff --git a/catopt_grid/__init__.py b/catopt_grid/__init__.py index 200ed3b..4cb3f44 100644 --- a/catopt_grid/__init__.py +++ b/catopt_grid/__init__.py @@ -1,4 +1,9 @@ -from .core import LocalProblem, SharedVariable, PlanDelta, TimeRound -from .solver import admm_lite +"""CatOpt-Grid MVP: category-theoretic compositional optimizer (ADMM-lite). -__all__ = ["LocalProblem", "SharedVariable", "PlanDelta", "TimeRound", "admm_lite"] +This package provides a tiny, production-friendly core for testing the +distributed-optimization primitives inspired by the CatOpt-Grid vision. +""" + +from .admm_lite import LocalProblem, SharedVariable, ADMMLiteSolver + +__all__ = ["LocalProblem", "SharedVariable", "ADMMLiteSolver"] diff --git a/catopt_grid/adapters/__init__.py b/catopt_grid/adapters/__init__.py new file mode 100644 index 0000000..ed80cce --- /dev/null +++ b/catopt_grid/adapters/__init__.py @@ -0,0 +1,11 @@ +"""Adapters package for CatOpt-Grid interoperability (toy seeds). + +This module exposes two starter adapters: rover_planner and habitat_module. +They provide minimal scaffolding to illustrate how device-specific problems map +to the canonical IR defined in catopt_grid.bridge. +""" + +from .rover_planner import RoverPlannerAdapter +from .habitat_module import HabitatModuleAdapter + +__all__ = ["RoverPlannerAdapter", "HabitatModuleAdapter"] diff --git a/catopt_grid/adapters/base.py b/catopt_grid/adapters/base.py new file mode 100644 index 0000000..8cca41c --- /dev/null +++ b/catopt_grid/adapters/base.py @@ -0,0 +1,31 @@ +"""Base class for adapters mapping local problems to canonical IR.""" +from __future__ import annotations + +from typing import Dict, Optional + +from catopt_grid.bridge import IRObject, IRMorphism, PlanDelta +from catopt_grid.adapters import __version__ as _ADAPTER_VERSION # type: ignore + + +class AdapterBase: + """Minimal adapter base class. + + Subclasses should implement to_ir_object and to_morphism mappings. + """ + + name: str + version: str = "0.0.1" + + def __init__(self, name: str) -> None: + self.name = name + + def to_ir_object(self, local_problem) -> Optional[IRObject]: # pragma: no cover + """Convert a local problem to canonical IRObject. Override in subclasses.""" + return None + + def to_morphism(self, data) -> Optional[IRMorphism]: # pragma: no cover + """Convert data to a canonical IRMorphism. Override in subclasses.""" + return None + + +__all__ = ["AdapterBase"] diff --git a/catopt_grid/adapters/habitat_module.py b/catopt_grid/adapters/habitat_module.py new file mode 100644 index 0000000..7b377ac --- /dev/null +++ b/catopt_grid/adapters/habitat_module.py @@ -0,0 +1,20 @@ +"""Toy Habitat Module Adapter. + +Provides a minimal interface to map habitat/local-grid problems to the IR. +""" +from __future__ import annotations + +from catopt_grid.bridge import IRObject +from .base import AdapterBase + + +class HabitatModuleAdapter(AdapterBase): + def __init__(self, name: str = "habitat_module") -> None: + super().__init__(name) + + def to_ir_object(self, local_problem) -> IRObject: + # Basic heuristic mapping; richer metadata can be added later + return IRObject(id=getattr(local_problem, "name", "habitat"), dimension=int(getattr(local_problem, "n", 1)), metadata={"source": self.name}) + + +__all__ = ["HabitatModuleAdapter"] diff --git a/catopt_grid/adapters/rover_planner.py b/catopt_grid/adapters/rover_planner.py new file mode 100644 index 0000000..eb2a737 --- /dev/null +++ b/catopt_grid/adapters/rover_planner.py @@ -0,0 +1,30 @@ +"""Toy Rover Planner Adapter. + +Maps a rover-local planning problem to the canonical IRObject and uses a +simple local solution strategy. This is intentionally minimal to seed +interoperability and can be evolved into a full adapter later. +""" +from __future__ import annotations + +from typing import Optional + +from catopt_grid.bridge import IRObject, GraphOfContracts +from .base import AdapterBase + + +class RoverPlannerAdapter(AdapterBase): + def __init__(self, name: str = "rover_planner") -> None: + super().__init__(name) + self._goc = GraphOfContracts() + + def to_ir_object(self, local_problem) -> Optional[IRObject]: + # Minimal mapping: copy dimension as the IR object's dimension. + if getattr(local_problem, "n", None) is None: + return None + return IRObject(id=getattr(local_problem, "name", "rover"), dimension=int(local_problem.n), metadata={"source": self.name}) + + def contract_registry(self) -> GraphOfContracts: + return self._goc + + +__all__ = ["RoverPlannerAdapter"] diff --git a/catopt_grid/admm_lite.py b/catopt_grid/admm_lite.py new file mode 100644 index 0000000..7a2ae5f --- /dev/null +++ b/catopt_grid/admm_lite.py @@ -0,0 +1,104 @@ +import numpy as _np +from typing import List, Tuple, Optional + + +class LocalProblem: + """A minimal local optimization problem of the form: + min_x 0.5 x^T Q x + b^T x subject to lb <= x <= ub + which is convex if Q is positive semidefinite. + """ + + def __init__( + self, + n: int, + Q: _np.ndarray, + b: _np.ndarray, + lb: Optional[_np.ndarray] = None, + ub: Optional[_np.ndarray] = None, + name: Optional[str] = None, + ) -> None: + self.n = int(n) + self.Q = _np.asarray(Q).reshape((self.n, self.n)) + self.b = _np.asarray(b).reshape((self.n,)) + self.lb = _np.asarray(lb).reshape((self.n,)) if lb is not None else _np.full((self.n,), -_np.inf) + self.ub = _np.asarray(ub).reshape((self.n,)) if ub is not None else _np.full((self.n,), _np.inf) + self.name = name or f"LocalProblem_{self.n}d" + + def __repr__(self) -> str: + return f"LocalProblem(name={self.name}, n={self.n})" + + +class SharedVariable: + """Represents a global/shared variable (consensus variable) across agents.""" + + def __init__(self, dim: int): + self.dim = int(dim) + self.value = _np.zeros(self.dim) + + def __repr__(self) -> str: + return f"SharedVariable(dim={self.dim})" + + +class ADMMLiteSolver: + """A lightweight, ADMM-like solver for separable quadratic problems. + + Assumes all LocalProblem instances share the same x dimension. Each agent + solves its own quadratic subproblem with a quadratic penalty coupling term + to the global consensus z. The updates are: + x_i = (Q_i + rho I)^{-1} [ rho*(z - u_i) - b_i ] + z = average_i ( x_i + u_i ) + u_i = u_i + x_i - z + and then x_i is clipped to [lb_i, ub_i]. + """ + + def __init__(self, problems: List[LocalProblem], rho: float = 1.0, max_iter: int = 100, tol: float = 1e-4): + if not problems: + raise ValueError("ADMMLiteSolver requires at least one LocalProblem") + self.problems = problems + self.rho = float(rho) + self.max_iter = int(max_iter) + self.tol = float(tol) + + # Validate dimensions and prepare internal structures + self._validate_dimensions() + + def _validate_dimensions(self) -> None: + n0 = self.problems[0].n + for p in self.problems: + if p.n != n0: + raise ValueError("All LocalProblem instances must have the same dimension n") + self.n = n0 + + def solve(self, initial_z: _np.ndarray | None = None) -> Tuple[_np.ndarray, List[_np.ndarray], List[_np.ndarray]]: + N = len(self.problems) + if initial_z is None: + z = _np.zeros(self.n) + else: + z = _np.asarray(initial_z).reshape((self.n,)) + + us: List[_np.ndarray] = [_np.zeros(self.n) for _ in range(N)] + xs: List[_np.ndarray] = [_np.zeros(self.n) for _ in range(N)] + + # Precompute cholesky-like solve factors if Q is diagonalizable; for general Q we'll use numpy.solve + for it in range(self.max_iter): + max_diff = 0.0 + # x-update per agent + for i, lp in enumerate(self.problems): + M = lp.Q + self.rho * _np.eye(self.n) + rhs = self.rho * (z - us[i]) - lp.b + xi = _np.linalg.solve(M, rhs) + # apply simple bound projection + xi = _np.minimum(_np.maximum(xi, lp.lb), lp.ub) + xs[i] = xi + diff = _np.linalg.norm(xi - z) + if diff > max_diff: + max_diff = diff + # z-update (consensus) + z_new = sum((xs[i] + us[i]) for i in range(N)) / N + # dual update + for i in range(N): + us[i] = us[i] + xs[i] - z_new + z = z_new + if max_diff < self.tol: + break + return z, xs, us diff --git a/catopt_grid/bridge.py b/catopt_grid/bridge.py new file mode 100644 index 0000000..ff55f3a --- /dev/null +++ b/catopt_grid/bridge.py @@ -0,0 +1,90 @@ +"""CatOpt-Grid Bridge: Canonical Interoperability Layer (minimal). + +This module provides a small, production-friendly scaffold to map the +CatOpt-Grid primitives to a vendor-agnostic intermediate representation +(IR). It is intentionally lightweight and intended as a starting point for the +MVP wiring described in the community plan. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional +import time + +@dataclass +class PrivacyBudget: + budget: float + unit: str = "epsilon" + + +@dataclass +class AuditLogEntry: + message: str + timestamp: float = field(default_factory=lambda: time.time()) + signature: Optional[str] = None + + +@dataclass +class PlanDelta: + delta: List[float] + version: int + timestamp: float = field(default_factory=lambda: time.time()) + + +@dataclass +class IRObject: + """Canonical Object: a local optimization problem (per-asset task). + + This is intentionally aligned with LocalProblem from catopt_grid.core/admm_lite + to ease adoption. Users can extend the IR with extra metadata as needed. + """ + id: str + dimension: int + # Optional per-object metadata + metadata: Dict[str, object] = field(default_factory=dict) + + +@dataclass +class IRMorphism: + """Canonical Morphism: a channel carrying shared data among agents.""" + name: str + value: List[float] + version: int = 0 + + +class GraphOfContracts: + """Lightweight registry mapping adapters to their contract metadata. + + This is a very small stand-in for a more feature-rich contract registry + (GoC) that would live in a real deployment. It records versions and simple + schemas for cross-domain interoperability. + """ + + def __init__(self) -> None: + self._contracts: Dict[str, Dict[str, object]] = {} + + def register(self, adapter_name: str, contract: Dict[str, object]) -> None: + self._contracts[adapter_name] = { + "contract": contract, + "version": contract.get("version", 1), + "timestamp": time.time(), + } + + def get_contract(self, adapter_name: str) -> Optional[Dict[str, object]]: + return self._contracts.get(adapter_name, None) + + def list_contracts(self) -> List[Dict[str, object]]: + items = [] + for name, meta in self._contracts.items(): + items.append({"adapter": name, **meta}) + return sorted(items, key=lambda x: x.get("timestamp", 0)) + + +__all__ = [ + "PrivacyBudget", + "AuditLogEntry", + "PlanDelta", + "IRObject", + "IRMorphism", + "GraphOfContracts", +] diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..9713ef7 --- /dev/null +++ b/conftest.py @@ -0,0 +1,8 @@ +import sys +import os + +# Ensure the repository root is on Python path so tests can import the package +# even if pytest is invoked from a subdirectory or with an altered PYTHONPATH. +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +if ROOT_DIR not in sys.path: + sys.path.insert(0, ROOT_DIR) diff --git a/pyproject.toml b/pyproject.toml index 08f0c57..7c31c0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,14 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [project] name = "catopt-grid" -version = "0.1.0" -description = "Category-theoretic compositional optimizer skeleton for cross-domain distributed edge meshes (MVP)." +version = "0.0.1" +description = "Category-theoretic compositional optimizer MVP for cross-domain distributed optimization" readme = "README.md" -requires-python = ">=3.9" -license = {text = "MIT"} -authors = [{name = "OpenCode AI", email = "ops@example.com"}] -dependencies = [ - "numpy>=1.23", - "pydantic>=1.10", -] +requires-python = ">=3.8" +dependencies = ["numpy"] [tool.setuptools.packages.find] -where = ["catopt_grid"] +where = ["."] diff --git a/tests/test_admm.py b/tests/test_admm.py new file mode 100644 index 0000000..7c33c75 --- /dev/null +++ b/tests/test_admm.py @@ -0,0 +1,28 @@ +import numpy as _np +from catopt_grid import LocalProblem, ADMMLiteSolver + + +def test_admm_lite_two_agents_quadratic_convergence(): + # Two 1-D local problems with simple quadratics. + # f1(x) = 0.5 * 2 * x^2 + (-6) * x => Q1=2, b1=-6, bounds [0, 5] + Q1 = _np.array([[2.0]]) + b1 = _np.array([-6.0]) + lp1 = LocalProblem(n=1, Q=Q1, b=b1, lb=_np.array([0.0]), ub=_np.array([5.0]), name="lp1") + + # f2(x) = 0.5 * 1 * x^2 + (-2) * x => Q2=1, b2=-2, bounds [0, 5] + Q2 = _np.array([[1.0]]) + b2 = _np.array([-2.0]) + lp2 = LocalProblem(n=1, Q=Q2, b=b2, lb=_np.array([0.0]), ub=_np.array([5.0]), name="lp2") + + solver = ADMMLiteSolver([lp1, lp2], rho=1.0, max_iter=1000, tol=1e-6) + z, xs, us = solver.solve() + + # Check that the consensus value is within bounds and that x_i are close to each other + assert z.shape == (1,) + assert all(0.0 <= xi[0] <= 5.0 for xi in xs) + # Since both x_i converge to the same z, their difference should be small + diffs = [_np.linalg.norm(xs[i] - xs[0]) for i in range(1, len(xs))] + assert max(diffs) < 1e-4 + # And u_i should be finite + for ui in us: + assert _np.all(_np.isfinite(ui))