Skip to content

Compare a source against another source (2.12.0) - #48

Merged
flic merged 6 commits into
mainfrom
compare-sources
Aug 4, 2026
Merged

Compare a source against another source (2.12.0)#48
flic merged 6 commits into
mainfrom
compare-sources

Conversation

@flic

@flic flic commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Every rule in hal2 compared one source against a constant: a number, a string, or a variable read at evaluation time. Comparing two live readings was not expressible, and the workaround cost correctness.

The case that exposed it: a Gate answering "is everyone who is home asleep?" by comparing countTrue of the people-home group against flow.phones_charging, a count a change node had stored earlier. The two sides are then sampled at different moments — someone comes home, the variable has not caught up, and the equality silently fails. It is self-correcting, and therefore the kind of wrong that never gets reported.

Reading both sides at evaluation time makes the staleness impossible, and removes the change node, the variable, and the discipline of remembering to recompute it from both directions.

What it looks like

A new value type, state, in hal2Gate, hal2Event and hal2Bayes. Choosing it reveals a second source picker beside the value:

Thing:    Fredrik & Peggy Hemma
Value:    count true
Rule:     ==  [state]
Compare:  Telefoner som laddar   count true

Groups on the right-hand side are the point rather than a bonus — the case that prompted this compares two group counts, and readGroup() already computes any function on demand.

A flat list was not an option: this location has 281 readable items and roughly a hundred group/function combinations. The picker is a source selector of the same shape as the one the rule already uses.

Design

One spec shape everywhere — { src, thing, item, group, groupFunction }, which is what hal2Bayes already used for its steps. Both sides of a rule and all three nodes describe a source the same way, and in Bayes the comparison side gets thing, group, flow, global and env for free, because resolveState already handles them.

Gate's sourceOf turned out to be the resolver already, minus a name. Extracted as readSource(spec) and used for both sides.

Bayes was the sensitive one. conditionMatches was pure, and the resolver had to be threaded through it and conditionHolds — five call sites. Two of them are reachable without a resolver, where a state comparison cannot be evaluated; it returns false there, joining the existing rule that a condition which cannot be evaluated has not matched. There is a test for exactly that path, because one unevaluable step taking an entire Bayes evaluation with it is the failure this change could have introduced.

state is offered only where comparing values makes sense — not for true/false, regex, or Gate's last_* operators, which compare against a duration.

Editor fixes found while testing this

  • The operator handler is the last writer of the value field's type list. In Event and Bayes it rewrote the list on every operator change and at construction, without state, so offering the type anywhere else was decoration.
  • Gate's value field is constructed with no types list, so it starts with Node-RED's built-in set, which has no state. The saved type was restored before the operator handler installed the real list, and typedInput silently drops a type it does not offer — every saved state comparison came back as something else.
  • Removed three reads of rule.ct in Gate, which is assigned nowhere; with an undefined argument typedInput's type() is a getter, so they did nothing while looking like they preserved the type.
  • The compare picker now presents its source the way the primary picker does: thing and item on separate rows in Event and Gate, one row in line 1's proportions in Bayes, and a tip line saying what a group's function means or what notes the thing and item carry. That tip moved into halFillSourcePicker rather than being written three times.

Out of scope

  • Arithmetic between sources (a > b + 2). The comparison is against the other reading as it is; a derived value still belongs in a flow.
  • last_* against another source. Those compare against a duration, and "changed longer ago than that one" is a different feature with its own questions.

Verification

329 tests passing; clean restart with no [error] in the log. Verified live in the editor across all three nodes.

🤖 Generated with Claude Code

flic and others added 6 commits August 4, 2026 12:35
Every comparison weighed a source against a constant. Comparing two live
readings was not expressible, and the workaround cost correctness: the live
"Alla laddar" gate compares a group's countTrue against flow.phones_charging, a
count some other node stored earlier. The two sides are sampled at different
moments, so someone coming home makes the equality fail until the variable
catches up — self-correcting, and therefore the kind of wrong nobody notices.

