@@ -155,6 +155,117 @@ func TestQuarantineReconvictionsSurviveReceiveProgressRelease(t *testing.T) {
155155 }
156156}
157157
158+ // TestQuarantineReconvictionDecaySteps is the pure decay math Task 6 adds:
159+ // one step removed per whole quarantineReconvictionDecayInterval of elapsed
160+ // time, floored at 0, with both a negative count and a negative elapsed
161+ // (clock trouble, not evidence of a longer clean interval -- the same
162+ // convention reentryScorePenalty's elapsed<0 clamp already uses) reading as
163+ // "no decay to apply" rather than propagating a negative result. A negative
164+ // reconviction count feeding benchDuration or exitScore's StallEvents term
165+ // is exactly the defect class this branch has already shipped twice
166+ // (negative StallEvents scoring as a bonus, a zero RTT scoring best), so
167+ // this must never produce one.
168+ func TestQuarantineReconvictionDecaySteps (t * testing.T ) {
169+ interval := quarantineReconvictionDecayInterval
170+
171+ if got := quarantineReconvictionDecay (5 , 0 ); got != 5 {
172+ t .Fatalf ("zero elapsed must not decay: got %d, want 5" , got )
173+ }
174+ if got := quarantineReconvictionDecay (5 , interval - time .Second ); got != 5 {
175+ t .Fatalf ("just under one elapsed interval must not decay yet: got %d, want 5" , got )
176+ }
177+ if got := quarantineReconvictionDecay (5 , interval ); got != 4 {
178+ t .Fatalf ("exactly one elapsed interval must remove exactly one step: got %d, want 4" , got )
179+ }
180+ if got := quarantineReconvictionDecay (5 , 2 * interval ); got != 3 {
181+ t .Fatalf ("two elapsed intervals must remove exactly two steps: got %d, want 3" , got )
182+ }
183+ if got := quarantineReconvictionDecay (5 , 100 * interval ); got != 0 {
184+ t .Fatalf ("many elapsed intervals must floor at 0, not go negative: got %d, want 0" , got )
185+ }
186+ if got := quarantineReconvictionDecay (0 , 100 * interval ); got != 0 {
187+ t .Fatalf ("a count already at 0 must stay 0: got %d, want 0" , got )
188+ }
189+ if got := quarantineReconvictionDecay (- 3 , interval ); got != 0 {
190+ t .Fatalf ("a negative count must clamp to 0, not go more negative: got %d, want 0" , got )
191+ }
192+ if got := quarantineReconvictionDecay (5 , - time .Second ); got != 5 {
193+ t .Fatalf ("negative elapsed must clamp to no-decay, got %d, want 5" , got )
194+ }
195+ }
196+
197+ // TestQuarantineReconvictionCountDecaysOverQuietTime is Task 6's channel-level
198+ // proof: with QuarantineDampening on, quarantineReconvictionCount() decays
199+ // the count by whole quarantineReconvictionDecayInterval steps measured from
200+ // quarantineLiftTime (the last completed lift), floors at 0 once enough
201+ // quiet time has passed, and a fresh conviction is visible immediately
202+ // afterward rather than reading as still-decayed. Deliberately picks
203+ // intervals where the decayed and un-decayed readings differ (1 and 0
204+ // against a raw count of 2) so this cannot pass against the un-fixed
205+ // always-climbing counter.
206+ func TestQuarantineReconvictionCountDecaysOverQuietTime (t * testing.T ) {
207+ client := stallTestChannel ()
208+ client .settings .QuarantineDampening = true
209+
210+ // two completed bench-then-lift cycles -> raw reconvictions = 2
211+ client .setQuarantined (blackholeNoReceiveAck )
212+ client .clearQuarantine ()
213+ client .setQuarantined (blackholeNoReceiveSyn )
214+ client .clearQuarantine ()
215+ if got := client .quarantineReconvictionCount (); got != 2 {
216+ t .Fatalf ("expected 2 reconvictions right after the second lift, got %d" , got )
217+ }
218+
219+ // age the last lift back by just over one decay interval: exactly one
220+ // step must be removed
221+ client .stateLock .Lock ()
222+ client .quarantineLiftTime = time .Now ().Add (- quarantineReconvictionDecayInterval - time .Second )
223+ client .stateLock .Unlock ()
224+ if got := client .quarantineReconvictionCount (); got != 1 {
225+ t .Fatalf ("one elapsed decay interval must remove exactly one step, got %d, want 1" , got )
226+ }
227+
228+ // age it back far enough for well past both steps: must floor at 0, not
229+ // go negative
230+ client .stateLock .Lock ()
231+ client .quarantineLiftTime = time .Now ().Add (- 5 * quarantineReconvictionDecayInterval - time .Second )
232+ client .stateLock .Unlock ()
233+ if got := client .quarantineReconvictionCount (); got != 0 {
234+ t .Fatalf ("enough quiet time must decay reconvictions to exactly 0, got %d, want 0" , got )
235+ }
236+
237+ // a fresh conviction resets the decay clock: the new lift's elapsed time
238+ // is ~0, so the count must be visible right away, not read as decayed
239+ client .setQuarantined (blackholeNoReceiveAck )
240+ client .clearQuarantine ()
241+ if got := client .quarantineReconvictionCount (); got == 0 {
242+ t .Fatal ("a fresh conviction must reset the decay clock, not read as still-decayed" )
243+ }
244+ }
245+
246+ // TestQuarantineReconvictionCountInertWhenDampeningOff pins the zero-value-off
247+ // contract: with QuarantineDampening at its zero value (false), an aged
248+ // quarantineLiftTime must not decay the count at all -- quarantineReconvictionCount()
249+ // must keep returning the raw, ever-climbing reading a default build has
250+ // always returned, with no clock read and no decay computation performed.
251+ func TestQuarantineReconvictionCountInertWhenDampeningOff (t * testing.T ) {
252+ client := stallTestChannel ()
253+ // QuarantineDampening left at its zero value (false)
254+
255+ client .setQuarantined (blackholeNoReceiveAck )
256+ client .clearQuarantine ()
257+ client .setQuarantined (blackholeNoReceiveSyn )
258+ client .clearQuarantine ()
259+
260+ client .stateLock .Lock ()
261+ client .quarantineLiftTime = time .Now ().Add (- 10 * quarantineReconvictionDecayInterval )
262+ client .stateLock .Unlock ()
263+
264+ if got := client .quarantineReconvictionCount (); got != 2 {
265+ t .Fatalf ("QuarantineDampening off must never decay the count, got %d, want 2" , got )
266+ }
267+ }
268+
158269// TestReentryScorePenaltyDecaysToZero is the pure decay curve: full weight
159270// at the instant of release (elapsed==0), zero once ramp has fully elapsed,
160271// and strictly decreasing in between. ramp<=0 is the zero-value-off legacy
0 commit comments