Conversation
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 "—" 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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, oron a full cycle. A rule that is a singlenowstep 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/envvariables, 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/*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,
scaleShareaccepting 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:updateStateused a shared closure timestamp only set on input/heartbeat, so external callers got stale or undefinedheartbeat/last_change. It now takes its own. Sub-millisecond difference for existing paths.