diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index bca7fe6e3..8e261c87c 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -906,6 +906,41 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String { format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)") } +/// HH:MM UTC of a unix time (the day is not shown: "since 18:39 UTC"). +pub fn hhmm_utc(unix: f64) -> String { + let s = unix.max(0.0) as u64 % 86_400; + format!("{:02}:{:02}", s / 3600, (s % 3600) / 60) +} + +/// Horizon polish Q83: the one sentence every surface shows while finality is paused. The cause is the node's own +/// (`reason`, with who holds it) when it carries one, else the plain two-thirds line; never the word "final". +pub fn finality_paused_line(since_unix: f64, reason: &str, held_by: &str) -> String { + let cause = if reason.trim().is_empty() { + "under two thirds of the weight is signing".to_string() + } else if held_by.trim().is_empty() { + reason.trim().to_string() + } else { + format!("{} (held by {})", reason.trim(), held_by.trim()) + }; + format!("Finality paused since {} UTC: {cause}", hhmm_utc(since_unix)) +} + +/// The node's pause line, when it carries one: `... finality_reason= held_by=`. +/// Returns (reason, held_by); None when the line is not one. +pub fn parse_finality_line(text: &str) -> Option<(String, String)> { + let i = text.find("finality_reason=")?; + let rest = &text[i + "finality_reason=".len()..]; + let (reason, after) = if let Some(q) = rest.strip_prefix('"') { + let end = q.find('"')?; + (q[..end].to_string(), &q[end + 1..]) + } else { + let end = rest.find(' ').unwrap_or(rest.len()); + (rest[..end].replace('_', " "), &rest[end..]) + }; + let held_by = after.find("held_by=").map(|j| after[j + 8..].split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default(); + Some((reason, held_by)) +} + /// Miner UI 4 (6 October 2026): the fleet's draw now: the sum over cards that mine and have a draw reading under /// `max_age_s` old (a stale reading is not a draw). pub fn fleet_watts<'a>(cards: impl Iterator, now: f64, max_age_s: f64) -> f64 { @@ -1063,6 +1098,20 @@ mod tests { assert_eq!(wei_from_hex("soon"), None); } + #[test] + fn the_finality_pause_line_names_the_time_and_the_cause_and_never_says_final() { + // 6 October 2026 18:39:00Z + let since = 1_791_311_940.0; + assert_eq!(hhmm_utc(since), "18:39"); + let plain = finality_paused_line(since, "", ""); + assert_eq!(plain, "Finality paused since 18:39 UTC: under two thirds of the weight is signing"); + assert!(!plain.to_ascii_lowercase().contains("final "), "no 'final' word while paused: {plain}"); + assert_eq!(finality_paused_line(since, "a checkpoint vote is split", "ae432dc7"), "Finality paused since 18:39 UTC: a checkpoint vote is split (held by ae432dc7)"); + assert_eq!(parse_finality_line("1791311940 finality paused finality_reason=\"vote split at index 412\" held_by=ae432dc7"), Some(("vote split at index 412".into(), "ae432dc7".into()))); + assert_eq!(parse_finality_line("x finality_reason=under_two_thirds"), Some(("under two thirds".into(), String::new()))); + assert_eq!(parse_finality_line("status: accepted 3 blocks"), None); + } + #[test] fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() { assert_eq!(goal_for("", "balanced"), Goal::Balanced); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 78b652164..74034db68 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -3095,7 +3095,7 @@ impl Engine { node_synced: st.node.synced && st.clock.severity != "block", // Horizon frontier lane: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S // (the last LOCK line's age; or, when none was ever seen this run, the engine's own uptime) - finality_paused: st.node.synced && (if st.finality.last_lock > 0 { st.finality.age_s } else { st.uptime_s as f64 }) > crate::manifest::FINALITY_PAUSE_S, + finality_paused: st.finality.paused, boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None }, // a remote job in progress counts as busy: no update applies under it (src/jobrun.rs) miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting") || self.jobs.active(), @@ -3624,6 +3624,18 @@ impl Engine { st.finality.age_s = unix - st.finality.last_lock_at; st.finality.message = String::new(); } + // Horizon polish Q83/Q84: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S (the + // last lock's age, else the engine's uptime); since the last lock (else the start); one sentence everywhere + let gap = if st.finality.last_lock > 0 { st.finality.age_s } else { st.uptime_s as f64 }; + let paused = st.node.synced && gap > crate::manifest::FINALITY_PAUSE_S; + if paused && !st.finality.paused { + st.finality.paused_since = if st.finality.last_lock > 0 { st.finality.last_lock_at } else { unix - st.uptime_s as f64 }; + } + st.finality.paused = paused; + st.finality.line = if paused { crate::ember::finality_paused_line(st.finality.paused_since, &st.finality.reason, &st.finality.held_by) } else { String::new() }; + if paused { + st.finality.message = st.finality.line.clone(); + } if self.running { let mining_now = st.mining.cards.iter().any(|c| c.state == "mining"); let any_slot = !self.miners.is_empty(); @@ -4029,6 +4041,13 @@ impl Engine { st.finality.age_s = 0.0; } } + } else if text.contains("finality_reason=") { + // the node lane's pause line (0.3.16): the cause and who holds it, shown in the one sentence + if let Some((reason, held_by)) = crate::ember::parse_finality_line(text) { + let mut st = self.st(); + st.finality.reason = reason; + st.finality.held_by = held_by; + } } else if text.contains(" VOTE index=") { self.st().finality.votes += 1; } else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") { diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index 7ff1a94d1..c6ab0253c 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -366,7 +366,10 @@ pub fn safe_to_apply(m: &Moment) -> Result<(), String> { /// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score). pub fn fork_is_close(activation_height: Option, daa: u64) -> bool { match activation_height { - Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h, + // Horizon polish Q4 (6 October 2026): an activation at or below the DAA has PASSED, nothing is pending; the + // old rule read it as close, so every update since the 0.3.14 manifest said "0 blocks away, installing now", + // stripped Later and skipped every safe-moment guard (PC 1's 17:52:54Z install under a job came through it) + Some(h) if daa > 0 && h > daa => daa.saturating_add(FORK_URGENT_BLOCKS) >= h, _ => false, } } @@ -555,8 +558,11 @@ mod tests { assert!(!fork_is_close(Some(120_000), 0)); assert!(!fork_is_close(Some(120_000), 118_199)); assert!(fork_is_close(Some(120_000), 118_200)); - assert!(fork_is_close(Some(120_000), 120_000)); - assert!(fork_is_close(Some(120_000), 130_000)); + assert!(fork_is_close(Some(120_000), 119_999), "one block before the activation"); + // a passed activation is not close (Horizon polish Q4): at the height and after it, nothing is pending + assert!(!fork_is_close(Some(120_000), 120_000)); + assert!(!fork_is_close(Some(120_000), 130_000)); + assert!(!fork_is_close(Some(33_000), 201_776), "the 0.3.14 manifest's case: difficulty v2 at 33,000 against PC 1's DAA"); let m = parse(SAMPLE).unwrap(); assert!(!unsupported(&m, "0.3.0")); assert!(unsupported(&m, "0.2.9")); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index bc5ed11e1..6797a2ee3 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -257,6 +257,15 @@ pub struct FinalityState { pub age_s: f64, pub votes: u64, pub message: String, + /// Horizon polish Q83/Q84 (6 October 2026): the network's finality is paused (a synced node, no checkpoint lock + /// for manifest::FINALITY_PAUSE_S); since when (the last lock's time, else the engine's start); the cause when + /// the node carries it (its `finality_reason=` / `held_by=` line), else the plain two-thirds line; and the one + /// sentence every surface shows: "Finality paused since 18:39 UTC: under two thirds of the weight is signing" + pub paused: bool, + pub paused_since: f64, + pub reason: String, + pub held_by: String, + pub line: String, } /// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind). diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 1df3cc395..70e9da41e 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -340,6 +340,13 @@ var View = (function () { } // the Node page's plain words: n = state.node, clock = state.clock, eta = the sync ETA sentence (may be '') + // Horizon polish Q83/Q84 (6 October 2026): while the network's finality is paused every surface shows the engine's + // one sentence (f.line, "Finality paused since 18:39 UTC: ...") and no surface claims a lock is final + function finalityWords(f) { + if (f && f.paused) return { paused: true, age: 'paused', note: f.line || 'Finality paused: under two thirds of the weight is signing' }; + if (f && f.last_lock > 0) return { paused: false, age: rel(f.age_s), note: 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the voting weight.' }; + return { paused: false, age: 'n/a', note: (f && f.message) || 'Locks appear once the miner votes on checkpoints.' }; + } function nodeWords(n, clock, eta) { var bad = clock && clock.severity === 'block'; if (bad) return { word: n.state === 'synced' ? 'synced' : n.state, line: 'Your clock is off by ' + skewWord(clock.skew_s) + '. Mining waits until it is synced.', tone: 'bad' }; @@ -413,7 +420,7 @@ var View = (function () { else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.'); return parts.join(' '); } - return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote }; + return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, toggle: toggle, nodeWords: nodeWords, finalityWords: finalityWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote }; })(); /* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ---------- One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned @@ -1122,6 +1129,8 @@ if (typeof document !== 'undefined') (function () { function renderNode(s) { var n = s.node, f = s.finality, eta = syncEta(n, s.now); var w = View.nodeWords(n, s.clock, eta); + var fw = View.finalityWords(f); + if (fw.paused) w.line = fw.note; setText('d-node', w.word); setText('d-node-sub', w.line); $('d-node-sub').title = w.line + (n.last_reading_age_s >= 0 ? ' · last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : ''); var cell = $('n-state-cell'); cell.classList.toggle('ok', w.tone === 'ok'); cell.classList.toggle('bad', w.tone === 'bad'); @@ -1143,8 +1152,8 @@ if (typeof document !== 'undefined') (function () { switchesSig = sig; $('n-switches').innerHTML = (n.consensus_switches || []).map(function (x) { return '' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '' + withCommas(x.daa) + ''; }).join(''); } - if (f.last_lock > 0) { setText('f-lock', '#' + withCommas(f.last_lock)); setText('f-age', rel(f.age_s)); setText('f-note', 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the 30-day weight; this machine votes every 30 s.'); } - else { setText('f-lock', 'none yet'); setText('f-age', 'n/a'); setText('f-note', f.message || 'Locks appear once the miner votes on checkpoints.'); } + setText('f-lock', f.last_lock > 0 ? '#' + withCommas(f.last_lock) : 'none yet'); setText('f-age', fw.age); setText('f-note', fw.note); + $('f-age').classList.toggle('warn', fw.paused); setText('f-votes', withCommas(f.votes)); } diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index 21af9f898..9326943cb 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -198,3 +198,18 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {}); assert.equal(t2.sub, 'mining on 1 of 1 card'); }); + +test('Horizon polish Q83/Q84: while finality is paused every surface shows the one sentence and no lock is called final', () => { + const V = mod.exports.View; + const paused = V.finalityWords({ paused: true, last_lock: 412, age_s: 1200, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' }); + assert.equal(paused.paused, true); + assert.equal(paused.age, 'paused'); + assert.equal(paused.note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing'); + assert.ok(!/\bfinal\b/i.test(paused.note), 'no "final" word while paused'); + const locked = V.finalityWords({ paused: false, last_lock: 412, age_s: 90, line: '' }); + assert.equal(locked.paused, false); + assert.ok(locked.note.indexOf('never be undone') > 0); + const none = V.finalityWords({ paused: false, last_lock: 0, age_s: 0, message: 'waiting for the miner' }); + assert.equal(none.age, 'n/a'); + assert.equal(none.note, 'waiting for the miner'); +}); diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index cd0b93776..2ba3b6400 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -326,6 +326,8 @@ For 0.3.16 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, o | `state.mining.pounds_per_day` | that draw as £ a day at `settings.power_price_pence` (0 when no price) | `ember::pounds_per_day` | | `state.address.balance_wei` | the payout address's balance in wei as a decimal string, `eth_getBalance` through the node's own RPC every 30 s while the node runs (60 s after a failure); null until read; `balance_age_s` (-1 until then), `balance_note` (the last error in words) | engine `tick_balance`, `Cmd::BalanceRead`, `ember::wei_from_hex` + test | | `state.address.price_gbp_per_ign` | null. Its one source will be a SIGNED field of the OTA manifest (`price`: gbp_per_ign, as_of, source), checked like the tuning object; the app never computes or fetches a price itself | state.rs (documented), no code until a market exists | +| Horizon polish Q4 (updater) | `fork_is_close`: an activation height at or below the DAA has passed, nothing is pending (the old rule read it as close: every update since the 0.3.14 manifest said "0 blocks away, installing now", stripped Later and skipped every guard; PC 1's 17:52:54Z install under a job came through it); `publish-manifest.sh` refuses an activation height at or below the live DAA (/api/live state.daa) unless `--allow-passed-activation` | manifest.rs + test, packaging/ota/publish-manifest.sh | +| Horizon polish Q83/Q84/Q2 (the pause shown) | `state.finality.paused`, `paused_since` (the last lock's time, else the engine's start), `reason`, `held_by`, `line` = "Finality paused since 18:39 UTC: under two thirds of the weight is signing" (the node's cause when it carries one: the engine parses a node log line carrying `finality_reason= held_by=`, the node lane's to emit); the node line, the Overview's state and the Finality card show the one sentence while paused, the Finality card's age reads "paused", and no surface calls a lock final; `finality.message` carries the sentence too so older UIs show it | ember.rs `finality_paused_line`, `parse_finality_line`, `hhmm_utc` + tests; engine.rs derive and the node-line parse; ui/app.js `View.finalityWords` + test | | the finality rule (updater) | Horizon frontier lane: the updater installs nothing while the network's finality is paused (a synced node with no checkpoint lock for `manifest::FINALITY_PAUSE_S` = 15 min; the last LOCK line's age, else the engine's uptime); the update card reads "waiting for finality: ..."; slot, catch-up and patience rules unchanged otherwise; only the signed manifest's own `urgent` flag installs through a pause (a fork-close or unsupported urgency does not); the known-failed case is the test | manifest.rs `Moment.finality_paused`, `manifest_urgent`, `Manifest.urgent`, `safe_to_apply` + test; ota.rs `Ctx`; engine.rs | | `GET /api/live` | the observer's reply shape from a local source: `"source": "node"` when igneumd carries `igneum_getRecentBlocks(seconds)` (the node lane, a283f5f0d364ceef0; the engine computes miners_10m, blocks_10m, blocks_per_minute, the 90 s blocks and the miners list from it and takes the DAG numbers from its node state), else `"source": "site"` (the public reply, fetched by curl, cached 60 s) with `age_s`; `pending: true` before the first fetch | src/live.rs (`shape_from_blocks`, `parse_recent` + tests), server.rs | diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index e0c8e54ff..4eee0a156 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -7,6 +7,7 @@ # packaging/ota/publish-manifest.sh --version 0.3.1 --mac packaging/mac/dist/Igneum-Miner-0.3.1.dmg \ # [--win packaging/windows/dist/Igneum-Miner-Setup-0.3.1.exe] --notes "one line of what changed" \ # [--activation-height 120000 --deadline-note "difficulty v2"] [--min-supported 0.3.0] [--channel devnet] [--deploy] +# [--allow-passed-activation] (Horizon polish Q4: an activation height at or below the live DAA is refused otherwise) # [--override '{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":120000}'] # consensus.override: the exact object every app writes to its override.json (the node's # --override-params-file), so it carries EVERY height switch, not just the new one; @@ -47,6 +48,7 @@ while [ $# -gt 0 ]; do --win) WIN="$2"; shift 2 ;; --notes) NOTES="$2"; shift 2 ;; --activation-height) ACTIVATION="$2"; shift 2 ;; + --allow-passed-activation) ALLOW_PASSED=1; shift ;; --deadline-note) DEADLINE="$2"; shift 2 ;; --override) OVERRIDE="$2"; shift 2 ;; # consensus.override: the exact JSON object every app writes to its override.json (all height switches, not just the new one) --min-supported) MIN_SUPPORTED="$2"; shift 2 ;; @@ -173,6 +175,23 @@ fi # canonical JSON: sorted keys, no whitespace; the signature is over these exact bytes NEW="$DEST/igneum-app-latest.json.new" +# Horizon polish Q4 (6 October 2026): an activation height that has already passed makes every app read the fork as +# close (0.3.14's manifest carried difficulty v2 at 33,000 against a DAA over 200,000: "0 blocks away, installing +# now" on every update, every safe-moment guard skipped). The live DAA comes from the public /api/live; a height at or +# below it is refused unless --allow-passed-activation says so. +if [ -n "$ACTIVATION" ]; then + LIVE_DAA="$(curl -fsS --max-time 10 "${IGNEUM_LIVE_API:-https://igneum.network/api/live}" 2>/dev/null | python3 -c 'import json,sys; print(int((json.load(sys.stdin).get("state") or {}).get("daa") or 0))' 2>/dev/null || echo 0)" + if [ "${LIVE_DAA:-0}" -gt 0 ] && [ "$ACTIVATION" -le "$LIVE_DAA" ]; then + if [ "${ALLOW_PASSED:-0}" = 1 ]; then + echo "activation height $ACTIVATION is at or below the live DAA $LIVE_DAA (passed); published anyway on --allow-passed-activation" >&2 + else + echo "refused: activation height $ACTIVATION is at or below the live DAA $LIVE_DAA, so it has passed; a passed activation makes every app read the fork as close (0.3.14 manifest). Drop --activation-height or pass --allow-passed-activation" >&2 + exit 2 + fi + elif [ "${LIVE_DAA:-0}" -eq 0 ]; then + echo "note: the live DAA could not be read from /api/live; the activation height $ACTIVATION is not checked against it" >&2 + fi +fi python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" <<'PY' import json, sys, datetime out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning = sys.argv[1:12]