A tiny decision-table and scorecard evaluator with explainable, auditable output. You express the decision as data; it returns the outcome and a full trace of which rules fired and why. The engine, not a platform.
TypeScript-first, zero runtime dependencies, and safe by construction — no
eval, no Function, no I/O.
import { evaluate, explain } from '@pametan/decide';
import { affordabilityScorecard } from '@pametan/decide/examples';
const result = evaluate(affordabilityScorecard, { age: 22, incomeMonthly: 1800, existingDebt: 900 });
// result.outcome === 'decline', result.score === 30
explain(result); // ['Existing debt >= 500', 'Thin-file age band']Approve / decline / refer, risk bands, pricing tiers, KYC flags — these decisions
too often live as tangled if/else code that's hard to change, hard for risk and
compliance to read, and very hard to explain after the fact. Regulators
increasingly require that explanation (FCA Consumer Duty and CONC; ECOA/Reg B
adverse-action reason codes). decide keeps the decision as declarative data and
hands back a trace you can log or turn into a customer-facing reason.
npm install @pametan/decideRequires Node 24+. Ships ESM with bundled type declarations.
Rules of conditions → outcomes, with a configurable hit policy:
import { evaluate } from '@pametan/decide';
import type { DecisionTable } from '@pametan/decide';
const table: DecisionTable = {
kind: 'table',
name: 'eligibility',
hitPolicy: 'first', // 'first' | 'collect' | 'priority'
rules: [
{ id: 'under-18', when: { field: 'age', op: 'lt', value: 18 }, outcome: 'decline', reason: 'Under 18' },
{ id: 'tenure', when: { expr: 'employmentMonths < 6' }, outcome: 'refer', reason: 'Short tenure' },
],
fallback: 'approve',
};
evaluate(table, { age: 30, employmentMonths: 24 }).outcome; // 'approve'first— first matching rule wins (deterministic; evaluation stops there).priority— among matches, highestprioritywins.collect— returns every matching outcome as a list, or — withcollectMerge: trueand object outcomes — one shallow-merged object.
Characteristics contribute points (first matching attribute each); the score falls into a band:
import type { Scorecard } from '@pametan/decide';
const card: Scorecard = {
kind: 'scorecard',
name: 'affordability',
base: 50,
characteristics: [
{ id: 'dti', attributes: [
{ when: { field: 'existingDebt', op: 'gte', value: 500 }, points: -15, reason: 'Debt >= 500' },
{ when: { field: 'existingDebt', op: 'lt', value: 500 }, points: 5 },
] },
],
bands: [
{ min: -100, max: 34, outcome: 'decline' },
{ min: 35, max: 50, outcome: 'refer' },
{ min: 51, max: 100, outcome: 'approve' },
],
};// 1. Fixed operators (declarative, serialisable — the default)
{ field: 'score', op: 'between', value: [600, 800] }
// ops: eq ne gt gte lt lte in nin between matches exists
// 2. Named custom predicates (supplied at evaluation time)
evaluate(model, input, { predicates: { thinFile: (i) => /* ... */ true } });
// referenced as: { predicate: 'thinFile' }
// 3. A safe expression string
{ expr: 'age >= 18 && country == "GB"' }Combine with { all: [...] }, { any: [...] }, { not: ... }.
The expression language is deliberately tiny and safe. It supports only
field references, number/string/boolean/null literals, the comparison operators
== != > >= < <=, the boolean operators && || !, and parentheses. There is
no arithmetic, no function calls, no member/index access, no eval — so even
untrusted rule text cannot execute code. Anything outside the grammar is a parse
error. You can also restrict which forms are allowed per evaluation via
allow: { operators, predicates, expressions }.
interface DecisionResult {
outcome: Outcome | Outcome[] | null; // list under 'collect'; null if no match & no fallback
matched: boolean;
trace: TraceEntry[]; // every rule/attribute considered, with a readable detail
score?: number; // scorecard only
band?: string; // scorecard only
}explain(result) returns the fired reasons as string[]. The whole trace is
shaped to be logged verbatim (e.g. by @pametan/pii-redact-safe
audit logging).
| Export | Description |
|---|---|
evaluate(model, input, options?) |
Evaluate a table or scorecard. |
evaluateTable / evaluateScorecard |
Model-specific entry points. |
createEvaluator(model, options?) |
Bind a model + options once. |
validateModel(model, options?) |
Static checks → list of error strings. |
explain(result) |
Fired reasons as string[]. |
evaluateExpression, parseExpression |
The safe expression evaluator. |
evaluateCondition |
Evaluate a single condition tree. |
Three ready-made example models ship under @pametan/decide/examples
(lendingEligibilityTable, affordabilityScorecard, kycRiskTable). All types
are exported.
import { validateModel } from '@pametan/decide';
validateModel(model); // [] when valid; otherwise unknown operators, bad expressions,
// unknown predicates, overlapping/inverted bands, etc.- Deterministic and non-mutating; same model + input → same result + trace.
- For money, use integer minor units (pence/cents) to avoid floating-point drift.
npm install
npm run typecheck
npm test # operators, hit policies, scorecard, safe-expression, examples
npm run build # emit dist/Provided as an engineering aid, not legal, financial or compliance advice. The
rules you encode — and whether they meet your regulatory obligations — are yours
to verify. MIT licensed — see LICENSE.
We're Pametan — a specialist fintech/regtech engineering agency working across UK, US and Canadian rails (FCA · CFPB · FCAC). We build the regulated, audited decisioning systems these primitives sit inside: lending decision engines, scorecards, and the audit trails around them.