Split heartbeat pacing seam

This commit is contained in:
Meta Vens 2026-05-18 00:48:29 +01:00
commit a50beb8db1
5 changed files with 207 additions and 165 deletions

View file

@ -9,6 +9,7 @@ use std::fs;
use std::path::Path;
mod heartbeat_documents;
mod heartbeat_pacing;
mod heartbeat_projection;
mod heartbeat_roles;
mod heartbeat_store;
@ -29,6 +30,11 @@ use heartbeat_projection::history_event_json;
use heartbeat_projection::pending_turn_json;
use heartbeat_projection::schedule_participant_json;
use heartbeat_projection::selection_policy_json;
use heartbeat_pacing::adaptive_swarm_pacing;
use heartbeat_pacing::effective_cooldown_multiplier;
use heartbeat_pacing::mood_cooldown_multiplier;
use heartbeat_pacing::personality_cooldown_multiplier;
use heartbeat_pacing::running_turn_count;
use heartbeat_roles::ROLE_ORDER;
use heartbeat_roles::agent_id_for_role;
use heartbeat_roles::default_participant;
@ -1339,165 +1345,6 @@ fn apply_mood_timing_from_appraisals(state: &mut EpiphanyHeartbeatStateEntry, ap
}
}
#[derive(Clone, Debug)]
struct AdaptiveSwarmPacing {
pressure: f64,
effective_heartbeat_rate: f64,
target_concurrency: usize,
running_turns: usize,
active_participants: usize,
signals: Value,
}
fn adaptive_swarm_pacing(
state: &EpiphanyHeartbeatStateEntry,
options: &HeartbeatPumpOptions,
) -> AdaptiveSwarmPacing {
let active_participants = state
.participants
.iter()
.filter(|participant| participant.status == "active")
.count();
let running_turns = running_turn_count(state);
let mood_signals = swarm_mood_signals(state);
let external_urgency = options.external_urgency.clamp(0.0, 1.0);
let pending_pressure = if active_participants == 0 {
0.0
} else {
(running_turns as f64 / active_participants as f64).clamp(0.0, 1.0)
};
let pressure = [
external_urgency,
mood_signals.max_anxiety,
mood_signals.max_urgency,
mood_signals.max_reaction_intensity,
mood_signals.max_thought_pressure,
pending_pressure * 0.65,
]
.into_iter()
.fold(0.0_f64, f64::max)
.clamp(0.0, 1.0);
let base_rate = options
.base_heartbeat_rate
.max(state.pacing_policy.minimum_effective_rate)
.max(0.001);
let min_rate = options.min_heartbeat_rate.max(0.001).min(base_rate);
let max_rate = options.max_heartbeat_rate.max(base_rate).max(min_rate);
let pressure_curve = pressure * pressure;
let effective_heartbeat_rate = round6(min_rate + (max_rate - min_rate) * pressure_curve);
let max_concurrency = options
.max_concurrency
.max(1)
.min(active_participants.max(1));
let relaxed_floor = if pressure < 0.18 {
0
} else {
options.min_concurrency.min(max_concurrency)
};
let target_concurrency = if pressure < 0.18 {
relaxed_floor
} else {
let span = max_concurrency.saturating_sub(relaxed_floor);
(relaxed_floor + (span as f64 * pressure).ceil() as usize).min(max_concurrency)
};
AdaptiveSwarmPacing {
pressure: round3(pressure),
effective_heartbeat_rate,
target_concurrency,
running_turns,
active_participants,
signals: serde_json::json!({
"externalUrgency": round3(external_urgency),
"maxAnxiety": round3(mood_signals.max_anxiety),
"averageAnxiety": round3(mood_signals.average_anxiety),
"maxUrgency": round3(mood_signals.max_urgency),
"maxArousal": round3(mood_signals.max_arousal),
"maxThoughtPressure": round3(mood_signals.max_thought_pressure),
"maxReactionIntensity": round3(mood_signals.max_reaction_intensity),
"pendingPressure": round3(pending_pressure),
"contract": "Anxiety-like state raises tempo and concurrency; calm state lets the swarm sleep slow.",
}),
}
}
#[derive(Clone, Debug, Default)]
struct SwarmMoodSignals {
max_anxiety: f64,
average_anxiety: f64,
max_urgency: f64,
max_arousal: f64,
max_thought_pressure: f64,
max_reaction_intensity: f64,
}
fn swarm_mood_signals(state: &EpiphanyHeartbeatStateEntry) -> SwarmMoodSignals {
let mut signals = SwarmMoodSignals::default();
let mut anxiety_total = 0.0;
let mut anxiety_count = 0_usize;
for participant in &state.participants {
let Some(mood) = participant.extra.get("moodTiming") else {
continue;
};
let anxiety = number_at(mood, "/anxiety");
let urgency = number_at(mood, "/urgency");
let arousal = number_at(mood, "/arousal");
let thought_pressure = number_at(mood, "/thoughtPressure");
let reaction_intensity = number_at(mood, "/reactionIntensity");
signals.max_anxiety = signals.max_anxiety.max(anxiety);
signals.max_urgency = signals.max_urgency.max(urgency);
signals.max_arousal = signals.max_arousal.max(arousal);
signals.max_thought_pressure = signals.max_thought_pressure.max(thought_pressure);
signals.max_reaction_intensity = signals.max_reaction_intensity.max(reaction_intensity);
anxiety_total += anxiety;
anxiety_count += 1;
}
if anxiety_count > 0 {
signals.average_anxiety = anxiety_total / anxiety_count as f64;
}
signals
}
fn running_turn_count(state: &EpiphanyHeartbeatStateEntry) -> usize {
state
.participants
.iter()
.filter(|participant| is_turn_pending(participant))
.count()
}
fn number_at(value: &Value, pointer: &str) -> f64 {
value
.pointer(pointer)
.and_then(Value::as_f64)
.unwrap_or(0.0)
}
pub(super) fn personality_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
participant
.extra
.get("personalityCooldownMultiplier")
.and_then(Value::as_f64)
.unwrap_or(1.0)
.clamp(0.25, 3.0)
}
pub(super) fn mood_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
participant
.extra
.get("moodCooldownMultiplier")
.and_then(Value::as_f64)
.unwrap_or(1.0)
.clamp(0.25, 3.0)
}
pub(super) fn effective_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
round3(personality_cooldown_multiplier(participant) * mood_cooldown_multiplier(participant))
.clamp(0.20, 4.0)
}
fn build_memory_resonance(records: &[RoleMemoryRecord]) -> Value {
let mut pairs = Vec::new();
for (left_index, left) in records.iter().enumerate() {
@ -3220,11 +3067,11 @@ fn now_stamp() -> String {
chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string()
}
fn round6(value: f64) -> f64 {
pub(super) fn round6(value: f64) -> f64 {
(value * 1_000_000.0).round() / 1_000_000.0
}
fn round3(value: f64) -> f64 {
pub(super) fn round3(value: f64) -> f64 {
(value * 1_000.0).round() / 1_000.0
}

View file

@ -0,0 +1,193 @@
use super::EpiphanyHeartbeatStateEntry;
use super::HeartbeatParticipant;
use super::HeartbeatPumpOptions;
use super::round3;
use super::round6;
use serde_json::Value;
#[derive(Clone, Debug)]
pub(super) struct AdaptiveSwarmPacing {
pub(super) pressure: f64,
pub(super) effective_heartbeat_rate: f64,
pub(super) target_concurrency: usize,
pub(super) running_turns: usize,
pub(super) active_participants: usize,
pub(super) signals: Value,
}
pub(super) fn adaptive_swarm_pacing(
state: &EpiphanyHeartbeatStateEntry,
options: &HeartbeatPumpOptions,
) -> AdaptiveSwarmPacing {
let active_participants = state
.participants
.iter()
.filter(|participant| participant.status == "active")
.count();
let running_turns = running_turn_count(state);
let mood_signals = swarm_mood_signals(state);
let external_urgency = options.external_urgency.clamp(0.0, 1.0);
let pending_pressure = if active_participants == 0 {
0.0
} else {
(running_turns as f64 / active_participants as f64).clamp(0.0, 1.0)
};
let pressure = [
external_urgency,
mood_signals.max_anxiety,
mood_signals.max_urgency,
mood_signals.max_reaction_intensity,
mood_signals.max_thought_pressure,
pending_pressure * 0.65,
]
.into_iter()
.fold(0.0_f64, f64::max)
.clamp(0.0, 1.0);
let base_rate = options
.base_heartbeat_rate
.max(state.pacing_policy.minimum_effective_rate)
.max(0.001);
let min_rate = options.min_heartbeat_rate.max(0.001).min(base_rate);
let max_rate = options.max_heartbeat_rate.max(base_rate).max(min_rate);
let pressure_curve = pressure * pressure;
let effective_heartbeat_rate = round6(min_rate + (max_rate - min_rate) * pressure_curve);
let max_concurrency = options
.max_concurrency
.max(1)
.min(active_participants.max(1));
let relaxed_floor = if pressure < 0.18 {
0
} else {
options.min_concurrency.min(max_concurrency)
};
let target_concurrency = if pressure < 0.18 {
relaxed_floor
} else {
let span = max_concurrency.saturating_sub(relaxed_floor);
(relaxed_floor + (span as f64 * pressure).ceil() as usize).min(max_concurrency)
};
AdaptiveSwarmPacing {
pressure: round3(pressure),
effective_heartbeat_rate,
target_concurrency,
running_turns,
active_participants,
signals: serde_json::json!({
"externalUrgency": round3(external_urgency),
"maxAnxiety": round3(mood_signals.max_anxiety),
"averageAnxiety": round3(mood_signals.average_anxiety),
"maxUrgency": round3(mood_signals.max_urgency),
"maxArousal": round3(mood_signals.max_arousal),
"maxThoughtPressure": round3(mood_signals.max_thought_pressure),
"maxReactionIntensity": round3(mood_signals.max_reaction_intensity),
"pendingPressure": round3(pending_pressure),
"contract": "Anxiety-like state raises tempo and concurrency; calm state lets the swarm sleep slow.",
}),
}
}
#[derive(Default)]
struct SwarmMoodSignals {
max_anxiety: f64,
average_anxiety: f64,
max_urgency: f64,
max_arousal: f64,
max_thought_pressure: f64,
max_reaction_intensity: f64,
}
fn swarm_mood_signals(state: &EpiphanyHeartbeatStateEntry) -> SwarmMoodSignals {
let mut signals = SwarmMoodSignals::default();
let mut anxiety_total = 0.0;
let mut anxiety_count = 0_usize;
for participant in &state.participants {
let Some(mood) = participant.extra.get("moodTiming") else {
continue;
};
let anxiety = number_at(mood, "/anxiety");
let urgency = number_at(mood, "/urgency");
let arousal = number_at(mood, "/arousal");
let thought_pressure = number_at(mood, "/thoughtPressure");
let reaction_intensity = number_at(mood, "/reactionIntensity");
signals.max_anxiety = signals.max_anxiety.max(anxiety);
signals.max_urgency = signals.max_urgency.max(urgency);
signals.max_arousal = signals.max_arousal.max(arousal);
signals.max_thought_pressure = signals.max_thought_pressure.max(thought_pressure);
signals.max_reaction_intensity = signals.max_reaction_intensity.max(reaction_intensity);
anxiety_total += anxiety;
anxiety_count += 1;
}
if anxiety_count > 0 {
signals.average_anxiety = anxiety_total / anxiety_count as f64;
}
signals
}
pub(super) fn running_turn_count(state: &EpiphanyHeartbeatStateEntry) -> usize {
state
.participants
.iter()
.filter(|participant| {
participant
.pending_turn
.as_ref()
.is_some_and(|turn| turn.status == "running")
})
.count()
}
fn number_at(value: &Value, pointer: &str) -> f64 {
value
.pointer(pointer)
.and_then(Value::as_f64)
.unwrap_or(0.0)
}
pub(super) fn personality_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
participant
.extra
.get("personalityCooldownMultiplier")
.and_then(Value::as_f64)
.unwrap_or(1.0)
.clamp(0.25, 3.0)
}
pub(super) fn mood_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
participant
.extra
.get("moodCooldownMultiplier")
.and_then(Value::as_f64)
.unwrap_or(1.0)
.clamp(0.25, 3.0)
}
pub(super) fn effective_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
round3(personality_cooldown_multiplier(participant) * mood_cooldown_multiplier(participant))
.clamp(0.20, 4.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::heartbeat_state::default_heartbeat_state;
use pretty_assertions::assert_eq;
#[test]
fn running_turn_count_reads_only_running_pending_turns() {
let mut state = default_heartbeat_state(1.0);
state.participants[0].pending_turn =
Some(crate::heartbeat_state::HeartbeatPendingTurn {
status: "running".to_string(),
..Default::default()
});
state.participants[1].pending_turn =
Some(crate::heartbeat_state::HeartbeatPendingTurn {
status: "completed".to_string(),
..Default::default()
});
assert_eq!(running_turn_count(&state), 1);
}
}

View file

@ -60,6 +60,7 @@ remembers doctrine; the branch remembers the blade.
- Turn control routes now live in `codex_message_processor/turn_routes.rs`: turn start, thread item injection, app-server client info setting, turn steer, turn interrupt, mode normalization, and input-limit validation with module-local unit tests.
- Heartbeat durable document/schema types now live in `epiphany-core/src/heartbeat_state/heartbeat_documents.rs`, role/default construction lives in `epiphany-core/src/heartbeat_state/heartbeat_roles.rs`, persistence/validation lives in `epiphany-core/src/heartbeat_state/heartbeat_store.rs`, and status/artifact JSON projection lives in `epiphany-core/src/heartbeat_state/heartbeat_projection.rs`. The root `heartbeat_state.rs` keeps scheduling and Void cognition orchestration for the next cuts. Focused heartbeat tests and `epiphany-core` check pass after the splits.
- Heartbeat role/default and store seams now have module-local unit tests: fixed lane catalog, Ghostlight scene role projection, and typed state/cognition CultCache round-trip. Keep future heartbeat cuts aligned to those seam tests instead of growing root integration-only coverage.
- Heartbeat adaptive pacing now lives in `epiphany-core/src/heartbeat_state/heartbeat_pacing.rs`, including running-turn count and effective cooldown multipliers. It has a module-local unit test for pending-turn counting; root scheduling calls the pacing seam instead of owning pressure/concurrency math directly.
- Thread projection now lives in `codex_message_processor/thread_projection.rs`: state-db/thread-store/rollout summary projection, `ConversationSummary` to API `Thread` mapping, thread-title attachment helpers, turn pagination, and rollout turn reconstruction. `codex_message_processor.rs` is about 1,009 lines after this extraction and mostly owns JSON-RPC dispatch, processor construction, shared thread lookup/submit/error helpers, and lifecycle shutdown.
- Turn paging/reconstruction now lives in `codex_message_processor/thread_turn_projection.rs`; `thread_projection.rs` keeps state-db/thread-store/rollout summary projection and `ConversationSummary` to API `Thread` mapping. Focused `thread_turns_list` cursor tests and stale in-progress turn interruption coverage pass.
- The `thread/turns/list` route handler now lives in `codex_message_processor/thread_turn_routes.rs`, beside the turn projection helpers it consumes. `thread_read_routes.rs` is back to persisted/live thread listing, `thread/read`, loaded-thread listing, and summary reads.

Binary file not shown.

File diff suppressed because one or more lines are too long