Skip to Content
DocsHow it worksIncremental scaling

Incremental scaling

Tiaude is designed for backend systems that receive product events continuously.

The common runtime problem is simple:

A new event arrived. What is this user's churn risk now?

The naive answer is to recompute from scratch every time.

Tiaude’s answer is to continue from a compact previous state.

The incremental runtime path

With Tiaude, the hot path can look like this:

load previousState process one event persist next state

That is what track() does.

nextState = track(previousState, event, now)

Instead of replaying history, the SDK advances the residues already stored in the state.

The cost becomes closer to:

runtime cost ~= number of configured signals

not:

runtime cost ~= number of historical events

previousState is a continuation object

previousState contains the information needed to keep the mathematical process going:

  • one residue per signal;
  • current intensity and impact per signal;
  • event counters;
  • latest event timestamps;
  • freshness and confidence inputs;
  • compatibility metadata;
  • timeline metadata.

That means track() can update the state without asking the database for every historical event.

What track() does

At a high level, track() performs this sequence:

  1. validate the event;
  2. validate the previous state if present;
  3. reject incompatible config or user mismatch;
  4. reject out-of-order events;
  5. update event counters and timestamps;
  6. age frequency residues to the event time;
  7. apply the new event to matching signals;
  8. age frequency residues to now;
  9. recalculate every signal intensity and impact;
  10. rebuild the aggregate score.

The key detail is step 9:

Tiaude recalculates all impacts after updating residues.

That makes the model safer because every signal may change with time, even if the current event only matched one signal.

Why event indexing matters

At scorer creation time, Tiaude builds an internal index:

event name -> signal ids

Example:

feature.used -> low_usage, strong_usage app.opened -> inactive

When an event arrives, the SDK can immediately know which signal residues receive the event.

This keeps event ingestion direct:

event.name -> matching signals -> update residues

The SDK still recalculates all signal impacts afterward, but it does not need to rediscover event-to-signal relationships on every call.

Why refreshScore() exists

Some risk changes with time even when no new event arrives.

For example:

  • inactivity risk may increase;
  • occurrence risk may fade;
  • frequency usage may decay;
  • freshness may become stale.

refreshScore() advances a state to a later now without adding an event.

nextState = refreshScore(previousState, now)

It is the time-only version of incremental continuation.

It preserves:

  • event count;
  • relevant event count;
  • last event timestamp;
  • last relevant event timestamp.

It updates:

  • frequency residues;
  • intensities;
  • impacts;
  • score;
  • risk level;
  • freshness;
  • computedAt.

Why scoreUser() still matters

Incremental scoring is only safe because there is a canonical full recompute path.

scoreUser() is that path.

It:

  • accepts raw historical events;
  • sorts them by timestamp;
  • rebuilds state from zero;
  • applies the same signal model;
  • produces the same result shape as track().

This is the function you use when the compact state cannot be trusted or reused.

Typical cases:

  • config changed incompatibly;
  • a signal was added, removed, or changed;
  • events arrived out of order;
  • historical events were edited or deleted;
  • the persisted state is missing or corrupted;
  • you are migrating SDK versions;
  • you want to audit or debug a score.

For normal event ingestion, use track() as the fast path.

If it fails because incremental reuse is unsafe, fall back to scoreUser().

try { const result = scorer.track({ userId, previousState: user.churnState, event, }); await saveChurnState(userId, result.state); } catch (error) { if ( error instanceof ConfigMismatchError || error instanceof OutOfOrderEventError || error instanceof StateValidationError ) { const events = await loadUserEvents(userId); const result = scorer.scoreUser({ userId, events, }); await saveChurnState(userId, result.state); return; } throw error; }

The same idea applies to refreshScore():

try refreshScore(previousState) catch unsafe state -> load events -> scoreUser()

This is the intended production convention.

Why raw events should still be stored

The compact state is designed for efficient continuation.

It is not designed to replace event storage.

If you do not store raw events, you lose the ability to:

  • recompute after config changes;
  • fix out-of-order delivery;
  • apply new signals historically;
  • audit the score;
  • recover from corrupted state.

So the operational model should be:

store raw events for correctness store Tiaude state for efficiency

Why time cannot go backward

A compact state can move only forward.

If a state was computed at May 10, the SDK rejects attempts to reuse it at May 5.

That is because the residues have already been advanced.

The SDK cannot “un-age” a frequency residue without raw history.

For historical debugging, use:

scoreUser(events, now = past date)

not:

refreshScore(previousState computed in the future, now = past date)

This keeps incremental state mathematically coherent.

Why compatibility is strict

Incremental state is only valid under the model that produced it.

If the model changes, the residues may no longer mean the same thing.

For example:

  • a new threshold changes frequency intensity;
  • a new half-life changes decay;
  • a new event name changes matching;
  • a new signal requires a residue that old states do not have.

That is why Tiaude stores a compatibility hash.

A state is reusable only when its scoring configuration is compatible with the current scorer.

Otherwise, the SDK asks you to recompute from raw events.