diff --git a/Cargo.toml b/Cargo.toml index 696c4cd..12f7147 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/arcane-core", "crates/arcane-spatial", "crates/arcane-rules", + "crates/arcane-affinity", "crates/arcane-pool", "crates/arcane-infra", "crates/arcane-wire", diff --git a/crates/arcane-affinity/Cargo.toml b/crates/arcane-affinity/Cargo.toml new file mode 100644 index 0000000..b2c287c --- /dev/null +++ b/crates/arcane-affinity/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "arcane-affinity" +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-only" +description = "Arcane Engine — AffinityEngine (IN-08), interaction-weighted IClusteringModel" + +[dependencies] +arcane-core = { path = "../arcane-core" } +uuid = { version = "1.0", features = ["v4"] } +tracing = "0.1" + +[dev-dependencies] +uuid = { version = "1.0", features = ["v4"] } diff --git a/crates/arcane-affinity/src/config.rs b/crates/arcane-affinity/src/config.rs new file mode 100644 index 0000000..2ba7572 --- /dev/null +++ b/crates/arcane-affinity/src/config.rs @@ -0,0 +1,58 @@ +/// All tunable parameters for AffinityEngine. Every field has a sensible default. +/// Make all fields pub so the benchmark harness can construct configs for parameter sweeps. +#[derive(Debug, Clone)] +pub struct AffinityConfig { + // Interaction Graph + pub decay_factor: f64, + pub gc_threshold: f64, + pub gc_interval: u32, + + // Interaction weights + pub weight_collision: f64, + pub weight_game_action: f64, + pub weight_party_member: f64, + pub weight_guild_member: f64, + pub weight_proximity_per_tick: f64, + pub proximity_radius: f64, + + // Scoring + pub spatial_weight: f64, + + // Hysteresis + pub migration_threshold: f64, + pub cooldown_ticks: u32, + + // Capacity + pub max_entities_per_cluster: usize, + pub capacity_soft_limit_fraction: f64, + + // Decision translation + pub merge_entity_threshold: usize, +} + +impl Default for AffinityConfig { + fn default() -> Self { + Self { + decay_factor: 0.97, + gc_threshold: 0.001, + gc_interval: 100, + + weight_collision: 1.0, + weight_game_action: 2.0, + weight_party_member: 5.0, + weight_guild_member: 1.0, + weight_proximity_per_tick: 0.1, + proximity_radius: 50.0, + + spatial_weight: 0.2, + + migration_threshold: 3.0, + cooldown_ticks: 50, + + max_entities_per_cluster: 0, + capacity_soft_limit_fraction: 0.8, + + merge_entity_threshold: 5, + } + } +} diff --git a/crates/arcane-affinity/src/hysteresis.rs b/crates/arcane-affinity/src/hysteresis.rs new file mode 100644 index 0000000..90f4803 --- /dev/null +++ b/crates/arcane-affinity/src/hysteresis.rs @@ -0,0 +1,106 @@ +use std::collections::HashMap; +use uuid::Uuid; + +/// Per-entity migration cooldown tracking. Prevents oscillation after a migration. +pub struct MigrationState { + cooldowns: HashMap, +} + +impl MigrationState { + pub fn new() -> Self { + Self { + cooldowns: HashMap::new(), + } + } + + /// Record that an entity just migrated. Sets its cooldown to cooldown_ticks. + pub fn record_migration(&mut self, entity: Uuid, cooldown_ticks: u32) { + self.cooldowns.insert(entity, cooldown_ticks); + } + + /// True if entity is currently in cooldown and cannot migrate. + pub fn is_on_cooldown(&self, entity: Uuid) -> bool { + self.cooldowns.get(&entity).copied().unwrap_or(0) > 0 + } + + /// Decrement all cooldowns by 1. Remove entries that reach 0. + pub fn tick(&mut self) { + self.cooldowns.retain(|_, ticks| { + *ticks = ticks.saturating_sub(1); + *ticks > 0 + }); + } + + /// Remove cooldown state for an entity (on disconnect/despawn). + pub fn remove_entity(&mut self, entity: Uuid) { + self.cooldowns.remove(&entity); + } + + /// Number of entities currently on cooldown. For metrics. + pub fn cooldown_count(&self) -> usize { + self.cooldowns.len() + } +} + +impl Default for MigrationState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uuid(n: u8) -> Uuid { + Uuid::from_bytes([n; 16]) + } + + #[test] + fn new_state_has_no_cooldowns() { + let s = MigrationState::new(); + assert!(!s.is_on_cooldown(uuid(1))); + assert_eq!(s.cooldown_count(), 0); + } + + #[test] + fn record_migration_sets_cooldown() { + let mut s = MigrationState::new(); + s.record_migration(uuid(1), 10); + assert!(s.is_on_cooldown(uuid(1))); + assert_eq!(s.cooldown_count(), 1); + } + + #[test] + fn tick_decrements_cooldown() { + let mut s = MigrationState::new(); + s.record_migration(uuid(1), 3); + s.tick(); + assert!(s.is_on_cooldown(uuid(1))); + s.tick(); + assert!(s.is_on_cooldown(uuid(1))); + s.tick(); + assert!(!s.is_on_cooldown(uuid(1))); + assert_eq!(s.cooldown_count(), 0); + } + + #[test] + fn cooldown_expires_at_exact_tick_count() { + let mut s = MigrationState::new(); + s.record_migration(uuid(1), 50); + for _ in 0..49 { + s.tick(); + } + assert!(s.is_on_cooldown(uuid(1))); + s.tick(); + assert!(!s.is_on_cooldown(uuid(1))); + } + + #[test] + fn remove_entity_clears_cooldown() { + let mut s = MigrationState::new(); + s.record_migration(uuid(1), 100); + s.remove_entity(uuid(1)); + assert!(!s.is_on_cooldown(uuid(1))); + } +} diff --git a/crates/arcane-affinity/src/interaction_graph.rs b/crates/arcane-affinity/src/interaction_graph.rs new file mode 100644 index 0000000..d6c7e45 --- /dev/null +++ b/crates/arcane-affinity/src/interaction_graph.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; +use uuid::Uuid; + +/// Canonical ordered pair key — always (min, max) to avoid duplicate (A,B)/(B,A) entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EntityPair(Uuid, Uuid); + +impl EntityPair { + pub fn new(a: Uuid, b: Uuid) -> Self { + if a <= b { + EntityPair(a, b) + } else { + EntityPair(b, a) + } + } +} + +/// Tracks decaying pairwise interaction weights between entities. +pub struct InteractionGraph { + weights: HashMap, + tick_count: u32, +} + +impl InteractionGraph { + pub fn new() -> Self { + Self { + weights: HashMap::new(), + tick_count: 0, + } + } + + /// Record an interaction between two entities. Adds weight to existing value (does not replace). + pub fn record_interaction(&mut self, a: Uuid, b: Uuid, weight: f64) { + if a == b { + return; + } + let pair = EntityPair::new(a, b); + *self.weights.entry(pair).or_insert(0.0) += weight; + } + + /// Apply exponential decay to all weights. Every gc_interval ticks, prune entries below gc_threshold. + pub fn tick(&mut self, decay_factor: f64, gc_threshold: f64, gc_interval: u32) { + self.tick_count = self.tick_count.wrapping_add(1); + + for weight in self.weights.values_mut() { + *weight *= decay_factor; + } + + if gc_interval > 0 && self.tick_count.is_multiple_of(gc_interval) { + self.weights.retain(|_, w| *w >= gc_threshold); + } + } + + /// Get interaction weight between two entities. Returns 0.0 if no record. + pub fn get_weight(&self, a: Uuid, b: Uuid) -> f64 { + self.weights + .get(&EntityPair::new(a, b)) + .copied() + .unwrap_or(0.0) + } + + /// Iterate all entities with non-zero interaction weight with the given entity. + pub fn neighbors(&self, entity: Uuid) -> impl Iterator + '_ { + self.weights.iter().filter_map(move |(pair, &weight)| { + if pair.0 == entity { + Some((pair.1, weight)) + } else if pair.1 == entity { + Some((pair.0, weight)) + } else { + None + } + }) + } + + /// Remove all entries involving an entity (on disconnect/despawn). + pub fn remove_entity(&mut self, entity: Uuid) { + self.weights + .retain(|pair, _| pair.0 != entity && pair.1 != entity); + } + + /// Number of tracked pairs. For metrics. + pub fn pair_count(&self) -> usize { + self.weights.len() + } +} + +impl Default for InteractionGraph { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uuid(n: u8) -> Uuid { + Uuid::from_bytes([n; 16]) + } + + #[test] + fn record_creates_entry() { + let mut g = InteractionGraph::new(); + g.record_interaction(uuid(1), uuid(2), 1.0); + assert_eq!(g.get_weight(uuid(1), uuid(2)), 1.0); + } + + #[test] + fn record_adds_not_replaces() { + let mut g = InteractionGraph::new(); + g.record_interaction(uuid(1), uuid(2), 1.0); + g.record_interaction(uuid(1), uuid(2), 0.5); + assert_eq!(g.get_weight(uuid(1), uuid(2)), 1.5); + } + + #[test] + fn canonical_ordering_symmetric() { + let mut g = InteractionGraph::new(); + g.record_interaction(uuid(1), uuid(2), 1.0); + assert_eq!(g.get_weight(uuid(2), uuid(1)), 1.0); + assert_eq!(g.pair_count(), 1); + } + + #[test] + fn tick_applies_decay() { + let mut g = InteractionGraph::new(); + g.record_interaction(uuid(1), uuid(2), 1.0); + g.tick(0.5, 0.0, 0); + assert!((g.get_weight(uuid(1), uuid(2)) - 0.5).abs() < 1e-10); + } + + #[test] + fn tick_gc_removes_below_threshold() { + let mut g = InteractionGraph::new(); + g.record_interaction(uuid(1), uuid(2), 0.0005); + g.tick(1.0, 0.001, 1); + assert_eq!(g.get_weight(uuid(1), uuid(2)), 0.0); + assert_eq!(g.pair_count(), 0); + } + + #[test] + fn neighbors_returns_interacting_entities() { + let mut g = InteractionGraph::new(); + g.record_interaction(uuid(1), uuid(2), 2.0); + g.record_interaction(uuid(1), uuid(3), 3.0); + g.record_interaction(uuid(2), uuid(3), 1.0); + + let mut neighbors: Vec<(Uuid, f64)> = g.neighbors(uuid(1)).collect(); + neighbors.sort_by_key(|(id, _)| *id); + assert_eq!(neighbors.len(), 2); + } + + #[test] + fn remove_entity_cleans_all_pairs() { + let mut g = InteractionGraph::new(); + g.record_interaction(uuid(1), uuid(2), 1.0); + g.record_interaction(uuid(1), uuid(3), 1.0); + g.record_interaction(uuid(2), uuid(3), 1.0); + g.remove_entity(uuid(1)); + assert_eq!(g.pair_count(), 1); + assert_eq!(g.get_weight(uuid(1), uuid(2)), 0.0); + assert_eq!(g.get_weight(uuid(2), uuid(3)), 1.0); + } + + #[test] + fn self_interaction_ignored() { + let mut g = InteractionGraph::new(); + g.record_interaction(uuid(1), uuid(1), 5.0); + assert_eq!(g.pair_count(), 0); + } +} diff --git a/crates/arcane-affinity/src/lib.rs b/crates/arcane-affinity/src/lib.rs new file mode 100644 index 0000000..a71f463 --- /dev/null +++ b/crates/arcane-affinity/src/lib.rs @@ -0,0 +1,717 @@ +pub mod config; +pub mod hysteresis; +pub mod interaction_graph; +pub mod scorer; + +use config::AffinityConfig; +use hysteresis::MigrationState; +use interaction_graph::InteractionGraph; +use scorer::score_entity; + +use arcane_core::{ + clustering_model::{ + ClusterDecision, DecisionReason, DecisionType, ModelInfo, ValidationResult, WorldStateView, + }, + types::Vec2, + IClusteringModel, +}; +use std::collections::HashMap; +use std::sync::Mutex; +use uuid::Uuid; + +pub struct AffinityEngine { + config: AffinityConfig, + interaction_graph: Mutex, + migration_state: Mutex, + current_assignments: Mutex>, +} + +impl AffinityEngine { + pub fn new(config: AffinityConfig) -> Self { + Self { + config, + interaction_graph: Mutex::new(InteractionGraph::new()), + migration_state: Mutex::new(MigrationState::new()), + current_assignments: Mutex::new(HashMap::new()), + } + } + + /// Inner computation: update state and return per-entity desired assignments. + /// Handles all 6 phases before decision translation. + fn compute_assignments_inner(&self, view: &WorldStateView) -> HashMap { + let mut graph = self.interaction_graph.lock().unwrap(); + let mut migration = self.migration_state.lock().unwrap(); + let mut assignments = self.current_assignments.lock().unwrap(); + + // Phase 1a: decay interaction graph + graph.tick( + self.config.decay_factor, + self.config.gc_threshold, + self.config.gc_interval, + ); + + // Phase 1b: inject party/guild signals + let players = &view.players; + for i in 0..players.len() { + for j in (i + 1)..players.len() { + let a = &players[i]; + let b = &players[j]; + + if let (Some(pa), Some(pb)) = (a.party_id, b.party_id) { + if pa == pb { + graph.record_interaction( + a.player_id, + b.player_id, + self.config.weight_party_member, + ); + } + } + + if let (Some(ga), Some(gb)) = (a.guild_id, b.guild_id) { + if ga == gb { + graph.record_interaction( + a.player_id, + b.player_id, + self.config.weight_guild_member, + ); + } + } + } + } + + // Phase 1c: inject proximity signals + let r_sq = self.config.proximity_radius * self.config.proximity_radius; + for i in 0..players.len() { + for j in (i + 1)..players.len() { + let a = &players[i]; + let b = &players[j]; + let dx = a.position.x - b.position.x; + let dy = a.position.y - b.position.y; + if dx * dx + dy * dy <= r_sq { + graph.record_interaction( + a.player_id, + b.player_id, + self.config.weight_proximity_per_tick, + ); + } + } + } + + // Phase 2: tick migration cooldowns + migration.tick(); + + // Phase 3: build cluster membership and centroids + let mut cluster_members: HashMap> = HashMap::new(); + for cluster in &view.clusters { + cluster_members + .entry(cluster.cluster_id) + .or_default() + .extend(cluster.player_ids.iter().copied()); + } + // Also incorporate assignments for entities not yet in cluster.player_ids + for player in players { + cluster_members + .entry(player.cluster_id) + .or_default() + .push(player.player_id); + } + // Dedup + for members in cluster_members.values_mut() { + members.sort_unstable(); + members.dedup(); + } + + let cluster_centroids: HashMap = view + .clusters + .iter() + .map(|c| (c.cluster_id, c.centroid)) + .collect(); + + let cluster_sizes: HashMap = cluster_members + .iter() + .map(|(id, members)| (*id, members.len())) + .collect(); + + // Phase 4: score each entity and decide migrations. + // Seed from cache first, then fill gaps from view.players (authoritative current + // assignment). This ensures entities that score below the migration threshold are + // already in new_assignments with their current cluster — Phase 5 must not override them. + let mut new_assignments: HashMap = assignments.clone(); + for player in players { + new_assignments + .entry(player.player_id) + .or_insert(player.cluster_id); + } + + for player in players { + let current_cluster = assignments + .get(&player.player_id) + .copied() + .unwrap_or(player.cluster_id); + + if migration.is_on_cooldown(player.player_id) { + continue; + } + + if cluster_centroids.is_empty() { + continue; + } + + let result = score_entity( + player.player_id, + player.position, + current_cluster, + &cluster_members, + &cluster_centroids, + &cluster_sizes, + &graph, + &self.config, + ); + + let improvement = result.best_score - result.current_score; + if result.best_cluster != current_cluster + && improvement > self.config.migration_threshold + { + new_assignments.insert(player.player_id, result.best_cluster); + migration.record_migration(player.player_id, self.config.cooldown_ticks); + } + } + + // Phase 5: new entities with no history → spatial fallback + for player in players { + if let std::collections::hash_map::Entry::Vacant(e) = + new_assignments.entry(player.player_id) + { + if let Some(cid) = nearest_cluster(player.position, &cluster_centroids) { + e.insert(cid); + } + } + } + + // Phase 6: clean up removed entities + let active: std::collections::HashSet = players.iter().map(|p| p.player_id).collect(); + for entity in assignments + .keys() + .filter(|e| !active.contains(*e)) + .copied() + .collect::>() + { + graph.remove_entity(entity); + migration.remove_entity(entity); + } + new_assignments.retain(|e, _| active.contains(e)); + + *assignments = new_assignments.clone(); + new_assignments + } + + /// Log per-tick metrics via tracing. + fn emit_metrics(&self, entity_assignments: &HashMap, view: &WorldStateView) { + let graph = self.interaction_graph.lock().unwrap(); + let migration = self.migration_state.lock().unwrap(); + + let cluster_sizes: HashMap = { + let mut m: HashMap = HashMap::new(); + for &cid in entity_assignments.values() { + *m.entry(cid).or_insert(0) += 1; + } + m + }; + let max_size = cluster_sizes.values().copied().max().unwrap_or(0); + let min_size = cluster_sizes.values().copied().min().unwrap_or(0); + + tracing::debug!( + interaction_pairs = graph.pair_count(), + migrations_blocked_cooldown = migration.cooldown_count(), + max_cluster_size = max_size, + min_cluster_size = min_size, + total_players = view.players.len(), + "affinity_engine_tick" + ); + } +} + +impl Default for AffinityEngine { + fn default() -> Self { + Self::new(AffinityConfig::default()) + } +} + +impl IClusteringModel for AffinityEngine { + fn evaluate(&self, view: &WorldStateView) -> Vec { + let entity_assignments = self.compute_assignments_inner(view); + self.emit_metrics(&entity_assignments, view); + + // Phase 7: translate per-entity assignments into merge/split decisions + assignments_to_decisions(&entity_assignments, view, &self.config) + } + + fn get_model_info(&self) -> ModelInfo { + ModelInfo { + model_type: "affinity_engine".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + trained_at: None, + feature_count: Some(3), // party, guild, proximity + } + } + + fn validate_view(&self, view: &WorldStateView) -> ValidationResult { + let mut warnings = Vec::new(); + let mut errors = Vec::new(); + + if view.players.is_empty() { + warnings.push( + "WorldStateView.players is empty — AffinityEngine running in degraded mode \ + (proximity/party/guild signals unavailable)" + .to_string(), + ); + } + + // Check player cluster references exist + let cluster_ids: std::collections::HashSet = + view.clusters.iter().map(|c| c.cluster_id).collect(); + for player in &view.players { + if !cluster_ids.contains(&player.cluster_id) { + errors.push(format!( + "player {} references unknown cluster {}", + player.player_id, player.cluster_id + )); + } + } + + ValidationResult { + valid: errors.is_empty(), + warnings, + errors, + } + } + + fn compute_entity_assignments(&self, view: &WorldStateView) -> HashMap { + self.compute_assignments_inner(view) + } +} + +/// Phase 7: convert per-entity desired assignments to merge/split ClusterDecisions. +fn assignments_to_decisions( + entity_assignments: &HashMap, + view: &WorldStateView, + config: &AffinityConfig, +) -> Vec { + // Build current cluster membership from view + let mut current_cluster: HashMap = HashMap::new(); + for cluster in &view.clusters { + for &pid in &cluster.player_ids { + current_cluster.insert(pid, cluster.cluster_id); + } + } + for player in &view.players { + current_cluster + .entry(player.player_id) + .or_insert(player.cluster_id); + } + + // Count how many entities want to move from cluster A to cluster B + let mut migration_counts: HashMap<(Uuid, Uuid), u32> = HashMap::new(); + for (&entity, &desired) in entity_assignments { + let current = match current_cluster.get(&entity) { + Some(&c) => c, + None => continue, + }; + if current != desired { + *migration_counts.entry((current, desired)).or_insert(0) += 1; + } + } + + let mut decisions = Vec::new(); + let mut handled_pairs: std::collections::HashSet<(Uuid, Uuid)> = + std::collections::HashSet::new(); + + for ((src, dst), count) in &migration_counts { + if *count < config.merge_entity_threshold as u32 { + continue; + } + // Normalize pair to avoid duplicate decisions + let key = if src < dst { + (*src, *dst) + } else { + (*dst, *src) + }; + if handled_pairs.contains(&key) { + continue; + } + handled_pairs.insert(key); + + decisions.push(ClusterDecision { + decision_type: DecisionType::Merge, + priority: 5, + reason: DecisionReason { + code: "HIGH_INTERACTION_RATE".to_string(), + detail: format!( + "{} entities have higher affinity with cluster {} than their current cluster {}", + count, dst, src + ), + }, + confidence: 1.0, + source_cluster_id: Some(*src), + target_cluster_id: Some(*dst), + cluster_id: None, + split_group_a: None, + split_group_b: None, + }); + } + + decisions +} + +/// Find the nearest cluster centroid. Returns None if no clusters exist. +fn nearest_cluster(pos: Vec2, centroids: &HashMap) -> Option { + centroids + .iter() + .min_by(|(id_a, ca), (id_b, cb)| { + let da = { + let dx = pos.x - ca.x; + let dy = pos.y - ca.y; + dx * dx + dy * dy + }; + let db = { + let dx = pos.x - cb.x; + let dy = pos.y - cb.y; + dx * dx + dy * dy + }; + da.partial_cmp(&db) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| id_a.cmp(id_b)) + }) + .map(|(&id, _)| id) +} + +#[cfg(test)] +mod tests { + use super::*; + use arcane_core::clustering_model::{ClusterInfo, PlayerInfo}; + + fn uuid(n: u8) -> Uuid { + Uuid::from_bytes([n; 16]) + } + + fn make_view(clusters: Vec, players: Vec) -> WorldStateView { + WorldStateView { + timestamp: 0.0, + evaluation_budget_ms: 50, + clusters, + players, + } + } + + fn cluster(id: Uuid, player_ids: Vec, cx: f64, cy: f64) -> ClusterInfo { + ClusterInfo { + cluster_id: id, + server_host: "localhost".to_string(), + player_ids, + player_count: 0, + cpu_pct: 0.0, + centroid: Vec2::new(cx, cy), + spread_radius: 0.0, + rpc_rate_out: 0.0, + } + } + + fn player(id: Uuid, cluster_id: Uuid, x: f64, y: f64) -> PlayerInfo { + PlayerInfo { + player_id: id, + cluster_id, + position: Vec2::new(x, y), + velocity: Vec2::new(0.0, 0.0), + guild_id: None, + party_id: None, + } + } + + #[test] + fn valid_assignments_for_all_entities() { + let c1 = uuid(10); + let c2 = uuid(11); + let p1 = uuid(1); + let p2 = uuid(2); + let p3 = uuid(3); + + let view = make_view( + vec![ + cluster(c1, vec![p1, p2], 0.0, 0.0), + cluster(c2, vec![p3], 100.0, 0.0), + ], + vec![ + player(p1, c1, 0.0, 0.0), + player(p2, c1, 5.0, 0.0), + player(p3, c2, 100.0, 0.0), + ], + ); + + let engine = AffinityEngine::default(); + let result = engine.compute_entity_assignments(&view); + + // Every entity must be assigned to an existing cluster + let cluster_ids: std::collections::HashSet = [c1, c2].into_iter().collect(); + for assigned_cluster in result.values() { + assert!(cluster_ids.contains(assigned_cluster)); + } + } + + #[test] + fn validate_view_warns_on_empty_players() { + let engine = AffinityEngine::default(); + let view = make_view(vec![cluster(uuid(1), vec![], 0.0, 0.0)], vec![]); + let result = engine.validate_view(&view); + assert!(result.valid); + assert!(!result.warnings.is_empty()); + } + + #[test] + fn validate_view_errors_on_unknown_cluster() { + let engine = AffinityEngine::default(); + let view = make_view( + vec![cluster(uuid(1), vec![], 0.0, 0.0)], + vec![player(uuid(2), uuid(99), 0.0, 0.0)], // cluster 99 doesn't exist + ); + let result = engine.validate_view(&view); + assert!(!result.valid); + } + + #[test] + fn get_model_info_returns_affinity_type() { + let engine = AffinityEngine::default(); + let info = engine.get_model_info(); + assert_eq!(info.model_type, "affinity_engine"); + assert!(info.trained_at.is_none()); + } + + #[test] + fn drop_in_replacement_no_panic() { + let c1 = uuid(10); + let c2 = uuid(11); + let view = make_view( + vec![ + cluster(c1, vec![uuid(1), uuid(2)], 0.0, 0.0), + cluster(c2, vec![uuid(3)], 50.0, 0.0), + ], + vec![ + player(uuid(1), c1, 0.0, 0.0), + player(uuid(2), c1, 2.0, 0.0), + player(uuid(3), c2, 50.0, 0.0), + ], + ); + + let engine = AffinityEngine::default(); + let decisions = engine.evaluate(&view); + // No panic, decisions is a valid vec (may be empty) + let _ = decisions; + } + + // ── Integration / behavioural tests (#76) ────────────────────────────────── + + /// A raid group of 20 entities with heavy mutual interactions stays on the same + /// cluster even after moving into the other cluster's spatial territory. + /// + /// Setup: cluster 1 centroid at x=-200, cluster 2 centroid at x=+200. + /// All 20 entities start in cluster 1 at x=-5 and interact heavily. + /// After building history, we reposition them to x=+5 (closer to C2 spatially). + /// + /// Expected: interaction score with C1 (all 19 partners in C1) >> spatial score + /// for C2, so improvement = score(C2) - score(C1) is negative → no migration. + #[test] + fn raid_group_stays_together_across_boundary() { + let c1 = uuid(10); + let c2 = uuid(11); + + // 20 group members + let members: Vec = (1u8..=20).map(uuid).collect(); + + let engine = AffinityEngine::new(AffinityConfig { + weight_game_action: 2.0, + spatial_weight: 0.2, + migration_threshold: 3.0, + ..AffinityConfig::default() + }); + + // Phase A: build interaction history — all members fight each other in C1. + // C1 centroid at x=-200, C2 centroid at x=+200. + // Members at x=-5: clearly C1 territory spatially. + let view_build = make_view( + vec![ + cluster(c1, members.clone(), -200.0, 0.0), + cluster(c2, vec![], 200.0, 0.0), + ], + members + .iter() + .map(|&id| player(id, c1, -5.0, 0.0)) + .collect(), + ); + + // Run 10 ticks of interaction: record game_action between every pair + // by evaluating the view (proximity signal fires every tick since all at x=-5 + // and proximity_radius=50 covers them all). + for _ in 0..10 { + engine.evaluate(&view_build); + } + + // Phase B: reposition all members to x=+5 — spatially closer to C2. + // But they're still assigned to C1, and interaction history is rich. + let view_moved = make_view( + vec![ + cluster(c1, members.clone(), -200.0, 0.0), + cluster(c2, vec![], 200.0, 0.0), + ], + members.iter().map(|&id| player(id, c1, 5.0, 0.0)).collect(), + ); + + let assignments = engine.compute_entity_assignments(&view_moved); + + // All 20 members must be assigned to the same cluster (they stay together). + let assigned_clusters: std::collections::HashSet = + members.iter().map(|id| assignments[id]).collect(); + assert_eq!( + assigned_clusters.len(), + 1, + "raid group scattered across clusters: {:?}", + assigned_clusters + ); + } + + /// Two-part hysteresis test: + /// + /// Part 1 — threshold guard: entity has marginal interaction with C2 entities + /// (weight < migration_threshold) so it stays in C1 despite spatial pull toward C2. + /// + /// Part 2 — cooldown guard: entity gets overwhelming interaction with C2 and + /// migrates. Immediately after, cooldown prevents a re-migration back to C1. + #[test] + fn hysteresis_prevents_boundary_oscillation() { + let c1 = uuid(10); + let c2 = uuid(11); + let entity = uuid(1); + let c1_partner = uuid(2); // lives in C1 + let c2_partner = uuid(3); // lives in C2 + + // Config: migration_threshold=3.0, cooldown_ticks=5 (short for test speed) + let engine = AffinityEngine::new(AffinityConfig { + spatial_weight: 0.0, // pure interaction — isolates the hysteresis logic + migration_threshold: 3.0, + cooldown_ticks: 5, + ..AffinityConfig::default() + }); + + // ── Part 1: threshold guard ────────────────────────────────────────── + // Entity is in C1. It has interaction weight 1.0 with a C2 entity. + // improvement = score(C2) - score(C1) = 1.0 - 0.0 = 1.0 < 3.0 → no migration. + + // Seed a single interaction with the C2 partner (weight 1.0). + { + let mut graph = engine.interaction_graph.lock().unwrap(); + graph.record_interaction(entity, c2_partner, 1.0); + } + + let view_threshold = make_view( + vec![ + cluster(c1, vec![entity, c1_partner], 0.0, 0.0), + cluster(c2, vec![c2_partner], 0.0, 0.0), + ], + vec![ + player(entity, c1, 0.0, 0.0), + player(c1_partner, c1, 0.0, 0.0), + player(c2_partner, c2, 0.0, 0.0), + ], + ); + + let assignments = engine.compute_entity_assignments(&view_threshold); + assert_eq!( + assignments.get(&entity).copied().unwrap_or(c1), + c1, + "threshold guard failed: entity migrated with insufficient improvement" + ); + + // ── Part 2a: overwhelming interaction triggers migration ────────────── + // Add strong interaction with C2 partner: total weight now >> migration_threshold. + { + let mut graph = engine.interaction_graph.lock().unwrap(); + graph.record_interaction(entity, c2_partner, 10.0); + } + // Also clear any existing assignment cache so entity starts fresh from C1. + { + let mut assignments_cache = engine.current_assignments.lock().unwrap(); + assignments_cache.insert(entity, c1); + } + + let assignments2 = engine.compute_entity_assignments(&view_threshold); + let entity_cluster_after_migration = assignments2.get(&entity).copied().unwrap_or(c1); + assert_eq!( + entity_cluster_after_migration, c2, + "entity should have migrated to C2 with overwhelming interaction" + ); + + // ── Part 2b: cooldown prevents immediate re-migration ───────────────── + // Entity is now in C2. Build a view that would spatially/interactionally + // suggest C1 (add heavy interaction with C1 partner, clear C2 interaction). + { + let mut graph = engine.interaction_graph.lock().unwrap(); + // Replace weights: heavy C1 interaction, zero C2 + graph.remove_entity(c2_partner); + graph.record_interaction(entity, c1_partner, 20.0); + } + + let view_cooldown = make_view( + vec![ + cluster(c1, vec![c1_partner], 0.0, 0.0), + cluster(c2, vec![entity], 0.0, 0.0), + ], + vec![ + player(entity, c2, 0.0, 0.0), + player(c1_partner, c1, 0.0, 0.0), + ], + ); + + // Entity just migrated — must be on cooldown → stays in C2 despite C1 pull. + let assignments3 = engine.compute_entity_assignments(&view_cooldown); + assert_eq!( + assignments3.get(&entity).copied().unwrap_or(c2), + c2, + "cooldown guard failed: entity re-migrated within cooldown window" + ); + } + + /// An entity with no interaction history migrates to the spatially nearest cluster when the + /// spatial improvement exceeds the migration threshold. migration_threshold=0.0 removes the + /// gate so any positive spatial improvement is sufficient — this is the correct way to test + /// that pure spatial scoring governs assignment when interaction history is absent. + /// + /// (Phase 5 spatial fallback only fires for entities with no cluster assignment at all; + /// for assigned entities Phase 4 must produce sufficient improvement to trigger migration.) + #[test] + fn isolated_entity_uses_spatial_fallback() { + let c1 = uuid(10); + let c2 = uuid(11); + let loner = uuid(1); + let anchor = uuid(2); // gives c1 a member so centroid is populated + + let engine = AffinityEngine::new(AffinityConfig { + migration_threshold: 0.0, // any positive spatial improvement triggers migration + ..AffinityConfig::default() + }); + + // Loner has no interactions. C1 centroid at x=-200, C2 centroid at x=+5. + // Loner is at x=0 — closer to C2. spatial improvement > 0 → migrates to C2. + let view = make_view( + vec![ + cluster(c1, vec![anchor], -200.0, 0.0), + cluster(c2, vec![], 5.0, 0.0), + ], + vec![player(loner, c1, 0.0, 0.0), player(anchor, c1, -200.0, 0.0)], + ); + + let assignments = engine.compute_entity_assignments(&view); + assert_eq!( + assignments.get(&loner).copied().unwrap_or(c1), + c2, + "loner should migrate to spatially nearest cluster C2" + ); + } +} diff --git a/crates/arcane-affinity/src/scorer.rs b/crates/arcane-affinity/src/scorer.rs new file mode 100644 index 0000000..176047d --- /dev/null +++ b/crates/arcane-affinity/src/scorer.rs @@ -0,0 +1,263 @@ +use crate::{config::AffinityConfig, interaction_graph::InteractionGraph}; +use arcane_core::types::Vec2; +use std::collections::HashMap; +use uuid::Uuid; + +/// Result of scoring an entity against all clusters. +pub struct ScoringResult { + pub best_cluster: Uuid, + pub best_score: f64, + pub current_score: f64, +} + +/// Compute the affinity score of `entity` for each cluster and return the best assignment. +/// +/// score(E, C) = interaction_score(E, C) + spatial_score(E, C) +/// +/// interaction_score = sum of interaction_weight(E, F) for all F in C +/// spatial_score = spatial_weight / (1.0 + distance(E_pos, C_centroid)) +/// +/// A soft capacity penalty is applied when a cluster exceeds capacity_soft_limit_fraction. +#[allow(clippy::too_many_arguments)] +pub fn score_entity( + entity: Uuid, + entity_pos: Vec2, + current_cluster: Uuid, + cluster_members: &HashMap>, + cluster_centroids: &HashMap, + cluster_sizes: &HashMap, + interaction_graph: &InteractionGraph, + config: &AffinityConfig, +) -> ScoringResult { + // Build a quick lookup: entity → cluster for interaction scoring + let entity_cluster: HashMap = cluster_members + .iter() + .flat_map(|(cid, members)| members.iter().map(move |&eid| (eid, *cid))) + .collect(); + + let mut best_cluster = current_cluster; + let mut best_score = f64::NEG_INFINITY; + let mut current_score = f64::NEG_INFINITY; + + for (cluster_id, centroid) in cluster_centroids { + let interaction_score: f64 = interaction_graph + .neighbors(entity) + .filter_map(|(neighbor, weight)| { + if entity_cluster.get(&neighbor) == Some(cluster_id) { + Some(weight) + } else { + None + } + }) + .sum(); + + let dx = entity_pos.x - centroid.x; + let dy = entity_pos.y - centroid.y; + let dist = (dx * dx + dy * dy).sqrt(); + let spatial_score = config.spatial_weight / (1.0 + dist); + + let mut score = interaction_score + spatial_score; + + // Soft capacity penalty + if config.max_entities_per_cluster > 0 { + let size = cluster_sizes.get(cluster_id).copied().unwrap_or(0); + let soft_limit = (config.max_entities_per_cluster as f64 + * config.capacity_soft_limit_fraction) as usize; + if size > soft_limit { + let overflow = (size - soft_limit) as f64; + let max_overflow = (config.max_entities_per_cluster - soft_limit).max(1) as f64; + let penalty = 1.0 - (overflow / max_overflow); + score *= penalty.max(0.1); + } + } + + if score > best_score { + best_score = score; + best_cluster = *cluster_id; + } + if *cluster_id == current_cluster { + current_score = score; + } + } + + // If current_cluster wasn't in centroids (shouldn't happen, but be safe) + if current_score == f64::NEG_INFINITY { + current_score = 0.0; + } + + ScoringResult { + best_cluster, + best_score, + current_score, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uuid(n: u8) -> Uuid { + Uuid::from_bytes([n; 16]) + } + + fn vec2(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) + } + + type ClusterMaps = ( + HashMap>, + HashMap, + HashMap, + ); + + fn build_test_clusters(assignments: &[(Uuid, Uuid, Vec2)]) -> ClusterMaps { + let mut members: HashMap> = HashMap::new(); + let mut centroids: HashMap = HashMap::new(); + let mut sizes: HashMap = HashMap::new(); + let mut cluster_pos_sum: HashMap = HashMap::new(); + + for &(entity, cluster, pos) in assignments { + members.entry(cluster).or_default().push(entity); + *sizes.entry(cluster).or_insert(0) += 1; + let e = cluster_pos_sum.entry(cluster).or_insert((0.0, 0.0, 0)); + e.0 += pos.x; + e.1 += pos.y; + e.2 += 1; + } + for (cid, (sx, sy, n)) in &cluster_pos_sum { + centroids.insert(*cid, vec2(sx / *n as f64, sy / *n as f64)); + } + (members, centroids, sizes) + } + + #[test] + fn interaction_dominated_scoring() { + let e = uuid(1); + let c1 = uuid(10); + let c2 = uuid(11); + let f1 = uuid(2); + let f2 = uuid(3); + + let assignments = [ + (e, c1, vec2(0.0, 0.0)), + (f1, c1, vec2(1.0, 0.0)), + (f2, c2, vec2(100.0, 0.0)), + ]; + let (members, centroids, sizes) = build_test_clusters(&assignments); + + let mut graph = InteractionGraph::new(); + graph.record_interaction(e, f2, 10.0); // heavy interaction with c2 entity + + let config = AffinityConfig { + spatial_weight: 0.0, // pure interaction + ..AffinityConfig::default() + }; + let result = score_entity( + e, + vec2(0.0, 0.0), + c1, + &members, + ¢roids, + &sizes, + &graph, + &config, + ); + + assert_eq!(result.best_cluster, c2); + } + + #[test] + fn spatial_fallback_no_interactions() { + let e = uuid(1); + let c1 = uuid(10); + let c2 = uuid(11); + let f1 = uuid(2); + let f2 = uuid(3); + + let assignments = [(f1, c1, vec2(1000.0, 0.0)), (f2, c2, vec2(5.0, 0.0))]; + let (members, centroids, sizes) = build_test_clusters(&assignments); + + let graph = InteractionGraph::new(); // no interactions + let config = AffinityConfig::default(); + + // Entity at (0,0) — closer to c2 centroid at (5,0) than c1 at (1000,0) + let result = score_entity( + e, + vec2(0.0, 0.0), + c1, + &members, + ¢roids, + &sizes, + &graph, + &config, + ); + assert_eq!(result.best_cluster, c2); + } + + #[test] + fn capacity_penalty_reduces_score() { + let e = uuid(1); + let c1 = uuid(10); + let c2 = uuid(11); + + let members_c1: Vec = (2..12).map(uuid).collect(); + let mut members: HashMap> = HashMap::new(); + members.insert(c1, members_c1); + members.insert(c2, vec![uuid(20)]); + let centroids: HashMap = [(c1, vec2(0.0, 0.0)), (c2, vec2(0.0, 0.0))] + .into_iter() + .collect(); + let sizes: HashMap = [(c1, 10), (c2, 1)].into_iter().collect(); + + let graph = InteractionGraph::new(); + let config = AffinityConfig { + max_entities_per_cluster: 10, + capacity_soft_limit_fraction: 0.8, + spatial_weight: 1.0, // pure spatial (same centroid, so equal) + ..AffinityConfig::default() + }; + + let result = score_entity( + e, + vec2(0.0, 0.0), + c1, + &members, + ¢roids, + &sizes, + &graph, + &config, + ); + // c1 at capacity, c2 has room — c2 should score better despite same spatial distance + assert_eq!(result.best_cluster, c2); + } + + #[test] + fn capacity_penalty_never_fully_zeros_score() { + let e = uuid(1); + let c1 = uuid(10); + + let members: HashMap> = [(c1, vec![uuid(2)])].into_iter().collect(); + let centroids: HashMap = [(c1, vec2(0.0, 0.0))].into_iter().collect(); + let sizes: HashMap = [(c1, 100)].into_iter().collect(); // way over limit + + let graph = InteractionGraph::new(); + let config = AffinityConfig { + max_entities_per_cluster: 10, + capacity_soft_limit_fraction: 0.8, + spatial_weight: 1.0, + ..AffinityConfig::default() + }; + + let result = score_entity( + e, + vec2(0.0, 0.0), + c1, + &members, + ¢roids, + &sizes, + &graph, + &config, + ); + assert!(result.best_score > 0.0); + } +} diff --git a/crates/arcane-core/src/clustering_model.rs b/crates/arcane-core/src/clustering_model.rs index 594bc3e..8c5dda7 100644 --- a/crates/arcane-core/src/clustering_model.rs +++ b/crates/arcane-core/src/clustering_model.rs @@ -75,6 +75,16 @@ pub trait IClusteringModel: Send + Sync { /// Validate the view before evaluation. Caller may use this to skip invalid views. fn validate_view(&self, view: &WorldStateView) -> ValidationResult; + + /// Return per-entity cluster assignments (entity_id → cluster_id). + /// Entities not in the map retain their current assignment. + /// Default returns empty map — models that reason per-entity override this. + fn compute_entity_assignments( + &self, + _view: &WorldStateView, + ) -> std::collections::HashMap { + std::collections::HashMap::new() + } } #[derive(Clone, Debug)] diff --git a/crates/arcane-infra/Cargo.toml b/crates/arcane-infra/Cargo.toml index fa6fc53..7faa036 100644 --- a/crates/arcane-infra/Cargo.toml +++ b/crates/arcane-infra/Cargo.toml @@ -8,6 +8,7 @@ description = "Arcane Engine — ClusterManager, ClusterServer, ReplicationChann arcane-core = { path = "../arcane-core" } arcane-spatial = { path = "../arcane-spatial" } arcane-rules = { path = "../arcane-rules" } +arcane-affinity = { path = "../arcane-affinity", optional = true } arcane-pool = { path = "../arcane-pool" } arcane-wire = { path = "../arcane-wire" } axum = { version = "0.7", optional = true } @@ -26,6 +27,7 @@ default = ["cluster-ws"] manager = ["dep:axum", "dep:tokio"] cluster-ws = ["dep:tokio", "dep:tokio-tungstenite", "dep:futures-util", "dep:rayon"] spacetimedb-persist = ["dep:reqwest"] +affinity-clustering = ["dep:arcane-affinity"] [[bin]] name = "arcane-cluster" diff --git a/crates/arcane-infra/src/cluster_manager.rs b/crates/arcane-infra/src/cluster_manager.rs index a44f2c9..e4d088e 100644 --- a/crates/arcane-infra/src/cluster_manager.rs +++ b/crates/arcane-infra/src/cluster_manager.rs @@ -1,13 +1,14 @@ //! ClusterManager (IN-01) — central coordinator. use arcane_core::{ - clustering_model::{ClusterInfo, WorldStateView}, + clustering_model::{ClusterInfo, PlayerInfo, WorldStateView}, types::Vec2, IClusteringModel, IServerPool, ServerHandle, }; use arcane_pool::LocalPool; use arcane_rules::RulesEngine; use arcane_spatial::SpatialIndex; +use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; @@ -43,6 +44,17 @@ impl ClusterManager { ) } + /// Create with a named clustering model. Supported values: "rules" (default), "affinity". + /// The "affinity" variant requires the `affinity-clustering` feature flag. + pub fn with_model(model_type: &str) -> Self { + let model: Arc = match model_type { + #[cfg(feature = "affinity-clustering")] + "affinity" => Arc::new(arcane_affinity::AffinityEngine::default()), + _ => Arc::new(RulesEngine::new()), + }; + Self::new(model, Arc::new(LocalPool::default()), SpatialIndex::new()) + } + /// Feed entity position into the spatial index (e.g. from SpacetimeDB or test harness). pub fn update_entity( &mut self, @@ -71,12 +83,23 @@ impl ClusterManager { if snapshot.is_empty() { return Ok(()); } + + // Build entity data for WorldStateView.players + let entity_data = self.spatial_index.snapshot_entities(); + let mut cluster_player_ids: HashMap> = HashMap::new(); + for &(entity_id, cluster_id, _) in &entity_data { + cluster_player_ids + .entry(cluster_id) + .or_default() + .push(entity_id); + } + let clusters: Vec = snapshot .into_iter() .map(|g| ClusterInfo { cluster_id: g.cluster_id, server_host: "localhost".to_string(), - player_ids: vec![], + player_ids: cluster_player_ids.remove(&g.cluster_id).unwrap_or_default(), player_count: g.entity_count, cpu_pct: 0.0, centroid: Vec2::new(g.centroid.x, g.centroid.z), @@ -84,11 +107,24 @@ impl ClusterManager { rpc_rate_out: 0.0, }) .collect(); + + let players: Vec = entity_data + .iter() + .map(|&(entity_id, cluster_id, pos)| PlayerInfo { + player_id: entity_id, + cluster_id, + position: Vec2::new(pos.x, pos.z), + velocity: Vec2::new(0.0, 0.0), + guild_id: None, + party_id: None, + }) + .collect(); + let view = WorldStateView { timestamp: 0.0, evaluation_budget_ms: 50, clusters, - players: vec![], + players, }; let _decisions = self.model.evaluate(&view); // Minimal apply: if we have clusters in the world and no servers allocated, allocate one. diff --git a/crates/arcane-spatial/src/index.rs b/crates/arcane-spatial/src/index.rs index 638d458..f9dfa4e 100644 --- a/crates/arcane-spatial/src/index.rs +++ b/crates/arcane-spatial/src/index.rs @@ -111,6 +111,15 @@ impl SpatialIndex { cluster_ids } + /// Return all entities as (entity_id, cluster_id, position) triples. + /// Used by ClusterManager to populate WorldStateView.players. + pub fn snapshot_entities(&self) -> Vec<(Uuid, Uuid, Vec3)> { + self.entities + .iter() + .map(|(&entity_id, &(cluster_id, position))| (entity_id, cluster_id, position)) + .collect() + } + /// Snapshot of all clusters for building WorldStateView. Called by ClusterManager before evaluate(). pub fn snapshot_for_view(&self) -> Vec { let mut cluster_ids: Vec = self.entities.values().map(|(c, _)| *c).collect();