Skip to content
academia.sh

Lesson 06 / 18

Blackboard Architecture

Resolvers that do not know each other meeting through a shared field of information: the number of names units know about each other coming out to zero in determining a fee zone from an address, the extra reads and polls spent to reach the same result, the number of files a new resolver edits, and whether the result is deterministic across twenty-four poll orders.

Contents

The previous lesson’s core did not know the plugins’ names, but it knew what the order was and how the result would be summed.

This lesson removes that center too. Units recognize neither each other nor a pattern that calls them; they write to a shared field, read from it, and the field itself provides the meeting point. Three things are measured: the names they know, the extra work spent on the same result — how many units read the same data, how many rounds spun empty — and whether the same input gives the same output every run.

Blackboard, Resolver, and Control

The blackboard pattern has three parts. The first is the board: the shared field of named partial results. The second is the resolvers: units that read from and write to the board, not knowing each other’s names. The third is the control loop: the loop deciding which resolver to poll and when, without knowing what they do.

This differs from the observer pattern’s notification: in the Design Patterns course’s Observer lesson, the publisher keeps a subscriber list and sends the notification directly, while on the blackboard the writer does not know who will read it. The distinguishing constraint is this: a resolver can only read and write the board, never call another resolver.

Ambiguous Zone and Four Resolvers

The zone the fee depends on cannot be determined from the incoming address alone: the postal code is missing a digit. Four resolvers satisfy the same contract — name and resolve(read) — and each returns a partial result along with a confidence degree. The province-name resolver writes no zone claim, only the normalized province name; the postal-code resolver needs that name when the code is incomplete.

mkdir -p blackboard/resolvers
cat > blackboard/fee.mjs <<'EOF'
// blackboard/fee.mjs — zone to fee: same shipment, coefficient by zone
const BASE = 8490;
const STEP = 50;
const COEFFICIENT = { near: 1, mid: 1.35, far: 1.8 };

export const amount = (zone) => Math.round((BASE * (COEFFICIENT[zone] ?? COEFFICIENT.far)) / STEP) * STEP;
EOF
cat > blackboard/resolvers/province-name.mjs <<'EOF'
// blackboard/resolvers/province-name.mjs — normalizes the province name; writes no zone claim
const PROVINCES = ["istanbul", "ankara", "izmir", "van"];

export const provinceName = { name: "province-name", resolve: (read) => {
  const name = read("address").province.toLowerCase();
  return PROVINCES.includes(name) ? { confidence: 0.7, write: { normalizedProvince: name } } : null;
} };
EOF
cat > blackboard/resolvers/postal-code.mjs <<'EOF'
// blackboard/resolvers/postal-code.mjs — full code directly; missing code falls to the province's farthest zone
const PREFIX = { 34: "near", "06": "mid", 35: "mid", 65: "far" };
const PROVINCE_FARTHEST = { istanbul: "mid", ankara: "far", izmir: "far", van: "far" };

export const postalCode = { name: "postal-code", resolve: (read) => {
  const code = read("address").postalCode;
  if (code.length === 5) return { confidence: 0.85, write: { zone: PREFIX[code.slice(0, 2)] ?? "far" } };
  const province = read("normalizedProvince");
  return province === undefined ? null : { confidence: 0.3, write: { zone: PROVINCE_FARTHEST[province] ?? "far" } };
} };
EOF
cat > blackboard/resolvers/coordinate.mjs <<'EOF'
// blackboard/resolvers/coordinate.mjs — maps the approximate straight-line distance to the center against thresholds
const CENTER = { latitude: 41.02, longitude: 28.98 };
const THRESHOLD = [[300, "near"], [900, "mid"]];

export const coordinate = { name: "coordinate", resolve: (read) => {
  const a = read("address");
  const dLatitude = (a.latitude - CENTER.latitude) * 111;
  const dLongitude = (a.longitude - CENTER.longitude) * 111 * Math.cos((CENTER.latitude * Math.PI) / 180);
  const km = Math.round(Math.hypot(dLatitude, dLongitude));
  return { confidence: 0.9, write: { zone: THRESHOLD.find(([s]) => km <= s)?.[1] ?? "far" } };
} };
EOF
cat > blackboard/resolvers/shipment-history.mjs <<'EOF'
// blackboard/resolvers/shipment-history.mjs — the zone of the same customer's most recent shipments
const HISTORY = { "M-118": "near", "M-204": "mid" };

export const shipmentHistory = { name: "shipment-history", resolve: (read) => {
  const z = HISTORY[read("address").customer];
  return z === undefined ? null : { confidence: 0.55, write: { zone: z } };
} };
EOF

