Skip to main content

What a decision trace is

Every time /api/v1/recommend runs, the engine takes a list of candidate offers and pushes them through four stages — qualification (which offers is this customer eligible for?), contact policy (which of those are we allowed to send right now?), scoring (how good is each remaining offer for this customer?), and ranking (given placements and channel coupling, which N do we return?). A decision trace is the forensic record of that journey: how many candidates entered each stage, which rules fired, which offers were filtered out and why, what scores the models produced, and which offers made the final cut. Traces are written asynchronously after the decision is delivered, so they never slow down /recommend. They are sampled per tenant (decisionTraceEnabled + decisionTraceSampleRate); at 10% sampling a high-traffic tenant still gets enough forensic coverage to investigate any specific customer or incident.

What you see when you open a trace in Studio

Open Studio → Decision Traces and click any row to expand it. The expanded view has three layers, each answering a different question:

1. Stage Timeline — which stage filtered offers, and by how much?

A vertical timeline of the four pipeline stages with the candidate count entering and exiting each one (12 → 4, 4 → 4, 4 → 4, 4 → 4). A green dot means the stage ran cleanly; an amber dot means it ran in degraded mode (a scoring model threw, fallback scores were used). This is the “where did the candidates go?” view. Use it when you suspect a stage is filtering too aggressively or running slow.

2. Per-Offer Journey — for THIS customer and THIS request, why this offer and not that one?

A table with one row per offer ID that appeared anywhere in the trace, showing its trajectory across all four stages on a single line:
  • Qualification — pass (✓) or fail (✗) with the exact reason text and the ruleId of the rule that fired. Example: Missing attribute "tier" (d0cc9358-…).
  • Contact Policy — pass (✓) or blocked (○) with the policy reason and policyId. Example: frequency_cap (pol_email_daily_cap).
  • Score — the numerical score, the rank within the scoring stage, the modelType that produced it (gradient_boosted, thompson_bandit, etc.), and the top-3 signed feature contributions rendered as horizontal bars (green = positive, red = negative).
  • Final — a 🏆 trophy with the rank in the delivered response, or em-dash if the offer didn’t make it.
A header strip above the table shows the experiment variant (when present), ranking weights, and the first 8 chars of inputsHash / policyVersionHash so you can cross-reference against drift logs and policy snapshots. Selected offers are highlighted green. This is the “Why didn’t Customer X get Offer Y?” view. Compliance reviewers, support escalations, and ops engineers debugging weird-looking results live here.

3. Explain button — narrative prose for a human reader

The Explain button at the right of each trace row opens a dialog that calls the LLM-narrated explanation endpoint. The dialog has three tabs — one per audience: A Regenerate button bypasses the 7-day cache (noCache: true); the dialog footer shows which model produced the narrative, whether the result was cached, and the token counts. The Explain button is gated by a per-tenant opt-in (tenantSettings.aiAnalyzerSettings.llmExplanationsEnabled); when it’s off, the button shows a banner that links to settings. See LLM Explanations for the full lifecycle, PII redaction, audit log, and rate limits.

When to reach for which layer

  • “Latency is up, where?” → Stage Timeline.
  • “Customer X is asking why they didn’t get the Premium Card.” → Per-Offer Journey for that customer’s trace.
  • “Our compliance officer wants a written record for the Brown v. Tenant audit.” → Explain → Regulator tab.
  • “The support agent UI needs to render the explanation.” → Explain → Agent tab (JSON).
  • “Embed a one-liner under the offer card in the customer app.” → Explain → Customer tab.

GET /api/v1/decision-traces

List decision traces with offset-based pagination. Traces are only recorded when tracing is enabled in tenant settings and the request passes the sample rate check.

Query Parameters

Response


GET /api/v1/decision-traces/

Get a single decision trace with full pipeline detail.

Response

Returns the complete trace object with all pipeline stages, scoring details, and result.

Per-stage timeline (UI)

The studio detail view at /studio/decision-traces/{id} renders a per-stage timeline alongside the JSON trace. Each pipeline stage (Enrich, Compute, Eligibility, Fit, Match, Ranking, Negotiation) is shown as a horizontal bar with the stage label, elapsed milliseconds, and the candidate count before/after. Hovering any bar reveals the rule firings, scoring inputs, and reasons recorded for that stage. Use this to diagnose which stage is slow or filtering more aggressively than expected without scrolling through the raw trace payload.

Provenance deep-dive — “Why this customer got these offers”

Expanding any row at /studio/decision-traces opens a per-offer journey table directly inline. For each offer ID that appeared anywhere in the persisted JSON arrays, the row shows the offer’s trajectory across all four stages on one line: qualification (pass / fail with the exact rule reason and ruleId), contact policy (pass / blocked with the policy reason and policyId), score (the model’s score, rank, and modelType, plus the top-3 signed feature contributions from scoringResults[].explanations[] rendered as horizontal bars), and final (selected with rank, or not selected). Rows that were selected by the decision flow are highlighted in green. The header strip above the table surfaces, when present:
  • experiment.variant — which experiment arm this decision was assigned to.
  • rankingWeights — the ranking-profile weights (PRIE coefficients, diversity / emphasis multipliers).
  • The first 8 chars of inputsHash and policyVersionHash — for cross-referencing against drift logs or policy snapshots.
  • A degraded badge when degradedScoring = true. This is set whenever a scoring model ran in fail-soft fallback mode rather than producing a real score — a model key that couldn’t be resolved, a scorer that threw, or an imported ONNX model whose runtime is unavailable or errored (the score falls back to 0.5). The per-decision badge complements the aggregate scoring_model_failures metric, so a single trace shows the degradation instead of hiding it behind a fleet-wide counter.
