@@ -229,3 +229,135 @@ func TestLogBrain_ClassifyLifecycle(t *testing.T) {
229229 t .Errorf ("spike baseline = %v, want the pre-fold baseline > 0" , v .Baseline )
230230 }
231231}
232+
233+ // --- auto_promote_after threshold semantics (QA-028) --------------------------
234+ // Spike is left disabled (SpikeMultiplier == 0) throughout so it can never mask
235+ // the count-based promotion verdict under test.
236+
237+ // (a) The shipped default (100, supplied by the embedded default_config layer
238+ // for an unset key) still promotes exactly at the 100th sighting.
239+ func TestLogBrain_AutoPromoteAfter_DefaultPromotesAt100 (t * testing.T ) {
240+ b , c := newLogBrainForTest (t , config.AgentCatalogConfig {AutoPromoteAfter : 100 })
241+
242+ for i := 0 ; i < 99 ; i ++ {
243+ classifyOnce (t , b , logObs ("p" , 1 ))
244+ }
245+ if got := c .Get ("p" ).Count ; got != 99 {
246+ t .Fatalf ("count = %d, want 99" , got )
247+ }
248+ if c .Get ("p" ).Verdict == "known" {
249+ t .Fatalf ("promoted early at count=99 (threshold 100)" )
250+ }
251+
252+ v := classifyOnce (t , b , logObs ("p" , 1 )) // 100th sighting crosses the threshold
253+ if got := c .Get ("p" ).Count ; got != 100 {
254+ t .Fatalf ("count = %d, want 100" , got )
255+ }
256+ if v .Class != core .VerdictKnownPattern {
257+ t .Fatalf ("at-threshold verdict = %v, want known" , v .Class )
258+ }
259+ if c .Get ("p" ).Verdict != "known" {
260+ t .Fatalf ("catalog verdict = %q, want known (MarkKnown must fire at 100)" , c .Get ("p" ).Verdict )
261+ }
262+ }
263+
264+ // (b) A positive custom threshold promotes exactly at that count, not at 100.
265+ func TestLogBrain_AutoPromoteAfter_CustomThresholdPromotes (t * testing.T ) {
266+ b , c := newLogBrainForTest (t , config.AgentCatalogConfig {AutoPromoteAfter : 50 })
267+
268+ for i := 0 ; i < 49 ; i ++ {
269+ classifyOnce (t , b , logObs ("p" , 1 ))
270+ }
271+ if c .Get ("p" ).Verdict == "known" {
272+ t .Fatalf ("promoted before custom threshold 50 (count=%d)" , c .Get ("p" ).Count )
273+ }
274+
275+ v := classifyOnce (t , b , logObs ("p" , 1 )) // 50th sighting
276+ if got := c .Get ("p" ).Count ; got != 50 {
277+ t .Fatalf ("count = %d, want 50" , got )
278+ }
279+ if v .Class != core .VerdictKnownPattern {
280+ t .Fatalf ("at-custom-threshold verdict = %v, want known" , v .Class )
281+ }
282+ if c .Get ("p" ).Verdict != "known" {
283+ t .Fatalf ("catalog verdict = %q, want known at the custom threshold" , c .Get ("p" ).Verdict )
284+ }
285+ }
286+
287+ // (c) QA-028: auto_promote_after: 0 DISABLES count-based promotion — a pattern
288+ // is never marked "known" no matter how many times it is seen. Drives well past
289+ // the old 100 fallback to prove the explicit 0 is honoured, not re-mapped.
290+ func TestLogBrain_AutoPromoteAfter_ZeroDisablesPromotion (t * testing.T ) {
291+ cat := config.AgentCatalogConfig {
292+ AutoPromoteAfter : 0 , // documented: disables promotion
293+ SpikeMultiplier : 0 , // disable spike so it can't mask the verdict
294+ }
295+ b , c := newLogBrainForTest (t , cat )
296+
297+ var v core.TypedVerdict
298+ for i := 0 ; i < 12 ; i ++ {
299+ v = classifyOnce (t , b , logObs ("p" , 10 )) // 120 sightings, well past 100
300+ }
301+ if got := c .Get ("p" ).Count ; got != 120 {
302+ t .Fatalf ("count = %d, want 120" , got )
303+ }
304+ if c .Get ("p" ).Verdict == "known" {
305+ t .Fatalf ("auto_promote_after=0 promoted pattern to %q; 0 must disable promotion (QA-028)" , c .Get ("p" ).Verdict )
306+ }
307+ if v .Class != core .VerdictUnknown {
308+ t .Fatalf ("verdict at count=120 = %v, want unknown (0 disables promotion)" , v .Class )
309+ }
310+ }
311+
312+ // (d) A negative threshold (any value ≤ 0) also disables promotion.
313+ func TestLogBrain_AutoPromoteAfter_NegativeDisablesPromotion (t * testing.T ) {
314+ cat := config.AgentCatalogConfig {
315+ AutoPromoteAfter : - 1 ,
316+ SpikeMultiplier : 0 ,
317+ }
318+ b , c := newLogBrainForTest (t , cat )
319+
320+ var v core.TypedVerdict
321+ for i := 0 ; i < 12 ; i ++ {
322+ v = classifyOnce (t , b , logObs ("p" , 10 )) // 120 sightings
323+ }
324+ if c .Get ("p" ).Verdict == "known" {
325+ t .Fatalf ("auto_promote_after=-1 promoted pattern to %q; any value ≤0 must disable promotion" , c .Get ("p" ).Verdict )
326+ }
327+ if v .Class != core .VerdictUnknown {
328+ t .Fatalf ("verdict = %v, want unknown (negative disables promotion)" , v .Class )
329+ }
330+ }
331+
332+ // (e) An operator-labelled "known" pattern STAYS known even when count-based
333+ // promotion is disabled (threshold 0). This bites the `prevVerdict == "known"`
334+ // clause independently of the count clause: were the guard rewritten as
335+ // `threshold > 0 && (prevVerdict == "known" || postCount >= threshold)`, a
336+ // disabled threshold would silently un-suppress a hand-labelled pattern.
337+ func TestLogBrain_AutoPromoteAfter_AlreadyKnownStaysKnown (t * testing.T ) {
338+ cat := config.AgentCatalogConfig {
339+ AutoPromoteAfter : 0 , // count-based promotion disabled
340+ SpikeMultiplier : 0 , // disable spike so it can't mask the verdict
341+ }
342+ b , c := newLogBrainForTest (t , cat )
343+
344+ // Seed the pattern (stays Unknown — 0 disables count-based promotion), then
345+ // label it known by hand as an operator would.
346+ classifyOnce (t , b , logObs ("p" , 5 ))
347+ if ! c .MarkKnown ("p" ) {
348+ t .Fatalf ("MarkKnown(p) did not mark the seeded pattern" )
349+ }
350+ if c .Get ("p" ).Verdict != "known" {
351+ t .Fatalf ("precondition: catalog verdict = %q, want known" , c .Get ("p" ).Verdict )
352+ }
353+
354+ // Further sightings must keep it known — the prior "known" verdict wins even
355+ // though the count threshold is disabled.
356+ v := classifyOnce (t , b , logObs ("p" , 5 ))
357+ if v .Class != core .VerdictKnownPattern {
358+ t .Fatalf ("already-known verdict = %v, want known (prior known must win with threshold 0)" , v .Class )
359+ }
360+ if c .Get ("p" ).Verdict != "known" {
361+ t .Fatalf ("catalog verdict = %q, want known (an already-known pattern must stay known)" , c .Get ("p" ).Verdict )
362+ }
363+ }
0 commit comments