Skip to content

Numeric comparisons in conditions, and two loop defects they uncovered - #12

Merged
inhuman merged 3 commits into
mainfrom
numeric-conditions
Aug 13, 2026
Merged

Numeric comparisons in conditions, and two loop defects they uncovered#12
inhuman merged 3 commits into
mainfrom
numeric-conditions

Conversation

@inhuman

@inhuman inhuman commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Closes the embedder's task on numeric conditions: >, >=, <, <= where
== and contains already live. Format 2.3.0.

Why

Numbers were in the flow all along — counters, ids, thresholds parsed out of a
request — and the only thing expressible about one was equality with a literal
(ci.id == 0 as a sentinel). Everything else went into the TEXT of a step,
where a deterministic rule ends up applied by a model, or into an asset: a
network call to compare two numbers.

The refusal was not hypothetical. On the first day skills were being written by
a model rather than by hand, it wrote {{pod.restartCount}} > 5, got a parse
error, and wrote the same form again in another step of the same session. Both
attempts were a condition in the body of a for_each.

What is in

  • var > 5, var >= req.limit, var < 0.5, var <= days. The right side may
    be a number or the NAME of a variable holding one — a threshold is rarely a
    constant.
  • Both ways of not being a number are LOUD. A non-number stops the turn; an
    empty or failed variable is NOT zero and stops it too, with the error naming
    var is not empty as the way to allow emptiness. A condition is the one place
    where a wrong answer leaves no trace: restarts > 5 looks right whatever it
    returns.
  • Two integers compare as integers, so a nineteen-digit id does not lose its
    last digits to float64. NaN, Inf and hex floats are not numbers here.
  • == is unchanged and still textual: "5" == "5.0" stays false, and
    coercion would quietly change equalities that compare ids and sentinels.
  • Braces are still refused ({{x}} > 5), but the error now names the braces and
    prints the condition without them, instead of listing the allowed shapes.
  • No arithmetic, no collection filter: for_each plus a condition in its body
    is the shape the case actually arrives in.

Two defects the live shape uncovered

Both were blocking the very example the task is written around, and both are
separate commits.

  • A loop over a JSON array of OBJECTS handed its body Go's map formatting
    (map[name:api restartCount:12]). Every field lookup inside the loop —
    {{pod.name}}, a condition on pod.restartCount — resolved to emptiness, and
    the model was shown a syntax belonging to the language the engine happens to
    be written in.
  • collect gathered a duplicate wherever an iteration produced nothing. The
    variable was read after every iteration and never cleared, so a loop picking
    two items out of three returned three, and the extra one looks exactly like an
    honest result. parallel had it right all along (it collects produced());
    for_each was the odd one out.

Go API

CondVarCondVars ([]string). A comparison names a variable on both
sides, and a reader that saw only the left would leave a typo in a threshold
unchecked — which is what such a reader is usually looking for. The linter's W14
now catches it.

Цикл по JSON-массиву объектов отдавал элемент через fmt.Sprint, то есть
Go-форматированием карты (`map[name:api restartCount:12]`). Внутри тела
разваливалось всё, что обращается к полю: `{{pod.name}}` и условие на
`pod.restartCount` находили пустоту, а модели показывался синтаксис языка,
на котором случайно написан движок.

Строка остаётся строкой, всё остальное сериализуется обратно в JSON.

Вылезло на числовых условиях: живой случай из задачи — порог внутри
for_each по подам — без этого не работает вовсе.
`var > 5`, `>=`, `<`, `<=` рядом с `==`, `is [not] empty` и `contains`.
Справа либо число, либо ИМЯ переменной: порог почти никогда не литерал,
он приезжает из шага, разобравшего запрос.

Живой отказ, ради которого это сделано: в первый же день, когда скиллы
стал писать не человек, а модель, она написала `{{pod.restartCount}} > 5`,
получила отказ разбора и написала ту же форму ещё раз другим шагом. Оба
раза — условие в теле for_each: «оставить те, что выше порога» пишут
циклом с ветвлением внутри, поэтому отдельной фильтрации коллекций не
добавлено.

Два правила против невидимо неверной ветки:
- не-число РОНЯЕТ ход, а не сваливается тихо в false. Условие —
  единственное место, где неверный ответ не оставляет следа;
- пустая (или отказавшая) переменная — НЕ ноль. Прочитать её как 0 значит
  сделать «шаг ничего не вернул» неотличимым от «число маленькое»; кому
  пустота законна, пишет `var is not empty` рядом.

Два целых сравниваются как целые: девятнадцатизначный идентификатор не
теряет последние цифры в float64. `==` остаётся ТЕКСТОВЫМ — `"5" == "5.0"`
по-прежнему ложно, иначе поменялся бы смысл уже написанных равенств по
идентификаторам и часовым. Арифметики нет: выражения — дверь к скиллам,
которые нельзя прочитать сверху вниз.

Скобки в условии по-прежнему отвергаются (поле держит ИМЯ, одно написание),
но текст отказа теперь называет именно их и печатает условие без них.

CondVar → CondVars: сравнение называет переменную с обеих сторон, и
читатель, видевший только левую, пропускал опечатку в пороге — ровно то,
что такой читатель и ищет. Линтер W14 теперь её видит.
Переменная читалась после каждой итерации и никогда не очищалась, поэтому
итерация, ничего не записавшая (ветка внутри не сработала), вносила
оставленное предыдущей. Цикл, выбирающий два элемента из трёх, возвращал
три, и лишний неотличим от честного результата.

Теперь она опустошается перед телом. Тем же движением чинится протухшее
чтение в другую сторону: шаг ниже по телу видел значение ПРЕДЫДУЩЕЙ
итерации там, где эта ничего не записала.

Соседняя конструкция вела себя правильно всё это время: `parallel`
собирает `produced()` — только то, что ветка действительно записала.
`for_each` был единственным местом с такой семантикой.

Дедупликации не появилось: две итерации, честно давшие одинаковый ответ,
дают два результата. Обход через пустой `else` больше не нужен (и убран
из примера в README).
@inhuman
inhuman merged commit 2a3109c into main Aug 13, 2026
1 check passed
@inhuman
inhuman deleted the numeric-conditions branch August 13, 2026 08:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant