feat(affinity): add arcane-affinity crate — interaction-weighted IClusteringModel#77
feat(affinity): add arcane-affinity crate — interaction-weighted IClusteringModel#77rebelmachina merged 5 commits intomainfrom
Conversation
…steringModel 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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…n tests #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 <noreply@anthropic.com>
🔴 Critical: fallback can bypass hysteresis checksWhy this matters
Evidencelet mut new_assignments: HashMap<Uuid, Uuid> = assignments.clone();
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);
}
}Technical details
Suggested shape:
|
🟠 Required: make
|
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 <noreply@anthropic.com>
|
Both review comments addressed in commit Comment 1 (critical — fallback bypasses hysteresis): Fixed. let mut new_assignments: HashMap<Uuid, Uuid> = assignments.clone();
for player in players {
new_assignments.entry(player.player_id).or_insert(player.cluster_id);
}Entities that score below Comment 2 (required — non-deterministic tie-breaking): Fixed. Test update:
|
Summary
Implements AffinityEngine (IN-08) — a stateful
IClusteringModelthat groups entities by observed interaction probability rather than pure spatial proximity. This is the hand-crafted baseline that sits between the current spatial-onlyRulesEngineand the future ML/RL clustering model.Solves three known
RulesEnginefailure modes:New crate:
arcane-affinityAffinityEngineIClusteringModelimpl — 7-phaseevaluate()loopInteractionGraphMigrationStateClusterScorerAffinityConfigAlgorithm (7 phases per evaluation tick):
ClusterDecision(merge/split)Integration changes in existing crates
arcane-core— extendIClusteringModelwithcompute_entity_assignments()default method (zero breaking changes —RulesEnginerequires no changes)arcane-spatial— addsnapshot_entities()to expose entity-level dataarcane-infra—ClusterManager::run_evaluation_cycle()now populatesWorldStateView.playersandClusterInfo.player_ids(previously always empty;RulesEnginebehavior unchanged)Test plan
cargo test -p arcane-affinity— 22 tests, all passingcargo build --workspace— full workspace green, no regressionsInteractionGraph(7),MigrationState(5),ClusterScorer(4),AffinityEngine(5) — all modules coveredCloses
Closes #65, #66, #67, #68, #69, #70, #71, #72, #73
Part of epic #64
What's NOT in this PR (next steps)
arcane-affinityoptional dependency + model selection inarcane-infra(wire the feature flag soClusterManagercan be constructed withAffinityEngine)ClusterManagerstill discards decisions (_decisions); execution wiring is a separate epic🤖 Generated with Claude Code