tiaude
v0.0.1
Published
A TypeScript SDK that turns product events into explainable churn risk scores.
Maintainers
Readme
Tiaude
A TypeScript SDK that turns product events into explainable churn risk scores.
It is deterministic, configurable and stateless. It is not a machine learning model, does not store events, does not call a backend and does not collect data.
Install
npm install tiaudepnpm add tiaudeQuick start
import { createChurnScorer } from "tiaude";
const scorer = createChurnScorer({
baseRisk: 20,
levels: {
medium: 30,
high: 60,
critical: 80,
},
confidence: {
targetRelevantEvents: 10,
},
freshness: {
freshAfterHours: 24,
staleAfterHours: 72,
},
signals: [
{
id: "inactive",
type: "absence",
event: "app.opened",
weight: 30,
halfLifeDays: 7,
reason: "No recent activity",
action: "Send a re-engagement message",
},
{
id: "low_usage",
type: "frequency_below",
event: "feature.used",
weight: 25,
halfLifeDays: 7,
threshold: 3,
mode: "linear",
reason: "Low recent product usage",
action: "Suggest a relevant feature or workflow",
},
{
id: "billing_failed",
type: "occurrence",
event: "billing.payment_failed",
weight: 40,
halfLifeDays: 3,
reason: "Recent billing failure",
action: "Ask the user to update their payment method",
},
],
});
const result = scorer.track({
userId: "user_123",
previousState: user.churnState,
event: {
name: "feature.used",
timestamp: new Date(),
},
});
await db.user.update({
where: { id: "user_123" },
data: {
churnState: result.state,
churnRiskScore: result.riskScore,
churnRiskLevel: result.riskLevel,
},
});Save result.state and pass it back on the next call. The SDK does not persist anything itself.
API
createChurnScorer(config)
Creates a scorer from your configuration.
const scorer = createChurnScorer(config);The scorer exposes three methods:
scorer.track(input);
scorer.scoreUser(input);
scorer.refreshScore(input);track()
Use track() when a new product event arrives.
const result = scorer.track({
userId,
previousState,
event: {
name: "feature.used",
timestamp: new Date(),
},
});track() incrementally updates the previous state and returns a new score.
Rules:
previousStatecan benullorundefinedfor the first event.- Events must be sent in chronological order.
- If
nowis provided, it must be greater than or equal toevent.timestamp. - If the previous state belongs to another user, the SDK rejects it.
- If the previous state was produced with an incompatible config, the SDK rejects it.
scoreUser()
Use scoreUser() to rebuild a score from historical events.
const result = scorer.scoreUser({
userId,
events,
});scoreUser() sorts events by timestamp and rebuilds the state from zero.
Use it for:
- first-time bootstrap;
- full recompute;
- recovery after out-of-order events;
- recovery after config changes;
- debugging or tests.
If now is provided, it must be greater than or equal to the latest event timestamp.
refreshScore()
Use refreshScore() when time passes but no new event arrives.
const result = scorer.refreshScore({
userId,
previousState,
});refreshScore() recalculates time-sensitive signals without consuming a new event.
It updates the score, but it does not make old data fresh. Freshness is based on the latest relevant event, not only on the refresh time.
Result
type ChurnResult = {
userId?: string;
riskScore: number;
riskLevel: "low" | "medium" | "high" | "critical";
rawScore: number;
confidence: number;
freshness: "fresh" | "aging" | "stale";
reasons: ChurnReason[];
recommendedActions: string[];
state: ChurnScoreState;
};Typical fields to persist:
result.state;
result.riskScore;
result.riskLevel;Typical fields to show in your product:
result.riskScore;
result.riskLevel;
result.reasons;
result.recommendedActions;
result.confidence;
result.freshness;Events
type ChurnEvent = {
name: string;
timestamp: string | Date;
properties?: Record<string, unknown>;
};Event names are matched against your configured signals.
Timestamps can be passed as Date objects or supported ISO strings.
Recommended:
"2026-05-06T12:00:00.000Z";Ambiguous or impossible dates are rejected:
"05/06/2026"; // invalid
"2026-02-31T00:00:00.000Z"; // invalidStore your raw events
track() and refreshScore() are incremental optimizations. They let your app avoid loading and recomputing a user's full event history on every request.
They are not a replacement for storing raw events.
For production usage, your application should store product events in your own database. This allows you to run a full recompute with scoreUser() when needed.
You should use scoreUser() when:
- the config changed in an incompatible way;
- a signal was added, removed or changed;
- events were received out of order;
- historical events were edited or deleted;
- a persisted state is missing, invalid or corrupted;
- you are migrating SDK versions;
- you want to debug or audit a score.
Recommended production pattern:
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;
}Config
type ChurnScorerConfig = {
baseRisk: number;
levels?: {
medium: number;
high: number;
critical: number;
};
confidence?: {
targetRelevantEvents: number;
};
freshness?: {
freshAfterHours: number;
staleAfterHours: number;
};
signals: ChurnSignal[];
};type ChurnSignal = {
id: string;
type: "occurrence" | "absence" | "frequency_above" | "frequency_below";
event: string;
weight: number;
halfLifeDays: number;
threshold?: number;
mode?: "linear" | "binary";
reason: string;
action?: string;
};Signal types:
occurrence: risk based on a recent event;absence: risk based on a missing or old event;frequency_above: intensity increases when repeated usage is above a threshold;frequency_below: intensity increases when repeated usage is below a threshold.
How scoring works
Each signal produces an intensity between 0 and 1.
impact = weight * intensity
rawScore = baseRisk + sum(impacts)
riskScore = clamp(rawScore, 0, 100)A positive weight increases risk. A negative weight reduces risk.
The SDK also returns:
confidence: how much relevant signal is available;freshness: how recent the latest relevant event is;reasons: the strongest active signals;recommendedActions: suggested actions from positive-risk signals.
State compatibility
Each returned state contains a stateCompatibilityHash.
If the config changes in a way that makes the previous state unsafe to reuse, the SDK throws ConfigMismatchError.
When this happens, rebuild the state with scoreUser() from historical events.
Changing only reason or action does not break state compatibility.
Error handling
import {
ConfigMismatchError,
OutOfOrderEventError,
StateUserMismatchError,
} from "tiaude";
try {
const result = scorer.track({
userId,
previousState,
event,
});
await saveChurnState(userId, result.state);
} catch (error) {
if (
error instanceof ConfigMismatchError ||
error instanceof OutOfOrderEventError
) {
const result = scorer.scoreUser({
userId,
events: await loadUserEvents(userId),
});
await saveChurnState(userId, result.state);
return;
}
if (error instanceof StateUserMismatchError) {
// The previous state belongs to another user.
// Do not reuse it.
throw error;
}
throw error;
}Exported errors:
ConfigValidationErrorInvalidEventErrorConfigMismatchErrorOutOfOrderEventErrorStateValidationErrorStateUserMismatchError
Notes
- The SDK is framework-agnostic.
- It works in Node.js, serverless functions and backend frameworks.
- It has no runtime dependency.
- It does not store raw events.
- Your app is responsible for storing
result.state. - Use
scoreUser()when you need a full recompute. - Use
refreshScore()for time-based recalculation without new events.
License
This project is licensed under the MIT License - see the LICENSE file for details.