The Board, the Control Loop, and the Direct Counterpart

The board takes one of two write rules: under first write wins, a second write to an occupied key is rejected; under most confident wins, only a write with higher confidence passes.

// blackboard/board.mjs — the shared field: write rule, reads and writes counters
export const board = (rule, initial) => {
  const field = new Map(Object.entries(initial));
  const confidence = new Map();
  const readers = new Map();
  const counters = { reads: 0, writes: 0, rejected: 0 };
  return {
    read: (key, who) => {
      counters.reads += 1;
      readers.set(key, (readers.get(key) ?? new Set()).add(who));
      return field.get(key);
    },
    write: (key, value, c) => {
      const occupied = field.has(key);
      const passes = rule === "first-write" ? occupied === false : (confidence.get(key) ?? -1) < c;
      if (passes === false) { counters.rejected += 1; return; }
      field.set(key, value);
      confidence.set(key, c);
      counters.writes += 1;
    },
    value: (key) => field.get(key),
    measure: () => ({ ...counters, readersByKey: Object.fromEntries([...readers].map(([k, s]) => [k, s.size])) }),
  };
};

The control loop polls in the given order: a resolver that contributes leaves the pending set, one that cannot is polled again next round, and the loop runs until it sees a round with no contribution.

// blackboard/control.mjs — control loop: polls in the given order, cycles rounds until no contribution remains
export const controlLoop = (t, resolvers) => {
  const pending = new Set(resolvers);
  let round = 0, poll = 0, contribution = 0, emptyRound = 0;
  while (true) {
    round += 1;
    let roundContribution = 0;
    for (const r of resolvers) {
      if (pending.has(r) === false) continue;
      poll += 1;
      const result = r.resolve((k) => t.read(k, r.name));
      if (result === null) continue;
      pending.delete(r);
      for (const [k, v] of Object.entries(result.write)) t.write(k, v, result.confidence);
      contribution += 1;
      roundContribution += 1;
    }
    if (roundContribution === 0) { emptyRound += 1; break; }
  }
  return { round, poll, contribution, emptyRound };
};

The arrangement to compare calls the same four resolvers directly: the order and the selection rule are written in this file; the order was parameterized for measurement.

// blackboard/direct.mjs — direct call: this file knows the four names, the order, and the selection rule
import { provinceName } from "./resolvers/province-name.mjs";
import { postalCode } from "./resolvers/postal-code.mjs";
import { coordinate } from "./resolvers/coordinate.mjs";
import { shipmentHistory } from "./resolvers/shipment-history.mjs";

export const ORDER = [provinceName, postalCode, coordinate, shipmentHistory];

export const directResolve = (address, order = ORDER) => {
  const data = { address };
  let reads = 0, contribution = 0, best = { confidence: -1, zone: undefined };
  for (const r of order) {
    const result = r.resolve((k) => (reads += 1, data[k]));
    if (result === null) continue;
    contribution += 1;
    Object.assign(data, result.write);
    const z = result.write.zone;
    if (z !== undefined && result.confidence > best.confidence) best = { confidence: result.confidence, zone: z };
  }
  return { ...best, round: 1, poll: order.length, contribution, reads };
};
// blackboard/main.mjs — composition root: resolver list and the measured address
import { postalCode } from "./resolvers/postal-code.mjs";
import { provinceName } from "./resolvers/province-name.mjs";
import { coordinate } from "./resolvers/coordinate.mjs";
import { shipmentHistory } from "./resolvers/shipment-history.mjs";

export const RESOLVERS = [postalCode, provinceName, coordinate, shipmentHistory];

export const ADDRESS = { postalCode: "0680", province: "ANKARA", latitude: 39.93, longitude: 32.86, customer: "M-118" };

Known Names and Work Done

The script counts how many other unit names appear in each file and runs both arrangements.

// names-and-work.mjs — how many other unit names each unit knows, and the work both arrangements do
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { board } from "./blackboard/board.mjs";
import { controlLoop } from "./blackboard/control.mjs";
import { directResolve } from "./blackboard/direct.mjs";
import { RESOLVERS, ADDRESS } from "./blackboard/main.mjs";
import { amount } from "./blackboard/fee.mjs";

const NAMES = RESOLVERS.map((r) => r.name);
const files = readdirSync("blackboard", { recursive: true })
  .map((a) => join("blackboard", a)).filter((y) => y.endsWith(".mjs")).sort();

const table = files.map((path) => {
  const text = readFileSync(path, "utf8");
  const own = NAMES.find((a) => path.includes(a));
  return [path, NAMES.filter((a) => a !== own && text.includes(a))];
});
console.log(`file = ${table.length}, other unit name 0 = ${table.filter(([, b]) => b.length === 0).length}`);
for (const [path, b] of table.filter(([, b]) => b.length > 0)) {
  console.log(`  ${path.padEnd(23)} other unit name = ${b.length}  (${b.join(", ")})`);
}

const t = board("most-confident", { address: ADDRESS });
const o = controlLoop(t, RESOLVERS);
const m = t.measure();
const z = t.value("zone");
console.log(`blackboard     zone = ${z} amount = ${amount(z)}`
  + `  round = ${o.round}  poll = ${o.poll}  contribution = ${o.contribution}  empty round = ${o.emptyRound}`);
console.log(`  reads = ${m.reads}  writes = ${m.writes}  rejected write = ${m.rejected}`
  + `  readers by key = ${Object.entries(m.readersByKey).map(([a, n]) => `${a}:${n}`).join(" ")}`);

const d = directResolve(ADDRESS);
console.log(`direct call    zone = ${d.zone} amount = ${amount(d.zone)}`
  + `  round = ${d.round}  poll = ${d.poll}  contribution = ${d.contribution}  empty round = 0`);
console.log(`  reads = ${d.reads}  writes = none  rejected write = none`);
node names-and-work.mjs
file = 9, other unit name 0 = 7
  blackboard/direct.mjs   other unit name = 4  (postal-code, province-name, coordinate, shipment-history)
  blackboard/main.mjs     other unit name = 4  (postal-code, province-name, coordinate, shipment-history)
blackboard     zone = mid amount = 11450  round = 3  poll = 5  contribution = 4  empty round = 1
  reads = 7  writes = 2  rejected write = 2  readers by key = address:4 normalizedProvince:1
direct call    zone = mid amount = 11450  round = 1  poll = 4  contribution = 4  empty round = 0
  reads = 5  writes = none  rejected write = none

Seven of the nine files carry no other unit’s name: the board, the control loop, the fee conversion, and all four resolvers are 0. Two files know the names, both carrying 4, and they differ in kind: on the blackboard the knowledge sits in a composition-root list; in the direct-call arrangement it sits next to the call order and the selection rule.

The second half shows the cost. Both arrangements found the same zone and amount, but the blackboard spent 3 rounds, 5 polls, and 7 reads; the direct call finished in 1 round with 4 polls and 5 reads. The extra poll belongs to the postal-code resolver: on the first round the normalized province was not yet on the board, so it was polled again on the second; one round spun empty just to detect termination. Four units read the address key, since none knows what the others read.

Same Input, Twenty-Four Orders

The control loop takes the order from outside; whether the result changes when the order changes is a measurable question. The 24 arrangements of the four resolvers are run.

// determinism.mjs — same input, 24 different poll orders: is the result and contribution order-dependent?
import { board } from "./blackboard/board.mjs";
import { controlLoop } from "./blackboard/control.mjs";
import { directResolve } from "./blackboard/direct.mjs";
import { RESOLVERS, ADDRESS } from "./blackboard/main.mjs";
import { amount } from "./blackboard/fee.mjs";

const permutation = (items) => (items.length <= 1 ? [items]
  : items.flatMap((e, i) => permutation([...items.slice(0, i), ...items.slice(i + 1)]).map((p) => [e, ...p])));
const ORDERS = permutation(RESOLVERS);

const distribution = (runs, attr) => {
  const m = new Map();
  for (const r of runs) m.set(r[attr], (m.get(r[attr]) ?? 0) + 1);
  return [...m].sort();
};

const report = (label, runs) => {
  const zones = distribution(runs, "zone");
  console.log(`${label.padEnd(27)} distinct zone = ${zones.length}:`
    + ` ${zones.map(([z, n]) => `${z} ${n} orders ${amount(z)}`).join(", ")}`);
  console.log(`  contribution = ${distribution(runs, "contribution").map(([k, n]) => `${k} (${n} orders)`).join(", ")}`);
};

for (const rule of ["first-write", "most-confident"]) {
  report(`blackboard / ${rule}`, ORDERS.map((order) => {
    const t = board(rule, { address: ADDRESS });
    const o = controlLoop(t, order);
    return { zone: t.value("zone"), contribution: o.contribution };
  }));
}
report("direct call", ORDERS.map((order) => directResolve(ADDRESS, order)));
node determinism.mjs
blackboard / first-write    distinct zone = 3: far 2 orders 15300, mid 11 orders 11450, near 11 orders 8500
  contribution = 4 (24 orders)
blackboard / most-confident distinct zone = 1: mid 24 orders 11450
  contribution = 4 (24 orders)
