# Rules in memory

The memory is read by a one-pass decision engine, not a reasoner. Measured while building the demos and cookbooks with the English checkpoint, four things decide how a rule should be written.

## 1. Put conditions in code: rules with `when`

The engine does not compare numbers. With the line "Mia's stop-loss for a session is 30 units." in memory, the stop answer was 0.12 to 0.18 at a loss of 12 and still 0.22 to 0.30 at a loss of 30, where the limit is reached (measured 2026-09-23, `cookbooks/rules_in_memory.py`).

So Jepela checks the condition in code and shows the engine the line only when it holds:

```json
POST /v1/memory/rules
{"subject": "mia", "when": "session_result <= -30", "text": "Mia's stop-loss is reached: she stops playing now."}

POST /v1/systemone
{"subject": "mia", "values": {"session_result": -32}, "state": "...", "questions": {...}}
```

`when` is an expression over the request's `values` and the state's fields (`reading.t > 75`, `days_since(order.date) > 30`, `contains(lower(message), "refund")`; a bare name is read from `values` first, so a field of the state cannot replace a value you send and switch a rule off; `state.` and `values.` name one of them), evaluated by an allow-listed evaluator: comparisons, `and`/`or`/`not`, arithmetic, and the functions `days_since`, `days_between`, `len`, `lower`, `contains`, `number`, `abs`, `min`, `max`, `round`. Nothing else runs. A fired rule's text is read like a memory line, placed before the recalled lines, and listed in `memory.rules_fired`; a rule whose expression cannot be evaluated is skipped, listed in `rules_broken` with the reason (`error`), and the decision goes on. When the request simply has no field the rule reads, the entry names it (`missing`) and the warning is the milder `rule_skipped`: normal for a rule meant for other requests. Otherwise (a value it compares is text that is not one number, such as a date; see Request options for how numbers in text are read) the warning is `rule_broken`. An expression the evaluator cannot run, such as a field or an index read from a value (`[1, 2][0] > 0`), is refused (422) when the rule is added. Rules may expire: send `expires` (an ISO date or time) or `ttl_seconds` (at most ten years), and the rule list shows `expires_at`. A rule past its expiry is deleted: it never fires, and neither the rule list nor `GET /v1/memory` counts or shows it. A `when` has at most 300 characters and a rule's text at most 500; a subject holds at most 100 rules. Rules are checked only when the decision uses the subject's memory, not with `"memory": {"use": false}`. `GET /v1/memory/rules?subject=` lists them and `POST /v1/memory/rules/delete` removes one. A rule is billed as one memory line written.

Measured through the gateway on 2026-09-23: with the rule above, the stop answer was 0.11 at −10 (not fired) and 0.80 at −30 (fired). In the roulette demo the rule fired once, at −32, and the stop answer was 79%; before it fired the median stop answer was 13%.

## 2. Leave forbidden options out of the question

The engine does not read negation: naming the forbidden thing pulls it toward the answer. In the Dungeon demo, "Never walk along the corridor with the gold coins." raised the coins from 83% to 94%, and Jepela warns when such a line is written.

A line that says what to do can help: in the Dungeon's Stone Hall the old bridge had 94% without memory, and with the line "Next time there, the hero goes along a dark muddy path" the muddy path had 70% and the hero took it (checked by the demo's test). It does not always help. On ten roulette states where 17 had just come up, the engine bet on 17 in 9 of 10 with no memory, 9 of 10 with a rule naming 17, and 10 of 10 with "Mia bets red, black, even, odd, low or high". What worked every time was offering only Mia's bets in the question: 0 of 10 (measured 2026-09-23, `cookbooks/rules_in_memory.py`).

So: when an option must never be chosen, remove it before the engine sees it. Jepela can do that for you from memory: an **exclusion rule**.

```json
POST /v1/memory/rules
{"subject": "player-9", "text": "Gold coins are off for this player (coin bug).", "exclude": ["gold_coins"], "question": "reward"}
```

When the rule holds (no `when` means always), `gold_coins` is removed from the `reward` question in code, the answer shows it with probability 0 and `excluded` says why, and the rule's text is never sent to the engine. Measured on 2026-09-26: "Never give player-9 gold coins" as a line raised gold coins from 0.63 to 0.98; as an exclusion it was 0.00. An exclusion never removes a question's last option (`exclusions_skipped` says so). `question` belongs to an exclusion: a rule with `question` and no `exclude` is refused with a 422, because its line would reach every question. Use memory lines for what should make an allowed answer more likely.

To see what a rule took away, ask with `"memory": {"compare": true}`. The answer without memory is asked with every option, as a call without the subject would be. The question's entry in `changes` lists the excluded options, with the rule's text:

```json
"changes": {"system_action": {"answer_changed": true, "with": "none", "without": "lock",
                              "top_probability_with": 0.6847, "top_probability_without": 0.0037,
                              "excluded": {"lock": "never lock the screen"}}}
```

When `without` is one of the excluded options, the rule changed the answer. Refuse and say why ("I won't lock the screen: you asked me never to"), rather than doing the runner-up. Measured on 2026-09-29, for the state "lock the screen" and the rule `{"exclude": ["lock"], "question": "system_action", "text": "never lock the screen"}`: with memory, none 0.68, sleep 0.32 and lock 0; without memory, lock 0.99. It works the same for a choice of more than 20 options answered by matching: lock was 0 with memory and 0.97 without.

## Pinned rules: lines that always count

A rule with `"pin": true` (and no `when`) is sent first at every decision about the subject, whatever the state says and whether recall would have found it: premium support, an opt-out, an allergy.

```json
POST /v1/memory/rules
{"subject": "acct-7781", "pin": true, "text": "Acct-7781 has premium support: every ticket from them is escalated."}
```

With a `when`, a pinned rule is sent first whenever its condition holds. The answer lists pinned rule ids in `memory.pinned`. Measured on 2026-09-26: this pinned line moved the same ticket from 0.35 to 0.86. Pin only what must count every time; every pinned line is read, and billed, at every decision about the subject.

## 3. Keep memories short

Lines can move an answer just by being there, in either direction: five neutral lines moved an escalation answer from 0.46 (no memory) to 0.28 (measured 2026-09-23). Replace lines rather than append them, and check with `"memory": {"placebo": true}`: it reports questions that move with neutral lines as much as with yours.

## 4. Answers near a threshold are noisy

In the roulette demo, with the stop threshold at 50%, stray answers of up to 52% ended 2 of 9 sessions early (2026-09-23). For expensive actions require a fired rule, or two answers in a row above the threshold, and count early actions.

## The pattern

- Facts as positive statements with the names your states use.
- Conditions as rules with `when`, evaluated in code on `values` and the state's fields.
- Options that must never be chosen removed in code: an exclusion rule, or left out of the question; lines that say what to do may help but guarantee nothing.
- Facts that must count at every decision pinned; `"memory": {"explain": true}` shows how far each line moved the answer.
- `compare` and `placebo` on while you develop, so every decision shows what the memory did.
