phase 3: control loop — scheduler, actuator, learning, notifier, policy, API endpoints
Implemented the full OODA control loop: Scheduler: - Check_defs runner with bounded worker pool (errgroup) - Signal dedup via partial unique index (UpsertSignal) - Recovery auto-resolves open signals - Metrics writing (InsertMetricSample) and entity_status updates - Housekeeping (idempotency-key prune) - Graceful shutdown via ctx cancellation Actuator: - Auto-act signal consumer with FOR UPDATE SKIP LOCKED pattern - Per-target serialization with pg_advisory_xact_lock - Circuit breaker per target host (N consecutive failures → open) - Autonomy kill-switch (global.auto_act, never_auto_act.<slug>) - Execution record lifecycle (proposed → running → completed) Learning engine: - Hourly feedback extraction past watermark - Wilson score confidence lower bound (conservative for small N) - Pattern status: hypothesized → validated (N≥5, confidence ≥0.7) - Anomaly quarantine for burst feedback - Cap confidence by sample_size/5 (nothing confident before 5 samples) Notifier: - Approval token generation (HMAC single-use, hashed at rest) - Pending approval expiry detection - DB rendezvous pattern (no service-to-service RPC) Policy classifier: - Risk class resolution from policy tables - Autonomy checks (global + per-entity kill-switch) - Blast radius computation - Classification routes: auto-act / escalate / hold API endpoints (31 endpoints implemented): - Checks: ListChecks, CreateCheck, PatchCheck - Classifications: ListClassifications - Executions: ListExecutions, GetExecution, RequestExecution, CancelExecution - Approvals: ListApprovals, DecideApproval - Patterns: ListPatterns, PatchPattern - Skills: ListSkills, PatchSkill, ListSkillVersions - Policy: ListApprovalRules, CreateApprovalRule, PatchApprovalRule, GetAutonomySettings, PatchAutonomySettings, ListRiskClasses - Relationships: CreateRelationship, EndRelationship - Entity types: CreateEntityType, PatchEntityType - Metrics: QueryMetrics, GetTrends - Knowledge: SearchKnowledge, GetEntityKnowledge (stubs) - Agent activity: QueryAgentActivity (stub) Infrastructure: - Migration 009: knowledge_entities table with FTS indexes - Config: scheduler/notifier/actuator/learning env vars - sqlc: 30+ new Phase 3 queries - Integration tests for all new endpoints - go.sum updated with golang.org/x/sync
This commit is contained in:
169
internal/learning/learning.go
Normal file
169
internal/learning/learning.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// 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
|
||||
patternID, _ := uuid.NewV7()
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user