jevbooks

← All design patterns

Thermostat

Hysteresis · 恒温器

On at 20°, off at 22°

One high line to enter a mode, a lower one to leave it, so the mode stops flickering every time the number wobbles.

.6.8exitenteronoff

The problem

You are building an overlay for a game: the moment the player is in combat, the health bar and the skill row grow; when the fight ends they shrink back. Writing that as a rule is hopeless — monsters, effects and interfaces differ in every game — so you let Jev glance at the text on screen each frame and hand you a probability that a fight is happening.

You set a line at 0.7 and ship it. On day one someone posts a clip with the caption "why is this thing seizing". You pull the log for those seconds: 0.68, 0.72, 0.69, 0.71. Nothing answered wrongly. The edges of a fight really are ambiguous, and your single line translated "roughly" into "on, off, on, off" five times a second.

You raise the line to 0.8: less flicker, but the bar now grows half a second late and players take a hit before they can read their health. You drop it to 0.6 and the flicker moves to the end of the fight. No value works, because the trouble is not where the line sits — it is that there is only one.

The solution

The thermostat pattern says: use two lines, and remember what you concluded last time. Entering takes the high line — the overlay only grows at 0.8, so it would rather be a beat late than wrong.

Leaving takes the low line. Once you are in the fight, anything at 0.6 or above keeps you there; only a drop below 0.6 ends it. The strip between 0.6 and 0.8 belongs to "roughly": land in it and nothing changes.

The third piece is the memory. This frame is judged against the last one. If you were already in a fight, 0.71 means carry on; if you were not, the same 0.71 means not yet. One number, two meanings, and the difference is the memory.

Ask · Answer · Act

  1. AskPut the text and buttons currently on screen — the material Jev is shown, which the docs call the state — in front of it and ask one question: is a fight happening right now?
  2. Answer0.71.
  3. ActThe last frame was already in a fight and 0.71 has not fallen under the exit line, so it stays in the fight and the overlay does not move.

Where the name comes from

A house thermostat turns the heating on at 20° and off at 22°. If both were set to 21°, the boiler would click on and off between 20.9 and 21.1 all night long. Those two degrees in between are the space it gives to "roughly".

Use it when

What you are judging is a condition that lasts a while — in combat, this task needs a human, this route should use the big model — and switching costs something: a flicker, a duplicate notification, a cache rebuild.

Not when

Each call judges an independent thing: one email, one change, one comment. There is no "last time" between them, and forcing a memory in only drags the previous verdict onto the next item.

Projects that do this

  • huncho — Decisions as code on System One models: typed questions, hysteresis, journal, calibration.
  • foreman — Agent supervisor and software factory foreman using decision models.
  • jev-browser — Fast, cheap browser automation driven by TypeSafe's Jev model.

All projects with this pattern →

Read this code
  • hunchosrc/policy.ts:268-276

    Ten lines: an entry line, an exit line, and last time’s conclusion decide together whether the state holds.

  • foremansrc/foreman/policy.py:21-127

    A supervisor waits thirty seconds before it interrupts a worker, and feeds its own last assessment back in: the same idea, measured in time instead of probability.

  • jev-browsersrc/navigate.ts:441-458

    "Stuck" does not count until the third step, and an action that fails twice in a row hands over to the runner-up instead of repeating itself.

Show me the code
function active(p, previous, { enter, exit }) {
  if (p >= enter) return true;              // reached the high line: enter
  return previous === true && p >= exit;    // already inside and not below the low line: hold
}
const prev = held.get(session.id) ?? false;
const now = active(answers.combat.p, prev, { enter: 0.8, exit: 0.6 });
held.set(session.id, now);
How jevbooks recognises it

How do we know a project does this? We put one question about its README to Jev and it returns a percentage; the higher, the more it looks like it. If you copy the question, copy one thing: ask whether the text mentions this one concrete thing, never whether the project is good.

questions.json · sent for every README
{
  "hysteresis": {
    "type": "noul",
    "instructions": {
      "what": "Does the text describe different thresholds for entering and leaving a state, or feeding the previous decision back to prevent flip-flopping?",
      "not_for": "A single static threshold."
    },
    "criteria": {
      "true": "The text explicitly describes this. One sentence is enough; other content does not cancel it.",
      "false": "The text does not describe this."
    }
  }
}

Build this with Jev →