Skip to main content

Underwriting Process Replay — Design

2026-05-29 · Repo: ghl-leadbuilt-marketplace-app-fundingmachine · Surface: Underwriting Review Console (app/admin/internal/underwriting-review)

Summary

Upgrade the console’s static Multiplier replay card into an interactive, animated Process Replay: a scrubbable, “slowed down” playback of how an underwriting decision is assembled stage-by-stage, with a persistent roster of every account that lights up to show which accounts drove each stage. The playback is tweak-aware — it animates over the decision produced by the current Tweak-panel knob settings — so an operator can watch a knob change ripple through individual accounts and rebuild the funding multiplier. This is a purely client-side, presentation-layer feature. It introduces no change to runUnderwriting’s output, no RULESET_CODE_VERSION bump, and no golden-snapshot churn.

Goals

  • Let an operator watch the decision build: each multiplier stage in sequence, the running product compounding, the funding range resolving.
  • Show, per stage, the specific accounts that drove it (e.g. “Derog ×0.80 ← ONEMAIN −5pt, SYNCB/LOWES −3pt”).
  • Let an operator follow a single account’s journey across all stages (click an account → cross-highlight every stage it touched).
  • React live to calibration tweaks: change a knob, the playback re-derives and you see the delta at the stage you’re on.

Non-goals (YAGNI — explicitly out of scope)

  • Batch-wide simultaneous playback (multiple reports animating at once).
  • GIF / video export of the animation.
  • Persisting playback position across sessions.
  • Any server-side or engine-trace change (e.g. enriching UnderwritingTrace with per-account arrays).
  • Side-by-side comparison of two different tweak states.

Background: what already exists

  • convex/lib/underwritingReviewReplay.tsbuildStageTimeline(decision) produces the ordered multiplier stages (base → score → newCards → derog → thinFile → latePayment → unsecuredRecent → oldBankruptcy → clamp → range) with each stage’s multiplier, compounding runningProduct, band, reason, and gate. It is a pure presentation transform over the decision’s explainability trace.
  • app/admin/internal/underwriting-review/_components/ReplayStepper.tsx renders that timeline statically.
  • ReportDetail (in page.tsx) reconstructs the baseline UnderwritingInput (lib/underwriting/reconstructInput.ts), runs runUnderwriting with no tweaks, and renders the static stepper.
  • The Tweak panel manages knobChanges via useReviewSession; applyKnobChanges (lib/underwriting/knobRegistry.ts) converts them into the { thresholds, multiplierOverrides } pair the batch diff re-runs with.
  • Per-account scoring already exists as pure modules: convex/lib/derogScoring.ts returns DerogDetail[] (creditor, type, weighted points); convex/lib/lateScoring.ts returns LateDetail[] (creditor, 30/60/90 instances).

Architecture

All client-side. Data flow for the selected report:
selected report
  → reconstructUnderwritingInput(report)              (existing)
  → applyKnobChanges(baseInput, knobChanges)          (existing — tweak-aware)
  → runUnderwriting(input)  →  UnderwritingDecision (+ trace)
  → buildProcessReplay(decision, reportData)          (NEW pure module)
        ├─ stages[]    (extends buildStageTimeline: + touchedAccountIds + per-account effects)
        └─ accounts[]  (roster: every tradeline + public record, keyed by accountIdentifier)
  → <ProcessReplay>  (NEW component: timeline + roster + transport controls)

Tweak-aware integration

ReportDetail is extended to apply the current session knobChanges to the selected report’s reconstructed input before running the engine, reusing the exact applyKnobChanges path the batch diff uses. This guarantees the replay and the batch-diff numbers can never disagree. On knob change, the replay re-derives and holds the current stage index (clamped) so the operator sees how the knob moved the stage they are viewing.

buildProcessReplay — the new pure transform

Lives beside buildStageTimeline (no ctx, no I/O, no React; unit-tested directly). Returns:
  • stages: ProcessStage[] — the existing Stage shape plus, for account-driven stages, the list of touchedAccountIds and each account’s effect for that stage.
  • accounts: ReplayAccount[] — the full roster: every tradeline + public record, each with a stable accountIdentifier, display label (creditorName), type, and a key figure.

