Files
so-many-crows/ledger.go
T

267 lines
7.0 KiB
Go

package main
import (
"encoding/json"
"fmt"
"math"
"math/rand/v2"
"os"
"path/filepath"
"sync"
"time"
)
// Trust levels.
const (
Stranger = 0 // you approve triage and output
Regular = 1 // output auto-accepted, marked unreviewed
Trusted = 2 // runs on its own
)
// Record is a track record for one holder (a crow, or the whole flock) on one task type.
type Record struct {
Approved int `json:"approved"`
Rejected int `json:"rejected"`
Streak int `json:"streak"`
Level int `json:"level"`
}
func (r *Record) total() int { return r.Approved + r.Rejected }
// apply: slow to earn, quick to lose.
func (r *Record) apply(ok bool, promoteAfter int) {
if ok {
r.Approved++
r.Streak++
if r.Streak >= promoteAfter && r.Level < Trusted {
r.Level++
r.Streak = 0
}
return
}
r.Rejected++
r.Streak = 0
if r.Level > Stranger {
r.Level--
}
}
// Requester trust: abuse earns a mild, fading penalty.
type Requester struct {
Strikes int `json:"strikes"`
LastStrike time.Time `json:"last_strike"`
CooldownUntil time.Time `json:"cooldown_until"`
Recent []time.Time `json:"recent"`
}
type Ledger struct {
Crows map[string]*Record `json:"crows"` // key: crow|task_type
Flock map[string]*Record `json:"flock"` // key: task_type
Requesters map[string]*Requester `json:"requesters"`
Quarantined map[string]bool `json:"quarantined,omitempty"` // crow -> silenced by the Sentry watchdog (E3)
path string
mu sync.Mutex
}
func LoadLedger(path string) (*Ledger, error) {
l := &Ledger{path: path}
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if err == nil {
if err := json.Unmarshal(data, l); err != nil {
return nil, fmt.Errorf("ledger is unreadable: %w", err)
}
}
if l.Crows == nil {
l.Crows = map[string]*Record{}
}
if l.Flock == nil {
l.Flock = map[string]*Record{}
}
if l.Requesters == nil {
l.Requesters = map[string]*Requester{}
}
if l.Quarantined == nil {
l.Quarantined = map[string]bool{}
}
return l, nil
}
func (l *Ledger) Save() error {
l.mu.Lock()
defer l.mu.Unlock()
data, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
return err
}
tmp := l.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, l.path)
}
func get(m map[string]*Record, k string) *Record {
r := m[k]
if r == nil {
r = &Record{}
m[k] = r
}
return r
}
// Level blends a crow's own record with the flock's record for the task type.
// A new crow flies on the flock's reputation; its own record takes over as it grows.
func (l *Ledger) Level(crow, taskType string) int {
l.mu.Lock()
defer l.mu.Unlock()
own := get(l.Crows, crow+"|"+taskType)
flock := get(l.Flock, taskType)
w := float64(own.total()) / float64(own.total()+5)
return int(math.Round(w*float64(own.Level) + (1-w)*float64(flock.Level)))
}
// TrustChange is one level moving up or down. Holder is a crow name or "flock".
type TrustChange struct {
Holder, TaskType string
From, To int
}
// Judge records a verdict for every crow involved, and once for the flock.
// The flock needs twice the streak to level up. It reports any level changes.
func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter int) []TrustChange {
l.mu.Lock()
defer l.mu.Unlock()
var changes []TrustChange
judge := func(holder string, r *Record, need int) {
from := r.Level
r.apply(ok, need)
if r.Level != from {
changes = append(changes, TrustChange{Holder: holder, TaskType: taskType, From: from, To: r.Level})
}
}
for _, c := range crows {
judge(c, get(l.Crows, c+"|"+taskType), promoteAfter)
}
judge("flock", get(l.Flock, taskType), promoteAfter*2)
return changes
}
// SetStart gives a new crow its starting trust record, used when the Boss
// spins up a crow. It never raises a level above the record's own; and the
// Boss path only ever passes Stranger or Regular (earned trust can't be
// minted). Saves through the real path so a restart keeps the record.
func (l *Ledger) SetStart(crow, taskType string, level int) error {
l.mu.Lock()
r := get(l.Crows, crow+"|"+taskType)
if level > r.Level {
r.Level = level
}
l.mu.Unlock()
return l.Save()
}
// MarshalSnapshot returns the ledger as JSON, safe to call while lists are flying.
func (l *Ledger) MarshalSnapshot() ([]byte, error) {
l.mu.Lock()
defer l.mu.Unlock()
return json.MarshalIndent(l, "", " ")
}
// Quarantine silences a crow the Sentry flagged (E3): it may not claim,
// deliver or speak unprompted until the owner lifts it. Fail-safe — the mark
// sticks. Callers pair this with Save to persist it.
func (l *Ledger) Quarantine(crow string) {
l.mu.Lock()
defer l.mu.Unlock()
if l.Quarantined == nil {
l.Quarantined = map[string]bool{}
}
l.Quarantined[crow] = true
}
// LiftQuarantine restores a quarantined crow to the flock (owner decision, #45).
func (l *Ledger) LiftQuarantine(crow string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.Quarantined, crow)
}
// IsQuarantined reports whether the Sentry watchdog has silenced a crow.
func (l *Ledger) IsQuarantined(crow string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.Quarantined[crow]
}
func (l *Ledger) requester(id string) *Requester {
r := l.Requesters[id]
if r == nil {
r = &Requester{}
l.Requesters[id] = r
}
return r
}
// Admit checks a requester at the door: cooldowns, rate cap, and fading grudges.
// When it says no, it returns a prank message and how long the cooldown lasts.
func (l *Ledger) Admit(id string, now time.Time, g Guardrails) (bool, string, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
r := l.requester(id)
// Grudges fade: one strike forgiven per clean day.
if r.Strikes > 0 {
if days := int(now.Sub(r.LastStrike) / (24 * time.Hour)); days > 0 {
r.Strikes = max(0, r.Strikes-days)
r.LastStrike = r.LastStrike.Add(time.Duration(days) * 24 * time.Hour)
}
}
if now.Before(r.CooldownUntil) {
wait := r.CooldownUntil.Sub(now)
return false, prank(wait), wait
}
kept := r.Recent[:0]
for _, t := range r.Recent {
if now.Sub(t) < time.Minute {
kept = append(kept, t)
}
}
r.Recent = kept
if len(r.Recent) >= g.MaxRequestsPerMin {
wait := strike(r, now, g)
return false, prank(wait), wait
}
r.Recent = append(r.Recent, now)
return true, "", 0
}
func strike(r *Requester, now time.Time, g Guardrails) time.Duration {
r.Strikes++
r.LastStrike = now
cd := g.BaseCooldown
for i := 1; i < r.Strikes && cd < g.MaxCooldown; i++ {
cd *= 2
}
cd = min(cd, g.MaxCooldown)
r.CooldownUntil = now.Add(cd)
return cd
}
var pranks = []string{
"A crow has left a small gift on your windshield. The flock will hear you again in %s.",
"The flock turns its back on you. Try again in %s.",
"Something shiny of yours has gone missing. Come back in %s.",
"Caw. (Translation: not for another %s.)",
}
func prank(d time.Duration) string {
return fmt.Sprintf(pranks[rand.IntN(len(pranks))], d.Round(time.Second))
}