Horizon polish for 0.3.16: (Q4) fork_is_close treats a passed activation as not close (every update since the 0.3.14 manifest read it as 0 blocks away and skipped every guard), publish-manifest.sh refuses an activation height at or below the live DAA unless --allow-passed-activation; (Q83, Q84, Q2) the finality pause shown: state.finality.paused, paused_since, 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 finality_reason/held_by), on the node line, the Overview's state and the Finality card, no lock called final while paused; tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 20:28:23 +00:00
parent f3b43179de
commit e5e1e92e37
8 changed files with 135 additions and 7 deletions

View file

@ -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=<words or "quoted words"> held_by=<id>`.
/// 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<Item = (&'a str, f64, f64)>, 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);

View file

@ -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 ") {

View file

@ -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<u64>, 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"));

View file

@ -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).

View file

@ -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 '<span class="' + (sw && x.key === sw.key ? 'next' : x.daa <= n.daa ? 'past' : '') + '"><span>' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '</span><span>' + withCommas(x.daa) + '</span></span>'; }).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));
}

View file

@ -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');
});

View file

@ -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=<words or "quoted words"> held_by=<id>`, 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 |

View file

@ -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]