package ai import ( "strings" "time" ) const ( ScoutConstellationMinAgents = 3 ScoutConstellationWindow = 10 * time.Minute VenueAirport = "airport" VenueCampus = "campus" VenueRetail = "retail" VenueUnknown = "unknown" ) // ScoutHit is one scout_report observation for constellation clustering. type ScoutHit struct { AgentID string At time.Time ServiceCount int } // ScoutConstellation is an active venue cluster keyed by Wi-Fi SSID. type ScoutConstellation struct { SSID string `json:"ssid"` VenueClass string `json:"venue_class"` PersonaPack string `json:"persona_pack"` AgentIDs []string `json:"agent_ids"` Hits int `json:"hits"` FormedAt time.Time `json:"formed_at"` UpdatedAt time.Time `json:"updated_at"` } // ScoutConstellationRegistry tracks scout_report hits and active constellations. type ScoutConstellationRegistry struct { hits map[string][]ScoutHit active map[string]ScoutConstellation agentSSID map[string]string } // NewScoutConstellationRegistry returns an empty scout constellation tracker. func NewScoutConstellationRegistry() *ScoutConstellationRegistry { return &ScoutConstellationRegistry{ hits: make(map[string][]ScoutHit), active: make(map[string]ScoutConstellation), agentSSID: make(map[string]string), } } // Record ingests a scout_report hit. The second return is true when the constellation changed. func (r *ScoutConstellationRegistry) Record(agentID, ssid string, serviceCount int, now time.Time) (ScoutConstellation, bool) { ssid = normalizeScoutSSID(ssid) if ssid == "" || strings.TrimSpace(agentID) == "" { return ScoutConstellation{}, false } if now.IsZero() { now = time.Now().UTC() } r.hits[ssid] = append(r.hits[ssid], ScoutHit{ AgentID: agentID, At: now, ServiceCount: serviceCount, }) r.hits[ssid] = pruneScoutHits(r.hits[ssid], now.Add(-ScoutConstellationWindow)) r.agentSSID[agentID] = ssid agents, totalHits, maxServices := scoutWindowStats(r.hits[ssid]) if len(agents) < ScoutConstellationMinAgents { return ScoutConstellation{}, false } venue := InferVenueClass(ssid, maxServices, len(agents)) persona := VenuePersonaPack(venue) prev, had := r.active[ssid] changed := !had || prev.VenueClass != venue || prev.PersonaPack != persona || !sameAgentSet(prev.AgentIDs, agents) c := ScoutConstellation{ SSID: ssid, VenueClass: venue, PersonaPack: persona, AgentIDs: append([]string(nil), agents...), Hits: totalHits, UpdatedAt: now, } if had { c.FormedAt = prev.FormedAt } else { c.FormedAt = now } r.active[ssid] = c return c, changed } // Snapshot returns all active scout constellations. func (r *ScoutConstellationRegistry) Snapshot() []ScoutConstellation { out := make([]ScoutConstellation, 0, len(r.active)) for _, c := range r.active { out = append(out, c) } return out } // ForAgent returns the active constellation for a scout agent, if any. func (r *ScoutConstellationRegistry) ForAgent(agentID string) *ScoutConstellation { ssid, ok := r.agentSSID[strings.TrimSpace(agentID)] if !ok { return nil } c, ok := r.active[ssid] if !ok { return nil } cp := c return &cp } // BuildScoutSpreadPolicy returns spread_policy JSON fields for a venue constellation. func BuildScoutSpreadPolicy(constellation ScoutConstellation) map[string]interface{} { temp := PersonaSpreadTemperament(constellation.PersonaPack) return map[string]interface{}{ "persona_pack": constellation.PersonaPack, "venue_class": constellation.VenueClass, "constellation_ssid": constellation.SSID, "spread_temperament": temp, "scout_constellation": true, } } // InferVenueClass heuristically classifies a Wi-Fi venue from SSID + scout telemetry. func InferVenueClass(ssid string, serviceCount, agentCount int) string { s := strings.ToLower(strings.TrimSpace(ssid)) switch { case containsAny(s, "airport", "lounge", "terminal", "inflight", "gogoinflight", "fly", "united", "delta", "ba-wifi"): return VenueAirport case containsAny(s, "edu", "university", "college", "campus", "student", "academic"): return VenueCampus case containsAny(s, "guest", "public", "free", "store", "mall", "shop", "retail", "cafe", "coffee", "starbucks", "walmart", "target"): return VenueRetail case serviceCount >= 20 && agentCount >= ScoutConstellationMinAgents: return VenueAirport case serviceCount >= 12: return VenueCampus default: return VenueUnknown } } // VenuePersonaPack maps venue class to a Calibrate persona temperament. func VenuePersonaPack(venue string) string { switch strings.ToLower(strings.TrimSpace(venue)) { case VenueAirport: return PersonaPersuasive case VenueCampus: return PersonaBalanced case VenueRetail: return PersonaAggressive default: return PersonaBalanced } } func normalizeScoutSSID(ssid string) string { return strings.TrimSpace(ssid) } func pruneScoutHits(hits []ScoutHit, cutoff time.Time) []ScoutHit { out := hits[:0] for _, h := range hits { if !h.At.Before(cutoff) { out = append(out, h) } } return out } func scoutWindowStats(hits []ScoutHit) (agents []string, totalHits, maxServices int) { seen := make(map[string]bool) for _, h := range hits { totalHits++ if h.ServiceCount > maxServices { maxServices = h.ServiceCount } id := strings.TrimSpace(h.AgentID) if id == "" || seen[id] { continue } seen[id] = true agents = append(agents, id) } return agents, totalHits, maxServices } func sameAgentSet(a, b []string) bool { if len(a) != len(b) { return false } seen := make(map[string]int, len(a)) for _, id := range a { seen[id]++ } for _, id := range b { seen[id]-- if seen[id] < 0 { return false } } for _, n := range seen { if n != 0 { return false } } return true } func containsAny(s string, needles ...string) bool { for _, n := range needles { if strings.Contains(s, n) { return true } } return false }