DEV Community

473185670
473185670

Posted on

How I Built a Habituation Pattern Detector in 90 Lines of Vanilla JavaScript — No AI, No ML, No Time-Series Library

ERP is the gold-standard treatment for OCD. Habituation is the mechanism that makes it work. Here's how I detect whether it's happening — using nothing but plain JavaScript.


If you've ever done exposure and response prevention (ERP) — the gold-standard CBT technique for OCD — you know the core question every session boils down to:

"Is my anxiety dropping on its own, without the compulsion?"

That's habituation. It's the whole point. You confront the obsession, you refuse the ritual, and you watch your anxiety peak and then fall. If it falls, the obsession loses its power. If it doesn't, you need to stay longer or pick an easier exposure.

The problem: most people track ERP in a notebook, eyeball the numbers, and guess whether habituation is happening. There's no trend line, no split-half comparison, no "your starting anxiety is decreasing across sessions — ERP is working." Just a list of SUDS ratings and a feeling.

So I built a habituation pattern detector — a function that takes your ERP log entries and tells you, quantitatively, whether anxiety is habituating, what your response-prevention rate is, and what your next step should be. In 90 lines of vanilla JavaScript. No AI. No ML. No time-series analysis library.

Try the live ERP tracker here — log exposures, see habituation detected in real time. No signup, no backend, your data stays in your browser.


The two-level detection

Habituation happens at two levels, and the detector handles both:

Level 1 — Per-exposure habituation. For each individual exposure session, did anxiety drop? By how much? The detector classifies each entry:

function dwInsightFor(entry) {
  var drop = entry.anxBefore - entry.anxAfter;
  if (entry.resisted === 'Gave in') {
    return "You did the compulsion this time — that's okay, it's data. " +
           "Next time, try to delay the compulsion by 5 minutes, then 10, " +
           "building toward full response prevention.";
  }
  if (drop >= 30) {
    return "Excellent habituation. Anxiety dropped " + drop + " points without " +
           "the compulsion. This is ERP working — you've proven you don't need " +
           "the ritual. The brain learned the obsession was a false alarm.";
  }
  if (drop >= 10) {
    return "Good progress. Anxiety dropped " + drop + " points. Habituation is " +
           "happening. Stay longer next time (until SUDS drops by half).";
  }
  if (drop >= 0) {
    return "Anxiety dropped only " + drop + " points — you may have left too " +
           "early. Habituation needs time. Try staying longer and repeating daily.";
  }
  return "Anxiety went up by " + Math.abs(drop) + " points. This can happen early " +
         "in ERP. It's not failure — it means this exposure is high on your hierarchy.";
}
Enter fullscreen mode Exit fullscreen mode

That's 16 lines. Four buckets: excellent (≥30), good (≥10), minimal (≥0), anxiety rose. Plus a special case for "gave in to the compulsion" — because doing the ritual means habituation didn't get a chance to happen, and the insight should say so honestly.

The thresholds (30/10) come from the ERP literature. A drop of 30+ SUDS points in a single session is strong habituation. Under 10 is weak. These aren't magic numbers I tuned — they're the clinical convention.

Level 2 — Across-exposure trend. This is the pattern detector. A single good exposure doesn't mean ERP is working. You need to see the trend across sessions: is your starting anxiety decreasing? If exposures are getting easier over time, the obsession is losing its power.

