Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions crates/arcane-affinity/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
58 changes: 58 additions & 0 deletions crates/arcane-affinity/src/config.rs
Original file line number Diff line number Diff line change
@@ -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,
}
}
}
106 changes: 106 additions & 0 deletions crates/arcane-affinity/src/hysteresis.rs
Original file line number Diff line number Diff line change
@@ -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<Uuid, u32>,
}

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)));
}
}
171 changes: 171 additions & 0 deletions crates/arcane-affinity/src/interaction_graph.rs
Original file line number Diff line number Diff line change
@@ -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<EntityPair, f64>,
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<Item = (Uuid, f64)> + '_ {
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);
}
}
Loading
Loading