Files
AetherForge/server/internal/ai/scout_constellation.go
AetherForge bbab38f8e1
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add scout constellation mode for APK venue persona packs.
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.
2026-06-07 09:18:58 -07:00

229 lines
5.9 KiB
Go

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
}