@@ -267,13 +267,13 @@ func (b *logBrain) Classify(obs core.Observation, mean, std float64, confident b
267267 prevCount := postCount - tickFreq // recover the pre-fold count
268268 prevVerdict := p .Verdict // Upsert never mutates Verdict, so == pre-fold
269269
270- // AutoPromoteAfter ≤ 0 disables count-based promotion entirely ("0 disables
271- // the promotion"): a pattern is never marked "known" by sighting count
272- // alone, so it keeps flowing to detect-AI however often it is seen. The 100
273- // default for an UNSET key is supplied by the embedded default_config layer
274- // (loaded as the base before user overrides ), so an omitted key arrives here
275- // as 100 — only an explicit 0 (or negative) reaches the disabled branch. A
276- // pattern already promoted to "known" stays known regardless of threshold.
270+ // The effective auto-promotion threshold gates count-based promotion: once
271+ // a pattern's sighting count reaches it, the pattern is marked "known" and
272+ // stops flowing to detect-AI on count alone. A non-positive configured
273+ // value is resolved to the default by isLogKnown (there is no way to turn
274+ // count-based promotion off ), and an omitted key already arrives here as the
275+ // default via the config layer. A pattern already promoted to "known" stays
276+ // known regardless of threshold.
277277 threshold := b .cat .AutoPromoteAfter
278278 isKnown := isLogKnown (prevVerdict , postCount , threshold )
279279 if isKnown {
@@ -340,9 +340,9 @@ func (b *logBrain) confirmSpike(key string, res spikeResult) bool {
340340// training this is what keeps the Verdict column and the readiness "To known"
341341// column in agreement (both derive from isLogKnown).
342342//
343- // AutoPromoteAfter <= 0 disables count-based promotion entirely: for a not-yet-
344- // known pattern isLogKnown then returns false, so Promote never marks a pattern
345- // known by count in any mode; an operator-set "known" is untouched .
343+ // A non-positive AutoPromoteAfter is resolved to the default by isLogKnown, so
344+ // count-based promotion is always in effect; there is no "promotion disabled"
345+ // state .
346346func (b * logBrain ) Promote (key string ) {
347347 p := b .catalog .Get (key )
348348 if p == nil {
@@ -353,16 +353,30 @@ func (b *logBrain) Promote(key string) {
353353 }
354354}
355355
356+ // effectiveAutoPromote resolves the auto-promotion threshold used everywhere in
357+ // the learning engine. A non-positive value (an operator who omitted, blanked,
358+ // or zeroed the key, or a test that passes 0 directly) is treated as the
359+ // default rather than as a "promotion disabled" state — there is no way to turn
360+ // count-based promotion off. The config loader already normalizes the loaded
361+ // config to a positive value; this is the belt-and-suspenders guard so a 0 that
362+ // reaches the engine by any other path still resolves to a sane gate.
363+ func effectiveAutoPromote (n int ) int {
364+ if n <= 0 {
365+ return config .DefaultAutoPromoteAfter
366+ }
367+ return n
368+ }
369+
356370// isLogKnown is the SINGLE definition of log "known" — the exact predicate
357371// logBrain.Classify gates promotion on. Both Classify and LogReadiness call it
358372// so the classifier and the read-side readiness view can never drift; a drift
359373// test pins them equal. A pattern is known when an operator (or a prior
360- // auto-promotion) already marked it "known", OR count-based promotion is
361- // enabled (autoPromoteAfter > 0) and the sighting count has reached it.
362- // autoPromoteAfter <= 0 disables count-based promotion , so only a pre-set
363- // "known" verdict counts .
374+ // auto-promotion) already marked it "known", OR the sighting count has reached
375+ // the effective auto-promotion threshold. A non-positive autoPromoteAfter is
376+ // resolved to the default (see effectiveAutoPromote) , so count-based promotion
377+ // is always in effect .
364378func isLogKnown (prevVerdict string , count , autoPromoteAfter int ) bool {
365- return prevVerdict == "known" || ( autoPromoteAfter > 0 && count >= autoPromoteAfter )
379+ return prevVerdict == "known" || count >= effectiveAutoPromote ( autoPromoteAfter )
366380}
367381
368382// LogReadiness computes the readiness of one log pattern as a generic
@@ -372,9 +386,9 @@ func isLogKnown(prevVerdict string, count, autoPromoteAfter int) bool {
372386// gate compares) and the catalog's AutoPromoteAfter threshold.
373387//
374388// - Seen = the pattern's sighting count (the gate's own counter).
375- // - Needed = autoPromoteAfter when count -promotion is enabled (>0); 0 is the
376- // indeterminate sentinel when promotion is disabled (autoPromoteAfter<=0 →
377- // manual-only) .
389+ // - Needed = the effective auto -promotion threshold — always a positive gate
390+ // (a non-positive configured value is resolved to the default), so there is
391+ // no indeterminate/ manual-only case .
378392// - Ready = isLogKnown(...), so an already-"known"/"spike"-marked or
379393// count-promoted pattern reports Ready.
380394// - RatePerMin = the pattern's learned per-second sighting rate
@@ -387,12 +401,10 @@ func LogReadiness(p *Pattern, autoPromoteAfter int, pollInterval time.Duration)
387401 return core.Readiness {}
388402 }
389403 r := core.Readiness {
390- Seen : p .Count ,
391- Ready : isLogKnown (p .Verdict , p .Count , autoPromoteAfter ),
404+ Seen : p .Count ,
405+ Needed : effectiveAutoPromote (autoPromoteAfter ),
406+ Ready : isLogKnown (p .Verdict , p .Count , autoPromoteAfter ),
392407 }
393- if autoPromoteAfter > 0 {
394- r .Needed = autoPromoteAfter
395- } // else Needed=0 → indeterminate (manual-only promotion)
396408 if ! r .Ready && pollInterval > 0 && p .BaselineFrequency > 0 {
397409 // BaselineFrequency is now a per-second sighting rate, so sightings/min
398410 // is just rate × 60 — poll-interval-independent. pollInterval is kept as
0 commit comments