Add Strain Hospice for graceful low-win strain retirement.
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
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:
@@ -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 {
|
||||
|
||||
94
server/internal/strategy/hospice.go
Normal file
94
server/internal/strategy/hospice.go
Normal 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])
|
||||
}
|
||||
26
server/internal/strategy/hospice_test.go
Normal file
26
server/internal/strategy/hospice_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user