Skip to content

The consumer picks the group's function, not the group - #46

Merged
flic merged 10 commits into
mainfrom
group-function
Aug 2, 2026
Merged

The consumer picks the group's function, not the group#46
flic merged 10 commits into
mainfrom
group-function

Conversation

@flic

@flic flic commented Aug 2, 2026

Copy link
Copy Markdown
Owner

A group was configured with one aggregation function, and that single choice had to serve every
reader. Building the MCP tools showed why that is the wrong shape: get_groups lets an assistant
ask for any function over the same members, and the moment it could, the configured one stopped
looking like the group's value and started looking like an arbitrary default. Any true asks "is a
lamp on?", all true asks "did the command to turn them all on work?" — the same members, two
questions, and only one of them reachable from a flow.

The engine never needed a value per function

Two things in the code decided how far this could go:

  • The engine emits on every member update, not only when the group's value changes.
  • hal2Event compares state against laststate in the payload, not against anything the
    engine holds.

So a consumer can take the wake-up and compute its own value with its own function. The emission is
a wake-up first and a value second. Value, Gate and Bayes read by pull and needed a parameter;
Event needed to keep its own previous value, persisted, to do its own change detection — the one
node that gains state, and the one where the payoff shows: two Event nodes can now watch the same
group for different things.

The Value field is gone

A group's default is derived from its HAType — a temperature group averages, a light or switch
group answers any true. There is nothing to configure, and a HAType with no natural summary
(Other, the modes, colours) gets no default rather than a meaningless latest.

Boolean groups default to any true because it is honest standing still: false means everything
is off and nothing else, where all true would report 38 of 39 lamps as false — which in
get_all_states reads as "all dark".

Saved aggregate values are ignored with no migration: hal2 is published at 2.4.2, so none of the
group-value model has ever shipped.

Every function is tracked, not just the default

last_change cannot be derived. We hold each member's current last_change, not its history,
and for min, max and any-true-when-true a member can change without moving the value at all. On
the live nine-sensor indoor group, the minimum had been steady since 06:50 while the newest member
change was 07:16 — a derived answer would have been 26 minutes wrong, and Gate's
last_change_gte would have acted on it silently.

So the engine keeps a record per function the group's HAType can serve — seven or eight, bounded
either way. Timestamps are real for every reader, including the one-off ones from MCP, and no
registration API was needed. The live handler shows the distinction the whole thing exists for:
min last changed at 09:27:42, latest at 09:28:34. The cost is a context file of 30 kB rather
than 1 kB.

Whose value it is, and who moved it

Two questions that had been conflated, and coincide only for latest:

  • source — the member the value came from, for latest, min and max where one member
    owns it. "The coldest room is the laundry" becomes one read. A mean belongs to nobody and carries
    no source rather than an arbitrary one.
  • last_changed_by — the member that last moved the value. For min, the member that reported
    most recently is rarely the one holding the minimum; reporting that as the source would have read
    as "Maja is the coldest room" when it is the laundry.

Editor

A function selector beside each group picker in all four nodes, filtered by HAType rather than by
live values — the editor cannot see runtime state, and a temperature item will not start reporting
booleans. A saved function that no longer fits is kept in the list with a warning, the rule the
group HAType select already follows, so a node can never be trapped by a group whose members
changed under it. At runtime it degrades to undefined, which every consumer already treats as no
value.

The Groups tab loses the Value row and gains a summary line saying what MCP says: how many members
can be read, how many can be commanded — separate sets, either of which can be empty, which is the
one thing about groups that surprises people.

Testing

302 passing. The new ones cover defaultFunction (including that it answers null where no
default is warranted), functionsForHaType, valueSource (including that it names nobody where no
member owns the value), and the provenance fields.

Verified live throughout: all ten groups report a derived value, per-function records carry
genuinely different last_change values, min sources to the laundry sensor, and the payoff test
— two Event nodes on one group, one on any true and one on all true — behaves as intended.

flic and others added 10 commits August 2, 2026 08:12
One configured function had to serve every reader. Building the MCP tools showed
why that is the wrong shape: get_groups lets an assistant ask for any function
over the same members, and the moment it could, the configured one stopped
looking like the group's value and started looking like an arbitrary default.
Any true asks "is a lamp on?", all true asks "did the command to turn them all
on work?" — the same members, two questions, one of them unreachable from a flow.

Two things in the code decided how far this could go. The engine emits on every
member update rather than only on a change of value, and hal2Event compares
state against laststate in the payload rather than against anything the engine
holds. So a consumer can take the wake-up and compute its own value with its own
function: the engine never needs a value per function. It says something moved,
and each node decides what that means to it.

Value, Gate and Bayes read by pull and needed a parameter. Event needed to keep
its own previous value, persisted, to do its own change detection — the one node
that gains state, and the one where the payoff is clearest: two Event nodes can
now watch the same group for different things.

