Skip to content

hal2Bayes: explain the estimate, and let a rule be an AND - #44

Merged
flic merged 7 commits into
mainfrom
bayes-explain
Aug 1, 2026
Merged

hal2Bayes: explain the estimate, and let a rule be an AND#44
flic merged 7 commits into
mainfrom
bayes-explain

Conversation

@flic

@flic flic commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Tuning a Bayes node meant reading a probability and guessing what produced it. The snapshot on
output 2 already carried the raw material — activeRules, terms and fsm — but not in a form
anyone could use: rules appeared as internal ids, contributions were in log-odds while the whole
editor speaks share of the way to on in percent, the three fields had to be cross-read against
each other, and only contributing rules appeared at all. The question while tuning is usually
why a rule is not firing, and nothing in the snapshot answered it.

One rules array

The three fields are replaced by one array listing every configured rule, every time, with a
status saying which case it is in.

{ "p": 0.86, "logOdds": 1.86, "share": 108.4, "binary": true, "held": false,
  "rules": [
    { "label": "While Hall Rörelse · Motion is true",
      "status": "contributing", "share": 73.8, "logOdds": 2.303, "value": true },
    { "label": "When Ytterdörr · Contact is true on a full cycle and Hall Rörelse · Motion is true",
      "status": "waiting", "step": 2, "steps": 2, "deadline": 47 },
    { "label": "While Kontor Sensor · Temperature > 25",
      "status": "condition-false", "value": 23.4 }
  ] }

contributing, fading (with age and half-life), waiting (with step and deadline), armed,
condition-false (with failedStep), no-value, injected, never-fires. Each entry carries
what its source reads right now, so a false condition can be seen rather than inferred.

Contributions are reported as shares of the way from the prior to the on-threshold — the unit
the rule bars and the summary already use, so what you tuned is what you read back. Log-odds are
additive, so the shares are too: the contributing ones sum to the snapshot's own share, pinned by
a test, because a report that does not add up is a report that lies.

The report is built in the same pass that computes the estimate, so a rule cannot be described as
contributing unless its weight really went into the log-odds.

Labels are derived from the steps and phrased as the editor phrases them. The phrasing lives in a
UMD module (resources/bayes-label.js) so the editor can adopt it later; name resolution stays in
the node, the only layer that can reach RED.nodes.

A rule of only conditions is an AND

Found by using the report: a live rule sat at armed forever, and asking why exposed a real gap.

"While it is 09:00–10:00 and the terrace is above 100 lux" could not be expressed. Anything
with more than one step went to the sequence machinery, which is driven by edges — and a condition
is not an event, so the first step never completed. The editor warned about it, but the only way
out was to rewrite the rule as something that meant something else.

A rule whose steps are all conditions is now continuous: one weight that applies while every
condition holds at the same time. isContinuous grows from "a lone level check" to "every step is
a level check", and the label module and the editor follow, so such a rule reads "While A and B" in
all three places. A rule that opens with a condition but waits for an event later is still
unreachable and now says so as never-fires rather than sitting at armed looking merely idle.

The editor stops offering what such a step cannot use: in an AND there is no previous step to be
"soon" after, so later steps offer only "now". A step that is already a condition is narrowed with
a notice, since the behaviour is identical either way; an event after a leading condition is left
alone — that is a broken rule rather than an AND, and coercing it would quietly turn "then the door
opens" into "and the door is open".

A crash on the way out

hal2Bayes has a regex operator but never forced the matching value type, and COMPARE.regex
calls b.test — so a rule saved as regex-against-a-string threw a TypeError out of evaluate()
and took the whole evaluation with it, every other rule included. Reachable from the editor until
now.

The value types are narrowed to what each operator can use, following what hal2Event and hal2Gate
already did rather than inventing a third behaviour. And conditionMatches now guards the
comparison as well as the conversion: a condition that cannot be evaluated has not matched, and its
neighbours still run.

Testing

249 passing. The new ones cover every status, that every configured rule appears exactly once in
every state, that the shares sum to the total, the AND semantics at each edge, never-fires, and
the regex crash.

Verified against the running instance: labels rendered from the live configuration, the rule that
prompted this simulated through all four combinations of its two conditions, and waiting with a
counting-down deadline plus fading decaying per tick observed on a real node.

flic and others added 7 commits August 1, 2026 08:55
Tuning meant reading a probability and guessing what produced it. The snapshot
carried the raw material — activeRules, terms and fsm — but not in a form anyone
could use: rules appeared as internal ids, contributions were in log-odds while
the whole editor speaks share-of-the-way in percent, the three fields had to be
cross-read against each other, and only contributing rules appeared at all. The
question while tuning is usually why a rule is *not* firing, and nothing in the
snapshot answered it.

