diff --git a/.claude/agents/consensus-engineer.md b/.claude/agents/consensus-engineer.md index 6bf57700d..115bc2d7d 100644 --- a/.claude/agents/consensus-engineer.md +++ b/.claude/agents/consensus-engineer.md @@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, Edit, Write, WebSearch, WebFetch, Agent model: fable --- -You are the consensus engineer on a GPU-mined layer 1 built on a fork of rusty-kaspa. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them. +You are the consensus engineer on a GPU-mined layer 1 built on a fork of rusty-kaspa. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them. ## What you carry in your head The code of every major PoW node and how each one handles the problems you are about to meet: diff --git a/.claude/agents/cryptographer.md b/.claude/agents/cryptographer.md index 9b11b105b..cca4238bc 100644 --- a/.claude/agents/cryptographer.md +++ b/.claude/agents/cryptographer.md @@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, WebSearch, WebFetch, Agent model: fable --- -You are the cryptographer and proof-systems engineer on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them. +You are the cryptographer and proof-systems engineer on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them. ## What you carry in your head The whole history of proof of work and of proof systems, and you use it. When you make a claim about a chain, name the chain, the mechanism and where it lives in that chain's code. Examples of what you draw on: @@ -29,7 +29,7 @@ The whole history of proof of work and of proof systems, and you use it. When yo - Numbers are measured or cited. A number from memory is labelled approximate. Never state an ASIC gain, a proving time or a verification time you have not measured or sourced. - Write for an external reviewer: a spec section should let a stranger reproduce the argument. - Prototype in Rust, with Metal on this Mac for GPU work and CUDA or OpenCL ports noted for miners. Benchmarks go in bench/ with the exact command and hardware. -- When you disagree with the design doc, say so once, with the attack or the measurement that drives it, then do the work under the doc's decision unless the project lead overrides. +- When you disagree with the design doc, say so once, with the attack or the measurement that drives it, then do the work under the doc's decision unless the founder overrides. ## Writing rules No em dashes. Short sentences. Numbers in tables. The project is called Igneum. Approximate figures say so. diff --git a/.claude/agents/execution-engineer.md b/.claude/agents/execution-engineer.md index 11d55638d..613cff2e4 100644 --- a/.claude/agents/execution-engineer.md +++ b/.claude/agents/execution-engineer.md @@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, Edit, Write, WebSearch, WebFetch, Agent model: fable --- -You are the execution engineer on a GPU-mined layer 1 whose every block is ZK-proven by its miners. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them. +You are the execution engineer on a GPU-mined layer 1 whose every block is ZK-proven by its miners. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them. ## What you carry in your head Every Ethereum client and every open zkVM, and what it costs to prove them: diff --git a/.claude/agents/miner-community-lead.md b/.claude/agents/miner-community-lead.md index ba6c82718..eb1d857e4 100644 --- a/.claude/agents/miner-community-lead.md +++ b/.claude/agents/miner-community-lead.md @@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, WebSearch, WebFetch, Agent model: fable --- -You are the miner-community lead on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them. +You are the miner-community lead on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them. ## What you carry in your head Fifteen years of mining communities, and you remember what they did and why: diff --git a/.forgejo/issue_template/grant.md b/.forgejo/issue_template/grant.md new file mode 100644 index 000000000..59ae0a533 --- /dev/null +++ b/.forgejo/issue_template/grant.md @@ -0,0 +1,26 @@ +--- +name: Grant application +about: Apply for an Igneum grant: tooling, a reference app, infrastructure or research, paid in IGN on delivery +title: "Grant: " +labels: grant +--- + + + +## What you will build + + + +## What done looks like + + + +## Your public work + + + +## How to reach you + + + + diff --git a/.github/workflows/ci-red.yml b/.github/workflows/ci-red.yml new file mode 100644 index 000000000..401005dbf --- /dev/null +++ b/.github/workflows/ci-red.yml @@ -0,0 +1,48 @@ +# The red watcher as its own workflow, on workflow_run, so the copy on master watches EVERY branch's ci run whatever +# ci.yml that branch carries: GitHub runs a workflow_run workflow from the default branch only, and the branch's own +# ci.yml never enters it (7 October 2026: the inline `red` job of ci.yml was conditioned on master and release-*, and +# a feature branch would have waited for a merge of master before its reds were posted at all). +# +# One line per failed, cancelled or timed-out run (tools/ci/red-watch.mjs record, idempotent per run attempt) to /srv/ci-red/red.jsonl on the +# box; the box's igneum-ci-red.timer posts each new line once to the hidden updates channel, naming the branch, the +# commit, the red check and the pushing author. Runs on the box's own runner (not a GitHub-hosted machine: the billing +# block of 6 October 2026, 18:37Z to 20:10Z, failed every hosted job at start and nobody was told). Never blocks a +# release: it reads the run, writes one line, and ends. +name: ci-red +on: + workflow_run: + workflows: [ci] + types: [completed] +jobs: + red: + name: red watcher (every branch; one line per failed run, with the branch, commit, red check and pushing author, to the updates channel and the box file) + # failure, and since 7 October 2026 (17:2x UK) cancelled and timed_out too: a job that hangs into its timeout-minutes or a run + # someone cancels is a run that never answered, and a lane reads it like a red (tools/ci/red-watch.mjs names the kind) + if: ${{ github.event.workflow_run.conclusion == 'failure' || github.event.workflow_run.conclusion == 'cancelled' || github.event.workflow_run.conclusion == 'timed_out' }} + # the label ci-red is on igneum-build-1 only (added through the runners API on 7 October 2026; the default of + # RUNNER_LABELS in provision.sh carries it): the record file and the poster (igneum-ci-red.timer, the webhook file) + # live on that box, and the pool label igneum-build-1 is shared with igneum-build-2 since the same day + runs-on: [self-hosted, linux, x64, ci-red] + timeout-minutes: 5 + permissions: + actions: read # the failed run's jobs API (the first real red run, 21:19Z on 6 October: the default token answered 403 and the line carried no step) + contents: read + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: tools/ci + - name: record the failed run (one line, the branch, the commit, the failed jobs and their first failed step from the run's own API, the pushing author) + env: + GITHUB_TOKEN: ${{ github.token }} + RED_WATCH_RUN_ID: ${{ github.event.workflow_run.id }} + RED_WATCH_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + RED_WATCH_WORKFLOW: ${{ github.event.workflow_run.name }} + RED_WATCH_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + RED_WATCH_BRANCH: ${{ github.event.workflow_run.head_branch }} + RED_WATCH_SHA: ${{ github.event.workflow_run.head_sha }} + RED_WATCH_EVENT: ${{ github.event.workflow_run.event }} + RED_WATCH_URL: ${{ github.event.workflow_run.html_url }} + RED_WATCH_ACTOR: ${{ github.event.workflow_run.actor.login }} + RED_WATCH_TITLE: ${{ github.event.workflow_run.head_commit.message }} + RED_WATCH_AUTHOR: ${{ github.event.workflow_run.head_commit.author.name }} + run: node tools/ci/red-watch.mjs record --file /srv/ci-red/red.jsonl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 255a4fbe1..a09e40319 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,23 @@ # CI on every push and pull request (private repository, free runner minutes). # # What runs: the lottery-hash crate's tests (igneum-pow, release profile), the census tool's build, the two Python -# simulators' --quick modes (each under two minutes), the site build with an internal link check, the gh-free -# identity grep of the public export list (tools/ci/forbidden-strings.txt), and the no-secrets check of the tree -# (tools/ci/no-secrets-check.sh: no file named like a key of ~/.config/igneum, no 64-hex value assigned to a -# token/key/secret name outside tests and the allowlist; docs/security/keys.md). +# simulators' --quick modes (each under two minutes), and the tree gate: every fast check in ONE script, +# tools/ci/pre-push.sh (site build and link check, the ledger sentences, the identity grep of the public export list +# and the served site, Windows-valid paths, workflow shell syntax, the copied-sources and playbook classes, the unit +# tests, the no-secrets check). The pre-push hook runs the SAME script before a push to master or release-*, so the +# local gate and CI cannot drift (6 October 2026: 131 red `ci` runs in three days, 92 of them on master, every one a +# tree check that would have failed on the pushing machine in under 25 s; docs/analysis/ci-failures-2026-10-06.md). +# +# Where it runs: `pow` and `sims` go to the self-hosted pool (label igneum-build-1: the runners on igneum-build-1 and, since +# 7 October 2026, igneum-build-2, which carries that label too; rustc pinned, sccache read-only) and only when the push +# touched code (the `changes` job; a docs-only push skips them) +# when the repository variable IGNEUM_CI_RUNNER is `box`, else to ubuntu-latest (docs/plans/ci-self-hosted.md; GitHub +# has no fallback in runs-on, the variable is the switch). The `site` job stays on GitHub's machines. The red watcher +# is its own workflow, .github/workflows/ci-red.yml (workflow_run, so the copy on master watches every branch's run +# whatever ci.yml that branch carries): one line per failed run, naming the branch, the commit, the red check and the +# pushing author, to the hidden updates channel and to /srv/ci-red/red.jsonl (tools/ci/red-watch.mjs; +# infra/build-server/ci-red), so nobody opens the Actions page to learn a branch is red (the inline `red` job here +# watched master and release-* only until 7 October 2026, when eight red runs on ca3-v4-node went unseen). # # What does not run, on purpose: the node fork (vendor/igneum-node*, a rusty-kaspa fork of about 500 crates with # rocksdb, blst and the execution layer) is gitignored here and too big for the free runners today (a cold build is @@ -15,9 +28,42 @@ on: push: pull_request: jobs: + changes: + # What the push touched (tools/ci/docs-only-check.sh): a push of documents only (docs/, site/, *.md) skips the two + # compile-or-compute jobs below, which read none of those paths, so the self-hosted queue carries only runs that can + # change their result (7 October 2026: 31 runs queued on one runner, most of them status-document pushes). The tree + # gate (the `site` job) runs on ubuntu-latest for every push. A pull request, a new branch or a force push answers + # code=true (no `before` to compare from), as does any error reading the compare API: when in doubt, run. + name: what the push touched (docs-only runs skip the Rust and simulator jobs) + runs-on: ubuntu-latest + timeout-minutes: 10 # a 7 s API call; every job carries a budget (tools/ci/workflow-timeouts-check.sh) + outputs: + code: ${{ steps.classify.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: tools/ci + - id: classify + env: + GH_TOKEN: ${{ github.token }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + REPO: ${{ github.repository }} + EVENT: ${{ github.event_name }} + run: | + if [ "$EVENT" != push ] || [ -z "$BEFORE" ] || [ "$BEFORE" = 0000000000000000000000000000000000000000 ]; then + echo "code=true" >> "$GITHUB_OUTPUT"; echo "no base to compare from ($EVENT): the compile jobs run"; exit 0 + fi + files="$(gh api "repos/$REPO/compare/$BEFORE...$AFTER" --paginate --jq '.files[].filename' 2>/dev/null || true)" + line="$(printf '%s\n' "$files" | bash tools/ci/docs-only-check.sh)" + echo "$line" >> "$GITHUB_OUTPUT" + echo "$line: $(printf '%s\n' "$files" | grep -c .) changed path(s) between ${BEFORE:0:8} and ${AFTER:0:8}" pow: name: igneum-pow tests, igneum-census build - runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.code == 'true' }} + runs-on: ${{ vars.IGNEUM_CI_RUNNER == 'box' && fromJSON('["self-hosted", "linux", "x64", "igneum-build-1"]') || 'ubuntu-latest' }} + timeout-minutes: 60 # the box's suite ran 45 s to 2 min 40 s on 7 October 2026; a hosted fallback compiles cold steps: - uses: actions/checkout@v4 - name: toolchain @@ -32,13 +78,20 @@ jobs: run: cargo build --release sims: name: simulators, quick modes - runs-on: ubuntu-latest + needs: changes + # master and release-* pushes, and pull requests into them, only (main, 7 October 2026: every code push cost two box jobs and the + # queue read 22); a feature-branch code push runs the igneum-pow tests alone. tools/ci/sims-branch-check.sh holds this rule. + if: ${{ needs.changes.outputs.code == 'true' && ((github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-'))) || (github.event_name == 'pull_request' && (github.base_ref == 'master' || startsWith(github.base_ref, 'release-')))) }} + runs-on: ${{ vars.IGNEUM_CI_RUNNER == 'box' && fromJSON('["self-hosted", "linux", "x64", "igneum-build-1"]') || 'ubuntu-latest' }} + timeout-minutes: 45 # two simulators under 120 s each by their own timeout, plus a hosted fallback's pip install steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 + if: vars.IGNEUM_CI_RUNNER != 'box' # the box has python3 and numpy from provision.sh with: python-version: '3.12' - run: python3 -m pip install --quiet numpy + if: vars.IGNEUM_CI_RUNNER != 'box' - name: finality_v2.py --quick (under two minutes) working-directory: sim run: time timeout 120 python3 finality_v2.py --quick > finality_quick.md @@ -54,49 +107,22 @@ jobs: site: name: site build, link check, identity grep runs-on: ubuntu-latest + # 15: the gate took 229 s on a hosted runner on 7 October 2026 plus a 40 s Playwright install; the same day three + # hosted site jobs on master hung in the gate for over two hours each with no budget, and GitHub's six-hour default + # would have ended each as a failure email. A hung job is a red the watcher posts (ci-red.yml fires on timed_out). + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' - - name: site build - run: node site/build.mjs - - name: internal link check of site/*.html - run: node tools/ci/link-check.mjs - - name: identity grep of the public export list - run: bash tools/ci/identity-check.sh - - name: no conflict markers in tracked files - run: bash tools/ci/no-conflict-markers.sh - - name: copied sources are re-stamped before a build - run: bash tools/ci/copied-sources-check.sh - - name: second-engine playbooks log to a file and end their tree (C35) - run: bash tools/ci/second-engine-check.sh - - name: no playbook quits, pauses or resumes the installed app (self-test first, then the tree) - run: bash tools/ci/playbook-quit-check.sh --self-test && bash tools/ci/playbook-quit-check.sh - - name: the signer is never piped into head - run: bash tools/ci/signer-pipe-check.sh - - name: bash bodies in PowerShell job scripts pass bash -n, the lost-quote class (self-test first, then the tree) - run: bash tools/ci/bash-body-check.sh --self-test && bash tools/ci/bash-body-check.sh - - name: run jobs test their fetched kit before use, the wiped-jobs-folder class (self-test first, then the tree) - run: bash tools/ci/kit-path-check.sh --self-test && bash tools/ci/kit-path-check.sh - - name: every Windows spawn of the app runs with a hidden console (self-test first, then the tree) - run: node tools/ci/windows-spawn-check.mjs --self-test && node tools/ci/windows-spawn-check.mjs - - name: pinned guest programs match their manifest and are built only by pin-guests.sh - run: bash tools/ci/pinned-guests-check.sh - - name: root prover playbooks kill the GPU server and unlink its socket (the root-socket class, 5 October 2026) - run: bash tools/ci/prover-socket-check.sh - - name: no secret file names and no 64-hex secrets in the tree (self-test first, then the tree) - run: bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh - - name: faucet unit tests (validation, the daily limits, the signed transaction; keccak, RLP and secp256k1 vectors) - run: node --test site/api/faucet.test.mjs - - name: explorer and public stats unit tests (search router, formatters, emission rule against the node's own test values, the documented API fields from a fixture) - run: node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs + - name: a headless Chromium for the text-overlap sweep (Playwright outside the tree; the gate finds it through IGNEUM_PLAYWRIGHT_DIR) + run: | + mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.56 | tail -1 + npx playwright install --with-deps chromium | tail -1 + echo "IGNEUM_PLAYWRIGHT_DIR=/tmp/pw" >> "$GITHUB_ENV" + - name: the tree gate, tools/ci/pre-push.sh --ci (the same script the pre-push hook runs; one line per check, a red check prints its output) + run: bash tools/ci/pre-push.sh --ci - name: public stats API answers with the documented fields (the live site; master only, the endpoints exist there after the merge) if: github.ref == 'refs/heads/master' - run: node tools/ci/public-api-check.mjs https://igneum.network - - name: ship tool self-test (version bump, the dl-both and public manifest helpers) - run: node tools/ship-app.mjs --self-test - - name: relay unit tests (parsers, secret compare, the wake endpoint) - run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs - - name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows) - run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs + run: bash tools/ci/retry-once.sh public-api node tools/ci/public-api-check.mjs https://igneum.network # a live host: one retry before red diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 377b452e9..f4f486d5c 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -216,8 +216,14 @@ jobs: run: | $payload = Resolve-Path 'packaging\windows\igneum-windows-app' function Run-Capture([string]$exe, [string]$flag) { + # Start-Process -Wait on an exe that exits in milliseconds can throw "Cannot process request because the process has + # exited" before it attaches (release-0.3.21 run 37653903394, 7 October 2026, 17:40 UK): one retry before the verdict. $out = Join-Path $env:RUNNER_TEMP ('smoke-' + [IO.Path]::GetRandomFileName() + '.txt') - $p = Start-Process -FilePath $exe -ArgumentList $flag -Wait -NoNewWindow -PassThru -RedirectStandardOutput $out + $p = $null + foreach ($try in 1, 2) { + try { $p = Start-Process -FilePath $exe -ArgumentList $flag -Wait -NoNewWindow -PassThru -RedirectStandardOutput $out; break } + catch { if ($try -eq 2) { throw }; Write-Host ("Start-Process on {0} {1} failed once ({2}); second try" -f (Split-Path -Leaf $exe), $flag, $_.Exception.Message); Start-Sleep -Milliseconds 500 } + } $text = if (Test-Path $out) { (Get-Content $out -Raw) } else { '' } Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $p.ExitCode, $text.Trim()) if ($p.ExitCode -ne 0) { throw "$exe $flag exited $($p.ExitCode)" } @@ -293,3 +299,24 @@ jobs: path: build/inputs-artifact/ retention-days: 90 if-no-files-found: error + + red: + # The red watcher for the Windows pipeline (see ci.yml `red`): one line per failed master or release-* run to the + # hidden updates channel and /srv/ci-red/red.jsonl on the box, recorded by the box's own runner. + name: red watcher (master and release-* only; one line per failed run to the updates channel and the box file) + needs: [parse, build] + if: ${{ failure() && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')) }} + runs-on: [self-hosted, linux, x64, igneum-build-1] + timeout-minutes: 5 + permissions: + actions: read # the run's jobs API (the first real red run, 21:19Z: the default token answered 403 and the line carried no step) + contents: read + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: tools/ci + - name: record this run (one line, the failed jobs and their first failed step, from the run's own API) + env: + GITHUB_TOKEN: ${{ github.token }} + RED_WATCH_TITLE: ${{ github.event.head_commit.message }} + run: node tools/ci/red-watch.mjs record --file /srv/ci-red/red.jsonl diff --git a/.gitignore b/.gitignore index e60bf5fb6..d5959dccb 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ vendor/igneum-node-ship/ # Trademark instruction packs name the director and the applicant company; never in the repository brand/trademark/pbip-pack/ brand/trademark/*.zip + +# the Discord bot and reddit bot dry-run renders +tools/community/out/ +# the GPU workers built on igneum-build-1 (tools/workers-remote.sh); binaries, never committed +infra/cross/out-workers-box/ diff --git a/app/igneum-app/Cargo.lock b/app/igneum-app/Cargo.lock index 557c75108..a1d5e9db0 100644 --- a/app/igneum-app/Cargo.lock +++ b/app/igneum-app/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "igneum-app" -version = "0.3.13" +version = "0.3.14" dependencies = [ "ed25519-dalek", "getrandom", diff --git a/app/igneum-app/Cargo.toml b/app/igneum-app/Cargo.toml index 28d4ac4f8..82a8803ba 100644 --- a/app/igneum-app/Cargo.toml +++ b/app/igneum-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-app" -version = "0.3.13" +version = "0.3.14" edition = "2021" description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1" license = "MIT" diff --git a/app/igneum-app/build.rs b/app/igneum-app/build.rs index e2661b97a..d62b1670b 100644 --- a/app/igneum-app/build.rs +++ b/app/igneum-app/build.rs @@ -1,6 +1,6 @@ // Build script for igneum-app. On a Windows target it compiles resources/igneum-app.rc (the coin icon Explorer shows // and the version block under Properties > Details) with windres and links the object into igneum-app.exe. Other -// targets: nothing. the project lead's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the +// targets: nothing. The founder's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the // Mac app and DMG. No crate dependency: windres is called directly (x86_64-w64-mingw32-windres from Homebrew mingw-w64 // on the Mac, windres from MSYS2 on a PC; IGNEUM_WINDRES names another one). use std::env; diff --git a/app/igneum-app/resources/igneum-app.rc b/app/igneum-app/resources/igneum-app.rc index 829760c08..a9b419a1a 100644 --- a/app/igneum-app/resources/igneum-app.rc +++ b/app/igneum-app/resources/igneum-app.rc @@ -1,13 +1,13 @@ // Windows resources for igneum-app.exe: the coin icon Explorer shows and the version block under Properties > Details. // Compiled with x86_64-w64-mingw32-windres (the icon path is relative to brand/icons, passed with -I). -// the project lead's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the Mac app and DMG. +// the founder's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the Mac app and DMG. #include 1 ICON "igneum.ico" 1 VERSIONINFO -FILEVERSION 0,3,13,0 -PRODUCTVERSION 0,3,13,0 +FILEVERSION 0,3,14,0 +PRODUCTVERSION 0,3,14,0 FILEFLAGSMASK 0x3fL FILEFLAGS 0x0L FILEOS VOS_NT_WINDOWS32 @@ -20,12 +20,12 @@ BEGIN BEGIN VALUE "CompanyName", "Igneum" VALUE "FileDescription", "Igneum Miner engine" - VALUE "FileVersion", "0.3.13" + VALUE "FileVersion", "0.3.14" VALUE "InternalName", "igneum-app" VALUE "LegalCopyright", "Igneum contributors" VALUE "OriginalFilename", "igneum-app.exe" VALUE "ProductName", "Igneum Miner" - VALUE "ProductVersion", "0.3.13" + VALUE "ProductVersion", "0.3.14" END END BLOCK "VarFileInfo" diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 1f481e1a3..1cab39fe8 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -39,6 +39,9 @@ pub struct CardPref { pub sweep_class: String, #[serde(default)] pub sweep_source: String, + /// Ember 2: the memory clock the last tune chose (0 = the driver's default) + #[serde(default)] + pub sweep_mem_mhz: u32, } #[derive(Clone, Serialize, Deserialize)] @@ -84,12 +87,21 @@ pub struct Settings { /// (on when that is switched on, never effective while it is off). A pinned card is skipped. #[serde(default)] pub sweep: bool, - /// Power control (the project lead, 5 October 2026: "if we don't have to ask then don't ask"): the NVIDIA power cap and the + /// Power control (the founder, 5 October 2026: "if we don't have to ask then don't ask"): the NVIDIA power cap and the /// efficiency sweep need administrator rights (one UAC prompt on Windows). Default OFF on every machine; the app /// never raises the prompt on its own. Switching it on asks once, at that moment; a refused, cancelled or /// unanswered prompt switches it back off with a notice, no retries. #[serde(default)] pub power_control: bool, + /// Ember 2 (6 October 2026): the tune's goal ("efficiency" = most MH per watt within 10% of the top rate, + /// "balanced" = within 1%, "rate" = the fastest point), the electricity price in pence per kWh for the £/day + /// reading on each card, and the hill-climb switch (memory up, core down from the fleet prior; off = the ladders). + #[serde(default = "balanced")] + pub tune_goal: String, + #[serde(default)] + pub power_price_pence: f64, + #[serde(default)] + pub tune_climb: bool, /// When this install first ran (unix s), for the "first hour after install" sweep. #[serde(default)] pub installed_at: u64, @@ -113,13 +125,16 @@ pub struct Settings { fn one() -> u32 { 1 } +fn balanced() -> String { + "balanced".into() +} fn yes() -> bool { true } impl Default for Settings { fn default() -> Settings { - Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false } + Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false } } } @@ -330,7 +345,7 @@ pub struct Runtime { pub host: String, /// Per-install random id (16 hex, app data dir/machine-id, locked to the user). Identity labels, vote keys and /// the upload fields come from this, so two PCs cloned with the same COMPUTERNAME never share a key - /// (found 4 October 2026 on the project lead's two DESKTOP-KMCV30N machines). + /// (found 4 October 2026 on the founder's two DESKTOP-KMCV30N machines). pub machine_id: String, } @@ -342,7 +357,7 @@ impl Runtime { let p2p_port = env("IGNEUM_APP_P2P_PORT").and_then(|v| v.parse().ok()).unwrap_or(26611); let peers = match std::env::var("IGNEUM_APP_PEERS") { Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), - // the public seed node first, then the project lead's Mac on the house LAN (devnet only) + // the public seed node first, then the founder's Mac on the house LAN (devnet only) Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "192.168.68.64:26611".to_string()], Err(_) => vec![], }; diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index d839135e5..6cdf4a8c9 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -20,9 +20,18 @@ use std::time::{Duration, Instant}; /// The power steps, percent of the card's default limit, highest first (the same ladder as src/sweep.rs). pub const POWER_STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50]; /// The clock steps, percent of the card's maximum core clock, highest first; 100 = unlocked (the card's own boost). -pub const CLOCK_STEPS_PCT: [u32; 5] = [100, 90, 80, 70, 60]; -/// A card's clock floor when the vendor reports none: this share of its maximum core clock. -pub const CLOCK_FLOOR_PCT: u32 = 60; +/// 6 October 2026, run 6: the 5090's best MH/W sat on the 60% floor (1,854 MHz: 0.563 MH/W, the rate within 0.15%), +/// so the ladder and the floor go to 45% of the maximum; the 1% rate tolerance is the guard below that +pub const CLOCK_STEPS_PCT: [u32; 7] = [100, 90, 80, 70, 60, 50, 45]; +/// A card's clock floor when the vendor reports none: this share of its maximum core clock. 7 October 2026, the PC 1 +/// efficiency passes (docs/bench-log.md): the 5090's best MH per watt sat at 1,200 to 1,300 MHz (39 to 42 percent of +/// 3,090) and the rate fell past 5 percent only at 1,200 (class v3) and 1,100 (class v4), under the old 45 percent +/// floor; so the ladder continues below 45 percent in [`CLOCK_FINE_STEP_MHZ`] steps down to 20 percent of the maximum +/// (618 MHz on the 5090) and the stop rule, not the floor, ends the search. +pub const CLOCK_FLOOR_PCT: u32 = 20; +/// Below the percent ladder's last rung (45 percent) the clock ladder descends in steps of this many MHz until the +/// rate falls more than the tolerance under the cap point's rate (the knee), a step faults, or the floor is reached. +pub const CLOCK_FINE_STEP_MHZ: u32 = 100; /// A point may lose this much rate against the fastest point and still win on MH per watt (the manifest can change it). pub const RATE_TOLERANCE_PCT: f64 = 1.0; /// A step whose hottest GPU reading reaches this is marked hot and cannot win (the engine aborts at 90). @@ -49,6 +58,10 @@ pub struct Limits { pub clock_max_mhz: u32, /// the lowest cap the vendor allows (the ADLX gmax_range floor); 0 = CLOCK_FLOOR_PCT of the maximum pub clock_min_mhz: u32, + /// Ember 2: the memory clock the card runs at by default and the vendor's maximum (nvidia-smi clocks.mem and + /// clocks.max.mem); 0 = no memory knob (AMD through ADLX on RDNA 4 exposes none) + pub mem_default_mhz: u32, + pub mem_max_mhz: u32, } impl Limits { @@ -66,6 +79,13 @@ impl Limits { } mhz.clamp(self.clock_floor(), self.clock_max_mhz) } + /// A memory clock inside the vendor's range; 0 stays 0 (the default). + pub fn clamp_mem(&self, mhz: u32) -> u32 { + if mhz == 0 || self.mem_max_mhz == 0 || self.mem_default_mhz == 0 { + return 0; + } + mhz.clamp(self.mem_default_mhz, self.mem_max_mhz) + } /// The watts a power percent asks for, inside the card's min and max, rounded to a watt. pub fn watts_for(&self, pct: u32) -> f64 { let mut w = self.power_default_w * pct as f64 / 100.0; @@ -79,13 +99,15 @@ impl Limits { } } -/// One setting of the two knobs. +/// One setting of the knobs (Ember 2 adds the memory clock: 0 = the driver's default). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Point { /// the core clock cap in MHz; 0 = unlocked pub clock_mhz: u32, /// the power limit, percent of the default pub power_pct: u32, + /// the memory clock in MHz (NVIDIA `-lmc`, locked to one value); 0 = the driver's default + pub mem_mhz: u32, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -96,6 +118,8 @@ pub enum Kind { Clock, /// the fleet prior and one neighbour Confirm, + /// Ember 2: a hill-climb probe + Climb, } #[derive(Clone, Debug, PartialEq)] @@ -106,11 +130,53 @@ pub struct Step { pub kind: Kind, } +/// What the tune is for (Settings > Ember Tune > goal). The rate floor is the share of the best rate seen a point +/// must keep to win on MH per watt: efficiency keeps 90%, balanced 99% (the 1% rule of lever 3), maximum rate +/// takes the fastest point and uses MH per watt only to break ties. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Goal { + Efficiency, + Balanced, + MaxRate, +} + +impl Goal { + pub fn parse(s: &str) -> Goal { + match s { + "efficiency" | "eff" => Goal::Efficiency, + "rate" | "max_rate" | "maximum" => Goal::MaxRate, + _ => Goal::Balanced, + } + } + pub fn name(&self) -> &'static str { + match self { + Goal::Efficiency => "efficiency", + Goal::Balanced => "balanced", + Goal::MaxRate => "rate", + } + } + /// The rate tolerance the choice rule uses, percent under the best rate. + pub fn tolerance_pct(&self, manifest_default: f64) -> f64 { + match self { + Goal::Efficiency => 10.0, + Goal::Balanced => manifest_default, + Goal::MaxRate => 0.0, + } + } +} + +/// Pounds a day for a draw at a price in pence per kWh: watts × 24 h / 1000 × price / 100. +pub fn pounds_per_day(watts: f64, pence_per_kwh: f64) -> f64 { + watts * 24.0 / 1000.0 * pence_per_kwh / 100.0 +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PlanKind { Full, Confirm, Baseline, + /// Ember 2: the hill-climb over memory up and core down from the start point + Climb, } impl PlanKind { @@ -119,6 +185,7 @@ impl PlanKind { PlanKind::Full => "full", PlanKind::Confirm => "confirm", PlanKind::Baseline => "baseline", + PlanKind::Climb => "climb", } } } @@ -133,10 +200,86 @@ pub struct Plan { pub tolerance_pct: f64, power: Vec, clock_pcts: Vec, + /// the clock ladder below the percent rungs: MHz values from the last rung minus one fine step down to the floor + /// (the core-clock knob of 7 October 2026; empty when the card has no readable maximum clock) + clock_fine: Vec, fixed: Vec, + /// Ember 2 (Climb): the start point, the step sizes and the step budget + climb: Option, +} + +/// The hill-climb's shape: from `start`, each probe moves the memory clock up by `mem_step` or the core clock down +/// by `core_step` (or both), keeps the move when the goal's score improves, else turns to the other knob; a +/// refused step (a fault on it) backs that knob off for good. At most `budget` steps including the start. +#[derive(Clone, Debug, PartialEq)] +pub struct Climb { + pub start: Point, + pub mem_step: u32, + pub core_step: u32, + pub budget: usize, + pub goal: Goal, } impl Plan { + /// Ember 2: the hill-climb. The start is the fleet prior (or the card's current point); a probe step is 5% of + /// the memory range above the default (0 when the card has no memory knob) and 5% of the maximum core clock; + /// five steps of 60 s converge in under 10 minutes. + pub fn climb(limits: &Limits, start: Point, goal: Goal, tolerance_pct: f64) -> Plan { + let mem_step = if limits.mem_max_mhz > limits.mem_default_mhz { ((limits.mem_max_mhz - limits.mem_default_mhz) / 20).max(25) } else { 0 }; + let core_step = if limits.clock_max_mhz > 0 { (limits.clock_max_mhz / 20).max(25) } else { 0 }; + let start = Point { clock_mhz: limits.clamp_clock(start.clock_mhz), power_pct: start.power_pct.clamp(50, 100), mem_mhz: limits.clamp_mem(start.mem_mhz) }; + Plan { kind: PlanKind::Climb, limits: limits.clone(), before: start, tolerance_pct: goal.tolerance_pct(tolerance_pct), power: Vec::new(), clock_pcts: Vec::new(), clock_fine: Vec::new(), fixed: Vec::new(), climb: Some(Climb { start, mem_step, core_step, budget: 5, goal }) } + } + + /// The goal's score of a row: MH per watt for efficiency and balanced, the rate for maximum rate. + pub fn score(&self, r: &Row) -> f64 { + match self.climb.as_ref().map(|c| c.goal) { + Some(Goal::MaxRate) => r.mhs, + _ => r.eff, + } + } + + /// The climb's next probe from the rows so far: None when the budget is spent or no move is left. + fn climb_next(&self, rows: &[Row]) -> Option { + let c = self.climb.as_ref()?; + let step = |p: Point| Step { point: p, watts: self.limits.watts_for(p.power_pct), kind: Kind::Climb }; + if rows.is_empty() { + return Some(step(c.start)); + } + if rows.len() >= c.budget { + return None; + } + // the best usable row so far is the hill's top; a knob that produced a marked (refused) row is backed off + let best = rows.iter().filter(|r| r.usable()).max_by(|a, b| self.score(a).partial_cmp(&self.score(b)).unwrap_or(std::cmp::Ordering::Equal))?; + let refused_mem = rows.iter().any(|r| !r.usable() && r.point.mem_mhz > best.point.mem_mhz); + let refused_core = rows.iter().any(|r| !r.usable() && r.point.clock_mhz != 0 && (best.point.clock_mhz == 0 || r.point.clock_mhz < best.point.clock_mhz)); + let last = rows.last()?; + let mem_up = |p: Point| -> Option { + if c.mem_step == 0 || refused_mem { return None; } + let base = if p.mem_mhz == 0 { self.limits.mem_default_mhz } else { p.mem_mhz }; + let m = self.limits.clamp_mem(base + c.mem_step); + (m > 0 && m != p.mem_mhz && m != base).then_some(Point { mem_mhz: m, ..p }) + }; + let core_down = |p: Point| -> Option { + if c.core_step == 0 || refused_core { return None; } + let base = if p.clock_mhz == 0 { self.limits.clock_max_mhz } else { p.clock_mhz }; + let k = self.limits.clamp_clock(base.saturating_sub(c.core_step)); + (k > 0 && k != p.clock_mhz).then_some(Point { clock_mhz: k, ..p }) + }; + let tried = |p: Point| rows.iter().any(|r| r.point == p); + // the last move improved: keep going the same way from the top; else turn: memory first, then core, then both + let last_improved = last.usable() && last.point == best.point && rows.len() > 1; + let last_was_mem = rows.len() > 1 && last.point.mem_mhz != rows[rows.len() - 2].point.mem_mhz; + let candidates: Vec> = if last_improved && last_was_mem { + vec![mem_up(best.point), core_down(best.point)] + } else if last_improved { + vec![core_down(best.point), mem_up(best.point)] + } else { + vec![mem_up(best.point), core_down(best.point), mem_up(best.point).and_then(core_down)] + }; + candidates.into_iter().flatten().find(|p| !tried(*p)).map(step) + } + /// Power 100% to 50% at the unlocked clock (duplicate watts dropped, as the card's floor clamps them), then the /// clock ladder 90% to the floor of the maximum core clock at the chosen power. A card without a readable /// maximum clock gets the power ladder only; a card without a default limit gets the clock ladder only. @@ -148,42 +291,95 @@ impl Plan { if power.last().map(|s: &Step| (s.watts - w).abs() < 0.5).unwrap_or(false) { continue; } - power.push(Step { point: Point { clock_mhz: 0, power_pct: pct }, watts: w, kind: Kind::Power }); + power.push(Step { point: Point { clock_mhz: 0, power_pct: pct, mem_mhz: 0 }, watts: w, kind: Kind::Power }); } } let clock_pcts = if limits.clock_max_mhz > 0 { CLOCK_STEPS_PCT[1..].to_vec() } else { Vec::new() }; - Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, fixed: Vec::new() } + // the fine ladder: from the last percent rung down to the floor in CLOCK_FINE_STEP_MHZ steps (the knob of + // 7 October 2026; the stop rule in `next` ends it at the knee) + let mut clock_fine = Vec::new(); + if limits.clock_max_mhz > 0 { + let last_pct = limits.clamp_clock(limits.clock_max_mhz * CLOCK_STEPS_PCT[CLOCK_STEPS_PCT.len() - 1] / 100); + let floor = limits.clock_floor(); + let mut m = (last_pct / CLOCK_FINE_STEP_MHZ) * CLOCK_FINE_STEP_MHZ; + if m >= last_pct { + m = m.saturating_sub(CLOCK_FINE_STEP_MHZ); + } + while m >= floor && m > 0 { + clock_fine.push(m); + m = m.saturating_sub(CLOCK_FINE_STEP_MHZ); + } + } + Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, clock_fine, fixed: Vec::new(), climb: None } } /// The prior's point, then one neighbour: the next clock step up when the prior caps the clock (is the cap /// costing rate?), else one power step down (is there efficiency left?). pub fn confirm(limits: &Limits, prior: Point, before: Point, tolerance_pct: f64) -> Plan { - let p = Point { clock_mhz: limits.clamp_clock(prior.clock_mhz), power_pct: prior.power_pct.clamp(50, 100) }; + let p = Point { clock_mhz: limits.clamp_clock(prior.clock_mhz), power_pct: prior.power_pct.clamp(50, 100), mem_mhz: limits.clamp_mem(prior.mem_mhz) }; let first = Step { point: p, watts: limits.watts_for(p.power_pct), kind: Kind::Confirm }; let neighbour = if p.clock_mhz > 0 && limits.clock_max_mhz > 0 { let up = p.clock_mhz + limits.clock_max_mhz / 10; let clock = if up >= limits.clock_max_mhz { 0 } else { limits.clamp_clock(up) }; - Point { clock_mhz: clock, power_pct: p.power_pct } + Point { clock_mhz: clock, power_pct: p.power_pct, mem_mhz: p.mem_mhz } } else { - Point { clock_mhz: p.clock_mhz, power_pct: (p.power_pct.saturating_sub(10)).max(50) } + Point { clock_mhz: p.clock_mhz, power_pct: (p.power_pct.saturating_sub(10)).max(50), mem_mhz: p.mem_mhz } }; let mut fixed = vec![first]; if neighbour != p { fixed.push(Step { point: neighbour, watts: limits.watts_for(neighbour.power_pct), kind: Kind::Confirm }); } - Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed } + Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), clock_fine: Vec::new(), fixed, climb: None } } /// One step at the card's current point: the before number, and all a measure-only card (Apple, or NVIDIA /// with Power control off) reports. pub fn baseline(limits: &Limits, before: Point, tolerance_pct: f64) -> Plan { let fixed = vec![Step { point: before, watts: limits.watts_for(before.power_pct), kind: Kind::Baseline }]; - Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed } + Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), clock_fine: Vec::new(), fixed, climb: None } } /// How many steps the plan has at most (the clock ladder counts whether or not it runs). pub fn len(&self) -> usize { - self.fixed.len() + self.power.len() + self.clock_pcts.len() + if let Some(c) = &self.climb { + return c.budget; + } + self.fixed.len() + self.power.len() + self.clock_pcts.len() + self.clock_fine.len() + } + + /// The cap point's row: the power ladder's choice (the row the clock search is read against), else the first row. + pub fn cap_row(&self, rows: &[Row]) -> Option { + if self.power.is_empty() { + rows.first().cloned() + } else { + choose(&rows[..self.power.len().min(rows.len())], self.tolerance_pct).or_else(|| rows.first().cloned()) + } + } + + /// Why the clock search ended after `rows`, in words, or None while it runs: a faulted clock row (a rejected or + /// mismatched hash during the hold: the fingerprint check), the knee (the rate under the cap point's by more than + /// the tolerance), or the floor. + pub fn clock_stop_reason(&self, rows: &[Row]) -> Option { + let last = rows.last()?; + if last.point.clock_mhz == 0 || !matches!(self.kind, PlanKind::Full) { + return None; + } + let clock_rows = rows.len().saturating_sub(self.power.len()); + if clock_rows == 0 { + return None; + } + if last.mark == Some(Mark::Faulted) { + return Some(format!("fingerprint mismatch at {} MHz, clocks reset", last.point.clock_mhz)); + } + if let Some(cap) = self.cap_row(rows) { + if last.usable() && cap.usable() && cap.mhs > 0.0 && last.mhs < cap.mhs * (1.0 - self.tolerance_pct.max(0.0) / 100.0) { + return Some(format!("rate fell {:.1} percent at {} MHz", 100.0 * (cap.mhs - last.mhs) / cap.mhs, last.point.clock_mhz)); + } + } + if clock_rows >= self.clock_pcts.len() + self.clock_fine.len() { + return Some(format!("the floor at {} MHz", last.point.clock_mhz)); + } + None } pub fn is_empty(&self) -> bool { self.len() == 0 @@ -191,6 +387,9 @@ impl Plan { /// The next step after `rows` (one row per step done so far), or None when the plan is complete. pub fn next(&self, rows: &[Row]) -> Option { + if self.climb.is_some() { + return self.climb_next(rows); + } let i = rows.len(); if !self.fixed.is_empty() { return self.fixed.get(i).cloned(); @@ -199,15 +398,27 @@ impl Plan { return Some(self.power[i].clone()); } let k = i - self.power.len(); - let pct = *self.clock_pcts.get(k)?; + // the stop rule (7 October 2026): a faulted clock row or the knee ends the search; the choice is made among + // the rows so far + if k > 0 { + if let Some(reason) = self.clock_stop_reason(rows) { + if !reason.starts_with("the floor") { + return None; + } + } + } + let clock = if k < self.clock_pcts.len() { + self.limits.clamp_clock(self.limits.clock_max_mhz * self.clock_pcts[k] / 100) + } else { + *self.clock_fine.get(k - self.clock_pcts.len())? + }; // the clock ladder rides the power point the power ladder chose (the before point when nothing won) let power_pct = if self.power.is_empty() { self.before.power_pct } else { choose(&rows[..self.power.len()], self.tolerance_pct).map(|r| r.point.power_pct).unwrap_or(self.before.power_pct) }; - let clock = self.limits.clamp_clock(self.limits.clock_max_mhz * pct / 100); // a step whose clamp lands on the previous step's clock is dropped (the floor was reached) if rows.last().map(|r| r.point.clock_mhz == clock).unwrap_or(false) { return None; } - Some(Step { point: Point { clock_mhz: clock, power_pct }, watts: self.limits.watts_for(power_pct), kind: Kind::Clock }) + Some(Step { point: Point { clock_mhz: clock, power_pct, mem_mhz: 0 }, watts: self.limits.watts_for(power_pct), kind: Kind::Clock }) } } @@ -305,9 +516,10 @@ impl Row { /// `TUNE card=