Skip to content

Send Pacing Scheduler

tempoledger, a scheduler I built for simulated phishing texts in security-awareness training, paces each message the way a person would send it: never sooner than it could have been typed. This page rebuilds it in your browser and runs its own audit, which catches the schedule breaking that rule.

Original built with Python · NumPy · Pydantic · FastAPI · PostgreSQL · Celery · OpenTelemetry

What I found

1,000 of 1,000 runs

of tempoledger’s own example, 12 messages over 2 hours, send their last two messages before they could have been typed.

About 3 messages a run

land on the campaign’s last instant. The plan treats clock times such as 09:00 as delays from the start, so a 2-hour campaign gets slots nine hours out and more, clamped back to its end.

99 of 1,000 runs

also send more than 3 messages within 90 seconds, breaking the scheduler’s own burst limit.

Try it

In this run of 12 messages, two go out before they could have been typed, and three land on the campaign’s final instant, 11:00.

Each row is one message, in send order. The scheduler prepares each message from the previous send and should wait at least as long as typing it would take; a red tick is a message sent before that. A seed fixes the random draws, so each seed is one reproducible run: step through them below, or pick a setup in the table to chart it.

Seed 7 12 messages, 09:00 to 11:00 UTC · two sent before they were typed · three at the campaign's final instant

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
Three messages go at the same instant, 11:00, the campaign’s end: anything scheduled past it is clamped back to it. waiting since the previous send planned slot, hidden under the tick when sent on schedule sent sent before it could be typed campaign end

Any whole number from 0 to 4,294,967,295.

What 1,000 random runs find for each setup

Pick a setup to chart it above. The burst limit is the scheduler’s own: no more than 3 sends within 90 seconds.

SetupRuns with a message sent before it was typedRuns over the burst limitRuns with a send outside business hoursMessages at the final instant, average per run
1,000 of 1,00099 of 1,0000 of 1,0003.0
1,000 of 1,00023 of 1,0000 of 1,0003.0
1,000 of 1,0001,000 of 1,0000 of 1,0005.0
1,000 of 1,000999 of 1,0000 of 1,0000.0, but 7.5 a run pile at 17:00, when business hours close
Change the campaign, read every message’s timing, export a replay

Charted above: seed 7, 12 messages between 09:00 and 11:00 UTC, two of them sent before they could be typed, three at the final instant.

Message ledger

Margin is the time sent minus the time typed by: below zero, the message went before it could have been typed. Planned is the slot the campaign plan gave it, when the plan moved it.

MessageTyping startsTyping timeTyped byPlannedSentMarginAudit
109:00:00.00
5.2 s70 wpm
09:00:05.1609:01:54.5509:01:54.55+109.4 sclean
209:01:54.55
29.5 s63 wpm + 23.8 s pause
09:02:24.0609:10:24.5509:10:24.55+480.5 sclean
309:10:24.55
29.2 s37 wpm + 19.6 s pause
09:10:53.7109:27:16.8409:27:16.84+983.1 sclean
409:27:16.84
12.9 s49 wpm + 5.5 s pause
09:27:29.7509:31:39.8709:31:39.87+250.1 sclean
509:31:39.87
10.9 s61 wpm + 5.0 s pause
09:31:50.8109:36:57.6909:36:57.69+306.9 sclean
609:36:57.69
4.5 s80 wpm
09:37:02.1909:50:19.4709:50:19.47+797.3 sclean
709:50:19.47
13.1 s45 wpm + 5.0 s pause
09:50:32.5609:52:36.5409:52:36.54+124.0 sclean
809:52:36.54
11.2 s58 wpm + 5.0 s pause
09:52:47.7009:54:40.2109:54:40.21+112.5 sclean
909:54:40.21
17.3 s49 wpm + 9.9 s pause
09:54:57.4610:03:19.9010:03:19.90+502.4 sclean
1010:03:19.90
10.8 s33 wpm
10:03:30.7311:00:00.0011:00:00.00+3389.3 sclean
1111:00:00.00
10.2 s35 wpm
11:00:10.17—11:00:00.00−10.2 ssent before it could be typed
1211:00:00.00
6.7 s54 wpm
11:00:06.72—11:00:00.00−6.7 ssent before it could be typed
Contents

Pacing a message like a person

tempoledger schedules outbound text messages so they go out the way a person would send them. Each message waits as long as typing it would take at a sampled speed, sometimes with a pause. Sends drift toward quarter hours in the busy hours, intervals are jittered (nudged at random) when they get too regular, bursts are spread out, and everything stays inside business hours and the campaign window.

Human pacing matters in security-awareness training: a simulated phishing text only teaches people to recognise the real thing if it arrives the way a real person's message would, not in a machine-timed burst. I built human-like scheduling and self-hosted SMS simulation for that kind of training at GhostEye (see my work). tempoledger is my separate, public scheduler for the same problem, not GhostEye's code.