The three fields are replaced by one `rules` array listing every configured rule,
every time, with a status saying which case it is in: contributing, fading (with
age and half-life), waiting (with step and deadline), armed, condition-false,
no-value, or injected. Each entry carries what its source reads right now, so a
false condition can be seen rather than inferred.

Contributions are reported as shares of the way from the prior to the
on-threshold — the unit the rule bars and the summary already use, so what you
tuned is what you read back. Log-odds are additive, so the shares are too: the
contributing ones sum to the snapshot's own share, which is pinned by a test
because a report that does not add up is a report that lies.

Labels are derived from the steps and phrased as the editor phrases them, so a
rule reads the same in both places. The phrasing lives in a UMD module for the
editor to adopt later; name resolution stays in the node, which is the only
layer that can reach RED.nodes.

The report is built in the same pass that computes the estimate, so a rule
cannot be described as contributing unless its weight really went into the
log-odds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"While it is 09:00–10:00 and the terrace is above 100 lux" could not be
expressed. Anything with more than one step went to the sequence machinery,
which is driven by edges — and a condition is not an event, so the first step
never completed and the rule sat armed forever. The editor warned about it, but
the only way out was to rewrite the rule as something that meant something else.

A rule whose steps are all conditions is now continuous: one weight that applies
while every condition holds at the same time. The estimator's isContinuous test
grows from "a lone level check" to "every step is a level check", and the label
module and the editor follow it, so such a rule reads "While A and B" in all
three places.

condition-false now carries failedStep. With one condition the status was the
whole story; with several, which one broke it is the question.

A rule that opens with a condition but waits for an event later is still
unreachable — nothing completes that first step — and now says so as
never-fires rather than sitting at armed looking merely idle. The editor's
warning is narrowed to that case and names both ways out.

Verified against the live rule that prompted this: it contributes at 35.2 %
inside the window with the lux above the threshold, and reports which of the two
conditions failed in each of the other three combinations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In an AND every condition is judged at the same instant, so there is no previous
step to be "soon" after — "now or soon" and "now" are the same thing there, and
the estimator already treats them identically. Later steps in such a rule now
offer only "now", which also removes the state that prompted this: a step
reading "now or soon" with no window field, because the window had been hidden
as inapplicable while the qualifier stayed on offer.

The first step decides the kind of rule and the rest follow from it. A step that
is already a condition is narrowed silently-but-announced, since the behaviour
is identical either way. An event after a leading condition is left alone: that
is a broken rule rather than an AND, and coercing it would quietly turn "then
the door opens" into "and the door is open" — the warning on the rule is what
should speak there, not a rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… one

Choosing "> 100" left the value on the default string type, where the comparison
only worked through JavaScript's coercion rules rather than because the field
said what it was.

It now switches to num for <, <=, > and >= — but only where nothing is lost by
it: the field must still be on its default string type and hold either nothing
or something that already reads as a number. Text typed on purpose, or a
flow/msg reference, is a decision and stays untouched. Every type remains on
offer, so this is a default and not a lock.

hal2Event and hal2Gate already restrict those operators to num outright; this is
the softer form, which is what the node's freer type list calls for.

The decision lives in resources/hal.js so it is testable without an editor, and
is pinned there — including that it does not read "Infinity" or "10 lux" as
numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follows hal2Event and hal2Gate instead of inventing a third behaviour: a
comparison that can only mean a number offers only num, regex offers only re,
and everything else keeps the node's full list.

That closed a hole rather than just tidying one. hal2Bayes has a regex operator
but never forced the matching type, and COMPARE.regex calls b.test — so a rule
saved as regex-against-a-string threw a TypeError out of evaluate() and took the
whole evaluation with it, every other rule included. conditionMatches now guards
the comparison as well as the conversion: a condition that cannot be evaluated
has not matched, and its neighbours still run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The README claimed a snapshot "on every evaluation", which reads as every tick.
It follows sensor reports and result flips; the tick produces one only when
Snapshot every tick is enabled, which is where to look if you want to watch
evidence decay between reports.

Also worth stating, since the wording invited the opposite conclusion: decay
does not depend on that setting. Contributions are computed from timestamps at
evaluation, so the value is right whenever the node looks — only the reporting
is on demand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flic
flic merged commit 4dc84c2 into main Aug 1, 2026
3 checks passed
@flic
flic deleted the bayes-explain branch August 1, 2026 13:39
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