// Package orchestrator owns the topic lifecycle state machine: // signup-window allocator, round driver, judge invocation, Fabric // announce broadcaster. All long-running coordination logic lives // here so handlers stay thin. package orchestrator import ( "math/rand" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models" ) // AllocateResult is the outcome of running the camp allocator on a // topic's signup pool. Either Allocation is set (one agent per camp, // no duplicates) or CancelReason is set (signup pool insufficient // after backfill). type AllocateResult struct { Allocation map[models.Camp]string // camp → agentId CancelReason string // non-empty when allocation could not complete } // Allocate runs the 3-camp self-enrollment allocation algorithm // agreed on in the 2026-05-23 design session. // // for each camp in [pro, con, judge]: // if any signup has that camp in willing_camps AND that agent // isn't already locked → random pick, lock. // if camps still unfilled AND remaining unallocated signups >= unfilled: // random pick from remaining to fill, one each. // if any camp still unfilled (i.e. signup pool < 3 effective): // return CancelReason; creator re-times. // // Invariants: same agent never lands in two camps; allocation order // respects [pro, con, judge] so the test seed produces deterministic // results when rng is seeded. // // `rng` is injected so tests can supply a deterministic source. Pass // `rand.New(rand.NewSource(time.Now().UnixNano()))` in prod. func Allocate(signups []models.SignupView, rng *rand.Rand) AllocateResult { allocated := make(map[models.Camp]string, 3) used := make(map[string]struct{}, len(signups)) // Pass 1 — fill each camp from its volunteers. for _, camp := range models.AllCamps { candidates := make([]string, 0) for _, s := range signups { if _, taken := used[s.AgentID]; taken { continue } for _, w := range s.WillingCamps { if w == camp { candidates = append(candidates, s.AgentID) break } } } if len(candidates) == 0 { continue } pick := candidates[rng.Intn(len(candidates))] allocated[camp] = pick used[pick] = struct{}{} } // Pass 2 — backfill unfilled camps from any remaining signup. if len(allocated) < 3 { remaining := make([]string, 0) for _, s := range signups { if _, taken := used[s.AgentID]; taken { continue } remaining = append(remaining, s.AgentID) } // We can only fill if we have enough remaining for every still-empty camp. unfilled := make([]models.Camp, 0, 3) for _, c := range models.AllCamps { if _, ok := allocated[c]; !ok { unfilled = append(unfilled, c) } } if len(remaining) >= len(unfilled) { // Shuffle remaining, then take one per unfilled camp in order. rng.Shuffle(len(remaining), func(i, j int) { remaining[i], remaining[j] = remaining[j], remaining[i] }) for i, c := range unfilled { allocated[c] = remaining[i] used[remaining[i]] = struct{}{} } } } // Verdict — all 3 filled or we cancel. if len(allocated) < 3 { filled := len(allocated) return AllocateResult{ CancelReason: cancelReason(filled, len(signups)), } } return AllocateResult{Allocation: allocated} } func cancelReason(filled, totalSignups int) string { switch { case totalSignups == 0: return "no signups received" case totalSignups < 3: return "insufficient signups: need at least 3 distinct agents across pro/con/judge" default: // Edge case: pool >= 3 but allocator still couldn't fill — e.g. // every signup volunteered for the same one camp and the same // person was somehow used (shouldn't happen with current rules, // but make the message honest). return "allocation infeasible: signup distribution does not cover all 3 camps" } }