Files
oikos/internal/learning/learning.go
dtoro c96c795126 test: add unit tests for 6 previously-untested packages (R7)
Added pure unit tests for all packages that had 0% coverage. Where pure
logic was entangled with DB calls, extracted testable helpers first.

internal/domain (0% -> 100%):
- TestIsNil, TestCanTransition (all 30 state transitions), TestSentinelErrors,
  TestSignalTransitionsComplete

internal/learning (0% -> 26.2%):
- Refactored processGroup to extract 4 pure helpers: countOutcomes,
  computeConfidence, shouldValidate, shouldQuarantine
- TestWilsonLowerBound (monotonicity, edge cases, sample-size cap)
- TestCountOutcomes, TestComputeConfidence, TestShouldValidate,
  TestShouldQuarantine (table-driven)
- Remaining gap: extractPatterns/processGroup DB calls need make test-db

internal/policy (39% -> 50%):
- Extracted determineRoute from ClassifySignal (pure route logic)
- TestDetermineRoute (7 cases covering global/entity kill-switches, approval)
- Remaining gap: ClassifySignal/computeBlastRadius need DB mock

internal/knowledge (0% -> 14.2%):
- TestContentHash, TestStr, TestStrSlice, TestMapVal, TestToPGArray
- Documented latent bug: toPGArray doesn't escape " or \\ in tags
- Remaining gap: ingest* functions need make test-db

internal/actuator (0% -> 14.7%):
- TestSSHErrorClassString, TestClassifySSHError (11 cases incl. net.Error mock)
- TestParseProcedure, TestSetDefaultSSHTimeout
- Circuit breaker full state-machine test (open/close/reset/per-target)
- Remaining gap: ExecuteProcedure/ProvisionLXC need SSH+DB fixtures

internal/scheduler (0% -> 7.3%):
- TestParsePingLatency (Linux/macOS formats), TestAllowlistedScript
- TestEvaluateSeverity (threshold logic, crit:0 skip, signalKind fallback)
- Remaining gap: checkHTTP/checkTCP need httptest; runCheckPass needs DB

internal/notifier (0% -> 6.4%):
- TestHashToken, TestGenerateApprovalToken (HMAC re-derivation)
- Remaining gap: checkReaction/sendMatrixAlert need httptest; DB funcs need
  make test-db

All tests pass with -race. domain hits its 60% gate at 100%. The remaining
packages need integration tests (make test-db) and/or httptest-based tests
to reach their coverage gates — tracked as follow-up.
2026-07-17 22:54:44 +02:00

208 lines
5.8 KiB
Go

