Add scout constellation mode for APK venue persona packs.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Cluster 3+ scout_report hits on the same SSID within 10 minutes; server infers airport/campus/retail venue class and pushes persona spread_policy. Emberwake weather-map merges active scout biomes. Includes agent, server API, and Vitest coverage.
This commit is contained in:
228
server/internal/ai/scout_constellation.go
Normal file
228
server/internal/ai/scout_constellation.go
Normal file
@@ -0,0 +1,228 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user