Hearsay-II solved speech understanding in 1975 with a shape almost nobody reaches for now: a shared knowledge space, a set of specialists that each declare when they are worth waking, and no control flow at all. Nothing calls anything. That last property is why it fits agents better than the pipeline you probably drew instead — a pipeline commits to the order of operations at design time, and an agent's whole value is deciding at runtime.
github.com/tacoda/iii-blackboard6 passed, offline and deterministic
A pipeline is a graph you draw. A blackboard is a space you post into. The difference shows up the moment the work stops matching the graph.
The blackboard is a shared, structured store partitioned into levels — raw input, derived evidence, candidate hypotheses, the solution. Independent knowledge sources watch it. Each declares an activation condition: a predicate over the blackboard's contents that says when this specialist has something to contribute. When a condition matches, that KS becomes eligible; a control component picks which eligible KS runs; it posts its contribution, which may make others eligible.
┌──────────────── blackboard ────────────────┐
│ raw evidence hypotheses solution │
└───┬───────┬────────────┬───────────┬──────┘
│ │ │ │
condition? condition? condition? condition?
│ │ │ │
extract propose score accept ← knowledge sources
└───────┴──────┬─────┴───────────┘
control: run the highest-priority eligible one
| Pipeline / DAG | Blackboard | |
|---|---|---|
| Order of operations | Decided when you draw it | Decided per step, from current state |
| Adding a capability | Rewire the graph | Add a KS; nothing else changes |
| A stage with nothing to do | Runs anyway, or you branch | Its condition doesn't match. It doesn't run |
| Partial results | Usually a failed run | Quiescence — an honest outcome |
| Predictability | You know what will happen | You know what can happen, not what will |
That last row is the price, and it is a real one. A blackboard run is harder to reason about before it happens and harder to test exhaustively. If your stages are genuinely fixed — intake, validate, transform, emit — a pipeline is clearer, cheaper, and easier to debug, and you should build that instead. Reach for a blackboard when the set of applicable steps varies per input, which is exactly the case a fixed graph handles worst.
The part you would expect to write as code — the predicate deciding whether a specialist wakes — turns out to be a declarative slot on a trigger binding.
Levels are state scopes; nothing needs creating, they exist on first write.
Knowledge sources are functions bound to a state trigger. The interesting
piece is conditions — an array on the binding, where fp::condition
expresses the precondition as data:
iii trigger engine::register_trigger \
trigger_type=state function_id=promote-hypothesis \
config='{"scope":"bb:hypotheses"}' \
conditions='[{
"function_id": "fp::condition",
"config": { "path": "/new_value/confidence",
"op": ">", "to": 0.8 }
}]'
Operators are == != > >=
< <= exists not_empty, plus
negate. And the predicate returns more than a boolean:
$ iii trigger fp::condition \
event='{"new_value":{"confidence":0.9}}' \
condition_config='{"path":"/new_value/confidence","op":">","to":0.8}'
{"decision": "allow", "reason": "`/new_value/confidence` is 0.9, expected > 0.8"}
$ # same condition, weaker hypothesis
{"decision": "skip", "reason": "`/new_value/confidence` is 0.5, expected > 0.8"}
That reason string is the detail worth stealing. Opportunistic control is the hardest kind to debug precisely because there is no call stack to read — "why didn't anything happen?" is the characteristic failure. Here every scheduling decision explains itself, and you did not have to instrument anything to get it. Classic blackboard implementations built that by hand.
Paths are JSON pointer — /new_value/confidence, not
new_value.confidence. A dotted path is not an error: it resolves to
"nothing", the condition evaluates to skip, and the knowledge source simply
never wakes. No exception, no warning, no failed binding — just a system that quietly
does nothing. This cost me a confused ten minutes and it will cost you the same.
Most of it is a rename. Two rows are not, and they are the ones to plan around.
| Hearsay-II | iii | Status |
|---|---|---|
| Blackboard, in levels | state scopes | config |
| Knowledge source | any registered function | config |
| Activation condition | fp::condition in conditions[] | config |
| Opportunistic triggering | state trigger, scope/key filters | config |
| Competing writes to a slot | state::compare-and-set | config |
| Change notification | state:created / updated / deleted | config |
| Explaining a decision | the condition's reason string | free |
| Agenda / priority scheduling | priority_field — RabbitMQ adapter only | gap |
| Quiescence detection | — | gap |
The agenda gap is a deployment detail with real consequences: the builtin and redis queue adapters ignore message priority entirely, so a single-node blackboard has an unordered agenda. Every eligible KS still runs; you just lose control over which one runs first, which — see the next rung — is the difference between an optimization and a bill.
Building it with nothing underneath is the only way to see which parts iii was quietly handling.
The store, the knowledge sources and the control loop are small. What takes the space is everything around them — content-hash compare-and-set, a change log so the loop can tell progress from stalling, and the termination logic.
def run(self) -> str:
for step in range(self.max_steps):
if not self.bb.empty("solution"):
return "solved"
eligible = [ks for ks in self.sources if ks.eligible(self.bb)]
if not eligible:
return "quiescent" # nothing applies. a real answer.
ks = max(eligible, key=lambda k: k.priority)
before = len(self.bb.log)
ks.action(self.bb)
if len(self.bb.log) == before:
return "stalled" # eligible but contributed nothing
return "exhausted"
Four outcomes, and only one of them is "solved". That is the honest shape of opportunistic control, and writing it out is what makes the earlier gap concrete: nothing calls anything, so nothing naturally ends. Termination is a thing you build. It looks free in the diagram.
stalled is the one I did not anticipate. A knowledge source whose condition
stays true but which contributes nothing will be selected forever — the single way this
loop livelocks. The check is three lines and it is not optional.
[0] extract-evidence (priority 10)
[1] propose-hypothesis (priority 20)
[2] score-hypothesis (priority 30)
[3] accept-hypothesis (priority 40)
[4] solution present — done
outcome: solved
answer: The backlog is a throughput limit, not a failure: one consumer, flat errors.
conf: 0.86
Each of these was invisible from one side or the other.
state::barrier
answers "wait for N contributions", not "nothing is eligible anymore" — a different
question, and the one that actually ends a run.
Agents are usually wired as pipelines because that is what the tooling suggests, not because the work is pipeline-shaped. If you have ever added a conditional branch to skip a stage that had nothing to do, you have started hand-rolling activation conditions. The blackboard is what that becomes if you take it seriously.
Not because it is clever, but because it declines to decide the thing agents need to decide at runtime.
Use a blackboard when the set of applicable steps varies per input. Not when the content varies — a pipeline handles that fine — but when you genuinely cannot say in advance which specialists apply. That is the case a fixed graph handles worst and the case agent systems hit constantly.
On iii, most of it is configuration. Levels are state scopes, knowledge
sources are bound functions, activation conditions are fp::condition
entries, and contention is compare-and-set. You get self-explaining
scheduling decisions for free. You do not get an ordered agenda without RabbitMQ, and you
do not get quiescence detection at all.
Build the termination logic first. It is the part that looks free in the diagram, is absent from both substrates, and determines whether your system stops or spins. Everything else is genuinely as easy as it looks.
The companion repo has both implementations side by side, plus a demo that walks the
activation conditions flipping between allow and skip against a
live engine. Six tests, offline and deterministic; the one worth reading first is
test_quiescence_when_nothing_eligible, because that outcome is the whole
architecture in one assertion.