The group's Value field is gone. The default is derived from the HAType, which
is what it always should have been — a fallback rather than a decision. Boolean
groups default to any true because it is honest standing still: false means
everything is off and nothing else, where all true would report 38 of 39 lamps
as false. A HAType with no natural summary gets no default at all rather than a
meaningless "latest", and the reading nodes must then each pick.

The editor's function list is derived from the HAType rather than from live
values, because a temperature item will not start reporting booleans and the
editor cannot see runtime state anyway. A saved function that no longer fits is
kept in the list with a warning — the rule the group HAType select already
follows, so a node can never be trapped by a group whose members changed under
it. At runtime it degrades to undefined, which every consumer already treats as
no value.

Also: halGetGroups' 'value' filter now asks whether a group has state-capable
members rather than whether someone configured a function, since nobody does
that any more.

Verified live: all ten groups now report a derived value, the seven light groups
included, with live correctly below members where devices are offline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A picker of fourteen entries each reading "average — the mean of all values" is
a wall of text you scan past. The label is now the short name alone, and what it
means appears once, under the picker, for the one actually chosen — using the
info line each of the four nodes already had.

FUNCTIONS keeps the two apart as `t` and `d`, with a test that the label never
swallows its description again. The line also covers the case that had no
wording before: with no function chosen it says which one the group falls back
to and what that one means, or that this HAType has no default at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Bayes selector snapped back to its saved value on every pick: its change
handler ran the same function that refills the options, and refilling sets the
select to the value it was passed. Filling now happens only when the group
changes; the function select's own change updates the tip and nothing else.

The other three nodes never had the snap-back, but they did refill from the
originally saved value, so changing group threw away a function just chosen.
All four now refill from the current selection when there is one.

"(group default: average)" did not fit the field. It is "(group default)" now,
and an unfit saved function reads "average (unfit)" — the tip line under the
picker says which default applies, what it means, and why a stray no longer fits,
so none of that has to survive inside a dropdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both pairs already sat in an identically sized wrapper; only the division inside
differed — thing/item share it 5:3 and group/function shared it evenly, so a
group step's columns did not line up with a thing step's above it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The only entry that still overflowed the narrower field. The tip line under the
picker already names which function the default is and what it means, so the
option itself only has to say that it is one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It was lost with the Value row. Restored, and widened to answer the question the
tab could not: what this group can actually do. Members split into how many can
be read and how many can be commanded — separate sets, either of which can be
empty, which is the one thing about groups that surprises people and which the
counts now make plain at a glance (Lampor Maja: 4 readable, 2 commandable).

It also names what the group reports by itself, and follows the HAType select,
so changing the type shows immediately what the group would then say.

Deliberately per-group only: that every reader can choose its own function is a
rule about groups rather than about this one, and repeating it on every row is
noise once read. The tab intro carries it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The summary line was wired to the HAType select above the point where its own
handler is assigned. syncSummary is a var, so the call hit undefined and threw
out of the editableList's addItem — which does not fail one row, it fails the
list, so the Groups tab came up empty.

Wired below the assignment instead. A function declaration would also have
hoisted past it, but the surrounding rows are all var-assigned closures and one
odd one out is worse than moving two lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two gaps, both from the same root: only the default function had a record, so
everything else had to answer with undefined or with something derived.

Deriving last_change is not possible. We hold each member's current last_change,
not its history, and for min, max and any-true-when-true a member can change
without moving the value at all. On the nine-sensor indoor group right now, the
minimum has been steady since 06:50 while the newest member change is 07:16 — a
derived answer would be 26 minutes wrong, and Gate's last_change_gte would act
on it silently.

So the engine now keeps a record per function the group's HAType can serve —
seven or eight, not fourteen, and bounded either way. last_change and
last_update are real for every reader, including the one-off ones from MCP, and
no registration API was needed to get there. The live handler shows it working:
min last changed at 09:27:42 and latest at 09:28:34, which is the distinction
the whole thing exists for. The cost is a context file of 30 kB instead of 1.

Alongside it, two different questions that had been conflated. `source` names
the member the value came from, for latest, min and max where one member owns
it; a mean belongs to nobody and carries none rather than an arbitrary one.
`last_changed_by` names the member that last moved the value. They coincide only
for latest — for min, the member that reported most recently is rarely the one
holding the minimum, and reporting that as the source would read as "Maja is the
coldest room" when it is the laundry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It reported the group's default — anyTrue whatever the node was set to — because
the engine's emission describes the default and the node was overriding only the
value, not the block that describes it.

Every field in that block belongs to a function, not just the name: live and the
timestamps come from the default's record, and source names the member that owns
the default's value. The block is rebuilt from this node's own read instead, and
source and last_changed_by are dropped rather than inherited when the chosen
function has none — allTrue is owned by no member, so carrying the default's
source would have pointed at one anyway.

The payload was right throughout, which is why this looked cosmetic: the node
computed and compared its own value correctly and only described it wrongly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flic
flic merged commit cd0616c into main Aug 2, 2026
3 checks passed
@flic
flic deleted the group-function branch August 2, 2026 07:43
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