Hearsay-II solved speech understanding in 1975 with a shape almost nobody reaches for now: a shared knowledge space, plus knowledge sources that each declare when they are worth waking. The shape has 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 one declares an activation condition, which is a predicate over the blackboard's contents. The predicate says when that knowledge source has something to contribute. When it matches, the knowledge source becomes eligible, and a control component picks which eligible one runs. It posts its contribution, and that contribution can make others eligible.
flowchart TD
subgraph bb["blackboard"]
raw["raw"]
evidence["evidence"]
hypotheses["hypotheses"]
solution["solution"]
end
subgraph ks["knowledge sources"]
extract["extract"]
propose["propose"]
score["score"]
accept["accept"]
end
control["control: run the highest-priority eligible one"]
raw -.- c1["condition?"] -.- extract
evidence -.- c2["condition?"] -.- propose
hypotheses -.- c3["condition?"] -.- score
solution -.- c4["condition?"] -.- accept
extract --- control
propose --- control
score --- control
accept --- control
class raw,evidence,hypotheses,solution dim
class c1,c2,c3,c4 block
class control pass
| 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 knowledge source; 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 never change (intake, validate, transform, emit), a pipeline is clearer, cheaper, and easier to debug. So build the pipeline instead. When the set of applicable steps varies per input, reach for a blackboard. A fixed graph handles that variation worst.
You would expect to write the predicate that wakes a knowledge source as code. On iii it is a declarative slot on a trigger binding.
Levels are state scopes, and nothing needs creating: they exist on first write.
A knowledge source is any function you bind 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, because there is no call stack to read. So "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 those explanations 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
never wakes. You get no exception, no warning, and no failed binding, just a system that
quietly does nothing. This typo 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, so a single-node blackboard has an unordered agenda. Every eligible knowledge source still runs, so you only lose control over which one runs first. The next rung shows why that ordering is the difference between an optimization and a bill.
Building a blackboard with nothing underneath shows which parts iii was handling for you.
The store, the knowledge sources, and the control loop are small. Everything around them takes the space: 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". Those four are the honest shape of opportunistic control. Writing them out makes the earlier gap concrete: nothing calls anything, so nothing ends on its own. Termination is a thing you build, and it looks free in the diagram.
stalled is the one I did not anticipate. If a knowledge source keeps its
condition true but contributes nothing, control selects it forever. That repeat selection is
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". It does not answer "nothing is eligible anymore", and
that second question is the one that ends a run.
Most teams wire agents as pipelines because that is what the tooling suggests, not because the work is pipeline-shaped. If you have ever added a branch to skip a stage with nothing to do, you have started hand-rolling activation conditions. The blackboard is what those branches become when you commit to them.
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, because a pipeline handles varying content fine. Use one when you cannot name in advance which knowledge sources apply, because a fixed graph handles that case worst. Agent systems hit it often.
On iii, most of the architecture 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 looks free in the diagram, and it is absent from both substrates. It decides whether your system stops or spins. Everything else is as easy as it looks.
The companion repo has both implementations side by side. It also has a demo that walks the
activation conditions flipping between allow and skip against a
live engine. The six tests are offline and deterministic. Read
test_quiescence_when_nothing_eligible first, because that outcome is the whole
architecture in one assertion.