Temporal Patterns#

Use global_iter() with math functions to make generated data change shape over the life of a workload. This lets you model real-world patterns like shifting popularity, price inflation, seasonal traffic, periodic spikes, and sensor calibration drift; all without external scripts.

Every pattern on this page uses the same building block: an expression that takes global_iter() as input and produces a value whose distribution or magnitude changes as the iteration counter grows.

The full config is at examples/temporal_patterns/crdb.yaml.

Globals Are User-Defined#

Names in the following globals: block are arbitrary and become variables available in any expression. The patterns below use names like total_iters and initial_skew, but you can name them whatever you like.

Patterns#

Zipf Skew Drift#

Product popularity starts nearly uniform and concentrates over time. The zipf() skew parameter is linearly interpolated from initial_skew to final_skew using global_iter().

Globals

let total_iters = 10000
let products = 100
let initial_skew = 1.2   # nearly uniform
let final_skew = 3.0     # heavily concentrated on the first product
globals:
  total_iters: 10000
  products: 100
  initial_skew: 1.2   # nearly uniform
  final_skew: 3.0     # heavily concentrated on the first product

Expression

zipf(initial_skew + (final_skew - initial_skew) * global_iter() / total_iters, 1, products - 1)

Config

insert_view(type: exec) `INSERT INTO product_views (product_id, skew_at_time, iteration)
  VALUES ($1::INT, $2::FLOAT8, $3::INT8)` (
  zipf(initial_skew + (final_skew - initial_skew) * global_iter() / total_iters, 1, products - 1),
  initial_skew + (final_skew - initial_skew) * global_iter() / total_iters,
  global_iter()
)
- name: insert_view
  type: exec
  args:
    - zipf(initial_skew + (final_skew - initial_skew) * global_iter() / total_iters, 1, products - 1)
    - initial_skew + (final_skew - initial_skew) * global_iter() / total_iters
    - global_iter()
  query: |-
    INSERT INTO product_views (product_id, skew_at_time, iteration)
    VALUES ($1::INT, $2::FLOAT8, $3::INT8)

Verification query

