← All dispatches
Saying What · Part 8 of 10 · Language

The alarm that cleared itself

A derived boolean clears itself the moment the reading dips. A flag fixes that and loses when and why. Fluents and signals keep both, and they are the part of Bondy that is designed but not yet built.

Alejandro M. RamalloOct 20265 min readLanguage

I'm publishing this series ahead of Code BEAM Europe this October, where I'll reveal Bondy, a new declarative language for the BEAM: statically typed, functional, relational and rule-based.

A temperature sensor, a threshold, and an alarm. First attempt:

elixir
def alarm_active?(reading), do: reading > 100.0

It is wrong in a way that takes a week to notice. The reading crosses a hundred, the alarm goes active, an operator looks up, the reading dips to ninety-nine, and the alarm is gone. Nobody acknowledged it. Nothing recorded that it happened. Real alarms latch: they stay active from the crossing until someone clears them, whatever the reading does in between.

Second attempt, the one everyone ships:

elixir
def handle_info({:reading, r}, %{alarm: false} = s) when r > 100.0,
  do: {:noreply, %{s | alarm: true}}

def handle_info({:reading, _r}, s),
  do: {:noreply, s}

def handle_call(:ack, _from, s),
  do: {:reply, :ok, %{s | alarm: false}}

Now it latches. And now three questions have no answer. Since when has the alarm been active? What crossed the threshold, and what was the reading? Was it active at 03:14 last Tuesday, when the incident report says the pump failed?

The boolean holds the truth and throws away the time, the cause, and the history. You then spend the next day reconstructing them from log lines, if the log lines are there.

The second post named the two ideas this needs: a role, the fluent, and a noun, the signal. This post shows what they look like in the design, and says plainly which parts exist.

Fluents: what is true now, and since when ​

A fluent is a statement whose truth changes only when a named event changes it, and which stays true or false in between. It is the latched alarm, with the time and the cause kept. In the design, a fluent is a constraint with an attribute, and the rules that change it use two built-in operations:

bondy
%% Design. Not in the compiler yet; see the roadmap.

type AlarmActive = {sensor: String}
@fluent
constraint AlarmActive

constraint Crossed      = {sensor: String, reading: Float}
constraint Acknowledged = {sensor: String}

rule latch =
    on Crossed(sensor: s, reading: _) as c
    remove c
    tell initiate(AlarmActive(sensor: s))
    end

rule clear =
    on Acknowledged(sensor: s) as a
    remove a
    tell terminate(AlarmActive(sensor: s))
    end

Read it against the Elixir. Crossed is the occurrence: it is told to the store, a rule removes it, and the rule's body does not set anything. It initiates the fluent, which records that AlarmActive(sensor: s) holds from now, because of this crossing. Acknowledged is the other occurrence, and its rule terminates the fluent, which records that it stopped holding now, because of this acknowledgement.

Between the two, the fluent stays true on its own. A reading of ninety-nine is not an occurrence that terminates anything, so it changes nothing. That is the behaviour the first attempt got wrong and the second attempt got right by accident.

The queries are where the three lost answers come back:

bondy
%% Design.
holds(AlarmActive(sensor: "t1"))          %% is it active now?
initiated_at(AlarmActive(sensor: "t1"))   %% since when?
holds_at(AlarmActive(sensor: "t1"), t)    %% was it active at t?

holds is the boolean you had. initiated_at is the timestamp you did not. holds_at is the historical query that the incident report needed, and it works because the design keeps terminated fluents in a history rather than deleting them.

Time here is logical, not wall-clock: a counter inside a process, and a hybrid logical clock between processes. That is a deliberate choice, and it is what keeps the fluent store deterministic without processes having to coordinate. "Expires in thirty seconds" becomes a checkpoint computed when the fluent starts, not a comparison against os:timestamp() in a guard. A guard must not read the clock for the same reason, as the post on effects explained.

Signals: what value does this always equal ​

The reading itself is the other idea. A temperature isn't a fact that holds or an event that happens. It's a value that is always current, comes from outside, and remembers nothing. That is a signal, and the compiler already accepts its declaration, though nothing runs behind it yet:

bondy
%% Design. The declarations compile; the engine is not built.

constraint Reading = {sensor: String, celsius: Float}

signal temperature := latest(Reading(sensor: "t1"))
signal too_hot     := temperature > 100.0

too_hot is the first attempt's boolean, and it is correct as a signal: true exactly when the reading is high, false the moment it is not, no inertia. The bug was never that expression. The bug was using it as the alarm. The crossing is the instant too_hot goes from false to true. In the design, that transition is what gets told to the store as Crossed, and there the fluent takes over.

So the whole scenario, with every noun and role in its place. The reading is a signal, and the threshold test is a signal over it. The crossing is an occurrence, told to the store. A rewrite rule matches it and initiates the fluent. An acknowledgement is another occurrence, and another rule terminates the fluent. Four nouns and roles, none of them standing in for another, and none of the three questions lost.

What ships, what doesn't ​

Everything shown as working in the earlier posts compiles on the current toolchain, and I have been careful to say so each time. Here is the same care in the other direction.

The signal name := expr declaration compiles. There is no signal engine behind it: nothing recomputes, nothing is sampled. The @fluent attribute, initiate, terminate, holds, holds_at and the history log do not exist in the compiler. What they need is an extra step in the reactor: after each round of rules, update the fluents and recompute the signals. That step is designed and planned for version 0.9.

I am publishing the design now, before the code, for the reason the second post gave. These two are the ones whose absence you feel least, until you try to write the alarm. Everyone's first alarm clears itself. Everyone's second alarm forgets why it fired. The language that has both fluents and signals is the one where the third attempt is the first.

The next post is the last in the series, and it is the whole rate limiter in one place.