Per-stage attribution sources

StageAccount-driven?Source
basenoportfolio scalar (file strength / time-in-business)
scorenosingle FICO number
newCardsyesreconstructed: revolving accounts opened within 12 months
derogyesauthoritative: derogScoring.calculateDerogScoreDerogDetail[]
thinFileyesreconstructed: counted revolving accounts, tagged strong/weak
latePaymentyesauthoritative: lateScoringLateDetail[]
unsecuredRecentyesreconstructed: unsecured installment accounts in recency bands
oldBankruptcyyesreconstructed: public records filed > 36 months ago
clampnofloor/ceiling math
rangenodollar conversion

Correctness: guarding against attribution drift

The reconstructed stages (newCards, thinFile, unsecuredRecent, oldBankruptcy) mirror engine classification client-side and could silently drift from the engine. Three mitigations:
  1. One source of truth. Reuse the engine’s pure classification/parsing helpers (creditMetrics exports, derogScoring, lateScoring) — never reimplement the rules. The replay imports them so it moves with the engine. The same parsed-tradeline preprocessing the engine uses feeds the scoring modules.
  2. Reconciliation tests (the primary guard). Every reconstructed list must reconcile to an aggregate the engine already publishes on the decision: reconstructed new-card count === decision.newCardsLast12Months; counted revolvers === filteredRevolvingCount; derog weighted points → multiplier === decision.derogMultiplier; etc. Any drift is a CI failure, not a wrong number on screen.
  3. Fail-safe at runtime. If a stage cannot reconcile for a given report, render that stage’s aggregate reason without account chips rather than show attribution that might be wrong.

UX

  • Transport controls: play / pause, step forward / back, restart, draggable scrub bar, speed selector (0.5× / 1× / 2×). Default pace deliberately gentle (~1–1.5s per stage).
  • Per-step animation: the current stage node highlights and its multiplier folds into the running product; in the roster, the accounts that stage touched light up with their effect, the rest dim. Account-less stages dwell on the scalar with the roster all-dim (so “no accounts here” reads clearly).
  • Two-way interaction: click a stage to jump to it; click an account to cross-highlight every stage it participated in (the single-account journey view).
  • Accessibility: respect prefers-reduced-motion (no auto-animation; controls become pure stepping). Timeline stays keyboard-navigable. Matches the console’s existing a11y posture.

Components & files

  • convex/lib/underwritingReviewReplay.ts — add buildProcessReplay (and ProcessStage / ReplayAccount types) alongside buildStageTimeline. Pure.
  • app/admin/internal/underwriting-review/_components/ProcessReplay.tsx — NEW interactive component (timeline + roster + transport). Replaces the static ReplayStepper usage inside the Multiplier-replay card. ProcessReplay renders its own reduced-motion mode (a non-animated, fully-stepped view of the same timeline + roster), so the standalone ReplayStepper component is removed and its presentation folded into ProcessReplay.
  • app/admin/internal/underwriting-review/page.tsxReportDetail applies knobChanges to the selected report’s input (tweak-aware) and renders <ProcessReplay>.
  • Tests: tests/underwritingReviewReplay.test.ts (or a sibling) — reconciliation assertions for each reconstructed stage against the decision’s published aggregates, plus structural assertions on the roster.

Testing

  • Unit-test buildProcessReplay directly with fixtures (the same pattern as buildStageTimeline).
  • Reconciliation tests as described above are the load-bearing correctness guard.
  • CI gates unchanged: bun run lint, bunx tsc --noEmit, bun test.

Risks & mitigations

  • Attribution drift (primary risk) — mitigated by reuse-not-reimplement + reconciliation tests + runtime fail-safe (above).
  • Tradeline parsing reuse — the replay must feed the scoring modules the same parsed-tradeline shape the engine builds; reuse the engine’s preprocessing rather than parsing reportData independently.
  • Performance — re-deriving on every knob change is cheap (one runUnderwriting + one pure transform per selected report), matching the cost the batch diff already pays.

Open questions

None blocking. Implementation will confirm the exact engine preprocessing helper to reuse for tradeline parsing.