Imitating a person is a set of timing promises, and the first is the simplest: a message cannot go before it could have been typed. The scheduler's own audit checks that, along with the burst limit, business hours and the campaign window, by looking at the finished schedule rather than trusting the steps that built it. This page runs that audit over a thousand seeds, each seed one reproducible random run.

What the audit finds

Twelve messages over two hours, tempoledger's own replay: in 1,000 of 1,000 seeds the last two are sent before they could have been typed, and 99 also put more than 3 sends in a 90-second window. The cause is in the campaign plan. 3 of its 12 planned slots are quarter hours between 09:00 and 16:45, as clock times, but the plan adds those to the campaign's start, so a two-hour campaign gets offsets of nine hours and more. They clamp to the campaign's end. About 3 messages a run land on its final instant, each prepared from the send before it, so each after the first goes out before it is typed.

Longer campaigns do not escape it: over eight hours, 1,000 of 1,000 seeds still end the same way. Twice the messages in two hours breaks the burst limit in 1,000. Starting at 16:00, the business-hours clamp piles sends at 17:00 instead, and 999 seeds break the burst limit. tempoledger's README says this plainly: a sampled distribution, a final projection and a queue are separate contracts, and clamping one does not keep the others.

Status: documented, not fixed. tempoledger's own failure catalog lists the pile-up, relative offsets containing clock hours hitting a short campaign's end, with the work that remains: telling clock windows from relative delays. It lists sends before preparation finishes too, with a lower-bound-aware projection as the fix. Its release notice says the heuristic's feasibility limitations are documented rather than represented as solved.

What this page is not

Not a model of real people: the timing heuristics were never validated against how anyone types or sends, and nothing here sends a message.

For engineers

The per-message and per-campaign rules, the port of NumPy's generator that makes the replays match, the original's stack, and how to reproduce it.

How the scheduler decides

Per message. A typing speed is drawn from a normal distribution, mean 50 and deviation 15 words a minute, clipped to 30 to 80. With probability 0.4 a pause is added, exponential with mean 12.5 seconds, clipped to 5 to 45. In the busy hours, 10:00 to 12:00 and 14:00 to 16:00, a target near a quarter hour is nudged toward it by a normal draw with a 3-minute deviation, which can move it earlier. Then the recent intervals are checked: a variance under 100 square seconds adds gamma noise, a repeated interval adds a normal nudge, and 3 sends inside 90 seconds add a 30-to-60-second delay. Outside 9:00 to 17:00, the target moves forward to the next opening.

Per campaign. A plan draws 30 percent of the messages from the busy windows, half uniformly over the campaign, and the rest at quarter hours. The busy windows and the quarter hours are clock times used as offsets from the start, so in a short campaign the busy-window draws fall back to uniform and the quarter hours clamp to the end. Each message takes the later of its own target and its planned slot, then is clamped into the campaign and into business hours within it. The next message is prepared from this one's send.

Checked against the original

The scheduler, its audit and its replay are ported from engine.py, audit.py and replay.py, with times as whole microseconds because Python's datetimes are. Matching a sampled schedule means matching its random numbers, so the page carries a port of NumPy's legacy RandomState: the Mersenne Twister, polar-method normals with the cached second value, Marsaglia and Tsang gamma, and masked-rejection integers. Its draws match NumPy bit for bit, and 11 of 11 recorded source replays come out identical: every send time to the microsecond, every typing time, every audit finding. Seed 7 gives the README's numbers: 7 pauses, a span of 7085.445401 seconds.

Where this comes from

tempoledger is my scheduler and backend for text-message campaigns: Pydantic models, the NumPy timing engine, FastAPI routes, PostgreSQL storage with migrations, Celery send tasks, a provider adapter behind explicit opt-ins, OpenTelemetry, and an agent that proposes schedule changes but only logs them. The public release runs offline, delivery is simulated, and no message is sent. It publishes no delivery rates or throughput, only its fixture statistics, which this page reproduces.

Limits in detail

The replays use six-word synthetic messages and UTC with no holidays or daylight saving. This page ports the scheduling engine, its audit and its replay, not the backend, queue or provider adapter. An exported file is checked for consistency, not authenticity.

Reproduce it

Step through seeds: the last rows stay red in every one. Pick the 16:00 replay and see the sends pile at closing time. Under the hood, raise the message count and read the burst findings in the message ledger. Export a replay and import it: the scheduler reruns and the file is refused if its send times do not follow. Files are limited to 50 KB. The port, the recorded source runs and the tests are in the code for this page. From the site's Next.js app:

npx vitest run src/lib/projects/send-pacing src/components/projects/send-pacing