diff --git a/docs/plans/ember-heat-shots/h01-cards-dark-1200.png b/docs/plans/ember-heat-shots/h01-cards-dark-1200.png new file mode 100644 index 00000000..f74ce9bb Binary files /dev/null and b/docs/plans/ember-heat-shots/h01-cards-dark-1200.png differ diff --git a/docs/plans/ember-heat-shots/h02-cards-light-1200.png b/docs/plans/ember-heat-shots/h02-cards-light-1200.png new file mode 100644 index 00000000..a36b180f Binary files /dev/null and b/docs/plans/ember-heat-shots/h02-cards-light-1200.png differ diff --git a/docs/plans/ember-heat-shots/h03-cards-resting-dark-1200.png b/docs/plans/ember-heat-shots/h03-cards-resting-dark-1200.png new file mode 100644 index 00000000..8c2cc3ee Binary files /dev/null and b/docs/plans/ember-heat-shots/h03-cards-resting-dark-1200.png differ diff --git a/docs/plans/ember-heat-shots/h04-settings-dark-1200.png b/docs/plans/ember-heat-shots/h04-settings-dark-1200.png new file mode 100644 index 00000000..c01b69a0 Binary files /dev/null and b/docs/plans/ember-heat-shots/h04-settings-dark-1200.png differ diff --git a/docs/plans/ember-heat-shots/h05-settings-light-1200.png b/docs/plans/ember-heat-shots/h05-settings-light-1200.png new file mode 100644 index 00000000..3856d85e Binary files /dev/null and b/docs/plans/ember-heat-shots/h05-settings-light-1200.png differ diff --git a/docs/plans/ember-heat-shots/h06-earnings-dark-1200.png b/docs/plans/ember-heat-shots/h06-earnings-dark-1200.png new file mode 100644 index 00000000..2cfbda5a Binary files /dev/null and b/docs/plans/ember-heat-shots/h06-earnings-dark-1200.png differ diff --git a/docs/plans/ember-heat-shots/h07-earnings-light-1200.png b/docs/plans/ember-heat-shots/h07-earnings-light-1200.png new file mode 100644 index 00000000..96a23c99 Binary files /dev/null and b/docs/plans/ember-heat-shots/h07-earnings-light-1200.png differ diff --git a/docs/plans/ember-heat-shots/h08-region-step-dark-1200.png b/docs/plans/ember-heat-shots/h08-region-step-dark-1200.png new file mode 100644 index 00000000..57826b1c Binary files /dev/null and b/docs/plans/ember-heat-shots/h08-region-step-dark-1200.png differ diff --git a/docs/plans/ember-heat-shots/h09-region-step-light-1200.png b/docs/plans/ember-heat-shots/h09-region-step-light-1200.png new file mode 100644 index 00000000..bbb961fc Binary files /dev/null and b/docs/plans/ember-heat-shots/h09-region-step-light-1200.png differ diff --git a/docs/plans/ember-heat.md b/docs/plans/ember-heat.md new file mode 100644 index 00000000..59c7a6f8 --- /dev/null +++ b/docs/plans/ember-heat.md @@ -0,0 +1,143 @@ +# Ember Heat: heat mode, the region and price prompt, the cost-against-rent line + +Mission item 7 (docs/analysis/mission/mission.md section 2.7; invent.md section 5; future.md sections 3, 4, 8 and 10). +the project lead ordered it built on 7 October 2026, 10:1x UK. Branch `ember-heat`, worktree `../igneum-wt-ember-heat`, based on +the miner-ui-4 tip 5d9c55a2 in the 0.3.19 line. App only: no digest, no protocol, ships in any cut. The first-month +lane (miner-ui-5) works beside it; this lane keeps to Ember, the Settings page and the Earnings cost line. + +## 1. What a miner sees + +| Where | What | Words | +|---|---|---| +| First run, step 2 of 3 | The region list, the price per kWh prefilled from the public table and editable, the restricted line for a listed region | "Every £ figure in the app comes from the price you type here. Nothing is fetched." | +| A Russian region or China chosen | The restricted line, the standing sentence first | "Mining may be restricted where you are. You are responsible for checking. Mining is banned in the Moscow region from 15 August 2026 to 2032." | +| Settings > Electricity | The same list and price, the source of the typical price | "Typical: 26.32 p per kWh (Ofgem cap, 1 October to 31 December 2026). Type what you pay, in pence per kWh. Nothing is fetched." | +| Settings > Heat mode | The switch, the set point, the schedule, a typed room reading, the live line | "Holding 20.0 °C: room 19.6 °C from your reading 40 min ago. heating, 5 min to go. Heat 60% of the time." | +| Cards, the Ember strip | The heat line | "Heat mode: holding 20.0 °C · room 19.6 °C · heating 58% of the time · 448 W of heat" | +| Cards, each row's Ember line | The duty and the watts | "heat 58% · 448 W of heat", or "heat 58% · resting" | +| A resting card | The row says why it shows 0 MH/s; the pill says Resting · heat mode; the big button keeps Stop mining | "resting: the room is 20.1 °C, holding 20.0 °C (heat mode)" | +| Earnings | The cost-against-rent row | "0.064 p per MH/s-hour · your cost of hash at 26.32 p/kWh and 2.44 W per MH/s, about USD 0.0008. Rented hash: USD 0.0117 per MH/s-hour (RunPod community pods, 38 pods at list price, 1,748 MH/s for USD 20.44 an hour, 6 October 2026). Yours is 14x cheaper than rented hash." | + +The honest sentence on the switch, from invent.md section 5: "In heat mode your card is an electric heater that also +earns; whether it beats your boiler depends on the network and the price, shown live." + +## 2. Heat mode: the loop (app/igneum-app/src/heat.rs, engine.rs `tick_heat`) + +| Part | Rule | +|---|---| +| Period | 10 minutes: the heating slice first, the rest after it. The hash follows: the miners run in the slice and stop for the rest. | +| Duty | Decided once per period on the reading at its start: duty = 0.5 per degree under the set point plus the integral; the integral moves 0.05 per degree per period and carries the steady share the room needs (starts at a half). Clamped 0 to 1. | +| Temperature source, in order | A typed reading (the miner's own thermometer, Settings > Heat mode > Room now), fresh for two hours. Else the coolest card's sensor after 3 minutes of rest, minus the idle offset (8 degrees until learned; typing a reading while the cards rest teaches the real offset), with the cooling tail still in the sensor taken off by its slope (one 60 s time constant, approximate). Else nothing: the loop heats 70% of every period and says "no room reading yet". | +| Card-sensor periods | The duty is capped at 70% so every period keeps the 3 minutes of rest the reading needs. A typed reading lifts the cap. | +| Schedule | "06:00 20, 22:00 16": each entry holds until the next; before the first the last of the day holds; times in the window's clock (the offset is saved with the schedule). Blank = one set point all day. | +| Set point | 5 to 30 degrees. A typed room reading is -20 to 50. | +| Rest mechanics | `heat_rest`: the miners stop the way a remote job stops them (stop_miners, the rows say why, nothing restarts them); the release re-arms every slot with a fresh pack check. A tune never starts during a rest and a running tune aborts into it. Paused mining, a remote job holding the cards, or no enabled card: no rest is taken. | +| Log | `HEAT t= phase=heating|resting set=20.0 room=19.6 src=typed duty=0.60 heat_w=448 hash=183.7 until=312` every 30 s; `heat: period duty=...` at each period start; the gate tool reads the HEAT lines. | +| State | `state.heat` (phase, set point, room and its source and error, duty this period and over the last hour, watts now and when heating, seconds to the next change, the one line). | +| Chain | Nothing. A heat-mode miner is a miner with a schedule (invent.md section 5); a thermostat that ramps over an hour is not a 10 percent removal (CLAUDE.md, the vote-weight rule). | + +Model result (the tests in heat.rs, a 400 kJ/K room losing 20 W/K to 10 degrees outside, a 300 W card): with a typed +reading refreshed every half hour the room stays within 1 degree of 20 for the four hours after the first, the hash on +exactly while the slice heats, the integral settling near the two-thirds share the room needs; with the card sensor +alone, the same hold, the estimate inside its stated 3 degrees. A model is not the gate; section 6 is. + +## 3. The region and price prompt (ui/app.js `View.REGIONS`, the first-run step, Settings > Electricity) + +| Rule | Where it comes from | +|---|---| +| The typical prices: UK 26.32 p (Ofgem cap Q4 2026), Ireland 40.42, Germany 38.69, Belgium 34.99, EU average 28.96, Hungary 10.82, Malta 12.82, Bulgaria 13.55 (euro cents, Eurostat H2 2025), US 17.7 c (EIA H2 2025), Kazakhstan about 4 c, Paraguay 4.4 c (4.4 to 6), Iran about 1 c, Ethiopia 2 c (2 to 5.3), Nigeria 4.8 c, the last five secondary and labelled | future.md 4.1 | +| A typed price, never fetched: the table prefills, the miner edits, the engine stores the number; no network call for a price or a rate | mission.md 2.7 | +| The restricted entries, and only these: Russia, Moscow region (15 August 2026 to 2032); Russia, Irkutsk, Buryatia, Zabaikalsky (seasonal bans); Russia, other region (ten regions, 1 January 2025 to 15 March 2031; the app says it does not carry the list of ten); China (illegal, the joint Notice of 6 February 2026) | future.md 4.4 and 8.3 item 7 | +| The sentence, verbatim in substance: "Mining may be restricted where you are. You are responsible for checking." | future.md 8.3 item 7 | +| Regions the file calls "nothing" for a home miner (Norway, Sweden, Kazakhstan, Paraguay, Iran, US states) carry no line | future.md 4.4 | +| The currency follows the region: £ and pence, € and euro cents, $ and US cents (every £ figure on every page) | this plan | +| The dollar rates used only to compare with the rent: USD 1.32 per pound, 1.08 per euro, the rates future.md 3.4 implies; approximate, dated 7 October 2026 | future.md 3.4 | + +The test `ui/heat-region.test.mjs` checks the restricted list against the research file when the file is in the tree +(it is internal, outside the public export list, so a public tree skips that one test). + +## 4. The cost-against-rent line (Earnings; `View.rentLine`, `View.RENT`) + +cost per MH/s-hour = watts per MH/s / 1000 × price per kWh, in the region's minor unit; the live draw and rate of the +mining cards, else the last tune's point ("the last tune"), else nothing. The rent is the bench log's measured +USD 0.0117 per MH/s-hour (RunPod community pods, 6 October 2026); the test reads docs/bench-log.md and fails when the +app's number drifts from the log's latest "Rental cost of hash" entry. The verdict: "Yours is Nx cheaper than rented +hash", "Rented hash undercuts your card by Nx", or "Level with rented hash". + +Per tier, at 26.32 p: a 5090 at 2.4 W per MH/s is 0.064 p (USD 0.0008), 14x under the rent; a 3.27 uJ card is +USD 0.00114, 10x (future.md 3.4's row); an 8 or 12 GB card at 1.3 to 1.7x the 5090's uJ is 6 to 8x; the line flips +the day idle datacentre hash on 5 c power sets the rent at USD 0.00016 (future.md 3.2 and 3.4), and the Earnings +page shows it the morning it happens. Apple silicon reports no draw, so its line says "no reading yet" until a tune +measures it. + +## 5. Per tier + +| Tier | Heat mode | The prompt and the rent line | +|---|---|---| +| 8 GB card at 150 W | a 150 W heater for a small room; the card sensor path works (NVIDIA and AMD report a temperature) | the dearest hash of the home tiers; the rent line shows it first | +| 12 and 16 GB cards | 160 to 220 W; the same | the same | +| 24 and 32 GB cards (5090 at 290 to 575 W) | a large-room heater; the typed reading is the better source at full duty | 10 to 14x under the rent at UK prices | +| A rig | a space heater that should not be in a bedroom; the whole rig heats and rests together (one room) | the rig's cost is the card's cost | +| A pool user | the duty shows as a share-rate pattern the pool sees; nothing else changes | the same | +| Windows, Linux | the same | the same | +| macOS, Apple silicon | 21 to 38 W is not a heater; no power reading, so the heat line says "no draw reading"; the mode still works on time | no cost line until a tune measures the draw | +| NVIDIA, AMD | the sensor is nvidia-smi or igneum-gpu-telemetry, read while the cards rest | the same | + +## 6. The gate: the PC 1 four-hour hold, a runbook for the project lead's desk + +This lane never touches PC 1 (CLAUDE.md: a job never quits, pauses or restarts the installed app). The gate is run by +hand at the desk, on the installed app once a cut carrying this branch is on PC 1. Expected time: 4 h 20 min of +waiting, 5 minutes of doing. + +1. Put a room thermometer on the desk, out of the PC's exhaust. Note the room temperature. +2. In Igneum Miner: Settings > Electricity: region United Kingdom, the price as billed. Settings > Heat mode: Hold at + a set point 1 degree above the room now (so the loop has something to do), schedule blank, Save. Room now: type + the thermometer's reading, Use it. Switch "Hold a room temperature" on. The line under it reads "Holding N °C: + room M °C from your reading just now. heating, 10 min to go." or close. +3. Every 30 minutes for 4 hours: read the thermometer and type it into Room now, Use it (a fresh reading keeps the + typed source for the whole run). Note the Cards strip line once an hour: the share of the hour and the watts. +4. After 4 hours: Settings > Logs > Copy the log, paste it into a file, and run from the main checkout: + `node tools/heat-gate.mjs --hours 4` + PASS = the room stayed within 1 degree of the set point on every sample, the hash was on exactly while the slice + heated (90 s forgiven after each change), and the measured heating share matched the planned duty within 15 + points. The tool's self-test (`--self-test`, in the pre-push gate) fires on a known hold and on a known drift, a + hash through the rest, a short log, an empty log and a log without readings. +5. Switch heat mode off. The cards go back to mining whenever the node is synced. + +What a FAIL means and what to do: a drift over 1 degree with a typed source is a gain problem (KP, KI in heat.rs; the +model room is not the desk) and comes back here with the log; a hash on through the rest is a rest that did not +hold (the log's `heat: release` lines say why); a missing room reading is a typed reading older than two hours. + +The card-sensor path (no typed reading) is the second run, same steps without step 3; its gate is the same tool with +the stated 3 degrees as the honest expectation of the estimate, and the band stays 1 degree on the thermometer. + +## 7. Files + +| File | What | +|---|---| +| `app/igneum-app/src/heat.rs` | the loop, the room estimate, the schedule, the log line, the words; 8 tests with a model room | +| `app/igneum-app/src/engine.rs` | `tick_heat`, `heat_rest`, `heat_release`, `Cmd::Heat`, `Cmd::Region`, the holds in tick_miners, tick_sweep and sweep_drive | +| `app/igneum-app/src/config.rs`, `state.rs` | the settings (`region`, `heat_*`) and `state.heat` | +| `app/igneum-app/src/server.rs` | `POST /api/region {region, price_pence}`, `POST /api/heat {on, set_c, schedule, tz_min, room_c}` | +| `app/igneum-app/ui/index.html`, `app.js`, `app.css` | the step, the two Settings cards, the strip line, the row words, the rent row, `?theme=light|dark` and `?region=` for screenshots | +| `app/igneum-app/ui/heat-region.test.mjs` | the view tests (the Russian entry, the bench-log rate, the words) | +| `tools/heat-gate.mjs` | the gate reader and its self-test | +| `tools/ui-mock/server.mjs` | the `heat` and `heat-rest` scenarios | +| `tools/ci/pre-push.sh` | the new test file and the gate reader's self-test on the one gate | + +## 8. Screenshots (the mock on port 4319, scenarios heat and heat-rest, the Mac host's snapshot mode) + +`docs/plans/ember-heat-shots/`: `h01-cards-dark-1200.png`, `h02-cards-light-1200.png` (the strip's heat line, the rows' +duty and watts), `h03-cards-resting-dark-1200.png` (the rest: the pill, the rows, the strip), `h04-settings-dark-1200.png`, +`h05-settings-light-1200.png` (Electricity and Heat mode), `h06-earnings-dark-1200.png`, `h07-earnings-light-1200.png` +(the cost-against-rent row under miner-ui-5's IGN-first rows; retaken after the rebase onto miner-ui-5 e9fe1106), `h08-region-step-dark-1200.png`, `h09-region-step-light-1200.png` (step 2 of 3 with the +Moscow region chosen and its restricted line). + +## 9. Owed + +| What | Who | +|---|---| +| The PC 1 four-hour hold (section 6), on the first cut carrying this branch | the project lead, at the desk | +| The card-sensor path's idle offset and cooling tail on a real 5090 and a 9070 XT (the model's 8 degrees and 60 s are approximate) | the first run's log | +| A curtailment input (a webhook, a schedule, a price threshold; future.md 4.3) | a later lane | +| The 2025/422 sustainability indicators (future.md 8.3 item 2) | the site, not the app | diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index c6b7e7c8..095f7eb7 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -80,6 +80,7 @@ tree_checks() { run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs run "ship tool self-test" node tools/ship-app.mjs --self-test run "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.mjs --self-test + run "heat gate reader self-test (a known hold passes, a known drift fails)" node tools/heat-gate.mjs --self-test run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs run "miner app notice strip and update card tests" 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 app/igneum-app/ui/ui-ota.test.mjs run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh' diff --git a/tools/heat-gate.mjs b/tools/heat-gate.mjs new file mode 100644 index 00000000..a480b683 --- /dev/null +++ b/tools/heat-gate.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// The Ember Heat gate reader (mission item 7, docs/plans/ember-heat.md section 6): reads the HEAT lines the engine +// writes every 30 s (src/heat.rs log_line) from an app log and says whether the set temperature was held within one +// degree for the window while the hash followed the duty cycle. The runbook for the project lead's desk (PC 1) ends with this +// command; the tool never touches a machine, it reads a file. +// +// node tools/heat-gate.mjs [--hours 4] [--band 1.0] PASS or FAIL with the numbers, exit 0 or 1 +// node tools/heat-gate.mjs --self-test a known hold passes, a known drift and a hash +// that ran while resting fail (the watcher rule) +// +// A line: HEAT t=1759800000 phase=heating set=20.0 room=19.6 src=typed duty=0.60 heat_w=180 hash=74.2 until=312 +import { readFileSync } from 'node:fs'; + +const RE = /HEAT t=(\d+) phase=(\w+) set=([-\d.]+) room=([-\d.]+|-) src=(\w+) duty=([\d.]+) heat_w=([\d.]+) hash=([\d.]+) until=(\d+)/; +// a worker needs this long after a phase change to load its program or to quit +const SETTLE_S = 90; + +export function parseLines(text) { + const out = []; + for (const line of text.split('\n')) { + const m = RE.exec(line); + if (!m) continue; + out.push({ t: +m[1], phase: m[2], set: +m[3], room: m[4] === '-' ? null : +m[4], src: m[5], duty: +m[6], heatW: +m[7], hash: +m[8], until: +m[9] }); + } + return out; +} + +// The verdict over the last `hours` of samples: {pass, reasons[], ...numbers} +export function judge(samples, hours = 4, band = 1.0) { + const r = { pass: false, reasons: [], samples: samples.length, spanH: 0, maxDev: null, coverage: 0, mismatch: 0, dutyPlanned: 0, dutyMeasured: 0, heatW: 0 }; + if (!samples.length) { r.reasons.push('no HEAT lines in the log'); return r; } + const end = samples[samples.length - 1].t, start = end - hours * 3600; + const w = samples.filter((s) => s.t >= start); + r.spanH = (w[w.length - 1].t - w[0].t) / 3600; + if (r.spanH < hours - 0.1) r.reasons.push(`the log covers ${r.spanH.toFixed(2)} h, the gate needs ${hours}`); + const read = w.filter((s) => s.room !== null && s.src !== 'none'); + r.coverage = read.length / w.length; + if (r.coverage < 0.9) r.reasons.push(`only ${(r.coverage * 100).toFixed(0)}% of samples carry a room reading`); + if (read.length) { + r.maxDev = Math.max(...read.map((s) => Math.abs(s.room - s.set))); + if (r.maxDev > band) r.reasons.push(`the room left the band: ${r.maxDev.toFixed(2)} degrees from the set point (band ${band})`); + } + // the hash follows the slice: on while heating, off while resting, SETTLE_S after each change forgiven + let bad = 0, judged = 0, lastPhase = null, changedAt = -Infinity; + for (const s of w) { + if (s.phase !== lastPhase) { lastPhase = s.phase; changedAt = s.t; } + if (s.t - changedAt < SETTLE_S) continue; + if (s.phase !== 'heating' && s.phase !== 'resting') continue; + judged++; + if ((s.phase === 'heating') !== (s.hash > 0)) bad++; + } + r.mismatch = judged ? bad / judged : 1; + if (!judged) r.reasons.push('no sample settled into heating or resting'); + else if (r.mismatch > 0.05) r.reasons.push(`the hash did not follow the slice in ${(r.mismatch * 100).toFixed(0)}% of settled samples`); + const heating = w.filter((s) => s.phase === 'heating').length; + r.dutyMeasured = heating / w.length; + r.dutyPlanned = w.reduce((a, s) => a + s.duty, 0) / w.length; + if (Math.abs(r.dutyMeasured - r.dutyPlanned) > 0.15) r.reasons.push(`the measured heating share ${(r.dutyMeasured * 100).toFixed(0)}% is not the planned duty ${(r.dutyPlanned * 100).toFixed(0)}%`); + const hw = w.filter((s) => s.phase === 'heating' && s.heatW > 0); + r.heatW = hw.length ? hw.reduce((a, s) => a + s.heatW, 0) / hw.length : 0; + r.pass = r.reasons.length === 0; + return r; +} + +export function report(r, hours, band) { + const lines = [ + `${r.pass ? 'PASS' : 'FAIL'}: heat gate over the last ${hours} h (band ${band} degree${band === 1 ? '' : 's'})`, + ` samples ${r.samples}, span ${r.spanH.toFixed(2)} h, room readings on ${(r.coverage * 100).toFixed(0)}% of samples`, + ` worst deviation from the set point: ${r.maxDev === null ? 'no reading' : r.maxDev.toFixed(2) + ' degrees'}`, + ` hash followed the slice: ${((1 - r.mismatch) * 100).toFixed(0)}% of settled samples; heating share ${(r.dutyMeasured * 100).toFixed(0)}% against planned duty ${(r.dutyPlanned * 100).toFixed(0)}%`, + ` heat while heating: ${r.heatW ? r.heatW.toFixed(0) + ' W' : 'no draw reading'}`, + ]; + for (const why of r.reasons) lines.push(` RED: ${why}`); + return lines.join('\n'); +} + +// A synthetic log: `hours` of 10-minute periods at `duty`, the room wandering `wobble` degrees around the set point, +// the hash on exactly while heating unless `hashLeak` keeps it on through the rest. +function synth({ hours = 4.5, duty = 0.6, set = 20, wobble = 0.4, hashLeak = false, drift = 0 }) { + const out = []; + const t0 = 1_759_800_000; + for (let t = 0; t <= hours * 3600; t += 30) { + const inPeriod = t % 600, heating = inPeriod < duty * 600; + const room = set + wobble * Math.sin(t / 900) + drift * (t / (hours * 3600)); + const hash = heating || hashLeak ? 183.7 : 0; + out.push(`12:00:00 app HEAT t=${t0 + t} phase=${heating ? 'heating' : 'resting'} set=${set.toFixed(1)} room=${room.toFixed(1)} src=typed duty=${duty.toFixed(2)} heat_w=${heating ? 448 : 0} hash=${hash.toFixed(1)} until=${heating ? Math.round(duty * 600 - inPeriod) : 600 - inPeriod}`); + } + return out.join('\n'); +} + +function selfTest() { + const fails = []; + const good = judge(parseLines(synth({})), 4, 1.0); + if (!good.pass) fails.push('a known hold did not pass: ' + good.reasons.join('; ')); + const drift = judge(parseLines(synth({ drift: 1.8 })), 4, 1.0); + if (drift.pass || !drift.reasons.some((w) => w.includes('left the band'))) fails.push('a room that drifted 1.8 degrees passed'); + const leak = judge(parseLines(synth({ hashLeak: true })), 4, 1.0); + if (leak.pass || !leak.reasons.some((w) => w.includes('did not follow the slice'))) fails.push('a hash that ran through the rest passed'); + const short = judge(parseLines(synth({ hours: 2 })), 4, 1.0); + if (short.pass || !short.reasons.some((w) => w.includes('covers'))) fails.push('a two-hour log passed a four-hour gate'); + const empty = judge(parseLines('nothing here\n'), 4, 1.0); + if (empty.pass) fails.push('an empty log passed'); + const noRead = judge(parseLines(synth({}).replace(/room=[-\d.]+ src=typed/g, 'room=- src=none')), 4, 1.0); + if (noRead.pass || !noRead.reasons.some((w) => w.includes('room reading'))) fails.push('a log without room readings passed'); + if (fails.length) { console.error('self-test failed:\n ' + fails.join('\n ')); process.exit(1); } + console.log('self-test passed: a known hold passes; a drift of 1.8 degrees, a hash through the rest, a short log, an empty log and a log without readings fail'); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + if (args.includes('--self-test')) { selfTest(); process.exit(0); } + const file = args.find((a) => !a.startsWith('--')); + if (!file) { console.error('usage: node tools/heat-gate.mjs [--hours 4] [--band 1.0] | --self-test'); process.exit(2); } + const opt = (name, d) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] ? parseFloat(args[i + 1]) : d; }; + const hours = opt('--hours', 4), band = opt('--band', 1.0); + const r = judge(parseLines(readFileSync(file, 'utf8')), hours, band); + console.log(report(r, hours, band)); + process.exit(r.pass ? 0 : 1); +}