This deep-dive reads only what the trace already persists — there is no new API call. Older traces persisted before the JSON-array enrichment landed render an empty-state caption; the count-based timeline continues to work for them.

Enabling Decision Traces

Decision traces are controlled by two tenant settings: Configure these via the Settings API or the platform UI under Settings.
At high traffic volumes, set the sample rate below 1.0 to avoid excessive storage. A 10% sample rate (0.1) typically provides sufficient forensic coverage.

JSON Field Shapes

Each trace persists five JSON arrays that capture the forensic detail of the decision. Downstream aggregators (the selection_frequency, anomaly_candidates, and why_not_ranked endpoints in Dashboard Data) expect the following shapes on newly-written rows. Older rows written before a field was added are still readable — fields default to null in the aggregation queries.

scoringResults

Best-first list of offers that reached the scoring stage. Each entry also carries model attribution — which model actually scored that candidate: modelId (the AlgorithmModel.id), modelType (e.g. scorecard, bayesian), and propensitySource (how the propensity was obtained — model, adaptation, or cold_start). With scoped model routing, different candidates in the same decision can be attributed to different models. Each entry also carries an explanations[] array of per-feature contributions — { field, contribution } for every predictor the scorer considered. The contribution is normalized across model types: linear/Bayesian scorers report their signed log-odds weight, and a scorecard reports the rule’s awarded points (previously these surfaced as 0). A fail-soft entry (e.g. a degraded ONNX score) additionally carries degraded: true and a reason, so the explanation says the score is a fallback rather than a real attribution.

selectedOffers

The final ranked result returned to the caller, in delivery order.

qualificationResults

One row per offer that the qualification stage evaluated. passed=false indicates a rejection; ruleId identifies the rule that evaluated. When a rule could not be evaluated because its required data was missing from the decision context and the rule’s onMissing behavior is "skip" (the default), the row carries skipped: true plus a warning describing what was missing and, when applicable, an attribute field naming the missing key — the fail-open is visible in the trace, not a silent pass. See Missing data: onMissing.

contactPolicyResults

One row per offer that the contact-policy stage evaluated. blocked=true indicates the policy suppressed the offer for this customer.

rejectedOffers

The merged per-stage rejection list — one entry per candidate any stage removed, in the same shape as the recommend response’s rejectedOffers[] (see Recommend). This list is complete: every candidate-dropping stage contributes a structured reason — decisioning gates (eligibility), contact-policy suppressions (contact_policy), consent, channel/creative/placement filters (channel), suppression rules, tenant-wide frequency caps (frequency_cap), the maturity-ramp cold-start floor (maturity_ramp), hard guardrails, the atomic channel-coupling cascade (coupling), and the rank node’s topN cut (rank). Entry shape: { offerId, offerName?, stage, reason, detail? } where stageeligibility | contact_policy | consent | channel | suppression | frequency_cap | maturity_ramp | guardrail | coupling | rank, reason is a self-contained human-readable sentence, and detail is an optional structured object (exposureProbability, capKey, scope, …). Rows written before the column landed default to [].

POST /api/v1/decisions//narrative

Generate an LLM-written natural-language explanation of a single decision trace. The feature is opt-in per tenant — see LLM Explanations.

Request Body

Response

Auth, Limits, and Tenancy

  • Requires tenant authentication (session or API key).
  • Rate limited to 20 requests / minute / tenant. Exceeding returns 429.
  • Tenant opt-in required: tenantSettings.aiAnalyzerSettings.llmExplanationsEnabled = true. Otherwise returns 403 with "LLM explanations are not enabled for this tenant.".
  • The decision trace must belong to the authenticated tenant or the response is 404.

Audit Log (regulator mode)

When mode = "regulator", a row is appended to the audit log:

Caching

Results are cached in Redis for 7 days under the composite key (tenantId × decisionTraceId × mode × model × inputsHash). Repeating the same request returns cached: true instantly.

POST /api/v1/decisions//shap

Return exact per-feature TreeSHAP contributions for a recorded decision’s gradient_boosted scoring step. Designed for EU AI Act Article 13/22 audit workflows: the response is a mathematically-grounded, additivity-verified breakdown of every feature’s effect on the raw margin (logit space). Unlike the LLM narrative endpoint, this returns raw numbers — deterministic, cacheable, and auditable independent of any LLM availability. Pair the two when you need both prose and numerics in a regulator export.

Request Body

Response

Additivity guarantee

For any input x:
This identity is built into the algorithm (Lundberg, Erion, Lee 2018, Algorithm 2 — path-dependent variant). The endpoint reports additivityResidual so consumers can verify the invariant on-wire.

Probability-space approximation

SHAP values are reported in raw-margin space — the only space where the additivity identity holds. To approximate a feature’s effect on the probability, use:
This is monotone with shap_i but not strictly additive in probability space. UI surfaces typically show both: the raw φ for compliance, and a sigmoid-mapped delta for human-readable display.

Auth, Limits, and Tenancy

  • Requires tenant authentication (session or API key).
  • Rate limited to 30 requests / minute / tenant.
  • Tenant opt-in required: tenantSettings.aiAnalyzerSettings.llmExplanationsEnabled = true (same flag as the narrative endpoint).
  • The decision trace, the model, and the request must all belong to the same tenant. Cross-tenant lookup returns 404.
  • Returns 400 if the model’s modelType is not gradient_boosted or if it has no trained trees.

Audit Log

Every successful call writes:
This ensures DSAR exports and regulator queries can trace which decisions have had SHAP attributions computed and when.

Roles

See also: LLM Explanations | Dashboards | Decision Flows