From 5d32225424a4e1c06acc9a8f09f01437c1437e47 Mon Sep 17 00:00:00 2001 From: rebelzion Date: Wed, 29 Apr 2026 03:24:30 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(affinity):=20add=20arcane-affinity=20c?= =?UTF-8?q?rate=20=E2=80=94=20interaction-weighted=20IClusteringModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements AffinityEngine (IN-08), a stateful clustering model that groups entities by observed interaction probability rather than pure spatial proximity. Solves three failure modes of RulesEngine: boundary thrashing, interaction blindness, and no temporal awareness. New crate: arcane-affinity - AffinityEngine: IClusteringModel impl with 7-phase evaluate() loop - InteractionGraph: decaying pairwise interaction weights with GC - MigrationState: per-entity cooldowns preventing post-migration oscillation - ClusterScorer: interaction + spatial affinity scoring with capacity penalty - AffinityConfig: all tunable parameters with sensible defaults Integration changes in existing crates: - arcane-core: extend IClusteringModel trait with compute_entity_assignments() default method (backward-compatible — RulesEngine requires no changes) - arcane-spatial: add snapshot_entities() to expose entity data for view building - arcane-infra: populate WorldStateView.players and ClusterInfo.player_ids from SpatialIndex in ClusterManager::run_evaluation_cycle() 22 new tests, full workspace green. Closes #65, #66, #67, #68, #69, #70, #71, #72, #73 Part of #64 (AffinityEngine epic) Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 1 + crates/arcane-affinity/Cargo.toml | 14 + crates/arcane-affinity/src/config.rs | 58 +++ crates/arcane-affinity/src/hysteresis.rs | 106 ++++ .../arcane-affinity/src/interaction_graph.rs | 171 ++++++ crates/arcane-affinity/src/lib.rs | 493 ++++++++++++++++++ crates/arcane-affinity/src/scorer.rs | 225 ++++++++ crates/arcane-core/src/clustering_model.rs | 10 + crates/arcane-infra/src/cluster_manager.rs | 28 +- crates/arcane-spatial/src/index.rs | 9 + 10 files changed, 1112 insertions(+), 3 deletions(-) create mode 100644 crates/arcane-affinity/Cargo.toml create mode 100644 crates/arcane-affinity/src/config.rs create mode 100644 crates/arcane-affinity/src/hysteresis.rs create mode 100644 crates/arcane-affinity/src/interaction_graph.rs create mode 100644 crates/arcane-affinity/src/lib.rs create mode 100644 crates/arcane-affinity/src/scorer.rs 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..084c83c --- /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 % gc_interval == 0 { + 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..ea6e498 --- /dev/null +++ b/crates/arcane-affinity/src/lib.rs @@ -0,0 +1,493 @@ +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 + let mut new_assignments: HashMap = assignments.clone(); + + 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 !new_assignments.contains_key(&player.player_id) { + let nearest = nearest_cluster(player.position, &cluster_centroids); + if let Some(cid) = nearest { + new_assignments.insert(player.player_id, 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(|(_, ca), (_, 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) + }) + .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 { + 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; + } +} diff --git a/crates/arcane-affinity/src/scorer.rs b/crates/arcane-affinity/src/scorer.rs new file mode 100644 index 0000000..89514b6 --- /dev/null +++ b/crates/arcane-affinity/src/scorer.rs @@ -0,0 +1,225 @@ +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. +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) + } + + fn build_test_clusters( + assignments: &[(Uuid, Uuid, Vec2)], // (entity, cluster, pos) + ) -> ( + HashMap>, + HashMap, + HashMap, + ) { + 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(|n| uuid(n)).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/src/cluster_manager.rs b/crates/arcane-infra/src/cluster_manager.rs index a44f2c9..309f74c 100644 --- a/crates/arcane-infra/src/cluster_manager.rs +++ b/crates/arcane-infra/src/cluster_manager.rs @@ -1,10 +1,11 @@ //! ClusterManager (IN-01) — central coordinator. use arcane_core::{ - clustering_model::{ClusterInfo, WorldStateView}, + clustering_model::{ClusterInfo, PlayerInfo, WorldStateView}, types::Vec2, IClusteringModel, IServerPool, ServerHandle, }; +use std::collections::HashMap; use arcane_pool::LocalPool; use arcane_rules::RulesEngine; use arcane_spatial::SpatialIndex; @@ -71,12 +72,20 @@ 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 +93,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(); From 31fc9639e58d963bc14ecb0c818a50d7ad88d95f Mon Sep 17 00:00:00 2001 From: rebelzion Date: Wed, 29 Apr 2026 03:27:26 -0700 Subject: [PATCH 2/5] style: apply cargo fmt to arcane-affinity and arcane-infra Co-Authored-By: Claude Sonnet 4.6 --- crates/arcane-affinity/src/lib.rs | 19 ++++--- crates/arcane-affinity/src/scorer.rs | 65 +++++++++++++++++----- crates/arcane-infra/src/cluster_manager.rs | 7 ++- 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/crates/arcane-affinity/src/lib.rs b/crates/arcane-affinity/src/lib.rs index ea6e498..2f49f50 100644 --- a/crates/arcane-affinity/src/lib.rs +++ b/crates/arcane-affinity/src/lib.rs @@ -10,8 +10,7 @@ use scorer::score_entity; use arcane_core::{ clustering_model::{ - ClusterDecision, DecisionReason, DecisionType, ModelInfo, ValidationResult, - WorldStateView, + ClusterDecision, DecisionReason, DecisionType, ModelInfo, ValidationResult, WorldStateView, }, types::Vec2, IClusteringModel, @@ -181,8 +180,7 @@ impl AffinityEngine { } // Phase 6: clean up removed entities - let active: std::collections::HashSet = - players.iter().map(|p| p.player_id).collect(); + let active: std::collections::HashSet = players.iter().map(|p| p.player_id).collect(); for entity in assignments .keys() .filter(|e| !active.contains(*e)) @@ -298,7 +296,9 @@ fn assignments_to_decisions( } } for player in &view.players { - current_cluster.entry(player.player_id).or_insert(player.cluster_id); + current_cluster + .entry(player.player_id) + .or_insert(player.cluster_id); } // Count how many entities want to move from cluster A to cluster B @@ -314,14 +314,19 @@ fn assignments_to_decisions( } let mut decisions = Vec::new(); - let mut handled_pairs: std::collections::HashSet<(Uuid, Uuid)> = std::collections::HashSet::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) }; + let key = if src < dst { + (*src, *dst) + } else { + (*dst, *src) + }; if handled_pairs.contains(&key) { continue; } diff --git a/crates/arcane-affinity/src/scorer.rs b/crates/arcane-affinity/src/scorer.rs index 89514b6..85922f9 100644 --- a/crates/arcane-affinity/src/scorer.rs +++ b/crates/arcane-affinity/src/scorer.rs @@ -60,13 +60,11 @@ pub fn score_entity( // 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; + 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 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); } @@ -139,7 +137,11 @@ mod tests { 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 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(); @@ -149,7 +151,16 @@ mod tests { spatial_weight: 0.0, // pure interaction ..AffinityConfig::default() }; - let result = score_entity(e, vec2(0.0, 0.0), c1, &members, ¢roids, &sizes, &graph, &config); + let result = score_entity( + e, + vec2(0.0, 0.0), + c1, + &members, + ¢roids, + &sizes, + &graph, + &config, + ); assert_eq!(result.best_cluster, c2); } @@ -169,7 +180,16 @@ mod tests { 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); + let result = score_entity( + e, + vec2(0.0, 0.0), + c1, + &members, + ¢roids, + &sizes, + &graph, + &config, + ); assert_eq!(result.best_cluster, c2); } @@ -183,10 +203,9 @@ mod tests { 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 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(); @@ -197,7 +216,16 @@ mod tests { ..AffinityConfig::default() }; - let result = score_entity(e, vec2(0.0, 0.0), c1, &members, ¢roids, &sizes, &graph, &config); + 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); } @@ -219,7 +247,16 @@ mod tests { ..AffinityConfig::default() }; - let result = score_entity(e, vec2(0.0, 0.0), c1, &members, ¢roids, &sizes, &graph, &config); + 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-infra/src/cluster_manager.rs b/crates/arcane-infra/src/cluster_manager.rs index 309f74c..d3b98e9 100644 --- a/crates/arcane-infra/src/cluster_manager.rs +++ b/crates/arcane-infra/src/cluster_manager.rs @@ -5,10 +5,10 @@ use arcane_core::{ types::Vec2, IClusteringModel, IServerPool, ServerHandle, }; -use std::collections::HashMap; use arcane_pool::LocalPool; use arcane_rules::RulesEngine; use arcane_spatial::SpatialIndex; +use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; @@ -77,7 +77,10 @@ impl ClusterManager { 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); + cluster_player_ids + .entry(cluster_id) + .or_default() + .push(entity_id); } let clusters: Vec = snapshot From 6ac92c2b8e45bc9684592834a03640b997aefe55 Mon Sep 17 00:00:00 2001 From: rebelzion Date: Wed, 29 Apr 2026 03:29:56 -0700 Subject: [PATCH 3/5] fix(affinity): resolve clippy warnings - interaction_graph: use .is_multiple_of() for GC interval check - scorer: allow too_many_arguments on score_entity (8-arg scoring fn), type alias for complex test return type, redundant closure in test - lib: use Entry API for spatial fallback insert, .values() in test loop Co-Authored-By: Claude Sonnet 4.6 --- crates/arcane-affinity/src/interaction_graph.rs | 2 +- crates/arcane-affinity/src/lib.rs | 11 ++++++----- crates/arcane-affinity/src/scorer.rs | 11 ++++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/arcane-affinity/src/interaction_graph.rs b/crates/arcane-affinity/src/interaction_graph.rs index 084c83c..d6c7e45 100644 --- a/crates/arcane-affinity/src/interaction_graph.rs +++ b/crates/arcane-affinity/src/interaction_graph.rs @@ -46,7 +46,7 @@ impl InteractionGraph { *weight *= decay_factor; } - if gc_interval > 0 && self.tick_count % gc_interval == 0 { + if gc_interval > 0 && self.tick_count.is_multiple_of(gc_interval) { self.weights.retain(|_, w| *w >= gc_threshold); } } diff --git a/crates/arcane-affinity/src/lib.rs b/crates/arcane-affinity/src/lib.rs index 2f49f50..1c4944e 100644 --- a/crates/arcane-affinity/src/lib.rs +++ b/crates/arcane-affinity/src/lib.rs @@ -171,10 +171,11 @@ impl AffinityEngine { // Phase 5: new entities with no history → spatial fallback for player in players { - if !new_assignments.contains_key(&player.player_id) { - let nearest = nearest_cluster(player.position, &cluster_centroids); - if let Some(cid) = nearest { - new_assignments.insert(player.player_id, cid); + 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); } } } @@ -441,7 +442,7 @@ mod tests { // 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 { + for assigned_cluster in result.values() { assert!(cluster_ids.contains(assigned_cluster)); } } diff --git a/crates/arcane-affinity/src/scorer.rs b/crates/arcane-affinity/src/scorer.rs index 85922f9..176047d 100644 --- a/crates/arcane-affinity/src/scorer.rs +++ b/crates/arcane-affinity/src/scorer.rs @@ -18,6 +18,7 @@ pub struct ScoringResult { /// 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, @@ -103,13 +104,13 @@ mod tests { Vec2::new(x, y) } - fn build_test_clusters( - assignments: &[(Uuid, Uuid, Vec2)], // (entity, cluster, pos) - ) -> ( + 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(); @@ -199,7 +200,7 @@ mod tests { let c1 = uuid(10); let c2 = uuid(11); - let members_c1: Vec = (2..12).map(|n| uuid(n)).collect(); + 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)]); From 1268a99d28250c15b5f51c8efb5c68614b0fe5d8 Mon Sep 17 00:00:00 2001 From: rebelzion Date: Wed, 29 Apr 2026 03:44:13 -0700 Subject: [PATCH 4/5] feat(affinity): #74 feature flag + model selector; #75/#76 integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #74 — arcane-infra/affinity-clustering feature flag - Add arcane-affinity as optional dependency in arcane-infra - Add ClusterManager::with_model("affinity"|"rules") constructor - Default build unchanged; affinity engine opt-in via feature flag #75/#76 — integration / behavioural tests (3 new tests) - raid_group_stays_together_across_boundary: 20 heavily interacting entities don't scatter when they cross a spatial boundary; interaction score dominates spatial pull - hysteresis_prevents_boundary_oscillation: threshold guard (improvement below migration_threshold → no move) and cooldown guard (post-migration lock prevents immediate re-migration) both validated - isolated_entity_uses_spatial_fallback: entity with no interaction history falls back to nearest cluster centroid 25 tests total, all passing. Full workspace green. Closes #74, #75, #76 Co-Authored-By: Claude Sonnet 4.6 --- crates/arcane-affinity/src/lib.rs | 203 +++++++++++++++++++++ crates/arcane-infra/Cargo.toml | 2 + crates/arcane-infra/src/cluster_manager.rs | 11 ++ 3 files changed, 216 insertions(+) diff --git a/crates/arcane-affinity/src/lib.rs b/crates/arcane-affinity/src/lib.rs index 1c4944e..9e657dc 100644 --- a/crates/arcane-affinity/src/lib.rs +++ b/crates/arcane-affinity/src/lib.rs @@ -496,4 +496,207 @@ mod tests { // 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 is assigned to the nearest cluster centroid + /// (spatial fallback), regardless of which cluster it was last in. + #[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::default(); + + // Loner has no interactions. C1 centroid at x=-200, C2 centroid at x=+5. + // Loner is at x=0 — closer 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), // nominally in C1 but no history + 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 fall back to spatially nearest cluster C2" + ); + } } 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 d3b98e9..e4d088e 100644 --- a/crates/arcane-infra/src/cluster_manager.rs +++ b/crates/arcane-infra/src/cluster_manager.rs @@ -44,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, From 0d1c7c18a1e95d7e00b7d3411eb366fd06904fbd Mon Sep 17 00:00:00 2001 From: rebelzion Date: Sun, 3 May 2026 00:32:03 -0700 Subject: [PATCH 5/5] fix(affinity): address PR #77 review comments Fix 1 (critical): seed new_assignments from view.players before Phase 4 so entities that score below migration_threshold already have their current cluster in the map. Phase 5 spatial fallback no longer fires for assigned entities, fixing the hysteresis oscillation bypass. Fix 2 (required): make nearest_cluster deterministic on equal distances by adding UUID tie-break (.then_with(|| id_a.cmp(id_b))) after the distance comparison, eliminating HashMap iteration-order sensitivity. Update isolated_entity_uses_spatial_fallback test: spatial fallback for assigned entities must now go through Phase 4. Test sets migration_threshold: 0.0 so any positive spatial improvement triggers migration, correctly expressing the original intent. Fixes #77 CI failure (hysteresis_prevents_boundary_oscillation). Co-Authored-By: Claude Sonnet 4.6 --- crates/arcane-affinity/src/lib.rs | 39 +++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/crates/arcane-affinity/src/lib.rs b/crates/arcane-affinity/src/lib.rs index 9e657dc..a71f463 100644 --- a/crates/arcane-affinity/src/lib.rs +++ b/crates/arcane-affinity/src/lib.rs @@ -132,8 +132,16 @@ impl AffinityEngine { .map(|(id, members)| (*id, members.len())) .collect(); - // Phase 4: score each entity and decide migrations + // 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 @@ -359,7 +367,7 @@ fn assignments_to_decisions( fn nearest_cluster(pos: Vec2, centroids: &HashMap) -> Option { centroids .iter() - .min_by(|(_, ca), (_, cb)| { + .min_by(|(id_a, ca), (id_b, cb)| { let da = { let dx = pos.x - ca.x; let dy = pos.y - ca.y; @@ -370,7 +378,9 @@ fn nearest_cluster(pos: Vec2, centroids: &HashMap) -> Option { let dy = pos.y - cb.y; dx * dx + dy * dy }; - da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + da.partial_cmp(&db) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| id_a.cmp(id_b)) }) .map(|(&id, _)| id) } @@ -668,8 +678,13 @@ mod tests { ); } - /// An entity with no interaction history is assigned to the nearest cluster centroid - /// (spatial fallback), regardless of which cluster it was last in. + /// 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); @@ -677,26 +692,26 @@ mod tests { let loner = uuid(1); let anchor = uuid(2); // gives c1 a member so centroid is populated - let engine = AffinityEngine::default(); + 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. + // 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), // nominally in C1 but no history - player(anchor, c1, -200.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 fall back to spatially nearest cluster C2" + "loner should migrate to spatially nearest cluster C2" ); } }