SELECT
  bucket,
  count(*) AS total,
  count(*) FILTER (WHERE product_id = 0) AS top_product,
  round((count(*) FILTER (WHERE product_id = 0))::FLOAT8 / count(*)::FLOAT8 * 100, 1) AS top_pct,
  repeat('▒', (round((count(*) FILTER (WHERE product_id = 0))::FLOAT8 / count(*)::FLOAT8 * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(10) OVER (ORDER BY iteration) AS bucket
  FROM product_views
)
GROUP BY bucket
ORDER BY bucket;
  bucket | total | top_product | top_pct |                histogram
---------+-------+-------------+---------+-------------------------------------------
       1 |  2644 |        1225 |    46.3 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |  2643 |        1959 |    74.1 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |  2643 |        2440 |    92.3 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |  2643 |        2593 |    98.1 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |  2643 |        2632 |    99.6 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |  2643 |        2637 |    99.8 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |  2643 |        2641 |    99.9 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |  2643 |        2642 |     100 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |  2643 |        2643 |     100 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |  2643 |        2643 |     100 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • initial_skew / final_skew - controls how much the distribution shifts. A smaller gap produces a subtler drift; a larger gap makes it dramatic. Values near 1.0 are nearly uniform, values above 3.0 concentrate almost all traffic on the lowest-ranked items.
  • total_iters - the iteration count at which final_skew is reached. After this point the skew parameter stays at final_skew. Set this to roughly match your expected total iterations (see Estimating total iterations).

Logarithmic Growth#

Prices rise steeply at first then plateau. Models inflation, adoption curves, or any quantity with diminishing returns.

Globals

let base_price = 50.0
let products = 100
globals:
  base_price: 50.0
  products: 100

Expression

floor(base_price * (1.0 + log(1.0 + global_iter() / 1000.0)) * 100.0) / 100.0

Config

insert_price(type: exec) `INSERT INTO price_history (product_id, price, iteration)
  VALUES ($1::INT, $2::FLOAT8, $3::INT8)` (
  uniform(1, products),
  floor(base_price * (1.0 + log(1.0 + global_iter() / 1000.0)) * 100.0) / 100.0,
  global_iter()
)
- name: insert_price
  type: exec
  args:
    - uniform(1, products)
    - floor(base_price * (1.0 + log(1.0 + global_iter() / 1000.0)) * 100.0) / 100.0
    - global_iter()
  query: |-
    INSERT INTO price_history (product_id, price, iteration)
    VALUES ($1::INT, $2::FLOAT8, $3::INT8)

Verification query

SELECT
  bucket,
  round(avg(price)::NUMERIC, 2) AS avg_price,
  round(min(price)::NUMERIC, 2) AS min_price,
  round(max(price)::NUMERIC, 2) AS max_price,
  repeat('▒', (round(avg(price) / max(avg(price)) OVER () * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(10) OVER (ORDER BY iteration) AS bucket
  FROM price_history
)
GROUP BY bucket
ORDER BY bucket;
  bucket | avg_price | min_price | max_price |                histogram
---------+-----------+-----------+-----------+-------------------------------------------
       1 |    105.75 |     50.09 |    139.66 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |    156.93 |    139.67 |    170.72 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |    185.96 |    170.73 |    200.89 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |    211.99 |    200.90 |    221.88 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |    229.27 |    221.89 |    236.09 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |    242.19 |    236.10 |    247.77 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |    252.52 |    247.78 |    256.94 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |    260.88 |    256.95 |    264.72 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |    268.11 |    264.72 |    271.35 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |    274.40 |    271.35 |    277.37 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • base_price - starting price before any growth.
  • The divisor inside log(1.0 + global_iter() / 1000.0) controls how quickly the curve rises. A smaller divisor (e.g. 100.0) makes it steeper; a larger divisor (e.g. 10000.0) flattens it.
  • floor(... * 100.0) / 100.0 rounds to cents. Remove the floor/division to keep full precision.

Sine Wave Seasonality#

Request counts oscillate around a slowly growing baseline. Models daily/weekly traffic cycles, seasonal patterns, or any periodic signal riding on a trend.

Globals

let traffic_period = 50000
let traffic_amplitude = 100.0
let base_traffic = 100.0
globals:
  traffic_period: 50000
  traffic_amplitude: 100.0
  base_traffic: 100.0

Expression

floor(abs(base_traffic + 0.5 * sqrt(global_iter()) + amplitude * sin(2.0 * pi * global_iter() / period)))

Config

insert_traffic(type: exec) `INSERT INTO traffic_log (endpoint, request_count, iteration)
  VALUES ($1, $2::INT, $3::INT8)` (
  set_rand(['GET /products', 'GET /orders', 'POST /checkout', 'GET /search'], []),
  floor(abs(base_traffic + 0.5 * sqrt(global_iter()) + traffic_amplitude * sin(2.0 * pi * global_iter() / traffic_period))),
  global_iter()
)
- name: insert_traffic
  type: exec
  args:
    - set_rand(['GET /products', 'GET /orders', 'POST /checkout', 'GET /search'], [])
    - floor(abs(base_traffic + 0.5 * sqrt(global_iter()) + traffic_amplitude * sin(2.0 * pi * global_iter() / traffic_period)))
    - global_iter()
  query: |-
    INSERT INTO traffic_log (endpoint, request_count, iteration)
    VALUES ($1, $2::INT, $3::INT8)

Verification query

SELECT
  bucket,
  round(avg(request_count)::NUMERIC, 1) AS avg_requests,
  min(request_count) AS min_requests,
  max(request_count) AS max_requests,
  repeat('▒', (round(avg(request_count) / max(avg(request_count)) OVER () * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(20) OVER (ORDER BY iteration) AS bucket
  FROM traffic_log
)
GROUP BY bucket
ORDER BY bucket;
  bucket | avg_requests | min_requests | max_requests |                histogram
---------+--------------+--------------+--------------+-------------------------------------------
       1 |        149.5 |          101 |          186 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |        212.8 |          186 |          236 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |        248.8 |          236 |          257 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |        253.9 |          247 |          257 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |        232.8 |          215 |          247 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |        192.7 |          170 |          215 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |        147.7 |          127 |          170 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |        111.6 |          100 |          127 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |         96.9 |           96 |          100 | ▒▒▒▒▒▒▒▒▒▒▒▒
      10 |        107.2 |           98 |          121 | ▒▒▒▒▒▒▒▒▒▒▒▒▒
      11 |        142.9 |          121 |          167 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      12 |        199.3 |          167 |          229 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      13 |        256.6 |          229 |          282 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      14 |        301.4 |          283 |          316 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      15 |        322.7 |          316 |          325 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      16 |        315.4 |          303 |          324 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      17 |        282.0 |          258 |          302 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      18 |        233.1 |          209 |          258 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      19 |        186.0 |          167 |          209 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      20 |        154.7 |          148 |          167 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • traffic_period - iterations per full sine cycle. This is the most important parameter. If each bucket in your verification query averages over a full cycle or more, the sine wave averages to zero and you only see the baseline trend. See Matching period to your run.
  • traffic_amplitude - peak deviation from the baseline. Must be large enough relative to the baseline to be visible. If base_traffic is 100 and amplitude is 10, the wave is only a 10% wobble.
  • 0.5 * sqrt(global_iter()) - the growth coefficient on the baseline trend. A larger coefficient makes the upward trend dominate the wave; a smaller one lets the oscillation stand out.
  • abs(...) - prevents negative request counts when the trough of the sine wave dips below zero.

Periodic Spikes#

Severity surges at regular intervals then drops to near zero. Models periodic maintenance windows, batch job impacts, or recurring failure modes.

Expression

pow(cos(pi * mod(global_iter(), interval) / interval), 2.0) * 10.0

cos² produces a smooth pulse: maximum (10.0) when mod(iter, interval) = 0, minimum (0.0) at the midpoint of each interval.

Globals

let spike_interval = 25000
globals:
  spike_interval: 25000

Config

insert_error(type: exec) `INSERT INTO error_events (error_code, severity, iteration)
  VALUES ($1::INT, $2::FLOAT8, $3::INT8)` (
  uniform(500, 599),
  pow(cos(pi * mod(global_iter(), spike_interval) / spike_interval), 2.0) * 10.0,
  global_iter()
)
- name: insert_error
  type: exec
  args:
    - uniform(500, 599)
    - pow(cos(pi * mod(global_iter(), spike_interval) / spike_interval), 2.0) * 10.0
    - global_iter()
  query: |-
    INSERT INTO error_events (error_code, severity, iteration)
    VALUES ($1::INT, $2::FLOAT8, $3::INT8)

Verification query

SELECT
  bucket,
  round(avg(severity)::NUMERIC, 2) AS avg_severity,
  round(max(severity)::NUMERIC, 2) AS max_severity,
  repeat('▒', (round(avg(severity) / max(avg(severity)) OVER () * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(20) OVER (ORDER BY iteration) AS bucket
  FROM error_events
)
GROUP BY bucket
ORDER BY bucket;
  bucket | avg_severity | max_severity |                histogram
---------+--------------+--------------+-------------------------------------------
       1 |         8.06 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |         1.43 |         4.67 | ▒▒▒▒▒▒
       3 |         2.85 |         6.56 | ▒▒▒▒▒▒▒▒▒▒▒▒
       4 |         9.05 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |         6.34 |         9.58 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |         0.59 |         2.20 | ▒▒▒
       7 |         4.52 |         8.16 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |         9.38 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |         4.34 |         8.12 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |         0.52 |         1.97 | ▒▒
      11 |         5.91 |         9.36 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      12 |         9.21 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      13 |         3.37 |         7.09 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      14 |         1.09 |         3.91 | ▒▒▒▒▒
      15 |         7.41 |         9.92 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      16 |         8.50 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      17 |         2.03 |         5.37 | ▒▒▒▒▒▒▒▒▒
      18 |         1.92 |         5.11 | ▒▒▒▒▒▒▒▒
      19 |         8.37 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      20 |         7.42 |         9.96 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • spike_interval - iterations between spike peaks. Same rule as traffic_period: if the interval is shorter than the iteration width of a single bucket, each bucket averages over multiple full cycles and flattens to ~5.0. See Matching period to your run.
  • The * 10.0 multiplier at the end sets the peak severity. Change it to scale the spike magnitude.

Bounded Drift (Arctangent Saturation)#

Sensor readings drift from a true value and asymptotically approach a maximum offset. Models calibration drift, battery degradation, or any quantity that grows quickly then saturates.

Expression (upward drift)

100.0 + (2.0 * atan(sqrt(global_iter()) / 100.0) / pi) * 15.0 + noise

Expression (downward drift)

100.0 - (2.0 * atan(sqrt(global_iter()) / 100.0) / pi) * 15.0 + noise

2 * atan(x) / pi maps any positive input to the range (0, 1). Multiplying by 15 bounds the drift to 0-15 units. The sqrt inside atan gives fast initial movement that slows as it approaches the asymptote. Flip + to - to model decay (e.g. battery degradation) instead of growth.

Config

insert_sensor(type: exec) `INSERT INTO sensor_calibration (sensor_id, reading, drift_offset, iteration)
  VALUES ($1::INT, $2::FLOAT8, $3::FLOAT8, $4::INT8)` (
  uniform(1, 50),
  100.0 + (2.0 * atan(sqrt(global_iter()) / 100.0) / pi) * 15.0 + norm_f(0, 0.5, -2, 2, 2),
  2.0 * atan(sqrt(global_iter()) / 100.0) / pi * 15.0,
  global_iter()
)
- name: insert_sensor
  type: exec
  args:
    - uniform(1, 50)
    - 100.0 + (2.0 * atan(sqrt(global_iter()) / 100.0) / pi) * 15.0 + norm_f(0, 0.5, -2, 2, 2)
    - 2.0 * atan(sqrt(global_iter()) / 100.0) / pi * 15.0
    - global_iter()
  query: |-
    INSERT INTO sensor_calibration (sensor_id, reading, drift_offset, iteration)
    VALUES ($1::INT, $2::FLOAT8, $3::FLOAT8, $4::INT8)

Verification query

SELECT
  bucket,
  round(avg(drift_offset)::NUMERIC, 3) AS avg_drift,
  round(avg(reading)::NUMERIC, 2) AS avg_reading,
  repeat('▒', (round(avg(drift_offset) / max(avg(drift_offset)) OVER () * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(10) OVER (ORDER BY iteration) AS bucket
  FROM sensor_calibration
)
GROUP BY bucket
ORDER BY bucket;

Upward drift - readings climb from 100 toward ~115:

  bucket | avg_drift | avg_reading |                histogram
---------+-----------+-------------+-------------------------------------------
       1 |     6.068 |      106.06 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |     9.114 |      109.12 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |    10.209 |      110.22 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |    10.853 |      110.86 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |    11.292 |      111.30 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |    11.621 |      111.62 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |    11.866 |      111.87 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |    12.068 |      112.05 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |    12.235 |      112.21 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |    12.376 |      112.37 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Downward drift - readings decay from 100 toward ~85:

  bucket | avg_drift | avg_reading |                histogram
---------+-----------+-------------+-------------------------------------------
       1 |   -12.376 |       87.63 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |   -12.235 |       87.79 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |   -12.068 |       87.95 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |   -11.866 |       88.13 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |   -11.621 |       88.38 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |   -11.292 |       88.70 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |   -10.853 |       89.14 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |   -10.209 |       89.78 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |    -9.114 |       90.88 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |    -6.068 |       93.94 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • The * 15.0 multiplier sets the maximum drift (asymptote). Change it to control how far the reading can wander.
  • The / 100.0 inside atan(sqrt(iter) / 100.0) controls how quickly saturation is reached. A smaller divisor reaches the asymptote sooner.
  • norm_f(0, 0.5, -2, 2, 2) adds Gaussian noise to individual readings. Increase the stddev (second arg) for noisier data.

Estimating Total Iterations#

The actual number of global_iter() increments depends on workers, duration, query complexity, and hardware. This matters because periodic parameters (traffic_period, spike_interval) must be tuned relative to the actual iteration count - not the configured total_iters global.

Estimate total iterations from three values:

total_iterations = workers * duration_seconds / avg_latency_seconds
VariableMeaning
workersNumber of concurrent workers (-w flag)
duration_secondsRun duration in seconds (-d flag)
avg_latency_secondsAverage time for one iteration (query execution + overhead)

Example: 10 workers, 60s duration, 5ms average latency:

10 * 60 / 0.005 = 120,000 iterations

This assumes each worker spends all its time executing queries. In practice, connection overhead and GC pauses reduce throughput slightly, so treat the result as an upper bound.

Matching Period to Your Run#

The verification queries use ntile(N) to divide rows into N equal-sized buckets by iteration order. For a periodic pattern to be visible in the histogram, each bucket must cover less than one full cycle. If a bucket spans one or more complete cycles, the periodic component averages out and disappears.

The rule: each bucket’s iteration width should be less than the period.

bucket_width = total_iterations / number_of_buckets

For the pattern to be clearly visible:

period > bucket_width

For 2 visible cycles across all buckets (a good default):

period = total_iterations / 2

Example: with ~100,000 actual iterations and 20 buckets:

  • Each bucket spans ~5,000 iterations
  • traffic_period: 50000 -> 2 full cycles, 10 buckets per cycle - clear wave
  • traffic_period: 5000 -> 20 full cycles, 1 cycle per bucket - wave averages out, histogram looks monotonic
  • traffic_period: 500 -> 200 cycles per bucket - completely flat

The same logic applies to spike_interval. If your histogram looks flat when you expect oscillation, increase the period.

Combining Patterns#

All five patterns run concurrently using run_weights to control the mix:

weights {
  insert_view = 25
  insert_price = 20
  insert_traffic = 25
  insert_error = 15
  insert_sensor = 15
}
run_weights:
  insert_view: 25
  insert_price: 20
  insert_traffic: 25
  insert_error: 15
  insert_sensor: 15

Adjust weights to emphasise specific patterns or to control relative table sizes. The weights don’t affect the drift expressions themselves - global_iter() increments globally regardless of which statement executes.


Correlated Multi-Table Signals PRO#

The patterns above work per-expression. Signals let you declare a shared time-varying pattern once and have multiple tables track it with lag and correlation.

Defining Signals#

Signals pre-compute an array of values at init time. Define them in the signals section using either a duration range or an explicit length.

Duration-based (computes length automatically):

signal traffic(from: '2024-01-01T00:00:00Z', to: '2024-01-08T00:00:00Z', interval: '5m') {
  1000 + 400 * sin(2 * pi * i / 288)
}
signals:
  traffic:
    from: '2024-01-01T00:00:00Z'
    to: '2024-01-08T00:00:00Z'
    interval: 5m
    expr: 1000 + 400 * sin(2 * pi * i / 288)

Length-based (explicit number of data points):

signal promo_boost(length: 2016) {
  floor(50 * pow(cos(pi * mod(i, 500) / 500), 20))
}
signals:
  promo_boost:
    length: 2016
    expr: floor(50 * pow(cos(pi * mod(i, 500) / 500), 20))

The variable i is the index into the signal buffer (0 to length-1). All built-in math functions and globals are available in signal expressions.

Consuming Signals#

Three functions read signal values:

FunctionDescription
signal('name')Value at the current iter(), wrapping around
signal_at('name', index)Value at a specific index
signal_correlated('name', lag, correlation)Lagged value with correlated noise

Correlated Signals#

signal_correlated is the key function for multi-table patterns. It takes three arguments:

  • name - the signal to correlate with
  • lag - how far behind to look (integer for iterations, or a duration string like '2h' if the signal was defined with from/to/interval)
  • correlation - a value from -1 to 1 controlling how closely the output tracks the signal

The correlation math:

result = r × S[iter - lag] + (1 - |r|) × mean(S) + √(1 - r²) × stddev(S) × noise
CorrelationBehaviour
1.0Exact copy of the lagged signal
0.7Follows the shape with moderate noise
0.3Weak tracking, mostly noise around the mean
0.0Random noise centred on the signal mean
-1.0Inverted copy of the lagged signal

Example: Cascading Ecommerce Effects#

Simulates a week of correlated observability data in a 5-minute run. As traffic ramps up, orders follow with a 15-minute lag, support tickets follow with a 2-hour lag, and server errors spike after promotional events. Each table reads from the signal buffers using signal_at() with global_iter() as the index, and timestamps are computed SQL-side using modular arithmetic to wrap at the week boundary.

signal traffic(from: '2024-01-01T00:00:00Z', to: '2024-01-08T00:00:00Z', interval: '5m') {
  1000 + 400 * sin(2 * 3.14159265 * i / 288) + 200 * sin(2 * 3.14159265 * i / 2016)
}

signal promo_boost(length: 2016) {
  floor(50 * pow(cos(3.14159265 * mod(i, 500) / 500), 20))
}

run {
  insert_page_views(type: exec)
    `INSERT INTO page_views (ts, view_count, page)
     VALUES (
       '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
       $2::INT,
       $3
     )` (
      global_iter(),
      floor(signal_at('traffic', int(global_iter())) + signal_at('promo_boost', int(global_iter()))),
      set_rand(['home','product','cart','checkout'], [40,30,20,10])
    )

  insert_orders(type: exec)
    `INSERT INTO orders (ts, order_count, amount)
     VALUES (
       '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
       $2::INT,
       $3::DECIMAL
     )` (
      global_iter(),
      floor(abs(signal_at('traffic', int(global_iter()) - 3) * 0.7 + norm(0, 300, -500, 500) * 0.3)),
      uniform_f(10, 500, 2)
    )

  insert_tickets(type: exec)
    `INSERT INTO support_tickets (ts, ticket_count, category)
     VALUES (
       '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
       $2::INT,
       $3
     )` (
      global_iter(),
      floor(abs(signal_at('traffic', int(global_iter()) - 24) * 0.3 + norm(0, 300, -500, 500) * 0.7)),
      set_rand(['billing','shipping','refund','other'], [30,35,20,15])
    )

  insert_errors(type: exec)
    `INSERT INTO server_errors (ts, error_count, error_type)
     VALUES (
       '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
       $2::INT,
       $3
     )` (
      global_iter(),
      floor(abs(signal_at('promo_boost', int(global_iter()) - 6) * 0.5 + norm(0, 10, -25, 25) * 0.5)),
      set_rand(['timeout','500','oom','connection_reset'], [35,30,20,15])
    )
}

weights {
  insert_page_views = 25
  insert_orders = 25
  insert_tickets = 25
  insert_errors = 25
}
signals:
  traffic:
    from: '2024-01-01T00:00:00Z'
    to: '2024-01-08T00:00:00Z'
    interval: 5m
    expr: 1000 + 400 * sin(2 * 3.14159265 * i / 288) + 200 * sin(2 * 3.14159265 * i / 2016)

  promo_boost:
    length: 2016
    expr: floor(50 * pow(cos(3.14159265 * mod(i, 500) / 500), 20))

run:
  - name: insert_page_views
    type: exec
    args:
      - global_iter()
      - floor(signal_at('traffic', int(global_iter())) + signal_at('promo_boost', int(global_iter())))
      - set_rand(['home','product','cart','checkout'], [40,30,20,10])
    query: |-
      INSERT INTO page_views (ts, view_count, page)
      VALUES (
        '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
        $2::INT,
        $3
      )

  - name: insert_orders
    type: exec
    args:
      - global_iter()
      - floor(abs(signal_at('traffic', int(global_iter()) - 3) * 0.7 + norm(0, 300, -500, 500) * 0.3))
      - uniform_f(10, 500, 2)
    query: |-
      INSERT INTO orders (ts, order_count, amount)
      VALUES (
        '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
        $2::INT,
        $3::DECIMAL
      )

  - name: insert_tickets
    type: exec
    args:
      - global_iter()
      - floor(abs(signal_at('traffic', int(global_iter()) - 24) * 0.3 + norm(0, 300, -500, 500) * 0.7))
      - set_rand(['billing','shipping','refund','other'], [30,35,20,15])
    query: |-
      INSERT INTO support_tickets (ts, ticket_count, category)
      VALUES (
        '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
        $2::INT,
        $3
      )

  - name: insert_errors
    type: exec
    args:
      - global_iter()
      - floor(abs(signal_at('promo_boost', int(global_iter()) - 6) * 0.5 + norm(0, 10, -25, 25) * 0.5))
      - set_rand(['timeout','500','oom','connection_reset'], [35,30,20,15])
    query: |-
      INSERT INTO server_errors (ts, error_count, error_type)
      VALUES (
        '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
        $2::INT,
        $3
      )

run_weights:
  insert_page_views: 25
  insert_orders: 25
  insert_tickets: 25
  insert_errors: 25

The full config is at examples/correlated_signals/crdb.yaml. Run with -w 1 -d 5m for a single-worker 5-minute simulation covering one week of data.

Verification queries

Daily traffic pattern - view counts should show a sine wave across 24-hour buckets:

SELECT
  extract(hour FROM ts) AS hour,
  round(avg(view_count)::NUMERIC, 0) AS avg_views,
  repeat('▒', (round(avg(view_count) / max(avg(view_count)) OVER () * 40))::INT) AS histogram
FROM page_views
GROUP BY hour
ORDER BY hour;
  hour | avg_views |                histogram
-------+-----------+-------------------------------------------
     0 |      1053 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     1 |      1159 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     2 |      1250 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     3 |      1321 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     4 |      1374 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     5 |      1408 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     6 |      1407 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     7 |      1381 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     8 |      1325 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     9 |      1255 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    10 |      1166 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    11 |      1064 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    12 |       959 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    13 |       861 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    14 |       768 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    15 |       692 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    16 |       642 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    17 |       612 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    18 |       614 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    19 |       641 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    20 |       691 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    21 |       759 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    22 |       854 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    23 |       951 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Order-traffic correlation - orders should loosely follow page views with a lag:

SELECT
  date_trunc('hour', v.ts) AS hour,
  repeat('▒', (round(avg(v.view_count) / max(avg(v.view_count)) OVER () * 20))::INT) AS views,
  repeat('▒', (round(avg(o.order_count) / max(avg(o.order_count)) OVER () * 20))::INT) AS orders
FROM page_views v
JOIN orders o ON date_trunc('hour', v.ts) = date_trunc('hour', o.ts)
GROUP BY date_trunc('hour', v.ts)
ORDER BY hour
LIMIT 24;
           hour          |       views        |       orders
-------------------------+--------------------+---------------------
  2024-01-01 00:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒     | ▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 01:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒    | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 02:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 03:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 04:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 05:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 06:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 07:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 08:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 09:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 10:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 11:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒     | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 12:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 13:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒       | ▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 14:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒        | ▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 15:00:00+00 | ▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 16:00:00+00 | ▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 17:00:00+00 | ▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒
  2024-01-01 18:00:00+00 | ▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒
  2024-01-01 19:00:00+00 | ▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 20:00:00+00 | ▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 21:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒        | ▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 22:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 23:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒     | ▒▒▒▒▒▒▒▒▒▒▒▒▒

Promo boost spikes - errors should spike shortly after promotional periods:

SELECT
  date_trunc('hour', ts) AS hour,
  sum(error_count) AS total_errors,
  repeat('▒', greatest(1, (round(sum(error_count)::FLOAT8 / max(sum(error_count)::FLOAT8) OVER () * 40))::INT)) AS histogram
FROM server_errors
GROUP BY hour
ORDER BY hour
LIMIT 24;
           hour          | total_errors |               histogram
-------------------------+--------------+-----------------------------------------
  2024-01-01 00:00:00+00 |        10656 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 01:00:00+00 |         9844 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 02:00:00+00 |         8972 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 03:00:00+00 |         6428 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 04:00:00+00 |         4179 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 05:00:00+00 |         2635 | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 06:00:00+00 |         1961 | ▒▒▒▒▒▒▒
  2024-01-01 07:00:00+00 |         1534 | ▒▒▒▒▒▒
  2024-01-01 08:00:00+00 |         1540 | ▒▒▒▒▒▒
  2024-01-01 09:00:00+00 |         1428 | ▒▒▒▒▒
  2024-01-01 10:00:00+00 |         1652 | ▒▒▒▒▒▒
  2024-01-01 11:00:00+00 |         1553 | ▒▒▒▒▒▒
  2024-01-01 12:00:00+00 |         1706 | ▒▒▒▒▒▒
  2024-01-01 13:00:00+00 |         1490 | ▒▒▒▒▒
  2024-01-01 14:00:00+00 |         1522 | ▒▒▒▒▒
  2024-01-01 15:00:00+00 |         1534 | ▒▒▒▒▒▒
  2024-01-01 16:00:00+00 |         1581 | ▒▒▒▒▒▒
  2024-01-01 17:00:00+00 |         1500 | ▒▒▒▒▒
  2024-01-01 18:00:00+00 |         1628 | ▒▒▒▒▒▒
  2024-01-01 19:00:00+00 |         1635 | ▒▒▒▒▒▒
  2024-01-01 20:00:00+00 |         1724 | ▒▒▒▒▒▒
  2024-01-01 21:00:00+00 |         1770 | ▒▒▒▒▒▒
  2024-01-01 22:00:00+00 |         1369 | ▒▒▒▒▒
  2024-01-01 23:00:00+00 |         1530 | ▒▒▒▒▒▒

Ticket lag - support tickets should trail traffic peaks by ~2 hours:

SELECT
  extract(hour FROM v.ts)::INT AS hour,
  repeat('▒', (round(avg(v.view_count) / max(avg(v.view_count)) OVER () * 20))::INT) AS traffic,
  repeat('▒', greatest(1, (round(avg(t.ticket_count) / max(avg(t.ticket_count)) OVER () * 20))::INT)) AS tickets
FROM page_views v
JOIN support_tickets t ON date_trunc('hour', v.ts) = date_trunc('hour', t.ts)
GROUP BY extract(hour FROM v.ts)
ORDER BY hour
LIMIT 24;
  hour |       traffic        |       tickets
-------+----------------------+-----------------------
     0 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒▒▒▒▒▒▒▒▒▒▒▒
     1 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒     | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     2 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     3 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     4 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     5 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     6 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     7 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     8 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     9 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    10 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒    | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    11 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    12 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒       | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    13 | ▒▒▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    14 | ▒▒▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    15 | ▒▒▒▒▒▒▒▒▒▒           | ▒▒▒▒▒▒▒▒▒▒▒▒
    16 | ▒▒▒▒▒▒▒▒▒            | ▒▒▒▒▒▒▒▒▒▒▒
    17 | ▒▒▒▒▒▒▒▒▒            | ▒▒▒▒▒▒▒▒▒▒▒
    18 | ▒▒▒▒▒▒▒▒▒            | ▒▒▒▒▒▒▒▒▒▒
    19 | ▒▒▒▒▒▒▒▒▒            | ▒▒▒▒▒▒▒▒▒▒
    20 | ▒▒▒▒▒▒▒▒▒▒           | ▒▒▒▒▒▒▒▒▒▒
    21 | ▒▒▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒▒
    22 | ▒▒▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒▒
    23 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒       | ▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • Signal length should match or exceed the number of iterations in your run. Values wrap around when global_iter() exceeds the signal length, repeating the pattern.
  • Lag is in iterations. If your signal has 5-minute intervals: 15 min = 3 iterations, 2 hours = 24 iterations.
  • Correlation is controlled by the * corr and * (1 - corr) weights on the signal and noise terms. Use norm() for Gaussian noise scaled to the signal’s range.
  • Wrap correlated outputs in floor(abs(...)) to avoid negative or fractional counts.
  • int(global_iter()) is required when passing to signal_at() because global_iter() returns int64 and signal_at() expects int.