Files
oikos/internal/learning/learning.go
dtoro 64f7d54011
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 2 — ports package, secrets port move, postgres adapter move
Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md)
must give the use-cases-to-be their contract surface: driven-port
interfaces, test fakes, the secrets interface moved into core, and the
postgres package inside the adapters tree — before the first vertical
slice (Phase 3) can wire a composition root.

Change:
- internal/core/ports: full driven-port catalog per plan §3.3 —
  repositories as transaction-scoped aggregates whose inputs carry
  derived checks, audit, and events (§3.6), plus CommandExecutor,
  TargetResolver, Checker, Secrets, EventPublisher, Provisioner.
  Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry,
  ExecResult) keep signatures off infrastructure; TypeTree aliases
  internal/ontology (pure over domain) until checkdefaults is absorbed.
  ReadModels intentionally not declared yet — it materializes with the
  Phase 3 slice and grows as report handlers rewire.
- secrets.Backend is now an alias of ports.Secrets; implementations
  (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend
  subset is deleted; tool constructors take ports.Secrets.
- internal/db → internal/adapters/postgres (mechanical import rewrite;
  package identifier stays db until the Phase 3 repository split).
  sqlc.yaml, Makefile, golangci exclusions, and docs follow the move;
  make generate-check verified.
- internal/adapters/ssh: Executor implements ports.CommandExecutor over
  the actuator dial pool + RunStreaming (10-min default timeout carried
  over from the httpapi path).
- internal/adapters/remote: Resolver implements ports.TargetResolver
  delegating to internal/remote (still pool-based; drops onto
  ports.EntityRepository when repositories land in Phase 3 — documented
  transitional import).
- internal/core/ports/portstest: importable fakes — in-memory
  EntityRepo (with check-then-act SetState, side-effect recording),
  RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction
  guards; tests.

Risk: ports are declared ahead of implementations — signatures firm up
per phase as slices land (documented in the package doc); the
remote→postgres transitional import is explicit and dissolves in
Phase 3.

Verification: go vet, make test (race, 19 packages), generate-check,
golangci on core+adapters — 0 issues; full-repo baseline down
365→344.
2026-08-15 22:56:56 +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/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/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)
}