Skip to Content
DocsHow it worksResidues and state

Residues and state

The central engineering idea in Tiaude is residue-based state compression.

The SDK does not return a user’s full event history.

Instead, it returns a compact state that contains the minimum information needed to continue scoring later.

That compact state is what makes track() possible.

State is not the source of truth

Before looking at residues, it is important to be clear about one thing:

Tiaude state is a continuation object, not your event database.

Your application should still store raw product events if you want a reliable production integration.

The raw event history is needed when you want to:

  • rebuild after a config change;
  • recover from out-of-order events;
  • fix corrupted state;
  • audit or explain a historical score;
  • migrate to a new version;
  • add new signals and apply them to past behavior.

The state exists to make the normal runtime path cheaper, especially at scale.

It lets your backend avoid loading and replaying all events on every request.

What a residue is

A residue is the compressed memory of one signal.

It is the smallest piece of information that preserves the future behavior of that signal under Tiaude’s update rules.

Different signal families need different residues.

Latest-event residues

Signals of type:

  • occurrence
  • absence

only need the latest matching timestamp.

Their residue is:

{ kind: "latest", lastMatchedAt }

Why this is enough:

  • occurrence cares about the age of the latest matching event;
  • absence cares about the age since the latest matching event.

Once a newer matching event exists, older matching events no longer affect that signal.

So a long history like this:

app.opened at day 1 app.opened at day 4 app.opened at day 12 app.opened at day 20

collapses to:

lastMatchedAt = day 20

That is exact for latest-event signals.

Frequency residues

Signals of type:

  • frequency_above
  • frequency_below

need a different kind of memory.

They care about repeated usage, so the latest timestamp alone is not enough.

Their residue is:

{ kind: "frequency", decayedCount }

decayedCount is a soft count of recent matching events.

It is not the total number of events ever seen but the remaining mass of past usage after time decay.

How frequency residue evolves

When time passes, the residue ages:

decayedCount = decayedCount * decay(deltaDays, halfLifeDays)

When a matching event arrives:

decayedCount = decayedCount + 1

Combined:

newDecayedCount = oldDecayedCount * decay(deltaDays, halfLifeDays) + 1

It lets Tiaude preserve the future behavior of many historical events with one number.

Why this works mathematically

The decay function has the property Tiaude needs:

decay(age + delta) = decay(age) * decay(delta)

That means a whole past history can be aged forward by multiplying its current residue by the decay over the elapsed time.

The SDK does not need to know each old event individually but only needs the aggregate residue, and that is why frequency state can remain compact.

What the full state contains

A persisted state contains global fields:

  • version
  • stateCompatibilityHash
  • userId
  • computedAt
  • lastEventAt
  • lastRelevantEventAt
  • eventCount
  • relevantEventCount
  • riskScore
  • riskLevel
  • confidence
  • freshness

and one SignalState per configured signal.

Each signal state stores:

  • whether the signal is active;
  • its current intensity;
  • its current impact;
  • how many matching events it has seen;
  • when it last matched;
  • when it was last updated;
  • its residue.

Conceptually:

state = { global scoring metadata, signal memories by signal id }

Why lastEventAt matters

lastEventAt is the timestamp of the latest event processed by the state.

It protects the incremental path.

If track() receives an event older than lastEventAt, the SDK rejects it because a compact state cannot insert an event into the past.

To do that correctly, the scorer would need to replay history in order.

That is exactly what scoreUser() is for.

Why computedAt matters

computedAt is the time at which the state was last calculated or refreshed.

It prevents time travel.

If a state has already been advanced to May 10, the SDK must not allow a later incremental call to reuse that state at May 5.

That would ask the compact state to go backward in time.

Tiaude rejects that.

The rule is:

time can move forward incrementally time cannot move backward from previousState

For historical debugging at a past now, use scoreUser() from raw events.

Why lastRelevantEventAt matters

lastRelevantEventAt is the latest event timestamp that matched at least one configured signal.

Freshness is based on this field.

This avoids a misleading result where calling refreshScore() today makes the data look fresh even though no relevant user activity happened recently.

refreshScore() updates computedAt refreshScore() does not automatically update lastRelevantEventAt

So freshness remains tied to user evidence, not to computation time.

Why relevantEventCount matters

relevantEventCount counts events that matched at least one signal.

If one event matches multiple signals, it still counts once.

This prevents confidence from increasing artificially just because more signals listen to the same event name.

Confidence is meant to measure the amount of observed relevant behavior, not the number of configured rules.

State compatibility

Each state includes a compatibility hash derived from the normalized scoring configuration.

The hash protects incremental reuse.

If the scoring model changed in a way that makes the old state unsafe, the SDK refuses to reuse it and throws ConfigMismatchError.

Typical incompatible changes include:

  • changing weights;
  • changing thresholds;
  • changing half-lives;
  • adding or removing signals;
  • changing signal event names;
  • changing score level settings;
  • changing confidence or freshness settings.

Changing only reason or action does not break compatibility because it changes the explanation text, not the scoring math.

Why adding a signal is treated as incompatible

Adding a signal may look harmless, but the old state has no residue for that signal.

The SDK could initialize the new signal at zero, but that would mean:

the new signal only starts counting from now

It would not reflect historical events that may already exist in your database.

Tiaude chooses the stricter rule:

new signal -> full recompute recommended

That keeps the compact state faithful to the configured model.

If you want the new signal to account for past behavior, load raw events and call scoreUser().

The recovery pattern

A robust backend integration usually looks like this:

try track(previousState, event) if success -> persist new state if incompatible/corrupt/out-of-order -> load raw events -> scoreUser()

The same applies to refreshScore().

This makes the architecture explicit:

  • track() is the fast path;
  • refreshScore() is the time-only fast path;
  • scoreUser() is the source-of-truth recompute path.

The practical payoff

The state is compact because each signal stores only the residue it needs.

The state is safe because it carries compatibility and timeline metadata.

The state is useful because it lets backend systems continue scoring without repeatedly loading historical events.

That is the core design:

raw events remain the source of truth state makes the runtime path efficient