From 5203ff849f69ffa7db667f5e81f718bf4b8dc2ec Mon Sep 17 00:00:00 2001 From: Tri Lam Date: Wed, 3 Jun 2026 17:58:25 -0700 Subject: [PATCH 1/2] refactor(patterns): introduce Detector registry seam Wave-end audit flagged the patterndetectorprocessor fanout site as an unmet refactor: ConsumeLogs hand-rolled the dispatch for every shipped detector (12 today, 7 inline + 5 wrapped), so adding pattern #13 required editing the fanout, not registering a new entry. This change introduces a minimal Detector interface + Registered slice in module/pkg/patterns/ that pins the full set, plus a detectorRunners closure list in the processor that ConsumeLogs iterates. ConsumeLogs drops from ~77 lines to 12. Adding pattern #13 = one append to each list, drift-pinned by TestRegistered_PinsAllPatterns. Detector contract is deliberately metadata-only (PatternID() string) - each detector's Evaluate signature is heterogeneous (different record shapes, different verdict types), so a uniform Evaluate method on the interface would force a lossy any-typed contract. Behavior preserved: same telemetry vocabulary, same emission order, same partial-confidence gating. Only pre-existing #497 failure (synthetic-2026-06-multi-rank-disk-pressure, fixed in Lane J) remains. Closes wave-end-audit next-wave item: pattern registry seam. Signed-off-by: Tri Lam --- module/pkg/patterns/detector.go | 100 ++++++++ module/pkg/patterns/detector_test.go | 91 +++++++ .../patterndetector.go | 236 ++++++++++++------ 3 files changed, 348 insertions(+), 79 deletions(-) create mode 100644 module/pkg/patterns/detector.go create mode 100644 module/pkg/patterns/detector_test.go diff --git a/module/pkg/patterns/detector.go b/module/pkg/patterns/detector.go new file mode 100644 index 00000000..89bb24f8 --- /dev/null +++ b/module/pkg/patterns/detector.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +package patterns + +// Detector is the minimal common contract every shipped pattern +// detector satisfies. It is intentionally a metadata-only seam: +// each detector's Evaluate signature is intrinsically heterogeneous +// (different input record shapes, different verdict types), so a +// uniform Evaluate method on the interface would force a lossy +// any-typed contract that the typed test suite has been fighting +// for the last 11 patterns. +// +// Instead, this interface pins identity. The companion Registered +// slice below is the single source of truth for "every shipped +// pattern detector" — adding pattern #12 is `append(Registered, ...)`, +// not editing the processor fanout site. +// +// Consumers (chiefly the patterndetectorprocessor) iterate Registered +// for shape-uniform behavior (count, telemetry-label enumeration, +// drift tests) and continue to bind the typed Evaluate calls at +// their concrete-runner sites. This is a deliberately conservative +// seam — see docs/rfcs/0013-distro-first-pivot.md for the wider +// pattern-library evolution. +type Detector interface { + // PatternID returns the stable string-typed numeric pattern ID + // (matches PatternID* constants and the on-the-wire + // `pattern.id` attribute on every emitted verdict log record). + PatternID() string +} + +// Registered is the canonical, ordered list of every shipped pattern +// detector. Iteration order is the registration order — tests pin +// the full set so accidental drops surface immediately. +// +// Adding a new pattern: implement the detector struct + verdict in +// a sibling file, add a PatternID() method returning the new +// PatternID* constant, and append the zero-value detector pointer +// here. The pin test in detector_test.go enforces count + ID set. +var Registered = []Detector{ + &PodEvictedDetector{}, + &NCCLHangDetector{}, + &XidCorrelationDetector{}, + &HBMECCDetector{}, + &ThermalThrottleDetector{}, + &PCIeAERDetector{}, + &IBLinkFlapDetector{}, + &CUDAOOMDetector{}, + &CheckpointerHangDetector{}, + &SilentDataCorruptionDetector{}, + &NCCLBootstrapDetector{}, + &DataLoaderHangDetector{}, +} + +// PatternID method implementations. Co-located here (not next to +// each *Detector struct) because they are one-line constant returns +// — keeping them in one file makes the registry contract auditable +// at a glance and removes a footgun where adding a new detector +// requires editing two files (struct file + here) instead of one. +// The constants themselves (PatternID*) still live next to each +// detector's verdict type for the verdict-emitting hot path. +// +// Each method returns the stable string-typed numeric pattern ID +// matching the corresponding PatternID* constant and the on-the-wire +// `pattern.id` attribute on every emitted verdict log record. + +// PatternID satisfies the Detector interface for PodEvictedDetector. +func (*PodEvictedDetector) PatternID() string { return PatternIDPodEvicted } + +// PatternID satisfies the Detector interface for NCCLHangDetector. +func (*NCCLHangDetector) PatternID() string { return PatternIDNCCLHang } + +// PatternID satisfies the Detector interface for XidCorrelationDetector. +func (*XidCorrelationDetector) PatternID() string { return PatternIDXidCorrelation } + +// PatternID satisfies the Detector interface for HBMECCDetector. +func (*HBMECCDetector) PatternID() string { return PatternIDHBMECC } + +// PatternID satisfies the Detector interface for ThermalThrottleDetector. +func (*ThermalThrottleDetector) PatternID() string { return PatternIDThermalThrottle } + +// PatternID satisfies the Detector interface for PCIeAERDetector. +func (*PCIeAERDetector) PatternID() string { return PatternIDPCIeAER } + +// PatternID satisfies the Detector interface for IBLinkFlapDetector. +func (*IBLinkFlapDetector) PatternID() string { return PatternIDIBLinkFlap } + +// PatternID satisfies the Detector interface for CUDAOOMDetector. +func (*CUDAOOMDetector) PatternID() string { return PatternIDCUDAOOM } + +// PatternID satisfies the Detector interface for CheckpointerHangDetector. +func (*CheckpointerHangDetector) PatternID() string { return PatternIDCheckpointerHang } + +// PatternID satisfies the Detector interface for SilentDataCorruptionDetector. +func (*SilentDataCorruptionDetector) PatternID() string { return PatternIDSilentDataCorruption } + +// PatternID satisfies the Detector interface for NCCLBootstrapDetector. +func (*NCCLBootstrapDetector) PatternID() string { return PatternIDNCCLBootstrap } + +// PatternID satisfies the Detector interface for DataLoaderHangDetector. +func (*DataLoaderHangDetector) PatternID() string { return PatternIDDataLoaderHang } diff --git a/module/pkg/patterns/detector_test.go b/module/pkg/patterns/detector_test.go new file mode 100644 index 00000000..5c7a7d63 --- /dev/null +++ b/module/pkg/patterns/detector_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +package patterns + +import ( + "sort" + "testing" +) + +// TestRegistered_PinsAllPatterns is the registry drift gate. It +// asserts the exact count and exact set of PatternIDs in Registered. +// Adding a new pattern requires updating this list — that's the point. +// Catches the silent-drop failure mode: a refactor that accidentally +// removes a detector from Registered would otherwise silently stop +// running it. +// +// The expected slice is sorted for set-equality semantics — the +// declaration order in detector.go is meaningful for iteration but +// not load-bearing for "is every pattern present". +func TestRegistered_PinsAllPatterns(t *testing.T) { + want := []string{ + PatternIDCUDAOOM, + PatternIDCheckpointerHang, + PatternIDDataLoaderHang, + PatternIDHBMECC, + PatternIDIBLinkFlap, + PatternIDNCCLBootstrap, + PatternIDNCCLHang, + PatternIDPCIeAER, + PatternIDPodEvicted, + PatternIDSilentDataCorruption, + PatternIDThermalThrottle, + PatternIDXidCorrelation, + } + sort.Strings(want) + + if got, wantN := len(Registered), len(want); got != wantN { + t.Fatalf("Registered: len=%d, want %d (adding a pattern? update want[] above too)", got, wantN) + } + + got := make([]string, 0, len(Registered)) + for i, d := range Registered { + if d == nil { + t.Errorf("Registered[%d] = nil; every entry must be a non-nil detector pointer", i) + continue + } + got = append(got, d.PatternID()) + } + sort.Strings(got) + + for i := range want { + if got[i] != want[i] { + t.Errorf("Registered sorted PatternID set mismatch:\n got: %v\n want: %v", got, want) + return + } + } +} + +// TestRegistered_UniquePatternIDs guards against the +// copy-paste-the-wrong-constant failure mode: registering two +// detector pointers whose PatternID() returns the same string. +// Would silently double-count in any future per-PatternID +// telemetry aggregation. +func TestRegistered_UniquePatternIDs(t *testing.T) { + seen := make(map[string]int, len(Registered)) + for i, d := range Registered { + if d == nil { + continue + } + id := d.PatternID() + if prev, ok := seen[id]; ok { + t.Errorf("Registered[%d].PatternID()=%q collides with Registered[%d]", i, id, prev) + } + seen[id] = i + } +} + +// TestRegistered_NonEmptyPatternIDs pins the "PatternID() returns +// the constant, not the zero string" contract. A new detector that +// forgets to wire the method body would zero-string here and silently +// pass the count check. +func TestRegistered_NonEmptyPatternIDs(t *testing.T) { + for i, d := range Registered { + if d == nil { + continue + } + if id := d.PatternID(); id == "" { + t.Errorf("Registered[%d].PatternID() = empty; wire it to the PatternID* constant", i) + } + } +} diff --git a/module/processor/patterndetectorprocessor/patterndetector.go b/module/processor/patterndetectorprocessor/patterndetector.go index 0179160f..56ed39e1 100644 --- a/module/processor/patterndetectorprocessor/patterndetector.go +++ b/module/processor/patterndetectorprocessor/patterndetector.go @@ -255,84 +255,170 @@ func (p *patterndetectorProcessor) Capabilities() consumer.Capabilities { } // ConsumeLogs projects the incoming logs into the pattern library's -// typed model, runs the configured detectors, and appends one verdict -// record per match before forwarding downstream. +// typed model, runs every registered detector, and appends one +// verdict record per match before forwarding downstream. +// +// Fanout is driven by detectorRunners (this file) — each registered +// runner closure captures one detector's typed Evaluate call and +// the surrounding emit + telemetry boilerplate. Adding pattern #N +// is an append to detectorRunners, not an edit to this method body. +// The pattern set itself is pinned by module/pkg/patterns/detector.go's +// Registered slice + TestRegistered_PinsAllPatterns. func (p *patterndetectorProcessor) ConsumeLogs(ctx context.Context, ld plog.Logs) error { - events, nodeConds, ncclRecs, xidRecs, hbmRecs, thermalRecs, pcieAERRecs, pcieIORecs, ibRecs := collectInputs(ld) - det := patterns.PodEvictedDetector{JoinWindow: p.cfg.JoinWindow} - verdicts := det.Evaluate(events, nodeConds) - - for _, v := range verdicts { - if v.Confidence == patterns.ConfidencePartial && !p.cfg.emitPartialEnabled() { - continue - } - appendVerdict(ld, v, p.logger()) - p.telemetry.IncVerdict(v.PatternID, string(v.Confidence)) - } - - ncclDet := patterns.NCCLHangDetector{HangThreshold: p.cfg.NCCLHangThreshold} - for _, v := range ncclDet.Evaluate(ncclRecs) { - appendVerdict(ld, v, p.logger()) - p.telemetry.IncVerdict(v.PatternID, "") - } + in := collectInputs(ld) - xidDet := patterns.XidCorrelationDetector{CorrelationWindow: p.cfg.XidCorrelationWindow} - for _, v := range xidDet.Evaluate(xidRecs, events) { - appendVerdict(ld, v, p.logger()) - p.telemetry.IncVerdict(v.PatternID, "") + for _, run := range detectorRunners { + run(p, ld, in) } - hbmDet := patterns.HBMECCDetector{ - CorrelationWindow: p.cfg.HBMECCWindow, - ECCDeltaThreshold: p.cfg.HBMECCDeltaThreshold, - } - for _, v := range hbmDet.Evaluate(hbmRecs, xidRecs) { - appendVerdict(ld, v, p.logger()) - p.telemetry.IncVerdict(v.PatternID, "") + if err := p.next.ConsumeLogs(ctx, ld); err != nil { + return fmt.Errorf("patterndetector: next.ConsumeLogs: %w", err) } + return nil +} - thermalDet := patterns.ThermalThrottleDetector{ - Window: p.cfg.ThermalThrottleWindow, - ThrottleDeltaThreshold: p.cfg.ThermalThrottleDeltaThreshold, - MinCascadeGPUs: p.cfg.ThermalThrottleMinCascadeGPUs, - } - for _, v := range thermalDet.Evaluate(thermalRecs) { +// emitAll is the shared loop body for runners whose detector does +// not emit partial-confidence verdicts. Appends each verdict and +// ticks the IncVerdict counter with the legacy empty-string +// confidence label (preserves pre-registry telemetry vocabulary). +func emitAll[V verdictAttrer](p *patterndetectorProcessor, ld plog.Logs, verdicts []V) { + for _, v := range verdicts { appendVerdict(ld, v, p.logger()) - p.telemetry.IncVerdict(v.PatternID, "") + p.telemetry.IncVerdict(v.Common().PatternID, "") } +} - pcieDet := patterns.PCIeAERDetector{ - CorrelationWindow: p.cfg.PCIeAERWindow, - RateDropThreshold: p.cfg.PCIeAERRateDropThreshold, - } - for _, v := range pcieDet.Evaluate(pcieAERRecs, pcieIORecs) { +// emitPodEvicted gates partial verdicts behind the operator opt-in +// and stamps the real confidence label on IncVerdict. Carved out +// (rather than a generic gated emitter) because Go generics cannot +// abstract over a struct field — PodEvictedVerdict.Confidence and +// IBLinkFlapVerdict.Confidence are field reads, not interface +// methods, and adding a Confidence() accessor would clash with the +// existing exported field name on the same type. +func emitPodEvicted(p *patterndetectorProcessor, ld plog.Logs, verdicts []patterns.PodEvictedVerdict) { + emitPartial := p.cfg.emitPartialEnabled() + for _, v := range verdicts { + if v.Confidence == patterns.ConfidencePartial && !emitPartial { + continue + } appendVerdict(ld, v, p.logger()) - p.telemetry.IncVerdict(v.PatternID, "") + p.telemetry.IncVerdict(v.PatternID, string(v.Confidence)) } +} - ibDet := patterns.IBLinkFlapDetector{ - CorrelationWindow: p.cfg.IBLinkFlapWindow, - MinTransitions: p.cfg.IBLinkFlapMinTransitions, - } - for _, v := range ibDet.Evaluate(ibRecs, ncclRecs) { - if v.Confidence == patterns.ConfidencePartial && !p.cfg.emitPartialEnabled() { +// emitIBLinkFlap mirrors emitPodEvicted for the IBLinkFlap detector +// — same gating + telemetry shape; see emitPodEvicted comment. +func emitIBLinkFlap(p *patterndetectorProcessor, ld plog.Logs, verdicts []patterns.IBLinkFlapVerdict) { + emitPartial := p.cfg.emitPartialEnabled() + for _, v := range verdicts { + if v.Confidence == patterns.ConfidencePartial && !emitPartial { continue } appendVerdict(ld, v, p.logger()) p.telemetry.IncVerdict(v.PatternID, string(v.Confidence)) } +} - runCUDAOOMDetector(ld, p.cfg, p.cfg.emitPartialEnabled(), p.fbBuffer, p.logger()) - runCheckpointerHangDetector(ld, p.cfg, p.cfg.emitPartialEnabled(), p.logger()) - runSDCDetector(ld, p.cfg, p.cfg.emitPartialEnabled(), p.logger()) - - runNCCLBootstrapDetector(ld, ncclRecs, p.cfg, p.cfg.emitPartialEnabled(), p.logger()) - runDataLoaderHangDetector(ld, p.cfg, p.logger()) +// detectorRunner is the uniform closure shape for every registered +// pattern detector. Each closure captures one detector's typed +// Evaluate call (or wrapped-runner indirection) plus the surrounding +// emit + telemetry boilerplate. The `in` argument carries every +// shared projection produced by collectInputs; runners pick the +// fields they consume and ignore the rest. Wrapped runners +// (CUDAOOM, Checkpointer, SDC, NCCLBootstrap, DataLoader) that +// own their own per-pattern collectXxxInputs walk still take `in` +// for signature uniformity — they just don't read it (except +// NCCLBootstrap which reuses in.nccl). +type detectorRunner func(p *patterndetectorProcessor, ld plog.Logs, in projectedInputs) + +// detectorRunners is the registered set, iterated by ConsumeLogs. +// Order is the legacy emission order so any downstream consumer +// that observed verdict-record interleaving keeps the same +// observable sequence post-refactor. Adding pattern #N is one +// append here + the matching entry in patterns.Registered (pinned +// by TestRegistered_PinsAllPatterns). +var detectorRunners = []detectorRunner{ + func(p *patterndetectorProcessor, ld plog.Logs, in projectedInputs) { + det := patterns.PodEvictedDetector{JoinWindow: p.cfg.JoinWindow} + emitPodEvicted(p, ld, det.Evaluate(in.events, in.nodeConds)) + }, + func(p *patterndetectorProcessor, ld plog.Logs, in projectedInputs) { + det := patterns.NCCLHangDetector{HangThreshold: p.cfg.NCCLHangThreshold} + emitAll(p, ld, det.Evaluate(in.nccl)) + }, + func(p *patterndetectorProcessor, ld plog.Logs, in projectedInputs) { + det := patterns.XidCorrelationDetector{CorrelationWindow: p.cfg.XidCorrelationWindow} + emitAll(p, ld, det.Evaluate(in.xids, in.events)) + }, + func(p *patterndetectorProcessor, ld plog.Logs, in projectedInputs) { + det := patterns.HBMECCDetector{ + CorrelationWindow: p.cfg.HBMECCWindow, + ECCDeltaThreshold: p.cfg.HBMECCDeltaThreshold, + } + emitAll(p, ld, det.Evaluate(in.hbms, in.xids)) + }, + func(p *patterndetectorProcessor, ld plog.Logs, in projectedInputs) { + det := patterns.ThermalThrottleDetector{ + Window: p.cfg.ThermalThrottleWindow, + ThrottleDeltaThreshold: p.cfg.ThermalThrottleDeltaThreshold, + MinCascadeGPUs: p.cfg.ThermalThrottleMinCascadeGPUs, + } + emitAll(p, ld, det.Evaluate(in.thermals)) + }, + func(p *patterndetectorProcessor, ld plog.Logs, in projectedInputs) { + det := patterns.PCIeAERDetector{ + CorrelationWindow: p.cfg.PCIeAERWindow, + RateDropThreshold: p.cfg.PCIeAERRateDropThreshold, + } + emitAll(p, ld, det.Evaluate(in.aers, in.ios)) + }, + func(p *patterndetectorProcessor, ld plog.Logs, in projectedInputs) { + det := patterns.IBLinkFlapDetector{ + CorrelationWindow: p.cfg.IBLinkFlapWindow, + MinTransitions: p.cfg.IBLinkFlapMinTransitions, + } + emitIBLinkFlap(p, ld, det.Evaluate(in.ibs, in.nccl)) + }, + // Wrapped runners — pattern-specific projections live inside + // each runXxxDetector helper (they walk plog.Logs themselves + // because their input shape doesn't fit collectInputs's shared + // projection). Their telemetry tick happens inside the helper + // today; preserving that path keeps this refactor + // behavior-identical for the noop_fallback IncVerdict tests. + func(p *patterndetectorProcessor, ld plog.Logs, _ projectedInputs) { + runCUDAOOMDetector(ld, p.cfg, p.cfg.emitPartialEnabled(), p.fbBuffer, p.logger()) + }, + func(p *patterndetectorProcessor, ld plog.Logs, _ projectedInputs) { + runCheckpointerHangDetector(ld, p.cfg, p.cfg.emitPartialEnabled(), p.logger()) + }, + func(p *patterndetectorProcessor, ld plog.Logs, _ projectedInputs) { + runSDCDetector(ld, p.cfg, p.cfg.emitPartialEnabled(), p.logger()) + }, + func(p *patterndetectorProcessor, ld plog.Logs, in projectedInputs) { + runNCCLBootstrapDetector(ld, in.nccl, p.cfg, p.cfg.emitPartialEnabled(), p.logger()) + }, + func(p *patterndetectorProcessor, ld plog.Logs, _ projectedInputs) { + runDataLoaderHangDetector(ld, p.cfg, p.logger()) + }, +} - if err := p.next.ConsumeLogs(ctx, ld); err != nil { - return fmt.Errorf("patterndetector: next.ConsumeLogs: %w", err) - } - return nil +// projectedInputs is the shared bag of typed record slices produced +// by collectInputs in one walk over plog.Logs. Each registered +// inline runner picks the field(s) it consumes — adding pattern #N +// that reuses an existing record shape costs zero extra walks. +// Adding pattern #N with a new record shape: add the field here, +// add the projection in collectInputs, and the runner reads the +// new field. +type projectedInputs struct { + events []patterns.Record + nodeConds []patterns.NodeRecord + nccl []patterns.NCCLFRRecord + xids []patterns.XidRecord + hbms []patterns.HBMECCRecord + thermals []patterns.ThermalThrottleRecord + aers []patterns.PCIeAERRecord + ios []patterns.PCIeIORecord + ibs []patterns.IBPortStateRecord } // collectInputs walks plog.Logs and projects each record into one of @@ -354,16 +440,8 @@ func (p *patterndetectorProcessor) ConsumeLogs(ctx context.Context, ld plog.Logs // // Priority reflects tighter discriminators winning over looser ones // (kernel-line regex > metric-derived bridge attribute). -func collectInputs(ld plog.Logs) ([]patterns.Record, []patterns.NodeRecord, []patterns.NCCLFRRecord, []patterns.XidRecord, []patterns.HBMECCRecord, []patterns.ThermalThrottleRecord, []patterns.PCIeAERRecord, []patterns.PCIeIORecord, []patterns.IBPortStateRecord) { - var events []patterns.Record - var nodes []patterns.NodeRecord - var nccl []patterns.NCCLFRRecord - var xids []patterns.XidRecord - var hbms []patterns.HBMECCRecord - var thermals []patterns.ThermalThrottleRecord - var aers []patterns.PCIeAERRecord - var ios []patterns.PCIeIORecord - var ibs []patterns.IBPortStateRecord +func collectInputs(ld plog.Logs) projectedInputs { + var in projectedInputs for i := 0; i < ld.ResourceLogs().Len(); i++ { rl := ld.ResourceLogs().At(i) resAttrs := rl.Resource().Attributes() @@ -372,44 +450,44 @@ func collectInputs(ld plog.Logs) ([]patterns.Record, []patterns.NodeRecord, []pa for k := 0; k < sl.LogRecords().Len(); k++ { lr := sl.LogRecords().At(k) if rec, ok := projectPodEvent(lr, resAttrs); ok { - events = append(events, rec) + in.events = append(in.events, rec) continue } if rec, ok := projectNodeCondition(lr, resAttrs); ok { - nodes = append(nodes, rec) + in.nodeConds = append(in.nodeConds, rec) continue } if rec, ok := projectNCCLFRRecord(lr, resAttrs); ok { - nccl = append(nccl, rec) + in.nccl = append(in.nccl, rec) continue } if rec, ok := projectXidRecord(lr, resAttrs); ok { - xids = append(xids, rec) + in.xids = append(in.xids, rec) continue } if rec, ok := projectHBMECCRecord(lr, resAttrs); ok { - hbms = append(hbms, rec) + in.hbms = append(in.hbms, rec) continue } if rec, ok := projectThermalThrottleRecord(lr, resAttrs); ok { - thermals = append(thermals, rec) + in.thermals = append(in.thermals, rec) continue } if rec, ok := projectPCIeAERRecord(lr, resAttrs); ok { - aers = append(aers, rec) + in.aers = append(in.aers, rec) continue } if rec, ok := projectPCIeIORecord(lr, resAttrs); ok { - ios = append(ios, rec) + in.ios = append(in.ios, rec) continue } if rec, ok := projectIBPortStateRecord(lr, resAttrs); ok { - ibs = append(ibs, rec) + in.ibs = append(in.ibs, rec) } } } } - return events, nodes, nccl, xids, hbms, thermals, aers, ios, ibs + return in } // projectNodeCondition reads OTel attributes off a log record and From df71365b8c03dcb05baba624114948d12a1b615b Mon Sep 17 00:00:00 2001 From: Tri Lam Date: Wed, 3 Jun 2026 18:11:44 -0700 Subject: [PATCH 2/2] docs(patterns): fix stale 11/12 count in detector.go Signed-off-by: Tri Lam --- module/pkg/patterns/detector.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/pkg/patterns/detector.go b/module/pkg/patterns/detector.go index 89bb24f8..8f9d644f 100644 --- a/module/pkg/patterns/detector.go +++ b/module/pkg/patterns/detector.go @@ -8,11 +8,11 @@ package patterns // (different input record shapes, different verdict types), so a // uniform Evaluate method on the interface would force a lossy // any-typed contract that the typed test suite has been fighting -// for the last 11 patterns. +// for the shipped patterns. // // Instead, this interface pins identity. The companion Registered // slice below is the single source of truth for "every shipped -// pattern detector" — adding pattern #12 is `append(Registered, ...)`, +// pattern detector" — adding a new pattern is `append(Registered, ...)`, // not editing the processor fanout site. // // Consumers (chiefly the patterndetectorprocessor) iterate Registered