# Mental Math — rules and recipes > Reference and cookbook for authoring mental-math rules and recipes: the rule expression language, the canonical rule catalog, and how recipes turn rules into generated exercises. A rule is one expression deciding whether a candidate value is allowed at a step. A recipe collects rules, gives each a job, and generates concrete exercises from them. --- # Mental Math rules and recipes Source: / ## What this site covers A **rule** is one expression that decides whether a candidate value is allowed at a step: ``` current_value > 0 AND formula = 8 AND digitBefore IN (0, 1) ``` A **recipe** collects rules, assigns each one a job (target, review, filler, forbidden, constraint), and bounds the exercise — producing a concrete sequence like `+8 +30 −7 +4 = 35`. If you are configuring training material, you will spend your time in the [guide](/guide/what-is-a-rule) and the [recipe cookbook](/recipes/cookbook). If you are verifying that an expression means exactly what you think it means, go to the [reference](/reference/context). ## Where to start | You want to… | Go to | |---|---| | Understand what a rule is at all | [What is a rule?](/guide/what-is-a-rule) | | Write your first expression | [Your first rule](/guide/getting-started) | | Understand `formula` and `digitBefore` | [Decimal places](/guide/decimal-places) | | Look up exact semantics of a variable | [Context variables](/reference/context) | | Find the expression behind a known technique | [Canonical rules](/reference/rule-catalog) | | Build a recipe that generates exercises | [Recipe concepts](/recipes/concepts) | | Train two-digit numbers like `+47` | [Multi-place combo steps](/recipes/combo-steps) | | Work out why a recipe generates nothing | [Troubleshooting](/recipes/troubleshooting) | ::: tip Using an AI assistant? See [For AI agents](/llms) — the whole site is also available as a single plain-text file designed to be pasted into a model's context. ::: --- # What is a rule? Source: /guide/what-is-a-rule # What is a rule? Rules are the core of how a mental-math exercise is designed. Each rule is a short expression that decides whether a player's chosen value is **allowed** at a given step. When a player picks a number, every rule in the ruleset is checked against that candidate. If any rule rejects it, the move is blocked. This guide teaches you how to write rules from scratch — no prior experience required. You do not need to be a programmer: a rule is closer to a sentence than to a program. Here is a complete, real rule: ``` current_value > 0 AND formula = 8 AND digitBefore IN (0, 1) ``` Read left to right, it says: the operation is an addition, it is an "8", and the digit it affects is currently 0 or 1. By the end of the guide, every part of that line will be familiar. ## The three outcomes A rule is a single expression that evaluates to one of three outcomes: | Outcome | Meaning | |---------|---------| | **Passed** | The condition is satisfied. This rule does not block the move. | | **Failed** | The condition is not satisfied. The move is rejected. | | **Skipped** | The rule has an `IF` guard that was false. The rule does not apply to this step and does not block the move. | The distinction between *Failed* and *Skipped* is what makes rules composable. A rule that does not apply to the current step steps aside instead of objecting — so you can write narrow, single-purpose rules without them fighting each other. ## How a ruleset combines rules A ruleset is a collection of rules. All rules run against every candidate value. A value is only allowed if **every** rule passes or skips. One failing rule is enough to reject it. ::: info Rules restrict; they do not propose A ruleset never *suggests* a value. It only narrows the set of values that are permitted. A [recipe](/recipes/concepts) is what turns that permitted set into an actual generated exercise, by giving each rule a job — target, review, filler, forbidden, or constraint. ::: ## Where to go next - [Your first rule](/guide/getting-started) — write a working expression in two minutes. - [Variables](/guide/variables) — the values a rule can talk about. - [Operators](/guide/operators) — comparisons, arithmetic, `AND`/`OR`/`NOT`, `IF…THEN`, `IN`. - [Decimal places](/guide/decimal-places) — `place`, `formula`, and `digitBefore`, which almost every real rule is built from. --- # Your first rule Source: /guide/getting-started # Your first rule The simplest rule is a comparison between two values: ``` current_value > 0 ``` This rule says: "the candidate value must be positive." If a player tries to play `0` or a negative number, this rule fails and the move is blocked. That is a complete rule. No boilerplate, no declarations — an expression is the whole thing. ## The two top-level forms There are two top-level forms a rule can take. ### Plain condition ``` current_value > 0 ``` The condition is always evaluated. It passes or fails with no special logic. ### IF…THEN (guarded rule) ``` IF step_index = 0 THEN current_value > 0 ``` The `IF` part is evaluated first. If it is **false**, the rule is **skipped** entirely — the candidate is not blocked. If it is **true**, the `THEN` part is evaluated and must pass. ## Which form to use Use a plain condition when the rule applies to every step. Use `IF…THEN` when the rule only applies in certain situations. The two forms above are not the same rule. `current_value > 0` forbids negative values on *every* step. `IF step_index = 0 THEN current_value > 0` forbids them only on the first step and leaves every later step alone — because on those steps the guard is false and the rule is skipped rather than failed. ::: tip Keywords are uppercase `IF` and `THEN` — like every keyword in the language — must be fully uppercase. `if … then` is not recognized. See [common mistakes](/guide/mistakes) for the other easy-to-hit syntax traps. ::: Next, learn [which variables](/guide/variables) you can put on either side of a comparison, and [which operators](/guide/operators) are available to combine them. --- # Variables Source: /guide/variables # Variables A rule does not declare anything. When it is evaluated, a fixed set of variables is already in scope, describing the step being judged. You write comparisons against those names directly. ## The six base variables | Variable | Type | Description | |----------|------|-------------| | `prev_sum` | number | The running total accumulated from all previous steps, **before** the current candidate is applied. | | `current_value` | number | The value the player is trying to play right now. | | `current_sum` | number | The total that would result if `current_value` is accepted. Equal to `prev_sum + current_value`. | | `step_index` | number | The zero-based position of the current step. `0` is the first step. | | `last_step_index` | number | The index of the final step (total steps minus one). | | `isLastStep` | boolean | `true` when `step_index = last_step_index`, `false` otherwise. | ::: tip Prefer `current_sum` Use `current_sum` instead of writing `prev_sum + current_value` — they are identical, but `current_sum` is easier to read. ::: ## The step, before and after The three sum-and-value variables describe one step from three angles, and picking the right one is most of what makes a rule readable: ``` prev_sum >= 0 -- the state the abacus was in before this step current_value < 0 -- the operation being performed current_sum <= 99 -- the state the abacus would be in afterwards ``` A rule about *what the player may do* uses `current_value`. A rule about *where the exercise may go* uses `current_sum`. A rule about *what the position must already look like* uses `prev_sum`. ## Position within the exercise `step_index`, `last_step_index`, and `isLastStep` locate the step in the sequence. They are almost always used as an `IF` guard rather than as the rule itself: ``` IF step_index = 0 THEN current_value > 0 -- constrains only the opening step IF isLastStep THEN current_sum = 100 -- constrains only the landing ``` `isLastStep` is a boolean, so it stands alone as a condition — write `IF isLastStep THEN …`, not `isLastStep = true`. ## The three derived variables Three more variables — `place`, `formula`, and `digitBefore` — are **derived** from `current_value` and `prev_sum` for you. They describe *which decimal place* the candidate affects: `place` is the place itself (`1`, `10`, `100`, …), `formula` is the normalized 1–9 digit of the operation, and `digitBefore` is the digit of the running total at that same place before the step is applied. They are what most real rules are built from, and all three are `null` whenever `current_value` touches more than one decimal place. [Decimal places](/guide/decimal-places) covers them in full, including the null behaviour that surprises most authors. That page is worth reading before you write your first real rule. ## See also - [Operators](/guide/operators) — what you can do with these variables. - [Digit functions](/guide/digit-functions) — inspecting the digits of any of them. - [Context variables](/reference/context) — exact semantics, for when you need to be certain. --- # Operators Source: /guide/operators # Operators Everything a rule can *do* comes from a small set of operators: compare two numbers, add or subtract, combine conditions, guard a rule, or test membership in a list. There is nothing else — which is why rules stay readable. ## Comparison operators | Operator | Meaning | Example | |----------|---------|---------| | `=` | Equal | `current_value = 5` | | `!=` | Not equal | `current_value != 0` | | `<` | Less than | `current_sum < 100` | | `<=` | Less than or equal | `current_sum <= 99` | | `>` | Greater than | `prev_sum > 0` | | `>=` | Greater than or equal | `current_value >= -9` | ::: warning Equality is a single `=` Use a single `=` for equality checks, not `==`. Comparisons also do not chain: `0 < current_value < 10` is not valid — write `current_value > 0 AND current_value < 10`. ::: ## Arithmetic You can use `+` and `-` inside comparisons: ``` prev_sum + current_value >= 0 ``` This is identical to `current_sum >= 0`. Addition and subtraction are the only supported arithmetic operators — multiplication and division are not available (and are rarely needed for this domain). Arithmetic binds more tightly than comparisons, so `a + b > c` means `(a + b) > c`. Parentheses work as expected: `(a + b) > c` and `a + b > c` are equivalent here. ## Combining conditions: AND, OR, NOT ### AND Both sides must be true: ``` current_sum >= 0 AND current_sum <= 99 ``` The running total must stay in the range 0–99. ### OR At least one side must be true: ``` current_value = 1 OR current_value = -1 ``` Only `1` and `-1` are allowed. ### NOT Inverts a condition: ``` NOT current_value IN (0, 5, 10) ``` Equivalent to `current_value NOT IN (0, 5, 10)` — see the membership section below for the cleaner form. ### Precedence `NOT` binds most tightly, then `AND`, then `OR`. This means: ``` a OR b AND c → a OR (b AND c) ``` Use parentheses whenever the intent might be ambiguous: ``` (prev_sum > 50 OR step_index = 0) AND current_value > 0 ``` ## Conditional rules: IF…THEN `IF…THEN` lets you write rules that only apply under certain conditions. When the `IF` guard is false, the rule is **skipped** — it neither passes nor fails. Skipped rules never block a move. **Only on the first step, the value must be positive:** ``` IF step_index = 0 THEN current_value > 0 ``` Steps 1, 2, 3, … are unaffected by this rule. Only step 0 is constrained. **When the sum is large, the player must reduce it:** ``` IF prev_sum > 50 THEN current_value < 0 ``` If `prev_sum` is 50 or less, the rule is skipped. If it exceeds 50, only negative values pass. **On the last step, the sum must hit the target:** ``` IF isLastStep THEN current_sum = 100 ``` Every intermediate step is free; only the final step is constrained to land on 100. ## Membership tests: IN and NOT IN Test whether a value is (or is not) in a specific set of numbers. ### IN ``` current_value IN (1, 2, 3, 4, 5, 6, 7, 8, 9) ``` The candidate must be one of the listed values. The list is comma-separated and enclosed in parentheses. ### NOT IN ``` current_value NOT IN (0, 5, 10, 15, 20) ``` The candidate must not be any of the listed values. `NOT IN` is two separate words — `NOTIN` is not recognized. ### More examples ``` highest_digit(current_value) IN (1, 3, 5, 7, 9) -- leading digit must be odd prev_sum NOT IN (11, 22, 33, 44, 55) -- sum must not be a double ``` ## See also - [Decimal places](/guide/decimal-places) — the derived variables these operators are usually applied to, and how `!=` and `NOT IN` behave when they are null. - [Quantifiers](/guide/quantifiers) — `EVERY` and `SOME`, for conditions over individual digits. - [Common mistakes](/guide/mistakes) — uppercase keywords, `==`, chained comparisons. - [Language reference](/reference/language) — the formal grammar and precedence table. --- # Decimal places Source: /guide/decimal-places # Decimal places ::: info Examples on this site use `--` annotations for readability The rule language has no comment syntax. Strip anything from `--` onward before pasting an expression into the editor. ::: Almost every real rule in this system is about **a technique at a decimal place**, not about a specific number. "Add 8 when the affected digit is 2" and "add 80 when the tens digit is 2" are the same technique performed one place apart. Writing those as separate rules would multiply your work for no benefit. So the language derives three variables for you, describing *where* the candidate acts: | Variable | Meaning | For `current_value = -70`, `prev_sum = 325` | |----------|---------|----------------------------------------------| | `place` | The decimal place affected: `1`, `10`, `100`, … | `10` | | `formula` | The normalized 1–9 digit, ignoring sign and place | `7` | | `digitBefore` | The digit of `prev_sum` at that same place, before the step | `2` | `digitBefore` is the important one. It tells you what the abacus looks like at the affected place *right now*, which is what decides whether a technique is even possible. ## The standard shape of a pattern rule Nearly every canonical rule follows one three-clause template: ``` current_value > 0 AND formula = 8 AND digitBefore IN (0, 1) ``` Read left to right: - `current_value > 0` — the operation is an **addition**. Use `current_value < 0` for subtraction. - `formula = 8` — the technique is an **8**. - `digitBefore IN (0, 1)` — the affected digit is currently **0 or 1**. Because `formula` and `digitBefore` are place-relative, that one rule matches `2 + 8`, `12 + 8`, `120 + 80`, and `3100 + 800` alike. See [the rule catalog](/reference/rule-catalog) for the full set built this way. ## Atomic and composite values These three variables only make sense when the candidate touches **exactly one** decimal place. Such a value is called **atomic**: | Value | Atomic? | Why | |-------|---------|-----| | `+7` | yes | one non-zero digit, at the ones place | | `-40` | yes | one non-zero digit, at the tens place | | `+300` | yes | one non-zero digit, at the hundreds place | | `+47` | **no** | two non-zero digits: `4` at tens *and* `7` at ones | | `-89` | **no** | two non-zero digits | For a **composite** value like `+47` there is no single place, no single formula digit, and no single "digit before" — so `place`, `formula`, and `digitBefore` are all **null**. Composite values are not hypothetical: a recipe can be configured to produce them deliberately, so a step like `+47` is a real thing your rule may be asked about. See [multi-place combo steps](/recipes/combo-steps). ## The null trap ::: danger This is the most common source of a rule that looks right but behaves strangely. When these variables are null, comparisons still evaluate — they just do not do what you would expect. **Positive tests fail, and negative tests pass.** ::: Given a composite candidate like `+47`: | Expression | Result | Effect on the step | |------------|--------|--------------------| | `formula = 8` | fails | rejects it | | `formula >= 1` | fails | rejects it | | `place IN (1, 10)` | fails | rejects it | | `digitBefore = 2` | fails | rejects it | | `formula != 8` | **passes** | allows it | | `formula < 1` | **passes** | allows it | | `place NOT IN (1, 10)` | **passes** | allows it | | `digitBefore != 2` | **passes** | allows it | Two consequences worth internalizing: **A rule meant to exclude something can stop excluding it.** `formula != 9`, intended as "never a 9", quietly permits *every* composite value — null is not 9, so the test passes. **A rule meant to restrict place can reject everything.** `place = 10`, intended as "tens only", quietly rejects *every* composite value, even one whose tens digit is exactly what you wanted. Neither is a bug. A composite value genuinely has no single formula or place, and the comparison is answering honestly. Both are just easy to write by accident. ::: tip Rule of thumb If a rule uses `place`, `formula`, or `digitBefore`, it is a rule about **atomic** steps. That is usually exactly what you want. Reach for `digitAt` below only when you deliberately need to inspect one place of a value that may touch several. ::: ## Inspecting a specific place with digitAt `digitAt(value, place)` reads the digit at any place you name, and does **not** care whether the value is atomic. It is the composite-safe way to talk about places: ``` digitAt(current_value, 10) = 4 -- the tens digit of the candidate is 4 digitAt(current_value, 1) != 9 -- the ones digit of the candidate is not 9 digitAt(prev_sum, 100) IN (0, 5) -- hundreds digit of the running total is 0 or 5 ``` For `current_value = 47`, `digitAt(current_value, 10)` is `4` and `digitAt(current_value, 1)` is `7` — while `place`, `formula`, and `digitBefore` are all null. A common use is pinning the opening position, where `digitBefore` would not help because it only ever describes the place the candidate itself affects: ``` IF step_index = 0 THEN digitAt(prev_sum, 1) = 0 -- start from a clean ones digit ``` ## Choosing between them | You want to say | Use | |-----------------|-----| | "This technique, at whatever place it occurs" | `formula` + `digitBefore` | | "Only steps in the ones place" | `place = 1` | | "This digit of a value, whatever else the value touches" | `digitAt(x, place)` | | "Never a 9 anywhere, including in composite steps" | `digitAt(current_value, 1) != 9` and so on per place, **not** `formula != 9` | ::: warning A rule scoped to one place-channel of a recipe may not reference `current_sum`. Inside a single channel, "the sum after this step" is not a state the exercise ever actually passes through, so it is rejected when the recipe is compiled. Put whole-step sum conditions in an ordinary, unscoped constraint rule instead. See [multi-place combo steps](/recipes/combo-steps). ::: ## See also - [Context variables](/reference/context) — exact definitions and the full null contract - [Built-in functions](/reference/functions) — `digitAt` and the digit helpers - [Canonical rules](/reference/rule-catalog) — every seeded rule, all built from this template - [Restricting places](/recipes/places) — applying this at the recipe level --- # Digit functions Source: /guide/digit-functions # Digit functions Most rules compare whole numbers. Sometimes you need to talk about the *digits* inside a number instead: "the leading digit must not be 9", "the result must end in 0", "no digit may be a 5". Five built-in functions cover that. All five operate on the **absolute value** of their argument. The sign is ignored, so `digits(-47)` and `digits(47)` are identical. ## The five functions | Function | Returns | Example | |----------|---------|---------| | `digits(x)` | Array of all digits, most significant first | `digits(123)` → `[1, 2, 3]` | | `highest_digit(x)` | The most significant (leading) digit | `highest_digit(123)` → `1` | | `lowest_digit(x)` | The least significant (trailing) digit | `lowest_digit(123)` → `3` | | `number_of_digits(x)` | Count of digits | `number_of_digits(123)` → `3` | | `digitAt(x, place)` | The digit at the named decimal place | `digitAt(123, 10)` → `2` | Four of them take exactly one argument. `digitAt` takes exactly **two**. Passing the wrong number of arguments is an error, and so is calling a function with no arguments at all. ## Using them A function call can appear anywhere a number can — on either side of a comparison, inside `IN`, or as an argument to another function. ``` highest_digit(prev_sum) < 5 -- leading digit of the running total is under 5 lowest_digit(current_value) = 0 -- candidate must end in 0 number_of_digits(current_sum) <= 2 -- result must be at most two digits ``` The argument does not have to be a bare variable. Arithmetic is allowed inside it: ``` highest_digit(prev_sum + current_value) != 9 ``` And the result can be tested with `IN` / `NOT IN` just like any other number: ``` highest_digit(current_value) IN (1, 3, 5, 7, 9) -- leading digit must be odd lowest_digit(current_sum) NOT IN (0, 5) -- result must not end in 0 or 5 ``` ## `digits` and quantifiers `digits(x)` is the odd one out: it returns an **array**, not a number, so it is not useful in a plain comparison. Its job is to feed a quantifier, which applies a condition to each digit in turn: ``` EVERY d IN digits(prev_sum): d < 9 -- no nines anywhere in the running total SOME d IN digits(current_value): d = 0 -- at least one digit of the candidate is zero ``` There is a shorthand for exactly this shape — `ALL_DIGITS(x)` and `SOME_DIGITS(x)`. Both are covered on the [quantifiers](/guide/quantifiers) page. ::: tip Leading digit vs. highest place `highest_digit` returns the *value* of the leading digit, not the place it sits in. For `1234` it returns `1`, not `1000`. If you want to know which decimal place a step affects, that is [`place`](/guide/decimal-places), not a digit function. ::: ## Edge cases These follow from how the functions are implemented, and are worth knowing before a rule surprises you: | Expression | Result | Why | |------------|--------|-----| | `digits(0)` | `[0]` | Zero has one digit | | `number_of_digits(0)` | `1` | Same reason | | `highest_digit(-70)` | `7` | Sign is stripped first | | `lowest_digit(-70)` | `0` | Sign is stripped first | | `digitAt(47, 100)` | `0` | Places above the number read as `0` | ## `digitAt` in brief `digitAt(value, place)` reads the digit of `value` sitting at the decimal place you name — `1` for ones, `10` for tens, `100` for hundreds, and so on. Formally it is `floor(abs(value) / place)` reduced to its last digit. ``` digitAt(current_value, 10) = 4 -- the tens digit of the candidate is 4 digitAt(prev_sum, 100) IN (0, 5) -- hundreds digit of the running total is 0 or 5 ``` Unlike the derived variables `place`, `formula` and `digitBefore`, `digitAt` works on values that touch several decimal places at once — for `current_value = 47` it happily reports `4` at the tens and `7` at the ones. That is its main reason to exist, and it is covered properly on the [decimal places](/guide/decimal-places) page. Read that page before reaching for `digitAt`: in most rules the derived variables are the shorter and clearer tool. --- # Quantifiers Source: /guide/quantifiers # Quantifiers A comparison like `current_sum < 100` talks about a number as a whole. Quantifiers let you talk about the digits *inside* it: "every digit is under 9", "at least one digit is a zero". ## Syntax ``` EVERY varName IN iterable : condition SOME varName IN iterable : condition ``` - `varName` is any identifier you choose — `d` is the convention. It stands for one element at a time. - `iterable` is an expression that returns an array. In practice this is almost always [`digits(...)`](/guide/digit-functions), the only built-in that returns one. - `condition` is an ordinary condition written in terms of `varName`. The colon is required. `EVERY`, `SOME` and `IN` must all be uppercase. ## EVERY True when the condition holds for **every** element: ``` EVERY d IN digits(prev_sum): d < 9 ``` Every digit of the running total must be less than 9 — that is, there are no nines in it. ## SOME True when the condition holds for **at least one** element: ``` SOME d IN digits(current_value): d = 0 ``` At least one digit of the candidate must be zero. ## Where quantifiers can appear A quantifier is a condition like any other, so it can go anywhere a condition can. In the guard of an `IF…THEN`: ``` IF SOME d IN digits(prev_sum): d = 9 THEN current_value < 0 ``` If any digit of the running total is a 9, the player must subtract. In the consequent: ``` IF prev_sum > 0 THEN EVERY d IN digits(current_value): d != 0 ``` ## The condition can be complex Everything after the `:` is a full condition, so `AND` and `OR` are allowed: ``` EVERY d IN digits(prev_sum): d >= 1 AND d <= 8 ``` Every digit must be between 1 and 8 inclusive. ::: warning The condition after `:` extends as far as it can Because the body is a full condition, it swallows any following `AND` / `OR`. In `EVERY d IN digits(prev_sum): d < 9 AND current_sum > 0`, the `current_sum > 0` is *inside* the loop body, not a separate top-level clause. If you meant two independent conditions, parenthesize the quantifier: ``` (EVERY d IN digits(prev_sum): d < 9) AND current_sum > 0 ``` ::: ## Empty iterables `EVERY` over an empty array is **true** (nothing violates the condition). `SOME` over an empty array is **false** (nothing satisfies it). In practice `digits(x)` is never empty — even `digits(0)` is `[0]`. ## Shorthand: ALL_DIGITS and SOME_DIGITS Writing `EVERY d IN digits(x): d op value` is common enough that the language gives you a shorter spelling: | Shorthand | Expands to | |-----------|-----------| | `ALL_DIGITS(x) op value` | `EVERY d IN digits(x): d op value` | | `SOME_DIGITS(x) op value` | `SOME d IN digits(x): d op value` | Both also work with `IN` and `NOT IN`: | Shorthand | Expands to | |-----------|-----------| | `ALL_DIGITS(x) IN (list)` | `EVERY d IN digits(x): d IN (list)` | | `ALL_DIGITS(x) NOT IN (list)` | `EVERY d IN digits(x): d NOT IN (list)` | | `SOME_DIGITS(x) IN (list)` | `SOME d IN digits(x): d IN (list)` | | `SOME_DIGITS(x) NOT IN (list)` | `SOME d IN digits(x): d NOT IN (list)` | ### Examples ``` ALL_DIGITS(prev_sum) < 9 -- no nines in the running total's digits ALL_DIGITS(current_value) NOT IN (0, 5) -- no digit may be 0 or 5 SOME_DIGITS(current_sum) = 7 -- at least one digit of the result is 7 ``` These are purely cosmetic — they expand to the equivalent `EVERY` / `SOME` form before evaluation, so they behave identically and appear identically in any explanation of why a rule failed. ### When the shorthand does not apply Two limits are worth knowing: - The shorthand is only recognised when `ALL_DIGITS(...)` or `SOME_DIGITS(...)` sits **directly on the left** of a comparison, `IN`, or `NOT IN`. Anywhere else it is left alone as an ordinary function call — and since no such function exists, evaluating it fails. - The expansion always uses the loop variable name `d`. Nesting a shorthand inside an `EVERY d …` of your own means the inner `d` shadows the outer one. Pick a different name for the outer loop, or write the inner quantifier out in full. Since the shorthand only handles a single comparison on the right, anything more involved — two conditions on the same digit, or a digit compared against something other than a literal — needs the full form: ``` -- not expressible as a shorthand EVERY d IN digits(current_sum): d >= 1 AND d <= 8 ``` --- # Common patterns Source: /guide/patterns # Common patterns A catalogue of rules that come up again and again. Find the goal, copy the expression, adjust the numbers. Each group below constrains a different aspect of a step. ## Sign and value of the candidate These constrain `current_value` — the number the player is about to play. | Goal | Rule | |------|------| | Candidate must be positive | `current_value > 0` | | Candidate must be negative | `current_value < 0` | | Candidate must not be zero (already enforced, but explicit) | `current_value != 0` | | Only single-digit values allowed (1–9) | `current_value IN (1, 2, 3, 4, 5, 6, 7, 8, 9)` | | Multiples of 5 are forbidden | `current_value NOT IN (5, 10, 15, 20, 25, 30, 35, 40, 45, 50)` | ## Sum constraints These constrain `current_sum` — the running total *after* the step — or `prev_sum`, the total before it. | Goal | Rule | |------|------| | Sum must stay in range 0–99 | `current_sum >= 0 AND current_sum <= 99` | | Sum must not exceed 100 | `current_sum <= 100` | | The result must be a two-digit number | `current_sum >= 10 AND current_sum <= 99` | | Result must be a single-digit number (1–9) | `current_sum >= 1 AND current_sum <= 9` | | When sum exceeds 50, player must subtract | `IF prev_sum > 50 THEN current_value < 0` | ## Digit constraints These reach inside a number with the [digit functions](/guide/digit-functions) and [quantifiers](/guide/quantifiers). | Goal | Rule | |------|------| | No zeros anywhere in the result's digits | `ALL_DIGITS(current_sum) != 0` | | No nines in the running total | `ALL_DIGITS(prev_sum) != 9` | | Restrict the result to digits 1–9 | `ALL_DIGITS(current_sum) IN (1, 2, 3, 4, 5, 6, 7, 8, 9)` | | At least one digit of the candidate must be odd | `SOME_DIGITS(current_value) IN (1, 3, 5, 7, 9)` | | Leading digit of the result must not be 9 | `highest_digit(current_sum) != 9` | Note that "every digit of the result must be distinct" is **not** directly expressible. The nearest workable substitute is restricting which digits are allowed at all, as in the third row above. ## Step-position constraints These use `step_index`, `last_step_index` and `isLastStep` to limit a rule to part of the exercise. All of them are `IF…THEN` rules, so they are *skipped* — not failed — on the steps they do not apply to. | Goal | Rule | |------|------| | On the last step, land on exactly 50 | `IF isLastStep THEN current_sum = 50` | | First step must be a positive single digit | `IF step_index = 0 THEN current_value IN (1, 2, 3, 4, 5, 6, 7, 8, 9)` | ## Place-based patterns These use the derived variables `place`, `formula` and `digitBefore`, which describe *which decimal place* a step affects. They are the shape almost every real technique rule takes — see [decimal places](/guide/decimal-places) for how they are derived and when they are `null`. | Goal | Rule | |------|------| | Add an 8 where the affected digit is 0 or 1 (any place) | `current_value > 0 AND formula = 8 AND digitBefore IN (0, 1)` | | Subtract a 1 where the affected digit is not 0 or 5 | `current_value < 0 AND formula = 1 AND digitBefore IN (1, 2, 3, 4, 6, 7, 8, 9)` | | Steps in the ones place only | `place = 1` | | Steps in the ones or tens place only | `place IN (1, 10)` | | Operate only on ±10, ±20, ±30, ±50 | `place = 10 AND formula IN (1, 2, 3, 5)` | | The candidate's tens digit must not be 9 (works for multi-place values too) | `digitAt(current_value, 10) != 9` | | Start from a clean ones digit | `IF step_index = 0 THEN digitAt(prev_sum, 1) = 0` | ::: warning `place`, `formula` and `digitBefore` are null on multi-place values A candidate like `+47` touches two decimal places, so it has no single place or formula digit and all three variables are `null`. Positive tests against them (`formula = 8`) then reject the step; negative tests (`formula != 9`) quietly let it through. Use `digitAt` when the value may span several places. [Full explanation](/guide/decimal-places). ::: --- # Common mistakes Source: /guide/mistakes # Common mistakes Almost every rule that fails to parse fails for one of the reasons below. Each entry shows the form that does not work and the form that does. ## Syntax traps ### Keywords must be uppercase ``` -- Wrong: if prev_sum > 50 then current_value < 0 -- Right: IF prev_sum > 50 THEN current_value < 0 ``` All keywords — `IF`, `THEN`, `AND`, `OR`, `NOT`, `IN`, `EVERY`, `SOME` — must be fully uppercase. The parser does no case-insensitive matching, so `if` and `If` are read as ordinary identifiers, not as the keyword. ### Use `=` for equality, not `==` ``` -- Wrong: current_value == 5 -- Right: current_value = 5 ``` There is one equals sign for equality and one only. `==` is not an operator in this language. ### Comparisons do not chain ``` -- Wrong: 0 < current_value < 10 -- Right: current_value > 0 AND current_value < 10 ``` Comparison operators are non-associative: each one takes exactly two arithmetic operands, and its result is a boolean that cannot be compared again. ### `NOT IN` is two separate words ``` -- Wrong: current_value NOTIN (0, 5) -- Right: current_value NOT IN (0, 5) ``` `NOTIN` is a single identifier as far as the parser is concerned. The two keywords need whitespace between them. ### `ANY` is not a keyword ``` -- Wrong: ANY d IN digits(prev_sum): d = 9 -- Right: SOME d IN digits(prev_sum): d = 9 ``` There are exactly two quantifiers, `EVERY` and `SOME`. `ANY`, `ALL` and `EXISTS` are not part of the language. ### Keywords must be separated by whitespace or punctuation ``` -- Wrong (IFprev_sum is treated as a single identifier): IFprev_sum > 0 THEN current_value < 10 -- Right: IF prev_sum > 0 THEN current_value < 10 ``` A keyword is only recognised when the character next to it is a space, a parenthesis, a comma, a colon, or the end of the rule. Anything else and it merges into the neighbouring identifier. ### Quantifiers need both `IN` and the colon ``` -- Wrong: EVERY d digits(prev_sum): d <= 5 EVERY d IN digits(prev_sum) d <= 5 -- Right: EVERY d IN digits(prev_sum): d <= 5 ``` The full shape is `EVERY name IN iterable : condition`, and none of the three separators — the loop variable, `IN`, the colon — is optional. ### Functions need at least one argument ``` -- Wrong: digits() -- Right: digits(current_value) ``` Zero-argument calls are not valid syntax. Note also that `digitAt` takes exactly **two** arguments, while `digits`, `highest_digit`, `lowest_digit` and `number_of_digits` take exactly one — see [digit functions](/guide/digit-functions). ## Meaning traps These parse cleanly. They just do not mean what they look like they mean. ### `NOT` is looser than a comparison ``` -- These are the same rule: NOT current_value > 5 NOT (current_value > 5) ``` `NOT` applies to the whole comparison to its right, never to just the left operand. If you want "the negative of `current_value` is greater than 5", write the arithmetic explicitly. ### A quantifier body runs to the end of the expression ``` -- Probably not what you meant — current_sum > 0 is inside the loop: EVERY d IN digits(prev_sum): d < 9 AND current_sum > 0 -- Two independent conditions: (EVERY d IN digits(prev_sum): d < 9) AND current_sum > 0 ``` Everything after the colon is a full condition, so it keeps consuming `AND` and `OR` clauses. Parenthesize the quantifier when you want it to stop. ### `AND` binds tighter than `OR` ``` -- Parses as: a OR (b AND c) prev_sum > 50 OR step_index = 0 AND current_value > 0 -- If you meant (a OR b) AND c: (prev_sum > 50 OR step_index = 0) AND current_value > 0 ``` ::: tip When in doubt, parenthesize Parentheses are free and never change a correct expression's meaning. Adding them to any rule that mixes `AND` with `OR`, or that puts a quantifier next to another clause, costs nothing and removes the whole class of bug above. ::: ### Negative tests pass on `null` place variables ``` -- Intended as "never a 9", but permits every multi-place value: formula != 9 -- Requires a single affected place first, then tests the formula: place IN (1, 10, 100, 1000) AND formula != 9 ``` On a candidate like `+47` that touches two decimal places, `place`, `formula` and `digitBefore` are all `null` — so `formula != 9` is true and the step is allowed. See [decimal places](/guide/decimal-places) for the full explanation and for `digitAt`, the multi-place-safe alternative. --- # Context variables Source: /reference/context # Context variables Every rule is evaluated against a **context**: a set of variables describing the candidate step. This page is the authoritative list. For a gentler introduction see [the guide](/guide/variables). ## Base variables Supplied directly for every evaluation. | Variable | Type | Definition | |----------|------|------------| | `prev_sum` | number | The running total from all previous steps, **before** the candidate is applied. | | `current_value` | number | The signed value of the candidate step. Positive is addition, negative is subtraction. Never `0`. | | `step_index` | number | Zero-based position of the current step. `0` is the first step. | | `last_step_index` | number | Index of the final step of the exercise — that is, total steps minus one. | ## Derived variables Computed from the base variables. You never set these; they are always consistent with the values above. | Variable | Type | Definition | |----------|------|------------| | `current_sum` | number | `prev_sum + current_value` — the total that results if the candidate is accepted. | | `isLastStep` | boolean | `true` when `step_index = last_step_index`. | | `place` | number \| null | The single decimal place `current_value` affects (`1`, `10`, `100`, …), or `null` if it affects more than one. | | `formula` | number \| null | `abs(current_value) / place`, a digit 1–9. `null` when `place` is `null`. | | `digitBefore` | number \| null | `floor(abs(prev_sum) / place) % 10` — the digit of the running total at the affected place. `null` when `place` is `null`. | ### How `place` is determined A value is **atomic** when exactly one of its decimal digits is non-zero. `place` is the positional value of that digit: | `current_value` | `place` | `formula` | |-----------------|---------|-----------| | `7` | `1` | `7` | | `-7` | `1` | `7` | | `40` | `10` | `4` | | `300` | `100` | `3` | | `47` | `null` | `null` | | `-89` | `null` | `null` | | `105` | `null` | `null` | Sign is ignored throughout: `place`, `formula`, and `digitBefore` are all computed from absolute values. Use `current_value > 0` / `current_value < 0` to test direction. ## The null contract ::: warning When `place`, `formula`, or `digitBefore` is `null`, comparisons against them **still evaluate**. They do not skip the rule, and they do not uniformly fail. ::: Null behaves as `0` in ordering comparisons and is unequal to every number in equality comparisons. The practical result, for a composite candidate such as `+47`: | Form | Example | Result | |------|---------|--------| | Equality | `formula = 8` | fails | | Membership | `place IN (1, 10)` | fails | | Lower bound | `formula >= 1` | fails | | Strictly greater | `formula > 0` | fails | | Inequality | `formula != 8` | **passes** | | Negative membership | `place NOT IN (1, 10)` | **passes** | | Upper bound | `formula <= 9` | **passes** | | Strictly less | `formula < 1` | **passes** | So **inclusive tests reject composite steps and exclusive tests admit them**. This is the single most common source of surprising rule behavior — see [the null trap](/guide/decimal-places#the-null-trap) for what it means in practice. The upside of the inclusive half: a standard pattern rule of the form `current_value > 0 AND formula = N AND digitBefore IN (…)` automatically fails on composite steps with no special handling. Pattern rules are about atomic techniques, and they self-exclude correctly. ## Scope and which variables apply A rule declares a **scope**, which decides when it runs: | Scope | Runs | |-------|------| | `step` | Against every candidate step. | | `last-step` | Against the final step only. | | `sequence` | Once, after a complete candidate sequence has been assembled. | All the variables above are available in `step` and `last-step` scope. A `sequence`-scope rule is evaluated once with the final step's values in scope, plus whole-sequence values describing the assembled exercise. ::: info Whole-sequence variables are supplied by the generator rather than by the base context, and the editor's linter does not currently recognize them as known identifiers — expect an "unknown variable" diagnostic even where they work. Prefer `step`-scope rules unless you specifically need to reason about a finished sequence. ::: ## Evaluation outcomes Every rule evaluates to one of three outcomes: | Outcome | Meaning | |---------|---------| | **Passed** | The condition held. | | **Failed** | The condition did not hold. For a constraint, this rejects the candidate. | | **Skipped** | The rule's `IF` guard was false, so the rule did not apply. A skipped rule never blocks a candidate, and never counts as a pattern match. | The distinction between *failed* and *skipped* matters for pattern rules: only a **passed** rule counts as a match toward a recipe's [count quotas](/recipes/concepts). ## See also - [Built-in functions](/reference/functions) - [Grammar and precedence](/reference/language) - [Decimal places](/guide/decimal-places) — the practical guide to `place` / `formula` / `digitBefore` --- # Built-in functions Source: /reference/functions # Built-in functions Five functions are available in rule expressions. There are no others, and there is no way to define your own. All of them operate on the **absolute value** of their numeric argument — the sign is discarded before any digit is inspected. | Function | Arity | Returns | Example | |----------|-------|---------|---------| | `digits(x)` | 1 | Array of all digits, most significant first | `digits(123)` → `[1, 2, 3]` | | `highest_digit(x)` | 1 | The most significant (leading) digit | `highest_digit(123)` → `1` | | `lowest_digit(x)` | 1 | The least significant (trailing) digit | `lowest_digit(123)` → `3` | | `number_of_digits(x)` | 1 | Count of digits | `number_of_digits(123)` → `3` | | `digitAt(x, place)` | 2 | The digit at a given decimal place | `digitAt(123, 10)` → `2` | Argument counts are fixed. Calling a function with the wrong number of arguments is an error, and there are no zero-argument calls — `digits()` is invalid. ## digits Returns every digit as an array, most significant first. Its main purpose is to feed a [quantifier](/guide/quantifiers). ``` digits(407) -- [4, 0, 7] digits(-407) -- [4, 0, 7] sign ignored digits(5) -- [5] ``` ``` EVERY d IN digits(current_sum): d != 9 ``` ## highest_digit The leading digit. ``` highest_digit(4071) -- 4 highest_digit(9) -- 9 ``` ``` highest_digit(current_sum) != 9 -- result must not start with a 9 ``` ## lowest_digit The trailing digit — equivalent to the value modulo 10. ``` lowest_digit(4071) -- 1 lowest_digit(40) -- 0 ``` ``` lowest_digit(current_value) = 0 -- candidate must end in 0 ``` ## number_of_digits How many digits the value has. ``` number_of_digits(7) -- 1 number_of_digits(4071) -- 4 ``` ``` number_of_digits(current_sum) <= 2 -- result must stay within two digits ``` ## digitAt Returns the digit of `x` at the decimal place named by `place`, computed as `floor(abs(x) / place) % 10`. The second argument is a **positional value** (`1` for ones, `10` for tens, `100` for hundreds), not an index. ``` digitAt(124, 1) -- 4 digitAt(124, 10) -- 2 digitAt(124, 100) -- 1 digitAt(-124, 10) -- 2 sign ignored digitAt(124, 1000) -- 0 no digit there ``` Unlike [`place` / `formula` / `digitBefore`](/reference/context), `digitAt` does not care whether the value touches one decimal place or several — which makes it the right tool for reasoning about multi-place values: ``` digitAt(current_value, 10) != 9 -- tens digit of the candidate is not 9 digitAt(prev_sum, 1) = 0 -- running total has a clean ones digit ``` ::: tip If you find yourself wanting `formula` or `digitBefore` on a step that might touch several places, `digitAt` is almost always what you actually want. See [decimal places](/guide/decimal-places). ::: ## Shorthands `ALL_DIGITS(x)` and `SOME_DIGITS(x)` look like functions but are **syntax**, not functions — they expand into quantifier expressions over `digits(x)` and can only appear in a comparison or membership test. See [quantifiers](/guide/quantifiers). ## See also - [Context variables](/reference/context) - [Grammar and precedence](/reference/language) - [Digit functions guide](/guide/digit-functions) --- # Language reference Source: /reference/language # Language reference This page specifies the rule expression language: what a valid rule is, how its parts bind, and what the grammar rejects. It is a reference, not a tutorial — for a worked introduction start at [what is a rule](/guide/what-is-a-rule). The language is **case-sensitive**. Keywords are uppercase only. Whitespace — spaces, tabs, newlines, carriage returns — is insignificant between tokens and required only where it separates two words that would otherwise merge. ## Top-level forms A rule is exactly one of two forms. ### Plain condition ``` condition ``` The condition is always evaluated. True → the rule **passes**. False → the rule **fails**. ### Guarded rule ``` IF condition THEN condition ``` The `IF` condition is evaluated first. - If it is false, the rule is **skipped** — neither passed nor failed. A skipped rule never blocks a move. - If it is true, the `THEN` condition is evaluated: true → passes, false → fails. `IF` without `THEN`, `THEN` without `IF`, an empty guard, and an empty consequent are all parse errors. ## Conditions A condition is a boolean expression. It is one of: | Form | Shape | |------|-------| | Comparison | `arithExpr op arithExpr` | | Membership | `arithExpr IN ( list )` or `arithExpr NOT IN ( list )` | | Quantified | `EVERY name IN iterable : condition` or `SOME name IN iterable : condition` | | Conjunction | `condition AND condition` | | Disjunction | `condition OR condition` | | Negation | `NOT condition` | | Grouping | `( condition )` | ## Operator precedence Listed from **lowest** binding strength to **highest**. Operators lower in the table bind more tightly and are therefore applied first. | Level | Operator(s) | Associativity | |-------|-------------|---------------| | 1 (lowest) | `OR` | Left | | 2 | `AND` | Left | | 3 | `NOT` | Prefix (right) | | 4 | Comparison: `=`, `!=`, `<`, `<=`, `>`, `>=` | Non-associative | | 4 | Membership: `IN`, `NOT IN` | Non-associative | | 4 | Quantifiers: `EVERY`, `SOME` | Prefix | | 5 | Arithmetic: `+`, `-` | Left | | 6 (highest) | Primary: literals, variables, function calls, `( expr )` | — | Consequences worth stating explicitly: - `a OR b AND c` is `a OR (b AND c)`. - `NOT a = b` is `NOT (a = b)` — `NOT` takes the whole comparison, never just its left operand. - `a + b > c` is `(a + b) > c`. - `a + b - c` is `(a + b) - c`. - Comparison and membership are non-associative, so `a < b < c` is a parse error rather than a chained test. Parentheses override precedence at any level. ::: warning A quantifier body extends to the right Everything after a quantifier's `:` is parsed as a **full condition**, so it absorbs any following `AND` / `OR` clauses. `EVERY d IN digits(x): d < 9 AND current_sum > 0` puts the second clause *inside* the loop body. Parenthesize the quantifier — `(EVERY d IN digits(x): d < 9) AND current_sum > 0` — to keep the clauses independent. ::: ## Keywords The complete reserved list. All are uppercase-only, and each must be bounded by whitespace, `(`, `)`, `:`, `,`, or the end of the rule — a keyword adjacent to a letter, digit, or underscore merges into an identifier instead (`IFprev_sum` is one identifier). | Keyword | Role | |---------|------| | `IF` | Guard clause opener | | `THEN` | Guard clause separator | | `AND` | Logical conjunction | | `OR` | Logical disjunction | | `NOT` | Logical negation; first half of `NOT IN` | | `IN` | Membership operator; quantifier separator | | `EVERY` | Universal quantifier | | `SOME` | Existential quantifier | Keywords cannot be used as variable or function names. `ALL_DIGITS` and `SOME_DIGITS` are **not** keywords. They are function-call patterns recognised by the macro expander and rewritten into `EVERY` / `SOME` form before evaluation — see [quantifiers](/guide/quantifiers). ## Operators ### Comparison `=` (strict equality), `!=`, `<`, `<=`, `>`, `>=`. Both operands are arithmetic expressions; the result is boolean. Equality is strict. This matters against the `null`-valued place variables: `place = 10` is false when `place` is `null`, and `place != 10` is true. See [decimal places](/guide/decimal-places). ### Membership ``` arithExpr IN ( item, item, … ) arithExpr NOT IN ( item, item, … ) ``` Each item is an arithmetic expression. At least one item is required; the list is comma-separated and parenthesized. `NOT IN` is a single two-word operator, not a `NOT` applied to an `IN` expression — though the two produce the same result. When the left-hand side evaluates to an array (only `digits(...)` does), membership tests whether **any** element of the array appears in the list. ### Arithmetic `+` and `-` only, left-associative. There is no multiplication, division, modulo, or exponentiation. ### Quantifiers ``` EVERY name IN iterable : condition SOME name IN iterable : condition ``` `name` is an identifier bound as the loop variable for the duration of `condition`; it shadows any outer variable of the same name and does not leak outside. `iterable` is an arithmetic expression that should evaluate to an array — in practice always a `digits(...)` call. The colon is required. `EVERY` over an empty iterable is true; `SOME` over an empty iterable is false. Quantifiers may nest, each with its own independent scope. ## Literals ### Numbers An integer, optionally preceded by a minus sign. Valid: `0`, `1`, `5`, `-3`, `100`, `-999` Invalid: `3.14`, `1.0`, `1e5`, `0x1F`, `1_000` ### There are no other literal types No booleans, no strings, no arrays. `true` and `false` are not literals — they parse as ordinary variable names and resolve to nothing. Arrays exist only as the return value of `digits(...)`; they cannot be written out. ## Identifiers An identifier begins with a letter and continues with letters, digits, and underscores. Identifiers are case-sensitive: `prev_sum` and `Prev_Sum` are different names. The grammar also accepts dot-separated paths (`a.b.c`), resolving the first segment from the evaluation context and the rest as property accesses. No context value in this system exposes properties, so a dotted path is never useful in practice. ### Variables Exactly nine names are in scope. Any other identifier is reported as an unknown variable by the editor's linter, and resolves to nothing at evaluation time. | Variable | Type | |----------|------| | `prev_sum` | number | | `current_value` | number | | `current_sum` | number | | `step_index` | number | | `last_step_index` | number | | `isLastStep` | boolean | | `place` | number or null | | `formula` | number or null | | `digitBefore` | number or null | Full descriptions are in [variables](/guide/variables) and the [context reference](/reference/context). ### Function calls ``` name ( arg, arg, … ) ``` Arguments are arithmetic expressions, comma-separated. **At least one argument is required** — zero-argument calls are not syntactically valid. Five functions exist, each with a fixed arity: | Function | Arity | |----------|-------| | `digits(x)` | 1 | | `highest_digit(x)` | 1 | | `lowest_digit(x)` | 1 | | `number_of_digits(x)` | 1 | | `digitAt(x, place)` | 2 | Calling an unknown name, or a known name with the wrong number of arguments, is flagged by the linter while editing; at evaluation time an unknown name raises a runtime error rather than a parse error. Semantics are in the [function reference](/reference/functions) and [digit functions](/guide/digit-functions). ## Not supported Constructs that are commonly attempted and are not part of the language: | Attempted | Status | |-----------|--------| | `prev_sum * 2`, `prev_sum / 10` | **Multiplication and division** are not in the grammar. Only `+` and `-` exist. | | `3.14`, `1.0` | **Decimal numbers** are not valid literals. Integers only. | | `1e5` | **Scientific notation** is not valid. | | `"abc"` | **Strings** do not exist — no string values, literals, or operators. | | `0 < current_value < 10` | **Chained comparisons** are not supported. Write `current_value > 0 AND current_value < 10`. | | `digits()` | **Zero-argument calls** are not valid syntax. Every function requires at least one argument. | | `ANY d IN digits(x): d = 9` | **`ANY` is not a keyword.** The quantifiers are `EVERY` and `SOME`. | | `current_value > 0 -- note` | **Comments** are not part of the grammar. The `--` annotations used in this documentation's examples are for the reader only; a rule containing one will not parse. | Also absent, with no planned equivalent: variable assignment, user-defined functions, string or date handling, and any form of loop other than the two quantifiers. --- # Rule Catalog Source: /reference/rule-catalog # Rule Catalog This is the lookup table for canonical rules. Each entry gives the seeded slug, the canonical rule id, the human-readable name, and the exact expression — copy the expression as written. ## The shape of a step-pattern rule Every step-pattern rule in this catalog is built from the same three clauses: ``` current_value > 0 AND formula = N AND digitBefore IN (d1, d2, …) ``` | Clause | Meaning | |--------|---------| | `current_value > 0` | The operation is an addition. Subtraction rules use `current_value < 0` instead. | | `formula = N` | The normalized formula digit is `N`. | | `digitBefore IN (…)` | The digit of the running total at the affected place must be one of the listed values. | Because `formula` normalizes the operand and `digitBefore` looks at a single digit place, these expressions are **place-independent**: one expression matches the pattern at the ones place, the tens place, the hundreds place, and beyond. Both `+8` and `+80` have `formula = 8`. See [decimal places](/guide/decimal-places) for how `formula` and `digitBefore` are derived, and [functions](/reference/functions) for their full definitions. The four step-pattern families partition every atomic step: a step crosses the 5-boundary (Rule 5), the 10-boundary (Rule 10), both (Combo), or neither (Plain). ## Rule 5 The operation crosses or reaches the 5-boundary within a single digit place, with no carry into the next place. Adding pushes the digit from the lower half-decade (0–4) up to or past 5; subtracting brings it back down through 5. Applies to formulas **1–4** only — larger formulas cannot cross 5 without also crossing 10. ### Addition | Slug | Rule ID | Name | Expression | |------|---------|------|------------| | `RULE-1` | `rule-5:1:add` | Rule 5 (1) - Addition | `current_value > 0 AND formula = 1 AND digitBefore IN (4)` | | `RULE-2` | `rule-5:2:add` | Rule 5 (2) - Addition | `current_value > 0 AND formula = 2 AND digitBefore IN (3, 4)` | | `RULE-3` | `rule-5:3:add` | Rule 5 (3) - Addition | `current_value > 0 AND formula = 3 AND digitBefore IN (2, 3, 4)` | | `RULE-4` | `rule-5:4:add` | Rule 5 (4) - Addition | `current_value > 0 AND formula = 4 AND digitBefore IN (1, 2, 3, 4)` | ### Subtraction | Slug | Rule ID | Name | Expression | |------|---------|------|------------| | `RULE-5` | `rule-5:1:subtract` | Rule 5 (1) - Subtraction | `current_value < 0 AND formula = 1 AND digitBefore IN (5)` | | `RULE-6` | `rule-5:2:subtract` | Rule 5 (2) - Subtraction | `current_value < 0 AND formula = 2 AND digitBefore IN (5, 6)` | | `RULE-7` | `rule-5:3:subtract` | Rule 5 (3) - Subtraction | `current_value < 0 AND formula = 3 AND digitBefore IN (5, 6, 7)` | | `RULE-8` | `rule-5:4:subtract` | Rule 5 (4) - Subtraction | `current_value < 0 AND formula = 4 AND digitBefore IN (5, 6, 7, 8)` | ## Rule 10 The operation crosses the 10-boundary of the affected place: the digit wraps off the top of the 0–9 range and carries into the next place, or wraps off the bottom and borrows. Applies to formulas **1–9**. For formulas 6–9 the digit sets split in two. Low-side digits carry without ever touching 5 (`4 + 6 = 10`); high-side digits carry from above 5 (`9 + 6 = 15`). The digits between those two groups belong to Combo instead, which is why the larger formulas have gapped digit lists such as `(4, 9)` and `(3, 4, 8, 9)`. ### Addition | Slug | Rule ID | Name | Expression | |------|---------|------|------------| | `RULE-9` | `rule-10:1:add` | Rule 10 (1) - Addition | `current_value > 0 AND formula = 1 AND digitBefore IN (9)` | | `RULE-10` | `rule-10:2:add` | Rule 10 (2) - Addition | `current_value > 0 AND formula = 2 AND digitBefore IN (8, 9)` | | `RULE-11` | `rule-10:3:add` | Rule 10 (3) - Addition | `current_value > 0 AND formula = 3 AND digitBefore IN (7, 8, 9)` | | `RULE-12` | `rule-10:4:add` | Rule 10 (4) - Addition | `current_value > 0 AND formula = 4 AND digitBefore IN (6, 7, 8, 9)` | | `RULE-13` | `rule-10:5:add` | Rule 10 (5) - Addition | `current_value > 0 AND formula = 5 AND digitBefore IN (5)` | | `RULE-14` | `rule-10:6:add` | Rule 10 (6) - Addition | `current_value > 0 AND formula = 6 AND digitBefore IN (4, 9)` | | `RULE-15` | `rule-10:7:add` | Rule 10 (7) - Addition | `current_value > 0 AND formula = 7 AND digitBefore IN (3, 4, 8, 9)` | | `RULE-16` | `rule-10:8:add` | Rule 10 (8) - Addition | `current_value > 0 AND formula = 8 AND digitBefore IN (2, 3, 4, 7, 8, 9)` | | `RULE-17` | `rule-10:9:add` | Rule 10 (9) - Addition | `current_value > 0 AND formula = 9 AND digitBefore IN (1, 2, 3, 4, 6, 7, 8, 9)` | ### Subtraction | Slug | Rule ID | Name | Expression | |------|---------|------|------------| | `RULE-18` | `rule-10:1:subtract` | Rule 10 (1) - Subtraction | `current_value < 0 AND formula = 1 AND digitBefore IN (0)` | | `RULE-19` | `rule-10:2:subtract` | Rule 10 (2) - Subtraction | `current_value < 0 AND formula = 2 AND digitBefore IN (0, 1)` | | `RULE-20` | `rule-10:3:subtract` | Rule 10 (3) - Subtraction | `current_value < 0 AND formula = 3 AND digitBefore IN (0, 1, 2)` | | `RULE-21` | `rule-10:4:subtract` | Rule 10 (4) - Subtraction | `current_value < 0 AND formula = 4 AND digitBefore IN (0, 1, 2, 3)` | | `RULE-22` | `rule-10:5:subtract` | Rule 10 (5) - Subtraction | `current_value < 0 AND formula = 5 AND digitBefore IN (0)` | | `RULE-23` | `rule-10:6:subtract` | Rule 10 (6) - Subtraction | `current_value < 0 AND formula = 6 AND digitBefore IN (0, 5, 6, 7, 8, 9)` | | `RULE-24` | `rule-10:7:subtract` | Rule 10 (7) - Subtraction | `current_value < 0 AND formula = 7 AND digitBefore IN (0, 1, 5, 6, 7, 8, 9)` | | `RULE-25` | `rule-10:8:subtract` | Rule 10 (8) - Subtraction | `current_value < 0 AND formula = 8 AND digitBefore IN (0, 1, 2, 5, 6, 7, 8, 9)` | | `RULE-26` | `rule-10:9:subtract` | Rule 10 (9) - Subtraction | `current_value < 0 AND formula = 9 AND digitBefore IN (0, 1, 2, 3, 5, 6, 7, 8, 9)` | ## Combo The operation crosses the 5-boundary and the 10-boundary at once — the digit passes through 5 on its way over 10, or back under 5 on its way below 0. Applies to formulas **6–9** only; smaller formulas cannot span both boundaries in one step. ### Addition | Slug | Rule ID | Name | Expression | |------|---------|------|------------| | `RULE-27` | `combo:6:add` | Combo (6) - Addition | `current_value > 0 AND formula = 6 AND digitBefore IN (5, 6, 7, 8)` | | `RULE-28` | `combo:7:add` | Combo (7) - Addition | `current_value > 0 AND formula = 7 AND digitBefore IN (5, 6, 7)` | | `RULE-29` | `combo:8:add` | Combo (8) - Addition | `current_value > 0 AND formula = 8 AND digitBefore IN (5, 6)` | | `RULE-30` | `combo:9:add` | Combo (9) - Addition | `current_value > 0 AND formula = 9 AND digitBefore IN (5)` | ### Subtraction | Slug | Rule ID | Name | Expression | |------|---------|------|------------| | `RULE-31` | `combo:6:subtract` | Combo (6) - Subtraction | `current_value < 0 AND formula = 6 AND digitBefore IN (1, 2, 3, 4)` | | `RULE-32` | `combo:7:subtract` | Combo (7) - Subtraction | `current_value < 0 AND formula = 7 AND digitBefore IN (2, 3, 4)` | | `RULE-33` | `combo:8:subtract` | Combo (8) - Subtraction | `current_value < 0 AND formula = 8 AND digitBefore IN (3, 4)` | | `RULE-34` | `combo:9:subtract` | Combo (9) - Subtraction | `current_value < 0 AND formula = 9 AND digitBefore IN (4)` | ## Plain The complement family: steps that cross no boundary at all. No carry, no borrow, no 5-crossing — the digit simply moves to another value inside the same half-decade (0–4 or 5–9). Applies to all formulas **1–9** in both directions. Each Plain digit set is exactly what is left over once the Rule 5, Rule 10, and Combo digits for that formula are removed. ### Addition | Slug | Rule ID | Name | Expression | |------|---------|------|------------| | `RULE-35` | `plain:1:add` | Plain (1) - Addition | `current_value > 0 AND formula = 1 AND digitBefore IN (0, 1, 2, 3, 5, 6, 7, 8)` | | `RULE-36` | `plain:2:add` | Plain (2) - Addition | `current_value > 0 AND formula = 2 AND digitBefore IN (0, 1, 2, 5, 6, 7)` | | `RULE-37` | `plain:3:add` | Plain (3) - Addition | `current_value > 0 AND formula = 3 AND digitBefore IN (0, 1, 5, 6)` | | `RULE-38` | `plain:4:add` | Plain (4) - Addition | `current_value > 0 AND formula = 4 AND digitBefore IN (0, 5)` | | `RULE-39` | `plain:5:add` | Plain (5) - Addition | `current_value > 0 AND formula = 5 AND digitBefore IN (1, 2, 3, 4)` | | `RULE-40` | `plain:6:add` | Plain (6) - Addition | `current_value > 0 AND formula = 6 AND digitBefore IN (0, 1, 2, 3)` | | `RULE-41` | `plain:7:add` | Plain (7) - Addition | `current_value > 0 AND formula = 7 AND digitBefore IN (0, 1, 2)` | | `RULE-42` | `plain:8:add` | Plain (8) - Addition | `current_value > 0 AND formula = 8 AND digitBefore IN (0, 1)` | | `RULE-43` | `plain:9:add` | Plain (9) - Addition | `current_value > 0 AND formula = 9 AND digitBefore IN (0)` | ### Subtraction | Slug | Rule ID | Name | Expression | |------|---------|------|------------| | `RULE-44` | `plain:1:subtract` | Plain (1) - Subtraction | `current_value < 0 AND formula = 1 AND digitBefore IN (1, 2, 3, 4, 6, 7, 8, 9)` | | `RULE-45` | `plain:2:subtract` | Plain (2) - Subtraction | `current_value < 0 AND formula = 2 AND digitBefore IN (2, 3, 4, 7, 8, 9)` | | `RULE-46` | `plain:3:subtract` | Plain (3) - Subtraction | `current_value < 0 AND formula = 3 AND digitBefore IN (3, 4, 8, 9)` | | `RULE-47` | `plain:4:subtract` | Plain (4) - Subtraction | `current_value < 0 AND formula = 4 AND digitBefore IN (4, 9)` | | `RULE-48` | `plain:5:subtract` | Plain (5) - Subtraction | `current_value < 0 AND formula = 5 AND digitBefore IN (6, 7, 8, 9)` | | `RULE-49` | `plain:6:subtract` | Plain (6) - Subtraction | `current_value < 0 AND formula = 6 AND digitBefore IN (6, 7, 8, 9)` | | `RULE-50` | `plain:7:subtract` | Plain (7) - Subtraction | `current_value < 0 AND formula = 7 AND digitBefore IN (7, 8, 9)` | | `RULE-51` | `plain:8:subtract` | Plain (8) - Subtraction | `current_value < 0 AND formula = 8 AND digitBefore IN (8, 9)` | | `RULE-52` | `plain:9:subtract` | Plain (9) - Subtraction | `current_value < 0 AND formula = 9 AND digitBefore IN (9)` | ## Global constraint rules These do not use the `formula` / `digitBefore` pattern structure. They read the running sum or the step position and act as guards, evaluated independently of the step-pattern families. | Slug | Rule ID | Name | Expression | |------|---------|------|------------| | `RULE-53` | `no_negative_sum` | No negative sum | `current_sum > 0 OR current_sum = 0` | | `RULE-54` | `plain:5:last_step_restriction` | Plain (5) - Last step restriction | `IF isLastStep THEN current_sum IN (0, 1, 2, 3, 4, 5) AND current_value > -6 AND current_value < 6` | `no_negative_sum` requires the running total to stay at or above zero at every step: `sum = 5 → 5 + 3 = 8` passes, `sum = 2 → 2 - 5 = -3` fails. `plain:5:last_step_restriction` only fires on the final step. When it fires it requires the total to land in `[0, 5]` and the step value to be small in absolute terms. On every other step the `IF` guard makes it vacuously true — see [quantifiers](/guide/quantifiers) for how conditional rules behave. ### Other seeded constraint rules The production data seeds a few more standalone constraint rules that are not part of the canonical step-pattern set. They are listed here because recipes reference them by slug. | Slug | Name | Expression | |------|------|------------| | `RULE-55` | Up to 4 | `current_value < 5 AND current_sum < 5` | | `RULE-56` | Current Value Up to 5 | `current_value < 6` | | `RULE-57` | Current Value up to 9 | `current_value < 10 AND current_value > -10` | | `RULE-58` | Place 1 | `place = 1` | | `RULE-63` | Place 2 | `place = 10` | ::: tip Place-scoped rules `place = 1` and `place = 10` restrict a rule to a single decimal-place channel. Use them when building multi-place composite steps — see [combo steps](/recipes/combo-steps) and [places](/recipes/places). ::: ## Consolidated "(any)" rules Each family also exists as a single consolidated rule that ORs together every clause in that family. Attach one of these when a recipe should accept the whole family rather than one specific formula and direction. They are exactly equivalent to attaching every individual rule of the family as alternatives — no new behavior, just fewer attachments. ### Rule 5 (any) Matches any Rule 5 step — all eight `rule-5:*` clauses, addition and subtraction, ORed together. Seeded as `RULE-59`. ``` (current_value > 0 AND formula = 1 AND digitBefore IN (4)) OR (current_value > 0 AND formula = 2 AND digitBefore IN (3, 4)) OR (current_value > 0 AND formula = 3 AND digitBefore IN (2, 3, 4)) OR (current_value > 0 AND formula = 4 AND digitBefore IN (1, 2, 3, 4)) OR (current_value < 0 AND formula = 1 AND digitBefore IN (5)) OR (current_value < 0 AND formula = 2 AND digitBefore IN (5, 6)) OR (current_value < 0 AND formula = 3 AND digitBefore IN (5, 6, 7)) OR (current_value < 0 AND formula = 4 AND digitBefore IN (5, 6, 7, 8)) ``` ### Rule 10 (any) Matches any Rule 10 step — all eighteen `rule-10:*` clauses, addition and subtraction. Seeded as `RULE-60`. ``` (current_value > 0 AND formula = 1 AND digitBefore IN (9)) OR (current_value > 0 AND formula = 2 AND digitBefore IN (8, 9)) OR (current_value > 0 AND formula = 3 AND digitBefore IN (7, 8, 9)) OR (current_value > 0 AND formula = 4 AND digitBefore IN (6, 7, 8, 9)) OR (current_value > 0 AND formula = 5 AND digitBefore IN (5)) OR (current_value > 0 AND formula = 6 AND digitBefore IN (4, 9)) OR (current_value > 0 AND formula = 7 AND digitBefore IN (3, 4, 8, 9)) OR (current_value > 0 AND formula = 8 AND digitBefore IN (2, 3, 4, 7, 8, 9)) OR (current_value > 0 AND formula = 9 AND digitBefore IN (1, 2, 3, 4, 6, 7, 8, 9)) OR (current_value < 0 AND formula = 1 AND digitBefore IN (0)) OR (current_value < 0 AND formula = 2 AND digitBefore IN (0, 1)) OR (current_value < 0 AND formula = 3 AND digitBefore IN (0, 1, 2)) OR (current_value < 0 AND formula = 4 AND digitBefore IN (0, 1, 2, 3)) OR (current_value < 0 AND formula = 5 AND digitBefore IN (0)) OR (current_value < 0 AND formula = 6 AND digitBefore IN (0, 5, 6, 7, 8, 9)) OR (current_value < 0 AND formula = 7 AND digitBefore IN (0, 1, 5, 6, 7, 8, 9)) OR (current_value < 0 AND formula = 8 AND digitBefore IN (0, 1, 2, 5, 6, 7, 8, 9)) OR (current_value < 0 AND formula = 9 AND digitBefore IN (0, 1, 2, 3, 5, 6, 7, 8, 9)) ``` ### Combo (any) Matches any Combo step — all eight `combo:*` clauses, addition and subtraction. Seeded as `RULE-61`. ``` (current_value > 0 AND formula = 6 AND digitBefore IN (5, 6, 7, 8)) OR (current_value > 0 AND formula = 7 AND digitBefore IN (5, 6, 7)) OR (current_value > 0 AND formula = 8 AND digitBefore IN (5, 6)) OR (current_value > 0 AND formula = 9 AND digitBefore IN (5)) OR (current_value < 0 AND formula = 6 AND digitBefore IN (1, 2, 3, 4)) OR (current_value < 0 AND formula = 7 AND digitBefore IN (2, 3, 4)) OR (current_value < 0 AND formula = 8 AND digitBefore IN (3, 4)) OR (current_value < 0 AND formula = 9 AND digitBefore IN (4)) ``` ### Plain (any) Matches any Plain step — all eighteen `plain:*` clauses, addition and subtraction. Seeded as `RULE-62`. ``` (current_value > 0 AND formula = 1 AND digitBefore IN (0, 1, 2, 3, 5, 6, 7, 8)) OR (current_value > 0 AND formula = 2 AND digitBefore IN (0, 1, 2, 5, 6, 7)) OR (current_value > 0 AND formula = 3 AND digitBefore IN (0, 1, 5, 6)) OR (current_value > 0 AND formula = 4 AND digitBefore IN (0, 5)) OR (current_value > 0 AND formula = 5 AND digitBefore IN (1, 2, 3, 4)) OR (current_value > 0 AND formula = 6 AND digitBefore IN (0, 1, 2, 3)) OR (current_value > 0 AND formula = 7 AND digitBefore IN (0, 1, 2)) OR (current_value > 0 AND formula = 8 AND digitBefore IN (0, 1)) OR (current_value > 0 AND formula = 9 AND digitBefore IN (0)) OR (current_value < 0 AND formula = 1 AND digitBefore IN (1, 2, 3, 4, 6, 7, 8, 9)) OR (current_value < 0 AND formula = 2 AND digitBefore IN (2, 3, 4, 7, 8, 9)) OR (current_value < 0 AND formula = 3 AND digitBefore IN (3, 4, 8, 9)) OR (current_value < 0 AND formula = 4 AND digitBefore IN (4, 9)) OR (current_value < 0 AND formula = 5 AND digitBefore IN (6, 7, 8, 9)) OR (current_value < 0 AND formula = 6 AND digitBefore IN (6, 7, 8, 9)) OR (current_value < 0 AND formula = 7 AND digitBefore IN (7, 8, 9)) OR (current_value < 0 AND formula = 8 AND digitBefore IN (8, 9)) OR (current_value < 0 AND formula = 9 AND digitBefore IN (9)) ``` --- # Recipe concepts Source: /recipes/concepts # Recipe concepts A **rule** decides whether one candidate step is allowed. A **recipe** decides what a whole exercise is allowed to look like — how long it is, how large the running sums may get, and what job each rule does inside it. A recipe is not one exercise. It is a blueprint: the trainer picks the exact step count at generation time, and the generator searches for a concrete sequence that satisfies everything the recipe asks for. ## Rule vs recipe | | Rule | Recipe | |---|---|---| | Scope | One candidate step (or one sequence, for sequence-scope rules) | A whole exercise | | Answers | "Is this move legal?" | "What kind of exercise may be generated?" | | Storage | Global and reusable across many recipes | References rules by attaching them | | Output | Passed / Failed / Skipped | A generated exercise, or a failure with a diagnosis | A rule never *proposes* a value — see [What is a rule?](/guide/what-is-a-rule). A recipe is what turns the permitted set into an actual sequence, by giving each rule a job. ## Recipe fields | Field | Type | Notes | |---|---|---| | `slug` | string | Stable identifier, e.g. `RECIPE-4`. | | `name` | string | 1–200 characters. | | `description` | string \| null | Up to 5000 characters. The learning goal, in plain language. | | `minStepsCount` | integer ≥ 1 | Minimum meaningful step count. Defaults to `1`. | | `sumMax` | integer 1–1999 | Inclusive running-sum ceiling. Defaults to `99`. | | `isActive` | boolean | Whether the recipe is offered for generation. | | `isSystem` | boolean | Marks seeded, canonical recipes. | | `rulesCount` | integer | Number of rule attachments; maintained by the server. | There is no `placeFilter` field and no per-exercise sum narrowing. Everything beyond `sumMax` and `minStepsCount` is expressed through the rules you attach — see [Restricting places](/recipes/places). ## The RecipeRule attachment A recipe does not own rules; it *attaches* them through a join record. The attachment — not the rule — carries the recipe-specific configuration: | Attachment field | Meaning | |---|---| | `ruleId` | Which global rule is being used. | | `usage` | What job the rule does in this recipe: `target`, `review`, `forbidden`, `constraint`, or `filler`. | | `place` | `0` for an ordinary global attachment; `1`, `10`, `100`, or `1000` to scope it to one decimal-place channel. See [Multi-place combo steps](/recipes/combo-steps). | | `minCount` / `maxCount` / `exactCount` | How many times the rule must or may match. All nullable. | The same rule can be attached to a recipe more than once, as long as each attachment uses a different `place`. Uniqueness is on the `(recipe, rule, place)` triple — a repeated `(rule, place)` pair is rejected as a duplicate. A single replace payload may carry up to 200 attachments. ::: tip Attach the same rule twice on purpose Attaching one rule at `place: 1` *and* at `place: 10` is a legitimate, common configuration: the same rule plays a different role in two channels of the same recipe. It is only an error when both attachments name the same place. ::: ## The five usage categories | Usage | Meaning | Required rule role | |---|---|---| | `target` | The main learning objective. The pattern the exercise is *about*. | `pattern` | | `review` | Previously learned material, allowed to reappear as repetition. | `pattern` | | `filler` | Permitted padding around the target steps. Anything the student may already do. | `pattern` | | `forbidden` | Must never appear. Logically equivalent to `maxCount = 0`. | `pattern` | | `constraint` | Validates whether a candidate step or sequence is allowed at all. | `constraint` | Role compatibility is enforced server-side, not merely suggested: attaching a `constraint`-role rule as a `target`, or a `pattern`-role rule as a `constraint`, is rejected. Only `pattern`-role attachments may carry a non-zero `place`. ### Why a recipe needs fillers A candidate step must match at least one `target`, `review`, or `filler` rule to be admitted. A step that matches nothing is rejected by default — the generator runs a whitelist, so a recipe can never quietly emit a pattern the student has not been taught just because nobody thought to forbid it. The practical consequence: a recipe with a narrow target and no fillers can only produce sequences its target alone can sustain. Fillers are what give the search room to move. ::: warning A recipe with no filler may not be able to open Generated exercises start from an empty abacus, so `prev_sum` is `0` at step 0 and `digitBefore` is `0` at every place. A recipe whose only pattern rules require a non-zero `digitBefore` has no legal first move and reports `infeasible`. The fix is a filler rule that can open from zero — see [Troubleshooting](/recipes/troubleshooting). ::: ## Count quotas Three optional integers on each attachment shape how often a rule may match: | Field | Meaning | |---|---| | `minCount` | The rule must match at least this many steps. | | `maxCount` | The rule may match at most this many steps. | | `exactCount` | The rule must match exactly this many steps. | Rules: - All three are non-negative integers, or `null`. - **`exactCount` cannot be combined with `minCount` or `maxCount`.** Use one style or the other. - When both are present, `minCount` must be ≤ `maxCount`. Typical usage by category: | Usage | Typical counts | |---|---| | `target` | `minCount` or `exactCount` | | `review` | `minCount`, `maxCount`, or `exactCount` | | `filler` | usually all null | | `forbidden` | all null — the usage already means "zero times" | | `constraint` | all null — counts do not apply to constraints | ## `minStepsCount` and the effective minimum `minStepsCount` is the *pedagogical* minimum an author sets. The recipe never stores an exact step count; the trainer supplies that at generation time. Separately, the count quotas imply their own floor. If a recipe demands at least one target step and at least two review steps, no exercise shorter than three steps can satisfy it: ```text derivedMinStepsCount = implied by the count quotas effectiveMinStepsCount = max(minStepsCount, derivedMinStepsCount) ``` A requested step count is valid when `stepsCount >= effectiveMinStepsCount`. Only `minStepsCount` is stored; the derived and effective values are computed. The derived value is estimated by summing `exactCount ?? minCount ?? 0` across `target`, `review`, and `filler` attachments. That is deliberately an *over*-estimate: one step can satisfy two rules at once (the canonical rule set is not a partition — some classifications overlap), so the true floor can be lower. The admin UI therefore shows it as a hint rather than a hard block. ## `sumMax` `sumMax` is the inclusive ceiling on every running sum an exercise may pass through. The floor is a hard `0` and is not configurable — a soroban has no negative state. It is a recipe field rather than a rule because the generator's feasibility analysis has to know the width of the sum axis before any rule runs. Rules can narrow the range further, but never widen it. [Sum range](/recipes/sum-range) covers the field in full, including how to confine sums to a window narrower than `sumMax` — which needs more care than it first appears. ## See also - [Sum range](/recipes/sum-range) — `sumMax`, and bounding sums to a window. - [Restricting places](/recipes/places) — "ones only", specific operand sets, pinning the opening. - [Multi-place combo steps](/recipes/combo-steps) — how `place` channels produce steps like `+47`. - [Worked examples](/recipes/cookbook) — complete, copy-pasteable configurations. --- # Sum range Source: /recipes/sum-range # Sum range Every exercise generated from a recipe walks a running total from step to step. `sumMax` is the highest value that total may ever reach. ## `sumMax` ```text 0 <= every running sum <= recipe.sumMax ``` The ceiling is **inclusive**: `sumMax: 19` permits a running sum of exactly `19`. The floor is a hard `0`, and it is not a recipe field — a soroban has no negative state, so there is nothing to configure. | Property | Value | |---|---| | Type | integer | | Range | `1` … `1999` | | Default on create | `99` | | Applies to | every running sum, including the value each step lands on | The upper bound of 1999 exists because `sumMax + 1` sizes the generator's feasibility analysis arrays; it is a memory bound, not a pedagogical one. `sumMax` also determines the generator's candidate operand alphabet: every power of ten up to `sumMax`. A recipe with `sumMax: 99` can produce steps in the ones and the tens; `sumMax: 9` can only produce steps in the ones. You do not configure that alphabet directly — you narrow it with rules, as [Restricting places](/recipes/places) describes. ## Why it is a recipe field, not a rule It is fair to ask why `sumMax` is not simply a constraint rule such as `current_sum <= 99`. The generator proves feasibility before it searches. That analysis is a dynamic program indexed by `(layer, sum)`, and it allocates its working arrays *before* any rule is evaluated — it has to know the width of the sum axis up front. A rule cannot size an allocation; it can only narrow a domain that already exists. So the division of labour is: - **`sumMax`** establishes the domain. Structural, authored on the recipe. - **A rule referencing `current_sum`** narrows that domain further. It can never widen it. Writing `current_sum <= 500` on a recipe with `sumMax: 99` does nothing. Writing `current_sum <= 19` on a recipe with `sumMax: 99` genuinely tightens the ceiling — with one important caveat, below. ## Confining sums to a narrower window Suppose you want every running sum inside `0..19`. The obvious move is to set `sumMax: 19` and be done, and for most recipes that is exactly right. But when you want a window narrower than the recipe's `sumMax` — or a floor above the structural `0` — you reach for a step-scope constraint rule. The naive version is not enough: ``` current_sum >= 0 AND current_sum <= 19 ``` This is incomplete. `current_sum` is `prev_sum + current_value`: it describes the state *after* the step is applied. It says nothing about where the exercise started. ::: warning `current_sum` alone does not bound the opening value `current_sum` constrains transitions only. With just the `current_sum` bound above, exercises were measured opening on start values well outside the window — 16 of 40 generated exercises opened above 19 (on values such as `26` and `22`), with the first step dropping *into* range from outside it. Every individual transition satisfied the rule; the opening was never a transition. ::: The fix is to bound the previous sum as well: ``` current_sum >= 0 AND current_sum <= 19 AND prev_sum <= 19 ``` `prev_sum <= 19` is not redundant with `current_sum <= 19`. The two clauses close different holes: | Clause | Rejects | |---|---| | `current_sum <= 19` | a step that would land above the window | | `prev_sum <= 19` | a step taken *from* a position already above the window — including the very first step, whose `prev_sum` is the opening value | With both clauses in place the same measurement produced 0 of 40 exercises opening outside `0..19`. This is the concrete technique for a recipe teaching carries into the tens, where sums must stay in `0..19` — a range no fixed digit-count setting could ever express. Either `sumMax: 19`, or a looser `sumMax` plus the rule above, works directly. ::: tip Prefer `sumMax` when the window starts at 0 If the window you want *is* `0..sumMax`, set `sumMax` and skip the rule — the ceiling is already enforced structurally and prunes the search more cheaply. Reach for the constraint rule when you need a floor above 0, or a ceiling below the recipe's `sumMax` for a subset of situations. ::: ## Related opening-value techniques A sum-range rule constrains transitions. If the *opening* value needs its own shape — a clean tens digit, a zero ones digit — guard on `step_index`: ``` IF step_index = 0 THEN digitAt(prev_sum, 1) = 0 ``` See [Restricting places](/recipes/places) for the full treatment of opening-value pinning, and [Variables](/guide/variables) for how `prev_sum`, `current_value`, and `current_sum` relate. ## Sum rules and place channels A rule attached to a place-channel (`place: 1`, `10`, `100`, `1000`) **cannot reference `current_sum`**, and compilation rejects it if it does. Inside a channel, `current_sum` would mean "`prev_sum` plus that one channel's digit" — an intermediate state a composite step never actually passes through, because it commits every channel together. Whole-step sum conditions belong on an ordinary global attachment (`place: 0`) with `usage: constraint`. Written that way, a sum rule works unchanged on recipes that produce composite steps. See [Multi-place combo steps](/recipes/combo-steps). ## See also - [Recipe concepts](/recipes/concepts) — where `sumMax` sits among the other recipe fields. - [Variables](/guide/variables) — `prev_sum` vs `current_sum`. - [Troubleshooting](/recipes/troubleshooting) — when a tight `sumMax` makes a recipe infeasible. --- # Restricting places Source: /recipes/places # Restricting places There is no `placeFilter` field on a recipe. "Ones only", "ones and tens", "everything except ones" — all of it is expressed through rules you attach to the recipe. This page is the practical catalogue of those expressions. It assumes you already know what `place`, `formula`, and `digitBefore` mean; if not, read [Decimal places](/guide/decimal-places) first. ## The basic place restriction Attach a rule with **`role: constraint`**, **`scope: step`**, **`usage: constraint`**, and leave the count fields null — counts do not apply to constraints. | Intent | Expression | |---|---| | Ones only | `place = 1` | | Tens only | `place = 10` | | Hundreds only | `place = 100` | | Ones and tens | `place IN (1, 10)` | | Tens and hundreds | `place IN (10, 100)` | | Everything except ones | `place NOT IN (1)` | These are evaluated inside the same classifier the feasibility analysis calls, so they prune candidates with the full power a dedicated structural filter would have had. The only difference is that an out-of-place candidate is generated and then rejected, rather than never generated — one extra memoized rule evaluation per distinct state. The generator's operand alphabet comes from `sumMax` alone (every power of ten up to the ceiling), so a `place` constraint is how you narrow it. See [Sum range](/recipes/sum-range). ::: danger A bare `place` constraint silently kills every composite step `place` is **`null`** on a multi-place composite step such as `+47` — a composite step does not have one affected place. A constraint like `place = 1` therefore rejects every composite step the recipe could otherwise produce, with no error and no diagnostic. The recipe simply generates atomic steps only, or reports `infeasible`. If your recipe uses place channels, do not restrict combinations with a bare `place` comparison. Use a per-place digit test instead: ``` digitAt(current_value, 1) != 0 ``` See [Multi-place combo steps](/recipes/combo-steps). ::: ## Restricting to a specific operand set Values like `10, 20, 30, 50` are **atomic** — exactly one non-zero digit — so they are already in the generator's candidate alphabet. Restricting a recipe to them needs no special mechanism, just a narrower expression: ``` place = 10 AND formula IN (1, 2, 3, 5) ``` Since `formula = abs(current_value) / place`, this matches the operands `±10, ±20, ±30, ±50`. `formula` uses `abs`, so **both signs are admitted by default**. Pin the sign explicitly when you want only additions: ``` current_value > 0 AND place = 10 AND formula IN (1, 2, 3, 5) ``` An expression this specific usually belongs on the pattern rule itself — the target or filler rule the recipe is built around — rather than as a separate global constraint. ## Conditioning on the running sum `digitBefore` is the digit of `prev_sum` at the place `current_value` affects. It is the key input for pattern classification, and it lets a rule describe the *position* a technique applies from: ``` current_value > 0 AND place = 10 AND formula = 8 AND digitBefore IN (2, 3, 4) ``` Read: "add 80, but only when the tens digit is currently 2, 3, or 4." A rule like this needs a `sumMax` with enough headroom for the resulting carry. `digitBefore >= 2` at the tens place implies `prev_sum >= 20`, and `+80` needs room up to roughly `prev_sum + 80`. A recipe whose `sumMax` is too tight for its own rules reports `infeasible` — correctly, not spuriously. ## Pinning the opening value Place and sum-range rules constrain *transitions*. They say nothing about `prev_sum` at step 0 unless a rule explicitly references `step_index`. This matters more than it sounds. A recipe pinning `place = 10` can still open on a value whose ones digit is non-zero, because nothing in `place = 10` constrains where the exercise starts. To pin the opening digit at a given place: ``` IF step_index = 0 THEN digitAt(prev_sum, 1) = 0 ``` `digitAt(value, place)` returns the digit of `abs(value)` at the given decimal place — useful for inspecting a place other than the one `current_value` itself affects. See [Digit functions](/guide/digit-functions) and the [function reference](/reference/functions). ::: tip When opening rules matter Every exercise the API generates opens from an empty abacus (`prev_sum = 0` at step 0), so a `step_index = 0` guard is often already satisfied. It becomes load-bearing when a caller supplies an explicit opening value as an authoring probe, since such a value is seated as `prev_sum` for step 0 without passing through the classifier — no pattern rule and no constraint applies to it. ::: ## Quick reference | Want | Expression | Attach as | |---|---|---| | Steps only in the ones | `place = 1` | `constraint` role, step scope, `usage: constraint` | | Steps only `±10, ±20, ±30, ±50` | `place = 10 AND formula IN (1, 2, 3, 5)` | `pattern` role, on the target or filler rule | | Only additions of that set | `current_value > 0 AND place = 10 AND formula IN (1, 2, 3, 5)` | `pattern` role | | Tens digit conditioned on the sum | `place = 10 AND formula = 8 AND digitBefore IN (2, 3, 4)` | `pattern` role, step scope | | Pin the opening digit | `IF step_index = 0 THEN digitAt(prev_sum, 1) = 0` | `constraint` role, step scope | | Restrict a channel combination | `digitAt(current_value, 1) != 0` | `constraint` role, step scope, `place: 0` | ## A note on conflicting restrictions A rule combination that admits nothing — two mutually exclusive `place` constraints on the same recipe, say — is **not detected when you save the recipe**. Feasibility is computed by the rule engine at generation time, not by the editor. The practical workflow is to generate a preview batch after editing and check that it still produces results. An empty or degraded batch is the signal; [Troubleshooting](/recipes/troubleshooting) covers reading the failure. ## See also - [Decimal places](/guide/decimal-places) — `place`, `formula`, `digitBefore`, and their null behaviour. - [Multi-place combo steps](/recipes/combo-steps) — producing steps that touch several places at once. - [Worked examples](/recipes/cookbook) — these expressions inside complete recipes. - [Common mistakes](/guide/mistakes) — expression-level pitfalls. --- # Multi-place combo steps Source: /recipes/combo-steps # Multi-place combo steps A step is normally **atomic** — it touches exactly one decimal place, like `+7` or `-30`. A recipe can also produce **composite** steps that touch several places at once, like `+47` or `-89`. This is how you build training material for whole two- and three-digit numbers: *"children work with numbers 10–99, where the tens and ones digits each independently follow the techniques they already know."* ## Channels An attachment of a rule to a recipe carries a **place**: | Place | Meaning | |-------|---------| | `0` | Ordinary, global attachment. The rule is eligible at every place. This is the default. | | `1`, `10`, `100`, `1000` | The attachment is scoped to that one decimal place — a **channel**. | A composite candidate is assembled by taking one digit per configured channel and combining them, with a single shared sign for the whole step. Each channel's digit is then classified **independently**, as if it were its own atomic step at that place. Every channel must find a matching rule, or the whole candidate is rejected. There is no cross-channel logic. No rule ever sees "the tens digit and the ones digit together" — each place is curated on its own terms. That is what makes the feature composable: you reuse the rules you already have, at the place you want them. ::: tip Place is per-attachment, not per-rule The same rule can be attached to one recipe more than once, at different places — for example a "Plain (1)" rule attached at both `10` and `1`. Each attachment is independent and carries its own usage and quotas. ::: ## Which places are available A channel above the recipe's own [`sumMax`](/recipes/sum-range) could never fire, so only places the sum ceiling can actually reach are offered: | `sumMax` | Places available | |----------|------------------| | 19 | `1`, `10` | | 99 | `1`, `10` | | 999 | `1`, `10`, `100` | | 1999 | `1`, `10`, `100`, `1000` | If you expected a hundreds channel and it is not offered, raise `sumMax` first. ## Eligibility: global rules still apply For any candidate landing on place `p` — whether it is an atomic step or one channel of a composite — the eligible rules are: > everything attached at `place: 0` **plus** everything attached at `place: p` Two consequences: **A channel-tagged rule also admits ordinary atomic steps at that place.** A rule attached at `place: 10` is not composite-only; it will happily match a plain `-10` step too. You do not need a second, global copy of it. **A global rule participates in every channel.** A rule attached at `place: 0` is eligible on the tens channel and the ones channel alike. Because every attachment defaults to `place: 0`, a recipe that uses no channels behaves exactly as it always did — this eligibility rule collapses to "check every attached rule". ## Two worked configurations ### Tens fixed at one bead, ones unrestricted *Numbers 10–19, where the tens digit only ever moves by a single bead and the ones digit follows the full set of techniques.* Recipe: `sumMax: 19`, `minStepsCount: 7`. | Rule | Usage | Place | |------|-------|-------| | Plain (1) – Addition | filler | `10` | | Plain (1) – Subtraction | filler | `10` | | Plain (9) – Addition / Subtraction | target | `1` | | Plain (7), (8) – Addition / Subtraction | review | `1` | | the remaining Plain rules | filler | `1` | | No negative sum | constraint | `0` | The tens channel gets only the two "1 bead" rules, so the tens digit can only ever step by one. The ones channel gets the whole family. Note that the Plain (1) rules appear **twice** — once in the tens channel, once as part of the ones channel's full family. That is intended, not a mistake. Generated steps look like `+11`, `+16`, `-19` — and also plain `-10`, which is an ordinary atomic step where the ones channel simply contributed nothing. Composite and atomic steps coexist freely; nothing forces every step to be composite. ### Both digits unrestricted *Numbers 10–99, where tens and ones each independently follow the same technique set.* Recipe: `sumMax: 99`, `minStepsCount: 6`. | Rule | Usage | Place | |------|-------|-------| | Plain (any) | target | `1` | | Plain (any) | target | `10` | | No negative sum | constraint | `0` | Two pattern attachments in total. `Plain (any)` is a consolidated rule matching the whole family in one expression — see [the rule catalog](/reference/rule-catalog). Generated steps look like `+25`, `-15`, `+37`, `-89`. ## Cost and limits ### Candidate growth Composite candidates grow as `2 × 9ⁿ` where *n* is the number of channels: | Channels | Candidates | |----------|-----------| | 2 | 162 | | 3 | 1,458 | | 4 | 13,122 | Channel count is not capped, but expect generation to get measurably slower past two or three channels. ### The 31-rule ceiling A recipe may attach at most **31 pattern-role rules**. Channels multiply against that ceiling: *N* rules across *C* channels costs `N × C` slots, not *N*. Attaching a full 18-rule family to two channels is 36 slots and simply will not compile. This is the trade-off the two configurations above illustrate: | Approach | Slots | You get | You give up | |----------|-------|---------|-------------| | Full family on one channel, minimal set on the other | 20 | Per-digit target / review / filler quotas | Variety on the restricted channel | | A consolidated "(any)" rule per channel | 2 | Full variety on every channel | Per-digit quotas | The consolidated "(any)" rules exist precisely as the escape hatch when you hit the ceiling and do not need per-digit quotas. ## Two things that will surprise you ::: danger A `place = X` constraint silently rejects every composite step `place` is `null` on a composite step, so a constraint like `place = 10` or `place IN (1, 10)` evaluates to false for *every* composite candidate — regardless of which digits it actually contains. The recipe then produces only atomic steps, or reports as infeasible, with no error explaining why. To restrict which places a composite step may touch, use `digitAt(current_value, p)` instead of `place`. See [the null trap](/guide/decimal-places#the-null-trap). ::: ::: warning A place-scoped rule may not reference `current_sum` Inside one channel, "the sum after this step" would mean the running total plus that single channel's digit — an intermediate state the exercise never actually passes through, since a composite step commits all of its digits together. Referencing `current_sum` in a place-scoped rule is rejected when the recipe is compiled. Whole-step sum conditions belong in an ordinary `constraint` rule at `place: 0`, where they are evaluated once against the real, complete step value. A "no negative sum" constraint works unchanged on composite recipes. ::: ## How a composite step is reported A generated composite step reports `place`, `formula`, and `digitBefore` as `null`, and instead carries a per-channel breakdown: for each place it touched, the digit and the specific attachment that matched there. An atomic step is the reverse — the three fields are set and the breakdown is absent. A step is always exactly one of the two. ## See also - [Decimal places](/guide/decimal-places) — what `place` / `formula` / `digitBefore` mean and why they go null - [Restricting places](/recipes/places) — single-place restriction, which still works the classic way - [Worked examples](/recipes/cookbook) - [Troubleshooting](/recipes/troubleshooting) --- # Worked examples Source: /recipes/cookbook # Worked examples Four complete recipes, each stated as a goal in plain language, then the full configuration, then what the generated output looks like. Rule slugs and names refer to the canonical seeded catalog — see [Canonical rules](/reference/rule-catalog). ## 1. A single-technique recipe **Goal.** Train the "no rule 9" Plain family in the ones: `9` is the technique being learned, `7` and `8` are recent enough to be worth reviewing, and everything smaller is fair padding. **Recipe settings** | Field | Value | |---|---| | `sumMax` | `9` | | `minStepsCount` | `2` | `sumMax: 9` does the place restriction on its own here: the operand alphabet derived from a ceiling of 9 contains only the ones place, so no `place` constraint is needed. **Rule attachments** | Rule | Name | Usage | Place | |---|---|---|---| | `RULE-43` | Plain (9) - Addition | target | 0 | | `RULE-52` | Plain (9) - Subtraction | target | 0 | | `RULE-41` | Plain (7) - Addition | review | 0 | | `RULE-42` | Plain (8) - Addition | review | 0 | | `RULE-50` | Plain (7) - Subtraction | review | 0 | | `RULE-51` | Plain (8) - Subtraction | review | 0 | | `RULE-35`…`RULE-40` | Plain (1)…(6) - Addition | filler | 0 | | `RULE-44`…`RULE-49` | Plain (1)…(6) - Subtraction | filler | 0 | | `RULE-53` | No negative sum | constraint | 0 | Count fields are left null throughout. The recipe leans on the whitelist rather than quotas: because a step must match *some* target, review, or filler rule to be admitted, the 18 Plain rules already define exactly the space the student is allowed to work in. **What it generates.** Single-digit steps only, running sums walking around `0..9`, mixing the target `±9` moves with smaller Plain padding. A typical trace: `0 → +3 → +5 → -1 → +2 → -9`. ::: tip Add a quota when the target must actually appear With no `minCount` on the target rules, nothing forces a `±9` step into any given exercise — the search is free to produce an all-filler sequence. Adding `minCount: 1` to one or both target attachments makes the technique mandatory, at the cost of a slightly harder search. ::: ## 2. A bounded-sum recipe (0..19) **Goal.** Train carrying into the tens, with every running sum confined to `0..19`. **Recipe settings** | Field | Value | |---|---| | `sumMax` | `19` | | `minStepsCount` | `6` | **Rule attachments** | Rule | Name | Usage | Place | |---|---|---|---| | `RULE-43` | Plain (9) - Addition | target | 0 | | `RULE-52` | Plain (9) - Subtraction | target | 0 | | `RULE-35`…`RULE-40` | Plain (1)…(6) - Addition | filler | 0 | | `RULE-44`…`RULE-49` | Plain (1)…(6) - Subtraction | filler | 0 | | `RULE-53` | No negative sum | constraint | 0 | Setting `sumMax: 19` is the whole story when the window starts at `0`. The ceiling is enforced structurally, and it also caps the operand alphabet at the tens. **If the window does not start at 0** — or if you want a `0..19` window on a recipe whose `sumMax` is deliberately looser — add one more constraint attachment: ``` current_sum >= 0 AND current_sum <= 19 AND prev_sum <= 19 ``` | Rule | Name | Usage | Place | |---|---|---|---| | *(new constraint rule)* | Sums within 0..19 | constraint | 0 | The `prev_sum <= 19` clause is not redundant. `current_sum` only describes the state *after* a step, so bounding it alone leaves the opening value free to sit outside the window — [Sum range](/recipes/sum-range) has the measured demonstration and the reasoning. **What it generates.** Sequences whose running total crosses 10 and comes back, never exceeding 19 and never going negative. ## 3. Multi-place: "10-19, tens fixed at 1 bead" **Goal.** Children learn the numbers 10-19. The tens digit only ever moves by exactly one bead, while the ones digit follows the full "no rule 9" Plain family. This is the first recipe on this page that produces **composite** steps — single steps touching two decimal places at once, such as `+11` or `-11`. That works by attaching rules to a place-*channel* rather than globally. [Multi-place combo steps](/recipes/combo-steps) explains the mechanism; here is the configuration. **Recipe settings** | Field | Value | |---|---| | `sumMax` | `19` | | `minStepsCount` | `7` | **Tens channel — one bead, no variety** | Rule | Name | Usage | Place | |---|---|---|---| | `RULE-35` | Plain (1) - Addition | filler | 10 | | `RULE-44` | Plain (1) - Subtraction | filler | 10 | **Ones channel — the full 18-rule Plain family** | Rule | Name | Usage | Place | |---|---|---|---| | `RULE-43` | Plain (9) - Addition | target | 1 | | `RULE-52` | Plain (9) - Subtraction | target | 1 | | `RULE-41` | Plain (7) - Addition | review | 1 | | `RULE-42` | Plain (8) - Addition | review | 1 | | `RULE-50` | Plain (7) - Subtraction | review | 1 | | `RULE-51` | Plain (8) - Subtraction | review | 1 | | `RULE-35`…`RULE-40` | Plain (1)…(6) - Addition | filler | 1 | | `RULE-44`…`RULE-49` | Plain (1)…(6) - Subtraction | filler | 1 | **Global attachment** | Rule | Name | Usage | Place | |---|---|---|---| | `RULE-53` | No negative sum | constraint | 0 | `RULE-35` and `RULE-44` are attached **twice each** — once at `place: 10` as the fixed-bead tens channel, once again at `place: 1` inside the ones channel's family. That is not a mistake: the same rule legitimately plays two different roles in two different channels of the same recipe, and uniqueness is on `(recipe, rule, place)`. Drop the `place: 1` copies and the ones channel loses its digit `1`, which makes `+11` and `-11` unreachable. **Cost.** 20 pattern-role attachments. The ceiling is 31, and channels multiply against it — see the note below. **What it generates.** A mix of composite steps such as `+11`, `+19`, `-13` and ordinary atomic steps. A `-10` in the output is not a bug: it is an atomic step where the ones channel simply contributed nothing that particular step. Atomic and composite steps coexist naturally, because a channel-tagged rule also admits a standalone atomic step at that same place. ::: warning Do not add a `place` constraint to a channel recipe Attaching a `place = 1`-style constraint rule here would reject every composite step silently — `place` is `null` on composite steps. See [Restricting places](/recipes/places) and [Multi-place combo steps](/recipes/combo-steps). Similarly, a last-step constraint written for single-digit work (e.g. one requiring `current_value > -6 AND current_value < 6`) is incompatible with a tens-channel step and will make this recipe infeasible. ::: ## 4. Multi-place: "10-99, both digits follow no rule 9" **Goal.** Children learn the numbers 10-99, where the tens digit and the ones digit each independently avoid Rule 9 — that is, `9` is the target digit at *both* places, not merely permitted there. **Recipe settings** | Field | Value | |---|---| | `sumMax` | `99` | | `minStepsCount` | `6` | **Rule attachments** | Rule | Name | Usage | Place | |---|---|---|---| | `RULE-62` | Plain (any) | target | 1 | | `RULE-62` | Plain (any) | target | 10 | | `RULE-53` | No negative sum | constraint | 0 | Three attachments; two of them pattern-role. `RULE-62` is a **consolidated** rule — one expression covering the whole Plain family, both signs, every digit — attached once per channel. **Why consolidate.** The alternative is the 18-rule family duplicated across two channels: `18 × 2 = 36` pattern-role attachments, over the ceiling of 31, which simply will not compile. Consolidating sidesteps the multiplication entirely. **What you give up.** A consolidated rule cannot carry a per-digit target/review/filler split. This configuration says "any Plain digit at either place", not "Plain(9) is specifically the target and Plain(7)/(8) are specifically review". For this recipe's stated intent that is the correct reading anyway — but if you need per-digit quotas on one channel, attach the full family to that channel and the consolidated rule to the other (`18 + 1 = 19`, comfortably under the ceiling). The other consolidated rules follow the same pattern: `RULE-59` (Rule 5 (any)), `RULE-60` (Rule 10 (any)), `RULE-61` (Combo (any)), `RULE-62` (Plain (any)). **What it generates.** Two-digit composite steps such as `+47` or `-83`, alongside atomic steps at either place, with running sums inside `0..99`. ## Choosing between examples 3 and 4 The two multi-place recipes sit at opposite ends of the same tradeoff: | | Example 3 | Example 4 | |---|---|---| | Pattern-role attachments | 20 | 2 | | Per-digit target/review/filler split | Yes, on the ones channel | No | | Channels | 2 (tens fixed, ones full family) | 2 (both consolidated) | | Headroom under the 31-attachment ceiling | 11 | 29 | Reach for consolidated "(any)" rules whenever a per-digit quota split is not actually needed for a given channel. Reach for the full family when the pedagogy depends on naming which digit is the target. ## See also - [Recipe concepts](/recipes/concepts) — usage categories, count quotas, `minStepsCount`. - [Sum range](/recipes/sum-range) — the `0..19` window technique in full. - [Restricting places](/recipes/places) — place expressions and opening-value pinning. - [Multi-place combo steps](/recipes/combo-steps) — how channels actually work. - [Troubleshooting](/recipes/troubleshooting) — when one of these stops generating. --- # Troubleshooting Source: /recipes/troubleshooting # Troubleshooting A recipe that will not generate fails in one of a small number of recognisable ways. This page maps each failure to its usual causes. ## Reading the failure Generation reports one of five outcomes: | Reason | Meaning | |---|---| | `invalid-options` | The requested step count or start value is not acceptable. | | `invalid-recipe` | The recipe could not even be compiled — a structural problem, reported before any search. | | `infeasible` | **Proven** by the feasibility analysis: no valid sequence exists, with attribution to the rules at fault. | | `exhausted` | The whole reachable search tree was explored without finding a sequence. | | `budget-exceeded` | The search ran out of node/time budget. **Not** a proof of infeasibility. | The distinction matters. `infeasible` is a mathematical statement about your recipe and always deserves a configuration change. `budget-exceeded` says only that the search gave up — retrying, or loosening the recipe slightly, may succeed. The failure carries diagnostics worth reading before changing anything: - **`unsatisfiable`** — which rules' count requirements cannot be met, and by how much. - **`rejectionHistogram`** — how many candidates each rule rejected, keyed by rule slug. A synthetic `unclassified` key counts candidates that matched no pattern rule at all. - **`minFeasibleSteps`** — the shortest step count that would have worked, when one exists. A rule sitting at the top of the rejection histogram is the first place to look. ## "Recipe is infeasible for the requested stepsCount" The message names the rules whose count requirements could not be satisfied: ```text Recipe is infeasible for the requested stepsCount: RULE-X cannot have its count requirements satisfied. ``` Three causes account for most occurrences. ### Cause 1: the rule's own transition structure cannot chain that far An `infeasible` verdict on a rule that "looks correct" is very often arithmetic, not a broken expression. Take a rule admitting only `+80` when the tens digit is 2, 3, or 4: ``` current_value > 0 AND place = 10 AND formula = 8 AND digitBefore IN (2, 3, 4) ``` Applying it maps the tens digit `4 → 2 → 0`. After two applications the tens digit is `0`, which the rule no longer admits. The rule sustains **exactly two consecutive steps** and goes infeasible at three or more, no matter how the rest of the recipe is configured. Before debugging the expression, check the requested step count against what the rule's structure can actually chain. Count the digit transitions the rule permits and see how long a walk they support. ### Cause 2: `sumMax` is too tight for the carry the rule needs A rule conditioning on `digitBefore` implicitly requires the running sum to reach a certain magnitude, and then to have headroom for the result. The same `+80`-at-`digitBefore IN (2, 3, 4)` rule needs `prev_sum >= 20` before it can ever fire, and room up to roughly `prev_sum + 80` afterwards. On a recipe with `sumMax: 99` there is barely any slack; on a tighter ceiling the rule can never fire at all. The `infeasible` report is correct in this case — the recipe genuinely has no solution. Raise `sumMax` (up to the limit of 1999) or relax the `digitBefore` set. ### Cause 3: no rule admits an opening from an empty abacus Generated exercises open from an empty abacus. That means `prev_sum` is `0` at step 0, so `digitBefore` is `0` at every place. A recipe whose pattern rules all require a non-zero `digitBefore` has no legal first move. It reports `infeasible`, and that is the honest signal: the recipe cannot be worked from zero. Plenty of canonical patterns do admit `digitBefore = 0` — most Plain additions include `0` in their digit set (`RULE-43`, Plain (9) - Addition, admits `digitBefore` of `0` and nothing else), and so do several Rule 10 subtractions. The fix is to give the recipe a filler rule that can open from zero. ::: tip Check the structural message If the failure message is the structural variant — *"No admissible sequence exists for any candidate start value"* — no individual rule could be blamed. That points at the constraints and the sum range rather than at any single pattern rule's quota. ::: ## A misspelled variable silently blocks everything ::: danger Variable names are `snake_case` `currentSum`, `prevSum`, and `stepIndex` are **not variables**. They are unrecognised identifiers. ::: An unrecognised identifier resolves to `undefined`. Every comparison against `undefined` is false, so the rule fails on every candidate and silently blocks the entire recipe. Nothing warns you: unknown *functions* raise an error, but unknown *variables* do not. Worse, the failure does not name the typo. It reports the *pattern* rule whose count requirement went unsatisfiable — an entirely different, correctly written rule: ```text Recipe is infeasible for the requested stepsCount: RULE-X cannot have its count requirements satisfied. ``` `RULE-X` is innocent. The constraint containing `currentSum <= 19` is what emptied the candidate space. **The diagnostic habit:** if a recipe worked and suddenly reports `infeasible` after an edit, check every newly added or edited expression for a stray camelCase variable *before* assuming the arithmetic is wrong. The correct spellings are `prev_sum`, `current_value`, `current_sum`, `step_index`, `last_step_index`, plus `isLastStep`, `place`, `formula`, and `digitBefore` — see [Variables](/guide/variables) and [Context variables](/reference/context). ## A `place` constraint on a recipe with channels If a recipe attaches rules to place-channels (`place: 1`, `10`, `100`, `1000`) *and* also carries a constraint rule that compares the `place` context variable: ``` place = 1 ``` …then every **composite** step is silently rejected. `place` is `null` on a composite step — a step touching several decimal places has no single affected place — so the comparison fails for all of them. Symptoms: the recipe generates, but produces only atomic steps and never the multi-place steps the channels were configured for. Or, if the channels were load-bearing, it reports `infeasible`. There is no error message pointing at the constraint. **The fix:** restrict channel combinations with a per-place digit test instead of a bare `place` comparison: ``` digitAt(current_value, 1) != 0 ``` The same trap catches constraints written for single-digit work. A last-step rule requiring `current_value > -6 AND current_value < 6`, for example, cannot be satisfied by a tens-channel step and will make a channel recipe infeasible. See [Restricting places](/recipes/places) and [Multi-place combo steps](/recipes/combo-steps). ## `current_sum` inside a place-scoped rule A rule attached at a non-zero `place` that references `current_sum` is rejected at compile time with an `invalid-recipe` failure. This is not a bug to work around: inside a channel, `current_sum` would mean "`prev_sum` plus that one channel's digit", a state the exercise never actually passes through, since a composite step commits every channel together. Move whole-step sum conditions to an ordinary `place: 0`, `usage: constraint` attachment. Written there they work unchanged on channel recipes. ## Exceeding the 31 pattern-rule ceiling Compilation fails with `invalid-recipe` when a recipe has more than **31 pattern-role attachments**: ```text Recipe has more than 31 pattern rules; the bitmask representation cannot address them. ``` The ceiling counts *attachments*, not distinct rules — and channels multiply against it. `N` rules attached across `C` channels costs `N × C` slots, not `N`: | Configuration | Pattern-role attachments | |---|---| | 18-rule Plain family, one channel | 18 | | 18-rule family on ones + 2 rules on tens | 20 | | 18-rule family on ones + 1 consolidated rule on tens | 19 | | 18-rule family duplicated across two channels | 36 — will not compile | | 1 consolidated rule on each of two channels | 2 | Constraint-role attachments do not count toward this ceiling. **The escape hatch** is the consolidated "(any)" rules — `RULE-59` (Rule 5 (any)), `RULE-60` (Rule 10 (any)), `RULE-61` (Combo (any)), `RULE-62` (Plain (any)). Each collapses a whole family into one rule and one slot. The cost is that a consolidated rule cannot carry a per-digit target/review/filler split. Reach for consolidation on the channels where a per-digit quota split is not actually needed, and keep the full family on the one channel where it is. [Worked examples](/recipes/cookbook) shows both ends of this tradeoff. ## Other structural rejections These all surface as `invalid-recipe`, before any search runs: | Problem | Rule of thumb | |---|---| | A `constraint`-role rule attached with usage `target`/`review`/`filler`/`forbidden` | Only `usage: constraint` accepts a constraint-role rule. | | A `pattern`-role rule attached with `usage: constraint` | Constraint usage requires a constraint-role rule. | | A non-zero `place` on a constraint-role rule | Only pattern rules may be scoped to a channel. | | `exactCount` alongside `minCount` or `maxCount` | Use one style or the other. | | `minCount` greater than `maxCount` | Impossible by construction. | | The same rule attached twice at the same place | Uniqueness is on `(recipe, rule, place)`. | ## A checklist When a recipe stops generating, in order: 1. Read the failure reason. Is it a proof (`infeasible`) or a give-up (`budget-exceeded`)? 2. Read the rejection histogram. Which rule is rejecting everything? 3. Scan every recently edited expression for camelCase variable names. 4. Check whether the target rule's digit transitions can chain for the requested step count. 5. Check `sumMax` against the largest running sum the rules imply. 6. Confirm at least one pattern rule admits `digitBefore = 0`, so the exercise can open. 7. If the recipe uses channels, confirm no constraint compares `place` directly. 8. Count the pattern-role attachments against the ceiling of 31. ## See also - [Recipe concepts](/recipes/concepts) — usage/role compatibility and count quotas. - [Sum range](/recipes/sum-range) — `sumMax` and narrower windows. - [Restricting places](/recipes/places) — place expressions and their null behaviour. - [Common mistakes](/guide/mistakes) — expression-level pitfalls.