A new value type, `state`, resolves the right-hand side in the same pass as the
left. The window cannot exist.

One spec describes it everywhere — { src, thing, item, group, groupFunction },
the shape hal2Bayes already used for its steps — so both sides of a rule and all
three rule nodes talk about a source the same way. Groups on the right are the
point rather than a bonus: the case that prompted this compares two group
counts, and readGroup already computes any function on demand.

Gate's sourceOf turned out to be the resolver already, minus a name. Extracted
as readSource(spec) and now used for both sides, which also removed the
duplicate thing-lookup it had grown.

Bayes was the sensitive one. conditionMatches was pure, and the resolver had to
be threaded through it and conditionHolds — five call sites. Two of them can be
reached without a resolver, where a state comparison cannot be evaluated: it
returns false there, joining the existing rule that a condition which cannot be
evaluated has not matched. A test drives that path, because one unevaluable step
taking a whole evaluation with it is exactly the failure this could have added.

The picker is a source selector of its own rather than an entry in the type
dropdown: this location alone has 281 readable items and about a hundred
group/function combinations.

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

Three faults, all in the editors — the runtime was fine.

Event built its typedInput with the state type and then threw it away: the
operator change handler rewrites the whole type list, and it ran on every
open. Adding state to the construction call was necessary and not sufficient.

Event also restored the saved type only when there was a saved value. state
has no value (hasValue:false), so every saved state comparison loaded back as
num. The type is now restored on its own.

Bayes set the types inside syncOperatorOptions, which returns early unless the
step's time-ness changed. It happened to run at construction, but the value
field is hidden for the default operator, so nothing was visible until an
operator was picked — and the type list was one refactor away from not being
set at all. Set at construction, where the other types are.
…list

Same fault in three places, found one node at a time.

Bayes: opHandler rewrites the type list on every operator change and at
construction, without state. Setting the types anywhere else was decoration.
Fixed there, where it is decided.

Gate: the value field is constructed with no types list, so it starts with
Node-RED's built-in set, which has no state. The saved type was restored
before the operator handler had installed the real list, and typedInput drops
a type it does not offer — so every saved state comparison came back as
something else. Restored after the operator now. Also removed three reads of
rule.ct, which is assigned nowhere; with an undefined argument typedInput's
type() is a getter, so they did nothing.

Event: the Compare row sat above Trigger though it belongs to the value below
it, and stayed visible when the operator stopped taking a value. One function
decides the row now, called from both the operator and the value.

The numeric operators keep a chosen state comparison instead of forcing num
back over it, in Event and Bayes both.
…ary one

Three complaints with one cause: the compare picker was built as a compact
add-on rather than as another source picker, so it laid out and explained
itself differently from the picker directly above it.

Thing and item are on separate rows now, in Event and Gate at the same widths
as everything else in those dialogs. Bayes cannot use those rows in a
multi-step editor, so the source row and the item/value row each span flex:1
between the 50px lead and the remove-button pad — which is exactly what line 1
gives its thing and item selects together, so the two sides of a comparison
line up down the dialog.

The tip line moved into halFillSourcePicker as a setTip callback rather than
being written three times. It says what a group's function means, or the notes
on the thing and item — the same things the primary picker says. It follows
the item and the function too, not only the source: which item is picked
decides which notes apply.

The item label reads Value for a group and Item for a thing, as above.

Three rows now move together, so each node has one showCmp that decides them.
refreshRuleView in Bayes was toggling only the first of the three, which would
have left the other two behind on the next change.
flex:5 for the source and flex:3 for what is read from it — the same split
line 1 gives its thing and item, so the compare pair sits directly under the
pair it is compared against. The tip row stays on its own line.
@flic
flic merged commit bd2d80f into main Aug 4, 2026
3 checks passed
@flic
flic deleted the compare-sources branch August 4, 2026 14:29
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