// 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 := 0 failureCount := 0 for _, f := range items { switch f.Outcome { case "success": successCount++ case "failure", "unexpected": failureCount++ case "partial": successCount++ // partial counts as half-success } } total := successCount + failureCount if total == 0 { return } // Compute Wilson score lower bound confidence := wilsonLowerBound(float64(successCount), float64(total), 0.95) // Cap by sample size: nothing looks confident before 5 samples confidence = math.Min(confidence, float64(total)/5.0) // 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 pat.EvidenceCount >= 5 && pat.Confidence >= 0.7 && !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) } // Anomaly check: >10 identical outcomes within 1h if total > 10 { _ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{ EntityID: pat.EntityID, Quarantined: true, }) slog.Warn("learning: pattern quarantined (anomaly burst)", "type", appliesType, "action", action) } } // 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) }