> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Underwriting process replay

# 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.ts` → `buildStageTimeline(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

| Stage           | Account-driven? | Source                                                                  |
| --------------- | --------------- | ----------------------------------------------------------------------- |
| base            | no              | portfolio scalar (file strength / time-in-business)                     |
| score           | no              | single FICO number                                                      |
| newCards        | yes             | **reconstructed**: revolving accounts opened within 12 months           |
| derog           | yes             | **authoritative**: `derogScoring.calculateDerogScore` → `DerogDetail[]` |
| thinFile        | yes             | **reconstructed**: counted revolving accounts, tagged strong/weak       |
| latePayment     | yes             | **authoritative**: `lateScoring` → `LateDetail[]`                       |
| unsecuredRecent | yes             | **reconstructed**: unsecured installment accounts in recency bands      |
| oldBankruptcy   | yes             | **reconstructed**: public records filed > 36 months ago                 |
| clamp           | no              | floor/ceiling math                                                      |
| range           | no              | dollar 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.tsx` — `ReportDetail` 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.
