// Package detect provides anomaly detection algorithms and ensemble logic. package detect import ( "fmt" "log" "codeberg.org/pata1704/copod" "codeberg.org/pata1704/guenther/pkg/types" ) // COPODDetector implements the AnomalyDetector interface by wrapping the // external codeberg.org/pata1704/copod package. // // Streaming mode: Score calls Update internally, so the sliding-window buffer // stays current without requiring a separate Update call. Callers (like SEAD) // only need to call Score per time step. // // Fit seeds the buffer with a batch of normal vectors. If Fit is not called // the detector starts cold and returns score=0 until the buffer has enough // points (controlled by bufferSize in the underlying library). type COPODDetector struct { detector *copod.Detector } // NewCOPODDetector initialises the streaming COPOD detector wrapper. // // - bufferSize: sliding-window capacity. Recommended: 100–200. // - threshold: score cutoff for standalone IsAnomaly. When used inside // SEAD the threshold is ignored (SEAD applies its own adaptive threshold). func NewCOPODDetector(bufferSize int, threshold float64) (*COPODDetector, error) { det, err := copod.NewDetector(bufferSize, threshold) if err != nil { return nil, fmt.Errorf("copod: initialize wrapped detector: %w", err) } return &COPODDetector{ detector: det, }, nil } // Fit seeds the COPOD history buffer with a slice of labelled-normal vectors. func (c *COPODDetector) Fit(vectors []types.FeatureVector) error { for _, v := range vectors { if err := c.update(v); err != nil { return err } } return nil } // Update adds a single observation to the sliding window. // Safe to call concurrently with Score. func (c *COPODDetector) Update(vector types.FeatureVector) error { return c.update(vector) } // Score computes the COPOD anomaly score for the given vector and // simultaneously updates the internal sliding window with the scored vector. // // The self-update ensures COPOD's buffer reflects the current data stream // without requiring a separate Update call after every Score. This is // consistent with the RRCF and IsolationForest detectors which also // update themselves inside Score. func (c *COPODDetector) Score(vector types.FeatureVector) (types.AnomalyResult, error) { vec := copod.FeatureVector{ NormalizedVector: vector.NormalizedVector, Timestamp: vector.Timestamp, } // Score first, then append to the buffer so the scored point does not // bias its own copula calculation (score-then-insert, same as RRCF). res, err := c.detector.Score(vec) if err != nil { return types.AnomalyResult{}, fmt.Errorf("copod: score: %w", err) } if err := c.update(vector); err != nil { // Log but don't fail: the score is already computed. log.Printf("copod: update after score: %v", err) } return types.AnomalyResult{ Timestamp: res.Timestamp, Score: res.Score, IsAnomaly: res.IsAnomaly, Confidence: res.Confidence, Method: res.Method, }, nil } // update is the internal helper that adds vector to the copod sliding window. func (c *COPODDetector) update(vector types.FeatureVector) error { vec := copod.FeatureVector{ NormalizedVector: vector.NormalizedVector, Timestamp: vector.Timestamp, } return c.detector.Update(vec) }