feat(mining): Pearl mining integration

Consolidates NVIDIA vLLM, Apple Silicon, CPU Pearl mining support, CLI/docs, and live H100 validation.
This commit is contained in:
Jon Saad-Falcon
2026-05-05 19:11:15 -07:00
committed by GitHub
parent e79bc1e196
commit f41cf420be
74 changed files with 14533 additions and 18 deletions
@@ -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 <pearl-model-id>` 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
+141
View File
@@ -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/<area>/`.
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.
+1
View File
@@ -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
@@ -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 14 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.050.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.18.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 04 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) |
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -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:<url>"; 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:<url>" 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:<pinned-ref>"
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:<ref> 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 3060 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 <tag>` → 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 = "<sha-or-tag>" # 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:<ref>` 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 49 above into ordered, independently-reviewable steps and call out which steps can be parallelized.
File diff suppressed because it is too large Load Diff
-1
View File
@@ -413,4 +413,3 @@ policy:
if the component requires new packages
See the [registry pattern](#registry-pattern) section above for complete examples.
@@ -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 '<public-wallet-passphrase-if-configured>' \
&
```
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='<pearld-rpc-password>'
export HF_TOKEN='<huggingface-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.
+84
View File
@@ -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.
@@ -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 <pearl-model-id>`
- `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 <pearl-model-id> --allow-planned --prompt
"Say hello in one sentence." --output <artifact>.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.
+29
View File
@@ -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 -- <pearld args>
jarvis pearl wallet -- <oyster args>
jarvis pearl ctl -- <prlctl args>
jarvis pearl address
```
All Pearl wrapper commands use the `jarvis pearl <command>` 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.
+162
View File
@@ -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 "<your-prl1...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.
+133
View File
@@ -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=<your-pearld-password>
export HF_TOKEN=<your-huggingface-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).
+58
View File
@@ -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 <command>`. 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.
+6
View File
@@ -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
+16 -4
View File
@@ -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",
]
+4
View File
@@ -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")
+7
View File
@@ -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`"
+753
View File
@@ -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))
+149
View File
@@ -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"]
+61 -3
View File
@@ -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:<url>', 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)
+11
View File
@@ -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",
+1
View File
@@ -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)
+49
View File
@@ -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:
+60
View File
@@ -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",
]
+78
View File
@@ -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
+55
View File
@@ -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"
+176
View File
@@ -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"
+420
View File
@@ -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/<ref> when ref is a branch; for a SHA this
# falls through naturally because `origin/<sha>` doesn't exist
# but `<sha>` 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
+62
View File
@@ -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."
)
+96
View File
@@ -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)
+231
View File
@@ -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)
+102
View File
@@ -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",
]
@@ -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)
+206
View File
@@ -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
+177
View File
@@ -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:<url>"
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
+103
View File
@@ -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)
+213
View File
@@ -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)
+9
View File
@@ -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
+152
View File
@@ -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)
+73 -1
View File
@@ -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"]
+7
View File
@@ -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
+12
View File
@@ -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
+419
View File
@@ -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
+129
View File
@@ -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",
)
]
+37
View File
@@ -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
+46
View File
@@ -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"
+17
View File
@@ -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
+75
View File
@@ -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")
+19 -8
View File
@@ -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 == ""
View File
+100
View File
@@ -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
+17
View File
@@ -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.
+12
View File
@@ -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"
+10
View File
@@ -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"
@@ -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
+207
View File
@@ -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
+190
View File
@@ -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
+73
View File
@@ -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"
+289
View File
@@ -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"
+245
View File
@@ -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
+309
View File
@@ -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)
+64
View File
@@ -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
+54
View File
@@ -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
+83
View File
@@ -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
+136
View File
@@ -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)
+84
View File
@@ -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()
+167
View File
@@ -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
+57
View File
@@ -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):
+111
View File
@@ -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 <site-packages>/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.
+139
View File
@@ -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()
Generated
+7 -1
View File
@@ -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" }]