starkernel: converge the tick path and wire the adaptive heartbeat (item 0.8)

Introduces src/starkernel/heartbeat.c as the shared top/bottom-half
implementation of heartbeat_init/tick/service/ticks/trust/state, replacing
the per-architecture duplicates in amd64/riscv64/aarch64 timer.c. Each
arch's timer.c now contributes only heartbeat_read_counter() (rdtsc /
rdtime / CNTPCT_EL0). Per the GAP-A1 ruling the top half stays counter+
latch only; heartbeat_service() (called every REPL idle iteration,
unconditionally per FABRIC.md's fidelity note) does the window/variance/
trust work outside interrupt context. vm_tick()'s call sites are
unchanged -- the engine still runs on the virtual tick.

Per FABRIC.md §26 (ruled 2026-08-03): wires Loop #7's execution-derived
stable/volatile signal into the physical re-arm period. vm_runtime.c's
existing Loop #7 site now calls heartbeat_set_adaptive_period_ns() with
tick_target_ns ratio-rescaled onto a 10ms kernel base (not the hosted
10us HEARTBEAT_TICK_NS -- see §26.3 for the scale mismatch). Each
architecture's re-arm function (apic_timer_rearm() on amd64/aarch64,
riscv64_timer_rearm()) now converts heartbeat_next_period_ns() to its
own raw counter units instead of a fixed constant; amd64 gained a
rearm function it didn't previously need, since periodic-mode auto-reload
never required one before this item.

Verified: all three architectures build with no new warnings and boot
cleanly to ok> with dict_hash=0x3d4e1daf289da94f, unchanged from the
pre-change baseline -- no regression. Verified NOT achieved: live re-arm
period variation under load. A temporary diagnostic (added and reverted)
confirmed Loop #7 never actually fired during a live QEMU session -- a
synthetic word-execution loop drove ~6,500 executions, past the 1000-tick
inference frequency, without tripping vm_tick_inference_engine()'s
pre-existing !vm->rolling_window.is_warm gate. That gate predates this
item and was not investigated -- out of scope. FABRIC.md's Done-when is
amended to record this honestly rather than claim it.