// Trend: first half vs second half of starting anxiety
var chrono = dwEntries.slice().sort(function(a,b){
  return new Date(a.when) - new Date(b.when);
});
var trendStr = 'stable';
if (chrono.length >= 4) {
  var half = Math.floor(chrono.length / 2);
  var firstAvg = chrono.slice(0, half)
    .reduce(function(s,e){ return s + e.anxBefore; }, 0) / half;
  var secondAvg = chrono.slice(half)
    .reduce(function(s,e){ return s + e.anxBefore; }, 0) / (chrono.length - half);
  if (secondAvg < firstAvg - 5) {
    trendStr = 'decreasing (exposures getting easier — ERP working!)';
  } else if (secondAvg > firstAvg + 5) {
    trendStr = 'increasing (you may be moving up the hierarchy — expected)';
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the core pattern-detection algorithm. Split-half comparison: sort all entries chronologically, split them in half, compare the average starting anxiety of the first half vs the second half. If the second half is ≥5 points lower, the trend is decreasing — ERP is working. If it's ≥5 points higher, you're moving up the hierarchy (expected, not failure). Otherwise, stable.

Why split-half and not a regression slope? Because:

  1. ERP logs are small (5-30 entries, not 10,000). A regression on 15 points is statistically fragile and visually unhelpful.
  2. The user needs a verdict, not a coefficient. "Decreasing — ERP working!" is actionable. "slope = -0.73" is not.
  3. Split-half is robust to noise. One bad session doesn't flip the trend if the other 14 are improving.
  4. It needs ≥4 entries to trigger — below that, there isn't enough data to detect a pattern, and the detector says so honestly (trendStr = 'stable' is the default, not a false "decreasing").

The aggregate detector

Beyond the trend, the detector computes four aggregate signals from the full log:

var total = dwEntries.length;
var resistedFull = dwEntries.filter(function(e){
  return e.resisted === 'Fully resisted';
}).length;
var resistedPart = dwEntries.filter(function(e){
  return e.resisted === 'Partially resisted';
}).length;
var resistRate = Math.round(
  (resistedFull + resistedPart * 0.5) / total * 100
);
var avgDrop = Math.round(
  dwEntries.reduce(function(s,e){
    return s + (e.anxBefore - e.anxAfter);
  }, 0) / total
);
Enter fullscreen mode Exit fullscreen mode
  • Response-prevention rate: fully-resisted counts as 1.0, partially-resisted as 0.5, gave-in as 0. This is the single most important ERP metric — if you're not preventing the response, the compulsion is still reinforcing the obsession.
  • Average anxiety drop: across all exposures. The overall habituation signal.
  • Most common obsession type: which trigger shows up most. Helps you see what you're working on.
  • Starting-anxiety trend: the split-half result from above.

The adaptive guidance engine

The detector doesn't just report numbers — it generates a specific next step based on your response-prevention rate:

if (resistRate < 50) {
  nextStep = "Your response-prevention rate is low. The compulsion is still " +
    "winning. Try delaying the compulsion (5 min, then 10) before full " +
    "prevention — even delay weakens the cycle. Pick easier hierarchy items first.";
} else if (resistRate < 80) {
  nextStep = "You're resisting more than half the time — the cycle is weakening. " +
    "Keep logging daily exposures and push toward full prevention on your " +
    "current tier before moving up.";
} else {
  nextStep = "You're consistently preventing the response. The obsession is " +
    "losing its power. Move up your hierarchy to harder items.";
}
Enter fullscreen mode Exit fullscreen mode

Three guidance branches, keyed to the response-prevention rate. Under 50% → delay strategy (easier than full prevention). 50-80% → consolidate current tier. Over 80% → move up the hierarchy. This is the CBT protocol encoded as a threshold function.


Why rule-based detection beats ML for habituation

This is the question I get every time: "Why not just train a model to detect habituation?"

Four reasons, specific to this domain:

1. The output space is tiny and known. The per-exposure classifier has 4 buckets. The trend detector has 3 states (decreasing/increasing/stable). The guidance engine has 3 branches. That's the entire output space — 4 × 3 × 3 = 36 possible combined verdicts, all defined by the CBT protocol. An ML model would learn to approximate a function we already have the exact definition of.

2. Explainability IS the intervention. When the detector says "Excellent habituation — anxiety dropped 35 points without the compulsion, the brain learned the obsession was a false alarm," that sentence is the therapeutic intervention. The user reads it and their belief about the obsession updates. An ML model that outputs habituation: true, confidence: 0.87 gives you nothing to show the user. The rendered insight has to be human-readable CBT language, not a probability — so the logic has to be rule-based anyway.

3. Determinism is a feature, not a limitation. Same log → same verdict, every time. If the detector says "ERP is working" today and "ERP is not working" tomorrow on the same data, the user loses trust and stops using it. ML models have non-determinism (sampling, dropout, batch effects). For a clinical feedback tool, non-determinism is a bug.

4. The thresholds are clinical, not statistical. The 30-point and 10-point cutoffs come from the ERP literature, not from a training set. You can't "learn" them from data without a labeled dataset of "this exposure showed good habituation" vs "this one didn't" — which doesn't exist at scale and would be expensive to create. The clinical convention is already the ground truth.

Where ML would actually help: personalized threshold tuning. The 30/10 cutoffs are population averages. Some people habituate faster (lower threshold), some slower. An ML model that takes your past 50 exposures and adjusts your personal "excellent habituation" threshold — that's a genuine ML use case. But it's a second stage, layered on top of the rule-based classifier, not a replacement for it. The core detection stays rule-based; ML tunes the parameters.


The full picture

Here's what the 90 lines do, end to end:

  1. Input: an array of ERP log entries, each with {when, trigger, type, anxBefore, anxAfter, resisted, urge, duration, notes}.
  2. Per-exposure classification (16 lines): dwInsightFor(entry) → one of 4 buckets + a CBT-informed insight sentence.
  3. Aggregate detection (15 lines): response-prevention rate, average drop, dominant obsession type.
  4. Trend detection (9 lines): split-half starting-anxiety comparison → decreasing/increasing/stable.
  5. Adaptive guidance (7 lines): next-step recommendation keyed to response-prevention rate.
  6. Render (~40 lines): display entries sorted by date, show aggregate stats, show trend, show guidance.

All of it runs in the browser. No server. No API. No model weights to download. The data lives in localStorage — your anxiety logs never leave your device.


The architecture lesson

The broader pattern: for constrained domains with a small known output space and a clinical/explainability requirement, rule-based detection isn't a compromise — it's the correct architecture. ML is the right tool when the output space is large, the mapping is fuzzy, and you have lots of labeled data. Habituation detection is the opposite: 4 buckets, an exact clinical definition, and a requirement that the output be a human-readable sentence.

I've built 23 mental health tools on this principle. Each one has a "detector" or "insight engine" at its core — cognitive distortion detection, core belief drilling, safety behavior classification, habituation pattern detection — and each one is rule-based for the same reasons. The detectors are the product. The rules come from decades of CBT research. The JavaScript is just the delivery layer.

If you want to try the ERP tracker with the habituation detector: it's here, free, no signup. Log a few exposures and watch the trend detection kick in after 4 entries.

And if you want a structured CBT thought record template to use alongside the tools: I made a Notion template ($7) — 7-column thought record, distortion reference, reframe prompts. It's the offline companion to the interactive tools.


The full source code is in the cbt-toolkit repo. The habituation detector is in seo/cbt-for-ocd.html. No dependencies, no build step, no framework.

Top comments (0)