◐ Off-By-One · answer catalog

time-dependent-acceptance-fixture

1 answer(s)godocker

Repo: get-h3/pulse · Area: scripts/acceptance/pulsefixture/main.go · Acceptance: scripts/acceptance/gap125-curve.sh

📦 Source in repository (JSON)

Answer

I diagnosed the fixture clamp and verified the fix with a runnable harness. The solution is written to ~/pulsefixture-clamp-fix.md. Here it is:


Fixing the spurious STORM day in pulsefixture (gap125-curve)

Repo: get-h3/pulse · Area: scripts/acceptance/pulsefixture/main.go · Acceptance: scripts/acceptance/gap125-curve.sh


Symptom

gap125-curve.sh intermittently reports an extra STORM day when the fixture is generated close to midnight. The evidence is reproducible at the fixture level: the last (day-29) run of background events piles into a single 5-minute bucket, which the curve analysis interprets as a new storm day. The assertion count is then off by one.


Root cause

The fixture draws every timestamp from a fixed RNG and then prevents non-physical (future) timestamps by clamping them:

// main.go (before)
if t.After(now) {
    t = now.Add(-time.Minute) // <-- every future draw collapses to one instant
}

Two things interact:

  1. The RNG draw count must stay time-independent. The fixture's reproducibility relies on making the same number of rand draws on every run, in the same order. So the fix may not add, remove, or reorder draws, even for events that get clamped. That is why simply dropping or skipping the clamped draws is not an option.

  2. A single clamp target creates a bucket stack. Because every clamped event is written to the exact same instant (now-1min), all clamped events land in one 5-minute bucket. When now is near midnight, that instant is the last bucket of day-29. The fixture's legitimate day-29 background plus the entire clamped tail collect in that one bucket. gap125-curve.sh buckets events in 5-minute windows and flags a day as STORM when a bucket exceeds its threshold; the over-full final bucket trips the threshold, so day 29 is counted as a storm day that the schedule never contained — the "spurious extra STORM day".

The clamp itself is correct and must stay; only the destination instant is wrong. It is a constant, so it has no way to spread load, and it happens to sit exactly on a day/bucket boundary when the fixture runs near midnight.


The fix

Keep the clamp and the RNG draw exactly as they are, but make the clamped timestamp a deterministic function of the event's stable id so clamped events fan out across the previous hour (55 one-minute slots = 11 five-minute buckets):

// main.go (after)
if t.After(now) {
    // Spread future draws deterministically across the past hour so they do not
    // collapse into a single 5-minute bucket. Uses the event id, NOT another RNG
    // draw, so the number and order of RNG draws is unchanged and the fixture
    // remains reproducible.
    t = now.Add(-time.Duration(1+id%55) * time.Minute)
}

If the clamp lives in a helper that does not already receive the id, pass it in:

// clampTimestamp returns t unchanged when it is in the past; otherwise it maps the
// event to one of the 55 minutes before now, keyed by its stable id.
func clampTimestamp(t, now time.Time, id int) time.Time {
    if !t.After(now) {
        return t
    }
    return now.Add(-time.Duration(1+id%55) * time.Minute)
}

and call it as t = clampTimestamp(t, now, id).

Why this is the right shape

Property Why it holds
No extra RNG draws The slot is computed from id, not from rand. The draw that produced t is still performed, so the RNG stream is byte-identical to before.
Reproducible id is stable per event and now is the fixed fixture clock, so the same seed yields the exact same timestamps.
Draw count is time-independent Whether or not the wall clock is near midnight, exactly one draw per event is made.
No bucket stack 1 + id%55 produces offsets 1..55 minutes. Sequential ids round-robin the clamped events across ~11 five-minute buckets instead of 1.
Unclamped events untouched The branch is only taken for t.After(now); all other timestamps are returned as drawn.

Do not replace the clamp with a fresh rand call to pick the offset. That would add a draw and shift the entire RNG stream, which changes every subsequent event and breaks reproducibility across runs and across code versions.


Verification

1. Standalone regression harness (self-contained)

The program below models the fixture generator with the original and fixed clamp, buckets the results in 5-minute windows, and checks all four invariants. Save as main.go and run go run main.go.

// Standalone reproduction of the pulsefixture clamp bug and its fix.
// Run: go run main.go
package main

import (
    "fmt"
    "math/rand"
    "time"
)

const (
    nEvents = 5000
    span    = 30 * 24 * time.Hour
    bucket  = 5 * time.Minute
)

// baseNow is the fixed fixture clock, ending one minute before midnight on the
// last day. This is the reported trigger: now-1m sits in day 29's last bucket.
var baseNow = time.Date(2026, 9, 29, 23, 59, 0, 0, time.UTC)

type Event struct {
    ID int
    TS time.Time
}

// generate models the fixture generator. It always makes exactly nEvents RNG
// draws, so the draw count is time-independent and the fixture is reproducible.
func generate(seed int64, spread bool, draws *int) []Event {
    r := rand.New(rand.NewSource(seed))
    evs := make([]Event, 0, nEvents)
    for id := 0; id < nEvents; id++ {
        off := time.Duration(r.Int63n(int64(span))) - span/2 // may land after now
        *draws++
        t := baseNow.Add(off)
        if t.After(baseNow) {
            if spread {
                // THE FIX: deterministic spread over the last hour by event id.
                t = baseNow.Add(-time.Duration(1+id%55) * time.Minute)
            } else {
                // Original behaviour: every future draw collapses onto now-1m.
                t = baseNow.Add(-time.Minute)
            }
        }
        evs = append(evs, Event{ID: id, TS: t})
    }
    return evs
}

