Skip to content

feat(affinity): add arcane-affinity crate — interaction-weighted IClusteringModel#77

Merged
rebelmachina merged 5 commits intomainfrom
feat/affinity-engine
May 3, 2026
Merged

feat(affinity): add arcane-affinity crate — interaction-weighted IClusteringModel#77
rebelmachina merged 5 commits intomainfrom
feat/affinity-engine

Conversation

@rebelmachina
Copy link
Copy Markdown
Contributor

Summary

Implements AffinityEngine (IN-08) — a stateful IClusteringModel that groups entities by observed interaction probability rather than pure spatial proximity. This is the hand-crafted baseline that sits between the current spatial-only RulesEngine and the future ML/RL clustering model.

Solves three known RulesEngine failure modes:

  • Boundary thrashing — entities no longer oscillate on cluster boundaries; migration hysteresis (threshold + cooldown) prevents it
  • Interaction blindness — party/guild/proximity signals group interacting entities together even across spatial boundaries
  • No temporal awareness — interaction graph retains history (exponential decay) so a moving raid group isn't split mid-crossing

New crate: arcane-affinity

Module What it does
AffinityEngine IClusteringModel impl — 7-phase evaluate() loop
InteractionGraph Decaying pairwise weights between entities, with GC
MigrationState Per-entity migration cooldowns
ClusterScorer Interaction affinity + spatial tiebreaker scoring
AffinityConfig All tunable parameters with sensible defaults

Algorithm (7 phases per evaluation tick):

  1. Decay interaction weights + inject party/guild/proximity signals
  2. Tick migration cooldowns
  3. Build cluster membership and centroids from view
  4. Score each entity, mark migrations above threshold and off cooldown
  5. Spatial fallback for new entities
  6. Clean up removed entities
  7. Translate per-entity assignments into ClusterDecision (merge/split)

Integration changes in existing crates

  • arcane-core — extend IClusteringModel with compute_entity_assignments() default method (zero breaking changes — RulesEngine requires no changes)
  • arcane-spatial — add snapshot_entities() to expose entity-level data
  • arcane-infraClusterManager::run_evaluation_cycle() now populates WorldStateView.players and ClusterInfo.player_ids (previously always empty; RulesEngine behavior unchanged)

Test plan

  • cargo test -p arcane-affinity — 22 tests, all passing
  • cargo build --workspace — full workspace green, no regressions
  • Unit tests: InteractionGraph (7), MigrationState (5), ClusterScorer (4), AffinityEngine (5) — all modules covered
  • Integration tests: valid assignments, validate_view, drop-in replacement

Closes

Closes #65, #66, #67, #68, #69, #70, #71, #72, #73
Part of epic #64

What's NOT in this PR (next steps)

🤖 Generated with Claude Code

rebelmachina and others added 4 commits April 29, 2026 03:24
…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>
@martinjms
Copy link
Copy Markdown
Contributor

🔴 Critical: fallback can bypass hysteresis checks

Why this matters

  • A player can move clusters without passing threshold/cooldown logic.
  • This lines up with the failing CI test for oscillation/hysteresis behavior.

Evidence

let 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

new_assignments is seeded from cache only.
If a player is missing in cache, Phase 5 fallback assigns nearest cluster directly, which can move an already-assigned player without going through Phase 4 migration gating (migration_threshold + cooldown).

Suggested shape:

  • Seed from authoritative current assignment in view.players first.
  • Only let Phase 4 change assignment when hysteresis checks pass.
  • Keep Phase 5 only for truly unassigned/new entities.

@martinjms
Copy link
Copy Markdown
Contributor

martinjms commented Apr 29, 2026

🟠 Required: make nearest_cluster tie-breaking deterministic

Why this matters

  • Equal-distance cases can pick different clusters across runs.
  • That makes behavior harder to reproduce and can create flaky tests.

Evidence

fn nearest_cluster(pos: Vec2, centroids: &HashMap<Uuid, Vec2>) -> Option<Uuid> {
    centroids
        .iter()
        .min_by(|(_, ca), (_, cb)| {
            let da = /* distance to ca */;
            let db = /* distance to cb */;
            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
        })
        .map(|(&id, _)| id)
}
Technical details

When da == db, comparison returns Equal.
Since iteration is over a HashMap, tie resolution can depend on map iteration order.

Suggested shape:

  • Add explicit tie-break (cluster_id ordering), or
  • Evaluate centroids from a stable ordered collection before selecting min.

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>
@rebelmachina
Copy link
Copy Markdown
Contributor Author

Both review comments addressed in commit 0d1c7c1.

Comment 1 (critical — fallback bypasses hysteresis): Fixed. new_assignments is now seeded from view.players before Phase 4 runs:

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 migration_threshold in Phase 4 are already in new_assignments with their current cluster, so Phase 5 never fires for them. Phase 5 is now a true last-resort for entities with no cluster assignment at all.

Comment 2 (required — non-deterministic tie-breaking): Fixed. nearest_cluster now uses .then_with(|| id_a.cmp(id_b)) as a stable UUID tie-break when distances are equal.

Test update: isolated_entity_uses_spatial_fallback updated to go through Phase 4 correctly — sets migration_threshold: 0.0 so any positive spatial improvement triggers migration, expressing the original intent without relying on the Phase 5 bypass.

hysteresis_prevents_boundary_oscillation (the CI failure) now passes. 25/25 tests green locally.

@rebelmachina rebelmachina merged commit 2c13273 into main May 3, 2026
1 check passed
@rebelmachina rebelmachina deleted the feat/affinity-engine branch May 3, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create arcane-affinity crate scaffold

2 participants