// 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) }