From 5ecc68b2da588ead7edb7e64a8cec11777f6169c Mon Sep 17 00:00:00 2001 From: Ray Okamoto Date: Thu, 12 Feb 2026 21:57:48 +1030 Subject: [PATCH] feat: improve typing throughout the codebase --- AGENTS.md | 37 +- environments/_template/env.py | 15 +- environments/genesis/env.py | 13 +- environments/metaworld/env.py | 19 +- kinitro/__init__.py | 5 + kinitro/api/routes/health.py | 4 +- kinitro/api/routes/miners.py | 31 +- kinitro/api/routes/scores.py | 20 +- kinitro/api/routes/tasks.py | 32 +- kinitro/api/routes/weights.py | 5 +- kinitro/backend/models.py | 46 ++- kinitro/backend/storage.py | 62 +-- kinitro/chain/__init__.py | 2 + kinitro/chain/commitments.py | 377 ++++++++++++------ kinitro/chain/weights.py | 40 +- kinitro/cli/crypto_commands.py | 14 +- kinitro/cli/db_commands.py | 2 +- kinitro/cli/env/commands.py | 3 +- kinitro/cli/miner/commitment.py | 174 +++++--- kinitro/cli/miner/deploy.py | 44 +- kinitro/cli/service_commands.py | 10 +- kinitro/cli/testing_commands.py | 12 +- kinitro/environments/base.py | 7 +- kinitro/environments/genesis/base.py | 27 +- .../environments/genesis/envs/g1_humanoid.py | 123 +++--- kinitro/environments/genesis/robot_config.py | 6 +- .../environments/genesis/scene_generator.py | 75 ++-- .../environments/genesis/task_generator.py | 36 +- kinitro/environments/genesis/task_types.py | 40 +- kinitro/environments/metaworld_env.py | 17 +- kinitro/environments/procedural.py | 10 +- kinitro/environments/registry.py | 17 +- kinitro/executor/api_client.py | 3 +- kinitro/executor/config.py | 7 +- kinitro/executor/env_loader.py | 6 +- kinitro/executor/family_worker.py | 5 +- kinitro/executor/verification.py | 17 +- kinitro/executor/worker.py | 8 +- kinitro/miner/template/env.py | 10 +- kinitro/miner/template/server.py | 6 +- kinitro/rl_interface.py | 47 +-- kinitro/scheduler/main.py | 39 +- kinitro/scheduler/scoring.py | 41 +- kinitro/scheduler/task_generator.py | 11 +- kinitro/scoring/pareto.py | 32 +- kinitro/scoring/threshold.py | 10 +- kinitro/scoring/winners_take_all.py | 54 ++- kinitro/types.py | 188 +++++++++ kinitro/validator/client.py | 4 +- kinitro/validator/main.py | 63 ++- pyproject.toml | 21 +- scripts/benchmark_genesis.py | 4 +- tests/unit/test_crypto.py | 45 ++- tests/unit/test_cycle_isolation.py | 8 +- tests/unit/test_genesis.py | 143 +++---- tests/unit/test_pareto.py | 242 ++++++----- tests/unit/test_procedural.py | 27 +- uv.lock | 141 +++---- 58 files changed, 1510 insertions(+), 997 deletions(-) create mode 100644 kinitro/types.py diff --git a/AGENTS.md b/AGENTS.md index 611b11a..41c87c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,13 @@ This file guides agentic coding tools working in this repo. Keep it current when commands or conventions change. ## Scope + - Project: Kinitro robotics evaluation subnet (Python 3.12). - Primary entry point: `kinitro` CLI (see `kinitro/cli/`). - Services: API, scheduler, executor, validator, miner tooling. ## Repo Map + - `demos/` demonstration scripts and examples. - `docs/` operator, validator, and miner guides. - `environments/` evaluation environments (MetaWorld, Genesis). @@ -27,44 +29,54 @@ Keep it current when commands or conventions change. - `kinitro/validator/` validator workflows. ### Key Modules + - `kinitro/cli/` CLI entry point and command groups. - `kinitro/config.py` runtime settings and configuration. - `kinitro/crypto.py` cryptography helpers. - `kinitro/rl_interface.py` RL interface utilities (Observation, Action, ProprioKeys, ActionKeys). ## Setup / Install + - Recommended: `uv sync` - Editable install: `pip install -e .` - Dev extras (if using pip): `pip install -e ".[dev]"` - Basilica CLI (for miner deployments): `curl -sSL https://basilica.ai/install.sh | bash` ## Common Commands + ### Lint + - `ruff check .` (checks entire project: kinitro/, tests/, environments/, scripts/, demos/) ### Type Check + - `ty check .` ### Tests + - `pytest tests/` - With MuJoCo: `MUJOCO_GL=egl pytest tests/` ### Single Test (examples) + - `pytest tests/unit/test_pareto.py::TestEpsilonDominates::test_clear_dominance` - `pytest tests/unit/test_pareto.py -k test_clear_dominance` - `pytest -k pareto tests/unit` ### CLI Examples + - List environments: `uv run kinitro env list` - Test an env: `uv run kinitro env test metaworld/pick-place-v3` - Build env image: `uv run kinitro env build --env-id metaworld/pick-place-v3 --tag my-env:v1` ## Git Hooks + - Hook script: `.githooks/pre-commit` invokes the `pre-commit` tool for ruff formatting/linting, then runs `ty` type checking. - Setup: `git config core.hooksPath .githooks && uv tool install pre-commit` - The hook uses `.pre-commit-config.yaml` for ruff rules via the pre-commit framework. ## Services (local dev) + - API: `uv run kinitro api --database-url postgresql://user:pass@host/db` - Scheduler: `uv run kinitro scheduler --netuid --network finney --database-url postgresql://user:pass@host/db` - Filter to specific environment families: `--env-families metaworld` or `--env-families metaworld,genesis` @@ -74,63 +86,80 @@ Keep it current when commands or conventions change. - Validator: `uv run kinitro validate --backend-url https://api.kinitro.ai --netuid --network finney` ## Backend Setup (operator quick start) + - Init DB: `uv run kinitro db init --database-url postgresql://user:pass@host/db` - DB status: `uv run kinitro db status --database-url postgresql://user:pass@host/db` ## Environment Config + - See `.env.example` for common env vars. - Runtime settings are read via Pydantic settings classes in `kinitro/config.py`. - Keep secrets out of the repo; do not commit `.env` files or any files listed in `.gitignore`. ## Code Style Guidelines + ### Imports + - Group imports: standard library, third-party, then local. - Keep imports at the top of the file (after module docstring if present). - Sort with Ruff `I` rules (no separate isort config). - Prefer explicit imports over wildcard imports. ### Formatting + - 4-space indentation. - Line length target: 100 (`ruff` ignores `E501` but still keep lines reasonable). - Use trailing commas in multi-line literals and call arguments. ### Typing + - Python 3.12 syntax (`list[int]`, `dict[str, float]`, `str | None`). -- Add return types to public functions and methods. +- Add type hints to all function signatures (parameters and return types), not just public ones. - Prefer `BaseSettings` and `BaseModel` type annotations for config/DTOs. - `ty` runs in CI; avoid `Any` unless required and explain why in code. +- Use `NewType` to distinguish domain identifiers that share an underlying primitive (e.g., `MinerUID`, `BlockNumber`, `EnvironmentId`, `Hotkey`). This prevents accidental misuse such as passing a `MinerUID` where a `BlockNumber` is expected. +- Centralize shared newtypes, type aliases, enums, and `TypedDict` definitions in `kinitro/types.py`. Import from there rather than re-defining types locally. +- When introducing a new domain concept that is fundamentally a `str`, `int`, or other primitive, create a `NewType` for it in `kinitro/types.py` and use it consistently across signatures, models, and data structures. +- Prefer `TypedDict` or `dataclasses.dataclass` over plain `dict` for structured data with known keys. ### Naming + - `snake_case` for functions, variables, modules. - `PascalCase` for classes, Pydantic models, and custom types. - `UPPER_CASE` for constants. - Test files: `test_*.py`; test classes: `Test*`. ### Error Handling + - CLI commands use `typer.Exit(1)` for user-facing failures. - Provide actionable error messages; log details with `structlog`. - Avoid broad `except Exception` unless you re-raise or return a clear error. ### Logging + - Use `structlog.get_logger()` and structured log fields. - Log at info/debug/warn/error levels consistently with existing patterns. ### Async and IO + - Use `async def` for I/O and DB operations. - Use `asyncio.run(...)` at CLI boundaries. - Use `asynccontextmanager` and session managers for DB work. ### Pydantic / API Models + - Pydantic models live in `kinitro/backend/models.py`. - Prefer `Field(...)` for validation constraints and docs. - Use `from_attributes = True` for ORM-to-model conversion. ### SQLAlchemy + - ORM models are in `kinitro/backend/models.py`. - Keep DB writes inside the storage layer (`kinitro/backend/storage.py`). - Use `select(...).where(...).limit(...)` patterns for queries. ### Tests + - Keep tests deterministic; use fixed seeds (`np.random.default_rng(42)`). - Use clear asserts; prefer `np.testing` for array comparisons. - Keep unit tests fast; integration tests go in `tests/integration`. @@ -140,6 +169,7 @@ Keep it current when commands or conventions change. For detailed testing docs and troubleshooting, see `docs/e2e-testing.md`. ### Quick Reference + ```bash # Start all services with mock miner (uses default ports) ./scripts/services.sh start --mock-miner @@ -156,13 +186,16 @@ For detailed testing docs and troubleshooting, see `docs/e2e-testing.md`. ``` For multi-worktree development (avoids port collisions between parallel checkouts): + ```bash ./scripts/worktree-env.sh # Generate isolated ports/database (once per worktree) ./scripts/services.sh start --all # Uses worktree-specific ports ``` ### API Endpoints + Note: The API prefix is `/v1/` (not `/api/v1/`). + - Health: `GET /health` - Miners: `GET /v1/miners` - Environments: `GET /v1/environments` @@ -171,6 +204,7 @@ Note: The API prefix is `/v1/` (not `/api/v1/`). - Task stats: `GET /v1/tasks/stats` ## Docs and References + - Developer overview: `README.md`. - Backend operator guide: `docs/backend-guide.md`. - Miner guide: `docs/miner-guide.md`. @@ -179,6 +213,7 @@ Note: The API prefix is `/v1/` (not `/api/v1/`). - **Adding new environments**: `environments/README.md` - Complete guide for integrating new robotics environments. ## Change Hygiene for Agents + - Do not modify files outside the task scope. - Avoid editing generated files and caches (e.g., `.ruff_cache/`). - Keep commits small and focused; update docs if behavior changes. diff --git a/environments/_template/env.py b/environments/_template/env.py index 7d4e49c..7d5599f 100644 --- a/environments/_template/env.py +++ b/environments/_template/env.py @@ -23,8 +23,10 @@ import structlog from kinitro.environments import get_environment +from kinitro.environments.base import RoboticsEnvironment from kinitro.environments.registry import get_environments_by_family from kinitro.rl_interface import Action +from kinitro.types import EnvironmentId logger = structlog.get_logger() @@ -36,7 +38,7 @@ def __init__(self): """Initialize the evaluation actor.""" self._env_cache = {} - def _get_env(self, env_id: str): + def _get_env(self, env_id: EnvironmentId) -> RoboticsEnvironment: """Get or create a robotics environment (lazy loading).""" if env_id not in self._env_cache: self._env_cache[env_id] = get_environment(env_id) @@ -67,7 +69,7 @@ async def _call_miner( resp.raise_for_status() return resp.json() - async def list_environments(self) -> list[str]: + async def list_environments(self) -> list[EnvironmentId]: """List available environments in this family.""" # TODO: Change "myenv" to your environment family prefix return get_environments_by_family("myenv") @@ -75,10 +77,10 @@ async def list_environments(self) -> list[str]: async def evaluate( self, task_id: int, + base_url: str, seed: int | None = None, model: str | None = None, - base_url: str | None = None, - env_id: str = "myenv/v0", # TODO: Change default env_id + env_id: EnvironmentId = EnvironmentId("myenv/v0"), # TODO: Change default env_id max_timesteps: int = 500, action_timeout: float = 0.5, use_images: bool = True, @@ -108,9 +110,6 @@ async def evaluate( error=f"Invalid env_id: {env_id}. Must start with 'myenv/'", ) - if base_url is None: - raise ValueError("base_url (miner endpoint) is required") - seed = seed if seed is not None else task_id start_time = time.time() @@ -207,7 +206,7 @@ async def evaluate( def _build_error_result( self, - env_id: str, + env_id: EnvironmentId, task_id: int, seed: int, start_time: float, diff --git a/environments/genesis/env.py b/environments/genesis/env.py index d07678f..084bb7a 100644 --- a/environments/genesis/env.py +++ b/environments/genesis/env.py @@ -32,6 +32,7 @@ from kinitro.environments.base import RoboticsEnvironment from kinitro.environments.registry import get_environments_by_family from kinitro.rl_interface import Action +from kinitro.types import EnvironmentId logger = structlog.get_logger() @@ -58,12 +59,12 @@ class Actor: def __init__(self) -> None: """Initialize the evaluation actor.""" self._env_cache = {} - self._env_locks: dict[str, asyncio.Lock] = {} + self._env_locks: dict[EnvironmentId, asyncio.Lock] = {} # Don't cache the HTTP client - create fresh for each evaluation # to avoid event loop binding issues when affinetes calls methods # from different event loops - def _get_env(self, env_id: str) -> RoboticsEnvironment: + def _get_env(self, env_id: EnvironmentId) -> RoboticsEnvironment: """Get or create a robotics environment.""" if env_id not in self._env_cache: self._env_cache[env_id] = get_environment(env_id) @@ -99,7 +100,7 @@ async def _call_miner( resp.raise_for_status() return resp.json() - async def list_environments(self) -> list[str]: + async def list_environments(self) -> list[EnvironmentId]: """List available Genesis environments.""" return get_environments_by_family("genesis") @@ -109,7 +110,7 @@ async def evaluate( base_url: str, seed: int | None = None, model: str | None = None, - env_id: str = "genesis/g1-v0", + env_id: EnvironmentId = EnvironmentId("genesis/g1-v0"), max_timesteps: int = 500, action_timeout: float = 0.5, use_images: bool = True, @@ -188,7 +189,7 @@ async def _run_evaluation( seed: int, model: str | None, base_url: str, - env_id: str, + env_id: EnvironmentId, max_timesteps: int, action_timeout: float, use_images: bool, @@ -322,7 +323,7 @@ async def _run_evaluation( def _build_error_result( self, - env_id: str, + env_id: EnvironmentId, task_id: int, seed: int, start_time: float, diff --git a/environments/metaworld/env.py b/environments/metaworld/env.py index 03543f3..83b1820 100644 --- a/environments/metaworld/env.py +++ b/environments/metaworld/env.py @@ -37,8 +37,10 @@ # Import from kinitro package (installed in container via PYTHONPATH) from kinitro.environments import get_environment +from kinitro.environments.base import RoboticsEnvironment from kinitro.environments.registry import get_environments_by_family from kinitro.rl_interface import Action +from kinitro.types import EnvironmentId logger = structlog.get_logger() @@ -58,7 +60,7 @@ def __init__(self): # to avoid event loop binding issues when affinetes calls methods # from different event loops - def _get_env(self, env_id: str): + def _get_env(self, env_id: EnvironmentId) -> RoboticsEnvironment: """Get or create a robotics environment.""" if env_id not in self._env_cache: self._env_cache[env_id] = get_environment(env_id) @@ -91,17 +93,17 @@ async def _call_miner( resp.raise_for_status() return resp.json() - async def list_environments(self) -> list[str]: + async def list_environments(self) -> list[EnvironmentId]: """List available MetaWorld environments.""" return get_environments_by_family("metaworld") async def evaluate( self, task_id: int, + base_url: str, seed: int | None = None, model: str | None = None, - base_url: str | None = None, - env_id: str = "metaworld/pick-place-v3", + env_id: EnvironmentId = EnvironmentId("metaworld/pick-place-v3"), max_timesteps: int = 500, action_timeout: float = 0.5, use_images: bool = True, @@ -148,9 +150,6 @@ async def evaluate( error=f"Invalid env_id for MetaWorld container: {env_id}. Must start with 'metaworld/'", ) - if base_url is None: - raise ValueError("base_url (miner endpoint) is required") - if seed is None: seed = task_id @@ -184,7 +183,7 @@ async def _run_evaluation( seed: int, model: str | None, base_url: str, - env_id: str, + env_id: EnvironmentId, max_timesteps: int, action_timeout: float, use_images: bool, @@ -318,7 +317,7 @@ async def _run_evaluation( def _build_error_result( self, - env_id: str, + env_id: EnvironmentId, task_id: int, seed: int, start_time: float, @@ -342,7 +341,7 @@ def _build_error_result( "extra": extra_fields, } - async def cleanup(self): + async def cleanup(self) -> None: """Cleanup resources.""" # HTTP clients are now created per-request, no need to close here diff --git a/kinitro/__init__.py b/kinitro/__init__.py index 6099a81..cf2b6e5 100644 --- a/kinitro/__init__.py +++ b/kinitro/__init__.py @@ -6,4 +6,9 @@ environments earn rewards via ε-Pareto dominance scoring. """ +import os + +# https://docs.learnbittensor.org/sdk/migration-guide#disabling-cli-argument-parsing +os.environ.setdefault("BT_NO_PARSE_CLI_ARGS", "1") + __version__ = "0.1.0" diff --git a/kinitro/api/routes/health.py b/kinitro/api/routes/health.py index 289bd1e..e387005 100644 --- a/kinitro/api/routes/health.py +++ b/kinitro/api/routes/health.py @@ -17,7 +17,7 @@ @router.get("/health", response_model=HealthResponse) -async def health_check(session: AsyncSession = Depends(get_session)): +async def health_check(session: AsyncSession = Depends(get_session)) -> HealthResponse: """Health check endpoint.""" try: await session.execute(text("SELECT 1")) @@ -35,7 +35,7 @@ async def health_check(session: AsyncSession = Depends(get_session)): async def get_status( session: AsyncSession = Depends(get_session), storage: Storage = Depends(get_storage), -): +) -> StatusResponse: """Get current backend status.""" # Get current/latest cycles current_cycle = await storage.get_running_cycle(session) diff --git a/kinitro/api/routes/miners.py b/kinitro/api/routes/miners.py index 86702dd..081d024 100644 --- a/kinitro/api/routes/miners.py +++ b/kinitro/api/routes/miners.py @@ -7,6 +7,7 @@ from kinitro.backend.models import EnvironmentInfo, MinerInfo from kinitro.backend.storage import Storage from kinitro.environments import get_all_environment_ids +from kinitro.types import EnvironmentId, EnvStatsEntry, Hotkey, MinerUID router = APIRouter(prefix="/v1", tags=["Miners & Environments"]) @@ -15,7 +16,7 @@ async def list_miners( session: AsyncSession = Depends(get_session), storage: Storage = Depends(get_storage), -): +) -> list[MinerInfo]: """List all miners that have been evaluated.""" # Get latest cycle's scores to get miner info cycle = await storage.get_latest_cycle(session, completed_only=True) @@ -25,21 +26,22 @@ async def list_miners( scores = await storage.get_scores_for_cycle(session, cycle.id) # Aggregate by miner - miners_dict: dict[int, MinerInfo] = {} + miners_dict: dict[MinerUID, MinerInfo] = {} for s in scores: - if s.uid not in miners_dict: - miners_dict[s.uid] = MinerInfo( - uid=s.uid, - hotkey=s.hotkey, + uid = MinerUID(s.uid) + if uid not in miners_dict: + miners_dict[uid] = MinerInfo( + uid=uid, + hotkey=Hotkey(s.hotkey), last_evaluated_block=cycle.block_number, avg_success_rate=0.0, environments_evaluated=[], ) - miners_dict[s.uid].environments_evaluated.append(s.env_id) + miners_dict[uid].environments_evaluated.append(EnvironmentId(s.env_id)) # Calculate average success rate per miner for uid, miner in miners_dict.items(): - miner_scores = [s.success_rate for s in scores if s.uid == uid] + miner_scores = [s.success_rate for s in scores if s.uid == int(uid)] if miner_scores: miner.avg_success_rate = sum(miner_scores) / len(miner_scores) @@ -50,21 +52,24 @@ async def list_miners( async def list_environments( session: AsyncSession = Depends(get_session), storage: Storage = Depends(get_storage), -): +) -> list[EnvironmentInfo]: """List all evaluation environments.""" env_ids = get_all_environment_ids() # Get latest cycle for stats cycle = await storage.get_latest_cycle(session, completed_only=True) - env_stats: dict[str, dict] = {env_id: {"count": 0, "total_sr": 0.0} for env_id in env_ids} + env_stats: dict[EnvironmentId, EnvStatsEntry] = { + env_id: EnvStatsEntry(count=0, total_sr=0.0) for env_id in env_ids + } if cycle: scores = await storage.get_scores_for_cycle(session, cycle.id) for s in scores: - if s.env_id in env_stats: - env_stats[s.env_id]["count"] += 1 - env_stats[s.env_id]["total_sr"] += s.success_rate + eid = EnvironmentId(s.env_id) + if eid in env_stats: + env_stats[eid]["count"] += 1 + env_stats[eid]["total_sr"] += s.success_rate result = [] for env_id in env_ids: diff --git a/kinitro/api/routes/scores.py b/kinitro/api/routes/scores.py index b374b92..202b6d0 100644 --- a/kinitro/api/routes/scores.py +++ b/kinitro/api/routes/scores.py @@ -12,6 +12,7 @@ ScoresResponse, ) from kinitro.backend.storage import Storage +from kinitro.types import EnvironmentId, Hotkey, MinerUID router = APIRouter(prefix="/v1/scores", tags=["Scores"]) @@ -22,9 +23,9 @@ def _build_scores_response( """Build a ScoresResponse from a cycle ORM object and its scores.""" scores = [ MinerScore( - uid=s.uid, - hotkey=s.hotkey, - env_id=s.env_id, + uid=MinerUID(s.uid), + hotkey=Hotkey(s.hotkey), + env_id=EnvironmentId(s.env_id), success_rate=s.success_rate, mean_reward=s.mean_reward, episodes_completed=s.episodes_completed, @@ -33,11 +34,12 @@ def _build_scores_response( for s in scores_orm ] - miner_summary: dict[int, dict[str, float]] = {} + miner_summary: dict[MinerUID, dict[EnvironmentId, float]] = {} for s in scores_orm: - if s.uid not in miner_summary: - miner_summary[s.uid] = {} - miner_summary[s.uid][s.env_id] = s.success_rate + uid = MinerUID(s.uid) + if uid not in miner_summary: + miner_summary[uid] = {} + miner_summary[uid][EnvironmentId(s.env_id)] = s.success_rate return ScoresResponse( cycle=EvaluationCycle.model_validate(cycle), @@ -50,7 +52,7 @@ def _build_scores_response( async def get_latest_scores( session: AsyncSession = Depends(get_session), storage: Storage = Depends(get_storage), -): +) -> ScoresResponse: """Get scores from the most recent completed evaluation cycle.""" cycle = await storage.get_latest_cycle(session, completed_only=True) if cycle is None: @@ -65,7 +67,7 @@ async def get_scores_for_cycle( cycle_id: int, session: AsyncSession = Depends(get_session), storage: Storage = Depends(get_storage), -): +) -> ScoresResponse: """Get scores for a specific evaluation cycle.""" cycle = await storage.get_cycle(session, cycle_id) if cycle is None: diff --git a/kinitro/api/routes/tasks.py b/kinitro/api/routes/tasks.py index fa2f84e..ddfb6a8 100644 --- a/kinitro/api/routes/tasks.py +++ b/kinitro/api/routes/tasks.py @@ -9,10 +9,12 @@ TaskFetchRequest, TaskFetchResponse, TaskPoolStats, + TaskStatus, TaskSubmitRequest, TaskSubmitResponse, ) from kinitro.backend.storage import Storage +from kinitro.types import EnvironmentId, Hotkey, MinerUID, Seed, TaskUUID router = APIRouter(prefix="/v1/tasks", tags=["Tasks"]) @@ -23,7 +25,7 @@ async def fetch_tasks( session: AsyncSession = Depends(get_session), storage: Storage = Depends(get_storage), _auth: None = Depends(verify_api_key), -): +) -> TaskFetchResponse: """ Fetch tasks from the task pool. @@ -46,16 +48,16 @@ async def fetch_tasks( tasks = [ Task( - task_uuid=t.task_uuid, + task_uuid=TaskUUID(t.task_uuid), cycle_id=t.cycle_id, - miner_uid=t.miner_uid, - miner_hotkey=t.miner_hotkey, + miner_uid=MinerUID(t.miner_uid), + miner_hotkey=Hotkey(t.miner_hotkey), miner_endpoint=t.miner_endpoint, miner_repo=t.miner_repo, miner_revision=t.miner_revision, - env_id=t.env_id, - seed=t.seed, - status=t.status, + env_id=EnvironmentId(t.env_id), + seed=Seed(t.seed), + status=TaskStatus(t.status), created_at=t.created_at, ) for t in tasks_orm @@ -73,7 +75,7 @@ async def submit_tasks( session: AsyncSession = Depends(get_session), storage: Storage = Depends(get_storage), _auth: None = Depends(verify_api_key), -): +) -> TaskSubmitResponse: """ Submit results for completed tasks. @@ -117,7 +119,7 @@ async def get_task_stats( cycle_id: int | None = None, session: AsyncSession = Depends(get_session), storage: Storage = Depends(get_storage), -): +) -> TaskPoolStats: """ Get task pool statistics. @@ -129,14 +131,4 @@ async def get_task_stats( running_cycle = await storage.get_running_cycle(session) cycle_id = running_cycle.id if running_cycle else None - stats = await storage.get_task_pool_stats(session, cycle_id=cycle_id) - - return TaskPoolStats( - total_tasks=stats["total_tasks"], - pending_tasks=stats["pending_tasks"], - assigned_tasks=stats["assigned_tasks"], - completed_tasks=stats["completed_tasks"], - failed_tasks=stats["failed_tasks"], - active_executors=stats["active_executors"], - current_cycle_id=stats["current_cycle_id"], - ) + return await storage.get_task_pool_stats(session, cycle_id=cycle_id) diff --git a/kinitro/api/routes/weights.py b/kinitro/api/routes/weights.py index 989024b..669906e 100644 --- a/kinitro/api/routes/weights.py +++ b/kinitro/api/routes/weights.py @@ -11,6 +11,7 @@ WeightsU16, ) from kinitro.backend.storage import Storage +from kinitro.types import MinerUID router = APIRouter(prefix="/v1/weights", tags=["Weights"]) @@ -23,9 +24,9 @@ def _build_weights_response( cycle_id=weights_orm.cycle_id, block_number=weights_orm.block_number, timestamp=weights_orm.created_at, - weights={int(k): float(v) for k, v in weights_orm.weights_json.items()}, + weights={MinerUID(int(k)): float(v) for k, v in weights_orm.weights_json.items()}, weights_u16=WeightsU16( - uids=weights_orm.weights_u16_json["uids"], + uids=[MinerUID(u) for u in weights_orm.weights_u16_json["uids"]], values=weights_orm.weights_u16_json["values"], ), metadata={ diff --git a/kinitro/backend/models.py b/kinitro/backend/models.py index 0236aac..d54a4d8 100644 --- a/kinitro/backend/models.py +++ b/kinitro/backend/models.py @@ -10,6 +10,8 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from kinitro.types import EnvironmentId, Hotkey, MinerUID, Seed, TaskUUID + def generate_task_uuid() -> str: """Generate a unique task UUID.""" @@ -166,6 +168,7 @@ class TaskPoolORM(Base): assigned_to: Mapped[str | None] = mapped_column(String(64), nullable=True) assigned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # Any: JSONB column — schema varies by environment/task type result: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=func.now() @@ -198,9 +201,9 @@ class HealthResponse(BaseModel): class MinerScore(BaseModel): """Score for one miner on one environment.""" - uid: int - hotkey: str - env_id: str + uid: MinerUID + hotkey: Hotkey + env_id: EnvironmentId success_rate: float mean_reward: float episodes_completed: int @@ -214,7 +217,7 @@ class EvaluationCycle(BaseModel): block_number: int started_at: datetime completed_at: datetime | None - status: str + status: EvaluationCycleStatus n_miners: int | None n_environments: int | None duration_seconds: float | None @@ -230,7 +233,7 @@ class ScoresResponse(BaseModel): scores: list[MinerScore] # Aggregated by miner - miner_summary: dict[int, dict[str, float]] = Field( + miner_summary: dict[MinerUID, dict[EnvironmentId, float]] = Field( default_factory=dict, description="Aggregated scores per miner: {uid: {env_id: success_rate}}", ) @@ -239,7 +242,7 @@ class ScoresResponse(BaseModel): class WeightsU16(BaseModel): """Weights in u16 format for chain submission.""" - uids: list[int] + uids: list[MinerUID] values: list[int] @@ -249,8 +252,9 @@ class WeightsResponse(BaseModel): cycle_id: int block_number: int timestamp: datetime - weights: dict[int, float] = Field(description="Normalized weights: {uid: weight}") + weights: dict[MinerUID, float] = Field(description="Normalized weights: {uid: weight}") weights_u16: WeightsU16 = Field(description="Weights formatted for chain submission") + # Any: open-ended metadata for extensibility (timestamps, debug info, etc.) metadata: dict[str, Any] = Field(default_factory=dict) @@ -261,24 +265,24 @@ class StatusResponse(BaseModel): last_completed_cycle: EvaluationCycle | None total_cycles: int total_miners_evaluated: int - environments: list[str] + environments: list[EnvironmentId] is_evaluating: bool class MinerInfo(BaseModel): """Information about a miner.""" - uid: int - hotkey: str + uid: MinerUID + hotkey: Hotkey last_evaluated_block: int | None avg_success_rate: float | None - environments_evaluated: list[str] + environments_evaluated: list[EnvironmentId] class EnvironmentInfo(BaseModel): """Information about an evaluation environment.""" - env_id: str + env_id: EnvironmentId env_name: str task_name: str n_evaluations: int @@ -293,16 +297,16 @@ class EnvironmentInfo(BaseModel): class Task(BaseModel): """A single evaluation task from the task pool.""" - task_uuid: str # Unique identifier for API calls + task_uuid: TaskUUID # Unique identifier for API calls cycle_id: int - miner_uid: int - miner_hotkey: str + miner_uid: MinerUID + miner_hotkey: Hotkey miner_endpoint: str miner_repo: str | None = None # HuggingFace repo for verification miner_revision: str | None = None # HuggingFace revision for verification - env_id: str - seed: int # Deterministic seed for reproducibility - status: str + env_id: EnvironmentId + seed: Seed # Deterministic seed for reproducibility + status: TaskStatus created_at: datetime class Config: @@ -314,7 +318,9 @@ class TaskFetchRequest(BaseModel): executor_id: str = Field(description="Unique identifier for the executor") batch_size: int = Field(default=10, ge=1, le=100, description="Number of tasks to fetch") - env_ids: list[str] | None = Field(default=None, description="Filter by environment IDs") + env_ids: list[EnvironmentId] | None = Field( + default=None, description="Filter by environment IDs" + ) class TaskFetchResponse(BaseModel): @@ -327,7 +333,7 @@ class TaskFetchResponse(BaseModel): class TaskResult(BaseModel): """Result of a single task execution.""" - task_uuid: str = Field(description="UUID of the task") + task_uuid: TaskUUID = Field(description="UUID of the task") success: bool score: float = Field(default=0.0) total_reward: float = Field(default=0.0) diff --git a/kinitro/backend/storage.py b/kinitro/backend/storage.py index 81dda7d..09b6b86 100644 --- a/kinitro/backend/storage.py +++ b/kinitro/backend/storage.py @@ -15,8 +15,18 @@ EvaluationCycleStatus, MinerScoreORM, TaskPoolORM, + TaskPoolStats, TaskStatus, ) +from kinitro.types import ( + EnvironmentId, + Hotkey, + MinerScoreData, + MinerUID, + Seed, + TaskCreateData, + TaskUUID, +) logger = structlog.get_logger() @@ -182,9 +192,9 @@ async def add_miner_score( self, session: AsyncSession, cycle_id: int, - uid: int, - hotkey: str, - env_id: str, + uid: MinerUID, + hotkey: Hotkey, + env_id: EnvironmentId, success_rate: float, mean_reward: float, episodes_completed: int, @@ -208,7 +218,7 @@ async def add_miner_scores_bulk( self, session: AsyncSession, cycle_id: int, - scores: list[dict], + scores: list[MinerScoreData], ) -> None: """Bulk add miner scores.""" for score_data in scores: @@ -230,7 +240,7 @@ async def get_scores_for_cycle( async def get_miner_history( self, session: AsyncSession, - uid: int, + uid: MinerUID, limit: int = 10, ) -> list[MinerScoreORM]: """Get recent scores for a specific miner.""" @@ -256,14 +266,14 @@ async def save_weights( session: AsyncSession, cycle_id: int, block_number: int, - weights: dict[int, float], + weights: dict[MinerUID, float], weights_u16: dict[str, list[int]], ) -> ComputedWeightsORM: """Save computed weights for a cycle.""" weights_orm = ComputedWeightsORM( cycle_id=cycle_id, block_number=block_number, - weights_json=weights, + weights_json={str(k): v for k, v in weights.items()}, weights_u16_json=weights_u16, created_at=datetime.now(timezone.utc), ) @@ -302,12 +312,12 @@ async def create_task( self, session: AsyncSession, cycle_id: int, - miner_uid: int, - miner_hotkey: str, + miner_uid: MinerUID, + miner_hotkey: Hotkey, miner_endpoint: str, - env_id: str, - seed: int, - task_uuid: str | None = None, + env_id: EnvironmentId, + seed: Seed, + task_uuid: TaskUUID | None = None, ) -> TaskPoolORM: """Create a new task in the task pool.""" # Build kwargs, only including task_uuid if provided @@ -331,7 +341,7 @@ async def create_task( async def create_tasks_bulk( self, session: AsyncSession, - tasks: list[dict], + tasks: list[TaskCreateData], ) -> int: """Bulk create tasks in the task pool. @@ -370,7 +380,7 @@ async def fetch_tasks( session: AsyncSession, executor_id: str, batch_size: int = 10, - env_ids: list[str] | None = None, + env_ids: list[EnvironmentId] | None = None, ) -> list[TaskPoolORM]: """Fetch and assign tasks to an executor. @@ -418,7 +428,7 @@ async def fetch_tasks( async def submit_task_result( self, session: AsyncSession, - task_uuid: str, + task_uuid: TaskUUID, executor_id: str, success: bool, score: float, @@ -493,7 +503,7 @@ async def get_task_pool_stats( self, session: AsyncSession, cycle_id: int | None = None, - ) -> dict: + ) -> TaskPoolStats: """Get statistics about the task pool. Args: @@ -501,7 +511,7 @@ async def get_task_pool_stats( cycle_id: Optional filter by cycle ID Returns: - Dict with task pool statistics + TaskPoolStats with task pool statistics """ # Base query base_filter = TaskPoolORM.cycle_id == cycle_id if cycle_id is not None else None @@ -523,15 +533,15 @@ async def get_task_pool_stats( result = await session.execute(assigned_query) active_executors = [r for r in result.scalars().all() if r is not None] - return { - "total_tasks": sum(status_counts.values()), - "pending_tasks": status_counts.get(TaskStatus.PENDING.value, 0), - "assigned_tasks": status_counts.get(TaskStatus.ASSIGNED.value, 0), - "completed_tasks": status_counts.get(TaskStatus.COMPLETED.value, 0), - "failed_tasks": status_counts.get(TaskStatus.FAILED.value, 0), - "active_executors": active_executors, - "current_cycle_id": cycle_id, - } + return TaskPoolStats( + total_tasks=sum(status_counts.values()), + pending_tasks=status_counts.get(TaskStatus.PENDING.value, 0), + assigned_tasks=status_counts.get(TaskStatus.ASSIGNED.value, 0), + completed_tasks=status_counts.get(TaskStatus.COMPLETED.value, 0), + failed_tasks=status_counts.get(TaskStatus.FAILED.value, 0), + active_executors=active_executors, + current_cycle_id=cycle_id, + ) async def count_pending_tasks( self, diff --git a/kinitro/chain/__init__.py b/kinitro/chain/__init__.py index c4d562f..7eec8c5 100644 --- a/kinitro/chain/__init__.py +++ b/kinitro/chain/__init__.py @@ -9,6 +9,7 @@ commit_model, parse_commitment, read_miner_commitments, + read_miner_commitments_async, ) from kinitro.chain.weights import set_weights, weights_to_u16 @@ -17,6 +18,7 @@ "commit_model", "parse_commitment", "read_miner_commitments", + "read_miner_commitments_async", "set_weights", "weights_to_u16", ] diff --git a/kinitro/chain/commitments.py b/kinitro/chain/commitments.py index b0f2f43..ecc7100 100644 --- a/kinitro/chain/commitments.py +++ b/kinitro/chain/commitments.py @@ -11,14 +11,18 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Any, cast import structlog +from bittensor import AsyncSubtensor, NeuronInfo, Subtensor +from bittensor_wallet import Wallet from cryptography.hazmat.primitives.asymmetric import x25519 from kinitro.crypto import ( decrypt_deployment_id, encrypt_deployment_id, ) +from kinitro.types import BlockNumber, Hotkey, MinerUID, ParsedCommitment logger = structlog.get_logger() @@ -49,13 +53,13 @@ class MinerCommitment: deployment_id is populated after decryption """ - uid: int - hotkey: str + uid: MinerUID + hotkey: Hotkey huggingface_repo: str revision_sha: str deployment_id: str # Basilica deployment ID (UUID, not full URL) - decrypted if encrypted docker_image: str - committed_block: int + committed_block: BlockNumber encrypted_deployment: str | None = field(default=None) # Base85 encrypted blob (if encrypted) @property @@ -93,7 +97,7 @@ def needs_decryption(self) -> bool: return bool(self.encrypted_deployment) and not bool(self.deployment_id) -def parse_commitment(raw: str) -> dict: +def parse_commitment(raw: str) -> ParsedCommitment: """ Parse raw commitment string from chain. @@ -152,8 +156,78 @@ def parse_commitment(raw: str) -> dict: } +def _parse_commitment_result(result: Any) -> tuple[str | None, int | None]: + """Parse a raw commitment query result into (commitment_string, block_number). + + ``result`` comes from ``subtensor.query_module("Commitments", ...)`` which + returns an untyped substrate response. In practice this is either: + - ``None`` when no commitment exists + - A ``dict`` with keys ``"deposit"``, ``"block"``, ``"info"`` + - A SCALE-decoded object exposing a ``.value`` attribute (dict payload) + We accept ``Any`` because the bittensor SDK does not export a concrete type + for substrate query results. + """ + if result is None: + return None, None + + if isinstance(result, dict): + data = result + elif hasattr(result, "value"): + data = result.value + else: + return None, None + + if not data: + return None, None + + # Handle structured commitment format: + # {'deposit': ..., 'block': ..., 'info': {'fields': ...}} + if isinstance(data, dict) and "info" in data: + data_dict = cast(dict[str, Any], data) + block = data_dict.get("block") + if block is not None: + try: + block = int(block) + except (TypeError, ValueError): + block = None + + info = data_dict.get("info", {}) + fields = info.get("fields", ()) if isinstance(info, dict) else () + + if fields and len(fields) > 0: + first_field = fields[0] + + if isinstance(first_field, tuple) and len(first_field) > 0: + first_field = first_field[0] + + if isinstance(first_field, dict): + for key, value in first_field.items(): + if key.startswith("Raw") or key == "Data": + if isinstance(value, tuple) and len(value) > 0: + byte_data = value[0] + if isinstance(byte_data, (list, tuple)): + return bytes(byte_data).decode("utf-8", errors="ignore"), block + elif isinstance(value, (bytes, bytearray)): + return value.decode("utf-8", errors="ignore"), block + elif isinstance(value, str): + return value, block + elif isinstance(first_field, (bytes, bytearray)): + return first_field.decode("utf-8", errors="ignore"), block + elif isinstance(first_field, str): + return first_field, block + + elif isinstance(data, (bytes, bytearray)): + return data.decode("utf-8", errors="ignore"), None + elif isinstance(data, str): + return data, None + + return None, None + + def _query_commitment_by_hotkey( - subtensor, netuid: int, hotkey: str + subtensor: Subtensor, + netuid: int, + hotkey: str, ) -> tuple[str | None, int | None]: """ Query commitment directly from chain storage by hotkey. @@ -170,78 +244,30 @@ def _query_commitment_by_hotkey( """ try: result = subtensor.substrate.query("Commitments", "CommitmentOf", [netuid, hotkey]) + return _parse_commitment_result(result) + except Exception as e: + logger.debug("commitment_query_failed", hotkey=hotkey[:16], error=str(e)) + return None, None - # Handle different result types - if result is None: - return None, None - - # Result might be a dict directly (newer substrate interface) - if isinstance(result, dict): - data = result - elif hasattr(result, "value"): - data = result.value - else: - return None, None - - if not data: - return None, None - - # Handle structured commitment format: {'deposit': ..., 'block': ..., 'info': {'fields': ...}} - if isinstance(data, dict) and "info" in data: - # Extract block number from commitment data - block = data.get("block") - if block is not None: - try: - block = int(block) - except (TypeError, ValueError): - block = None - - info = data.get("info", {}) - fields = info.get("fields", ()) - - if fields and len(fields) > 0: - # First field contains the raw data - first_field = fields[0] - - # Handle tuple format: ({'Raw94': ((bytes...),)},) - if isinstance(first_field, tuple) and len(first_field) > 0: - first_field = first_field[0] - - # Extract bytes from various formats - if isinstance(first_field, dict): - # Format: {'RawXX': ((bytes...),)} or {'Data': bytes} - for key, value in first_field.items(): - if key.startswith("Raw") or key == "Data": - # Extract the bytes tuple - if isinstance(value, tuple) and len(value) > 0: - byte_data = value[0] - if isinstance(byte_data, (list, tuple)): - return bytes(byte_data).decode("utf-8", errors="ignore"), block - elif isinstance(value, (bytes, bytearray)): - return value.decode("utf-8", errors="ignore"), block - elif isinstance(value, str): - return value, block - elif isinstance(first_field, (bytes, bytearray)): - return first_field.decode("utf-8", errors="ignore"), block - elif isinstance(first_field, str): - return first_field, block - - # Handle simple formats (no block info available) - elif isinstance(data, (bytes, bytearray)): - return data.decode("utf-8", errors="ignore"), None - elif isinstance(data, str): - return data, None - return None, None +async def _query_commitment_by_hotkey_async( + subtensor: AsyncSubtensor, + netuid: int, + hotkey: str, +) -> tuple[str | None, int | None]: + """Async version of :func:`_query_commitment_by_hotkey`.""" + try: + result = await subtensor.query_module("Commitments", "CommitmentOf", [netuid, hotkey]) + return _parse_commitment_result(result) except Exception as e: logger.debug("commitment_query_failed", hotkey=hotkey[:16], error=str(e)) return None, None def read_miner_commitments( - subtensor, # bt.Subtensor + subtensor: Subtensor, netuid: int, - neurons: list | None = None, # List of NeuronInfo (optional, will fetch if not provided) + neurons: list[NeuronInfo] | None = None, backend_private_key: x25519.X25519PrivateKey | None = None, ) -> list[MinerCommitment]: """ @@ -309,13 +335,90 @@ def read_miner_commitments( # can retry later with a different key if needed. commitment = MinerCommitment( - uid=uid, - hotkey=hotkey, + uid=MinerUID(uid), + hotkey=Hotkey(hotkey), + huggingface_repo=parsed["huggingface_repo"], + revision_sha=parsed["revision_sha"], + deployment_id=deployment_id, + docker_image=parsed["docker_image"], + committed_block=BlockNumber(committed_block), + encrypted_deployment=encrypted_deployment, + ) + if commitment.is_valid: + commitments.append(commitment) + logger.debug( + "found_commitment", + uid=uid, + repo=commitment.huggingface_repo, + block=committed_block, + encrypted=commitment.is_encrypted, + ) + except Exception as e: + logger.warning("commitment_read_failed", uid=uid, error=str(e)) + + logger.info("commitments_loaded", count=len(commitments), total_miners=n_neurons) + return commitments + + +async def read_miner_commitments_async( + subtensor: AsyncSubtensor, + netuid: int, + neurons: list[NeuronInfo] | None = None, + backend_private_key: x25519.X25519PrivateKey | None = None, +) -> list[MinerCommitment]: + """Async version of :func:`read_miner_commitments`. + + Uses :class:`AsyncSubtensor` for non-blocking chain I/O. + """ + if neurons is None: + neurons = await subtensor.neurons(netuid=netuid) + + if not neurons: + logger.warning("no_neurons_on_subnet", netuid=netuid) + return [] + + commitments = [] + n_neurons = len(neurons) + + for neuron in neurons: + uid = neuron.uid + hotkey = neuron.hotkey + + try: + raw, block = await _query_commitment_by_hotkey_async(subtensor, netuid, hotkey) + + if raw: + parsed = parse_commitment(raw) + committed_block = block if block is not None else neuron.last_update + + deployment_id = parsed["deployment_id"] + encrypted_deployment = parsed.get("encrypted_deployment") + + if encrypted_deployment and backend_private_key: + try: + deployment_id = decrypt_deployment_id( + encrypted_deployment, backend_private_key + ) + logger.debug( + "decrypted_endpoint", + uid=uid, + deployment_id=deployment_id[:12] + "...", + ) + except ValueError as e: + logger.warning( + "decryption_failed", + uid=uid, + error=str(e), + ) + + commitment = MinerCommitment( + uid=MinerUID(uid), + hotkey=Hotkey(hotkey), huggingface_repo=parsed["huggingface_repo"], revision_sha=parsed["revision_sha"], deployment_id=deployment_id, docker_image=parsed["docker_image"], - committed_block=committed_block, + committed_block=BlockNumber(committed_block), encrypted_deployment=encrypted_deployment, ) if commitment.is_valid: @@ -369,46 +472,18 @@ def decrypt_commitments( return commitments -def commit_model( - subtensor, # bt.Subtensor - wallet, # bt.Wallet - netuid: int, +def _build_commitment_data( repo: str, revision: str, deployment_id: str, backend_public_key: str | None = None, -) -> bool: - """ - Commit model info to chain using compact colon-separated format. +) -> str | None: + """Build the colon-separated commitment string. - This is called by miners to register their model. - - Format: - - Plain: "user/repo:rev8char:uuid" (~67 bytes for 30-char repo) - - Encrypted: "user/repo:rev8char:e:" (~121 bytes for 30-char repo) - - The 128-byte chain limit allows repo names up to ~37 chars for encrypted - commitments or ~97 chars for plain commitments. - - Args: - subtensor: Bittensor subtensor connection - wallet: Miner's wallet - netuid: Subnet UID - repo: HuggingFace repository (user/model), max ~37 chars for encrypted mode - revision: Commit SHA (will be truncated to 8 chars) - deployment_id: Basilica deployment ID (UUID only, not full URL) - backend_public_key: Optional hex-encoded X25519 public key for encrypting endpoint. - If provided, the deployment_id will be encrypted so only - the backend operator can decrypt it. - - Returns: - True if commitment succeeded + Returns the commitment data string, or None if validation/encryption fails. """ - # Truncate revision to 8 chars to fit within MAX_COMMITMENT_SIZE (128 bytes). - # HuggingFace supports short SHA resolution like git. revision_short = revision[:8] - # Validate no colons in fields (would break colon-separated format) if ":" in repo or ":" in revision_short or ":" in deployment_id: logger.error( "commitment_field_contains_colon", @@ -416,14 +491,11 @@ def commit_model( revision=revision_short, deployment_id=deployment_id, ) - return False + return None - # Build commitment data using colon-separated format (more compact than JSON) if backend_public_key: - # Encrypt the deployment ID try: encrypted_blob = encrypt_deployment_id(deployment_id, backend_public_key) - # Format: repo:revision:e: commitment_data = f"{repo}:{revision_short}:e:{encrypted_blob}" logger.info( "commitment_encrypted", @@ -432,14 +504,11 @@ def commit_model( ) except Exception as e: logger.exception("encryption_failed", error=str(e)) - return False + return None else: - # Plain commitment (deployment_id visible on-chain) - # Format: repo:revision:uuid commitment_data = f"{repo}:{revision_short}:{deployment_id}" logger.info("commitment_data", data=commitment_data, length=len(commitment_data)) - # Validate commitment size fits chain limit if len(commitment_data) > MAX_COMMITMENT_SIZE: logger.error( "commitment_too_large", @@ -447,6 +516,48 @@ def commit_model( max_size=MAX_COMMITMENT_SIZE, repo_length=len(repo), ) + return None + + return commitment_data + + +def commit_model( + subtensor: Subtensor, + wallet: Wallet, + netuid: int, + repo: str, + revision: str, + deployment_id: str, + backend_public_key: str | None = None, +) -> bool: + """ + Commit model info to chain using compact colon-separated format. + + This is called by miners to register their model. + + Format: + - Plain: "user/repo:rev8char:uuid" (~67 bytes for 30-char repo) + - Encrypted: "user/repo:rev8char:e:" (~121 bytes for 30-char repo) + + The 128-byte chain limit allows repo names up to ~37 chars for encrypted + commitments or ~97 chars for plain commitments. + + Args: + subtensor: Bittensor subtensor connection + wallet: Miner's wallet + netuid: Subnet UID + repo: HuggingFace repository (user/model), max ~37 chars for encrypted mode + revision: Commit SHA (will be truncated to 8 chars) + deployment_id: Basilica deployment ID (UUID only, not full URL) + backend_public_key: Optional hex-encoded X25519 public key for encrypting endpoint. + If provided, the deployment_id will be encrypted so only + the backend operator can decrypt it. + + Returns: + True if commitment succeeded + """ + commitment_data = _build_commitment_data(repo, revision, deployment_id, backend_public_key) + if commitment_data is None: return False try: @@ -458,8 +569,48 @@ def commit_model( wait_for_finalization=False, ) - # Handle both bool and ExtrinsicResponse return types - success = bool(result) if not hasattr(result, "is_success") else result.is_success + success = result.success + if success: + logger.info( + "commitment_submitted", + repo=repo, + revision=revision[:8], + deployment_id=deployment_id[:8] + "..." if deployment_id else None, + encrypted=bool(backend_public_key), + ) + return success + except Exception as e: + logger.error("commitment_failed", error=str(e)) + return False + + +async def commit_model_async( + subtensor: AsyncSubtensor, + wallet: Wallet, + netuid: int, + repo: str, + revision: str, + deployment_id: str, + backend_public_key: str | None = None, +) -> bool: + """Async version of :func:`commit_model`. + + Uses :class:`AsyncSubtensor` for non-blocking chain I/O. + """ + commitment_data = _build_commitment_data(repo, revision, deployment_id, backend_public_key) + if commitment_data is None: + return False + + try: + result = await subtensor.set_commitment( + wallet=wallet, + netuid=netuid, + data=commitment_data, + wait_for_inclusion=True, + wait_for_finalization=False, + ) + + success = result.success if success: logger.info( "commitment_submitted", diff --git a/kinitro/chain/weights.py b/kinitro/chain/weights.py index d4f3a2c..0b4d14f 100644 --- a/kinitro/chain/weights.py +++ b/kinitro/chain/weights.py @@ -2,11 +2,15 @@ import numpy as np import structlog +from bittensor import Subtensor +from bittensor_wallet import Wallet + +from kinitro.types import EligibilityResult, MinerUID logger = structlog.get_logger() -def weights_to_u16(weights: dict[int, float]) -> tuple[list[int], list[int]]: +def weights_to_u16(weights: dict[MinerUID, float]) -> tuple[list[MinerUID], list[int]]: """ Convert float weights to u16 format for chain submission. @@ -39,10 +43,10 @@ def weights_to_u16(weights: dict[int, float]) -> tuple[list[int], list[int]]: def set_weights( - subtensor, # bt.Subtensor - wallet, # bt.Wallet + subtensor: Subtensor, + wallet: Wallet, netuid: int, - weights: dict[int, float], + weights: dict[MinerUID, float], wait_for_inclusion: bool = True, wait_for_finalization: bool = False, ) -> bool: @@ -75,21 +79,21 @@ def set_weights( top_weights=[(uid, w) for uid, w in top_weights], ) - success = subtensor.set_weights( + result = subtensor.set_weights( wallet=wallet, netuid=netuid, - uids=uids, + uids=[int(uid) for uid in uids], weights=weights_u16, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) - if success: + if result.success: logger.info("weights_set_successfully") else: - logger.error("weights_set_failed") + logger.error("weights_set_failed", message=result.message) - return success + return result.success except Exception as e: logger.error("weights_set_exception", error=str(e)) @@ -97,10 +101,10 @@ def set_weights( def verify_weight_setting_eligibility( - subtensor, # bt.Subtensor - wallet, # bt.Wallet + subtensor: Subtensor, + wallet: Wallet, netuid: int, -) -> tuple[bool, str]: +) -> EligibilityResult: """ Check if validator can set weights. @@ -118,14 +122,14 @@ def verify_weight_setting_eligibility( neurons = subtensor.neurons(netuid=netuid) if not neurons: - return False, "No neurons found on subnet" + return EligibilityResult(False, "No neurons found on subnet") # Check if hotkey is registered hotkey = wallet.hotkey.ss58_address hotkeys = [n.hotkey for n in neurons] if hotkey not in hotkeys: - return False, "Hotkey not registered on subnet" + return EligibilityResult(False, "Hotkey not registered on subnet") uid = hotkeys.index(hotkey) _neuron = neurons[uid] # noqa: F841 - kept for future validation @@ -133,14 +137,14 @@ def verify_weight_setting_eligibility( # NOTE: this has been disabled for now do not check permit and stake # # Check if has validator permit # if not neuron.validator_permit: - # return False, "No validator permit" + # return EligibilityResult(False, "No validator permit") # # Check stake (neuron.stake is a Balance object, compare raw value) # stake_tao = float(neuron.stake.tao) # if stake_tao < 1.0: # Minimum stake threshold - # return False, f"Insufficient stake: {stake_tao}" + # return EligibilityResult(False, f"Insufficient stake: {stake_tao}") - return True, "Eligible" + return EligibilityResult(True, "Eligible") except Exception as e: - return False, f"Error checking eligibility: {e}" + return EligibilityResult(False, f"Error checking eligibility: {e}") diff --git a/kinitro/cli/crypto_commands.py b/kinitro/cli/crypto_commands.py index a04fc7d..2f768f2 100644 --- a/kinitro/cli/crypto_commands.py +++ b/kinitro/cli/crypto_commands.py @@ -5,6 +5,8 @@ from pathlib import Path import typer +from bittensor import Subtensor +from bittensor_wallet import Wallet from kinitro.chain.commitments import _query_commitment_by_hotkey from kinitro.crypto import BackendKeypair, decrypt_deployment_id, encrypt_deployment_id @@ -65,9 +67,7 @@ def fetch_backend_public_key( Returns: Public key hex string, or None if not found """ - import bittensor as bt # noqa: PLC0415 - lazy import to avoid argparse hijacking - - subtensor = bt.Subtensor(network=network) + subtensor = Subtensor(network=network) try: commitment_str, _ = _query_commitment_by_hotkey(subtensor, netuid, backend_hotkey) @@ -225,10 +225,8 @@ def publish_public_key( typer.echo(f" Commitment size: {len(commitment_data)} bytes") # Connect and publish - import bittensor as bt # noqa: PLC0415 - lazy import to avoid argparse hijacking - - subtensor = bt.Subtensor(network=network) - wallet = bt.Wallet(name=wallet_name, hotkey=hotkey_name) + subtensor = Subtensor(network=network) + wallet = Wallet(name=wallet_name, hotkey=hotkey_name) typer.echo(f" Wallet: {wallet_name}/{hotkey_name}") typer.echo(f" Hotkey: {wallet.hotkey.ss58_address}") @@ -242,7 +240,7 @@ def publish_public_key( wait_for_finalization=False, ) - success = bool(result) if not hasattr(result, "is_success") else result.is_success + success = result.success if success: typer.echo("") typer.echo("Public key published successfully!") diff --git a/kinitro/cli/db_commands.py b/kinitro/cli/db_commands.py index 2ba78bf..ddc7c60 100644 --- a/kinitro/cli/db_commands.py +++ b/kinitro/cli/db_commands.py @@ -41,7 +41,7 @@ async def _init(): await storage.initialize() await storage.close() - typer.echo(f"Initializing database: {database_url.split('@')[-1]}") + typer.echo(f"Initializing database: {database_url.rsplit('@', maxsplit=1)[-1]}") asyncio.run(_init()) typer.echo("Database initialized successfully!") diff --git a/kinitro/cli/env/commands.py b/kinitro/cli/env/commands.py index 34439c7..701464b 100644 --- a/kinitro/cli/env/commands.py +++ b/kinitro/cli/env/commands.py @@ -19,6 +19,7 @@ get_family_metadata, ) from kinitro.rl_interface import Action, ActionKeys, Observation +from kinitro.types import EnvironmentId # Available environment families for build command AVAILABLE_ENV_FAMILIES = ["metaworld", "genesis"] @@ -234,7 +235,7 @@ def test_env( typer.echo("Error: --episodes must be >= 1", err=True) raise typer.Exit(1) - env = get_environment(env_id, show_viewer=viewer) + env = get_environment(EnvironmentId(env_id), show_viewer=viewer) try: typer.echo(f" Canonical observation shape: {env.observation_shape}") typer.echo(f" Canonical action shape: {env.action_shape}") diff --git a/kinitro/cli/miner/commitment.py b/kinitro/cli/miner/commitment.py index 3f87fda..b6d3109 100644 --- a/kinitro/cli/miner/commitment.py +++ b/kinitro/cli/miner/commitment.py @@ -1,15 +1,67 @@ """Chain commitment commands for miners.""" +import asyncio + import typer +from bittensor import AsyncSubtensor +from bittensor_wallet import Wallet from kinitro.chain.commitments import ( - _query_commitment_by_hotkey, - commit_model, + _query_commitment_by_hotkey_async, + commit_model_async, parse_commitment, ) from kinitro.cli.crypto_commands import fetch_backend_public_key +async def _commit_async( + network: str, + wallet_name: str, + hotkey_name: str, + netuid: int, + repo: str, + revision: str, + deployment_id: str, + encrypt: bool, + backend_public_key: str | None, +) -> bool: + """Perform the on-chain commit using AsyncSubtensor.""" + async with AsyncSubtensor(network=network) as subtensor: + wallet = Wallet(name=wallet_name, hotkey=hotkey_name) + return await commit_model_async( + subtensor=subtensor, + wallet=wallet, + netuid=netuid, + repo=repo, + revision=revision, + deployment_id=deployment_id, + backend_public_key=backend_public_key if encrypt else None, + ) + + +async def _show_commitment_async( + network: str, + netuid: int, + query_hotkey: str, +) -> tuple[str | None, int | None]: + """Query a commitment from chain using AsyncSubtensor.""" + async with AsyncSubtensor(network=network) as subtensor: + return await _query_commitment_by_hotkey_async(subtensor, netuid, query_hotkey) + + +async def _get_neurons_hotkey_async( + network: str, + netuid: int, + uid: int, +) -> str | None: + """Look up a hotkey by UID using AsyncSubtensor.""" + async with AsyncSubtensor(network=network) as subtensor: + neurons = await subtensor.neurons(netuid=netuid) + if uid < 0 or uid >= len(neurons): + return None + return neurons[uid].hotkey + + def commit( repo: str = typer.Option(..., help="HuggingFace repo (user/model)"), revision: str = typer.Option(..., help="Commit SHA"), @@ -101,11 +153,6 @@ def commit( ) raise typer.Exit(1) - import bittensor as bt # noqa: PLC0415 - lazy import to avoid argparse hijacking - - subtensor = bt.Subtensor(network=network) - wallet = bt.Wallet(name=wallet_name, hotkey=hotkey_name) - typer.echo(f"Committing model to {network} (netuid={netuid})") typer.echo(f" Repo: {repo}") typer.echo(f" Revision: {revision}") @@ -116,14 +163,18 @@ def commit( typer.echo(" Encryption: disabled (endpoint visible on-chain)") try: - success = commit_model( - subtensor=subtensor, - wallet=wallet, - netuid=netuid, - repo=repo, - revision=revision, - deployment_id=deployment_id, - backend_public_key=backend_public_key if encrypt else None, + success = asyncio.run( + _commit_async( + network=network, + wallet_name=wallet_name, + hotkey_name=hotkey_name, + netuid=netuid, + repo=repo, + revision=revision, + deployment_id=deployment_id, + encrypt=encrypt, + backend_public_key=backend_public_key, + ) ) if success: @@ -131,8 +182,11 @@ def commit( else: typer.echo("Commitment failed!", err=True) raise typer.Exit(1) - finally: - subtensor.close() + except typer.Exit: + raise + except Exception as e: + typer.echo(f"Commitment failed: {e}", err=True) + raise typer.Exit(1) def show_commitment( @@ -148,53 +202,47 @@ def show_commitment( Query by wallet (default), UID, or hotkey address. """ - import bittensor as bt # noqa: PLC0415 - lazy import to avoid argparse hijacking - - subtensor = bt.Subtensor(network=network) - - try: - # Determine which hotkey to query - if hotkey: - query_hotkey = hotkey - typer.echo(f"Querying commitment for hotkey: {hotkey[:16]}...") - elif uid is not None: - neurons = subtensor.neurons(netuid=netuid) - if uid >= len(neurons): - typer.echo(f"UID {uid} not found on subnet {netuid}", err=True) - raise typer.Exit(1) - query_hotkey = neurons[uid].hotkey - typer.echo(f"Querying commitment for UID {uid} ({query_hotkey[:16]}...)") - else: - wallet = bt.Wallet(name=wallet_name, hotkey=hotkey_name) - query_hotkey = wallet.hotkey.ss58_address - typer.echo(f"Querying commitment for wallet {wallet_name}/{hotkey_name}") - typer.echo(f" Hotkey: {query_hotkey}") + # Determine which hotkey to query + if hotkey: + query_hotkey = hotkey + typer.echo(f"Querying commitment for hotkey: {hotkey[:16]}...") + elif uid is not None: + result_hotkey = asyncio.run(_get_neurons_hotkey_async(network, netuid, uid)) + if result_hotkey is None: + typer.echo(f"UID {uid} not found on subnet {netuid}", err=True) + raise typer.Exit(1) + query_hotkey = result_hotkey + typer.echo(f"Querying commitment for UID {uid} ({query_hotkey[:16]}...)") + else: + wallet = Wallet(name=wallet_name, hotkey=hotkey_name) + query_hotkey = wallet.hotkey.ss58_address + typer.echo(f"Querying commitment for wallet {wallet_name}/{hotkey_name}") + typer.echo(f" Hotkey: {query_hotkey}") - # Query the commitment - raw, block = _query_commitment_by_hotkey(subtensor, netuid, query_hotkey) + # Query the commitment + raw, block = asyncio.run(_show_commitment_async(network, netuid, query_hotkey)) - if not raw: - typer.echo("\nNo commitment found.", err=True) - raise typer.Exit(1) + if not raw: + typer.echo("\nNo commitment found.", err=True) + raise typer.Exit(1) - typer.echo(f"\nRaw commitment: {raw[:100]}{'...' if len(raw) > 100 else ''}") - if block is not None: - typer.echo(f"Committed at block: {block}") - - # Parse the commitment (supports both JSON and legacy formats) - parsed = parse_commitment(raw) - if parsed["huggingface_repo"]: - typer.echo("\nParsed commitment:") - typer.echo(f" Repo: {parsed['huggingface_repo']}") - typer.echo(f" Revision: {parsed['revision_sha']}") - if parsed.get("encrypted_deployment"): - typer.echo(" Encrypted: YES") - typer.echo(f" Encrypted Blob: {parsed['encrypted_deployment'][:40]}...") - else: - typer.echo(f" Deployment ID: {parsed['deployment_id']}") - if parsed["docker_image"]: - typer.echo(f" Docker Image: {parsed['docker_image']}") + typer.echo(f"\nRaw commitment: {raw[:100]}{'...' if len(raw) > 100 else ''}") + if block is not None: + typer.echo(f"Committed at block: {block}") + + # Parse the commitment (supports both JSON and legacy formats) + parsed = parse_commitment(raw) + if parsed["huggingface_repo"]: + typer.echo("\nParsed commitment:") + typer.echo(f" Repo: {parsed['huggingface_repo']}") + typer.echo(f" Revision: {parsed['revision_sha']}") + encrypted_blob = parsed.get("encrypted_deployment") + if encrypted_blob: + typer.echo(" Encrypted: YES") + typer.echo(f" Encrypted Blob: {encrypted_blob[:40]}...") else: - typer.echo("\nCould not parse commitment format.") - finally: - subtensor.close() + typer.echo(f" Deployment ID: {parsed['deployment_id']}") + if parsed["docker_image"]: + typer.echo(f" Docker Image: {parsed['docker_image']}") + else: + typer.echo("\nCould not parse commitment format.") diff --git a/kinitro/cli/miner/deploy.py b/kinitro/cli/miner/deploy.py index a55048a..ea8a202 100644 --- a/kinitro/cli/miner/deploy.py +++ b/kinitro/cli/miner/deploy.py @@ -1,12 +1,15 @@ """Deployment commands for Basilica and one-command deployment.""" +import asyncio import os import typer from basilica import BasilicaClient +from bittensor import AsyncSubtensor +from bittensor_wallet import Wallet from huggingface_hub import HfApi -from kinitro.chain.commitments import commit_model +from kinitro.chain.commitments import commit_model_async # Shared deployment configuration PIP_PACKAGES = [ @@ -461,30 +464,27 @@ def miner_deploy( f" [DRY RUN] Would commit {repo}@{revision_value[:12]}... with deployment_id {deployment_id}" ) else: - import bittensor as bt # noqa: PLC0415 - lazy import to avoid argparse hijacking - - subtensor = bt.Subtensor(network=network) - wallet = bt.Wallet(name=wallet_name, hotkey=hotkey_name) - + wallet = Wallet(name=wallet_name, hotkey=hotkey_name) typer.echo(f" Wallet: {wallet.hotkey.ss58_address[:16]}...") - try: - success = commit_model( - subtensor=subtensor, - wallet=wallet, - netuid=netuid, - repo=repo, - revision=revision_value, - deployment_id=deployment_id, - ) + async def _commit_on_chain() -> bool: + async with AsyncSubtensor(network=network) as subtensor: + return await commit_model_async( + subtensor=subtensor, + wallet=wallet, + netuid=netuid, + repo=repo, + revision=revision_value, + deployment_id=deployment_id, + ) - if success: - typer.echo(" Commitment successful!") - else: - typer.echo(" Commitment failed!", err=True) - raise typer.Exit(1) - finally: - subtensor.close() + success = asyncio.run(_commit_on_chain()) + + if success: + typer.echo(" Commitment successful!") + else: + typer.echo(" Commitment failed!", err=True) + raise typer.Exit(1) # Summary typer.echo("\n" + "=" * 60) diff --git a/kinitro/cli/service_commands.py b/kinitro/cli/service_commands.py index 7b7aba3..1741865 100644 --- a/kinitro/cli/service_commands.py +++ b/kinitro/cli/service_commands.py @@ -26,7 +26,7 @@ def api( help="Disable API key authentication for task endpoints.", ), log_level: str = typer.Option("INFO", help="Logging level"), -): +) -> None: """ Run the API service (lightweight REST API). @@ -56,7 +56,7 @@ def api( ) typer.echo(f"Starting API service on {host}:{port}") - typer.echo(f" Database: {database_url.split('@')[-1]}") + typer.echo(f" Database: {database_url.rsplit('@', maxsplit=1)[-1]}") if no_auth: typer.echo(" Auth: disabled (--no-auth)") elif config.api_key: @@ -81,7 +81,7 @@ def scheduler( help="Filter environments to specific families, comma-separated (e.g., metaworld,genesis)", ), log_level: str = typer.Option("INFO", help="Logging level"), -): +) -> None: """ Run the scheduler service. @@ -120,7 +120,7 @@ def scheduler( typer.echo("Starting scheduler service") typer.echo(f" Network: {network} (netuid={netuid})") - typer.echo(f" Database: {database_url.split('@')[-1]}") + typer.echo(f" Database: {database_url.rsplit('@', maxsplit=1)[-1]}") typer.echo(f" Eval interval: {eval_interval}s") asyncio.run(run_scheduler(config)) @@ -162,7 +162,7 @@ def executor( help="Comma-separated environment families to run (e.g., 'metaworld,genesis'). " "Defaults to families in --eval-images.", ), -): +) -> None: """ Run the executor service. diff --git a/kinitro/cli/testing_commands.py b/kinitro/cli/testing_commands.py index 371433f..1573994 100644 --- a/kinitro/cli/testing_commands.py +++ b/kinitro/cli/testing_commands.py @@ -15,13 +15,14 @@ compute_subset_scores_with_priority, scores_to_weights, ) +from kinitro.types import BlockNumber, EnvironmentId, MinerUID def test_scoring( n_miners: int = typer.Option(5, help="Number of simulated miners"), n_envs: int = typer.Option(3, help="Number of environments"), episodes_per_env: int = typer.Option(50, help="Simulated episodes per environment"), -): +) -> None: """ Test the Pareto scoring mechanism with simulated data. @@ -30,13 +31,14 @@ def test_scoring( typer.echo(f"Testing Pareto scoring with {n_miners} miners, {n_envs} environments\n") # Generate random scores and commit blocks - env_ids = [f"env_{i}" for i in range(n_envs)] + env_ids = [EnvironmentId(f"env_{i}") for i in range(n_envs)] miner_scores = {} miner_blocks = {} for uid in range(n_miners): - miner_scores[uid] = {env_id: float(np.random.uniform(0.3, 0.9)) for env_id in env_ids} - miner_blocks[uid] = 1000 + uid * 100 # Earlier UIDs committed earlier + miner_uid = MinerUID(uid) + miner_scores[miner_uid] = {env_id: float(np.random.uniform(0.3, 0.9)) for env_id in env_ids} + miner_blocks[miner_uid] = BlockNumber(1000 + uid * 100) # Earlier UIDs committed earlier # Display scores typer.echo("Miner scores (earlier block = first-commit advantage):") @@ -68,7 +70,7 @@ def mock_miner( host: str = typer.Option("127.0.0.1", help="Host to bind to (use 0.0.0.0 to expose)"), port: int = typer.Option(8001, help="Port to bind to"), random_actions: bool = typer.Option(True, help="Return random actions (won't solve tasks)"), -): +) -> None: """ Run a mock miner policy server for testing. diff --git a/kinitro/environments/base.py b/kinitro/environments/base.py index 7f7a7d8..2277d61 100644 --- a/kinitro/environments/base.py +++ b/kinitro/environments/base.py @@ -7,6 +7,7 @@ import numpy as np from kinitro.rl_interface import Action, Observation +from kinitro.types import StepInfo @dataclass @@ -29,10 +30,10 @@ class TaskConfig: # Physics randomization physics_params: dict[str, float] = field(default_factory=dict) - # Domain randomization (visual, etc.) + # Any: domain randomization params vary by environment (lighting, textures, etc.) domain_randomization: dict[str, Any] = field(default_factory=dict) - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> dict[str, Any]: # Any: mixed value types after serialization """Convert to dictionary for serialization to miner.""" return { "env_name": self.env_name, @@ -117,7 +118,7 @@ def reset(self, task_config: TaskConfig) -> Observation: pass @abstractmethod - def step(self, action: Action) -> tuple[Observation, float, bool, dict[str, Any]]: + def step(self, action: Action) -> tuple[Observation, float, bool, StepInfo]: """ Execute action in environment. diff --git a/kinitro/environments/genesis/base.py b/kinitro/environments/genesis/base.py index 2b1a697..8be01d7 100644 --- a/kinitro/environments/genesis/base.py +++ b/kinitro/environments/genesis/base.py @@ -22,6 +22,7 @@ from kinitro.environments.genesis.task_generator import TaskGenerator from kinitro.environments.genesis.task_types import SceneObject, TaskSpec, TaskType from kinitro.rl_interface import Action, ActionKeys, Observation, ProprioKeys, encode_image +from kinitro.types import EncodedImage, ObjectType, RobotStateDict, StepInfo logger = structlog.get_logger() @@ -318,7 +319,7 @@ def _get_task_generator(self) -> TaskGenerator: @abstractmethod def _compute_reward( self, - robot_state: dict[str, np.ndarray], + robot_state: RobotStateDict, object_states: dict[str, np.ndarray], task_spec: TaskSpec, ) -> float: @@ -327,7 +328,7 @@ def _compute_reward( @abstractmethod def _check_success( self, - robot_state: dict[str, np.ndarray], + robot_state: RobotStateDict, object_states: dict[str, np.ndarray], task_spec: TaskSpec, ) -> bool: @@ -356,7 +357,7 @@ def generate_task(self, seed: int) -> TaskConfig: target = scene_objects[0] task_spec = TaskSpec( task_type=TaskType.NAVIGATE, - task_prompt=f"Walk to the {target.color} {target.object_type}.", + task_prompt=f"Walk to the {target.color} {target.object_type.value}.", target_object_id=target.object_id, target_object_type=target.object_type, target_position=target.position, @@ -366,7 +367,7 @@ def generate_task(self, seed: int) -> TaskConfig: task_type=TaskType.NAVIGATE, task_prompt="Explore the environment.", target_object_id="", - target_object_type="", + target_object_type=ObjectType.BOX, target_position=[1.0, 0.0, 0.0], ) logger.warning("task_generation_fallback", seed=seed) @@ -382,7 +383,7 @@ def generate_task(self, seed: int) -> TaskConfig: "objects": [ { "object_id": obj.object_id, - "object_type": obj.object_type, + "object_type": obj.object_type.value, "position": obj.position, "color": obj.color, "color_rgb": list(obj.color_rgb), @@ -413,7 +414,7 @@ def reset(self, task_config: TaskConfig) -> Observation: obj_configs = [ SceneObjectConfig( object_id=obj["object_id"], - object_type=obj["object_type"], + object_type=ObjectType(obj["object_type"]), position=obj["position"], color=obj["color"], color_rgb=tuple(obj["color_rgb"]), @@ -442,7 +443,7 @@ def reset(self, task_config: TaskConfig) -> Observation: cam_rgb, cam_depth = self._capture_camera() return self._build_observation(robot_state, cam_rgb, cam_depth) - def step(self, action: Action) -> tuple[Observation, float, bool, dict[str, Any]]: + def step(self, action: Action) -> tuple[Observation, float, bool, StepInfo]: """Execute action in environment.""" self._episode_steps += 1 @@ -481,7 +482,7 @@ def step(self, action: Action) -> tuple[Observation, float, bool, dict[str, Any] obs = self._build_observation(robot_state, cam_rgb, cam_depth) - info: dict[str, Any] = { + info: StepInfo = { "task_prompt": self._current_task.task_prompt if self._current_task else "", "task_type": self._current_task.task_type.value if self._current_task else "", "episode_steps": self._episode_steps, @@ -637,7 +638,7 @@ def _validate_camera(self) -> None: exc_info=True, ) - def _read_robot_state(self) -> dict[str, np.ndarray]: + def _read_robot_state(self) -> RobotStateDict: """Read robot state from Genesis tensors, convert to numpy. Batches all GPU tensor reads into a single CPU transfer via @@ -742,16 +743,16 @@ def _capture_camera(self) -> tuple[np.ndarray | None, np.ndarray | None]: def _build_observation( self, - robot_state: dict[str, np.ndarray], + robot_state: RobotStateDict, cam_rgb: np.ndarray | None, cam_depth: np.ndarray | None, ) -> Observation: """Build the full Observation from robot state and camera images.""" - rgb: dict[str, dict | list] = {} + rgb: dict[str, EncodedImage] = {} if cam_rgb is not None: rgb["ego"] = encode_image(cam_rgb) - depth: dict[str, dict | list] = {} + depth: dict[str, EncodedImage] = {} if cam_depth is not None: depth["ego"] = encode_image(cam_depth) @@ -803,7 +804,7 @@ def _apply_action(self, action: Action) -> None: # Control only actuated joints (skip 6 floating base DOFs) self._robot.control_dofs_position(target_pos, dofs_idx_local=self._actuated_dof_idx) - def _check_fallen(self, robot_state: dict[str, np.ndarray]) -> bool: + def _check_fallen(self, robot_state: RobotStateDict) -> bool: """Check if robot has fallen over.""" base_height = robot_state["base_pos"][2] return bool(base_height < self._robot_config.fall_height_threshold) diff --git a/kinitro/environments/genesis/envs/g1_humanoid.py b/kinitro/environments/genesis/envs/g1_humanoid.py index 30fe9cc..74e1702 100644 --- a/kinitro/environments/genesis/envs/g1_humanoid.py +++ b/kinitro/environments/genesis/envs/g1_humanoid.py @@ -9,6 +9,7 @@ from kinitro.environments.genesis.scene_generator import SceneGenerator from kinitro.environments.genesis.task_generator import TaskGenerator from kinitro.environments.genesis.task_types import TaskSpec, TaskType +from kinitro.types import RobotStateDict class G1Environment(GenesisBaseEnvironment): @@ -44,7 +45,7 @@ def _get_task_generator(self) -> TaskGenerator: def _compute_reward( self, - robot_state: dict[str, np.ndarray], + robot_state: RobotStateDict, object_states: dict[str, np.ndarray], task_spec: TaskSpec, ) -> float: @@ -57,70 +58,76 @@ def _compute_reward( robot_pos = robot_state["base_pos"][:2] # xy only target_pos = np.array(task_spec.target_position[:2], dtype=np.float32) - if task_spec.task_type == TaskType.NAVIGATE: - # Reward for getting closer to target - dist = float(np.linalg.norm(robot_pos - target_pos)) - reward += max(0.0, 1.0 - dist / 5.0) * 0.1 # Progress reward - - elif task_spec.task_type == TaskType.PICKUP: - # Reward for approaching target object - obj_pos = object_states.get(task_spec.target_object_id) - if obj_pos is not None: - dist_to_obj = float(np.linalg.norm(robot_pos - obj_pos[:2])) - reward += max(0.0, 1.0 - dist_to_obj / 5.0) * 0.05 - - # Bonus if object is lifted - initial_height = task_spec.initial_state.get("initial_height", 0.0) - if obj_pos[2] > initial_height + 0.15: - reward += 1.0 - - elif task_spec.task_type == TaskType.PLACE: - obj_pos = object_states.get(task_spec.target_object_id) - dest_pos = task_spec.destination_position - if obj_pos is not None and dest_pos is not None: - dist_to_dest = float(np.linalg.norm(obj_pos - np.array(dest_pos))) - reward += max(0.0, 1.0 - dist_to_dest / 5.0) * 0.1 - - elif task_spec.task_type == TaskType.PUSH: - obj_pos = object_states.get(task_spec.target_object_id) - dest_pos = task_spec.destination_position - if obj_pos is not None and dest_pos is not None: - dist_to_dest = float(np.linalg.norm(obj_pos[:2] - np.array(dest_pos[:2]))) - reward += max(0.0, 1.0 - dist_to_dest / 5.0) * 0.1 + match task_spec.task_type: + case TaskType.NAVIGATE: + # Reward for getting closer to target + dist = float(np.linalg.norm(robot_pos - target_pos)) + reward += max(0.0, 1.0 - dist / 5.0) * 0.1 # Progress reward + + case TaskType.PICKUP: + # Reward for approaching target object + obj_pos = object_states.get(task_spec.target_object_id) + if obj_pos is not None: + dist_to_obj = float(np.linalg.norm(robot_pos - obj_pos[:2])) + reward += max(0.0, 1.0 - dist_to_obj / 5.0) * 0.05 + + # Bonus if object is lifted + initial_height = task_spec.initial_state.get("initial_height", 0.0) + if obj_pos[2] > initial_height + 0.15: + reward += 1.0 + + case TaskType.PLACE: + obj_pos = object_states.get(task_spec.target_object_id) + dest_pos = task_spec.destination_position + if obj_pos is not None and dest_pos is not None: + dist_to_dest = float(np.linalg.norm(obj_pos - np.array(dest_pos))) + reward += max(0.0, 1.0 - dist_to_dest / 5.0) * 0.1 + + case TaskType.PUSH: + obj_pos = object_states.get(task_spec.target_object_id) + dest_pos = task_spec.destination_position + if obj_pos is not None and dest_pos is not None: + dist_to_dest = float(np.linalg.norm(obj_pos[:2] - np.array(dest_pos[:2]))) + reward += max(0.0, 1.0 - dist_to_dest / 5.0) * 0.1 + + case _: + pass return reward def _check_success( self, - robot_state: dict[str, np.ndarray], + robot_state: RobotStateDict, object_states: dict[str, np.ndarray], task_spec: TaskSpec, ) -> bool: """Check task-specific success conditions.""" - if task_spec.task_type == TaskType.NAVIGATE: - robot_pos = robot_state["base_pos"][:2] - target_pos = np.array(task_spec.target_position[:2], dtype=np.float32) - return bool(np.linalg.norm(robot_pos - target_pos) < 0.5) - - elif task_spec.task_type == TaskType.PICKUP: - obj_pos = object_states.get(task_spec.target_object_id) - if obj_pos is None: - return False - initial_height = task_spec.initial_state.get("initial_height", 0.0) - return bool(obj_pos[2] > initial_height + 0.15) - - elif task_spec.task_type == TaskType.PLACE: - obj_pos = object_states.get(task_spec.target_object_id) - dest_pos = task_spec.destination_position - if obj_pos is None or dest_pos is None: - return False - return bool(np.linalg.norm(obj_pos - np.array(dest_pos)) < 0.3) - - elif task_spec.task_type == TaskType.PUSH: - obj_pos = object_states.get(task_spec.target_object_id) - dest_pos = task_spec.destination_position - if obj_pos is None or dest_pos is None: + match task_spec.task_type: + case TaskType.NAVIGATE: + robot_pos = robot_state["base_pos"][:2] + target_pos = np.array(task_spec.target_position[:2], dtype=np.float32) + return bool(np.linalg.norm(robot_pos - target_pos) < 0.5) + + case TaskType.PICKUP: + obj_pos = object_states.get(task_spec.target_object_id) + if obj_pos is None: + return False + initial_height = task_spec.initial_state.get("initial_height", 0.0) + return bool(obj_pos[2] > initial_height + 0.15) + + case TaskType.PLACE: + obj_pos = object_states.get(task_spec.target_object_id) + dest_pos = task_spec.destination_position + if obj_pos is None or dest_pos is None: + return False + return bool(np.linalg.norm(obj_pos - np.array(dest_pos)) < 0.3) + + case TaskType.PUSH: + obj_pos = object_states.get(task_spec.target_object_id) + dest_pos = task_spec.destination_position + if obj_pos is None or dest_pos is None: + return False + return bool(np.linalg.norm(obj_pos[:2] - np.array(dest_pos[:2])) < 0.5) + + case _: return False - return bool(np.linalg.norm(obj_pos[:2] - np.array(dest_pos[:2])) < 0.5) - - return False diff --git a/kinitro/environments/genesis/robot_config.py b/kinitro/environments/genesis/robot_config.py index 3abf956..f083c8a 100644 --- a/kinitro/environments/genesis/robot_config.py +++ b/kinitro/environments/genesis/robot_config.py @@ -4,6 +4,8 @@ from dataclasses import dataclass, field +from kinitro.environments.genesis.task_types import TaskType + @dataclass class RobotConfig: @@ -32,7 +34,7 @@ class RobotConfig: # Task capability flags can_manipulate: bool = False # Has hands/gripper can_locomote: bool = True # Can walk/move - supported_task_types: list[str] = field(default_factory=list) + supported_task_types: list[TaskType] = field(default_factory=list) def __post_init__(self) -> None: n = self.num_actuated_dofs @@ -167,5 +169,5 @@ def __post_init__(self) -> None: ego_camera_pos_offset=(0.15, 0.0, 0.25), # Forward 15cm, up 25cm from torso → ~head height can_manipulate=True, can_locomote=True, - supported_task_types=["navigate", "pickup", "place", "push"], + supported_task_types=[TaskType.NAVIGATE, TaskType.PICKUP, TaskType.PLACE, TaskType.PUSH], ) diff --git a/kinitro/environments/genesis/scene_generator.py b/kinitro/environments/genesis/scene_generator.py index 71622c4..d8d6f1c 100644 --- a/kinitro/environments/genesis/scene_generator.py +++ b/kinitro/environments/genesis/scene_generator.py @@ -13,6 +13,7 @@ OBJECT_TYPES, SceneObject, ) +from kinitro.types import ObjectType logger = structlog.get_logger() @@ -22,7 +23,7 @@ class SceneObjectConfig: """Configuration for an object to be placed in the scene.""" object_id: str - object_type: str # "box", "sphere", "cylinder" + object_type: ObjectType position: list[float] # [x, y, z] color: str color_rgb: tuple[float, float, float] @@ -35,6 +36,7 @@ class SceneConfig: """Configuration for a procedurally generated scene.""" terrain_type: str # Currently always "flat"; kept for future scene types + # Any: terrain params are engine-specific (reserved for future terrain types) terrain_params: dict[str, Any] = field(default_factory=dict) objects: list[SceneObjectConfig] = field(default_factory=list) @@ -126,7 +128,7 @@ def _generate_objects( for i in range(num_objects): color_name = available_colors[i % len(available_colors)] color_rgb = OBJECT_COLORS[color_name] - obj_type = rng.choice(OBJECT_TYPES) + obj_type = ObjectType(rng.choice(OBJECT_TYPES)) is_pickupable = i < num_pickupable if is_pickupable: @@ -139,7 +141,7 @@ def _generate_objects( objects.append( SceneObjectConfig( - object_id=f"obj_{i:02d}_{color_name}_{obj_type}", + object_id=f"obj_{i:02d}_{color_name}_{obj_type.value}", object_type=obj_type, position=position, color=color_name, @@ -197,39 +199,40 @@ def build_scene( surface = gs.surfaces.Default(color=obj_config.color_rgb) is_fixed = not obj_config.pickupable - if obj_config.object_type == "box": - entity = gs_scene.add_entity( - gs.morphs.Box( - pos=pos, - size=(obj_config.size, obj_config.size, obj_config.size), - fixed=is_fixed, - ), - surface=surface, - ) - elif obj_config.object_type == "sphere": - entity = gs_scene.add_entity( - gs.morphs.Sphere( - pos=pos, - radius=obj_config.size, - fixed=is_fixed, - ), - surface=surface, - ) - elif obj_config.object_type == "cylinder": - entity = gs_scene.add_entity( - gs.morphs.Cylinder( - pos=pos, - radius=obj_config.size, - height=obj_config.size * 2, - fixed=is_fixed, - ), - surface=surface, - ) - else: - raise ValueError( - f"Unknown object type {obj_config.object_type!r} " - f"for object {obj_config.object_id!r}" - ) + match obj_config.object_type: + case ObjectType.BOX: + entity = gs_scene.add_entity( + gs.morphs.Box( + pos=pos, + size=(obj_config.size, obj_config.size, obj_config.size), + fixed=is_fixed, + ), + surface=surface, + ) + case ObjectType.SPHERE: + entity = gs_scene.add_entity( + gs.morphs.Sphere( + pos=pos, + radius=obj_config.size, + fixed=is_fixed, + ), + surface=surface, + ) + case ObjectType.CYLINDER: + entity = gs_scene.add_entity( + gs.morphs.Cylinder( + pos=pos, + radius=obj_config.size, + height=obj_config.size * 2, + fixed=is_fixed, + ), + surface=surface, + ) + case _: + raise ValueError( + f"Unknown object type {obj_config.object_type!r} " + f"for object {obj_config.object_id!r}" + ) entities.append(entity) diff --git a/kinitro/environments/genesis/task_generator.py b/kinitro/environments/genesis/task_generator.py index 719acc2..19bf0ed 100644 --- a/kinitro/environments/genesis/task_generator.py +++ b/kinitro/environments/genesis/task_generator.py @@ -108,7 +108,7 @@ def generate_task( available_types = self._task_types if robot_config is not None and robot_config.supported_task_types: available_types = [ - t for t in self._task_types if t.value in robot_config.supported_task_types + t for t in self._task_types if t in robot_config.supported_task_types ] if not available_types: @@ -152,21 +152,23 @@ def _try_generate_task( """Attempt to generate a single task of the specified type.""" supported = robot_config.supported_task_types if robot_config else None - if task_type == TaskType.NAVIGATE: - return self._generate_navigate_task(objects, rng, supported) - elif task_type == TaskType.PICKUP: - return self._generate_pickup_task(objects, rng, supported) - elif task_type == TaskType.PLACE: - return self._generate_place_task(objects, rng, supported) - elif task_type == TaskType.PUSH: - return self._generate_push_task(objects, rng, supported) - return None + match task_type: + case TaskType.NAVIGATE: + return self._generate_navigate_task(objects, rng, supported) + case TaskType.PICKUP: + return self._generate_pickup_task(objects, rng, supported) + case TaskType.PLACE: + return self._generate_place_task(objects, rng, supported) + case TaskType.PUSH: + return self._generate_push_task(objects, rng, supported) + case _: + return None def _generate_navigate_task( self, objects: list[SceneObject], rng: np.random.Generator, - supported: list[str] | None, + supported: list[TaskType] | None, ) -> TaskSpec | None: """Generate a navigation task.""" if not objects: @@ -197,7 +199,7 @@ def _generate_pickup_task( self, objects: list[SceneObject], rng: np.random.Generator, - supported: list[str] | None, + supported: list[TaskType] | None, ) -> TaskSpec | None: """Generate a pickup task.""" candidates = _get_pickupable_objects(objects) @@ -229,7 +231,7 @@ def _generate_place_task( self, objects: list[SceneObject], rng: np.random.Generator, - supported: list[str] | None, + supported: list[TaskType] | None, ) -> TaskSpec | None: """Generate a place task (pick up object, place near destination).""" pickupables = _get_pickupable_objects(objects) @@ -266,7 +268,7 @@ def _generate_push_task( self, objects: list[SceneObject], rng: np.random.Generator, - supported: list[str] | None, + supported: list[TaskType] | None, ) -> TaskSpec | None: """Generate a push task.""" # Any non-picked-up object can be pushed; destination is a different object @@ -319,9 +321,9 @@ def _generate_prompt( if destination is not None: return template.format( color=target.color, - object=target.object_type, + object=target.object_type.value, dest_color=destination.color, - dest_object=destination.object_type, + dest_object=destination.object_type.value, ) - return template.format(color=target.color, object=target.object_type) + return template.format(color=target.color, object=target.object_type.value) diff --git a/kinitro/environments/genesis/task_types.py b/kinitro/environments/genesis/task_types.py index 19fb471..fe31dc8 100644 --- a/kinitro/environments/genesis/task_types.py +++ b/kinitro/environments/genesis/task_types.py @@ -6,6 +6,8 @@ from enum import Enum from typing import Any +from kinitro.types import FeasibilityResult, ObjectType + class TaskType(Enum): """Types of tasks the agent can be asked to perform.""" @@ -32,23 +34,23 @@ class TaskSpec: task_type: TaskType task_prompt: str target_object_id: str - target_object_type: str + target_object_type: ObjectType target_position: list[float] # [x, y, z] # For PLACE/PUSH tasks: where to deliver the object destination_object_id: str | None = None destination_position: list[float] | None = None - # For tracking initial state (to detect completion) + # Any: initial state values are heterogeneous (positions, flags, etc.) initial_state: dict[str, Any] = field(default_factory=dict) - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> dict[str, Any]: # Any: mixed value types after serialization """Serialize to dictionary for TaskConfig.""" return { "task_type": self.task_type.value, "task_prompt": self.task_prompt, "target_object_id": self.target_object_id, - "target_object_type": self.target_object_type, + "target_object_type": self.target_object_type.value, "target_position": self.target_position, "destination_object_id": self.destination_object_id, "destination_position": self.destination_position, @@ -56,13 +58,13 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def from_dict(cls, data: dict[str, Any]) -> TaskSpec: + def from_dict(cls, data: dict[str, Any]) -> TaskSpec: # Any: deserialized JSON values """Deserialize from dictionary.""" return cls( task_type=TaskType(data["task_type"]), task_prompt=data["task_prompt"], target_object_id=data["target_object_id"], - target_object_type=data["target_object_type"], + target_object_type=ObjectType(data["target_object_type"]), target_position=data["target_position"], destination_object_id=data.get("destination_object_id"), destination_position=data.get("destination_position"), @@ -75,7 +77,7 @@ class SceneObject: """Represents a primitive object placed in a Genesis scene.""" object_id: str - object_type: str # "box", "sphere", "cylinder" + object_type: ObjectType position: list[float] # [x, y, z] color: str # "red", "green", "blue", etc. color_rgb: tuple[float, float, float] @@ -100,15 +102,15 @@ class SceneObject: "white": (0.9, 0.9, 0.9), } -OBJECT_TYPES = ["box", "sphere", "cylinder"] +OBJECT_TYPES = [e.value for e in ObjectType] def check_task_feasibility( task_type: TaskType, target: SceneObject, destination: SceneObject | None = None, - robot_supported_tasks: list[str] | None = None, -) -> tuple[bool, str]: + robot_supported_tasks: list[TaskType] | None = None, +) -> FeasibilityResult: """ Check if a task is feasible given the target object and robot capabilities. @@ -117,14 +119,14 @@ def check_task_feasibility( """ # Check robot supports this task type if robot_supported_tasks is not None: - if task_type.value not in robot_supported_tasks: - return False, f"Robot does not support task type {task_type.value}" + if task_type not in robot_supported_tasks: + return FeasibilityResult(False, f"Robot does not support task type {task_type.value}") # Check required properties required_props = TASK_REQUIRED_PROPERTIES[task_type] for prop in required_props: if not getattr(target, prop, False): - return False, f"Object {target.object_type} is not {prop}" + return FeasibilityResult(False, f"Object {target.object_type.value} is not {prop}") # Task-specific checks match task_type: @@ -132,14 +134,16 @@ def check_task_feasibility( pass # Always feasible if target exists case TaskType.PICKUP: if target.is_picked_up: - return False, f"Object {target.object_type} is already picked up" + return FeasibilityResult( + False, f"Object {target.object_type.value} is already picked up" + ) case TaskType.PLACE: if destination is None: - return False, "Place task requires a destination" + return FeasibilityResult(False, "Place task requires a destination") case TaskType.PUSH: if destination is None: - return False, "Push task requires a destination" + return FeasibilityResult(False, "Push task requires a destination") if target.object_id == destination.object_id: - return False, "Cannot push object to itself" + return FeasibilityResult(False, "Cannot push object to itself") - return True, "Task is feasible" + return FeasibilityResult(True, "Task is feasible") diff --git a/kinitro/environments/metaworld_env.py b/kinitro/environments/metaworld_env.py index d76618c..319728f 100644 --- a/kinitro/environments/metaworld_env.py +++ b/kinitro/environments/metaworld_env.py @@ -15,6 +15,7 @@ encode_image, normalize_quaternion, ) +from kinitro.types import StepInfo logger = structlog.get_logger() @@ -95,6 +96,8 @@ def __init__( self._camera_names = camera_names or self.CAMERA_NAMES self._image_width, self._image_height = image_size + # MetaWorld types (ML1, SawyerEnv, etc.) are from an optional dependency + # that has no public type stubs, so we use Any for these runtime objects. self._env: Any | None = None self._camera_envs: dict[str, Any] = {} # Separate env instances for each camera self._ml1: Any | None = None @@ -216,6 +219,8 @@ def _resolve_action_format(self) -> None: if self._env is None: return + # cast(Any, …) silences type-checker on untyped MetaWorld attrs + # (action_space, sim, etc.) — see _env field comment above. env = cast(Any, self._env) action_dim = int(env.action_space.shape[0]) valid_formats = {"auto", "xyz_gripper", "xyz_quat", "xyz_quat_gripper"} @@ -514,7 +519,7 @@ def _apply_physics_randomization(self, physics_params: dict[str, float]) -> None def step( self, action: Action, - ) -> tuple[Observation, float, bool, dict[str, Any]]: + ) -> tuple[Observation, float, bool, StepInfo]: """ Execute action in environment. @@ -580,7 +585,7 @@ def step( mw_action = np.clip(mw_action, env.action_space.low, env.action_space.high) total_reward = 0.0 - info = {} + info: dict[str, Any] = {} # Any: MetaWorld step info has heterogeneous values terminated = False truncated = False full_obs = None @@ -595,11 +600,15 @@ def step( if info.get("success", False): self._episode_success = True - done = terminated or truncated + done: bool = terminated or truncated if full_obs is None: raise RuntimeError("MetaWorld step returned no observation") - return self._build_observation(full_obs), float(total_reward), done, info + + step_info: StepInfo = { + "success": self._episode_success, + } + return self._build_observation(full_obs), float(total_reward), done, step_info def get_success(self) -> bool: """Check if task was completed successfully.""" diff --git a/kinitro/environments/procedural.py b/kinitro/environments/procedural.py index 91e342e..3018b67 100644 --- a/kinitro/environments/procedural.py +++ b/kinitro/environments/procedural.py @@ -4,6 +4,8 @@ import numpy as np +from kinitro.types import ProceduralTaskResult + def randomize_positions( base: np.ndarray | list[float], @@ -47,8 +49,8 @@ def randomize_physics( def randomize_domain( rng: np.random.Generator, - config: dict[str, dict[str, Any]], -) -> dict[str, Any]: + config: dict[str, dict[str, Any]], # Any: randomization specs vary by param type +) -> dict[str, Any]: # Any: generated values are floats, ints, or tuples """ Generate domain randomization parameters. @@ -96,7 +98,7 @@ def __init__( env_id: str, position_ranges: dict[str, np.ndarray] | None = None, physics_ranges: dict[str, tuple[float, float]] | None = None, - domain_config: dict[str, dict[str, Any]] | None = None, + domain_config: dict[str, dict[str, Any]] | None = None, # Any: see randomize_domain ): """ Initialize generator with randomization parameters. @@ -124,7 +126,7 @@ def generate( seed: int, base_object_pos: np.ndarray | None = None, base_target_pos: np.ndarray | None = None, - ) -> dict[str, Any]: + ) -> ProceduralTaskResult: """ Generate procedural task parameters. diff --git a/kinitro/environments/registry.py b/kinitro/environments/registry.py index 08e3470..ed2014b 100644 --- a/kinitro/environments/registry.py +++ b/kinitro/environments/registry.py @@ -9,6 +9,7 @@ import structlog from kinitro.environments.base import RoboticsEnvironment +from kinitro.types import EnvironmentFamily, EnvironmentId logger = structlog.get_logger() @@ -25,7 +26,7 @@ def _make_metaworld_env(task: str) -> EnvFactory: """Create factory for MetaWorld environment.""" - def factory(**_kwargs: Any) -> RoboticsEnvironment: + def factory(**_kwargs: Any) -> RoboticsEnvironment: # Any: matches EnvFactory signature # Lazy import to allow containers with partial dependencies from kinitro.environments.metaworld_env import MetaWorldEnvironment # noqa: PLC0415 @@ -37,7 +38,7 @@ def factory(**_kwargs: Any) -> RoboticsEnvironment: def _make_genesis_env(env_cls_name: str, task: str) -> EnvFactory: """Create factory for Genesis environment.""" - def factory(**kwargs: Any) -> RoboticsEnvironment: + def factory(**kwargs: Any) -> RoboticsEnvironment: # Any: forwarded to env constructor # Lazy import to allow containers with partial dependencies from kinitro.environments.genesis import envs # noqa: PLC0415 @@ -74,7 +75,9 @@ def factory(**kwargs: Any) -> RoboticsEnvironment: } -def get_environment(env_id: str, **kwargs: Any) -> RoboticsEnvironment: +def get_environment( + env_id: EnvironmentId, **kwargs: Any +) -> RoboticsEnvironment: # Any: forwarded to factory """ Load a robotics environment by ID. @@ -97,12 +100,12 @@ def get_environment(env_id: str, **kwargs: Any) -> RoboticsEnvironment: return ENVIRONMENTS[env_id](**kwargs) -def get_all_environment_ids() -> list[str]: +def get_all_environment_ids() -> list[EnvironmentId]: """Get list of all registered environment IDs.""" - return list(ENVIRONMENTS.keys()) + return [EnvironmentId(env_id) for env_id in ENVIRONMENTS] -def get_environments_by_family(family: str) -> list[str]: +def get_environments_by_family(family: str | EnvironmentFamily) -> list[EnvironmentId]: """ Get environment IDs for a specific family. @@ -112,7 +115,7 @@ def get_environments_by_family(family: str) -> list[str]: Returns: List of environment IDs in that family """ - return [env_id for env_id in ENVIRONMENTS if env_id.startswith(f"{family}/")] + return [EnvironmentId(env_id) for env_id in ENVIRONMENTS if env_id.startswith(f"{family}/")] def _load_family_metadata() -> dict[str, dict[str, str]]: diff --git a/kinitro/executor/api_client.py b/kinitro/executor/api_client.py index 4dca7ab..2ecbc3e 100644 --- a/kinitro/executor/api_client.py +++ b/kinitro/executor/api_client.py @@ -4,6 +4,7 @@ import structlog from kinitro.backend.models import Task, TaskResult +from kinitro.types import EnvironmentId logger = structlog.get_logger() @@ -38,7 +39,7 @@ async def close(self) -> None: async def fetch_tasks( self, batch_size: int = 10, - env_ids: list[str] | None = None, + env_ids: list[EnvironmentId] | None = None, ) -> list[Task]: """ Fetch tasks from the API. diff --git a/kinitro/executor/config.py b/kinitro/executor/config.py index 28cfdea..b764bff 100644 --- a/kinitro/executor/config.py +++ b/kinitro/executor/config.py @@ -6,6 +6,8 @@ from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from kinitro.types import EnvironmentId, env_family_from_id + def default_executor_id() -> str: """Generate a default executor ID.""" @@ -44,7 +46,7 @@ class ExecutorConfig(BaseSettings): default=5, description="Seconds to wait between polling for tasks", ) - env_ids: list[str] | None = Field( + env_ids: list[EnvironmentId] | None = Field( default=None, description="Filter tasks by environment IDs (None = all envs)", ) @@ -212,8 +214,7 @@ def get_image_for_env(self, env_id: str) -> str: Raises: ValueError: If no image is configured for the environment's family """ - # Extract family from env_id (e.g., 'metaworld' from 'metaworld/pick-place-v3') - family = env_id.split("/")[0] if "/" in env_id else env_id + family = env_family_from_id(env_id) if family not in self.eval_images: available = list(self.eval_images.keys()) diff --git a/kinitro/executor/env_loader.py b/kinitro/executor/env_loader.py index 80bec47..79529cf 100644 --- a/kinitro/executor/env_loader.py +++ b/kinitro/executor/env_loader.py @@ -9,6 +9,7 @@ import structlog from kinitro.backend.models import Task, TaskResult +from kinitro.types import AffinetesEnv logger = structlog.get_logger() @@ -38,6 +39,7 @@ def build_load_kwargs( Returns: Dict of keyword arguments for af_env.load_env(). """ + # Any: affinetes.load_env() accepts heterogeneous kwargs (str, bool, list, etc.) load_kwargs: dict[str, Any] = { "image": image, "mode": eval_mode, @@ -68,7 +70,7 @@ def build_load_kwargs( return load_kwargs -async def load_and_warmup_env(family: str, image: str, load_kwargs: dict[str, Any]) -> Any: +async def load_and_warmup_env(family: str, image: str, load_kwargs: dict[str, Any]) -> AffinetesEnv: """Load an affinetes environment and perform a warmup call. Args: @@ -98,7 +100,7 @@ async def load_and_warmup_env(family: str, image: str, load_kwargs: dict[str, An async def run_evaluation( - env: Any, + env: AffinetesEnv, task: Task, max_timesteps: int, action_timeout: float, diff --git a/kinitro/executor/family_worker.py b/kinitro/executor/family_worker.py index 69a49a9..2cf6068 100644 --- a/kinitro/executor/family_worker.py +++ b/kinitro/executor/family_worker.py @@ -4,7 +4,6 @@ import logging import multiprocessing as mp import os -from typing import Any import structlog @@ -17,6 +16,7 @@ load_and_warmup_env, run_evaluation, ) +from kinitro.types import AffinetesEnv # Configure structlog for this subprocess structlog.configure( @@ -89,7 +89,7 @@ def __init__( # Async primitives (initialized in run()) self.task_queue: asyncio.Queue | None = None self.semaphore: asyncio.Semaphore | None = None - self.env: Any = None + self.env: AffinetesEnv | None = None self.running = False # Metrics @@ -222,6 +222,7 @@ async def _execute_task(self, task: Task) -> TaskResult: env_id=task.env_id, ) + assert self.env is not None, "env not initialized" task_result = await run_evaluation( env=self.env, task=task, diff --git a/kinitro/executor/verification.py b/kinitro/executor/verification.py index f794f95..2a4f3c3 100644 --- a/kinitro/executor/verification.py +++ b/kinitro/executor/verification.py @@ -30,6 +30,7 @@ from huggingface_hub import HfApi, snapshot_download from kinitro.rl_interface import Action, Observation, ProprioKeys +from kinitro.types import Hotkey, MinerUID, VerificationDetails logger = structlog.get_logger() @@ -38,14 +39,14 @@ class VerificationResult: """Result of a model verification check.""" - miner_uid: int - miner_hotkey: str + miner_uid: MinerUID + miner_hotkey: Hotkey repo: str revision: str verified: bool match_score: float # 0.0 = no match, 1.0 = perfect match error: str | None = None - details: dict[str, Any] | None = None + details: VerificationDetails | None = None class PolicyVerifier: @@ -94,6 +95,7 @@ def __init__( self.num_samples = num_samples self.cache_dir = cache_dir or tempfile.mkdtemp(prefix="kinitro_verify_") self.max_repo_size_bytes = int(max_repo_size_gb * 1024 * 1024 * 1024) + # Any: cached policies are user-provided objects with no shared base class self._policy_cache: dict[str, Any] = {} def should_verify(self) -> bool: @@ -102,8 +104,8 @@ def should_verify(self) -> bool: async def verify_miner( self, - miner_uid: int, - miner_hotkey: str, + miner_uid: MinerUID, + miner_hotkey: Hotkey, repo: str, revision: str, endpoint: str, @@ -326,7 +328,10 @@ def _set_seed(self, seed: int) -> None: np.random.seed(seed) async def _get_local_action( - self, policy: Any, obs: Observation, seed: int + self, + policy: Any, + obs: Observation, + seed: int, # Any: user-provided policy, no common Protocol ) -> np.ndarray | None: """Get action from local policy.""" try: diff --git a/kinitro/executor/worker.py b/kinitro/executor/worker.py index 2944d0d..fde03aa 100644 --- a/kinitro/executor/worker.py +++ b/kinitro/executor/worker.py @@ -1,7 +1,6 @@ """Worker that executes evaluation tasks using affinetes.""" import asyncio -from typing import Any import structlog @@ -14,6 +13,7 @@ run_evaluation, ) from kinitro.executor.verification import PolicyVerifier, VerificationResult +from kinitro.types import AffinetesEnv, env_family_from_id logger = structlog.get_logger() @@ -31,7 +31,7 @@ class Worker: def __init__(self, config: ExecutorConfig): self.config = config # Per-family eval environments: family -> affinetes env - self._envs: dict[str, Any] = {} + self._envs: dict[str, AffinetesEnv] = {} self._env_lock = asyncio.Lock() # Initialize verifier if enabled @@ -58,9 +58,9 @@ def __init__(self, config: ExecutorConfig): def _get_family(self, env_id: str) -> str: """Extract family from env_id (e.g., 'metaworld' from 'metaworld/pick-place-v3').""" - return env_id.split("/")[0] if "/" in env_id else env_id + return env_family_from_id(env_id) - async def _get_eval_environment(self, env_id: str): + async def _get_eval_environment(self, env_id: str) -> AffinetesEnv: """ Get or create the affinetes-managed eval environment for a given env_id. diff --git a/kinitro/miner/template/env.py b/kinitro/miner/template/env.py index 2e53e78..dca7b0e 100644 --- a/kinitro/miner/template/env.py +++ b/kinitro/miner/template/env.py @@ -82,7 +82,9 @@ def __init__(self): self._image_size = (84, 84) self._camera_names = ["corner", "corner2"] - async def reset(self, task_config: dict[str, Any]) -> None: + async def reset( + self, task_config: dict[str, Any] + ) -> None: # Any: task config is env-specific JSON """ Reset policy for a new episode. @@ -105,7 +107,9 @@ async def reset(self, task_config: dict[str, Any]) -> None: # Example: You might want to condition your policy on the task # self.task_embedding = self.encode_task(task_config) - async def act(self, observation: dict[str, Any]) -> dict[str, Any]: + async def act( + self, observation: dict[str, Any] + ) -> dict[str, Any]: # Any: obs/action are env-specific JSON """ Get action for current observation. @@ -183,7 +187,7 @@ def __init__(self, model_path: str = "policy.pt"): else: print(f"Warning: Model not found at {model_path}") - def preprocess_images(self, images: dict[str, np.ndarray]) -> "Any": + def preprocess_images(self, images: dict[str, np.ndarray]) -> np.ndarray | None: """Preprocess camera images for the policy.""" if not images: return None diff --git a/kinitro/miner/template/server.py b/kinitro/miner/template/server.py index 7d228ac..b7e4d72 100644 --- a/kinitro/miner/template/server.py +++ b/kinitro/miner/template/server.py @@ -65,7 +65,9 @@ def __init__(self, name: str): self.name = name - def _log(self, level: str, message: str, **kwargs: Any) -> None: + def _log( + self, level: str, message: str, **kwargs: Any + ) -> None: # Any: arbitrary structured log fields """Emit a structured log entry.""" entry = { "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), @@ -135,7 +137,7 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) -# Global state +# Any: global state holds heterogeneous values (policy object, counters, etc.) _state: dict[str, Any] = { "policy": None, "request_count": 0, diff --git a/kinitro/rl_interface.py b/kinitro/rl_interface.py index f8bc61c..c9606ba 100644 --- a/kinitro/rl_interface.py +++ b/kinitro/rl_interface.py @@ -20,11 +20,10 @@ from typing import Any import numpy as np +from numpy.typing import ArrayLike from pydantic import BaseModel, ConfigDict, Field -# ============================================================================= -# Conventional Key Names -# ============================================================================= +from kinitro.types import EncodedImage class ProprioKeys: @@ -95,12 +94,7 @@ class ActionKeys: RIGHT_GRIPPER = "right_gripper" -# ============================================================================= -# Image Encoding/Decoding -# ============================================================================= - - -def encode_image(img: np.ndarray) -> dict[str, Any]: +def encode_image(img: np.ndarray) -> EncodedImage: """Encode image as base64 with metadata for efficient serialization.""" return { "data": base64.b64encode(img.tobytes()).decode("ascii"), @@ -109,23 +103,14 @@ def encode_image(img: np.ndarray) -> dict[str, Any]: } -def decode_image(encoded: dict[str, Any] | list) -> np.ndarray: - """Decode image from base64 or nested list format.""" - if isinstance(encoded, list): - # Legacy nested list format - return np.array(encoded, dtype=np.uint8) - # Base64 encoded format +def decode_image(encoded: EncodedImage) -> np.ndarray: + """Decode a base64-encoded image back to a numpy array.""" data = base64.b64decode(encoded["data"]) shape = tuple(encoded["shape"]) dtype = np.dtype(encoded["dtype"]) return np.frombuffer(data, dtype=dtype).reshape(shape) -# ============================================================================= -# Observation Class -# ============================================================================= - - class Observation(BaseModel): """ Extensible observation for any robot embodiment. @@ -162,11 +147,12 @@ class Observation(BaseModel): model_config = ConfigDict(extra="forbid") - rgb: dict[str, dict | list] = Field(default_factory=dict) - depth: dict[str, dict | list] = Field(default_factory=dict) + rgb: dict[str, EncodedImage] = Field(default_factory=dict) + depth: dict[str, EncodedImage] = Field(default_factory=dict) proprio: dict[str, list[float]] = Field(default_factory=dict) cam_intrinsics: dict[str, list[list[float]]] = Field(default_factory=dict) cam_extrinsics: dict[str, list[list[float]]] = Field(default_factory=dict) + # Any: open-ended for env-specific data extra: dict[str, Any] = Field(default_factory=dict) def get_image(self, camera: str) -> np.ndarray | None: @@ -206,7 +192,9 @@ def proprio_array(self, keys: list[str] | None = None) -> np.ndarray: return np.array([], dtype=np.float32) return np.concatenate(arrays).astype(np.float32) - def to_payload(self, include_images: bool = True) -> dict[str, Any]: + def to_payload( + self, include_images: bool = True + ) -> dict[str, Any]: # Any: Pydantic model_dump output """Serialize for network transport.""" data = self.model_dump(mode="python") if not include_images: @@ -215,11 +203,6 @@ def to_payload(self, include_images: bool = True) -> dict[str, Any]: return data -# ============================================================================= -# Action Class -# ============================================================================= - - class Action(BaseModel): """ Extensible action for any robot embodiment. @@ -247,6 +230,7 @@ class Action(BaseModel): continuous: dict[str, list[float]] = Field(default_factory=dict) discrete: dict[str, int] = Field(default_factory=dict) + # Any: open-ended for env-specific data extra: dict[str, Any] = Field(default_factory=dict) def get_continuous(self, key: str) -> np.ndarray | None: @@ -265,7 +249,7 @@ def continuous_array(self, keys: list[str] | None = None) -> np.ndarray: return np.concatenate(arrays).astype(np.float32) @classmethod - def from_array(cls, array: Any, schema: dict[str, int]) -> Action: + def from_array(cls, array: ArrayLike, schema: dict[str, int]) -> Action: """ Construct from flat array given a schema. @@ -286,11 +270,6 @@ def to_array(self, keys: list[str] | None = None) -> np.ndarray: return self.continuous_array(keys) -# ============================================================================= -# Helper Functions -# ============================================================================= - - def normalize_quaternion(quat: np.ndarray) -> np.ndarray: """Normalize a quaternion to unit length.""" norm = np.linalg.norm(quat) diff --git a/kinitro/scheduler/main.py b/kinitro/scheduler/main.py index 079c6f2..92c253e 100644 --- a/kinitro/scheduler/main.py +++ b/kinitro/scheduler/main.py @@ -6,6 +6,7 @@ import time import structlog +from bittensor import Subtensor from kinitro.backend.storage import Storage from kinitro.chain.commitments import read_miner_commitments @@ -18,6 +19,7 @@ convert_to_scores_data, ) from kinitro.scheduler.task_generator import generate_tasks +from kinitro.types import BlockNumber, EnvironmentId logger = structlog.get_logger() @@ -39,7 +41,7 @@ def __init__(self, config: SchedulerConfig, storage: Storage): self.storage = storage # Filter environments by family if configured if self.config.env_families: - env_ids: list[str] = [] + env_ids: list[EnvironmentId] = [] missing_families: list[str] = [] for family in self.config.env_families: family_envs = get_environments_by_family(family) @@ -47,7 +49,7 @@ def __init__(self, config: SchedulerConfig, storage: Storage): missing_families.append(family) env_ids.extend(family_envs) # Deduplicate while preserving order - seen: set[str] = set() + seen: set[EnvironmentId] = set() self.env_ids = [e for e in env_ids if not (e in seen or seen.add(e))] if missing_families: logger.warning( @@ -63,21 +65,12 @@ def __init__(self, config: SchedulerConfig, storage: Storage): else: self.env_ids = get_all_environment_ids() self._running = False - self._subtensor = None + self.subtensor: Subtensor | None = None self._backend_keypair: BackendKeypair | None = None # Load backend keypair for decrypting miner endpoints self._load_backend_keypair() - @property - def subtensor(self): - """Lazy-load subtensor connection.""" - if self._subtensor is None: - import bittensor as bt # noqa: PLC0415 - lazy import to avoid argparse hijacking - - self._subtensor = bt.Subtensor(network=self.config.network) - return self._subtensor - def _load_backend_keypair(self) -> None: """Load the backend keypair for decrypting miner endpoints.""" if self.config.backend_private_key: @@ -118,6 +111,9 @@ async def start(self) -> None: logger.warning("scheduler_already_running") return + # Initialize Subtensor (blocking RPC) off the event loop + self.subtensor = await asyncio.to_thread(Subtensor, network=self.config.network) + # Clean up incomplete cycles from previous runs (cycle isolation) async with self.storage.session() as session: cycles_cancelled, tasks_cancelled = await self.storage.cancel_incomplete_cycles(session) @@ -165,10 +161,12 @@ async def stop(self) -> None: async def _run_evaluation_cycle(self) -> None: """Run a single evaluation cycle.""" + if self.subtensor is None: + raise RuntimeError("subtensor not initialized; call start() first") start_time = time.time() # Get current block - block_number = self.subtensor.block + block_number = BlockNumber(self.subtensor.block) logger.info("starting_evaluation_cycle", block=block_number) # Create cycle in database @@ -275,7 +273,7 @@ async def _run_evaluation_cycle(self) -> None: session, cycle_id=cycle_id, block_number=block_number, - weights={int(k): float(v) for k, v in weights.items()}, + weights={k: float(v) for k, v in weights.items()}, weights_u16=weights_u16, ) @@ -331,10 +329,10 @@ async def _wait_for_cycle_completion(self, cycle_id: int) -> bool: logger.info( "waiting_for_tasks", cycle_id=cycle_id, - pending=stats["pending_tasks"], - assigned=stats["assigned_tasks"], - completed=stats["completed_tasks"], - failed=stats["failed_tasks"], + pending=stats.pending_tasks, + assigned=stats.assigned_tasks, + completed=stats.completed_tasks, + failed=stats.failed_tasks, elapsed_seconds=int(elapsed), ) @@ -366,5 +364,10 @@ async def run_scheduler(config: SchedulerConfig) -> None: try: await scheduler.start() finally: + if scheduler.subtensor is not None: + try: + await asyncio.to_thread(scheduler.subtensor.close) + except Exception: + logger.warning("subtensor_close_failed", exc_info=True) await scheduler.stop() await storage.close() diff --git a/kinitro/scheduler/scoring.py b/kinitro/scheduler/scoring.py index b8e8b4e..8fee4e9 100644 --- a/kinitro/scheduler/scoring.py +++ b/kinitro/scheduler/scoring.py @@ -11,6 +11,15 @@ compute_subset_scores_with_priority, scores_to_weights, ) +from kinitro.types import ( + BlockNumber, + EnvironmentId, + Hotkey, + MinerFirstBlocks, + MinerScoreData, + MinerScores, + MinerUID, +) logger = structlog.get_logger() PLACEHOLDER_BLOCK_NUM = 2**32 # Large block number for missing data @@ -18,7 +27,7 @@ def aggregate_task_results( tasks: list[TaskPoolORM], -) -> dict[int, dict[str, float]]: +) -> MinerScores: """ Aggregate task results into miner scores. @@ -29,14 +38,14 @@ def aggregate_task_results( Dict mapping uid -> env_id -> success_rate """ # Group by (miner_uid, env_id) - scores: dict[int, dict[str, list[float]]] = {} + scores: dict[MinerUID, dict[EnvironmentId, list[float]]] = {} for task in tasks: if task.result is None: continue - uid = task.miner_uid - env_id = task.env_id + uid = MinerUID(task.miner_uid) + env_id = EnvironmentId(task.env_id) if uid not in scores: scores[uid] = {} @@ -48,7 +57,7 @@ def aggregate_task_results( scores[uid][env_id].append(1.0 if success else 0.0) # Average to get success rates - result: dict[int, dict[str, float]] = {} + result: MinerScores = {} for uid, env_scores in scores.items(): result[uid] = {} for env_id, task_scores in env_scores.items(): @@ -67,15 +76,15 @@ def aggregate_task_results( def compute_weights( - miner_scores: dict[int, dict[str, float]], - env_ids: list[str], + miner_scores: MinerScores, + env_ids: list[EnvironmentId], episodes_per_env: int, - miners: dict[int, MinerCommitment], + miners: dict[MinerUID, MinerCommitment], pareto_temperature: float = 1.0, threshold_z_score: float = 1.5, threshold_min_gap: float = 0.02, threshold_max_gap: float = 0.10, -) -> tuple[dict[int, float], dict[str, list[int]]]: +) -> tuple[dict[MinerUID, float], dict[str, list[int]]]: """ Compute weights from miner scores using Pareto frontier with first-commit advantage. @@ -124,14 +133,14 @@ def compute_weights( ) # Extract first_block for each miner - miner_first_blocks = { + miner_first_blocks: MinerFirstBlocks = { uid: miners[uid].committed_block for uid in miner_scores.keys() if uid in miners } # Fill in missing first_blocks with a large value (disadvantaged) for uid in miner_scores.keys(): if uid not in miner_first_blocks: - miner_first_blocks[uid] = PLACEHOLDER_BLOCK_NUM + miner_first_blocks[uid] = BlockNumber(PLACEHOLDER_BLOCK_NUM) logger.info( "first_commit_advantage", @@ -155,16 +164,16 @@ def compute_weights( # Convert to u16 for chain submission uids, values = weights_to_u16(weights) - weights_u16 = {"uids": uids, "values": values} + weights_u16: dict[str, list[int]] = {"uids": [int(u) for u in uids], "values": values} return weights, weights_u16 def convert_to_scores_data( - miner_scores: dict[int, dict[str, float]], - miners_by_uid: dict[int, str], # uid -> hotkey + miner_scores: MinerScores, + miners_by_uid: dict[MinerUID, Hotkey], episodes_per_env: int, -) -> list[dict]: +) -> list[MinerScoreData]: """ Convert miner scores to format for storage. @@ -179,7 +188,7 @@ def convert_to_scores_data( scores_data = [] for uid, env_scores in miner_scores.items(): - hotkey = miners_by_uid.get(uid, "unknown") + hotkey = miners_by_uid.get(uid, Hotkey("unknown")) for env_id, success_rate in env_scores.items(): scores_data.append( diff --git a/kinitro/scheduler/task_generator.py b/kinitro/scheduler/task_generator.py index 770111a..abec4b4 100644 --- a/kinitro/scheduler/task_generator.py +++ b/kinitro/scheduler/task_generator.py @@ -6,11 +6,12 @@ import structlog from kinitro.chain.commitments import MinerCommitment +from kinitro.types import BlockNumber, EnvironmentId, Seed, TaskCreateData logger = structlog.get_logger() -def generate_seed(task_uuid: str) -> int: +def generate_seed(task_uuid: str) -> Seed: """ Generate a deterministic seed from task UUID. @@ -28,7 +29,7 @@ def generate_seed(task_uuid: str) -> int: """ hash_bytes = hashlib.sha256(task_uuid.encode()).digest()[:4] # Mask to 31 bits to fit PostgreSQL signed int4 (max 2,147,483,647) - return int.from_bytes(hash_bytes, byteorder="big") & 0x7FFFFFFF + return Seed(int.from_bytes(hash_bytes, byteorder="big") & 0x7FFFFFFF) def get_miner_endpoint(miner: MinerCommitment) -> str: @@ -47,11 +48,11 @@ def get_miner_endpoint(miner: MinerCommitment) -> str: def generate_tasks( miners: list[MinerCommitment], - env_ids: list[str], + env_ids: list[EnvironmentId], episodes_per_env: int, - block_number: int, + block_number: BlockNumber, cycle_id: int, -) -> list[dict]: +) -> list[TaskCreateData]: """ Generate evaluation tasks for all miners and environments. diff --git a/kinitro/scoring/pareto.py b/kinitro/scoring/pareto.py index 9a6889a..1709aad 100644 --- a/kinitro/scoring/pareto.py +++ b/kinitro/scoring/pareto.py @@ -4,13 +4,15 @@ import numpy as np +from kinitro.types import EnvironmentId, MinerScores, MinerUID + @dataclass class ParetoResult: """Result of Pareto frontier computation.""" # UIDs of miners on the Pareto frontier - frontier_uids: list[int] + frontier_uids: list[MinerUID] # Dominance matrix: dominance[i, j] = True if miner i dominates miner j dominance_matrix: np.ndarray @@ -22,7 +24,7 @@ class ParetoResult: score_matrix: np.ndarray # Mapping from matrix index to UID - uid_mapping: list[int] = field(default_factory=list) + uid_mapping: list[MinerUID] = field(default_factory=list) def compute_epsilon( @@ -98,9 +100,9 @@ def epsilon_dominates( def compute_pareto_frontier( - miner_scores: dict[int, dict[str, float]], - env_ids: list[str], - n_samples_per_env: int | dict[str, int], + miner_scores: MinerScores, + env_ids: list[EnvironmentId], + n_samples_per_env: int | dict[EnvironmentId, int], ) -> ParetoResult: """ Compute ε-Pareto frontier across all miners. @@ -171,7 +173,7 @@ def compute_pareto_frontier( ) -def get_dominating_miners(pareto_result: ParetoResult, uid: int) -> list[int]: +def get_dominating_miners(pareto_result: ParetoResult, uid: MinerUID) -> list[MinerUID]: """ Get list of miners that dominate the given miner. @@ -182,15 +184,16 @@ def get_dominating_miners(pareto_result: ParetoResult, uid: int) -> list[int]: Returns: List of UIDs that dominate this miner """ - if uid not in pareto_result.uid_mapping: + uid_to_idx = {u: i for i, u in enumerate(pareto_result.uid_mapping)} + if uid not in uid_to_idx: return [] - idx = pareto_result.uid_mapping.index(uid) + idx = uid_to_idx[uid] dominating_indices = np.where(pareto_result.dominance_matrix[:, idx])[0] return [pareto_result.uid_mapping[i] for i in dominating_indices] -def get_dominated_miners(pareto_result: ParetoResult, uid: int) -> list[int]: +def get_dominated_miners(pareto_result: ParetoResult, uid: MinerUID) -> list[MinerUID]: """ Get list of miners that are dominated by the given miner. @@ -201,18 +204,19 @@ def get_dominated_miners(pareto_result: ParetoResult, uid: int) -> list[int]: Returns: List of UIDs that this miner dominates """ - if uid not in pareto_result.uid_mapping: + uid_to_idx = {u: i for i, u in enumerate(pareto_result.uid_mapping)} + if uid not in uid_to_idx: return [] - idx = pareto_result.uid_mapping.index(uid) + idx = uid_to_idx[uid] dominated_indices = np.where(pareto_result.dominance_matrix[idx, :])[0] return [pareto_result.uid_mapping[i] for i in dominated_indices] def later_beats_earlier( - later_scores: dict[str, float], - earlier_thresholds: dict[str, float], - env_ids: list[str], + later_scores: dict[EnvironmentId, float], + earlier_thresholds: dict[EnvironmentId, float], + env_ids: list[EnvironmentId], ) -> bool: """ Check if a later miner beats an earlier miner's thresholds on all environments. diff --git a/kinitro/scoring/threshold.py b/kinitro/scoring/threshold.py index 877bf1f..e20c601 100644 --- a/kinitro/scoring/threshold.py +++ b/kinitro/scoring/threshold.py @@ -2,6 +2,8 @@ import math +from kinitro.types import EnvironmentId, MinerScores, MinerThresholds + def calculate_threshold( prior_score: float, @@ -37,12 +39,12 @@ def calculate_threshold( def compute_miner_thresholds( - miner_scores: dict[int, dict[str, float]], - episodes_per_env: int | dict[str, int], + miner_scores: MinerScores, + episodes_per_env: int | dict[EnvironmentId, int], z_score: float = 1.5, min_gap: float = 0.02, max_gap: float = 0.10, -) -> dict[int, dict[str, float]]: +) -> MinerThresholds: """ Compute thresholds for all miners across all environments. @@ -56,7 +58,7 @@ def compute_miner_thresholds( Returns: Dict mapping uid -> env_id -> threshold """ - thresholds: dict[int, dict[str, float]] = {} + thresholds: MinerThresholds = {} for uid, env_scores in miner_scores.items(): thresholds[uid] = {} diff --git a/kinitro/scoring/winners_take_all.py b/kinitro/scoring/winners_take_all.py index 84fefcc..5d0854b 100644 --- a/kinitro/scoring/winners_take_all.py +++ b/kinitro/scoring/winners_take_all.py @@ -4,12 +4,21 @@ import numpy as np +from kinitro.types import ( + EnvironmentId, + MinerFirstBlocks, + MinerScores, + MinerThresholds, + MinerUID, + SubsetWeightScheme, +) + def scores_to_weights( - scores: dict[int, float], + scores: dict[MinerUID, float], temperature: float = 1.0, min_weight: float = 0.0, -) -> dict[int, float]: +) -> dict[MinerUID, float]: """ Convert scores to normalized weights via softmax. @@ -58,11 +67,11 @@ def scores_to_weights( def find_subset_winner_with_priority( - miner_scores: dict[int, dict[str, float]], - miner_thresholds: dict[int, dict[str, float]], - miner_first_blocks: dict[int, int], - subset: tuple[str, ...], -) -> int | None: + miner_scores: MinerScores, + miner_thresholds: MinerThresholds, + miner_first_blocks: MinerFirstBlocks, + subset: tuple[EnvironmentId, ...], +) -> MinerUID | None: """ Find the miner that dominates all others on a subset, with first-commit advantage. @@ -136,12 +145,12 @@ def find_subset_winner_with_priority( def compute_subset_scores_with_priority( - miner_scores: dict[int, dict[str, float]], - miner_thresholds: dict[int, dict[str, float]], - miner_first_blocks: dict[int, int], - env_ids: list[str], - subset_weight_scheme: str = "linear", -) -> dict[int, float]: + miner_scores: MinerScores, + miner_thresholds: MinerThresholds, + miner_first_blocks: MinerFirstBlocks, + env_ids: list[EnvironmentId], + subset_weight_scheme: SubsetWeightScheme = SubsetWeightScheme.LINEAR, +) -> dict[MinerUID, float]: """ Compute winners-take-all scores with first-commit advantage. @@ -154,7 +163,7 @@ def compute_subset_scores_with_priority( miner_thresholds: Dict mapping uid -> env_id -> threshold miner_first_blocks: Dict mapping uid -> first committed block env_ids: List of environment IDs - subset_weight_scheme: How to weight subsets ("linear", "exponential", "equal") + subset_weight_scheme: How to weight subsets Returns: Dict mapping uid -> total score @@ -168,14 +177,15 @@ def compute_subset_scores_with_priority( # Iterate over all non-empty subsets for subset_size in range(1, len(env_ids) + 1): # Compute weight for this subset size - if subset_weight_scheme == "linear": - subset_weight = float(subset_size) - elif subset_weight_scheme == "exponential": - subset_weight = float(2 ** (subset_size - 1)) - elif subset_weight_scheme == "equal": - subset_weight = 1.0 - else: - subset_weight = float(subset_size) + match subset_weight_scheme: + case SubsetWeightScheme.LINEAR: + subset_weight = float(subset_size) + case SubsetWeightScheme.EXPONENTIAL: + subset_weight = float(2 ** (subset_size - 1)) + case SubsetWeightScheme.EQUAL: + subset_weight = 1.0 + case _: + raise ValueError(f"Unknown subset weight scheme: {subset_weight_scheme}") # Check each subset of this size for subset in combinations(env_ids, subset_size): diff --git a/kinitro/types.py b/kinitro/types.py new file mode 100644 index 0000000..3c6e5b9 --- /dev/null +++ b/kinitro/types.py @@ -0,0 +1,188 @@ +""" +Cross-module type definitions for Kinitro. + +This module centralizes NewTypes, type aliases, enums, and TypedDicts +used across multiple Kinitro modules. It has zero kinitro imports +to avoid circular dependency risk. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, NamedTuple, NewType, NotRequired, Protocol, TypeAlias, TypedDict + +import numpy as np + +MinerUID = NewType("MinerUID", int) +BlockNumber = NewType("BlockNumber", int) +EnvironmentId = NewType("EnvironmentId", str) +TaskUUID = NewType("TaskUUID", str) +Hotkey = NewType("Hotkey", str) +Seed = NewType("Seed", int) + +MinerScores: TypeAlias = dict[MinerUID, dict[EnvironmentId, float]] # uid -> env_id -> score +MinerThresholds: TypeAlias = dict[ + MinerUID, dict[EnvironmentId, float] +] # uid -> env_id -> threshold +MinerFirstBlocks: TypeAlias = dict[MinerUID, BlockNumber] # uid -> block_number + + +def env_family_from_id(env_id: str) -> str: + """Extract the family prefix from an environment ID. + + Example: ``"metaworld/pick-place-v3"`` → ``"metaworld"``. + """ + return env_id.split("/", maxsplit=1)[0] if "/" in env_id else env_id + + +class EnvironmentFamily(str, Enum): + """Environment family discriminator.""" + + METAWORLD = "metaworld" + GENESIS = "genesis" + + +class ObjectType(Enum): + """Primitive object types for Genesis scenes.""" + + BOX = "box" + SPHERE = "sphere" + CYLINDER = "cylinder" + + +class SubsetWeightScheme(Enum): + """Weight scheme for environment subsets.""" + + LINEAR = "linear" + EXPONENTIAL = "exponential" + EQUAL = "equal" + + +class EncodedImage(TypedDict): + """Base64-encoded image with metadata, returned by encode_image().""" + + data: str + shape: list[int] + dtype: str + + +class ParsedCommitment(TypedDict): + """Parsed commitment fields from parse_commitment().""" + + huggingface_repo: str + revision_sha: str + deployment_id: str + encrypted_deployment: str | None + docker_image: str + + +class TaskResultData(TypedDict): + """Inline result dict stored in TaskPoolORM.result.""" + + success: bool + score: float + total_reward: float + timesteps: int + error: str | None + + +class MinerScoreData(TypedDict): + """Score data dict for add_miner_scores_bulk().""" + + uid: MinerUID + hotkey: Hotkey + env_id: EnvironmentId + success_rate: float + mean_reward: float + episodes_completed: int + episodes_failed: int + + +class TaskCreateData(TypedDict): + """Task data dict for create_tasks_bulk().""" + + cycle_id: int + miner_uid: MinerUID + miner_hotkey: Hotkey + miner_endpoint: str + env_id: EnvironmentId + seed: Seed + task_uuid: NotRequired[TaskUUID] + miner_repo: NotRequired[str | None] + miner_revision: NotRequired[str | None] + + +class VerificationDetails(TypedDict): + """Details dict for VerificationResult.details.""" + + match_scores: list[float] + test_seed: int + num_samples: int + + +class StepInfo(TypedDict, total=False): + """Info dict returned by step(). total=False because environments populate different subsets.""" + + task_prompt: str + task_type: str + episode_steps: int + success: bool + fallen: bool + + +class FeasibilityResult(NamedTuple): + """Result of check_task_feasibility(). Backwards-compatible with tuple unpacking.""" + + feasible: bool + reason: str + + +class EligibilityResult(NamedTuple): + """Result of verify_weight_setting_eligibility(). Backwards-compatible with tuple unpacking.""" + + eligible: bool + reason: str + + +class EnvStatsEntry(TypedDict): + """Per-environment statistics entry used in API routes.""" + + count: int + total_sr: float + + +class RobotStateDict(TypedDict): + """Robot state dictionary used across Genesis base and environment classes.""" + + base_pos: np.ndarray # (3,) + base_quat: np.ndarray # (4,) wxyz + base_vel: np.ndarray # (3,) + base_ang_vel: np.ndarray # (3,) + dof_pos: np.ndarray # (n_dofs,) + dof_vel: np.ndarray # (n_dofs,) + + +class ProceduralTaskResult(TypedDict): + """Result of ProceduralTaskGenerator.generate().""" + + object_positions: np.ndarray # (n_objects, 3) + target_positions: np.ndarray # (n_objects, 3) + physics_params: dict[str, float] + # Any: env-specific randomization params vary by implementation + domain_randomization: dict[str, Any] + + +class AffinetesEnv(Protocol): + """Structural interface for affinetes-managed evaluation environments. + + Used by the executor subsystem to interact with Docker/Basilica + environments without depending on the ``affinetes`` package at + type-checking time. + """ + + def is_ready(self) -> bool: ... + async def list_environments(self) -> list[EnvironmentId]: ... + # Any kwargs/return: evaluation payloads are environment-specific and vary + # by implementation (e.g. Genesis vs MetaWorld), so we cannot tighten these. + async def evaluate(self, **kwargs: Any) -> dict[str, Any]: ... + async def cleanup(self) -> None: ... diff --git a/kinitro/validator/client.py b/kinitro/validator/client.py index e3c11b5..3030fc4 100644 --- a/kinitro/validator/client.py +++ b/kinitro/validator/client.py @@ -18,7 +18,7 @@ class WeightsData: weights: dict[int, float] uids: list[int] values_u16: list[int] - metadata: dict[str, Any] + metadata: dict[str, Any] # Any: open-ended backend metadata class BackendClient: @@ -150,7 +150,7 @@ async def get_weights_for_block(self, block_number: int) -> WeightsData | None: logger.error("backend_request_error", error=str(e)) return None - async def get_status(self) -> dict[str, Any] | None: + async def get_status(self) -> dict[str, Any] | None: # Any: backend status schema is open-ended """ Get backend status. diff --git a/kinitro/validator/main.py b/kinitro/validator/main.py index cef0a20..1facdd8 100644 --- a/kinitro/validator/main.py +++ b/kinitro/validator/main.py @@ -5,9 +5,12 @@ import asyncio import structlog +from bittensor import Subtensor +from bittensor_wallet import Wallet from kinitro.chain.weights import set_weights, verify_weight_setting_eligibility from kinitro.config import ValidatorConfig +from kinitro.types import MinerUID from kinitro.validator.client import BackendClient logger = structlog.get_logger() @@ -37,10 +40,11 @@ def __init__( self.config = config self.backend_url = backend_url self.client = BackendClient(backend_url) - - # Lazy-loaded Bittensor objects - self._subtensor = None - self._wallet = None + self.subtensor: Subtensor | None = None + self.wallet = Wallet( + name=config.wallet_name, + hotkey=config.hotkey_name, + ) # State self._last_submitted_block = 0 @@ -52,27 +56,6 @@ def __init__( backend_url=backend_url, ) - @property - def subtensor(self): - """Lazy-load subtensor connection.""" - if self._subtensor is None: - import bittensor as bt # noqa: PLC0415 - lazy import to avoid argparse hijacking - - self._subtensor = bt.Subtensor(network=self.config.network) - return self._subtensor - - @property - def wallet(self): - """Lazy-load wallet.""" - if self._wallet is None: - import bittensor as bt # noqa: PLC0415 - lazy import to avoid argparse hijacking - - self._wallet = bt.Wallet( - name=self.config.wallet_name, - hotkey=self.config.hotkey_name, - ) - return self._wallet - async def run(self) -> None: """ Main validator loop. @@ -81,9 +64,15 @@ async def run(self) -> None: """ logger.info("starting_validator_loop") + if self.subtensor is None: + raise RuntimeError("subtensor is not initialized") + # Check eligibility - eligible, reason = verify_weight_setting_eligibility( - self.subtensor, self.wallet, self.config.netuid + eligible, reason = await asyncio.to_thread( + verify_weight_setting_eligibility, + self.subtensor, + self.wallet, + self.config.netuid, ) if not eligible: logger.error("validator_not_eligible", reason=reason) @@ -113,6 +102,9 @@ async def run(self) -> None: async def _weight_setting_cycle(self) -> None: """Single cycle: fetch weights from backend and submit if new.""" + if self.subtensor is None: + raise RuntimeError("subtensor is not initialized") + # Get latest weights from backend weights_data = await self.client.get_latest_weights() @@ -137,11 +129,12 @@ async def _weight_setting_cycle(self) -> None: ) # Submit weights to chain - success = set_weights( - subtensor=self.subtensor, - wallet=self.wallet, - netuid=self.config.netuid, - weights=weights_data.weights, + success = await asyncio.to_thread( + set_weights, + self.subtensor, + self.wallet, + self.config.netuid, + {MinerUID(k): v for k, v in weights_data.weights.items()}, ) if success: @@ -172,6 +165,12 @@ async def run_validator( """ validator = Validator(config, backend_url) try: + validator.subtensor = await asyncio.to_thread(Subtensor, network=config.network) await validator.run() finally: + if validator.subtensor is not None: + try: + await asyncio.to_thread(validator.subtensor.close) + except Exception: + logger.warning("subtensor_close_failed", exc_info=True) await validator.close() diff --git a/pyproject.toml b/pyproject.toml index aa220c9..dc1cff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ license = {text = "MIT"} dependencies = [ # Bittensor - "bittensor>=8.0.0", + "bittensor>=10.1.0", + "bittensor-wallet>=4.0.0", # Container execution (for running eval environments) "affinetes @ git+https://github.com/AffineFoundation/affinetes.git", "docker>=7.0.0", @@ -20,13 +21,13 @@ dependencies = [ "httpx>=0.25.0", "aiohttp>=3.9.0", # Numerics - "numpy>=1.24.0,<2.3", # capped for numba compatibility (Genesis dependency) + "numpy>=2.0.1,<2.3", # lower bound from bittensor >=10.1; upper cap for numba (Genesis dep) "scipy>=1.11.0", # Utils - "typer[all]>=0.9.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "structlog>=23.0.0", + "typer>=0.23.0", # Image processing "Pillow>=10.0.0", # Backend API @@ -56,10 +57,10 @@ dependencies = [ # ] # Development dev = [ - "pytest>=9.0.0", - "pytest-asyncio>=0.21.0", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", "pytest-cov>=4.0.0", - "ruff>=0.1.0", + "ruff>=0.14.13", "ty>=0.0.14", ] @@ -110,11 +111,3 @@ allowed-unresolved-imports = ["genesis", "torch"] [tool.ty.src] # Exclude miner templates (they import from local rl_interface.py which is copied at deploy time) exclude = [".git", ".pyenv", ".ruff_cache", ".venv", ".vscode", "kinitro/miner/template", "environments/genesis/warmup_kernel_cache.py"] - -[dependency-groups] -dev = [ - "pytest>=9.0.2", - "pytest-asyncio>=1.3.0", - "ruff>=0.14.13", - "ty>=0.0.14", -] diff --git a/scripts/benchmark_genesis.py b/scripts/benchmark_genesis.py index beef049..025f468 100644 --- a/scripts/benchmark_genesis.py +++ b/scripts/benchmark_genesis.py @@ -25,6 +25,8 @@ import numpy as np +from kinitro.types import RobotStateDict + @dataclass class TimingBucket: @@ -227,7 +229,7 @@ def benchmark( # 8. Reward/success check (lightweight but measure anyway) t = time.perf_counter() - robot_state = { + robot_state: RobotStateDict = { "base_pos": pos, "base_quat": quat, "base_vel": vel, diff --git a/tests/unit/test_crypto.py b/tests/unit/test_crypto.py index cf6929f..dcedf51 100644 --- a/tests/unit/test_crypto.py +++ b/tests/unit/test_crypto.py @@ -17,7 +17,7 @@ @pytest.fixture() -def keypair(): +def keypair() -> BackendKeypair: return BackendKeypair.generate() @@ -31,14 +31,14 @@ class TestUUIDConversion: pytest.param("95edf2b6e18b400a83985573df10e5e4", id="no_dashes"), ], ) - def test_uuid_to_bytes(self, uuid_str): + def test_uuid_to_bytes(self, uuid_str: str) -> None: """Both UUID formats should produce the same 16-byte result.""" result = uuid_to_bytes(uuid_str) assert len(result) == 16 assert result.hex() == "95edf2b6e18b400a83985573df10e5e4" - def test_bytes_to_uuid_roundtrip(self): + def test_bytes_to_uuid_roundtrip(self) -> None: """Bytes -> UUID -> Bytes roundtrip.""" original = "95edf2b6-e18b-400a-8398-5573df10e5e4" as_bytes = uuid_to_bytes(original) @@ -46,12 +46,12 @@ def test_bytes_to_uuid_roundtrip(self): assert back_to_uuid == original - def test_uuid_to_bytes_invalid_length(self): + def test_uuid_to_bytes_invalid_length(self) -> None: """Invalid UUID length should raise.""" with pytest.raises(ValueError, match="Invalid UUID format"): uuid_to_bytes("abc123") - def test_bytes_to_uuid_invalid_length(self): + def test_bytes_to_uuid_invalid_length(self) -> None: """Invalid bytes length should raise.""" with pytest.raises(ValueError, match="Invalid UUID bytes length"): bytes_to_uuid(b"too short") @@ -60,12 +60,12 @@ def test_bytes_to_uuid_invalid_length(self): class TestBackendKeypair: """Tests for BackendKeypair class.""" - def test_generate_creates_valid_keypair(self, keypair): + def test_generate_creates_valid_keypair(self, keypair: BackendKeypair) -> None: """Generate should create a valid keypair.""" assert keypair.private_key is not None assert keypair.public_key is not None - def test_public_key_hex_length(self, keypair): + def test_public_key_hex_length(self, keypair: BackendKeypair) -> None: """Public key hex should be 64 characters (32 bytes).""" pub_hex = keypair.public_key_hex() @@ -73,7 +73,7 @@ def test_public_key_hex_length(self, keypair): # Should be valid hex bytes.fromhex(pub_hex) - def test_private_key_hex_length(self, keypair): + def test_private_key_hex_length(self, keypair: BackendKeypair) -> None: """Private key hex should be 64 characters (32 bytes).""" priv_hex = keypair.private_key_hex() @@ -81,7 +81,7 @@ def test_private_key_hex_length(self, keypair): # Should be valid hex bytes.fromhex(priv_hex) - def test_from_private_key_hex_roundtrip(self, keypair): + def test_from_private_key_hex_roundtrip(self, keypair: BackendKeypair) -> None: """Load keypair from hex should preserve keys.""" priv_hex = keypair.private_key_hex() @@ -90,7 +90,7 @@ def test_from_private_key_hex_roundtrip(self, keypair): assert restored.public_key_hex() == keypair.public_key_hex() assert restored.private_key_hex() == keypair.private_key_hex() - def test_from_private_key_file(self, keypair, tmp_path): + def test_from_private_key_file(self, keypair: BackendKeypair, tmp_path) -> None: """Load keypair from file.""" key_file = tmp_path / "test.key" keypair.save_private_key(key_file) @@ -99,7 +99,7 @@ def test_from_private_key_file(self, keypair, tmp_path): assert restored.public_key_hex() == keypair.public_key_hex() - def test_save_private_key_permissions(self, keypair, tmp_path): + def test_save_private_key_permissions(self, keypair: BackendKeypair, tmp_path) -> None: """Private key file should have restricted permissions (0600).""" key_file = tmp_path / "test.key" keypair.save_private_key(key_file) @@ -107,7 +107,7 @@ def test_save_private_key_permissions(self, keypair, tmp_path): mode = os.stat(key_file).st_mode & 0o777 assert mode == 0o600 - def test_save_public_key(self, keypair, tmp_path): + def test_save_public_key(self, keypair: BackendKeypair, tmp_path) -> None: """Save and read public key.""" pub_file = tmp_path / "test.pub" keypair.save_public_key(pub_file) @@ -119,7 +119,7 @@ def test_save_public_key(self, keypair, tmp_path): class TestLoadPublicKey: """Tests for load_public_key function.""" - def test_load_valid_public_key(self): + def test_load_valid_public_key(self) -> None: """Load a valid public key from hex.""" pub_hex = BackendKeypair.generate().public_key_hex() @@ -128,7 +128,7 @@ def test_load_valid_public_key(self): # Should be able to use for encryption assert loaded is not None - def test_load_invalid_length(self): + def test_load_invalid_length(self) -> None: """Invalid length should raise.""" with pytest.raises(ValueError, match="Invalid public key length"): load_public_key("abcd1234") @@ -146,7 +146,7 @@ class TestEncryptDecrypt: pytest.param(True, id="key_object"), ], ) - def test_encrypt_decrypt_roundtrip(self, keypair, use_key_object): + def test_encrypt_decrypt_roundtrip(self, keypair: BackendKeypair, use_key_object) -> None: """Encrypt and decrypt should return original value (hex string or key object).""" pub_key = keypair.public_key if use_key_object else keypair.public_key_hex() @@ -155,7 +155,7 @@ def test_encrypt_decrypt_roundtrip(self, keypair, use_key_object): assert decrypted == self.DEPLOYMENT_ID - def test_encrypted_blob_is_base85(self, keypair): + def test_encrypted_blob_is_base85(self, keypair: BackendKeypair) -> None: """Encrypted blob should be base85 encoded.""" encrypted = encrypt_deployment_id(self.DEPLOYMENT_ID, keypair.public_key_hex()) @@ -163,14 +163,14 @@ def test_encrypted_blob_is_base85(self, keypair): decoded = base64.b85decode(encrypted.encode("ascii")) assert len(decoded) == 64 # 32 + 16 + 16 (pubkey + ciphertext + tag, nonce derived) - def test_encrypted_blob_length(self, keypair): + def test_encrypted_blob_length(self, keypair: BackendKeypair) -> None: """Encrypted blob should be ~95 characters (base85 of 76 bytes).""" encrypted = encrypt_deployment_id(self.DEPLOYMENT_ID, keypair.public_key_hex()) # Base85: 64 bytes -> ceil(64 * 5 / 4) = 80 characters assert len(encrypted) == 80 - def test_decrypt_with_wrong_key_fails(self, keypair): + def test_decrypt_with_wrong_key_fails(self, keypair: BackendKeypair) -> None: """Decryption with wrong key should fail.""" keypair2 = BackendKeypair.generate() @@ -179,7 +179,7 @@ def test_decrypt_with_wrong_key_fails(self, keypair): with pytest.raises(ValueError, match="Decryption failed"): decrypt_deployment_id(encrypted, keypair2.private_key) - def test_decrypt_tampered_data_fails(self, keypair): + def test_decrypt_tampered_data_fails(self, keypair: BackendKeypair) -> None: """Decryption of tampered data should fail.""" encrypted = encrypt_deployment_id(self.DEPLOYMENT_ID, keypair.public_key_hex()) @@ -189,7 +189,7 @@ def test_decrypt_tampered_data_fails(self, keypair): with pytest.raises(ValueError): decrypt_deployment_id(tampered, keypair.private_key) - def test_each_encryption_is_unique(self, keypair): + def test_each_encryption_is_unique(self, keypair: BackendKeypair) -> None: """Each encryption should produce different output (fresh ephemeral key).""" encrypted1 = encrypt_deployment_id(self.DEPLOYMENT_ID, keypair.public_key_hex()) encrypted2 = encrypt_deployment_id(self.DEPLOYMENT_ID, keypair.public_key_hex()) @@ -205,7 +205,7 @@ def test_each_encryption_is_unique(self, keypair): class TestIntegration: """Integration tests for the full encryption flow.""" - def test_full_commitment_flow(self, keypair): + def test_full_commitment_flow(self, keypair: BackendKeypair) -> None: """Test full flow: generate keys, encrypt, parse, decrypt.""" # Miner encrypts their deployment ID deployment_id = "95edf2b6-e18b-400a-8398-5573df10e5e4" @@ -226,11 +226,12 @@ def test_full_commitment_flow(self, keypair): assert parsed["encrypted_deployment"] == encrypted_blob # Backend decrypts the endpoint + assert parsed["encrypted_deployment"] is not None decrypted = decrypt_deployment_id(parsed["encrypted_deployment"], keypair.private_key) assert decrypted == deployment_id - def test_plain_commitment_still_works(self): + def test_plain_commitment_still_works(self) -> None: """Plain commitments should still work.""" commitment = "user/policy:abc123:95edf2b6-e18b-400a-8398-5573df10e5e4" diff --git a/tests/unit/test_cycle_isolation.py b/tests/unit/test_cycle_isolation.py index c0e40a3..5afc60c 100644 --- a/tests/unit/test_cycle_isolation.py +++ b/tests/unit/test_cycle_isolation.py @@ -45,13 +45,13 @@ class TestCancelIncompleteCycles: """Tests for Storage.cancel_incomplete_cycles().""" @pytest.fixture - def mock_session(self): + def mock_session(self) -> AsyncMock: """Create a mock async session.""" session = AsyncMock() return session @pytest.mark.asyncio - async def test_no_incomplete_cycles(self, mock_session): + async def test_no_incomplete_cycles(self, mock_session: AsyncMock) -> None: """When no incomplete cycles exist, nothing is cancelled.""" mock_session.execute = _mock_execute_results([]) @@ -63,7 +63,7 @@ async def test_no_incomplete_cycles(self, mock_session): assert tasks_cancelled == 0 @pytest.mark.asyncio - async def test_cancels_running_cycle_and_tasks(self, mock_session): + async def test_cancels_running_cycle_and_tasks(self, mock_session: AsyncMock) -> None: """Running cycles and their pending/assigned tasks are cancelled.""" mock_cycle = _make_mock_cycle() mock_task1 = _make_mock_task(TaskStatus.PENDING.value) @@ -89,7 +89,7 @@ async def test_cancels_running_cycle_and_tasks(self, mock_session): assert mock_task2.status == TaskStatus.FAILED.value @pytest.mark.asyncio - async def test_leaves_completed_tasks_unchanged(self, mock_session): + async def test_leaves_completed_tasks_unchanged(self, mock_session: AsyncMock) -> None: """Completed/failed tasks from incomplete cycles are not modified.""" mock_cycle = _make_mock_cycle() mock_task = _make_mock_task(TaskStatus.PENDING.value) diff --git a/tests/unit/test_genesis.py b/tests/unit/test_genesis.py index db2726b..1830f90 100644 --- a/tests/unit/test_genesis.py +++ b/tests/unit/test_genesis.py @@ -1,5 +1,7 @@ """Tests for Genesis physics simulation environments.""" +from typing import Any + import numpy as np import pytest @@ -25,6 +27,7 @@ check_task_feasibility, ) from kinitro.environments.registry import ENVIRONMENTS +from kinitro.types import ObjectType # ============================================================================= # Helpers @@ -33,7 +36,7 @@ def _make_scene_object( object_id: str = "obj_00_red_box", - object_type: str = "box", + object_type: ObjectType = ObjectType.BOX, position: list[float] | None = None, color: str = "red", size: float = 0.05, @@ -56,16 +59,16 @@ def _make_scene_object( def _make_task_spec( task_type: TaskType = TaskType.NAVIGATE, target_object_id: str = "obj_00_red_box", - target_object_type: str = "box", + target_object_type: ObjectType = ObjectType.BOX, target_position: list[float] | None = None, destination_object_id: str | None = None, destination_position: list[float] | None = None, - initial_state: dict | None = None, + initial_state: dict[str, Any] | None = None, ) -> TaskSpec: """Convenience builder for TaskSpec.""" return TaskSpec( task_type=task_type, - task_prompt=f"Do something with {target_object_type}.", + task_prompt=f"Do something with {target_object_type.value}.", target_object_id=target_object_id, target_object_type=target_object_type, target_position=target_position or [2.0, 2.0, 0.05], @@ -95,21 +98,21 @@ def _make_test_objects() -> list[SceneObject]: return [ _make_scene_object( object_id="obj_00_red_box", - object_type="box", + object_type=ObjectType.BOX, color="red", pickupable=True, position=[1.5, 0.0, 0.05], ), _make_scene_object( object_id="obj_01_green_sphere", - object_type="sphere", + object_type=ObjectType.SPHERE, color="green", pickupable=True, position=[0.0, 1.5, 0.05], ), _make_scene_object( object_id="obj_02_blue_cylinder", - object_type="cylinder", + object_type=ObjectType.CYLINDER, color="blue", size=0.15, pickupable=False, @@ -117,7 +120,7 @@ def _make_test_objects() -> list[SceneObject]: ), _make_scene_object( object_id="obj_03_yellow_box", - object_type="box", + object_type=ObjectType.BOX, color="yellow", size=0.2, pickupable=False, @@ -134,26 +137,26 @@ def _make_test_objects() -> list[SceneObject]: class TestTaskType: """Tests for TaskType enum and related constants.""" - def test_enum_values(self): + def test_enum_values(self) -> None: """Enum values should match expected strings.""" assert TaskType.NAVIGATE.value == "navigate" assert TaskType.PICKUP.value == "pickup" assert TaskType.PLACE.value == "place" assert TaskType.PUSH.value == "push" - def test_all_task_types_in_required_properties(self): + def test_all_task_types_in_required_properties(self) -> None: """Every TaskType should have an entry in TASK_REQUIRED_PROPERTIES.""" for task_type in TaskType: assert task_type in TASK_REQUIRED_PROPERTIES - def test_required_properties_are_lists(self): + def test_required_properties_are_lists(self) -> None: """All required property entries should be lists of strings.""" for task_type, props in TASK_REQUIRED_PROPERTIES.items(): assert isinstance(props, list), f"{task_type} properties not a list" for p in props: assert isinstance(p, str) - def test_object_types_are_valid_strings(self): + def test_object_types_are_valid_strings(self) -> None: """OBJECT_TYPES should contain known primitive types.""" assert len(OBJECT_TYPES) > 0 for t in OBJECT_TYPES: @@ -162,7 +165,7 @@ def test_object_types_are_valid_strings(self): assert "sphere" in OBJECT_TYPES assert "cylinder" in OBJECT_TYPES - def test_enum_has_exactly_four_members(self): + def test_enum_has_exactly_four_members(self) -> None: """Should have exactly NAVIGATE, PICKUP, PLACE, PUSH.""" assert len(TaskType) == 4 @@ -175,7 +178,7 @@ def test_enum_has_exactly_four_members(self): class TestTaskSpec: """Tests for TaskSpec serialization.""" - def test_roundtrip_all_fields(self): + def test_roundtrip_all_fields(self) -> None: """to_dict → from_dict should preserve all fields.""" spec = _make_task_spec( task_type=TaskType.PLACE, @@ -194,7 +197,7 @@ def test_roundtrip_all_fields(self): assert restored.destination_position == spec.destination_position assert restored.initial_state == spec.initial_state - def test_roundtrip_optional_fields_none(self): + def test_roundtrip_optional_fields_none(self) -> None: """Optional destination fields should survive roundtrip as None.""" spec = _make_task_spec(task_type=TaskType.NAVIGATE) restored = TaskSpec.from_dict(spec.to_dict()) @@ -202,7 +205,7 @@ def test_roundtrip_optional_fields_none(self): assert restored.destination_object_id is None assert restored.destination_position is None - def test_roundtrip_with_initial_state(self): + def test_roundtrip_with_initial_state(self) -> None: """initial_state dict should survive roundtrip.""" spec = _make_task_spec( task_type=TaskType.PICKUP, @@ -211,14 +214,14 @@ def test_roundtrip_with_initial_state(self): restored = TaskSpec.from_dict(spec.to_dict()) assert restored.initial_state == {"initial_height": 0.05, "extra": [1, 2, 3]} - def test_from_dict_invalid_task_type(self): + def test_from_dict_invalid_task_type(self) -> None: """from_dict should raise ValueError on invalid task_type string.""" data = _make_task_spec().to_dict() data["task_type"] = "fly_to_moon" with pytest.raises(ValueError): TaskSpec.from_dict(data) - def test_to_dict_keys(self): + def test_to_dict_keys(self) -> None: """to_dict should contain all expected keys.""" data = _make_task_spec().to_dict() expected_keys = { @@ -242,21 +245,21 @@ def test_to_dict_keys(self): class TestSceneObject: """Tests for SceneObject data class.""" - def test_construction(self): + def test_construction(self) -> None: """Should construct with all fields.""" obj = _make_scene_object() assert obj.object_id == "obj_00_red_box" - assert obj.object_type == "box" + assert obj.object_type == ObjectType.BOX assert obj.color == "red" assert obj.color_rgb == (0.9, 0.2, 0.2) assert obj.pickupable is True - def test_default_is_picked_up(self): + def test_default_is_picked_up(self) -> None: """is_picked_up should default to False.""" obj = _make_scene_object() assert obj.is_picked_up is False - def test_color_palette_distinct(self): + def test_color_palette_distinct(self) -> None: """All colors in the palette should have distinct RGB values.""" rgb_values = list(OBJECT_COLORS.values()) assert len(rgb_values) == len(set(rgb_values)), "Duplicate RGB values in palette" @@ -292,7 +295,7 @@ class TestCheckTaskFeasibility: ), ], ) - def test_feasible_cases(self, task_type, obj_kwargs, dest_factory, expected): + def test_feasible_cases(self, task_type, obj_kwargs, dest_factory, expected) -> None: obj = _make_scene_object(**obj_kwargs) dest = dest_factory() if dest_factory else None feasible, _ = check_task_feasibility(task_type, obj, destination=dest) @@ -328,23 +331,23 @@ def test_feasible_cases(self, task_type, obj_kwargs, dest_factory, expected): pytest.param(TaskType.PUSH, {}, None, "destination", id="push_no_dest"), ], ) - def test_infeasible_cases(self, task_type, obj_kwargs, dest_factory, reason_substr): + def test_infeasible_cases(self, task_type, obj_kwargs, dest_factory, reason_substr) -> None: obj = _make_scene_object(**obj_kwargs) dest = dest_factory() if dest_factory else None feasible, reason = check_task_feasibility(task_type, obj, destination=dest) assert feasible is False assert reason_substr in reason.lower() - def test_robot_capability_filtering_unsupported(self): + def test_robot_capability_filtering_unsupported(self) -> None: """Task type not in robot_supported_tasks should be infeasible.""" obj = _make_scene_object() feasible, reason = check_task_feasibility( - TaskType.PICKUP, obj, robot_supported_tasks=["navigate"] + TaskType.PICKUP, obj, robot_supported_tasks=[TaskType.NAVIGATE] ) assert feasible is False assert "does not support" in reason.lower() - def test_robot_capability_filtering_none_allows_all(self): + def test_robot_capability_filtering_none_allows_all(self) -> None: """robot_supported_tasks=None should allow all task types.""" obj = _make_scene_object(pickupable=True) feasible, _ = check_task_feasibility(TaskType.PICKUP, obj, robot_supported_tasks=None) @@ -359,7 +362,7 @@ def test_robot_capability_filtering_none_allows_all(self): class TestSceneGenerator: """Tests for procedural scene generation.""" - def test_deterministic(self): + def test_deterministic(self) -> None: """Same seed should produce identical scene config.""" gen = SceneGenerator() s1 = gen.generate_scene(42) @@ -371,14 +374,14 @@ def test_deterministic(self): assert a.object_id == b.object_id assert a.position == b.position - def test_object_count_in_range(self): + def test_object_count_in_range(self) -> None: """Object count should be within the configured range.""" gen = SceneGenerator(num_objects=(3, 6)) for seed in range(20): scene = gen.generate_scene(seed) assert 3 <= len(scene.objects) <= 6, f"seed={seed}: {len(scene.objects)} objects" - def test_at_least_one_pickupable_and_one_landmark(self): + def test_at_least_one_pickupable_and_one_landmark(self) -> None: """Each scene should have at least 1 pickupable and 1 non-pickupable object.""" gen = SceneGenerator(num_objects=(3, 6)) for seed in range(20): @@ -388,7 +391,7 @@ def test_at_least_one_pickupable_and_one_landmark(self): assert len(pickupable) >= 1, f"seed={seed}: no pickupable objects" assert len(landmarks) >= 1, f"seed={seed}: no landmark objects" - def test_objects_avoid_center(self): + def test_objects_avoid_center(self) -> None: """All objects should be placed away from the robot spawn area.""" gen = SceneGenerator(num_objects=(3, 6)) for seed in range(20): @@ -397,7 +400,7 @@ def test_objects_avoid_center(self): dist = np.sqrt(obj.position[0] ** 2 + obj.position[1] ** 2) assert dist >= 0.8, f"seed={seed}: {obj.object_id} too close to center ({dist:.2f})" - def test_pickupable_within_reachable_range(self): + def test_pickupable_within_reachable_range(self) -> None: """Pickupable objects should be within 70% of arena half-size.""" gen = SceneGenerator(num_objects=(3, 6), arena_size=5.0) max_dist = (5.0 / 2.0) * 0.7 @@ -410,7 +413,7 @@ def test_pickupable_within_reachable_range(self): f"seed={seed}: pickupable {obj.object_id} too far ({dist:.2f} > {max_dist:.2f})" ) - def test_always_flat_terrain(self): + def test_always_flat_terrain(self) -> None: """All seeds should produce flat terrain with empty params.""" gen = SceneGenerator() for seed in range(20): @@ -418,7 +421,7 @@ def test_always_flat_terrain(self): assert scene.terrain_type == "flat", f"seed={seed}: got {scene.terrain_type}" assert scene.terrain_params == {}, f"seed={seed}: non-empty terrain_params" - def test_get_scene_objects_conversion(self): + def test_get_scene_objects_conversion(self) -> None: """get_scene_objects() should produce SceneObject instances matching config.""" gen = SceneGenerator() scene = gen.generate_scene(42) @@ -433,7 +436,7 @@ def test_get_scene_objects_conversion(self): assert obj.pickupable == cfg.pickupable assert obj.is_picked_up is False - def test_object_sizes(self): + def test_object_sizes(self) -> None: """Pickupable objects should be small, landmarks should be large.""" gen = SceneGenerator() for seed in range(20): @@ -457,7 +460,7 @@ def test_object_sizes(self): class TestTaskGenerator: """Tests for scene-grounded task prompt generation.""" - def test_deterministic(self): + def test_deterministic(self) -> None: """Same seed + same objects should produce same task.""" gen = TaskGenerator() objects = _make_test_objects() @@ -473,14 +476,14 @@ def test_deterministic(self): assert task1.target_object_id == task2.target_object_id assert task1.task_prompt == task2.task_prompt - def test_returns_none_for_empty_objects(self): + def test_returns_none_for_empty_objects(self) -> None: """Should return None when no objects are available.""" gen = TaskGenerator() rng = np.random.default_rng(42) task = gen.generate_task([], rng) assert task is None - def test_navigate_selects_any_object(self): + def test_navigate_selects_any_object(self) -> None: """NAVIGATE should select from any object in the scene.""" gen = TaskGenerator(task_types=[TaskType.NAVIGATE]) objects = _make_test_objects() @@ -492,7 +495,7 @@ def test_navigate_selects_any_object(self): assert task.task_type == TaskType.NAVIGATE assert task.target_object_id in [o.object_id for o in objects] - def test_pickup_only_pickupable(self): + def test_pickup_only_pickupable(self) -> None: """PICKUP should only select pickupable objects.""" gen = TaskGenerator(task_types=[TaskType.PICKUP]) objects = _make_test_objects() @@ -505,7 +508,7 @@ def test_pickup_only_pickupable(self): assert task.task_type == TaskType.PICKUP assert task.target_object_id in pickupable_ids - def test_place_requires_pickupable_and_landmark(self): + def test_place_requires_pickupable_and_landmark(self) -> None: """PLACE should require both pickupable and landmark objects.""" gen = TaskGenerator(task_types=[TaskType.PLACE]) objects = _make_test_objects() @@ -520,7 +523,7 @@ def test_place_requires_pickupable_and_landmark(self): target = next(o for o in objects if o.object_id == task.target_object_id) assert target.pickupable is True - def test_place_returns_none_no_landmarks(self): + def test_place_returns_none_no_landmarks(self) -> None: """PLACE should return None when there are no landmark objects.""" gen = TaskGenerator(task_types=[TaskType.PLACE]) # All pickupable, no landmarks @@ -533,7 +536,7 @@ def test_place_returns_none_no_landmarks(self): task = gen.generate_task(objects, rng) assert task is None - def test_push_requires_two_objects(self): + def test_push_requires_two_objects(self) -> None: """PUSH should require at least 2 objects (target != destination).""" gen = TaskGenerator(task_types=[TaskType.PUSH]) objects = _make_test_objects() @@ -545,7 +548,7 @@ def test_push_requires_two_objects(self): assert task.task_type == TaskType.PUSH assert task.target_object_id != task.destination_object_id - def test_push_returns_none_single_object(self): + def test_push_returns_none_single_object(self) -> None: """PUSH should return None with only 1 object.""" gen = TaskGenerator(task_types=[TaskType.PUSH]) objects = [_make_scene_object(object_id="only_one")] @@ -554,7 +557,7 @@ def test_push_returns_none_single_object(self): task = gen.generate_task(objects, rng) assert task is None - def test_prompt_contains_target_info(self): + def test_prompt_contains_target_info(self) -> None: """Generated prompt should contain target color and object type.""" gen = TaskGenerator(task_types=[TaskType.NAVIGATE]) objects = _make_test_objects() @@ -565,9 +568,9 @@ def test_prompt_contains_target_info(self): assert task is not None target = next(o for o in objects if o.object_id == task.target_object_id) assert target.color in task.task_prompt.lower() - assert target.object_type in task.task_prompt.lower() + assert target.object_type.value in task.task_prompt.lower() - def test_place_prompt_contains_destination_info(self): + def test_place_prompt_contains_destination_info(self) -> None: """PLACE prompt should contain destination color and object type.""" gen = TaskGenerator(task_types=[TaskType.PLACE]) objects = _make_test_objects() @@ -578,9 +581,9 @@ def test_place_prompt_contains_destination_info(self): assert task is not None dest = next(o for o in objects if o.object_id == task.destination_object_id) assert dest.color in task.task_prompt.lower() - assert dest.object_type in task.task_prompt.lower() + assert dest.object_type.value in task.task_prompt.lower() - def test_robot_capability_filtering(self): + def test_robot_capability_filtering(self) -> None: """Unsupported task types should be excluded by robot config.""" navigate_only_config = RobotConfig( name="test", @@ -595,7 +598,7 @@ def test_robot_capability_filtering(self): fall_height_threshold=0.2, ego_camera_link="link", ego_camera_pos_offset=(0.0, 0.0, 0.0), - supported_task_types=["navigate"], + supported_task_types=[TaskType.NAVIGATE], ) gen = TaskGenerator() objects = _make_test_objects() @@ -607,7 +610,7 @@ def test_robot_capability_filtering(self): if task is not None: assert task.task_type == TaskType.NAVIGATE - def test_generate_task_with_specific_type(self): + def test_generate_task_with_specific_type(self) -> None: """generate_task with specific task_type parameter should honour it.""" gen = TaskGenerator() objects = _make_test_objects() @@ -627,7 +630,7 @@ def test_generate_task_with_specific_type(self): class TestG1Reward: """Tests for G1Environment._compute_reward using object.__new__() bypass.""" - def test_navigate_alive_bonus(self, g1_env): + def test_navigate_alive_bonus(self, g1_env) -> None: """NAVIGATE reward should always include alive bonus.""" robot_state = {"base_pos": np.array([0.0, 0.0, 0.75])} spec = _make_task_spec(task_type=TaskType.NAVIGATE, target_position=[5.0, 5.0, 0.0]) @@ -636,7 +639,7 @@ def test_navigate_alive_bonus(self, g1_env): assert reward >= 0.01 # alive bonus - def test_navigate_reward_increases_closer(self, g1_env): + def test_navigate_reward_increases_closer(self, g1_env) -> None: """NAVIGATE reward should increase as distance decreases.""" spec = _make_task_spec(task_type=TaskType.NAVIGATE, target_position=[3.0, 0.0, 0.0]) @@ -648,7 +651,7 @@ def test_navigate_reward_increases_closer(self, g1_env): assert r_near > r_far - def test_navigate_high_reward_near_target(self, g1_env): + def test_navigate_high_reward_near_target(self, g1_env) -> None: """NAVIGATE reward should be higher when very close to target.""" spec = _make_task_spec(task_type=TaskType.NAVIGATE, target_position=[0.1, 0.0, 0.0]) robot_state = {"base_pos": np.array([0.0, 0.0, 0.75])} @@ -656,7 +659,7 @@ def test_navigate_high_reward_near_target(self, g1_env): reward = g1_env._compute_reward(robot_state, {}, spec) assert reward > 0.01 # more than just alive bonus - def test_pickup_approach_reward(self, g1_env): + def test_pickup_approach_reward(self, g1_env) -> None: """PICKUP should give approach reward when near the object.""" spec = _make_task_spec( task_type=TaskType.PICKUP, @@ -670,7 +673,7 @@ def test_pickup_approach_reward(self, g1_env): reward = g1_env._compute_reward(robot_state, obj_states, spec) assert reward > 0.01 # alive + approach - def test_pickup_lift_bonus(self, g1_env): + def test_pickup_lift_bonus(self, g1_env) -> None: """PICKUP should give large bonus when object is lifted above threshold.""" spec = _make_task_spec( task_type=TaskType.PICKUP, @@ -684,7 +687,7 @@ def test_pickup_lift_bonus(self, g1_env): reward = g1_env._compute_reward(robot_state, obj_states, spec) assert reward >= 1.0 # lift bonus of 1.0 - def test_pickup_no_lift_bonus_below_threshold(self, g1_env): + def test_pickup_no_lift_bonus_below_threshold(self, g1_env) -> None: """PICKUP should not give lift bonus when height below threshold.""" spec = _make_task_spec( task_type=TaskType.PICKUP, @@ -698,7 +701,7 @@ def test_pickup_no_lift_bonus_below_threshold(self, g1_env): reward = g1_env._compute_reward(robot_state, obj_states, spec) assert reward < 1.0 # no lift bonus - def test_pickup_missing_object_only_alive_bonus(self, g1_env): + def test_pickup_missing_object_only_alive_bonus(self, g1_env) -> None: """PICKUP should return only alive bonus when object not in states.""" spec = _make_task_spec( task_type=TaskType.PICKUP, @@ -710,7 +713,7 @@ def test_pickup_missing_object_only_alive_bonus(self, g1_env): reward = g1_env._compute_reward(robot_state, {}, spec) assert abs(reward - 0.01) < 1e-6 - def test_place_reward_based_on_distance(self, g1_env): + def test_place_reward_based_on_distance(self, g1_env) -> None: """PLACE reward should depend on object-to-destination distance.""" spec = _make_task_spec( task_type=TaskType.PLACE, @@ -729,7 +732,7 @@ def test_place_reward_based_on_distance(self, g1_env): assert r_close > r_far - def test_push_reward_based_on_xy_distance(self, g1_env): + def test_push_reward_based_on_xy_distance(self, g1_env) -> None: """PUSH reward should depend on XY distance to destination.""" spec = _make_task_spec( task_type=TaskType.PUSH, @@ -746,7 +749,7 @@ def test_push_reward_based_on_xy_distance(self, g1_env): assert r_close > r_far - def test_all_rewards_non_negative(self, g1_env): + def test_all_rewards_non_negative(self, g1_env) -> None: """All task types should produce non-negative rewards (alive bonus).""" robot_state = {"base_pos": np.array([0.0, 0.0, 0.75])} obj_states = {"obj_00": np.array([2.0, 2.0, 0.05])} @@ -855,7 +858,9 @@ class TestG1Success: ), ], ) - def test_check_success(self, g1_env, task_type, spec_kwargs, robot_pos, obj_states, expected): + def test_check_success( + self, g1_env, task_type, spec_kwargs, robot_pos, obj_states, expected + ) -> None: robot_state = {"base_pos": np.array(robot_pos)} spec = _make_task_spec(task_type=task_type, **spec_kwargs) assert g1_env._check_success(robot_state, obj_states, spec) is expected @@ -869,27 +874,27 @@ def test_check_success(self, g1_env, task_type, spec_kwargs, robot_pos, obj_stat class TestRobotConfig: """Tests for G1_CONFIG robot configuration consistency.""" - def test_g1_dof_consistency(self): + def test_g1_dof_consistency(self) -> None: """joint_names, default_dof_pos, and action_scale should all match num_actuated_dofs.""" assert len(G1_CONFIG.joint_names) == G1_CONFIG.num_actuated_dofs assert len(G1_CONFIG.default_dof_pos) == G1_CONFIG.num_actuated_dofs assert len(G1_CONFIG.action_scale) == G1_CONFIG.num_actuated_dofs - def test_g1_init_pos_and_quat(self): + def test_g1_init_pos_and_quat(self) -> None: """init_pos should have 3 elements, init_quat should have 4.""" assert len(G1_CONFIG.init_pos) == 3 assert len(G1_CONFIG.init_quat) == 4 - def test_g1_supported_task_types_non_empty(self): + def test_g1_supported_task_types_non_empty(self) -> None: """supported_task_types should be non-empty.""" assert len(G1_CONFIG.supported_task_types) > 0 - def test_g1_ego_camera_link_non_empty(self): + def test_g1_ego_camera_link_non_empty(self) -> None: """ego_camera_link should be a non-empty string.""" assert isinstance(G1_CONFIG.ego_camera_link, str) assert len(G1_CONFIG.ego_camera_link) > 0 - def test_g1_ego_camera_pos_offset(self): + def test_g1_ego_camera_pos_offset(self) -> None: """ego_camera_pos_offset should have 3 elements.""" assert len(G1_CONFIG.ego_camera_pos_offset) == 3 @@ -902,14 +907,14 @@ def test_g1_ego_camera_pos_offset(self): class TestGenesisRegistry: """Tests for genesis environment registration.""" - def test_g1_in_environments(self): + def test_g1_in_environments(self) -> None: """genesis/g1-v0 should be in the ENVIRONMENTS registry.""" assert "genesis/g1-v0" in ENVIRONMENTS - def test_factory_is_callable(self): + def test_factory_is_callable(self) -> None: """The factory for genesis/g1-v0 should be callable.""" assert callable(ENVIRONMENTS["genesis/g1-v0"]) - def test_cli_families_include_genesis(self): + def test_cli_families_include_genesis(self) -> None: """AVAILABLE_ENV_FAMILIES should include 'genesis'.""" assert "genesis" in AVAILABLE_ENV_FAMILIES diff --git a/tests/unit/test_pareto.py b/tests/unit/test_pareto.py index 17284d5..f720bf6 100644 --- a/tests/unit/test_pareto.py +++ b/tests/unit/test_pareto.py @@ -13,12 +13,18 @@ find_subset_winner_with_priority, scores_to_weights, ) +from kinitro.types import BlockNumber, EnvironmentId, MinerUID + +ENV_A = EnvironmentId("a") +ENV_B = EnvironmentId("b") +ENV_AB = [ENV_A, ENV_B] +ENV_AB_TUPLE = (ENV_A, ENV_B) class TestEpsilonDominates: """Tests for epsilon dominance.""" - def test_clear_dominance(self): + def test_clear_dominance(self) -> None: """A clearly dominates B.""" a = np.array([0.9, 0.8, 0.85]) b = np.array([0.7, 0.6, 0.65]) @@ -27,7 +33,7 @@ def test_clear_dominance(self): assert epsilon_dominates(a, b, eps) is True assert epsilon_dominates(b, a, eps) is False - def test_tie_no_dominance(self): + def test_tie_no_dominance(self) -> None: """Equal scores mean no dominance (anti-copy mechanism).""" a = np.array([0.8, 0.7, 0.75]) b = np.array([0.8, 0.7, 0.75]) @@ -36,7 +42,7 @@ def test_tie_no_dominance(self): assert epsilon_dominates(a, b, eps) is False assert epsilon_dominates(b, a, eps) is False - def test_pareto_incomparable(self): + def test_pareto_incomparable(self) -> None: """Neither dominates when each is better on different dims.""" a = np.array([0.9, 0.5]) # Better on env 0 b = np.array([0.5, 0.9]) # Better on env 1 @@ -45,7 +51,7 @@ def test_pareto_incomparable(self): assert epsilon_dominates(a, b, eps) is False assert epsilon_dominates(b, a, eps) is False - def test_within_epsilon_no_dominance(self): + def test_within_epsilon_no_dominance(self) -> None: """Small differences within epsilon don't count.""" a = np.array([0.82, 0.72]) b = np.array([0.80, 0.70]) @@ -57,107 +63,130 @@ def test_within_epsilon_no_dominance(self): class TestParetoFrontier: """Tests for Pareto frontier computation.""" - def test_single_miner(self): + def test_single_miner(self) -> None: """Single miner is always on frontier.""" - scores = {0: {"env_a": 0.8, "env_b": 0.7}} - result = compute_pareto_frontier(scores, ["env_a", "env_b"], 50) + scores = {MinerUID(0): {EnvironmentId("env_a"): 0.8, EnvironmentId("env_b"): 0.7}} + result = compute_pareto_frontier( + scores, [EnvironmentId("env_a"), EnvironmentId("env_b")], 50 + ) - assert result.frontier_uids == [0] + assert result.frontier_uids == [MinerUID(0)] - def test_dominant_miner(self): + def test_dominant_miner(self) -> None: """Clearly dominant miner is sole frontier member.""" scores = { - 0: {"env_a": 0.9, "env_b": 0.9}, - 1: {"env_a": 0.5, "env_b": 0.5}, - 2: {"env_a": 0.6, "env_b": 0.6}, + MinerUID(0): {EnvironmentId("env_a"): 0.9, EnvironmentId("env_b"): 0.9}, + MinerUID(1): {EnvironmentId("env_a"): 0.5, EnvironmentId("env_b"): 0.5}, + MinerUID(2): {EnvironmentId("env_a"): 0.6, EnvironmentId("env_b"): 0.6}, } - result = compute_pareto_frontier(scores, ["env_a", "env_b"], 50) + result = compute_pareto_frontier( + scores, [EnvironmentId("env_a"), EnvironmentId("env_b")], 50 + ) - assert 0 in result.frontier_uids - assert 1 not in result.frontier_uids - assert 2 not in result.frontier_uids + assert MinerUID(0) in result.frontier_uids + assert MinerUID(1) not in result.frontier_uids + assert MinerUID(2) not in result.frontier_uids - def test_pareto_incomparable_both_on_frontier(self): + def test_pareto_incomparable_both_on_frontier(self) -> None: """Pareto-incomparable miners both on frontier.""" scores = { - 0: {"env_a": 0.9, "env_b": 0.5}, # Specialist in A - 1: {"env_a": 0.5, "env_b": 0.9}, # Specialist in B + MinerUID(0): { + EnvironmentId("env_a"): 0.9, + EnvironmentId("env_b"): 0.5, + }, # Specialist in A + MinerUID(1): { + EnvironmentId("env_a"): 0.5, + EnvironmentId("env_b"): 0.9, + }, # Specialist in B } - result = compute_pareto_frontier(scores, ["env_a", "env_b"], 50) + result = compute_pareto_frontier( + scores, [EnvironmentId("env_a"), EnvironmentId("env_b")], 50 + ) - assert set(result.frontier_uids) == {0, 1} + assert set(result.frontier_uids) == {MinerUID(0), MinerUID(1)} - def test_copy_attack_both_on_frontier(self): + def test_copy_attack_both_on_frontier(self) -> None: """Copying another miner results in tie (both on frontier).""" scores = { - 0: {"env_a": 0.8, "env_b": 0.8}, - 1: {"env_a": 0.8, "env_b": 0.8}, # Copy of miner 0 + MinerUID(0): {EnvironmentId("env_a"): 0.8, EnvironmentId("env_b"): 0.8}, + MinerUID(1): { + EnvironmentId("env_a"): 0.8, + EnvironmentId("env_b"): 0.8, + }, # Copy of miner 0 } - result = compute_pareto_frontier(scores, ["env_a", "env_b"], 50) + result = compute_pareto_frontier( + scores, [EnvironmentId("env_a"), EnvironmentId("env_b")], 50 + ) # Both are on frontier (tie) - assert set(result.frontier_uids) == {0, 1} + assert set(result.frontier_uids) == {MinerUID(0), MinerUID(1)} class TestWinnersTakeAll: """Tests for winners-take-all scoring.""" - def test_clear_winner_gets_all_points(self): + def test_clear_winner_gets_all_points(self) -> None: """Clear winner gets points from all subsets.""" scores = { - 0: {"a": 0.9, "b": 0.9}, - 1: {"a": 0.5, "b": 0.5}, + MinerUID(0): {ENV_A: 0.9, ENV_B: 0.9}, + MinerUID(1): {ENV_A: 0.5, ENV_B: 0.5}, } thresholds = compute_miner_thresholds(scores, episodes_per_env=50) - first_blocks = {0: 1000, 1: 1000} # Same block + first_blocks = { + MinerUID(0): BlockNumber(1000), + MinerUID(1): BlockNumber(1000), + } # Same block subset_scores = compute_subset_scores_with_priority( - scores, thresholds, first_blocks, ["a", "b"] + scores, thresholds, first_blocks, ENV_AB ) - assert subset_scores[0] > subset_scores[1] + assert subset_scores[MinerUID(0)] > subset_scores[MinerUID(1)] - def test_specialists_split_points(self): + def test_specialists_split_points(self) -> None: """Specialists win their respective single-env subsets.""" scores = { - 0: {"a": 0.9, "b": 0.3}, # Specialist in A - 1: {"a": 0.3, "b": 0.9}, # Specialist in B + MinerUID(0): {ENV_A: 0.9, ENV_B: 0.3}, # Specialist in A + MinerUID(1): {ENV_A: 0.3, ENV_B: 0.9}, # Specialist in B } thresholds = compute_miner_thresholds(scores, episodes_per_env=50) - first_blocks = {0: 1000, 1: 1000} # Same block + first_blocks = { + MinerUID(0): BlockNumber(1000), + MinerUID(1): BlockNumber(1000), + } # Same block subset_scores = compute_subset_scores_with_priority( - scores, thresholds, first_blocks, ["a", "b"] + scores, thresholds, first_blocks, ENV_AB ) # Both should have some points (from single-env subsets) - assert subset_scores[0] > 0 - assert subset_scores[1] > 0 + assert subset_scores[MinerUID(0)] > 0 + assert subset_scores[MinerUID(1)] > 0 - def test_scores_to_weights_normalized(self): + def test_scores_to_weights_normalized(self) -> None: """Weights should sum to 1.""" - scores = {0: 10.0, 1: 5.0, 2: 3.0} + scores = {MinerUID(0): 10.0, MinerUID(1): 5.0, MinerUID(2): 3.0} weights = scores_to_weights(scores) assert abs(sum(weights.values()) - 1.0) < 1e-6 - def test_sybil_resistance(self): + def test_sybil_resistance(self) -> None: """Sybil attack (copies) doesn't increase total reward.""" - env_ids = ["a", "b"] + env_ids = ENV_AB # Single honest miner - single_scores = {0: {"a": 0.8, "b": 0.8}} + single_scores = {MinerUID(0): {ENV_A: 0.8, ENV_B: 0.8}} single_thresholds = compute_miner_thresholds(single_scores, episodes_per_env=50) - single_blocks = {0: 1000} + single_blocks = {MinerUID(0): BlockNumber(1000)} single_subset = compute_subset_scores_with_priority( single_scores, single_thresholds, single_blocks, env_ids ) single_weights = scores_to_weights(single_subset) # Same policy across 5 sybil identities (all same block) - sybil_scores = {i: {"a": 0.8, "b": 0.8} for i in range(5)} + sybil_scores = {MinerUID(i): {ENV_A: 0.8, ENV_B: 0.8} for i in range(5)} sybil_thresholds = compute_miner_thresholds(sybil_scores, episodes_per_env=50) - sybil_blocks = {i: 1000 for i in range(5)} # All same block + sybil_blocks = {MinerUID(i): BlockNumber(1000) for i in range(5)} # All same block sybil_subset = compute_subset_scores_with_priority( sybil_scores, sybil_thresholds, sybil_blocks, env_ids ) @@ -173,7 +202,7 @@ def test_sybil_resistance(self): class TestComputeEpsilon: """Tests for epsilon computation.""" - def test_high_variance_high_epsilon(self): + def test_high_variance_high_epsilon(self) -> None: """High variance should give higher epsilon.""" low_var = np.array([0.5, 0.5, 0.5, 0.5]) high_var = np.array([0.1, 0.9, 0.2, 0.8]) @@ -183,7 +212,7 @@ def test_high_variance_high_epsilon(self): assert eps_high > eps_low - def test_more_samples_lower_epsilon(self): + def test_more_samples_lower_epsilon(self) -> None: """More samples should give lower epsilon.""" values = np.array([0.6, 0.7, 0.65, 0.75]) @@ -196,13 +225,13 @@ def test_more_samples_lower_epsilon(self): class TestThreshold: """Tests for threshold calculation.""" - def test_threshold_basic(self): + def test_threshold_basic(self) -> None: """Threshold should be score + gap.""" threshold = calculate_threshold(0.5, 100) assert threshold > 0.5 assert threshold <= 0.6 # Should be <= score + max_gap - def test_threshold_more_samples_smaller_gap(self): + def test_threshold_more_samples_smaller_gap(self) -> None: """More samples should result in smaller gap.""" t_few = calculate_threshold(0.5, 50) t_many = calculate_threshold(0.5, 500) @@ -210,125 +239,140 @@ def test_threshold_more_samples_smaller_gap(self): # More samples = smaller gap = lower threshold assert t_many < t_few - def test_threshold_min_gap_enforced(self): + def test_threshold_min_gap_enforced(self) -> None: """Minimum gap should be enforced.""" # With many samples, gap would be tiny, but min_gap enforces 2% threshold = calculate_threshold(0.5, 10000, min_gap=0.02) assert threshold >= 0.52 # At least score + min_gap - def test_threshold_max_gap_enforced(self): + def test_threshold_max_gap_enforced(self) -> None: """Maximum gap should be enforced.""" # With few samples, gap would be huge, but max_gap caps at 10% threshold = calculate_threshold(0.5, 5, max_gap=0.10) assert threshold <= 0.60 # At most score + max_gap - def test_compute_miner_thresholds(self): + def test_compute_miner_thresholds(self) -> None: """Should compute thresholds for all miners and environments.""" scores = { - 0: {"a": 0.8, "b": 0.7}, - 1: {"a": 0.6, "b": 0.9}, + MinerUID(0): {ENV_A: 0.8, ENV_B: 0.7}, + MinerUID(1): {ENV_A: 0.6, ENV_B: 0.9}, } thresholds = compute_miner_thresholds(scores, episodes_per_env=50) - assert 0 in thresholds - assert 1 in thresholds - assert "a" in thresholds[0] - assert "b" in thresholds[0] + assert MinerUID(0) in thresholds + assert MinerUID(1) in thresholds + assert ENV_A in thresholds[MinerUID(0)] + assert ENV_B in thresholds[MinerUID(0)] # Threshold should be higher than score - assert thresholds[0]["a"] > 0.8 - assert thresholds[1]["b"] > 0.9 + assert thresholds[MinerUID(0)][ENV_A] > 0.8 + assert thresholds[MinerUID(1)][ENV_B] > 0.9 class TestFirstCommitAdvantage: """Tests for first-commit advantage scoring.""" - def test_earlier_miner_wins_tie(self): + def test_earlier_miner_wins_tie(self) -> None: """Earlier miner should win when scores are identical.""" scores = { - 0: {"a": 0.8, "b": 0.8}, - 1: {"a": 0.8, "b": 0.8}, # Identical to miner 0 + MinerUID(0): {ENV_A: 0.8, ENV_B: 0.8}, + MinerUID(1): {ENV_A: 0.8, ENV_B: 0.8}, # Identical to miner 0 } thresholds = compute_miner_thresholds(scores, episodes_per_env=50) - first_blocks = {0: 1000, 1: 2000} # Miner 0 came first + first_blocks = { + MinerUID(0): BlockNumber(1000), + MinerUID(1): BlockNumber(2000), + } # Miner 0 came first - winner = find_subset_winner_with_priority(scores, thresholds, first_blocks, ("a", "b")) + winner = find_subset_winner_with_priority(scores, thresholds, first_blocks, ENV_AB_TUPLE) # Miner 0 should win because they came first - assert winner == 0 + assert winner == MinerUID(0) - def test_later_miner_wins_if_beats_threshold(self): + def test_later_miner_wins_if_beats_threshold(self) -> None: """Later miner should win if they beat the threshold on all envs.""" scores = { - 0: {"a": 0.7, "b": 0.7}, - 1: {"a": 0.9, "b": 0.9}, # Much better than miner 0 + MinerUID(0): {ENV_A: 0.7, ENV_B: 0.7}, + MinerUID(1): { + ENV_A: 0.9, + ENV_B: 0.9, + }, # Much better than miner 0 } thresholds = compute_miner_thresholds(scores, episodes_per_env=50) - first_blocks = {0: 1000, 1: 2000} # Miner 0 came first + first_blocks = { + MinerUID(0): BlockNumber(1000), + MinerUID(1): BlockNumber(2000), + } # Miner 0 came first - winner = find_subset_winner_with_priority(scores, thresholds, first_blocks, ("a", "b")) + winner = find_subset_winner_with_priority(scores, thresholds, first_blocks, ENV_AB_TUPLE) # Miner 1 should win because they beat the threshold - assert winner == 1 + assert winner == MinerUID(1) - def test_tradeoff_earlier_wins_by_default(self): + def test_tradeoff_earlier_wins_by_default(self) -> None: """Earlier miner wins tradeoff because later can't beat threshold on all envs.""" scores = { - 0: {"a": 0.9, "b": 0.5}, # Specialist in A, came first - 1: {"a": 0.5, "b": 0.9}, # Specialist in B, came later + MinerUID(0): { + ENV_A: 0.9, + ENV_B: 0.5, + }, # Specialist in A, came first + MinerUID(1): { + ENV_A: 0.5, + ENV_B: 0.9, + }, # Specialist in B, came later } thresholds = compute_miner_thresholds(scores, episodes_per_env=50) - first_blocks = {0: 1000, 1: 2000} + first_blocks = {MinerUID(0): BlockNumber(1000), MinerUID(1): BlockNumber(2000)} - winner = find_subset_winner_with_priority(scores, thresholds, first_blocks, ("a", "b")) + winner = find_subset_winner_with_priority(scores, thresholds, first_blocks, ENV_AB_TUPLE) # Miner 0 wins because miner 1 can't beat threshold on env "a" # (1 has 0.5, threshold for 0's 0.9 is ~0.92-1.0) - assert winner == 0 + assert winner == MinerUID(0) - def test_tradeoff_lower_uid_wins_same_block(self): + def test_tradeoff_lower_uid_wins_same_block(self) -> None: """Lower UID wins when both registered at the same block.""" scores = { - 0: {"a": 0.9, "b": 0.5}, # Specialist in A - 1: {"a": 0.5, "b": 0.9}, # Specialist in B + MinerUID(0): {ENV_A: 0.9, ENV_B: 0.5}, # Specialist in A + MinerUID(1): {ENV_A: 0.5, ENV_B: 0.9}, # Specialist in B } thresholds = compute_miner_thresholds(scores, episodes_per_env=50) # Same block - neither has time priority, so lower UID wins - first_blocks = {0: 1000, 1: 1000} + first_blocks = {MinerUID(0): BlockNumber(1000), MinerUID(1): BlockNumber(1000)} - winner = find_subset_winner_with_priority(scores, thresholds, first_blocks, ("a", "b")) + winner = find_subset_winner_with_priority(scores, thresholds, first_blocks, ENV_AB_TUPLE) # Miner 0 wins by UID tiebreaker (lower UID when same block) - assert winner == 0 + assert winner == MinerUID(0) - def test_copy_attack_fails(self): + def test_copy_attack_fails(self) -> None: """Copying the leader should not help the copier.""" scores = { - 0: {"a": 0.8, "b": 0.8}, # Leader - 1: {"a": 0.8, "b": 0.8}, # Copier + MinerUID(0): {ENV_A: 0.8, ENV_B: 0.8}, # Leader + MinerUID(1): {ENV_A: 0.8, ENV_B: 0.8}, # Copier } thresholds = compute_miner_thresholds(scores, episodes_per_env=50) - first_blocks = {0: 1000, 1: 2000} + first_blocks = {MinerUID(0): BlockNumber(1000), MinerUID(1): BlockNumber(2000)} subset_scores = compute_subset_scores_with_priority( - scores, thresholds, first_blocks, ["a", "b"] + scores, thresholds, first_blocks, ENV_AB ) # Leader should get all points, copier gets nothing - assert subset_scores[0] > 0 - assert subset_scores[1] == 0 + assert subset_scores[MinerUID(0)] > 0 + assert subset_scores[MinerUID(1)] == 0 - def test_genuine_improvement_rewarded(self): + def test_genuine_improvement_rewarded(self) -> None: """Genuine improvement over the leader should be rewarded.""" scores = { - 0: {"a": 0.7, "b": 0.7}, # Leader - 1: {"a": 0.85, "b": 0.85}, # Genuinely better + MinerUID(0): {ENV_A: 0.7, ENV_B: 0.7}, # Leader + MinerUID(1): {ENV_A: 0.85, ENV_B: 0.85}, # Genuinely better } thresholds = compute_miner_thresholds(scores, episodes_per_env=50) - first_blocks = {0: 1000, 1: 2000} + first_blocks = {MinerUID(0): BlockNumber(1000), MinerUID(1): BlockNumber(2000)} subset_scores = compute_subset_scores_with_priority( - scores, thresholds, first_blocks, ["a", "b"] + scores, thresholds, first_blocks, ENV_AB ) # The genuinely better miner should get more points - assert subset_scores[1] > subset_scores[0] + assert subset_scores[MinerUID(1)] > subset_scores[MinerUID(0)] diff --git a/tests/unit/test_procedural.py b/tests/unit/test_procedural.py index 32aa080..7dcf6fa 100644 --- a/tests/unit/test_procedural.py +++ b/tests/unit/test_procedural.py @@ -10,12 +10,13 @@ randomize_positions, ) from kinitro.scheduler.task_generator import generate_seed +from kinitro.types import ProceduralTaskResult class TestRandomizePositions: """Tests for position randomization.""" - def test_output_shape(self): + def test_output_shape(self) -> None: """Output should be same shape as base.""" rng = np.random.default_rng(42) base = np.array([1.0, 2.0, 3.0]) @@ -23,7 +24,7 @@ def test_output_shape(self): assert result.shape == base.shape - def test_within_range(self): + def test_within_range(self) -> None: """Positions should stay within specified range.""" rng = np.random.default_rng(42) base = np.array([0.0, 0.0, 0.0]) @@ -33,7 +34,7 @@ def test_within_range(self): result = randomize_positions(base, rng, range_xyz) assert np.all(np.abs(result - base) <= range_xyz) - def test_deterministic_with_seed(self): + def test_deterministic_with_seed(self) -> None: """Same seed should give same result.""" base = np.array([1.0, 1.0, 1.0]) @@ -49,7 +50,7 @@ def test_deterministic_with_seed(self): class TestRandomizePhysics: """Tests for physics randomization.""" - def test_all_params_generated(self): + def test_all_params_generated(self) -> None: """All requested params should be in result.""" rng = np.random.default_rng(42) params = { @@ -62,7 +63,7 @@ def test_all_params_generated(self): assert set(result.keys()) == {"friction", "damping", "mass"} - def test_within_range(self): + def test_within_range(self) -> None: """Values should be within specified range.""" rng = np.random.default_rng(42) params = {"friction": (0.5, 1.5)} @@ -75,7 +76,7 @@ def test_within_range(self): class TestGenerateSeed: """Tests for seed generation from task UUID.""" - def test_deterministic(self): + def test_deterministic(self) -> None: """Same UUID should give same seed.""" task_uuid = str(uuid.uuid4()) seed1 = generate_seed(task_uuid) @@ -83,7 +84,7 @@ def test_deterministic(self): assert seed1 == seed2 - def test_different_uuids_different_seeds(self): + def test_different_uuids_different_seeds(self) -> None: """Different UUIDs should give different seeds.""" uuid1 = str(uuid.uuid4()) uuid2 = str(uuid.uuid4()) @@ -92,14 +93,14 @@ def test_different_uuids_different_seeds(self): assert seed1 != seed2 - def test_positive_31bit(self): + def test_positive_31bit(self) -> None: """Seed should be positive 31-bit integer (fits PostgreSQL int4).""" for _ in range(100): task_uuid = str(uuid.uuid4()) seed = generate_seed(task_uuid) assert 0 <= seed <= 0x7FFFFFFF - def test_consistent_across_calls(self): + def test_consistent_across_calls(self) -> None: """Same UUID should produce same seed across multiple calls.""" test_uuid = "550e8400-e29b-41d4-a716-446655440000" seeds = [generate_seed(test_uuid) for _ in range(10)] @@ -109,17 +110,17 @@ def test_consistent_across_calls(self): class TestProceduralTaskGenerator: """Tests for the procedural task generator.""" - def test_generate_returns_dict(self): + def test_generate_returns_dict(self) -> None: """Generate should return dict with expected keys.""" gen = ProceduralTaskGenerator(env_id="test_env") - result = gen.generate(seed=42) + result: ProceduralTaskResult = gen.generate(seed=42) assert "object_positions" in result assert "target_positions" in result assert "physics_params" in result assert "domain_randomization" in result - def test_deterministic(self): + def test_deterministic(self) -> None: """Same seed should give same result.""" gen = ProceduralTaskGenerator(env_id="test_env") @@ -129,7 +130,7 @@ def test_deterministic(self): np.testing.assert_array_equal(result1["object_positions"], result2["object_positions"]) assert result1["physics_params"] == result2["physics_params"] - def test_different_seeds_different_results(self): + def test_different_seeds_different_results(self) -> None: """Different seeds should give different results.""" gen = ProceduralTaskGenerator(env_id="test_env") diff --git a/uv.lock b/uv.lock index 42d787b..5677366 100644 --- a/uv.lock +++ b/uv.lock @@ -358,7 +358,7 @@ wheels = [ [[package]] name = "bittensor" -version = "10.0.1" +version = "10.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -384,9 +384,9 @@ dependencies = [ { name = "uvicorn" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/6d/cb6d019386fabcd28449889a290fe93f2781ebc637604917984ca1dc4c69/bittensor-10.0.1.tar.gz", hash = "sha256:900697ba9ccaeb8a22419560631132dbb3578bff0a9d8d1e19ae48d352d85328", size = 381830, upload-time = "2025-12-22T19:07:04.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/8d/9c97dcf8e038fdb7738e5193af482bbe1ea8b2e30c0852a6522126daad6b/bittensor-10.1.0.tar.gz", hash = "sha256:c47aa2548762441baea07c0b62c4ee42e224690d0d868bfe939f653f25ef4a52", size = 382672, upload-time = "2026-01-29T23:19:03.986Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/8c/af29be209e01f3dbaf2517c943118764018157f4fe236f29a737f09537a0/bittensor-10.0.1-py3-none-any.whl", hash = "sha256:cb80262ff9ff43386ebb1a15ba0a17b94be8966121f852d7fe9bfebc83fad052", size = 452691, upload-time = "2025-12-22T19:07:02.804Z" }, + { url = "https://files.pythonhosted.org/packages/be/5e/1ec242ec368fa0c4e1fcf2bfd5b31ec70b34271716ba35d457443631ef06/bittensor-10.1.0-py3-none-any.whl", hash = "sha256:6ce5fb6c46d0cfe2615ebd30978ca754d12d69e02de9d0020a40bab03ce450b2", size = 453603, upload-time = "2026-01-29T23:19:01.968Z" }, ] [[package]] @@ -952,7 +952,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, @@ -960,7 +959,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, @@ -968,7 +966,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, @@ -976,7 +973,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, @@ -1173,6 +1169,8 @@ dependencies = [ { name = "asyncpg" }, { name = "basilica-sdk" }, { name = "bittensor" }, + { name = "bittensor-wallet" }, + { name = "docker" }, { name = "fastapi" }, { name = "gymnasium" }, { name = "httpx" }, @@ -1199,14 +1197,6 @@ dev = [ { name = "ty" }, ] -[package.dev-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, - { name = "ty" }, -] - [package.metadata] requires-dist = [ { name = "affinetes", git = "https://github.com/AffineFoundation/affinetes.git" }, @@ -1214,38 +1204,32 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "basilica-sdk", specifier = "==0.15.0" }, - { name = "bittensor", specifier = ">=8.0.0" }, + { name = "bittensor", specifier = ">=10.1.0" }, + { name = "bittensor-wallet", specifier = ">=4.0.0" }, + { name = "docker", specifier = ">=7.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "gymnasium", specifier = ">=1.1" }, { name = "httpx", specifier = ">=0.25.0" }, { name = "huggingface-hub", specifier = ">=0.20.0" }, { name = "metaworld", git = "https://github.com/Farama-Foundation/Metaworld.git?rev=master" }, { name = "mujoco", specifier = ">=3.0.0" }, - { name = "numpy", specifier = ">=1.24.0" }, + { name = "numpy", specifier = ">=2.0.1,<2.3" }, { name = "pillow", specifier = ">=10.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.13" }, { name = "scipy", specifier = ">=1.11.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "structlog", specifier = ">=23.0.0" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.14" }, - { name = "typer", extras = ["all"], specifier = ">=0.9.0" }, + { name = "typer", specifier = ">=0.23.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, ] provides-extras = ["dev"] -[package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-asyncio", specifier = ">=1.3.0" }, - { name = "ruff", specifier = ">=0.14.13" }, - { name = "ty", specifier = ">=0.0.14" }, -] - [[package]] name = "mako" version = "1.3.10" @@ -1573,63 +1557,40 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, - { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, - { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, - { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, - { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, - { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, - { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, - { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, - { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, - { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, - { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, - { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, - { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, - { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, - { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, - { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, - { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, - { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, - { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, - { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, - { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, - { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, - { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, - { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, - { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, - { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, ] [[package]] @@ -2419,17 +2380,17 @@ wheels = [ [[package]] name = "typer" -version = "0.21.1" +version = "0.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/e6/44e073787aa57cd71c151f44855232feb0f748428fd5242d7366e3c4ae8b/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc", size = 120181, upload-time = "2026-02-11T15:22:18.637Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ed/d6fca788b51d0d4640c4bc82d0e85bad4b49809bca36bf4af01b4dcb66a7/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913", size = 56668, upload-time = "2026-02-11T15:22:21.075Z" }, ] [[package]]