direct call                 distinct zone = 1: mid 24 orders 11450
  contribution = 3 (12 orders), 4 (12 orders)

The first pair of lines shows the pattern’s most expensive flaw. Under the first-write rule, the same address resolved to three different zones across the 24 orderings, and the same shipment was fee’d at 8,500, 11,450, or 15,300 cents. What determines the result is not the address but which resolver the control loop polls first; under this rule the pattern is not deterministic.

The second pair shows the flaw is in the write rule, not the board: under the most-confident rule, all 24 orderings gave the same zone and amount. Determinism comes from having a comparable confidence degree in the contract.

The third pair gives the blackboard’s counterpart. The direct call produced the same result in every order, but in 12 of the 24 orderings the contribution came out 3 instead of 4: the postal-code resolver was called before the normalized province name, so it was eliminated and never asked again. On the blackboard, the contribution is 4 in every case, since a pending resolver is polled again next round.

The Fifth Resolver

The fifth resolver proposes the default zone written in the customer contract. The block adds it to a copy of the tree, lists the edited files, and loads the patched tree.

cp -r blackboard fifth
cat > fifth/resolvers/contract-default.mjs <<'EOF'
// fifth/resolvers/contract-default.mjs — fifth resolver: default zone written in the contract
const CONTRACT = { "M-118": "mid", "M-204": "near" };

export const contractDefault = { name: "contract-default", resolve: (read) => {
  const z = CONTRACT[read("address").customer];
  return z === undefined ? null : { confidence: 0.4, write: { zone: z } };
} };
EOF
python3 - <<'EOF'
from pathlib import Path

HISTORY = 'import { shipmentHistory } from "./resolvers/shipment-history.mjs";'
NEW = 'import { contractDefault } from "./resolvers/contract-default.mjs";'
PAIR = [(HISTORY, f"{HISTORY}\n{NEW}"), ("shipmentHistory];", "shipmentHistory, contractDefault];")]
for name in ["main.mjs", "direct.mjs"]:
    path = Path("fifth") / name
    text = path.read_text().replace("// blackboard/", "// fifth/")
    for old, new in PAIR:
        text = text.replace(old, new)
    path.write_text(text)
EOF
diff -rq blackboard fifth
node -e 'Promise.all([import("./fifth/main.mjs"), import("./fifth/direct.mjs")])
  .then(([k, d]) => console.log(`fifth tree: main list = ${k.RESOLVERS.length}, direct order = ${d.ORDER.length}`))'
Files blackboard/direct.mjs and fifth/direct.mjs differ
Files blackboard/main.mjs and fifth/main.mjs differ
Only in fifth/resolvers: contract-default.mjs
fifth tree: main list = 5, direct order = 5

One file was added and two edited: the composition root and the direct-call arrangement. The board, the control loop, the fee conversion, and the four resolvers did not change at all. The file count is the same in both arrangements, not the kind — one is only a list, the other the call order plus the selection rule.

The Trade-off’s Numbers

The gain was measured in two quality attributes: in modifiability, seven of nine files carry no other unit’s name, and the fifth resolver left the board, the control loop, and the four resolvers untouched; in integrity, the contribution is 4 in every one of the 24 orderings.

The cost has three items. Extra work: 3 rounds, 5 polls, and 7 reads against the direct call’s 1 round, 4 polls, and 5 reads. Determinism: with the wrong write rule, the same address produced three different fees. Visibility: only the board’s record shows which resolver determined the result.

Summary

  • The blackboard pattern has three parts: the shared field partial results are written to, resolvers that do not know each other’s names, and a control loop that does not know what they do; since the writer does not know who reads, this differs from the observer pattern’s notification.
  • Seven of nine files carry 0 other-unit names; the two that know the names are the composition root and the direct-call arrangement, and both carry 4.
  • For the same result the blackboard spent 3 rounds, 5 polls, and 7 reads; the direct call spent 1 round, 4 polls, and 5 reads; 4 units read the address key, and 1 round spun empty.
  • Under the first-write rule, the 24 orderings produced 3 zones and 3 amounts (8,500, 11,450, 15,300); under the most-confident rule, all gave a single zone and a single amount.
  • The contribution is 4 in all 24 orderings on the blackboard, and 3 in 12 orderings for the direct call; the fifth resolver added 1 file and edited 2.

Next Step

This topic addressed how units are arranged and which way the dependency arrow points: layered orders, inward-facing rings, distributable components, pluggable plugins, and resolvers that never know each other. In the blackboard pattern, units wrote to a shared field, read from it, and the field itself provided the meeting point. The next topic asks not about arrangement but about the conversation style: in an interaction between two units, who initiates, who waits, and how much must be known about the other side.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close