package detect import ( "context" "log" "sync" "time" "codeberg.org/pata1704/guenther/pkg/types" ) // AnomalyDetector is the common interface for all detection algorithms. // Implementations must be safe for concurrent use. type AnomalyDetector interface { // Fit trains the model on the supplied slice of labelled-normal vectors. Fit(vectors []types.FeatureVector) error // Score returns an anomaly assessment for vector. It must not block. Score(vector types.FeatureVector) (types.AnomalyResult, error) // Update buffers vector for incremental model updates. Update(vector types.FeatureVector) error } // DetectionLayer reads FeatureVectors from inputChan, scores them with the // configured AnomalyDetector, and forwards AnomalyResults to outputChan. // // The layer runs a single event-loop goroutine (no additional worker pool is // needed because detection is CPU-bound in a single model, not I/O-bound). // Health metrics are emitted to healthChan every 5 seconds. // // Backpressure: if outputChan is full the result is dropped and a warning is // logged. This prevents the detection goroutine from blocking the upstream // TransformEngine via backpressure handling. type DetectionLayer struct { detector AnomalyDetector inputChan <-chan types.FeatureVector outputChan chan<- types.AnomalyResult healthChan chan<- types.StageHealth scalingController *ScalingController // optional wg sync.WaitGroup mu sync.Mutex processed uint64 dropped uint64 avgLatency float64 } // NewDetectionLayer constructs a DetectionLayer wired to the given channels. func NewDetectionLayer( detector AnomalyDetector, input <-chan types.FeatureVector, output chan<- types.AnomalyResult, health chan<- types.StageHealth, ) *DetectionLayer { return &DetectionLayer{ detector: detector, inputChan: input, outputChan: output, healthChan: health, } } // SetScalingController attaches an auto-scaling controller to the layer. func (l *DetectionLayer) SetScalingController(sc *ScalingController) { l.scalingController = sc } // Start launches the detection event loop in a background goroutine. // The method is idempotent: calling Start twice panics (close of closed channel). func (l *DetectionLayer) Start(ctx context.Context) { l.wg.Go(func() { reportTicker := time.NewTicker(5 * time.Second) defer reportTicker.Stop() for { select { case fv := <-l.inputChan: l.handle(fv) case <-reportTicker.C: l.emitHealth() case <-ctx.Done(): return } } }) } // Wait waits for the event loop to exit after context cancellation. func (l *DetectionLayer) Wait() { l.wg.Wait() } func (l *DetectionLayer) handle(fv types.FeatureVector) { if l.scalingController != nil { l.scalingController.ObserveCPU(fv.AvgCPUPercent) } start := time.Now() result, err := l.detector.Score(fv) ms := time.Since(start).Seconds() * 1e3 l.mu.Lock() l.processed++ if l.avgLatency == 0 { l.avgLatency = ms } else { l.avgLatency = l.avgLatency*0.8 + ms*0.2 } l.mu.Unlock() if err != nil { log.Printf("detection: score error: %v", err) return } select { case l.outputChan <- result: default: l.mu.Lock() l.dropped++ l.mu.Unlock() log.Printf("detection: output channel full – dropping result (score=%.4f)", result.Score) } } // emitHealth sends a StageHealth snapshot to healthChan. // Non-blocking: skips the report if healthChan is full. func (l *DetectionLayer) emitHealth() { l.mu.Lock() p := l.processed d := l.dropped avg := l.avgLatency l.mu.Unlock() select { case l.healthChan <- types.StageHealth{ StageName: "detection_layer", EventsProcessed: p, EventsDropped: d, AvgLatencyMs: avg, LastUpdate: time.Now(), }: default: } }