mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 00:47:52 +00:00
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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 1–4 GPU-kernel plan in §5–§8 is preserved as the v2/v3 path; v1 ships using upstream Pearl's PyTorch reference and pure-Rust miner.
|
||||
|
||||
## 1. Cold-start brief
|
||||
|
||||
**One-paragraph problem statement.** OpenJarvis is adding a `mining` subsystem (Spec A) that lets users mine the Pearl PoUW blockchain through their local LLM inference. Pearl's reference miner is CUDA-only and bound to NVIDIA Hopper (`sm_90a`, H100/H200) — most OpenJarvis users on Apple Silicon are locked out at the protocol level. **This spec is the plan to unblock them.** The OJ-side integration work is small (a new `MiningProvider` implementation that drops into the existing `MinerRegistry` from Spec A); the substantial work is a Metal port of Pearl's `NoisyGEMM` kernel and a matching plugin into an Apple-native inference backend (MLX or llama.cpp Metal). The Pearl validation path is plonky2-STARK-based and **already hardware-neutral** — see §3 for the evidence — so a correct Metal implementation produces blocks Pearl validators accept without any consensus changes.
|
||||
|
||||
**Three things to know before doing any work.**
|
||||
|
||||
1. The Pearl validator (`pearl/zk-pow/src/api/verify.rs::verify_block`) operates on a STARK proof and references no hardware. **The protocol does not care what GPU produced the work** as long as the math is correct and the proof verifies. CUDA-only is a performance choice, not a consensus choice.
|
||||
2. Pearl's CUDA kernel (`pearl/miner/pearl-gemm/csrc/gemm/`) uses Hopper-only primitives (TMA, WGMMA, thread-block clusters, CUTLASS 3.x). A Metal port is **not a translation** — it's a from-scratch reimplementation against a different programming model. Plan effort accordingly.
|
||||
3. The OJ integration boundary is the `MiningProvider` ABC defined in Spec A §4.4. **Do not modify Spec A.** Add a new provider file (`mining/mlx_pearl.py` or `mining/llamacpp_pearl_metal.py`), implement the ABC, register via `MinerRegistry`, ship a new optional extra. Everything else in Spec A — sidecar shape, config schema, telemetry adapter contract, v2 fee/pool seams — applies unchanged.
|
||||
|
||||
## 1.5. Phase 0 findings — significant scope simplification (2026-05-05)
|
||||
|
||||
Phase 0 investigation produced four findings that reshape this spec. **The original §5–§8 plan (Metal NoisyGEMM kernel + custom MLX/llama.cpp plugin) is preserved as v2/v3, but is no longer required for v1.**
|
||||
|
||||
### 1.5.1 The validator is hardware-neutral (confirmed)
|
||||
|
||||
`zk-pow/src/api/verify.rs::verify_block` and `verify_plain_proof` are pure Rust + plonky2; no CUDA, no GPU paths, no hardware introspection. §3.1's claim is **verified by direct code reading** (see file paths in §11). The protocol accepts blocks from any implementation that produces correct math.
|
||||
|
||||
### 1.5.2 A complete hardware-neutral miner already exists upstream
|
||||
|
||||
`pearl/zk-pow/src/ffi/mine.rs::mine()` is a pure-Rust mining function:
|
||||
|
||||
- Generates random `i8` matrices `A` (m×k) and `B` (k×n) with values in `[-64, 64]`
|
||||
- Computes blake3-derived noise via `circuit/pearl_noise.rs::compute_noise_for_indices`
|
||||
- Performs the noised dot products in tile patterns (`PeriodicPattern.rows_pattern × cols_pattern`)
|
||||
- Hashes the jackpot tile and checks the difficulty target
|
||||
- Returns a `PlainProof` that `verify_plain_proof` accepts
|
||||
|
||||
It is exposed to Python via `py-pearl-mining` (`pearl_mining.mine`). Dependencies: pure Rust (`zk-pow`, `pearl-blake3`, `blake3`, `rayon`, `pyo3`, `tikv-jemallocator`). **No CUDA, no platform-specific code in the Cargo dependency tree.**
|
||||
|
||||
### 1.5.3 A PyTorch reference of the *production* NoisyGEMM also exists upstream
|
||||
|
||||
`pearl/miner/miner-base/src/miner_base/noisy_gemm.py::NoisyGemm` is a complete PyTorch reference of the same NoisyGEMM that vllm-miner accelerates with the H100 CUDA kernel:
|
||||
|
||||
- `noise_A`, `noise_B`, `gemm`, `noisy_gemm` methods replicating the kernel's math in `torch.matmul` calls
|
||||
- The denoising path produces **bit-exact results** versus a vanilla `torch.matmul(A.int32, B.int32)` — verified by the `assert torch.equal(result, expected)` test at `miner/miner-base/tests/test_noisy_gemm.py:92`. The "fp16 tolerance" budget I assumed in the original §5.2 is unnecessary for the int7×int7→int32 protocol path
|
||||
- Dependencies: `torch==2.11.0`, `blake3`, `numpy`, `pearl-gateway`, `py-pearl-mining` — all install on macOS arm64
|
||||
|
||||
### 1.5.4 Empirical confirmation (2026-05-05, on this hardware)
|
||||
|
||||
`py-pearl-mining` was built from the upstream source on the spec author's M2 Max. The build produced `py_pearl_mining-0.1.0-cp312-abi3-macosx_11_0_arm64.whl` in 56 seconds. End-to-end mining cycle:
|
||||
|
||||
```
|
||||
running mine(m=256, n=128, k=1024, rank=32) on Apple Silicon CPU…
|
||||
mine() returned a proof in 0.078s
|
||||
verify_plain_proof: ok=True, msg='Mining solution verified successfully' (0.3 ms)
|
||||
END-TO-END MINING ON APPLE SILICON SUCCEEDED
|
||||
```
|
||||
|
||||
The test difficulty here is `nbits=0x1D2FFFFF` (the test fixture from `py-pearl-mining/tests/test_python_api.py`), much lower than mainnet difficulty — so 78 ms is **per-share at the test difficulty**, not real expected wall-clock per share at the network's current difficulty. But the *correctness* of the path is proven.
|
||||
|
||||
### 1.5.5 Reframed v1 — what to build, what to defer
|
||||
|
||||
| Layer | Original plan (§5–§8) | New v1 plan |
|
||||
|---|---|---|
|
||||
| Reference oracle (Phase 0-B) | Build from scratch in PyTorch, validate against H100 CUDA | **Already exists upstream** — `miner-base.noisy_gemm` + `pearl_mining.mine`. OJ ships a thin wrapper, no reimplementation. |
|
||||
| Inference-backend plugin (Phase 2) | Custom MLX or llama.cpp Metal plugin doing NoisyGEMM | **Deferred to v2.** v1 is **decoupled mining** — mining runs as a separate process via the upstream Rust miner; user's existing inference (Ollama, MLX, llama.cpp) is unaffected. |
|
||||
| Metal NoisyGEMM kernel (Phase 1) | Months of GPU-kernel engineering | **Deferred to v3** as a perf optimization once v1 ships and demand is proven. Original §6.1 content preserved as the v3 plan. |
|
||||
| OJ provider integration (Phase 3) | New `MiningProvider` impl | **v1 ships this** — see §13. `MiningProvider` ABC from Spec A unchanged. |
|
||||
| Bringup + verification (Phase 4) | Hardware matrix + testnet | **v1 ships this** — see §14. Same hardware matrix, simpler scope. |
|
||||
| Pearl coordination (Phase 0-A) | Confirm upstream-vs-fork posture | Still needed (see §12) — but the bar is lower since v1 doesn't require any code from us in Pearl's tree. |
|
||||
|
||||
### 1.5.6 Honest performance expectations for v1
|
||||
|
||||
This is **not** competitive mining. The point of vllm-miner is that it amortizes mining work over LLM inference matmuls (the matmul you're already doing for inference *is* the mining work). v1 here decouples them: your CPU does mining, your GPU does inference. The hashrate will be low. **But it works today, and it ships with a credible upgrade path.** Document this transparently in `mine doctor` and the user guide.
|
||||
|
||||
The v2 (PyTorch-MPS NoisyGEMM coupled with MLX/llama.cpp inference) and v3 (native Metal kernel) work paths in §5–§8 remain the route to competitive Apple Silicon mining. They're explicitly not blocking v1.
|
||||
|
||||
## 2. Why this is its own spec
|
||||
|
||||
Spec A's scope is the v1 integration that ships today on the only working configuration (vLLM + sm90). Apple Silicon enablement is a separate, parallelizable workstream because:
|
||||
|
||||
- **Different ownership boundary.** Spec A is Python integration of an existing Pearl Docker image. Spec B is GPU-kernel engineering with potential upstream contribution to Pearl. These need different reviewers, different CI surfaces (no H100 needed, but Apple Silicon required), and different release cadence.
|
||||
- **Different timeline.** Spec A is weeks. Spec B is plausibly months for the kernel work alone.
|
||||
- **Different blast radius.** Spec A ships zero risk to non-mining users; even mining users who edit the wrong config get a clear error. Spec B carries protocol-correctness risk — a bug in NoisyGEMM produces invalid blocks that get rejected by validators.
|
||||
- **Parallel-agent ergonomics.** The user has explicitly asked for this spec to be picked up by a separate agent in parallel. Self-containment is a design goal.
|
||||
|
||||
## 3. Evidence: Apple Silicon support is possible
|
||||
|
||||
### 3.1 The validator is hardware-neutral
|
||||
|
||||
`pearl/zk-pow/src/api/verify.rs::verify_block`:
|
||||
|
||||
```rust
|
||||
pub fn verify_block(public_params: &PublicProofParams, proof: &ZKProof, cache: &mut CircuitCache) -> Result<()> {
|
||||
let (params, pis) = prepare_verification(public_params, proof, None)?;
|
||||
PearlRecursion::compile_circuits(params, cache, false)?;
|
||||
verify_with_cache(params, cache, &pis, proof)
|
||||
}
|
||||
```
|
||||
|
||||
Verification is `PearlRecursion::verify(params, cache, pis, &proof.plonky2_proof)` — a recursive plonky2 STARK check. No GPU code path, no CUDA dependency. Validator nodes run pure Rust.
|
||||
|
||||
The mining work consists of three things, all of which are mathematical specifications — not implementation specifications:
|
||||
|
||||
1. A NoisyGEMM result whose noise pattern is derived from blake3 of a per-block key
|
||||
2. A blake3 commitment hash over the noised matmul that meets a difficulty target
|
||||
3. A plonky2 STARK proof that ties the result to the commitment
|
||||
|
||||
Any implementation that produces matching outputs is acceptable to the network. **This is the design intent of PoUW** — the work has to be replayable and verifiable, but not hardware-bound.
|
||||
|
||||
### 3.2 The Pearl team explicitly anticipates non-CUDA plugins
|
||||
|
||||
From `pearl/miner/README.md`:
|
||||
|
||||
> "Currently only mining via vLLM is supported, in the future we hope to supply plugins for other LLM inference libraries, like SGLang, TensorRT-LLM, Ollama, ..."
|
||||
|
||||
Apple is not in their list, but the framing — "supply plugins for other LLM inference libraries" — implies the boundary is at the inference backend, not at the consensus protocol. Confirms the architectural read.
|
||||
|
||||
### 3.3 Reference implementation exists in py-pearl-mining
|
||||
|
||||
`pearl/py-pearl-mining/` is a PyO3 crate exposing Pearl mining primitives in Python. **Read it before designing the Metal port** — it likely contains the protocol-relevant constants in a hardware-neutral form, suitable as a reference oracle for Phase 0 testing (§5).
|
||||
|
||||
## 4. Scope
|
||||
|
||||
### 4.1 In scope
|
||||
|
||||
- **Phase 0** (§5): protocol-acceptance verification + Pearl-team coordination + Python reference oracle
|
||||
- **Phase 1** (§6.1): Metal NoisyGEMM kernel — the substantive engineering
|
||||
- **Phase 2** (§6.2): inference-backend plugin — MLX or llama.cpp Metal
|
||||
- **Phase 3** (§7): OJ provider integration — new `MiningProvider` impl, new optional extra, registry hookup
|
||||
- **Phase 4** (§8): verification matrix across Apple Silicon variants and bringup on Pearl testnet
|
||||
- Documentation deliverables and the upstream-contribution path
|
||||
|
||||
### 4.2 Out of scope
|
||||
|
||||
- Pool support and the 20% OJ fee (Spec A §8.5; that lives in a future v2 pool spec)
|
||||
- Custody, signing, or routing Pearl funds (Spec A anti-goal; same here)
|
||||
- AMD ROCm enablement (separate spec, parallel structure to this one)
|
||||
- Intel Arc / Mac Intel / older CUDA enablement (separate specs)
|
||||
- Modifying anything in Spec A. **Spec B is purely additive.**
|
||||
- Pearl protocol changes (none required; see §3.1)
|
||||
|
||||
### 4.3 Explicit non-goal: economic competitiveness
|
||||
|
||||
This spec does not promise that Apple Silicon mining will be **profitable**. The performance gap to a tuned H100 kernel is likely large (§6.1.5 discusses why). What this spec *does* promise: a correct, working Apple Silicon path that's enabled the day the kernel ships, with a transparent doctor surface that tells Mac users honestly what their hashrate looks like. Whether it's worth the electricity is a user decision.
|
||||
|
||||
## 5. Phase 0 — investigation, coordination, reference oracle
|
||||
|
||||
The phase that costs the least and prevents the most rework. Three workstreams in parallel.
|
||||
|
||||
### 5.1 Workstream P0-A: Pearl-side coordination
|
||||
|
||||
**Goal:** Confirm protocol acceptance in writing from Pearl maintainers; align on whether OJ contributes upstream or ships independently.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Open a GitHub Discussion on `pearl-research-labs/pearl`: "Apple Silicon / Metal NoisyGEMM enablement — coordination". Reference Spec B URL.
|
||||
2. Get explicit confirmation from a Pearl maintainer that:
|
||||
- Validator path is hardware-neutral as believed (§3.1).
|
||||
- There is no Pearl-internal Metal port already in flight that would conflict.
|
||||
- LICENSE compatibility allows OJ-authored kernel code to be either contributed upstream (preferred) or distributed alongside OJ.
|
||||
3. Discuss the upstream-vs-fork question. Strong default: **contribute upstream into a new `pearl/miner/pearl-gemm-metal/` crate**, paralleling `pearl-gemm/`, so Pearl owns the kernel long-term and we benefit from their CI and review. Fork only if upstream contribution is blocked.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- [ ] Written confirmation of protocol acceptance
|
||||
- [ ] Agreement on contribution model (upstream / coordinated fork / independent)
|
||||
- [ ] No duplicate-effort risk
|
||||
|
||||
### 5.2 Workstream P0-B: build a reference oracle
|
||||
|
||||
**Goal:** A pure-Python (or pure-Rust) implementation of NoisyGEMM that produces bit-exact-or-fp16-tolerance-bounded outputs versus Pearl's CUDA reference. **Used as the test oracle for Phase 1** — without it, you can't verify the Metal kernel's correctness against a portable baseline.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Read `pearl/miner/pearl-gemm/csrc/gemm/` end-to-end. Catalog the protocol-relevant constants in `pearl_gemm_constants.hpp`:
|
||||
- `kAxEBLScaleFactor = 1 << 14`
|
||||
- `kEARxBpEBScaleFactor = 1 << 12`
|
||||
- `kIntToFp16ScaleFactor = 1 << 12`
|
||||
- `kEBRScaleFactorDenoise`, `kEALScaleFactorDenoise`
|
||||
2. Read `pearl/py-pearl-mining/` to see what's already exposed in Python. If a reference impl already lives there, **use it**; do not duplicate.
|
||||
3. If gaps exist, build them in PyTorch (CPU). Mirror the structure of the CUDA kernels:
|
||||
- `noise_generation.cu` → `noise_generation.py` — derive `EAL`, `EAR`, `EBL`, `EBR` from blake3-of-key + seed
|
||||
- `pearl_gemm` (matmul + noise) → `pearl_gemm.py` — compute `Y_noisy = (A + EAL·EAR) × (B + EBL·EBR)` with the documented scaling
|
||||
- `inner_hash_kernel.cu` → `inner_hash.py` — blake3 commitment over the noised matmul
|
||||
- `denoise_converter.cu` → `denoise.py` — recover `Y_clean = A·B` from `Y_noisy` and the noise components
|
||||
- `pow_utils.hpp` → `pow_check.py` — difficulty target check
|
||||
4. Cross-check: run a corpus of 100+ inputs through the Pearl CUDA reference (on an H100 dev box; see §5.4) and through the Python reference. Assert outputs match within the documented tolerance — most likely **bit-exact for int paths and fp16-tolerance for the denoised result**.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- [ ] `tools/pearl-reference-oracle/` (in OJ repo, or separate repo) builds and tests pass
|
||||
- [ ] Parity confirmed against Pearl CUDA on ≥100 input sets
|
||||
- [ ] Constants table documented in this spec (replace the bullet list above with the verified values)
|
||||
|
||||
### 5.3 Workstream P0-C: Apple-side viability
|
||||
|
||||
**Goal:** Decide between MLX and llama.cpp Metal as the integration host before designing the kernel.
|
||||
|
||||
**Decision criteria:**
|
||||
|
||||
| Factor | MLX (`ml-explore/mlx`, `mlx-lm`) | llama.cpp Metal (`ggerganov/llama.cpp`) |
|
||||
|---|---|---|
|
||||
| Op-replacement hooks | Less mature; would likely require monkey-patching `mlx.nn.Linear` or upstream PR adding plugin hooks | More mature; `ggml` op tree is open and Metal backend has clear extension points (`ggml-metal.metal`) |
|
||||
| Apple-native quantization story | Excellent (4-bit, 8-bit native ops) | Good but not as native |
|
||||
| Inference-quality fidelity for OJ users today | High — MLX-LM is the de facto Mac LLM stack | High — also widely used |
|
||||
| Upstream-contribution complexity | Higher (smaller team, less plugin culture) | Lower (large open community, clear contributor flow) |
|
||||
| Ecosystem alignment with OJ engine map | OJ's `engine/` doesn't currently have an MLX engine; would need both | OJ already has llama.cpp via `engine/openai_compat_engines.py` |
|
||||
|
||||
**Recommendation:** **llama.cpp Metal first**, MLX as a fast-follow. Reasoning: ggml's op tree gives a cleaner extension path for a custom NoisyGEMM op; OJ already has llama.cpp engine wiring; and the upstream-contribution path is more navigable. MLX is a better long-term fit for Apple-native users but is currently a harder integration target.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Spike: implement a no-op "custom op" passthrough in llama.cpp Metal. ~1-2 days work to confirm the integration mechanism is real and the build pipeline cooperates.
|
||||
2. Spike: same in MLX. Compare effort.
|
||||
3. Pick one. Document the decision in this spec.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- [ ] Decision made and documented in §6.2
|
||||
- [ ] Trivial plugin hook proven on the chosen backend
|
||||
|
||||
### 5.4 Hardware required for Phase 0
|
||||
|
||||
- One H100/H200 box (cloud rental fine — Lambda, RunPod, Crusoe). Used for: running Pearl's CUDA reference to capture parity test vectors, running the Pearl Docker miner end-to-end as a known-good baseline.
|
||||
- Apple Silicon dev machines: M2 Max minimum, M3/M4 Pro+ preferred. M-series Ultra ideal for any perf experiments.
|
||||
- Estimated cloud cost for Phase 0: < $200.
|
||||
|
||||
## 6. Phases 1 and 2 — kernel and plugin
|
||||
|
||||
### 6.1 Phase 1 — Metal NoisyGEMM kernel
|
||||
|
||||
**Goal:** A Metal compute-shader implementation of NoisyGEMM that produces outputs matching the Phase 0 reference oracle, performant enough to make Mac mining a real (if low-yield) feature.
|
||||
|
||||
#### 6.1.1 Implementation surface
|
||||
|
||||
Two viable targets, in order of preference:
|
||||
|
||||
**A. Direct Metal Shading Language (MSL) compute kernels.** Maximum control, maximum performance ceiling, maximum effort. The CUDA reference is highly tuned (TMA, WGMMA, multi-stage pipelines); a direct MSL port can lean on Apple's matmul intrinsics where they exist (`simdgroup_matrix` ops on M3+).
|
||||
|
||||
**B. Metal Performance Shaders Graph (MPSGraph).** Higher-level than raw MSL; uses Apple's tuned matmul kernels under the hood; limited control over the in-kernel commitment hash. Likely path: do the matmul via MPSGraph, do noise generation + commitment hashing as separate kernels, accept the perf hit from less fusion.
|
||||
|
||||
**Recommendation:** Start with B for correctness and shipping speed; profile; move hot paths to A only if economically justified. Apple's matmul intrinsics are fast enough that the perf gap to a fused implementation may be acceptable.
|
||||
|
||||
#### 6.1.2 Algorithm structure
|
||||
|
||||
Following the Pearl CUDA reference, end-to-end work performed for one mining attempt:
|
||||
|
||||
1. **Quantize inputs.** `A: fp16 → int8 + scale_A`, `B: fp16 → int8 + scale_B`. Match Pearl's `quantize_kernel.cu` semantics (per-row or per-channel scales — verify via Phase 0 oracle).
|
||||
2. **Generate noise tensors.** From `key_A`, `key_B` (per-block blake3-derived seeds), produce `EAL` (m, R), `EAR` (k, R), `EBL` (k, R), `EBR` (n, R) of int8. Scale factors per `pearl_gemm_constants.hpp`.
|
||||
3. **Noisy matmul.** Compute `Y_noisy = (A + EAL · EAR_T) × (B + EBL · EBR_T)`. Output is int32 then converted to fp16.
|
||||
4. **Inner-hash commitment.** blake3 over `Y_noisy` (or a row-tile of it) to produce the PoW target candidate. This is the hottest path — the noise + commitment loop runs at every share.
|
||||
5. **PoW check.** Compare commitment digest against the difficulty target (`make_pow_target_tensor` semantics from Pearl's Python interface).
|
||||
6. **On hit: denoise.** Compute `Y_clean = Y_noisy - (noise contributions)` to feed back into vLLM/MLX as the actual matmul output. Inference cannot be wrong.
|
||||
7. **Post-hit: STARK proof generation.** When a share meets the network difficulty target, the miner generates a plonky2 STARK proof tying the noisy matmul + commitment to the block. This proving step is **separate from the Metal kernel** — it runs in pure Rust via Pearl's existing `zk-pow/` and `py-pearl-mining` code paths and should work cross-platform unchanged. Cost: seconds-to-minutes of CPU per block. Confirm cross-platform builds during Phase 0-C and §7.5.
|
||||
|
||||
#### 6.1.3 Crate / package layout
|
||||
|
||||
Strong preference: **upstream contribution to Pearl** as `pearl/miner/pearl-gemm-metal/` paralleling the existing `pearl-gemm/`:
|
||||
|
||||
```
|
||||
pearl/miner/pearl-gemm-metal/
|
||||
Cargo.toml (or pyproject.toml + setup.py — match Pearl conventions)
|
||||
metal/ (.metal MSL source files)
|
||||
src/
|
||||
lib.rs (or src/pearl_gemm_metal/__init__.py)
|
||||
tests/
|
||||
```
|
||||
|
||||
If upstream contribution is blocked (Phase 0 outcome), fork with attribution into `OpenJarvis/vendor/pearl-gemm-metal/` and document the divergence policy in this spec.
|
||||
|
||||
#### 6.1.4 Testing
|
||||
|
||||
- **Parity tests.** Each kernel (noise gen, matmul, inner hash, denoise, PoW check) tested independently against the Phase 0 reference oracle. Bit-exact for int paths; fp16-tolerance bounded for fp paths (specific tolerance: TBD via Phase 0 measurement).
|
||||
- **End-to-end correctness.** Full mining attempt produces a candidate proof that the reference Rust prover (`zk-pow/`) accepts.
|
||||
- **Hardware fuzz.** Run on M1 Pro, M2 Max, M3 Max, M4 Max, and M-Ultra variants. Catch any silently-wrong hardware behavior (Metal feature variance across generations is real).
|
||||
|
||||
#### 6.1.5 Performance expectations
|
||||
|
||||
Honest baseline: **expect 0.05–0.2× the share rate of an H100** on a high-end M-Ultra, and proportionally less on smaller chips. Reasons:
|
||||
|
||||
- H100 has dedicated FP8/FP16 tensor cores with WGMMA throughput Apple Silicon does not match
|
||||
- Pearl's CUDA kernel is heavily fused (matmul + noise + commitment in one kernel via TMA pipelining); a Metal version will likely be less fused
|
||||
- 70B model bandwidth requirements stress unified memory
|
||||
|
||||
This is fine. Mac mining is a feature for Apple Silicon owners who want to participate, not a competitive yield product. Document it transparently in `mine doctor` and the user guide.
|
||||
|
||||
#### 6.1.6 Exit criteria for Phase 1
|
||||
|
||||
- [ ] Parity tests pass on M2 Max and M4 Max
|
||||
- [ ] End-to-end mining attempt produces a valid proof accepted by `zk-pow::verify_block`
|
||||
- [ ] Performance characterized and published (M-series matrix)
|
||||
- [ ] Code merged upstream OR forked-with-policy per Phase 0 outcome
|
||||
|
||||
### 6.2 Phase 2 — Inference-backend plugin
|
||||
|
||||
**Goal:** A llama.cpp Metal (or MLX, per Phase 0-C) plugin that swaps the standard quantized linear op for Phase 1's NoisyGEMM during inference, so a Mac running this plugin produces both correct LLM outputs and valid mining shares.
|
||||
|
||||
#### 6.2.1 Path: llama.cpp Metal (assuming Phase 0-C selected this)
|
||||
|
||||
- Add a custom `ggml` op `GGML_OP_PEARL_NOISY_GEMM` with a Metal backend implementation that calls Phase 1's kernels.
|
||||
- Plugin entry point: a small library that, when loaded, replaces the default linear op in the model graph during loading.
|
||||
- Build artifact: `libpearl_metal_plugin.dylib` (or static lib).
|
||||
|
||||
#### 6.2.2 Path: MLX (alternate)
|
||||
|
||||
- Define `mlx.NoisyLinear` as a subclass of `mlx.nn.Linear` that calls Phase 1's kernels via a custom Metal op binding.
|
||||
- Provide a model-loading shim: `from openjarvis.mining import patch_mlx_for_pearl; patch_mlx_for_pearl()` that monkey-patches `mlx.nn.Linear` instances at load time. Less elegant; works.
|
||||
|
||||
#### 6.2.3 Inference-quality regression tests
|
||||
|
||||
The plugin is correctness-critical: a noised model that doesn't fully denoise produces degraded responses. Test:
|
||||
|
||||
- Load a small reference model (e.g., a 1-3B parameter Pearl-blessed model if one exists for testing, otherwise the smallest model the protocol accepts).
|
||||
- Run a fixed prompt set through both noised+denoised and standard paths.
|
||||
- Assert outputs are bit-exact or within fp16 tolerance.
|
||||
- Run OJ's existing eval framework (`src/openjarvis/evals/`) on a small benchmark (e.g., an MMLU subset registered as a Pearl-mining-mode dataset). Assert no degradation > the tolerance budget. Falling back to `lm-eval-harness` is acceptable if OJ's eval surface for Mac is incomplete at the time.
|
||||
|
||||
#### 6.2.4 Exit criteria
|
||||
|
||||
- [ ] Plugin loads in chosen backend
|
||||
- [ ] End-to-end inference produces correct outputs (regression tests pass)
|
||||
- [ ] Mining shares are submitted to a Pearl testnet during inference
|
||||
- [ ] At least one block found on testnet from a Mac
|
||||
|
||||
## 7. Phase 3 — OpenJarvis provider integration
|
||||
|
||||
Where the OJ-side work is small. Inherits the entire `MiningProvider` ABC, registry, sidecar, config schema, telemetry adapter, and v2 seams from Spec A unchanged.
|
||||
|
||||
### 7.1 New files
|
||||
|
||||
```
|
||||
src/openjarvis/mining/
|
||||
llamacpp_pearl_metal.py # OR mlx_pearl.py — depending on Phase 2 path
|
||||
# @MinerRegistry.register("llamacpp-pearl-metal")
|
||||
# implements MiningProvider ABC from Spec A §4.4
|
||||
```
|
||||
|
||||
### 7.2 New optional extra
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
mining-pearl-metal = [
|
||||
"pearl-metal-plugin>=0.1", # the Phase 2 plugin, however published
|
||||
# MLX path adds: "mlx>=0.X", "mlx-lm>=0.X"
|
||||
# llama.cpp path adds: "llama-cpp-python>=0.X" with Metal extras
|
||||
]
|
||||
```
|
||||
|
||||
### 7.3 Capability detection
|
||||
|
||||
```python
|
||||
# src/openjarvis/mining/llamacpp_pearl_metal.py
|
||||
class LlamaCppPearlMetalProvider(MiningProvider):
|
||||
provider_id = "llamacpp-pearl-metal"
|
||||
|
||||
@classmethod
|
||||
def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities:
|
||||
if hw.platform != "darwin":
|
||||
return MiningCapabilities(False, reason="Apple Silicon required (platform != darwin)")
|
||||
if hw.gpu is None or hw.gpu.vendor != "apple":
|
||||
return MiningCapabilities(False, reason="Apple Silicon GPU required")
|
||||
if engine_id not in {"llamacpp", "llama-cpp"}:
|
||||
return MiningCapabilities(False, reason=f"engine '{engine_id}' has no Pearl Metal plugin; use llamacpp")
|
||||
if not _pearl_metal_plugin_available():
|
||||
return MiningCapabilities(False, reason="install with `uv sync --extra mining-pearl-metal`")
|
||||
if not _model_has_pearl_variant(model):
|
||||
return MiningCapabilities(False, reason=f"model '{model}' has no Pearl-blessed variant")
|
||||
return MiningCapabilities(True, estimated_hashrate=_estimate_hashrate(hw))
|
||||
```
|
||||
|
||||
Each branch is exactly the kind of "why can't I mine" message Spec A's `mine doctor` surfaces verbatim.
|
||||
|
||||
### 7.4 Lifecycle
|
||||
|
||||
Unlike Spec A's vLLM provider which orchestrates a Docker container, the Apple provider runs **two coordinated subprocesses directly on the host**:
|
||||
|
||||
1. The inference server (llama.cpp server with the Pearl Metal plugin loaded, or MLX-LM server depending on Phase 0-C path)
|
||||
2. `pearl-gateway` as a sibling process — same one that runs inside the Docker container in Spec A, but here it runs natively on the Mac
|
||||
|
||||
Lifecycle:
|
||||
|
||||
- `start()`: spawn (1) with the Pearl Metal plugin pre-loaded (`DYLD_INSERT_LIBRARIES`-style or `--plugin` flag depending on chosen backend's invocation contract), then spawn (2) pointing at it. Write the same sidecar shape Spec A defines, with `gateway_url` pointing at the native pearl-gateway. Track both PIDs internally.
|
||||
- `stop()`: SIGTERM (2) first, then (1), with bounded waits and SIGKILL fallback. Remove sidecar.
|
||||
- `is_running()`, `stats()`: identical contract to vLLM provider; `stats()` reads from the native pearl-gateway's `:8339/metrics`.
|
||||
|
||||
**No Docker.** Apple Silicon Docker doesn't pass through Metal; running Pearl in a Mac Docker container would defeat the purpose. Document this explicitly in §7 of this spec; do not attempt a Docker path.
|
||||
|
||||
### 7.5 Pearl gateway on Mac
|
||||
|
||||
The Pearl `pearl-gateway` process is currently only documented as part of the Docker container. For Mac, we need it to run natively. Two options:
|
||||
|
||||
1. Build `pearl-gateway` from source via `uv sync --package pearl-gateway` — same workspace package the Docker image uses. Should work cross-platform since it's pure Python plus py-pearl-mining bindings. Verify.
|
||||
2. If (1) fails on Apple Silicon, work with Pearl maintainers (Phase 0-A) to port it — a small amount of work compared to the kernel.
|
||||
|
||||
**Phase 3 verifies (1).** This is a Phase 0-A coordination point.
|
||||
|
||||
### 7.6 Exit criteria
|
||||
|
||||
- [ ] `LlamaCppPearlMetalProvider` registered, detection matrix correct on M1/M2/M3/M4
|
||||
- [ ] `jarvis mine init` runs to completion on Apple Silicon
|
||||
- [ ] `jarvis mine start` launches subprocess + Pearl gateway on Mac
|
||||
- [ ] `jarvis mine status` returns valid `MiningStats` from a real Mac mining session
|
||||
- [ ] `jarvis mine doctor` produces honest, actionable output for Mac users
|
||||
|
||||
## 8. Phase 4 — Verification & bringup
|
||||
|
||||
### 8.1 Hardware matrix
|
||||
|
||||
| Chip | Test priority | Expected outcome |
|
||||
|---|---|---|
|
||||
| M1 / M1 Pro / M1 Max | low — generation 1 GPU may have feature gaps | works but slow |
|
||||
| M2 / M2 Pro / M2 Max | medium | works |
|
||||
| M2 Ultra | medium | best M2-class hashrate |
|
||||
| M3 / M3 Pro / M3 Max | high — first gen with `simdgroup_matrix` | works, meaningful share rate |
|
||||
| M4 / M4 Pro / M4 Max | high — current flagship | best non-M-Ultra hashrate |
|
||||
|
||||
For each chip in the matrix, run:
|
||||
|
||||
1. `jarvis mine init` end-to-end
|
||||
2. `jarvis mine start` and run for ≥4 h continuous
|
||||
3. Capture and publish: shares submitted, shares accepted, block-find time distribution, GPU temp, system load impact on normal use
|
||||
4. Run a parallel `lm-eval-harness` on the mining endpoint to assert inference quality is unaffected
|
||||
|
||||
### 8.2 Pearl testnet bringup
|
||||
|
||||
Before any mainnet recommendation:
|
||||
|
||||
- Mine on Pearl testnet for ≥7 continuous days from at least two Apple Silicon variants
|
||||
- Find at least one block on testnet from each variant
|
||||
- Verify all blocks accepted by `zk-pow::verify_block` on a reference validator node
|
||||
- Report results to Pearl maintainers; gate any mainnet announcement on their sign-off
|
||||
|
||||
### 8.3 Documentation deliverables
|
||||
|
||||
- `docs/user-guide/mining-apple-silicon.md` — user-facing: prerequisites, install flow, doctor reading guide, performance expectations table, links to share-rate calculators
|
||||
- `docs/development/mining-providers.md` — generalized "how to add a new provider" guide using this spec as the canonical worked example
|
||||
- An update to `docs/user-guide/mining.md` (Spec A) adding Apple Silicon to the supported-platforms list
|
||||
|
||||
### 8.4 Exit criteria
|
||||
|
||||
- [ ] Hardware matrix covered
|
||||
- [ ] Testnet bringup complete
|
||||
- [ ] Documentation merged
|
||||
- [ ] Pearl maintainer sign-off obtained
|
||||
- [ ] OJ release notes call out Apple Silicon mining as supported
|
||||
|
||||
## 9. Risks
|
||||
|
||||
| ID | Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|---|
|
||||
| R1 | Pearl validator rejects non-CUDA-mined blocks despite hardware-neutral validator code | low (validator code reviewed) | catastrophic (whole spec invalid) | Phase 0-A explicit confirmation; Phase 0-B oracle reduces likelihood of math drift |
|
||||
| R2 | Pearl ships their own Metal port, conflicts with OJ's | medium (depends on Pearl roadmap) | high (rework or fork) | Phase 0-A coordination; default to upstream contribution |
|
||||
| R3 | Metal NoisyGEMM is so slow that mining is uneconomical even for hobbyists | medium-high | medium (feature ships but unused) | §4.3 names this as a non-goal; transparency in `mine doctor`; consider M-Ultra-only-by-default in v1 of this spec |
|
||||
| R4 | NoisyGEMM correctness bug → invalid blocks → wasted user electricity | low if §6.1.4 testing rigorous | high (trust hit) | Strong parity testing against oracle; testnet bringup before mainnet |
|
||||
| R5 | Inference-quality regression — denoised path doesn't fully recover model fidelity | medium | high | §6.2.3 regression tests; eval-harness gate before ship |
|
||||
| R6 | Pearl protocol changes between Phase 0 and Phase 4 (multi-month) | medium | medium | Pin Phase 0 ref same as Spec A; renegotiate at each Pearl rev |
|
||||
| R7 | Apple changes Metal API in macOS update | low-medium | medium | Use stable MSL features; pin Xcode toolchain |
|
||||
| R8 | Upstream Pearl PR rejected | low (Pearl wants this) | medium (forced fork) | Phase 0-A negotiates upstream-vs-fork up front |
|
||||
| R9 | `pearl-gateway` doesn't build on Apple Silicon (§7.5) | medium (it's Python — should work, but py-pearl-mining has Rust deps) | low (small fix) | Phase 0 verifies builds; Phase 0-A coordination if not |
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
Phase 0 answered most of these from the upstream code (annotations below). The remaining open items are ones that require either Pearl maintainer input or empirical measurement on real network conditions.
|
||||
|
||||
1. **(Open — coordination)** Is there a Pearl-blessed "small" model for testing? The reference miner uses a 70B model — too big for fast iteration. A 7B or 13B variant for development would dramatically speed up v2/v3 plugin work. *Less critical for v1, since v1 is decoupled from inference and runs `mine()` directly without a model.*
|
||||
2. **(Answered — N/A for v1)** ~~Documented fp16 tolerance budget for denoised matmul output~~ — `miner-base/tests/test_noisy_gemm.py:92` does `torch.equal(result, expected)`: the int7×int7→int32 path is **bit-exact**, no fp16 tolerance budget is needed. (May reappear in v3 Metal kernel work if int↔fp16 conversions are introduced for perf.)
|
||||
3. **(Answered — yes)** Does `py-pearl-mining` already expose enough of NoisyGEMM in Python that Phase 0-B becomes a thin wrapper? **Yes.** `pearl_mining.mine` runs the entire mining algorithm in pure Rust. Additionally, `miner-base.NoisyGemm` provides a PyTorch reference of the production NoisyGEMM. Phase 0-B's "build a reference oracle" deliverable is now a *thin OJ-side wrapper* that calls upstream — see §13 and `tools/pearl-reference-oracle/` (created in this session).
|
||||
4. **(Answered — likely yes; empirically verified for `py-pearl-mining`)** Is `pearl-gateway` cross-platform? Its `pyproject.toml` requires Python ≥ 3.10 and depends on `aiohttp`, `bitcoin-utils`, `blake3`, `numpy`, `prometheus-client`, `pybase64`, `pydantic`, `pyyaml`, `torch==2.11.0`, `py-pearl-mining` — all install on macOS arm64. Empirical install of the workspace was not run in this session; **action item for v1 implementation**: `uv sync` the workspace on macOS-15 and capture the build output.
|
||||
5. **(Open — coordination)** Does Pearl gate any difficulty / consensus parameters on hardware introspection? Code review found none. Confirm in Phase 0-A discussion.
|
||||
6. **(Open — measurement)** Minimum acceptable hashrate floor for `jarvis mine init` on Apple Silicon. The `MiningCapabilities.estimated_hashrate` field in Spec A §4.4 exists for this. v1 will populate from a calibration run during `mine init`. The *floor* is a policy decision, not a technical one — defer to user-research / community feedback once v1 ships.
|
||||
7. **(Open — coordination)** Upstream contribution / CLA / LICENSE. Pearl is ISC; OJ is Apache-2.0; both are permissive and combine cleanly. **CLA TBD via Phase 0-A discussion**, but for v1 this is moot — OJ contributes no code into Pearl's tree, only consumes their published Python packages.
|
||||
8. **(Answered — yes)** Apple Silicon CI on GitHub Actions: `macos-14` / `macos-15` runners are arm64 and can install `py-pearl-mining` via the wheel build verified in §1.5.4. OJ's CI can run mining unit tests. Mining the *real* network in CI is still out of scope.
|
||||
9. **(Answered — `"llamacpp"`)** OJ's llama.cpp engine_id is `"llamacpp"` (single token, no hyphen). Confirmed at `src/openjarvis/engine/openai_compat_engines.py:9` and `src/openjarvis/engine/_discovery.py:18`. Update §7.3 capability detection to use this key. *(For v1 in §13, this only matters if we add an "informational" mining-aware hint to the existing llamacpp engine — v1 does not require any plugin into the engine.)*
|
||||
10. **(Open — measurement, but de-risked)** plonky2 STARK proving latency on Apple Silicon CPU. Spec A §1 already notes proving is seconds-to-minutes of CPU per block (cross-platform, runs unchanged). For v1 the hashrate is so low that block-find latency is dominated by the search, not the proof. Empirical measurement still needed for v2/v3.
|
||||
11. **(New — v1 specific)** Does `bitcoin-utils>=0.7.0` (a `pearl-gateway` dependency) have C extensions that need Apple-specific build flags? Likely pure-Python; verify during the v1 install workstream.
|
||||
12. **(New — v1 specific)** Will `torch==2.11.0` (the version `miner-base` and `pearl-gateway` pin) install cleanly on macOS arm64? PyTorch generally has arm64 macOS wheels. Verify during v1 install.
|
||||
|
||||
## 11. Cross-references
|
||||
|
||||
- **[Spec A](2026-05-05-vllm-pearl-mining-integration-design.md)** — the v1 integration this extends. Read §4.4 (the `MiningProvider` ABC), §5.3 (sidecar shape), §8.1–8.2 (telemetry adapter contract), §8.5 (v2 fee/pool seams). All apply unchanged.
|
||||
- **Pearl coordination thread (P0-A draft):** [`2026-05-05-pearl-coordination-discussion-draft.md`](2026-05-05-pearl-coordination-discussion-draft.md) — content the user posts on `pearl-research-labs/pearl` to confirm protocol acceptance and align on contribution model.
|
||||
- **OJ-side Phase 0 deliverables (created this session):**
|
||||
- `tools/pearl-reference-oracle/` — thin Python wrapper around upstream Pearl bindings + smoke test, runnable on Apple Silicon
|
||||
- **Pearl repo paths read in Phase 0 (in priority order):**
|
||||
1. `pearl/zk-pow/src/api/verify.rs` — the validator. Pure Rust, no GPU. **Hardware-neutrality verified.**
|
||||
2. `pearl/zk-pow/src/api/proof.rs` — `PublicProofParams`, `ZKProof`, `PrivateProofParams`, `IncompleteBlockHeader`, `MiningConfiguration`, `MMAType`. Defines what the protocol commits to.
|
||||
3. `pearl/zk-pow/src/ffi/mine.rs` — **the entire hardware-neutral mining function**. Pure Rust. Already exposed to Python.
|
||||
4. `pearl/zk-pow/src/circuit/pearl_noise.rs` — noise generation: `compute_noise_for_indices`, `generate_uniform_random_matrix`, `generate_permutation_matrix`. Hardware-neutral.
|
||||
5. `pearl/py-pearl-mining/src/lib.rs` — PyO3 module. Re-exports `mine`, `verify_plain_proof`, `generate_proof`, `verify_proof`, `warmup_prove`. **Builds on macOS arm64, verified §1.5.4.**
|
||||
6. `pearl/py-pearl-mining/Cargo.toml` — pure Rust deps: `pearl-blake3`, `zk-pow`, `blake3`, `rayon`, `pyo3`, `lazy_static`, `tikv-jemallocator`. No CUDA in tree.
|
||||
7. `pearl/py-pearl-mining/tests/test_python_api.py` — the canonical end-to-end test. Use as the OJ smoke-test template.
|
||||
8. `pearl/miner/miner-base/src/miner_base/noisy_gemm.py` — PyTorch reference of the production NoisyGEMM. The "reference oracle" §5.2 wanted to build is here.
|
||||
9. `pearl/miner/miner-base/src/miner_base/noise_generation.py` — PyTorch noise generation matching `pearl_noise.rs`.
|
||||
10. `pearl/miner/miner-base/src/miner_base/inner_hash.py` — PyTorch inner-hash with XOR reduction.
|
||||
11. `pearl/miner/miner-base/tests/test_noisy_gemm.py` — bit-exact denoising verified at line 92.
|
||||
12. `pearl/miner/miner-base/pyproject.toml` — deps (`torch==2.11.0`, `blake3`, `numpy`, `pearl-gateway`, `py-pearl-mining`); **no platform markers** → installs on Apple Silicon.
|
||||
13. `pearl/miner/pearl-gateway/pyproject.toml` — deps (pure Python + py-pearl-mining + torch); **no platform markers**.
|
||||
14. `pearl/miner/vllm-miner/src/vllm_miner/register.py` — vLLM plugin registration via `vllm.general_plugins` entry point. The pattern Phase 2 (v2 plan) would mirror.
|
||||
15. `pearl/miner/pearl-gemm/csrc/gemm/pearl_gemm_constants.hpp` — protocol scale factors. Verified values:
|
||||
- `kAxEBLScaleFactor = 1<<14 = 16384`
|
||||
- `kEARxBpEBScaleFactor = 1<<12 = 4096`
|
||||
- `kIntToFp16ScaleFactor = 1<<12 = 4096`
|
||||
- `kEBRScaleFactorDenoise = -4` (= -kAxEBLScaleFactor / kIntToFp16ScaleFactor)
|
||||
- `kEALScaleFactorDenoise = -1` (= -kEARxBpEBScaleFactor / kIntToFp16ScaleFactor)
|
||||
16. `pearl/miner/pearl-gemm/setup.py:88` — `COMPUTE_CAPABILITY = "arch=compute_90a,code=sm_90a"`. Confirms CUDA kernel is Hopper-only.
|
||||
17. `pearl/Taskfile.yml` — `build:miner` task is gated to `platforms: [linux, windows]`. **The miner Python install path Pearl ships today is Linux/Windows-only**; OJ's v1 path uses the components that *do* install on macOS, sidestepping this gate.
|
||||
- **Pearl paper:** [Proof-of-Useful-Work via matrix multiplication (arXiv:2504.09971)](https://arxiv.org/abs/2504.09971) — read for the math formalization. Less critical now that the PyTorch reference exists upstream.
|
||||
- **Apple references (still relevant for v2/v3):**
|
||||
- [Metal Shading Language Specification](https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf)
|
||||
- [MPS / MPSGraph documentation](https://developer.apple.com/documentation/metalperformanceshadersgraph)
|
||||
- [MLX](https://github.com/ml-explore/mlx) — alternative v2 plugin host
|
||||
- [PyTorch MPS backend docs](https://pytorch.org/docs/stable/notes/mps.html) — relevant for v2 (MPS-accelerated decoupled mining)
|
||||
|
||||
## 12. Implementation plan
|
||||
|
||||
Two implementation plans now live alongside this spec:
|
||||
|
||||
- **v1 plan (decoupled CPU mining via upstream Pearl):** Written via `superpowers:writing-plans` after this Phase 0 update. Tracking issue: [`2026-05-05-apple-silicon-pearl-mining-plan-v1.md`](2026-05-05-apple-silicon-pearl-mining-plan-v1.md).
|
||||
- **v2 plan (PyTorch-MPS or MLX/llama.cpp coupled mining):** TBD. Written when v1 ships and we have empirical hashrate data justifying the next investment.
|
||||
- **v3 plan (native Metal NoisyGEMM kernel):** TBD. Written only if v2 measurements show the additional kernel work is economically justified.
|
||||
|
||||
The original §5–§8 content describing Phases 0–4 of the *kernel-first* approach is preserved as the v3 plan's reference. Do not delete it — when the time comes to write the v3 plan, that content is the starting point.
|
||||
|
||||
## 13. Apple Silicon v1 — minimal path
|
||||
|
||||
This section defines the v1 design that ships in weeks rather than months. v1 is **decoupled mining**: the user's existing inference workflow (Ollama, MLX-LM, llama.cpp, vLLM-on-CPU, anything) is untouched; mining runs as a separate process via the upstream Pearl miner.
|
||||
|
||||
### 13.1 Architecture
|
||||
|
||||
```
|
||||
OpenJarvis user (Apple Silicon)
|
||||
┌────────────────────────────────┐
|
||||
│ jarvis mine start │
|
||||
│ ↓ │
|
||||
│ CpuPearlProvider (this spec) │
|
||||
│ ↓ subprocess.Popen │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ pearl-gateway (Python) │ │
|
||||
│ │ ↑ JSON-RPC :8337 │ │
|
||||
│ │ pearl-mine-loop (Python) │ │ ← uses py-pearl-mining
|
||||
│ │ wraps pearl_mining.mine│ │ (pure Rust)
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ Inference (untouched) │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ Ollama / MLX / llamacpp │ │
|
||||
│ └──────────────────────────┘ │
|
||||
└────────────────────────────────┘
|
||||
↓
|
||||
pearld (BYO, same as Spec A)
|
||||
```
|
||||
|
||||
The mining loop wraps `pearl_mining.mine()` in a process that:
|
||||
|
||||
1. Polls `pearl-gateway` for current `IncompleteBlockHeader` + `MiningConfiguration`
|
||||
2. Calls `pearl_mining.mine(m, n, k, header, config)` to find a `PlainProof`
|
||||
3. Submits the proof back to `pearl-gateway`, which generates the ZK proof and forwards to `pearld`
|
||||
4. Loops
|
||||
|
||||
This is the *same* control flow vllm-miner runs — just without coupling the matmul to vLLM's inference. Pearl's existing `pearl-gateway` already does the orchestration we need; we just need a small mining loop that uses the CPU `mine()` instead of the CUDA path.
|
||||
|
||||
### 13.2 Module layout in OJ
|
||||
|
||||
```
|
||||
src/openjarvis/mining/
|
||||
cpu_pearl.py # @MinerRegistry.register("cpu-pearl") — v1 provider
|
||||
_pearl_subprocess.py # PearlSubprocessLauncher — gateway + miner subprocesses
|
||||
# Reused for future Apple-MPS / Metal providers
|
||||
src/openjarvis/cli/
|
||||
# mine_cmd.py is unchanged; cpu-pearl participates via the provider ABC
|
||||
|
||||
tests/mining/test_cpu_pearl.py
|
||||
tools/pearl-reference-oracle/
|
||||
README.md # documentation: oracle exists upstream
|
||||
smoke_test.py # end-to-end mine + verify smoke test (created in this session)
|
||||
```
|
||||
|
||||
### 13.3 Optional extra
|
||||
|
||||
```toml
|
||||
mining-pearl-cpu = [
|
||||
"py-pearl-mining>=0.1", # the wheel built in §1.5.4
|
||||
"miner-base>=0.1", # PyTorch reference (used for parity testing)
|
||||
"pearl-gateway>=0.1", # gateway service
|
||||
]
|
||||
```
|
||||
|
||||
When Pearl publishes these as PyPI wheels, the install is `uv sync --extra mining-pearl-cpu`. Until then, the spec for the implementation plan covers the local-build fallback (clone Pearl at the pinned ref, `maturin build` `py-pearl-mining`, `uv pip install` the workspace packages from local paths).
|
||||
|
||||
### 13.4 Capability detection
|
||||
|
||||
```python
|
||||
# src/openjarvis/mining/cpu_pearl.py
|
||||
class CpuPearlProvider(MiningProvider):
|
||||
provider_id = "cpu-pearl"
|
||||
|
||||
@classmethod
|
||||
def detect(cls, hw: HardwareInfo, engine_id: str, model: str) -> MiningCapabilities:
|
||||
# cpu-pearl is engine-independent — it doesn't plug into inference
|
||||
if not _pearl_mining_available():
|
||||
return MiningCapabilities(False, reason="install with `uv sync --extra mining-pearl-cpu`")
|
||||
if not _pearl_gateway_available():
|
||||
return MiningCapabilities(False, reason="pearl-gateway package not installed")
|
||||
if hw.platform not in {"darwin", "linux"}:
|
||||
return MiningCapabilities(False, reason=f"platform '{hw.platform}' not yet supported")
|
||||
# Optional: hardware-specific hashrate estimates
|
||||
return MiningCapabilities(True, estimated_hashrate=_estimate_cpu_hashrate(hw))
|
||||
```
|
||||
|
||||
The `engine_id` parameter is ignored because v1 is decoupled — mining works with **any** OJ engine, including no engine at all. (A future Apple-coupled provider would inspect `engine_id` to require `"llamacpp"` or `"mlx"`.)
|
||||
|
||||
### 13.5 Lifecycle (from Spec A's `MiningProvider` ABC)
|
||||
|
||||
- `start(config)`: spawn (1) `pearl-gateway` and (2) `pearl-mine-loop` subprocesses. Wait for gateway readiness on `:8339/metrics`. Write the standard sidecar JSON (Spec A §5.3) with `provider="cpu-pearl"`, gateway URL, and PIDs of both subprocesses.
|
||||
- `stop()`: SIGTERM mining loop, then gateway. Bounded waits, SIGKILL fallback.
|
||||
- `is_running()`: check sidecar + both PIDs.
|
||||
- `stats()`: read from `pearl-gateway`'s `:8339/metrics` exactly as Spec A §8.1 specifies. **Same metrics adapter contract.** No code changes in OJ's gateway-metrics adapter.
|
||||
|
||||
### 13.6 Configuration
|
||||
|
||||
Inherits Spec A's `[mining]` config schema unchanged. v1 uses:
|
||||
|
||||
```toml
|
||||
[mining]
|
||||
provider = "cpu-pearl" # NEW: was "vllm-pearl" in Spec A
|
||||
wallet_address = "prl1q..."
|
||||
submit_target = "solo"
|
||||
fee_bps = 0
|
||||
fee_payout_address = ""
|
||||
|
||||
[mining.extra]
|
||||
gateway_port = 8337
|
||||
metrics_port = 8339
|
||||
pearld_rpc_url = "http://localhost:44107"
|
||||
pearld_rpc_user = "rpcuser"
|
||||
pearld_rpc_password_env = "PEARLD_RPC_PASSWORD"
|
||||
# v1-specific: matmul shape for the search loop
|
||||
m = 256
|
||||
n = 128
|
||||
k = 1024
|
||||
rank = 32
|
||||
```
|
||||
|
||||
The `m / n / k / rank` shape can be tuned per Phase 0-A measurement (or per chip). Larger shapes search more space per call but use more memory.
|
||||
|
||||
### 13.7 Doctor surface (Apple Silicon)
|
||||
|
||||
```
|
||||
$ jarvis mine doctor
|
||||
Hardware
|
||||
GPU vendor apple ✓
|
||||
Apple chip M2 Max ✓
|
||||
Unified memory 96 GB ✓
|
||||
Pearl install
|
||||
py-pearl-mining 0.1.0 (cp312-abi3-macos-arm64) ✓
|
||||
miner-base 0.1.0 ✓
|
||||
pearl-gateway 0.1.0 ✓
|
||||
Pearl node
|
||||
RPC http://localhost:44107 ✓
|
||||
Auth ok ✓
|
||||
Block height 442107 (synced) ✓
|
||||
Wallet
|
||||
Address format prl1q... ✓
|
||||
Provider capability
|
||||
cpu-pearl SUPPORTED (est. 0.X share/h on M2 Max)
|
||||
Notes
|
||||
- This is decoupled mining: your normal LLM inference is unaffected
|
||||
- Hashrate is far below H100 mining; see docs/user-guide/mining-apple-silicon.md
|
||||
- Metal-accelerated mining: planned for v2; not available yet
|
||||
Session
|
||||
Sidecar absent (not running)
|
||||
```
|
||||
|
||||
Each row maps to a check function in `mining/_discovery.py`. The "est. share/h" line is populated from a one-time calibration during `mine init` — runs `pearl_mining.mine` in a 30-second loop and extrapolates.
|
||||
|
||||
### 13.8 v1 anti-goals
|
||||
|
||||
- **No coupling to inference.** The user's MLX-LM / Ollama / llama.cpp inference is untouched. v1 does not introduce a custom matmul. The "use AI = mine" narrative is **explicitly deferred to v2**.
|
||||
- **No Metal kernel.** All math is in upstream Rust + PyTorch + Python. Zero MSL written.
|
||||
- **No Pearl tree changes.** We consume their published packages; we contribute zero code into Pearl's repo for v1. (Phase 0-A discussion still happens — but it's lower-stakes since we're a downstream consumer in v1, not a contributor.)
|
||||
- **No upstream PRs blocking v1 ship.** v1 ships against the Pearl ref pinned in `mining/_constants.py` (Spec A §6) regardless of whether any of our coordination questions are answered.
|
||||
|
||||
### 13.9 v1 exit criteria
|
||||
|
||||
- [ ] `mining-pearl-cpu` extra installs cleanly on macOS arm64 (M1, M2, M3, M4 — at minimum the chip the spec author owns)
|
||||
- [ ] `jarvis mine init` completes successfully on macOS arm64
|
||||
- [ ] `jarvis mine start` launches gateway + miner subprocesses; sidecar valid; `mine status` reports live data
|
||||
- [ ] `mine doctor` produces honest, actionable output for Mac users
|
||||
- [ ] At least one block found on Pearl testnet from at least one Apple Silicon variant
|
||||
- [ ] User-facing doc `docs/user-guide/mining-apple-silicon.md` ships, including the honest hashrate caveat
|
||||
|
||||
### 13.10 Out of v1, into v2/v3
|
||||
|
||||
- **v2 (months):** Re-route `noisy_gemm` math to PyTorch-MPS for Apple Silicon GPU acceleration; integrate as a plugin into MLX-LM or `llama-cpp-python` so inference matmuls produce mining work (preserving the "use AI = mine" narrative). The original §5–§8 plan applies, swapped to use PyTorch MPS instead of raw MSL.
|
||||
- **v3 (months — optional, only if v2 perf is insufficient):** Native Metal Shading Language NoisyGEMM kernel as an upstream Pearl contribution. The original §5–§8 plan applies as written.
|
||||
|
||||
## 14. Phase 0 deliverables status (this session, 2026-05-05)
|
||||
|
||||
Tracking what was actually produced, against the §5 Phase 0 plan and the §1.5 reframing.
|
||||
|
||||
| Workstream | Original plan | Status | Deliverable |
|
||||
|---|---|---|---|
|
||||
| P0-A | Open Pearl GitHub Discussion, get protocol-acceptance confirmation | Draft written; user posts | `docs/design/2026-05-05-pearl-coordination-discussion-draft.md` |
|
||||
| P0-B | Build reference oracle from scratch in PyTorch, validate against H100 CUDA | **Reference oracle exists upstream.** Built thin OJ-side wrapper + verified empirically that `pearl_mining.mine` runs on Apple Silicon (78 ms / proof at test difficulty) | `tools/pearl-reference-oracle/` |
|
||||
| P0-C | Decide MLX vs llama.cpp Metal | **Deferred to v2.** v1 doesn't need either. | — |
|
||||
| Spec update | Capture findings | Done | This document, §1.5, §10–§14 |
|
||||
| v1 implementation plan | Plan written via `superpowers:writing-plans` after Phase 0 | Pending | `2026-05-05-apple-silicon-pearl-mining-plan-v1.md` (next deliverable) |
|
||||
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 30–60 min on first init.
|
||||
|
||||
### 7.2 Hybrid image acquisition
|
||||
|
||||
| Mode | Behavior | When |
|
||||
|---|---|---|
|
||||
| **Pre-built pull** | OJ `docker pull`s the configured tag if it resolves in a registry | Default once Pearl publishes; users with private registry; CI |
|
||||
| **Build-from-pin** | OJ git-clones Pearl at a pinned ref into `~/.openjarvis/cache/pearl/`, then `docker buildx build` | v1 default (Pearl publishes nothing today) |
|
||||
| **BYO image** | User sets `mining.extra.docker_image_tag` to an image they built/pulled themselves | Power users, air-gapped envs |
|
||||
|
||||
Selection logic in `_docker.PearlDockerLauncher.ensure_image()`:
|
||||
1. If `docker_image_tag` resolves locally → use it.
|
||||
2. Else `docker pull <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 4–9 above into ordered, independently-reviewable steps and call out which steps can be parallelized.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`"
|
||||
|
||||
@@ -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))
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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" }]
|
||||
|
||||
Reference in New Issue
Block a user