Punch list §25 item 0.8 complete (per amended, weaker acceptance -- see
the item's own annotation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-08-04 00:01:48 -04:00
co-authored by Claude Sonnet 5
parent da4cb14702
commit 3699be964d
23 changed files with 42170 additions and 449 deletions
+14 -92
View File
@@ -96,7 +96,6 @@ static uint64_t s_base_count = 0;
static uint64_t s_base_ns = 0;
static timer_calibration_record_t s_cal;
static TimeTrustState g_heartbeat;
/*
* @brief Initialise the RISC-V timer subsystem (M5 milestone).
@@ -232,99 +231,22 @@ const timer_calibration_record_t *timer_calibration_record(void)
return &s_cal;
}
/*
* @brief Initialise the heartbeat rolling-window state.
/**
* @brief Read the raw counter the riscv64 heartbeat is paced against.
*
* Zeroes @c g_heartbeat and sets the expected inter-tick interval as
* @c tsc_hz / @c tick_hz `time`-counter ticks. Falls back to 10,000,000
* ticks if either argument is zero. Initial @c trust is @c Q48_ONE
* (full confidence) — the RISC-V @c time counter is architecturally
* invariant regardless of which frequency source populated @c tsc_hz.
* Item 0.8 (FABRIC.md §25.1): the shared heartbeat.c now owns
* heartbeat_init()/heartbeat_tick()/heartbeat_service()/heartbeat_ticks()/
* heartbeat_trust()/heartbeat_state(). This is the one piece that stays
* per-architecture -- the same @c rdtime() the timer deadline is armed
* against, not @c rdcycle() or any other source. Must read the same
* counter the deadline was programmed against: @c expected_delta is
* derived from timebase-frequency and is therefore in @c time units;
* measuring the interval with @c cycle instead would difference two
* unrelated clocks.
*
* @param tsc_hz `time` counter frequency (Hz); from @c timer_tsc_hz().
* @param tick_hz Heartbeat rate (Hz); from @c apic_timer_init().
* @return Current `time` CSR value.
*/
void heartbeat_init(uint64_t tsc_hz, uint64_t tick_hz)
uint64_t heartbeat_read_counter(void)
{
g_heartbeat.ticks = 0;
g_heartbeat.last_tsc = 0;
g_heartbeat.total_samples = 0;
g_heartbeat.variance = 0;
g_heartbeat.trust = Q48_ONE;
g_heartbeat.window.pos = 0;
g_heartbeat.window.count = 0;
for (int i = 0; i < TIME_WINDOW_SIZE; i++)
g_heartbeat.window.deltas[i] = 0;
g_heartbeat.expected_delta = (tick_hz > 0 && tsc_hz > 0)
? (tsc_hz / tick_hz) : 10000000ULL;
return rdtime();
}
/**
* @brief Record one heartbeat tick and update the inter-tick deviation window.
*
* Reads @c rdtime() and, if @c last_tsc is non-zero, records the signed
* deviation @c ((now - last_tsc) - expected_delta) into the circular
* @c window.deltas[] buffer. Increments @c ticks and @c total_samples.
* Sets @c trust = @c Q48_ONE unconditionally — the RISC-V @c time counter
* is invariant and needs no statistical quality estimate.
*
* Must read the same counter the deadline was programmed against.
* @c expected_delta is derived from @c timebase-frequency and is therefore in
* @c time units; measuring the interval with @c cycle instead would difference
* two unrelated clocks and produce exactly the wrong-expected-interval defect
* that switching off @c rdcycle was meant to remove.
*
* Called from @c riscv64_interrupt_handler() on @c scause cause 5, after the
* timer has been re-armed.
*/
void heartbeat_tick(void)
{
uint64_t now = rdtime();
if (g_heartbeat.last_tsc != 0) {
int64_t delta = (int64_t)(now - g_heartbeat.last_tsc)
- (int64_t)g_heartbeat.expected_delta;
uint32_t pos = g_heartbeat.window.pos % TIME_WINDOW_SIZE;
g_heartbeat.window.deltas[pos] = delta;
g_heartbeat.window.pos++;
if (g_heartbeat.window.count < TIME_WINDOW_SIZE)
g_heartbeat.window.count++;
g_heartbeat.total_samples++;
}
g_heartbeat.last_tsc = now;
g_heartbeat.ticks++;
g_heartbeat.trust = Q48_ONE;
}
/**
* @brief Return the total number of heartbeat ticks since @c heartbeat_init().
*
* @return Monotonic tick counter; incremented once per @c heartbeat_tick() call.
*/
uint64_t heartbeat_ticks(void) { return g_heartbeat.ticks; }
/**
* @brief Return the current TIME-TRUST quality metric in Q48.16 format.
*
* Always returns @c Q48_ONE on RISC-V because the @c time counter's *rate
* stability* is invariant by specification — this tracks jitter in the
* tick-to-tick interval, and is orthogonal to whether the frequency value
* itself was discovered or is the fallback (see @c timer_calibration_record()
* for that distinction). The x86-64 implementation derives this from
* rolling-window variance.
*
* @return TIME-TRUST as Q48.16; always @c Q48_ONE on RISC-V.
*/
time_trust_t heartbeat_trust(void) { return g_heartbeat.trust; }
/**
* @brief Return a pointer to the heartbeat @c TimeTrustState.
*
* Provides read access to the full @c g_heartbeat structure for
* @c sk_parity_collect(), @c sk_hal_time_trust(), and other consumers.
* The pointer is valid for the lifetime of the kernel.
*
* @return Pointer to @c g_heartbeat; never NULL.
*/
const TimeTrustState *heartbeat_state(void) { return &g_heartbeat; }