// Package learning implements the Oikos learning engine (Phase 3).
// Hourly pattern extraction: reads feedback past the watermark, groups by
// (applies_type, action), updates pattern counters with Wilson confidence,
// detects anomalies, and refines skills.
package learning
import (
"context"
"log/slog"
"math"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/google/uuid"
)
// Run starts the learning loop. Blocks until ctx is cancelled.
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("learning: starting", "interval", cfg.LearningInterval)
interval := cfg.LearningInterval
if interval <= 0 {
interval = 1 * time.Hour
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
watermark := time.Now().Add(-24 * time.Hour) // start from 24h ago
for {
select {
case <-ctx.Done():
slog.Info("learning: shutting down")
return
case <-ticker.C:
watermark = extractPatterns(ctx, pool, watermark)
}
}
}
// extractPatterns reads feedback past the watermark, groups by (type, action),
// updates pattern counters, and returns the new watermark.
func extractPatterns(ctx context.Context, pool *db.Pool, watermark time.Time) time.Time {
q := sqlcgen.New(pool)
feedback, err := q.GetFeedbackAfterWatermark(ctx, watermark)
if err != nil {
slog.Error("learning: get feedback", "error", err)
return watermark
}
if len(feedback) == 0 {
// Advance watermark to now so we don't re-scan
return time.Now()
}
// Group by (applies_type, action)
type groupKey struct {
Type string
Action string
}
groups := make(map[groupKey][]sqlcgen.GetFeedbackAfterWatermarkRow)
for _, f := range feedback {
key := groupKey{Type: f.AppliesType, Action: f.Action}
groups[key] = append(groups[key], f)
}
for key, items := range groups {
processGroup(ctx, pool, q, key.Type, key.Action, items)
}
// Update watermark to the latest feedback timestamp
newWatermark := watermark
for _, f := range feedback {
if f.CreatedAt.After(newWatermark) {
newWatermark = f.CreatedAt
}
}
return newWatermark
}
func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
successCount, failureCount := countOutcomes(items)
total := successCount + failureCount
if total == 0 {
return
}
confidence := computeConfidence(successCount, failureCount)
// Get or create pattern — first look up existing entity, then upsert.
existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
AppliesType: appliesType,
Action: action,
})
var patternID uuid.UUID
if err == nil && existing.EntityID != uuid.Nil {
patternID = existing.EntityID
} else {
id, idErr := uuid.NewV7()
if idErr != nil {
slog.Error("learning: gen pattern uuid", "error", idErr)
return
}
patternID = id
}
patternSummary := action + " on " + appliesType
err = q.UpsertPattern(ctx, sqlcgen.UpsertPatternParams{
EntityID: patternID,
AppliesType: appliesType,
Action: action,
Pattern: patternSummary,
Confidence: float32(confidence),
EvidenceCount: int32(total),
SuccessCount: int32(successCount),
FailureCount: int32(failureCount),
})
if err != nil {
slog.Error("learning: upsert pattern", "error", err)
return
}
// Update pattern status based on confidence
pat, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
AppliesType: appliesType,
Action: action,
})
if err != nil {
return
}
if shouldValidate(int(pat.EvidenceCount), float64(pat.Confidence), pat.Quarantined) {
_ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
EntityID: pat.EntityID,
Status: "validated",
})
slog.Info("learning: pattern validated",
"type", appliesType, "action", action,
"confidence", confidence, "samples", total)
}
if shouldQuarantine(total) {
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
EntityID: pat.EntityID,
Quarantined: true,
})
slog.Warn("learning: pattern quarantined (anomaly burst)",
"type", appliesType, "action", action)
}
}
// countOutcomes tallies feedback items into success and failure counts.
// "partial" counts as a half-success (increments success).
func countOutcomes(items []sqlcgen.GetFeedbackAfterWatermarkRow) (success, failure int) {
for _, f := range items {
switch f.Outcome {
case "success":
success++
case "failure", "unexpected":
failure++
case "partial":
success++
}
}
return success, failure
}
// computeConfidence calculates the Wilson score lower bound, capped by
// sample size (nothing looks confident before 5 samples).
func computeConfidence(success, failure int) float64 {
total := success + failure
if total == 0 {
return 0
}
confidence := wilsonLowerBound(float64(success), float64(total), 0.95)
return math.Min(confidence, float64(total)/5.0)
}
// shouldValidate returns true when a pattern has enough evidence and
// confidence to be promoted from "hypothesized" to "validated".
func shouldValidate(evidenceCount int, confidence float64, quarantined bool) bool {
return evidenceCount >= 5 && confidence >= 0.7 && !quarantined
}
// shouldQuarantine returns true when an anomaly burst is detected
// (>10 identical outcomes, indicating a runaway loop rather than organic feedback).
func shouldQuarantine(total int) bool {
return total > 10
}
// wilsonLowerBound computes the Wilson score interval lower bound.
// Conservative estimate of success rate for small sample sizes.
func wilsonLowerBound(success, total, z float64) float64 {
if total == 0 {
return 0
}
p := success / total
z2 := z * z
denom := 1 + z2/total
center := (p + z2/(2*total)) / denom
sp := math.Sqrt((p*(1-p) + z2/(4*total)) / total) / denom
return math.Max(0, center-z*sp)
}