Skip to content

Add hal2Bayes: probabilistic binary-state estimation - #41

Merged
flic merged 35 commits into
mainfrom
bayes
Jul 31, 2026
Merged

Add hal2Bayes: probabilistic binary-state estimation#41
flic merged 35 commits into
mainfrom
bayes

Conversation

@flic

@flic flic commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Adds hal2Bayes, a node that estimates a hidden binary state — typically "is this person home?" — by weighing several unreliable sensors instead of trusting any one of them. A phone presence sensor that reports "home" from the street corner is not enough alone; combined with the front door cycling and movement in the hallway, it becomes convincing.

Nothing existing changes behaviour. The one edit outside the new files is a latent-bug fix in core/thing.js.

The model

Everything is a rule, built from steps. A step names a source and a condition, then says when that condition has to hold: now, now or soon, on change, or on a full cycle. A rule that is a single now step is continuous — it contributes while its condition holds. Anything else is momentary: the steps must complete in order, each within its window, and completing the last one gives a one-off push that then fades.

Weights are word strengths (slight…certain) mapping to likelihood ratios, but the editor shows every rule as a share of the way to on. Shares add exactly, because log-odds do — so 74 % + 35 % = 109 % turns the output on, and that arithmetic is how shared sensors are disambiguated without any special logic: give the door/motion rule a share too small to cross the line alone.

Sources are Thing items, flow/global/env variables, or a time window with weekdays. Only Things are subscribed to, so the others are conditions rather than events — an edge on a polled source could only be sampled on the tick and would miss anything faster.

A rule's weight can also follow the measured value via a two-point linear map, which is the step from naive Bayes over binary features to logistic regression over a continuous one. Soil moisture is the motivating case: watering in direct sun is normally wrong, but critically dry soil must override it.

The lock encodes "absence of evidence is not evidence of absence": only rules that make it false can turn the output off, so a phone rebooting indoors cannot flip the estimate. An optional hour limit is the safety valve.

Structure

resources/bayes-scale.js   pure arithmetic — strengths, shares, decay, scaling
resources/bayes-time.js    pure time windows and weekdays
lib/bayes.js               the estimator: no Node-RED, injected clock
core/bayes.js              thin RED shell: subscriptions, tick, persistence
core/bayes.html            all presentation

