Split heartbeat pacing seam
This commit is contained in:
parent
12ad6e17b0
commit
a50beb8db1
5 changed files with 207 additions and 165 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
193
epiphany-core/src/heartbeat_state/heartbeat_pacing.rs
Normal file
193
epiphany-core/src/heartbeat_state/heartbeat_pacing.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Reference in a new issue