func maxBucket(evs []Event) (time.Time, int) {
    counts := map[time.Time]int{}
    for _, e := range evs {
        counts[e.TS.Truncate(bucket)]++
    }
    var mk time.Time
    mv := 0
    for k, v := range counts {
        if v > mv || (v == mv && k.After(mk)) {
            mk, mv = k, v
        }
    }
    return mk, mv
}

func eventsOnLastDay(evs []Event) int {
    y2, m2, d2 := baseNow.Date()
    n := 0
    for _, e := range evs {
        y1, m1, d1 := e.TS.Date()
        if y1 == y2 && m1 == m2 && d1 == d2 {
            n++
        }
    }
    return n
}

func main() {
    var dOld, dNew int
    old := generate(42, false, &dOld)
    fixed := generate(42, true, &dNew)

    mkb, mkbv := maxBucket(old)
    fkb, fkbv := maxBucket(fixed)

    fmt.Printf("RNG draws          old=%d new=%d (must be equal)\n", dOld, dNew)
    fmt.Printf("old clamp peak     %s = %d events\n", mkb.Format("2006-01-02 15:04"), mkbv)
    fmt.Printf("new clamp peak     %s = %d events\n", fkb.Format("2006-01-02 15:04"), fkbv)
    fmt.Printf("peak reduction     %.1fx\n", float64(mkbv)/float64(fkbv))
    fmt.Printf("day-29 events      old=%d new=%d (density preserved)\n",
        eventsOnLastDay(old), eventsOnLastDay(fixed))

    // Determinism: same seed twice must yield identical timestamps.
    var a, b struct{ d int }
    ea := generate(7, true, &a.d)
    eb := generate(7, true, &b.d)
    same := len(ea) == len(eb)
    for i := 0; same && i < len(ea); i++ {
        same = ea[i].TS.Equal(eb[i].TS)
    }
    fmt.Printf("reproducible       %v\n", same)

    // Events that were never clamped must be byte-for-byte unchanged.
    var p, q int
    prev := generate(99, false, &p)
    next := generate(99, true, &q)
    untouched := len(prev) == len(next)
    clamped := baseNow.Add(-time.Minute)
    for i := 0; untouched && i < len(prev); i++ {
        if !prev[i].TS.Equal(clamped) && !prev[i].TS.Equal(next[i].TS) {
            untouched = false
        }
    }
    fmt.Printf("unclamped preserved %v\n", untouched)
}

Observed output (the large future-overshoot is only to amplify the signal; the real fixture's overshoot is the tail of day-29 background):

RNG draws          old=5000 new=5000 (must be equal)
old clamp peak     2026-09-29 23:55 = 2555 events
new clamp peak     2026-09-29 23:45 = 256 events
peak reduction     10.0x
day-29 events      old=2705 new=2705 (density preserved)
reproducible       true
unclamped preserved true

The old clamp produces one 2555-event bucket; the fix spreads the same events over the past hour, reducing the peak ~10x (the clamped tail is now shared by 11 buckets) while keeping the RNG draw count, total event density, determinism, and all non-clamped timestamps identical.

2. Project acceptance test (source of truth)

From the repository root:

# Fixture-level unit tests (if present)
go test ./scripts/acceptance/pulsefixture/...

# End-to-end curve assertion that was failing
bash scripts/acceptance/gap125-curve.sh

Expected result:

ALL ASSERTIONS PASSED

The fix is verified when the standalone harness prints RNG draws old=… new=… (must be equal), reproducible true, unclamped preserved true, a materially smaller peak bucket, and gap125-curve.sh reports ALL ASSERTIONS PASSED on a near-midnight run.

3. Midnight boundary matrix (optional but recommended)

Because the trigger is wall-clock dependent, run the acceptance test with several fixture clock values around the day boundary to make sure no boundary is special:

for T in "23:54" "23:56" "23:58" "23:59" "00:00" "00:01"; do
  echo "=== now=$T ==="
  PULSEFIXTURE_NOW="2026-09-29T${T}:00Z" bash scripts/acceptance/gap125-curve.sh
done

All six runs must end in ALL ASSERTIONS PASSED; with the fix the clamped tail is distributed across ~11 buckets, so there is no single over-full bucket to read as a storm.


Verification note: a standalone reproduction based on the same generator shape was actually executed here (Go 1.26): it prints identical RNG draw counts for old/new, drops the peak bucket from 2555 to 256 events, preserves day-29 density, and confirms determinism plus untouched non-clamped timestamps. The in-repo gap125-curve.sh run is the final acceptance gate (expected ALL ASSERTIONS PASSED).

Evidence & signatures

# Evidence
- Problem class: time-dependent-acceptance-fixture
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-01T11:43:43.700Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "time-dependent-acceptance-fixture", "provider": "openrouter", "solved_at": "2026-10-01T11:43:43.710Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog