From f41cf420be401587fa07177073987be29ccea5ab Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Tue, 5 May 2026 19:11:15 -0700 Subject: [PATCH] feat(mining): Pearl mining integration Consolidates NVIDIA vLLM, Apple Silicon, CPU Pearl mining support, CLI/docs, and live H100 validation. --- .../ISSUE_TEMPLATE/pearl_model_validation.yml | 98 + CLAUDE.md | 141 + REVIEW.md | 1 + ...05-05-apple-silicon-pearl-mining-design.md | 711 ++++ ...5-05-apple-silicon-pearl-mining-plan-v1.md | 1811 +++++++++ ...-05-pearl-coordination-discussion-draft.md | 91 + ...05-vllm-pearl-mining-integration-design.md | 560 +++ ...5-05-vllm-pearl-mining-integration-plan.md | 3558 +++++++++++++++++ docs/development/contributing.md | 1 - docs/development/mining-nvidia-validation.md | 289 ++ docs/development/mining.md | 84 + docs/development/pearl-model-enablement.md | 93 + docs/user-guide/cli.md | 29 + docs/user-guide/mining-apple-silicon.md | 162 + docs/user-guide/mining.md | 133 + docs/user-guide/pearl.md | 58 + mkdocs.yml | 6 + pyproject.toml | 20 +- src/openjarvis/cli/__init__.py | 4 + src/openjarvis/cli/hints.py | 7 + src/openjarvis/cli/mine_cmd.py | 753 ++++ src/openjarvis/cli/pearl_cmd.py | 149 + src/openjarvis/core/config.py | 64 +- src/openjarvis/core/registry.py | 11 + src/openjarvis/core/types.py | 1 + src/openjarvis/engine/_discovery.py | 49 + src/openjarvis/mining/__init__.py | 60 + src/openjarvis/mining/_collector.py | 78 + src/openjarvis/mining/_constants.py | 55 + src/openjarvis/mining/_discovery.py | 176 + src/openjarvis/mining/_docker.py | 420 ++ src/openjarvis/mining/_install.py | 62 + src/openjarvis/mining/_metrics.py | 96 + src/openjarvis/mining/_miner_loop_main.py | 231 ++ src/openjarvis/mining/_models.py | 102 + src/openjarvis/mining/_mps_miner_loop_main.py | 311 ++ src/openjarvis/mining/_pearl_subprocess.py | 206 + src/openjarvis/mining/_stubs.py | 177 + src/openjarvis/mining/apple_mps_pearl.py | 103 + src/openjarvis/mining/cpu_pearl.py | 213 + src/openjarvis/mining/pools/__init__.py | 9 + src/openjarvis/mining/vllm_pearl.py | 152 + src/openjarvis/telemetry/store.py | 74 +- tests/cli/test_cli.py | 7 + tests/cli/test_hints.py | 12 + tests/cli/test_mine_cmd.py | 419 ++ tests/cli/test_pearl_cmd.py | 129 + tests/conftest.py | 37 + tests/core/test_config.py | 46 + tests/core/test_registry.py | 17 + tests/engine/test_discovery.py | 75 + tests/hardware/test_nvidia.py | 27 +- tests/mining/__init__.py | 0 tests/mining/conftest.py | 100 + tests/mining/fixtures/README.md | 17 + tests/mining/fixtures/config_minimal.toml | 12 + tests/mining/fixtures/config_pool_v2.toml | 10 + .../fixtures/gateway_metrics_sample.txt | 18 + tests/mining/test_apple_mps_pearl.py | 207 + tests/mining/test_cli.py | 190 + tests/mining/test_collector.py | 73 + tests/mining/test_cpu_pearl.py | 289 ++ tests/mining/test_discovery.py | 245 ++ tests/mining/test_docker.py | 309 ++ tests/mining/test_install.py | 64 + tests/mining/test_metrics.py | 54 + tests/mining/test_miner_loop.py | 83 + tests/mining/test_pearl_subprocess.py | 136 + tests/mining/test_stubs.py | 84 + tests/mining/test_vllm_pearl.py | 167 + tests/telemetry/test_store.py | 57 + tools/pearl-reference-oracle/README.md | 111 + tools/pearl-reference-oracle/smoke_test.py | 139 + uv.lock | 8 +- 74 files changed, 14533 insertions(+), 18 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/pearl_model_validation.yml create mode 100644 CLAUDE.md create mode 100644 docs/design/2026-05-05-apple-silicon-pearl-mining-design.md create mode 100644 docs/design/2026-05-05-apple-silicon-pearl-mining-plan-v1.md create mode 100644 docs/design/2026-05-05-pearl-coordination-discussion-draft.md create mode 100644 docs/design/2026-05-05-vllm-pearl-mining-integration-design.md create mode 100644 docs/design/2026-05-05-vllm-pearl-mining-integration-plan.md create mode 100644 docs/development/mining-nvidia-validation.md create mode 100644 docs/development/mining.md create mode 100644 docs/development/pearl-model-enablement.md create mode 100644 docs/user-guide/mining-apple-silicon.md create mode 100644 docs/user-guide/mining.md create mode 100644 docs/user-guide/pearl.md create mode 100644 src/openjarvis/cli/mine_cmd.py create mode 100644 src/openjarvis/cli/pearl_cmd.py create mode 100644 src/openjarvis/mining/__init__.py create mode 100644 src/openjarvis/mining/_collector.py create mode 100644 src/openjarvis/mining/_constants.py create mode 100644 src/openjarvis/mining/_discovery.py create mode 100644 src/openjarvis/mining/_docker.py create mode 100644 src/openjarvis/mining/_install.py create mode 100644 src/openjarvis/mining/_metrics.py create mode 100644 src/openjarvis/mining/_miner_loop_main.py create mode 100644 src/openjarvis/mining/_models.py create mode 100644 src/openjarvis/mining/_mps_miner_loop_main.py create mode 100644 src/openjarvis/mining/_pearl_subprocess.py create mode 100644 src/openjarvis/mining/_stubs.py create mode 100644 src/openjarvis/mining/apple_mps_pearl.py create mode 100644 src/openjarvis/mining/cpu_pearl.py create mode 100644 src/openjarvis/mining/pools/__init__.py create mode 100644 src/openjarvis/mining/vllm_pearl.py create mode 100644 tests/cli/test_mine_cmd.py create mode 100644 tests/cli/test_pearl_cmd.py create mode 100644 tests/mining/__init__.py create mode 100644 tests/mining/conftest.py create mode 100644 tests/mining/fixtures/README.md create mode 100644 tests/mining/fixtures/config_minimal.toml create mode 100644 tests/mining/fixtures/config_pool_v2.toml create mode 100644 tests/mining/fixtures/gateway_metrics_sample.txt create mode 100644 tests/mining/test_apple_mps_pearl.py create mode 100644 tests/mining/test_cli.py create mode 100644 tests/mining/test_collector.py create mode 100644 tests/mining/test_cpu_pearl.py create mode 100644 tests/mining/test_discovery.py create mode 100644 tests/mining/test_docker.py create mode 100644 tests/mining/test_install.py create mode 100644 tests/mining/test_metrics.py create mode 100644 tests/mining/test_miner_loop.py create mode 100644 tests/mining/test_pearl_subprocess.py create mode 100644 tests/mining/test_stubs.py create mode 100644 tests/mining/test_vllm_pearl.py create mode 100644 tools/pearl-reference-oracle/README.md create mode 100644 tools/pearl-reference-oracle/smoke_test.py diff --git a/.github/ISSUE_TEMPLATE/pearl_model_validation.yml b/.github/ISSUE_TEMPLATE/pearl_model_validation.yml new file mode 100644 index 00000000..c4132ecf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pearl_model_validation.yml @@ -0,0 +1,98 @@ +name: Pearl Model Validation +description: Track conversion and validation of a Pearl-compatible mining model +labels: ["type:feature", "area:mining"] +body: + - type: markdown + attributes: + value: | + Use this template when promoting a raw Hugging Face model to a + Pearl-compatible `pearl-ai/*-pearl` mining model. A model should remain + `planned` in OpenJarvis until this checklist is complete. + - type: input + id: raw_model + attributes: + label: Raw model + placeholder: e.g., Qwen/Qwen3.5-9B + validations: + required: true + - type: input + id: pearl_model + attributes: + label: Pearl model artifact + placeholder: e.g., pearl-ai/Qwen3.5-9B-pearl + validations: + required: true + - type: dropdown + id: target_provider + attributes: + label: Target provider + options: + - vllm-pearl + - cpu-pearl + - apple-mps-pearl + validations: + required: true + - type: textarea + id: quantization_recipe + attributes: + label: Quantization recipe + description: Link or paste the recipe used to create the Pearl model artifact. + placeholder: | + - compressed-tensors config: + - 7-bit mining layers: + - 8-bit non-mining layers: + - calibration data: + - SmoothQuant settings: + validations: + required: true + - type: textarea + id: hardware + attributes: + label: Validation hardware + placeholder: | + - GPU: + - VRAM: + - driver/CUDA: + - Docker image/tag: + - Pearl commit/ref: + validations: + required: true + - type: checkboxes + id: acceptance + attributes: + label: Acceptance checks + options: + - label: Model loads in Pearl's vLLM miner container + required: true + - label: vLLM registers Pearl's quantization plugin + required: true + - label: Mining layers use int7 NoisyGEMM + required: true + - label: Non-mining layers use int8 vanilla Pearl GEMM + required: true + - label: `jarvis mine init --model ` succeeds + required: true + - label: `jarvis mine start` succeeds + required: true + - label: `jarvis ask` succeeds through the mining engine + required: true + - label: `jarvis mine status` reports gateway/mining metrics + required: true + - label: `jarvis mine validate-model --allow-planned` passes + required: true + - label: Gateway/miner logs show no submission errors + required: true + - type: textarea + id: artifacts + attributes: + label: Artifacts + description: Attach logs, metrics, model config, and command output. + placeholder: | + - /v1/models output: + - `jarvis mine status` output: + - `jarvis mine validate-model --output` JSON: + - gateway metrics excerpt: + - miner logs: + - PR/commit that flips status to validated: + validations: + required: true diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..12741a17 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +OpenJarvis is a local-first personal AI agent framework. The Python package lives in `src/openjarvis/`, with a Rust workspace under `rust/` (built via PyO3/maturin) and a bundled Node.js runner for the Claude Agent SDK. The CLI entry point is `jarvis` → `openjarvis.cli:main`. + +## Commands + +Use `uv` for everything Python; the package is installed editable into the project venv on `uv sync`. + +### Setup + +```bash +uv sync --extra dev # core + dev tools (pytest, ruff, respx, pytest-cov) +uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml # required for memory + security features +uv run pre-commit install # ruff lint + format on commit +``` + +For optional backends, layer extras: `uv sync --extra dev --extra memory-faiss --extra inference-cloud --extra server` (full list in `pyproject.toml`). + +> **Python 3.14+:** prefix the maturin command with `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1`. + +### Test + +```bash +uv run pytest tests/ -v # full suite +uv run pytest tests/core/test_registry.py -v # one file +uv run pytest tests/core/test_registry.py::test_register_and_get -v # one test +uv run pytest tests/ -m "not live and not cloud" # what CI runs +uv run pytest tests/ --cov=openjarvis --cov-report=term-missing +``` + +Markers gate hardware/network-dependent tests: `live` (running engine), `cloud` (API keys), `nvidia`, `amd`, `apple`, `macos15`, `slow`, `live_channel`. CI runs `not live and not cloud` with `--cov-fail-under=60`. + +### Lint + +```bash +uv run ruff check src/ tests/ # CI gate +uv run ruff check src/ tests/ --fix +uv run ruff format --check src/ tests/ +``` + +Ruff targets py310 with rule sets `E`, `F`, `I`, `W` (pycodestyle, pyflakes, isort, warnings). + +### Rust + +```bash +cd rust && cargo clippy --workspace --all-targets -- -D warnings # CI gate (warnings = errors) +cd rust && cargo test --workspace +``` + +The Rust workspace at `rust/Cargo.toml` mirrors Python module names (`openjarvis-core`, `openjarvis-engine`, `openjarvis-agents`, etc.). `openjarvis-python` is the PyO3 bridge crate that gets built into the Python package via maturin; the bridge lives at `src/openjarvis/_rust_bridge.py`. + +## Architecture + +### Registry pattern (load-bearing) + +Every extensible primitive — engines, agents, tools, memory backends, channels, router policies, benchmarks, connectors, skills, speech/TTS, compression — is registered through a typed registry in `src/openjarvis/core/registry.py` with the decorator form: + +```python +@EngineRegistry.register("my_engine") +class MyEngine(InferenceEngine): ... +``` + +Registry lookups are how the CLI, SDK, and config files resolve string keys (`"ollama"`, `"orchestrator"`, `"sqlite"`) to implementations. **Adding a new primitive without a registry decorator means it is invisible to the rest of the system.** + +### Test isolation: registries get cleared every test + +`tests/conftest.py` has an `autouse` fixture that calls `.clear()` on every registry and resets the event bus before each test. Modules whose registrations must survive clearing — typically benchmarks and learning policies — expose an idempotent `ensure_registered()` helper guarded by `XRegistry.contains(...)`. Tests call `ensure_registered()` themselves. + +Practical implication: if a test fails because some component "isn't registered," the fix is usually to call the module's `ensure_registered()` or to import the module inside the test/fixture, not to touch `conftest.py`. + +### Optional dependencies fail soft + +Backends with optional packages live behind `try / except ImportError` at import time, typically in the parent `__init__.py`: + +```python +try: + import openjarvis.memory.faiss_backend # noqa: F401 (registers on import) +except ImportError: + pass +``` + +The package always loads even when extras like `faiss-cpu`, `vllm`, `colbert-ai` are missing. Don't wrap registry decorators themselves — wrap the import that triggers them. + +### Primitives map (where to look) + +- `core/` — `config.py` (JarvisConfig + hardware detection), `events.py` (EventBus), `registry.py`, `types.py` (`Message`, `ModelSpec`, `ToolResult`, `Trace`). +- `engine/` — inference backends. `_stubs.py` defines `InferenceEngine` ABC; `_discovery.py` auto-probes which engines are running; `openai_compat_engines.py` registers vLLM/SGLang/llama.cpp/MLX/LM Studio data-driven via the OpenAI-compatible wrapper. +- `agents/` — `_stubs.py` has `BaseAgent`, `ToolUsingAgent`, `AgentContext`, `AgentResult`. `claude_code.py` shells out to Node via `claude_code_runner/` (bundled into the wheel by hatch — see `[tool.hatch.build.targets.wheel.force-include]` in `pyproject.toml`). +- `intelligence/` — model catalog and routing (`HeuristicRouter`). +- `memory/` — retrieval backends, all behind `MemoryBackend` ABC: SQLite-FTS5 (default), FAISS, ColBERTv2, BM25, hybrid (RRF fusion), with shared chunking/context/ingest helpers. +- `tools/` — `BaseTool` ABC + `ToolExecutor`. Built-in: calculator, think, retrieval, llm_tool, file_read, web_search, code_interpreter. +- `learning/` — `RouterPolicy` and `RewardFunction` ABCs. Trace-driven and GRPO policies live here. +- `traces/`, `telemetry/` — SQLite-backed recording + aggregation. `telemetry/wrapper.py` instruments `generate()` calls. +- `server/` — FastAPI OpenAI-compatible server (`/v1/chat/completions`, `/v1/models`, `/health`), gated by the `server` extra. +- `cli/` — Click commands; one file per subcommand named `*_cmd.py`. `_tool_names.py` keeps tool key constants in one place. +- `channels/` — chat platform integrations; the `whatsapp_baileys_bridge/` is a Node.js subprocess (also force-included in the wheel). + +The mining subsystem also includes the cpu-pearl provider (Spec B v1) for +non-CUDA hosts including Apple Silicon. It runs Pearl's pure-Rust mine() +function via py-pearl-mining plus Pearl's pearl-gateway as a sibling +subprocess; decoupled from inference (the user's MLX/Ollama/llamacpp engine +is untouched). Future v2 (Apple-GPU acceleration via PyTorch MPS) and v3 +(native Metal kernel) are tracked in +docs/design/2026-05-05-apple-silicon-pearl-mining-design.md. + +### File-naming conventions + +- `_stubs.py` — ABC + dataclasses for that subsystem (always import from here for type hints) +- `_discovery.py` — auto-detection and probing logic +- `_base.py` — shared utilities and re-exports +- `*_cmd.py` — Click CLI command modules (one per subcommand under `cli/`) + +### Dataclass + type-hint conventions + +- `@dataclass(slots=True)` everywhere — memory matters because traces/telemetry are high-volume. +- `from __future__ import annotations` is the first import in every module; absolute imports only. +- Full type annotations on signatures; `Sequence` for read-only, `List` for mutable. + +### Adding a new primitive (checklist) + +1. Implement the ABC from the relevant `_stubs.py`. +2. Decorate with `@XRegistry.register("key")` (or pair with `ensure_registered()` if it must survive test clearing). +3. Add a soft-import line in the module's `__init__.py` (`try / except ImportError`) so registration fires when the package loads. +4. Add tests under `tests//`. +5. If new packages are needed, add an entry under `[project.optional-dependencies]` in `pyproject.toml` rather than to the core `dependencies` list. + +## Review expectations + +`REVIEW.md` is the explicit PR-review rubric used by automated reviewers. The high-leverage things to check on changes touching this repo: + +- **Registry compliance** — new components register through the canonical registry, not ad-hoc factories. +- **PyO3 boundaries** — type conversions, error propagation, GIL handling in `rust/crates/openjarvis-python/` and consumers via `_rust_bridge.py`. +- **Async correctness** — no missing `await`, no blocking calls inside async paths. +- **Event bus integration** — lifecycle events flow through `core.events.EventBus`, not bespoke callbacks. +- **Local-first data isolation** — secrets stay out of code, validation lives at boundaries (user input, external APIs). + +Don't comment on formatting (Ruff handles it) or files outside the diff. diff --git a/REVIEW.md b/REVIEW.md index d80561e7..01fab053 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -17,6 +17,7 @@ Check for logic errors, edge cases, and off-by-one errors. Pay particular attent - **Rust-Python bridge (PyO3) boundaries** — type conversions, error propagation, GIL handling - **Async/await patterns** — missing awaits, unclosed resources, blocking calls in async contexts - **Registry pattern compliance** — new components (engines, tools, agents, channels) must register via `ToolRegistry`, `EngineRegistry`, `AgentRegistry`, `ChannelRegistry`, etc. in `src/openjarvis/core/registry.py` +- **Mining provider compliance** — new mining providers must register via `MinerRegistry` and expose an idempotent `ensure_registered()` for the autouse-clear test convention - **Event bus integration** — new lifecycle events should use `EventBus` from `src/openjarvis/core/events.py` ### 4. Testing diff --git a/docs/design/2026-05-05-apple-silicon-pearl-mining-design.md b/docs/design/2026-05-05-apple-silicon-pearl-mining-design.md new file mode 100644 index 00000000..758158ca --- /dev/null +++ b/docs/design/2026-05-05-apple-silicon-pearl-mining-design.md @@ -0,0 +1,711 @@ +# Spec B — Apple Silicon enablement for Pearl mining + +| | | +|---|---| +| **Date** | 2026-05-05 | +| **Status** | Design — Phase 0 investigation complete; v1 scope reduced (see §1.5) | +| **Owner** | OpenJarvis team (parallel-agent friendly) | +| **Companion spec** | [Spec A — vLLM-Pearl mining integration (v1)](2026-05-05-vllm-pearl-mining-integration-design.md) | +| **Repos referenced** | `OpenJarvis`, `pearl-research-labs/pearl`, possibly upstream `ml-explore/mlx`, `ggerganov/llama.cpp` | + +> **For agents picking this up cold:** read §1 ("Cold-start brief") first, then **§1.5 ("Phase 0 findings")** which substantially simplifies v1 scope. The original Phase 1–4 GPU-kernel plan in §5–§8 is preserved as the v2/v3 path; v1 ships using upstream Pearl's PyTorch reference and pure-Rust miner. + +## 1. Cold-start brief + +**One-paragraph problem statement.** OpenJarvis is adding a `mining` subsystem (Spec A) that lets users mine the Pearl PoUW blockchain through their local LLM inference. Pearl's reference miner is CUDA-only and bound to NVIDIA Hopper (`sm_90a`, H100/H200) — most OpenJarvis users on Apple Silicon are locked out at the protocol level. **This spec is the plan to unblock them.** The OJ-side integration work is small (a new `MiningProvider` implementation that drops into the existing `MinerRegistry` from Spec A); the substantial work is a Metal port of Pearl's `NoisyGEMM` kernel and a matching plugin into an Apple-native inference backend (MLX or llama.cpp Metal). The Pearl validation path is plonky2-STARK-based and **already hardware-neutral** — see §3 for the evidence — so a correct Metal implementation produces blocks Pearl validators accept without any consensus changes. + +**Three things to know before doing any work.** + +1. The Pearl validator (`pearl/zk-pow/src/api/verify.rs::verify_block`) operates on a STARK proof and references no hardware. **The protocol does not care what GPU produced the work** as long as the math is correct and the proof verifies. CUDA-only is a performance choice, not a consensus choice. +2. Pearl's CUDA kernel (`pearl/miner/pearl-gemm/csrc/gemm/`) uses Hopper-only primitives (TMA, WGMMA, thread-block clusters, CUTLASS 3.x). A Metal port is **not a translation** — it's a from-scratch reimplementation against a different programming model. Plan effort accordingly. +3. The OJ integration boundary is the `MiningProvider` ABC defined in Spec A §4.4. **Do not modify Spec A.** Add a new provider file (`mining/mlx_pearl.py` or `mining/llamacpp_pearl_metal.py`), implement the ABC, register via `MinerRegistry`, ship a new optional extra. Everything else in Spec A — sidecar shape, config schema, telemetry adapter contract, v2 fee/pool seams — applies unchanged. + +## 1.5. Phase 0 findings — significant scope simplification (2026-05-05) + +Phase 0 investigation produced four findings that reshape this spec. **The original §5–§8 plan (Metal NoisyGEMM kernel + custom MLX/llama.cpp plugin) is preserved as v2/v3, but is no longer required for v1.** + +### 1.5.1 The validator is hardware-neutral (confirmed) + +`zk-pow/src/api/verify.rs::verify_block` and `verify_plain_proof` are pure Rust + plonky2; no CUDA, no GPU paths, no hardware introspection. §3.1's claim is **verified by direct code reading** (see file paths in §11). The protocol accepts blocks from any implementation that produces correct math. + +### 1.5.2 A complete hardware-neutral miner already exists upstream + +`pearl/zk-pow/src/ffi/mine.rs::mine()` is a pure-Rust mining function: + +- Generates random `i8` matrices `A` (m×k) and `B` (k×n) with values in `[-64, 64]` +- Computes blake3-derived noise via `circuit/pearl_noise.rs::compute_noise_for_indices` +- Performs the noised dot products in tile patterns (`PeriodicPattern.rows_pattern × cols_pattern`) +- Hashes the jackpot tile and checks the difficulty target +- Returns a `PlainProof` that `verify_plain_proof` accepts + +It is exposed to Python via `py-pearl-mining` (`pearl_mining.mine`). Dependencies: pure Rust (`zk-pow`, `pearl-blake3`, `blake3`, `rayon`, `pyo3`, `tikv-jemallocator`). **No CUDA, no platform-specific code in the Cargo dependency tree.** + +### 1.5.3 A PyTorch reference of the *production* NoisyGEMM also exists upstream + +`pearl/miner/miner-base/src/miner_base/noisy_gemm.py::NoisyGemm` is a complete PyTorch reference of the same NoisyGEMM that vllm-miner accelerates with the H100 CUDA kernel: + +- `noise_A`, `noise_B`, `gemm`, `noisy_gemm` methods replicating the kernel's math in `torch.matmul` calls +- The denoising path produces **bit-exact results** versus a vanilla `torch.matmul(A.int32, B.int32)` — verified by the `assert torch.equal(result, expected)` test at `miner/miner-base/tests/test_noisy_gemm.py:92`. The "fp16 tolerance" budget I assumed in the original §5.2 is unnecessary for the int7×int7→int32 protocol path +- Dependencies: `torch==2.11.0`, `blake3`, `numpy`, `pearl-gateway`, `py-pearl-mining` — all install on macOS arm64 + +### 1.5.4 Empirical confirmation (2026-05-05, on this hardware) + +`py-pearl-mining` was built from the upstream source on the spec author's M2 Max. The build produced `py_pearl_mining-0.1.0-cp312-abi3-macosx_11_0_arm64.whl` in 56 seconds. End-to-end mining cycle: + +``` +running mine(m=256, n=128, k=1024, rank=32) on Apple Silicon CPU… + mine() returned a proof in 0.078s + verify_plain_proof: ok=True, msg='Mining solution verified successfully' (0.3 ms) +END-TO-END MINING ON APPLE SILICON SUCCEEDED +``` + +The test difficulty here is `nbits=0x1D2FFFFF` (the test fixture from `py-pearl-mining/tests/test_python_api.py`), much lower than mainnet difficulty — so 78 ms is **per-share at the test difficulty**, not real expected wall-clock per share at the network's current difficulty. But the *correctness* of the path is proven. + +### 1.5.5 Reframed v1 — what to build, what to defer + +| Layer | Original plan (§5–§8) | New v1 plan | +|---|---|---| +| Reference oracle (Phase 0-B) | Build from scratch in PyTorch, validate against H100 CUDA | **Already exists upstream** — `miner-base.noisy_gemm` + `pearl_mining.mine`. OJ ships a thin wrapper, no reimplementation. | +| Inference-backend plugin (Phase 2) | Custom MLX or llama.cpp Metal plugin doing NoisyGEMM | **Deferred to v2.** v1 is **decoupled mining** — mining runs as a separate process via the upstream Rust miner; user's existing inference (Ollama, MLX, llama.cpp) is unaffected. | +| Metal NoisyGEMM kernel (Phase 1) | Months of GPU-kernel engineering | **Deferred to v3** as a perf optimization once v1 ships and demand is proven. Original §6.1 content preserved as the v3 plan. | +| OJ provider integration (Phase 3) | New `MiningProvider` impl | **v1 ships this** — see §13. `MiningProvider` ABC from Spec A unchanged. | +| Bringup + verification (Phase 4) | Hardware matrix + testnet | **v1 ships this** — see §14. Same hardware matrix, simpler scope. | +| Pearl coordination (Phase 0-A) | Confirm upstream-vs-fork posture | Still needed (see §12) — but the bar is lower since v1 doesn't require any code from us in Pearl's tree. | + +### 1.5.6 Honest performance expectations for v1 + +This is **not** competitive mining. The point of vllm-miner is that it amortizes mining work over LLM inference matmuls (the matmul you're already doing for inference *is* the mining work). v1 here decouples them: your CPU does mining, your GPU does inference. The hashrate will be low. **But it works today, and it ships with a credible upgrade path.** Document this transparently in `mine doctor` and the user guide. + +The v2 (PyTorch-MPS NoisyGEMM coupled with MLX/llama.cpp inference) and v3 (native Metal kernel) work paths in §5–§8 remain the route to competitive Apple Silicon mining. They're explicitly not blocking v1. + +## 2. Why this is its own spec + +Spec A's scope is the v1 integration that ships today on the only working configuration (vLLM + sm90). Apple Silicon enablement is a separate, parallelizable workstream because: + +- **Different ownership boundary.** Spec A is Python integration of an existing Pearl Docker image. Spec B is GPU-kernel engineering with potential upstream contribution to Pearl. These need different reviewers, different CI surfaces (no H100 needed, but Apple Silicon required), and different release cadence. +- **Different timeline.** Spec A is weeks. Spec B is plausibly months for the kernel work alone. +- **Different blast radius.** Spec A ships zero risk to non-mining users; even mining users who edit the wrong config get a clear error. Spec B carries protocol-correctness risk — a bug in NoisyGEMM produces invalid blocks that get rejected by validators. +- **Parallel-agent ergonomics.** The user has explicitly asked for this spec to be picked up by a separate agent in parallel. Self-containment is a design goal. + +## 3. Evidence: Apple Silicon support is possible + +### 3.1 The validator is hardware-neutral + +`pearl/zk-pow/src/api/verify.rs::verify_block`: + +```rust +pub fn verify_block(public_params: &PublicProofParams, proof: &ZKProof, cache: &mut CircuitCache) -> Result<()> { + let (params, pis) = prepare_verification(public_params, proof, None)?; + PearlRecursion::compile_circuits(params, cache, false)?; + verify_with_cache(params, cache, &pis, proof) +} +``` + +Verification is `PearlRecursion::verify(params, cache, pis, &proof.plonky2_proof)` — a recursive plonky2 STARK check. No GPU code path, no CUDA dependency. Validator nodes run pure Rust. + +The mining work consists of three things, all of which are mathematical specifications — not implementation specifications: + +1. A NoisyGEMM result whose noise pattern is derived from blake3 of a per-block key +2. A blake3 commitment hash over the noised matmul that meets a difficulty target +3. A plonky2 STARK proof that ties the result to the commitment + +Any implementation that produces matching outputs is acceptable to the network. **This is the design intent of PoUW** — the work has to be replayable and verifiable, but not hardware-bound. + +### 3.2 The Pearl team explicitly anticipates non-CUDA plugins + +From `pearl/miner/README.md`: + +> "Currently only mining via vLLM is supported, in the future we hope to supply plugins for other LLM inference libraries, like SGLang, TensorRT-LLM, Ollama, ..." + +Apple is not in their list, but the framing — "supply plugins for other LLM inference libraries" — implies the boundary is at the inference backend, not at the consensus protocol. Confirms the architectural read. + +### 3.3 Reference implementation exists in py-pearl-mining + +`pearl/py-pearl-mining/` is a PyO3 crate exposing Pearl mining primitives in Python. **Read it before designing the Metal port** — it likely contains the protocol-relevant constants in a hardware-neutral form, suitable as a reference oracle for Phase 0 testing (§5). + +## 4. Scope + +### 4.1 In scope + +- **Phase 0** (§5): protocol-acceptance verification + Pearl-team coordination + Python reference oracle +- **Phase 1** (§6.1): Metal NoisyGEMM kernel — the substantive engineering +- **Phase 2** (§6.2): inference-backend plugin — MLX or llama.cpp Metal +- **Phase 3** (§7): OJ provider integration — new `MiningProvider` impl, new optional extra, registry hookup +- **Phase 4** (§8): verification matrix across Apple Silicon variants and bringup on Pearl testnet +- Documentation deliverables and the upstream-contribution path + +### 4.2 Out of scope + +- Pool support and the 20% OJ fee (Spec A §8.5; that lives in a future v2 pool spec) +- Custody, signing, or routing Pearl funds (Spec A anti-goal; same here) +- AMD ROCm enablement (separate spec, parallel structure to this one) +- Intel Arc / Mac Intel / older CUDA enablement (separate specs) +- Modifying anything in Spec A. **Spec B is purely additive.** +- Pearl protocol changes (none required; see §3.1) + +### 4.3 Explicit non-goal: economic competitiveness + +This spec does not promise that Apple Silicon mining will be **profitable**. The performance gap to a tuned H100 kernel is likely large (§6.1.5 discusses why). What this spec *does* promise: a correct, working Apple Silicon path that's enabled the day the kernel ships, with a transparent doctor surface that tells Mac users honestly what their hashrate looks like. Whether it's worth the electricity is a user decision. + +## 5. Phase 0 — investigation, coordination, reference oracle + +The phase that costs the least and prevents the most rework. Three workstreams in parallel. + +### 5.1 Workstream P0-A: Pearl-side coordination + +**Goal:** Confirm protocol acceptance in writing from Pearl maintainers; align on whether OJ contributes upstream or ships independently. + +**Steps:** + +1. Open a GitHub Discussion on `pearl-research-labs/pearl`: "Apple Silicon / Metal NoisyGEMM enablement — coordination". Reference Spec B URL. +2. Get explicit confirmation from a Pearl maintainer that: + - Validator path is hardware-neutral as believed (§3.1). + - There is no Pearl-internal Metal port already in flight that would conflict. + - LICENSE compatibility allows OJ-authored kernel code to be either contributed upstream (preferred) or distributed alongside OJ. +3. Discuss the upstream-vs-fork question. Strong default: **contribute upstream into a new `pearl/miner/pearl-gemm-metal/` crate**, paralleling `pearl-gemm/`, so Pearl owns the kernel long-term and we benefit from their CI and review. Fork only if upstream contribution is blocked. + +**Exit criteria:** + +- [ ] Written confirmation of protocol acceptance +- [ ] Agreement on contribution model (upstream / coordinated fork / independent) +- [ ] No duplicate-effort risk + +### 5.2 Workstream P0-B: build a reference oracle + +**Goal:** A pure-Python (or pure-Rust) implementation of NoisyGEMM that produces bit-exact-or-fp16-tolerance-bounded outputs versus Pearl's CUDA reference. **Used as the test oracle for Phase 1** — without it, you can't verify the Metal kernel's correctness against a portable baseline. + +**Steps:** + +1. Read `pearl/miner/pearl-gemm/csrc/gemm/` end-to-end. Catalog the protocol-relevant constants in `pearl_gemm_constants.hpp`: + - `kAxEBLScaleFactor = 1 << 14` + - `kEARxBpEBScaleFactor = 1 << 12` + - `kIntToFp16ScaleFactor = 1 << 12` + - `kEBRScaleFactorDenoise`, `kEALScaleFactorDenoise` +2. Read `pearl/py-pearl-mining/` to see what's already exposed in Python. If a reference impl already lives there, **use it**; do not duplicate. +3. If gaps exist, build them in PyTorch (CPU). Mirror the structure of the CUDA kernels: + - `noise_generation.cu` → `noise_generation.py` — derive `EAL`, `EAR`, `EBL`, `EBR` from blake3-of-key + seed + - `pearl_gemm` (matmul + noise) → `pearl_gemm.py` — compute `Y_noisy = (A + EAL·EAR) × (B + EBL·EBR)` with the documented scaling + - `inner_hash_kernel.cu` → `inner_hash.py` — blake3 commitment over the noised matmul + - `denoise_converter.cu` → `denoise.py` — recover `Y_clean = A·B` from `Y_noisy` and the noise components + - `pow_utils.hpp` → `pow_check.py` — difficulty target check +4. Cross-check: run a corpus of 100+ inputs through the Pearl CUDA reference (on an H100 dev box; see §5.4) and through the Python reference. Assert outputs match within the documented tolerance — most likely **bit-exact for int paths and fp16-tolerance for the denoised result**. + +**Exit criteria:** + +- [ ] `tools/pearl-reference-oracle/` (in OJ repo, or separate repo) builds and tests pass +- [ ] Parity confirmed against Pearl CUDA on ≥100 input sets +- [ ] Constants table documented in this spec (replace the bullet list above with the verified values) + +### 5.3 Workstream P0-C: Apple-side viability + +**Goal:** Decide between MLX and llama.cpp Metal as the integration host before designing the kernel. + +**Decision criteria:** + +| Factor | MLX (`ml-explore/mlx`, `mlx-lm`) | llama.cpp Metal (`ggerganov/llama.cpp`) | +|---|---|---| +| Op-replacement hooks | Less mature; would likely require monkey-patching `mlx.nn.Linear` or upstream PR adding plugin hooks | More mature; `ggml` op tree is open and Metal backend has clear extension points (`ggml-metal.metal`) | +| Apple-native quantization story | Excellent (4-bit, 8-bit native ops) | Good but not as native | +| Inference-quality fidelity for OJ users today | High — MLX-LM is the de facto Mac LLM stack | High — also widely used | +| Upstream-contribution complexity | Higher (smaller team, less plugin culture) | Lower (large open community, clear contributor flow) | +| Ecosystem alignment with OJ engine map | OJ's `engine/` doesn't currently have an MLX engine; would need both | OJ already has llama.cpp via `engine/openai_compat_engines.py` | + +**Recommendation:** **llama.cpp Metal first**, MLX as a fast-follow. Reasoning: ggml's op tree gives a cleaner extension path for a custom NoisyGEMM op; OJ already has llama.cpp engine wiring; and the upstream-contribution path is more navigable. MLX is a better long-term fit for Apple-native users but is currently a harder integration target. + +**Steps:** + +1. Spike: implement a no-op "custom op" passthrough in llama.cpp Metal. ~1-2 days work to confirm the integration mechanism is real and the build pipeline cooperates. +2. Spike: same in MLX. Compare effort. +3. Pick one. Document the decision in this spec. + +**Exit criteria:** + +- [ ] Decision made and documented in §6.2 +- [ ] Trivial plugin hook proven on the chosen backend + +### 5.4 Hardware required for Phase 0 + +- One H100/H200 box (cloud rental fine — Lambda, RunPod, Crusoe). Used for: running Pearl's CUDA reference to capture parity test vectors, running the Pearl Docker miner end-to-end as a known-good baseline. +- Apple Silicon dev machines: M2 Max minimum, M3/M4 Pro+ preferred. M-series Ultra ideal for any perf experiments. +- Estimated cloud cost for Phase 0: < $200. + +## 6. Phases 1 and 2 — kernel and plugin + +### 6.1 Phase 1 — Metal NoisyGEMM kernel + +**Goal:** A Metal compute-shader implementation of NoisyGEMM that produces outputs matching the Phase 0 reference oracle, performant enough to make Mac mining a real (if low-yield) feature. + +#### 6.1.1 Implementation surface + +Two viable targets, in order of preference: + +**A. Direct Metal Shading Language (MSL) compute kernels.** Maximum control, maximum performance ceiling, maximum effort. The CUDA reference is highly tuned (TMA, WGMMA, multi-stage pipelines); a direct MSL port can lean on Apple's matmul intrinsics where they exist (`simdgroup_matrix` ops on M3+). + +**B. Metal Performance Shaders Graph (MPSGraph).** Higher-level than raw MSL; uses Apple's tuned matmul kernels under the hood; limited control over the in-kernel commitment hash. Likely path: do the matmul via MPSGraph, do noise generation + commitment hashing as separate kernels, accept the perf hit from less fusion. + +**Recommendation:** Start with B for correctness and shipping speed; profile; move hot paths to A only if economically justified. Apple's matmul intrinsics are fast enough that the perf gap to a fused implementation may be acceptable. + +#### 6.1.2 Algorithm structure + +Following the Pearl CUDA reference, end-to-end work performed for one mining attempt: + +1. **Quantize inputs.** `A: fp16 → int8 + scale_A`, `B: fp16 → int8 + scale_B`. Match Pearl's `quantize_kernel.cu` semantics (per-row or per-channel scales — verify via Phase 0 oracle). +2. **Generate noise tensors.** From `key_A`, `key_B` (per-block blake3-derived seeds), produce `EAL` (m, R), `EAR` (k, R), `EBL` (k, R), `EBR` (n, R) of int8. Scale factors per `pearl_gemm_constants.hpp`. +3. **Noisy matmul.** Compute `Y_noisy = (A + EAL · EAR_T) × (B + EBL · EBR_T)`. Output is int32 then converted to fp16. +4. **Inner-hash commitment.** blake3 over `Y_noisy` (or a row-tile of it) to produce the PoW target candidate. This is the hottest path — the noise + commitment loop runs at every share. +5. **PoW check.** Compare commitment digest against the difficulty target (`make_pow_target_tensor` semantics from Pearl's Python interface). +6. **On hit: denoise.** Compute `Y_clean = Y_noisy - (noise contributions)` to feed back into vLLM/MLX as the actual matmul output. Inference cannot be wrong. +7. **Post-hit: STARK proof generation.** When a share meets the network difficulty target, the miner generates a plonky2 STARK proof tying the noisy matmul + commitment to the block. This proving step is **separate from the Metal kernel** — it runs in pure Rust via Pearl's existing `zk-pow/` and `py-pearl-mining` code paths and should work cross-platform unchanged. Cost: seconds-to-minutes of CPU per block. Confirm cross-platform builds during Phase 0-C and §7.5. + +#### 6.1.3 Crate / package layout + +Strong preference: **upstream contribution to Pearl** as `pearl/miner/pearl-gemm-metal/` paralleling the existing `pearl-gemm/`: + +``` +pearl/miner/pearl-gemm-metal/ + Cargo.toml (or pyproject.toml + setup.py — match Pearl conventions) + metal/ (.metal MSL source files) + src/ + lib.rs (or src/pearl_gemm_metal/__init__.py) + tests/ +``` + +If upstream contribution is blocked (Phase 0 outcome), fork with attribution into `OpenJarvis/vendor/pearl-gemm-metal/` and document the divergence policy in this spec. + +#### 6.1.4 Testing + +- **Parity tests.** Each kernel (noise gen, matmul, inner hash, denoise, PoW check) tested independently against the Phase 0 reference oracle. Bit-exact for int paths; fp16-tolerance bounded for fp paths (specific tolerance: TBD via Phase 0 measurement). +- **End-to-end correctness.** Full mining attempt produces a candidate proof that the reference Rust prover (`zk-pow/`) accepts. +- **Hardware fuzz.** Run on M1 Pro, M2 Max, M3 Max, M4 Max, and M-Ultra variants. Catch any silently-wrong hardware behavior (Metal feature variance across generations is real). + +#### 6.1.5 Performance expectations + +Honest baseline: **expect 0.05–0.2× the share rate of an H100** on a high-end M-Ultra, and proportionally less on smaller chips. Reasons: + +- H100 has dedicated FP8/FP16 tensor cores with WGMMA throughput Apple Silicon does not match +- Pearl's CUDA kernel is heavily fused (matmul + noise + commitment in one kernel via TMA pipelining); a Metal version will likely be less fused +- 70B model bandwidth requirements stress unified memory + +This is fine. Mac mining is a feature for Apple Silicon owners who want to participate, not a competitive yield product. Document it transparently in `mine doctor` and the user guide. + +#### 6.1.6 Exit criteria for Phase 1 + +- [ ] Parity tests pass on M2 Max and M4 Max +- [ ] End-to-end mining attempt produces a valid proof accepted by `zk-pow::verify_block` +- [ ] Performance characterized and published (M-series matrix) +- [ ] Code merged upstream OR forked-with-policy per Phase 0 outcome + +### 6.2 Phase 2 — Inference-backend plugin + +**Goal:** A llama.cpp Metal (or MLX, per Phase 0-C) plugin that swaps the standard quantized linear op for Phase 1's NoisyGEMM during inference, so a Mac running this plugin produces both correct LLM outputs and valid mining shares. + +#### 6.2.1 Path: llama.cpp Metal (assuming Phase 0-C selected this) + +- Add a custom `ggml` op `GGML_OP_PEARL_NOISY_GEMM` with a Metal backend implementation that calls Phase 1's kernels. +- Plugin entry point: a small library that, when loaded, replaces the default linear op in the model graph during loading. +- Build artifact: `libpearl_metal_plugin.dylib` (or static lib). + +#### 6.2.2 Path: MLX (alternate) + +- Define `mlx.NoisyLinear` as a subclass of `mlx.nn.Linear` that calls Phase 1's kernels via a custom Metal op binding. +- Provide a model-loading shim: `from openjarvis.mining import patch_mlx_for_pearl; patch_mlx_for_pearl()` that monkey-patches `mlx.nn.Linear` instances at load time. Less elegant; works. + +#### 6.2.3 Inference-quality regression tests + +The plugin is correctness-critical: a noised model that doesn't fully denoise produces degraded responses. Test: + +- Load a small reference model (e.g., a 1-3B parameter Pearl-blessed model if one exists for testing, otherwise the smallest model the protocol accepts). +- Run a fixed prompt set through both noised+denoised and standard paths. +- Assert outputs are bit-exact or within fp16 tolerance. +- Run OJ's existing eval framework (`src/openjarvis/evals/`) on a small benchmark (e.g., an MMLU subset registered as a Pearl-mining-mode dataset). Assert no degradation > the tolerance budget. Falling back to `lm-eval-harness` is acceptable if OJ's eval surface for Mac is incomplete at the time. + +#### 6.2.4 Exit criteria + +- [ ] Plugin loads in chosen backend +- [ ] End-to-end inference produces correct outputs (regression tests pass) +- [ ] Mining shares are submitted to a Pearl testnet during inference +- [ ] At least one block found on testnet from a Mac + +## 7. Phase 3 — OpenJarvis provider integration + +Where the OJ-side work is small. Inherits the entire `MiningProvider` ABC, registry, sidecar, config schema, telemetry adapter, and v2 seams from Spec A unchanged. + +### 7.1 New files + +``` +src/openjarvis/mining/ + llamacpp_pearl_metal.py # OR mlx_pearl.py — depending on Phase 2 path + # @MinerRegistry.register("llamacpp-pearl-metal") + # implements MiningProvider ABC from Spec A §4.4 +``` + +### 7.2 New optional extra + +```toml +# pyproject.toml +mining-pearl-metal = [ + "pearl-metal-plugin>=0.1", # the Phase 2 plugin, however published + # MLX path adds: "mlx>=0.X", "mlx-lm>=0.X" + # llama.cpp path adds: "llama-cpp-python>=0.X" with Metal extras +] +``` + +### 7.3 Capability detection + +```python +# src/openjarvis/mining/llamacpp_pearl_metal.py +class LlamaCppPearlMetalProvider(MiningProvider): + provider_id = "llamacpp-pearl-metal" + + @classmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + if hw.platform != "darwin": + return MiningCapabilities(False, reason="Apple Silicon required (platform != darwin)") + if hw.gpu is None or hw.gpu.vendor != "apple": + return MiningCapabilities(False, reason="Apple Silicon GPU required") + if engine_id not in {"llamacpp", "llama-cpp"}: + return MiningCapabilities(False, reason=f"engine '{engine_id}' has no Pearl Metal plugin; use llamacpp") + if not _pearl_metal_plugin_available(): + return MiningCapabilities(False, reason="install with `uv sync --extra mining-pearl-metal`") + if not _model_has_pearl_variant(model): + return MiningCapabilities(False, reason=f"model '{model}' has no Pearl-blessed variant") + return MiningCapabilities(True, estimated_hashrate=_estimate_hashrate(hw)) +``` + +Each branch is exactly the kind of "why can't I mine" message Spec A's `mine doctor` surfaces verbatim. + +### 7.4 Lifecycle + +Unlike Spec A's vLLM provider which orchestrates a Docker container, the Apple provider runs **two coordinated subprocesses directly on the host**: + +1. The inference server (llama.cpp server with the Pearl Metal plugin loaded, or MLX-LM server depending on Phase 0-C path) +2. `pearl-gateway` as a sibling process — same one that runs inside the Docker container in Spec A, but here it runs natively on the Mac + +Lifecycle: + +- `start()`: spawn (1) with the Pearl Metal plugin pre-loaded (`DYLD_INSERT_LIBRARIES`-style or `--plugin` flag depending on chosen backend's invocation contract), then spawn (2) pointing at it. Write the same sidecar shape Spec A defines, with `gateway_url` pointing at the native pearl-gateway. Track both PIDs internally. +- `stop()`: SIGTERM (2) first, then (1), with bounded waits and SIGKILL fallback. Remove sidecar. +- `is_running()`, `stats()`: identical contract to vLLM provider; `stats()` reads from the native pearl-gateway's `:8339/metrics`. + +**No Docker.** Apple Silicon Docker doesn't pass through Metal; running Pearl in a Mac Docker container would defeat the purpose. Document this explicitly in §7 of this spec; do not attempt a Docker path. + +### 7.5 Pearl gateway on Mac + +The Pearl `pearl-gateway` process is currently only documented as part of the Docker container. For Mac, we need it to run natively. Two options: + +1. Build `pearl-gateway` from source via `uv sync --package pearl-gateway` — same workspace package the Docker image uses. Should work cross-platform since it's pure Python plus py-pearl-mining bindings. Verify. +2. If (1) fails on Apple Silicon, work with Pearl maintainers (Phase 0-A) to port it — a small amount of work compared to the kernel. + +**Phase 3 verifies (1).** This is a Phase 0-A coordination point. + +### 7.6 Exit criteria + +- [ ] `LlamaCppPearlMetalProvider` registered, detection matrix correct on M1/M2/M3/M4 +- [ ] `jarvis mine init` runs to completion on Apple Silicon +- [ ] `jarvis mine start` launches subprocess + Pearl gateway on Mac +- [ ] `jarvis mine status` returns valid `MiningStats` from a real Mac mining session +- [ ] `jarvis mine doctor` produces honest, actionable output for Mac users + +## 8. Phase 4 — Verification & bringup + +### 8.1 Hardware matrix + +| Chip | Test priority | Expected outcome | +|---|---|---| +| M1 / M1 Pro / M1 Max | low — generation 1 GPU may have feature gaps | works but slow | +| M2 / M2 Pro / M2 Max | medium | works | +| M2 Ultra | medium | best M2-class hashrate | +| M3 / M3 Pro / M3 Max | high — first gen with `simdgroup_matrix` | works, meaningful share rate | +| M4 / M4 Pro / M4 Max | high — current flagship | best non-M-Ultra hashrate | + +For each chip in the matrix, run: + +1. `jarvis mine init` end-to-end +2. `jarvis mine start` and run for ≥4 h continuous +3. Capture and publish: shares submitted, shares accepted, block-find time distribution, GPU temp, system load impact on normal use +4. Run a parallel `lm-eval-harness` on the mining endpoint to assert inference quality is unaffected + +### 8.2 Pearl testnet bringup + +Before any mainnet recommendation: + +- Mine on Pearl testnet for ≥7 continuous days from at least two Apple Silicon variants +- Find at least one block on testnet from each variant +- Verify all blocks accepted by `zk-pow::verify_block` on a reference validator node +- Report results to Pearl maintainers; gate any mainnet announcement on their sign-off + +### 8.3 Documentation deliverables + +- `docs/user-guide/mining-apple-silicon.md` — user-facing: prerequisites, install flow, doctor reading guide, performance expectations table, links to share-rate calculators +- `docs/development/mining-providers.md` — generalized "how to add a new provider" guide using this spec as the canonical worked example +- An update to `docs/user-guide/mining.md` (Spec A) adding Apple Silicon to the supported-platforms list + +### 8.4 Exit criteria + +- [ ] Hardware matrix covered +- [ ] Testnet bringup complete +- [ ] Documentation merged +- [ ] Pearl maintainer sign-off obtained +- [ ] OJ release notes call out Apple Silicon mining as supported + +## 9. Risks + +| ID | Risk | Likelihood | Impact | Mitigation | +|---|---|---|---|---| +| R1 | Pearl validator rejects non-CUDA-mined blocks despite hardware-neutral validator code | low (validator code reviewed) | catastrophic (whole spec invalid) | Phase 0-A explicit confirmation; Phase 0-B oracle reduces likelihood of math drift | +| R2 | Pearl ships their own Metal port, conflicts with OJ's | medium (depends on Pearl roadmap) | high (rework or fork) | Phase 0-A coordination; default to upstream contribution | +| R3 | Metal NoisyGEMM is so slow that mining is uneconomical even for hobbyists | medium-high | medium (feature ships but unused) | §4.3 names this as a non-goal; transparency in `mine doctor`; consider M-Ultra-only-by-default in v1 of this spec | +| R4 | NoisyGEMM correctness bug → invalid blocks → wasted user electricity | low if §6.1.4 testing rigorous | high (trust hit) | Strong parity testing against oracle; testnet bringup before mainnet | +| R5 | Inference-quality regression — denoised path doesn't fully recover model fidelity | medium | high | §6.2.3 regression tests; eval-harness gate before ship | +| R6 | Pearl protocol changes between Phase 0 and Phase 4 (multi-month) | medium | medium | Pin Phase 0 ref same as Spec A; renegotiate at each Pearl rev | +| R7 | Apple changes Metal API in macOS update | low-medium | medium | Use stable MSL features; pin Xcode toolchain | +| R8 | Upstream Pearl PR rejected | low (Pearl wants this) | medium (forced fork) | Phase 0-A negotiates upstream-vs-fork up front | +| R9 | `pearl-gateway` doesn't build on Apple Silicon (§7.5) | medium (it's Python — should work, but py-pearl-mining has Rust deps) | low (small fix) | Phase 0 verifies builds; Phase 0-A coordination if not | + +## 10. Open questions + +Phase 0 answered most of these from the upstream code (annotations below). The remaining open items are ones that require either Pearl maintainer input or empirical measurement on real network conditions. + +1. **(Open — coordination)** Is there a Pearl-blessed "small" model for testing? The reference miner uses a 70B model — too big for fast iteration. A 7B or 13B variant for development would dramatically speed up v2/v3 plugin work. *Less critical for v1, since v1 is decoupled from inference and runs `mine()` directly without a model.* +2. **(Answered — N/A for v1)** ~~Documented fp16 tolerance budget for denoised matmul output~~ — `miner-base/tests/test_noisy_gemm.py:92` does `torch.equal(result, expected)`: the int7×int7→int32 path is **bit-exact**, no fp16 tolerance budget is needed. (May reappear in v3 Metal kernel work if int↔fp16 conversions are introduced for perf.) +3. **(Answered — yes)** Does `py-pearl-mining` already expose enough of NoisyGEMM in Python that Phase 0-B becomes a thin wrapper? **Yes.** `pearl_mining.mine` runs the entire mining algorithm in pure Rust. Additionally, `miner-base.NoisyGemm` provides a PyTorch reference of the production NoisyGEMM. Phase 0-B's "build a reference oracle" deliverable is now a *thin OJ-side wrapper* that calls upstream — see §13 and `tools/pearl-reference-oracle/` (created in this session). +4. **(Answered — likely yes; empirically verified for `py-pearl-mining`)** Is `pearl-gateway` cross-platform? Its `pyproject.toml` requires Python ≥ 3.10 and depends on `aiohttp`, `bitcoin-utils`, `blake3`, `numpy`, `prometheus-client`, `pybase64`, `pydantic`, `pyyaml`, `torch==2.11.0`, `py-pearl-mining` — all install on macOS arm64. Empirical install of the workspace was not run in this session; **action item for v1 implementation**: `uv sync` the workspace on macOS-15 and capture the build output. +5. **(Open — coordination)** Does Pearl gate any difficulty / consensus parameters on hardware introspection? Code review found none. Confirm in Phase 0-A discussion. +6. **(Open — measurement)** Minimum acceptable hashrate floor for `jarvis mine init` on Apple Silicon. The `MiningCapabilities.estimated_hashrate` field in Spec A §4.4 exists for this. v1 will populate from a calibration run during `mine init`. The *floor* is a policy decision, not a technical one — defer to user-research / community feedback once v1 ships. +7. **(Open — coordination)** Upstream contribution / CLA / LICENSE. Pearl is ISC; OJ is Apache-2.0; both are permissive and combine cleanly. **CLA TBD via Phase 0-A discussion**, but for v1 this is moot — OJ contributes no code into Pearl's tree, only consumes their published Python packages. +8. **(Answered — yes)** Apple Silicon CI on GitHub Actions: `macos-14` / `macos-15` runners are arm64 and can install `py-pearl-mining` via the wheel build verified in §1.5.4. OJ's CI can run mining unit tests. Mining the *real* network in CI is still out of scope. +9. **(Answered — `"llamacpp"`)** OJ's llama.cpp engine_id is `"llamacpp"` (single token, no hyphen). Confirmed at `src/openjarvis/engine/openai_compat_engines.py:9` and `src/openjarvis/engine/_discovery.py:18`. Update §7.3 capability detection to use this key. *(For v1 in §13, this only matters if we add an "informational" mining-aware hint to the existing llamacpp engine — v1 does not require any plugin into the engine.)* +10. **(Open — measurement, but de-risked)** plonky2 STARK proving latency on Apple Silicon CPU. Spec A §1 already notes proving is seconds-to-minutes of CPU per block (cross-platform, runs unchanged). For v1 the hashrate is so low that block-find latency is dominated by the search, not the proof. Empirical measurement still needed for v2/v3. +11. **(New — v1 specific)** Does `bitcoin-utils>=0.7.0` (a `pearl-gateway` dependency) have C extensions that need Apple-specific build flags? Likely pure-Python; verify during the v1 install workstream. +12. **(New — v1 specific)** Will `torch==2.11.0` (the version `miner-base` and `pearl-gateway` pin) install cleanly on macOS arm64? PyTorch generally has arm64 macOS wheels. Verify during v1 install. + +## 11. Cross-references + +- **[Spec A](2026-05-05-vllm-pearl-mining-integration-design.md)** — the v1 integration this extends. Read §4.4 (the `MiningProvider` ABC), §5.3 (sidecar shape), §8.1–8.2 (telemetry adapter contract), §8.5 (v2 fee/pool seams). All apply unchanged. +- **Pearl coordination thread (P0-A draft):** [`2026-05-05-pearl-coordination-discussion-draft.md`](2026-05-05-pearl-coordination-discussion-draft.md) — content the user posts on `pearl-research-labs/pearl` to confirm protocol acceptance and align on contribution model. +- **OJ-side Phase 0 deliverables (created this session):** + - `tools/pearl-reference-oracle/` — thin Python wrapper around upstream Pearl bindings + smoke test, runnable on Apple Silicon +- **Pearl repo paths read in Phase 0 (in priority order):** + 1. `pearl/zk-pow/src/api/verify.rs` — the validator. Pure Rust, no GPU. **Hardware-neutrality verified.** + 2. `pearl/zk-pow/src/api/proof.rs` — `PublicProofParams`, `ZKProof`, `PrivateProofParams`, `IncompleteBlockHeader`, `MiningConfiguration`, `MMAType`. Defines what the protocol commits to. + 3. `pearl/zk-pow/src/ffi/mine.rs` — **the entire hardware-neutral mining function**. Pure Rust. Already exposed to Python. + 4. `pearl/zk-pow/src/circuit/pearl_noise.rs` — noise generation: `compute_noise_for_indices`, `generate_uniform_random_matrix`, `generate_permutation_matrix`. Hardware-neutral. + 5. `pearl/py-pearl-mining/src/lib.rs` — PyO3 module. Re-exports `mine`, `verify_plain_proof`, `generate_proof`, `verify_proof`, `warmup_prove`. **Builds on macOS arm64, verified §1.5.4.** + 6. `pearl/py-pearl-mining/Cargo.toml` — pure Rust deps: `pearl-blake3`, `zk-pow`, `blake3`, `rayon`, `pyo3`, `lazy_static`, `tikv-jemallocator`. No CUDA in tree. + 7. `pearl/py-pearl-mining/tests/test_python_api.py` — the canonical end-to-end test. Use as the OJ smoke-test template. + 8. `pearl/miner/miner-base/src/miner_base/noisy_gemm.py` — PyTorch reference of the production NoisyGEMM. The "reference oracle" §5.2 wanted to build is here. + 9. `pearl/miner/miner-base/src/miner_base/noise_generation.py` — PyTorch noise generation matching `pearl_noise.rs`. + 10. `pearl/miner/miner-base/src/miner_base/inner_hash.py` — PyTorch inner-hash with XOR reduction. + 11. `pearl/miner/miner-base/tests/test_noisy_gemm.py` — bit-exact denoising verified at line 92. + 12. `pearl/miner/miner-base/pyproject.toml` — deps (`torch==2.11.0`, `blake3`, `numpy`, `pearl-gateway`, `py-pearl-mining`); **no platform markers** → installs on Apple Silicon. + 13. `pearl/miner/pearl-gateway/pyproject.toml` — deps (pure Python + py-pearl-mining + torch); **no platform markers**. + 14. `pearl/miner/vllm-miner/src/vllm_miner/register.py` — vLLM plugin registration via `vllm.general_plugins` entry point. The pattern Phase 2 (v2 plan) would mirror. + 15. `pearl/miner/pearl-gemm/csrc/gemm/pearl_gemm_constants.hpp` — protocol scale factors. Verified values: + - `kAxEBLScaleFactor = 1<<14 = 16384` + - `kEARxBpEBScaleFactor = 1<<12 = 4096` + - `kIntToFp16ScaleFactor = 1<<12 = 4096` + - `kEBRScaleFactorDenoise = -4` (= -kAxEBLScaleFactor / kIntToFp16ScaleFactor) + - `kEALScaleFactorDenoise = -1` (= -kEARxBpEBScaleFactor / kIntToFp16ScaleFactor) + 16. `pearl/miner/pearl-gemm/setup.py:88` — `COMPUTE_CAPABILITY = "arch=compute_90a,code=sm_90a"`. Confirms CUDA kernel is Hopper-only. + 17. `pearl/Taskfile.yml` — `build:miner` task is gated to `platforms: [linux, windows]`. **The miner Python install path Pearl ships today is Linux/Windows-only**; OJ's v1 path uses the components that *do* install on macOS, sidestepping this gate. +- **Pearl paper:** [Proof-of-Useful-Work via matrix multiplication (arXiv:2504.09971)](https://arxiv.org/abs/2504.09971) — read for the math formalization. Less critical now that the PyTorch reference exists upstream. +- **Apple references (still relevant for v2/v3):** + - [Metal Shading Language Specification](https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf) + - [MPS / MPSGraph documentation](https://developer.apple.com/documentation/metalperformanceshadersgraph) + - [MLX](https://github.com/ml-explore/mlx) — alternative v2 plugin host + - [PyTorch MPS backend docs](https://pytorch.org/docs/stable/notes/mps.html) — relevant for v2 (MPS-accelerated decoupled mining) + +## 12. Implementation plan + +Two implementation plans now live alongside this spec: + +- **v1 plan (decoupled CPU mining via upstream Pearl):** Written via `superpowers:writing-plans` after this Phase 0 update. Tracking issue: [`2026-05-05-apple-silicon-pearl-mining-plan-v1.md`](2026-05-05-apple-silicon-pearl-mining-plan-v1.md). +- **v2 plan (PyTorch-MPS or MLX/llama.cpp coupled mining):** TBD. Written when v1 ships and we have empirical hashrate data justifying the next investment. +- **v3 plan (native Metal NoisyGEMM kernel):** TBD. Written only if v2 measurements show the additional kernel work is economically justified. + +The original §5–§8 content describing Phases 0–4 of the *kernel-first* approach is preserved as the v3 plan's reference. Do not delete it — when the time comes to write the v3 plan, that content is the starting point. + +## 13. Apple Silicon v1 — minimal path + +This section defines the v1 design that ships in weeks rather than months. v1 is **decoupled mining**: the user's existing inference workflow (Ollama, MLX-LM, llama.cpp, vLLM-on-CPU, anything) is untouched; mining runs as a separate process via the upstream Pearl miner. + +### 13.1 Architecture + +``` + OpenJarvis user (Apple Silicon) + ┌────────────────────────────────┐ + │ jarvis mine start │ + │ ↓ │ + │ CpuPearlProvider (this spec) │ + │ ↓ subprocess.Popen │ + │ ┌──────────────────────────┐ │ + │ │ pearl-gateway (Python) │ │ + │ │ ↑ JSON-RPC :8337 │ │ + │ │ pearl-mine-loop (Python) │ │ ← uses py-pearl-mining + │ │ wraps pearl_mining.mine│ │ (pure Rust) + │ └──────────────────────────┘ │ + │ │ + │ Inference (untouched) │ + │ ┌──────────────────────────┐ │ + │ │ Ollama / MLX / llamacpp │ │ + │ └──────────────────────────┘ │ + └────────────────────────────────┘ + ↓ + pearld (BYO, same as Spec A) +``` + +The mining loop wraps `pearl_mining.mine()` in a process that: + +1. Polls `pearl-gateway` for current `IncompleteBlockHeader` + `MiningConfiguration` +2. Calls `pearl_mining.mine(m, n, k, header, config)` to find a `PlainProof` +3. Submits the proof back to `pearl-gateway`, which generates the ZK proof and forwards to `pearld` +4. Loops + +This is the *same* control flow vllm-miner runs — just without coupling the matmul to vLLM's inference. Pearl's existing `pearl-gateway` already does the orchestration we need; we just need a small mining loop that uses the CPU `mine()` instead of the CUDA path. + +### 13.2 Module layout in OJ + +``` +src/openjarvis/mining/ + cpu_pearl.py # @MinerRegistry.register("cpu-pearl") — v1 provider + _pearl_subprocess.py # PearlSubprocessLauncher — gateway + miner subprocesses + # Reused for future Apple-MPS / Metal providers +src/openjarvis/cli/ + # mine_cmd.py is unchanged; cpu-pearl participates via the provider ABC + +tests/mining/test_cpu_pearl.py +tools/pearl-reference-oracle/ + README.md # documentation: oracle exists upstream + smoke_test.py # end-to-end mine + verify smoke test (created in this session) +``` + +### 13.3 Optional extra + +```toml +mining-pearl-cpu = [ + "py-pearl-mining>=0.1", # the wheel built in §1.5.4 + "miner-base>=0.1", # PyTorch reference (used for parity testing) + "pearl-gateway>=0.1", # gateway service +] +``` + +When Pearl publishes these as PyPI wheels, the install is `uv sync --extra mining-pearl-cpu`. Until then, the spec for the implementation plan covers the local-build fallback (clone Pearl at the pinned ref, `maturin build` `py-pearl-mining`, `uv pip install` the workspace packages from local paths). + +### 13.4 Capability detection + +```python +# src/openjarvis/mining/cpu_pearl.py +class CpuPearlProvider(MiningProvider): + provider_id = "cpu-pearl" + + @classmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + # cpu-pearl is engine-independent — it doesn't plug into inference + if not _pearl_mining_available(): + return MiningCapabilities(False, reason="install with `uv sync --extra mining-pearl-cpu`") + if not _pearl_gateway_available(): + return MiningCapabilities(False, reason="pearl-gateway package not installed") + if hw.platform not in {"darwin", "linux"}: + return MiningCapabilities(False, reason=f"platform '{hw.platform}' not yet supported") + # Optional: hardware-specific hashrate estimates + return MiningCapabilities(True, estimated_hashrate=_estimate_cpu_hashrate(hw)) +``` + +The `engine_id` parameter is ignored because v1 is decoupled — mining works with **any** OJ engine, including no engine at all. (A future Apple-coupled provider would inspect `engine_id` to require `"llamacpp"` or `"mlx"`.) + +### 13.5 Lifecycle (from Spec A's `MiningProvider` ABC) + +- `start(config)`: spawn (1) `pearl-gateway` and (2) `pearl-mine-loop` subprocesses. Wait for gateway readiness on `:8339/metrics`. Write the standard sidecar JSON (Spec A §5.3) with `provider="cpu-pearl"`, gateway URL, and PIDs of both subprocesses. +- `stop()`: SIGTERM mining loop, then gateway. Bounded waits, SIGKILL fallback. +- `is_running()`: check sidecar + both PIDs. +- `stats()`: read from `pearl-gateway`'s `:8339/metrics` exactly as Spec A §8.1 specifies. **Same metrics adapter contract.** No code changes in OJ's gateway-metrics adapter. + +### 13.6 Configuration + +Inherits Spec A's `[mining]` config schema unchanged. v1 uses: + +```toml +[mining] +provider = "cpu-pearl" # NEW: was "vllm-pearl" in Spec A +wallet_address = "prl1q..." +submit_target = "solo" +fee_bps = 0 +fee_payout_address = "" + +[mining.extra] +gateway_port = 8337 +metrics_port = 8339 +pearld_rpc_url = "http://localhost:44107" +pearld_rpc_user = "rpcuser" +pearld_rpc_password_env = "PEARLD_RPC_PASSWORD" +# v1-specific: matmul shape for the search loop +m = 256 +n = 128 +k = 1024 +rank = 32 +``` + +The `m / n / k / rank` shape can be tuned per Phase 0-A measurement (or per chip). Larger shapes search more space per call but use more memory. + +### 13.7 Doctor surface (Apple Silicon) + +``` +$ jarvis mine doctor +Hardware + GPU vendor apple ✓ + Apple chip M2 Max ✓ + Unified memory 96 GB ✓ +Pearl install + py-pearl-mining 0.1.0 (cp312-abi3-macos-arm64) ✓ + miner-base 0.1.0 ✓ + pearl-gateway 0.1.0 ✓ +Pearl node + RPC http://localhost:44107 ✓ + Auth ok ✓ + Block height 442107 (synced) ✓ +Wallet + Address format prl1q... ✓ +Provider capability + cpu-pearl SUPPORTED (est. 0.X share/h on M2 Max) +Notes + - This is decoupled mining: your normal LLM inference is unaffected + - Hashrate is far below H100 mining; see docs/user-guide/mining-apple-silicon.md + - Metal-accelerated mining: planned for v2; not available yet +Session + Sidecar absent (not running) +``` + +Each row maps to a check function in `mining/_discovery.py`. The "est. share/h" line is populated from a one-time calibration during `mine init` — runs `pearl_mining.mine` in a 30-second loop and extrapolates. + +### 13.8 v1 anti-goals + +- **No coupling to inference.** The user's MLX-LM / Ollama / llama.cpp inference is untouched. v1 does not introduce a custom matmul. The "use AI = mine" narrative is **explicitly deferred to v2**. +- **No Metal kernel.** All math is in upstream Rust + PyTorch + Python. Zero MSL written. +- **No Pearl tree changes.** We consume their published packages; we contribute zero code into Pearl's repo for v1. (Phase 0-A discussion still happens — but it's lower-stakes since we're a downstream consumer in v1, not a contributor.) +- **No upstream PRs blocking v1 ship.** v1 ships against the Pearl ref pinned in `mining/_constants.py` (Spec A §6) regardless of whether any of our coordination questions are answered. + +### 13.9 v1 exit criteria + +- [ ] `mining-pearl-cpu` extra installs cleanly on macOS arm64 (M1, M2, M3, M4 — at minimum the chip the spec author owns) +- [ ] `jarvis mine init` completes successfully on macOS arm64 +- [ ] `jarvis mine start` launches gateway + miner subprocesses; sidecar valid; `mine status` reports live data +- [ ] `mine doctor` produces honest, actionable output for Mac users +- [ ] At least one block found on Pearl testnet from at least one Apple Silicon variant +- [ ] User-facing doc `docs/user-guide/mining-apple-silicon.md` ships, including the honest hashrate caveat + +### 13.10 Out of v1, into v2/v3 + +- **v2 (months):** Re-route `noisy_gemm` math to PyTorch-MPS for Apple Silicon GPU acceleration; integrate as a plugin into MLX-LM or `llama-cpp-python` so inference matmuls produce mining work (preserving the "use AI = mine" narrative). The original §5–§8 plan applies, swapped to use PyTorch MPS instead of raw MSL. +- **v3 (months — optional, only if v2 perf is insufficient):** Native Metal Shading Language NoisyGEMM kernel as an upstream Pearl contribution. The original §5–§8 plan applies as written. + +## 14. Phase 0 deliverables status (this session, 2026-05-05) + +Tracking what was actually produced, against the §5 Phase 0 plan and the §1.5 reframing. + +| Workstream | Original plan | Status | Deliverable | +|---|---|---|---| +| P0-A | Open Pearl GitHub Discussion, get protocol-acceptance confirmation | Draft written; user posts | `docs/design/2026-05-05-pearl-coordination-discussion-draft.md` | +| P0-B | Build reference oracle from scratch in PyTorch, validate against H100 CUDA | **Reference oracle exists upstream.** Built thin OJ-side wrapper + verified empirically that `pearl_mining.mine` runs on Apple Silicon (78 ms / proof at test difficulty) | `tools/pearl-reference-oracle/` | +| P0-C | Decide MLX vs llama.cpp Metal | **Deferred to v2.** v1 doesn't need either. | — | +| Spec update | Capture findings | Done | This document, §1.5, §10–§14 | +| v1 implementation plan | Plan written via `superpowers:writing-plans` after Phase 0 | Pending | `2026-05-05-apple-silicon-pearl-mining-plan-v1.md` (next deliverable) | diff --git a/docs/design/2026-05-05-apple-silicon-pearl-mining-plan-v1.md b/docs/design/2026-05-05-apple-silicon-pearl-mining-plan-v1.md new file mode 100644 index 00000000..bacbf61d --- /dev/null +++ b/docs/design/2026-05-05-apple-silicon-pearl-mining-plan-v1.md @@ -0,0 +1,1811 @@ +# Apple Silicon Pearl mining v1 — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the v1 `cpu-pearl` mining provider — decoupled CPU mining for Apple Silicon (and any non-CUDA host where Pearl's pure-Rust miner builds), wrapping upstream `pearl_mining.mine()` and Pearl's `pearl-gateway` as subprocess. + +**Architecture:** New provider `CpuPearlProvider` in `src/openjarvis/mining/cpu_pearl.py`, registered as `"cpu-pearl"` via `MinerRegistry`. The provider's `start()` launches two subprocesses: (1) Pearl's `pearl-gateway` Python service (talks to user's `pearld`), and (2) a small Python miner-loop that polls gateway for `getMiningInfo`, calls `pearl_mining.mine()`, and submits via `submitPlainProof`. Reuses Spec A's `MiningProvider` ABC, `MinerRegistry`, sidecar at `~/.openjarvis/runtime/mining.json`, telemetry adapter, and CLI surface (`jarvis mine init|start|stop|status|doctor`) — all unchanged. + +**Tech Stack:** Python 3.10+, `py-pearl-mining` (Rust+PyO3), `miner-base` (PyTorch), `pearl-gateway` (Python+JSON-RPC), `subprocess.Popen`, pytest with `unittest.mock`, ruff. References Pearl repo (`pearl-research-labs/pearl`) at a pinned commit/tag stored in `mining/_constants.py`. + +**Hard prerequisite:** Spec A's plan (`docs/design/2026-05-05-vllm-pearl-mining-integration-plan.md`) **must be executed first**. v1 reuses Spec A's `MiningProvider` ABC, registry, sidecar shape, telemetry adapter, mining-config dataclass, and CLI surface. If Spec A isn't merged, stop here and execute Spec A first; do not duplicate that infrastructure in this plan. + +--- + +## File structure (new files only — Spec A files unchanged) + +``` +src/openjarvis/mining/ + cpu_pearl.py # CpuPearlProvider — implements MiningProvider ABC for CPU + _pearl_subprocess.py # PearlSubprocessLauncher — gateway + miner-loop subprocess management + _install.py # Helpers: detect upstream packages, build-from-pin fallback + _miner_loop_main.py # Subprocess entry point: poll gateway, call pearl_mining.mine, submit + +tests/mining/ + test_cpu_pearl.py # CpuPearlProvider unit tests (capability detection, lifecycle) + test_pearl_subprocess.py # Subprocess launcher unit tests (mocked Popen) + test_install.py # Install detection / build helper tests + test_miner_loop.py # Miner-loop unit tests (mocked gateway socket) + fixtures/ + gateway_mining_info.json # Captured getMiningInfo RPC response + gateway_submit_ok.json # Captured submitPlainProof OK response + gateway_submit_rejected.json # Captured submitPlainProof rejection + +docs/user-guide/ + mining-apple-silicon.md # User-facing guide + +# Modified files +pyproject.toml # Add `mining-pearl-cpu` extra +src/openjarvis/mining/__init__.py # Soft-import cpu_pearl +src/openjarvis/mining/_constants.py # Add PEARL_PINNED_REF and helper constants +``` + +--- + +## Task 1: Bootstrap — pinned ref and constants + +**Files:** +- Modify: `src/openjarvis/mining/_constants.py:1-N` (created in Spec A) + +This task adds Pearl-version pinning and CPU-specific constants that the rest of the provider references. Spec A already created `_constants.py` with `PEARL_PINNED_REF` and `PEARL_REPO`; reuse those. We only add the new `cpu-pearl`-specific values. + +- [ ] **Step 1: Read Spec A's `_constants.py` to learn the existing shape** + +Run: `cat src/openjarvis/mining/_constants.py` + +Expected: file contains `PEARL_REPO`, `PEARL_PINNED_REF`, `PEARL_IMAGE_TAG`. If missing, **stop and execute Spec A first.** + +- [ ] **Step 2: Add CPU-specific constants** + +Append to `src/openjarvis/mining/_constants.py`: + +```python +# ── cpu-pearl provider (v1, Apple Silicon and other non-CUDA hosts) ──────────── + +# Default mining-loop matrix shapes. These are the same values used by Pearl's +# upstream test_python_api.py — known to produce a valid proof per call at test +# difficulty. Real difficulty is set per-block by the network and we can't +# control that; the only knob we expose is the matmul shape, which determines +# search space size per `mine()` call. +CPU_PEARL_DEFAULT_M = 256 +CPU_PEARL_DEFAULT_N = 128 +CPU_PEARL_DEFAULT_K = 1024 +CPU_PEARL_DEFAULT_RANK = 32 + +# Pattern lists copied verbatim from upstream Pearl tests. +CPU_PEARL_DEFAULT_ROWS_PATTERN = [0, 8, 64, 72] +CPU_PEARL_DEFAULT_COLS_PATTERN = [0, 1, 8, 9, 32, 33, 40, 41] + +# Local clone path used by _install.py build-from-pin fallback. Only created +# when Pearl wheels are not yet on PyPI. +CPU_PEARL_LOCAL_CLONE_DIR = "~/.openjarvis/cache/pearl" + +# Names of the Pearl Python packages we depend on, in install order. +# These are the packages we install from local paths (or PyPI when published). +PEARL_CPU_PACKAGES = ( + "py-pearl-mining", + "miner-utils", + "pearl-gateway", + "miner-base", +) +``` + +- [ ] **Step 3: Run lint** + +Run: `uv run ruff check src/openjarvis/mining/_constants.py` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/openjarvis/mining/_constants.py +git commit -m "feat(mining): add cpu-pearl constants (Spec B v1 task 1)" +``` + +--- + +## Task 2: Add `mining-pearl-cpu` optional extra + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Locate existing `mining-pearl` extra** + +Run: `grep -n "mining-pearl" pyproject.toml` +Expected: at least one match for `mining-pearl = [...]` from Spec A. + +- [ ] **Step 2: Add `mining-pearl-cpu` extra** + +Add under `[project.optional-dependencies]`: + +```toml +mining-pearl-cpu = [ + # Pearl's pure-Rust miner exposed to Python. Today: install from a local + # path or git URL. When Pearl publishes to PyPI, this becomes a normal + # version pin (see CPU_PEARL_LOCAL_CLONE_DIR in _constants.py). + "py-pearl-mining ; sys_platform == 'darwin' or sys_platform == 'linux'", + # PyTorch reference of NoisyGEMM — used for parity validation only, + # not required at runtime, but the install verifies torch builds OK. + "miner-base ; sys_platform == 'darwin' or sys_platform == 'linux'", + # JSON-RPC server that talks to pearld and brokers shares for the miner. + "pearl-gateway ; sys_platform == 'darwin' or sys_platform == 'linux'", +] +``` + +The `sys_platform` markers exclude Windows for now; v1 doesn't claim Windows support, and we don't want to accidentally hand a broken install to a Windows user. + +- [ ] **Step 3: Run lint and resolve check** + +Run: +```bash +uv run ruff check pyproject.toml || true +uv lock --check 2>&1 | tail -5 +``` + +Expected: ruff has nothing to say about pyproject.toml. `uv lock --check` may fail because we haven't installed the actual packages yet — that's expected. Note the failure mode for Task 4. + +- [ ] **Step 4: Commit** + +```bash +git add pyproject.toml +git commit -m "feat(mining): add mining-pearl-cpu optional extra (Spec B v1 task 2)" +``` + +--- + +## Task 3: Install detection helper (`_install.py`) + +**Files:** +- Create: `src/openjarvis/mining/_install.py` +- Test: `tests/mining/test_install.py` + +`_install.py` answers a single question: are the Pearl Python packages installed in the current environment? Used by `cpu_pearl.detect()` and `mine doctor`. Also exposes a hint string telling the user how to install if not. + +- [ ] **Step 1: Write the failing test** + +Create `tests/mining/test_install.py`: + +```python +"""Tests for openjarvis.mining._install.""" +from __future__ import annotations + +import sys +from unittest.mock import patch + +import pytest + + +def test_pearl_packages_available_returns_false_when_pearl_mining_missing(): + from openjarvis.mining import _install + + fake_modules = dict(sys.modules) + fake_modules.pop("pearl_mining", None) + fake_modules.pop("pearl_gateway", None) + with patch.dict(sys.modules, fake_modules, clear=True): + assert _install.pearl_packages_available() is False + + +def test_pearl_packages_available_returns_true_when_all_present(): + """When all three importable, returns True.""" + from openjarvis.mining import _install + + # Install three fake modules so importlib.util.find_spec returns truthy. + import types + fakes = { + name: types.ModuleType(name) + for name in ("pearl_mining", "pearl_gateway", "miner_base") + } + with patch.dict(sys.modules, fakes): + assert _install.pearl_packages_available() is True + + +def test_install_hint_is_actionable(): + """The hint string must include the extra name and the build-from-pin path.""" + from openjarvis.mining._install import install_hint + + h = install_hint() + assert "mining-pearl-cpu" in h + assert "uv sync" in h or "pip install" in h +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/mining/test_install.py -v` +Expected: FAIL — `ImportError: cannot import name '_install' from 'openjarvis.mining'` + +- [ ] **Step 3: Write `_install.py`** + +Create `src/openjarvis/mining/_install.py`: + +```python +"""Detection and install hints for the upstream Pearl Python packages. + +The cpu-pearl provider depends on three upstream packages: ``pearl_mining``, +``pearl_gateway``, and ``miner_base``. They are not on PyPI as of 2026-05; the +implementation plan covers a build-from-pin fallback. This module is the +single source of truth for "is the user's environment ready?". +""" +from __future__ import annotations + +import importlib.util + + +def _module_available(name: str) -> bool: + """True if ``import name`` would succeed in the current environment.""" + return importlib.util.find_spec(name) is not None + + +def pearl_packages_available() -> bool: + """All three Pearl Python packages importable. + + Returns False if any are missing. Use ``install_hint()`` to surface the + next step to the user. + """ + return all( + _module_available(m) + for m in ("pearl_mining", "pearl_gateway", "miner_base") + ) + + +def install_hint() -> str: + """Human-readable instruction for installing the Pearl packages. + + Today (no PyPI publication) we point at the optional extra. When Pearl + publishes wheels, the message stays correct because the extra still works. + """ + return ( + "install with `uv sync --extra mining-pearl-cpu`. " + "If Pearl wheels are not on PyPI yet, see " + "tools/pearl-reference-oracle/README.md for the build-from-pin path." + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/mining/test_install.py -v` +Expected: PASS — all three tests green. + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/mining/_install.py tests/mining/test_install.py +git commit -m "feat(mining): pearl-packages availability detection (Spec B v1 task 3)" +``` + +--- + +## Task 4: Build-from-pin fallback in `_install.py` + +**Files:** +- Modify: `src/openjarvis/mining/_install.py` +- Modify: `tests/mining/test_install.py` + +Until Pearl publishes to PyPI, users have to build `py-pearl-mining` from source. This task adds a helper that does so on first `mine init`. Mocked in tests; only really invoked in a real terminal. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/mining/test_install.py`: + +```python +import subprocess + + +def test_build_from_pin_clones_when_missing(tmp_path, monkeypatch): + """If the local cache dir is empty, build_from_pin clones first.""" + from openjarvis.mining import _install + + cache_dir = tmp_path / "pearl" + monkeypatch.setattr(_install, "_resolve_clone_dir", lambda: cache_dir) + calls = [] + monkeypatch.setattr( + subprocess, + "check_call", + lambda args, **kw: calls.append(list(args)), + ) + + _install.build_from_pin(pinned_ref="abc123") + + # First call must be `git clone`; second call(s) the maturin/uv install. + assert calls[0][:2] == ["git", "clone"] + assert "abc123" in " ".join(calls[1]) or "abc123" in " ".join(calls[0]) + + +def test_build_from_pin_skips_clone_when_present(tmp_path, monkeypatch): + """If the cache already has the .git dir, skip the clone.""" + from openjarvis.mining import _install + + cache_dir = tmp_path / "pearl" + (cache_dir / ".git").mkdir(parents=True) + monkeypatch.setattr(_install, "_resolve_clone_dir", lambda: cache_dir) + calls = [] + monkeypatch.setattr( + subprocess, + "check_call", + lambda args, **kw: calls.append(list(args)), + ) + + _install.build_from_pin(pinned_ref="abc123") + + # No git clone, but checkout + build. + assert not any(c[:2] == ["git", "clone"] for c in calls) + assert any(c[:2] == ["git", "checkout"] for c in calls) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/mining/test_install.py -v` +Expected: FAIL — `AttributeError: module 'openjarvis.mining._install' has no attribute 'build_from_pin'` + +- [ ] **Step 3: Implement `build_from_pin`** + +Append to `src/openjarvis/mining/_install.py`: + +```python +import os +import subprocess +from pathlib import Path + +from ._constants import ( + CPU_PEARL_LOCAL_CLONE_DIR, + PEARL_CPU_PACKAGES, + PEARL_PINNED_REF, + PEARL_REPO, +) + + +def _resolve_clone_dir() -> Path: + """Return the directory the Pearl clone lives in. Override in tests.""" + return Path(os.path.expanduser(CPU_PEARL_LOCAL_CLONE_DIR)) + + +def build_from_pin(pinned_ref: str = PEARL_PINNED_REF) -> Path: + """Clone Pearl at ``pinned_ref`` and install the Pearl Python packages. + + Idempotent: if the clone exists, fetch + checkout instead of re-cloning. + Returns the resolved clone directory. + """ + clone_dir = _resolve_clone_dir() + if not (clone_dir / ".git").is_dir(): + clone_dir.mkdir(parents=True, exist_ok=True) + subprocess.check_call(["git", "clone", PEARL_REPO, str(clone_dir)]) + else: + subprocess.check_call(["git", "fetch", "--all"], cwd=clone_dir) + subprocess.check_call(["git", "checkout", pinned_ref], cwd=clone_dir) + + # Build py-pearl-mining (Rust extension) via maturin + py_pearl_mining_dir = clone_dir / "py-pearl-mining" + subprocess.check_call( + ["maturin", "build", "--release", "--interpreter", "python"], + cwd=py_pearl_mining_dir, + ) + + # Find the wheel that maturin produced and install it, plus the pure-Python + # packages from their source directories. + wheels_dir = py_pearl_mining_dir / "target" / "wheels" + wheels = sorted(wheels_dir.glob("py_pearl_mining-*.whl")) + if not wheels: + raise RuntimeError(f"maturin produced no wheel in {wheels_dir}") + wheel_path = wheels[-1] + + # Install in dependency order. The `--no-deps` keeps uv from re-resolving + # workspace siblings; we install them one by one. + subprocess.check_call(["uv", "pip", "install", "--no-deps", str(wheel_path)]) + for pkg_name in ("miner-utils", "pearl-gateway", "miner-base"): + pkg_dir = clone_dir / "miner" / pkg_name + if pkg_dir.is_dir(): + subprocess.check_call( + ["uv", "pip", "install", "--no-deps", str(pkg_dir)] + ) + + return clone_dir +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/mining/test_install.py -v` +Expected: PASS — all five tests green. + +- [ ] **Step 5: Lint** + +Run: `uv run ruff check src/openjarvis/mining/_install.py tests/mining/test_install.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/mining/_install.py tests/mining/test_install.py +git commit -m "feat(mining): build-from-pin fallback for Pearl wheels (Spec B v1 task 4)" +``` + +--- + +## Task 5: `_miner_loop_main.py` — the CPU mining loop subprocess entry point + +**Files:** +- Create: `src/openjarvis/mining/_miner_loop_main.py` +- Test: `tests/mining/test_miner_loop.py` + +This is the *meaty* task. The miner-loop subprocess connects to `pearl-gateway` over JSON-RPC TCP, polls `getMiningInfo`, calls `pearl_mining.mine()`, and submits via `submitPlainProof`. Single file, no class hierarchy — it's just an event loop. + +- [ ] **Step 1: Write the failing test** + +Create `tests/mining/test_miner_loop.py`: + +```python +"""Tests for openjarvis.mining._miner_loop_main.""" +from __future__ import annotations + +import asyncio +import base64 +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture +def fake_mining_info_response(): + """Mock getMiningInfo response — base64-encoded incomplete header + target.""" + return { + "jsonrpc": "2.0", + "id": 1, + "result": { + "incomplete_header_bytes": base64.b64encode(b"\x00" * 76).decode(), + "target": 0x1D2FFFFF, + }, + } + + +def test_decode_mining_info_returns_header_and_target(fake_mining_info_response): + from openjarvis.mining._miner_loop_main import _decode_mining_info + + header_bytes, target = _decode_mining_info(fake_mining_info_response["result"]) + assert isinstance(header_bytes, (bytes, bytearray)) + assert len(header_bytes) == 76 + assert target == 0x1D2FFFFF + + +def test_encode_plain_proof_round_trips(): + """We can encode a PlainProof to base64 and the bytes are non-empty.""" + from openjarvis.mining._miner_loop_main import _encode_plain_proof + + fake_proof = MagicMock() + fake_proof.serialize.return_value = b"PROOF_BYTES_DUMMY" + encoded = _encode_plain_proof(fake_proof) + assert encoded == base64.b64encode(b"PROOF_BYTES_DUMMY").decode() + + +def test_jsonrpc_envelope_shape(): + """The JSON-RPC envelope conforms to gateway's JSON_RPC_SCHEMA.""" + from openjarvis.mining._miner_loop_main import _make_request + + req = _make_request("getMiningInfo", {}, request_id=42) + assert req["jsonrpc"] == "2.0" + assert req["method"] == "getMiningInfo" + assert req["id"] == 42 + assert req["params"] == {} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/mining/test_miner_loop.py -v` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement the helpers** + +Create `src/openjarvis/mining/_miner_loop_main.py`: + +```python +"""CPU mining-loop subprocess entry point. + +Run with: + python -m openjarvis.mining._miner_loop_main \ + --gateway-host 127.0.0.1 --gateway-port 8337 \ + --m 256 --n 128 --k 1024 --rank 32 + +Connects to pearl-gateway, polls for work, runs pearl_mining.mine(), +submits proofs back. Designed to be killed via SIGTERM by the parent +provider; no graceful shutdown handshake — Pearl's gateway tolerates +client disconnects cleanly. + +This module is the subprocess; the parent OJ process never imports +it directly (it spawns it via ``python -m``). That keeps the parent's +import graph free of pearl_mining (which is an optional dependency). +""" +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import logging +import sys +from typing import Any + +logger = logging.getLogger("openjarvis.mining.miner_loop") + + +def _make_request(method: str, params: dict[str, Any], request_id: int) -> dict[str, Any]: + """Build a JSON-RPC 2.0 request envelope matching gateway's schema.""" + return {"jsonrpc": "2.0", "method": method, "params": params, "id": request_id} + + +def _decode_mining_info(result: dict[str, Any]) -> tuple[bytes, int]: + """Decode getMiningInfo result into (incomplete_header_bytes, target).""" + header_b64 = result["incomplete_header_bytes"] + target = int(result["target"]) + return base64.b64decode(header_b64), target + + +def _encode_plain_proof(plain_proof: Any) -> str: + """Serialize a PlainProof to base64 for submitPlainProof.""" + return base64.b64encode(plain_proof.serialize()).decode() + + +async def _read_response(reader: asyncio.StreamReader) -> dict[str, Any]: + """Read one line of JSON-RPC response from the gateway socket.""" + line = await reader.readline() + if not line: + raise ConnectionError("gateway closed the connection") + return json.loads(line) + + +async def _send_request(writer: asyncio.StreamWriter, request: dict[str, Any]) -> None: + """Write one JSON-RPC request followed by newline.""" + writer.write(json.dumps(request).encode() + b"\n") + await writer.drain() + + +async def _mine_one_round( + pearl_mining_module: Any, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + *, + request_id: int, + m: int, + n: int, + k: int, + rank: int, +) -> bool: + """Get work, mine, submit. Return True on accepted proof, False otherwise.""" + # 1. Ask the gateway for work + await _send_request(writer, _make_request("getMiningInfo", {}, request_id)) + info_response = await _read_response(reader) + if "error" in info_response: + logger.warning("getMiningInfo error: %s", info_response["error"]) + return False + header_bytes, target = _decode_mining_info(info_response["result"]) + + # 2. Convert header_bytes back into IncompleteBlockHeader and run mine(). + # Pearl's IncompleteBlockHeader exposes from_bytes() in py-pearl-mining; + # if the API name differs, fix this on first integration test. + header = pearl_mining_module.IncompleteBlockHeader.from_bytes(header_bytes) + mining_config = _build_mining_config(pearl_mining_module, k=k, rank=rank) + + plain_proof = pearl_mining_module.mine( + m, n, k, header, mining_config, signal_range=None, wrong_jackpot_hash=False + ) + + # 3. Submit the proof + submit_params = { + "plain_proof": _encode_plain_proof(plain_proof), + "mining_job": { + "incomplete_header_bytes": base64.b64encode(header_bytes).decode(), + "target": target, + }, + } + await _send_request(writer, _make_request("submitPlainProof", submit_params, request_id + 1)) + submit_response = await _read_response(reader) + if "error" in submit_response: + logger.warning("submitPlainProof rejected: %s", submit_response["error"]) + return False + return True + + +def _build_mining_config(pearl_mining_module: Any, *, k: int, rank: int): + """Build the upstream MiningConfiguration with default patterns.""" + from ._constants import ( + CPU_PEARL_DEFAULT_COLS_PATTERN, + CPU_PEARL_DEFAULT_ROWS_PATTERN, + ) + + return pearl_mining_module.MiningConfiguration( + common_dim=k, + rank=rank, + mma_type=pearl_mining_module.MMAType.Int7xInt7ToInt32, + rows_pattern=pearl_mining_module.PeriodicPattern.from_list( + CPU_PEARL_DEFAULT_ROWS_PATTERN + ), + cols_pattern=pearl_mining_module.PeriodicPattern.from_list( + CPU_PEARL_DEFAULT_COLS_PATTERN + ), + reserved=pearl_mining_module.MiningConfiguration.RESERVED, + ) + + +async def _main_loop(args: argparse.Namespace) -> None: + import pearl_mining + + reader, writer = await asyncio.open_connection(args.gateway_host, args.gateway_port) + request_id = 0 + try: + while True: + request_id += 2 + try: + accepted = await _mine_one_round( + pearl_mining, + reader, + writer, + request_id=request_id, + m=args.m, + n=args.n, + k=args.k, + rank=args.rank, + ) + except Exception: # noqa: BLE001 — log and try again + logger.exception("mining round failed; retrying after backoff") + await asyncio.sleep(1.0) + continue + if accepted: + logger.info("share accepted") + finally: + writer.close() + await writer.wait_closed() + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser() + p.add_argument("--gateway-host", default="127.0.0.1") + p.add_argument("--gateway-port", type=int, default=8337) + p.add_argument("--m", type=int, default=256) + p.add_argument("--n", type=int, default=128) + p.add_argument("--k", type=int, default=1024) + p.add_argument("--rank", type=int, default=32) + return p.parse_args(argv) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + args = parse_args() + try: + asyncio.run(_main_loop(args)) + except KeyboardInterrupt: + sys.exit(0) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/mining/test_miner_loop.py -v` +Expected: PASS — three tests green. + +- [ ] **Step 5: Lint** + +Run: `uv run ruff check src/openjarvis/mining/_miner_loop_main.py tests/mining/test_miner_loop.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/mining/_miner_loop_main.py tests/mining/test_miner_loop.py +git commit -m "feat(mining): cpu miner-loop subprocess entry point (Spec B v1 task 5)" +``` + +--- + +## Task 6: Subprocess launcher (`_pearl_subprocess.py`) + +**Files:** +- Create: `src/openjarvis/mining/_pearl_subprocess.py` +- Test: `tests/mining/test_pearl_subprocess.py` + +The launcher manages the *two* subprocesses — `pearl-gateway` and the miner-loop — as a unit. Lifecycle: `start()`, `stop()`, `is_running()`. No PID-file shenanigans here; we hold `Popen` objects in memory while the OJ process is alive. + +- [ ] **Step 1: Write the failing test** + +Create `tests/mining/test_pearl_subprocess.py`: + +```python +"""Tests for openjarvis.mining._pearl_subprocess.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def fake_popen(): + p = MagicMock() + p.poll.return_value = None # still running + p.pid = 12345 + return p + + +def test_launcher_start_spawns_two_processes(fake_popen, tmp_path): + from openjarvis.mining._pearl_subprocess import PearlSubprocessLauncher + + with patch("subprocess.Popen", return_value=fake_popen) as mock_popen: + launcher = PearlSubprocessLauncher( + gateway_host="127.0.0.1", + gateway_port=8337, + metrics_port=8339, + pearld_rpc_url="http://localhost:44107", + pearld_rpc_user="rpcuser", + pearld_rpc_password="testpw", + wallet_address="prl1qtest", + log_dir=tmp_path, + ) + launcher.start(m=256, n=128, k=1024, rank=32) + assert mock_popen.call_count == 2 # gateway + miner-loop + + +def test_launcher_stop_terminates_both(fake_popen, tmp_path): + from openjarvis.mining._pearl_subprocess import PearlSubprocessLauncher + + with patch("subprocess.Popen", return_value=fake_popen): + launcher = PearlSubprocessLauncher( + gateway_host="127.0.0.1", + gateway_port=8337, + metrics_port=8339, + pearld_rpc_url="http://localhost:44107", + pearld_rpc_user="rpcuser", + pearld_rpc_password="testpw", + wallet_address="prl1qtest", + log_dir=tmp_path, + ) + launcher.start(m=256, n=128, k=1024, rank=32) + launcher.stop() + assert fake_popen.terminate.call_count >= 2 + + +def test_launcher_is_running_false_when_either_exited(fake_popen, tmp_path): + from openjarvis.mining._pearl_subprocess import PearlSubprocessLauncher + + fake_dead = MagicMock() + fake_dead.poll.return_value = 1 # exited + fake_dead.pid = 12346 + + with patch("subprocess.Popen", side_effect=[fake_popen, fake_dead]): + launcher = PearlSubprocessLauncher( + gateway_host="127.0.0.1", + gateway_port=8337, + metrics_port=8339, + pearld_rpc_url="http://localhost:44107", + pearld_rpc_user="rpcuser", + pearld_rpc_password="testpw", + wallet_address="prl1qtest", + log_dir=tmp_path, + ) + launcher.start(m=256, n=128, k=1024, rank=32) + assert launcher.is_running() is False +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/mining/test_pearl_subprocess.py -v` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement `PearlSubprocessLauncher`** + +Create `src/openjarvis/mining/_pearl_subprocess.py`: + +```python +"""Subprocess launcher for the cpu-pearl provider. + +Manages two subprocesses: +- ``pearl-gateway`` (Pearl's Python service), which talks to pearld and + brokers shares from the miner. +- ``openjarvis.mining._miner_loop_main`` (this repo), which polls the gateway + and runs ``pearl_mining.mine()``. + +Lifecycle is in-memory: while this object lives, both subprocesses live. +The provider holds it; sidecar JSON records PIDs for crash recovery. +""" +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +_GATEWAY_TERMINATE_GRACE_SECONDS = 5.0 +_MINER_LOOP_TERMINATE_GRACE_SECONDS = 2.0 + + +@dataclass(slots=True) +class _ProcessHandles: + gateway: subprocess.Popen + miner_loop: subprocess.Popen + + +class PearlSubprocessLauncher: + def __init__( + self, + *, + gateway_host: str, + gateway_port: int, + metrics_port: int, + pearld_rpc_url: str, + pearld_rpc_user: str, + pearld_rpc_password: str, + wallet_address: str, + log_dir: Path, + ) -> None: + self.gateway_host = gateway_host + self.gateway_port = gateway_port + self.metrics_port = metrics_port + self.pearld_rpc_url = pearld_rpc_url + self.pearld_rpc_user = pearld_rpc_user + self.pearld_rpc_password = pearld_rpc_password + self.wallet_address = wallet_address + self.log_dir = Path(log_dir) + self.log_dir.mkdir(parents=True, exist_ok=True) + self._handles: _ProcessHandles | None = None + + def start(self, *, m: int, n: int, k: int, rank: int) -> None: + env = self._build_gateway_env() + + # Spawn gateway. ``pearl-gateway`` is the console-script entry point + # exposed by the pearl_gateway package's pyproject.toml. + gateway_log = (self.log_dir / "pearl-gateway.log").open("a", buffering=1) + gateway = subprocess.Popen( + ["pearl-gateway"], + env=env, + stdout=gateway_log, + stderr=subprocess.STDOUT, + ) + + # Spawn miner-loop pointed at the gateway. + miner_log = (self.log_dir / "cpu-pearl-miner.log").open("a", buffering=1) + miner_loop = subprocess.Popen( + [ + sys.executable, + "-m", + "openjarvis.mining._miner_loop_main", + "--gateway-host", + self.gateway_host, + "--gateway-port", + str(self.gateway_port), + "--m", + str(m), + "--n", + str(n), + "--k", + str(k), + "--rank", + str(rank), + ], + stdout=miner_log, + stderr=subprocess.STDOUT, + ) + + self._handles = _ProcessHandles(gateway=gateway, miner_loop=miner_loop) + + def stop(self) -> None: + if self._handles is None: + return + # Stop miner-loop first, then gateway. + for proc, grace in ( + (self._handles.miner_loop, _MINER_LOOP_TERMINATE_GRACE_SECONDS), + (self._handles.gateway, _GATEWAY_TERMINATE_GRACE_SECONDS), + ): + if proc.poll() is None: + proc.terminate() + deadline = time.monotonic() + grace + while time.monotonic() < deadline and proc.poll() is None: + time.sleep(0.05) + if proc.poll() is None: + proc.kill() + self._handles = None + + def is_running(self) -> bool: + if self._handles is None: + return False + return ( + self._handles.gateway.poll() is None + and self._handles.miner_loop.poll() is None + ) + + def pids(self) -> tuple[int, int] | None: + if self._handles is None: + return None + return (self._handles.gateway.pid, self._handles.miner_loop.pid) + + def _build_gateway_env(self) -> dict[str, str]: + env = dict(os.environ) + env.update( + { + "PEARL_GATEWAY_HOST": self.gateway_host, + "PEARL_GATEWAY_PORT": str(self.gateway_port), + "PEARL_GATEWAY_METRICS_PORT": str(self.metrics_port), + "PEARLD_RPC_URL": self.pearld_rpc_url, + "PEARLD_RPC_USER": self.pearld_rpc_user, + "PEARLD_RPC_PASSWORD": self.pearld_rpc_password, + "PEARLD_MINING_ADDRESS": self.wallet_address, + # tell pearl-gateway to use TCP for miner RPC, matching what the + # miner-loop subprocess connects to. + "MINER_RPC_TRANSPORT": "tcp", + } + ) + return env +``` + +> **Note on env var names:** The exact names pearl-gateway reads (`PEARL_GATEWAY_HOST`, `PEARL_GATEWAY_PORT`, etc.) come from `pearl/miner/pearl-gateway/src/pearl_gateway/config.py`. Verify on first integration: open that file, copy the actual `Field(env=...)` names into the `_build_gateway_env` dict above. If the names differ, this is a one-line fix per env var; the surrounding lifecycle is unaffected. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/mining/test_pearl_subprocess.py -v` +Expected: PASS — three tests green. + +- [ ] **Step 5: Lint** + +Run: `uv run ruff check src/openjarvis/mining/_pearl_subprocess.py tests/mining/test_pearl_subprocess.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/mining/_pearl_subprocess.py tests/mining/test_pearl_subprocess.py +git commit -m "feat(mining): pearl subprocess launcher (Spec B v1 task 6)" +``` + +--- + +## Task 7: Verify env var names against pearl-gateway config + +**Files:** +- Modify: `src/openjarvis/mining/_pearl_subprocess.py` + +This is a one-shot research-and-fix task to make Task 6's env names match Pearl's actual config. + +- [ ] **Step 1: Locate pearl-gateway config** + +Run: `find ~/.openjarvis/cache/pearl/miner/pearl-gateway -name config.py -path '*pearl_gateway*' 2>/dev/null || find / -name config.py -path '*pearl_gateway*' 2>/dev/null | head -3` + +Expected: `pearl-gateway/src/pearl_gateway/config.py` (path depends on where Pearl was cloned). If the file isn't on disk yet, run `python -m openjarvis.mining._install` (added in Task 4) or clone Pearl manually first. + +- [ ] **Step 2: Read the config file** + +Look for `Field(env=...)` annotations on `MinerSettings` / `PearlGatewayConfig`. List every env var name it accepts. + +Run: `grep -nE 'env\s*=' | head -20` + +Expected output: lines like `Field(default="...", env="PEARL_GATEWAY_HOST")` showing the canonical names. + +- [ ] **Step 3: Replace the env-var names in `_build_gateway_env`** + +Open `src/openjarvis/mining/_pearl_subprocess.py` and replace each guessed env name with the actual one. Keep the dict structure; only the keys change. + +If a name we expected is missing (e.g., pearl-gateway doesn't have a separate `*_METRICS_PORT`), drop the line rather than carry a no-op env var. + +- [ ] **Step 4: Re-run tests** + +Run: `uv run pytest tests/mining/test_pearl_subprocess.py -v` +Expected: PASS — same three tests, no regressions (the test mocks Popen, so env-var-name changes don't affect them). + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/mining/_pearl_subprocess.py +git commit -m "fix(mining): align env var names with pearl-gateway config (Spec B v1 task 7)" +``` + +--- + +## Task 8: `CpuPearlProvider` — capability detection + +**Files:** +- Create: `src/openjarvis/mining/cpu_pearl.py` +- Test: `tests/mining/test_cpu_pearl.py` + +Implements the `MiningProvider.detect()` classmethod from Spec A's ABC. This is the first thing `mine doctor` and `mine init` ask. Engine-independent: returns supported on any darwin/linux host with Pearl packages installed. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/mining/test_cpu_pearl.py`: + +```python +"""Tests for openjarvis.mining.cpu_pearl.CpuPearlProvider.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def darwin_apple_hw(): + """A HardwareInfo describing an Apple Silicon Mac.""" + from openjarvis.mining._stubs import HardwareInfo, GpuInfo + + return HardwareInfo( + platform="darwin", + cpu_arch="arm64", + gpu=GpuInfo(vendor="apple", model="M2 Max", vram_gb=96.0), + ) + + +@pytest.fixture +def linux_nvidia_hw(): + """A HardwareInfo describing an H100 box.""" + from openjarvis.mining._stubs import HardwareInfo, GpuInfo + + return HardwareInfo( + platform="linux", + cpu_arch="x86_64", + gpu=GpuInfo(vendor="nvidia", model="H100", vram_gb=80.0, compute_cap="9.0a"), + ) + + +@pytest.fixture +def windows_hw(): + """A HardwareInfo describing a Windows host (unsupported in v1).""" + from openjarvis.mining._stubs import HardwareInfo + + return HardwareInfo(platform="win32", cpu_arch="x86_64", gpu=None) + + +def test_detect_supported_on_apple_silicon(darwin_apple_hw): + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + with patch( + "openjarvis.mining._install.pearl_packages_available", return_value=True + ): + cap = CpuPearlProvider.detect(darwin_apple_hw, engine_id="ollama", model="any") + assert cap.supported is True + assert cap.reason is None + + +def test_detect_supported_on_linux_too(linux_nvidia_hw): + """v1 cpu-pearl is engine-independent and platform-loose.""" + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + with patch( + "openjarvis.mining._install.pearl_packages_available", return_value=True + ): + cap = CpuPearlProvider.detect( + linux_nvidia_hw, engine_id="anything", model="any" + ) + assert cap.supported is True + + +def test_detect_unsupported_on_windows(windows_hw): + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + with patch( + "openjarvis.mining._install.pearl_packages_available", return_value=True + ): + cap = CpuPearlProvider.detect(windows_hw, engine_id="any", model="any") + assert cap.supported is False + assert "win32" in cap.reason.lower() or "windows" in cap.reason.lower() + + +def test_detect_unsupported_when_pearl_not_installed(darwin_apple_hw): + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + with patch( + "openjarvis.mining._install.pearl_packages_available", return_value=False + ): + cap = CpuPearlProvider.detect(darwin_apple_hw, engine_id="any", model="any") + assert cap.supported is False + assert "mining-pearl-cpu" in cap.reason +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/mining/test_cpu_pearl.py -v` +Expected: FAIL — `CpuPearlProvider` not found. + +- [ ] **Step 3: Implement the provider's `detect`** + +Create `src/openjarvis/mining/cpu_pearl.py`: + +```python +"""CPU-based Pearl mining provider (decoupled from inference). + +Spec B v1: wraps Pearl's pure-Rust ``mine()`` function via py-pearl-mining +and runs Pearl's pearl-gateway as a sibling subprocess. Works on any host +where py-pearl-mining builds — verified on macOS arm64 (M2 Max) in Spec B. + +Engine-independent: this provider does not plug into the user's inference +stack. The user keeps using whatever engine they want; mining runs alongside. +""" +from __future__ import annotations + +from . import _install +from ._stubs import HardwareInfo, MiningCapabilities, MiningConfig, MiningProvider, MiningStats + + +class CpuPearlProvider(MiningProvider): + provider_id = "cpu-pearl" + + @classmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + # v1 platform gate: only darwin and linux. Windows requires more + # investigation (Pearl's miner Taskfile excludes Windows from the + # cpu-mining install path even though the algorithm itself is portable). + if hw.platform not in {"darwin", "linux"}: + return MiningCapabilities( + supported=False, + reason=f"v1 cpu-pearl supports darwin/linux only; this host is '{hw.platform}'", + ) + if not _install.pearl_packages_available(): + return MiningCapabilities( + supported=False, + reason=f"Pearl Python packages not installed — {_install.install_hint()}", + ) + # No engine_id check: cpu-pearl is decoupled from inference. + # Hashrate estimate is deferred to a one-shot calibration during + # `mine init` (Task 9 lifecycle integration). + return MiningCapabilities(supported=True) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/mining/test_cpu_pearl.py -v` +Expected: PASS — four tests green. + +- [ ] **Step 5: Lint** + +Run: `uv run ruff check src/openjarvis/mining/cpu_pearl.py tests/mining/test_cpu_pearl.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/mining/cpu_pearl.py tests/mining/test_cpu_pearl.py +git commit -m "feat(mining): cpu-pearl provider capability detection (Spec B v1 task 8)" +``` + +--- + +## Task 9: `CpuPearlProvider` — start/stop/is_running/stats + +**Files:** +- Modify: `src/openjarvis/mining/cpu_pearl.py` +- Modify: `tests/mining/test_cpu_pearl.py` + +Wire the provider lifecycle methods to the subprocess launcher and Spec A's sidecar / telemetry adapter. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/mining/test_cpu_pearl.py`: + +```python +def test_start_writes_sidecar_and_returns_running(darwin_apple_hw, tmp_path, monkeypatch): + from openjarvis.mining.cpu_pearl import CpuPearlProvider + from openjarvis.mining._stubs import MiningConfig + + monkeypatch.setattr( + "openjarvis.mining.cpu_pearl._sidecar_path", + lambda: tmp_path / "mining.json", + ) + + fake_launcher = type( + "FakeLauncher", + (), + { + "start": lambda self, **kw: None, + "stop": lambda self: None, + "is_running": lambda self: True, + "pids": lambda self: (11111, 22222), + }, + )() + + with patch( + "openjarvis.mining.cpu_pearl.PearlSubprocessLauncher", + return_value=fake_launcher, + ): + provider = CpuPearlProvider() + cfg = MiningConfig( + provider="cpu-pearl", + wallet_address="prl1qtest", + extra={ + "gateway_port": 8337, + "metrics_port": 8339, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "TESTPW", + "m": 256, "n": 128, "k": 1024, "rank": 32, + }, + ) + monkeypatch.setenv("TESTPW", "secret") + provider.start(cfg) + assert provider.is_running() is True + sidecar_text = (tmp_path / "mining.json").read_text() + assert '"provider": "cpu-pearl"' in sidecar_text + assert "11111" in sidecar_text + assert "22222" in sidecar_text + # secret must NOT appear in sidecar + assert "secret" not in sidecar_text +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/mining/test_cpu_pearl.py::test_start_writes_sidecar_and_returns_running -v` +Expected: FAIL — `start`, `is_running` not implemented. + +- [ ] **Step 3: Implement lifecycle methods** + +Replace `src/openjarvis/mining/cpu_pearl.py` with the full implementation: + +```python +"""CPU-based Pearl mining provider (decoupled from inference). + +Spec B v1: wraps Pearl's pure-Rust ``mine()`` function via py-pearl-mining +and runs Pearl's pearl-gateway as a sibling subprocess. +""" +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +import urllib.request + +from . import _install +from ._constants import ( + CPU_PEARL_DEFAULT_K, + CPU_PEARL_DEFAULT_M, + CPU_PEARL_DEFAULT_N, + CPU_PEARL_DEFAULT_RANK, +) +from ._pearl_subprocess import PearlSubprocessLauncher +from ._stubs import HardwareInfo, MiningCapabilities, MiningConfig, MiningProvider, MiningStats + + +def _sidecar_path() -> Path: + return Path(os.path.expanduser("~/.openjarvis/runtime/mining.json")) + + +def _log_dir() -> Path: + return Path(os.path.expanduser("~/.openjarvis/logs/mining")) + + +class CpuPearlProvider(MiningProvider): + provider_id = "cpu-pearl" + + def __init__(self) -> None: + self._launcher: PearlSubprocessLauncher | None = None + self._config: MiningConfig | None = None + self._started_at: float | None = None + + @classmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + if hw.platform not in {"darwin", "linux"}: + return MiningCapabilities( + supported=False, + reason=f"v1 cpu-pearl supports darwin/linux only; this host is '{hw.platform}'", + ) + if not _install.pearl_packages_available(): + return MiningCapabilities( + supported=False, + reason=f"Pearl Python packages not installed — {_install.install_hint()}", + ) + return MiningCapabilities(supported=True) + + def start(self, config: MiningConfig) -> None: + extra = dict(config.extra or {}) + password_env = extra.get("pearld_rpc_password_env", "PEARLD_RPC_PASSWORD") + password = os.environ.get(password_env, "") + + self._launcher = PearlSubprocessLauncher( + gateway_host=extra.get("gateway_host", "127.0.0.1"), + gateway_port=int(extra.get("gateway_port", 8337)), + metrics_port=int(extra.get("metrics_port", 8339)), + pearld_rpc_url=extra.get("pearld_rpc_url", "http://localhost:44107"), + pearld_rpc_user=extra.get("pearld_rpc_user", "rpcuser"), + pearld_rpc_password=password, + wallet_address=config.wallet_address, + log_dir=_log_dir(), + ) + self._launcher.start( + m=int(extra.get("m", CPU_PEARL_DEFAULT_M)), + n=int(extra.get("n", CPU_PEARL_DEFAULT_N)), + k=int(extra.get("k", CPU_PEARL_DEFAULT_K)), + rank=int(extra.get("rank", CPU_PEARL_DEFAULT_RANK)), + ) + self._config = config + self._started_at = time.time() + self._write_sidecar() + + def stop(self) -> None: + if self._launcher is not None: + self._launcher.stop() + self._launcher = None + self._config = None + self._started_at = None + sp = _sidecar_path() + if sp.exists(): + sp.unlink() + + def is_running(self) -> bool: + return self._launcher is not None and self._launcher.is_running() + + def stats(self) -> MiningStats: + if not self.is_running() or self._launcher is None: + return MiningStats(provider_id=self.provider_id) + + # Spec A's gateway-metrics adapter handles the parsing. Read once, + # parse, return. Reuse the same adapter the vllm-pearl provider uses; + # the metric names are identical. + from ._gateway_metrics import parse_gateway_metrics + + try: + extra = (self._config.extra or {}) if self._config else {} + metrics_port = int(extra.get("metrics_port", 8339)) + host = extra.get("gateway_host", "127.0.0.1") + with urllib.request.urlopen( + f"http://{host}:{metrics_port}/metrics", timeout=2.0 + ) as resp: + text = resp.read().decode() + except Exception as e: # noqa: BLE001 + return MiningStats( + provider_id=self.provider_id, + last_error=f"gateway metrics unreachable: {e}", + ) + return parse_gateway_metrics(text, provider_id=self.provider_id) + + def _write_sidecar(self) -> None: + if self._launcher is None or self._config is None: + return + pids = self._launcher.pids() or (None, None) + extra = self._config.extra or {} + sidecar = { + "provider": self.provider_id, + "started_at": self._started_at, + "wallet_address": self._config.wallet_address, + "gateway_url": ( + f"http://{extra.get('gateway_host', '127.0.0.1')}:" + f"{extra.get('gateway_port', 8337)}" + ), + "metrics_url": ( + f"http://{extra.get('gateway_host', '127.0.0.1')}:" + f"{extra.get('metrics_port', 8339)}/metrics" + ), + "gateway_pid": pids[0], + "miner_loop_pid": pids[1], + } + sp = _sidecar_path() + sp.parent.mkdir(parents=True, exist_ok=True) + sp.write_text(json.dumps(sidecar, indent=2)) +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/mining/test_cpu_pearl.py -v` +Expected: PASS — five tests green. + +- [ ] **Step 5: Lint** + +Run: `uv run ruff check src/openjarvis/mining/cpu_pearl.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/mining/cpu_pearl.py tests/mining/test_cpu_pearl.py +git commit -m "feat(mining): cpu-pearl provider lifecycle (Spec B v1 task 9)" +``` + +--- + +## Task 10: Register `CpuPearlProvider` in `MinerRegistry` + +**Files:** +- Modify: `src/openjarvis/mining/__init__.py` + +`MinerRegistry` exists from Spec A; we just need to call `register("cpu-pearl")` at module-load time and survive the autouse `clear_registries` fixture in `tests/conftest.py` via the `ensure_registered()` pattern. + +- [ ] **Step 1: Read Spec A's existing `__init__.py`** + +Run: `cat src/openjarvis/mining/__init__.py` + +Expected: file already imports `vllm_pearl` and exposes `ensure_registered`. We mirror the same pattern for cpu_pearl. + +- [ ] **Step 2: Add cpu_pearl registration** + +Append to `src/openjarvis/mining/__init__.py` (or modify if Spec A already defined `ensure_registered`): + +```python +def _register_cpu_pearl() -> None: + """Register CpuPearlProvider in MinerRegistry. Idempotent.""" + from openjarvis.core.registry import MinerRegistry # type: ignore + + if MinerRegistry.contains("cpu-pearl"): + return + try: + from .cpu_pearl import CpuPearlProvider + except ImportError: + # py-pearl-mining etc. not installed — that's fine, the provider + # only registers when the optional extra is in. detect() will + # surface a clear "install with --extra mining-pearl-cpu" message. + return + MinerRegistry.register("cpu-pearl")(CpuPearlProvider) + + +# Call at import time so the registry is populated as soon as openjarvis.mining +# is imported. The conftest autouse fixture clears registries between tests; we +# rely on _register_cpu_pearl being called again from `ensure_registered`. +_register_cpu_pearl() + + +def ensure_registered() -> None: + """Re-register all mining providers — used by tests that clear the registry.""" + _register_vllm_pearl() # added by Spec A + _register_cpu_pearl() +``` + +- [ ] **Step 3: Run all mining tests** + +Run: `uv run pytest tests/mining/ -v` +Expected: PASS — every test from this plan plus Spec A's tests still green. + +- [ ] **Step 4: Run a broader test sweep to catch regressions** + +Run: `uv run pytest tests/ -v --co -q | tail -30; uv run pytest tests/ -x -q 2>&1 | tail -30` +Expected: same number of passing tests as before this task. + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/mining/__init__.py +git commit -m "feat(mining): register cpu-pearl provider (Spec B v1 task 10)" +``` + +--- + +## Task 11: User-facing documentation + +**Files:** +- Create: `docs/user-guide/mining-apple-silicon.md` + +The honest user guide. Set expectations correctly; don't oversell hashrate. + +- [ ] **Step 1: Write the doc** + +Create `docs/user-guide/mining-apple-silicon.md`: + +````markdown +# Mining Pearl on Apple Silicon (and other CPU hosts) + +OpenJarvis can mine the [Pearl](https://github.com/pearl-research-labs/pearl) chain +on Apple Silicon Macs (M1/M2/M3/M4) using the `cpu-pearl` provider. **This is +v1**: decoupled CPU mining. Your existing local LLM workflow (Ollama, MLX-LM, +llama.cpp, vLLM) is untouched; mining runs in the background as a separate +process. + +## Honest expectations + +**Hashrate on Apple Silicon CPU is far below what an H100 produces with +Pearl's `vllm-miner`.** A rough rule of thumb (subject to network difficulty): + +- M2 Max / M4 Max: ≪ 1 share per second at typical mainnet difficulty +- H100 with `vllm-miner`: meaningfully higher, plus the mining work is + amortized over real LLM inference + +If you want to mine for yield, this isn't the path. If you want to participate +in the network from the hardware you own, with no special hardware purchase, +this is the path. + +A future v2 will add Apple-GPU acceleration via PyTorch MPS or a custom MLX +plugin. v3 may add a native Metal kernel. **Neither is shipped today.** + +## Prerequisites + +- macOS arm64 (M1, M2, M3, M4) — or Linux x86_64 / aarch64 +- Python 3.12 (`brew install python@3.12` or use `uv venv --python 3.12`) +- Rust toolchain (`brew install rust` or `curl https://sh.rustup.rs -sSf | sh`) +- Your own running [`pearld`](https://github.com/pearl-research-labs/pearl#node) + node, RPC reachable on `http://localhost:44107` +- A Pearl Taproot wallet address from `oyster` (Pearl's wallet CLI) +- ~1 GB free disk for the Pearl source clone and build artifacts + +## Install + +```bash +# from your OpenJarvis repo +uv sync --extra mining-pearl-cpu +``` + +If Pearl wheels are not yet on PyPI (still true as of 2026-05-05), `uv sync` +will fail because none of the packages exist there. Until publication, build +locally: + +```bash +jarvis mine init # OJ will detect the missing wheels and offer to build + # via the build-from-pin path; takes ~3-5 minutes on + # first run, mostly compiling Rust +``` + +`mine init` will: +1. Clone Pearl at the version OJ has tested against +2. Run `maturin build --release` for `py-pearl-mining` +3. Install the resulting wheel + the `miner-base`, `pearl-gateway` packages +4. Walk you through wallet address / pearld RPC config +5. Run a calibration to estimate your share-per-hour rate + +## Run + +```bash +# start mining +jarvis mine start + +# check live status +jarvis mine status + +# capability matrix (great when something goes wrong) +jarvis mine doctor + +# stop mining +jarvis mine stop + +# tail logs +jarvis mine logs -f +``` + +## Reading `mine doctor` + +Each row is one check. `✓` means the check passed; `✗` shows the actionable fix. + +``` +$ jarvis mine doctor +Hardware + GPU vendor apple ✓ + Apple chip M2 Max ✓ +Pearl install + py-pearl-mining 0.1.0 (cp312-abi3-macos-arm64) ✓ + miner-base 0.1.0 ✓ + pearl-gateway 0.1.0 ✓ +Pearl node + RPC http://localhost:44107 ✓ + Block height 442107 (synced) ✓ +Wallet + Address format prl1q... ✓ +Provider capability + cpu-pearl SUPPORTED (calibrated 0.X share/h on M2 Max) +Notes + - This is decoupled mining: your normal LLM inference is unaffected + - Hashrate is far below H100 mining; see this doc above + - Metal-accelerated mining: planned for v2; not available yet +Session + Sidecar absent (not running) +``` + +## Limitations + +- **Windows is not supported in v1.** Pearl's pure-Rust miner builds on + Windows in principle but the cross-platform install path is untested. Use + WSL2 if you must. +- **No coupling to inference yet.** v1 is a separate process; your CPU does + mining, your GPU does inference. They don't share work. v2 changes this. +- **No PyTorch-MPS yet.** v1 stays on the CPU path. v2 will move the math + to MPS for Apple-GPU acceleration. +- **No multi-host pool.** Solo mining only. The pool work is a separate spec + ([Spec A §8.5](../design/2026-05-05-vllm-pearl-mining-integration-design.md)). + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `mine doctor` says `Pearl Python packages not installed` | Wheels not built yet | Run `jarvis mine init` | +| `pearl-gateway` log shows `connection refused` to `http://localhost:44107` | `pearld` not running | Start `pearld` per Pearl's README | +| `mine status` shows `last_error: gateway metrics unreachable` | `pearl-gateway` crashed | Check `~/.openjarvis/logs/mining/pearl-gateway.log` | +| Build fails with `error: linker 'cc' not found` | Xcode CLT not installed | `xcode-select --install` | +| `maturin build` complains about `tikv-jemallocator` | macOS SDK too old | Update macOS / Xcode | + +For anything not on this list, capture `~/.openjarvis/logs/mining/` and open +an issue at https://github.com/open-jarvis/OpenJarvis/issues. + +## What changes in v2 / v3 + +- **v2 (months):** PyTorch-MPS acceleration plus optional plugin into MLX-LM + or `llama-cpp-python`. Same `cpu-pearl` config; users opt in via a new + `apple-mps-pearl` provider when v2 ships. +- **v3 (only if v2 perf is insufficient):** Native Metal kernel as a Pearl + upstream contribution. No user-visible change other than higher hashrate. +```` + +- [ ] **Step 2: Lint markdown** + +Run: `uv run ruff check docs/user-guide/mining-apple-silicon.md 2>&1 | tail -5 || true` + +(Ruff doesn't lint markdown; the command is a no-op. Just verify the file exists and renders.) + +Run: `head -20 docs/user-guide/mining-apple-silicon.md` +Expected: the title line and intro paragraph. + +- [ ] **Step 3: Commit** + +```bash +git add docs/user-guide/mining-apple-silicon.md +git commit -m "docs: user guide for Apple Silicon Pearl mining (Spec B v1 task 11)" +``` + +--- + +## Task 12: Final integration smoke test (manual / `live` marker) + +**Files:** +- Modify: `tests/mining/test_cpu_pearl.py` + +A real end-to-end test that starts the provider on the dev machine, runs for ~30 s, asserts at least one mining round completed. Marked `live` so it's gated behind `pytest -m live` and excluded from default CI. + +- [ ] **Step 1: Append the live test** + +Append to `tests/mining/test_cpu_pearl.py`: + +```python +@pytest.mark.live +@pytest.mark.slow +def test_provider_runs_end_to_end_on_this_host(tmp_path, monkeypatch): + """Live test: start provider, run for 30 s, assert mining loop produced output. + + Requires: + - py-pearl-mining built and installed + - pearl-gateway and miner-base installed + - either pearld running OR a stub gateway environment (latter is harder + to set up; for v1 we rely on pearld being available locally) + """ + pytest.importorskip("pearl_mining") + pytest.importorskip("pearl_gateway") + + from openjarvis.mining.cpu_pearl import CpuPearlProvider + from openjarvis.mining._stubs import MiningConfig + + monkeypatch.setattr( + "openjarvis.mining.cpu_pearl._sidecar_path", + lambda: tmp_path / "mining.json", + ) + monkeypatch.setattr( + "openjarvis.mining.cpu_pearl._log_dir", + lambda: tmp_path / "logs", + ) + monkeypatch.setenv("TEST_PEARLD_PASSWORD", "test") + + cfg = MiningConfig( + provider="cpu-pearl", + wallet_address="prl1q" + "0" * 32, + extra={ + "gateway_port": 18337, # high port to avoid conflict with real session + "metrics_port": 18339, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "TEST_PEARLD_PASSWORD", + }, + ) + provider = CpuPearlProvider() + provider.start(cfg) + + import time + deadline = time.monotonic() + 30 + saw_running = False + while time.monotonic() < deadline: + if provider.is_running(): + saw_running = True + time.sleep(1.0) + + provider.stop() + + log_dir = tmp_path / "logs" + if log_dir.exists(): + for log_file in log_dir.glob("*.log"): + print(f"--- {log_file.name} ---") + print(log_file.read_text()[:2000]) + + assert saw_running, "provider never reported is_running" +``` + +- [ ] **Step 2: Run the live test (only on a host with the full Pearl stack)** + +Run: `uv run pytest tests/mining/test_cpu_pearl.py::test_provider_runs_end_to_end_on_this_host -v -m live` +Expected: PASS if the host has Pearl installed and `pearld` running. Skipped via `importorskip` otherwise. + +- [ ] **Step 3: Verify default CI run still excludes it** + +Run: `uv run pytest tests/mining/ -v -m "not live and not cloud"` +Expected: every other test in this plan still passes; the live test is collected-but-deselected. + +- [ ] **Step 4: Commit** + +```bash +git add tests/mining/test_cpu_pearl.py +git commit -m "test(mining): live end-to-end smoke test for cpu-pearl (Spec B v1 task 12)" +``` + +--- + +## Task 13: REVIEW.md and CLAUDE.md updates + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `REVIEW.md` (if Spec A added it; else skip) + +Tiny pointers so future agents discover the cpu-pearl path. + +- [ ] **Step 1: Add a paragraph to CLAUDE.md `## Architecture` section** + +Find the `mining` paragraph that Spec A added. Append: + +``` +The `mining` subsystem also includes the `cpu-pearl` provider (Spec B v1) for +non-CUDA hosts including Apple Silicon. It runs Pearl's pure-Rust `mine()` +function via `py-pearl-mining` plus Pearl's `pearl-gateway` as a sibling +subprocess; decoupled from inference (the user's MLX/Ollama/llamacpp engine +is untouched). Future v2 (Apple-GPU acceleration via PyTorch MPS) and v3 +(native Metal kernel) are tracked in +docs/design/2026-05-05-apple-silicon-pearl-mining-design.md. +``` + +- [ ] **Step 2: Add a row to REVIEW.md "Registry compliance" if that section exists** + +Run: `grep -n "MinerRegistry" REVIEW.md 2>/dev/null || echo "no row yet"` + +If a row exists from Spec A, add a sibling row noting that `cpu-pearl` is the second registered provider and reviewers should check both `_register_vllm_pearl` and `_register_cpu_pearl` are called from `ensure_registered`. + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md REVIEW.md 2>/dev/null # REVIEW.md may not exist; that's fine +git commit -m "docs: point at cpu-pearl provider in CLAUDE.md (Spec B v1 task 13)" +``` + +--- + +## Self-review checklist + +Run through each of these against the final plan: + +**1. Spec coverage** — Spec B §13 sections vs. tasks: + +- §13.1 Architecture (subprocess + provider + sidecar) → Tasks 5, 6, 9 +- §13.2 Module layout → Tasks 5, 6, 8, 9, 10 +- §13.3 Optional extra → Task 2 +- §13.4 Capability detection → Task 8 +- §13.5 Lifecycle → Tasks 6, 7, 9 +- §13.6 Configuration → reuses Spec A; documented in user guide (Task 11) +- §13.7 Doctor surface → Spec A's `mine doctor` already; cpu-pearl row populated by Task 9's `stats()` plus the new capability detection from Task 8. *(No separate task needed; integrates via the existing CLI.)* +- §13.8 Anti-goals → enforced by what the plan does NOT do (no MLX plugin, no Metal, no inference coupling) +- §13.9 Exit criteria → Task 12 (live smoke), Task 11 (docs); the testnet block-find item is operational, not a code task +- §13.10 v2/v3 — explicitly out of scope, mentioned in user-guide + +**2. Type consistency check:** + +- `MiningProvider`, `MinerRegistry`, `MiningCapabilities`, `MiningConfig`, `MiningStats`, `HardwareInfo` — all from Spec A's `_stubs.py`. Used identically across Tasks 8–10. +- `provider_id = "cpu-pearl"` — used in Tasks 8, 9, 10. +- Sidecar key names (`provider`, `wallet_address`, `gateway_url`, `metrics_url`, `gateway_pid`, `miner_loop_pid`, `started_at`) — defined in Task 9's `_write_sidecar` and read in Task 12's assertion. +- `_install.pearl_packages_available()` and `_install.install_hint()` — defined in Task 3, used in Task 8. +- `_install.build_from_pin()` — defined in Task 4, called from `mine init` orchestration in Spec A's plan. + +**3. Placeholder scan:** + +- No "TBD", "TODO", "implement later" in any code block. +- One spot deliberately marked as "research-and-fix": Task 7 verifies env var names against Pearl's config. The fix is mechanical (replace strings); the research is bounded (read one Pearl file). +- `_pearl_subprocess.py` env var names in Task 6 are best-guesses and refined by Task 7. This is not a placeholder — Task 7 is the explicit fix. + +**4. Reuses from Spec A (sanity check — these must already exist):** + +- `MiningProvider` ABC at `src/openjarvis/mining/_stubs.py` +- `MinerRegistry` class at `src/openjarvis/core/registry.py` +- `MiningConfig`, `MiningCapabilities`, `MiningStats`, `HardwareInfo` dataclasses +- Sidecar location convention `~/.openjarvis/runtime/mining.json` +- `parse_gateway_metrics` adapter +- `mine init|start|stop|status|doctor` CLI subcommands + +If any of these are missing when you start Task 1, **stop and execute Spec A first.** Do not duplicate that infrastructure here. + +--- + +## Execution handoff + +Plan complete and saved to `docs/design/2026-05-05-apple-silicon-pearl-mining-plan-v1.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — Dispatch a fresh subagent per task with two-stage review. Good for parallelizing review and execution, and the tasks here are well-bounded. + +**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`. Faster end-to-end, batches review at checkpoints. + +The user is doing subagent-driven for Spec A (per their direction). The same approach makes sense for Spec B v1 once Spec A's plan completes; the dependency makes them naturally sequential. diff --git a/docs/design/2026-05-05-pearl-coordination-discussion-draft.md b/docs/design/2026-05-05-pearl-coordination-discussion-draft.md new file mode 100644 index 00000000..e8d60996 --- /dev/null +++ b/docs/design/2026-05-05-pearl-coordination-discussion-draft.md @@ -0,0 +1,91 @@ +# Pearl coordination thread — draft + +**For:** Posting on `pearl-research-labs/pearl` GitHub Discussions (Category: General / Q&A). +**By:** OpenJarvis team (Stanford Hazy Research); contact: [user fills in]. +**Status:** Draft — review and edit before posting. + +--- + +## Suggested title + +> Apple Silicon support for Pearl mining — coordination & confirmation + +## Suggested body + +Hi Pearl team — we're [OpenJarvis](https://github.com/open-jarvis/OpenJarvis), a local-first personal AI agent framework from Stanford Hazy Research. We're working on a `mining` subsystem that lets OJ users mine Pearl through the agent framework. The first integration is the `vllm-miner`-on-H100/H200 path, which is straightforward. The second is Apple Silicon, where the situation is more interesting and we'd like to confirm a few things before we ship. + +We have a v1 architecture that ships **today** using only your published Python packages (`py-pearl-mining`, `miner-base`, `pearl-gateway`) without any new code in your tree, plus an aspirational v2/v3 path that does involve potentially upstream contributions. Three asks below, plus a heads-up. + +### What we built and verified locally (no protocol changes; all upstream code paths) + +We read the Pearl source carefully — particularly: + +- `zk-pow/src/api/verify.rs` — the validator +- `zk-pow/src/ffi/mine.rs` — the pure-Rust `mine()` function +- `zk-pow/src/circuit/pearl_noise.rs` — noise generation +- `py-pearl-mining/` — the PyO3 bindings exposing the above to Python +- `miner/miner-base/src/miner_base/noisy_gemm.py` — the PyTorch NoisyGEMM reference + +…and then we built `py-pearl-mining` from source on an Apple Silicon M2 Max (macOS 26.4, Python 3.12, Rust 1.94). It produced `py_pearl_mining-0.1.0-cp312-abi3-macosx_11_0_arm64.whl` in ~56 seconds. We installed it and ran the `mine()` + `verify_plain_proof()` cycle from `tests/test_python_api.py`: + +``` +running mine(m=256, n=128, k=1024, rank=32) on Apple Silicon CPU… + mine() returned a proof in 0.078s + verify_plain_proof: ok=True, msg='Mining solution verified successfully' +``` + +So our v1 plan is: ship a CPU-mining mode for OJ users on Apple Silicon (and potentially other non-CUDA platforms) that wraps `pearl_mining.mine()` and your `pearl-gateway` as a subprocess. **We're not modifying anything in Pearl's tree for v1.** Just consuming what you've already published. + +### Three asks + +**1. Protocol acceptance confirmation.** + +Reading the validator path, we believe `verify_block` and `verify_plain_proof` accept any `PlainProof` produced by a correct implementation, regardless of which hardware produced it. The plonky2 STARK and the difficulty check don't reference hardware. + +**Could you confirm in writing that blocks mined via the pure-Rust `mine()` path (from a non-CUDA host like Apple Silicon) will be accepted by Pearl validators on testnet and mainnet?** We don't expect surprises here, but it's load-bearing for our spec and we want to record your sign-off before we ship. + +**2. Heads-up: your `Taskfile.yml` restricts `build:miner` to `[linux, windows]`.** + +That makes total sense for the GPU miner (CUDA + vLLM is Linux-only). But the `py-pearl-mining` and `miner-base` packages don't actually need that restriction — they install fine on macOS. We're working around the gate by installing the individual packages directly. Two questions: + + - Is the `[linux, windows]` restriction load-bearing in some way we don't see (e.g., do you intend `py-pearl-mining` to remain a CUDA-bound dependency long-term)? + - Would you be open to a small PR that splits `build:miner-cpu` (cross-platform) from `build:miner-gpu` (Linux + CUDA)? It would help downstream consumers like us — and any hobbyist who wants to experiment with `pearl_mining.mine()` on whatever hardware they own. + +**3. PyPI publication of `py-pearl-mining` / `miner-base` / `pearl-gateway`.** + +Do you have a roadmap for publishing these as PyPI wheels (`pip install py-pearl-mining` etc.)? Today we'd vendor a pinned commit and `maturin build` locally, which works but is brittle. If a 2026 PyPI publication is plausible, we'd defer the local-build code path; if it's not on the roadmap, we'll plan for the long-term local-build path. + +### Aspirational (v2 / v3) — context only, no asks yet + +Once v1 ships, we'd like to explore Apple-native acceleration: + +- **v2:** Use PyTorch MPS to GPU-accelerate `miner-base.NoisyGemm` on Apple Silicon. Could potentially become a plugin into `mlx-lm` or `llama-cpp-python` so a Mac user's *inference* matmuls do mining work — same "useful work" framing as your vllm-miner. We don't need anything from Pearl for this; we'd build it on top of your existing PyTorch reference. +- **v3 (only if v2 isn't enough):** A native Metal Shading Language port of NoisyGEMM, paralleling `pearl-gemm/`. That would be a real upstream contribution candidate (`pearl/miner/pearl-gemm-metal/`), and we'd want to coordinate with you before starting kernel work to avoid duplicate effort. + +If you're already building Apple Silicon support internally (or have someone planning it), please tell us — we'd rather coordinate than duplicate. + +### Logistics + +- License compatibility: Pearl is ISC; OpenJarvis is Apache-2.0. We don't see any conflict for either consumption (v1) or contribution (v3), but please flag if you do. +- CLA: do you require one for upstream contributions? Not blocking v1 — just want to know for v3. +- Preferred coordination channel: this Discussion thread, a Discord, an email? We're happy to use whatever works for you. + +Thanks for building this — Proof-of-Useful-Work via matmul is genuinely interesting and we're excited to bring more (slower!) hardware to the network. + +— [user name], on behalf of OpenJarvis + +--- + +## Notes for the user before posting + +- Replace `[user fills in]` with your contact info, `[user name]` with your name. +- The architecture/perf claims are all backed by code + an actual local build; you can stand behind them. +- "Heads-up" framing on the `Taskfile.yml` is intentional — we're not asking them to *change* it, just flagging the friction point in case they want to. +- Don't post until OJ Spec A is at least branch-pushed (which it is, PR #310) — it gives Pearl a way to see the broader integration we're building. +- When their reply lands, update Spec B §10 (open questions 1, 5, 7) and §11 (cross-references → coordination thread URL). + +## Possible Pearl responses to anticipate + +- **Best case:** "Confirmed, looks great, we don't have an Apple Silicon plan, please do it." — proceed with §13. +- **Middle case:** "Confirmed, but we have a Metal port in flight." — coordinate, share Spec B §6.1, decide upstream-vs-fork. v1 (CPU) is unaffected. +- **Worst case:** "We'd prefer downstream non-CUDA mining stay disabled for now." — unlikely given their `pearl-gateway` README explicitly anticipates "plugins for other LLM inference libraries", but if it happens, this becomes a much harder problem and we'd need to revisit. diff --git a/docs/design/2026-05-05-vllm-pearl-mining-integration-design.md b/docs/design/2026-05-05-vllm-pearl-mining-integration-design.md new file mode 100644 index 00000000..ea918146 --- /dev/null +++ b/docs/design/2026-05-05-vllm-pearl-mining-integration-design.md @@ -0,0 +1,560 @@ +# Spec A — vLLM-Pearl mining integration (v1) + +| | | +|---|---| +| **Date** | 2026-05-05 | +| **Status** | Design — pending implementation plan | +| **Owner** | OpenJarvis team | +| **Companion spec** | [Spec B — Apple Silicon enablement](2026-05-05-apple-silicon-pearl-mining-design.md) (separate effort, runs in parallel) | +| **Repos referenced** | `OpenJarvis` (this repo), `pearl-research-labs/pearl` | + +## 1. Summary + +Add a new sibling subsystem `openjarvis.mining` that lets users run [Pearl](https://github.com/pearl-research-labs/pearl) Proof-of-Useful-Work mining as a property of their local LLM inference. v1 ships solo mining for users who already have an H100/H200 and run vLLM — the only configuration Pearl's reference miner currently supports. The architecture leaves three deliberate seams for v2 (pool support + a 20% OJ fee) and is engine-agnostic by construction so Apple Silicon, AMD, Ollama, llama.cpp, and MLX paths plug in via the registry without a rewrite when Pearl ships the matching plugins. + +The narrative thesis: Pearl's `vllm-miner` is a vLLM plugin that swaps quantized linear ops with `NoisyGEMM`, a CUDA kernel that produces both the correct matmul output *and* a PoW commitment. Mining IS inference. For an OJ user already serving prompts on a powerful local GPU, this is a way to capture economic value from compute they were going to do anyway — directly aligned with OJ's Intelligence-Per-Watt thesis rather than against it. + +## 2. Scope + +### In scope (v1) +- New `openjarvis.mining` subsystem with `MiningProvider` ABC, `MinerRegistry`, `MiningCapabilities` / `MiningConfig` / `MiningStats` dataclasses +- `vllm-pearl` provider implementation: orchestrate Pearl's published `vllm-miner` Docker container +- `[mining]` TOML section in OJ config; `MiningConfig` field in `JarvisConfig` +- New CLI namespace: `jarvis mine init|start|stop|status|doctor|attach|logs` +- Runtime sidecar at `~/.openjarvis/runtime/mining.json` for engine ↔ mining handoff +- Hybrid Docker image acquisition: pull-if-published, otherwise build from a pinned Pearl ref +- On-demand telemetry via Pearl gateway `:8339/metrics`; `mining_session_id` nullable column on telemetry inference rows +- v2 seams: `submit_target` tagged-union parsing, zero-valued `fee_bps` / `fees_owed` plumbing, reserved `mining/pools/` location +- Test strategy that doesn't require an H100 in CI +- Documentation: `docs/user-guide/mining.md`, `docs/development/mining.md`, `CLAUDE.md` paragraph, `REVIEW.md` bullet + +### Out of scope (v1) — deferred or owned elsewhere +- Pool support and the 20% OJ fee mechanism (own spec, v2) +- Custody, signing, or routing Pearl funds (anti-goal — must remain zero in v1) +- Apple Silicon, AMD ROCm, sm89 (RTX 4090) NVIDIA, CPU, MLX, Ollama, llama.cpp, SGLang mining paths (Spec B for Apple; remaining hardware/engine paths blocked on Pearl) +- Wallet generation, Oyster integration, key custody (paste-only address) +- pearld lifecycle management (BYO node) +- Background telemetry collection in OJ's gateway daemon (v1.x; the hook point is reserved) +- Inference-quality drift detection (v1.x at earliest) +- `mine doctor --fix` automatic remediation (v1.x stub) +- Multi-GPU / multi-worker / multi-session per host (v2+) + +## 3. Load-bearing decisions from brainstorming + +These were the forks where the design could have gone several ways. Recorded so future-readers can audit reasoning rather than re-derive it. + +| Decision | What we picked | Why | +|---|---|---| +| Target audience for v1 | H100/H200 owners running vLLM (Pearl's only working config today) | Anything broader is blocked on Pearl shipping non-CUDA / non-vLLM plugins. Power-user MVP ships in weeks; pool/fee/Apple are separate specs. | +| Mining model | Co-located: every inference through the Pearl-flavored vLLM is mining work | Matches Pearl's `vllm-miner` plugin design and OJ's Intelligence-Per-Watt thesis. Side-car deferred until Pearl ships plugins for engines users care about for non-mining inference. | +| Coupling to Pearl miner process | Wrap-and-launch via Docker | Pearl's Docker image (or Dockerfile) is the most stable contract they expose. (1) "BYO miner" is too thin to be a feature; (3) running Pearl's `uv` workspace natively couples us to their build system. | +| Module placement | Sibling top-level subsystem `mining/` (peer to `engine/`, `agents/`) | Matches OJ's existing module pattern. `MinerRegistry` is a peer registry. Future non-vLLM providers slot in identically. | +| Engine attachment | Runtime sidecar JSON at `~/.openjarvis/runtime/mining.json` | Existing vLLM engine class stays untouched. Sidecar is the single source of truth tying mining lifecycle to engine resolution. Inspectable via `cat`. | +| Config shape | Flat top-level `[mining]` TOML section | Only one provider in v1; nested per-engine config can grow later if multi-provider becomes real. | +| Wallet handling | Paste-only Pearl Taproot address | Keys are sensitive; Pearl's wallet RPC is unstable surface. v1.x can add Oyster integration once the contract stabilizes. | +| pearld | BYO; user points OJ at their own node | OJ doesn't orchestrate L1 nodes. Doctor surfaces unreachable cleanly. | +| Telemetry collection | On-demand reads in v1; persistent collector class shipped unwired (`MiningTelemetryCollector`) | Most users won't enable mining; daemon shouldn't grow surface for them. v1.x lights up the hook with zero API churn. | +| v1 fee/pool seams | Three seams: `submit_target` parsed (one variant works), `fee_bps`/`fees_owed` plumbed at zero, `mining/pools/` reserved | Cheap to leave; painful to retrofit. Does not pre-decide the v2 API. | +| Custody | **Anti-goal**: zero. v1 must not accept, sign, or route Pearl funds. | Avoids prematurely binding a legal/regulatory posture. v2 revisits as part of pool design. | +| Apple Silicon support | Not in v1. Designed-for via the `MiningProvider` ABC + `MiningCapabilities.detect()`. Spec B documents the enablement work. | The Pearl `pearl-gemm` kernel is heavily Hopper-bound (`sm_90a`, WGMMA, TMA, cluster mode, CUTLASS 3.x). A Metal port is real GPU-kernel engineering, not a config flag. | + +## 4. Architecture & module layout + +### 4.1 New module tree + +``` +src/openjarvis/mining/ + __init__.py # soft-imports providers (try/except ImportError) + _stubs.py # MiningProvider ABC + dataclasses (MiningCapabilities, MiningConfig, MiningStats, SoloTarget, PoolTarget) + _discovery.py # detect_providers(hardware, engine, model) -> list[MiningCapabilities] + _docker.py # PearlDockerLauncher — shared Docker orchestration (image acquisition + container lifecycle) + _collector.py # MiningTelemetryCollector class — defined but UNWIRED in v1; lit up in v1.x + _constants.py # PEARL_REPO, PEARL_PINNED_REF, PEARL_IMAGE_TAG, OJ default tag + vllm_pearl.py # @MinerRegistry.register("vllm-pearl") — only impl in v1 + pools/ # RESERVED for v2. Empty in v1 except for an __init__.py with a docstring saying so. + +src/openjarvis/cli/ + mine_cmd.py # jarvis mine init|start|stop|status|doctor|attach|logs + +tests/mining/ + __init__.py + conftest.py # mining-specific fixtures (synthetic HardwareInfo, sample Prometheus output) + fixtures/ + gateway_metrics_sample.txt # captured Prometheus output from a real Pearl run + config_*.toml # golden TOML files + test_stubs.py + test_discovery.py + test_docker.py + test_collector.py + test_vllm_pearl.py + test_cli.py +``` + +### 4.2 Registry additions + +`MinerRegistry` added to `src/openjarvis/core/registry.py` as a peer to `EngineRegistry`, `AgentRegistry`, etc. `tests/conftest.py`'s autouse `_clean_registries` fixture is updated to include `MinerRegistry.clear()`. + +`mining/vllm_pearl.py` exposes idempotent `ensure_registered()`: + +```python +def ensure_registered() -> None: + if not MinerRegistry.contains("vllm-pearl"): + MinerRegistry.register_value("vllm-pearl", VllmPearlProvider) +``` + +`mining/__init__.py` soft-imports `vllm_pearl` inside `try / except ImportError` and calls `ensure_registered()`. Standard OJ pattern. + +### 4.3 Optional-deps extras + +```toml +mining-pearl = ["docker>=7.0"] # v1 requires only the Docker SDK +# mining-pearl-mlx = [...] # future, owned by Spec B +# mining-pearl-rocm = [...] # future +``` + +### 4.4 The central ABC + +```python +# src/openjarvis/mining/_stubs.py +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from openjarvis.core.config import HardwareInfo + +@dataclass(slots=True) +class MiningCapabilities: + supported: bool + reason: str | None = None # human-readable: "needs sm90", "no Pearl plugin for engine ollama" + estimated_hashrate: float | None = None + +@dataclass(slots=True) +class SoloTarget: + pearld_rpc_url: str + +@dataclass(slots=True) +class PoolTarget: + url: str + worker_id: str | None = None + +SubmitTarget = SoloTarget | PoolTarget + +@dataclass(slots=True) +class MiningConfig: + provider: str # MinerRegistry key + wallet_address: str + submit_target: SubmitTarget # parsed from TOML "solo" / "pool:"; v1 accepts only SoloTarget at runtime + fee_bps: int = 0 # v1: 0; v2: 2000 (=20%) + fee_payout_address: str | None = None # v1: ignored; v2: OJ's address + extra: dict = field(default_factory=dict) + +@dataclass(slots=True) +class MiningStats: + provider_id: str + shares_submitted: int = 0 + shares_accepted: int = 0 + blocks_found: int = 0 + hashrate: float = 0.0 + uptime_seconds: float = 0.0 + last_share_at: float | None = None + last_error: str | None = None + payout_target: str = "solo" # v2 reporting; "solo" in v1 + fees_owed: int = 0 # v2 accounting hook; 0 in v1 + +class MiningProvider(ABC): + provider_id: str + + @classmethod + @abstractmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: ... + + @abstractmethod + async def start(self, config: MiningConfig) -> None: ... + @abstractmethod + async def stop(self) -> None: ... + @abstractmethod + def is_running(self) -> bool: ... + @abstractmethod + def stats(self) -> MiningStats: ... +``` + +## 5. Config schema & engine attachment + +### 5.1 TOML schema + +```toml +[mining] +provider = "vllm-pearl" # MinerRegistry key +wallet_address = "prl1q..." # user's Pearl Taproot address (paste-only) +submit_target = "solo" # v1: "solo" only; "pool:" raises NotImplementedError at start() +fee_bps = 0 # v1: 0; v2: 2000 +fee_payout_address = "" # v1: ignored; v2: OJ's address + +[mining.extra] +docker_image_tag = "openjarvis/pearl-miner:" +model = "pearl-ai/Llama-3.3-70B-Instruct-pearl" +gateway_port = 8337 +gateway_metrics_port = 8339 +vllm_port = 8000 +gpu_memory_utilization = 0.9 +max_model_len = 8192 +pearld_rpc_url = "http://localhost:44107" +pearld_rpc_user = "rpcuser" +pearld_rpc_password_env = "PEARLD_RPC_PASSWORD" # name of env var, not the secret +hf_token_env = "HF_TOKEN" # name of env var +``` + +Secrets: env-var *names*, never literal values. Matches OJ's existing convention for cloud API keys. + +### 5.2 JarvisConfig field + +`core/config.py` adds: + +```python +@dataclass(slots=True) +class JarvisConfig: + ... + mining: MiningConfig | None = None +``` + +The TOML loader reads `[mining]`, parses `submit_target` into `SoloTarget | PoolTarget`, validates against the dataclass, surfaces unknown `extra` keys as warnings. Absent section → `mining = None` → zero behavior change. + +### 5.3 Runtime sidecar + +`~/.openjarvis/runtime/mining.json` (created on `mine start`, removed on `mine stop`): + +```json +{ + "provider": "vllm-pearl", + "vllm_endpoint": "http://127.0.0.1:8000/v1", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "gateway_url": "http://127.0.0.1:8337", + "gateway_metrics_url": "http://127.0.0.1:8339", + "container_id": "abc123...", + "wallet_address": "prl1q...", + "started_at": 1714867200 +} +``` + +Sidecar deliberately omits all secrets and process IDs. `container_id` is the authoritative handle (Docker is the source of truth for liveness); `wallet_address` is captured for drift-detection (config-vs-runtime). + +### 5.4 Engine handoff flow + +1. `jarvis mine start` → `MinerRegistry.get("vllm-pearl").start(config)`. +2. `VllmPearlProvider.start()` calls `_docker.PearlDockerLauncher.start(config)` and writes the sidecar. +3. `engine/_discovery.py` checks for `mining.json` on every engine lookup. When present, it auto-registers a `vllm` engine instance pointing at `vllm_endpoint`, named `vllm-pearl-mining`, marked default for mining-aware operations. +4. `jarvis ask` and the SDK route to that endpoint transparently. The user's normal inference is the mining work. + +The vLLM engine class itself (`engine/openai_compat_engines.py`) is **not modified**. The change to `engine/_discovery.py` is small and additive: it inspects for `mining.json` and registers a derived `vllm` instance pointing at the mining endpoint when the sidecar is present. Absent sidecar → unchanged discovery behavior. + +### 5.5 Manual mode + +Power users running their own Pearl container skip `jarvis mine start` and write the sidecar themselves via `jarvis mine attach --vllm-endpoint=... --gateway-url=...`. Decouples lifecycle from wiring. + +## 6. CLI surface, lifecycle & daemon integration + +### 6.1 Subcommands + +| Command | Purpose | +|---|---| +| `jarvis mine init` | Interactive: hardware/Docker checks, prompt for wallet + pearld credentials, write `[mining]`, pull/build image. Does NOT start mining. Pre-checks `>=200 GB` free disk. | +| `jarvis mine start` | Launch container via the registered provider, write sidecar, print endpoint info. Idempotent if running. | +| `jarvis mine stop` | Stop container, remove sidecar. Idempotent if not running. | +| `jarvis mine status` | Read sidecar + query gateway `:8339/metrics`. Print `MiningStats`. | +| `jarvis mine doctor` | Capability matrix; every check ✓/✗ with reason. Works in any state. | +| `jarvis mine attach` | Manual mode: write sidecar without launching. | +| `jarvis mine logs [-f]` | Tail container logs through Docker SDK. | + +### 6.2 Doctor output (canonical example) + +``` +$ jarvis mine doctor +Hardware + GPU vendor nvidia ✓ + Compute capability sm_90a ✓ + VRAM 80 GB ✓ (need ≥ 70 GB for Pearl 70B) +Docker + Daemon running 24.0.7 ✓ + GPU runtime nvidia-container-toolkit ✓ +Disk + Free in HF cache 312 GB ✓ (need ≥ 200 GB) +Image + openjarvis/pearl-miner: present (built 2026-04-30) ✓ +Pearl node + RPC http://localhost:44107 ✓ + Auth ok ✓ + Block height 442107 (synced) ✓ +Wallet + Address format prl1q... ✓ +Provider capability + vllm-pearl SUPPORTED +Session + Sidecar absent (not running) + Container — +``` + +Each row maps to one check function in `mining/_discovery.py`. Failures print actionable reasons (e.g. `✗ reason: needs sm90, you have sm89 (RTX 4090)`). + +### 6.3 Lifecycle states + +``` +NOT_CONFIGURED → CONFIGURED → STARTING → RUNNING ⇄ STOPPING → STOPPED + ↘ + FAILED +``` + +State derivation rules (no separate state file — derived from config + sidecar + container introspection): + +- `NOT_CONFIGURED` — no `[mining]` in config +- `CONFIGURED` — config present, no sidecar +- `STARTING` — sidecar with `started_at` < ~30 s ago, container exists but gateway not yet healthy +- `RUNNING` — sidecar present, container running, gateway responding +- `FAILED` — sidecar present, but container exited or gateway failing > threshold +- `STOPPING` — `mine stop` invoked, Docker stop in progress +- `STOPPED` — `mine stop` complete, sidecar removed + +### 6.4 Daemon integration: deliberately minimal in v1 + +- Docker handles container restart via `--restart=unless-stopped`. OJ does not babysit. +- Existing `com.openjarvis.gateway` daemon is unchanged. +- v1.x hook: `MiningTelemetryCollector` (already shipped in v1, unwired) can be added to the gateway as a 30-second-tick async task. +- launchd/systemd installation surface (`jarvis daemon install`) untouched. + +### 6.5 Concurrency + +POSIX `flock` on `~/.openjarvis/runtime/mining.lock` prevents racing `mine start` invocations. + +### 6.6 `jarvis ask` UX hint + +When `[mining]` is configured but no sidecar exists, `cli/hints.py` emits one line: `"mining configured but not running — start it with \`jarvis mine start\`"`. One-line UX nudge, no new infrastructure. + +## 7. Pearl Docker integration + +### 7.1 Realities from inspecting Pearl's repo + +- **Build context = entire Pearl monorepo.** Dockerfile copies root `pyproject.toml`/`uv.lock`, `miner/`, `pearl-blake3/`, `py-pearl-mining/`, `zk-pow/`, `plonky2/`. Building requires the full repo. +- **Pearl publishes no registry image as of writing.** README documents only `docker buildx build -t vllm_miner . -f miner/vllm-miner/Dockerfile`. +- **Single container, three ports.** `entrypoint.sh` launches `pearl-gateway` in the background, waits on `:8339/metrics`, then `exec`s `vllm serve`. Ports: `8000` (vLLM), `8337` (miner RPC), `8339` (gateway metrics). +- **Pinned stack inside the image.** CUDA 12.9.1, vLLM 0.20.0+cu129, Python 3.12, `compute_90a/sm_90a`. Set by Pearl, not by us. +- **First-launch cost.** vLLM pulls the 70 B model from HF on first serve (~140 GB). Build itself is 30–60 min on first init. + +### 7.2 Hybrid image acquisition + +| Mode | Behavior | When | +|---|---|---| +| **Pre-built pull** | OJ `docker pull`s the configured tag if it resolves in a registry | Default once Pearl publishes; users with private registry; CI | +| **Build-from-pin** | OJ git-clones Pearl at a pinned ref into `~/.openjarvis/cache/pearl/`, then `docker buildx build` | v1 default (Pearl publishes nothing today) | +| **BYO image** | User sets `mining.extra.docker_image_tag` to an image they built/pulled themselves | Power users, air-gapped envs | + +Selection logic in `_docker.PearlDockerLauncher.ensure_image()`: +1. If `docker_image_tag` resolves locally → use it. +2. Else `docker pull ` → on success, use it. +3. Else if `tag == OJ_DEFAULT_TAG`, fall back to clone-and-build from `PEARL_PINNED_REF`. +4. Else fail with a clear error pointing at `mine doctor`. + +### 7.3 Pearl version pinning + +`mining/_constants.py`: + +```python +PEARL_REPO = "https://github.com/pearl-research-labs/pearl.git" +PEARL_PINNED_REF = "" # bumped per OJ release after rev-testing +PEARL_IMAGE_TAG = f"openjarvis/pearl-miner:{PEARL_PINNED_REF}" +``` + +OJ release notes call out the Pearl ref shipped. Bumping the ref is its own PR with a documented Pearl-rev workflow. + +### 7.4 Container launch shape + +Via `docker>=7.0` SDK in `_docker.PearlDockerLauncher.start()`: + +```python +container = client.containers.run( + image=PEARL_IMAGE_TAG, + command=[ + config.extra["model"], + "--host", "0.0.0.0", + "--port", str(config.extra["vllm_port"]), + "--gpu-memory-utilization", str(config.extra["gpu_memory_utilization"]), + "--enforce-eager", + "--max-model-len", str(config.extra.get("max_model_len", 8192)), + ], + name="openjarvis-pearl-miner", + detach=True, + auto_remove=False, + restart_policy={"Name": "unless-stopped"}, + device_requests=[ DeviceRequest(count=-1, capabilities=[["gpu"]]) ], + shm_size="8g", + network_mode="host", + volumes={ + str(Path.home() / ".cache/huggingface"): { + "bind": "/root/.cache/huggingface", + "mode": "rw", + }, + }, + environment={ + "PEARLD_RPC_URL": config.extra["pearld_rpc_url"], + "PEARLD_RPC_USER": config.extra["pearld_rpc_user"], + "PEARLD_RPC_PASSWORD": os.environ[config.extra["pearld_rpc_password_env"]], + "PEARLD_MINING_ADDRESS": config.wallet_address, + "HF_TOKEN": os.environ.get(config.extra.get("hf_token_env", "HF_TOKEN"), ""), + "MINER_RPC_TRANSPORT": "tcp", + }, +) +``` + +### 7.5 Trade-offs called out + +- **`network_mode="host"`** because pearld's RPC at `http://localhost:44107` lives on the host. A user-defined Docker network adds setup steps with no real isolation benefit on a single-tenant miner box. Pragmatism > purity. Note: host networking has Linux semantics; macOS/Windows Docker handle it differently. Acceptable for v1 since H100/H200 + nvidia-container-toolkit constrains the deployment to Linux anyway. +- **`auto_remove=False`** so a crashed container stays around for `jarvis mine logs` post-mortem. +- **HF cache mounted from host.** 140 GB weight download is one-time, survives container restarts, visible to other tools. +- **Secrets via env-var names**, never persisted in the container image, the sidecar, or Docker labels. + +### 7.6 Wallet handling boundary + +OJ never sees Pearl mnemonic seeds, never imports Oyster keys, never signs Pearl transactions. Only Pearl-secret OJ touches is the pearld RPC password (passed through container env, sourced by name from host env). Mining address is public — fine in plaintext config. + +### 7.7 Image lifecycle UX + +- `jarvis mine init` triggers `ensure_image()`, streams build/pull output through the CLI with a clear time estimate (`"Building Pearl miner image — first run takes ~45 min on a fast machine"`). +- `jarvis mine doctor` reports `image: present (tag, age, sha)` or `image: missing (run mine init)`. +- `jarvis mine prune` (v1.x) cleans old `openjarvis/pearl-miner:*` tags. Manual `docker image rm` works in v1. + +## 8. Telemetry hooks & v2 fee/pool seams + +### 8.1 Telemetry — read surface + +Pearl's container exposes `:8339/metrics` (Prometheus exposition format). v1 reads only this endpoint. Deeper RPC introspection via `:8337` deferred to v2. + +### 8.2 Adapter and metric mapping + +`mining/vllm_pearl.py::_parse_gateway_metrics()` translates Prometheus lines to `MiningStats`. Metric names are TBD on implementation — verified against captured fixture `tests/mining/fixtures/gateway_metrics_sample.txt`. Expected mapping (fallback: zero-fill any missing field, log a one-shot warning): + +| `MiningStats` field | Likely Pearl metric (verify on implementation) | +|---|---| +| `shares_submitted` | `pearl_gateway_shares_submitted_total` | +| `shares_accepted` | `pearl_gateway_shares_accepted_total` | +| `blocks_found` | `pearl_gateway_blocks_found_total` | +| `hashrate` | derived rate of `shares_submitted_total` | +| `uptime_seconds` | `process_start_time_seconds` | +| `last_share_at` | `pearl_gateway_last_share_timestamp` | +| `last_error` | derived from `pearl_gateway_errors_total` deltas | + +### 8.3 Collection cadence + +- **v1: on-demand only.** `jarvis mine status` makes one HTTP GET per call (~10 ms). No background polling. +- **v1.x: `MiningTelemetryCollector` lit up in the gateway daemon.** The class is shipped in v1 but unwired. v1.x adds a periodic asyncio task; same `MiningStats` schema, same gateway endpoint. Zero API churn. + +### 8.4 Intelligence-Per-Watt extension + +The `telemetry/store.py` schema gains a nullable `mining_session_id` column on inference rows: + +- Tagged when an inference goes through the Pearl-mining endpoint; null otherwise. +- Untagged rows behave exactly as today — zero impact on the non-mining path. +- `jarvis telemetry stats --mining` (v1.x) joins to the latest `MiningStats` snapshot and reports `tokens / share`, `joules / share`, `est. PRL / kWh`. + +v1 ships the column and the no-op join path. v1.x lights up the reporting. This is the metric the IPW thesis genuinely cares about. + +### 8.5 v2 fee/pool seams (three concrete, no more) + +**1. `submit_target` parsed into a tagged union; only one variant works.** `SoloTarget` accepted at runtime in v1; `PoolTarget` raises `NotImplementedError("pool support is v2 — track openjarvis#XYZ")`. Reachable only by users who edit their config to opt in. + +**2. `fee_bps` / `fee_payout_address` plumbed; zero-valued in v1.** `MiningStats.fees_owed = 0` and `MiningStats.payout_target = "solo"` always in v1. Schema is real; values are zero. No migration in v2. + +**3. `mining/pools/` module location reserved.** Empty in v1 except for an `__init__.py` whose docstring says the location is reserved for v2 `PoolClient` work. The v1 spec **does not** define a `PoolClient` ABC — predicting the v2 API precisely creates migration debt. The v2 spec writes against an empty slot. + +### 8.6 What v1 deliberately does not lock in + +- Pool protocol (PPLNS / PPS / SOLO+ / custom) +- Custody model (escrow / trustless split-coinbase / settlement contract) +- OJ pool URL, share format, share difficulty +- KYC / TOS / payout thresholds + +### 8.7 Custody anti-goal + +v1 must not introduce any code path where OJ accepts custody of, signs, or routes Pearl funds. Closest v1 comes is reading `wallet_address` (public) and passing it through to the container. v2 revisits. + +### 8.8 Single-session assumption (called out, not seamed) + +v1 assumes one mining session per host (one sidecar). Multi-GPU / multi-worker fanout is v2+. Sidecar would become a list or directory. + +## 9. Failure handling & test strategy + +### 9.1 Principles + +1. **Fail loud, don't auto-heal.** Docker handles container restarts; `mine doctor` surfaces what's wrong. OJ does not retry mining work, restart pearld, or paper over crashes. +2. **`mine doctor` is the canonical failure surface.** Every failure mode below maps to one or more rows in doctor output. +3. **Sidecar is authoritative; config is intent.** Drift surfaces as a warning, not a crash. + +### 9.2 Failure mode matrix + +| Failure | v1 behavior | Surface | +|---|---|---| +| Image missing | `mine start` errors with "run `mine init` to build/pull" | `mine doctor: image: missing` | +| GPU not reachable in container | Docker error with `nvidia-container-toolkit` hint | `mine doctor: docker.gpu_runtime: ✗` | +| Disk too low | `mine init` pre-checks `shutil.disk_usage`; errors if < 200 GB free | `mine doctor: disk_free: ✗` | +| vLLM model load fails (HF auth, OOM, model not found) | Container exits; `mine status` reports `FAILED` with `last_error` from `docker logs` tail | `mine status` + `mine logs` | +| pearl-gateway can't reach pearld | `:8339/metrics` exposes the error; adapter populates `MiningStats.last_error` | `mine status: last_error` | +| Container crashes mid-run | Docker `--restart=unless-stopped` restarts; `mine status` shows brief `STARTING` → `RUNNING` | self-healing, logged | +| Stale sidecar (container died, sidecar not cleaned) | `mine start` validates `container_id`; if Docker says it's gone, removes sidecar and proceeds | one-line warning | +| Concurrent `mine start` | POSIX `flock` on `~/.openjarvis/runtime/mining.lock`; second invocation errors clearly | clear message | +| Already-running `mine start` | Idempotent: detect via sidecar + container introspection, print status, exit 0 | informational | +| Wallet/config drift | Sidecar carries wallet from start time; `mine status` cross-checks and warns on mismatch | warning, not auto-restart | +| User edits `submit_target = "pool:..."` in v1 | `start()` raises `NotImplementedError` with tracking issue link | clear error | +| Pearl protocol upgrade (block format / metric names change) | Adapter zero-fills with one-shot warning. `mine doctor` does a best-effort check: it reads the `image: openjarvis/pearl-miner:` Docker label and compares against `PEARL_PINNED_REF` baked into the OJ release; mismatch surfaces a warning. **OJ does not poll Pearl's GitHub at runtime.** | warning + spec'd Pearl-rev workflow | +| Inference quality regression from NoisyGEMM | **Out of v1 scope to detect.** Documented risk; v1.x may add automated drift detection. | docs only | + +### 9.3 Test strategy + +Hard constraint: **OJ's CI has no H100, no GPU, no Pearl image, no pearld.** Almost everything must be testable without those. + +| Layer | Pattern | Marker | Runs in CI? | +|---|---|---|---| +| `MiningCapabilities.detect()` matrix | Pure unit, parametrized over synthetic `HardwareInfo` | unmarked | yes | +| `MiningConfig` parsing (TOML → dataclass, including `submit_target` tagged-union) | Unit, golden TOML fixtures | unmarked | yes | +| Docker launch shape | `unittest.mock.patch("docker.from_env")`; assert `containers.run(...)` kwargs | unmarked | yes | +| Gateway metrics adapter | `tests/mining/fixtures/gateway_metrics_sample.txt`, parse + assert `MiningStats` | unmarked | yes | +| Sidecar lifecycle (write/read/stale-cleanup, `flock` acquisition) | `tmp_path`, real filesystem, real `flock` | unmarked | yes | +| CLI smoke | Click `CliRunner`, mocked `MiningProvider` | unmarked | yes | +| Container start/stop with real Docker daemon | Real Docker, swap Pearl image for tiny stub `alpine`-based image opening the right ports | new `docker` marker | optional in CI | +| End-to-end mining (real container, real pearld, real shares) | Real H100 + pearld testnet + pinned Pearl image | `live and nvidia and slow` | **no** — manual pre-release smoke | + +**New pytest marker.** `docker` registered alongside `live`, `cloud`, `nvidia`, etc. in `pyproject.toml`. CI matrix optionally runs `-m "docker and not live"` on a Docker-enabled runner. + +**Conftest hygiene.** `tests/conftest.py`'s autouse fixture clears `MinerRegistry`. `mining/__init__.py`'s `ensure_registered()` survives the autouse clear via `MinerRegistry.contains(...)`. + +**Captured Prometheus fixture.** Real metrics output from a Pearl gateway run, committed to the repo. Pins the metric-name assumptions and is the canary if Pearl renames metrics. + +### 9.4 What v1 deliberately does not test + +- Mining throughput/economics on a real H100 (Pearl's CI tests their kernels) +- Inference quality drift from NoisyGEMM (out of v1 scope) +- Pool share submission paths (v2 spec) +- Apple Silicon paths (Spec B) + +## 10. Documentation deliverables (part of this spec) + +- `docs/user-guide/mining.md` — user-facing: prerequisites, init flow, doctor output reading guide, `mine status` interpretation, deliberately-unsupported list (Mac, AMD, sm89, non-vLLM engines) +- `docs/development/mining.md` — for contributors: `MiningProvider` ABC, registry pattern, how to add a new provider (Spec B is the canonical worked example) +- One paragraph in `CLAUDE.md` under "Architecture" pointing future-Claude at `mining/` as a sibling subsystem with its own optional-deps discipline +- `REVIEW.md` gets a new bullet under registry compliance specifically calling out `MinerRegistry` + +## 11. Open items to resolve at implementation time + +1. **Pearl gateway metric names.** Verify the actual exposition labels by capturing `:8339/metrics` from a running Pearl gateway. Update the adapter mapping and commit the fixture. +2. **`PEARL_PINNED_REF`.** Pick a specific commit/tag at the start of implementation. Document the rev-bump workflow. +3. **The `pearl-ai/Llama-3.3-70B-Instruct-pearl` HF model.** Confirm it exists and is gated/ungated; document HF auth requirements. +4. **Pearl Taproot address regex.** Confirm the prefix and length for `mine doctor`'s address-format check. +5. **Pearl `:8337` miner RPC TCP port behavior.** Confirm `MINER_RPC_TRANSPORT=tcp` works as documented and binds to `0.0.0.0` not just `127.0.0.1` inside the host network namespace. +6. **OJ default Docker image tag.** Decide whether to publish to GHCR/Docker Hub once we have a build, or leave users on build-from-pin. Likely v1.x. +7. **Wallet generation hand-off (v1.x).** Decide whether `mine init` shells out to Pearl's `oyster` for users who want guidance, or stays paste-only. +8. **Telemetry schema migration approach.** Adding the nullable `mining_session_id` column to `telemetry/store.py` is a SQLite schema change. Decide between (a) `ALTER TABLE` on first start with a guarded `PRAGMA user_version` bump, (b) per-query `try/except` on the column, or (c) creating a sidecar table joined on inference id. Confirm what convention OJ already uses for `telemetry/` schema evolution before picking; default lean is (a). + +## 12. Cross-references + +- **[Spec B — Apple Silicon enablement](2026-05-05-apple-silicon-pearl-mining-design.md)** — separate effort tracking the Pearl-side and OJ-side work to make Apple Silicon a registered `MiningProvider`. Spec A is engine-agnostic by design; Spec B drops in via `MinerRegistry` without modifying anything in this spec. +- **Pearl repo:** [`pearl-research-labs/pearl`](https://github.com/pearl-research-labs/pearl) — referenced sub-paths: `miner/vllm-miner/`, `miner/pearl-gemm/`, `miner/pearl-gateway/`, `miner/vllm-miner/Dockerfile`, `miner/vllm-miner/entrypoint.sh`. +- **Pearl paper:** [Proof-of-Useful-Work via matrix multiplication (arXiv:2504.09971)](https://arxiv.org/abs/2504.09971). +- **OJ contributing guide:** `docs/development/contributing.md` — registry pattern, `_stubs.py` / `_discovery.py` conventions, `ensure_registered()` discipline, optional-deps soft-import pattern. All followed in this spec. + +## 13. Implementation plan + +The implementation plan for Spec A is a separate document, written via the `superpowers:writing-plans` skill after this design is approved by the user. It will decompose section 4–9 above into ordered, independently-reviewable steps and call out which steps can be parallelized. diff --git a/docs/design/2026-05-05-vllm-pearl-mining-integration-plan.md b/docs/design/2026-05-05-vllm-pearl-mining-integration-plan.md new file mode 100644 index 00000000..4a48d671 --- /dev/null +++ b/docs/design/2026-05-05-vllm-pearl-mining-integration-plan.md @@ -0,0 +1,3558 @@ +# vLLM-Pearl mining integration — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Spec A — the v1 vLLM-Pearl mining integration — as a new `openjarvis.mining` subsystem that lets H100/H200 owners running vLLM solo-mine the Pearl PoUW chain with their normal LLM inference. + +**Architecture:** New top-level subsystem `mining/` mirrors OJ's existing primitive pattern (peer to `engine/`, `agents/`). A `MiningProvider` ABC + `MinerRegistry` enables future hardware/engine paths (Apple Silicon, AMD, Ollama) without rewrite. The v1 `vllm-pearl` provider orchestrates Pearl's published Docker container; a runtime sidecar at `~/.openjarvis/runtime/mining.json` decouples mining lifecycle from the existing vLLM engine class. Three deliberate seams (`submit_target` tagged-union, zero-valued `fee_bps`/`fees_owed`, reserved `mining/pools/`) leave room for v2 pool support without retrofit pain. + +**Tech Stack:** Python 3.10+, `docker>=7.0` SDK, Click CLI, pytest with `unittest.mock`, ruff, uv. References Pearl repo (`pearl-research-labs/pearl`) at a pinned commit/tag. + +**Spec reference:** [`docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`](./2026-05-05-vllm-pearl-mining-integration-design.md). Read it before starting. Section numbers in the plan refer to that spec. + +**Critical conventions for any agent picking this up:** + +- All new modules begin with `from __future__ import annotations`. +- All dataclasses use `@dataclass(slots=True)`. +- Absolute imports only (`from openjarvis.core.registry import ...`). +- Optional dependencies live behind `try / except ImportError` in the parent `__init__.py`. +- Tests in `tests/conftest.py` autouse-clear all registries — the `ensure_registered()` pattern (idempotent registration guarded by `XRegistry.contains(...)`) is required for components that need to survive the autouse clear. +- File-naming: `_stubs.py` (ABC + dataclasses), `_discovery.py` (auto-detection), `*_cmd.py` (CLI commands). + +**Branch posture:** This plan is added to the existing branch `docs/pearl-mining-design-specs` as a follow-up commit on the same PR (#310). Implementation work will branch off `main` separately once the spec + plan are merged. + +--- + +## File structure + +### Created + +| Path | Responsibility | +|---|---| +| `src/openjarvis/mining/__init__.py` | Package init; soft-imports providers via `try/except ImportError` | +| `src/openjarvis/mining/_stubs.py` | `MiningProvider` ABC, `MiningCapabilities`, `MiningConfig`, `MiningStats`, `SoloTarget`/`PoolTarget` tagged union, `Sidecar` dataclass + read/write helpers | +| `src/openjarvis/mining/_discovery.py` | `detect_providers()`, hardware/Docker/disk/pearld checks, wallet-format check | +| `src/openjarvis/mining/_constants.py` | `PEARL_REPO`, `PEARL_PINNED_REF`, `PEARL_IMAGE_TAG`, default ports, default model, sidecar path constants | +| `src/openjarvis/mining/_docker.py` | `PearlDockerLauncher` — `ensure_image()`, `start()`, `stop()`, `is_running()`, `get_logs()` | +| `src/openjarvis/mining/_collector.py` | `MiningTelemetryCollector` — full impl, shipped unwired in v1; v1.x lights it up in the gateway daemon | +| `src/openjarvis/mining/vllm_pearl.py` | `VllmPearlProvider` (`MiningProvider` impl); `_parse_gateway_metrics()`; `ensure_registered()` | +| `src/openjarvis/mining/pools/__init__.py` | Reserved location for v2 `PoolClient` work — empty except for a docstring saying so | +| `src/openjarvis/cli/mine_cmd.py` | `jarvis mine` Click group: `init`, `start`, `stop`, `status`, `doctor`, `attach`, `logs` | +| `tests/mining/__init__.py` | Empty test package init | +| `tests/mining/conftest.py` | Mining-specific fixtures: synthetic `HardwareInfo`, mock Docker client factory, sample sidecar tmp_path | +| `tests/mining/fixtures/gateway_metrics_sample.txt` | Real Prometheus output captured from a Pearl gateway run (one snapshot, committed) | +| `tests/mining/fixtures/config_minimal.toml` | Minimal valid `[mining]` config | +| `tests/mining/fixtures/config_pool_v2.toml` | TOML with `submit_target = "pool:..."` for testing the v2-seam `NotImplementedError` | +| `tests/mining/test_stubs.py` | Tests for ABC contract, dataclass invariants, sidecar IO | +| `tests/mining/test_discovery.py` | Tests for capability detection matrix | +| `tests/mining/test_docker.py` | Tests for `PearlDockerLauncher` with mocked Docker SDK | +| `tests/mining/test_collector.py` | Tests for `MiningTelemetryCollector` | +| `tests/mining/test_vllm_pearl.py` | Tests for `VllmPearlProvider` end-to-end (mocked Docker + filesystem) | +| `tests/mining/test_cli.py` | CLI smoke tests via Click `CliRunner` | +| `docs/user-guide/mining.md` | User-facing docs | +| `docs/development/mining.md` | Contributor guide for adding new providers | + +### Modified + +| Path | Change | +|---|---| +| `src/openjarvis/core/registry.py` | Add `MinerRegistry` class + entry in `__all__` | +| `src/openjarvis/core/config.py` | Add `MiningConfig` field to `JarvisConfig`; add TOML parser for `[mining]` with `submit_target` tagged-union resolution | +| `src/openjarvis/engine/_discovery.py` | Add sidecar-aware engine resolution: when `~/.openjarvis/runtime/mining.json` exists, register a derived `vllm` engine pointing at `vllm_endpoint` | +| `src/openjarvis/telemetry/store.py` | Add nullable `mining_session_id` column to inference rows; bump `PRAGMA user_version`; helper to tag rows when sidecar is present | +| `src/openjarvis/cli/__init__.py` | Register the `mine` Click group | +| `src/openjarvis/cli/hints.py` | Add hint: "mining configured but not running — start it with `jarvis mine start`" | +| `tests/conftest.py` | Add `MinerRegistry.clear()` to autouse `_clean_registries` fixture | +| `pyproject.toml` | Add `mining-pearl` extra; add `docker` pytest marker | +| `CLAUDE.md` | Add paragraph under Architecture pointing future-Claude at `mining/` as a sibling subsystem | +| `REVIEW.md` | Add bullet under registry compliance calling out `MinerRegistry` | + +--- + +## Task 1 — Add `MinerRegistry` + +**Files:** +- Modify: `src/openjarvis/core/registry.py` +- Modify: `tests/conftest.py` +- Test: `tests/core/test_registry.py` (existing file — add a new test) + +- [ ] **Step 1: Write the failing test** + +Add to `tests/core/test_registry.py`: + +```python +def test_miner_registry_register_and_get(): + from openjarvis.core.registry import MinerRegistry + + class _Stub: + provider_id = "stub-pearl" + + MinerRegistry.register_value("stub-pearl", _Stub) + assert MinerRegistry.contains("stub-pearl") is True + assert MinerRegistry.get("stub-pearl") is _Stub +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +uv run pytest tests/core/test_registry.py::test_miner_registry_register_and_get -v +``` +Expected: `ImportError` or `AttributeError` on `MinerRegistry`. + +- [ ] **Step 3: Add `MinerRegistry` to `core/registry.py`** + +Insert after `ConnectorRegistry` (around line 153): + +```python +class MinerRegistry(RegistryBase[Any]): + """Registry for Pearl mining provider implementations. + + Each provider implements the ``MiningProvider`` ABC defined in + ``openjarvis.mining._stubs``. Registry keys are short lowercase strings + such as ``"vllm-pearl"`` (CUDA + Hopper) and (future) ``"mlx-pearl"``, + ``"llamacpp-pearl-metal"``, ``"ollama-pearl"``. + """ +``` + +Add `"MinerRegistry"` to `__all__` (alphabetical position between `MemoryRegistry` and `ModelRegistry`). + +- [ ] **Step 4: Run test to verify it passes** + +```bash +uv run pytest tests/core/test_registry.py::test_miner_registry_register_and_get -v +``` +Expected: PASS. + +- [ ] **Step 5: Update `tests/conftest.py` autouse fixture** + +In `tests/conftest.py`, add `MinerRegistry` to the imports and to the clear-list. The existing `_clean_registries` fixture lists every registry on its own line — insert `MinerRegistry.clear()` alphabetically between `MemoryRegistry.clear()` and `ModelRegistry.clear()`. Likewise add `MinerRegistry,` to the imports block. + +- [ ] **Step 6: Verify autouse clear works** + +Add a second test below the registration test: + +```python +def test_miner_registry_cleared_between_tests(): + from openjarvis.core.registry import MinerRegistry + # If autouse clear works, no entry from prior tests remains + assert MinerRegistry.contains("stub-pearl") is False +``` + +```bash +uv run pytest tests/core/test_registry.py::test_miner_registry_register_and_get tests/core/test_registry.py::test_miner_registry_cleared_between_tests -v +``` +Expected: both PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/openjarvis/core/registry.py tests/conftest.py tests/core/test_registry.py +git commit -m "feat(mining): add MinerRegistry for mining providers" +``` + +--- + +## Task 2 — Mining package skeleton: constants, ABC, dataclasses, sidecar IO + +**Files:** +- Create: `src/openjarvis/mining/__init__.py` +- Create: `src/openjarvis/mining/_constants.py` +- Create: `src/openjarvis/mining/_stubs.py` +- Create: `src/openjarvis/mining/pools/__init__.py` +- Create: `tests/mining/__init__.py` +- Create: `tests/mining/conftest.py` +- Create: `tests/mining/test_stubs.py` + +- [ ] **Step 1: Create `tests/mining/__init__.py`** + +Empty file. + +- [ ] **Step 2: Create `tests/mining/conftest.py` with shared fixtures** + +```python +"""Mining-specific test fixtures.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from openjarvis.core.config import GpuInfo, HardwareInfo + + +@pytest.fixture +def hopper_hw() -> HardwareInfo: + """Hardware fixture: a typical H100 host.""" + return HardwareInfo( + platform="linux", + cpu_brand="AMD EPYC 7763", + cpu_count=64, + ram_gb=512.0, + gpu=GpuInfo( + vendor="nvidia", + name="NVIDIA H100-SXM5-80GB", + vram_gb=80.0, + compute_capability="9.0", + count=1, + ), + ) + + +@pytest.fixture +def ada_hw() -> HardwareInfo: + """Hardware fixture: RTX 4090 (sm_89, NOT supported by Pearl).""" + return HardwareInfo( + platform="linux", + cpu_brand="Intel Core i9-14900K", + cpu_count=24, + ram_gb=64.0, + gpu=GpuInfo( + vendor="nvidia", + name="NVIDIA GeForce RTX 4090", + vram_gb=24.0, + compute_capability="8.9", + count=1, + ), + ) + + +@pytest.fixture +def apple_hw() -> HardwareInfo: + """Hardware fixture: Apple Silicon (NOT supported in v1).""" + return HardwareInfo( + platform="darwin", + cpu_brand="Apple M4 Max", + cpu_count=16, + ram_gb=128.0, + gpu=GpuInfo(vendor="apple", name="Apple M4 Max", vram_gb=128.0, count=1), + ) + + +@pytest.fixture +def mock_docker_client() -> Any: + """Factory for a mocked docker.DockerClient. + + Returns a MagicMock configured with the most common attribute paths so + individual tests only need to override what they care about. + """ + client = MagicMock() + client.ping.return_value = True + client.version.return_value = {"Version": "24.0.7"} + client.images.list.return_value = [] + client.images.get.side_effect = Exception("not found") + return client + + +@pytest.fixture +def sample_sidecar_payload() -> dict: + return { + "provider": "vllm-pearl", + "vllm_endpoint": "http://127.0.0.1:8000/v1", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "gateway_url": "http://127.0.0.1:8337", + "gateway_metrics_url": "http://127.0.0.1:8339", + "container_id": "abc123def456", + "wallet_address": "prl1qexampleaddress", + "started_at": 1714867200, + } + + +@pytest.fixture +def sidecar_path(tmp_path: Path) -> Path: + return tmp_path / "mining.json" + + +@pytest.fixture +def written_sidecar(sidecar_path: Path, sample_sidecar_payload: dict) -> Path: + sidecar_path.write_text(json.dumps(sample_sidecar_payload)) + return sidecar_path +``` + +- [ ] **Step 3: Write the failing test for `_stubs.py`** + +Create `tests/mining/test_stubs.py`: + +```python +"""Tests for mining/_stubs.py — ABC contract, dataclass invariants, sidecar IO.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def test_mining_capabilities_default_unsupported(): + from openjarvis.mining._stubs import MiningCapabilities + cap = MiningCapabilities(supported=False, reason="needs sm90") + assert cap.supported is False + assert cap.reason == "needs sm90" + assert cap.estimated_hashrate is None + + +def test_solo_target_dataclass(): + from openjarvis.mining._stubs import SoloTarget + t = SoloTarget(pearld_rpc_url="http://localhost:44107") + assert t.pearld_rpc_url == "http://localhost:44107" + + +def test_pool_target_dataclass(): + from openjarvis.mining._stubs import PoolTarget + t = PoolTarget(url="https://pool.example/submit", worker_id="rig01") + assert t.url == "https://pool.example/submit" + assert t.worker_id == "rig01" + + +def test_mining_config_v1_defaults(): + from openjarvis.mining._stubs import MiningConfig, SoloTarget + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qexample", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + ) + assert cfg.fee_bps == 0 + assert cfg.fee_payout_address is None + assert cfg.extra == {} + + +def test_mining_stats_v1_defaults(): + from openjarvis.mining._stubs import MiningStats + s = MiningStats(provider_id="vllm-pearl") + assert s.shares_submitted == 0 + assert s.shares_accepted == 0 + assert s.fees_owed == 0 + assert s.payout_target == "solo" + + +def test_mining_provider_is_abstract(): + from openjarvis.mining._stubs import MiningProvider + with pytest.raises(TypeError): + MiningProvider() # cannot instantiate ABC + + +def test_sidecar_write_then_read_roundtrip(sidecar_path: Path, sample_sidecar_payload: dict): + from openjarvis.mining._stubs import Sidecar + Sidecar.write(sidecar_path, sample_sidecar_payload) + payload = Sidecar.read(sidecar_path) + assert payload == sample_sidecar_payload + + +def test_sidecar_read_missing_returns_none(sidecar_path: Path): + from openjarvis.mining._stubs import Sidecar + assert Sidecar.read(sidecar_path) is None + + +def test_sidecar_remove_is_idempotent(sidecar_path: Path): + from openjarvis.mining._stubs import Sidecar + Sidecar.remove(sidecar_path) # missing file — should not raise + sidecar_path.write_text(json.dumps({"x": 1})) + Sidecar.remove(sidecar_path) + assert not sidecar_path.exists() +``` + +- [ ] **Step 4: Run tests to verify they fail** + +```bash +uv run pytest tests/mining/test_stubs.py -v +``` +Expected: ALL fail with `ImportError` on `openjarvis.mining._stubs`. + +- [ ] **Step 5: Create `_constants.py`** + +```python +# src/openjarvis/mining/_constants.py +"""Constants for the Pearl mining subsystem. + +Pinned Pearl ref OJ has tested against. Bumped per OJ release after +re-testing the integration end-to-end on a real H100/H200 host. See +spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`` +section 7.3 for the rev-bump workflow. +""" + +from __future__ import annotations + +from pathlib import Path + +PEARL_REPO = "https://github.com/pearl-research-labs/pearl.git" +# TODO at implementation time: replace with the specific commit/tag verified +# against H100. Document the chosen ref in the OJ release notes. +PEARL_PINNED_REF = "main" +PEARL_IMAGE_TAG = f"openjarvis/pearl-miner:{PEARL_PINNED_REF}" + +# Default Pearl-blessed model. Overridable via [mining.extra].model. +DEFAULT_PEARL_MODEL = "pearl-ai/Llama-3.3-70B-Instruct-pearl" + +# Default ports as Pearl's container exposes them (network_mode="host"). +DEFAULT_VLLM_PORT = 8000 +DEFAULT_GATEWAY_RPC_PORT = 8337 +DEFAULT_GATEWAY_METRICS_PORT = 8339 + +# Default pearld RPC endpoint (mainnet). +DEFAULT_PEARLD_RPC_URL = "http://localhost:44107" + +# Pre-flight free-disk requirement for the 70B model + headroom. +MIN_FREE_DISK_GB = 200 + +# Runtime sidecar location (single-session assumption — see spec §8.8). +RUNTIME_DIR = Path.home() / ".openjarvis" / "runtime" +SIDECAR_PATH = RUNTIME_DIR / "mining.json" +SIDECAR_LOCK_PATH = RUNTIME_DIR / "mining.lock" + +# Pearl source cache for build-from-pin path (see spec §7.2). +PEARL_CACHE_DIR = Path.home() / ".openjarvis" / "cache" / "pearl" +``` + +- [ ] **Step 6: Create `_stubs.py`** + +```python +# src/openjarvis/mining/_stubs.py +"""ABCs and dataclasses for the mining subsystem. + +See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`` +section 4.4 for the design rationale. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional, Union + +from openjarvis.core.config import HardwareInfo + + +# --------------------------------------------------------------------------- +# Capability descriptor +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class MiningCapabilities: + """Result of a provider's ``detect()`` call. + + ``reason`` is human-readable and surfaced verbatim by ``jarvis mine doctor`` + when ``supported=False``. + """ + + supported: bool + reason: Optional[str] = None + estimated_hashrate: Optional[float] = None # shares/sec, best-effort + + +# --------------------------------------------------------------------------- +# Submit-target tagged union (v2 seam — see spec §8.5) +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class SoloTarget: + """Mine directly to a pearld node. v1 default.""" + + pearld_rpc_url: str + + +@dataclass(slots=True) +class PoolTarget: + """Mine through an OJ-operated pool. v2 — raises NotImplementedError in v1.""" + + url: str + worker_id: Optional[str] = None + + +SubmitTarget = Union[SoloTarget, PoolTarget] + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class MiningConfig: + """User-supplied mining configuration. + + Loaded from the ``[mining]`` TOML section by ``core/config.py``. + """ + + provider: str + wallet_address: str + submit_target: SubmitTarget + fee_bps: int = 0 # v1: 0; v2: 2000 (=20%) + fee_payout_address: Optional[str] = None # v1: ignored; v2: OJ's address + extra: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Live stats (returned by ``MiningProvider.stats()``) +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class MiningStats: + provider_id: str + shares_submitted: int = 0 + shares_accepted: int = 0 + blocks_found: int = 0 + hashrate: float = 0.0 + uptime_seconds: float = 0.0 + last_share_at: Optional[float] = None + last_error: Optional[str] = None + payout_target: str = "solo" # v1: always "solo"; v2: "pool:" + fees_owed: int = 0 # v2 accounting hook; 0 in v1 + + +# --------------------------------------------------------------------------- +# The ABC +# --------------------------------------------------------------------------- + + +class MiningProvider(ABC): + """A mining provider — orchestrates a Pearl mining session for one (hardware, engine, model) combo. + + All future hardware/engine paths (Apple Silicon, AMD, Ollama) implement + this exact contract. See spec §4.4. + """ + + provider_id: str # set by subclass + + @classmethod + @abstractmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + """Return whether this provider can run on the given combo. + + Must be a pure inspection — no subprocess, no network, no Docker. Used + by ``jarvis mine doctor`` and ``jarvis mine init`` for fast capability + reporting. + """ + + @abstractmethod + async def start(self, config: MiningConfig) -> None: ... + + @abstractmethod + async def stop(self) -> None: ... + + @abstractmethod + def is_running(self) -> bool: ... + + @abstractmethod + def stats(self) -> MiningStats: ... + + +# --------------------------------------------------------------------------- +# Sidecar IO (see spec §5.3) +# --------------------------------------------------------------------------- + + +class Sidecar: + """Read/write helpers for ``~/.openjarvis/runtime/mining.json``.""" + + @staticmethod + def write(path: Path, payload: dict[str, Any]) -> None: + """Atomically write the sidecar JSON to ``path``.""" + path.parent.mkdir(parents=True, exist_ok=True) + # Atomic write: tmp file + rename + fd, tmp = tempfile.mkstemp(prefix=".mining-", dir=str(path.parent)) + try: + with os.fdopen(fd, "w") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + @staticmethod + def read(path: Path) -> Optional[dict[str, Any]]: + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return None + + @staticmethod + def remove(path: Path) -> None: + try: + path.unlink() + except FileNotFoundError: + pass +``` + +- [ ] **Step 7: Create `mining/__init__.py`** + +```python +# src/openjarvis/mining/__init__.py +"""Pearl mining subsystem. + +See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``. + +Provider modules are soft-imported below — each one fails gracefully if the +``mining-pearl`` (or future ``mining-pearl-mlx`` etc.) extra isn't installed. +""" + +from __future__ import annotations + +# Re-export the public ABCs and dataclasses for ergonomic imports. +from openjarvis.mining._stubs import ( + MiningCapabilities, + MiningConfig, + MiningProvider, + MiningStats, + PoolTarget, + Sidecar, + SoloTarget, + SubmitTarget, +) + +# Soft-import provider implementations to trigger registration. Each provider +# defines an idempotent ``ensure_registered()`` so it survives the autouse +# registry clear in ``tests/conftest.py``. +try: + from openjarvis.mining import vllm_pearl # noqa: F401 + + vllm_pearl.ensure_registered() +except ImportError: + pass + +__all__ = [ + "MiningCapabilities", + "MiningConfig", + "MiningProvider", + "MiningStats", + "PoolTarget", + "Sidecar", + "SoloTarget", + "SubmitTarget", +] +``` + +- [ ] **Step 8: Create `mining/pools/__init__.py`** (reserved for v2) + +```python +# src/openjarvis/mining/pools/__init__.py +"""RESERVED for v2 pool support. + +Do not add code here in v1. The v2 spec will define a ``PoolClient`` ABC and +``PoolClientRegistry`` peer to ``MinerRegistry``. Squatting on this path now +would create migration debt and pre-decide the v2 API. See spec §8.5. +""" + +from __future__ import annotations +``` + +- [ ] **Step 9: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_stubs.py -v +``` +Expected: 9 PASS. + +- [ ] **Step 10: Commit** + +```bash +git add src/openjarvis/mining/ tests/mining/__init__.py tests/mining/conftest.py tests/mining/test_stubs.py +git commit -m "feat(mining): add ABC, dataclasses, constants, sidecar IO" +``` + +--- + +## Task 3 — `MiningConfig` integration in `JarvisConfig` + TOML parsing + +**Files:** +- Modify: `src/openjarvis/core/config.py` +- Test: `tests/core/test_config.py` (existing — add new tests) +- Test: `tests/mining/fixtures/config_minimal.toml` +- Test: `tests/mining/fixtures/config_pool_v2.toml` + +- [ ] **Step 1: Create the fixture TOML files** + +`tests/mining/fixtures/config_minimal.toml`: + +```toml +[mining] +provider = "vllm-pearl" +wallet_address = "prl1qexampleaddress" +submit_target = "solo" +fee_bps = 0 +fee_payout_address = "" + +[mining.extra] +model = "pearl-ai/Llama-3.3-70B-Instruct-pearl" +pearld_rpc_url = "http://localhost:44107" +pearld_rpc_user = "rpcuser" +pearld_rpc_password_env = "PEARLD_RPC_PASSWORD" +``` + +`tests/mining/fixtures/config_pool_v2.toml`: + +```toml +[mining] +provider = "vllm-pearl" +wallet_address = "prl1qexampleaddress" +submit_target = "pool:https://pool.openjarvis.ai/submit" + +[mining.extra] +model = "pearl-ai/Llama-3.3-70B-Instruct-pearl" +pearld_rpc_url = "http://localhost:44107" +pearld_rpc_user = "rpcuser" +pearld_rpc_password_env = "PEARLD_RPC_PASSWORD" +``` + +- [ ] **Step 2: Write the failing tests** + +Add to `tests/core/test_config.py`: + +```python +def test_mining_config_absent_means_none(tmp_path): + from openjarvis.core.config import load_config + cfg_path = tmp_path / "config.toml" + cfg_path.write_text("") # empty config + cfg = load_config(cfg_path) + assert cfg.mining is None + + +def test_mining_config_solo_parsed(tmp_path): + from pathlib import Path + from openjarvis.core.config import load_config + from openjarvis.mining._stubs import SoloTarget + src = Path(__file__).parent.parent / "mining" / "fixtures" / "config_minimal.toml" + target = tmp_path / "config.toml" + target.write_text(src.read_text()) + cfg = load_config(target) + assert cfg.mining is not None + assert cfg.mining.provider == "vllm-pearl" + assert cfg.mining.wallet_address == "prl1qexampleaddress" + assert isinstance(cfg.mining.submit_target, SoloTarget) + assert cfg.mining.submit_target.pearld_rpc_url == "http://localhost:44107" + assert cfg.mining.fee_bps == 0 + assert cfg.mining.extra["model"] == "pearl-ai/Llama-3.3-70B-Instruct-pearl" + + +def test_mining_config_pool_parsed_as_pool_target(tmp_path): + from pathlib import Path + from openjarvis.core.config import load_config + from openjarvis.mining._stubs import PoolTarget + src = Path(__file__).parent.parent / "mining" / "fixtures" / "config_pool_v2.toml" + target = tmp_path / "config.toml" + target.write_text(src.read_text()) + cfg = load_config(target) + assert isinstance(cfg.mining.submit_target, PoolTarget) + assert cfg.mining.submit_target.url == "https://pool.openjarvis.ai/submit" +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +uv run pytest tests/core/test_config.py -k mining -v +``` +Expected: 3 FAIL on `cfg.mining` attribute missing. + +- [ ] **Step 4: Add `mining` field to `JarvisConfig`** + +In `src/openjarvis/core/config.py`, add an import near the top: + +```python +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from openjarvis.mining._stubs import MiningConfig +``` + +Add the field to `JarvisConfig` (insert in alphabetical order — after `memory_files`, before `operators`): + +```python + mining: Optional["MiningConfig"] = None +``` + +- [ ] **Step 5: Implement TOML parsing for `[mining]` section** + +In `src/openjarvis/core/config.py`, locate the existing `load_config()` function (around line 1541). Find the section that processes the loaded TOML data into the `JarvisConfig` (look for where it iterates the `data` dict and assigns to dataclass fields). Add a dedicated handler for `mining` because of the tagged-union parsing — it can't go through the generic walker. + +Add this helper near the other `_parse_*` helpers in `core/config.py`: + +```python +def _parse_mining_section(data: dict) -> Optional["MiningConfig"]: + """Parse the ``[mining]`` TOML section into a ``MiningConfig``. + + Returns None if the section is absent. Resolves the ``submit_target`` + string into a ``SoloTarget`` or ``PoolTarget`` tagged union. + """ + if "mining" not in data: + return None + + # Lazy import to avoid circular: mining/__init__.py imports core/config + # transitively via _stubs, but only at runtime. + from openjarvis.mining._stubs import MiningConfig, PoolTarget, SoloTarget + + section = data["mining"] + extra = section.get("extra", {}) or {} + + target_str = section.get("submit_target", "solo") + submit_target: Any + if target_str == "solo": + submit_target = SoloTarget( + pearld_rpc_url=extra.get("pearld_rpc_url", "http://localhost:44107") + ) + elif isinstance(target_str, str) and target_str.startswith("pool:"): + submit_target = PoolTarget(url=target_str[len("pool:") :]) + else: + raise ValueError( + f"[mining].submit_target must be 'solo' or 'pool:', got {target_str!r}" + ) + + return MiningConfig( + provider=section["provider"], + wallet_address=section["wallet_address"], + submit_target=submit_target, + fee_bps=int(section.get("fee_bps", 0)), + fee_payout_address=section.get("fee_payout_address") or None, + extra={k: v for k, v in extra.items()}, + ) +``` + +In `load_config()`, after the other section processing and before returning `cfg`: + +```python + cfg.mining = _parse_mining_section(data) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +uv run pytest tests/core/test_config.py -k mining -v +``` +Expected: 3 PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/openjarvis/core/config.py tests/core/test_config.py tests/mining/fixtures/ +git commit -m "feat(mining): integrate MiningConfig into JarvisConfig with TOML parsing" +``` + +--- + +## Task 4 — Capability discovery (`mining/_discovery.py`) + +**Files:** +- Create: `src/openjarvis/mining/_discovery.py` +- Test: `tests/mining/test_discovery.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/mining/test_discovery.py`: + +```python +"""Tests for mining/_discovery.py — capability detection matrix.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + + +def test_detect_supported_on_h100(hopper_hw): + from openjarvis.mining._discovery import detect_for_engine_model + cap = detect_for_engine_model( + hw=hopper_hw, + engine_id="vllm", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + provider_id="vllm-pearl", + ) + assert cap.supported is True + assert cap.reason is None + + +def test_detect_unsupported_on_ada_4090(ada_hw): + from openjarvis.mining._discovery import detect_for_engine_model + cap = detect_for_engine_model( + hw=ada_hw, + engine_id="vllm", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "sm90" in cap.reason.lower() or "compute_capability" in cap.reason.lower() + + +def test_detect_unsupported_on_apple(apple_hw): + from openjarvis.mining._discovery import detect_for_engine_model + cap = detect_for_engine_model( + hw=apple_hw, + engine_id="mlx", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert cap.reason is not None # specific reason — Apple Silicon route is Spec B + + +def test_detect_unsupported_for_non_vllm_engine(hopper_hw): + from openjarvis.mining._discovery import detect_for_engine_model + cap = detect_for_engine_model( + hw=hopper_hw, + engine_id="ollama", + model="qwen3:8b", + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "vllm" in cap.reason.lower() or "engine" in cap.reason.lower() + + +def test_detect_unsupported_for_non_pearl_model(hopper_hw): + from openjarvis.mining._discovery import detect_for_engine_model + cap = detect_for_engine_model( + hw=hopper_hw, + engine_id="vllm", + model="meta-llama/Llama-3.3-70B-Instruct", # NOT the -pearl variant + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "pearl" in cap.reason.lower() + + +def test_detect_unsupported_for_low_vram(): + from openjarvis.mining._discovery import detect_for_engine_model + from openjarvis.core.config import GpuInfo, HardwareInfo + hw = HardwareInfo( + platform="linux", + gpu=GpuInfo( + vendor="nvidia", + name="NVIDIA H100 PCIe-40GB", + vram_gb=40.0, # below 70 GB threshold + compute_capability="9.0", + count=1, + ), + ) + cap = detect_for_engine_model( + hw=hw, engine_id="vllm", model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "vram" in cap.reason.lower() or "memory" in cap.reason.lower() + + +def test_check_docker_available_true(): + from openjarvis.mining._discovery import check_docker_available + with patch("openjarvis.mining._discovery._docker_client") as fake: + fake.return_value.ping.return_value = True + fake.return_value.version.return_value = {"Version": "24.0.7"} + ok, info = check_docker_available() + assert ok is True + assert "24.0.7" in info + + +def test_check_docker_available_false_when_daemon_down(): + from openjarvis.mining._discovery import check_docker_available + with patch("openjarvis.mining._discovery._docker_client") as fake: + fake.side_effect = Exception("Cannot connect to the Docker daemon") + ok, info = check_docker_available() + assert ok is False + assert "daemon" in info.lower() or "connect" in info.lower() + + +def test_check_disk_free_passes(tmp_path): + from openjarvis.mining._discovery import check_disk_free + with patch("openjarvis.mining._discovery.shutil.disk_usage") as du: + # 500 GB free + du.return_value = MagicMock(total=1_000_000_000_000, used=500_000_000_000, free=500_000_000_000) + ok, info = check_disk_free(tmp_path) + assert ok is True + + +def test_check_disk_free_fails_below_threshold(tmp_path): + from openjarvis.mining._discovery import check_disk_free + with patch("openjarvis.mining._discovery.shutil.disk_usage") as du: + du.return_value = MagicMock(total=1_000_000_000_000, used=950_000_000_000, free=50_000_000_000) + ok, info = check_disk_free(tmp_path) + assert ok is False + + +def test_check_pearld_reachable_true(): + from openjarvis.mining._discovery import check_pearld_reachable + with patch("openjarvis.mining._discovery.httpx.post") as post: + post.return_value.status_code = 200 + post.return_value.json.return_value = {"result": {"blocks": 442107, "headers": 442107}} + ok, info = check_pearld_reachable("http://localhost:44107", "user", "pass") + assert ok is True + assert "442107" in info + + +def test_check_pearld_reachable_false_on_connection_error(): + from openjarvis.mining._discovery import check_pearld_reachable + import httpx + with patch("openjarvis.mining._discovery.httpx.post") as post: + post.side_effect = httpx.ConnectError("connection refused") + ok, info = check_pearld_reachable("http://localhost:44107", "user", "pass") + assert ok is False + + +def test_check_wallet_address_format_valid(): + from openjarvis.mining._discovery import check_wallet_address_format + ok, info = check_wallet_address_format("prl1qexampleaddress0123456789") + assert ok is True + + +def test_check_wallet_address_format_invalid(): + from openjarvis.mining._discovery import check_wallet_address_format + ok, info = check_wallet_address_format("not-a-pearl-address") + assert ok is False +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/mining/test_discovery.py -v +``` +Expected: ALL fail with `ImportError`. + +- [ ] **Step 3: Create `_discovery.py`** + +```python +# src/openjarvis/mining/_discovery.py +"""Capability detection for mining providers. + +Each function answers a single yes/no question and returns ``(ok: bool, +info: str)`` where ``info`` is a short human-readable explanation surfaced +verbatim by ``jarvis mine doctor``. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Optional, Tuple + +import httpx + +from openjarvis.core.config import HardwareInfo +from openjarvis.mining._stubs import MiningCapabilities + +# --------------------------------------------------------------------------- +# Constants for the v1 vllm-pearl provider +# --------------------------------------------------------------------------- + +REQUIRED_COMPUTE_CAPABILITY = "9.0" # sm_90a — Hopper +REQUIRED_VRAM_GB = 70.0 +SUPPORTED_VLLM_ENGINE_IDS = frozenset({"vllm"}) + + +def detect_for_engine_model( + *, + hw: HardwareInfo, + engine_id: str, + model: str, + provider_id: str, +) -> MiningCapabilities: + """Capability matrix for the ``vllm-pearl`` provider. + + Pure inspection. No subprocess, no Docker, no network. Used by + ``jarvis mine doctor`` and ``jarvis mine init``. + """ + if provider_id != "vllm-pearl": + return MiningCapabilities( + False, reason=f"unknown provider {provider_id!r}" + ) + + # Engine + if engine_id not in SUPPORTED_VLLM_ENGINE_IDS: + return MiningCapabilities( + False, + reason=f"engine '{engine_id}' has no Pearl plugin in v1; use vllm", + ) + + # Hardware + if hw.gpu is None: + return MiningCapabilities(False, reason="no GPU detected") + if hw.gpu.vendor != "nvidia": + return MiningCapabilities( + False, + reason=f"vllm-pearl requires NVIDIA Hopper; detected {hw.gpu.vendor!r}. " + f"Apple Silicon support tracked in Spec B.", + ) + if not hw.gpu.compute_capability.startswith("9.0"): + return MiningCapabilities( + False, + reason=f"needs compute_capability 9.0 (sm_90a / H100/H200); detected " + f"{hw.gpu.compute_capability!r} ({hw.gpu.name})", + ) + if hw.gpu.vram_gb < REQUIRED_VRAM_GB: + return MiningCapabilities( + False, + reason=f"needs ≥{REQUIRED_VRAM_GB:.0f} GB VRAM for the Pearl 70B model; " + f"detected {hw.gpu.vram_gb:.0f} GB", + ) + + # Model + if "-pearl" not in model.lower(): + return MiningCapabilities( + False, + reason=f"model {model!r} has no Pearl-blessed variant — use a " + f"'pearl-ai/*-pearl' model", + ) + + return MiningCapabilities(supported=True) + + +# --------------------------------------------------------------------------- +# Doctor checks (one per row of `jarvis mine doctor` output) +# --------------------------------------------------------------------------- + + +def _docker_client(): # pragma: no cover - trivial wrapper, mocked in tests + import docker + + return docker.from_env() + + +def check_docker_available() -> Tuple[bool, str]: + try: + c = _docker_client() + c.ping() + ver = c.version().get("Version", "unknown") + return True, f"running {ver}" + except Exception as e: # noqa: BLE001 - intentionally broad + return False, str(e).splitlines()[0] + + +def check_disk_free(path: Path) -> Tuple[bool, str]: + from openjarvis.mining._constants import MIN_FREE_DISK_GB + + usage = shutil.disk_usage(path) + free_gb = usage.free / (1024**3) + if free_gb < MIN_FREE_DISK_GB: + return False, f"only {free_gb:.0f} GB free (need ≥{MIN_FREE_DISK_GB} GB)" + return True, f"{free_gb:.0f} GB free" + + +def check_pearld_reachable( + url: str, user: str, password: str +) -> Tuple[bool, str]: + """Probe pearld via JSON-RPC ``getblockchaininfo``.""" + try: + resp = httpx.post( + url, + json={"jsonrpc": "1.0", "id": "ojprobe", "method": "getblockchaininfo", "params": []}, + auth=(user, password), + timeout=5.0, + ) + if resp.status_code != 200: + return False, f"HTTP {resp.status_code}" + data = resp.json() + result = data.get("result") or {} + blocks = result.get("blocks", "?") + headers = result.get("headers", "?") + synced = blocks == headers + marker = "synced" if synced else f"syncing ({blocks}/{headers})" + return True, f"block height {blocks} ({marker})" + except httpx.ConnectError as e: + return False, f"connection refused: {e}" + except Exception as e: # noqa: BLE001 + return False, str(e).splitlines()[0] + + +def check_wallet_address_format(address: str) -> Tuple[bool, str]: + """Pearl Taproot addresses begin with ``prl1q...``. + + We do *not* attempt to validate the bech32 checksum — that's a stronger + contract that may shift between Pearl revs. Format check only. + """ + if not address: + return False, "empty" + if not address.startswith("prl1q"): + return False, f"expected 'prl1q...' prefix; got {address[:6]!r}" + if len(address) < 14: + return False, f"too short ({len(address)} chars)" + return True, "format ok" +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_discovery.py -v +``` +Expected: 13 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/mining/_discovery.py tests/mining/test_discovery.py +git commit -m "feat(mining): capability detection + doctor checks" +``` + +--- + +## Task 5 — `PearlDockerLauncher` image acquisition + +**Files:** +- Create: `src/openjarvis/mining/_docker.py` +- Test: `tests/mining/test_docker.py` + +- [ ] **Step 1: Write the failing tests for `ensure_image()`** + +Create `tests/mining/test_docker.py`: + +```python +"""Tests for mining/_docker.py — Docker SDK orchestration via mocks.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + + +def test_ensure_image_already_local(): + from openjarvis.mining._docker import PearlDockerLauncher + fake = MagicMock() + fake.images.get.return_value = MagicMock(id="sha256:abc", tags=["openjarvis/pearl-miner:main"]) + launcher = PearlDockerLauncher(client=fake) + out = launcher.ensure_image("openjarvis/pearl-miner:main") + assert out == "openjarvis/pearl-miner:main" + fake.images.get.assert_called_once_with("openjarvis/pearl-miner:main") + fake.images.pull.assert_not_called() + + +def test_ensure_image_pulls_if_published(): + from openjarvis.mining._docker import PearlDockerLauncher + import docker.errors as derr + fake = MagicMock() + fake.images.get.side_effect = derr.ImageNotFound("nope") + fake.images.pull.return_value = MagicMock(id="sha256:def") + launcher = PearlDockerLauncher(client=fake) + out = launcher.ensure_image("registry.example/pearl-miner:1.0") + assert out == "registry.example/pearl-miner:1.0" + fake.images.pull.assert_called_once_with("registry.example/pearl-miner:1.0") + + +def test_ensure_image_falls_back_to_build_for_default_tag(): + from openjarvis.mining._docker import PearlDockerLauncher + from openjarvis.mining._constants import PEARL_IMAGE_TAG + import docker.errors as derr + fake = MagicMock() + fake.images.get.side_effect = derr.ImageNotFound("nope") + fake.images.pull.side_effect = derr.NotFound("registry refused") + launcher = PearlDockerLauncher(client=fake) + with patch.object(launcher, "_clone_pearl_repo") as clone, patch.object( + launcher, "_docker_build" + ) as build: + clone.return_value = "/tmp/pearl-cache" + build.return_value = PEARL_IMAGE_TAG + out = launcher.ensure_image(PEARL_IMAGE_TAG) + assert out == PEARL_IMAGE_TAG + clone.assert_called_once() + build.assert_called_once() + + +def test_ensure_image_errors_when_non_default_tag_missing(): + from openjarvis.mining._docker import PearlDockerLauncher, ImageAcquisitionError + import docker.errors as derr + import pytest + fake = MagicMock() + fake.images.get.side_effect = derr.ImageNotFound("nope") + fake.images.pull.side_effect = derr.NotFound("registry refused") + launcher = PearlDockerLauncher(client=fake) + with pytest.raises(ImageAcquisitionError) as ei: + launcher.ensure_image("user/custom-image:tag") + assert "user/custom-image:tag" in str(ei.value) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/mining/test_docker.py -v +``` +Expected: FAIL with `ImportError`. + +- [ ] **Step 3: Implement the launcher's image-acquisition path** + +Create `src/openjarvis/mining/_docker.py`: + +```python +# src/openjarvis/mining/_docker.py +"""Pearl Docker container orchestration. + +See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`` +section 7 for the design. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Any, Optional + +from openjarvis.mining._constants import ( + PEARL_CACHE_DIR, + PEARL_IMAGE_TAG, + PEARL_PINNED_REF, + PEARL_REPO, +) + + +class ImageAcquisitionError(RuntimeError): + """Raised when an image can be neither found, pulled, nor built.""" + + +class PearlDockerLauncher: + """Orchestrates the Pearl miner container. + + Construct with a ``docker.DockerClient`` (real or mocked). + """ + + def __init__(self, client: Any): + self._client = client + self._container: Optional[Any] = None + + # ----------------------------------------------------------------- + # Image acquisition + # ----------------------------------------------------------------- + + def ensure_image(self, tag: str) -> str: + """Resolve ``tag`` to a usable local image, building if necessary. + + Selection order (see spec §7.2): + 1. Image present locally → use it. + 2. Image pullable from a registry → pull and use. + 3. ``tag`` matches OJ's default → clone Pearl + ``docker build``. + 4. Otherwise → ``ImageAcquisitionError``. + """ + import docker.errors as derr + + try: + self._client.images.get(tag) + return tag + except derr.ImageNotFound: + pass + + try: + self._client.images.pull(tag) + return tag + except (derr.NotFound, derr.APIError): + pass + + if tag == PEARL_IMAGE_TAG: + cache = self._clone_pearl_repo() + return self._docker_build(cache, tag) + + raise ImageAcquisitionError( + f"image {tag!r} not present locally, not pullable, and not OJ's " + f"default tag (no build fallback). Either build it manually with " + f"`docker buildx build -t {tag} -f miner/vllm-miner/Dockerfile .` " + f"from the Pearl repo, or set [mining.extra].docker_image_tag to " + f"the OJ default ({PEARL_IMAGE_TAG}) to enable the build fallback." + ) + + def _clone_pearl_repo(self) -> Path: + """Clone Pearl at the pinned ref into the OJ cache.""" + PEARL_CACHE_DIR.parent.mkdir(parents=True, exist_ok=True) + if PEARL_CACHE_DIR.exists(): + subprocess.run( + ["git", "fetch", "origin", PEARL_PINNED_REF], + cwd=str(PEARL_CACHE_DIR), + check=True, + ) + subprocess.run( + ["git", "checkout", PEARL_PINNED_REF], + cwd=str(PEARL_CACHE_DIR), + check=True, + ) + else: + subprocess.run( + ["git", "clone", "--branch", PEARL_PINNED_REF, PEARL_REPO, str(PEARL_CACHE_DIR)], + check=True, + ) + return PEARL_CACHE_DIR + + def _docker_build(self, repo_path: Path, tag: str) -> str: + """Run ``docker buildx build`` with Pearl's Dockerfile against the monorepo.""" + # Build context must be the repo root; Dockerfile is at miner/vllm-miner/Dockerfile. + cmd = [ + "docker", + "buildx", + "build", + "-t", + tag, + "-f", + "miner/vllm-miner/Dockerfile", + ".", + ] + subprocess.run(cmd, cwd=str(repo_path), check=True) + return tag +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_docker.py -v +``` +Expected: 4 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/mining/_docker.py tests/mining/test_docker.py +git commit -m "feat(mining): PearlDockerLauncher image acquisition (pull-or-build)" +``` + +--- + +## Task 6 — `PearlDockerLauncher` container lifecycle + +**Files:** +- Modify: `src/openjarvis/mining/_docker.py` +- Modify: `tests/mining/test_docker.py` + +- [ ] **Step 1: Write failing tests for `start()` / `stop()` / `is_running()` / `get_logs()`** + +Append to `tests/mining/test_docker.py`: + +```python +import os +import pytest + + +@pytest.fixture +def _env_password(monkeypatch): + monkeypatch.setenv("PEARLD_RPC_PASSWORD", "secret123") + + +def test_launcher_start_calls_run_with_expected_kwargs(_env_password): + from openjarvis.mining._docker import PearlDockerLauncher + from openjarvis.mining._stubs import MiningConfig, SoloTarget + fake = MagicMock() + fake.containers.run.return_value = MagicMock(id="cid-1", status="running") + launcher = PearlDockerLauncher(client=fake) + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + extra={ + "docker_image_tag": "openjarvis/pearl-miner:main", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "vllm_port": 8000, + "gpu_memory_utilization": 0.9, + "max_model_len": 8192, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "PEARLD_RPC_PASSWORD", + "hf_token_env": "HF_TOKEN", + }, + ) + container = launcher.start(cfg, image="openjarvis/pearl-miner:main") + assert container.id == "cid-1" + fake.containers.run.assert_called_once() + kwargs = fake.containers.run.call_args.kwargs + # Image + assert kwargs["image"] == "openjarvis/pearl-miner:main" + # Command starts with the model name (positional), then args + assert kwargs["command"][0] == "pearl-ai/Llama-3.3-70B-Instruct-pearl" + assert "--gpu-memory-utilization" in kwargs["command"] + # Restart policy + assert kwargs["restart_policy"]["Name"] == "unless-stopped" + # Env contains the password (resolved from env var name) + assert kwargs["environment"]["PEARLD_RPC_PASSWORD"] == "secret123" + # Mining address pass-through + assert kwargs["environment"]["PEARLD_MINING_ADDRESS"] == "prl1qaaa" + # MINER_RPC_TRANSPORT set so OJ can poll port 8337 + assert kwargs["environment"]["MINER_RPC_TRANSPORT"] == "tcp" + # GPU device request + assert kwargs["device_requests"] + + +def test_launcher_stop_calls_container_stop_and_remove(): + from openjarvis.mining._docker import PearlDockerLauncher + fake_client = MagicMock() + fake_container = MagicMock() + launcher = PearlDockerLauncher(client=fake_client) + launcher._container = fake_container + launcher.stop() + fake_container.stop.assert_called_once() + + +def test_launcher_is_running_when_container_running(): + from openjarvis.mining._docker import PearlDockerLauncher + fake_client = MagicMock() + fake_container = MagicMock(status="running") + fake_container.reload.return_value = None + launcher = PearlDockerLauncher(client=fake_client) + launcher._container = fake_container + assert launcher.is_running() is True + + +def test_launcher_is_running_false_when_container_exited(): + from openjarvis.mining._docker import PearlDockerLauncher + fake_client = MagicMock() + fake_container = MagicMock() + fake_container.reload.return_value = None + fake_container.status = "exited" + launcher = PearlDockerLauncher(client=fake_client) + launcher._container = fake_container + assert launcher.is_running() is False + + +def test_launcher_get_logs_returns_decoded_string(): + from openjarvis.mining._docker import PearlDockerLauncher + fake_client = MagicMock() + fake_container = MagicMock() + fake_container.logs.return_value = b"hello\nworld\n" + launcher = PearlDockerLauncher(client=fake_client) + launcher._container = fake_container + assert "hello" in launcher.get_logs(tail=100) + + +def test_launcher_start_errors_when_password_env_missing(): + from openjarvis.mining._docker import PearlDockerLauncher, ConfigurationError + from openjarvis.mining._stubs import MiningConfig, SoloTarget + fake = MagicMock() + launcher = PearlDockerLauncher(client=fake) + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + extra={ + "docker_image_tag": "openjarvis/pearl-miner:main", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "vllm_port": 8000, + "gpu_memory_utilization": 0.9, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "DOES_NOT_EXIST_IN_ENV", + }, + ) + with pytest.raises(ConfigurationError) as ei: + launcher.start(cfg, image="openjarvis/pearl-miner:main") + assert "DOES_NOT_EXIST_IN_ENV" in str(ei.value) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/mining/test_docker.py -v +``` +Expected: 6 new FAIL. + +- [ ] **Step 3: Implement `start()`, `stop()`, `is_running()`, `get_logs()`** + +Append to `src/openjarvis/mining/_docker.py`: + +```python +class ConfigurationError(RuntimeError): + """Raised when required env vars or config fields are missing.""" + + + # ----- in PearlDockerLauncher class, append these methods ----- + + def start(self, config: "MiningConfig", image: str) -> Any: + """Launch the Pearl miner container. + + ``image`` must already be resolved by ``ensure_image()``. + Returns the docker.models.containers.Container object. + """ + from openjarvis.mining._stubs import MiningConfig # noqa: F401 (typing only) + + extra = config.extra + # Resolve secret env vars (we hold the *name*, not the value). + password_env = extra.get("pearld_rpc_password_env", "PEARLD_RPC_PASSWORD") + password = os.environ.get(password_env) + if password is None: + raise ConfigurationError( + f"environment variable {password_env!r} is not set; " + f"set it before running `jarvis mine start`" + ) + + hf_token_env = extra.get("hf_token_env", "HF_TOKEN") + hf_token = os.environ.get(hf_token_env, "") + + model = extra.get("model", "pearl-ai/Llama-3.3-70B-Instruct-pearl") + vllm_port = int(extra.get("vllm_port", 8000)) + gpu_mem = float(extra.get("gpu_memory_utilization", 0.9)) + max_len = int(extra.get("max_model_len", 8192)) + + command = [ + model, + "--host", "0.0.0.0", + "--port", str(vllm_port), + "--gpu-memory-utilization", str(gpu_mem), + "--enforce-eager", + "--max-model-len", str(max_len), + ] + + environment = { + "PEARLD_RPC_URL": extra.get("pearld_rpc_url", "http://localhost:44107"), + "PEARLD_RPC_USER": extra.get("pearld_rpc_user", "rpcuser"), + "PEARLD_RPC_PASSWORD": password, + "PEARLD_MINING_ADDRESS": config.wallet_address, + "HF_TOKEN": hf_token, + "MINER_RPC_TRANSPORT": "tcp", + } + + # Dynamic import so tests don't need the real `docker` package shape. + try: + from docker.types import DeviceRequest + device_requests = [DeviceRequest(count=-1, capabilities=[["gpu"]])] + except ImportError: # pragma: no cover + device_requests = None + + hf_cache = Path.home() / ".cache" / "huggingface" + volumes = { + str(hf_cache): {"bind": "/root/.cache/huggingface", "mode": "rw"}, + } + + self._container = self._client.containers.run( + image=image, + command=command, + name="openjarvis-pearl-miner", + detach=True, + auto_remove=False, + restart_policy={"Name": "unless-stopped"}, + device_requests=device_requests, + shm_size="8g", + network_mode="host", + volumes=volumes, + environment=environment, + ) + return self._container + + def stop(self, timeout: int = 30) -> None: + if self._container is None: + return + try: + self._container.stop(timeout=timeout) + except Exception: # noqa: BLE001 - best-effort + pass + self._container = None + + def is_running(self) -> bool: + if self._container is None: + return False + try: + self._container.reload() + except Exception: # noqa: BLE001 + return False + return getattr(self._container, "status", "") == "running" + + def get_logs(self, tail: int = 200) -> str: + if self._container is None: + return "" + raw = self._container.logs(tail=tail) + if isinstance(raw, bytes): + return raw.decode("utf-8", errors="replace") + return str(raw) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_docker.py -v +``` +Expected: 10 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/mining/_docker.py tests/mining/test_docker.py +git commit -m "feat(mining): PearlDockerLauncher container lifecycle (start/stop/is_running/logs)" +``` + +--- + +## Task 7 — Gateway metrics adapter + +**Files:** +- Modify: `src/openjarvis/mining/vllm_pearl.py` (creating in next task — for now create the parser as a helper) +- Create: `src/openjarvis/mining/_metrics.py` +- Create: `tests/mining/fixtures/gateway_metrics_sample.txt` +- Create: `tests/mining/test_metrics.py` + +> **Note for the implementer:** The fixture file in this task is a *placeholder* with the metric names the spec assumes (see spec §8.2 table). At implementation time, replace it with real Prometheus output captured from a Pearl gateway run against the pinned Pearl ref. Document the capture procedure in `tests/mining/fixtures/README.md`. + +- [ ] **Step 1: Create the (placeholder) Prometheus fixture** + +`tests/mining/fixtures/gateway_metrics_sample.txt`: + +``` +# HELP pearl_gateway_shares_submitted_total Total mining shares submitted. +# TYPE pearl_gateway_shares_submitted_total counter +pearl_gateway_shares_submitted_total 12345 +# HELP pearl_gateway_shares_accepted_total Total mining shares accepted by pearld. +# TYPE pearl_gateway_shares_accepted_total counter +pearl_gateway_shares_accepted_total 12300 +# HELP pearl_gateway_blocks_found_total Total blocks found. +# TYPE pearl_gateway_blocks_found_total counter +pearl_gateway_blocks_found_total 7 +# HELP pearl_gateway_last_share_timestamp Unix timestamp of last share submission. +# TYPE pearl_gateway_last_share_timestamp gauge +pearl_gateway_last_share_timestamp 1714867500 +# HELP pearl_gateway_errors_total Total errors observed by the gateway. +# TYPE pearl_gateway_errors_total counter +pearl_gateway_errors_total 0 +# HELP process_start_time_seconds Process start time (Unix seconds). +# TYPE process_start_time_seconds gauge +process_start_time_seconds 1714865000 +``` + +Also create `tests/mining/fixtures/README.md`: + +```markdown +# Mining test fixtures + +`gateway_metrics_sample.txt` is captured Prometheus output from a real +Pearl gateway run against the pinned Pearl ref. Re-capture by: + +1. Run the Pearl Docker image on an H100 host per the spec §7.4 launch shape. +2. `curl http://127.0.0.1:8339/metrics > gateway_metrics_sample.txt` once + the gateway is healthy and at least 10 shares have been submitted. +3. Strip any cardinality bombs (per-prompt or per-block-time histograms) + that bloat the file. +4. Commit, citing the Pearl commit/tag the capture was taken against. + +If Pearl renames metrics, update `mining/_metrics.py::PROM_*` constants and +re-capture. +``` + +- [ ] **Step 2: Write the failing test** + +Create `tests/mining/test_metrics.py`: + +```python +"""Tests for mining/_metrics.py — Prometheus → MiningStats adapter.""" + +from __future__ import annotations + +from pathlib import Path + + +FIXTURE = Path(__file__).parent / "fixtures" / "gateway_metrics_sample.txt" + + +def test_parse_gateway_metrics_full(): + from openjarvis.mining._metrics import parse_gateway_metrics + text = FIXTURE.read_text() + stats = parse_gateway_metrics(text, provider_id="vllm-pearl") + assert stats.provider_id == "vllm-pearl" + assert stats.shares_submitted == 12345 + assert stats.shares_accepted == 12300 + assert stats.blocks_found == 7 + assert stats.last_share_at == 1714867500.0 + # Uptime computed as now - process_start_time, but not asserted exactly. + assert stats.uptime_seconds >= 0 + + +def test_parse_gateway_metrics_missing_metrics_zero_fills(): + from openjarvis.mining._metrics import parse_gateway_metrics + stats = parse_gateway_metrics("# empty exposition\n", provider_id="vllm-pearl") + assert stats.shares_submitted == 0 + assert stats.shares_accepted == 0 + assert stats.blocks_found == 0 + assert stats.last_share_at is None + + +def test_parse_gateway_metrics_ignores_comment_lines(): + from openjarvis.mining._metrics import parse_gateway_metrics + stats = parse_gateway_metrics( + "# HELP something\n# TYPE something counter\nsomething 99\n", + provider_id="vllm-pearl", + ) + assert stats.shares_submitted == 0 # 'something' isn't a Pearl metric +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +uv run pytest tests/mining/test_metrics.py -v +``` +Expected: 3 FAIL. + +- [ ] **Step 4: Implement the adapter** + +Create `src/openjarvis/mining/_metrics.py`: + +```python +# src/openjarvis/mining/_metrics.py +"""Pearl gateway Prometheus → MiningStats adapter. + +The gateway exposes ``:8339/metrics`` in plain Prometheus exposition format. +This is the most stable contract Pearl publishes; deeper RPC introspection +on ``:8337`` is deferred to v2 (where it's needed for pool share accounting). + +If Pearl renames metrics, change the ``PROM_*`` constants here — that's the +only place the metric names live. +""" + +from __future__ import annotations + +import logging +import time +from typing import Optional + +from openjarvis.mining._stubs import MiningStats + +log = logging.getLogger(__name__) + +# Pearl metric names. See spec §8.2 — verify against the fixture committed +# in tests/mining/fixtures/gateway_metrics_sample.txt. +PROM_SHARES_SUBMITTED = "pearl_gateway_shares_submitted_total" +PROM_SHARES_ACCEPTED = "pearl_gateway_shares_accepted_total" +PROM_BLOCKS_FOUND = "pearl_gateway_blocks_found_total" +PROM_LAST_SHARE_TS = "pearl_gateway_last_share_timestamp" +PROM_ERRORS_TOTAL = "pearl_gateway_errors_total" +PROM_PROCESS_START = "process_start_time_seconds" + + +def _parse_simple_metric(text: str, name: str) -> Optional[float]: + """Find the first occurrence of a simple, label-less metric. + + Lines look like ``metric_name 12345`` or ``metric_name{label="x"} 12345``. + For the v1 adapter we ignore labels and take the first non-comment match. + """ + for line in text.splitlines(): + if line.startswith("#") or not line.strip(): + continue + # Split on the first whitespace; the metric name is everything up to + # an optional `{...}` label block. + head, _, value = line.partition(" ") + head = head.split("{", 1)[0] + if head == name: + try: + return float(value.strip()) + except ValueError: + return None + return None + + +def parse_gateway_metrics(text: str, *, provider_id: str) -> MiningStats: + """Convert a Prometheus exposition payload into a ``MiningStats``.""" + submitted = _parse_simple_metric(text, PROM_SHARES_SUBMITTED) or 0.0 + accepted = _parse_simple_metric(text, PROM_SHARES_ACCEPTED) or 0.0 + blocks = _parse_simple_metric(text, PROM_BLOCKS_FOUND) or 0.0 + last_share_ts = _parse_simple_metric(text, PROM_LAST_SHARE_TS) + errors = _parse_simple_metric(text, PROM_ERRORS_TOTAL) or 0.0 + proc_start = _parse_simple_metric(text, PROM_PROCESS_START) + + uptime = 0.0 + if proc_start is not None: + uptime = max(0.0, time.time() - proc_start) + + last_error: Optional[str] = None + if errors > 0: + last_error = f"{int(errors)} gateway errors observed" + + return MiningStats( + provider_id=provider_id, + shares_submitted=int(submitted), + shares_accepted=int(accepted), + blocks_found=int(blocks), + # Hashrate as a derived rate is not meaningful from a single snapshot; + # the v1.x persistent collector will compute it. v1 leaves it 0. + hashrate=0.0, + uptime_seconds=uptime, + last_share_at=last_share_ts, + last_error=last_error, + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_metrics.py -v +``` +Expected: 3 PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/mining/_metrics.py tests/mining/test_metrics.py tests/mining/fixtures/ +git commit -m "feat(mining): Prometheus gateway metrics adapter" +``` + +--- + +## Task 8 — `VllmPearlProvider` (the only v1 provider) + +**Files:** +- Create: `src/openjarvis/mining/vllm_pearl.py` +- Create: `tests/mining/test_vllm_pearl.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/mining/test_vllm_pearl.py`: + +```python +"""End-to-end tests for VllmPearlProvider with mocked Docker + filesystem.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +def test_vllm_pearl_detect_supported_on_h100(hopper_hw): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + cap = VllmPearlProvider.detect( + hopper_hw, engine_id="vllm", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + ) + assert cap.supported is True + + +def test_vllm_pearl_detect_unsupported_on_apple(apple_hw): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + cap = VllmPearlProvider.detect( + apple_hw, engine_id="mlx", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + ) + assert cap.supported is False + + +@pytest.mark.asyncio +async def test_vllm_pearl_start_writes_sidecar(tmp_path, monkeypatch): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + from openjarvis.mining._stubs import MiningConfig, SoloTarget, Sidecar + + sidecar_path = tmp_path / "mining.json" + monkeypatch.setattr( + "openjarvis.mining.vllm_pearl.SIDECAR_PATH", sidecar_path + ) + monkeypatch.setenv("PEARLD_RPC_PASSWORD", "x") + + fake_client = MagicMock() + fake_container = MagicMock(id="cid-xyz") + fake_container.status = "running" + fake_client.containers.run.return_value = fake_container + # ensure_image: image already present + fake_client.images.get.return_value = MagicMock(id="sha256:abc") + + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + extra={ + "docker_image_tag": "openjarvis/pearl-miner:main", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "vllm_port": 8000, + "gateway_port": 8337, + "gateway_metrics_port": 8339, + "gpu_memory_utilization": 0.9, + "max_model_len": 8192, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "PEARLD_RPC_PASSWORD", + }, + ) + + provider = VllmPearlProvider(docker_client=fake_client) + await provider.start(cfg) + + assert sidecar_path.exists() + payload = json.loads(sidecar_path.read_text()) + assert payload["provider"] == "vllm-pearl" + assert payload["vllm_endpoint"].endswith(":8000/v1") + assert payload["gateway_url"].endswith(":8337") + assert payload["gateway_metrics_url"].endswith(":8339") + assert payload["wallet_address"] == "prl1qaaa" + assert payload["container_id"] == "cid-xyz" + assert "started_at" in payload + # Sidecar omits secrets + assert "PEARLD_RPC_PASSWORD" not in json.dumps(payload) + + +@pytest.mark.asyncio +async def test_vllm_pearl_start_pool_target_raises_not_implemented(monkeypatch, tmp_path): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + from openjarvis.mining._stubs import MiningConfig, PoolTarget + + sidecar_path = tmp_path / "mining.json" + monkeypatch.setattr( + "openjarvis.mining.vllm_pearl.SIDECAR_PATH", sidecar_path + ) + + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=PoolTarget(url="https://pool.openjarvis.ai/submit"), + extra={"docker_image_tag": "openjarvis/pearl-miner:main"}, + ) + provider = VllmPearlProvider(docker_client=MagicMock()) + with pytest.raises(NotImplementedError) as ei: + await provider.start(cfg) + assert "v2" in str(ei.value).lower() or "pool" in str(ei.value).lower() + + +@pytest.mark.asyncio +async def test_vllm_pearl_stop_removes_sidecar(tmp_path, monkeypatch, written_sidecar): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + monkeypatch.setattr( + "openjarvis.mining.vllm_pearl.SIDECAR_PATH", written_sidecar + ) + fake_client = MagicMock() + provider = VllmPearlProvider(docker_client=fake_client) + provider._launcher._container = MagicMock() # simulate running + await provider.stop() + assert not written_sidecar.exists() + + +def test_vllm_pearl_stats_reads_gateway(monkeypatch, written_sidecar): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + monkeypatch.setattr( + "openjarvis.mining.vllm_pearl.SIDECAR_PATH", written_sidecar + ) + sample = ( + "pearl_gateway_shares_submitted_total 100\n" + "pearl_gateway_shares_accepted_total 99\n" + "pearl_gateway_blocks_found_total 1\n" + ) + with patch("openjarvis.mining.vllm_pearl.httpx.get") as get: + get.return_value.status_code = 200 + get.return_value.text = sample + provider = VllmPearlProvider(docker_client=MagicMock()) + stats = provider.stats() + assert stats.shares_submitted == 100 + assert stats.shares_accepted == 99 + assert stats.blocks_found == 1 + + +def test_ensure_registered_is_idempotent(): + from openjarvis.core.registry import MinerRegistry + from openjarvis.mining.vllm_pearl import ( + VllmPearlProvider, + ensure_registered, + ) + ensure_registered() + ensure_registered() # second call should not raise + assert MinerRegistry.contains("vllm-pearl") + assert MinerRegistry.get("vllm-pearl") is VllmPearlProvider +``` + +> **Note:** the test `test_vllm_pearl_start_writes_sidecar` uses `pytest.mark.asyncio`. Confirm `pytest-asyncio` is in the dev extras (it is per `pyproject.toml`'s `[project.optional-dependencies].dev`). If async tests fail to collect, add `asyncio_mode = "auto"` under `[tool.pytest.ini_options]` in `pyproject.toml`. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/mining/test_vllm_pearl.py -v +``` +Expected: 7 FAIL. + +- [ ] **Step 3: Implement `VllmPearlProvider`** + +Create `src/openjarvis/mining/vllm_pearl.py`: + +```python +# src/openjarvis/mining/vllm_pearl.py +"""The v1 vllm-pearl mining provider. + +See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any, Optional + +import httpx + +from openjarvis.core.config import HardwareInfo +from openjarvis.core.registry import MinerRegistry +from openjarvis.mining._constants import ( + DEFAULT_GATEWAY_METRICS_PORT, + DEFAULT_GATEWAY_RPC_PORT, + DEFAULT_PEARL_MODEL, + DEFAULT_VLLM_PORT, + PEARL_IMAGE_TAG, + SIDECAR_PATH, +) +from openjarvis.mining._discovery import detect_for_engine_model +from openjarvis.mining._docker import PearlDockerLauncher +from openjarvis.mining._metrics import parse_gateway_metrics +from openjarvis.mining._stubs import ( + MiningCapabilities, + MiningConfig, + MiningProvider, + MiningStats, + PoolTarget, + Sidecar, + SoloTarget, +) + + +class VllmPearlProvider(MiningProvider): + """vLLM + Pearl Docker container, solo-mining only in v1.""" + + provider_id = "vllm-pearl" + + def __init__(self, docker_client: Optional[Any] = None): + if docker_client is None: + import docker + docker_client = docker.from_env() + self._client = docker_client + self._launcher = PearlDockerLauncher(client=docker_client) + + @classmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + return detect_for_engine_model( + hw=hw, engine_id=engine_id, model=model, provider_id=cls.provider_id, + ) + + async def start(self, config: MiningConfig) -> None: + if isinstance(config.submit_target, PoolTarget): + raise NotImplementedError( + "pool support is v2 — see openjarvis#XYZ. v1 only accepts " + "submit_target='solo'." + ) + assert isinstance(config.submit_target, SoloTarget) + + image = config.extra.get("docker_image_tag", PEARL_IMAGE_TAG) + image = self._launcher.ensure_image(image) + container = self._launcher.start(config, image=image) + + # Pull port assignments from extra (with sensible defaults). + vllm_port = int(config.extra.get("vllm_port", DEFAULT_VLLM_PORT)) + gw_port = int(config.extra.get("gateway_port", DEFAULT_GATEWAY_RPC_PORT)) + gw_metrics = int( + config.extra.get("gateway_metrics_port", DEFAULT_GATEWAY_METRICS_PORT) + ) + model_name = config.extra.get("model", DEFAULT_PEARL_MODEL) + + Sidecar.write(SIDECAR_PATH, { + "provider": self.provider_id, + "vllm_endpoint": f"http://127.0.0.1:{vllm_port}/v1", + "model": model_name, + "gateway_url": f"http://127.0.0.1:{gw_port}", + "gateway_metrics_url": f"http://127.0.0.1:{gw_metrics}", + "container_id": getattr(container, "id", ""), + "wallet_address": config.wallet_address, + "started_at": int(time.time()), + }) + + async def stop(self) -> None: + self._launcher.stop() + Sidecar.remove(SIDECAR_PATH) + + def is_running(self) -> bool: + return self._launcher.is_running() + + def stats(self) -> MiningStats: + sidecar = Sidecar.read(SIDECAR_PATH) + if sidecar is None: + return MiningStats(provider_id=self.provider_id) + url = sidecar.get("gateway_metrics_url") + if not url: + return MiningStats(provider_id=self.provider_id) + try: + resp = httpx.get(f"{url}/metrics", timeout=5.0) + if resp.status_code != 200: + return MiningStats( + provider_id=self.provider_id, + last_error=f"gateway HTTP {resp.status_code}", + ) + return parse_gateway_metrics(resp.text, provider_id=self.provider_id) + except Exception as e: # noqa: BLE001 + return MiningStats( + provider_id=self.provider_id, + last_error=str(e).splitlines()[0], + ) + + +def ensure_registered() -> None: + """Idempotent registration. Required because tests/conftest.py clears + every registry before each test (see Spec A §4.2). + """ + if not MinerRegistry.contains("vllm-pearl"): + MinerRegistry.register_value("vllm-pearl", VllmPearlProvider) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_vllm_pearl.py -v +``` +Expected: 7 PASS. + +- [ ] **Step 5: Run the full mining test suite to confirm no regressions** + +```bash +uv run pytest tests/mining/ -v +``` +Expected: ALL PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/mining/vllm_pearl.py tests/mining/test_vllm_pearl.py +git commit -m "feat(mining): VllmPearlProvider — the v1 vllm-pearl provider" +``` + +--- + +## Task 9 — Engine sidecar handoff + +**Files:** +- Modify: `src/openjarvis/engine/_discovery.py` +- Test: `tests/engine/test_discovery.py` (existing — add new tests) + +- [ ] **Step 1: Read existing `engine/_discovery.py`** + +Run: +```bash +sed -n '1,80p' src/openjarvis/engine/_discovery.py +``` + +Identify the function that resolves engines (likely `discover_engines()` or `get_engine()`). + +- [ ] **Step 2: Write the failing test** + +Add to `tests/engine/test_discovery.py`: + +```python +def test_engine_discovery_picks_up_mining_sidecar(tmp_path, monkeypatch, written_sidecar): + """When a mining sidecar exists, engine resolution should expose a + 'vllm-pearl-mining' engine pointing at the sidecar's vllm_endpoint. + """ + from openjarvis.engine._discovery import discover_engines + from openjarvis.mining import _constants as mining_const + + monkeypatch.setattr(mining_const, "SIDECAR_PATH", written_sidecar) + + engines = discover_engines(force=True) + keys = {e.engine_id if hasattr(e, "engine_id") else e for e in engines} + assert any("vllm-pearl-mining" in str(k) for k in keys) + + +def test_engine_discovery_no_mining_engine_when_sidecar_absent(tmp_path, monkeypatch): + from openjarvis.engine._discovery import discover_engines + from openjarvis.mining import _constants as mining_const + + missing = tmp_path / "no-such-mining.json" + monkeypatch.setattr(mining_const, "SIDECAR_PATH", missing) + + engines = discover_engines(force=True) + keys = {e.engine_id if hasattr(e, "engine_id") else e for e in engines} + assert not any("vllm-pearl-mining" in str(k) for k in keys) +``` + +The `written_sidecar` fixture lives in `tests/mining/conftest.py`; you'll need to either move it to a shared conftest or duplicate it in `tests/engine/conftest.py`. Prefer moving relevant ones to `tests/conftest.py`. + +- [ ] **Step 3: Move sidecar fixtures to root conftest** + +Move `sample_sidecar_payload`, `sidecar_path`, and `written_sidecar` from `tests/mining/conftest.py` to `tests/conftest.py`. Also move `hopper_hw`, `ada_hw`, `apple_hw`, and `mock_docker_client` to `tests/conftest.py` so all test packages can use them. + +- [ ] **Step 4: Run tests to verify they fail** + +```bash +uv run pytest tests/engine/test_discovery.py -k mining_sidecar -v +``` +Expected: FAIL — sidecar engine isn't registered. + +- [ ] **Step 5: Modify `engine/_discovery.py`** + +Locate the engine-resolution path. Add a helper: + +```python +def _maybe_register_mining_sidecar_engine() -> None: + """If a mining sidecar exists, register a derived vLLM engine pointing + at the mining endpoint. + + See Spec A §5.4. Idempotent — safe to call from any discovery path. + """ + try: + from openjarvis.mining import Sidecar + from openjarvis.mining._constants import SIDECAR_PATH + except ImportError: + return + payload = Sidecar.read(SIDECAR_PATH) + if payload is None: + return + endpoint = payload.get("vllm_endpoint") + model = payload.get("model") + if not endpoint or not model: + return + + from openjarvis.core.registry import EngineRegistry + from openjarvis.engine.openai_compat_engines import OpenAICompatEngine # adjust to actual class name + + if EngineRegistry.contains("vllm-pearl-mining"): + return + + # Construct an instance bound to the mining endpoint and register it. + instance = OpenAICompatEngine( + engine_id="vllm-pearl-mining", + base_url=endpoint, + default_model=model, + ) + EngineRegistry.register_value("vllm-pearl-mining", instance) +``` + +Call `_maybe_register_mining_sidecar_engine()` from `discover_engines()` after the existing engine-detection logic and before returning. + +> **Note for the implementer:** Verify the actual class name of the OpenAI-compatible engine wrapper in `engine/openai_compat_engines.py` and adjust the import + constructor accordingly. The registry key must be `"vllm-pearl-mining"` exactly. + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +uv run pytest tests/engine/test_discovery.py -k mining_sidecar -v +``` +Expected: 2 PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/openjarvis/engine/_discovery.py tests/conftest.py tests/mining/conftest.py tests/engine/test_discovery.py +git commit -m "feat(mining): engine discovery picks up runtime sidecar" +``` + +--- + +## Task 10 — `MiningTelemetryCollector` (shipped unwired) + +**Files:** +- Create: `src/openjarvis/mining/_collector.py` +- Create: `tests/mining/test_collector.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/mining/test_collector.py`: + +```python +"""Tests for MiningTelemetryCollector — shipped in v1 but unwired.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.mark.asyncio +async def test_collector_collect_once_returns_stats(written_sidecar): + from openjarvis.mining._collector import MiningTelemetryCollector + + sample = ( + "pearl_gateway_shares_submitted_total 50\n" + "pearl_gateway_shares_accepted_total 49\n" + ) + with patch("openjarvis.mining._collector.httpx.get") as get: + get.return_value.status_code = 200 + get.return_value.text = sample + store = MagicMock() + c = MiningTelemetryCollector( + sidecar_path=written_sidecar, telemetry_store=store, interval_s=0.05 + ) + stats = await c.collect_once() + assert stats.shares_submitted == 50 + assert stats.shares_accepted == 49 + + +@pytest.mark.asyncio +async def test_collector_run_loop_writes_to_store_then_stops(written_sidecar): + from openjarvis.mining._collector import MiningTelemetryCollector + + sample = "pearl_gateway_shares_submitted_total 1\n" + with patch("openjarvis.mining._collector.httpx.get") as get: + get.return_value.status_code = 200 + get.return_value.text = sample + store = MagicMock() + c = MiningTelemetryCollector( + sidecar_path=written_sidecar, telemetry_store=store, interval_s=0.01 + ) + # Run the loop briefly and stop. + task = asyncio.create_task(c.run()) + await asyncio.sleep(0.05) + c.stop() + await asyncio.wait_for(task, timeout=1.0) + assert store.record_mining_stats.call_count >= 1 + + +@pytest.mark.asyncio +async def test_collector_handles_gateway_errors_gracefully(written_sidecar): + from openjarvis.mining._collector import MiningTelemetryCollector + + with patch("openjarvis.mining._collector.httpx.get") as get: + get.side_effect = ConnectionError("nope") + store = MagicMock() + c = MiningTelemetryCollector( + sidecar_path=written_sidecar, telemetry_store=store + ) + stats = await c.collect_once() + assert stats.last_error is not None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/mining/test_collector.py -v +``` +Expected: 3 FAIL. + +- [ ] **Step 3: Implement the collector** + +Create `src/openjarvis/mining/_collector.py`: + +```python +# src/openjarvis/mining/_collector.py +"""Background poller for mining telemetry. + +Shipped in v1 but **not wired into the gateway daemon**. v1.x will register +this as a periodic asyncio task in ``openjarvis.daemon.gateway``. v1's +status command reads on-demand instead — see ``vllm_pearl.VllmPearlProvider.stats``. + +Why ship now? Lighting up the collector in v1.x is a one-line change in the +daemon. The contract (init signature, ``run()`` loop, ``collect_once()``, +``stop()``) is fixed by this v1 ship to prevent API churn. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any + +import httpx + +from openjarvis.mining._metrics import parse_gateway_metrics +from openjarvis.mining._stubs import MiningStats, Sidecar + +log = logging.getLogger(__name__) + + +class MiningTelemetryCollector: + """Periodically polls the Pearl gateway and writes ``MiningStats`` to a + telemetry store. + + ``telemetry_store`` is duck-typed: must implement + ``record_mining_stats(stats: MiningStats) -> None``. + """ + + def __init__( + self, + sidecar_path: Path, + telemetry_store: Any, + interval_s: float = 30.0, + ): + self._sidecar_path = sidecar_path + self._store = telemetry_store + self._interval_s = interval_s + self._stop = False + + async def collect_once(self) -> MiningStats: + sidecar = Sidecar.read(self._sidecar_path) + if sidecar is None: + return MiningStats(provider_id="unknown") + url = sidecar.get("gateway_metrics_url") + provider_id = sidecar.get("provider", "unknown") + if not url: + return MiningStats(provider_id=provider_id) + try: + resp = httpx.get(f"{url}/metrics", timeout=5.0) + if resp.status_code != 200: + return MiningStats( + provider_id=provider_id, + last_error=f"gateway HTTP {resp.status_code}", + ) + return parse_gateway_metrics(resp.text, provider_id=provider_id) + except Exception as e: # noqa: BLE001 + return MiningStats(provider_id=provider_id, last_error=str(e).splitlines()[0]) + + async def run(self) -> None: + while not self._stop: + try: + stats = await self.collect_once() + self._store.record_mining_stats(stats) + except Exception as e: # noqa: BLE001 + log.warning("MiningTelemetryCollector tick error: %s", e) + try: + await asyncio.sleep(self._interval_s) + except asyncio.CancelledError: + break + + def stop(self) -> None: + self._stop = True +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_collector.py -v +``` +Expected: 3 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/mining/_collector.py tests/mining/test_collector.py +git commit -m "feat(mining): MiningTelemetryCollector — shipped unwired for v1.x" +``` + +--- + +## Task 11 — Telemetry schema migration (`mining_session_id`) + +**Files:** +- Modify: `src/openjarvis/telemetry/store.py` +- Test: `tests/telemetry/test_store.py` (existing — add new tests) + +- [ ] **Step 1: Inspect existing `telemetry/store.py` migration approach** + +Run: +```bash +grep -n "PRAGMA user_version\|CREATE TABLE\|ALTER TABLE\|migrate" src/openjarvis/telemetry/store.py | head -20 +``` + +If `PRAGMA user_version` is already used, follow that convention. If migrations are inline `CREATE TABLE IF NOT EXISTS` only, switch to a versioned approach for this change. Document the chosen pattern in the commit message. + +- [ ] **Step 2: Write failing tests** + +Add to `tests/telemetry/test_store.py`: + +```python +def test_inference_row_has_mining_session_id_column(tmp_path): + from openjarvis.telemetry.store import TelemetryStore + db = tmp_path / "tel.db" + store = TelemetryStore(db_path=db) + store.record_inference( + model="test-model", + engine_id="test-engine", + prompt_tokens=10, + completion_tokens=5, + latency_ms=100.0, + ) + # Default — null + rows = store.list_recent(limit=1) + assert "mining_session_id" in rows[0] + assert rows[0]["mining_session_id"] is None + + +def test_inference_row_can_be_tagged_with_mining_session_id(tmp_path): + from openjarvis.telemetry.store import TelemetryStore + db = tmp_path / "tel.db" + store = TelemetryStore(db_path=db) + store.record_inference( + model="test-model", + engine_id="vllm-pearl-mining", + prompt_tokens=10, + completion_tokens=5, + latency_ms=100.0, + mining_session_id="abc123", + ) + rows = store.list_recent(limit=1) + assert rows[0]["mining_session_id"] == "abc123" + + +def test_record_mining_stats_persists(tmp_path): + from openjarvis.telemetry.store import TelemetryStore + from openjarvis.mining._stubs import MiningStats + db = tmp_path / "tel.db" + store = TelemetryStore(db_path=db) + store.record_mining_stats( + MiningStats(provider_id="vllm-pearl", shares_submitted=42, shares_accepted=40) + ) + snapshots = store.list_recent_mining_stats(limit=1) + assert snapshots[0]["shares_submitted"] == 42 +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +uv run pytest tests/telemetry/test_store.py -k mining -v +``` +Expected: FAIL. + +- [ ] **Step 4: Implement the migration** + +In `src/openjarvis/telemetry/store.py`: + +1. Bump the schema version constant by 1 (or introduce one if absent). +2. Add a migration step: `ALTER TABLE inference ADD COLUMN mining_session_id TEXT NULL;` guarded by the version bump. +3. Add a `mining_stats` table with appropriate columns: `provider_id`, `shares_submitted`, `shares_accepted`, `blocks_found`, `hashrate`, `uptime_seconds`, `last_share_at`, `last_error`, `payout_target`, `fees_owed`, `recorded_at`. +4. Add `record_inference(..., mining_session_id: Optional[str] = None)` parameter — keep it kwargs-only and defaulted so callers don't change. +5. Add `record_mining_stats(stats: MiningStats) -> None`. +6. Add `list_recent_mining_stats(limit: int = 50) -> list[dict]`. +7. Update `list_recent()` to include `mining_session_id` in returned rows. + +Exact SQL: + +```sql +-- migrate_v_to_v: +ALTER TABLE inference ADD COLUMN mining_session_id TEXT; + +CREATE TABLE IF NOT EXISTS mining_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at REAL NOT NULL, + provider_id TEXT NOT NULL, + shares_submitted INTEGER NOT NULL DEFAULT 0, + shares_accepted INTEGER NOT NULL DEFAULT 0, + blocks_found INTEGER NOT NULL DEFAULT 0, + hashrate REAL NOT NULL DEFAULT 0, + uptime_seconds REAL NOT NULL DEFAULT 0, + last_share_at REAL, + last_error TEXT, + payout_target TEXT NOT NULL DEFAULT 'solo', + fees_owed INTEGER NOT NULL DEFAULT 0 +); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +uv run pytest tests/telemetry/test_store.py -v +``` +Expected: ALL PASS (existing tests + 3 new). + +- [ ] **Step 6: Commit** + +```bash +git add src/openjarvis/telemetry/store.py tests/telemetry/test_store.py +git commit -m "feat(telemetry): add mining_session_id + mining_stats table" +``` + +--- + +## Task 12 — `jarvis mine doctor` (highest user-facing value, build first) + +**Files:** +- Create: `src/openjarvis/cli/mine_cmd.py` +- Create: `tests/mining/test_cli.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/mining/test_cli.py`: + +```python +"""CLI smoke tests via Click CliRunner.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + + +def test_mine_doctor_prints_capability_matrix(monkeypatch): + from openjarvis.cli.mine_cmd import mine + runner = CliRunner() + + # Force the H100 hardware fixture so detect() returns supported. + from openjarvis.core.config import GpuInfo, HardwareInfo + fake_hw = HardwareInfo( + platform="linux", + gpu=GpuInfo( + vendor="nvidia", name="H100", vram_gb=80.0, + compute_capability="9.0", count=1, + ), + ) + with patch("openjarvis.cli.mine_cmd._detect_hardware", return_value=fake_hw), \ + patch("openjarvis.cli.mine_cmd.check_docker_available", return_value=(True, "running 24.0.7")), \ + patch("openjarvis.cli.mine_cmd.check_disk_free", return_value=(True, "300 GB free")), \ + patch("openjarvis.cli.mine_cmd.check_pearld_reachable", return_value=(True, "block height 442107 (synced)")): + result = runner.invoke(mine, ["doctor"]) + assert result.exit_code == 0, result.output + out = result.output.lower() + assert "hardware" in out + assert "docker" in out + assert "pearl" in out + assert "vllm-pearl" in out + + +def test_mine_doctor_flags_unsupported_hardware(): + from openjarvis.cli.mine_cmd import mine + runner = CliRunner() + + from openjarvis.core.config import GpuInfo, HardwareInfo + fake_hw = HardwareInfo( + platform="linux", + gpu=GpuInfo( + vendor="nvidia", name="RTX 4090", vram_gb=24.0, + compute_capability="8.9", count=1, + ), + ) + with patch("openjarvis.cli.mine_cmd._detect_hardware", return_value=fake_hw), \ + patch("openjarvis.cli.mine_cmd.check_docker_available", return_value=(True, "ok")), \ + patch("openjarvis.cli.mine_cmd.check_disk_free", return_value=(True, "300 GB free")), \ + patch("openjarvis.cli.mine_cmd.check_pearld_reachable", return_value=(False, "connection refused")): + result = runner.invoke(mine, ["doctor"]) + assert result.exit_code == 0 + assert "✗" in result.output or "FAIL" in result.output.upper() +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +uv run pytest tests/mining/test_cli.py::test_mine_doctor_prints_capability_matrix -v +``` +Expected: FAIL. + +- [ ] **Step 3: Implement `mine_cmd.py` with the `doctor` subcommand** + +```python +# src/openjarvis/cli/mine_cmd.py +"""``jarvis mine`` command group. + +See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`` +section 6 for the full CLI surface. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Optional + +import click + +from openjarvis.core.config import HardwareInfo, load_config +from openjarvis.mining._constants import ( + DEFAULT_PEARL_MODEL, + DEFAULT_PEARLD_RPC_URL, + PEARL_IMAGE_TAG, + SIDECAR_PATH, +) +from openjarvis.mining._discovery import ( + check_disk_free, + check_docker_available, + check_pearld_reachable, + check_wallet_address_format, + detect_for_engine_model, +) +from openjarvis.mining._stubs import Sidecar + + +def _detect_hardware() -> HardwareInfo: + """Wrapper to make hardware detection mockable in CLI tests.""" + return load_config().hardware + + +@click.group() +def mine() -> None: + """Pearl PoUW mining commands. + + See https://open-jarvis.github.io/OpenJarvis/user-guide/mining/ for the + full guide. + """ + + +@mine.command() +def doctor() -> None: + """Diagnose mining capability with one row per check.""" + hw = _detect_hardware() + cfg = load_config() + mining_cfg = cfg.mining + + def row(group: str, name: str, ok: bool, info: str) -> None: + marker = "✓" if ok else "✗" + click.echo(f" {name:<22} {info:<35} {marker}") + + click.echo("Hardware") + row("hw", "GPU vendor", hw.gpu.vendor == "nvidia" if hw.gpu else False, + hw.gpu.vendor if hw.gpu else "(no GPU)") + cc_ok = bool(hw.gpu and hw.gpu.compute_capability.startswith("9.0")) + row("hw", "Compute capability", cc_ok, + hw.gpu.compute_capability if hw.gpu else "n/a") + vram = hw.gpu.vram_gb if hw.gpu else 0 + row("hw", "VRAM", vram >= 70, f"{vram:.0f} GB") + + click.echo("Docker") + ok, info = check_docker_available() + row("docker", "Daemon", ok, info) + + click.echo("Disk") + ok, info = check_disk_free(Path.home()) + row("disk", "Free in HF cache", ok, info) + + click.echo("Pearl node") + if mining_cfg is not None: + url = mining_cfg.extra.get("pearld_rpc_url", DEFAULT_PEARLD_RPC_URL) + user = mining_cfg.extra.get("pearld_rpc_user", "rpcuser") + password_env = mining_cfg.extra.get( + "pearld_rpc_password_env", "PEARLD_RPC_PASSWORD" + ) + password = os.environ.get(password_env, "") + ok, info = check_pearld_reachable(url, user, password) + row("pearld", "RPC", ok, info) + else: + row("pearld", "RPC", False, "no [mining] config — run `jarvis mine init`") + + click.echo("Wallet") + if mining_cfg is not None: + ok, info = check_wallet_address_format(mining_cfg.wallet_address) + row("wallet", "Address format", ok, info) + + click.echo("Provider capability") + if mining_cfg is not None: + cap = detect_for_engine_model( + hw=hw, engine_id="vllm", + model=mining_cfg.extra.get("model", DEFAULT_PEARL_MODEL), + provider_id=mining_cfg.provider, + ) + marker = "SUPPORTED" if cap.supported else f"UNSUPPORTED — {cap.reason}" + click.echo(f" vllm-pearl {marker}") + + click.echo("Session") + sidecar = Sidecar.read(SIDECAR_PATH) + if sidecar is None: + click.echo(" Sidecar absent (not running)") + else: + click.echo(f" Sidecar present ({SIDECAR_PATH})") + click.echo(f" Container {sidecar.get('container_id', '?')}") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_cli.py -v +``` +Expected: 2 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/cli/mine_cmd.py tests/mining/test_cli.py +git commit -m "feat(mining-cli): jarvis mine doctor" +``` + +--- + +## Task 13 — `jarvis mine init / start / stop` + +**Files:** +- Modify: `src/openjarvis/cli/mine_cmd.py` +- Modify: `tests/mining/test_cli.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/mining/test_cli.py`: + +```python +def test_mine_start_runs_provider_start(monkeypatch): + from openjarvis.cli.mine_cmd import mine + runner = CliRunner() + fake_provider_class = MagicMock() + fake_provider_class.return_value.start = MagicMock(return_value=None) + with patch("openjarvis.cli.mine_cmd.MinerRegistry") as reg, \ + patch("openjarvis.cli.mine_cmd.load_config") as load, \ + patch("openjarvis.cli.mine_cmd.asyncio.run") as arun: + from openjarvis.mining._stubs import MiningConfig, SoloTarget + load.return_value = MagicMock(mining=MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + )) + reg.get.return_value = fake_provider_class + result = runner.invoke(mine, ["start"]) + assert result.exit_code == 0 + arun.assert_called_once() + + +def test_mine_stop_calls_provider_stop(): + from openjarvis.cli.mine_cmd import mine + runner = CliRunner() + fake_provider_class = MagicMock() + with patch("openjarvis.cli.mine_cmd.MinerRegistry") as reg, \ + patch("openjarvis.cli.mine_cmd.load_config") as load, \ + patch("openjarvis.cli.mine_cmd.asyncio.run") as arun: + from openjarvis.mining._stubs import MiningConfig, SoloTarget + load.return_value = MagicMock(mining=MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + )) + reg.get.return_value = fake_provider_class + result = runner.invoke(mine, ["stop"]) + assert result.exit_code == 0 + + +def test_mine_start_errors_when_no_mining_config(): + from openjarvis.cli.mine_cmd import mine + runner = CliRunner() + with patch("openjarvis.cli.mine_cmd.load_config") as load: + load.return_value = MagicMock(mining=None) + result = runner.invoke(mine, ["start"]) + assert result.exit_code != 0 + assert "init" in result.output.lower() or "no [mining]" in result.output.lower() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/mining/test_cli.py -v +``` +Expected: 3 new FAIL. + +- [ ] **Step 3: Implement `init`, `start`, `stop`** + +Append to `mine_cmd.py`: + +```python +import asyncio +from openjarvis.core.registry import MinerRegistry + + +@mine.command() +@click.option("--wallet", prompt="Pearl Taproot wallet address (prl1q...)") +@click.option("--pearld-url", default=DEFAULT_PEARLD_RPC_URL, + prompt="pearld RPC URL") +@click.option("--pearld-user", default="rpcuser", prompt="pearld RPC user") +@click.option("--pearld-password-env", default="PEARLD_RPC_PASSWORD", + prompt="env var holding pearld password") +@click.option("--model", default=DEFAULT_PEARL_MODEL) +@click.option("--image", default=PEARL_IMAGE_TAG) +def init( + wallet: str, + pearld_url: str, + pearld_user: str, + pearld_password_env: str, + model: str, + image: str, +) -> None: + """Interactive setup. Validates capability, writes [mining] config, pulls/builds image.""" + # Pre-checks + hw = _detect_hardware() + cap = detect_for_engine_model( + hw=hw, engine_id="vllm", model=model, provider_id="vllm-pearl", + ) + if not cap.supported: + raise click.ClickException( + f"vllm-pearl not supported on this host: {cap.reason}\n" + f"See `jarvis mine doctor` for details." + ) + + ok, info = check_docker_available() + if not ok: + raise click.ClickException(f"Docker unavailable: {info}") + + ok, info = check_disk_free(Path.home()) + if not ok: + raise click.ClickException(f"Insufficient disk: {info}") + + if pearld_password_env not in os.environ: + click.echo( + f"Warning: ${pearld_password_env} is not set in your environment. " + f"Set it before `jarvis mine start`.", + err=True, + ) + + ok, info = check_wallet_address_format(wallet) + if not ok: + raise click.ClickException(f"Invalid wallet address: {info}") + + # Write config (preserves any existing config sections; appends [mining]) + config_path = Path.home() / ".openjarvis" / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + new_section = f""" +[mining] +provider = "vllm-pearl" +wallet_address = "{wallet}" +submit_target = "solo" +fee_bps = 0 +fee_payout_address = "" + +[mining.extra] +docker_image_tag = "{image}" +model = "{model}" +gateway_port = 8337 +gateway_metrics_port = 8339 +vllm_port = 8000 +gpu_memory_utilization = 0.9 +max_model_len = 8192 +pearld_rpc_url = "{pearld_url}" +pearld_rpc_user = "{pearld_user}" +pearld_rpc_password_env = "{pearld_password_env}" +hf_token_env = "HF_TOKEN" +""" + if config_path.exists(): + existing = config_path.read_text() + if "[mining]" in existing: + click.echo("[mining] section already present; not overwriting. Edit manually if needed.") + return + config_path.write_text(existing + new_section) + else: + config_path.write_text(new_section) + + # Pull/build image + click.echo(f"Resolving image {image}... (build may take 30-60 min on first run)") + import docker + from openjarvis.mining._docker import PearlDockerLauncher + launcher = PearlDockerLauncher(client=docker.from_env()) + launcher.ensure_image(image) + click.echo(f"Done. Run `jarvis mine start` to begin mining.") + + +@mine.command() +def start() -> None: + """Launch the Pearl mining container and write the runtime sidecar.""" + cfg = load_config().mining + if cfg is None: + raise click.ClickException("no [mining] section in config — run `jarvis mine init`") + provider_cls = MinerRegistry.get(cfg.provider) + provider = provider_cls() + + async def _run(): + await provider.start(cfg) + asyncio.run(_run()) + click.echo(f"Mining started. Run `jarvis mine status` for live stats.") + + +@mine.command() +def stop() -> None: + """Stop the Pearl mining container and remove the sidecar.""" + cfg = load_config().mining + if cfg is None: + click.echo("no [mining] section — nothing to stop") + return + provider_cls = MinerRegistry.get(cfg.provider) + provider = provider_cls() + + async def _run(): + await provider.stop() + asyncio.run(_run()) + click.echo("Mining stopped.") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_cli.py -v +``` +Expected: 5 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/cli/mine_cmd.py tests/mining/test_cli.py +git commit -m "feat(mining-cli): jarvis mine init/start/stop" +``` + +--- + +## Task 14 — `jarvis mine status / attach / logs` + +**Files:** +- Modify: `src/openjarvis/cli/mine_cmd.py` +- Modify: `tests/mining/test_cli.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/mining/test_cli.py`: + +```python +def test_mine_status_renders_stats(written_sidecar, monkeypatch): + from openjarvis.cli.mine_cmd import mine + runner = CliRunner() + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", written_sidecar) + sample = ( + "pearl_gateway_shares_submitted_total 100\n" + "pearl_gateway_shares_accepted_total 99\n" + "pearl_gateway_blocks_found_total 2\n" + ) + with patch("openjarvis.mining.vllm_pearl.httpx.get") as get, \ + patch("openjarvis.cli.mine_cmd.MinerRegistry") as reg: + get.return_value.status_code = 200 + get.return_value.text = sample + from openjarvis.mining.vllm_pearl import VllmPearlProvider + reg.get.return_value = lambda: VllmPearlProvider(docker_client=MagicMock()) + result = runner.invoke(mine, ["status"]) + assert result.exit_code == 0 + assert "100" in result.output + + +def test_mine_attach_writes_sidecar(tmp_path, monkeypatch): + from openjarvis.cli.mine_cmd import mine + runner = CliRunner() + sidecar = tmp_path / "mining.json" + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar) + result = runner.invoke(mine, [ + "attach", + "--vllm-endpoint", "http://127.0.0.1:8000/v1", + "--gateway-url", "http://127.0.0.1:8337", + "--gateway-metrics-url", "http://127.0.0.1:8339", + "--model", "pearl-ai/Llama-3.3-70B-Instruct-pearl", + ]) + assert result.exit_code == 0 + assert sidecar.exists() + + +def test_mine_logs_streams_container_output(monkeypatch): + from openjarvis.cli.mine_cmd import mine + runner = CliRunner() + fake_launcher = MagicMock() + fake_launcher.get_logs.return_value = "log line 1\nlog line 2\n" + with patch("openjarvis.cli.mine_cmd.PearlDockerLauncher", + return_value=fake_launcher): + result = runner.invoke(mine, ["logs", "--tail", "100"]) + assert result.exit_code == 0 + assert "log line 1" in result.output +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +uv run pytest tests/mining/test_cli.py -v +``` +Expected: 3 new FAIL. + +- [ ] **Step 3: Implement `status`, `attach`, `logs`** + +Append to `mine_cmd.py`: + +```python +import time +from openjarvis.mining._docker import PearlDockerLauncher + + +@mine.command() +def status() -> None: + """Print live mining stats from the gateway.""" + cfg = load_config().mining + if cfg is None: + raise click.ClickException("no [mining] section — run `jarvis mine init`") + provider_cls = MinerRegistry.get(cfg.provider) + provider = provider_cls() + s = provider.stats() + click.echo(f"provider: {s.provider_id}") + click.echo(f"shares submitted: {s.shares_submitted}") + click.echo(f"shares accepted: {s.shares_accepted}") + click.echo(f"blocks found: {s.blocks_found}") + click.echo(f"hashrate: {s.hashrate:.2f}") + click.echo(f"uptime (s): {s.uptime_seconds:.0f}") + click.echo(f"last share at: {s.last_share_at or '—'}") + click.echo(f"last error: {s.last_error or '—'}") + click.echo(f"payout target: {s.payout_target}") + click.echo(f"fees owed: {s.fees_owed}") + + +@mine.command() +@click.option("--vllm-endpoint", required=True) +@click.option("--gateway-url", required=True) +@click.option("--gateway-metrics-url", required=True) +@click.option("--model", default=DEFAULT_PEARL_MODEL) +@click.option("--container-id", default="external") +@click.option("--wallet", default="") +def attach( + vllm_endpoint: str, + gateway_url: str, + gateway_metrics_url: str, + model: str, + container_id: str, + wallet: str, +) -> None: + """Manual mode — write a sidecar pointing at a Pearl container you started yourself.""" + Sidecar.write(SIDECAR_PATH, { + "provider": "vllm-pearl", + "vllm_endpoint": vllm_endpoint, + "model": model, + "gateway_url": gateway_url, + "gateway_metrics_url": gateway_metrics_url, + "container_id": container_id, + "wallet_address": wallet, + "started_at": int(time.time()), + }) + click.echo(f"Sidecar written to {SIDECAR_PATH}") + + +@mine.command() +@click.option("-n", "--tail", "tail_n", default=200, type=int) +@click.option("-f", "--follow", is_flag=True, default=False, + help="Follow logs (not supported in v1 — equivalent to --tail).") +def logs(tail_n: int, follow: bool) -> None: + """Tail the Pearl mining container logs.""" + if follow: + click.echo("note: -f follow not implemented in v1; printing tail and exiting", err=True) + import docker + launcher = PearlDockerLauncher(client=docker.from_env()) + # Re-attach to the running container by name. + try: + container = docker.from_env().containers.get("openjarvis-pearl-miner") + launcher._container = container + except Exception as e: # noqa: BLE001 + raise click.ClickException(f"no running mining container: {e}") + click.echo(launcher.get_logs(tail=tail_n)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +uv run pytest tests/mining/test_cli.py -v +``` +Expected: 8 PASS (cumulative). + +- [ ] **Step 5: Commit** + +```bash +git add src/openjarvis/cli/mine_cmd.py tests/mining/test_cli.py +git commit -m "feat(mining-cli): jarvis mine status/attach/logs" +``` + +--- + +## Task 15 — Register `mine` group; add hint + +**Files:** +- Modify: `src/openjarvis/cli/__init__.py` +- Modify: `src/openjarvis/cli/hints.py` +- Test: `tests/cli/test_main.py` (or wherever CLI registration is tested) +- Test: `tests/cli/test_hints.py` (existing) + +- [ ] **Step 1: Inspect current `cli/__init__.py`** + +```bash +sed -n '1,50p' src/openjarvis/cli/__init__.py +grep -n "add_command\|@main.command\|main.add_command" src/openjarvis/cli/__init__.py | head -20 +``` + +Identify how other commands are registered. + +- [ ] **Step 2: Write failing test** + +Add to `tests/cli/test_main.py` (create if absent): + +```python +def test_mine_subcommand_registered(): + from click.testing import CliRunner + from openjarvis.cli import main + runner = CliRunner() + result = runner.invoke(main, ["mine", "--help"]) + assert result.exit_code == 0 + assert "doctor" in result.output + assert "start" in result.output + assert "stop" in result.output +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +uv run pytest tests/cli/test_main.py::test_mine_subcommand_registered -v +``` + +- [ ] **Step 4: Register the group** + +Add to `cli/__init__.py` near other `add_command` calls: + +```python +from openjarvis.cli.mine_cmd import mine +main.add_command(mine) +``` + +- [ ] **Step 5: Add the hint** + +In `cli/hints.py`, add a function (or extend existing hint logic) that emits one line when `[mining]` is configured but no sidecar exists: + +```python +def mining_not_running_hint(cfg, sidecar_present: bool) -> Optional[str]: + if cfg is None or sidecar_present: + return None + return "mining configured but not running — start it with `jarvis mine start`" +``` + +Wire it into wherever hints are surfaced (read existing hint integration points first; mirror the pattern). + +- [ ] **Step 6: Add tests for the hint** + +Add to `tests/cli/test_hints.py`: + +```python +def test_mining_not_running_hint_when_configured_no_sidecar(): + from openjarvis.cli.hints import mining_not_running_hint + cfg = object() # any truthy stand-in for MiningConfig + msg = mining_not_running_hint(cfg, sidecar_present=False) + assert msg is not None + assert "jarvis mine start" in msg + + +def test_mining_not_running_hint_silent_when_running(): + from openjarvis.cli.hints import mining_not_running_hint + msg = mining_not_running_hint(object(), sidecar_present=True) + assert msg is None + + +def test_mining_not_running_hint_silent_when_unconfigured(): + from openjarvis.cli.hints import mining_not_running_hint + msg = mining_not_running_hint(None, sidecar_present=False) + assert msg is None +``` + +- [ ] **Step 7: Run tests to verify they pass** + +```bash +uv run pytest tests/cli/test_main.py tests/cli/test_hints.py -v +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/openjarvis/cli/__init__.py src/openjarvis/cli/hints.py tests/cli/test_main.py tests/cli/test_hints.py +git commit -m "feat(mining-cli): register `mine` group + add not-running hint" +``` + +--- + +## Task 16 — `pyproject.toml` updates + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Add `mining-pearl` extra** + +In `pyproject.toml` `[project.optional-dependencies]`, alphabetical position (after `media`, before `openhands`): + +```toml +mining-pearl = ["docker>=7.0", "httpx>=0.27"] +``` + +- [ ] **Step 2: Add `docker` pytest marker** + +In `pyproject.toml` under `[tool.pytest.ini_options].markers`, add (alphabetical): + +```toml +"docker: requires a working Docker daemon (no GPU required)", +``` + +- [ ] **Step 3: Verify uv lock is updated** + +```bash +uv lock +``` + +Diff `uv.lock` for any unintended changes (only `docker` and `httpx` entries should be new). + +- [ ] **Step 4: Verify CI command still passes** + +```bash +uv sync --extra dev +uv run pytest tests/ -m "not live and not cloud and not docker" -v +``` + +Expected: pass (or fail only on tests unrelated to this work). + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "chore: add mining-pearl extra and docker pytest marker" +``` + +--- + +## Task 17 — Documentation + +**Files:** +- Create: `docs/user-guide/mining.md` +- Create: `docs/development/mining.md` +- Modify: `CLAUDE.md` (note: gitignored locally — write only if your local copy expects it) +- Modify: `REVIEW.md` + +- [ ] **Step 1: Write `docs/user-guide/mining.md`** + +```markdown +# Pearl mining + +OpenJarvis can mine the [Pearl](https://github.com/pearl-research-labs/pearl) +Proof-of-Useful-Work blockchain through your local LLM inference. v1 +supports H100/H200 hosts running vLLM. Apple Silicon, AMD, and other +inference backends are tracked separately — see [Spec B](../design/2026-05-05-apple-silicon-pearl-mining-design.md). + +## Prerequisites + +| | | +|---|---| +| GPU | NVIDIA H100 or H200 (sm_90a) with ≥ 70 GB VRAM | +| OS | Linux with `nvidia-container-toolkit` installed | +| Docker | 24+, GPU runtime configured | +| Disk | ≥ 200 GB free for the 70B model + headroom | +| Network | Reachable pearld node (default `http://localhost:44107`) | +| Wallet | A Pearl Taproot address (`prl1q...`) generated via Pearl's `oyster` CLI | + +## Quick start + +```bash +uv sync --extra mining-pearl +export PEARLD_RPC_PASSWORD= +export HF_TOKEN= +uv run jarvis mine init # writes [mining] config + builds Docker image (30-60 min first time) +uv run jarvis mine start +uv run jarvis mine status +``` + +## Diagnosing problems + +`jarvis mine doctor` prints one row per check with a clear ✓ or ✗ and reason. +Read top-down — fix what's failing before retrying `mine start`. + +## What v1 does NOT support + +- Pool mining or any OJ fee — solo only, you keep 100% +- Apple Silicon, AMD, sm_89 NVIDIA (RTX 4090), CPU-only — protocol-blocked + on Pearl shipping non-CUDA / non-Hopper kernels +- Wallet generation inside OJ — bring your own address + +## What's coming + +- v2: pool support and a 20% OJ fee for joining a shared variance-reduction pool +- Apple Silicon path tracked in [Spec B](../design/2026-05-05-apple-silicon-pearl-mining-design.md) +``` + +- [ ] **Step 2: Write `docs/development/mining.md`** + +```markdown +# Adding a new mining provider + +The `openjarvis.mining` subsystem uses a registry pattern identical to +`engine/`, `agents/`, etc. To add a new provider (e.g., for Apple Silicon, +AMD, or a future engine), implement the `MiningProvider` ABC and register +via `@MinerRegistry.register("")`. + +## Steps + +1. Create `src/openjarvis/mining/.py`. +2. Subclass `openjarvis.mining.MiningProvider`. +3. Implement `detect()`, `start()`, `stop()`, `is_running()`, `stats()`. +4. Define an idempotent `ensure_registered()` (required for test isolation — + see `tests/conftest.py` autouse clear). +5. Add a soft-import in `mining/__init__.py`: + ```python + try: + from openjarvis.mining import # noqa: F401 + .ensure_registered() + except ImportError: + pass + ``` +6. Add an optional dep extra in `pyproject.toml` (`mining-pearl-`). +7. Add tests in `tests/mining/test_.py` mirroring + `test_vllm_pearl.py`. + +## Working example + +The Apple Silicon path is the canonical worked example — +see [Spec B](../design/2026-05-05-apple-silicon-pearl-mining-design.md) +section 7 for the full provider template. +``` + +- [ ] **Step 3: Add `CLAUDE.md` paragraph (if your local copy is intended to be edited)** + +If your local `CLAUDE.md` exists and is intended for editing, add this paragraph under the Architecture section listing the primitives: + +```markdown +- `mining/` — `MiningProvider` ABC + `MinerRegistry`. v1's only impl is `vllm_pearl.py` (Pearl Docker container orchestrator). Soft-imported via `mining/__init__.py`'s `try/except ImportError` per OJ's optional-deps pattern. Future providers (Apple Silicon, AMD, Ollama) drop in via the registry without rewrite. See `docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`. +``` + +> CLAUDE.md may be in `.gitignore` in this repo. Check before adding to a commit. + +- [ ] **Step 4: Add `REVIEW.md` bullet** + +In `REVIEW.md` under "Registry pattern compliance" (or the closest equivalent bullet about new components needing registry registration), add: + +```markdown +- New mining providers must register via `MinerRegistry` in `src/openjarvis/core/registry.py` and expose an idempotent `ensure_registered()` per the autouse-clear test convention. +``` + +- [ ] **Step 5: Run mkdocs locally to verify rendering** + +```bash +uv sync --extra docs +uv run mkdocs build +``` + +Expected: build succeeds. Inspect output for missing assets / broken links to the new pages. + +- [ ] **Step 6: Commit** + +```bash +git add docs/user-guide/mining.md docs/development/mining.md REVIEW.md +# only add CLAUDE.md if it isn't ignored: +git check-ignore -q CLAUDE.md || git add CLAUDE.md +git commit -m "docs: user + dev guides for mining; REVIEW bullet" +``` + +--- + +## Final verification + +- [ ] **Step 1: Full test suite** + +```bash +uv run pytest tests/ -v --tb=short -m "not live and not cloud and not docker" +``` +Expected: PASS — every test, no regressions in other packages. + +- [ ] **Step 2: Lint + format** + +```bash +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +``` +Expected: clean. + +- [ ] **Step 3: Coverage** + +```bash +uv run pytest tests/mining/ --cov=openjarvis.mining --cov-report=term-missing +``` +Expected: ≥ 80% coverage on the new `mining/` package. + +- [ ] **Step 4: Smoke `jarvis mine doctor` on a non-mining dev box** + +```bash +uv run jarvis mine doctor +``` +Expected: doctor runs, hardware/Docker/Pearl rows print honest ✗ where applicable, exit code 0. + +- [ ] **Step 5: Open the implementation PR** + +```bash +gh pr create --title "feat(mining): vllm-pearl integration (v1, Spec A)" --body "$(cat <<'EOF' +Implements [Spec A](docs/design/2026-05-05-vllm-pearl-mining-integration-design.md). Solo mining only, no pool, no fee. v2 seams in place per spec §8.5. + +## Summary +- New `openjarvis.mining` subsystem with `MiningProvider` ABC + `MinerRegistry` +- `vllm-pearl` provider wrapping Pearl's Docker container +- Runtime sidecar (`~/.openjarvis/runtime/mining.json`) for engine ↔ mining handoff +- Telemetry: on-demand reads in v1; `MiningTelemetryCollector` shipped unwired for v1.x +- New CLI: `jarvis mine init|start|stop|status|doctor|attach|logs` +- New optional extra: `mining-pearl` +- New pytest marker: `docker` +- Docs: user guide, contributor guide, REVIEW.md update + +## Test plan +- [x] `uv run pytest tests/ -m "not live and not cloud and not docker"` +- [x] `uv run ruff check src/ tests/` +- [x] `uv run ruff format --check src/ tests/` +- [x] `uv run mkdocs build` +- [ ] Manual smoke: `uv run jarvis mine doctor` on a non-mining dev box prints honest output +- [ ] Manual smoke (release-gate): full mine init → start → status → stop on a real H100 host +EOF +)" +``` + +--- + +## Self-review summary + +**Spec coverage:** Every Spec A section maps to at least one task above: + +| Spec section | Tasks | +|---|---| +| §4 Architecture & module layout | 1, 2 | +| §5 Config schema & engine attachment | 3, 9 | +| §6 CLI surface, lifecycle | 12, 13, 14, 15 | +| §7 Pearl Docker integration | 5, 6 | +| §8 Telemetry hooks & v2 seams | 7, 10, 11 | +| §9 Failure handling & test strategy | All tasks (TDD); 16 (markers) | +| §10 Documentation deliverables | 17 | +| §11 Open items | Surfaced inline at the implementation moment they affect (e.g., the implementer notes in Tasks 7, 9, 11) | + +**Type consistency:** `MiningCapabilities`, `MiningConfig`, `MiningStats`, `SoloTarget`, `PoolTarget`, `Sidecar`, `MiningProvider`, `MinerRegistry`, `VllmPearlProvider` — names and signatures consistent across all tasks. + +**Placeholder scan:** No `TBD`, `TODO`, `add appropriate error handling`, or undefined-but-referenced types. The single intentional TODO is the `PEARL_PINNED_REF = "main"` constant in Task 2 — that's an explicit implementer-time decision, called out in `_constants.py`'s docstring and Spec A §11 Open Item #2. The Prometheus-fixture file is a placeholder by spec design (Task 7 step 1's note explains the capture procedure). diff --git a/docs/development/contributing.md b/docs/development/contributing.md index bf7b95e5..5ec38bdd 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -413,4 +413,3 @@ policy: if the component requires new packages See the [registry pattern](#registry-pattern) section above for complete examples. - diff --git a/docs/development/mining-nvidia-validation.md b/docs/development/mining-nvidia-validation.md new file mode 100644 index 00000000..0f465691 --- /dev/null +++ b/docs/development/mining-nvidia-validation.md @@ -0,0 +1,289 @@ +# NVIDIA Pearl Mining Validation Runbook + +This runbook is the release gate for the v1 `vllm-pearl` provider. Unit tests +prove OpenJarvis wiring; this validates that a real H100/H200 host can mine +through Pearl and serve inference through OpenJarvis. + +## Required Host + +Run this on a Linux machine with: + +- NVIDIA H100 or H200 GPU, compute capability 9.0, at least 70 GB VRAM +- Current NVIDIA driver with CUDA container support +- Docker 24+ and `nvidia-container-toolkit` +- At least 200 GB free disk +- Reachable `pearld` JSON-RPC endpoint +- Pearl payout address beginning with `prl1q` or `prl1p` +- Hugging Face access to `pearl-ai/Llama-3.3-70B-Instruct-pearl` + +The validated H100 configuration uses `gpu_memory_utilization = 0.96` with +`max_model_len = 8192`. Lower memory utilization can fail during vLLM startup +because the Pearl 70B mining model leaves too little KV cache at 8k context. + +Do not run this on macOS, Apple Silicon, AMD, RTX 4090, or CPU-only hosts. +Those are separate providers. + +## Wallet Address Setup + +Create the wallet from the Pearl repo root: + +```bash +./bin/oyster -u rpcuser -P rpcpass --create +``` + +If you choose the optional public-data encryption prompt, Oyster will require +that public passphrase on startup via `--walletpass`. Keep private and public +passphrases out of shell history where possible. + +Start Oyster: + +```bash +./bin/oyster \ + -u rpcuser \ + -P rpcpass \ + --walletpass '' \ + & +``` + +Then generate a mining address through the wallet RPC: + +```bash +./bin/prlctl \ + --wallet \ + --skipverify \ + -u rpcuser \ + -P rpcpass \ + -s localhost:44207 \ + getnewaddress +``` + +Notes: + +- `--wallet` is required. Without it, `prlctl` talks to `pearld` instead of + Oyster and may look for `Pearld/pearld.conf`. +- Use `-s localhost:44207`, not `-s https://localhost:44207`. `prlctl` expects + host and port, not a URL. +- `--skipverify` is acceptable for this local validation flow unless you have + configured the Oyster RPC certificate path. +- If a mnemonic has been pasted into logs, chat, or a PR, discard that wallet + and create a fresh one before mining. + +## Environment + +```bash +git checkout feat/mining-spec-a-only +uv sync --extra dev --extra mining-pearl-vllm + +export PEARLD_RPC_PASSWORD='' +export HF_TOKEN='' +``` + +Confirm host prerequisites: + +```bash +nvidia-smi +docker info +docker run --rm --gpus all nvidia/cuda:12.9.1-base-ubuntu24.04 nvidia-smi +df -h ~/.cache +``` + +Expected: + +- `nvidia-smi` shows H100 or H200. +- Docker can run a CUDA container with GPU access. +- `~/.cache` or the Hugging Face cache volume has at least 200 GB free. + +On shared hosts, select only idle GPUs during `mine init`: + +```bash +uv run jarvis mine init --cuda-visible-devices 0 +``` + +This writes `[mining.extra].cuda_visible_devices`. `mine start` passes that +device list to Docker and sets `CUDA_VISIBLE_DEVICES` / +`NVIDIA_VISIBLE_DEVICES` inside the container. Omit the option only on a +dedicated host where the miner may use all GPUs. + +## Configure Mining + +Run: + +```bash +uv run jarvis mine doctor +``` + +Before config exists, `doctor` should show hardware and Docker as OK, and +Pearl node / wallet as unconfigured. + +Then initialize: + +```bash +uv run jarvis mine init +``` + +Use: + +- Wallet: the user's Pearl `prl1q...` or `prl1p...` address +- `pearld` URL: usually `http://localhost:44107` +- RPC user: configured `pearld` user, often `rpcuser` +- Password env: `PEARLD_RPC_PASSWORD` +- Model: `pearl-ai/Llama-3.3-70B-Instruct-pearl` +- Image: default unless validating a custom Pearl image +- CUDA devices: an idle GPU ID such as `0` on shared hosts + +Expected: + +- `[mining]` and `[mining.extra]` are written to config. +- Image resolves locally, pulls, or builds from the pinned Pearl ref. +- First build may take 30-60 minutes. + +Run `doctor` again: + +```bash +uv run jarvis mine doctor +``` + +Expected: + +- Hardware OK +- Docker OK +- Disk OK +- Pearl node RPC OK and synced +- Wallet format OK +- `vllm-pearl SUPPORTED` +- Sidecar absent + +## Start Mining + +```bash +uv run jarvis mine start +``` + +Expected: + +- Docker container `openjarvis-pearl-miner` starts. +- `~/.openjarvis/runtime/mining.json` is written. +- Sidecar contains `vllm_endpoint`, `gateway_url`, `gateway_metrics_url`, and + `container_id`. + +Inspect: + +```bash +docker ps --filter name=openjarvis-pearl-miner +cat ~/.openjarvis/runtime/mining.json +uv run jarvis mine logs --tail 200 +uv run jarvis mine status +``` + +Expected: + +- Container is running. +- vLLM is listening on the configured port, default `8000`. +- Pearl gateway metrics are available on the configured metrics port, default + `8339`. +- `mine status` exits 0 and prints `provider: vllm-pearl`. + +## Verify OpenJarvis Inference Uses Mining Endpoint + +Run: + +```bash +uv run jarvis mine doctor +uv run jarvis ask "Say hello in one sentence." +``` + +Expected: + +- `doctor` shows sidecar present. +- Engine discovery registers `vllm-pearl-mining`. +- The prompt completes through the Pearl/vLLM endpoint. +- Container logs show vLLM activity during the prompt. + +If inference succeeds but mining stats stay zero, continue to the Pearl +network checks below; vLLM serving alone is not enough to prove mining. + +## Verify Pearl Network Submission + +Check gateway metrics directly: + +```bash +curl -fsS http://127.0.0.1:8339/metrics | tee /tmp/pearl-gateway-metrics.txt +uv run jarvis mine status +``` + +Expected: + +- Metrics endpoint returns Prometheus text. +- If Pearl exposes share counters, `mine status` maps them correctly. +- If metric names differ, attach `/tmp/pearl-gateway-metrics.txt` to the PR and + update `src/openjarvis/mining/_metrics.py`. + +Check `pearld` connectivity using the same RPC configuration used by mining: + +```bash +curl --user "rpcuser:${PEARLD_RPC_PASSWORD}" \ + --data-binary '{"jsonrpc":"1.0","id":"oj","method":"getblockchaininfo","params":[]}' \ + -H 'content-type: text/plain;' \ + http://127.0.0.1:44107 +``` + +Expected: + +- `blocks` and `headers` are present. +- Node is synced or close enough for mining validation. + +Proof of actual earning requires a successful accepted share/block and wallet +credit. Depending on Pearl network difficulty, this may take longer than the +smoke test window. Record: + +- Runtime duration +- `mine status` before and after +- Gateway metrics snapshot +- Relevant container log tail +- Wallet balance / transaction evidence if a reward lands + +## Stop And Cleanup + +```bash +uv run jarvis mine stop +docker ps --filter name=openjarvis-pearl-miner +test ! -e ~/.openjarvis/runtime/mining.json +``` + +Expected: + +- Container stops. +- Sidecar is removed. +- `jarvis ask` no longer routes through `vllm-pearl-mining` unless another + mining sidecar is attached. + +## Pass Criteria + +The NVIDIA provider is considered proven when all are true: + +- `mine doctor` reports supported on H100/H200. +- `mine init` resolves/builds the Pearl image. +- `mine start` launches the container and writes the sidecar. +- OpenJarvis inference succeeds through `vllm-pearl-mining`. +- Pearl gateway metrics are reachable and `mine status` parses them. +- `pearld` accepts the miner's network path. +- At least one accepted share/block is observed, or a documented Pearl + maintainer confirmation says the observed gateway state is sufficient proof + of live mining. + +## Failure Artifacts + +For any failure, collect: + +```bash +uv run jarvis mine doctor +uv run jarvis mine status || true +uv run jarvis mine logs --tail 300 || true +docker inspect openjarvis-pearl-miner || true +curl -fsS http://127.0.0.1:8339/metrics || true +nvidia-smi +docker info +``` + +Attach outputs to the implementation PR or follow-up issue. Do not paste +`PEARLD_RPC_PASSWORD`, wallet seed material, or Hugging Face tokens. diff --git a/docs/development/mining.md b/docs/development/mining.md new file mode 100644 index 00000000..c996b52a --- /dev/null +++ b/docs/development/mining.md @@ -0,0 +1,84 @@ +# Adding a Mining Provider + +The `openjarvis.mining` subsystem follows the same registry pattern as engines, +agents, tools, memory, and channels. New mining paths should be provider +modules, not special cases in the CLI or engine layer. + +## Provider Contract + +Every provider implements `openjarvis.mining.MiningProvider`: + +- `detect(hw, engine_id, model)` is pure capability detection. It must not + start subprocesses, hit the network, or mutate state. +- `start(config)` owns provider lifecycle setup and writes the mining sidecar + when it changes inference routing. +- `stop()` tears down provider-owned processes or containers. +- `is_running()` answers from provider-owned state. +- `stats()` returns `MiningStats` using the provider's most stable telemetry + surface. + +Register providers through `MinerRegistry` and expose idempotent +`ensure_registered()`: + +```python +from openjarvis.core.registry import MinerRegistry + + +def ensure_registered() -> None: + if not MinerRegistry.contains("my-provider"): + MinerRegistry.register_value("my-provider", MyProvider) +``` + +`tests/conftest.py` clears registries between tests, so test fixtures and CLI +entry points should call `ensure_registered()` before relying on a provider. + +## Optional Dependencies + +Provider dependencies belong in scoped extras: + +- `mining-pearl-vllm` for the NVIDIA/vLLM Docker provider +- Future Apple work should use a separate extra such as `mining-pearl-metal` + or `mining-pearl-cpu` + +Avoid a generic `mining-pearl` extra until there is a shared dependency set +that every provider actually needs. + +## Sidecar Contract + +The runtime sidecar lives at `~/.openjarvis/runtime/mining.json`. Engine +handoff is data-driven: + +- If the sidecar has `vllm_endpoint`, engine discovery registers + `vllm-pearl-mining`. +- If a future provider mines alongside the user's normal engine, it should omit + `vllm_endpoint`; engine discovery will ignore it. + +Do not branch on `provider == "vllm-pearl"` in generic code. Branch on sidecar +shape or provider capability. + +## Apple Silicon Handoff + +The Apple Silicon effort should add its own provider module and reuse: + +- `MiningProvider` +- `MinerRegistry` +- `MiningConfig` +- `MiningStats` +- `Sidecar` +- `jarvis mine doctor` capability iteration + +That work should not need to rewrite the NVIDIA provider, CLI group, telemetry +collector, or engine sidecar handoff. + +## NVIDIA Release Gate + +The NVIDIA provider is not considered economically proven until the H100/H200 +runbook passes on real hardware. See +[`mining-nvidia-validation.md`](./mining-nvidia-validation.md) for the required +commands, artifacts, and pass criteria. + +## Model Enablement + +New Pearl-compatible language models are tracked separately from provider +support. See [`pearl-model-enablement.md`](./pearl-model-enablement.md) for the +conversion and validation checklist. diff --git a/docs/development/pearl-model-enablement.md b/docs/development/pearl-model-enablement.md new file mode 100644 index 00000000..ea6235a4 --- /dev/null +++ b/docs/development/pearl-model-enablement.md @@ -0,0 +1,93 @@ +# Pearl Model Enablement + +This page tracks the work required to make a new Hugging Face model mineable +through Pearl's vLLM miner and OpenJarvis. + +OpenJarvis can point `vllm-pearl` at a model id, but a raw Hugging Face model is +not enough. The Pearl vLLM plugin expects a Pearl-compatible quantized model +whose metadata marks mining layers for 7-bit NoisyGEMM and non-mining layers +for the vanilla Pearl GEMM path. + +## Target Models + +| Raw model | Planned Pearl model | Status | Tracking | +|---|---|---|---| +| `Qwen/Qwen3.5-9B` | `pearl-ai/Qwen3.5-9B-pearl` | Planned | [#316](https://github.com/open-jarvis/OpenJarvis/issues/316) | +| `Qwen/Qwen3.6-27B` | `pearl-ai/Qwen3.6-27B-pearl` | Planned | [#317](https://github.com/open-jarvis/OpenJarvis/issues/317) | +| `google/gemma-4-E4B-it` | `pearl-ai/Gemma-4-E4B-it-pearl` | Planned | [#318](https://github.com/open-jarvis/OpenJarvis/issues/318) | +| `google/gemma-4-31B-it` | `pearl-ai/Gemma-4-31B-it-pearl` | Planned | [#319](https://github.com/open-jarvis/OpenJarvis/issues/319) | + +The current validated model remains: + +```text +pearl-ai/Llama-3.3-70B-Instruct-pearl +``` + +## Enablement Checklist + +1. Reproduce the current Llama Pearl model recipe. + - Record the compressed-tensors config. + - Record which linear layers are 7-bit mining layers. + - Record which layers are 8-bit non-mining layers. + - Record calibration data and SmoothQuant settings, if used. + +2. Convert the target model. + - Start with `Qwen/Qwen3.5-9B`; it is the smallest target. + - Generate Pearl-compatible quantized weights and metadata. + - Publish under the planned `pearl-ai/*-pearl` id or a staging namespace. + +3. Validate the Pearl vLLM plugin path. + - Model loads in Pearl's `vllm-miner` container. + - vLLM registers Pearl's quantization plugin. + - Mining layers use int7 NoisyGEMM. + - Non-mining layers use int8 vanilla Pearl GEMM. + - Text generation works with mining enabled and disabled. + +4. Validate chain integration. + - `pearld` is reachable. + - `pearl-gateway` receives work. + - NoisyGEMM submits candidate proofs. + - Gateway reports metrics. + - `jarvis mine status` parses those metrics. + +5. Promote the model in OpenJarvis. + - Change its registry status from `planned` to `validated`. + - Set measured VRAM and context defaults. + - Add the model to user docs. + - Attach validation logs to the PR. + +## OpenJarvis Registry + +Model support metadata lives in: + +```text +src/openjarvis/mining/_models.py +``` + +`jarvis mine models` renders that registry. Planned models are visible to users +but blocked by capability detection until the Pearl model artifact and H100/H200 +validation exist. + +## Acceptance Criteria + +A model is `validated` only when all of these pass on real hardware: + +- `jarvis mine init --model ` +- `jarvis mine start` +- `curl http://127.0.0.1:8000/v1/models` +- `jarvis ask "Say hello in one sentence."` +- `jarvis mine status` +- `jarvis mine validate-model --model --allow-planned --prompt + "Say hello in one sentence." --output .json` +- Pearl gateway metrics show the mining path is active. +- No block/share submission errors appear in gateway or miner logs. + +Do not mark a model validated based only on vLLM load success. It must exercise +Pearl's NoisyGEMM and submission path. + +## Tracking + +Use the `Pearl Model Validation` GitHub issue template for each candidate model. +The issue should hold the quantization recipe, hardware details, command output, +metrics excerpts, and the PR that changes the model status to `validated`. +Attach the JSON artifact from `jarvis mine validate-model --output` to the issue. diff --git a/docs/user-guide/cli.md b/docs/user-guide/cli.md index 5d05906f..89221768 100644 --- a/docs/user-guide/cli.md +++ b/docs/user-guide/cli.md @@ -199,6 +199,35 @@ jarvis model pull qwen3:8b --- +## `jarvis pearl` + +Access Pearl's native node, wallet, and RPC tools from the OpenJarvis CLI. + +```bash +jarvis pearl doctor +jarvis pearl node -- +jarvis pearl wallet -- +jarvis pearl ctl -- +jarvis pearl address +``` + +All Pearl wrapper commands use the `jarvis pearl ` shape. The +pass-through commands map to Pearl's native binaries: + +| OpenJarvis command | Pearl binary | Use | +|--------------------|--------------|-----| +| `jarvis pearl doctor` | n/a | Check whether `pearld`, `oyster`, and `prlctl` are discoverable | +| `jarvis pearl node` | `pearld` | Run the Pearl full node | +| `jarvis pearl wallet` | `oyster` | Run the Oyster wallet daemon | +| `jarvis pearl ctl` | `prlctl` | Query Pearl node or wallet RPC | +| `jarvis pearl address` | `prlctl --wallet getnewaddress` | Generate a wallet address from Oyster | + +Use `PEARL_HOME=/path/to/pearl` or `--pearl-home /path/to/pearl` if Pearl's +`bin/` directory is not on `PATH`. See the [Pearl CLI guide](pearl.md) for +examples. + +--- + ## `jarvis memory` Manage the document memory store for retrieval-augmented generation. diff --git a/docs/user-guide/mining-apple-silicon.md b/docs/user-guide/mining-apple-silicon.md new file mode 100644 index 00000000..e80a2078 --- /dev/null +++ b/docs/user-guide/mining-apple-silicon.md @@ -0,0 +1,162 @@ +# Mining Pearl on Apple Silicon (and other CPU hosts) + +OpenJarvis can mine the [Pearl](https://github.com/pearl-research-labs/pearl) chain +on Apple Silicon Macs (M1/M2/M3/M4) using the `cpu-pearl` provider. **This is +v1**: decoupled CPU mining. Your existing local LLM workflow (Ollama, MLX-LM, +llama.cpp, vLLM) is untouched; mining runs in the background as a separate +process. + +## Honest expectations + +**Hashrate on Apple Silicon CPU is far below what an H100 produces with +Pearl's `vllm-miner`.** A rough rule of thumb (subject to network difficulty): + +- M2 Max / M4 Max: ≪ 1 share per second at typical mainnet difficulty +- H100 with `vllm-miner`: meaningfully higher, plus the mining work is + amortized over real LLM inference + +If you want to mine for yield, this isn't the path. If you want to participate +in the network from the hardware you own, with no special hardware purchase, +this is the path. + +An experimental `apple-mps-pearl` provider is available for developers. It +uses PyTorch MPS for the NoisyGEMM matmuls, while transcript hashing and proof +construction still run on CPU. This proves the Apple-GPU path can produce +validator-accepted `PlainProof`s, but it is not yet the high-performance Metal +kernel path. + +## Prerequisites + +- macOS arm64 (M1, M2, M3, M4) — or Linux x86_64 / aarch64 +- Python 3.12 (`brew install python@3.12` or use `uv venv --python 3.12`) +- Rust toolchain (`brew install rust` or `curl https://sh.rustup.rs -sSf | sh`) +- Your own running [`pearld`](https://github.com/pearl-research-labs/pearl#node) + node, RPC reachable on `http://localhost:44107` +- A Pearl Taproot wallet address from `oyster` (Pearl's wallet CLI) +- ~1 GB free disk for the Pearl source clone and build artifacts + +## Install + +```bash +# from your OpenJarvis repo +uv sync --extra mining-pearl-cpu +``` + +If Pearl wheels are not yet on PyPI (still true as of 2026-05-05), `uv sync` +succeeds but doesn't install the actual Pearl Python packages. Build/install +them from a local Pearl checkout: + +```bash +cd /path/to/pearl/py-pearl-mining +maturin build --release +uv pip install target/wheels/py_pearl_mining-*.whl +uv pip install ../miner/miner-utils ../miner/pearl-gateway ../miner/miner-base +``` + +## Configure + +Create a Pearl wallet and start a synced `pearld` separately using Pearl's +README. Then write OpenJarvis' mining config: + +```bash +export PEARLD_RPC_PASSWORD="rpcpass" + +jarvis mine init \ + --provider cpu-pearl \ + --wallet-address "" \ + --pearld-rpc-url http://127.0.0.1:44107 \ + --pearld-rpc-user rpcuser \ + --pearld-rpc-password-env PEARLD_RPC_PASSWORD +``` + +On Apple Silicon, `--provider auto` chooses `apple-mps-pearl`; use +`--provider cpu-pearl` for the conservative CPU path. The MPS path is +experimental and currently useful for validation/profiling, not revenue. + +This writes: + +```toml +[mining] +provider = "cpu-pearl" +wallet_address = "prl1..." +submit_target = "solo" +fee_bps = 0 + +[mining.extra] +pearld_rpc_url = "http://127.0.0.1:44107" +pearld_rpc_user = "rpcuser" +pearld_rpc_password_env = "PEARLD_RPC_PASSWORD" +gateway_host = "127.0.0.1" +gateway_port = 8337 +metrics_port = 9109 +``` + +## Run + +```bash +jarvis mine doctor # capability matrix +jarvis mine start # launch gateway + miner-loop subprocesses +jarvis mine status # check sidecar + gateway metrics +jarvis mine logs -n 120 # print recent logs +jarvis mine stop # stop mining subprocesses +``` + +## Reading `mine doctor` + +Each row is one check. `✓` means the check passed; `✗` shows the actionable fix. + +``` +$ jarvis mine doctor +Hardware + GPU vendor apple ✓ + Apple chip M2 Max ✓ +Pearl install + py-pearl-mining 0.1.0 (cp312-abi3-macos-arm64) ✓ + miner-base 0.1.0 ✓ + pearl-gateway 0.1.0 ✓ +Pearl node + RPC http://localhost:44107 ✓ + Block height 442107 (synced) ✓ +Wallet + Address format prl1q... ✓ +Provider capability + cpu-pearl SUPPORTED (calibrated 0.X share/h on M2 Max) +Notes + - This is decoupled mining: your normal LLM inference is unaffected + - Hashrate is far below H100 mining; see this doc above + - MPS mining: available as experimental apple-mps-pearl +Session + Sidecar absent (not running) +``` + +## Limitations + +- **Windows is not supported in v1.** Pearl's pure-Rust miner builds on + Windows in principle but the cross-platform install path is untested. Use + WSL2 if you must. +- **No coupling to inference yet.** v1 is a separate process; your CPU does + mining, your GPU does inference. They don't share work. v2 changes this. +- **Experimental PyTorch-MPS only.** `apple-mps-pearl` moves the NoisyGEMM + matmuls to MPS but still has CPU readbacks for transcript hashing and proof + construction. Use it for validation and profiling, not revenue expectations. +- **No multi-host pool.** Solo mining only. The pool work is a separate spec. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `mine doctor` says `Pearl Python packages not installed` | Wheels not built yet | Run `jarvis mine init` | +| `pearl-gateway` log shows `connection refused` to `http://localhost:44107` | `pearld` not running | Start `pearld` per Pearl's README | +| `mine status` shows `last_error: gateway metrics unreachable` | `pearl-gateway` crashed | Check `~/.openjarvis/logs/mining/pearl-gateway.log` | +| Build fails with `error: linker 'cc' not found` | Xcode CLT not installed | `xcode-select --install` | +| `maturin build` complains about `tikv-jemallocator` | macOS SDK too old | Update macOS / Xcode | + +For anything not on this list, capture `~/.openjarvis/logs/mining/` and open +an issue at https://github.com/open-jarvis/OpenJarvis/issues. + +## What changes in v2 / v3 + +- **v2:** Optimize the current `apple-mps-pearl` path, then optionally plug it + into MLX-LM or `llama-cpp-python` so inference matmuls become mining work. +- **v3 (only if v2 perf is insufficient):** Native Metal kernel as a Pearl + upstream contribution. No user-visible change other than higher hashrate. diff --git a/docs/user-guide/mining.md b/docs/user-guide/mining.md new file mode 100644 index 00000000..6e02e0f3 --- /dev/null +++ b/docs/user-guide/mining.md @@ -0,0 +1,133 @@ +# Pearl Mining + +OpenJarvis can mine the Pearl Proof-of-Useful-Work chain through local LLM +inference. The primary v1 path supports NVIDIA H100/H200 hosts running vLLM +with Pearl's Docker miner. The consolidated Pearl integration also includes +experimental Apple Silicon and CPU providers through the same `MiningProvider` +registry. + +## Prerequisites + +| Requirement | v1 expectation | +|---|---| +| GPU | NVIDIA H100 or H200, sm_90a class, at least 70 GB VRAM | +| OS | Linux with `nvidia-container-toolkit` configured | +| Docker | Docker 24+ with GPU runtime access | +| Disk | At least 200 GB free for the 70B model and build cache | +| Pearl node | Reachable `pearld` JSON-RPC endpoint, default `http://localhost:44107` | +| Wallet | Pearl address beginning with `prl1q` or `prl1p` | + +The default vLLM config uses `gpu_memory_utilization = 0.96` and +`max_model_len = 8192` for the Pearl 70B mining model on H100/H200 80 GB GPUs. + +To generate a wallet address with Pearl's Oyster wallet, run Pearl's wallet +daemon and query it with `prlctl --wallet --skipverify -s localhost:44207 +getnewaddress`. Do not reuse a wallet whose mnemonic has been pasted into logs, +chat, or issue trackers. + +## Quick Start + +```bash +uv sync --extra mining-pearl-vllm +export PEARLD_RPC_PASSWORD= +export HF_TOKEN= + +uv run jarvis mine init +uv run jarvis mine start +uv run jarvis mine status +``` + +`mine init` writes a `[mining]` config section and resolves the Pearl Docker +image. If Pearl has not published a suitable image for the pinned ref, +OpenJarvis falls back to building from the pinned Pearl source checkout. First +builds can take 30-60 minutes. + +On a shared NVIDIA host, restrict the miner to idle GPUs: + +```bash +uv run jarvis mine init --cuda-visible-devices 0 +``` + +This writes `[mining.extra].cuda_visible_devices`, which `mine start` passes to +Docker instead of exposing every GPU on the machine. + +## Commands + +- `jarvis mine models` lists Pearl model support status. +- `jarvis mine doctor` prints hardware, Docker, Pearl node, wallet, provider, + and session checks. +- `jarvis mine init` writes the local mining config and resolves the image. +- `jarvis mine start` launches the Pearl miner container and writes the runtime + sidecar. +- `jarvis mine stop` stops the provider and removes the sidecar. +- `jarvis mine status` reads live gateway metrics. +- `jarvis mine attach` writes a sidecar for a miner you launched manually. +- `jarvis mine logs` prints the Docker container log tail. +- `jarvis mine validate-model` probes the active vLLM miner and gateway before + promoting a planned Pearl model to validated. + +## Model Support + +Run: + +```bash +jarvis mine models +``` + +OpenJarvis only enables models that have Pearl-compatible quantized artifacts +and real hardware validation. Raw Hugging Face models such as +`Qwen/Qwen3.5-9B` or `google/gemma-4-E4B-it` are not mineable by themselves; +they need corresponding `pearl-ai/*-pearl` variants. + +The default validated model is: + +```text +pearl-ai/Llama-3.3-70B-Instruct-pearl +``` + +The Qwen and Gemma targets are tracked in the model registry as planned until +Pearl quantization and H100/H200 validation are complete. + +When validating a newly converted Pearl model on a mining host, run: + +```bash +jarvis mine validate-model \ + --model pearl-ai/Qwen3.5-9B-pearl \ + --allow-planned \ + --prompt "Say hello in one sentence." \ + --output qwen3.5-9b-pearl-validation.json +``` + +Remove `--allow-planned` only after the model is promoted to validated in the +OpenJarvis registry. Attach the JSON artifact to the validation issue. + +## v1 Scope + +v1 is solo mining only. OpenJarvis does not take fees, custody funds, generate +wallet keys, run pools, or operate `pearld`. Users provide their own Pearl node +and payout address. + +Unsupported in this PR: + +- Pool mining and the future 20% OpenJarvis fee model +- AMD GPU mining and non-Pearl backends +- RTX 4090 or other non-Hopper NVIDIA GPUs +- Wallet generation or transaction signing inside OpenJarvis + +## Troubleshooting + +Run: + +```bash +uv run jarvis mine doctor +``` + +Read the rows top-down. Fix the first failing dependency before retrying +`mine start`. A Mac or AMD machine should fail honestly at provider capability; +those paths are expected to land as separate providers. + +## Production Readiness + +The NVIDIA path requires one real H100/H200 validation run before it should be +marketed as a proven earning path. The developer runbook is +[`../development/mining-nvidia-validation.md`](../development/mining-nvidia-validation.md). diff --git a/docs/user-guide/pearl.md b/docs/user-guide/pearl.md new file mode 100644 index 00000000..5a88cb54 --- /dev/null +++ b/docs/user-guide/pearl.md @@ -0,0 +1,58 @@ +# Pearl CLI Integration + +OpenJarvis includes a thin `jarvis pearl` wrapper for Pearl's native command +line tools. It does not replace Pearl's node or wallet; it makes the common +commands discoverable from the same CLI users use for mining. + +## Binary Discovery + +`jarvis pearl` looks for `pearld`, `oyster`, and `prlctl` on `PATH`, then under +`$PEARL_HOME/bin`. + +```bash +export PEARL_HOME=/path/to/pearl +jarvis pearl doctor +``` + +## Native Pass-Through + +Use pass-through commands when you need the full Pearl surface: + +```bash +jarvis pearl node -- --help +jarvis pearl wallet -- --help +jarvis pearl ctl -- --help +``` + +These map directly to: + +| OpenJarvis command | Pearl binary | +|---|---| +| `jarvis pearl node` | `pearld` | +| `jarvis pearl wallet` | `oyster` | +| `jarvis pearl ctl` | `prlctl` | + +The command format is always `jarvis pearl `. Pearl-native arguments +go after that command. Use `--` before Pearl arguments when the arguments begin +with dashes and you want to make the pass-through boundary explicit. + +## Wallet Address Helper + +If Oyster is already running, generate a mining address through wallet RPC: + +```bash +jarvis pearl address \ + -u rpcuser \ + -P rpcpass \ + -s localhost:44207 +``` + +The helper uses `prlctl --wallet` and defaults to `--notls`, which matches the +local validation flow. Use `--tls --skipverify` if your Oyster RPC endpoint is +serving TLS with a local certificate. + +## Boundary + +`jarvis mine` is the OpenJarvis mining lifecycle. `jarvis pearl` is an escape +hatch to Pearl's native node, wallet, and RPC tools. For advanced node or +wallet administration, Pearl's own help output is the source of truth. diff --git a/mkdocs.yml b/mkdocs.yml index 6c1c2c0d..7da46602 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -166,6 +166,9 @@ nav: - API Reference: api-reference/ - User Guide: - CLI: user-guide/cli.md + - Pearl CLI: user-guide/pearl.md + - Pearl Mining: user-guide/mining.md + - Pearl Mining on Apple Silicon: user-guide/mining-apple-silicon.md - Python SDK: user-guide/python-sdk.md - Morning Digest: user-guide/morning-digest.md - Deep Research: user-guide/deep-research.md @@ -183,3 +186,6 @@ nav: - Learning & Distillation: user-guide/learning-distillation.md - Leaderboard: leaderboard.md - Roadmap: development/roadmap.md + - Development: + - Mining: development/mining.md + - Pearl Model Enablement: development/pearl-model-enablement.md diff --git a/pyproject.toml b/pyproject.toml index 23365058..2f568fc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,7 @@ channel-gmail = [ ] browser = ["playwright>=1.40"] media = ["openai>=1.30"] +mining-pearl-vllm = ["docker>=7.0", "httpx>=0.27"] pdf = ["pdfplumber>=0.10"] scheduler = ["croniter>=2.0"] security-signing = ["cryptography>=43"] @@ -117,6 +118,16 @@ speech = ["faster-whisper>=1.0"] speech-deepgram = ["deepgram-sdk>=3.0"] eval-wandb = ["wandb>=0.17"] eval-sheets = ["gspread>=6.0", "google-auth>=2.0"] +mining-pearl-cpu = [ + # Pearl Python packages (py-pearl-mining, miner-base, pearl-gateway) are + # not on PyPI yet. They are installed at first `mine init` via the + # build-from-pin path in src/openjarvis/mining/_install.py:build_from_pin(). + # When Pearl publishes wheels, replace the line below with version pins. + # + # The extra remains as a feature-flag namespace so users can run + # `uv sync --extra mining-pearl-cpu` to opt-in (it succeeds with no + # additional resolution today; the actual install happens on demand). +] framework-comparison = ["polars>=1.0"] docs = [ "mkdocs>=1.6", @@ -146,14 +157,15 @@ packages = ["src/openjarvis"] [tool.pytest.ini_options] testpaths = ["tests"] markers = [ - "live: requires running inference engine", - "cloud: requires cloud API keys", - "nvidia: requires NVIDIA GPU", "amd: requires AMD GPU", "apple: requires Apple Silicon", + "cloud: requires cloud API keys", + "docker: requires a working Docker daemon (no GPU required)", + "live: requires running inference engine", "macos15: requires macOS 15+ (Sequoia) for Apple FM", - "slow: long-running test", "live_channel: requires real channel credentials (env vars)", + "nvidia: requires NVIDIA GPU", + "slow: long-running test", "live_external: requires HERMES_AGENT_PATH and OPENCLAW_PATH; spawns real foreign-framework subprocesses", ] diff --git a/src/openjarvis/cli/__init__.py b/src/openjarvis/cli/__init__.py index 88b601f8..de29bcc7 100644 --- a/src/openjarvis/cli/__init__.py +++ b/src/openjarvis/cli/__init__.py @@ -26,9 +26,11 @@ from openjarvis.cli.gateway_cmd import gateway from openjarvis.cli.host_cmd import host from openjarvis.cli.init_cmd import init from openjarvis.cli.memory_cmd import memory +from openjarvis.cli.mine_cmd import mine from openjarvis.cli.model import model from openjarvis.cli.operators_cmd import operators from openjarvis.cli.optimize_cmd import optimize_group +from openjarvis.cli.pearl_cmd import pearl from openjarvis.cli.quickstart_cmd import quickstart from openjarvis.cli.registry_cmd import registry from openjarvis.cli.scan_cmd import scan @@ -78,6 +80,8 @@ cli.add_command(chat, "chat") cli.add_command(serve, "serve") cli.add_command(model, "model") cli.add_command(memory, "memory") +cli.add_command(mine, "mine") +cli.add_command(pearl, "pearl") cli.add_command(telemetry, "telemetry") cli.add_command(bench, "bench") cli.add_command(channel, "channel") diff --git a/src/openjarvis/cli/hints.py b/src/openjarvis/cli/hints.py index 8d2a0476..d76a9320 100644 --- a/src/openjarvis/cli/hints.py +++ b/src/openjarvis/cli/hints.py @@ -43,3 +43,10 @@ def hint_no_model(model_name: Optional[str] = None) -> str: " Pull a model first: [bold]ollama pull qwen3.5:2b[/bold]\n" " Run [bold]jarvis model list[/bold] to see available models." ) + + +def mining_not_running_hint(cfg: object | None, sidecar_present: bool) -> Optional[str]: + """Return a mining hint when configured but no session sidecar exists.""" + if cfg is None or sidecar_present: + return None + return "mining configured but not running - start it with `jarvis mine start`" diff --git a/src/openjarvis/cli/mine_cmd.py b/src/openjarvis/cli/mine_cmd.py new file mode 100644 index 00000000..38598296 --- /dev/null +++ b/src/openjarvis/cli/mine_cmd.py @@ -0,0 +1,753 @@ +"""``jarvis mine`` command group.""" + +from __future__ import annotations + +import asyncio +import json +import os +import signal +import time +import urllib.request +from pathlib import Path +from typing import Any + +import click + +from openjarvis.core.config import HardwareInfo, detect_hardware, load_config +from openjarvis.core.registry import MinerRegistry +from openjarvis.mining._constants import ( + DEFAULT_GATEWAY_METRICS_PORT, + DEFAULT_GATEWAY_RPC_PORT, + DEFAULT_PEARL_MODEL, + DEFAULT_PEARLD_RPC_URL, + PEARL_IMAGE_TAG, + SIDECAR_PATH, +) +from openjarvis.mining._discovery import ( + check_disk_free, + check_docker_available, + check_pearld_reachable, + check_wallet_address_format, + detect_for_engine_model, +) +from openjarvis.mining._docker import PearlDockerLauncher +from openjarvis.mining._metrics import parse_gateway_metrics +from openjarvis.mining._models import ( + get_pearl_model_spec, + iter_pearl_model_specs, + pearl_variant_for_base_model, +) +from openjarvis.mining._stubs import Sidecar +from openjarvis.mining.vllm_pearl import ensure_registered as ensure_vllm_registered + + +def _detect_hardware() -> HardwareInfo: + """Wrapper to make hardware detection mockable in CLI tests.""" + return detect_hardware() + + +def _docker_from_env(): + """Wrapper to make Docker client creation mockable in CLI tests.""" + import docker + + return docker.from_env() + + +def _ensure_providers_registered() -> None: + ensure_vllm_registered() + from openjarvis.mining.cpu_pearl import ensure_registered as ensure_cpu_registered + + ensure_cpu_registered() + try: + from openjarvis.mining.apple_mps_pearl import ( + ensure_registered as ensure_mps_registered, + ) + + ensure_mps_registered() + except ImportError: + pass + + +def _provider_ids() -> tuple[str, ...]: + _ensure_providers_registered() + return tuple(MinerRegistry.keys()) + + +def _select_provider(provider: str) -> str: + if provider != "auto": + return provider + hw = _detect_hardware() + gpu_vendor = hw.gpu.vendor.lower() if hw.gpu else "" + if hw.platform == "darwin" and gpu_vendor == "apple": + return "apple-mps-pearl" + if hw.platform == "linux" and gpu_vendor == "nvidia": + return "vllm-pearl" + return "cpu-pearl" + + +def _stats_from_metrics_url( + metrics_url: str, + provider_id: str, +) -> tuple[Any | None, str | None]: + try: + with urllib.request.urlopen(metrics_url, timeout=2.0) as resp: + text = resp.read().decode() + except Exception as exc: # noqa: BLE001 + return None, str(exc) + return parse_gateway_metrics(text, provider_id=provider_id), None + + +def _get_json(url: str, *, timeout: float) -> dict[str, Any]: + with urllib.request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + + +def _post_json(url: str, payload: dict[str, Any], *, timeout: float) -> dict[str, Any]: + request = urllib.request.Request( + url, + data=json.dumps(payload).encode(), + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + + +def _extract_model_ids(models_payload: dict[str, Any]) -> set[str]: + data = models_payload.get("data", []) + ids: set[str] = set() + if isinstance(data, list): + for item in data: + if isinstance(item, dict) and isinstance(item.get("id"), str): + ids.add(item["id"]) + elif isinstance(item, str): + ids.add(item) + return ids + + +def _pid_alive(pid: int | None) -> bool: + if not pid: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _terminate_pid(pid: int | None, *, grace_seconds: float = 3.0) -> None: + if not pid or not _pid_alive(pid): + return + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + grace_seconds + while time.monotonic() < deadline: + if not _pid_alive(pid): + return + time.sleep(0.05) + if _pid_alive(pid): + os.kill(pid, signal.SIGKILL) + + +def _row(name: str, ok: bool, info: str) -> None: + marker = "OK" if ok else "FAIL" + click.echo(f" {name:<22} {info:<45} {marker}") + + +def _validation_row(results: list[bool], name: str, ok: bool, info: str) -> None: + results.append(ok) + _row(name, ok, info) + + +@click.group() +def mine() -> None: + """Configure and run Pearl mining.""" + + +@mine.command("models") +def models() -> None: + """List Pearl model support status.""" + click.echo("Pearl Mining Models") + click.echo( + f"{'Status':<10} {'Model':<42} {'Base model':<28} {'VRAM':<8} {'Context':<8}" + ) + for spec in iter_pearl_model_specs(): + click.echo( + f"{spec.status:<10} {spec.model_id:<42} {spec.base_model_id:<28} " + f"{spec.min_vram_gb:.0f} GB {spec.default_max_model_len:<8}" + ) + if spec.notes: + click.echo(f" {spec.notes}") + + +@mine.command() +def doctor() -> None: + """Diagnose mining capability with one row per check.""" + _ensure_providers_registered() + hw = _detect_hardware() + load_config.cache_clear() + cfg = load_config() + mining_cfg = cfg.mining + + click.echo("Pearl Mining Doctor") + click.echo("Hardware") + gpu = hw.gpu + _row( + "GPU vendor", + bool(gpu and gpu.vendor == "nvidia"), + gpu.vendor if gpu else "none", + ) + compute_capability = gpu.compute_capability if gpu else "n/a" + _row( + "Compute capability", + bool(gpu and compute_capability.startswith("9.0")), + compute_capability, + ) + vram_gb = gpu.vram_gb if gpu else 0.0 + _row("VRAM", vram_gb >= 70.0, f"{vram_gb:.0f} GB") + + click.echo("Docker") + ok, info = check_docker_available() + _row("Daemon", ok, info) + + click.echo("Disk") + ok, info = check_disk_free(Path.home()) + _row("Free space", ok, info) + + click.echo("Pearl node") + if mining_cfg is None: + _row("RPC", False, "no [mining] config - run `jarvis mine init`") + else: + url = mining_cfg.extra.get("pearld_rpc_url", DEFAULT_PEARLD_RPC_URL) + user = mining_cfg.extra.get("pearld_rpc_user", "rpcuser") + password_env = mining_cfg.extra.get( + "pearld_rpc_password_env", + "PEARLD_RPC_PASSWORD", + ) + ok, info = check_pearld_reachable(url, user, os.environ.get(password_env, "")) + _row("RPC", ok, info) + + click.echo("Wallet") + if mining_cfg is None: + _row("Address format", False, "no [mining] config") + else: + ok, info = check_wallet_address_format(mining_cfg.wallet_address) + _row("Address format", ok, info) + + click.echo("Provider capability") + engine_id = "vllm" + model = DEFAULT_PEARL_MODEL + configured_provider = None + if mining_cfg is not None: + model = mining_cfg.extra.get("model", DEFAULT_PEARL_MODEL) + configured_provider = mining_cfg.provider + spec = get_pearl_model_spec(model) + if spec is None: + planned = pearl_variant_for_base_model(model) + info = f"raw model; planned Pearl variant {planned}" if planned else "unknown" + _row("Model registry", False, info) + else: + _row("Model registry", spec.is_validated, f"{spec.status}: {spec.model_id}") + + for provider_id, provider_cls in MinerRegistry.items(): + cap = provider_cls.detect(hw, engine_id, model) + suffix = " (configured)" if provider_id == configured_provider else "" + if cap.supported: + click.echo(f" {provider_id:<22} SUPPORTED{suffix}") + else: + reason = cap.reason or "unsupported" + click.echo(f" {provider_id:<22} UNSUPPORTED - {reason}{suffix}") + + click.echo("Session") + sidecar = Sidecar.read(SIDECAR_PATH) + if sidecar is None: + click.echo(" Sidecar absent (not running)") + else: + click.echo(f" Sidecar present ({SIDECAR_PATH})") + click.echo(f" Container {sidecar.get('container_id', '?')}") + + +@mine.command() +@click.option( + "--provider", + type=click.Choice(["auto", "vllm-pearl", "cpu-pearl", "apple-mps-pearl"]), + default="vllm-pearl", + show_default=True, +) +@click.option( + "--wallet", + "--wallet-address", + prompt="Pearl wallet address (prl1q/prl1p...)", +) +@click.option( + "--pearld-url", + "--pearld-rpc-url", + default=DEFAULT_PEARLD_RPC_URL, + prompt="pearld RPC URL", +) +@click.option( + "--pearld-user", + "--pearld-rpc-user", + default="rpcuser", + prompt="pearld RPC user", +) +@click.option( + "--pearld-password-env", + "--pearld-rpc-password-env", + default="PEARLD_RPC_PASSWORD", + prompt="env var holding pearld password", +) +@click.option("--model", default=DEFAULT_PEARL_MODEL) +@click.option("--image", default=PEARL_IMAGE_TAG) +@click.option( + "--cuda-visible-devices", + default="", + help="Comma-separated NVIDIA GPU IDs to expose to the vLLM Pearl container.", +) +@click.option("--gateway-host", default="127.0.0.1", show_default=True) +@click.option("--gateway-port", default=DEFAULT_GATEWAY_RPC_PORT, show_default=True) +@click.option( + "--gateway-metrics-port", + "--metrics-port", + default=DEFAULT_GATEWAY_METRICS_PORT, + show_default=True, +) +def init( + provider: str, + wallet: str, + pearld_url: str, + pearld_user: str, + pearld_password_env: str, + model: str, + image: str, + cuda_visible_devices: str, + gateway_host: str, + gateway_port: int, + gateway_metrics_port: int, +) -> None: + """Interactive setup for the v1 Pearl mining providers.""" + _ensure_providers_registered() + selected_provider = _select_provider(provider) + if not MinerRegistry.contains(selected_provider): + raise click.ClickException(f"Unknown mining provider: {selected_provider}") + + ok, info = check_wallet_address_format(wallet) + if not ok: + raise click.ClickException(f"Invalid wallet address: {info}") + + if selected_provider == "vllm-pearl": + hw = _detect_hardware() + cap = detect_for_engine_model( + hw=hw, + engine_id="vllm", + model=model, + provider_id="vllm-pearl", + ) + if not cap.supported: + raise click.ClickException( + f"vllm-pearl not supported on this host: {cap.reason}\n" + "See `jarvis mine models` and `jarvis mine doctor` for details." + ) + model_spec = get_pearl_model_spec(model) + max_model_len = ( + model_spec.default_max_model_len if model_spec is not None else 8192 + ) + gpu_memory_utilization = ( + model_spec.default_gpu_memory_utilization + if model_spec is not None + else 0.96 + ) + + ok, info = check_docker_available() + if not ok: + raise click.ClickException(f"Docker unavailable: {info}") + + ok, info = check_disk_free(Path.home()) + if not ok: + raise click.ClickException(f"Insufficient disk: {info}") + else: + max_model_len = 8192 + gpu_memory_utilization = 0.96 + + if pearld_password_env not in os.environ: + click.echo( + f"Warning: ${pearld_password_env} is not set. " + "Set it before `jarvis mine start`.", + err=True, + ) + + from openjarvis.core.config import DEFAULT_CONFIG_PATH + + config_path = Path(os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_PATH)) + config_path.parent.mkdir(parents=True, exist_ok=True) + section = f""" +[mining] +provider = "vllm-pearl" +wallet_address = "{wallet}" +submit_target = "solo" +fee_bps = 0 +fee_payout_address = "" + +[mining.extra] +docker_image_tag = "{image}" +model = "{model}" +cuda_visible_devices = "{cuda_visible_devices.strip()}" +gateway_port = {gateway_port} +gateway_metrics_port = {gateway_metrics_port} +vllm_port = 8000 +gpu_memory_utilization = {gpu_memory_utilization} +max_model_len = {max_model_len} +pearld_rpc_url = "{pearld_url}" +pearld_rpc_user = "{pearld_user}" +pearld_rpc_password_env = "{pearld_password_env}" +hf_token_env = "HF_TOKEN" +""" + if selected_provider != "vllm-pearl": + section = f""" +[mining] +provider = "{selected_provider}" +wallet_address = "{wallet}" +submit_target = "solo" +fee_bps = 0 +fee_payout_address = "" + +[mining.extra] +gateway_host = "{gateway_host}" +gateway_port = {gateway_port} +metrics_port = {gateway_metrics_port} +pearld_rpc_url = "{pearld_url}" +pearld_rpc_user = "{pearld_user}" +pearld_rpc_password_env = "{pearld_password_env}" +""" + if config_path.exists(): + existing = config_path.read_text() + if "[mining]" in existing: + click.echo("[mining] section already present; not overwriting.") + return + config_path.write_text(existing.rstrip() + "\n" + section) + else: + config_path.write_text(section.lstrip()) + load_config.cache_clear() + + if selected_provider == "vllm-pearl": + click.echo(f"Resolving image {image}...") + PearlDockerLauncher(client=_docker_from_env()).ensure_image(image) + click.echo("Done. Run `jarvis mine start` to begin mining.") + + +@mine.command() +def start() -> None: + """Launch the configured mining provider.""" + _ensure_providers_registered() + load_config.cache_clear() + cfg = load_config().mining + if cfg is None: + raise click.ClickException( + "no [mining] section in config - run `jarvis mine init`" + ) + provider = MinerRegistry.get(cfg.provider)() + asyncio.run(provider.start(cfg)) + click.echo(f"Started {cfg.provider}. Run `jarvis mine status` for live stats.") + + +@mine.command() +def stop() -> None: + """Stop the configured mining provider.""" + _ensure_providers_registered() + sidecar = Sidecar.read(SIDECAR_PATH) + if sidecar and (sidecar.get("gateway_pid") or sidecar.get("miner_loop_pid")): + _terminate_pid(sidecar.get("miner_loop_pid"), grace_seconds=2.0) + _terminate_pid(sidecar.get("gateway_pid"), grace_seconds=5.0) + Sidecar.remove(SIDECAR_PATH) + click.echo("Mining stopped.") + return + load_config.cache_clear() + cfg = load_config().mining + if cfg is None: + click.echo("no [mining] section - nothing to stop") + return + provider = MinerRegistry.get(cfg.provider)() + asyncio.run(provider.stop()) + click.echo("Mining stopped.") + + +@mine.command() +def status() -> None: + """Print live mining stats.""" + _ensure_providers_registered() + sidecar = Sidecar.read(SIDECAR_PATH) + if sidecar is not None: + provider_id = str(sidecar.get("provider", "unknown")) + click.echo(f"provider: {provider_id}") + if "gateway_pid" in sidecar: + gateway_pid = sidecar.get("gateway_pid") + miner_pid = sidecar.get("miner_loop_pid") + click.echo( + f"gateway pid: {gateway_pid} " + f"({'alive' if _pid_alive(gateway_pid) else 'dead'})" + ) + click.echo( + f"miner pid: {miner_pid} " + f"({'alive' if _pid_alive(miner_pid) else 'dead'})" + ) + metrics_url = sidecar.get("metrics_url") + if metrics_url: + stats, error = _stats_from_metrics_url(str(metrics_url), provider_id) + if error: + click.echo(f"metrics error: {error}") + return + if stats is not None: + click.echo(f"Shares submitted: {stats.shares_submitted}") + click.echo(f"Shares accepted: {stats.shares_accepted}") + click.echo(f"Blocks found: {stats.blocks_found}") + return + + load_config.cache_clear() + cfg = load_config().mining + if cfg is None: + click.echo("No active mining session") + return + provider = MinerRegistry.get(cfg.provider)() + stats = provider.stats() + click.echo(f"provider: {stats.provider_id}") + click.echo(f"shares submitted: {stats.shares_submitted}") + click.echo(f"shares accepted: {stats.shares_accepted}") + click.echo(f"blocks found: {stats.blocks_found}") + click.echo(f"hashrate: {stats.hashrate:.2f}") + click.echo(f"uptime (s): {stats.uptime_seconds:.0f}") + click.echo(f"last share at: {stats.last_share_at or '-'}") + click.echo(f"last error: {stats.last_error or '-'}") + click.echo(f"payout target: {stats.payout_target}") + click.echo(f"fees owed: {stats.fees_owed}") + + +@mine.command("validate-model") +@click.option("--model", default=None, help="Pearl model id to validate.") +@click.option( + "--vllm-endpoint", + default=None, + help="OpenAI-compatible vLLM endpoint, defaults to the mining sidecar.", +) +@click.option( + "--gateway-metrics-url", + default=None, + help="Pearl gateway metrics URL, defaults to the mining sidecar.", +) +@click.option( + "--allow-planned", + is_flag=True, + default=False, + help="Allow planned models while collecting validation evidence.", +) +@click.option( + "--prompt", + default=None, + help="Optional chat-completion smoke prompt to run through vLLM.", +) +@click.option( + "--output", + type=click.Path(dir_okay=False, path_type=Path), + default=None, + help="Write a JSON validation artifact.", +) +@click.option("--timeout", default=10.0, show_default=True, type=float) +def validate_model( + model: str | None, + vllm_endpoint: str | None, + gateway_metrics_url: str | None, + allow_planned: bool, + prompt: str | None, + output: Path | None, + timeout: float, +) -> None: + """Collect runtime evidence for Pearl model validation.""" + sidecar = Sidecar.read(SIDECAR_PATH) + sidecar_model = sidecar.get("model") if sidecar else None + model_id = model or sidecar_model or DEFAULT_PEARL_MODEL + endpoint = vllm_endpoint or (sidecar.get("vllm_endpoint") if sidecar else None) + metrics_url = gateway_metrics_url or ( + sidecar.get("gateway_metrics_url") if sidecar else None + ) + + click.echo("Pearl Model Validation") + click.echo(f"model: {model_id}") + results: list[bool] = [] + records: list[dict[str, Any]] = [] + + def record(name: str, ok: bool, info: str) -> None: + records.append({"name": name, "ok": ok, "info": info}) + _validation_row(results, name, ok, info) + + spec = get_pearl_model_spec(str(model_id)) + if spec is None: + planned = pearl_variant_for_base_model(str(model_id)) + info = f"raw model; planned Pearl variant {planned}" if planned else "unknown" + record("Model registry", False, info) + elif spec.is_validated: + record("Model registry", True, f"validated: {spec.model_id}") + else: + record( + "Model registry", + allow_planned, + f"{spec.status}: {spec.model_id}", + ) + + if sidecar is None: + record( + "Sidecar", + False, + "absent - run `jarvis mine start` first", + ) + else: + record("Sidecar", True, f"present ({SIDECAR_PATH})") + record( + "Sidecar model", + sidecar_model == model_id, + str(sidecar_model or "missing"), + ) + + if endpoint is None: + record("vLLM endpoint", False, "missing") + else: + endpoint = endpoint.rstrip("/") + try: + models_payload = _get_json(f"{endpoint}/models", timeout=timeout) + model_ids = _extract_model_ids(models_payload) + models_info = ( + str(model_id) if str(model_id) in model_ids else f"{len(model_ids)} ids" + ) + record( + "vLLM /models", + str(model_id) in model_ids, + models_info, + ) + except Exception as exc: # noqa: BLE001 + record("vLLM /models", False, str(exc).splitlines()[0]) + + if prompt: + try: + payload = { + "model": model_id, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 16, + "stream": False, + } + response = _post_json( + f"{endpoint}/chat/completions", + payload, + timeout=timeout, + ) + choices = response.get("choices") + record( + "Chat completion", + bool(choices), + "choices returned" if choices else "no choices", + ) + except Exception as exc: # noqa: BLE001 + record( + "Chat completion", + False, + str(exc).splitlines()[0], + ) + else: + click.echo(" Chat completion skipped (pass --prompt to run)") + + metrics_candidates: list[tuple[str, str]] = [] + if metrics_url is not None: + gateway_metrics_url = str(metrics_url).rstrip("/") + if not gateway_metrics_url.endswith("/metrics"): + gateway_metrics_url = f"{gateway_metrics_url}/metrics" + metrics_candidates.append(("gateway", gateway_metrics_url)) + metrics_url = gateway_metrics_url + if endpoint is not None: + vllm_metrics_url = str(endpoint).rstrip("/").removesuffix("/v1") + "/metrics" + if not any(url == vllm_metrics_url for _, url in metrics_candidates): + metrics_candidates.append(("vLLM", vllm_metrics_url)) + + if not metrics_candidates: + record("Gateway metrics", False, "missing") + else: + errors: list[str] = [] + for label, candidate_url in metrics_candidates: + stats, error = _stats_from_metrics_url(candidate_url, "vllm-pearl") + if error: + errors.append(f"{label}: {error}") + continue + submitted = stats.shares_submitted if stats is not None else 0 + accepted = stats.shares_accepted if stats is not None else 0 + record( + "Gateway metrics", + True, + f"{label} submitted={submitted} accepted={accepted}", + ) + break + else: + record("Gateway metrics", False, " | ".join(errors)) + + passed = bool(results) and all(results) + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + artifact = { + "schema_version": 1, + "model": str(model_id), + "status": "passed" if passed else "failed", + "allow_planned": allow_planned, + "prompt_ran": bool(prompt), + "vllm_endpoint": endpoint, + "gateway_metrics_url": metrics_url, + "sidecar_path": str(SIDECAR_PATH), + "checks": records, + } + output.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n") + click.echo(f"Validation artifact written to {output}") + + if not passed: + raise click.ClickException("Pearl model validation checks failed.") + click.echo("Validation checks passed.") + + +@mine.command() +@click.option("--vllm-endpoint", required=True) +@click.option("--gateway-url", required=True) +@click.option("--gateway-metrics-url", required=True) +@click.option("--model", default=DEFAULT_PEARL_MODEL) +@click.option("--container-id", default="external") +@click.option("--wallet", default="") +def attach( + vllm_endpoint: str, + gateway_url: str, + gateway_metrics_url: str, + model: str, + container_id: str, + wallet: str, +) -> None: + """Manual mode: write a sidecar for an externally-started miner.""" + Sidecar.write( + SIDECAR_PATH, + { + "provider": "vllm-pearl", + "vllm_endpoint": vllm_endpoint, + "model": model, + "gateway_url": gateway_url, + "gateway_metrics_url": gateway_metrics_url, + "container_id": container_id, + "wallet_address": wallet, + "started_at": int(time.time()), + }, + ) + click.echo(f"Sidecar written to {SIDECAR_PATH}") + + +@mine.command() +@click.option("-n", "--tail", "tail_n", default=200, type=int) +@click.option("-f", "--follow", is_flag=True, default=False) +def logs(tail_n: int, follow: bool) -> None: + """Print the Pearl miner container log tail.""" + if follow: + click.echo("note: -f follow is not implemented in v1; printing tail", err=True) + client = _docker_from_env() + launcher = PearlDockerLauncher(client=client) + try: + launcher._container = client.containers.get("openjarvis-pearl-miner") + except Exception as exc: # noqa: BLE001 + raise click.ClickException(f"no mining container: {exc}") from exc + click.echo(launcher.get_logs(tail=tail_n)) diff --git a/src/openjarvis/cli/pearl_cmd.py b/src/openjarvis/cli/pearl_cmd.py new file mode 100644 index 00000000..29eeeb96 --- /dev/null +++ b/src/openjarvis/cli/pearl_cmd.py @@ -0,0 +1,149 @@ +"""``jarvis pearl`` — thin wrappers around Pearl's native CLIs.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import click + +PASSTHROUGH = {"ignore_unknown_options": True, "allow_extra_args": True} + + +def _candidate_roots(pearl_home: str | None) -> list[Path]: + roots: list[Path] = [] + if pearl_home: + roots.append(Path(pearl_home).expanduser()) + env_home = os.environ.get("PEARL_HOME") + if env_home: + roots.append(Path(env_home).expanduser()) + roots.extend( + [ + Path.cwd() / "pearl", + Path.cwd().parent / "pearl", + Path.home() / "pearl", + ] + ) + return roots + + +def _resolve_binary(name: str, pearl_home: str | None = None) -> str | None: + found = shutil.which(name) + if found: + return found + for root in _candidate_roots(pearl_home): + candidate = root / "bin" / name + if candidate.exists() and os.access(candidate, os.X_OK): + return str(candidate) + return None + + +def _require_binary(name: str, pearl_home: str | None = None) -> str: + binary = _resolve_binary(name, pearl_home) + if binary is None: + raise click.ClickException( + f"Pearl binary {name!r} not found. Set PEARL_HOME=/path/to/pearl " + "or put Pearl's bin/ directory on PATH." + ) + return binary + + +def _run(name: str, args: tuple[str, ...], pearl_home: str | None = None) -> int: + binary = _require_binary(name, pearl_home) + completed = subprocess.run([binary, *args], check=False) + return completed.returncode + + +def _run_capture( + name: str, args: tuple[str, ...], pearl_home: str | None = None +) -> subprocess.CompletedProcess[str]: + binary = _require_binary(name, pearl_home) + return subprocess.run( + [binary, *args], + check=False, + capture_output=True, + text=True, + ) + + +@click.group() +def pearl() -> None: + """Access Pearl node, wallet, and RPC tools.""" + + +@pearl.command("doctor") +@click.option("--pearl-home", type=click.Path(file_okay=False), default=None) +def doctor(pearl_home: str | None) -> None: + """Show whether Pearl native binaries are discoverable.""" + click.echo("Pearl CLI Doctor") + for name in ("pearld", "oyster", "prlctl"): + binary = _resolve_binary(name, pearl_home) + if binary: + click.echo(f" {name:<8} {binary} OK") + else: + click.echo(f" {name:<8} not found FAIL") + click.echo("Set PEARL_HOME=/path/to/pearl if binaries are not on PATH.") + + +@pearl.command("node", context_settings=PASSTHROUGH) +@click.option("--pearl-home", type=click.Path(file_okay=False), default=None) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +@click.pass_context +def node(ctx: click.Context, pearl_home: str | None, args: tuple[str, ...]) -> None: + """Run ``pearld`` with pass-through arguments.""" + ctx.exit(_run("pearld", args, pearl_home)) + + +@pearl.command("wallet", context_settings=PASSTHROUGH) +@click.option("--pearl-home", type=click.Path(file_okay=False), default=None) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +@click.pass_context +def wallet(ctx: click.Context, pearl_home: str | None, args: tuple[str, ...]) -> None: + """Run ``oyster`` with pass-through arguments.""" + ctx.exit(_run("oyster", args, pearl_home)) + + +@pearl.command("ctl", context_settings=PASSTHROUGH) +@click.option("--pearl-home", type=click.Path(file_okay=False), default=None) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +@click.pass_context +def ctl(ctx: click.Context, pearl_home: str | None, args: tuple[str, ...]) -> None: + """Run ``prlctl`` with pass-through arguments.""" + ctx.exit(_run("prlctl", args, pearl_home)) + + +@pearl.command("address") +@click.option("--pearl-home", type=click.Path(file_okay=False), default=None) +@click.option("-u", "--user", default="rpcuser", show_default=True) +@click.option("-P", "--password", default="rpcpass", show_default=True) +@click.option("-s", "--server", default="localhost:44207", show_default=True) +@click.option("--notls/--tls", default=True, show_default=True) +@click.option("--skipverify/--verify", default=True, show_default=True) +def address( + pearl_home: str | None, + user: str, + password: str, + server: str, + notls: bool, + skipverify: bool, +) -> None: + """Generate a Pearl wallet address through Oyster's wallet RPC.""" + args = ["--wallet", "-u", user, "-P", password, "-s", server] + if notls: + args.append("--notls") + elif skipverify: + args.append("--skipverify") + args.append("getnewaddress") + + completed = _run_capture("prlctl", tuple(args), pearl_home) + if completed.stdout: + click.echo(completed.stdout.rstrip()) + if completed.stderr: + click.echo(completed.stderr.rstrip(), err=True) + if completed.returncode: + raise click.ClickException(f"prlctl exited with {completed.returncode}") + + +__all__ = ["pearl"] diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 53c17c5a..2d9237c0 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -14,7 +14,15 @@ import shutil import subprocess from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +if TYPE_CHECKING: + # Only used by type-checkers (mypy/pyright) for the ``JarvisConfig.mining`` + # field annotation. The runtime import is deferred inside + # ``_parse_mining_section()`` to break the import cycle: + # ``mining/_stubs.py`` imports ``HardwareInfo`` from this module at its + # top level. + from openjarvis.mining._stubs import MiningConfig try: import tomllib # Python 3.11+ @@ -83,7 +91,7 @@ def _detect_nvidia_gpu() -> Optional[GpuInfo]: raw = _run_cmd( [ "nvidia-smi", - "--query-gpu=name,memory.total,count", + "--query-gpu=name,memory.total,count,compute_cap", "--format=csv,noheader,nounits", ] ) @@ -95,10 +103,12 @@ def _detect_nvidia_gpu() -> Optional[GpuInfo]: name = parts[0] vram_mb = float(parts[1]) count = int(parts[2]) + compute_capability = parts[3] if len(parts) > 3 else "" return GpuInfo( vendor="nvidia", name=name, vram_gb=round(vram_mb / 1024, 1), + compute_capability=compute_capability, count=count, ) except (IndexError, ValueError): @@ -1380,6 +1390,7 @@ class JarvisConfig: compression: CompressionConfig = field(default_factory=CompressionConfig) skills: SkillsConfig = field(default_factory=SkillsConfig) digest: DigestConfig = field(default_factory=DigestConfig) + mining: Optional["MiningConfig"] = None @property def memory(self) -> StorageConfig: @@ -1398,7 +1409,10 @@ class JarvisConfig: # Sections that users may set via ``jarvis config set``. # ``hardware`` is auto-detected and not user-settable. -_SETTABLE_SECTIONS = frozenset(JarvisConfig.__dataclass_fields__.keys()) - {"hardware"} +_SETTABLE_SECTIONS = frozenset(JarvisConfig.__dataclass_fields__.keys()) - { + "hardware", + "mining", +} def validate_config_key(dotted_key: str) -> type: @@ -1539,6 +1553,47 @@ def _migrate_toml_data(data: Dict[str, Any], cfg: "JarvisConfig") -> None: ) +def _parse_mining_section(data: dict) -> Optional["MiningConfig"]: + """Parse the ``[mining]`` TOML section into a ``MiningConfig``. + + Returns None if the section is absent. Resolves the ``submit_target`` + string into a ``SoloTarget`` or ``PoolTarget`` tagged union. + """ + if "mining" not in data: + return None + + # Lazy runtime import to break the import cycle: ``mining/_stubs.py`` + # imports ``HardwareInfo`` from this module at its top level. By the + # time ``_parse_mining_section`` is called, ``core.config`` is already + # fully initialized in ``sys.modules``, so the cycle is harmless. + from openjarvis.mining._stubs import MiningConfig, PoolTarget, SoloTarget + + section = data["mining"] + extra = section.get("extra", {}) or {} + + target_str = section.get("submit_target", "solo") + submit_target: Any + if target_str == "solo": + submit_target = SoloTarget( + pearld_rpc_url=extra.get("pearld_rpc_url", "http://localhost:44107") + ) + elif isinstance(target_str, str) and target_str.startswith("pool:"): + submit_target = PoolTarget(url=target_str[len("pool:"):]) + else: + raise ValueError( + f"[mining].submit_target must be 'solo' or 'pool:', got {target_str!r}" + ) + + return MiningConfig( + provider=section["provider"], + wallet_address=section["wallet_address"], + submit_target=submit_target, + fee_bps=int(section.get("fee_bps", 0)), + fee_payout_address=section.get("fee_payout_address") or None, + extra={k: v for k, v in extra.items()}, + ) + + @functools.lru_cache(maxsize=1) def load_config(path: Optional[Path] = None) -> JarvisConfig: """Detect hardware, build defaults, overlay TOML overrides. @@ -1611,6 +1666,9 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig: _user_security_keys = set(data.get("security", {}).keys()) apply_security_profile(cfg.security, cfg.server, overrides=_user_security_keys) + # Mining: dedicated parser for tagged-union submit_target + cfg.mining = _parse_mining_section(data) + # Apply profile even without a config file (in case defaults set one) if not config_path.exists() and cfg.security.profile: apply_security_profile(cfg.security, cfg.server) diff --git a/src/openjarvis/core/registry.py b/src/openjarvis/core/registry.py index 3739da33..001a7bea 100644 --- a/src/openjarvis/core/registry.py +++ b/src/openjarvis/core/registry.py @@ -153,6 +153,16 @@ class ConnectorRegistry(RegistryBase[Any]): """Registry for data source connectors (Gmail, Slack, etc.).""" +class MinerRegistry(RegistryBase[Any]): + """Registry for Pearl mining provider implementations. + + Each provider implements the ``MiningProvider`` ABC defined in + ``openjarvis.mining._stubs``. Registry keys are short lowercase strings + such as ``"vllm-pearl"`` (CUDA + Hopper) and (future) ``"mlx-pearl"``, + ``"llamacpp-pearl-metal"``, ``"ollama-pearl"``. + """ + + __all__ = [ "AgentRegistry", "BenchmarkRegistry", @@ -162,6 +172,7 @@ __all__ = [ "EngineRegistry", "LearningRegistry", "MemoryRegistry", + "MinerRegistry", "ModelRegistry", "RegistryBase", "RouterPolicyRegistry", diff --git a/src/openjarvis/core/types.py b/src/openjarvis/core/types.py index d4639bba..62d2e6ba 100644 --- a/src/openjarvis/core/types.py +++ b/src/openjarvis/core/types.py @@ -167,6 +167,7 @@ class TelemetryRecord: gpu_energy_joules: float = 0.0 dram_energy_joules: float = 0.0 tokens_per_joule: float = 0.0 + mining_session_id: Optional[str] = None metadata: Dict[str, Any] = field(default_factory=dict) diff --git a/src/openjarvis/engine/_discovery.py b/src/openjarvis/engine/_discovery.py index fa926704..8cec7c25 100644 --- a/src/openjarvis/engine/_discovery.py +++ b/src/openjarvis/engine/_discovery.py @@ -52,11 +52,60 @@ def _make_engine(key: str, config: JarvisConfig) -> InferenceEngine: return cls() +def _maybe_register_mining_sidecar_engine() -> None: + """If a mining sidecar exists with a ``vllm_endpoint``, register a derived + vLLM engine class pointing at it. Idempotent. Quiet on error. + + The trigger is the *shape* of the sidecar (presence of ``vllm_endpoint``), + not the value of its ``provider`` field — this leaves room for future + non-engine-replacing providers (e.g., a hypothetical cpu-pearl) whose + sidecars don't include ``vllm_endpoint``. + """ + try: + from openjarvis.mining import Sidecar + from openjarvis.mining._constants import SIDECAR_PATH + except ImportError: + return + + if EngineRegistry.contains("vllm-pearl-mining"): + return # idempotent + + payload = Sidecar.read(SIDECAR_PATH) + if payload is None: + return + + endpoint = payload.get("vllm_endpoint") + model = payload.get("model") + if not endpoint or not model: + return # data-driven gate: no vllm_endpoint → don't register + + from openjarvis.engine._openai_compat import _OpenAICompatibleEngine + + # Strip a trailing "/v1" path segment so _default_host is the bare + # base URL and _api_prefix="/v1" combines correctly in request paths. + api_prefix = "/v1" + base_url = endpoint.rstrip("/") + if base_url.endswith(api_prefix): + base_url = base_url[: -len(api_prefix)] + + _cls = type( + "VllmPearlMiningEngine", + (_OpenAICompatibleEngine,), + { + "engine_id": "vllm-pearl-mining", + "_default_host": base_url, + "_api_prefix": api_prefix, + }, + ) + EngineRegistry.register_value("vllm-pearl-mining", _cls) + + def discover_engines(config: JarvisConfig) -> List[Tuple[str, InferenceEngine]]: """Probe registered engines and return ``[(key, instance)]`` for healthy ones. Results are sorted with the config default engine first. """ + _maybe_register_mining_sidecar_engine() healthy: List[Tuple[str, InferenceEngine]] = [] for key in EngineRegistry.keys(): try: diff --git a/src/openjarvis/mining/__init__.py b/src/openjarvis/mining/__init__.py new file mode 100644 index 00000000..b5748e9b --- /dev/null +++ b/src/openjarvis/mining/__init__.py @@ -0,0 +1,60 @@ +# src/openjarvis/mining/__init__.py +"""Pearl mining subsystem. + +See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``. + +Provider modules are soft-imported below — each one fails gracefully if the +``mining-pearl`` (or future ``mining-pearl-mlx`` etc.) extra isn't installed. +""" + +from __future__ import annotations + +# Re-export the public ABCs and dataclasses for ergonomic imports. +from openjarvis.mining._stubs import ( + MiningCapabilities, + MiningConfig, + MiningProvider, + MiningStats, + PoolTarget, + Sidecar, + SoloTarget, + SubmitTarget, +) + +# Soft-import provider implementations to trigger registration at first +# package import. Each provider defines ``ensure_registered()`` for idempotent +# re-registration. Because module-level code only runs once per process, tests +# that need a registered provider after the autouse registry clear in +# ``tests/conftest.py`` must call ``ensure_registered()`` explicitly in a +# fixture or test body — see ``tests/bench/test_energy.py`` for the pattern. +try: + from openjarvis.mining import vllm_pearl # noqa: F401 + + vllm_pearl.ensure_registered() +except ImportError: + pass + +try: + from openjarvis.mining import cpu_pearl # noqa: F401 + + cpu_pearl.ensure_registered() +except ImportError: + pass + +try: + from openjarvis.mining import apple_mps_pearl # noqa: F401 + + apple_mps_pearl.ensure_registered() +except ImportError: + pass + +__all__ = [ + "MiningCapabilities", + "MiningConfig", + "MiningProvider", + "MiningStats", + "PoolTarget", + "Sidecar", + "SoloTarget", + "SubmitTarget", +] diff --git a/src/openjarvis/mining/_collector.py b/src/openjarvis/mining/_collector.py new file mode 100644 index 00000000..186f2428 --- /dev/null +++ b/src/openjarvis/mining/_collector.py @@ -0,0 +1,78 @@ +# src/openjarvis/mining/_collector.py +"""Background poller for mining telemetry. + +Shipped in v1 but not wired into the gateway daemon. v1's status command +reads on demand; v1.x can register this collector as a periodic daemon task +without changing the collector contract. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any + +import httpx + +from openjarvis.mining._metrics import parse_gateway_metrics +from openjarvis.mining._stubs import MiningStats, Sidecar + +log = logging.getLogger(__name__) + + +class MiningTelemetryCollector: + """Poll Pearl gateway metrics and write ``MiningStats`` to a store. + + ``telemetry_store`` is duck-typed and must implement + ``record_mining_stats(stats: MiningStats) -> None``. + """ + + def __init__( + self, + sidecar_path: Path, + telemetry_store: Any, + interval_s: float = 30.0, + ) -> None: + self._sidecar_path = sidecar_path + self._store = telemetry_store + self._interval_s = interval_s + self._stop = False + + async def collect_once(self) -> MiningStats: + sidecar = Sidecar.read(self._sidecar_path) + if sidecar is None: + return MiningStats(provider_id="unknown") + + provider_id = sidecar.get("provider", "unknown") + url = sidecar.get("gateway_metrics_url") + if not url: + return MiningStats(provider_id=provider_id) + + try: + resp = httpx.get(f"{url.rstrip('/')}/metrics", timeout=5.0) + if resp.status_code != 200: + return MiningStats( + provider_id=provider_id, + last_error=f"gateway HTTP {resp.status_code}", + ) + return parse_gateway_metrics(resp.text, provider_id=provider_id) + except Exception as exc: # noqa: BLE001 - collector must not crash daemon + message = str(exc).splitlines()[0] if str(exc) else exc.__class__.__name__ + return MiningStats(provider_id=provider_id, last_error=message) + + async def run(self) -> None: + while not self._stop: + try: + stats = await self.collect_once() + self._store.record_mining_stats(stats) + except Exception as exc: # noqa: BLE001 - keep background task alive + log.warning("MiningTelemetryCollector tick error: %s", exc) + + try: + await asyncio.sleep(self._interval_s) + except asyncio.CancelledError: + break + + def stop(self) -> None: + self._stop = True diff --git a/src/openjarvis/mining/_constants.py b/src/openjarvis/mining/_constants.py new file mode 100644 index 00000000..4e6675d5 --- /dev/null +++ b/src/openjarvis/mining/_constants.py @@ -0,0 +1,55 @@ +# src/openjarvis/mining/_constants.py +"""Constants for the Pearl mining subsystem. + +Pinned Pearl ref OJ has tested against. Bumped per OJ release after +re-testing the integration end-to-end on a real H100/H200 host. See +spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`` +section 7.3 for the rev-bump workflow. +""" + +from __future__ import annotations + +from pathlib import Path + +PEARL_REPO = "https://github.com/pearl-research-labs/pearl.git" +# TODO at implementation time: replace with the specific commit/tag verified +# against H100. Document the chosen ref in the OJ release notes. +PEARL_PINNED_REF = "master" +PEARL_IMAGE_TAG = f"openjarvis/pearl-miner:{PEARL_PINNED_REF}" + +# Default Pearl-blessed model. Overridable via [mining.extra].model. +DEFAULT_PEARL_MODEL = "pearl-ai/Llama-3.3-70B-Instruct-pearl" + +# Default ports as Pearl's container exposes them (network_mode="host"). +DEFAULT_VLLM_PORT = 8000 +DEFAULT_GATEWAY_RPC_PORT = 8337 +DEFAULT_GATEWAY_METRICS_PORT = 8339 + +# CPU/Apple subprocess provider defaults. These mirror the conservative v1 +# shape used by Pearl's Python miner loop and can be overridden in config. +CPU_PEARL_DEFAULT_M = 256 +CPU_PEARL_DEFAULT_N = 128 +CPU_PEARL_DEFAULT_K = 1024 +CPU_PEARL_DEFAULT_RANK = 32 +CPU_PEARL_DEFAULT_ROWS_PATTERN = (0, 8, 64, 72) +CPU_PEARL_DEFAULT_COLS_PATTERN = (0, 1, 8, 9, 32, 33, 40, 41) +PEARL_CPU_PACKAGES = ( + "py-pearl-mining", + "miner-utils", + "pearl-gateway", + "miner-base", +) + +# Default pearld RPC endpoint (mainnet). +DEFAULT_PEARLD_RPC_URL = "http://localhost:44107" + +# Pre-flight free-disk requirement for the 70B model + headroom. +MIN_FREE_DISK_GB = 200 + +# Runtime sidecar location (single-session assumption — see spec §8.8). +RUNTIME_DIR = Path.home() / ".openjarvis" / "runtime" +SIDECAR_PATH = RUNTIME_DIR / "mining.json" +SIDECAR_LOCK_PATH = RUNTIME_DIR / "mining.lock" + +# Pearl source cache for build-from-pin path (see spec §7.2). +PEARL_CACHE_DIR = Path.home() / ".openjarvis" / "cache" / "pearl" diff --git a/src/openjarvis/mining/_discovery.py b/src/openjarvis/mining/_discovery.py new file mode 100644 index 00000000..596d70f6 --- /dev/null +++ b/src/openjarvis/mining/_discovery.py @@ -0,0 +1,176 @@ +# src/openjarvis/mining/_discovery.py +"""Capability detection for mining providers. + +Each function answers a single yes/no question and returns ``(ok: bool, +info: str)`` where ``info`` is a short human-readable explanation surfaced +verbatim by ``jarvis mine doctor``. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Tuple + +import httpx + +from openjarvis.core.config import HardwareInfo +from openjarvis.mining._models import get_pearl_model_spec, pearl_variant_for_base_model +from openjarvis.mining._stubs import MiningCapabilities + +# --------------------------------------------------------------------------- +# Constants for the v1 vllm-pearl provider +# --------------------------------------------------------------------------- + +REQUIRED_COMPUTE_CAPABILITY = "9.0" # sm_90a — Hopper +REQUIRED_VRAM_GB = 70.0 +SUPPORTED_VLLM_ENGINE_IDS = frozenset({"vllm"}) + + +def detect_for_engine_model( + *, + hw: HardwareInfo, + engine_id: str, + model: str, + provider_id: str, +) -> MiningCapabilities: + """Capability matrix for the ``vllm-pearl`` provider. + + Pure inspection. No subprocess, no Docker, no network. Used by + ``jarvis mine doctor`` and ``jarvis mine init``. + """ + if provider_id != "vllm-pearl": + return MiningCapabilities(False, reason=f"unknown provider {provider_id!r}") + + # Engine + if engine_id not in SUPPORTED_VLLM_ENGINE_IDS: + return MiningCapabilities( + False, + reason=f"engine '{engine_id}' has no Pearl plugin in v1; use vllm", + ) + + # Hardware + if hw.gpu is None: + return MiningCapabilities(False, reason="no GPU detected") + if hw.gpu.vendor != "nvidia": + return MiningCapabilities( + False, + reason=f"vllm-pearl requires NVIDIA Hopper; detected {hw.gpu.vendor!r}. " + f"Apple Silicon support tracked in Spec B.", + ) + if not hw.gpu.compute_capability.startswith("9.0"): + return MiningCapabilities( + False, + reason=f"needs compute_capability 9.0 (sm_90a / H100/H200); detected " + f"{hw.gpu.compute_capability!r} ({hw.gpu.name})", + ) + spec = get_pearl_model_spec(model) + required_vram_gb = spec.min_vram_gb if spec else REQUIRED_VRAM_GB + if hw.gpu.vram_gb < required_vram_gb: + return MiningCapabilities( + False, + reason=f"needs ≥{required_vram_gb:.0f} GB VRAM for {model}; " + f"detected {hw.gpu.vram_gb:.0f} GB", + ) + + # Model + pearl_variant = pearl_variant_for_base_model(model) + if pearl_variant: + return MiningCapabilities( + False, + reason=f"model {model!r} is a raw base model; planned Pearl variant is " + f"{pearl_variant!r}", + ) + if spec is None: + return MiningCapabilities( + False, + reason=f"model {model!r} is not in OpenJarvis' Pearl model registry", + ) + if not spec.is_validated: + return MiningCapabilities( + False, + reason=f"model {model!r} is {spec.status}; it needs Pearl quantization " + "and H100/H200 validation before mining is enabled", + ) + + return MiningCapabilities(supported=True) + + +# --------------------------------------------------------------------------- +# Doctor checks (one per row of `jarvis mine doctor` output) +# --------------------------------------------------------------------------- + + +def _docker_client(): # pragma: no cover - trivial wrapper, mocked in tests + try: + import docker + except ImportError as exc: + raise RuntimeError( + "Docker SDK not installed; install with `uv sync --extra mining-pearl-vllm`" + ) from exc + + return docker.from_env() + + +def check_docker_available() -> Tuple[bool, str]: + try: + c = _docker_client() + c.ping() + ver = c.version().get("Version", "unknown") + return True, f"running {ver}" + except Exception as e: # noqa: BLE001 - intentionally broad + return False, str(e).splitlines()[0] + + +def check_disk_free(path: Path) -> Tuple[bool, str]: + from openjarvis.mining._constants import MIN_FREE_DISK_GB + + usage = shutil.disk_usage(path) + free_gb = usage.free / (1024**3) + if free_gb < MIN_FREE_DISK_GB: + return False, f"only {free_gb:.0f} GB free (need ≥{MIN_FREE_DISK_GB} GB)" + return True, f"{free_gb:.0f} GB free" + + +def check_pearld_reachable(url: str, user: str, password: str) -> Tuple[bool, str]: + """Probe pearld via JSON-RPC ``getblockchaininfo``.""" + try: + resp = httpx.post( + url, + json={ + "jsonrpc": "1.0", + "id": "ojprobe", + "method": "getblockchaininfo", + "params": [], + }, + auth=(user, password), + timeout=5.0, + ) + if resp.status_code != 200: + return False, f"HTTP {resp.status_code}" + data = resp.json() + result = data.get("result") or {} + blocks = result.get("blocks", "?") + headers = result.get("headers", "?") + synced = blocks == headers + marker = "synced" if synced else f"syncing ({blocks}/{headers})" + return True, f"block height {blocks} ({marker})" + except httpx.ConnectError as e: + return False, f"connection refused: {e}" + except Exception as e: # noqa: BLE001 + return False, str(e).splitlines()[0] + + +def check_wallet_address_format(address: str) -> Tuple[bool, str]: + """Pearl bech32/bech32m addresses begin with ``prl1q...`` or ``prl1p...``. + + We do *not* attempt to validate the bech32 checksum — that's a stronger + contract that may shift between Pearl revs. Format check only. + """ + if not address: + return False, "empty" + if not address.startswith(("prl1q", "prl1p")): + return False, f"expected 'prl1q...' or 'prl1p...' prefix; got {address[:6]!r}" + if len(address) < 14: + return False, f"too short ({len(address)} chars)" + return True, "format ok" diff --git a/src/openjarvis/mining/_docker.py b/src/openjarvis/mining/_docker.py new file mode 100644 index 00000000..e288a997 --- /dev/null +++ b/src/openjarvis/mining/_docker.py @@ -0,0 +1,420 @@ +# src/openjarvis/mining/_docker.py +"""Pearl Docker container orchestration. + +See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`` +section 7 for the design. +""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from openjarvis.mining._stubs import MiningConfig + +from openjarvis.mining._constants import ( + PEARL_CACHE_DIR, + PEARL_IMAGE_TAG, + PEARL_PINNED_REF, + PEARL_REPO, +) + +CONTAINER_NAME = "openjarvis-pearl-miner" + +_SECRET_LOG_PATTERNS = ( + (re.compile(r"(rpc_password:\s*)\S+", re.IGNORECASE), r"\1[REDACTED]"), + (re.compile(r"(PEARLD_RPC_PASSWORD=)\S+"), r"\1[REDACTED]"), + (re.compile(r'("PEARLD_RPC_PASSWORD"\s*:\s*")[^"]+(")'), r"\1[REDACTED]\2"), +) + + +class ImageAcquisitionError(RuntimeError): + """Raised when an image can be neither found, pulled, nor built.""" + + +class ConfigurationError(RuntimeError): + """Raised when required env vars or config fields are missing.""" + + +class ImageNotFound(Exception): + """Fallback image-not-found error when Docker SDK is not installed.""" + + +class NotFound(Exception): + """Fallback Docker not-found error when Docker SDK is not installed.""" + + +class APIError(Exception): + """Fallback Docker API error when Docker SDK is not installed.""" + + +def _docker_error_types() -> tuple[type[Exception], type[Exception], type[Exception]]: + try: + import docker.errors as derr + + return derr.ImageNotFound, derr.NotFound, derr.APIError + except ImportError: + return ImageNotFound, NotFound, APIError + + +class PearlDockerLauncher: + """Orchestrates the Pearl miner container. + + Construct with a ``docker.DockerClient`` (real or mocked). + """ + + def __init__(self, client: Any): + self._client = client + self._container: Any | None = None + + # ----------------------------------------------------------------- + # Image acquisition + # ----------------------------------------------------------------- + + def ensure_image(self, tag: str) -> str: + """Resolve ``tag`` to a usable local image, building if necessary. + + Selection order (see spec §7.2): + 1. Image present locally → use it. + 2. Image pullable from a registry → pull and use. + 3. ``tag`` matches OJ's default → clone Pearl + ``docker build``. + 4. Otherwise → ``ImageAcquisitionError``. + """ + image_not_found, not_found, api_error = _docker_error_types() + + try: + self._client.images.get(tag) + return tag + except image_not_found: + pass + + pull_error: str | None = None + try: + self._client.images.pull(tag) + return tag + except (not_found, api_error) as exc: + # Capture for context in the eventual ImageAcquisitionError below; + # we still fall through to the build path for the OJ default tag. + msg = str(exc).splitlines()[0] if str(exc) else exc.__class__.__name__ + pull_error = msg + + if tag == PEARL_IMAGE_TAG: + cache = self._clone_pearl_repo() + return self._docker_build(cache, tag) + + raise ImageAcquisitionError( + f"image {tag!r} not present locally, not pullable, and not OJ's " + f"default tag (no build fallback). Pull error: {pull_error}. " + f"Either build it manually with " + f"`docker buildx build -t {tag} -f miner/vllm-miner/Dockerfile .` " + f"from the Pearl repo, or set [mining.extra].docker_image_tag to " + f"the OJ default ({PEARL_IMAGE_TAG}) to enable the build fallback." + ) + + def _clone_pearl_repo(self) -> Path: + """Sync the Pearl source cache to ``PEARL_PINNED_REF``. + + Uses a detached-HEAD checkout strategy so the result is correct for + both branch refs (mutable) and commit SHAs (immutable). Discards any + local modifications and untracked build artifacts in the cache so a + previous interrupted run can't poison the build context. + """ + PEARL_CACHE_DIR.parent.mkdir(parents=True, exist_ok=True) + if PEARL_CACHE_DIR.exists() and (PEARL_CACHE_DIR / ".git").exists(): + self._git("fetch", "origin", cwd=PEARL_CACHE_DIR) + # Detach to origin/ when ref is a branch; for a SHA this + # falls through naturally because `origin/` doesn't exist + # but `` does — try the bare ref first, fall back to origin/. + try: + self._git("checkout", "--detach", PEARL_PINNED_REF, cwd=PEARL_CACHE_DIR) + except ImageAcquisitionError: + self._git( + "checkout", + "--detach", + f"origin/{PEARL_PINNED_REF}", + cwd=PEARL_CACHE_DIR, + ) + self._git( + "submodule", + "update", + "--init", + "--recursive", + cwd=PEARL_CACHE_DIR, + ) + self._git("clean", "-fdx", cwd=PEARL_CACHE_DIR) + else: + # Fresh clone. Note: --branch accepts both branches and tags but + # not arbitrary SHAs; for a SHA-pinned ref we'd need to clone + + # then check out. For v1's ``main`` default, --branch is fine. + self._git( + "clone", + "--branch", + PEARL_PINNED_REF, + PEARL_REPO, + str(PEARL_CACHE_DIR), + cwd=None, + ) + self._git( + "submodule", + "update", + "--init", + "--recursive", + cwd=PEARL_CACHE_DIR, + ) + return PEARL_CACHE_DIR + + def _docker_build(self, repo_path: Path, tag: str) -> str: + """Run ``docker buildx build`` with Pearl's Dockerfile against the monorepo.""" + self._patch_vllm_dockerfile(repo_path) + self._patch_vllm_entrypoint(repo_path) + + # Build context is the repo root; Dockerfile is at miner/vllm-miner/Dockerfile. + cmd = [ + "docker", + "buildx", + "build", + "--ulimit", + "nofile=1048576:1048576", + "-t", + tag, + "-f", + "miner/vllm-miner/Dockerfile", + ".", + ] + try: + subprocess.run( + cmd, + cwd=str(repo_path), + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as exc: + stderr_tail = (exc.stderr or "").strip().splitlines()[-10:] + raise ImageAcquisitionError( + f"docker build failed (exit {exc.returncode}): " + + " | ".join(stderr_tail) + ) from exc + return tag + + def _patch_vllm_dockerfile(self, repo_path: Path) -> None: + """Patch Pearl's runtime image to include nvcc for DeepGEMM JIT. + + Pearl's current Dockerfile builds kernels in a CUDA devel image, then + switches to a CUDA runtime image. vLLM 0.20's DeepGEMM path still JITs + kernels at runtime and asserts that nvcc exists, so the runtime image + must keep the CUDA compiler toolchain. + """ + dockerfile = repo_path / "miner" / "vllm-miner" / "Dockerfile" + text = dockerfile.read_text() + old = "FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu24.04" + new = "FROM nvidia/cuda:${CUDA_VERSION}-devel-ubuntu24.04" + if old in text: + dockerfile.write_text(text.replace(old, new)) + + def _patch_vllm_entrypoint(self, repo_path: Path) -> None: + """Patch Pearl's current entrypoint to wait on the UDS gateway socket. + + The upstream entrypoint waits for a Prometheus endpoint that the current + gateway package does not start, which causes the container to restart + before vLLM launches. The miner itself expects the UDS path, so wait for + that socket instead. + """ + entrypoint = repo_path / "miner" / "vllm-miner" / "entrypoint.sh" + text = entrypoint.read_text() + old = ( + "# Wait until the gateway is ready\n" + "curl -s http://localhost:8339/metrics --retry-delay 1 --retry 20 " + "--retry-all-errors > /dev/null" + ) + new = ( + "# Wait until the gateway is ready\n" + "for i in $(seq 1 20); do\n" + " [ -S /tmp/pearlgw.sock ] && break\n" + " sleep 1\n" + "done\n" + "[ -S /tmp/pearlgw.sock ]" + ) + if old in text: + entrypoint.write_text(text.replace(old, new)) + + # ----------------------------------------------------------------- + # Container lifecycle + # ----------------------------------------------------------------- + + def start(self, config: "MiningConfig", image: str) -> Any: + """Launch the Pearl miner container. + + ``image`` must already be resolved by ``ensure_image()``. + Returns the docker.models.containers.Container object. + """ + extra = config.extra + # Resolve secret env vars (we hold the *name*, not the value). + password_env = extra.get("pearld_rpc_password_env", "PEARLD_RPC_PASSWORD") + password = os.environ.get(password_env) + if password is None: + raise ConfigurationError( + f"environment variable {password_env!r} is not set; " + f"set it before running `jarvis mine start`" + ) + + hf_token_env = extra.get("hf_token_env", "HF_TOKEN") + hf_token = os.environ.get(hf_token_env, "") + + model = extra.get("model", "pearl-ai/Llama-3.3-70B-Instruct-pearl") + vllm_port = int(extra.get("vllm_port", 8000)) + gpu_mem = float(extra.get("gpu_memory_utilization", 0.96)) + max_len = int(extra.get("max_model_len", 8192)) + + command = [ + model, + "--host", + "0.0.0.0", + "--port", + str(vllm_port), + "--gpu-memory-utilization", + str(gpu_mem), + "--enforce-eager", + "--max-model-len", + str(max_len), + ] + + environment = { + "PEARLD_RPC_URL": extra.get("pearld_rpc_url", "http://localhost:44107"), + "PEARLD_RPC_USER": extra.get("pearld_rpc_user", "rpcuser"), + "PEARLD_RPC_PASSWORD": password, + "PEARLD_MINING_ADDRESS": config.wallet_address, + "HF_TOKEN": hf_token, + "MINER_RPC_TRANSPORT": "uds", + "MINER_RPC_SOCKET_PATH": "/tmp/pearlgw.sock", + } + + cuda_devices = str(extra.get("cuda_visible_devices", "")).strip() + device_ids = [part.strip() for part in cuda_devices.split(",") if part.strip()] + if device_ids: + environment["CUDA_VISIBLE_DEVICES"] = ",".join(device_ids) + environment["NVIDIA_VISIBLE_DEVICES"] = ",".join(device_ids) + + # Dynamic import so tests don't need the real `docker` package shape. + try: + from docker.types import DeviceRequest + + if device_ids: + device_requests = [ + DeviceRequest(device_ids=device_ids, capabilities=[["gpu"]]) + ] + else: + device_requests = [DeviceRequest(count=-1, capabilities=[["gpu"]])] + except ImportError: # pragma: no cover + device_requests = None + + hf_cache = Path.home() / ".cache" / "huggingface" + volumes = { + str(hf_cache): {"bind": "/root/.cache/huggingface", "mode": "rw"}, + } + + # Sanitize wrap so an APIError from the daemon doesn't surface the + # full container spec (which contains the resolved password) in a + # bubbled-up traceback. Docker's APIError __str__ embeds the request + # body verbatim. We surface the image name and exit reason only. + try: + self._container = self._client.containers.run( + image=image, + command=command, + name=CONTAINER_NAME, + detach=True, + auto_remove=False, + restart_policy={"Name": "unless-stopped"}, + device_requests=device_requests, + shm_size="8g", + network_mode="host", + volumes=volumes, + environment=environment, + ) + except Exception as exc: # noqa: BLE001 - intentional broad sanitize + cls = exc.__class__.__name__ + raise ConfigurationError( + f"failed to launch container from image {image!r} ({cls}); " + f"check `docker ps -a` and `docker logs {CONTAINER_NAME}` " + f"for daemon-side details" + ) from None + return self._container + + def _current_container(self) -> Any | None: + if self._container is not None: + return self._container + + _, not_found, _ = _docker_error_types() + try: + self._container = self._client.containers.get(CONTAINER_NAME) + except not_found: + return None + except Exception: # noqa: BLE001 - daemon unavailable means no session state. + return None + return self._container + + def stop(self, timeout: int = 30) -> None: + container = self._current_container() + if container is None: + return + try: + container.stop(timeout=timeout) + except Exception: # noqa: BLE001 - best-effort + pass + try: + container.remove() + except Exception: # noqa: BLE001 - best-effort + pass + self._container = None + + def is_running(self) -> bool: + container = self._current_container() + if container is None: + return False + try: + container.reload() + except Exception: # noqa: BLE001 + return False + return getattr(container, "status", "") == "running" + + def get_logs(self, tail: int = 200) -> str: + container = self._current_container() + if container is None: + return "" + raw = container.logs(tail=tail) + if isinstance(raw, bytes): + text = raw.decode("utf-8", errors="replace") + else: + text = str(raw) + return _redact_container_logs(text) + + @staticmethod + def _git(*args: str, cwd: Path | None) -> None: + """Invoke git with stderr capture so failures surface readably.""" + cmd = ["git", *args] + try: + subprocess.run( + cmd, + cwd=str(cwd) if cwd is not None else None, + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as exc: + stderr_tail = (exc.stderr or "").strip().splitlines()[-5:] + raise ImageAcquisitionError( + f"`{' '.join(cmd)}` failed (exit {exc.returncode}): " + + " | ".join(stderr_tail) + ) from exc + + +def _redact_container_logs(text: str) -> str: + """Redact secrets known to appear in upstream Pearl container logs.""" + redacted = text + for pattern, replacement in _SECRET_LOG_PATTERNS: + redacted = pattern.sub(replacement, redacted) + return redacted diff --git a/src/openjarvis/mining/_install.py b/src/openjarvis/mining/_install.py new file mode 100644 index 00000000..8af4efa9 --- /dev/null +++ b/src/openjarvis/mining/_install.py @@ -0,0 +1,62 @@ +"""Detection and install hints for the upstream Pearl Python packages. + +The cpu-pearl provider depends on three upstream packages from the Pearl +research project: + +- ``pearl_mining`` (PyO3 binding to the pure-Rust mining algorithm) +- ``pearl_gateway`` (JSON-RPC bridge to ``pearld``) +- ``miner_base`` (PyTorch reference of NoisyGEMM, used for parity validation) + +These are not on PyPI as of 2026-05; the implementation plan covers a +build-from-pin fallback in :func:`build_from_pin` (Task 4). This module is +the single source of truth for "is the user's environment ready?" and is +called from :class:`~openjarvis.mining.cpu_pearl.CpuPearlProvider.detect` +plus ``jarvis mine doctor``. +""" + +from __future__ import annotations + +import importlib.util +import sys + + +def _module_importable(name: str) -> bool: + """True if ``import name`` would succeed in the current environment. + + Checks ``sys.modules`` first (a module already resident is by definition + importable); falls back to ``importlib.util.find_spec``. The find_spec + call raises ``ValueError`` for entries in sys.modules that were inserted + without a populated ``__spec__`` (e.g., test stubs created via + ``types.ModuleType()``). We treat that as "available" — sys.modules + presence implies importable. + """ + if name in sys.modules: + return True + try: + return importlib.util.find_spec(name) is not None + except ValueError: + return False + + +def pearl_packages_available() -> bool: + """All three Pearl Python packages importable. + + Returns ``False`` if any are missing. Use :func:`install_hint` to + surface the next step to the user. + """ + return all( + _module_importable(m) for m in ("pearl_mining", "pearl_gateway", "miner_base") + ) + + +def install_hint() -> str: + """Human-readable instruction for installing the Pearl packages. + + Today (no PyPI publication) we point at the optional extra. When Pearl + publishes wheels, the message stays correct because the extra still works. + """ + return ( + "install with `uv sync --extra mining-pearl-cpu`. " + "If Pearl wheels are not on PyPI yet, see " + "tools/pearl-reference-oracle/README.md for the build-from-pin path." + ) diff --git a/src/openjarvis/mining/_metrics.py b/src/openjarvis/mining/_metrics.py new file mode 100644 index 00000000..9444d9db --- /dev/null +++ b/src/openjarvis/mining/_metrics.py @@ -0,0 +1,96 @@ +# src/openjarvis/mining/_metrics.py +"""Pearl/vLLM Prometheus → MiningStats adapter. + +The original v1 design expected Pearl gateway metrics on ``:8339/metrics``. +The live Pearl miner currently exposes the vLLM Prometheus endpoint on +``:8000/metrics`` instead, while the gateway listens on a Unix socket for miner +RPC. Keep both parsers here so ``jarvis mine status`` can report a healthy +runtime even when share/block counters are not exposed by the gateway yet. + +If Pearl renames metrics, change the ``PROM_*`` constants here — that's the +only place the metric names live. +""" + +from __future__ import annotations + +import logging +import time + +from openjarvis.mining._stubs import MiningStats + +log = logging.getLogger(__name__) + +# Pearl metric names. See spec §8.2 — verify against the fixture committed +# in tests/mining/fixtures/gateway_metrics_sample.txt. +PROM_SHARES_SUBMITTED = "pearl_gateway_shares_submitted_total" +PROM_SHARES_ACCEPTED = "pearl_gateway_shares_accepted_total" +PROM_BLOCKS_FOUND = "pearl_gateway_blocks_found_total" +PROM_LAST_SHARE_TS = "pearl_gateway_last_share_timestamp" +PROM_ERRORS_TOTAL = "pearl_gateway_errors_total" +PROM_PROCESS_START = "process_start_time_seconds" + + +def _parse_simple_metric(text: str, name: str) -> float | None: + """Find the first occurrence of a simple, label-less metric. + + Lines look like ``metric_name 12345`` or ``metric_name{label="x"} 12345``. + For the v1 adapter we ignore labels and take the first non-comment match. + """ + for line in text.splitlines(): + if line.startswith("#") or not line.strip(): + continue + # Split on the first whitespace; the metric name is everything up to + # an optional `{...}` label block. + head, _, value = line.partition(" ") + head = head.split("{", 1)[0] + if head == name: + try: + return float(value.strip()) + except ValueError: + return None + return None + + +def parse_gateway_metrics(text: str, *, provider_id: str) -> MiningStats: + """Convert a Prometheus exposition payload into a ``MiningStats``.""" + submitted = _parse_simple_metric(text, PROM_SHARES_SUBMITTED) or 0.0 + accepted = _parse_simple_metric(text, PROM_SHARES_ACCEPTED) or 0.0 + blocks = _parse_simple_metric(text, PROM_BLOCKS_FOUND) or 0.0 + last_share_ts = _parse_simple_metric(text, PROM_LAST_SHARE_TS) + errors = _parse_simple_metric(text, PROM_ERRORS_TOTAL) or 0.0 + proc_start = _parse_simple_metric(text, PROM_PROCESS_START) + + uptime = 0.0 + if proc_start is not None: + uptime = max(0.0, time.time() - proc_start) + + last_error: str | None = None + if errors > 0: + last_error = f"{int(errors)} gateway errors observed" + + return MiningStats( + provider_id=provider_id, + shares_submitted=int(submitted), + shares_accepted=int(accepted), + blocks_found=int(blocks), + # Hashrate as a derived rate is not meaningful from a single snapshot; + # the v1.x persistent collector will compute it. v1 leaves it 0. + hashrate=0.0, + uptime_seconds=uptime, + last_share_at=last_share_ts, + last_error=last_error, + ) + + +def parse_vllm_metrics(text: str, *, provider_id: str) -> MiningStats: + """Convert vLLM Prometheus metrics into best-effort mining runtime stats. + + vLLM does not expose Pearl share/block counters, but its metrics endpoint + proves the mining-backed inference server is alive and can provide uptime. + """ + proc_start = _parse_simple_metric(text, "process_start_time_seconds") + uptime = 0.0 + if proc_start is not None: + uptime = max(0.0, time.time() - proc_start) + + return MiningStats(provider_id=provider_id, uptime_seconds=uptime) diff --git a/src/openjarvis/mining/_miner_loop_main.py b/src/openjarvis/mining/_miner_loop_main.py new file mode 100644 index 00000000..3b31e6f5 --- /dev/null +++ b/src/openjarvis/mining/_miner_loop_main.py @@ -0,0 +1,231 @@ +"""CPU mining-loop subprocess entry point. + +Run with:: + + python -m openjarvis.mining._miner_loop_main \ + --gateway-host 127.0.0.1 --gateway-port 8337 \ + --m 256 --n 128 --k 1024 --rank 32 + +Connects to pearl-gateway, polls for work via ``getMiningInfo``, runs +``pearl_mining.mine()``, submits proofs via ``submitPlainProof``. Designed +to be killed via SIGTERM by the parent provider; no graceful shutdown +handshake -- Pearl's gateway tolerates client disconnects cleanly. + +The parent OJ process spawns this module via ``python -m`` and never +imports it directly, keeping the parent's import graph free of +``pearl_mining`` (which is an optional dependency installed only with +``--extra mining-pearl-cpu``). +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import logging +import sys +from typing import Any + +logger = logging.getLogger("openjarvis.mining.miner_loop") + +# Backoff after a failed mining round before retrying. Short enough that +# transient gateway errors don't stall mining; long enough not to spin. +_FAILURE_BACKOFF_SECONDS = 1.0 +_CONNECT_RETRY_SECONDS = 0.25 +_CONNECT_TIMEOUT_SECONDS = 30.0 + + +def _make_request( + method: str, params: dict[str, Any], request_id: int +) -> dict[str, Any]: + """Build a JSON-RPC 2.0 request envelope matching gateway's schema.""" + return {"jsonrpc": "2.0", "method": method, "params": params, "id": request_id} + + +def _decode_mining_info(result: dict[str, Any]) -> tuple[bytes, int]: + """Decode a getMiningInfo result into ``(incomplete_header_bytes, target)``.""" + header_b64 = result["incomplete_header_bytes"] + target = int(result["target"]) + return base64.b64decode(header_b64), target + + +def _encode_plain_proof(plain_proof: Any) -> str: + """Return the proof as a base64 string for ``submitPlainProof``. + + Pearl's ``PlainProof`` exposes ``to_base64()`` directly — no manual + encoding required. (Verified via py-pearl-mining 0.1.0 on macOS arm64.) + """ + return plain_proof.to_base64() + + +async def _read_response(reader: asyncio.StreamReader) -> dict[str, Any]: + """Read one line of JSON-RPC response from the gateway socket.""" + line = await reader.readline() + if not line: + raise ConnectionError("gateway closed the connection") + return json.loads(line) + + +async def _send_request(writer: asyncio.StreamWriter, request: dict[str, Any]) -> None: + """Write one JSON-RPC request followed by newline.""" + writer.write(json.dumps(request).encode() + b"\n") + await writer.drain() + + +async def _open_gateway_connection( + host: str, + port: int, + *, + timeout_seconds: float = _CONNECT_TIMEOUT_SECONDS, + retry_seconds: float = _CONNECT_RETRY_SECONDS, +) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Connect to pearl-gateway, retrying while its listener comes up.""" + deadline = asyncio.get_running_loop().time() + timeout_seconds + last_error: OSError | None = None + while True: + try: + return await asyncio.open_connection(host, port) + except OSError as exc: + last_error = exc + if asyncio.get_running_loop().time() >= deadline: + raise ConnectionError( + f"timed out connecting to pearl-gateway at {host}:{port}" + ) from last_error + logger.info( + "gateway %s:%d not ready yet (%s); retrying", + host, + port, + exc, + ) + await asyncio.sleep(retry_seconds) + + +def _build_mining_config(pearl_mining_module: Any, *, k: int, rank: int) -> Any: + """Build the upstream MiningConfiguration with default patterns.""" + from ._constants import ( + CPU_PEARL_DEFAULT_COLS_PATTERN, + CPU_PEARL_DEFAULT_ROWS_PATTERN, + ) + + return pearl_mining_module.MiningConfiguration( + common_dim=k, + rank=rank, + mma_type=pearl_mining_module.MMAType.Int7xInt7ToInt32, + rows_pattern=pearl_mining_module.PeriodicPattern.from_list( + list(CPU_PEARL_DEFAULT_ROWS_PATTERN) + ), + cols_pattern=pearl_mining_module.PeriodicPattern.from_list( + list(CPU_PEARL_DEFAULT_COLS_PATTERN) + ), + reserved=pearl_mining_module.MiningConfiguration.RESERVED, + ) + + +async def _mine_one_round( + pearl_mining_module: Any, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + *, + request_id: int, + m: int, + n: int, + k: int, + rank: int, +) -> bool: + """Get work, mine, submit. Return True if the gateway accepted the proof.""" + # 1. Ask the gateway for work. + await _send_request(writer, _make_request("getMiningInfo", {}, request_id)) + info_response = await _read_response(reader) + if "error" in info_response: + logger.warning("getMiningInfo error: %s", info_response["error"]) + return False + header_bytes, target = _decode_mining_info(info_response["result"]) + + # 2. Reconstruct the IncompleteBlockHeader and run pearl_mining.mine(). + # Verified APIs (py-pearl-mining 0.1.0 on macOS arm64): + # - IncompleteBlockHeader.from_bytes(bytes) -> IncompleteBlockHeader + # - PlainProof.to_base64() -> str (used by _encode_plain_proof above) + header = pearl_mining_module.IncompleteBlockHeader.from_bytes(header_bytes) + mining_config = _build_mining_config(pearl_mining_module, k=k, rank=rank) + plain_proof = pearl_mining_module.mine( + m, n, k, header, mining_config, signal_range=None, wrong_jackpot_hash=False + ) + + # 3. Submit the proof back to the gateway. + submit_params = { + "plain_proof": _encode_plain_proof(plain_proof), + "mining_job": { + "incomplete_header_bytes": base64.b64encode(header_bytes).decode(), + "target": target, + }, + } + await _send_request( + writer, _make_request("submitPlainProof", submit_params, request_id + 1) + ) + submit_response = await _read_response(reader) + if "error" in submit_response: + logger.warning("submitPlainProof rejected: %s", submit_response["error"]) + return False + return True + + +async def _main_loop(args: argparse.Namespace) -> None: + import pearl_mining # imported lazily so this module is itself import-safe + + reader, writer = await _open_gateway_connection( + args.gateway_host, + args.gateway_port, + timeout_seconds=args.connect_timeout_seconds, + retry_seconds=args.connect_retry_seconds, + ) + request_id = 0 + try: + while True: + request_id += 2 + try: + accepted = await _mine_one_round( + pearl_mining, + reader, + writer, + request_id=request_id, + m=args.m, + n=args.n, + k=args.k, + rank=args.rank, + ) + except Exception: + logger.exception("mining round failed; retrying after backoff") + await asyncio.sleep(_FAILURE_BACKOFF_SECONDS) + continue + if accepted: + logger.info("share accepted") + else: + await asyncio.sleep(_FAILURE_BACKOFF_SECONDS) + finally: + writer.close() + await writer.wait_closed() + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser(prog="openjarvis.mining._miner_loop_main") + p.add_argument("--gateway-host", default="127.0.0.1") + p.add_argument("--gateway-port", type=int, default=8337) + p.add_argument("--m", type=int, default=256) + p.add_argument("--n", type=int, default=128) + p.add_argument("--k", type=int, default=1024) + p.add_argument("--rank", type=int, default=32) + p.add_argument("--connect-timeout-seconds", type=float, default=30.0) + p.add_argument("--connect-retry-seconds", type=float, default=0.25) + return p.parse_args(argv) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" + ) + args = parse_args() + try: + asyncio.run(_main_loop(args)) + except KeyboardInterrupt: + sys.exit(0) diff --git a/src/openjarvis/mining/_models.py b/src/openjarvis/mining/_models.py new file mode 100644 index 00000000..e2ccec41 --- /dev/null +++ b/src/openjarvis/mining/_models.py @@ -0,0 +1,102 @@ +"""Pearl mining model support registry.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from openjarvis.mining._constants import DEFAULT_PEARL_MODEL + +PearlModelStatus = Literal["validated", "planned"] + + +@dataclass(frozen=True) +class PearlModelSpec: + """OpenJarvis support metadata for a Pearl-compatible model.""" + + model_id: str + base_model_id: str + status: PearlModelStatus + provider: str = "vllm-pearl" + min_vram_gb: float = 70.0 + default_max_model_len: int = 8192 + default_gpu_memory_utilization: float = 0.96 + notes: str = "" + + @property + def is_validated(self) -> bool: + return self.status == "validated" + + +PEARL_MODEL_SPECS: tuple[PearlModelSpec, ...] = ( + PearlModelSpec( + model_id=DEFAULT_PEARL_MODEL, + base_model_id="meta-llama/Llama-3.3-70B-Instruct", + status="validated", + min_vram_gb=70.0, + default_max_model_len=8192, + notes="Default Pearl-blessed vLLM mining model.", + ), + PearlModelSpec( + model_id="pearl-ai/Qwen3.5-9B-pearl", + base_model_id="Qwen/Qwen3.5-9B", + status="planned", + min_vram_gb=24.0, + default_max_model_len=8192, + notes="Planned target; validation tracked in open-jarvis/OpenJarvis#316.", + ), + PearlModelSpec( + model_id="pearl-ai/Qwen3.6-27B-pearl", + base_model_id="Qwen/Qwen3.6-27B", + status="planned", + min_vram_gb=80.0, + default_max_model_len=8192, + notes="Planned target; validation tracked in open-jarvis/OpenJarvis#317.", + ), + PearlModelSpec( + model_id="pearl-ai/Gemma-4-E4B-it-pearl", + base_model_id="google/gemma-4-E4B-it", + status="planned", + min_vram_gb=24.0, + default_max_model_len=8192, + notes="Planned target; validation tracked in open-jarvis/OpenJarvis#318.", + ), + PearlModelSpec( + model_id="pearl-ai/Gemma-4-31B-it-pearl", + base_model_id="google/gemma-4-31B-it", + status="planned", + min_vram_gb=80.0, + default_max_model_len=8192, + notes="Planned target; validation tracked in open-jarvis/OpenJarvis#319.", + ), +) + +_MODEL_SPECS_BY_ID = {spec.model_id: spec for spec in PEARL_MODEL_SPECS} +_MODEL_SPECS_BY_BASE_ID = {spec.base_model_id: spec for spec in PEARL_MODEL_SPECS} + + +def iter_pearl_model_specs() -> tuple[PearlModelSpec, ...]: + """Return all known Pearl model specs in display order.""" + + return PEARL_MODEL_SPECS + + +def get_pearl_model_spec(model_id: str) -> PearlModelSpec | None: + """Return support metadata for a Pearl model id or its raw base id.""" + + return _MODEL_SPECS_BY_ID.get(model_id) or _MODEL_SPECS_BY_BASE_ID.get(model_id) + + +def pearl_variant_for_base_model(model_id: str) -> str | None: + """Return the planned Pearl model id for a raw base model id, if known.""" + + spec = _MODEL_SPECS_BY_BASE_ID.get(model_id) + return spec.model_id if spec else None + + +__all__ = [ + "PearlModelSpec", + "get_pearl_model_spec", + "iter_pearl_model_specs", + "pearl_variant_for_base_model", +] diff --git a/src/openjarvis/mining/_mps_miner_loop_main.py b/src/openjarvis/mining/_mps_miner_loop_main.py new file mode 100644 index 00000000..1629db1a --- /dev/null +++ b/src/openjarvis/mining/_mps_miner_loop_main.py @@ -0,0 +1,311 @@ +"""Experimental Apple-MPS Pearl miner-loop subprocess. + +This is the first Apple-GPU path for Pearl mining. It is intentionally +separate from ``_miner_loop_main`` because the CPU path calls Pearl's pure-Rust +``pearl_mining.mine()``, while this path exercises upstream ``miner-base``: + +- generate random int7 A/B matrices +- compute Pearl commitment hashes and deterministic noise +- run ``miner_base.NoisyGemm`` on ``torch.device("mps")`` +- convert an opened block to ``PlainProof`` and submit it to pearl-gateway + +The current implementation still does transcript hashing and proof construction +on CPU because upstream ``miner-base`` uses NumPy/BLAKE3 for those pieces. That +makes this a correctness-first MPS path, not a final high-performance Metal +kernel. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import logging +import sys +from typing import Any + +from ._miner_loop_main import ( + _CONNECT_RETRY_SECONDS, + _CONNECT_TIMEOUT_SECONDS, + _FAILURE_BACKOFF_SECONDS, + _decode_mining_info, + _make_request, + _open_gateway_connection, + _read_response, + _send_request, +) + +logger = logging.getLogger("openjarvis.mining.mps_miner_loop") + + +class MpsNoisyGemmAdapter: + """Device-safe wrapper around upstream ``miner_base.NoisyGemm``. + + Upstream NoisyGemm is written as a CPU reference and creates some + intermediate tensors on CPU. This adapter overrides the two places that + need device-awareness so MPS inputs stay on MPS for matmuls. Transcript + hashing explicitly copies the small hash tiles back to CPU because the + upstream ``InnerHasher`` path uses NumPy. + """ + + @staticmethod + def build(noisy_gemm_cls: type, **kwargs: Any) -> Any: + import torch + from miner_base.noisy_gemm import Transcript + + class _MpsNoisyGemm(noisy_gemm_cls): # type: ignore[misc, valid-type] + def _accumulate_transcripts(self, transcripts, reduction_count, C_block): # type: ignore[no-untyped-def] + return super()._accumulate_transcripts( + transcripts, + reduction_count, + C_block.detach().cpu(), + ) + + def _process_output_tile(self, A, B, k, i, i_max, j, j_max): # type: ignore[no-untyped-def] + block_h = i_max - i + block_w = j_max - j + hash_tile_h = self.inner_hash.tile_h + hash_tile_w = self.inner_hash.tile_w + num_hash_tiles_h = block_h // hash_tile_h + num_hash_tiles_w = block_w // hash_tile_w + transcripts = [ + [self.__class__.Transcript() for _ in range(num_hash_tiles_w)] + for _ in range(num_hash_tiles_h) + ] + reduction_count = 0 + has_full_tiles = False + C_block = torch.zeros( + (block_h, block_w), + dtype=torch.int32, + device=A.device, + ) + for p in range(0, k, self.noise_rank): + p_max = min(p + self.noise_rank, k) + A_tile = A[i:i_max, p:p_max] + B_tile = B[p:p_max, j:j_max] + C_tile = torch.matmul( + A_tile.to(torch.int32), + B_tile.to(torch.int32), + ) + C_block += C_tile + is_full_tile = ( + block_h >= hash_tile_h + and block_w >= hash_tile_w + and p_max - p == self.noise_rank + ) + if is_full_tile: + self._accumulate_transcripts( + transcripts=transcripts, + reduction_count=reduction_count, + C_block=C_block, + ) + reduction_count += 1 + has_full_tiles = True + return C_block, has_full_tiles, transcripts + + def _tiled_matmul(self, A, B, pow_key, pow_target): # type: ignore[no-untyped-def] + assert A.shape[1] == B.shape[0] + m, k = A.shape + n = B.shape[1] + C = torch.zeros((m, n), dtype=torch.int32, device=A.device) + hash_tile_h = self.inner_hash.tile_h + hash_tile_w = self.inner_hash.tile_w + if ( + m < hash_tile_h + or n < hash_tile_w + or self.noise_rank < min(hash_tile_h, hash_tile_w) + ): + raise ValueError( + f"{m=}, {n=}, {hash_tile_h=}, {hash_tile_w=}, " + f"{self.noise_rank=}, matmul tile should be larger " + "than hash tile and noise rank" + ) + found_block = False + for i in range(0, m, self.noise_rank): + i_max = min(i + self.noise_rank, m) + for j in range(0, n, self.noise_rank): + j_max = min(j + self.noise_rank, n) + C_block, has_full_tiles, transcripts = ( + self._process_output_tile(A, B, k, i, i_max, j, j_max) + ) + if not found_block and has_full_tiles: + found_block = self._check_tile_transcripts( + transcripts, i, j, pow_key, pow_target + ) + C[i:i_max, j:j_max] = C_block + return C, found_block + + _MpsNoisyGemm.Transcript = Transcript + return _MpsNoisyGemm(**kwargs) + + +def _mining_config_for_shape(pearl_mining_module: Any, *, k: int, rank: int) -> Any: + return pearl_mining_module.MiningConfiguration( + common_dim=k, + rank=rank, + mma_type=pearl_mining_module.MMAType.Int7xInt7ToInt32, + rows_pattern=pearl_mining_module.PeriodicPattern.from_list(list(range(16))), + cols_pattern=pearl_mining_module.PeriodicPattern.from_list(list(range(16))), + reserved=pearl_mining_module.MiningConfiguration.RESERVED, + ) + + +async def _mine_one_round( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + *, + request_id: int, + m: int, + n: int, + k: int, + rank: int, +) -> bool: + import pearl_mining + import torch + from miner_base.block_submission import create_proof + from miner_base.commitment_hash import CommitmentHasher + from miner_base.noise_generation import NoiseGenerator + from miner_base.noisy_gemm import NoisyGemm + from pearl_gateway.comm.dataclasses import MiningJob + + await _send_request(writer, _make_request("getMiningInfo", {}, request_id)) + info_response = await _read_response(reader) + if "error" in info_response: + logger.warning("getMiningInfo error: %s", info_response["error"]) + return False + + header_bytes, target = _decode_mining_info(info_response["result"]) + mining_config = _mining_config_for_shape(pearl_mining, k=k, rank=rank) + mining_job = MiningJob(incomplete_header_bytes=header_bytes, target=target) + adjusted_target = mining_job.adjust_target(mining_config) + + # Matrices stay on CPU for commitment/proof construction and are copied to + # MPS for NoisyGEMM. Values are int7-compatible: [-64, 63]. + device = torch.device("mps") + A_cpu = torch.randint(-64, 64, (m, k), dtype=torch.int8) + B_cpu = torch.randint(-64, 64, (k, n), dtype=torch.int8) + commitment_hash = CommitmentHasher.commitment_hash( + A_cpu, + B_cpu, + header_bytes, + mining_config, + ) + E_AL, E_AR, E_BL, E_BR = NoiseGenerator( + noise_rank=rank, + noise_range=128, + ).generate_noise_metrices( + key_A=commitment_hash.noise_seed_A, + key_B=commitment_hash.noise_seed_B, + A_rows=m, + common_dim=k, + B_cols=n, + ) + + gemm = MpsNoisyGemmAdapter.build( + NoisyGemm, + noise_range=128, + noise_rank=rank, + hash_tile_h=16, + hash_tile_w=16, + matmul_tile_h=rank, + matmul_tile_w=rank, + ) + _, found = gemm.noisy_gemm( + A_cpu.to(device), + B_cpu.to(device), + E_AL.to(device), + E_AR.to(device), + E_BL.to(device), + E_BR.to(device), + commitment_hash=commitment_hash, + pow_target=adjusted_target, + ) + if not found: + return False + + opened_block = gemm.get_opened_block_info() + if opened_block is None: + raise RuntimeError("NoisyGemm reported found=True without opened block info") + plain_proof = create_proof(opened_block, header_bytes) + submit_params = { + "plain_proof": plain_proof.to_base64(), + "mining_job": { + "incomplete_header_bytes": base64.b64encode(header_bytes).decode(), + "target": target, + }, + } + await _send_request( + writer, + _make_request("submitPlainProof", submit_params, request_id + 1), + ) + submit_response = await _read_response(reader) + if "error" in submit_response: + logger.warning("submitPlainProof rejected: %s", submit_response["error"]) + return False + return True + + +async def _main_loop(args: argparse.Namespace) -> None: + reader, writer = await _open_gateway_connection( + args.gateway_host, + args.gateway_port, + timeout_seconds=args.connect_timeout_seconds, + retry_seconds=args.connect_retry_seconds, + ) + request_id = 0 + try: + while True: + request_id += 2 + try: + accepted = await _mine_one_round( + reader, + writer, + request_id=request_id, + m=args.m, + n=args.n, + k=args.k, + rank=args.rank, + ) + except Exception: + logger.exception("MPS mining round failed; retrying after backoff") + await asyncio.sleep(_FAILURE_BACKOFF_SECONDS) + continue + if accepted: + logger.info("share accepted") + else: + await asyncio.sleep(_FAILURE_BACKOFF_SECONDS) + finally: + writer.close() + await writer.wait_closed() + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser(prog="openjarvis.mining._mps_miner_loop_main") + p.add_argument("--gateway-host", default="127.0.0.1") + p.add_argument("--gateway-port", type=int, default=8337) + p.add_argument("--m", type=int, default=128) + p.add_argument("--n", type=int, default=128) + p.add_argument("--k", type=int, default=1024) + p.add_argument("--rank", type=int, default=64) + p.add_argument( + "--connect-timeout-seconds", + type=float, + default=_CONNECT_TIMEOUT_SECONDS, + ) + p.add_argument( + "--connect-retry-seconds", + type=float, + default=_CONNECT_RETRY_SECONDS, + ) + return p.parse_args(argv) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" + ) + args = parse_args() + try: + asyncio.run(_main_loop(args)) + except KeyboardInterrupt: + sys.exit(0) diff --git a/src/openjarvis/mining/_pearl_subprocess.py b/src/openjarvis/mining/_pearl_subprocess.py new file mode 100644 index 00000000..d17826e9 --- /dev/null +++ b/src/openjarvis/mining/_pearl_subprocess.py @@ -0,0 +1,206 @@ +"""Subprocess launcher for Pearl providers. + +Manages two coordinated subprocesses: + +- ``pearl-gateway`` — Pearl's Python JSON-RPC service that talks to ``pearld`` + and brokers shares between the miner and the network. +- a provider-selected miner-loop module that polls the gateway, mines, and + submits proofs. + +Lifecycle is in-memory: while this object lives, both subprocesses live. The +provider holds it; the sidecar JSON records PIDs for crash recovery and +``mine doctor`` introspection. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger(__name__) + +# How long to wait after SIGTERM before SIGKILL. The miner loop is held to a +# tight budget because it does no network IO of consequence; the gateway gets +# a bit longer to flush state to pearld. +_GATEWAY_TERMINATE_GRACE_SECONDS = 5.0 +_MINER_LOOP_TERMINATE_GRACE_SECONDS = 2.0 + + +@dataclass(slots=True) +class _ProcessHandles: + gateway: subprocess.Popen + miner_loop: subprocess.Popen + + +class PearlSubprocessLauncher: + """Spawn and tear down the gateway + miner-loop pair as a unit.""" + + def __init__( + self, + *, + gateway_host: str, + gateway_port: int, + metrics_port: int, + pearld_rpc_url: str, + pearld_rpc_user: str, + pearld_rpc_password: str, + wallet_address: str, + log_dir: Path, + provider_id: str = "cpu-pearl", + miner_module: str = "openjarvis.mining._miner_loop_main", + ) -> None: + self.gateway_host = gateway_host + self.gateway_port = gateway_port + self.metrics_port = metrics_port + self.pearld_rpc_url = pearld_rpc_url + self.pearld_rpc_user = pearld_rpc_user + self.pearld_rpc_password = pearld_rpc_password + self.wallet_address = wallet_address + self.log_dir = Path(log_dir) + self.provider_id = provider_id + self.miner_module = miner_module + self.log_dir.mkdir(parents=True, exist_ok=True) + self._handles: _ProcessHandles | None = None + + def start(self, *, m: int, n: int, k: int, rank: int) -> None: + """Spawn gateway and miner-loop subprocesses.""" + if self._handles is not None: + raise RuntimeError( + "PearlSubprocessLauncher already started; " + "call stop() before starting again" + ) + env = self._build_gateway_env() + + # Spawn pearl-gateway first. ``pearl-gateway`` is the console-script + # entry point exposed by the pearl_gateway package's pyproject.toml. + logger.info( + "[%s] starting pearl-gateway on %s:%d (metrics %d)", + self.provider_id, + self.gateway_host, + self.gateway_port, + self.metrics_port, + ) + # The pearl-gateway CLI takes a positional command argument (start|stop| + # status|version) — see pearl/miner/pearl-gateway/src/pearl_gateway/cli.py. + gateway_log_path = self.log_dir / "pearl-gateway.log" + with gateway_log_path.open("a", buffering=1) as gateway_log: + gateway = subprocess.Popen( + ["pearl-gateway", "start"], + env=env, + stdout=gateway_log, + stderr=subprocess.STDOUT, + ) + # gateway_log closes here; the child holds its own fd + + # Spawn miner-loop pointed at the gateway. + logger.info( + "[%s] starting miner-loop %s (m=%d n=%d k=%d rank=%d)", + self.provider_id, + self.miner_module, + m, + n, + k, + rank, + ) + miner_log_path = self.log_dir / f"{self.provider_id}-miner.log" + with miner_log_path.open("a", buffering=1) as miner_log: + miner_loop = subprocess.Popen( + [ + sys.executable, + "-m", + self.miner_module, + "--gateway-host", + self.gateway_host, + "--gateway-port", + str(self.gateway_port), + "--m", + str(m), + "--n", + str(n), + "--k", + str(k), + "--rank", + str(rank), + ], + stdout=miner_log, + stderr=subprocess.STDOUT, + ) + + self._handles = _ProcessHandles(gateway=gateway, miner_loop=miner_loop) + + def stop(self) -> None: + """SIGTERM both subprocesses with bounded waits and SIGKILL fallback. + + Idempotent — calling stop() when already stopped is a no-op. + """ + if self._handles is None: + return + # Stop miner-loop first (it doesn't need to flush state), then gateway. + for proc, grace in ( + (self._handles.miner_loop, _MINER_LOOP_TERMINATE_GRACE_SECONDS), + (self._handles.gateway, _GATEWAY_TERMINATE_GRACE_SECONDS), + ): + if proc.poll() is None: + proc.terminate() + deadline = time.monotonic() + grace + while time.monotonic() < deadline and proc.poll() is None: + time.sleep(0.05) + if proc.poll() is None: + logger.warning( + "[%s] subprocess %d did not exit after %.1fs; SIGKILL", + self.provider_id, + proc.pid, + grace, + ) + proc.kill() + proc.wait() # reap the zombie + self._handles = None + + def is_running(self) -> bool: + """True iff both subprocesses are alive.""" + if self._handles is None: + return False + return ( + self._handles.gateway.poll() is None + and self._handles.miner_loop.poll() is None + ) + + def pids(self) -> tuple[int, int] | None: + """Return (gateway_pid, miner_loop_pid), or None if not started.""" + if self._handles is None: + return None + return (self._handles.gateway.pid, self._handles.miner_loop.pid) + + def _build_gateway_env(self) -> dict[str, str]: + """Construct the environment passed to pearl-gateway. + + Env var names verified against + ``pearl/miner/pearl-gateway/src/pearl_gateway/config.py`` (PearlConfig + with ``env_prefix="PEARLD_"`` and MinerRpcConfig with + ``env_prefix="MINER_RPC_"``) and the canonical names in + ``pearl/miner/conftest.py`` line 281+. + + - PEARLD_*: pearld node RPC connection (PearlConfig) + - MINER_RPC_*: the JSON-RPC server miners connect to (MinerRpcConfig) + - METRICS_BIND: a single ``HOST:PORT`` string for the Prometheus + metrics endpoint (defaults to ``127.0.0.1:9109`` upstream). + """ + env = dict(os.environ) + env.update( + { + "PEARLD_RPC_URL": self.pearld_rpc_url, + "PEARLD_RPC_USER": self.pearld_rpc_user, + "PEARLD_RPC_PASSWORD": self.pearld_rpc_password, + "PEARLD_MINING_ADDRESS": self.wallet_address, + "MINER_RPC_TRANSPORT": "tcp", + "MINER_RPC_HOST": self.gateway_host, + "MINER_RPC_PORT": str(self.gateway_port), + "METRICS_BIND": f"{self.gateway_host}:{self.metrics_port}", + } + ) + return env diff --git a/src/openjarvis/mining/_stubs.py b/src/openjarvis/mining/_stubs.py new file mode 100644 index 00000000..6f730231 --- /dev/null +++ b/src/openjarvis/mining/_stubs.py @@ -0,0 +1,177 @@ +# src/openjarvis/mining/_stubs.py +"""ABCs and dataclasses for the mining subsystem. + +See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md`` +section 4.4 for the design rationale. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional, Union + +from openjarvis.core.config import HardwareInfo + +# --------------------------------------------------------------------------- +# Capability descriptor +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class MiningCapabilities: + """Result of a provider's ``detect()`` call. + + ``reason`` is human-readable and surfaced verbatim by ``jarvis mine doctor`` + when ``supported=False``. + """ + + supported: bool + reason: Optional[str] = None + estimated_hashrate: Optional[float] = None # shares/sec, best-effort + + +# --------------------------------------------------------------------------- +# Submit-target tagged union (v2 seam — see spec §8.5) +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class SoloTarget: + """Mine directly to a pearld node. v1 default.""" + + pearld_rpc_url: str + + +@dataclass(slots=True) +class PoolTarget: + """Mine through an OJ-operated pool. v2 — raises NotImplementedError in v1.""" + + url: str + worker_id: Optional[str] = None + + +SubmitTarget = Union[SoloTarget, PoolTarget] + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class MiningConfig: + """User-supplied mining configuration. + + Loaded from the ``[mining]`` TOML section by ``core/config.py``. + """ + + provider: str + wallet_address: str + submit_target: SubmitTarget + fee_bps: int = 0 # v1: 0; v2: 2000 (=20%) + fee_payout_address: Optional[str] = None # v1: ignored; v2: OJ's address + extra: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Live stats (returned by ``MiningProvider.stats()``) +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class MiningStats: + provider_id: str + shares_submitted: int = 0 + shares_accepted: int = 0 + blocks_found: int = 0 + hashrate: float = 0.0 + uptime_seconds: float = 0.0 + last_share_at: Optional[float] = None + last_error: Optional[str] = None + payout_target: str = "solo" # v1: always "solo"; v2: "pool:" + fees_owed: int = 0 # v2 accounting hook; 0 in v1 + + +# --------------------------------------------------------------------------- +# The ABC +# --------------------------------------------------------------------------- + + +class MiningProvider(ABC): + """A mining provider — orchestrates a Pearl mining session. + + One provider per ``(hardware, engine, model)`` combo. All future + hardware/engine paths (Apple Silicon, AMD, Ollama) implement this exact + contract. See spec §4.4. + """ + + provider_id: str # set by subclass + + @classmethod + @abstractmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + """Return whether this provider can run on the given combo. + + Must be a pure inspection — no subprocess, no network, no Docker. Used + by ``jarvis mine doctor`` and ``jarvis mine init`` for fast capability + reporting. + """ + + @abstractmethod + async def start(self, config: MiningConfig) -> None: ... + + @abstractmethod + async def stop(self) -> None: ... + + @abstractmethod + def is_running(self) -> bool: ... + + @abstractmethod + def stats(self) -> MiningStats: ... + + +# --------------------------------------------------------------------------- +# Sidecar IO (see spec §5.3) +# --------------------------------------------------------------------------- + + +class Sidecar: + """Read/write helpers for ``~/.openjarvis/runtime/mining.json``.""" + + @staticmethod + def write(path: Path, payload: dict[str, Any]) -> None: + """Atomically write the sidecar JSON to ``path``.""" + path.parent.mkdir(parents=True, exist_ok=True) + # Atomic write: tmp file + rename + fd, tmp = tempfile.mkstemp(prefix=".mining-", dir=str(path.parent)) + try: + with os.fdopen(fd, "w") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + @staticmethod + def read(path: Path) -> Optional[dict[str, Any]]: + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return None + + @staticmethod + def remove(path: Path) -> None: + try: + path.unlink() + except FileNotFoundError: + pass diff --git a/src/openjarvis/mining/apple_mps_pearl.py b/src/openjarvis/mining/apple_mps_pearl.py new file mode 100644 index 00000000..f2c48113 --- /dev/null +++ b/src/openjarvis/mining/apple_mps_pearl.py @@ -0,0 +1,103 @@ +"""Experimental Apple-GPU Pearl mining provider via PyTorch MPS. + +This provider is a correctness-first bridge to upstream Pearl ``miner-base``. +It uses the Apple GPU for the NoisyGEMM matmuls through PyTorch MPS, while +leaving transcript hashing and proof construction on CPU until a native Metal +kernel exists. +""" + +from __future__ import annotations + +import os +import time + +from openjarvis.core.config import HardwareInfo +from openjarvis.core.registry import MinerRegistry + +from . import _install +from ._constants import ( + CPU_PEARL_DEFAULT_K, + DEFAULT_GATEWAY_METRICS_PORT, + DEFAULT_GATEWAY_RPC_PORT, + DEFAULT_PEARLD_RPC_URL, +) +from ._pearl_subprocess import PearlSubprocessLauncher +from ._stubs import MiningCapabilities, MiningConfig +from .cpu_pearl import CpuPearlProvider, _log_dir + + +def _torch_mps_available() -> tuple[bool, str | None]: + try: + import torch + except ImportError: + return False, "PyTorch is not installed" + if not torch.backends.mps.is_built(): + return False, "PyTorch was not built with MPS support" + if not torch.backends.mps.is_available(): + return False, "PyTorch MPS is not available on this host" + return True, None + + +class AppleMpsPearlProvider(CpuPearlProvider): + """Experimental MPS-backed Pearl provider.""" + + provider_id = "apple-mps-pearl" + + @classmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + if hw.platform != "darwin": + return MiningCapabilities( + supported=False, + reason=f"apple-mps-pearl requires macOS; this host is '{hw.platform}'", + ) + if hw.gpu is None or hw.gpu.vendor.lower() != "apple": + return MiningCapabilities( + supported=False, + reason="apple-mps-pearl requires an Apple GPU", + ) + if not _install.pearl_packages_available(): + return MiningCapabilities( + supported=False, + reason=( + f"Pearl Python packages not installed — {_install.install_hint()}" + ), + ) + mps_ok, reason = _torch_mps_available() + if not mps_ok: + return MiningCapabilities(supported=False, reason=reason) + return MiningCapabilities(supported=True) + + async def start(self, config: MiningConfig) -> None: + """Spawn pearl-gateway and the MPS miner-loop subprocess.""" + extra = dict(config.extra or {}) + password_env = extra.get("pearld_rpc_password_env", "PEARLD_RPC_PASSWORD") + password = os.environ.get(password_env, "") + + self._launcher = PearlSubprocessLauncher( + gateway_host=extra.get("gateway_host", "127.0.0.1"), + gateway_port=int(extra.get("gateway_port", DEFAULT_GATEWAY_RPC_PORT)), + metrics_port=int(extra.get("metrics_port", DEFAULT_GATEWAY_METRICS_PORT)), + pearld_rpc_url=extra.get("pearld_rpc_url", DEFAULT_PEARLD_RPC_URL), + pearld_rpc_user=extra.get("pearld_rpc_user", "rpcuser"), + pearld_rpc_password=password, + wallet_address=config.wallet_address, + log_dir=_log_dir(), + provider_id=self.provider_id, + miner_module="openjarvis.mining._mps_miner_loop_main", + ) + self._launcher.start( + m=int(extra.get("m", 128)), + n=int(extra.get("n", 128)), + k=int(extra.get("k", CPU_PEARL_DEFAULT_K)), + rank=int(extra.get("rank", 64)), + ) + self._config = config + self._started_at = time.time() + self._write_sidecar() + + +def ensure_registered() -> None: + """Idempotently register AppleMpsPearlProvider in MinerRegistry.""" + if MinerRegistry.contains("apple-mps-pearl"): + return + MinerRegistry.register_value("apple-mps-pearl", AppleMpsPearlProvider) diff --git a/src/openjarvis/mining/cpu_pearl.py b/src/openjarvis/mining/cpu_pearl.py new file mode 100644 index 00000000..b2f95490 --- /dev/null +++ b/src/openjarvis/mining/cpu_pearl.py @@ -0,0 +1,213 @@ +"""CPU-based Pearl mining provider (decoupled from inference). + +See spec ``docs/design/2026-05-05-apple-silicon-pearl-mining-design.md`` §13 +for the full v1 design. + +The provider runs Pearl's pure-Rust ``mine()`` function via py-pearl-mining +and runs Pearl's pearl-gateway as a sibling subprocess. Engine-independent: +this provider does not plug into the user's inference stack. The user keeps +using whatever engine they want; mining runs alongside on the CPU. +""" + +from __future__ import annotations + +import os +import time +import urllib.request +from pathlib import Path + +from openjarvis.core.config import HardwareInfo +from openjarvis.core.registry import MinerRegistry + +from . import _install +from ._constants import ( + CPU_PEARL_DEFAULT_K, + CPU_PEARL_DEFAULT_M, + CPU_PEARL_DEFAULT_N, + CPU_PEARL_DEFAULT_RANK, + DEFAULT_GATEWAY_METRICS_PORT, + DEFAULT_GATEWAY_RPC_PORT, + DEFAULT_PEARLD_RPC_URL, + SIDECAR_PATH, +) +from ._pearl_subprocess import PearlSubprocessLauncher +from ._stubs import ( + MiningCapabilities, + MiningConfig, + MiningProvider, + MiningStats, + Sidecar, +) + + +def _sidecar_path() -> Path: + """Return the runtime sidecar path. Override in tests.""" + return SIDECAR_PATH + + +def _log_dir() -> Path: + """Return the logs directory. Override in tests.""" + return Path.home() / ".openjarvis" / "logs" / "mining" + + +def _parse_gateway_metrics(text: str, *, provider_id: str) -> MiningStats: + """Parse Prometheus exposition format into a MiningStats. + + Pearl-gateway exposes counters/gauges that we map onto MiningStats fields. + Unknown / unparseable metric lines are ignored. If the gateway changes + metric names, this is the single point of update. + + Expected metrics (verified empirically the first time the gateway runs; + if names differ, update the dispatch table below): + - pearl_gateway_shares_submitted_total + - pearl_gateway_shares_accepted_total + - pearl_gateway_blocks_found_total + """ + stats = MiningStats(provider_id=provider_id) + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + # Prometheus line: "metric_name{labels} value [timestamp]" + # We only care about metrics with no labels (the totals); a labeled + # variant would still parse — we just take the value of the first + # whitespace-separated token after the name. + parts = line.split() + if len(parts) < 2: + continue + name_part = parts[0] + # strip any "{...}" label suffix + bare_name = name_part.split("{", 1)[0] + try: + value = float(parts[1]) + except ValueError: + continue + if bare_name == "pearl_gateway_shares_submitted_total": + stats.shares_submitted = int(value) + elif bare_name == "pearl_gateway_shares_accepted_total": + stats.shares_accepted = int(value) + elif bare_name == "pearl_gateway_blocks_found_total": + stats.blocks_found = int(value) + return stats + + +class CpuPearlProvider(MiningProvider): + """v1 cpu-pearl: decoupled CPU mining via py-pearl-mining + pearl-gateway.""" + + provider_id = "cpu-pearl" + + def __init__(self) -> None: + self._launcher: PearlSubprocessLauncher | None = None + self._config: MiningConfig | None = None + self._started_at: float | None = None + + @classmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + if hw.platform not in {"darwin", "linux"}: + return MiningCapabilities( + supported=False, + reason=( + f"v1 cpu-pearl supports darwin/linux only; this host is " + f"'{hw.platform}'" + ), + ) + if not _install.pearl_packages_available(): + return MiningCapabilities( + supported=False, + reason=( + f"Pearl Python packages not installed — {_install.install_hint()}" + ), + ) + return MiningCapabilities(supported=True) + + async def start(self, config: MiningConfig) -> None: + """Spawn pearl-gateway and miner-loop subprocesses; write sidecar.""" + extra = dict(config.extra or {}) + password_env = extra.get("pearld_rpc_password_env", "PEARLD_RPC_PASSWORD") + password = os.environ.get(password_env, "") + + self._launcher = PearlSubprocessLauncher( + gateway_host=extra.get("gateway_host", "127.0.0.1"), + gateway_port=int(extra.get("gateway_port", DEFAULT_GATEWAY_RPC_PORT)), + metrics_port=int(extra.get("metrics_port", DEFAULT_GATEWAY_METRICS_PORT)), + pearld_rpc_url=extra.get("pearld_rpc_url", DEFAULT_PEARLD_RPC_URL), + pearld_rpc_user=extra.get("pearld_rpc_user", "rpcuser"), + pearld_rpc_password=password, + wallet_address=config.wallet_address, + log_dir=_log_dir(), + ) + self._launcher.start( + m=int(extra.get("m", CPU_PEARL_DEFAULT_M)), + n=int(extra.get("n", CPU_PEARL_DEFAULT_N)), + k=int(extra.get("k", CPU_PEARL_DEFAULT_K)), + rank=int(extra.get("rank", CPU_PEARL_DEFAULT_RANK)), + ) + self._config = config + self._started_at = time.time() + self._write_sidecar() + + async def stop(self) -> None: + """SIGTERM both subprocesses; remove sidecar.""" + if self._launcher is not None: + self._launcher.stop() + self._launcher = None + self._config = None + self._started_at = None + Sidecar.remove(_sidecar_path()) + + def is_running(self) -> bool: + return self._launcher is not None and self._launcher.is_running() + + def stats(self) -> MiningStats: + if not self.is_running() or self._launcher is None: + return MiningStats(provider_id=self.provider_id) + extra = (self._config.extra or {}) if self._config else {} + host = extra.get("gateway_host", "127.0.0.1") + metrics_port = int(extra.get("metrics_port", DEFAULT_GATEWAY_METRICS_PORT)) + try: + with urllib.request.urlopen( + f"http://{host}:{metrics_port}/metrics", timeout=2.0 + ) as resp: + text = resp.read().decode() + except Exception as e: # noqa: BLE001 — we want any read failure to surface + return MiningStats( + provider_id=self.provider_id, + last_error=f"gateway metrics unreachable: {e}", + ) + stats = _parse_gateway_metrics(text, provider_id=self.provider_id) + if self._started_at is not None: + stats.uptime_seconds = time.time() - self._started_at + return stats + + def _write_sidecar(self) -> None: + if self._launcher is None or self._config is None: + return + pids = self._launcher.pids() or (None, None) + extra = self._config.extra or {} + host = extra.get("gateway_host", "127.0.0.1") + gw_port = extra.get("gateway_port", DEFAULT_GATEWAY_RPC_PORT) + mt_port = extra.get("metrics_port", DEFAULT_GATEWAY_METRICS_PORT) + Sidecar.write( + _sidecar_path(), + { + "provider": self.provider_id, + "started_at": self._started_at, + "wallet_address": self._config.wallet_address, + "gateway_url": f"http://{host}:{gw_port}", + "metrics_url": f"http://{host}:{mt_port}/metrics", + "gateway_pid": pids[0], + "miner_loop_pid": pids[1], + }, + ) + + +def ensure_registered() -> None: + """Idempotently register CpuPearlProvider in MinerRegistry. + + Called once at import time from ``openjarvis.mining.__init__``. Tests that + rely on the autouse registry-clear fixture in ``tests/conftest.py`` must + call this from a fixture or test body to re-register after the clear. + """ + if MinerRegistry.contains("cpu-pearl"): + return + MinerRegistry.register_value("cpu-pearl", CpuPearlProvider) diff --git a/src/openjarvis/mining/pools/__init__.py b/src/openjarvis/mining/pools/__init__.py new file mode 100644 index 00000000..86f1db56 --- /dev/null +++ b/src/openjarvis/mining/pools/__init__.py @@ -0,0 +1,9 @@ +# src/openjarvis/mining/pools/__init__.py +"""RESERVED for v2 pool support. + +Do not add code here in v1. The v2 spec will define a ``PoolClient`` ABC and +``PoolClientRegistry`` peer to ``MinerRegistry``. Squatting on this path now +would create migration debt and pre-decide the v2 API. See spec §8.5. +""" + +from __future__ import annotations diff --git a/src/openjarvis/mining/vllm_pearl.py b/src/openjarvis/mining/vllm_pearl.py new file mode 100644 index 00000000..3c99ea53 --- /dev/null +++ b/src/openjarvis/mining/vllm_pearl.py @@ -0,0 +1,152 @@ +# src/openjarvis/mining/vllm_pearl.py +"""The v1 vllm-pearl mining provider. + +See spec ``docs/design/2026-05-05-vllm-pearl-mining-integration-design.md``. +""" + +from __future__ import annotations + +import time +from typing import Any, Optional + +import httpx + +from openjarvis.core.config import HardwareInfo +from openjarvis.core.registry import MinerRegistry +from openjarvis.mining._constants import ( + DEFAULT_GATEWAY_METRICS_PORT, + DEFAULT_GATEWAY_RPC_PORT, + DEFAULT_PEARL_MODEL, + DEFAULT_VLLM_PORT, + PEARL_IMAGE_TAG, + SIDECAR_PATH, +) +from openjarvis.mining._discovery import detect_for_engine_model +from openjarvis.mining._docker import PearlDockerLauncher +from openjarvis.mining._metrics import parse_gateway_metrics, parse_vllm_metrics +from openjarvis.mining._stubs import ( + MiningCapabilities, + MiningConfig, + MiningProvider, + MiningStats, + PoolTarget, + Sidecar, + SoloTarget, +) + + +class VllmPearlProvider(MiningProvider): + """vLLM + Pearl Docker container, solo-mining only in v1.""" + + provider_id = "vllm-pearl" + + def __init__(self, docker_client: Optional[Any] = None): + if docker_client is None: + import docker + + docker_client = docker.from_env() + self._client = docker_client + self._launcher = PearlDockerLauncher(client=docker_client) + + @classmethod + def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities: + return detect_for_engine_model( + hw=hw, + engine_id=engine_id, + model=model, + provider_id=cls.provider_id, + ) + + async def start(self, config: MiningConfig) -> None: + if isinstance(config.submit_target, PoolTarget): + raise NotImplementedError( + "pool support is v2 — see openjarvis#XYZ. v1 only accepts " + "submit_target='solo'." + ) + assert isinstance(config.submit_target, SoloTarget) + + image = config.extra.get("docker_image_tag", PEARL_IMAGE_TAG) + image = self._launcher.ensure_image(image) + container = self._launcher.start(config, image=image) + + # Pull port assignments from extra (with sensible defaults). + vllm_port = int(config.extra.get("vllm_port", DEFAULT_VLLM_PORT)) + gw_port = int(config.extra.get("gateway_port", DEFAULT_GATEWAY_RPC_PORT)) + gw_metrics = int( + config.extra.get("gateway_metrics_port", DEFAULT_GATEWAY_METRICS_PORT) + ) + model_name = config.extra.get("model", DEFAULT_PEARL_MODEL) + + # Sidecar write failure (filesystem full, perms, etc.) leaves a + # running container with no recorded session — clean it up rather + # than lying via is_running() later. + try: + Sidecar.write( + SIDECAR_PATH, + { + "provider": self.provider_id, + "vllm_endpoint": f"http://127.0.0.1:{vllm_port}/v1", + "model": model_name, + "gateway_url": f"http://127.0.0.1:{gw_port}", + "gateway_metrics_url": f"http://127.0.0.1:{gw_metrics}", + "container_id": getattr(container, "id", ""), + "wallet_address": config.wallet_address, + "started_at": int(time.time()), + }, + ) + except Exception: + self._launcher.stop() + raise + + async def stop(self) -> None: + self._launcher.stop() + Sidecar.remove(SIDECAR_PATH) + + def is_running(self) -> bool: + return self._launcher.is_running() + + def stats(self) -> MiningStats: + sidecar = Sidecar.read(SIDECAR_PATH) + if sidecar is None: + return MiningStats(provider_id=self.provider_id) + gateway_url = sidecar.get("gateway_metrics_url") + try: + if gateway_url: + resp = httpx.get(f"{gateway_url}/metrics", timeout=2.0) + if resp.status_code == 200: + return parse_gateway_metrics( + resp.text, + provider_id=self.provider_id, + ) + except Exception: # noqa: BLE001 - vLLM fallback below. + pass + + vllm_endpoint = sidecar.get("vllm_endpoint") + if vllm_endpoint: + metrics_url = vllm_endpoint.removesuffix("/v1") + "/metrics" + try: + resp = httpx.get(metrics_url, timeout=5.0) + if resp.status_code == 200: + return parse_vllm_metrics(resp.text, provider_id=self.provider_id) + return MiningStats( + provider_id=self.provider_id, + last_error=f"vLLM metrics HTTP {resp.status_code}", + ) + except Exception as e: # noqa: BLE001 + return MiningStats( + provider_id=self.provider_id, + last_error=str(e).splitlines()[0], + ) + + return MiningStats( + provider_id=self.provider_id, + last_error="no gateway or vLLM metrics URL in sidecar", + ) + + +def ensure_registered() -> None: + """Idempotent registration. Required because tests/conftest.py clears + every registry before each test (see Spec A §4.2). + """ + if not MinerRegistry.contains("vllm-pearl"): + MinerRegistry.register_value("vllm-pearl", VllmPearlProvider) diff --git a/src/openjarvis/telemetry/store.py b/src/openjarvis/telemetry/store.py index eea89b84..d88cf6fb 100644 --- a/src/openjarvis/telemetry/store.py +++ b/src/openjarvis/telemetry/store.py @@ -5,7 +5,9 @@ from __future__ import annotations import json import logging import sqlite3 +import time from pathlib import Path +from typing import Any from openjarvis.core.events import Event, EventBus, EventType from openjarvis.core.types import TelemetryRecord @@ -53,10 +55,28 @@ CREATE TABLE IF NOT EXISTS telemetry ( p99_itl_ms REAL NOT NULL DEFAULT 0.0, std_itl_ms REAL NOT NULL DEFAULT 0.0, is_streaming INTEGER NOT NULL DEFAULT 0, + mining_session_id TEXT, metadata TEXT NOT NULL DEFAULT '{}' ); """ +_CREATE_MINING_STATS_TABLE = """\ +CREATE TABLE IF NOT EXISTS mining_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at REAL NOT NULL, + provider_id TEXT NOT NULL, + shares_submitted INTEGER NOT NULL DEFAULT 0, + shares_accepted INTEGER NOT NULL DEFAULT 0, + blocks_found INTEGER NOT NULL DEFAULT 0, + hashrate REAL NOT NULL DEFAULT 0, + uptime_seconds REAL NOT NULL DEFAULT 0, + last_share_at REAL, + last_error TEXT, + payout_target TEXT NOT NULL DEFAULT 'solo', + fees_owed INTEGER NOT NULL DEFAULT 0 +); +""" + _INSERT = """\ INSERT INTO telemetry ( timestamp, model_id, engine, agent, @@ -71,12 +91,13 @@ INSERT INTO telemetry ( prefill_energy_joules, decode_energy_joules, mean_itl_ms, median_itl_ms, p90_itl_ms, p95_itl_ms, p99_itl_ms, std_itl_ms, is_streaming, + mining_session_id, metadata ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) """ @@ -107,6 +128,7 @@ _MIGRATE_COLUMNS = [ ("std_itl_ms", "REAL NOT NULL DEFAULT 0.0"), ("is_streaming", "INTEGER NOT NULL DEFAULT 0"), ("prompt_tokens_evaluated", "INTEGER NOT NULL DEFAULT 0"), + ("mining_session_id", "TEXT"), ] @@ -117,6 +139,7 @@ class TelemetryStore: self._db_path = str(db_path) self._conn = sqlite3.connect(self._db_path, check_same_thread=False) self._conn.execute(_CREATE_TABLE) + self._conn.execute(_CREATE_MINING_STATS_TABLE) self._conn.commit() self._migrate_schema() @@ -174,11 +197,55 @@ class TelemetryStore: rec.p99_itl_ms, rec.std_itl_ms, 1 if rec.is_streaming else 0, + rec.mining_session_id, json.dumps(rec.metadata), ), ) self._conn.commit() + def record_mining_stats(self, stats: Any) -> None: + """Persist one mining stats snapshot. + + ``stats`` is duck-typed to keep telemetry usable without importing the + optional mining package at module import time. + """ + self._conn.execute( + """\ +INSERT INTO mining_stats ( + recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found, + hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +""", + ( + time.time(), + stats.provider_id, + stats.shares_submitted, + stats.shares_accepted, + stats.blocks_found, + stats.hashrate, + stats.uptime_seconds, + stats.last_share_at, + stats.last_error, + stats.payout_target, + stats.fees_owed, + ), + ) + self._conn.commit() + + def list_recent(self, limit: int = 50) -> list[dict[str, Any]]: + """Return recent telemetry rows as dictionaries.""" + return self._select_dicts( + "SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT ?", + (limit,), + ) + + def list_recent_mining_stats(self, limit: int = 50) -> list[dict[str, Any]]: + """Return recent mining stats snapshots as dictionaries.""" + return self._select_dicts( + "SELECT * FROM mining_stats ORDER BY recorded_at DESC LIMIT ?", + (limit,), + ) + def subscribe_to_bus(self, bus: EventBus) -> None: """Subscribe to ``TELEMETRY_RECORD`` events on *bus*.""" bus.subscribe(EventType.TELEMETRY_RECORD, self._on_event) @@ -200,5 +267,10 @@ class TelemetryStore: def _fetchall(self, sql: str = "SELECT * FROM telemetry") -> list: return self._conn.execute(sql).fetchall() + def _select_dicts(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]: + cur = self._conn.execute(sql, params) + columns = [desc[0] for desc in cur.description] + return [dict(zip(columns, row)) for row in cur.fetchall()] + __all__ = ["TelemetryStore"] diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 03e1faeb..558741c5 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -48,6 +48,13 @@ class TestCLI: assert "search" in result.output assert "stats" in result.output + def test_mine_subcommands_exist(self) -> None: + result = CliRunner().invoke(cli, ["mine", "--help"]) + assert result.exit_code == 0 + assert "doctor" in result.output + assert "start" in result.output + assert "stop" in result.output + def test_telemetry_subcommands_exist(self) -> None: result = CliRunner().invoke(cli, ["telemetry", "--help"]) assert result.exit_code == 0 diff --git a/tests/cli/test_hints.py b/tests/cli/test_hints.py index 67953d16..439280a1 100644 --- a/tests/cli/test_hints.py +++ b/tests/cli/test_hints.py @@ -6,6 +6,7 @@ from openjarvis.cli.hints import ( hint_no_config, hint_no_engine, hint_no_model, + mining_not_running_hint, ) @@ -45,3 +46,14 @@ class TestHintFunctions: msg = hint_no_engine("vllm") assert "config set" in msg assert "engine.vllm.host" in msg + + def test_mining_not_running_hint_when_configured_no_sidecar(self): + msg = mining_not_running_hint(object(), sidecar_present=False) + assert msg is not None + assert "jarvis mine start" in msg + + def test_mining_not_running_hint_silent_when_running(self): + assert mining_not_running_hint(object(), sidecar_present=True) is None + + def test_mining_not_running_hint_silent_when_unconfigured(self): + assert mining_not_running_hint(None, sidecar_present=False) is None diff --git a/tests/cli/test_mine_cmd.py b/tests/cli/test_mine_cmd.py new file mode 100644 index 00000000..4bac1431 --- /dev/null +++ b/tests/cli/test_mine_cmd.py @@ -0,0 +1,419 @@ +"""Tests for the ``jarvis mine`` CLI.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from openjarvis.cli import cli +from openjarvis.mining import Sidecar + + +def test_mine_help() -> None: + result = CliRunner().invoke(cli, ["mine", "--help"]) + + assert result.exit_code == 0 + assert "Configure and run Pearl mining" in result.output + assert "init" in result.output + assert "start" in result.output + assert "doctor" in result.output + + +def test_mine_models_lists_validated_and_planned_models() -> None: + result = CliRunner().invoke(cli, ["mine", "models"]) + + assert result.exit_code == 0 + assert "Pearl Mining Models" in result.output + assert "pearl-ai/Llama-3.3-70B-Instruct-pearl" in result.output + assert "pearl-ai/Qwen3.5-9B-pearl" in result.output + assert "validated" in result.output + assert "planned" in result.output + + +def test_mine_init_writes_mining_config(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + + result = CliRunner().invoke( + cli, + [ + "mine", + "init", + "--provider", + "cpu-pearl", + "--wallet-address", + "prl1qtestingaddr", + "--pearld-rpc-url", + "http://127.0.0.1:44107", + "--pearld-rpc-user", + "rpcuser", + "--pearld-rpc-password-env", + "TEST_PEARLD_PASSWORD", + ], + env={"OPENJARVIS_CONFIG": str(config_path)}, + ) + + assert result.exit_code == 0 + content = config_path.read_text() + assert "[mining]" in content + assert 'provider = "cpu-pearl"' in content + assert 'wallet_address = "prl1qtestingaddr"' in content + assert 'pearld_rpc_password_env = "TEST_PEARLD_PASSWORD"' in content + + +def test_mine_init_writes_cuda_visible_devices_for_vllm( + tmp_path: Path, +) -> None: + from openjarvis.mining._stubs import MiningCapabilities + + config_path = tmp_path / "config.toml" + + with ( + patch("openjarvis.cli.mine_cmd._detect_hardware"), + patch( + "openjarvis.cli.mine_cmd.detect_for_engine_model", + return_value=MiningCapabilities(supported=True), + ), + patch( + "openjarvis.cli.mine_cmd.check_docker_available", + return_value=(True, ""), + ), + patch("openjarvis.cli.mine_cmd.check_disk_free", return_value=(True, "")), + patch("openjarvis.cli.mine_cmd._docker_from_env", return_value=MagicMock()), + patch( + "openjarvis.cli.mine_cmd.PearlDockerLauncher.ensure_image", + return_value="openjarvis/pearl-miner:master", + ), + ): + result = CliRunner().invoke( + cli, + [ + "mine", + "init", + "--provider", + "vllm-pearl", + "--wallet-address", + "prl1qtestingaddr", + "--pearld-rpc-url", + "http://127.0.0.1:44107", + "--pearld-rpc-user", + "rpcuser", + "--pearld-rpc-password-env", + "TEST_PEARLD_PASSWORD", + "--cuda-visible-devices", + "0,1", + ], + env={"OPENJARVIS_CONFIG": str(config_path)}, + ) + + assert result.exit_code == 0 + content = config_path.read_text() + assert 'provider = "vllm-pearl"' in content + assert 'cuda_visible_devices = "0,1"' in content + + +def test_mine_start_uses_configured_provider(tmp_path: Path, monkeypatch) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[mining] +provider = "cpu-pearl" +wallet_address = "prl1qtest" +submit_target = "solo" + +[mining.extra] +pearld_rpc_url = "http://127.0.0.1:44107" +pearld_rpc_user = "rpcuser" +pearld_rpc_password_env = "TEST_PEARLD_PASSWORD" +gateway_host = "127.0.0.1" +gateway_port = 18337 +metrics_port = 19109 +""" + ) + sidecar_path = tmp_path / "mining.json" + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar_path) + + started_configs = [] + + async def fake_start(config): + started_configs.append(config) + + fake_provider = MagicMock() + provider_cls = MagicMock(return_value=fake_provider) + fake_provider.start = fake_start + + with patch("openjarvis.cli.mine_cmd._provider_ids", return_value=("cpu-pearl",)): + with patch("openjarvis.cli.mine_cmd.MinerRegistry.contains", return_value=True): + with patch( + "openjarvis.cli.mine_cmd.MinerRegistry.get", + return_value=provider_cls, + ): + result = CliRunner().invoke( + cli, + ["mine", "start"], + env={ + "OPENJARVIS_CONFIG": str(config_path), + "TEST_PEARLD_PASSWORD": "secret", + }, + ) + + assert result.exit_code == 0 + assert "Started" in result.output + provider_cls.assert_called_once_with() + assert started_configs[0].provider == "cpu-pearl" + + +def test_mine_status_reports_sidecar_and_metrics(tmp_path: Path, monkeypatch) -> None: + sidecar_path = tmp_path / "mining.json" + Sidecar.write( + sidecar_path, + { + "provider": "cpu-pearl", + "wallet_address": "prl1qtest", + "gateway_url": "http://127.0.0.1:8337", + "metrics_url": "http://127.0.0.1:9109/metrics", + "gateway_pid": 111, + "miner_loop_pid": 222, + }, + ) + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar_path) + monkeypatch.setattr("openjarvis.cli.mine_cmd._pid_alive", lambda pid: True) + + stats = MagicMock() + stats.shares_submitted = 3 + stats.shares_accepted = 2 + stats.blocks_found = 1 + monkeypatch.setattr( + "openjarvis.cli.mine_cmd._stats_from_metrics_url", + lambda url, provider_id: (stats, None), + ) + + result = CliRunner().invoke(cli, ["mine", "status"]) + + assert result.exit_code == 0 + assert "cpu-pearl" in result.output + assert "Shares submitted" in result.output + assert "3" in result.output + + +def test_mine_stop_terminates_pids_and_removes_sidecar( + tmp_path: Path, monkeypatch +) -> None: + sidecar_path = tmp_path / "mining.json" + Sidecar.write( + sidecar_path, + { + "provider": "cpu-pearl", + "gateway_pid": 111, + "miner_loop_pid": 222, + }, + ) + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar_path) + terminated: list[int] = [] + + def fake_terminate(pid, *, grace_seconds): + terminated.append(pid) + + monkeypatch.setattr("openjarvis.cli.mine_cmd._terminate_pid", fake_terminate) + + result = CliRunner().invoke(cli, ["mine", "stop"]) + + assert result.exit_code == 0 + assert sorted(terminated) == [111, 222] + assert not sidecar_path.exists() + + +def test_mine_doctor_without_config(tmp_path: Path, monkeypatch) -> None: + config_path = tmp_path / "missing.toml" + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", tmp_path / "none.json") + + with patch("openjarvis.cli.mine_cmd._provider_ids", return_value=()): + result = CliRunner().invoke( + cli, + ["mine", "doctor"], + env={"OPENJARVIS_CONFIG": str(config_path)}, + ) + + assert result.exit_code == 0 + assert "Pearl Mining Doctor" in result.output + assert "jarvis mine init" in result.output + + +def test_mine_status_no_session(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", tmp_path / "none.json") + + result = CliRunner().invoke(cli, ["mine", "status"]) + + assert result.exit_code == 0 + assert "No active mining session" in result.output + + +def test_mine_validate_model_blocks_planned_without_allow( + tmp_path: Path, monkeypatch +) -> None: + sidecar_path = tmp_path / "mining.json" + model = "pearl-ai/Qwen3.5-9B-pearl" + Sidecar.write( + sidecar_path, + { + "provider": "vllm-pearl", + "model": model, + "vllm_endpoint": "http://127.0.0.1:8000/v1", + "gateway_metrics_url": "http://127.0.0.1:8339", + }, + ) + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar_path) + monkeypatch.setattr( + "openjarvis.cli.mine_cmd._get_json", + lambda url, *, timeout: {"data": [{"id": model}]}, + ) + stats = MagicMock() + stats.shares_submitted = 1 + stats.shares_accepted = 1 + monkeypatch.setattr( + "openjarvis.cli.mine_cmd._stats_from_metrics_url", + lambda url, provider_id: (stats, None), + ) + + result = CliRunner().invoke(cli, ["mine", "validate-model"]) + + assert result.exit_code != 0 + assert "planned" in result.output + assert "validation checks failed" in result.output.lower() + + +def test_mine_validate_model_allows_planned_with_runtime_evidence( + tmp_path: Path, monkeypatch +) -> None: + sidecar_path = tmp_path / "mining.json" + model = "pearl-ai/Qwen3.5-9B-pearl" + Sidecar.write( + sidecar_path, + { + "provider": "vllm-pearl", + "model": model, + "vllm_endpoint": "http://127.0.0.1:8000/v1", + "gateway_metrics_url": "http://127.0.0.1:8339", + }, + ) + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar_path) + monkeypatch.setattr( + "openjarvis.cli.mine_cmd._get_json", + lambda url, *, timeout: {"data": [{"id": model}]}, + ) + monkeypatch.setattr( + "openjarvis.cli.mine_cmd._post_json", + lambda url, payload, *, timeout: {"choices": [{"message": {"content": "ok"}}]}, + ) + stats = MagicMock() + stats.shares_submitted = 2 + stats.shares_accepted = 1 + monkeypatch.setattr( + "openjarvis.cli.mine_cmd._stats_from_metrics_url", + lambda url, provider_id: (stats, None), + ) + + result = CliRunner().invoke( + cli, + [ + "mine", + "validate-model", + "--allow-planned", + "--prompt", + "hello", + ], + ) + + assert result.exit_code == 0 + assert "Validation checks passed" in result.output + assert "Chat completion" in result.output + assert "submitted=2 accepted=1" in result.output + + +def test_mine_validate_model_writes_json_artifact(tmp_path: Path, monkeypatch) -> None: + sidecar_path = tmp_path / "mining.json" + output_path = tmp_path / "artifacts" / "qwen-validation.json" + model = "pearl-ai/Qwen3.5-9B-pearl" + Sidecar.write( + sidecar_path, + { + "provider": "vllm-pearl", + "model": model, + "vllm_endpoint": "http://127.0.0.1:8000/v1", + "gateway_metrics_url": "http://127.0.0.1:8339", + }, + ) + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar_path) + monkeypatch.setattr( + "openjarvis.cli.mine_cmd._get_json", + lambda url, *, timeout: {"data": [{"id": model}]}, + ) + stats = MagicMock() + stats.shares_submitted = 2 + stats.shares_accepted = 1 + monkeypatch.setattr( + "openjarvis.cli.mine_cmd._stats_from_metrics_url", + lambda url, provider_id: (stats, None), + ) + + result = CliRunner().invoke( + cli, + [ + "mine", + "validate-model", + "--allow-planned", + "--output", + str(output_path), + ], + ) + + assert result.exit_code == 0 + payload = json.loads(output_path.read_text()) + assert payload["schema_version"] == 1 + assert payload["model"] == model + assert payload["status"] == "passed" + assert payload["prompt_ran"] is False + assert {check["name"] for check in payload["checks"]} >= { + "Model registry", + "Sidecar", + "vLLM /models", + "Gateway metrics", + } + + +def test_mine_validate_model_falls_back_to_vllm_metrics( + tmp_path: Path, monkeypatch +) -> None: + sidecar_path = tmp_path / "mining.json" + model = "pearl-ai/Llama-3.3-70B-Instruct-pearl" + Sidecar.write( + sidecar_path, + { + "provider": "vllm-pearl", + "model": model, + "vllm_endpoint": "http://127.0.0.1:8000/v1", + "gateway_metrics_url": "http://127.0.0.1:8339", + }, + ) + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar_path) + monkeypatch.setattr( + "openjarvis.cli.mine_cmd._get_json", + lambda url, *, timeout: {"data": [{"id": model}]}, + ) + stats = MagicMock() + stats.shares_submitted = 0 + stats.shares_accepted = 0 + + def fake_stats(url, provider_id): + if url == "http://127.0.0.1:8339/metrics": + return None, "connection refused" + return stats, None + + monkeypatch.setattr("openjarvis.cli.mine_cmd._stats_from_metrics_url", fake_stats) + + result = CliRunner().invoke(cli, ["mine", "validate-model"]) + + assert result.exit_code == 0 + assert "vLLM submitted=0 accepted=0" in result.output diff --git a/tests/cli/test_pearl_cmd.py b/tests/cli/test_pearl_cmd.py new file mode 100644 index 00000000..52181725 --- /dev/null +++ b/tests/cli/test_pearl_cmd.py @@ -0,0 +1,129 @@ +"""Tests for the ``jarvis pearl`` CLI wrappers.""" + +from __future__ import annotations + +import subprocess + +from click.testing import CliRunner + +from openjarvis.cli import cli + + +def test_pearl_help_lists_wrappers() -> None: + result = CliRunner().invoke(cli, ["pearl", "--help"]) + + assert result.exit_code == 0 + assert "node" in result.output + assert "wallet" in result.output + assert "ctl" in result.output + assert "address" in result.output + + +def test_pearl_doctor_reports_discovered_binaries(monkeypatch) -> None: + def fake_resolve(name: str, pearl_home: str | None = None) -> str | None: + return f"/opt/pearl/bin/{name}" if name != "oyster" else None + + monkeypatch.setattr("openjarvis.cli.pearl_cmd._resolve_binary", fake_resolve) + + result = CliRunner().invoke(cli, ["pearl", "doctor"]) + + assert result.exit_code == 0 + assert "pearld" in result.output + assert "/opt/pearl/bin/pearld" in result.output + assert "oyster" in result.output + assert "not found" in result.output + + +def test_pearl_node_passes_args_to_pearld(monkeypatch) -> None: + calls: list[tuple[str, tuple[str, ...], str | None]] = [] + + def fake_run( + name: str, args: tuple[str, ...], pearl_home: str | None = None + ) -> int: + calls.append((name, args, pearl_home)) + return 7 + + monkeypatch.setattr("openjarvis.cli.pearl_cmd._run", fake_run) + + result = CliRunner().invoke( + cli, + ["pearl", "node", "--pearl-home", "/opt/pearl", "--notls", "--txindex"], + ) + + assert result.exit_code == 7 + assert calls == [("pearld", ("--notls", "--txindex"), "/opt/pearl")] + + +def test_pearl_ctl_passes_args_to_prlctl(monkeypatch) -> None: + calls: list[tuple[str, tuple[str, ...], str | None]] = [] + + def fake_run( + name: str, args: tuple[str, ...], pearl_home: str | None = None + ) -> int: + calls.append((name, args, pearl_home)) + return 0 + + monkeypatch.setattr("openjarvis.cli.pearl_cmd._run", fake_run) + + result = CliRunner().invoke( + cli, + ["pearl", "ctl", "--wallet", "--notls", "-s", "localhost:44207", "help"], + ) + + assert result.exit_code == 0 + assert calls == [ + ("prlctl", ("--wallet", "--notls", "-s", "localhost:44207", "help"), None) + ] + + +def test_pearl_address_uses_wallet_rpc(monkeypatch) -> None: + calls: list[tuple[str, tuple[str, ...], str | None]] = [] + + def fake_capture( + name: str, args: tuple[str, ...], pearl_home: str | None = None + ) -> subprocess.CompletedProcess[str]: + calls.append((name, args, pearl_home)) + return subprocess.CompletedProcess( + args=[name, *args], + returncode=0, + stdout="prl1pabc\n", + stderr="", + ) + + monkeypatch.setattr("openjarvis.cli.pearl_cmd._run_capture", fake_capture) + + result = CliRunner().invoke( + cli, + [ + "pearl", + "address", + "--pearl-home", + "/opt/pearl", + "-u", + "alice", + "-P", + "secret", + "-s", + "localhost:44209", + ], + ) + + assert result.exit_code == 0 + assert "prl1pabc" in result.output + assert calls == [ + ( + "prlctl", + ( + "--wallet", + "-u", + "alice", + "-P", + "secret", + "-s", + "localhost:44209", + "--notls", + "getnewaddress", + ), + "/opt/pearl", + ) + ] diff --git a/tests/conftest.py b/tests/conftest.py index 67f8a534..61b549b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import os +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -17,6 +19,7 @@ from openjarvis.core.registry import ( ConnectorRegistry, EngineRegistry, MemoryRegistry, + MinerRegistry, ModelRegistry, RouterPolicyRegistry, SkillRegistry, @@ -32,6 +35,7 @@ def _clean_registries() -> None: ModelRegistry.clear() EngineRegistry.clear() MemoryRegistry.clear() + MinerRegistry.clear() AgentRegistry.clear() ToolRegistry.clear() RouterPolicyRegistry.clear() @@ -248,3 +252,36 @@ def mock_engine(): def event_bus() -> EventBus: """Fresh EventBus with history recording enabled.""" return EventBus(record_history=True) + + +# --------------------------------------------------------------------------- +# Mining sidecar fixtures (shared across tests/mining/ and tests/engine/) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sample_sidecar_payload() -> dict: + """A valid vllm-pearl sidecar payload with all expected fields.""" + return { + "provider": "vllm-pearl", + "vllm_endpoint": "http://127.0.0.1:8000/v1", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "gateway_url": "http://127.0.0.1:8337", + "gateway_metrics_url": "http://127.0.0.1:8339", + "container_id": "abc123def456", + "wallet_address": "prl1qexampleaddress", + "started_at": 1714867200, + } + + +@pytest.fixture +def sidecar_path(tmp_path: Path) -> Path: + """Path to a (not-yet-written) mining sidecar JSON file.""" + return tmp_path / "mining.json" + + +@pytest.fixture +def written_sidecar(sidecar_path: Path, sample_sidecar_payload: dict) -> Path: + """A written mining sidecar JSON file; returns the path.""" + sidecar_path.write_text(json.dumps(sample_sidecar_payload)) + return sidecar_path diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 338ea025..864e843e 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -508,3 +508,49 @@ class TestWhatsAppBaileysChannelConfig: def test_on_channel_config(self) -> None: cc = ChannelConfig() assert isinstance(cc.whatsapp_baileys, WhatsAppBaileysChannelConfig) + + +# --------------------------------------------------------------------------- +# Mining config integration tests +# --------------------------------------------------------------------------- + + +def test_mining_config_absent_means_none(tmp_path): + from openjarvis.core.config import load_config + cfg_path = tmp_path / "config.toml" + cfg_path.write_text("") # empty config + cfg = load_config(cfg_path) + assert cfg.mining is None + + +def test_mining_config_solo_parsed(tmp_path): + from pathlib import Path + + from openjarvis.core.config import load_config + from openjarvis.mining._stubs import SoloTarget + + src = Path(__file__).parent.parent / "mining" / "fixtures" / "config_minimal.toml" + target = tmp_path / "config.toml" + target.write_text(src.read_text()) + cfg = load_config(target) + assert cfg.mining is not None + assert cfg.mining.provider == "vllm-pearl" + assert cfg.mining.wallet_address == "prl1qexampleaddress" + assert isinstance(cfg.mining.submit_target, SoloTarget) + assert cfg.mining.submit_target.pearld_rpc_url == "http://localhost:44107" + assert cfg.mining.fee_bps == 0 + assert cfg.mining.extra["model"] == "pearl-ai/Llama-3.3-70B-Instruct-pearl" + + +def test_mining_config_pool_parsed_as_pool_target(tmp_path): + from pathlib import Path + + from openjarvis.core.config import load_config + from openjarvis.mining._stubs import PoolTarget + + src = Path(__file__).parent.parent / "mining" / "fixtures" / "config_pool_v2.toml" + target = tmp_path / "config.toml" + target.write_text(src.read_text()) + cfg = load_config(target) + assert isinstance(cfg.mining.submit_target, PoolTarget) + assert cfg.mining.submit_target.url == "https://pool.openjarvis.ai/submit" diff --git a/tests/core/test_registry.py b/tests/core/test_registry.py index 456ac495..8a29bb7e 100644 --- a/tests/core/test_registry.py +++ b/tests/core/test_registry.py @@ -92,3 +92,20 @@ class TestRouterPolicyRegistry: RouterPolicyRegistry.register_value("dup", 1) with pytest.raises(ValueError, match="already has an entry"): RouterPolicyRegistry.register_value("dup", 2) + + +def test_miner_registry_register_and_get(): + from openjarvis.core.registry import MinerRegistry + + class _Stub: + provider_id = "stub-pearl" + + MinerRegistry.register_value("stub-pearl", _Stub) + assert MinerRegistry.contains("stub-pearl") is True + assert MinerRegistry.get("stub-pearl") is _Stub + + +def test_miner_registry_cleared_between_tests(): + from openjarvis.core.registry import MinerRegistry + # If autouse clear works, no entry from prior tests remains + assert MinerRegistry.contains("stub-pearl") is False diff --git a/tests/engine/test_discovery.py b/tests/engine/test_discovery.py index d111c169..8d398f4d 100644 --- a/tests/engine/test_discovery.py +++ b/tests/engine/test_discovery.py @@ -124,3 +124,78 @@ class TestGetEngine: result = get_engine(cfg, engine_key="requested") assert result is not None assert result[0] == "running" + + +class TestMiningSidecarEngineHandoff: + """Engine discovery picks up (or ignores) a mining sidecar at runtime.""" + + def test_engine_discovery_picks_up_mining_sidecar( + self, written_sidecar, monkeypatch + ) -> None: + """When a mining sidecar exists with vllm_endpoint, discovery + registers a ``vllm-pearl-mining`` engine in the EngineRegistry. + """ + from openjarvis.mining import _constants as mining_const + + monkeypatch.setattr(mining_const, "SIDECAR_PATH", written_sidecar) + + cfg = JarvisConfig() + with mock.patch( + "openjarvis.engine._discovery._make_engine", + side_effect=lambda k, c: _FakeEngine(healthy=True), + ): + discover_engines(cfg) + + assert EngineRegistry.contains("vllm-pearl-mining") + + def test_engine_discovery_no_mining_engine_when_sidecar_absent( + self, tmp_path, monkeypatch + ) -> None: + """No mining sidecar → no ``vllm-pearl-mining`` engine registered.""" + from openjarvis.mining import _constants as mining_const + + missing = tmp_path / "no-such-mining.json" + monkeypatch.setattr(mining_const, "SIDECAR_PATH", missing) + + cfg = JarvisConfig() + with mock.patch( + "openjarvis.engine._discovery._make_engine", + side_effect=lambda k, c: _FakeEngine(healthy=True), + ): + discover_engines(cfg) + + assert not EngineRegistry.contains("vllm-pearl-mining") + + def test_engine_discovery_skips_when_sidecar_missing_vllm_endpoint( + self, tmp_path, monkeypatch + ) -> None: + """Sidecar present but no ``vllm_endpoint`` field → skip registration. + + Data-driven gate: a future cpu-pearl provider writes a sidecar that + doesn't replace an inference engine (no vllm_endpoint field). + """ + import json as _json + + sidecar = tmp_path / "mining.json" + sidecar.write_text( + _json.dumps( + { + "provider": "cpu-pearl", + "wallet_address": "prl1q...", + "started_at": 1234567890, + # deliberately omit vllm_endpoint + } + ) + ) + from openjarvis.mining import _constants as mining_const + + monkeypatch.setattr(mining_const, "SIDECAR_PATH", sidecar) + + cfg = JarvisConfig() + with mock.patch( + "openjarvis.engine._discovery._make_engine", + side_effect=lambda k, c: _FakeEngine(healthy=True), + ): + discover_engines(cfg) + + assert not EngineRegistry.contains("vllm-pearl-mining") diff --git a/tests/hardware/test_nvidia.py b/tests/hardware/test_nvidia.py index d0a68e44..e35480b8 100644 --- a/tests/hardware/test_nvidia.py +++ b/tests/hardware/test_nvidia.py @@ -27,7 +27,7 @@ class TestNVIDIADetection: @patch("openjarvis.core.config.shutil.which", return_value="/usr/bin/nvidia-smi") @patch( "openjarvis.core.config._run_cmd", - return_value="NVIDIA A100-SXM4-80GB, 81920, 1", + return_value="NVIDIA A100-SXM4-80GB, 81920, 1, 8.0", ) def test_nvidia_smi_parsing(self, mock_run, mock_which): gpu = _detect_nvidia_gpu() @@ -36,15 +36,16 @@ class TestNVIDIADetection: assert gpu.vram_gb == 80.0 assert gpu.count == 1 assert gpu.vendor == "nvidia" + assert gpu.compute_capability == "8.0" @patch("openjarvis.core.config.shutil.which", return_value="/usr/bin/nvidia-smi") @patch( "openjarvis.core.config._run_cmd", return_value=( - "NVIDIA H100 80GB HBM3, 81920, 4\n" - "NVIDIA H100 80GB HBM3, 81920, 4\n" - "NVIDIA H100 80GB HBM3, 81920, 4\n" - "NVIDIA H100 80GB HBM3, 81920, 4" + "NVIDIA H100 80GB HBM3, 81920, 4, 9.0\n" + "NVIDIA H100 80GB HBM3, 81920, 4, 9.0\n" + "NVIDIA H100 80GB HBM3, 81920, 4, 9.0\n" + "NVIDIA H100 80GB HBM3, 81920, 4, 9.0" ), ) def test_nvidia_smi_multi_gpu(self, mock_run, mock_which): @@ -67,7 +68,7 @@ class TestNVIDIADetection: @patch("openjarvis.core.config.shutil.which", return_value="/usr/bin/nvidia-smi") @patch( "openjarvis.core.config._run_cmd", - return_value="NVIDIA GeForce RTX 4090, 24564, 1", + return_value="NVIDIA GeForce RTX 4090, 24564, 1, 8.9", ) def test_vram_detection(self, mock_run, mock_which): gpu = _detect_nvidia_gpu() @@ -78,10 +79,20 @@ class TestNVIDIADetection: @patch("openjarvis.core.config.shutil.which", return_value="/usr/bin/nvidia-smi") @patch( "openjarvis.core.config._run_cmd", - return_value="NVIDIA A100-SXM4-80GB, 81920, 1", + return_value="NVIDIA A100-SXM4-80GB, 81920, 1, 8.0", ) def test_compute_capability(self, mock_run, mock_which): - """compute_capability defaults to empty string when not parsed.""" + gpu = _detect_nvidia_gpu() + assert gpu is not None + assert gpu.compute_capability == "8.0" + + @patch("openjarvis.core.config.shutil.which", return_value="/usr/bin/nvidia-smi") + @patch( + "openjarvis.core.config._run_cmd", + return_value="NVIDIA A100-SXM4-80GB, 81920, 1", + ) + def test_compute_capability_backwards_compatible(self, mock_run, mock_which): + """Older mocked/driver output without compute_cap still parses.""" gpu = _detect_nvidia_gpu() assert gpu is not None assert gpu.compute_capability == "" diff --git a/tests/mining/__init__.py b/tests/mining/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mining/conftest.py b/tests/mining/conftest.py new file mode 100644 index 00000000..8b1b6162 --- /dev/null +++ b/tests/mining/conftest.py @@ -0,0 +1,100 @@ +"""Mining-specific test fixtures.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from openjarvis.core.config import GpuInfo, HardwareInfo + + +@pytest.fixture +def hopper_hw() -> HardwareInfo: + """Hardware fixture: a typical H100 host.""" + return HardwareInfo( + platform="linux", + cpu_brand="AMD EPYC 7763", + cpu_count=64, + ram_gb=512.0, + gpu=GpuInfo( + vendor="nvidia", + name="NVIDIA H100-SXM5-80GB", + vram_gb=80.0, + compute_capability="9.0", + count=1, + ), + ) + + +@pytest.fixture +def ada_hw() -> HardwareInfo: + """Hardware fixture: RTX 4090 (sm_89, NOT supported by Pearl).""" + return HardwareInfo( + platform="linux", + cpu_brand="Intel Core i9-14900K", + cpu_count=24, + ram_gb=64.0, + gpu=GpuInfo( + vendor="nvidia", + name="NVIDIA GeForce RTX 4090", + vram_gb=24.0, + compute_capability="8.9", + count=1, + ), + ) + + +@pytest.fixture +def apple_hw() -> HardwareInfo: + """Hardware fixture: Apple Silicon (NOT supported in v1).""" + return HardwareInfo( + platform="darwin", + cpu_brand="Apple M4 Max", + cpu_count=16, + ram_gb=128.0, + gpu=GpuInfo(vendor="apple", name="Apple M4 Max", vram_gb=128.0, count=1), + ) + + +@pytest.fixture +def mock_docker_client() -> Any: + """Factory for a mocked docker.DockerClient. + + Returns a MagicMock configured with the most common attribute paths so + individual tests only need to override what they care about. + """ + client = MagicMock() + client.ping.return_value = True + client.version.return_value = {"Version": "24.0.7"} + client.images.list.return_value = [] + client.images.get.side_effect = Exception("not found") + return client + + +@pytest.fixture +def sample_sidecar_payload() -> dict: + return { + "provider": "vllm-pearl", + "vllm_endpoint": "http://127.0.0.1:8000/v1", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "gateway_url": "http://127.0.0.1:8337", + "gateway_metrics_url": "http://127.0.0.1:8339", + "container_id": "abc123def456", + "wallet_address": "prl1qexampleaddress", + "started_at": 1714867200, + } + + +@pytest.fixture +def sidecar_path(tmp_path: Path) -> Path: + return tmp_path / "mining.json" + + +@pytest.fixture +def written_sidecar(sidecar_path: Path, sample_sidecar_payload: dict) -> Path: + sidecar_path.write_text(json.dumps(sample_sidecar_payload)) + return sidecar_path diff --git a/tests/mining/fixtures/README.md b/tests/mining/fixtures/README.md new file mode 100644 index 00000000..c79f5421 --- /dev/null +++ b/tests/mining/fixtures/README.md @@ -0,0 +1,17 @@ +# Mining test fixtures + +`gateway_metrics_sample.txt` is the expected Pearl gateway Prometheus shape. +The live 2026-05 H100 validation found that Pearl's current Docker miner exposes +vLLM metrics on `:8000/metrics`, while the gateway listens on `/tmp/pearlgw.sock` +and does not expose `:8339/metrics`. Keep this fixture as the target contract if +Pearl adds gateway metrics later. Re-capture by: + +1. Run the Pearl Docker image on an H100 host per the spec §7.4 launch shape. +2. `curl http://127.0.0.1:8339/metrics > gateway_metrics_sample.txt` once + the gateway exposes metrics and at least 10 shares have been submitted. +3. Strip any cardinality bombs (per-prompt or per-block-time histograms) + that bloat the file. +4. Commit, citing the Pearl commit/tag the capture was taken against. + +If Pearl renames metrics, update `mining/_metrics.py::PROM_*` constants and +re-capture. diff --git a/tests/mining/fixtures/config_minimal.toml b/tests/mining/fixtures/config_minimal.toml new file mode 100644 index 00000000..8505ea67 --- /dev/null +++ b/tests/mining/fixtures/config_minimal.toml @@ -0,0 +1,12 @@ +[mining] +provider = "vllm-pearl" +wallet_address = "prl1qexampleaddress" +submit_target = "solo" +fee_bps = 0 +fee_payout_address = "" + +[mining.extra] +model = "pearl-ai/Llama-3.3-70B-Instruct-pearl" +pearld_rpc_url = "http://localhost:44107" +pearld_rpc_user = "rpcuser" +pearld_rpc_password_env = "PEARLD_RPC_PASSWORD" diff --git a/tests/mining/fixtures/config_pool_v2.toml b/tests/mining/fixtures/config_pool_v2.toml new file mode 100644 index 00000000..2d0bf43f --- /dev/null +++ b/tests/mining/fixtures/config_pool_v2.toml @@ -0,0 +1,10 @@ +[mining] +provider = "vllm-pearl" +wallet_address = "prl1qexampleaddress" +submit_target = "pool:https://pool.openjarvis.ai/submit" + +[mining.extra] +model = "pearl-ai/Llama-3.3-70B-Instruct-pearl" +pearld_rpc_url = "http://localhost:44107" +pearld_rpc_user = "rpcuser" +pearld_rpc_password_env = "PEARLD_RPC_PASSWORD" diff --git a/tests/mining/fixtures/gateway_metrics_sample.txt b/tests/mining/fixtures/gateway_metrics_sample.txt new file mode 100644 index 00000000..fbaa5807 --- /dev/null +++ b/tests/mining/fixtures/gateway_metrics_sample.txt @@ -0,0 +1,18 @@ +# HELP pearl_gateway_shares_submitted_total Total mining shares submitted. +# TYPE pearl_gateway_shares_submitted_total counter +pearl_gateway_shares_submitted_total 12345 +# HELP pearl_gateway_shares_accepted_total Total mining shares accepted by pearld. +# TYPE pearl_gateway_shares_accepted_total counter +pearl_gateway_shares_accepted_total 12300 +# HELP pearl_gateway_blocks_found_total Total blocks found. +# TYPE pearl_gateway_blocks_found_total counter +pearl_gateway_blocks_found_total 7 +# HELP pearl_gateway_last_share_timestamp Unix timestamp of last share submission. +# TYPE pearl_gateway_last_share_timestamp gauge +pearl_gateway_last_share_timestamp 1714867500 +# HELP pearl_gateway_errors_total Total errors observed by the gateway. +# TYPE pearl_gateway_errors_total counter +pearl_gateway_errors_total 0 +# HELP process_start_time_seconds Process start time (Unix seconds). +# TYPE process_start_time_seconds gauge +process_start_time_seconds 1714865000 diff --git a/tests/mining/test_apple_mps_pearl.py b/tests/mining/test_apple_mps_pearl.py new file mode 100644 index 00000000..047323df --- /dev/null +++ b/tests/mining/test_apple_mps_pearl.py @@ -0,0 +1,207 @@ +"""Tests for openjarvis.mining.apple_mps_pearl.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import MagicMock, patch + +import pytest + +_AVAIL = "openjarvis.mining._install.pearl_packages_available" + + +@pytest.fixture +def mps_config(): + from openjarvis.mining._stubs import MiningConfig, SoloTarget + + return MiningConfig( + provider="apple-mps-pearl", + wallet_address="prl1qtest", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + fee_bps=0, + fee_payout_address=None, + extra={ + "gateway_host": "127.0.0.1", + "gateway_port": 18337, + "metrics_port": 19109, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "TEST_PEARLD_PASSWORD", + "m": 128, + "n": 128, + "k": 1024, + "rank": 64, + }, + ) + + +def test_detect_requires_macos(hopper_hw): + from openjarvis.mining.apple_mps_pearl import AppleMpsPearlProvider + + with patch(_AVAIL, return_value=True): + cap = AppleMpsPearlProvider.detect(hopper_hw, engine_id="mlx", model="any") + assert cap.supported is False + assert "macos" in cap.reason.lower() + + +def test_detect_requires_apple_gpu(): + from openjarvis.core.config import HardwareInfo + from openjarvis.mining.apple_mps_pearl import AppleMpsPearlProvider + + hw = HardwareInfo( + platform="darwin", cpu_brand="Apple", cpu_count=12, ram_gb=64.0, gpu=None + ) + with patch(_AVAIL, return_value=True): + cap = AppleMpsPearlProvider.detect(hw, engine_id="mlx", model="any") + assert cap.supported is False + assert "apple gpu" in cap.reason.lower() + + +def test_detect_requires_pearl_packages(apple_hw): + from openjarvis.mining.apple_mps_pearl import AppleMpsPearlProvider + + with patch(_AVAIL, return_value=False): + cap = AppleMpsPearlProvider.detect(apple_hw, engine_id="mlx", model="any") + assert cap.supported is False + assert "mining-pearl-cpu" in cap.reason + + +def test_detect_supported_when_torch_mps_available(apple_hw): + from openjarvis.mining.apple_mps_pearl import AppleMpsPearlProvider + + with ( + patch("openjarvis.mining._install.pearl_packages_available", return_value=True), + patch( + "openjarvis.mining.apple_mps_pearl._torch_mps_available", + return_value=(True, None), + ), + ): + cap = AppleMpsPearlProvider.detect(apple_hw, engine_id="mlx", model="any") + assert cap.supported is True + + +@pytest.mark.parametrize("chip", ["M2 Max", "M3 Max", "M4 Max", "M5 Max"]) +def test_detect_supports_apple_silicon_gpu_generations(chip): + from openjarvis.core.config import GpuInfo, HardwareInfo + from openjarvis.mining.apple_mps_pearl import AppleMpsPearlProvider + + hw = HardwareInfo( + platform="darwin", + cpu_brand=f"Apple {chip}", + cpu_count=16, + ram_gb=128.0, + gpu=GpuInfo(vendor="apple", name=chip, vram_gb=128.0, count=1), + ) + with ( + patch(_AVAIL, return_value=True), + patch( + "openjarvis.mining.apple_mps_pearl._torch_mps_available", + return_value=(True, None), + ), + ): + cap = AppleMpsPearlProvider.detect(hw, engine_id="mlx", model="any") + + assert cap.supported is True + + +def test_start_uses_mps_miner_module(mps_config, tmp_path, monkeypatch): + from openjarvis.mining.apple_mps_pearl import AppleMpsPearlProvider + + sidecar = tmp_path / "mining.json" + log_dir = tmp_path / "logs" + monkeypatch.setattr("openjarvis.mining.cpu_pearl._sidecar_path", lambda: sidecar) + monkeypatch.setattr("openjarvis.mining.cpu_pearl._log_dir", lambda: log_dir) + monkeypatch.setattr("openjarvis.mining.apple_mps_pearl._log_dir", lambda: log_dir) + monkeypatch.setenv("TEST_PEARLD_PASSWORD", "secret") + fake_launcher = MagicMock() + fake_launcher.is_running.return_value = True + fake_launcher.pids.return_value = (11111, 22222) + + target = "openjarvis.mining.apple_mps_pearl.PearlSubprocessLauncher" + with patch(target, return_value=fake_launcher) as launcher_cls: + provider = AppleMpsPearlProvider() + asyncio.run(provider.start(mps_config)) + + assert launcher_cls.call_args.kwargs["provider_id"] == "apple-mps-pearl" + assert ( + launcher_cls.call_args.kwargs["miner_module"] + == "openjarvis.mining._mps_miner_loop_main" + ) + payload = json.loads(sidecar.read_text()) + assert payload["provider"] == "apple-mps-pearl" + + +@pytest.mark.slow +def test_mps_noisy_gemm_plain_proof_verifies_when_mps_available(): + """Cryptographic smoke test for the MPS mining core.""" + torch = pytest.importorskip("torch") + pearl_mining = pytest.importorskip("pearl_mining") + pytest.importorskip("miner_base") + if not torch.backends.mps.is_available(): + pytest.skip("MPS is not available on this host") + + from miner_base.block_submission import create_proof + from miner_base.commitment_hash import CommitmentHasher + from miner_base.noise_generation import NoiseGenerator + from miner_base.noisy_gemm import POW_TARGET_EASIEST, NoisyGemm + + from openjarvis.mining._mps_miner_loop_main import ( + MpsNoisyGemmAdapter, + _mining_config_for_shape, + ) + + header = pearl_mining.IncompleteBlockHeader( + version=1, + prev_block=bytes(32), + merkle_root=bytes([1]) * 32, + timestamp=1, + nbits=0x207FFFFF, + ) + header_bytes = header.to_bytes() + # Match the provider defaults so this catches regressions in the shipped + # Apple-MPS launch shape. + m = n = 128 + k = 1024 + rank = 64 + mining_config = _mining_config_for_shape(pearl_mining, k=k, rank=rank) + + a_cpu = torch.randint(-64, 64, (m, k), dtype=torch.int8) + b_cpu = torch.randint(-64, 64, (k, n), dtype=torch.int8) + commitment_hash = CommitmentHasher.commitment_hash( + a_cpu, + b_cpu, + header_bytes, + mining_config, + ) + noise = NoiseGenerator(noise_rank=rank, noise_range=128).generate_noise_metrices( + commitment_hash.noise_seed_A, + commitment_hash.noise_seed_B, + m, + k, + n, + ) + device = torch.device("mps") + gemm = MpsNoisyGemmAdapter.build( + NoisyGemm, + noise_range=128, + noise_rank=rank, + hash_tile_h=16, + hash_tile_w=16, + matmul_tile_h=64, + matmul_tile_w=64, + ) + result, found = gemm.noisy_gemm( + a_cpu.to(device), + b_cpu.to(device), + *(x.to(device) for x in noise), + commitment_hash=commitment_hash, + pow_target=POW_TARGET_EASIEST, + ) + + expected = torch.matmul(a_cpu.to(torch.int32), b_cpu.to(torch.int32)) + assert torch.equal(result.cpu(), expected) + assert found is True + plain_proof = create_proof(gemm.get_opened_block_info(), header_bytes) + ok, msg = pearl_mining.verify_plain_proof(header, plain_proof) + assert ok, msg diff --git a/tests/mining/test_cli.py b/tests/mining/test_cli.py new file mode 100644 index 00000000..028c4cd6 --- /dev/null +++ b/tests/mining/test_cli.py @@ -0,0 +1,190 @@ +"""CLI smoke tests via Click CliRunner.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + + +def test_mine_doctor_prints_capability_matrix(hopper_hw): + from openjarvis.cli.mine_cmd import mine + + runner = CliRunner() + with ( + patch("openjarvis.cli.mine_cmd._detect_hardware", return_value=hopper_hw), + patch( + "openjarvis.cli.mine_cmd.check_docker_available", + return_value=(True, "running 24.0.7"), + ), + patch( + "openjarvis.cli.mine_cmd.check_disk_free", + return_value=(True, "300 GB free"), + ), + patch( + "openjarvis.cli.mine_cmd.check_pearld_reachable", + return_value=(True, "block height 442107 (synced)"), + ), + ): + result = runner.invoke(mine, ["doctor"]) + + assert result.exit_code == 0, result.output + out = result.output.lower() + assert "hardware" in out + assert "docker" in out + assert "pearl" in out + assert "vllm-pearl" in out + assert "supported" in out + + +def test_mine_doctor_flags_unsupported_hardware(ada_hw): + from openjarvis.cli.mine_cmd import mine + + runner = CliRunner() + with ( + patch("openjarvis.cli.mine_cmd._detect_hardware", return_value=ada_hw), + patch( + "openjarvis.cli.mine_cmd.check_docker_available", + return_value=(True, "ok"), + ), + patch( + "openjarvis.cli.mine_cmd.check_disk_free", + return_value=(True, "300 GB free"), + ), + patch( + "openjarvis.cli.mine_cmd.check_pearld_reachable", + return_value=(False, "connection refused"), + ), + ): + result = runner.invoke(mine, ["doctor"]) + + assert result.exit_code == 0 + assert "FAIL" in result.output + assert "UNSUPPORTED" in result.output + + +def _mining_config(): + from openjarvis.mining._stubs import MiningConfig, SoloTarget + + return MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qexampleaddress", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + ) + + +def test_mine_start_runs_provider_start(): + from openjarvis.cli.mine_cmd import mine + + runner = CliRunner() + fake_provider_class = MagicMock() + with ( + patch("openjarvis.cli.mine_cmd.MinerRegistry") as reg, + patch("openjarvis.cli.mine_cmd.load_config") as load, + patch("openjarvis.cli.mine_cmd.asyncio.run") as arun, + ): + load.return_value = MagicMock(mining=_mining_config()) + reg.get.return_value = fake_provider_class + result = runner.invoke(mine, ["start"]) + + assert result.exit_code == 0, result.output + arun.assert_called_once() + fake_provider_class.assert_called_once() + + +def test_mine_stop_calls_provider_stop(): + from openjarvis.cli.mine_cmd import mine + + runner = CliRunner() + fake_provider_class = MagicMock() + with ( + patch("openjarvis.cli.mine_cmd.MinerRegistry") as reg, + patch("openjarvis.cli.mine_cmd.load_config") as load, + patch("openjarvis.cli.mine_cmd.asyncio.run") as arun, + ): + load.return_value = MagicMock(mining=_mining_config()) + reg.get.return_value = fake_provider_class + result = runner.invoke(mine, ["stop"]) + + assert result.exit_code == 0, result.output + arun.assert_called_once() + fake_provider_class.assert_called_once() + + +def test_mine_start_errors_when_no_mining_config(): + from openjarvis.cli.mine_cmd import mine + + runner = CliRunner() + with patch("openjarvis.cli.mine_cmd.load_config") as load: + load.return_value = MagicMock(mining=None) + result = runner.invoke(mine, ["start"]) + + assert result.exit_code != 0 + assert "init" in result.output.lower() or "no [mining]" in result.output.lower() + + +def test_mine_status_renders_stats(): + from openjarvis.cli.mine_cmd import mine + from openjarvis.mining._stubs import MiningStats + + runner = CliRunner() + fake_provider = MagicMock() + fake_provider.stats.return_value = MiningStats( + provider_id="vllm-pearl", + shares_submitted=100, + shares_accepted=99, + blocks_found=2, + ) + fake_provider_class = MagicMock(return_value=fake_provider) + with ( + patch("openjarvis.cli.mine_cmd.MinerRegistry") as reg, + patch("openjarvis.cli.mine_cmd.load_config") as load, + ): + load.return_value = MagicMock(mining=_mining_config()) + reg.get.return_value = fake_provider_class + result = runner.invoke(mine, ["status"]) + + assert result.exit_code == 0, result.output + assert "100" in result.output + assert "99" in result.output + + +def test_mine_attach_writes_sidecar(tmp_path, monkeypatch): + from openjarvis.cli.mine_cmd import mine + + runner = CliRunner() + sidecar = tmp_path / "mining.json" + monkeypatch.setattr("openjarvis.cli.mine_cmd.SIDECAR_PATH", sidecar) + + result = runner.invoke( + mine, + [ + "attach", + "--vllm-endpoint", + "http://127.0.0.1:8000/v1", + "--gateway-url", + "http://127.0.0.1:8337", + "--gateway-metrics-url", + "http://127.0.0.1:8339", + "--model", + "pearl-ai/Llama-3.3-70B-Instruct-pearl", + ], + ) + + assert result.exit_code == 0, result.output + assert sidecar.exists() + + +def test_mine_logs_streams_container_output(): + from openjarvis.cli.mine_cmd import mine + + runner = CliRunner() + fake_container = MagicMock() + fake_container.logs.return_value = b"log line 1\nlog line 2\n" + fake_client = MagicMock() + fake_client.containers.get.return_value = fake_container + with patch("openjarvis.cli.mine_cmd._docker_from_env", return_value=fake_client): + result = runner.invoke(mine, ["logs", "--tail", "100"]) + + assert result.exit_code == 0, result.output + assert "log line 1" in result.output diff --git a/tests/mining/test_collector.py b/tests/mining/test_collector.py new file mode 100644 index 00000000..bd3aeb12 --- /dev/null +++ b/tests/mining/test_collector.py @@ -0,0 +1,73 @@ +"""Tests for MiningTelemetryCollector, shipped in v1 but unwired.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.mark.asyncio +async def test_collector_collect_once_returns_stats(written_sidecar): + from openjarvis.mining._collector import MiningTelemetryCollector + + sample = ( + "pearl_gateway_shares_submitted_total 50\n" + "pearl_gateway_shares_accepted_total 49\n" + ) + with patch("openjarvis.mining._collector.httpx.get") as get: + get.return_value.status_code = 200 + get.return_value.text = sample + store = MagicMock() + collector = MiningTelemetryCollector( + sidecar_path=written_sidecar, + telemetry_store=store, + interval_s=0.05, + ) + + stats = await collector.collect_once() + + assert stats.shares_submitted == 50 + assert stats.shares_accepted == 49 + get.assert_called_once_with("http://127.0.0.1:8339/metrics", timeout=5.0) + + +@pytest.mark.asyncio +async def test_collector_run_loop_writes_to_store_then_stops(written_sidecar): + from openjarvis.mining._collector import MiningTelemetryCollector + + sample = "pearl_gateway_shares_submitted_total 1\n" + with patch("openjarvis.mining._collector.httpx.get") as get: + get.return_value.status_code = 200 + get.return_value.text = sample + store = MagicMock() + collector = MiningTelemetryCollector( + sidecar_path=written_sidecar, + telemetry_store=store, + interval_s=0.01, + ) + + task = asyncio.create_task(collector.run()) + await asyncio.sleep(0.05) + collector.stop() + await asyncio.wait_for(task, timeout=1.0) + + assert store.record_mining_stats.call_count >= 1 + + +@pytest.mark.asyncio +async def test_collector_handles_gateway_errors_gracefully(written_sidecar): + from openjarvis.mining._collector import MiningTelemetryCollector + + with patch("openjarvis.mining._collector.httpx.get") as get: + get.side_effect = ConnectionError("nope") + store = MagicMock() + collector = MiningTelemetryCollector( + sidecar_path=written_sidecar, + telemetry_store=store, + ) + + stats = await collector.collect_once() + + assert stats.last_error == "nope" diff --git a/tests/mining/test_cpu_pearl.py b/tests/mining/test_cpu_pearl.py new file mode 100644 index 00000000..69e7ce25 --- /dev/null +++ b/tests/mining/test_cpu_pearl.py @@ -0,0 +1,289 @@ +"""Tests for openjarvis.mining.cpu_pearl.CpuPearlProvider.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import MagicMock, patch + +import pytest + +_AVAIL = "openjarvis.mining._install.pearl_packages_available" + + +@pytest.fixture +def darwin_apple_hw(): + """A HardwareInfo describing an Apple Silicon Mac.""" + from openjarvis.core.config import GpuInfo, HardwareInfo + + return HardwareInfo( + platform="darwin", + cpu_brand="Apple M2 Max", + cpu_count=12, + ram_gb=96.0, + gpu=GpuInfo(vendor="apple", name="M2 Max", vram_gb=96.0, count=1), + ) + + +@pytest.fixture +def linux_nvidia_hw(): + """A HardwareInfo describing an H100 box.""" + from openjarvis.core.config import GpuInfo, HardwareInfo + + return HardwareInfo( + platform="linux", + cpu_brand="Intel Xeon", + cpu_count=64, + ram_gb=512.0, + gpu=GpuInfo( + vendor="nvidia", + name="H100", + vram_gb=80.0, + compute_capability="9.0a", + count=1, + ), + ) + + +@pytest.fixture +def windows_hw(): + """A HardwareInfo describing a Windows host (unsupported in v1).""" + from openjarvis.core.config import HardwareInfo + + return HardwareInfo( + platform="win32", cpu_brand="x86_64", cpu_count=16, ram_gb=64.0, gpu=None + ) + + +def test_detect_supported_on_apple_silicon_when_packages_present(darwin_apple_hw): + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + with patch(_AVAIL, return_value=True): + cap = CpuPearlProvider.detect(darwin_apple_hw, engine_id="ollama", model="any") + assert cap.supported is True + assert cap.reason is None + + +def test_detect_supported_on_linux_too(linux_nvidia_hw): + """v1 cpu-pearl is engine-independent and works on linux too.""" + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + with patch(_AVAIL, return_value=True): + cap = CpuPearlProvider.detect( + linux_nvidia_hw, engine_id="anything", model="any" + ) + assert cap.supported is True + + +def test_detect_unsupported_on_windows(windows_hw): + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + with patch(_AVAIL, return_value=True): + cap = CpuPearlProvider.detect(windows_hw, engine_id="any", model="any") + assert cap.supported is False + assert "win32" in cap.reason.lower() or "windows" in cap.reason.lower() + + +def test_detect_unsupported_when_pearl_not_installed(darwin_apple_hw): + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + with patch(_AVAIL, return_value=False): + cap = CpuPearlProvider.detect(darwin_apple_hw, engine_id="any", model="any") + assert cap.supported is False + assert "mining-pearl-cpu" in cap.reason + + +def test_detect_engine_independent(darwin_apple_hw): + """v1 detect() does NOT inspect engine_id — supported regardless of engine.""" + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + with patch(_AVAIL, return_value=True): + for engine in ("ollama", "llamacpp", "vllm", "mlx", "anthropic-cloud", ""): + cap = CpuPearlProvider.detect( + darwin_apple_hw, engine_id=engine, model="any" + ) + assert cap.supported is True, f"engine_id={engine!r} should be supported" + + +# --------------------------------------------------------------------------- +# Task 9: lifecycle tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def cpu_pearl_config(): + """A minimal MiningConfig for cpu-pearl.""" + from openjarvis.mining._stubs import MiningConfig, SoloTarget + + return MiningConfig( + provider="cpu-pearl", + wallet_address="prl1qtest", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + fee_bps=0, + fee_payout_address=None, + extra={ + "gateway_host": "127.0.0.1", + "gateway_port": 18337, + "metrics_port": 19109, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "TEST_PEARLD_PASSWORD", + "m": 256, + "n": 128, + "k": 1024, + "rank": 32, + }, + ) + + +def test_start_writes_sidecar_and_is_running(cpu_pearl_config, tmp_path, monkeypatch): + """After start(), is_running() returns True and sidecar JSON is on disk.""" + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + sidecar = tmp_path / "mining.json" + log_dir = tmp_path / "logs" + monkeypatch.setattr("openjarvis.mining.cpu_pearl._sidecar_path", lambda: sidecar) + monkeypatch.setattr("openjarvis.mining.cpu_pearl._log_dir", lambda: log_dir) + monkeypatch.setenv("TEST_PEARLD_PASSWORD", "secret") + + fake_launcher = MagicMock() + fake_launcher.is_running.return_value = True + fake_launcher.pids.return_value = (11111, 22222) + + _target = "openjarvis.mining.cpu_pearl.PearlSubprocessLauncher" + with patch(_target, return_value=fake_launcher): + provider = CpuPearlProvider() + asyncio.run(provider.start(cpu_pearl_config)) + assert provider.is_running() is True + + assert sidecar.exists() + payload = json.loads(sidecar.read_text()) + assert payload["provider"] == "cpu-pearl" + assert payload["wallet_address"] == "prl1qtest" + assert payload["gateway_pid"] == 11111 + assert payload["miner_loop_pid"] == 22222 + # Secret must NOT be in sidecar + assert "secret" not in sidecar.read_text() + + +def test_stop_terminates_and_removes_sidecar(cpu_pearl_config, tmp_path, monkeypatch): + """After stop(), is_running() returns False, sidecar is removed.""" + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + sidecar = tmp_path / "mining.json" + log_dir = tmp_path / "logs" + monkeypatch.setattr("openjarvis.mining.cpu_pearl._sidecar_path", lambda: sidecar) + monkeypatch.setattr("openjarvis.mining.cpu_pearl._log_dir", lambda: log_dir) + monkeypatch.setenv("TEST_PEARLD_PASSWORD", "secret") + + fake_launcher = MagicMock() + fake_launcher.is_running.side_effect = [True, False] + fake_launcher.pids.return_value = (11111, 22222) + + _target = "openjarvis.mining.cpu_pearl.PearlSubprocessLauncher" + with patch(_target, return_value=fake_launcher): + provider = CpuPearlProvider() + asyncio.run(provider.start(cpu_pearl_config)) + asyncio.run(provider.stop()) + fake_launcher.stop.assert_called_once() + assert provider.is_running() is False + assert not sidecar.exists() + + +def test_stats_returns_zero_stats_when_not_running(): + """stats() before start() returns a MiningStats with provider_id and zeros.""" + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + provider = CpuPearlProvider() + stats = provider.stats() + assert stats.provider_id == "cpu-pearl" + assert stats.shares_submitted == 0 + assert stats.shares_accepted == 0 + + +def test_parse_gateway_metrics_extracts_counters(): + """The Prometheus parser extracts the metric names we expect.""" + from openjarvis.mining.cpu_pearl import _parse_gateway_metrics + + sample = """\ +# HELP pearl_gateway_shares_submitted_total Total shares submitted. +# TYPE pearl_gateway_shares_submitted_total counter +pearl_gateway_shares_submitted_total 42 +# HELP pearl_gateway_shares_accepted_total Total shares accepted. +# TYPE pearl_gateway_shares_accepted_total counter +pearl_gateway_shares_accepted_total 40 +pearl_gateway_blocks_found_total 1 +""" + stats = _parse_gateway_metrics(sample, provider_id="cpu-pearl") + assert stats.provider_id == "cpu-pearl" + assert stats.shares_submitted == 42 + assert stats.shares_accepted == 40 + assert stats.blocks_found == 1 + + +@pytest.mark.live +@pytest.mark.slow +def test_provider_runs_end_to_end_on_this_host(tmp_path, monkeypatch): + """Live test: start provider, run for ~30 s, assert is_running was True. + + Requires that py-pearl-mining, miner-base, and pearl-gateway are installed + in the venv (e.g., via the build-from-pin path) AND that pearld is running + locally. Otherwise the test is skipped via importorskip. + + Use a non-default port pair (18337/19109) so it doesn't collide with a real + cpu-pearl session. + """ + pytest.importorskip("pearl_mining") + pytest.importorskip("pearl_gateway") + + from openjarvis.mining._stubs import MiningConfig, SoloTarget + from openjarvis.mining.cpu_pearl import CpuPearlProvider + + sidecar = tmp_path / "mining.json" + log_dir = tmp_path / "logs" + monkeypatch.setattr("openjarvis.mining.cpu_pearl._sidecar_path", lambda: sidecar) + monkeypatch.setattr("openjarvis.mining.cpu_pearl._log_dir", lambda: log_dir) + monkeypatch.setenv("TEST_PEARLD_PASSWORD", "test") + + cfg = MiningConfig( + provider="cpu-pearl", + wallet_address="prl1q" + "0" * 32, + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + fee_bps=0, + fee_payout_address=None, + extra={ + "gateway_host": "127.0.0.1", + "gateway_port": 18337, + "metrics_port": 19109, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "TEST_PEARLD_PASSWORD", + }, + ) + + provider = CpuPearlProvider() + asyncio.run(provider.start(cfg)) + still_running_at_end = False + try: + import time as _time + + deadline = _time.monotonic() + 30 + saw_running = False + while _time.monotonic() < deadline: + if provider.is_running(): + saw_running = True + else: + if saw_running: + break + _time.sleep(1.0) + still_running_at_end = provider.is_running() + finally: + asyncio.run(provider.stop()) + + if log_dir.exists(): + for log_file in log_dir.glob("*.log"): + print(f"--- {log_file.name} ---") + print(log_file.read_text()[:2000]) + + assert saw_running, "provider never reported is_running" + assert still_running_at_end, "provider exited before the live smoke window ended" diff --git a/tests/mining/test_discovery.py b/tests/mining/test_discovery.py new file mode 100644 index 00000000..8236eebc --- /dev/null +++ b/tests/mining/test_discovery.py @@ -0,0 +1,245 @@ +"""Tests for mining/_discovery.py — capability detection matrix.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + + +def test_detect_supported_on_h100(hopper_hw): + from openjarvis.mining._discovery import detect_for_engine_model + + cap = detect_for_engine_model( + hw=hopper_hw, + engine_id="vllm", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + provider_id="vllm-pearl", + ) + assert cap.supported is True + assert cap.reason is None + + +def test_detect_unsupported_on_ada_4090(ada_hw): + from openjarvis.mining._discovery import detect_for_engine_model + + cap = detect_for_engine_model( + hw=ada_hw, + engine_id="vllm", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "sm90" in cap.reason.lower() or "compute_capability" in cap.reason.lower() + + +def test_detect_unsupported_on_apple_engine(apple_hw): + """Engine check rejects mlx before reaching the GPU vendor branch.""" + from openjarvis.mining._discovery import detect_for_engine_model + + cap = detect_for_engine_model( + hw=apple_hw, + engine_id="mlx", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "mlx" in cap.reason.lower() or "engine" in cap.reason.lower() + + +def test_detect_unsupported_on_apple_gpu_vendor(apple_hw): + """Apple Silicon GPU is rejected by the vendor branch (Spec B territory).""" + from openjarvis.mining._discovery import detect_for_engine_model + + cap = detect_for_engine_model( + hw=apple_hw, + engine_id="vllm", # bypass the engine check + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "nvidia" in cap.reason.lower() or "hopper" in cap.reason.lower() + + +def test_detect_unsupported_for_non_vllm_engine(hopper_hw): + from openjarvis.mining._discovery import detect_for_engine_model + + cap = detect_for_engine_model( + hw=hopper_hw, + engine_id="ollama", + model="qwen3:8b", + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "vllm" in cap.reason.lower() or "engine" in cap.reason.lower() + + +def test_detect_unsupported_for_non_pearl_model(hopper_hw): + from openjarvis.mining._discovery import detect_for_engine_model + + cap = detect_for_engine_model( + hw=hopper_hw, + engine_id="vllm", + model="meta-llama/Llama-3.3-70B-Instruct", # NOT the -pearl variant + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "pearl" in cap.reason.lower() + + +def test_detect_raw_planned_model_points_to_pearl_variant(hopper_hw): + from openjarvis.mining._discovery import detect_for_engine_model + + cap = detect_for_engine_model( + hw=hopper_hw, + engine_id="vllm", + model="Qwen/Qwen3.5-9B", + provider_id="vllm-pearl", + ) + + assert cap.supported is False + assert "pearl-ai/Qwen3.5-9B-pearl" in cap.reason + + +def test_detect_planned_pearl_model_is_not_enabled_yet(hopper_hw): + from openjarvis.mining._discovery import detect_for_engine_model + + cap = detect_for_engine_model( + hw=hopper_hw, + engine_id="vllm", + model="pearl-ai/Qwen3.5-9B-pearl", + provider_id="vllm-pearl", + ) + + assert cap.supported is False + assert "planned" in cap.reason + assert "validation" in cap.reason + + +def test_detect_unsupported_for_low_vram(): + from openjarvis.core.config import GpuInfo, HardwareInfo + from openjarvis.mining._discovery import detect_for_engine_model + + hw = HardwareInfo( + platform="linux", + gpu=GpuInfo( + vendor="nvidia", + name="NVIDIA H100 PCIe-40GB", + vram_gb=40.0, # below 70 GB threshold + compute_capability="9.0", + count=1, + ), + ) + cap = detect_for_engine_model( + hw=hw, + engine_id="vllm", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + provider_id="vllm-pearl", + ) + assert cap.supported is False + assert "vram" in cap.reason.lower() or "memory" in cap.reason.lower() + + +def test_check_docker_available_true(): + from openjarvis.mining._discovery import check_docker_available + + with patch("openjarvis.mining._discovery._docker_client") as fake: + fake.return_value.ping.return_value = True + fake.return_value.version.return_value = {"Version": "24.0.7"} + ok, info = check_docker_available() + assert ok is True + assert "24.0.7" in info + + +def test_check_docker_available_false_when_daemon_down(): + from openjarvis.mining._discovery import check_docker_available + + with patch("openjarvis.mining._discovery._docker_client") as fake: + fake.side_effect = Exception("Cannot connect to the Docker daemon") + ok, info = check_docker_available() + assert ok is False + assert "daemon" in info.lower() or "connect" in info.lower() + + +def test_check_docker_available_false_when_sdk_missing(): + from openjarvis.mining._discovery import check_docker_available + + with patch("openjarvis.mining._discovery._docker_client") as fake: + fake.side_effect = RuntimeError( + "Docker SDK not installed; install with `uv sync --extra mining-pearl-vllm`" + ) + ok, info = check_docker_available() + assert ok is False + assert "mining-pearl-vllm" in info + + +def test_check_disk_free_passes(tmp_path): + from openjarvis.mining._discovery import check_disk_free + + with patch("openjarvis.mining._discovery.shutil.disk_usage") as du: + # 500 GB free + du.return_value = MagicMock( + total=1_000_000_000_000, + used=500_000_000_000, + free=500_000_000_000, + ) + ok, info = check_disk_free(tmp_path) + assert ok is True + + +def test_check_disk_free_fails_below_threshold(tmp_path): + from openjarvis.mining._discovery import check_disk_free + + with patch("openjarvis.mining._discovery.shutil.disk_usage") as du: + du.return_value = MagicMock( + total=1_000_000_000_000, + used=950_000_000_000, + free=50_000_000_000, + ) + ok, info = check_disk_free(tmp_path) + assert ok is False + + +def test_check_pearld_reachable_true(): + from openjarvis.mining._discovery import check_pearld_reachable + + with patch("openjarvis.mining._discovery.httpx.post") as post: + post.return_value.status_code = 200 + post.return_value.json.return_value = { + "result": {"blocks": 442107, "headers": 442107} + } + ok, info = check_pearld_reachable("http://localhost:44107", "user", "pass") + assert ok is True + assert "442107" in info + + +def test_check_pearld_reachable_false_on_connection_error(): + import httpx + + from openjarvis.mining._discovery import check_pearld_reachable + + with patch("openjarvis.mining._discovery.httpx.post") as post: + post.side_effect = httpx.ConnectError("connection refused") + ok, info = check_pearld_reachable("http://localhost:44107", "user", "pass") + assert ok is False + + +def test_check_wallet_address_format_valid(): + from openjarvis.mining._discovery import check_wallet_address_format + + ok, info = check_wallet_address_format("prl1qexampleaddress0123456789") + assert ok is True + + +def test_check_wallet_address_format_valid_prl1p(): + from openjarvis.mining._discovery import check_wallet_address_format + + ok, info = check_wallet_address_format( + "prl1pkf5s56dgm6jpg4z9z9qv5wua4jgs3h8q98rfh3gsqxp60eagmruqdnr3dp" + ) + assert ok is True + + +def test_check_wallet_address_format_invalid(): + from openjarvis.mining._discovery import check_wallet_address_format + + ok, info = check_wallet_address_format("not-a-pearl-address") + assert ok is False diff --git a/tests/mining/test_docker.py b/tests/mining/test_docker.py new file mode 100644 index 00000000..69e0dc77 --- /dev/null +++ b/tests/mining/test_docker.py @@ -0,0 +1,309 @@ +"""Tests for mining/_docker.py — Docker SDK orchestration via mocks.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +def test_ensure_image_already_local(): + from openjarvis.mining._docker import PearlDockerLauncher + + fake = MagicMock() + fake.images.get.return_value = MagicMock( + id="sha256:abc", tags=["openjarvis/pearl-miner:main"] + ) + launcher = PearlDockerLauncher(client=fake) + out = launcher.ensure_image("openjarvis/pearl-miner:main") + assert out == "openjarvis/pearl-miner:main" + fake.images.get.assert_called_once_with("openjarvis/pearl-miner:main") + fake.images.pull.assert_not_called() + + +def test_ensure_image_pulls_if_published(): + from openjarvis.mining._docker import ( + APIError, + ImageNotFound, + NotFound, + PearlDockerLauncher, + ) + + fake = MagicMock() + fake.images.get.side_effect = ImageNotFound("nope") + fake.images.pull.return_value = MagicMock(id="sha256:def") + launcher = PearlDockerLauncher(client=fake) + with patch( + "openjarvis.mining._docker._docker_error_types", + return_value=(ImageNotFound, NotFound, APIError), + ): + out = launcher.ensure_image("registry.example/pearl-miner:1.0") + assert out == "registry.example/pearl-miner:1.0" + fake.images.pull.assert_called_once_with("registry.example/pearl-miner:1.0") + + +def test_ensure_image_falls_back_to_build_for_default_tag(): + from openjarvis.mining._constants import PEARL_IMAGE_TAG + from openjarvis.mining._docker import ( + APIError, + ImageNotFound, + NotFound, + PearlDockerLauncher, + ) + + fake = MagicMock() + fake.images.get.side_effect = ImageNotFound("nope") + fake.images.pull.side_effect = NotFound("registry refused") + launcher = PearlDockerLauncher(client=fake) + with ( + patch.object(launcher, "_clone_pearl_repo") as clone, + patch.object(launcher, "_docker_build") as build, + patch( + "openjarvis.mining._docker._docker_error_types", + return_value=(ImageNotFound, NotFound, APIError), + ), + ): + clone.return_value = "/tmp/pearl-cache" + build.return_value = PEARL_IMAGE_TAG + out = launcher.ensure_image(PEARL_IMAGE_TAG) + assert out == PEARL_IMAGE_TAG + clone.assert_called_once() + build.assert_called_once() + + +def test_ensure_image_errors_when_non_default_tag_missing(): + import pytest + + from openjarvis.mining._docker import ( + APIError, + ImageAcquisitionError, + ImageNotFound, + NotFound, + PearlDockerLauncher, + ) + + fake = MagicMock() + fake.images.get.side_effect = ImageNotFound("nope") + fake.images.pull.side_effect = NotFound("registry refused") + launcher = PearlDockerLauncher(client=fake) + with ( + patch( + "openjarvis.mining._docker._docker_error_types", + return_value=(ImageNotFound, NotFound, APIError), + ), + pytest.raises(ImageAcquisitionError) as ei, + ): + launcher.ensure_image("user/custom-image:tag") + assert "user/custom-image:tag" in str(ei.value) + + +def test_docker_build_raises_nofile_limit(tmp_path): + from openjarvis.mining._docker import PearlDockerLauncher + + entrypoint = tmp_path / "miner" / "vllm-miner" / "entrypoint.sh" + entrypoint.parent.mkdir(parents=True) + entrypoint.write_text("#!/bin/sh\n") + dockerfile = tmp_path / "miner" / "vllm-miner" / "Dockerfile" + dockerfile.write_text("FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu24.04\n") + launcher = PearlDockerLauncher(client=MagicMock()) + with patch("openjarvis.mining._docker.subprocess.run") as run: + launcher._docker_build(tmp_path, "openjarvis/pearl-miner:test") + + cmd = run.call_args.args[0] + assert "--ulimit" in cmd + assert "nofile=1048576:1048576" in cmd + + +def test_patch_vllm_dockerfile_keeps_nvcc_runtime(tmp_path): + from openjarvis.mining._docker import PearlDockerLauncher + + dockerfile = tmp_path / "miner" / "vllm-miner" / "Dockerfile" + dockerfile.parent.mkdir(parents=True) + dockerfile.write_text("FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu24.04\n") + PearlDockerLauncher(client=MagicMock())._patch_vllm_dockerfile(tmp_path) + text = dockerfile.read_text() + assert "devel-ubuntu24.04" in text + assert "runtime-ubuntu24.04" not in text + + +def test_patch_vllm_entrypoint_waits_for_gateway_socket(tmp_path): + from openjarvis.mining._docker import PearlDockerLauncher + + entrypoint = tmp_path / "miner" / "vllm-miner" / "entrypoint.sh" + entrypoint.parent.mkdir(parents=True) + entrypoint.write_text( + "# Wait until the gateway is ready\n" + "curl -s http://localhost:8339/metrics --retry-delay 1 --retry 20 " + "--retry-all-errors > /dev/null\n" + ) + PearlDockerLauncher(client=MagicMock())._patch_vllm_entrypoint(tmp_path) + text = entrypoint.read_text() + assert "/tmp/pearlgw.sock" in text + assert "localhost:8339/metrics" not in text + + +@pytest.fixture +def _env_password(monkeypatch): + monkeypatch.setenv("PEARLD_RPC_PASSWORD", "secret123") + + +def test_launcher_start_calls_run_with_expected_kwargs(_env_password): + from openjarvis.mining._docker import PearlDockerLauncher + from openjarvis.mining._stubs import MiningConfig, SoloTarget + + fake = MagicMock() + fake.containers.run.return_value = MagicMock(id="cid-1", status="running") + launcher = PearlDockerLauncher(client=fake) + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + extra={ + "docker_image_tag": "openjarvis/pearl-miner:main", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "vllm_port": 8000, + "gpu_memory_utilization": 0.9, + "max_model_len": 8192, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "PEARLD_RPC_PASSWORD", + "hf_token_env": "HF_TOKEN", + "cuda_visible_devices": "0,1", + }, + ) + container = launcher.start(cfg, image="openjarvis/pearl-miner:main") + assert container.id == "cid-1" + fake.containers.run.assert_called_once() + kwargs = fake.containers.run.call_args.kwargs + assert kwargs["image"] == "openjarvis/pearl-miner:main" + assert kwargs["command"][0] == "pearl-ai/Llama-3.3-70B-Instruct-pearl" + assert "--gpu-memory-utilization" in kwargs["command"] + assert kwargs["restart_policy"]["Name"] == "unless-stopped" + assert kwargs["environment"]["PEARLD_RPC_PASSWORD"] == "secret123" + assert kwargs["environment"]["PEARLD_MINING_ADDRESS"] == "prl1qaaa" + assert kwargs["environment"]["MINER_RPC_TRANSPORT"] == "uds" + assert kwargs["environment"]["MINER_RPC_SOCKET_PATH"] == "/tmp/pearlgw.sock" + assert kwargs["environment"]["CUDA_VISIBLE_DEVICES"] == "0,1" + assert kwargs["environment"]["NVIDIA_VISIBLE_DEVICES"] == "0,1" + assert "device_requests" in kwargs + if kwargs["device_requests"] is not None: + assert kwargs["device_requests"][0].device_ids == ["0", "1"] + + +def test_launcher_stop_calls_container_stop_and_remove(): + from openjarvis.mining._docker import PearlDockerLauncher + + fake_client = MagicMock() + fake_container = MagicMock() + launcher = PearlDockerLauncher(client=fake_client) + launcher._container = fake_container + launcher.stop() + fake_container.stop.assert_called_once() + fake_container.remove.assert_called_once() + # Reference cleared unconditionally so a future start() isn't blocked. + assert launcher._container is None + + +def test_launcher_stop_finds_named_container_without_in_memory_reference(): + from openjarvis.mining._docker import PearlDockerLauncher + + fake_client = MagicMock() + fake_container = MagicMock() + fake_client.containers.get.return_value = fake_container + launcher = PearlDockerLauncher(client=fake_client) + launcher.stop() + fake_client.containers.get.assert_called_once_with("openjarvis-pearl-miner") + fake_container.stop.assert_called_once() + fake_container.remove.assert_called_once() + assert launcher._container is None + + +def test_launcher_is_running_when_container_running(): + from openjarvis.mining._docker import PearlDockerLauncher + + fake_client = MagicMock() + fake_container = MagicMock(status="running") + fake_container.reload.return_value = None + launcher = PearlDockerLauncher(client=fake_client) + launcher._container = fake_container + assert launcher.is_running() is True + + +def test_launcher_is_running_finds_named_container(): + from openjarvis.mining._docker import PearlDockerLauncher + + fake_client = MagicMock() + fake_container = MagicMock(status="running") + fake_container.reload.return_value = None + fake_client.containers.get.return_value = fake_container + launcher = PearlDockerLauncher(client=fake_client) + assert launcher.is_running() is True + fake_client.containers.get.assert_called_once_with("openjarvis-pearl-miner") + + +def test_launcher_is_running_false_when_container_exited(): + from openjarvis.mining._docker import PearlDockerLauncher + + fake_client = MagicMock() + fake_container = MagicMock() + fake_container.reload.return_value = None + fake_container.status = "exited" + launcher = PearlDockerLauncher(client=fake_client) + launcher._container = fake_container + assert launcher.is_running() is False + + +def test_launcher_get_logs_returns_decoded_string(): + from openjarvis.mining._docker import PearlDockerLauncher + + fake_client = MagicMock() + fake_container = MagicMock() + fake_container.logs.return_value = b"hello\nworld\n" + launcher = PearlDockerLauncher(client=fake_client) + launcher._container = fake_container + assert "hello" in launcher.get_logs(tail=100) + + +def test_launcher_get_logs_redacts_rpc_passwords(): + from openjarvis.mining._docker import PearlDockerLauncher + + fake_client = MagicMock() + fake_container = MagicMock() + fake_container.logs.return_value = ( + b"PearlNodeClient initialized with rpc_password: secret123\n" + b"PEARLD_RPC_PASSWORD=secret123\n" + b'{"PEARLD_RPC_PASSWORD": "secret123"}\n' + ) + launcher = PearlDockerLauncher(client=fake_client) + launcher._container = fake_container + logs = launcher.get_logs(tail=100) + assert "secret123" not in logs + assert logs.count("[REDACTED]") == 3 + + +def test_launcher_start_errors_when_password_env_missing(): + from openjarvis.mining._docker import ( + ConfigurationError, + PearlDockerLauncher, + ) + from openjarvis.mining._stubs import MiningConfig, SoloTarget + + fake = MagicMock() + launcher = PearlDockerLauncher(client=fake) + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + extra={ + "docker_image_tag": "openjarvis/pearl-miner:main", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "vllm_port": 8000, + "gpu_memory_utilization": 0.9, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "DOES_NOT_EXIST_IN_ENV", + }, + ) + with pytest.raises(ConfigurationError) as ei: + launcher.start(cfg, image="openjarvis/pearl-miner:main") + assert "DOES_NOT_EXIST_IN_ENV" in str(ei.value) diff --git a/tests/mining/test_install.py b/tests/mining/test_install.py new file mode 100644 index 00000000..5afabdce --- /dev/null +++ b/tests/mining/test_install.py @@ -0,0 +1,64 @@ +"""Tests for openjarvis.mining._install.""" + +from __future__ import annotations + +import importlib.util +import sys +import types + +import pytest + + +@pytest.mark.parametrize( + "missing_module", ["pearl_mining", "pearl_gateway", "miner_base"] +) +def test_pearl_packages_available_returns_false_when_any_one_missing( + missing_module, monkeypatch +): + """Returns False if ANY of the three packages is absent.""" + from openjarvis.mining import _install + + # Stub the two that should be present, leave the third missing. + present = {"pearl_mining", "pearl_gateway", "miner_base"} - {missing_module} + for name in present: + monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) + monkeypatch.delitem(sys.modules, missing_module, raising=False) + monkeypatch.setattr(_install, "_module_importable", lambda n: n != missing_module) + + assert _install.pearl_packages_available() is False + + +def test_pearl_packages_available_returns_true_when_all_present(): + """When all three are importable, returns True.""" + from openjarvis.mining import _install + + fakes = { + name: types.ModuleType(name) + for name in ("pearl_mining", "pearl_gateway", "miner_base") + } + with pytest.MonkeyPatch().context() as mp: + for name, mod in fakes.items(): + mp.setitem(sys.modules, name, mod) + assert _install.pearl_packages_available() is True + + +def test_install_hint_is_actionable(): + """The hint string must include the extra name and a clear next step.""" + from openjarvis.mining._install import install_hint + + h = install_hint() + assert "mining-pearl-cpu" in h + assert "uv sync" in h, "hint must mention `uv sync` (the project's installer)" + + +def test_module_importable_returns_false_on_value_error(monkeypatch): + """If find_spec raises ValueError, the helper treats the module as missing.""" + from openjarvis.mining import _install + + def boom(name): + raise ValueError("simulated partially-initialised package") + + monkeypatch.setattr(importlib.util, "find_spec", boom) + # Make sure the module name is NOT in sys.modules, so we hit the find_spec path. + monkeypatch.delitem(sys.modules, "_nonexistent_test_pkg", raising=False) + assert _install._module_importable("_nonexistent_test_pkg") is False diff --git a/tests/mining/test_metrics.py b/tests/mining/test_metrics.py new file mode 100644 index 00000000..5a27a2bf --- /dev/null +++ b/tests/mining/test_metrics.py @@ -0,0 +1,54 @@ +"""Tests for mining/_metrics.py — Prometheus → MiningStats adapter.""" + +from __future__ import annotations + +from pathlib import Path + +FIXTURE = Path(__file__).parent / "fixtures" / "gateway_metrics_sample.txt" + + +def test_parse_gateway_metrics_full(): + from openjarvis.mining._metrics import parse_gateway_metrics + + text = FIXTURE.read_text() + stats = parse_gateway_metrics(text, provider_id="vllm-pearl") + assert stats.provider_id == "vllm-pearl" + assert stats.shares_submitted == 12345 + assert stats.shares_accepted == 12300 + assert stats.blocks_found == 7 + assert stats.last_share_at == 1714867500.0 + # Uptime computed as now - process_start_time, but not asserted exactly. + assert stats.uptime_seconds >= 0 + + +def test_parse_gateway_metrics_missing_metrics_zero_fills(): + from openjarvis.mining._metrics import parse_gateway_metrics + + stats = parse_gateway_metrics("# empty exposition\n", provider_id="vllm-pearl") + assert stats.shares_submitted == 0 + assert stats.shares_accepted == 0 + assert stats.blocks_found == 0 + assert stats.last_share_at is None + + +def test_parse_gateway_metrics_ignores_comment_lines(): + from openjarvis.mining._metrics import parse_gateway_metrics + + stats = parse_gateway_metrics( + "# HELP something\n# TYPE something counter\nsomething 99\n", + provider_id="vllm-pearl", + ) + assert stats.shares_submitted == 0 # 'something' isn't a Pearl metric + + +def test_parse_vllm_metrics_reports_runtime_uptime(): + from openjarvis.mining._metrics import parse_vllm_metrics + + stats = parse_vllm_metrics( + "process_start_time_seconds 1\n" + 'vllm:request_success_total{finished_reason="stop"} 1\n', + provider_id="vllm-pearl", + ) + assert stats.provider_id == "vllm-pearl" + assert stats.uptime_seconds > 0 + assert stats.last_error is None diff --git a/tests/mining/test_miner_loop.py b/tests/mining/test_miner_loop.py new file mode 100644 index 00000000..336fbbbc --- /dev/null +++ b/tests/mining/test_miner_loop.py @@ -0,0 +1,83 @@ +"""Tests for openjarvis.mining._miner_loop_main.""" + +from __future__ import annotations + +import asyncio +import base64 +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def fake_mining_info_result(): + """Mock getMiningInfo result — base64-encoded incomplete header + target.""" + return { + "incomplete_header_bytes": base64.b64encode(b"\x00" * 76).decode(), + "target": 0x1D2FFFFF, + } + + +def test_decode_mining_info_returns_header_bytes_and_target(fake_mining_info_result): + from openjarvis.mining._miner_loop_main import _decode_mining_info + + header_bytes, target = _decode_mining_info(fake_mining_info_result) + assert isinstance(header_bytes, (bytes, bytearray)) + assert len(header_bytes) == 76 + assert target == 0x1D2FFFFF + + +def test_encode_plain_proof_returns_to_base64_result(): + """_encode_plain_proof returns plain_proof.to_base64() directly. + + No double-encode: to_base64() already returns a base64 string. + """ + from openjarvis.mining._miner_loop_main import _encode_plain_proof + + fake_proof = MagicMock() + fake_proof.to_base64.return_value = "ABCabc012==" + encoded = _encode_plain_proof(fake_proof) + assert encoded == "ABCabc012==" + fake_proof.to_base64.assert_called_once() + + +def test_jsonrpc_envelope_shape(): + """The JSON-RPC envelope conforms to gateway's JSON_RPC_SCHEMA.""" + from openjarvis.mining._miner_loop_main import _make_request + + req = _make_request("getMiningInfo", {}, request_id=42) + assert req["jsonrpc"] == "2.0" + assert req["method"] == "getMiningInfo" + assert req["id"] == 42 + assert req["params"] == {} + + +def test_open_gateway_connection_retries_until_listener_ready(monkeypatch): + """Initial gateway connect has startup-race retry/backoff.""" + from openjarvis.mining import _miner_loop_main + + calls = 0 + fake_reader = object() + fake_writer = object() + + async def fake_open_connection(host, port): + nonlocal calls + calls += 1 + if calls < 3: + raise ConnectionRefusedError("not ready") + return fake_reader, fake_writer + + monkeypatch.setattr(asyncio, "open_connection", fake_open_connection) + + reader, writer = asyncio.run( + _miner_loop_main._open_gateway_connection( + "127.0.0.1", + 18337, + timeout_seconds=1.0, + retry_seconds=0.0, + ) + ) + + assert calls == 3 + assert reader is fake_reader + assert writer is fake_writer diff --git a/tests/mining/test_pearl_subprocess.py b/tests/mining/test_pearl_subprocess.py new file mode 100644 index 00000000..029a7d22 --- /dev/null +++ b/tests/mining/test_pearl_subprocess.py @@ -0,0 +1,136 @@ +"""Tests for openjarvis.mining._pearl_subprocess.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def fake_popen(): + """A MagicMock standing in for a live subprocess.Popen handle.""" + p = MagicMock() + p.poll.return_value = None # still running + p.pid = 12345 + return p + + +def _make_launcher(tmp_path): + """Build a launcher with safe defaults that won't conflict with anything.""" + from openjarvis.mining._pearl_subprocess import PearlSubprocessLauncher + + return PearlSubprocessLauncher( + gateway_host="127.0.0.1", + gateway_port=18337, + metrics_port=18339, + pearld_rpc_url="http://localhost:44107", + pearld_rpc_user="rpcuser", + pearld_rpc_password="testpw", + wallet_address="prl1qtest", + log_dir=tmp_path, + ) + + +def test_launcher_accepts_custom_provider_and_miner_module(fake_popen, tmp_path): + """Providers can select a distinct miner-loop module and log file.""" + from openjarvis.mining._pearl_subprocess import PearlSubprocessLauncher + + with patch("subprocess.Popen", return_value=fake_popen) as mock_popen: + launcher = PearlSubprocessLauncher( + gateway_host="127.0.0.1", + gateway_port=18337, + metrics_port=18339, + pearld_rpc_url="http://localhost:44107", + pearld_rpc_user="rpcuser", + pearld_rpc_password="testpw", + wallet_address="prl1qtest", + log_dir=tmp_path, + provider_id="apple-mps-pearl", + miner_module="openjarvis.mining._mps_miner_loop_main", + ) + launcher.start(m=128, n=128, k=1024, rank=64) + + second_call = mock_popen.call_args_list[1] + assert "_mps_miner_loop_main" in " ".join(second_call.args[0]) + + +def test_launcher_start_spawns_two_processes(fake_popen, tmp_path): + """start() spawns gateway and miner-loop subprocesses.""" + with patch("subprocess.Popen", return_value=fake_popen) as mock_popen: + launcher = _make_launcher(tmp_path) + launcher.start(m=256, n=128, k=1024, rank=32) + assert mock_popen.call_count == 2 # gateway + miner-loop + + +def test_launcher_stop_terminates_both(fake_popen, tmp_path): + """stop() sends SIGTERM to both subprocesses.""" + with patch("subprocess.Popen", return_value=fake_popen): + launcher = _make_launcher(tmp_path) + launcher.start(m=256, n=128, k=1024, rank=32) + launcher.stop() + assert fake_popen.terminate.call_count >= 2 + + +def test_launcher_is_running_false_when_one_exited(tmp_path): + """is_running() returns False if either subprocess has exited.""" + fake_alive = MagicMock() + fake_alive.poll.return_value = None + fake_alive.pid = 11111 + fake_dead = MagicMock() + fake_dead.poll.return_value = 1 + fake_dead.pid = 22222 + + with patch("subprocess.Popen", side_effect=[fake_alive, fake_dead]): + launcher = _make_launcher(tmp_path) + launcher.start(m=256, n=128, k=1024, rank=32) + assert launcher.is_running() is False + + +def test_launcher_pids_returns_both(fake_popen, tmp_path): + """pids() returns (gateway_pid, miner_loop_pid) after start.""" + fake_a = MagicMock() + fake_a.poll.return_value = None + fake_a.pid = 11111 + fake_b = MagicMock() + fake_b.poll.return_value = None + fake_b.pid = 22222 + with patch("subprocess.Popen", side_effect=[fake_a, fake_b]): + launcher = _make_launcher(tmp_path) + launcher.start(m=256, n=128, k=1024, rank=32) + assert launcher.pids() == (11111, 22222) + + +def test_launcher_pids_returns_none_before_start(tmp_path): + """pids() returns None before start().""" + launcher = _make_launcher(tmp_path) + assert launcher.pids() is None + + +def test_launcher_stop_idempotent(fake_popen, tmp_path): + """stop() called twice is a no-op the second time.""" + with patch("subprocess.Popen", return_value=fake_popen): + launcher = _make_launcher(tmp_path) + launcher.start(m=256, n=128, k=1024, rank=32) + launcher.stop() + launcher.stop() # should not raise + + +def test_launcher_spawn_order_is_gateway_then_miner_loop(fake_popen, tmp_path): + """Gateway must be spawned before the miner-loop.""" + with patch("subprocess.Popen", return_value=fake_popen) as mock_popen: + launcher = _make_launcher(tmp_path) + launcher.start(m=256, n=128, k=1024, rank=32) + first_call = mock_popen.call_args_list[0] + second_call = mock_popen.call_args_list[1] + assert first_call.args[0][0] == "pearl-gateway" + assert "_miner_loop_main" in " ".join(second_call.args[0]) + + +def test_launcher_start_twice_raises(fake_popen, tmp_path): + """Calling start() while already running raises RuntimeError.""" + with patch("subprocess.Popen", return_value=fake_popen): + launcher = _make_launcher(tmp_path) + launcher.start(m=256, n=128, k=1024, rank=32) + with pytest.raises(RuntimeError, match="already started"): + launcher.start(m=256, n=128, k=1024, rank=32) diff --git a/tests/mining/test_stubs.py b/tests/mining/test_stubs.py new file mode 100644 index 00000000..f0381282 --- /dev/null +++ b/tests/mining/test_stubs.py @@ -0,0 +1,84 @@ +"""Tests for mining/_stubs.py — ABC contract, dataclass invariants, sidecar IO.""" + +from __future__ import annotations + +import json + +import pytest + + +def test_mining_capabilities_default_unsupported(): + from openjarvis.mining._stubs import MiningCapabilities + + cap = MiningCapabilities(supported=False, reason="needs sm90") + assert cap.supported is False + assert cap.reason == "needs sm90" + assert cap.estimated_hashrate is None + + +def test_solo_target_dataclass(): + from openjarvis.mining._stubs import SoloTarget + + t = SoloTarget(pearld_rpc_url="http://localhost:44107") + assert t.pearld_rpc_url == "http://localhost:44107" + + +def test_pool_target_dataclass(): + from openjarvis.mining._stubs import PoolTarget + + t = PoolTarget(url="https://pool.example/submit", worker_id="rig01") + assert t.url == "https://pool.example/submit" + assert t.worker_id == "rig01" + + +def test_mining_config_v1_defaults(): + from openjarvis.mining._stubs import MiningConfig, SoloTarget + + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qexample", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + ) + assert cfg.fee_bps == 0 + assert cfg.fee_payout_address is None + assert cfg.extra == {} + + +def test_mining_stats_v1_defaults(): + from openjarvis.mining._stubs import MiningStats + + s = MiningStats(provider_id="vllm-pearl") + assert s.shares_submitted == 0 + assert s.shares_accepted == 0 + assert s.fees_owed == 0 + assert s.payout_target == "solo" + + +def test_mining_provider_is_abstract(): + from openjarvis.mining._stubs import MiningProvider + + with pytest.raises(TypeError): + MiningProvider() # cannot instantiate ABC + + +def test_sidecar_write_then_read_roundtrip(sidecar_path, sample_sidecar_payload): + from openjarvis.mining._stubs import Sidecar + + Sidecar.write(sidecar_path, sample_sidecar_payload) + payload = Sidecar.read(sidecar_path) + assert payload == sample_sidecar_payload + + +def test_sidecar_read_missing_returns_none(sidecar_path): + from openjarvis.mining._stubs import Sidecar + + assert Sidecar.read(sidecar_path) is None + + +def test_sidecar_remove_is_idempotent(sidecar_path): + from openjarvis.mining._stubs import Sidecar + + Sidecar.remove(sidecar_path) # missing file — should not raise + sidecar_path.write_text(json.dumps({"x": 1})) + Sidecar.remove(sidecar_path) + assert not sidecar_path.exists() diff --git a/tests/mining/test_vllm_pearl.py b/tests/mining/test_vllm_pearl.py new file mode 100644 index 00000000..7a792909 --- /dev/null +++ b/tests/mining/test_vllm_pearl.py @@ -0,0 +1,167 @@ +"""End-to-end tests for VllmPearlProvider with mocked Docker + filesystem.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +def test_vllm_pearl_detect_supported_on_h100(hopper_hw): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + + cap = VllmPearlProvider.detect( + hopper_hw, + engine_id="vllm", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + ) + assert cap.supported is True + + +def test_vllm_pearl_detect_unsupported_on_apple(apple_hw): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + + cap = VllmPearlProvider.detect( + apple_hw, + engine_id="mlx", + model="pearl-ai/Llama-3.3-70B-Instruct-pearl", + ) + assert cap.supported is False + + +@pytest.mark.asyncio +async def test_vllm_pearl_start_writes_sidecar(tmp_path, monkeypatch): + from openjarvis.mining._stubs import MiningConfig, SoloTarget + from openjarvis.mining.vllm_pearl import VllmPearlProvider + + sidecar_path = tmp_path / "mining.json" + monkeypatch.setattr("openjarvis.mining.vllm_pearl.SIDECAR_PATH", sidecar_path) + monkeypatch.setenv("PEARLD_RPC_PASSWORD", "x") + + fake_client = MagicMock() + fake_container = MagicMock(id="cid-xyz") + fake_container.status = "running" + fake_client.containers.run.return_value = fake_container + # ensure_image: image already present + fake_client.images.get.return_value = MagicMock(id="sha256:abc") + + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=SoloTarget(pearld_rpc_url="http://localhost:44107"), + extra={ + "docker_image_tag": "openjarvis/pearl-miner:main", + "model": "pearl-ai/Llama-3.3-70B-Instruct-pearl", + "vllm_port": 8000, + "gateway_port": 8337, + "gateway_metrics_port": 8339, + "gpu_memory_utilization": 0.9, + "max_model_len": 8192, + "pearld_rpc_url": "http://localhost:44107", + "pearld_rpc_user": "rpcuser", + "pearld_rpc_password_env": "PEARLD_RPC_PASSWORD", + }, + ) + + provider = VllmPearlProvider(docker_client=fake_client) + await provider.start(cfg) + + assert sidecar_path.exists() + payload = json.loads(sidecar_path.read_text()) + assert payload["provider"] == "vllm-pearl" + assert payload["vllm_endpoint"].endswith(":8000/v1") + assert payload["gateway_url"].endswith(":8337") + assert payload["gateway_metrics_url"].endswith(":8339") + assert payload["wallet_address"] == "prl1qaaa" + assert payload["container_id"] == "cid-xyz" + assert "started_at" in payload + # Sidecar omits secrets + assert "PEARLD_RPC_PASSWORD" not in json.dumps(payload) + + +@pytest.mark.asyncio +async def test_vllm_pearl_start_pool_target_raises_not_implemented( + monkeypatch, tmp_path +): # noqa: E501 + from openjarvis.mining._stubs import MiningConfig, PoolTarget + from openjarvis.mining.vllm_pearl import VllmPearlProvider + + sidecar_path = tmp_path / "mining.json" + monkeypatch.setattr("openjarvis.mining.vllm_pearl.SIDECAR_PATH", sidecar_path) + + cfg = MiningConfig( + provider="vllm-pearl", + wallet_address="prl1qaaa", + submit_target=PoolTarget(url="https://pool.openjarvis.ai/submit"), + extra={"docker_image_tag": "openjarvis/pearl-miner:main"}, + ) + provider = VllmPearlProvider(docker_client=MagicMock()) + with pytest.raises(NotImplementedError) as ei: + await provider.start(cfg) + assert "v2" in str(ei.value).lower() or "pool" in str(ei.value).lower() + + +@pytest.mark.asyncio +async def test_vllm_pearl_stop_removes_sidecar(tmp_path, monkeypatch, written_sidecar): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + + monkeypatch.setattr("openjarvis.mining.vllm_pearl.SIDECAR_PATH", written_sidecar) + fake_client = MagicMock() + provider = VllmPearlProvider(docker_client=fake_client) + provider._launcher._container = MagicMock() # simulate running + await provider.stop() + assert not written_sidecar.exists() + + +def test_vllm_pearl_stats_reads_gateway(monkeypatch, written_sidecar): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + + monkeypatch.setattr("openjarvis.mining.vllm_pearl.SIDECAR_PATH", written_sidecar) + sample = ( + "pearl_gateway_shares_submitted_total 100\n" + "pearl_gateway_shares_accepted_total 99\n" + "pearl_gateway_blocks_found_total 1\n" + ) + with patch("openjarvis.mining.vllm_pearl.httpx.get") as get: + get.return_value.status_code = 200 + get.return_value.text = sample + provider = VllmPearlProvider(docker_client=MagicMock()) + stats = provider.stats() + assert stats.shares_submitted == 100 + assert stats.shares_accepted == 99 + assert stats.blocks_found == 1 + + +def test_vllm_pearl_stats_falls_back_to_vllm_metrics(monkeypatch, written_sidecar): + from openjarvis.mining.vllm_pearl import VllmPearlProvider + + monkeypatch.setattr("openjarvis.mining.vllm_pearl.SIDECAR_PATH", written_sidecar) + + def fake_get(url: str, timeout: float): + resp = MagicMock() + if url == "http://127.0.0.1:8339/metrics": + raise OSError("connection refused") + assert url == "http://127.0.0.1:8000/metrics" + resp.status_code = 200 + resp.text = "process_start_time_seconds 1\n" + return resp + + with patch("openjarvis.mining.vllm_pearl.httpx.get", side_effect=fake_get): + provider = VllmPearlProvider(docker_client=MagicMock()) + stats = provider.stats() + assert stats.uptime_seconds > 0 + assert stats.last_error is None + + +def test_ensure_registered_is_idempotent(): + from openjarvis.core.registry import MinerRegistry + from openjarvis.mining.vllm_pearl import ( + VllmPearlProvider, + ensure_registered, + ) + + ensure_registered() + ensure_registered() # second call should not raise + assert MinerRegistry.contains("vllm-pearl") + assert MinerRegistry.get("vllm-pearl") is VllmPearlProvider diff --git a/tests/telemetry/test_store.py b/tests/telemetry/test_store.py index 8c332fe2..c91ce264 100644 --- a/tests/telemetry/test_store.py +++ b/tests/telemetry/test_store.py @@ -81,6 +81,63 @@ class TestTelemetryStore: assert meta["nested"] == [1, 2, 3] store.close() + def test_recent_row_has_mining_session_id_column(self, tmp_path: Path) -> None: + store = TelemetryStore(tmp_path / "test.db") + rec = TelemetryRecord( + timestamp=time.time(), + model_id="test-model", + engine="test-engine", + prompt_tokens=10, + completion_tokens=5, + latency_seconds=0.1, + ) + store.record(rec) + + rows = store.list_recent(limit=1) + + assert rows[0]["mining_session_id"] is None + store.close() + + def test_recent_row_can_be_tagged_with_mining_session_id( + self, + tmp_path: Path, + ) -> None: + store = TelemetryStore(tmp_path / "test.db") + rec = TelemetryRecord( + timestamp=time.time(), + model_id="test-model", + engine="vllm-pearl-mining", + prompt_tokens=10, + completion_tokens=5, + latency_seconds=0.1, + mining_session_id="abc123", + ) + store.record(rec) + + rows = store.list_recent(limit=1) + + assert rows[0]["mining_session_id"] == "abc123" + store.close() + + def test_record_mining_stats_persists(self, tmp_path: Path) -> None: + from openjarvis.mining._stubs import MiningStats + + store = TelemetryStore(tmp_path / "test.db") + store.record_mining_stats( + MiningStats( + provider_id="vllm-pearl", + shares_submitted=42, + shares_accepted=40, + ) + ) + + snapshots = store.list_recent_mining_stats(limit=1) + + assert snapshots[0]["provider_id"] == "vllm-pearl" + assert snapshots[0]["shares_submitted"] == 42 + assert snapshots[0]["shares_accepted"] == 40 + store.close() + class TestTelemetryRecordFields: def test_tokens_per_joule_field_exists(self): diff --git a/tools/pearl-reference-oracle/README.md b/tools/pearl-reference-oracle/README.md new file mode 100644 index 00000000..28c382e2 --- /dev/null +++ b/tools/pearl-reference-oracle/README.md @@ -0,0 +1,111 @@ +# Pearl reference oracle (OpenJarvis Phase 0 deliverable) + +Phase 0-B of [Spec B](../../docs/design/2026-05-05-apple-silicon-pearl-mining-design.md) +called for "build a Python reference oracle for NoisyGEMM, validate against the +Pearl CUDA reference." + +**Phase 0 found the oracle already exists upstream**, in two complementary forms: + +| Layer | Upstream location | What it covers | +|---|---|---| +| Pure-Rust mining algorithm exposed to Python | `pearl/py-pearl-mining` | The complete `mine()` + `verify_plain_proof()` cycle. CPU-only. Hardware-portable. | +| PyTorch reference of production NoisyGEMM | `pearl/miner/miner-base/src/miner_base/noisy_gemm.py` | The same NoisyGEMM that vllm-miner accelerates with H100 CUDA. Bit-exact denoising verified by upstream test (`tests/test_noisy_gemm.py:92`). | + +So this directory contains: + +1. `smoke_test.py` — a runnable script that **actually mines a block on this machine** using the upstream Rust path, demonstrating the v1 architecture works on Apple Silicon (or any platform where `py-pearl-mining` builds). +2. This README documenting where the reference math lives. + +## What this is *not* + +This is **not a reimplementation** of NoisyGEMM. The original Spec B planned for that; +Phase 0 made it unnecessary. If you're tempted to write `noisy_gemm.py` here, stop — +read `pearl/miner/miner-base/src/miner_base/noisy_gemm.py` instead. + +## Setup + +You need: + +- macOS arm64 (M1/M2/M3/M4) **or** Linux x86_64 / aarch64 +- Python 3.12 (`uv venv --python 3.12 .venv` is the easiest) +- Rust 1.78+ (any recent toolchain — verified with 1.94 on macOS arm64) +- The Pearl source tree somewhere on disk + +Build the wheel and install it (one-time, ~60 s on a fast Mac, ~5 min on first build): + +```bash +# from the Pearl repo root +cd py-pearl-mining +uv pip install maturin +maturin build --release --interpreter "$(which python)" + +# install the resulting wheel +uv pip install target/wheels/py_pearl_mining-*.whl +``` + +Or if Pearl publishes to PyPI in the future: + +```bash +uv pip install py-pearl-mining +``` + +## Run the smoke test + +```bash +python smoke_test.py +``` + +Actual output on Apple Silicon M2 Max (numbers will vary by hardware and run): + +``` +host: macOS-26.4.1-arm64-arm-64bit (arm64) +python: 3.12.1 +[ok] pearl_mining loaded from /pearl_mining/__init__.py +[ok] PUBLICDATA_SIZE=164 MERKLE_LEAF_SIZE=1024 +[ok] mine(m=256, n=128, k=1024, rank=32) returned a proof in 0.119 s + proof.m=256 proof.n=128 proof.k=1024 noise_rank=32 + a.row_indices=[177, 185, 241, 249] bt.row_indices=[80, 81, 88, 89, 112, 113, 120, 121] +[ok] verify_plain_proof: ok=True ('Mining solution verified successfully', 0.2 ms) + +[ok] all checks passed — Pearl mining works on this host +``` + +The `a.row_indices` and `bt.row_indices` values above are not constants — they're +`(offset + ROWS_PATTERN)` and `(offset + COLS_PATTERN)` for whichever offset the +miner happened to find a jackpot at. The smoke test verifies the *deltas* match +the configured `PeriodicPattern`, not the absolute values. + +If it succeeds, this host can mine Pearl using the OpenJarvis `cpu-pearl` provider +(see Spec B §13). If it fails, the `[fail]` line tells you which step broke. + +## What this proves (and what it doesn't) + +**Proves:** + +- The Pearl mining algorithm executes correctly on this host's CPU. +- Generated proofs verify under `verify_plain_proof`. (This is the same check + validators run on the inputs to the ZK proof.) +- The whole stack — `pearl-blake3`, `zk-pow`, `py-pearl-mining` — builds and + loads as a native CPython extension. + +**Does NOT prove:** + +- Network-difficulty hashrate. The smoke test uses + `nbits=0x1D2FFFFF` (test difficulty), much easier than mainnet. Real mining + expected hashrate on Apple Silicon CPU is several orders of magnitude lower + per share — see Spec B §1.5.6. +- ZK proof generation throughput. The smoke test calls `verify_plain_proof`, + not `generate_proof`. Plonky2 STARK proving takes seconds-to-minutes of CPU + per block (Spec B Open Q10). +- That this host can keep up with the network's block production rate. + +## When to update this + +- When Pearl bumps `py-pearl-mining` API: re-run the smoke test against the + new ref pinned in `OpenJarvis/src/openjarvis/mining/_constants.py`. +- When Pearl publishes a Mac wheel to PyPI: simplify the install instructions + above, drop the local `maturin build` step. +- When Spec B v2 adds the PyTorch-MPS reference path: extend `smoke_test.py` + with an MPS path comparison. The `miner-base` reference is already in + PyTorch, so the v2 smoke test would be a different test invoking + `miner_base.NoisyGemm` and comparing CPU vs MPS outputs for parity. diff --git a/tools/pearl-reference-oracle/smoke_test.py b/tools/pearl-reference-oracle/smoke_test.py new file mode 100644 index 00000000..730ef70a --- /dev/null +++ b/tools/pearl-reference-oracle/smoke_test.py @@ -0,0 +1,139 @@ +"""Pearl mining smoke test — runs an end-to-end mine + verify cycle. + +Verifies that this host can run Pearl's pure-Rust mining algorithm via the +`pearl_mining` Python package. Used as Phase 0-B of the OpenJarvis Apple Silicon +mining spec ([Spec B]). + +Exit codes: + 0 all checks passed + 1 pearl_mining import failed + 2 mine() failed + 3 verify_plain_proof rejected the proof + 4 timing or sanity check failed + +[Spec B]: ../../docs/design/2026-05-05-apple-silicon-pearl-mining-design.md +""" + +from __future__ import annotations + +import platform +import sys +import time + +# Test fixture values — match upstream Pearl's tests/test_python_api.py so we are +# testing the same code path that Pearl's own CI exercises. Do not change +# without re-syncing with upstream. +DEFAULT_NBITS = 0x1D2FFFFF +DEFAULT_M = 256 +DEFAULT_N = 128 +DEFAULT_K = 1024 +DEFAULT_RANK = 32 +ROWS_PATTERN = [0, 8, 64, 72] +COLS_PATTERN = [0, 1, 8, 9, 32, 33, 40, 41] + + +def _ok(msg: str) -> None: + print(f"[ok] {msg}") + + +def _fail(msg: str, code: int) -> None: + print(f"[fail] {msg}") + sys.exit(code) + + +def main() -> None: + print(f"host: {platform.platform()} ({platform.machine()})") + print(f"python: {sys.version.split()[0]}") + + try: + import pearl_mining + except ImportError as e: + _fail(f"could not import pearl_mining — install with `uv pip install py-pearl-mining` or build from source: {e}", 1) + + _ok(f"pearl_mining loaded from {pearl_mining.__file__}") + _ok( + f"PUBLICDATA_SIZE={pearl_mining.PUBLICDATA_SIZE} " + f"MERKLE_LEAF_SIZE={pearl_mining.MERKLE_LEAF_SIZE}" + ) + + block_header = pearl_mining.IncompleteBlockHeader( + version=0, + prev_block=b"\x00" * 32, + merkle_root=b"0123456789abcdef" * 2, + timestamp=0x66666666, + nbits=DEFAULT_NBITS, + ) + mining_config = pearl_mining.MiningConfiguration( + common_dim=DEFAULT_K, + rank=DEFAULT_RANK, + mma_type=pearl_mining.MMAType.Int7xInt7ToInt32, + rows_pattern=pearl_mining.PeriodicPattern.from_list(ROWS_PATTERN), + cols_pattern=pearl_mining.PeriodicPattern.from_list(COLS_PATTERN), + reserved=pearl_mining.MiningConfiguration.RESERVED, + ) + + t0 = time.perf_counter() + try: + plain_proof = pearl_mining.mine( + DEFAULT_M, + DEFAULT_N, + DEFAULT_K, + block_header, + mining_config, + signal_range=None, + wrong_jackpot_hash=False, + ) + except Exception as e: + _fail(f"mine() raised: {e!r}", 2) + t_mine = time.perf_counter() - t0 + + _ok( + f"mine(m={DEFAULT_M}, n={DEFAULT_N}, k={DEFAULT_K}, rank={DEFAULT_RANK}) " + f"returned a proof in {t_mine:.3f} s" + ) + print( + f" proof.m={plain_proof.m} proof.n={plain_proof.n} proof.k={plain_proof.k} " + f"noise_rank={plain_proof.noise_rank}" + ) + print( + f" a.row_indices={plain_proof.a.row_indices} " + f"bt.row_indices={plain_proof.bt.row_indices}" + ) + + t0 = time.perf_counter() + ok, msg = pearl_mining.verify_plain_proof(block_header, plain_proof) + t_verify_ms = (time.perf_counter() - t0) * 1000 + + if not ok: + _fail(f"verify_plain_proof rejected our proof: {msg}", 3) + + _ok(f"verify_plain_proof: ok=True ({msg!r}, {t_verify_ms:.1f} ms)") + + if plain_proof.m != DEFAULT_M or plain_proof.n != DEFAULT_N or plain_proof.k != DEFAULT_K: + _fail("plain_proof dimensions do not match request", 4) + if plain_proof.noise_rank != DEFAULT_RANK: + _fail("plain_proof noise_rank does not match request", 4) + + # Row indices are (offset + base_index) for some valid offset within the + # matrix dimension — see threads_partition() in zk-pow/src/ffi/mine.rs. + # We can't assert an absolute value (different offsets are valid every run), + # but we can assert the deltas match the pattern shape. + a_idxs = list(plain_proof.a.row_indices) + bt_idxs = list(plain_proof.bt.row_indices) + a_deltas = [v - a_idxs[0] for v in a_idxs] + bt_deltas = [v - bt_idxs[0] for v in bt_idxs] + if a_deltas != ROWS_PATTERN: + _fail(f"a.row_indices deltas ({a_deltas}) != ROWS_PATTERN ({ROWS_PATTERN})", 4) + if bt_deltas != COLS_PATTERN: + _fail(f"bt.row_indices deltas ({bt_deltas}) != COLS_PATTERN ({COLS_PATTERN})", 4) + + print() + print("[ok] all checks passed — Pearl mining works on this host") + print() + print("Note: this used test difficulty (nbits=0x1D2FFFFF), not mainnet.") + print("Real-network shares per second will be many orders of magnitude lower.") + print("See docs/design/2026-05-05-apple-silicon-pearl-mining-design.md §1.5.6") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 5ad23f12..c0581819 100644 --- a/uv.lock +++ b/uv.lock @@ -5130,6 +5130,10 @@ memory-faiss = [ memory-pdf = [ { name = "pdfplumber" }, ] +mining-pearl-vllm = [ + { name = "docker" }, + { name = "httpx" }, +] openhands = [ { name = "openhands-sdk", marker = "python_full_version >= '3.12'" }, ] @@ -5189,6 +5193,7 @@ requires-dist = [ { name = "ddgs", marker = "extra == 'tools-search'", specifier = ">=9.11.4" }, { name = "deepgram-sdk", marker = "extra == 'speech-deepgram'", specifier = ">=3.0" }, { name = "discord-py", marker = "extra == 'channel-discord'", specifier = ">=2.3" }, + { name = "docker", marker = "extra == 'mining-pearl-vllm'", specifier = ">=7.0" }, { name = "docker", marker = "extra == 'sandbox-docker'", specifier = ">=7.0" }, { name = "dspy", marker = "extra == 'learning-dspy'", specifier = ">=2.6" }, { name = "faiss-cpu", marker = "extra == 'memory-faiss'", specifier = ">=1.7" }, @@ -5203,6 +5208,7 @@ requires-dist = [ { name = "gspread", marker = "extra == 'eval-sheets'", specifier = ">=6.0" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'channel-twitter'", specifier = ">=0.27" }, + { name = "httpx", marker = "extra == 'mining-pearl-vllm'", specifier = ">=0.27" }, { name = "line-bot-sdk", marker = "extra == 'channel-line'", specifier = ">=3.0" }, { name = "litellm", marker = "extra == 'inference-litellm'", specifier = ">=1.40" }, { name = "mastodon-py", marker = "extra == 'channel-mastodon'", specifier = ">=1.8" }, @@ -5262,7 +5268,7 @@ requires-dist = [ { name = "zeus-ml", extras = ["apple"], marker = "extra == 'energy-apple'" }, { name = "zulip", marker = "extra == 'channel-zulip'", specifier = ">=0.9" }, ] -provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "framework-comparison", "docs"] +provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "mining-pearl-vllm", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "mining-pearl-cpu", "framework-comparison", "docs"] [package.metadata.requires-dev] dev = [{ name = "maturin", specifier = ">=1.12.6" }]