Turn end-of-course critiques into a ranked after-action worklist — what to sustain, what to improve, and which fixes are quick wins versus structural redesign.
Every course ends with a pile of critiques, and most of them go into a drawer. Hotwash reads that pile and answers the questions an instructor actually has: What worked? What hurt the class the most? And is this the same thing we said we'd fix last cycle? It grades the course, not the student — and because it looks across class iterations, it gets sharper every time you run it.
The analytics are pure and deterministic (no model, no network, no key). An optional narrative step will write the AAR up as prose using any OpenAI-compatible model, with a deterministic memo fallback so it always returns something useful offline.
npm install hotwashRequires Node 18+. ES modules only.
import { hotwash } from 'hotwash';
const critiques = [
{ area: 'Fire-order clarity', kind: 'improve', severity: 4, iteration: '2025-3' },
{ area: 'Fire-order clarity', kind: 'improve', severity: 5, iteration: '2026-1' },
{ area: 'Fire-order clarity', kind: 'improve', severity: 5, iteration: '2026-2' },
{ area: 'Comms lab bench time', kind: 'improve', severity: 3, iteration: '2025-3' },
{ area: 'Comms lab bench time', kind: 'improve', severity: 1, iteration: '2026-2' },
{ area: 'Instructor prep', kind: 'sustain', iteration: '2026-2' },
];
const report = hotwash({ critiques });
// report.improves[0] →
// { area: 'Fire-order clarity', mentions: 3, meanSeverity: 4.667,
// impact: 14, priority: 1, horizon: 'long_term', trend: 'worsening' }
// report.sustains → [ { area: 'Instructor prep', mentions: 1 } ]
// report.meta → { critiques: 6, areas: 3, iterations: 3, shortTerm: 1, longTerm: 1 }A single piece of end-of-course feedback:
| field | meaning |
|---|---|
area |
what it's about (also accepts topic / lesson / module) — the grouping key |
kind |
'sustain' (worked, keep it) or 'improve' (fix it). Defaults to improve. |
severity |
1..5, how badly it hurt the class (improve only). Defaults to 3, clamped into range. Numeric strings ('4') are coerced; a non-numeric string ('high') throws TypeError; a NaN severity falls back to the default 3. |
iteration |
which class cycle it came from (e.g. '2026-1', '2025-Q4', or 1) — unlocks the trend read. Cycles are ordered chronologically, never by input order (see below). |
weight |
optional multiplier for feedback that speaks for many (e.g. a class-wide vote). Defaults to 1; a finite weight >= 0 is honored (0 legitimately zeroes a critique); a negative or non-numeric weight throws TypeError. |
impact = mentions × mean severity — how often it came up, times how bad it was. priority is that
impact normalized 0..1 against the worst finding, so it's comparable across courses.
horizon sorts each finding into a fix window:
short_term— fix it before the next class. The default.long_term— the area recurs across two or more iterations without improving: a structural problem a single patch won't solve. This is the signal that only shows up because Hotwash looks across cycles.
import { trendByArea } from 'hotwash';
trendByArea(critiques);
// [ { area: 'Fire-order clarity', direction: 'worsening', delta: 1,
// iterations: [ { iteration: '2025-3', improve: 1, meanSeverity: 4 }, ... ] },
// { area: 'Comms lab bench time', direction: 'improving', delta: -2, ... } ]The signal is mean improve-severity per iteration. improving means the latest cycle hurt less than
the first; worsening the reverse; flat within an epsilon dead-band; single when there isn't
enough history to call it.
Iterations are always ordered chronologically, never by the order critiques happen to appear in
the input: plain numbers sort numerically, calendar labels YYYY-N and YYYY-QN sort by year then
period (so '2025-Q4' before '2026-Q1'), and any other strings sort with a numeric-aware compare
('wk2' before 'wk10'). This keeps the trend direction correct even when the pile is unsorted.
import { narrativeAAR } from 'hotwash';
const { memo, source } = await narrativeAAR(report, { course: 'Basic Electronics Course (28xx)' });
// source: 'heuristic' offline, or 'model' when HOTWASH_API_KEY / OPENROUTER_API_KEY is set
console.log(memo);
// AFTER-ACTION REVIEW — Basic Electronics Course (28xx)
//
// SUSTAIN — what worked, keep it
// • Instructor prep (1)
//
// IMPROVE — before the next class (short-term)
// 1. Comms lab bench time — 2 mentions · severity 2 · improving
//
// IMPROVE — course redesign (long-term)
// 1. Fire-order clarity — 3 mentions · severity 4.667 · worsening — recurring across iterationsConfigure the model with HOTWASH_API_KEY (or OPENROUTER_API_KEY), HOTWASH_ENDPOINT, and
HOTWASH_MODEL, or pass { apiKey, endpoint, model }. Pass { heuristicOnly: true } to force the
offline memo. A hung endpoint is bounded by timeoutMs (default 15000, or HOTWASH_TIMEOUT_MS):
on timeout — as on any network error — narrativeAAR degrades to the deterministic heuristic memo
and never throws on I/O.
| export | purpose |
|---|---|
hotwash({ critiques, epsilon? }) |
the whole report: sustains, ranked improves, per-area rollup, meta |
rankFindings(critiques, opts?) |
ranked improve findings with impact, priority, horizon, trend |
rollup(critiques) |
raw per-area counts, mean severity, improve rate |
trendByArea(critiques, opts?) |
per-area direction across iterations |
sustains(critiques) |
what worked, most-mentioned first |
narrativeAAR(report, opts?) |
prose memo (model or heuristic); resolves, never throws on I/O |
heuristicMemo(report, opts?) |
the deterministic memo directly |
Every analytics function validates its input and throws TypeError on a bad shape.
meta.iterations counts the distinct iteration labels across all critiques (how many class
cycles are represented) — sustain and improve alike. The per-area trend read is improve-only, so
an individual area's trend may span fewer iterations than this course-wide count.
node example/demo.mjsApache-2.0.