resources/* are loaded by both the editor and the runtime through one UMD wrapper, so the numbers the GUI shows cannot drift from what the engine computes.

Testing

126 tests, Mocha + node:assert, matching the repo's convention of testing pure modules rather than instantiating nodes. Every semantic decision that was hard to reach is pinned: midnight-crossing time windows, the overlap rule, exit protection, certainty clearing, latch and maxHold behaviour, and the boundary that a 100 % share reaches the threshold exactly — so any opposing evidence blocks it.

Reviewed

A pass over the finished branch found and fixed: a maxHold valve that stayed dead after restoring a held-on state, state for deleted rules persisting forever, scaleShare accepting booleans as readings, a summary that silently excluded every non-Thing rule, and a duplicated harvest in the editor — the same class that had already cost a lost-id bug.

Note

core/thing.js: updateState used a shared closure timestamp only set on input/heartbeat, so external callers got stale or undefined heartbeat/last_change. It now takes its own. Sub-millisecond difference for existing paths.

flic and others added 30 commits July 28, 2026 16:18
General naive-Bayes estimator in log-odds, inspired by HA's Bayesian
binary sensor and the presence-bayes lab. Hybrid evidence: state rows
contribute while their condition holds, event rows add decaying
one-shot terms (per-row half-life). Built-in sequence rules
(edge/cycle arm + confirm window, confirm-during-arm) with candidacy
gating frozen at arm time. Hysteresis output, context-store
persistence with wall-clock decay, optional write-back of the
estimate to thing items (feedback-loop guarded), msg escape hatches
(reset/set/evidence). Pure math in lib/bayes.js with 28 mocha tests.

Prerequisite fix in thing.js updateState: use a fresh local timestamp
instead of the stale closure var so external callers get correct
heartbeat/last_change.

Bump 2.4.2 -> 2.5.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The node was too advanced to configure: likelihood ratios, arm
patterns and candidate freezing on the first screen. Simple mode is
now the default and speaks in sentences — "when <sensor> is on, that
indicates yes, moderate" — with word strengths mapping to LR
1.5/3/10/30 (reciprocal for "indicates no"). Model parameters are
hidden behind sensible defaults.

Entry detection becomes a ready-made template (door + motion + the
person's sensor); lib/bayesEntry.js expands it at runtime into the
same rows and composite an advanced user would write by hand, so
lib/bayes.js is untouched.

Simple representability is derived from the stored lr, so no extra
fields are persisted and mode toggling cannot desync. Rows with no
simple equivalent render read-only and are written back verbatim.
Pre-2.6 configured nodes open in advanced mode.

Also drops all Home Assistant references from README and help text.

Bump 2.5.0 -> 2.6.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Editor feedback round:

- Prior and both LR fields validated red: min="0.001" with a step that
  does not divide evenly means the HTML5 grid (min + n*step) excludes
  the defaults. Use step="any" on all numeric fields.
- The strength separator rendered as literal "&mdash;" because it was
  set with .text(); use .html().
- Drop the per-row Name field. Rows are now labelled by what they
  watch ("Thing · Item"), which is both shorter to configure and more
  informative in the reference dropdowns.
- Simple rows get the full operator picker (halOperators) plus a value
  typedInput instead of on/off, and "indicates" is true/false rather
  than yes/no. simpleView() widens accordingly: any operator is simple
  as long as the row is state-typed, undecayed, uncandidated and its
  LR is one of the four strengths.
- The Entry detection tab merges into Sensors as an "arrival" row kind
  — one list holds every piece of evidence, and arrival is visibly
  just another kind of it. lib/bayesEntry.js becomes
  lib/bayesArrival.js and expands any number of arrival rows (the old
  single config.entry is migrated in both editor and runtime).
- The Output tab and all write-back are removed: pushing state into a
  Thing behind the flow graph goes against Node-RED's model. Wire
  output 1 onward instead.

Bump 2.6.0 -> 2.7.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "add arrival" button never rendered: editableList appends its add
button to an internal topContainer, not as a sibling of the <ol>, so
the insertAfter() target matched nothing and failed silently. The
widget supports an `options.buttons` array for exactly this — use it
instead of reaching into its DOM.

Sequences kept a Name field that only ever fed a warning message.
Drop it, consistent with sensor rows: a sequence is identified by what
it watches, shown as a live "arm → confirm" header derived from the
two selects, and warnings refer to its position in the list.

Bump 2.7.0 -> 2.7.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild around the model the user actually described: a rule is 1..N
steps in time order. One 'becomes' step = continuous (While…, holds
while true); a cycle step or several steps = momentary (When…then…,
one-shot term that fades). The sensor/arrival split, the read-only
cards and the whole candidacy machinery (onlyAsCandidate,
candidateRow, freeze-at-arm) are gone. Shared sensors are handled by
threshold arithmetic instead: the editor shows each rule as a share
of the way from prior to on-threshold (strong = 74 %), shares add
exactly, and the summary states what combination turns the node on
and how long it takes to fall back off.

Fading becomes visible: word rates (quick/normal/slow) per momentary
rule with a decay caption, and a "turns off after ~X" line computed
by the same code the engine uses (resources/bayes-scale.js, shared
browser/node single source).

New latch for "absence of evidence is not evidence of absence": with
the lock on, only rules that make it false can turn the output off —
a phone rebooting indoors cannot flip the estimate. Optional max-hold
hours as a safety valve; status/snapshot show held. Certainty rules:
a certain (LR 400) firing clears opposing terms, and a stored certain
statement is cleared by any later contradiction, so a decayed exit
never blocks a return home.

Advanced and simple share one data model — advanced only reveals raw
LR/half-life/model parameters. lib/bayesArrival.js deleted. 94 tests.

Bump 2.7.1 -> 3.0.0 (breaking schema; no deployed nodes existed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Triggering the arrival rule on the phone's rising edge assumed the
phone appears before the door opens. It usually does — the sensor
trips from the street — but not always, and when it doesn't the rule
fails silently: the overlap rule only accepts edges since the
sequence armed, so a phone that was already true never satisfies a
"becomes" step.

The door is the better trigger anyway: it is the discriminating
event, precisely timed, while the phone is a background state. So
steps gain a third pattern, "is" — a condition checked at the instant
the previous step completes, reading "and …" rather than "then …":

  When front door goes on then off, and iPhone Fredrik is true
    → makes it true, decisive

This also gives per-person disambiguation for free: someone else
arriving cannot fire the rule, because their phone is not here. No
candidacy, no thresholds tuned against each other.

A lone "is" step is now the continuous case (While X is true); the
pre-3.1 one-step "becomes" form meant exactly that and is migrated in
normalisation. A level check that fails aborts the sequence. The
editor warns when an "is" step is placed first in a multi-step rule,
where nothing would trigger it.

Bump 3.0.0 -> 3.1.0. 97 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Output 1 already emitted only on a real transition — `change` is null
unless evaluate() flipped the binary result — so a rule firing again
while the node is on sent nothing. That was implicit and easy to
mistake for the snapshot stream on output 2, which does follow every
evaluation by design.

Make it a visible choice instead: "Emit output 1: only when the result
changes" (default, unchanged behaviour) or "on every evaluation" for
flows that want the state re-asserted. Messages now carry
msg.changed so a consumer of the continuous form can still tell real
transitions apart.

Bump 3.1.0 -> 3.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two editor problems. The step row had grown past readability, and
"open ≤ 180 s" was ambiguous — it reads like an arming window when it
is actually the longest the condition may stay true.

Move both timing numbers onto an indented second line under each
step, shown only where they apply. With the horizontal pressure gone
they can spell themselves out: "within N s of the previous step" and
"stays on for at most N s". The step row itself loses two spans and
gets narrower despite the extra dropdown entry.

Reframe the pattern dropdown as a timing qualifier that reads as a
suffix to the condition — "iPhone Presence is true — right now" —
with a new third option for slow sensors: "now or soon" checks the
level immediately like "right now", but instead of aborting when it
does not hold yet, it waits for the change until the window expires.
A phone that takes a few seconds to register no longer loses the
arrival.

Bump 3.2.0 -> 3.3.0. 100 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With the lock on, fading no longer makes the output fall back by
itself — but it is not inert, so both new options earn their place.
Fading still decides whether a push is strong enough to turn the
output on, and how long a "makes it false" rule stays able to turn it
off: negativeActive requires the term to be above the prune floor at
the same evaluation where p drops below the off threshold, so a false
rule that fires while the estimate is still high can fade out and be
wasted.

"never" uses a finite sentinel (1e12 s) rather than Infinity, which
JSON.stringify turns into null on the way to the context store; its
millisecond form still fits inside MAX_SAFE_INTEGER and a term keeps
99.8 % of its weight over a century.

"custom…" reveals a half-life field next to the dropdown, replacing
the separate advanced-only one — one field with one rule (it applies
when the fade is custom) instead of a hidden override that silently
won. Rules already carrying a half-life open as custom.

Also documents that strength buys almost no duration: each doubling
of ln(LR) adds one half-life, so certain outlasts decisive by well
under one.

Bump 3.3.0 -> 3.4.0. 101 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
editableList passes addItem a <div> inside its <li>, so the rule id
stored with container.data('ruleId') was invisible to the .each()
over the list's children in oneditsave. Every other field survived
because .find() searches descendants; the id was the only value read
with .data(). Rules were therefore written to the flow without an id.

Two consequences, both severe. In the editor, addItem gates on
opt.id, so reopening the dialog threw the saved steps away and drew a
fresh empty rule. At runtime every rule mapped to the key undefined,
collapsing the rule map to a single entry so only the last rule
existed and every step hit resolved to it.

Keep the id in a hidden field reachable by .find() from either level,
adopt any incoming rule that carries steps even without an id so
already-saved configs are recovered rather than discarded, and fall
back to the list position at runtime so a missing id can never make
rules collide again.

Bump 3.4.0 -> 3.4.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The step row used fixed pixel widths totalling ~586 px (~700 with a
value field shown) against roughly 436 px of usable space in a
default edit tray, so the right-hand fields were cut off. There is no
supported way to ask for a wider tray: editTrayWidthCache is private
to RED.editor, keyed by node type and populated only when the user
drags, and the tray body is width:100% with overflow:auto, so content
never pushes it wider.

Split each step over two flex lines — sensor and item on the first,
condition and timing qualifier on the second — with flex:1 1 0 and
min-width:0 so the fields share whatever width is available and grow
when the tray is dragged out. The value field lives in a wrapper that
is hidden outright for is-true/is-false conditions, collapsing its
flex space instead of leaving a gap. The outcome, bar and caption
rows lose white-space:nowrap so their text wraps rather than
overflowing.

Also folds the timing line into the step's own container. It used to
be a sibling reached with .next(), the same cross-element coupling
that caused the lost-id bug; now a step owns all of its fields and
harvesting only ever searches downwards.

Bump 3.4.1 -> 3.4.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The branch had climbed to 3.4.2 by bumping minor at every increment
and major when the unreleased hal2Bayes schema was rewritten. Nothing
published ever broke: main is 2.4.2 and contains no bayes node, and
no other node changed incompatibly, so from a consumer's point of
view the whole branch is "one new node was added" — 2.5.0.

Sharpens memory/feedback_versioning.md accordingly: one minor per
finished feature, patch levels while it is being built, and major
only when something already published stops working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The topic field was hidden behind advanced mode and always
synthesised "bayes/<name>" when left blank, so a plain msg.topic could
neither be set from simple mode nor turned off.

Follow hal2Event instead: a plain text field on the General tab,
visible in both modes, and msg.topic is only set when it is filled in.
Output 1 carries the topic as written, output 2 the same with
"/snapshot" appended.

Bump 2.5.0 -> 2.5.1 (patch — hal2Bayes is still unreleased work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rule steps could only read a Thing item, so facts living elsewhere in
the flow — a calendar saying we are away, guest mode, a holiday flag —
had no way into a rule.

Scope follows from how the node works rather than from what Gate
offers. Gate's rule source is always a thing/item and only the
comparison value is typed, because Gate is message-driven while this
node is subscription-driven and evaluates on a timer. That gives a
crossed symmetry: flow/global/env are persistent readable state and so
work as conditions, evaluate() can read them at any moment; but they
have no change notification, so as events they could only be sampled
on the tick and a variable flipping twice between ticks would be
missed. msg is the mirror image — fine as an event, wrong as a
condition, since at tick/restore/other-sensor time there is no
message. So: flow/global/env only, and only on condition qualifiers
(right now / now or soon). msg-driven evidence stays on the existing
msg.topic = "evidence" hatch.

Closes a gap this exposes: "now or soon" waited for a rising edge that
never arrives from a polled source, quietly degrading to "right now,
but fails later". tick() now re-checks a pending condition step's
level, so the qualifier means what it says for variables too.

The editor gains a source selector per step, swapping the thing+item
pair for a variable-name field, and offers edge qualifiers only for
Thing sources. A saved edge qualifier on a polled source is coerced to
"right now" with a notification rather than silently never firing.
Steps without src keep behaving as Thing sources.

Bump 2.5.1 -> 2.5.2. 106 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rules could read a Thing item or a flow/global/env variable, but had
no way to say "during the night", "on weekdays" or "outside working
hours" without a helper variable pushed in from another flow.

Time of day is readable state, not an event, so it becomes a fourth
source alongside the polled ones and inherits their restriction:
condition qualifiers only. resolveState returns a boolean, which means
the existing is-true/is-false operator gives inside/outside inversion
for free — one window covers both "during the night" and "outside
working hours". lib/bayes.js is untouched.

Weekdays are judged by the day it is right now, not the day a window
started: with 22:00-06:00 on Mon-Fri, Tuesday 02:00 counts and
Saturday 02:00 does not, even though that is Friday night. Predictable
without reasoning about which day a night belongs to. Windows may
cross midnight, start is inclusive and end exclusive, and start ==
end is never active rather than always.

Two things the environment gave for free: the container runs
TZ=Europe/Stockholm so getHours()/getDay() are local time with DST,
and the repo already uses native HTML5 time widgets, so type="time"
is house style and its value is always 24-hour regardless of the
browser's display locale.

resources/bayes-time.js is shared by runtime and editor through the
same UMD wrapper as bayes-scale.js, so the editor's live "active right
now" hint cannot drift from what the node computes.

Bump 2.5.2 -> 2.5.3. 115 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t fire

Two problems with the time step's dropdowns.

"inside"/"outside" was too terse to explain itself. It now reads as a
full clause — "is inside the window" / "is outside the window" — using
the width freed by hiding the value field and, where applicable, the
qualifier.

"right now"/"now or soon" was worse than unclear on a first step: it
was broken. A polled source has no subscription to wake it and no
previous step to be soon after, so nothing ever drives its state
machine. Verified with a condition held true throughout:

  single polled step, pattern=is           p=0.990 binary=true
  single polled step, pattern=isOrBecomes  p=0.200 binary=false

The qualifier list is now derived from source and position together —
"now or soon" only appears where something can actually complete it —
and the dropdown hides entirely when only one option is valid. The
runtime coerces a saved polled head step to "right now" for the same
reason, so an already-deployed rule cannot sit dead either.

The window read-back moves to its own line under the weekdays.

Bump 2.5.3 -> 2.5.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erator

Position alone was the wrong rule. A time step at index > 0 still
offered "now or soon", which would mean waiting up to the step window
for the clock to cross into the window — never something anyone
wants. A time source is now always a plain condition, at any position,
in both the editor and the runtime, so the qualifier disappears
entirely for it.

The read-back line said whether the clock was inside the window, which
did not move when inside/outside was flipped and so answered the wrong
question. It now reports whether the step would match right now,
combining the window with the chosen operator.

Bump 2.5.4 -> 2.5.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Choosing "when it changes" on a single-step rule saved correctly but
had no effect: normalisation rewrote a lone 'becomes' step to 'is'.

That line was compatibility for configs written before 'is' existed,
when a lone 'becomes' was how the continuous case was expressed. Now
that 'is' is a real option and the default, picking 'becomes' is a
deliberate and different choice — a decaying pulse each time the
condition turns true rather than a contribution that holds:

  pattern=is       at the edge p=0.882 | 90 min later p=0.882
  pattern=becomes  at the edge p=0.882 | 90 min later p=0.225

The rule could not tell an old config from a deliberate choice, so it
silently destroyed the latter. Nothing is left to migrate — hal2Bayes
has never been published, and the only existing configs have been
re-saved through the current editor.

Bump 2.5.5 -> 2.5.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regression from 2.5.4. Moving the qualifier list out of addStepRow
into refreshRuleView took the four option-building calls with it, so
the select was created empty. patSel.val(step.pattern) at load was
therefore a no-op, and the first refresh rebuilt the options with
keep=null and settled on 'is'. A saved "when it changes" was silently
replaced on load, and the next save wrote 'is' over it.

Options are now built by one setPatternOptions helper used both at
creation (full set, so the saved value can be selected at all) and on
refresh (narrowed by source and position, preserving the current
value). It only rebuilds when the option set actually differs, so a
selection survives every refresh.

Also settles the lead word from the pattern after syncing rather than
before, so While/When/and/then reflect what is actually selected.

Bump 2.5.6 -> 2.5.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The condition and the qualifier answer different questions — what has
to be true, versus when it has to be true — but sat side by side and
were read as one sentence: "When Hall Dörr Contact is false when it
changes". The two temporal words collided on top of that.

The qualifier moves down to the line that already carries timing
numbers, behind a clock icon, so the condition line ends where the
sentence does and everything about *when* is grouped together. Labels
lose their temporal conjunctions: now, now or soon, on change, on a
full cycle.

That also puts "now or soon" next to the field that defines it, which
was previously unstated: the step's window is the deadline, so the
label now reads '"soon" means within N s of the previous step' when
that qualifier is chosen. The cycle limit gets the same treatment —
'a full cycle = on then off again, within N s' — rather than assuming
the reader knows what a cycle is.

Bump 2.5.7 -> 2.5.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The clock sat inline at the start of the timing line, pushing the
qualifier dropdown out of alignment with the source and condition
selects above it.

The timing line now uses the same flex shape as the two lines above:
the icon owns the 50 px lead column where While/When/and/then sit, and
the dropdown starts where the selects above start.

Bump 2.5.8 -> 2.5.9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three editor fixes.

The clock and arrow markers are right-aligned in the 50 px lead column
so they sit against the controls they label instead of floating.

Line 2's field ran out to the right margin while line 1 stopped short
of the remove button. The button now has a fixed 28 px footprint and
the lines below reserve the same width, so every line's content ends
at the same edge.

"now or soon" had no time field on a first step, because there is no
previous step for it to be soon after — and at the head of a rule the
runtime already treated it exactly like "on change". It is therefore
no longer offered there, which means wherever it does appear its
window field appears with it. Narrowing an existing step prefers
"on change" over "now", so behaviour is preserved rather than silently
turned into a continuous check.

Bump 2.5.9 -> 2.5.10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The window label and its input wrapped onto a line of their own: the
row needed roughly 580 px against about 436 px of usable tray width.
Trimmed to fit — the qualifier select to 130 px, "must happen within"
to "within", the soon gloss to '"soon" = within', and the cycle label
to "on then off within". The clock icon already marks the row as
timing, so the longer phrasing was carrying little.

Also fixes the previous commit putting the alignment spacer on the
timing line instead of the condition line, which was the line whose
field actually ran out to the margin. It now sits after the value
field, so line 2 ends where line 1 does.

Bump 2.5.10 -> 2.5.11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rows had first columns of 90, 110 and 130 px, so the fields after
them neither started nor ended on the same x — which is why line 2's
value field looked wrong however the trailing reserve was tuned.
Widening only line 2's dropdown would have made its field shorter
still.

All three are now 130 px, so every row's content region starts at the
same offset and ends at the same edge, and the value field comes out
the right length on its own.

Bump 2.5.11 -> 2.5.12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thing names ("Kök Taklampa") are consistently longer than item names
("on", "light", "color"), but the two selects split their space
evenly. The thing now takes twice the item's share.

The value field stopped short of the right margin because typedInput
fixes a pixel width when it is constructed and does not follow the
flex recalculation afterwards. Told to fill its wrapper with
typedInput('width', '100%'), the same way gate.html sizes its rule
value field.

Bump 2.5.12 -> 2.5.13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The remove button is hidden on a single-step rule, so line 1 runs to
the full width — but line 2's alignment spacer had no condition and
reserved its 34 px regardless, leaving the value field short by exactly
the button's footprint. Measured on a screenshot: line 1 ended at 557
CSS px, line 2 at 528.

The spacer now carries a class and is toggled alongside the button, so
the two lines end level whether or not the button is present. Earlier
passes at this blamed the right margin and typedInput's fixed width —
both were real problems, but neither was this one.

Also nudges the thing/item split from 2:1 to 5:3, giving the item
select the couple of characters it was missing while the thing stays
the wider of the two.

Bump 2.5.13 -> 2.5.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The value field rendered as a bare underline instead of a closed box:
it carried an inline width:100% as well as typedInput('width','100%').
The widget sizes its inner input to (container - type button), so the
inline rule overrode that, overflowed by the button's width and pushed
the container's right edge out of view.

Removed, leaving only the typedInput width call — exactly how
gate.html sizes its rule value field.

Bump 2.5.14 -> 2.5.15.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts the previous commit, which had it backwards. Reading the
typedInput source: on construction it looks for a width in the
element's own inline style and, finding one, sets the inner input to
100% and the container to that declared width — both declarative, so
they follow the layout. With no inline width it instead measures
outerWidth() once, and that measurement runs while the Rules tab is
still display:none, so it comes out as 0 and the container never gets
a width at all.

So the inline width:100% was the supported path; the separate
typedInput('width') call was the redundant part. Inline style kept,
call dropped.

Bump 2.5.15 -> 2.5.16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A rule's weight was always constant, so "this matters more the drier
the soil gets" was inexpressible — and that is exactly what the
irrigation case needs: watering in direct sun is normally a bad idea,
but critically dry soil has to override it.

The new strength "scaled…" maps a reading onto a share of the way to
on through two points, interpolated between and clamped outside.
Conceptually this is the step from naive Bayes over binary features to
logistic regression over a continuous one, and the additive log-odds
engine already in place is what that needs.

Share rather than likelihood ratio as the unit, decisively: an LR
cannot be <= 0, and the obvious formula reaches 0 at full moisture. As
a share, 0 means no evidence and negative pushes toward false — so the
sign lives in the shares and the direction dropdown is hidden for a
scaled rule, letting one rule push both ways across its range.

Single-step rules only: with several steps there is no non-arbitrary
answer to which value scales the weight. Momentary rules snapshot the
weight when they fire, which is the right semantics and means stored
terms need no change. An unusable reading contributes nothing rather
than producing NaN, and a scaled rule is never treated as a "certain"
statement however large its share.

A test expectation caught something worth documenting: a 100 % share
reaches the threshold exactly from the prior, so any opposing evidence
blocks it — overriding a moderate 35 % objection needs about 150 %.
Pinned as its own test. The editor cannot read live sensor values
(RED.nodes.node returns config, not runtime state), so the bar reports
the ceiling and the caption samples the map instead.

Bump 2.5.16 -> 2.5.17. 124 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two point rows were bare number pairs — nothing said which side
was the sensor reading and which the weight it produces, so "at 60 → 0
%" did not read as "a reading of 60 is worth nothing".

A single column header now labels both sides once (reading / share of
the way to on), the per-row prose is gone, and the fields are aligned
to those columns.

The preview line also stops hiding the clamping behind a parenthetical
and states it: "reads 20 or less → 150 % · 40 → 75 % · 60 or more →
0 %". That reads as behaviour rather than as three sample points.

Bump 2.5.17 -> 2.5.18.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
flic and others added 5 commits July 30, 2026 11:32
The previous attempt made it worse: a column header explained the two
numbers, then a preview line said the same thing again, then the bar
caption said it a third time — and the caption was long enough to wrap
with "its own" landing outside the indent.

Each row now states its own meaning, so the clamping sits where it
applies and nothing above it is needed:

  reads [20] or less  →  [100] %
  reads [60] or more  →  [0] %
  linear in between

The or-less/or-more word is chosen by comparing the two readings, so
the rows stay true whichever order they are entered in. The preview
line is gone as a preview and kept only to report a spec that cannot
work (both readings equal). The scaled bar caption is shortened so it
stops wrapping.

Bump 2.5.18 -> 2.5.19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"reads" becomes "value", and the share input gains a "weight" label so
both numbers say what they are rather than only the left one:

  value [20] or less  →  weight [100] %
  value [60] or more  →  weight [0] %

"weight" and "share of the way to on" are not synonyms — one names the
number's role, the other its scale — so the help text and README now
state the link explicitly: a rule's weight is its share of the way to
on, the same percentage the bars show, and 100 % is exactly enough to
flip the output when nothing opposes it. Without that sentence the two
terms would compete instead of reinforcing each other.

Bump 2.5.19 -> 2.5.20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three findings from the branch review.

The maxHold valve was dead after a restart when the node came back
held-on without a lastPositiveAt (state saved by an older shape):
maxHoldExpired requires the timestamp, so the latch would hold forever
in exactly the scenario the valve exists for. restore() now takes the
clock, in the same injected style as everything else, and starts the
silence clock at the restore when a held-on state arrives without one.

restore() also scopes state to the current rule set. fsm entries,
lastMatch/lastTrueEdge keys and terms belonging to deleted rules were
re-serialized on every persist and exposed in output 2's fsm, forever.
Injected evidence survives, having no owning rule.

scaleShare() accepted anything Number() could digest, so a scaled rule
with an is-true condition interpolated at Number(true) = 1 — a
nonsense weight instead of no contribution. Only numbers and numeric
strings are readings now.

Cleanups from the same review: lib/bayes.js imports logit/sigmoid from
bayes-scale instead of keeping a second implementation, and tick() no
longer returns a fired list nothing consumed.

Bump 2.5.20 -> 2.5.21. 126 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two findings from the branch review, plus a cleanup it surfaced.

The summary skipped every rule whose first step had no thing — which
is all flow/global/env/time rules, so "Together: X %" lied as soon as
the newer sources were used. Validity is now judged per source through
stepValid(), mirroring the runtime's normalisation filter, so the
summary counts exactly the rules the engine will run.

oneditsave carried a full second copy of the row harvest. That
duplication is the exact class that caused the lost-id bug earlier on
this branch, so it is gone: oneditprepare exposes the one harvestRule
path as node.__harvestRules and oneditsave delegates to it.

With the harvest unified, day toggles and time fields are only
harvested for time steps — every thing step used to carry a
meaningless days:[0..6] into the flow file.

Bump 2.5.21 -> 2.5.22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The branch climbed to 2.5.22 through patch bumps while hal2Bayes was
being built. Every one of those was iteration on code that has never
been published, so from the perspective of anyone installing the
package the whole branch is a single new node: one minor bump from
2.4.2, per memory/feedback_versioning.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@flic
flic merged commit 35bbc14 into main Jul 31, 2026
3 checks passed
@flic
flic deleted the bayes branch July 31, 2026 12:52
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