---
title: "The Architect's Role"
source: 'https://academia.sh/en/courses/architect-role'
language: en
updated: '2026-08-23T07:01:02+00:00'
license: 'CC BY-SA 4.0'
lessons: 11
outcomes:
    - "Separates the levels of architecture and each level's decision scope"
    - "Defines the architect's technical and non-technical responsibilities"
    - 'Applies the practice of simplifying and striking balance'
---

# The Architect's Role

Defining architecture as a set of decisions and as a role: telling apart the decisions that are expensive to change, separating the application, solution, and enterprise levels by decision scope, the decision, communication, and oversight responsibilities, the ivory tower problem in the relationship between architect and developer, carrying the decision across to the team, and the practices of striking balance, simplifying, reversibility, stakeholder management, technology evaluation, and continuous learning.

## What Is Architecture

The definition and scope of architecture: telling apart decisions that are expensive to change, separating the application, solution, and enterprise levels by decision scope, the decision, communication, and oversight responsibilities, how a decision cut off from the code becomes the ivory tower problem, and carrying the decision to the team through consulting and coaching.

### Software Architecture Definition

Source: https://academia.sh/en/courses/architect-role/software-architecture-definition

Defining software architecture as a set of decisions that are expensive to reverse: reading each decision's cost of reversal from the dependent closure in a model codebase, ranking decisions by that number, writing down the source of the threshold, and comparing that ranking against one by body size.

The prerequisite curricula established decisions themselves. The Software Design and Architectural
Principles curriculum defined coupling, cohesion, design patterns, and architectural styles, and
measured each of them; the System Design and Distributed Systems curriculum tied scaling,
resilience, and data distribution decisions from assumption to calculation, and from calculation
to measurement. Both curricula answered the same question: what is the difference between these
options, and what number does it show up as.

Neither asked: **who** makes this decision, on what basis, by ruling out which alternative, and
what happens to it after it is made. This curriculum's subject is not decisions themselves but the
process and the trail a decision leaves. The first question is a definition: which of the hundreds
of decisions in a codebase count as "architectural"? This lesson ties that distinction to a
number — the decision's **cost of reversal**.

#### The Decision's Body and Its Reversal

A decision sits somewhere in the code. The body of the decision "the loan period rule is read from
configuration" is the rule module; the body of the decision "authentication is consolidated in a
single module" is that module. The body can be small. The cost of reversing the decision, though,
is measured not by the body but by everything that depends on the body: breaking the decision takes
more than changing the body — it requires touching every module directly or indirectly dependent
on it.

Measurement needs a codebase. The file below is the model for the example that will run throughout
this curriculum: **the software system of a regional library network**. The network has multiple
branches, an IT unit with its own budget, an externally sourced catalog system, and in-house loan
services. This is a model; it does not describe a real institution. The module names, file counts,
and import edges are chosen by hand (**WA1**).

```js
// architecture/codebase.mjs — MODEL codebase for the regional library network software system.
// Not a real institution; module names, file counts, and import edges are chosen by hand.
export const FILES = {
  configuration: 3, logging: 4, clock: 2, "data-access": 9, "event-bus": 7,
  authentication: 8, authorization: 5, "catalog-connector": 11, "catalog-cache": 6,
  "loan-rule": 5, "loan-core": 14, reservation: 8, "member-registry": 9, "member-penalty": 6,
  "branch-inventory": 10, "branch-sync": 12, "notification-queue": 6, "notification-email": 15,
  "report-daily": 7, "report-batch": 13, "web-front": 18, "staff-front": 22,
  "kiosk-front": 9, "batch-job": 8,
};

// IMPORTS[a] = modules that a imports (a -> b: a depends on b)
export const IMPORTS = {
  configuration: [], clock: [], logging: ["configuration"],
  "data-access": ["configuration", "logging"],
  "event-bus": ["configuration", "logging"],
  authentication: ["data-access", "configuration", "logging"],
  authorization: ["authentication"],
  "catalog-connector": ["configuration", "logging"],
  "catalog-cache": ["catalog-connector", "clock"],
  "loan-rule": ["configuration", "clock"],
  "loan-core": ["loan-rule", "data-access", "event-bus", "clock", "catalog-connector"],
  reservation: ["loan-core", "notification-queue", "data-access"],
  "member-registry": ["data-access", "logging"],
  "member-penalty": ["member-registry", "loan-rule", "clock", "data-access"],
  "branch-inventory": ["data-access", "event-bus"],
  "branch-sync": ["branch-inventory", "event-bus", "catalog-connector", "logging"],
  "notification-queue": ["data-access", "event-bus"],
  "notification-email": ["notification-queue", "configuration"],
  "report-daily": ["data-access", "clock"],
  "report-batch": ["data-access", "branch-inventory", "clock", "logging"],
  "web-front": ["loan-core", "catalog-cache", "member-registry", "reservation", "authentication"],
  "staff-front": ["loan-core", "member-registry", "member-penalty", "branch-inventory", "report-daily",
    "authentication", "authorization"],
  "kiosk-front": ["loan-core", "catalog-cache", "authentication"],
  "batch-job": ["report-batch", "branch-sync", "notification-queue", "notification-email"],
};

// DECISIONS[i].body = the modules that are the decision's counterpart in code (the decision's body)
export const DECISIONS = [
  { name: "data access goes through a single layer", body: ["data-access"] },
  { name: "settings are read from a single source", body: ["configuration"] },
  { name: "inventory is synced over the event bus", body: ["event-bus", "branch-sync"] },
  { name: "the external catalog is accessed through a single connector", body: ["catalog-connector"] },
  { name: "authentication is consolidated in a single module", body: ["authentication"] },
  { name: "the loan period rule is read from configuration", body: ["loan-rule"] },
  { name: "the clock source is taken from a single module", body: ["clock"] },
  { name: "penalty calculation stays separate from the member record", body: ["member-penalty"] },
  { name: "the daily report is produced in a separate module", body: ["report-daily"] },
  { name: "email notifications go out with a plain-text body", body: ["notification-email"] },
  { name: "the kiosk front is a separate deployment unit", body: ["kiosk-front"] },
];

export const MODULES = Object.keys(FILES);

// modules directly or indirectly dependent on b (the reverse closure)
export function dependentClosure(core) {
  const closed = new Set(core);
  let grew = true;
  while (grew) {
    grew = false;
    for (const m of MODULES) {
      if (closed.has(m)) continue;
      if (IMPORTS[m].some((h) => closed.has(h))) { closed.add(m); grew = true; }
    }
  }
  return closed;
}

export const sumFiles = (set) => [...set].reduce((t, m) => t + FILES[m], 0);
```

The measure's definition lives in the `dependentClosure` function: it starts from a set of modules,
and every module dependent on a module already in the set is added to it; this continues until the
set stops growing. This is the decision's **reach**. The cost of reversal is the number of modules
and files in that reach (**WA2**). The number is an upper bound: it assumes every dependent module
will actually be touched, though some of them may not need a change. Measuring an upper bound
rather than a lower bound is deliberate in this lesson — the question being asked is how expensive
a decision could turn out to be.

#### Measurement

The second file ranks the eleven decisions and tries two thresholds.

```js
// architecture/reversal.mjs — each decision's cost of reversal and a comparison of two rankings
import { FILES, DECISIONS, MODULES, dependentClosure, sumFiles } from "./codebase.mjs";

const totalFiles = sumFiles(new Set(MODULES));
console.log(`model codebase: ${MODULES.length} modules, ${totalFiles} files`);

const measurements = DECISIONS.map((k) => {
  const localFiles = k.body.reduce((t, m) => t + FILES[m], 0);
  const closed = dependentClosure(k.body);
  return { name: k.name, localModules: k.body.length, localFiles,
    spreadModules: closed.size, spreadFiles: sumFiles(closed) };
});
const sorted = [...measurements].sort((a, b) => b.spreadFiles - a.spreadFiles);

const col = (s, n) => String(s).padEnd(n);
console.log("\n" + col("decision", 60) + col("body", 10) + col("reversal", 12) + "ratio");
console.log("-".repeat(90));
for (const o of sorted)
  console.log(col(o.name, 60) + col(`${o.localModules}m/${o.localFiles}f`, 10) +
    col(`${o.spreadModules}m/${o.spreadFiles}f`, 12) + (o.spreadFiles / o.localFiles).toFixed(1) + "x");

// threshold 1: an explicitly chosen rule — more than half the codebase
const thresholdA = Math.floor(totalFiles / 2) + 1;
const aboveA = sorted.filter((o) => o.spreadFiles >= thresholdA);
const avg = (d) => (d.reduce((t, o) => t + o.spreadFiles, 0) / d.length).toFixed(1);
console.log(`\nthreshold A (chosen rule, half the codebase) = ${thresholdA} files`);
console.log(`  above it: ${aboveA.length} decisions (avg. ${avg(aboveA)} files), below it: ${sorted.length - aboveA.length} decisions (avg. ${avg(sorted.slice(aboveA.length))} files)`);
console.log(`  the decision closest to the threshold is ${thresholdA - sorted[aboveA.length].spreadFiles} files below it`);

// threshold 2: the largest absolute gap in the ranking
let maxGap = 0, cut = 0;
for (let i = 0; i < sorted.length - 1; i++) {
  const b = sorted[i].spreadFiles - sorted[i + 1].spreadFiles;
  if (b > maxGap) { maxGap = b; cut = i + 1; }
}
console.log(`threshold B (largest gap in the ranking) = ${sorted[cut].spreadFiles + 1} files (${sorted[cut - 1].spreadFiles} -> ${sorted[cut].spreadFiles}, a ${maxGap}-file jump)`);
console.log(`  above it: ${cut} decisions, below it: ${sorted.length - cut} decisions`);
console.log(`decisions both thresholds select = ${cut} / ${aboveA.length}; same ranking, different cutoff`);

// comparison: ranking by body size / ranking by cost of reversal
const A = [...measurements].sort((a, b) => b.localFiles - a.localFiles).map((o) => o.name);
const B = sorted.map((o) => o.name);
let inverted = 0;
for (let i = 0; i < A.length; i++)
  for (let j = i + 1; j < A.length; j++) if (B.indexOf(A[i]) > B.indexOf(A[j])) inverted++;
const pairs = (A.length * (A.length - 1)) / 2;
const shared = A.slice(0, 4).filter((x) => B.slice(0, 4).includes(x)).length;
console.log(`\nbody ranking <-> reversal ranking: ${inverted}/${pairs} inverted pairs (${((inverted / pairs) * 100).toFixed(0)}%)`);
console.log(`overlap in the top four decisions = ${shared}/4`);
const topA = measurements.find((o) => o.name === A[0]), topB = measurements.find((o) => o.name === B[0]);
console.log(`decision with the largest body : ${topA.localFiles} files body, ${topA.spreadFiles} files reversal`);
console.log(`most expensive to reverse      : ${topB.localFiles} files body, ${topB.spreadFiles} files reversal`);
```

```
model codebase: 24 modules, 217 files

decision                                                    body      reversal    ratio
------------------------------------------------------------------------------------------
settings are read from a single source                      1m/3f     23m/215f    71.7x
data access goes through a single layer                     1m/9f     17m/179f    19.9x
inventory is synced over the event bus                      2m/19f    12m/142f    7.5x
the clock source is taken from a single module              1m/2f     12m/118f    59.0x
the external catalog is accessed through a single connector 1m/11f    9m/108f     9.8x
the loan period rule is read from configuration             1m/5f     7m/82f      16.4x
authentication is consolidated in a single module           1m/8f     5m/62f      7.8x
the daily report is produced in a separate module           1m/7f     2m/29f      4.1x
penalty calculation stays separate from the member record   1m/6f     2m/28f      4.7x
email notifications go out with a plain-text body           1m/15f    2m/23f      1.5x
the kiosk front is a separate deployment unit               1m/9f     1m/9f       1.0x

threshold A (chosen rule, half the codebase) = 109 files
  above it: 4 decisions (avg. 163.5 files), below it: 7 decisions (avg. 48.7 files)
  the decision closest to the threshold is 1 files below it
threshold B (largest gap in the ranking) = 143 files (179 -> 142, a 37-file jump)
  above it: 2 decisions, below it: 9 decisions
decisions both thresholds select = 2 / 4; same ranking, different cutoff

body ranking <-> reversal ranking: 31/55 inverted pairs (56%)
overlap in the top four decisions = 2/4
decision with the largest body : 19 files body, 142 files reversal
most expensive to reverse      : 3 files body, 215 files reversal
```

The table's last column is the measure's real finding. The decision that settings are read from a
single source sits in a three-file body; reversing it touches 23 modules and 215 files — nearly the
entire model codebase — **71.7 times**. The clock source being taken from a single module spreads
from a two-file body to 118 files. At the other end, the decision that the kiosk front is a separate
deployment unit starts at nine files and ends at nine files: no module depends on it, a ratio of
**1.0**.

#### The Threshold's Source

Ranking alone does not give a distinction; a distinction needs a threshold, and the threshold must
have a source. The output tried two thresholds. Threshold A is an explicitly chosen rule: a decision
whose reversal touches more than half the codebase (**WA3**). This rule gives a 109-file cutoff and
separates four decisions; the top four average 163.5 files, the bottom seven average 48.7 files — a
ratio of 3.4 between them. Threshold B comes from the measured distribution: the largest jump in the
ranking is the 37-file gap from 179 down to 142, and it separates two decisions.

The two thresholds give different numbers, and this is the definition's fragile spot. The decision
closest to threshold A stays **one file** below the boundary; a one-file difference switches a
decision's class. The rule that follows is this: the ranking is defensible, the boundary is open to
argument. What should be said when calling a decision "architecturally significant" is not a class
but the rank and the number that gives that rank. The sentence "this decision is architectural" is
unmeasured; the sentence "reversing this decision touches 23 modules, 215 files" is verifiable.

#### Why Looking at the Body Is Misleading

The comparison runs two rankings on the same input. The first ranks the decision by the size of its
**body**: how much room the change takes up. The second ranks it by cost of reversal. For the same
eleven decisions, the two rankings disagree on 31 of 55 pairs — **56%**. Only two decisions are
shared in the top four.

The extremes show the difference plainly. The decision with the largest body has a 19-file body and
its reversal touches 142 files; the most expensive decision to reverse sits in a three-file body and
touches 215 files. The decision that email notifications go out with a plain-text body ranks near
the top in body size with 15 files, and third from the bottom in cost of reversal with 23 files. In
a review, that is the one that would get flagged as a "large change"; the one that would prove
impossible to reverse is the three-line settings-read decision.

This sets the measure's direction. A decision's weight is read not from the effort spent writing it,
but from how much others have come to assume it. This looks in the same direction as the Software
Design and Architectural Principles curriculum's coupling measure; the difference is that the
measure there counts the bond between modules, while this one counts a **decision's** reach.

#### Definition

The measure yields the definition. **Software architecture** is the set of a system's decisions that
are expensive to reverse. The word "expensive" was tied to a number in this lesson: the number of
modules and files that must be touched to break the decision. An **architecturally significant
decision** is one whose number stays above a chosen threshold; the threshold is written down and its
source is stated.

The same measure also yields a second distinction. A **reversible decision** is one whose reach is
small — a choice like the kiosk front being a separate deployment unit, which starts and ends at
nine files. What gets called an **irreversible decision** is not an absolute barrier but a decision
whose cost of reversal approaches the entire codebase as its reach grows. The difference between the
two extremes in this model is 215 to 9, a ratio of 23.9.

The definition's practical consequence is this: whether a decision is architectural cannot be told
by looking at its subject matter. Where the loan period rule is read from looks like a small detail,
and it touches 82 files in the model codebase; choosing an architectural style looks like a large
decision, and that choice's real weight is likewise read from its reach. The layered, hexagonal,
event-driven, and microservices styles established in the Architectural Styles course will not be
retold in this curriculum; this course's subject is not the style itself but the decision that
chooses it.

#### Summary

- A decision's body is the set of modules that are its counterpart in code; its cost of reversal is
  the file count of every module directly or indirectly dependent on that body.
- Eleven decisions were measured in the model codebase (24 modules, 217 files); the most expensive
  decision had a three-file body and a 215-file reach — 71.7 times.
- There is no distinction without a written threshold: the half-the-codebase rule separated four
  decisions (averages of 163.5 and 48.7 files), the largest-gap rule separated two decisions; the
  decision closest to the threshold stayed one file below the boundary.
- Ranking by body size against ranking by cost of reversal disagreed on 31 of 55 pairs; the decision
  with the largest body touches 142 files, one of the smallest bodies touches 215 files.
- Software architecture is the set of decisions that are expensive to reverse; the sentence "this
  decision is architectural" is unmeasured — the number of modules and files touched is written
  instead.

#### Next Step

This lesson measured decisions on a single codebase and ranked them all on the same plane. Yet the
decisions in the output are not all made in the same place. Where settings are read from can stay
inside a single application; how inventory is synced across branches concerns more than one
application; and accessing the externally sourced catalog system through a single connector binds
every branch and the IT unit's budget. The next lesson distributes the same set of decisions across
three levels — application, solution, and enterprise architecture — and counts how many decisions
each level covers and how many units it affects. What it actually measures is this: when a decision
is made at the wrong level, how many units are left uninformed, and how many times is the same
decision made over again.

### Architecture Levels

Source: https://academia.sh/en/courses/architect-role/architecture-levels

Separating application, solution, and enterprise architecture by decision scope: distributing the same set of decisions across three levels by how many units they operate, the number of decisions each level covers and the number of units it affects, and the counts of uninformed units, repeated decisions, and conflicting implementations when a decision is made at the wrong level.

The previous lesson measured eleven decisions on a single codebase and ranked them all on the same
plane. Yet those decisions are not all made in the same place. Where settings are read from binds
the entire system; the daily report being produced in a separate module does not reach beyond two
units; the kiosk front being a separate deployment unit stays within itself alone.

Architecture levels are the name for this distinction: **application architecture**, **solution
architecture**, and **enterprise architecture**. The three levels are usually described by title.
In this lesson they are defined not by title but by scope, and the scope is counted: how many
deployment units a decision binds.

#### Scope Determines the Level

The previous lesson's reach measure was at the module level. The question of level needs one more
mapping: which unit runs which module. A **deployment unit** is a piece that is released
separately, runs separately, and is usually looked after by a separate team; it looks in the same
direction as the bounded context distinction established in the Software Design and Architectural
Principles curriculum's Domain-Driven Design course, but here what draws the boundary is not the
domain but operating responsibility.

A decision's scope is this: the set of units that run at least one of the modules in the decision's
reach (**WA4**). If the scope is one unit, the decision is at the application level; if it binds the
whole network or most of it, it is enterprise; if it falls between the two, it is at the solution
level. "Most of it" is a threshold, and the threshold's source must be written down: here, two-thirds
of the units was chosen (**WA5**).

The first file gives the previous lesson's import graph and decision bodies again; the graph is
repeated so the block runs on its own.

```js
// architecture/model.mjs — MODEL library network codebase: import graph and decision bodies.
// Not a real institution; edges and decision bodies are chosen by hand.
export const IMPORTS = {
  configuration: [], clock: [], logging: ["configuration"],
  "data-access": ["configuration", "logging"], "event-bus": ["configuration", "logging"],
  authentication: ["data-access", "configuration", "logging"], authorization: ["authentication"],
  "catalog-connector": ["configuration", "logging"],
  "catalog-cache": ["catalog-connector", "clock"],
  "loan-rule": ["configuration", "clock"],
  "loan-core": ["loan-rule", "data-access", "event-bus", "clock", "catalog-connector"],
  reservation: ["loan-core", "notification-queue", "data-access"],
  "member-registry": ["data-access", "logging"],
  "member-penalty": ["member-registry", "loan-rule", "clock", "data-access"],
  "branch-inventory": ["data-access", "event-bus"],
  "branch-sync": ["branch-inventory", "event-bus", "catalog-connector", "logging"],
  "notification-queue": ["data-access", "event-bus"],
  "notification-email": ["notification-queue", "configuration"],
  "report-daily": ["data-access", "clock"],
  "report-batch": ["data-access", "branch-inventory", "clock", "logging"],
  "web-front": ["loan-core", "catalog-cache", "member-registry", "reservation", "authentication"],
  "staff-front": ["loan-core", "member-registry", "member-penalty", "branch-inventory", "report-daily",
    "authentication", "authorization"],
  "kiosk-front": ["loan-core", "catalog-cache", "authentication"],
  "batch-job": ["report-batch", "branch-sync", "notification-queue", "notification-email"],
};
export const DECISIONS = [
  { name: "data access goes through a single layer", body: ["data-access"] },
  { name: "settings are read from a single source", body: ["configuration"] },
  { name: "inventory is synced over the event bus", body: ["event-bus", "branch-sync"] },
  { name: "the external catalog is accessed through a single connector", body: ["catalog-connector"] },
  { name: "authentication is consolidated in a single module", body: ["authentication"] },
  { name: "the loan period rule is read from configuration", body: ["loan-rule"] },
  { name: "the clock source is taken from a single module", body: ["clock"] },
  { name: "penalty calculation stays separate from the member record", body: ["member-penalty"] },
  { name: "the daily report is produced in a separate module", body: ["report-daily"] },
  { name: "email notifications go out with a plain-text body", body: ["notification-email"] },
  { name: "the kiosk front is a separate deployment unit", body: ["kiosk-front"] },
];
export const MODULES = Object.keys(IMPORTS);
export function dependentClosure(core) {
  const closed = new Set(core);
  for (let grew = true; grew; ) {
    grew = false;
    for (const m of MODULES)
      if (!closed.has(m) && IMPORTS[m].some((h) => closed.has(h))) { closed.add(m); grew = true; }
  }
  return closed;
}
```

#### Measurement

The second file builds the unit mapping, counts each decision's scope, and runs the same set of
decisions across three schemes.

```js
// architecture/level.mjs — the number of units a decision covers, and its spread across three levels
import { DECISIONS, dependentClosure } from "./model.mjs";

// UNIT — deployment units of the MODEL library network; which modules each unit runs
const UNIT = {
  "web-portal": ["web-front"],
  "staff-app": ["staff-front"],
  "kiosk-network": ["kiosk-front"],
  "loan-service": ["loan-core", "loan-rule", "reservation"],
  "member-service": ["member-registry", "member-penalty"],
  "catalog-integration": ["catalog-connector", "catalog-cache"],
  "inventory-service": ["branch-inventory", "branch-sync"],
  "notification-service": ["notification-queue", "notification-email"],
  "report-service": ["report-daily", "report-batch", "batch-job"],
  "shared-infrastructure": ["configuration", "logging", "clock", "data-access", "event-bus",
    "authentication", "authorization"],
};
const UNITS = Object.keys(UNIT);
const U = UNITS.length;
const ENTERPRISE_THRESHOLD = Math.ceil((U * 2) / 3); // chosen rule: two-thirds of the units

const scope = (k) => {
  const reach = dependentClosure(k.body);
  return UNITS.filter((u) => UNIT[u].some((m) => reach.has(m)));
};
const levelName = (n, e = ENTERPRISE_THRESHOLD) => (n === 1 ? "application" : n >= e ? "enterprise" : "solution");

const measurements = DECISIONS.map((k) => ({ name: k.name, n: scope(k).length, set: scope(k) }));
const col = (s, n) => String(s).padEnd(n);
console.log(`${U} units, ${DECISIONS.length} decisions, enterprise threshold = ${ENTERPRISE_THRESHOLD} units\n`);
console.log(col("decision", 60) + col("units", 8) + "level");
console.log("-".repeat(78));
for (const o of [...measurements].sort((a, b) => b.n - a.n)) console.log(col(o.name, 60) + col(o.n, 8) + levelName(o.n));

console.log("");
for (const d of ["enterprise", "solution", "application"]) {
  const g = measurements.filter((o) => levelName(o.n) === d);
  const affected = new Set(g.flatMap((o) => o.set));
  console.log(`${col(d, 13)} decisions=${col(g.length, 4)} units affected=${col(affected.size, 4)} avg. scope=${(g.reduce((t, o) => t + o.n, 0) / g.length).toFixed(1)}`);
}
console.log("threshold sensitivity: " + [6, 7, 8].map((e) =>
  `threshold ${e} -> ${measurements.filter((o) => levelName(o.n, e) === "enterprise").length} enterprise`).join(", "));

// ---- comparison: same set of decisions across three schemes ----
// OPTIONS[k] = the decision's number of reasonable options; the unit that decides independently picks one of them
const OPTIONS = [3, 4, 3, 2, 3, 4, 2, 3, 2, 3, 2];
function prng(seed) { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32; }
const rand = prng(48151); // seed is visible; the same seed gives the same numbers

let uninformed = 0, repeated = 0, conflicting = 0;
measurements.forEach((o, i) => {
  const others = o.n - 1;              // scope units other than the decision's owner
  uninformed += others; repeated += others;
  const ownersChoice = Math.floor(rand() * OPTIONS[i]);
  for (let j = 0; j < others; j++) if (Math.floor(rand() * OPTIONS[i]) !== ownersChoice) conflicting++;
});
const consultLocal = 0, consultThreeLevel = measurements.reduce((t, o) => t + o.n - 1, 0), consultCentral = DECISIONS.length * (U - 1);

console.log(`\n${col("scheme", 12) + col("uninformed", 12) + col("repeated", 10) + col("conflicting", 13)}consults`);
console.log("-".repeat(55));
console.log(col("local", 12) + col(uninformed, 12) + col(repeated, 10) + col(conflicting, 13) + consultLocal);
console.log(col("three-level", 12) + col(0, 12) + col(0, 10) + col(0, 13) + consultThreeLevel);
console.log(col("centralized", 12) + col(0, 12) + col(0, 10) + col(0, 13) + consultCentral);

console.log(`\nthe local scheme avoids ${consultThreeLevel} consults, in exchange it gets ${conflicting} conflicting implementations`);
console.log(`break-even: the three-level scheme wins if resolving a conflicting implementation costs more than ${(consultThreeLevel / conflicting).toFixed(2)} consults`);
console.log(`the centralized scheme spends ${consultCentral - consultThreeLevel} more consults than the three-level scheme, while conflicts are 0 in both`);
const soleScope = measurements.filter((o) => o.n === 1).length;
console.log(`of that, ${soleScope * (U - 1)} is spent on the ${soleScope} decision(s) whose scope is a single unit`);
```

```
10 units, 11 decisions, enterprise threshold = 7 units

decision                                                    units   level
------------------------------------------------------------------------------
settings are read from a single source                      10      enterprise
data access goes through a single layer                     9       enterprise
inventory is synced over the event bus                      8       enterprise
the clock source is taken from a single module              8       enterprise
the external catalog is accessed through a single connector 7       enterprise
the loan period rule is read from configuration             5       solution
authentication is consolidated in a single module           4       solution
penalty calculation stays separate from the member record   2       solution
the daily report is produced in a separate module           2       solution
email notifications go out with a plain-text body           2       solution
the kiosk front is a separate deployment unit               1       application

enterprise    decisions=5    units affected=10   avg. scope=8.4
solution      decisions=5    units affected=8    avg. scope=3.0
application   decisions=1    units affected=1    avg. scope=1.0
threshold sensitivity: threshold 6 -> 5 enterprise, threshold 7 -> 5 enterprise, threshold 8 -> 4 enterprise

scheme      uninformed  repeated  conflicting  consults
-------------------------------------------------------
local       47          47        31           0
three-level 0           0         0            47
centralized 0           0         0            99

the local scheme avoids 47 consults, in exchange it gets 31 conflicting implementations
break-even: the three-level scheme wins if resolving a conflicting implementation costs more than 1.52 consults
the centralized scheme spends 52 more consults than the three-level scheme, while conflicts are 0 in both
of that, 9 is spent on the 1 decision(s) whose scope is a single unit
```

#### Reading the Distribution

The eleven decisions split into five enterprise, five solution, and one application-level decision.
The average scope of the five enterprise-level decisions is 8.4 units, of the five solution-level
decisions, 3.0 units. The union column is informative too: the five enterprise-level decisions touch
**all** ten units, the five solution-level decisions touch eight.

The threshold's fragility is measured here too. Lowering the enterprise threshold from seven to six
changes no decision, because no decision has a scope of six units; raising it to eight drops the
external catalog connector decision from the enterprise level to the solution level. The threshold
is a choice, not a ranking; which decision outranks which comes from the measurement, the boundary
comes from a rule.

What the distribution actually says is this: no decision belongs to a level by virtue of its subject
matter. Where the clock is read from looks, as a subject, like an application detail; its scope is
eight units, and in this model it falls at the same level as inventory sync. A decision's level
comes not from its subject but from how many units depend on it.

#### The Cost of the Wrong Level

The table below runs the same eleven decisions across three schemes. In the **local scheme**, each
decision is made in the unit where its body sits and stays there. In the **centralized scheme**,
each decision is made by consulting every unit. In the **three-level scheme**, each decision is made
at the level of its measured scope and only the units in that scope are consulted.

In the local scheme, 47 in-scope units never hear the decision. Since a unit that does not hear it
still has to get its work done, it makes the same decision on its own: 47 repeated decisions. Not
all repeated decisions clash — for each decision the number of reasonable options is between two and
four (**WA6**), and the unit deciding independently picks one of them. With the generator seeded at
48151, **31** of the 47 repeats came out different from the owner's choice, that is, 31 conflicting
implementations.

The local scheme's gain is that it avoids 47 consults. From here the break-even point is read: if
reconciling a conflicting implementation costs more than **1.52 consults**, the three-level scheme
wins. This is a calculation, not a preference, and the number depends on the model's inputs; as the
option counts grow, conflicting implementations increase and the break-even point drops.

The centralized scheme brings the conflict down to zero too, but with 99 consults — 52 more than the
three-level scheme. Nine of those are spent on a single decision: the kiosk front being a separate
deployment unit has a scope of one unit, and yet nine more units are consulted anyway. Deciding at
the wrong level has two directions, and both have a number: deciding lower than the true level
produces uninformed units and conflicting implementations, deciding higher than the true level
produces wasted consults.

#### Summary

- A decision's level is read from its scope, not its subject; scope is the number of deployment
  units that run the modules in the decision's reach.
- The model network has ten units and eleven decisions; when the enterprise threshold is chosen as
  two-thirds of the units (7 units), five enterprise, five solution, and one application-level
  decision result.
- The five enterprise-level decisions touch all ten units (average scope 8.4); the five
  solution-level decisions touch eight units (average scope 3.0).
- In the local scheme, 47 units do not hear the decision, 47 decisions are made again, and with the
  generator seeded at 48151, 31 of these come out different from the owner's choice; the break-even
  point is where a conflicting implementation equals 1.52 consults.
- The centralized scheme brings the conflict to zero but spends 99 consults; nine of those are for
  the single decision whose scope is one unit.

#### Next Step

Both lessons treated the decision as something already made: the decision exists, its scope is
measured, its level is determined. Who makes the decision, who it is announced to and how once it is
made, and whether it is actually applied once announced — none of that was asked. Someone carries
out the three-level scheme's 47 consults; that person's job is not only to make decisions. The next
lesson splits the architect's job into three responsibilities — decision, communication, and
oversight — and builds all three as a matrix. What it measures is this: the share of decisions that,
despite having been made and announced, have no counterpart left in the code, and how much that
share changes once oversight is added.

### The Architect's Responsibilities

Source: https://academia.sh/en/courses/architect-role/architect-responsibilities

Building the decision, communication, and oversight responsibilities as a matrix: translating a decision into a machine-auditable constraint form, running the supervised and unsupervised schemes with the same edge sequence on a codebase that grows shortcuts, the share of decisions with no counterpart left in code, and the hour distribution across the three responsibilities.

The previous two lessons treated the decision as something already made: the decision exists, its
scope is measured, its level is determined. Someone carries out the three-level scheme's 47
consults, and that person's job is not only to make decisions. This lesson splits the architect's
job into three responsibilities.

**Decision** is the selection of an option. **Communication** is the decision reaching the units in
its scope in an applicable form. **Oversight** is the decision's counterpart in code continuing to
survive. There is no order among the three; missing one devalues the others. What gets measured is
exactly this: the share of decisions that, despite having been made and announced, have no
counterpart left in the code.

#### The Decision's Written Form

For a decision to be overseen, it has to be translated into a form the machine can audit. In this
model, the form is this: **which modules may not directly import which module** (**WA7**). The
decision "data access goes through a single layer" means the front modules cannot draw a direct edge
to the data access module; if the edge exists, the decision has been violated. This is the same form
as the boundary-violation measure established in the Service Architectures course.

Not every decision can be translated into this form. Email being a plain-text body, the daily report
being produced in a separate module, and the kiosk front being a separate deployment unit do not
show up in the import graph. These are audited by other means; the measurement keeps them separate
from the auditable decisions.

The model reuses the previous lesson's import graph; the graph is repeated so the block runs on its
own.

```js
// architecture/model.mjs — MODEL library network codebase: import graph and decision bodies.
// Not a real institution; edges and decision bodies are chosen by hand.
export const IMPORTS = {
  configuration: [], clock: [], logging: ["configuration"],
  "data-access": ["configuration", "logging"], "event-bus": ["configuration", "logging"],
  authentication: ["data-access", "configuration", "logging"], authorization: ["authentication"],
  "catalog-connector": ["configuration", "logging"],
  "catalog-cache": ["catalog-connector", "clock"],
  "loan-rule": ["configuration", "clock"],
  "loan-core": ["loan-rule", "data-access", "event-bus", "clock", "catalog-connector"],
  reservation: ["loan-core", "notification-queue", "data-access"],
  "member-registry": ["data-access", "logging"],
  "member-penalty": ["member-registry", "loan-rule", "clock", "data-access"],
  "branch-inventory": ["data-access", "event-bus"],
  "branch-sync": ["branch-inventory", "event-bus", "catalog-connector", "logging"],
  "notification-queue": ["data-access", "event-bus"],
  "notification-email": ["notification-queue", "configuration"],
  "report-daily": ["data-access", "clock"],
  "report-batch": ["data-access", "branch-inventory", "clock", "logging"],
  "web-front": ["loan-core", "catalog-cache", "member-registry", "reservation", "authentication"],
  "staff-front": ["loan-core", "member-registry", "member-penalty", "branch-inventory", "report-daily",
    "authentication", "authorization"],
  "kiosk-front": ["loan-core", "catalog-cache", "authentication"],
  "batch-job": ["report-batch", "branch-sync", "notification-queue", "notification-email"],
};
export const DECISIONS = [
  { name: "data access goes through a single layer", body: ["data-access"] },
  { name: "settings are read from a single source", body: ["configuration"] },
  { name: "inventory is synced over the event bus", body: ["event-bus", "branch-sync"] },
  { name: "the external catalog is accessed through a single connector", body: ["catalog-connector"] },
  { name: "authentication is consolidated in a single module", body: ["authentication"] },
  { name: "the loan period rule is read from configuration", body: ["loan-rule"] },
  { name: "the clock source is taken from a single module", body: ["clock"] },
  { name: "penalty calculation stays separate from the member record", body: ["member-penalty"] },
  { name: "the daily report is produced in a separate module", body: ["report-daily"] },
  { name: "email notifications go out with a plain-text body", body: ["notification-email"] },
  { name: "the kiosk front is a separate deployment unit", body: ["kiosk-front"] },
];
export const MODULES = Object.keys(IMPORTS);
```

#### Measurement

The second file builds the responsibility matrix, changes the codebase over ten rounds, and runs the
same sequence of edges across two schemes. The form the change takes is this: a developer draws a
direct edge to a module they already reach indirectly — a **shortcut** (**WA8**). A shortcut does
not create a new access path, it shortens an existing indirect one; so the pool is finite, and every
addition removes one edge from the pool. The sequence is generated once and handed unchanged to both
schemes.

```js
// architecture/responsibility.mjs — the responsibility matrix, comparing the scheme with and without oversight
import { IMPORTS, DECISIONS, MODULES } from "./model.mjs";

const FRONTS = ["web-front", "staff-front", "kiosk-front", "batch-job"];
// CONSTRAINTS — the decision's auditable counterpart in the import graph: no direct edge from source to target
const CONSTRAINTS = [
  { decision: 0, source: FRONTS, target: "data-access" },
  { decision: 1, source: FRONTS, target: "configuration" },
  { decision: 2, source: ["web-front", "kiosk-front", "staff-front"], target: "branch-inventory" },
  { decision: 3, source: FRONTS, target: "catalog-connector" },
  { decision: 4, source: ["reservation", "member-registry", "member-penalty", "branch-inventory", "report-daily", "report-batch"], target: "authentication" },
  { decision: 5, source: ["web-front", "staff-front", "kiosk-front"], target: "loan-rule" },
  { decision: 6, source: FRONTS, target: "clock" },
  { decision: 7, source: ["member-registry"], target: "member-penalty" },
];
const UNAUDITABLE = [8, 9, 10]; // where the daily report is produced, the email body format, kiosk deployment

// responsibility matrix: the state of the three responsibilities for each decision
// made = the decision has been taken; written = a machine-auditable constraint form exists; overseen = depends on the scheme
const matrix = DECISIONS.map((k, i) => ({
  name: k.name, made: true, written: !UNAUDITABLE.includes(i),
}));
const col = (s, n) => String(s).padEnd(n);
const yn = (b) => (b ? "yes" : "no");
console.log(col("decision", 60) + col("made", 8) + "written constraint");
console.log("-".repeat(86));
for (const m of matrix) console.log(col(m.name, 60) + col(yn(m.made), 8) + yn(m.written));
console.log(`\ndecisions made = ${matrix.length}/${matrix.length}; with a written constraint form = ${matrix.filter((m) => m.written).length}/${matrix.length}`);
console.log(`a decision with no written form cannot be overseen: ${UNAUDITABLE.map((i) => DECISIONS[i].name).join("; ")}`);

// ---- the codebase changes over time: a developer turns an indirect access into a direct edge ----
function prng(seed) { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32; }
const reachable = (g, a, b) => {
  const seen = new Set([a]), stack = [a];
  while (stack.length) for (const h of g[stack.pop()]) if (!seen.has(h)) { seen.add(h); stack.push(h); }
  return seen.has(b);
};
const shortcuts = (g) => {
  const c = [];
  for (const a of MODULES) for (const b of MODULES)
    if (a !== b && !g[a].includes(b) && reachable(g, a, b)) c.push([a, b]);
  return c;
};
const violates = (a, b) => CONSTRAINTS.filter((c) => c.target === b && c.source.includes(a));

const ROUNDS = 10, PER_ROUND = 5, SEED = 90210;
const base = Object.fromEntries(MODULES.map((m) => [m, [...IMPORTS[m]]]));
const pool = shortcuts(base);
const rand = prng(SEED);              // seed is visible; the sequence is generated once
const remaining = [...pool], SEQUENCE = [];
for (let i = 0; i < ROUNDS * PER_ROUND; i++) SEQUENCE.push(remaining.splice(Math.floor(rand() * remaining.length), 1)[0]);
console.log(`\nshortcut pool = ${pool.length} edges; ${SEQUENCE.length} edges drawn (seed ${SEED})`);
console.log(`${SEQUENCE.filter(([a, b]) => violates(a, b).length).length} of the drawn edges violate a constraint`);
const initial = [];
for (const a of MODULES) for (const b of IMPORTS[a]) for (const c of violates(a, b)) initial.push(`${a} -> ${b}`);
console.log(`constraints already violated at the moment the decision is made = ${initial.length} (${initial.join(", ") || "none"})`);

function run(oversight) {
  const g = Object.fromEntries(MODULES.map((m) => [m, [...IMPORTS[m]]]));
  let added = 0, reverted = 0, hours = 0;
  for (let t = 0; t < ROUNDS; t++) {
    for (const [a, b] of SEQUENCE.slice(t * PER_ROUND, (t + 1) * PER_ROUND)) { g[a].push(b); added++; }
    if (!oversight) continue;
    hours += 0.5;                        // one audit run per round
    for (const a of MODULES)
      for (const b of [...g[a]]) if (!IMPORTS[a].includes(b) && violates(a, b).length) {
        g[a].splice(g[a].indexOf(b), 1); reverted++; hours += 1; // cost of reverting
      }
  }
  const broken = new Set();
  for (const a of MODULES) for (const b of g[a]) for (const c of violates(a, b)) broken.add(c.decision);
  return { added, reverted, hours, broken: broken.size };
}

const C = CONSTRAINTS.length;
const noOversight = run(false), withOversight = run(true);
console.log(`\n${ROUNDS} rounds, ${PER_ROUND} shortcuts per round; auditable decisions = ${C}`);
console.log(col("scheme", 15) + col("added", 8) + col("reverted", 11) + col("broken decisions", 18) + col("compliant", 11) + "oversight hours");
console.log("-".repeat(78));
for (const [name, r] of [["no oversight", noOversight], ["oversight", withOversight]])
  console.log(col(name, 15) + col(r.added, 8) + col(r.reverted, 11) +
    col(`${r.broken}/${C}`, 18) + col(`${(((C - r.broken) / C) * 100).toFixed(0)}%`, 11) + r.hours);

console.log(`\ndecisions with no counterpart left in code: without oversight ${noOversight.broken}/${C}; with oversight ${withOversight.broken}/${C}`);
console.log(`decisions oversight saves = ${noOversight.broken - withOversight.broken}; cost ${withOversight.hours} hours, ${(withOversight.hours / (noOversight.broken - withOversight.broken)).toFixed(1)} hours per decision`);

// ---- how the architect's time is split ----
const HOURS = { "decision-making": DECISIONS.length * 3, communication: 47 * 0.5, oversight: withOversight.hours };
const total = Object.values(HOURS).reduce((a, b) => a + b, 0);
console.log(`\n${col("responsibility", 16)}hours   share`);
console.log("-".repeat(30));
for (const [k, v] of Object.entries(HOURS)) console.log(col(k, 16) + col(v.toFixed(1), 8) + `${((v / total) * 100).toFixed(0)}%`);
console.log(col("total", 16) + total.toFixed(1));
```

```
decision                                                    made    written constraint
--------------------------------------------------------------------------------------
data access goes through a single layer                     yes     yes
settings are read from a single source                      yes     yes
inventory is synced over the event bus                      yes     yes
the external catalog is accessed through a single connector yes     yes
authentication is consolidated in a single module           yes     yes
the loan period rule is read from configuration             yes     yes
the clock source is taken from a single module              yes     yes
penalty calculation stays separate from the member record   yes     yes
the daily report is produced in a separate module           yes     no
email notifications go out with a plain-text body           yes     no
the kiosk front is a separate deployment unit               yes     no

decisions made = 11/11; with a written constraint form = 8/11
a decision with no written form cannot be overseen: the daily report is produced in a separate module; email notifications go out with a plain-text body; the kiosk front is a separate deployment unit

shortcut pool = 58 edges; 50 edges drawn (seed 90210)
19 of the drawn edges violate a constraint
constraints already violated at the moment the decision is made = 1 (staff-front -> branch-inventory)

10 rounds, 5 shortcuts per round; auditable decisions = 8
scheme         added   reverted   broken decisions  compliant  oversight hours
------------------------------------------------------------------------------
no oversight   50      0          6/8               25%        0
oversight      50      19         1/8               88%        24

decisions with no counterpart left in code: without oversight 6/8; with oversight 1/8
decisions oversight saves = 5; cost 24 hours, 4.8 hours per decision

responsibility  hours   share
------------------------------
decision-making 33.0    41%
communication   23.5    29%
oversight       24.0    30%
total           80.5
```

#### The Compliance Rate of an Unsupervised Decision

All eleven decisions have been made, and eight of them have a written, auditable constraint form.
Over ten rounds, 50 shortcuts were added; 19 of them violate a constraint. In the scheme without
oversight, these 19 edges became permanent and **six** of the eight decisions lost their counterpart
in the code — a compliance rate of **25%**. In the scheme with oversight, the violating edges were
reverted at the end of every round, and in the end only one decision was left broken; a compliance
rate of **88%**.

That one remaining decision is the measurement's most instructive part. The staff app was already
looking directly at branch inventory on the very day the decision was made; oversight reverts only
the edges added after the decision. A decision already violated the moment it was made is not fixed
by oversight — it needs a separate migration effort. When this distinction is not written down, the
oversight report only says "one decision is still broken," and the reason stays invisible.

The cost of the difference is counted too: oversight saved five decisions and cost 24 hours —
**4.8 hours** per decision. This number is a justification. The sentence "the architect should track
decisions" is not a decision; the sentence "in the scheme without oversight, six of eight decisions
lose their counterpart in the code over ten rounds, and oversight saves five of them at 4.8 hours per
decision" is verifiable.

The state of the three unauditable decisions should be noted separately. The measurement never
counted them, because they leave no trace in the import graph. A decision with no written form
cannot be overseen; the compliance rate of a decision that cannot be overseen cannot be measured.
The responsibility matrix's "written constraint" column is therefore more informative than its
decision column: 11 of 11 decisions have been made, 8 are auditable.

#### Time Distribution

The final table totals the hours of the three responsibilities. Decision-making, at three hours per
decision, is 33 hours; communication, at half an hour for each of the 47 consults counted in the
previous lesson's three-level scheme, is 23.5 hours; oversight is the measured 24 hours (**WA9**). Of
the total 80.5 hours, **41%** is decision-making, **29%** is communication, **30%** is oversight.

What the distribution says is this: decision-making is less than half the architect's job.
Communication and oversight together take up 59%, and cutting either one produces nothing visible
at the time — cut communication produces the previous lesson's 31 conflicting implementations, cut
oversight produces this lesson's six broken decisions. The cost of both is paid not at the moment
the decision is made, but months later.

#### Summary

- The architect's job is three responsibilities: decision, communication, and oversight; for it to
  be measurable, the decision has to be translated into a machine-auditable constraint form.
- In the model codebase, 11 of 11 decisions have been made, but only 8 have an auditable counterpart
  in the import graph; the remaining three cannot be overseen and their compliance rate cannot be
  measured.
- Over ten rounds, 50 shortcuts were added, 19 of which violated a constraint; in the scheme without
  oversight, six of eight decisions lost their counterpart in code (25% compliance), in the scheme
  with oversight, one decision stayed broken (88%).
- The one remaining broken decision comes from an edge that existed before the decision; oversight
  protects only what comes after the decision, and a pre-existing violation is a separate migration
  effort.
- Oversight saved five decisions at 4.8 hours per decision; of the three responsibilities' 80.5-hour
  total, decision-making took 41%, communication 29%, oversight 30%.

#### Next Step

This lesson's oversight scheme silently assumed one thing: that the person making the decision can
actually see the import graph. The same person who writes the constraints, counts the violations,
and reverts them knows what state the code is in at that moment. This assumption does not hold in
every scheme. If the person making the decision never touches the code, a gap accumulates between
the graph at the moment they make the decision and the graph months later, and the decision is built
on top of that gap. The next lesson models that gap: the sets of modules known to the decision-maker
and to the implementer are kept separate, the hands-on and hands-off schemes are run on the same
sequence of changes, and two numbers are compared — the decision's feasibility rate and the number
of rounds it takes for a violation to be noticed.

### The Architect and Developer Relationship

Source: https://academia.sh/en/courses/architect-role/architect-and-developer-relationship

Modeling the ivory tower problem as a knowledge gap: keeping the import graph the decision-maker knows separate from the actual graph, running the hands-on and hands-off schemes on the same sequence of changes, and counting the stillborn-decision rate, the average knowledge gap, and the feedback delay.

The previous lesson's oversight scheme silently assumed one thing: that the person who writes the
constraints, counts the violations, and reverts them can actually see the import graph. This
assumption does not hold in every scheme. If the person making the decision never touches the code,
a gap accumulates between the graph at the moment they make the decision and the graph months
later.

**Ivory tower** is the name for this gap. It is usually described as a personality flaw — an
attitude that talks from a distance, disconnected from implementation. In this lesson it is taken
not as a flaw but as a measurable quantity: the edge difference between the graph the
decision-maker knows and the actual graph. As the gap grows, it becomes possible to count how many
decisions are already infeasible the moment they are published.

#### The Stillborn Decision

A decision is stillborn when it forbids a state that has already come to exist. In the library
network model this takes a concrete form: the architect says "the staff front must not look
directly at branch inventory"; but that edge was added three months ago, and the architect does not
know it. The decision is announced, the team says the decision is already violated, the decision is
withdrawn. The decision does not have to be wrong — being late is enough.

The model builds this as follows. Two graphs are kept: the **actual** graph and the graph the
architect **knows**. Developers add a shortcut every round; a shortcut is a direct edge drawn to a
module that is already reached indirectly. The architect makes one decision every round and chooses
it by looking only at the graph they know. The decision counts as stillborn if the edge it selects
already exists in the actual graph (**WA10**).

Two schemes are compared. The **hands-on architect** works on the code every round and sees 95% of
the added edges. The **hands-off architect** gets a report every six rounds, and 70% of the edges
appear in the report; the rest never appear at all (**WA11**). Both schemes get the same sequence of
shortcuts; the sequence is generated once and its seed is written down.

#### Measurement

```js
// architecture/knowledge-gap.mjs — the gap between the graph the decision-maker knows and the actual graph
// IMPORTS is the MODEL library network graph from the previous lessons; it is given again so the block runs on its own.
const IMPORTS = {
  configuration: [], clock: [], logging: ["configuration"],
  "data-access": ["configuration", "logging"], "event-bus": ["configuration", "logging"],
  authentication: ["data-access", "configuration", "logging"], authorization: ["authentication"],
  "catalog-connector": ["configuration", "logging"],
  "catalog-cache": ["catalog-connector", "clock"],
  "loan-rule": ["configuration", "clock"],
  "loan-core": ["loan-rule", "data-access", "event-bus", "clock", "catalog-connector"],
  reservation: ["loan-core", "notification-queue", "data-access"],
  "member-registry": ["data-access", "logging"],
  "member-penalty": ["member-registry", "loan-rule", "clock", "data-access"],
  "branch-inventory": ["data-access", "event-bus"],
  "branch-sync": ["branch-inventory", "event-bus", "catalog-connector", "logging"],
  "notification-queue": ["data-access", "event-bus"],
  "notification-email": ["notification-queue", "configuration"],
  "report-daily": ["data-access", "clock"],
  "report-batch": ["data-access", "branch-inventory", "clock", "logging"],
  "web-front": ["loan-core", "catalog-cache", "member-registry", "reservation", "authentication"],
  "staff-front": ["loan-core", "member-registry", "member-penalty", "branch-inventory", "report-daily",
    "authentication", "authorization"],
  "kiosk-front": ["loan-core", "catalog-cache", "authentication"],
  "batch-job": ["report-batch", "branch-sync", "notification-queue", "notification-email"],
};
const MODULES = Object.keys(IMPORTS);

function prng(seed) { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32; }
const copy = (g) => Object.fromEntries(MODULES.map((m) => [m, [...g[m]]]));
const reachable = (g, a, b) => {
  const seen = new Set([a]), stack = [a];
  while (stack.length) for (const h of g[stack.pop()]) if (!seen.has(h)) { seen.add(h); stack.push(h); }
  return seen.has(b);
};
const shortcuts = (g) => {
  const c = [];
  for (const a of MODULES) for (const b of MODULES)
    if (a !== b && !g[a].includes(b) && reachable(g, a, b)) c.push([a, b]);
  return c;
};
const edgeCount = (g) => MODULES.reduce((t, m) => t + g[m].length, 0);

const ROUNDS = 18, PER_ROUND = 3, SEED = 31337;
const base = copy(IMPORTS);
const pool = shortcuts(base);
const draw = prng(SEED);
const remaining = [...pool], SEQUENCE = [];
for (let i = 0; i < ROUNDS * PER_ROUND; i++) SEQUENCE.push(remaining.splice(Math.floor(draw() * remaining.length), 1)[0]);
console.log(`base graph ${edgeCount(base)} edges; ${ROUNDS} rounds add ${SEQUENCE.length} shortcuts (seed ${SEED})`);

// scheme: visibility = the share of actual edges seen at each refresh, refreshEvery = how many rounds between refreshes
function run(visibility, refreshEvery) {
  const actual = copy(base), known = copy(base);
  const rand = prng(SEED + 1);          // the decision draw and the visibility roll, from the same seed
  const addedAtRound = new Map();
  let stillborn = 0, gapTotal = 0;
  const delays = [];
  for (let t = 0; t < ROUNDS; t++) {
    const c = shortcuts(known);         // the architect looks only at the graph they know
    const [a, b] = c[Math.floor(rand() * c.length)];
    if (actual[a].includes(b)) stillborn++;  // the forbidden edge already exists in reality: the decision is stillborn
    for (const [x, y] of SEQUENCE.slice(t * PER_ROUND, (t + 1) * PER_ROUND)) {
      actual[x].push(y); addedAtRound.set(`${x}>${y}`, t);
    }
    gapTotal += edgeCount(actual) - edgeCount(known);
    if ((t + 1) % refreshEvery !== 0) continue;
    for (const x of MODULES) for (const y of actual[x])
      if (!known[x].includes(y) && rand() < visibility) {
        known[x].push(y); delays.push(t - addedAtRound.get(`${x}>${y}`));
      }
  }
  const unseen = edgeCount(actual) - edgeCount(known);
  return { stillborn, avgGap: gapTotal / ROUNDS, unseen,
    avgDelay: delays.length ? delays.reduce((a, b) => a + b, 0) / delays.length : 0 };
}

const col = (s, n) => String(s).padEnd(n);
const handsOn = run(0.95, 1);   // hands-on architect: every round, 95% of the edges
const handsOff = run(0.7, 6);   // hands-off architect: once every six rounds, 70% of the edges
console.log(`\n${col("scheme", 26) + col("stillborn", 12) + col("avg. knowledge gap", 21) + col("unseen edges", 15)}avg. delay (rounds)`);
console.log("-".repeat(93));
for (const [name, r] of [["hands-on architect", handsOn], ["hands-off architect", handsOff]])
  console.log(col(name, 26) + col(`${r.stillborn}/${ROUNDS}`, 12) + col(r.avgGap.toFixed(1), 21) +
    col(r.unseen, 15) + r.avgDelay.toFixed(2));

console.log(`\nfeasibility rate: hands-on ${(((ROUNDS - handsOn.stillborn) / ROUNDS) * 100).toFixed(0)}%, hands-off ${(((ROUNDS - handsOff.stillborn) / ROUNDS) * 100).toFixed(0)}%`);
console.log(`stillborn-decision gap = ${handsOff.stillborn - handsOn.stillborn} decisions; knowledge gap ${(handsOff.avgGap / Math.max(handsOn.avgGap, 0.1)).toFixed(1)} times`);
console.log(`delay gap = ${(handsOff.avgDelay - handsOn.avgDelay).toFixed(2)} rounds; edges the hands-off architect never sees at all = ${handsOff.unseen}`);
```

```
base graph 64 edges; 18 rounds add 54 shortcuts (seed 31337)

scheme                    stillborn   avg. knowledge gap   unseen edges   avg. delay (rounds)
---------------------------------------------------------------------------------------------
hands-on architect        0/18        3.1                  1              0.02
hands-off architect       8/18        13.8                 9              3.40

feasibility rate: hands-on 100%, hands-off 56%
stillborn-decision gap = 8 decisions; knowledge gap 4.5 times
delay gap = 3.38 rounds; edges the hands-off architect never sees at all = 9
```

#### Reading the Three Numbers

**Feasibility rate.** Eight of the hands-off architect's eighteen decisions forbid a state that has
already come to exist by the time they are published: a rate of **56%**. In this run, the hands-on
architect produced no stillborn decisions at all, a rate of 100%. The second number being exactly a
hundred is not the model's guarantee — that architect also had a one-edge blind spot, and none of
the eighteen draws happened to land on that edge; with a different seed, one or two stillborn
decisions could turn up. The direction of the difference, though, is independent of the seed:
information fed by a report arrives later and more incomplete than information fed by the code.

**Knowledge gap.** The average edge difference between the actual graph and the known graph is 3.1
for the hands-on architect, 13.8 for the hands-off architect — **4.5 times**. The 3.1 for the
hands-on architect is not zero, because the edges added within a round come after that round's
decision; this is a lag that no scheme can bring to zero. The 13.8 for the hands-off architect,
though, accumulates: the edges added between reports pile up on top of each other.

**Unseen edges.** This is the harshest number of the three. The number of edges the hands-off
architect never sees at all by the end of eighteen rounds is **9**. These are not delayed
information but information that never arrives; a report is a summary, and every edge left outside
the summary stays permanently invisible. The basis for the next decision is not a 64 + 54 = 118-edge
graph, but a graph missing nine edges.

**Feedback delay.** The average number of rounds between an edge being added and reaching the
decision-maker is 0.02 for the hands-on architect, **3.40** for the hands-off architect. This is the
average delay of a six-round report interval. The delay is not just a wait: every decision made
across those three and a half rounds rests on a stale graph, and that is exactly what feeds the
stillborn-decision rate.

#### Closing the Gap

The measure's practical consequence is that the architect touching the code is not a preference but
a **channel for refreshing knowledge**. The gap depends on two variables: refresh frequency and
refresh coverage. Getting a report once every six rounds lowers the frequency; the report carrying
only 70% of the edges lowers the coverage. Fixing only one of the two is not enough — if frequency
goes up while coverage stays low, unseen edges keep accumulating.

Two workable paths follow from this, and both are measurable. The first is raising coverage:
producing the graph a decision rests on directly from the code rather than from a report — the
previous lesson's constraint audit did exactly this, and the 24-hour oversight load measured there
is, at the same time, the load that closes the knowledge gap. The second is raising frequency: the
architect reading the modules a decision touches before making it, that is, making the decision by
looking at that part of the graph.

The ivory tower's problem is not that the decisions are bad. Ten of the hands-off architect's
decisions were feasible; eight lost their value because they came late. The same person is deciding
by the same criteria; the only thing that changes is the age of the graph they have on hand at the
moment of the decision.

#### Summary

- Ivory tower is not an attitude but the measurable knowledge gap between the graph the
  decision-maker knows and the actual graph.
- On the same 54-shortcut sequence of changes, eight of the hands-off architect's eighteen
  decisions were stillborn (56% feasibility); the hands-on architect produced no stillborn
  decisions in this run.
- The average knowledge gap is 3.1 edges against 13.8 edges, a factor of 4.5; the hands-on
  architect's 3.1 is a within-round lag that no scheme can bring to zero.
- The number of edges the hands-off architect never sees by the end of eighteen rounds is 9; a
  summary report produces not a delay but a permanent blind spot.
- Feedback delay is 0.02 rounds against 3.40 rounds; what changes is not the quality of the
  decision but the age of the graph at the moment of the decision.

#### Next Step

This lesson measured the conditions under which a decision is **born**: the fresher the graph the
decision-maker sees, the more feasible the decision. A decision being feasible, though, does not
mean it gets applied. The oversight measurement in the lesson before this one showed that decisions
lose their counterpart in the code; that measurement never distinguished how the decision reached
the team. Yet the same decision can be announced as a rule, written down with its rationale, or
conveyed by working together during implementation. The next lesson runs the same decision through
three forms of conveyance and counts two things: the correct-application rate and the cost of
conveyance — how many hours, how many people.

### Consulting and Coaching

Source: https://academia.sh/en/courses/architect-role/consulting-and-coaching

Conveying the same decision as a directive, a written rationale, and by working together: separating the common case from the edge case, measuring the correct-application rate and the cost of conveyance in a two-wave model, and counting which form of conveyance survives a team change.

The previous lesson measured the conditions under which a decision is born: the fresher the graph
the decision-maker sees, the more feasible the decision. A decision being feasible, though, does
not mean it gets applied. The third lesson's oversight measurement showed that decisions lose their
counterpart in the code, but it never distinguished how the decision reached the team.

The same decision can be conveyed in three separate forms. **Directive**: the rule is announced, no
rationale is given. **Written rationale**: the rule is written down together with its threshold and
trade-off — this is the form of the decision record established in the Introduction to System
Design course. **Working together**: the decision's first implementation is done together with a
few developers; the words **architect as consultant** and **coaching** describe this form. This
lesson runs all three on the same decision and the same set of cases.

#### Common Case and Edge Case

The difference between forms of conveyance does not show up in every case the decision applies to.
The cases a decision passes through split into two. The **common case** is the one that fits the
rule's wording exactly: the rule "front modules do not directly import the data access module"
applies directly when an edge to data access is drawn in a front module. This does not require
knowing the rationale, knowing the rule is enough.

The **edge case** is the one where the rule's wording is not enough: is the batch job module a
front, which layer must a daily report query pass through, does a new read path written for the
external catalog fall within this rule's scope. These questions can only be answered by knowing
**why** the decision was made. In the model, 35% of the cases are edge cases (**WA12**), and the
three forms of conveyance diverge only in these cases.

The second distinction is time. A decision does not get applied and finish in the week it is made;
it keeps being applied months later, by which time the team has changed. The model builds this with
two waves: twelve developers in the first wave, four of whom leave before the second wave and are
replaced by four people who never received the decision's conveyance at all (**WA13**).

#### Measurement

Each of the three forms gives the developer a level of understanding and a cost. A directive
reaches everyone and is cheap; a written rationale also reaches everyone but has to be read;
working together directly touches only three people and is expensive. All the probability and hour
values are the model's chosen inputs (**WA14**); what gets measured is the difference under these
inputs. The same set of cases and the same sequence of rolls are handed to all three forms, so the
comparison is paired.

```js
// architecture/conveyance.mjs — the correct-application rate and cost of the same decision in three forms of conveyance
// MODEL library network; no real people or teams. Two waves: 4 people leave before the second.
function prng(seed) { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32; }

const PEOPLE = 12, W1 = 8, W2 = 6, EDGE_RATIO = 0.35, DEPARTED = 4;
const HOURS = { directive: 2.2, "written rationale": 10.0, "working together": 24.0 };
const DIRECT = { directive: PEOPLE, "written rationale": PEOPLE, "working together": 3 };
const FORMS = Object.keys(HOURS);

function runOnce(seed) {
  const rand = prng(seed);
  const cases = (people, count) => people.flatMap((p) =>
    Array.from({ length: count }, () => ({ person: p, edge: rand() < EDGE_RATIO, roll: rand() })));

  const original = [...Array(PEOPLE).keys()];
  const waves = { one: cases(original, W1) };
  const readers = Array.from({ length: PEOPLE + DEPARTED }, () => rand() < 0.7); // those who read the rationale
  const paired = new Set();
  while (paired.size < 3) paired.add(Math.floor(rand() * PEOPLE));               // those worked with together
  const departed = new Set();
  while (departed.size < DEPARTED) departed.add(Math.floor(rand() * PEOPLE));
  const newHires = Array.from({ length: DEPARTED }, (_, i) => PEOPLE + i);
  const secondTeam = [...original.filter((p) => !departed.has(p)), ...newHires];
  waves.two = cases(secondTeam, W2);
  const pairedRemaining = [...paired].filter((p) => !departed.has(p)).length;

  // probability: in the common case, knowing the rule is enough; in the edge case, the rationale has to be understood
  const P = {
    directive: (p, edge) => (p >= PEOPLE ? (edge ? 0.10 : 0.30) : edge ? 0.15 : 0.95),
    "written rationale": (p, edge) => (edge ? (readers[p] ? 0.60 : 0.15) : 0.95),
    "working together": (p, edge) => (p >= PEOPLE
      ? (pairedRemaining ? (edge ? 0.50 : 0.95) : edge ? 0.15 : 0.60)
      : edge ? (paired.has(p) ? 0.95 : 0.50) : 0.95),
  };
  const count = (form, list) => list.filter((d) => d.roll < P[form](d.person, d.edge)).length;
  return { pairedRemaining, edge1: waves.one.filter((d) => d.edge).length, edge2: waves.two.filter((d) => d.edge).length,
    n1: waves.one.length, n2: waves.two.length,
    form: Object.fromEntries(FORMS.map((f) => [f, { d1: count(f, waves.one), d2: count(f, waves.two) }])) };
}

const SEED = 5150;
const result = runOnce(SEED);
const col = (s, n) => String(s).padEnd(n);
console.log(`${PEOPLE} developers; first wave ${result.n1} cases (${result.edge1} edge), second wave ${result.n2} cases (${result.edge2} edge)`);
console.log(`seed ${SEED}; ${DEPARTED} people left before the second wave, ${result.pairedRemaining}/3 paired people remained\n`);
console.log(col("form of conveyance", 22) + col("wave 1", 11) + col("wave 2", 11) + col("total", 17) + col("hours", 8) + "direct people");
console.log("-".repeat(82));
for (const f of FORMS) {
  const v = result.form[f], t = v.d1 + v.d2;
  console.log(col(f, 22) + col(`${v.d1}/${result.n1}`, 11) + col(`${v.d2}/${result.n2}`, 11) +
    col(`${t}/${result.n1 + result.n2} (${((t / (result.n1 + result.n2)) * 100).toFixed(0)}%)`, 17) + col(HOURS[f].toFixed(1), 8) + DIRECT[f]);
}

console.log("\nincrease over the previous form (this seed):");
for (let i = 1; i < FORMS.length; i++) {
  const a = result.form[FORMS[i - 1]], b = result.form[FORMS[i]];
  const dt = (b.d1 + b.d2) - (a.d1 + a.d2), dh = HOURS[FORMS[i]] - HOURS[FORMS[i - 1]];
  console.log(`  ${col(FORMS[i], 22)}+${col(dt, 4)}correct  +${col(dh.toFixed(1), 6)}hours  ${(dt / dh).toFixed(2)} correct per hour`);
}

// a single seed can be noisy: average over 200 seeds
const N = 200;
const totals = Object.fromEntries(FORMS.map((f) => [f, { d1: 0, d2: 0 }]));
for (let t = 0; t < N; t++) {
  const k = runOnce(1000 + t * 7);
  for (const f of FORMS) { totals[f].d1 += k.form[f].d1; totals[f].d2 += k.form[f].d2; }
}
console.log(`\naverage correct application over ${N} seeds:`);
console.log(col("form of conveyance", 22) + col("wave 1", 9) + col("wave 2", 9) + col("total", 9) + "per hour");
console.log("-".repeat(57));
for (const f of FORMS) {
  const d1 = totals[f].d1 / N, d2 = totals[f].d2 / N;
  console.log(col(f, 22) + col(d1.toFixed(1), 9) + col(d2.toFixed(1), 9) + col((d1 + d2).toFixed(1), 9) + ((d1 + d2) / HOURS[f]).toFixed(2));
}
const avg = (f) => (totals[f].d1 + totals[f].d2) / N;
console.log(`\nwave 2 difference: written rationale ${(totals["written rationale"].d2 / N).toFixed(1)}, working together ${(totals["working together"].d2 / N).toFixed(1)}, directive ${(totals.directive.d2 / N).toFixed(1)}`);
console.log(`written rationale - directive = ${(avg("written rationale") - avg("directive")).toFixed(1)} correct, +7.8 hours -> ${((avg("written rationale") - avg("directive")) / 7.8).toFixed(2)} per hour`);
console.log(`working together - written rationale = ${(avg("working together") - avg("written rationale")).toFixed(1)} correct, +14.0 hours -> ${((avg("working together") - avg("written rationale")) / 14).toFixed(2)} per hour`);
```
```
12 developers; first wave 96 cases (27 edge), second wave 72 cases (26 edge)
seed 5150; 4 people left before the second wave, 2/3 paired people remained

form of conveyance    wave 1     wave 2     total            hours   direct people
----------------------------------------------------------------------------------
directive             71/96      43/72      114/168 (68%)    2.2     12
written rationale     83/96      59/72      142/168 (85%)    10.0    12
working together      84/96      62/72      146/168 (87%)    24.0    3

increase over the previous form (this seed):
  written rationale     +28  correct  +7.8   hours  3.59 correct per hour
  working together      +4   correct  +14.0  hours  0.29 correct per hour

average correct application over 200 seeds:
form of conveyance    wave 1   wave 2   total    per hour
---------------------------------------------------------
directive             64.7     37.7     102.4    46.56
written rationale     75.1     56.3     131.4    13.14
working together      80.1     58.9     139.1    5.79

wave 2 difference: written rationale 56.3, working together 58.9, directive 37.7
written rationale - directive = 29.0 correct, +7.8 hours -> 3.71 per hour
working together - written rationale = 7.7 correct, +14.0 hours -> 0.55 per hour
```

#### The Difference Between the Three Forms

In the single-seed table, the three forms give 68%, 85%, and 87% correct application, in order.
Over two hundred seeds, the average totals are 102.4, 131.4, and 139.1. The order is as expected;
the real information is in the **shape of the increases**.

Going from a directive to a written rationale gains an average of **29 correct applications** and
costs 7.8 hours: 3.71 per hour. Going from a written rationale to working together gains **7.7
correct applications** and costs 14 hours: 0.55 per hour. The ratio between the two gains is
roughly sevenfold. This does not say coaching is worthless; what it says is that starting with
coaching where the rationale has not been written down is the most expensive path.

The "per hour" column on the right, though, has to be read in the opposite direction. A directive
produces 46.56 correct applications per hour, working together produces 5.79. Average yield is
highest for the cheapest form, and this is always true; the cheap one has high yield and a low
outcome. Choosing a form of conveyance by looking at average yield means choosing the directive.
The decision is made by looking at the **increase**: how many correct applications the next hour
brings.

#### What Remains When the Team Changes

The wave-2 column is this lesson's real finding. In the first wave, the average difference between
directive and written rationale is 10.4 correct applications (64.7 against 75.1). In the second
wave — that is, after four people leave and are replaced by new hires — the difference rises to
**18.6** (37.7 against 56.3). The directive collapses not with time but with **turnover**: even the
common case is uncertain in the hands of someone who never heard the announcement.

Working together's second-wave value is 58.9, the written rationale's is 56.3 — a difference of
2.6. This difference depends on whether the people worked with together have left or not. In the
single-seed run, two of the three had remained; had none remained, the form would have moved closer
to the directive. What coaching conveys does not live in a document but in a person, and it can
leave together with that person.

The one-sentence conclusion for the three forms is this, and all three are tied to a number: a
directive is the cheapest conveyance and passes 15% of edge cases correctly; a written rationale is
mid-cost, passes close to half of edge cases correctly, and survives a team change; working
together gives the highest accuracy, is the most expensive, and its durability depends on the
people worked with together staying.

#### Summary

- Forms of conveyance do not diverge in common cases; the difference comes only from edge cases
  that require the decision's rationale, and 35% of the cases in the model are edge cases.
- On the same 168 cases, the three forms gave 68%, 85%, and 87% correct application; the averages
  over two hundred seeds came to 102.4, 131.4, and 139.1.
- The increases are not equal: going from directive to written rationale brings 3.71 correct
  applications per hour, going from written rationale to working together brings 0.55 per hour.
- Average yield is highest for the cheapest form (directive 46.56, working together 5.79); a form
  of conveyance is chosen by looking at the increase, not the average yield.
- Team change raises the gap between directive and written rationale from 10.4 to 18.6; coaching's
  gain depends on the people worked with together staying, the written rationale's does not.

#### Next Step

This topic defined the architect's role and tied every part of it to a number: what a decision is
was measured by its cost of reversal, its level by the number of units it covers, its
responsibilities by the compliance rate of an unsupervised decision, the cost of being cut off from
the code by the stillborn-decision rate, and the form of conveyance by the correct-application
rate.

In all of these measurements, decisions were taken as a ready-made input. The set of decisions was
given, its scope measured, conveyed, and tracked — but the question of **how the decision itself
gets made** was never entered. How is a choice made between two conflicting quality attributes;
which part of a system's complexity comes from the nature of the work and which was added
afterward; how is it known at decision time which decision is reversible; how is a technology
choice's exit cost calculated. The next topic moves on to these questions, and the first is the
hardest: when two attributes conflict, where do the weights that pick the winner come from, and
does the winner change when the weight changes.

## The Architect's Practice

The daily practice of decision: reconciling conflicting quality attributes, deliberately reducing complexity, making reversible and irreversible decisions with separate methods, communicating with non-technical stakeholders, evaluating technology through maturity, ecosystem, and exit cost, and the learning discipline that sustains knowledge freshness.

### Striking Balance

Source: https://academia.sh/en/courses/architect-role/striking-balance

The practice of choosing between conflicting quality attributes: turning the alternative-attribute table into a data structure, scoring the same table with two weight sets, counting how the winning alternative changes as weight changes through a sensitivity sweep, and tying whether each weight's source is written down to the decision's defensibility.

The previous topic defined what the role is and how an already-made decision gets communicated
to the team. What remained open is the decision itself: which quality attribute gets chosen when
two cannot be satisfied at once, what the choice rests on, and what is left once the choice is
questioned.

That quality attributes can conflict, and that this conflict is a fact, was measured in the
Introduction to System Design course; the tension between availability and consistency was shown
there with numbers. It is not repeated here. This lesson's question is not whether the tension
exists, but **how** the decision gets made once the tension is given. That is also the quantity
measured: how much a decision holds onto which assumption.

#### The Decision's Object: The Alternative–Attribute Table

The through-line is the software system of a regional library network. It is a fictional model;
no real institution, team, or product is described. The model has fourteen branches, a central IT
unit with its own budget, a third-party catalog system, and in-house loan services.

The decision is this: how should branches access loan records? Four alternatives and four
attributes are defined (**AP1**; scores range 1–5, higher is better, and the values are the
model's):

- `single-center` — a single authoritative database; every branch connects to the center for
  every transaction.
- `branch-cache` — the center is authoritative, each branch holds a read-only cache; writes go to
  the center.
- `branch-replica` — each branch holds a full replica, closes its transaction locally, and
  synchronizes afterward.
- `external-module` — uses the third-party catalog system's own loan module.

As long as the decision is a preference held in someone's head, it cannot be measured. The only
thing that makes it measurable is turning it into a data structure.

```js
// balance/table.mjs — the alternative-attribute table as a data structure; this is a fictional model
export const ATTRIBUTE = ["outage-resilience", "record-freshness", "modifiability", "operating-cost"];

// Scores range 1-5, higher is better (AP1). Sources are written out in the lesson.
export const ALTERNATIVE = [
  { name: "single-center",   score: [1, 5, 4, 5] },
  { name: "branch-cache",    score: [3, 4, 3, 4] },
  { name: "branch-replica",  score: [5, 2, 2, 2] },
  { name: "external-module", score: [4, 3, 1, 3] },
];

export function score(weight) {
  return ALTERNATIVE.map((a) => ({
    name: a.name,
    total: a.score.reduce((t, p, i) => t + p * weight[i], 0),
  })).sort((x, y) => y.total - x.total);
}

export const winner = (weight) => score(weight)[0].name;
```

No row in the table dominates every column. That is exactly what a trade-off is: an alternative
that raises one column lowers another. What determines the winner is no longer the scores, but
the weights given to the columns.

#### The Same Table, Two Weight Sets

Two implementation forms are run on the same input. The first never asks the weight question and
counts all four attributes equally — in practice this is the most common form, because when
weights are not written down, equal weight is accepted by default. The second writes the weights
explicitly.

```js
// balance/score.mjs — same table with two weight sets: does the winning alternative change
import { ATTRIBUTE, ALTERNATIVE, score } from "./table.mjs";

const SET = {
  "equal-weight": [0.25, 0.25, 0.25, 0.25],
  "written-weight": [0.50, 0.20, 0.15, 0.15],
};

console.log(`attribute      : ${ATTRIBUTE.join(" ")}`);
for (const a of ALTERNATIVE) console.log(`${a.name.padEnd(15)}: ${a.score.join("    ")}`);
console.log("");
for (const [name, w] of Object.entries(SET)) {
  const s = score(w);
  console.log(`${name.padEnd(15)} w=[${w.join(" ")}]`);
  console.log(`  ${s.map((x) => `${x.name}=${x.total.toFixed(2)}`).join("  ")}`);
  console.log(`  winner: ${s[0].name} (margin over second ${(s[0].total - s[1].total).toFixed(2)})`);
}
```

```
attribute      : outage-resilience record-freshness modifiability operating-cost
single-center  : 1    5    4    5
branch-cache   : 3    4    3    4
branch-replica : 5    2    2    2
external-module: 4    3    1    3

equal-weight    w=[0.25 0.25 0.25 0.25]
  single-center=3.75  branch-cache=3.50  branch-replica=2.75  external-module=2.75
  winner: single-center (margin over second 0.25)
written-weight  w=[0.5 0.2 0.15 0.15]
  branch-replica=3.50  branch-cache=3.35  external-module=3.20  single-center=2.85
  winner: branch-replica (margin over second 0.15)
```

Same score table, same four alternatives; the winner became `branch-replica` instead of
`single-center`, and `single-center` fell from first to last. What determines the decision is not
the alternatives' measured attributes, but the weights. When weights are not written down, the
decision still gets made — equal weight is a weight set too, only nobody said they chose it.

The smallness of the margin column in both sets is also information: 0.25 and 0.15. The winning
alternative does not leave the runner-up half a point behind. That calls for asking how solidly
the decision stands.

#### Sensitivity Sweep

The result that comes out of a single weight set is not a decision, it is a sample. How resilient
the decision is can only be seen by sweeping the weight space. The sweep generates a grid with a
0.05 step such that the four weights sum to 1, and counts the winner at every point.

```js
// balance/sweep.mjs — sensitivity sweep: does the winning alternative change as weight changes
import { ATTRIBUTE, ALTERNATIVE, score, winner } from "./table.mjs";

const STEP = 0.05, N = ATTRIBUTE.length, EPS = 1e-9;
const grid = [];
(function generate(remaining, accumulated) {
  if (accumulated.length === N - 1) return grid.push([...accumulated, remaining]);
  for (let p = 0; p <= remaining + EPS; p += STEP) generate(remaining - p, [...accumulated, p]);
})(1, []);

const decisive = new Map(ALTERNATIVE.map((a) => [a.name, 0]));
let tie = 0;
for (const w of grid) {
  const s = score(w);
  if (s[0].total - s[1].total < EPS) { tie += 1; continue; }
  decisive.set(s[0].name, decisive.get(s[0].name) + 1);
}
console.log(`grid: step ${STEP}, ${grid.length} weight vectors`);
for (const [name, k] of [...decisive].sort((a, b) => b[1] - a[1]))
  console.log(`  ${name.padEnd(15)} first alone in first place: ${String(k).padStart(4)} vectors (${(100 * k / grid.length).toFixed(1)}%)`);
console.log(`  (${tie} vectors where the top two alternatives tied were not counted)`);

const SELECTED = [0.50, 0.20, 0.15, 0.15];
const selectedWinner = winner(SELECTED);
const l1 = (a, b) => a.reduce((t, v, i) => t + Math.abs(v - b[i]), 0);
let nearest = { d: Infinity, w: null, name: null };
for (const w of grid) {
  const k = winner(w);
  if (k === selectedWinner) continue;
  const d = l1(w, SELECTED);
  if (d < nearest.d) nearest = { d, w, name: k };
}
console.log(`\nselected weight [${SELECTED.join(" ")}] -> ${selectedWinner}`);
console.log(`breaking distance (L1): ${nearest.d.toFixed(2)}`);
console.log(`  nearest different vector [${nearest.w.map((x) => x.toFixed(2)).join(" ")}] -> ${nearest.name}`);

console.log("\nperturbing a single weight by 0.10 (the other three weights are rebalanced to preserve their ratio):");
for (let i = 0; i < N; i++) {
  const row = [];
  for (const delta of [-0.10, +0.10]) {
    const next = SELECTED.slice();
    next[i] = Math.max(0, Math.min(1, next[i] + delta));
    const share = (1 - next[i]) / (1 - SELECTED[i]);
    for (let j = 0; j < N; j++) if (j !== i) next[j] = SELECTED[j] * share;
    row.push(`${delta > 0 ? "+" : ""}${delta.toFixed(2)} -> ${winner(next)}`);
  }
  const changed = row.some((s) => !s.endsWith(selectedWinner));
  console.log(`  ${ATTRIBUTE[i].padEnd(20)} ${row.join(" , ")}${changed ? "   <- winner changed" : ""}`);
}
```

```
grid: step 0.05, 1771 weight vectors
  single-center   first alone in first place: 1211 vectors (68.4%)
  branch-replica  first alone in first place:  314 vectors (17.7%)
  branch-cache    first alone in first place:  220 vectors (12.4%)
  external-module first alone in first place:    0 vectors (0.0%)
  (26 vectors where the top two alternatives tied were not counted)

selected weight [0.5 0.2 0.15 0.15] -> branch-replica
breaking distance (L1): 0.10
  nearest different vector [0.45 0.20 0.15 0.20] -> branch-cache

perturbing a single weight by 0.10 (the other three weights are rebalanced to preserve their ratio):
  outage-resilience    -0.10 -> branch-cache , +0.10 -> branch-replica   <- winner changed
  record-freshness     -0.10 -> branch-replica , +0.10 -> branch-cache   <- winner changed
  modifiability        -0.10 -> branch-replica , +0.10 -> branch-replica
  operating-cost       -0.10 -> branch-replica , +0.10 -> branch-cache   <- winner changed
```

The sweep produces three separate numbers, and each says something different about the decision.

**Region won.** The selected alternative, `branch-replica`, is first in 314 of the 1771 vectors.
The decision is defensible in 17.7% of the weight space. `single-center` holds the largest region
at 68.4%; that is, the selected alternative is not the one that is "generally best," but the one
that is best under the written weights. The gap between these two sentences is the whole of the
decision's justification.

**Breaking distance.** The L1 distance from the selected vector to the nearest point where the
winner changes is 0.10; when the operating-cost weight rises from 0.15 to 0.20, the winner
becomes `branch-cache`. A 0.10 perturbation on three of the four weights changes the winner. This
is not a flaw, it is the decision's real sensitivity; the problem only starts when the decision is
made without knowing this sensitivity.

**The alternative that wins under no weight.** `external-module` is not first alone in any of the
1771 vectors. This result is independent of the weight debate: no matter which weight set gets
agreed on, this alternative will not be chosen, because `branch-cache` and `branch-replica`
together dominate it in every direction. The alternative most likely to be argued over in the
meeting is the one that can be eliminated without ever entering the debate.

#### The Weight's Source

Everything measured so far assumed the weights were correct. Where do the weights themselves come
from? The **threshold's source** measure established in the Introduction to System Design course's
What Is System Design lesson asks exactly this: when a number is written down, the reason it is
that number has to be written down too. A weight is also a threshold; a weight with no written
source leaves the same gap as a threshold with no written source.

The following run adds a source field to each weight and intersects two things: whether the
weight's source is written down, and whether that weight can change the winner. The three records
shown as sources are the model's input (**AP2**); the fourth weight has no record behind it.

```js
// balance/source.mjs — is each weight's source written down, and does breaking sit on that weight
import { ATTRIBUTE, winner } from "./table.mjs";

const WEIGHT = [
  { value: 0.50, source: "branch network downtime log (AP2)" },
  { value: 0.20, source: "the written return grace period in the loan rules (AP2)" },
  { value: 0.15, source: "annual rule-change count log (AP2)" },
  { value: 0.15, source: null },
];

function perturb(w, i, delta) {
  const next = w.slice();
  next[i] = Math.max(0, Math.min(1, next[i] + delta));
  const share = (1 - next[i]) / (1 - w[i]);
  for (let j = 0; j < w.length; j++) if (j !== i) next[j] = w[j] * share;
  return next;
}

function check(weight) {
  const w = weight.map((a) => a.value);
  const base = winner(w);
  const row = weight.map((a, i) => ({
    name: ATTRIBUTE[i],
    sourced: a.source !== null,
    fragile: [-0.10, 0.10].some((d) => winner(perturb(w, i, d)) !== base),
  }));
  return { base, row, exposed: row.filter((s) => s.fragile && !s.sourced).length };
}

for (const [name, weight] of [["initial state", WEIGHT],
  ["after the cost weight is measured", WEIGHT.map((a, i) =>
    i === 3 ? { value: 0.25, source: "the IT unit's budget cap and the annual cost per replica (AP3)" }
      : i === 0 ? { value: 0.40, source: a.source } : a)]]) {
  const d = check(weight);
  console.log(`${name}: w=[${weight.map((a) => a.value.toFixed(2)).join(" ")}] -> ${d.base}`);
  for (const s of d.row)
    console.log(`  ${s.name.padEnd(20)} source: ${(s.sourced ? "written" : "none").padEnd(7)}` +
      ` fragile: ${s.fragile ? "yes" : "no"}`);
  console.log(`  weight that can change the winner but has no written source: ${d.exposed}\n`);
}
```

```
initial state: w=[0.50 0.20 0.15 0.15] -> branch-replica
  outage-resilience    source: written fragile: yes
  record-freshness     source: written fragile: yes
  modifiability        source: written fragile: no
  operating-cost       source: none    fragile: yes
  weight that can change the winner but has no written source: 1

after the cost weight is measured: w=[0.40 0.20 0.15 0.25] -> branch-cache
  outage-resilience    source: written fragile: yes
  record-freshness     source: written fragile: no
  modifiability        source: written fragile: no
  operating-cost       source: written fragile: no
  weight that can change the winner but has no written source: 0
```
In the first run there is exactly one number the decision cannot carry: the operating-cost weight
both can change the winner and has no written source. The decision was made toward
`branch-replica`, but one leg of its justification is empty. That the `modifiability` weight's
source is written down does not strengthen the decision at all — perturbing that weight by 0.10
does not change the winner. The effort of writing the justification was spent where the decision
does not lean, not where it does.

The second run is the result of closing the missing source: once the IT unit's budget cap and the
annual cost per replica are measured (**AP3**), the cost weight comes out to 0.25 instead of 0.15,
and the outage-resilience weight drops from 0.50 to 0.40. The winner becomes `branch-cache`, and
the count of fragile weights with no written source drops from 1 to 0. The first decision was not
wrong; it was not yet measured. The difference between the two runs shows that the architect's job
is not to argue about weights, but to find the weights' source.

The measure that comes out of this is: **the count of weights that can change the winner but have
no written source.** If this count is not zero, the decision is not defensible — meaning it is not
known whether it is right or wrong. If the count is zero, the decision stays open to debate, but
the debate no longer runs on the weight itself; it runs on the record the weight rests on, and
that record is something measurable.

#### Summary

- Striking balance is not declaring a preference; it is turning the alternative-attribute table
  into a data structure and running weighted scoring; no alternative in the table dominates every
  column.
- The same four alternatives and the same scores gave `single-center` the win under equal weight
  and `branch-replica` under written weight; when weight is not written down, equal weight gets
  chosen by default.
- In the 1771-vector sensitivity sweep, `branch-replica` won in a 17.7% region and `single-center`
  in a 68.4% region; the breaking distance is 0.10, and a 0.10 perturbation on three of the four
  weights changes the winner.
- `external-module` is not first alone in any of the 1771 vectors; an alternative that can be
  eliminated without agreeing on weights is eliminated before the debate.
- The decision's defensibility is measured by a single number: the count of weights that can
  change the winner but have no written source. It was 1 in the first run and 0 after the cost
  weight was measured, with the winner becoming `branch-cache`.

#### Next Step

The balance-striking measure gave **which** alternative the decision went to, but it did not ask
how heavy the selected alternative itself is. When `branch-cache` is selected, a cache layer, a
synchronization path, and an invalidation rule appear; each is a new piece the system will carry.
The next question is: which of these pieces comes from the work itself, and which comes only from
how the solution is built? Every simplification made without separating the two either deletes
something needed or deletes nothing at all. The next lesson splits complexity into two components
and separates the share simplification can remove from the share it cannot, by counting them.

### Simplifying

Source: https://academia.sh/en/courses/architect-role/simplifying

The deliberate reduction of complexity: separating accidental from essential complexity, splitting a solution's module graph's total complexity into two components against the essential floor read from the requirement graph, counting the accidental share simplification removes and the share it cannot, and measuring what grows in return for simplification by running the same change set on both graphs.

The previous lesson measured which alternative the decision went to. The selected alternative
brings out a set of pieces: a cache layer, a synchronization path, an invalidation rule. Over time
these pieces accumulate and the system starts being called "complex." As long as complexity is
used as an adjective, no decision can be made about it, because it does not show the gap between
the desire to reduce and the ability to reduce.

This lesson's question is: how much of a solution's complexity can be removed, and how much comes
from the work itself? Simplification undertaken without this separation fails in one of two ways
— it either deletes something needed, or deletes nothing and merely rearranges. Coupling and
cohesion measures were established in prerequisite design courses; they are not repeated here.
What gets measured is complexity's split into two components.

#### Accidental and Essential Complexity

**Essential complexity** is the share that comes from the work being solved itself and that does
not disappear in any implementation. A borrowed book having a return date, a delay turning into a
fee, an extension depending on both the loan record and the fee — these come from the library
network's rules. However the code is written, these bonds remain.

**Accidental complexity** is the share that comes from how the solution is built. A second module
covering the same behavior, a wrapper that has stepped in without covering any behavior, a layer
built just to read a single setting. These are properties of the solution, not of the work.

The distinction sounds obvious but is open to argument in practice, because everyone considers the
layer they added essential. The only thing that closes the argument is tying the distinction to a
measure: **the essential share is read from the requirement, not from the solution.** The lowest
floor that any solution covering the same set of behaviors can descend to is the requirements' own
graph.

#### Two Graphs: Solution and Requirement

The model holds two graphs. The first is the behaviors the library network's rules impose and the
mandatory links between them. The second is the in-house loan services' present-day module
graph. It is a fictional model; no real institution or product is described.

```js
// simplify/graph.mjs — the module graph of loan services and the requirement graph; this is a fictional model
// REQUIREMENT: the behaviors the library network's rules impose and the mandatory links between them (AP4)
export const REQUIREMENT = {
  r1: { name: "lend", links: ["r7"] },
  r2: { name: "accept-return", links: ["r4"] },
  r3: { name: "extend", links: ["r1", "r4"] },
  r4: { name: "late-fee", links: [] },
  r5: { name: "reserve", links: [] },
  r6: { name: "inter-branch-request", links: ["r5"] },
  r7: { name: "membership-verification", links: [] },
  r8: { name: "penalty-waiver", links: ["r4"] },
  r9: { name: "shelf-status-notification", links: [] },
};

// MODULE: the solution's present state. readsConfig = a module that reads a per-branch setting directly (AP5)
export const MODULE = [
  { name: "loan-flow", needs: ["r1"], readsConfig: false },
  { name: "return-flow", needs: ["r2"], readsConfig: false },
  { name: "extension-flow", needs: ["r3"], readsConfig: false },
  { name: "fee-calculation", needs: ["r4"], readsConfig: false },
  { name: "reservation-flow", needs: ["r5"], readsConfig: false },
  { name: "inter-branch", needs: ["r6"], readsConfig: false },
  { name: "membership-gate", needs: ["r7"], readsConfig: false },
  { name: "waiver-flow", needs: ["r8"], readsConfig: false },
  { name: "shelf-notification", needs: ["r9"], readsConfig: false },
  { name: "loan-helper", needs: ["r1"], readsConfig: false },
  { name: "return-helper", needs: ["r2"], readsConfig: false },
  { name: "record-bridge", needs: [], readsConfig: false },
  { name: "event-bridge", needs: [], readsConfig: false },
  { name: "config-wrapper", needs: [], readsConfig: true },
];

export const LINK = [
  ["loan-flow", "loan-helper"], ["loan-flow", "membership-gate"],
  ["loan-flow", "record-bridge"], ["loan-flow", "config-wrapper"],
  ["return-flow", "return-helper"], ["return-flow", "fee-calculation"],
  ["return-flow", "record-bridge"], ["return-flow", "config-wrapper"],
  ["extension-flow", "loan-helper"], ["extension-flow", "fee-calculation"],
  ["extension-flow", "config-wrapper"], ["fee-calculation", "record-bridge"],
  ["fee-calculation", "config-wrapper"], ["reservation-flow", "record-bridge"],
  ["reservation-flow", "event-bridge"], ["reservation-flow", "config-wrapper"],
  ["inter-branch", "reservation-flow"], ["inter-branch", "event-bridge"],
  ["inter-branch", "config-wrapper"], ["waiver-flow", "fee-calculation"],
  ["waiver-flow", "record-bridge"], ["shelf-notification", "event-bridge"],
  ["membership-gate", "record-bridge"],
];

export const clone = () => ({
  module: MODULE.map((m) => ({ ...m, needs: [...m.needs] })),
  link: LINK.map((b) => [...b]),
});

export const measure = (g) => g.module.length + g.link.length;
```

The complexity measure was deliberately kept crude: module count plus link count. The measure's
refinement does not matter here, because what is measured is not an absolute magnitude, but the
difference between two graphs.

Simplification consists of two steps, and both rest on a single rule: a module can be removed only
if it does not distinguish any behavior.

```js
// simplify/simplify.mjs — two simplification steps: merge modules covering the same requirement,
// remove a wrapper that covers no requirement (its work passes to its callers)
function merge(g, drop, keep) {
  g.link = g.link.map(([a, b]) => [a === drop ? keep : a, b === drop ? keep : b])
    .filter(([a, b]) => a !== b);
  const k = g.module.find((m) => m.name === keep);
  k.readsConfig ||= g.module.find((m) => m.name === drop).readsConfig;
  g.module = g.module.filter((m) => m.name !== drop);
}

function remove(g, target) {
  const before = g.link.filter(([, b]) => b === target).map(([a]) => a);
  const after = g.link.filter(([a]) => a === target).map(([, b]) => b);
  const reads = g.module.find((m) => m.name === target).readsConfig;
  g.link = g.link.filter(([a, b]) => a !== target && b !== target);
  for (const o of before) {
    if (reads) g.module.find((m) => m.name === o).readsConfig = true;
    for (const s of after) if (o !== s && !g.link.some(([a, b]) => a === o && b === s)) g.link.push([o, s]);
  }
  g.module = g.module.filter((m) => m.name !== target);
}

export function simplify(g) {
  merge(g, "loan-helper", "loan-flow");
  merge(g, "return-helper", "return-flow");
  remove(g, "config-wrapper");
  return g;
}
```

One line in the `remove` function carries half the lesson: if the removed module `readsConfig`,
that attribute passes to the modules that call it. When the wrapper is removed, the work it did
does not disappear, it disperses.

#### Measuring the Split

```js
// simplify/split.mjs — splits total complexity into essential and accidental components, then runs simplification
import { REQUIREMENT, clone, measure } from "./graph.mjs";
import { simplify } from "./simplify.mjs";

const g = clone();
const total = measure(g);
const essentialModule = Object.keys(REQUIREMENT).length;
const essentialLink = Object.values(REQUIREMENT).reduce((t, r) => t + r.links.length, 0);
const essential = essentialModule + essentialLink;

console.log(`module graph     : ${g.module.length} modules + ${g.link.length} links = ${total}`);
console.log(`requirement graph: ${essentialModule} behaviors + ${essentialLink} mandatory links = ${essential}`);
console.log(`essential = ${essential}, accidental = ${total - essential} (${(100 * (total - essential) / total).toFixed(1)}%)`);

simplify(g);
const simplified = measure(g);
console.log(`\nafter simplification: ${g.module.length} modules + ${g.link.length} links = ${simplified}`);
console.log(`accidental share removed: ${total - simplified}`);
console.log(`accidental share remaining: ${simplified - essential}`);
console.log(`floor that cannot be lowered: ${essential} (essential)`);
console.log(`modules that read config directly: before 1, after ${g.module.filter((m) => m.readsConfig).length}`);
```

```
module graph     : 14 modules + 23 links = 37
requirement graph: 9 behaviors + 6 mandatory links = 15
essential = 15, accidental = 22 (59.5%)

after simplification: 11 modules + 15 links = 26
accidental share removed: 11
accidental share remaining: 11
floor that cannot be lowered: 15 (essential)
modules that read config directly: before 1, after 6
```

Total complexity is 37; 15 of it is essential, 22 is accidental. That is, more than half of the
solution comes not from the requirement, but from how the solution is built. This ratio alone is
not a call to action; what shows what the distinction is good for is the lines that follow.

Simplification brought 37 down to 26, meaning it removed 11 of the 22-unit accidental share. The
remaining 11 is also accidental but could not be removed: `record-bridge` and `event-bridge` cover
no behavior, yet they do real work — one gathers record writing, the other event publishing, in a
single place. Removing them does not eliminate the work, it copies it into six separate modules.
Being accidental is not the same thing as being removable.

The last line is simplification's price. Before, a single module read the per-branch setting;
after the wrapper is removed, six modules read it. The complexity measure dropped by 11, but the
number of places an attribute is scattered across rose sixfold. The measure does not see this; the
next run does.

#### What Simplification Costs In Return

The comparison runs the same set of changes on both graphs. The changes are the ones expected over
the next twelve months (**AP6**) and are of two types: those that change a behavior's rule, and
the spreading change that differentiates a rule per branch.

```js
// simplify/change.mjs — the same set of changes on two graphs: how many modules are touched
import { clone } from "./graph.mjs";
import { simplify } from "./simplify.mjs";

// AP6: changes expected over the next twelve months. "spreading" = a rule that differs per branch.
const CHANGE = [
  { name: "late fee rate", type: "requirement", target: "r4" },
  { name: "extension allowance count", type: "requirement", target: "r3" },
  { name: "reservation duration", type: "requirement", target: "r5" },
  { name: "loan rule", type: "requirement", target: "r1" },
  { name: "new step in the return flow", type: "requirement", target: "r2" },
  { name: "per-branch late fee", type: "spreading", target: null },
];

function touched(g, d) {
  if (d.type === "spreading") return g.module.filter((m) => m.readsConfig).map((m) => m.name);
  const carrying = g.module.filter((m) => m.needs.includes(d.target)).map((m) => m.name);
  const calling = g.link.filter(([, b]) => carrying.includes(b)).map(([a]) => a);
  return [...new Set([...carrying, ...calling])];
}

const graphs = { original: clone(), simplified: simplify(clone()) };
console.log("change                      | original | simplified | diff");
console.log("----------------------------|----------|------------|-----");
const total = { original: 0, simplified: 0 };
for (const d of CHANGE) {
  const o = touched(graphs.original, d).length, s = touched(graphs.simplified, d).length;
  total.original += o; total.simplified += s;
  const diff = s - o;
  console.log(`${d.name.padEnd(27)} | ${String(o).padStart(8)} | ${String(s).padStart(10)} | ` +
    `${diff > 0 ? "+" : ""}${diff}`);
}
console.log("----------------------------|----------|------------|-----");
console.log(`${"total touched modules".padEnd(27)} | ${String(total.original).padStart(8)} | ` +
  `${String(total.simplified).padStart(10)} | ${total.simplified - total.original}`);
const spread = CHANGE.filter((d) => d.type === "spreading");
console.log(`in the ${CHANGE.length - spread.length} known changes the simplified graph touched ` +
  `${total.original - touched(graphs.original, spread[0]).length - (total.simplified - touched(graphs.simplified, spread[0]).length)} fewer modules`);
console.log(`in the 1 spreading change the simplified graph touched ` +
  `${touched(graphs.simplified, spread[0]).length - touched(graphs.original, spread[0]).length} more modules`);
```

```
change                      | original | simplified | diff
----------------------------|----------|------------|-----
late fee rate               |        4 |          4 | 0
extension allowance count   |        1 |          1 | 0
reservation duration        |        2 |          2 | 0
loan rule                   |        3 |          2 | -1
new step in the return flow |        2 |          1 | -1
per-branch late fee         |        1 |          6 | +5
----------------------------|----------|------------|-----
total touched modules       |       13 |         16 | 3
in the 5 known changes the simplified graph touched 2 fewer modules
in the 1 spreading change the simplified graph touched 5 more modules
```

Simplification gained 2 modules across the five known changes; for the two behaviors whose second
module was removed, the change collapsed into a single place, and nothing changed for the
remaining three. In the single spreading change, however, it cost 5 modules. The total number of
touched modules rose from 13 to 16.

This does not mean simplification was wrong; it means simplification has a **price**. The removed
wrapper really was an accidental layer — it did not distinguish any behavior — but it held a point
of flexibility. When that point was deleted, the flexibility did not vanish, it scattered across
six modules. This is what grows in return for simplification: the number of places a change has to
pass through.

The decision therefore rests not on a single measure, but on comparing measures. If the spreading
change is believed to really be coming, the wrapper is kept despite being accidental; if it is
believed not to be coming, it is removed, and the cost of touching 6 modules on the day it does
arrive is accepted in advance. Both are defensible; the only thing that is not defensible is
adding or deleting a layer without ever computing this number.

#### Summary

- Essential complexity comes from the work itself and is read from the requirement graph;
  accidental complexity comes from how the solution is built and is found by subtracting the
  essential share from the total.
- In the model, total complexity is 37 (14 modules + 23 links), the essential floor is 15
  (9 behaviors + 6 mandatory links), and the accidental share is 22 — that is, 59.5% of the total.
- Simplification removed 11 of the accidental share and brought the total down to 26; the
  remaining 11 is also accidental but cannot be removed, because the two bridge modules do real
  work without covering any behavior.
- Being accidental is not the same thing as being removable; the essential floor of 15 is the
  limit no simplification can descend below.
- When the same six changes are run on both graphs, the simplified graph gained 2 modules across
  the five known changes and cost 5 modules on the single spreading change; the total rose from
  13 to 16.
- What grows in return for simplification is the number of places a change has to pass through.

#### Next Step

Simplification's price has been computed, but one thing has not: can the wrapper be brought back
once it is removed? Gathering the config reading scattered across six modules back into a single
point is not a job of the same size as removing the wrapper. Some decisions are like this —
reversing them after they are made costs many times more than making them; others can be reversed
the next day. When both classes are handled with the same method, two mistakes get made at once:
weeks are spent on reversible decisions, and irreversible decisions are made in a single meeting.
The next lesson computes the reversal cost on the same decision set, splits the decisions into two
classes, and runs two decision methods on both classes to compare waiting time against the cost of
a wrong decision.

### Decision Making

Source: https://academia.sh/en/courses/architect-role/decision-making

Making reversible and irreversible decisions with separate methods: computing reversal cost from a dependency set, splitting the same decision set into two classes by that cost, running the quick-trial and long-evaluation methods on both classes, and comparing wait time against wrong-decision cost across four policies.

The previous lesson computed a simplification's price but left one question open: can the removed
wrapper be brought back? Gathering the config reading scattered across six modules back into a
single point is not a job of the same size as removing the wrapper. Some decisions are like this;
reversing them costs many times more than making them.

This gap goes unseen most of the time in practice, because every decision is made in the same
place at the same speed. The result is a two-way waste: weeks of meetings get held for reversible
decisions, and irreversible decisions get closed in a single afternoon. What this lesson measures
is how many person-days these two mistakes cost.

#### Reversal Cost Is Read From a Dependency Set

The degree to which a decision is reversible is not a feeling, it is a computation. A decision lands on a
component; reversing it requires traversing that component and every component that depends on
it. The model is the library network's software system; it is fiction, no real institution is
described.

```js
// decision/cost.mjs — reversal cost is read from a set of dependencies; this is a fictional model
// DEPENDENCY[x] = the components x uses. Reversal requires traversing the changed component and
// every component that depends on it (AP7).
export const DEPENDENCY = {
  "data-store": [],
  "loan-core": ["data-store"],
  "membership": ["data-store"],
  "catalog-gateway": ["data-store"],
  "reservation": ["loan-core"],
  "reporting": ["data-store", "loan-core"],
  "payment-gateway": ["loan-core"],
  "notification": ["reservation"],
  "matching-pipeline": ["catalog-gateway", "data-store"],
  "branch-client": ["loan-core", "reservation", "catalog-gateway"],
};

// AP7 coefficients (person-days). Traversal per component 2; if the written records' format
// changes, migration is 15; if an exposed interface is versioned, coordination is 20.
export const COEFFICIENT = { component: 2, migration: 15, contract: 20 };

export const DECISION = [
  { name: "loan-record-format", component: "data-store", formatChange: true, external: false },
  { name: "branch-client-cache", component: "branch-client", formatChange: false, external: false },
  { name: "catalog-gateway-version", component: "catalog-gateway", formatChange: false, external: true },
  { name: "notification-text-template", component: "notification", formatChange: false, external: false },
  { name: "reporting-query-plan", component: "reporting", formatChange: false, external: false },
  { name: "loan-rule-engine", component: "loan-core", formatChange: false, external: false },
  { name: "reservation-ordering-rule", component: "reservation", formatChange: false, external: false },
  { name: "payment-interface-version", component: "payment-gateway", formatChange: false, external: true },
];

export function reached(component) {
  const set = new Set([component]);
  let grew = true;
  while (grew) {
    grew = false;
    for (const [x, uses] of Object.entries(DEPENDENCY))
      if (!set.has(x) && uses.some((y) => set.has(y))) { set.add(x); grew = true; }
  }
  return set;
}

export const reversalCost = (k) => reached(k.component).size * COEFFICIENT.component +
  (k.formatChange ? COEFFICIENT.migration : 0) + (k.external ? COEFFICIENT.contract : 0);
```

The computation has three components, and each counts a different fact. The count of reached
components is read from the graph and depends on where the decision lands. If the record format
changed, reversal concerns not only the code but the written records too; this share stands there
even without touching code. If an exposed interface has been versioned, reversal cannot be done
unilaterally, it requires coordination with the other side.

#### Two Classes

```js
// decision/classify.mjs — reversal cost is computed, decisions are split into two classes
import { DECISION, reached, reversalCost } from "./cost.mjs";

// AP8: the threshold is the capacity set aside for reversal in one release cycle: 10 person-days.
const THRESHOLD = 10;
console.log("decision                    | component        |   reached | migr |  ext | reversal cost");
console.log("----------------------------|------------------|-----------|------|------|--------------");
for (const k of DECISION)
  console.log(`${k.name.padEnd(27)} | ${k.component.padEnd(16)} | ` +
    `${String(reached(k.component).size).padStart(9)} | ${(k.formatChange ? "yes" : "-").padStart(4)} | ` +
    `${(k.external ? "yes" : "-").padStart(4)} | ${String(reversalCost(k)).padStart(13)}`);

const classOf = (k) => (reversalCost(k) <= THRESHOLD ? "reversible" : "irreversible");
const group = { reversible: [], irreversible: [] };
for (const k of DECISION) group[classOf(k)].push(k);
console.log(`\nthreshold ${THRESHOLD} person-days (AP8)`);
for (const [name, list] of Object.entries(group))
  console.log(`  ${name.padEnd(15)}: ${list.length} decisions, costs ` +
    `${list.map(reversalCost).join(", ")} (total ${list.reduce((t, k) => t + reversalCost(k), 0)})`);
```

```
decision                    | component        |   reached | migr |  ext | reversal cost
----------------------------|------------------|-----------|------|------|--------------
loan-record-format          | data-store       |        10 |  yes |    - |            35
branch-client-cache         | branch-client    |         1 |    - |    - |             2
catalog-gateway-version     | catalog-gateway  |         3 |    - |  yes |            26
notification-text-template  | notification     |         1 |    - |    - |             2
reporting-query-plan        | reporting        |         1 |    - |    - |             2
loan-rule-engine            | loan-core        |         6 |    - |    - |            12
reservation-ordering-rule   | reservation      |         3 |    - |    - |             6
payment-interface-version   | payment-gateway  |         1 |    - |  yes |            22

threshold 10 person-days (AP8)
  reversible     : 4 decisions, costs 2, 2, 2, 6 (total 12)
  irreversible   : 4 decisions, costs 35, 26, 12, 22 (total 95)
```

Four of the eight decisions are **reversible decisions**, four are **irreversible decisions**, and
95 of the total reversal cost — 88.8% — is concentrated in the four decisions. The distribution of
the numbers is also instructive: `payment-interface-version` lands on just one component, its
reached count is 1, but because it versions an exposed interface, its cost is 22.
`loan-rule-engine` spreads across six components but its reversal cost is 12. That is, "how
many places it touches" alone does not determine the class; what most often makes a decision
irreversible is not the number of places it touches, but the promise it makes outward or the
record it writes.

#### Two Methods, Two Classes

The comparison runs two decision methods on the same decision set. **Quick trial** makes the
decision in two days and has a high rate of turning out wrong; **long evaluation** waits fifteen
days and has a low rate of turning out wrong. The two methods produce two separate costs: every
day waited writes a delay cost, and every decision that turns out wrong writes its reversal cost.

```js
// decision/method.mjs — two decision methods on two classes: wait time and wrong-decision cost
import { DECISION, reversalCost } from "./cost.mjs";

// AP9: quick-trial waits 2 days and 35% of its decisions get reversed; long-evaluation
// waits 15 days and 10% get reversed. Each day waiting writes 0.4 person-days of delay cost.
const METHOD = {
  "quick-trial": { wait: 2, wrong: 0.35 },
  "long-evaluation": { wait: 15, wrong: 0.10 },
};
const DAILY = 0.4, THRESHOLD = 10;
const expected = (m, totalReversal, count) =>
  m.wait * count * DAILY + m.wrong * totalReversal;

const group = { reversible: [], irreversible: [] };
for (const k of DECISION) group[reversalCost(k) <= THRESHOLD ? "reversible" : "irreversible"].push(k);

console.log("class           | decisions | reversal cost |  quick-trial |  long-evaluation | winner");
console.log("----------------|-----------|---------------|--------------|------------------|-------");
for (const [name, list] of Object.entries(group)) {
  const t = list.reduce((s, k) => s + reversalCost(k), 0);
  const q = expected(METHOD["quick-trial"], t, list.length);
  const l = expected(METHOD["long-evaluation"], t, list.length);
  console.log(`${name.padEnd(15)} | ${String(list.length).padStart(9)} | ${String(t).padStart(13)} | ` +
    `${q.toFixed(2).padStart(12)} | ${l.toFixed(2).padStart(16)} | ${q < l ? "quick" : "long"} (diff ${Math.abs(q - l).toFixed(2)})`);
}

const quick = METHOD["quick-trial"], long = METHOD["long-evaluation"];
const breakeven = (long.wait - quick.wait) * DAILY / (quick.wrong - long.wrong);
console.log(`\nmethod breakeven point: once reversal cost passes ${breakeven.toFixed(1)} person-days` +
  ` long-evaluation gets cheaper`);
console.log(`class threshold (AP8) ${THRESHOLD}, breakeven point ${breakeven.toFixed(1)}: the two numbers are not the same`);

const POLICY = {
  "always-quick": () => "quick-trial",
  "always-long": () => "long-evaluation",
  "by-threshold": (k) => (reversalCost(k) <= THRESHOLD ? "quick-trial" : "long-evaluation"),
  "by-breakeven": (k) => (reversalCost(k) <= breakeven ? "quick-trial" : "long-evaluation"),
};
console.log("\npolicy          |   wait (days) | expected cost (person-days)");
console.log("----------------|---------------|----------------------------");
for (const [name, choose] of Object.entries(POLICY)) {
  let days = 0, cost = 0;
  for (const k of DECISION) {
    const m = METHOD[choose(k)];
    days += m.wait;
    cost += m.wait * DAILY + m.wrong * reversalCost(k);
  }
  console.log(`${name.padEnd(15)} | ${String(days).padStart(13)} | ${cost.toFixed(2).padStart(27)}`);
}
```

```
class           | decisions | reversal cost |  quick-trial |  long-evaluation | winner
----------------|-----------|---------------|--------------|------------------|-------
reversible      |         4 |            12 |         7.40 |            25.20 | quick (diff 17.80)
irreversible    |         4 |            95 |        36.45 |            33.50 | long (diff 2.95)

method breakeven point: once reversal cost passes 20.8 person-days long-evaluation gets cheaper
class threshold (AP8) 10, breakeven point 20.8: the two numbers are not the same

policy          |   wait (days) | expected cost (person-days)
----------------|---------------|----------------------------
always-quick    |            16 |                       43.85
always-long     |           120 |                       58.70
by-threshold    |            68 |                       40.90
by-breakeven    |            55 |                       38.70
```

The first table is the lesson's core. The same two methods, with the same measure, give opposite
results across the two classes. In the reversible class, quick trial is 17.80 person-days cheaper;
in the irreversible class, long evaluation is 2.95 person-days cheaper. Neither method is better
than the other; a method mismatched to its class is bad.

The magnitude of the two gaps is not equal either. The wrong method's penalty is 17.80 for
reversible decisions and 2.95 for irreversible ones. That is, in this model, the cost of doing
long evaluation for a reversible decision is larger than the cost of quick trial for an
irreversible decision — because long evaluation's delay cost is paid for every decision, while
reversal cost is paid only if the decision turns out wrong.

#### The Threshold's Source, Again

The lower table runs four policies on the same eight decisions. The `always-long` policy is both
the most expensive and the slowest: 58.70 person-days and 120 days of waiting. `always-quick` is
the fastest, at 16 days, but its cost is higher than the policies that split by class. Splitting
the two classes shortens the wait time by 52 days relative to `always-long`, while also dropping
the cost by 17.80 person-days.

But there is a mismatch between the two thresholds, and this is where the **threshold's source**
measure from the first lesson shows up here. The class threshold was taken as 10 person-days; its
source was the capacity set aside for reversal in one release cycle. The method's breakeven point,
however, is 20.8 person-days: this number was not chosen, it was derived from the methods' wait
time and wrong rate. Because the two are not the same, the `by-threshold` policy sends the
`loan-rule-engine` decision (reversal cost 12) to long evaluation, even though that decision is
below the breakeven point. The `by-breakeven` policy sends the same decision to quick trial and
gains both 13 days and 2.20 person-days.

The rule that comes out of this is narrower and more useful than the classification itself: **the
class threshold is derived from the method, not chosen.** The moment the two decision methods'
wait time and wrong rate are written down, the threshold becomes computable. When a number coming
from somewhere else, like capacity, is used as the threshold, classification still gains
something, but it stays below what it could gain.

#### Summary

- A decision's reversal cost is read from a dependency set, not from a feeling: the count of
  reached components, the migration of written records, and the coordination of an exposed
  interface.
- In the eight-decision set, 88.8% of the total reversal cost is concentrated in four irreversible
  decisions; the count of reached components alone does not determine the class.
- The same two methods gave opposite results across the two classes: quick trial is 17.80
  person-days cheaper in the reversible class, and long evaluation is 2.95 person-days cheaper in
  the irreversible class.
- When four policies are run on the same set, `always-long` came out both the most expensive and
  the slowest (58.70 person-days, 120 days); the policy that splits by class dropped to 40.90
  person-days and 68 days.
- The class threshold was 10, the method's breakeven point was 20.8; the policy that derives the
  threshold from the breakeven point came out best of the four, at 38.70 person-days and 55 days.
- The threshold is not chosen, it is computed from the two methods' wait time and wrong rate.

#### Next Step

Everything measured up to this point ends at the architect's desk: weight, complexity share,
reversal cost, threshold. None of the eight decisions gets applied at that desk. The
`loan-record-format` decision's reversal cost was computed as 35 person-days, but what this
number means differs for the unit holding the budget, differs for the team that will do the
record migration, and is something else entirely for the staff member doing the work at the
branch. The computation stays the same; how the other side reads it changes. The next lesson turns
communication with non-technical stakeholders into a data structure: when the same decision is
presented to the same stakeholders with two different narrations, the questions asked and the
rounds needed for approval are counted.

### Stakeholder Management

Source: https://academia.sh/en/courses/architect-role/stakeholder-management

Measuring communication with a non-technical counterpart in a stakeholder model: the same decision's technical-terminology narrative produces 27 questions and 8 rounds, while its outcome-focused narrative produces 0 questions and 3 rounds by eliminating four of six constraints and turning them into four surprises; a third narrative that restores the constraints in plain language brings the total to 3 rounds for four additional statements.

The previous lesson turned a decision into an object and classified it by reversibility: it
settled which decision gets which method. One thing it did not settle: who approves the decision
is not who makes it. In the regional library network's model, changing the loan service's data
flow touches the budget, changes what branch staff do, and concerns the contract of an
externally sourced catalog system. A non-technical person holds all three of these gates.

This lesson does not say "the architect must communicate." It turns communication into a data
structure: a stakeholder is a vocabulary plus a set of concerns, a narrative is a sequence of
statements. The same decision runs through the same model with two separate narratives, and two
numbers get counted: questions answered per decision and rounds needed for approval. A third
number gets counted too, because there is a reason the outcome-focused narrative looks cheap: the
**number of constraints the narrative eliminates**.

#### A Stakeholder Is a Vocabulary and a Set of Concerns

A **stakeholder** is the party that bears the outcome of a decision but does not make it. The
model has three stakeholders and none is drawn from a real organization: the budget owner, the
service owner, the procurement owner. Each one has a known vocabulary of terms and a set of
concerns that must be addressed for approval.

**AP10: a stakeholder grants approval once every concern in its concern set is addressed and no
open question remains.** If either condition is missing, approval does not come; the meeting runs
another round.

**AP11: a statement containing an unknown term is not understood in that round.** It does not
address its concern, does not carry its constraint; instead it produces one question per term.
The answer to a question permanently adds the term to the vocabulary, so the same term is never
asked about twice.

**AP12: every constraint that is not carried later produces a surprise for the stakeholder
affected by that constraint**, and a surprise adds one more round of cost. If the constraint was
carried, no one is surprised when the event happens.

```js
// stakeholder/model.mjs — model of communication with a non-technical counterpart. There is no
// real organization or person; a stakeholder is a vocabulary plus a set of concerns, a narrative
// is a sequence of statements. Time is not measured; questions answered, rounds, and constraints carried are counted.

// The constraints the decision carries. A narrative is not required to carry all of them; every
// constraint it does not carry is a later surprise candidate.
export const CONSTRAINTS = {
  C1: "the nightly report is produced fifteen minutes late",
  C2: "branch staff take training once",
  C3: "old records are double-written for two weeks",
  C4: "the decision can be reversed within a week",
  C5: "the contract with the external catalog provider does not change",
  C6: "delivery is delayed if backlog builds up during peak hours",
};

// Terms everyone knows in the organization's own language. Technical terms are not in this set.
const COMMON = ["branch", "staff", "duration", "cost", "risk", "report", "reader",
  "service outage", "training", "contract", "provider"];

export const STAKEHOLDERS = [
  { name: "budget owner", concerns: ["cost", "reversal"], vocabulary: [...COMMON, "budget"] },
  { name: "service owner", concerns: ["service outage", "training"], vocabulary: [...COMMON, "shelf"] },
  { name: "procurement owner", concerns: ["contract", "cost"], vocabulary: [...COMMON, "tender"] },
];

// The statement the architect adds when an unaddressed concern remains. Written in plain language.
export const ADDITIONAL_STATEMENT = {
  "cost": { terms: ["cost", "staff"], concerns: ["cost"], constraint: [] },
  "reversal": { terms: ["duration", "risk"], concerns: ["reversal"], constraint: ["C4"] },
  "service outage": { terms: ["service outage"], concerns: ["service outage"], constraint: [] },
  "training": { terms: ["staff", "branch"], concerns: ["training"], constraint: ["C2"] },
  "contract": { terms: ["contract", "provider"], concerns: ["contract"], constraint: ["C5"] },
};

// AP10 + AP11: one stakeholder, one narrative. An unknown term turns into a question; the answer is added to the vocabulary.
export function meeting(stakeholder, narrative, maxRounds = 12) {
  const vocabulary = new Set(stakeholder.vocabulary);
  const addressed = new Set(), carried = new Set(), additional = [];
  let questions = 0, round = 0;
  while (round < maxRounds) {
    round += 1;
    let roundQuestions = 0;
    for (const f of [...narrative, ...additional]) {
      const unknown = f.terms.filter((t) => !vocabulary.has(t));
      if (unknown.length > 0) {
        roundQuestions += unknown.length;
        for (const t of unknown) vocabulary.add(t);
        continue;
      }
      for (const c of f.concerns) addressed.add(c);
      for (const c of f.constraint) carried.add(c);
    }
    questions += roundQuestions;
    const missing = stakeholder.concerns.filter((c) => !addressed.has(c));
    if (roundQuestions === 0 && missing.length === 0) return { round, questions, carried, additional: additional.length };
    if (roundQuestions === 0) additional.push(ADDITIONAL_STATEMENT[missing[0]]);
  }
  return { round, questions, carried, additional: additional.length, unapproved: true };
}

// AP12: events a stakeholder who does not know a constraint is later surprised by: constraint -> affected stakeholder.
export const EVENTS = {
  C1: "budget owner", C2: "service owner", C3: "budget owner",
  C4: "budget owner", C5: "procurement owner", C6: "service owner",
};

// One narrative's total across the three stakeholders. Stakeholders are informed separately.
export function runNarrative(narrative, extraVocabulary = []) {
  const carriedByStakeholder = new Map();
  let questions = 0, round = 0, additional = 0;
  for (const s of STAKEHOLDERS) {
    const r = meeting({ ...s, vocabulary: [...s.vocabulary, ...extraVocabulary] }, narrative);
    questions += r.questions; round += r.round; additional += r.additional;
    carriedByStakeholder.set(s.name, r.carried);
  }
  const surprises = Object.entries(EVENTS)
    .filter(([c, name]) => !carriedByStakeholder.get(name).has(c)).map(([c]) => c);
  const combined = new Set([...carriedByStakeholder.values()].flatMap((s) => [...s]));
  const skipped = Object.keys(CONSTRAINTS).filter((c) => !combined.has(c));
  return { statements: narrative.length, questions, round, additional, skipped, surprises, totalRounds: round + surprises.length };
}
```

#### Three Narratives of the Same Decision

The decision is singular: converting loan records from a nightly batch transfer to a continuous
stream. Narrative A carries the decision's technical rationale as it is and carries all six
constraints. Narrative B tells the same decision in outcome language. Narrative C keeps B's
language and puts the constraints B eliminates back in as plain sentences.

```js
// stakeholder/narrative.mjs — three narratives of the same decision run through the same stakeholder model
import { CONSTRAINTS, STAKEHOLDERS, runNarrative, meeting } from "./model.mjs";

// A: technical-terminology narrative. Carries all six constraints, never mentions cost.
const A = [
  { terms: ["event stream", "queue", "backpressure"], concerns: ["service outage"], constraint: ["C6"] },
  { terms: ["double write", "transition window", "report"], concerns: [], constraint: ["C1", "C3"] },
  { terms: ["version pinning", "risk", "duration"], concerns: ["reversal"], constraint: ["C4"] },
  { terms: ["endpoint", "schema", "contract", "provider"], concerns: ["contract"], constraint: ["C5"] },
  { terms: ["client interface", "staff", "branch"], concerns: ["training"], constraint: ["C2"] },
];

// B: outcome-focused narrative. Addresses every concern, terms are in the organization's own language, eliminates constraints.
const B = [
  { terms: ["reader", "duration", "service outage"], concerns: ["service outage"], constraint: ["C6"] },
  { terms: ["cost", "staff"], concerns: ["cost"], constraint: [] },
  { terms: ["duration", "risk"], concerns: ["reversal"], constraint: ["C4"] },
  { terms: ["branch", "staff", "training"], concerns: ["training"], constraint: [] },
  { terms: ["contract", "provider"], concerns: ["contract"], constraint: [] },
];

// C: outcome-focused narrative + the eliminated constraints put back in plain language.
const C = [...B,
  { terms: ["report", "duration"], concerns: [], constraint: ["C1"] },
  { terms: ["branch", "staff", "training"], concerns: [], constraint: ["C2"] },
  { terms: ["duration", "risk", "cost"], concerns: [], constraint: ["C3"] },
  { terms: ["contract", "provider"], concerns: [], constraint: ["C5"] },
];

console.log(`stakeholders ${STAKEHOLDERS.length}, constraints ${Object.keys(CONSTRAINTS).length}, decisions 1\n`);
console.log("narrative  statements  questions  approval rounds  additional  constraints carried  surprises  total rounds");
console.log("--------- ----------- ---------- ---------------- ----------- -------------------- ---------- ------------");
const result = {};
for (const [name, narrative] of [["A", A], ["B", B], ["C", C]]) {
  const r = result[name] = runNarrative(narrative);
  console.log(`${name.padStart(9)} ${String(r.statements).padStart(11)} ${String(r.questions).padStart(10)} ` +
    `${String(r.round).padStart(16)} ${String(r.additional).padStart(11)} ` +
    `${`${6 - r.skipped.length}/6`.padStart(20)} ${String(r.surprises.length).padStart(10)} ` +
    `${String(r.totalRounds).padStart(12)}`);
}
console.log(`\nconstraints B skips: ${result.B.skipped.join(", ")}`);
console.log(`constraints that become surprises in B: ${result.B.surprises.join(", ")}`);
console.log(`constraints A skips: ${result.A.skipped.length === 0 ? "none" : result.A.skipped.join(", ")}`);
console.log(`constraints C skips: ${result.C.skipped.length === 0 ? "none" : result.C.skipped.join(", ")}`);

console.log(`\nper stakeholder (narrative A):`);
console.log("stakeholder           questions  rounds  additional");
for (const s of STAKEHOLDERS) {
  const r = meeting(s, A);
  console.log(`${s.name.padEnd(20)} ${String(r.questions).padStart(9)} ${String(r.round).padStart(6)} ` +
    `${String(r.additional).padStart(11)}`);
}

console.log(`\nA's cost depends on the listener (technical terms added to the stakeholder vocabulary):`);
console.log("terms added  questions  rounds");
const technical = ["event stream", "queue", "backpressure", "double write", "transition window",
  "version pinning", "endpoint", "schema", "client interface"];
for (const n of [0, 3, 6, 9]) {
  const r = runNarrative(A, technical.slice(0, n));
  console.log(`${String(n).padStart(11)} ${String(r.questions).padStart(10)} ${String(r.round).padStart(7)}`);
}
```

```
stakeholders 3, constraints 6, decisions 1

narrative  statements  questions  approval rounds  additional  constraints carried  surprises  total rounds
--------- ----------- ---------- ---------------- ----------- -------------------- ---------- ------------
        A           5         27                8           2                  6/6          0            8
        B           5          0                3           0                  2/6          4            7
        C           9          0                3           0                  6/6          0            3

constraints B skips: C1, C2, C3, C5
constraints that become surprises in B: C1, C2, C3, C5
constraints A skips: none
constraints C skips: none

per stakeholder (narrative A):
stakeholder           questions  rounds  additional
budget owner                 9      3           1
service owner                9      2           0
procurement owner            9      3           1

A's cost depends on the listener (technical terms added to the stakeholder vocabulary):
terms added  questions  rounds
          0         27       8
          3         18       8
          6          9       8
          9          0       5
```

All the numbers belong to the **computation** class: they were counted on a deterministic model.

#### The Cost of Simplification

The technical narrative produces **27 questions** for a single decision, and approval from the
three stakeholders arrives in **8 rounds**. The outcome-focused narrative gets the same decision
approved in **0 questions** and **3 rounds**. If the story stopped at this table, the conclusion
would be obvious: outcome language cuts the round count to a third.

The constraint column breaks this conclusion. Narrative B carries **two** of the six constraints;
C1, C2, C3, and C5 stay outside the narrative. These are not decoration: the nightly report is
produced fifteen minutes late, staff take training once, old records get double-written for two
weeks, and the contract does not change. All four are real consequences of the decision, and all
four show up eventually. In the model this counts as **4 surprises**, and every surprise adds one
more round: B's total is not 3 but **7**.

The real number here is that the outcome-focused narrative's total gain is from 8 rounds to 7
rounds — that is, **a single round**. Simplification lowers the conversation's cost up front and
loads it back on at the end. Narrative B is not a wrong narrative; it is an **incomplete** one, and
its incompleteness is measurable.

Narrative C shows this is fixable. Without breaking B's language at all, it puts the four
eliminated constraints back in as four sentences written in the same organizational language. The
cost is **9 statements** instead of 5 — four more sentences to write. The gain: 0 questions, 0
surprises, **3 rounds** total. The same decision gets approved in roughly a third of A's rounds,
and less than half of B's. The cost of carrying a constraint is paid in writing, not in meetings.

#### Term Mismatch and Concern Mismatch

The last table shows the technical narrative's 27 questions are not a property of the decision. As
nine technical terms are added to the stakeholders' vocabulary, the question count drops from 27
to 18, to 9, and to 0. The same decision, told in the same sentences, produces no questions at
all once told to a counterpart who knows the technical terms. A narrative's cost depends on **the
listener's vocabulary**.

The same table's second column draws the boundary of this. As the question count drops to zero,
the approval round count drops from 8 to only **5**, not to 3. The reason is that none of
narrative A's statements mention cost: cost is in the concern set of both the budget owner and
the procurement owner, and teaching terms does not close that gap. The model records this as **2
additional statements** — two sentences the architect ends up having to say later in the meeting.

These two failures are handled separately. **Term mismatch** is solved by translation: the same
content, in the counterpart's own vocabulary. **Concern mismatch** is not solved by translation;
the narrative needs a new statement that addresses that concern. A narrative being perfectly
simplified does not mean it has answered the stakeholder's question.

One consequence of this: the per-stakeholder question counts are equal (9, 9, 9) but the round
counts are not (3, 2, 3). The service owner grants approval in two rounds because both items in
its concern set are already inside the narrative. The difference does not come from the terms; it
comes from **who is asking about what**.

#### Summary

- A stakeholder is modeled as a vocabulary and a set of concerns; approval comes when no open
  question remains and every concern is addressed (AP10, AP11). This model was run on three
  stakeholders, six constraints, and a single decision.
- The technical-terminology narrative produced 27 questions and 8 rounds per decision; the
  outcome-focused narrative produced 0 questions and 3 rounds. Taken alone, this comparison shows
  outcome language at a third of the cost.
- The outcome-focused narrative eliminated four of the six constraints (C1, C2, C3, C5), and all
  four turned into surprises; its total became 7 rounds, not 3. The real gain is 7 rounds against
  8, that is, a single round.
- The narrative that puts the constraints back in plain language gave 0 questions, 0 surprises,
  and **3 rounds** in exchange for writing four more statements: simplification's loss can be
  recovered without breaking the language.
- The technical narrative's 27 questions depend on the listener's vocabulary and drop to 0 once
  nine terms are taught; but the round count drops from 8 to only 5. Term mismatch is solved by
  translation, concern mismatch by a new statement.

#### Next Step

This lesson measured the decision's narrative; it held the decision's **content** fixed. The
approved decision was a flow change, and behind it was a choice still to make: which tool would
carry that flow. The model has not made that choice yet. The next lesson lays out candidates and
asks: how does a tool's maturity and the ecosystem around it turn into a number, and if the
choice turns out wrong, what does leaving that tool cost in modules, records, and lines?

### Technology Evaluation

Source: https://academia.sh/en/courses/architect-role/technology-evaluation

Weighting candidate tools by maturity, ecosystem, and exit-cost metrics: a twelve-module source tree is written to disk and scanned, measuring exit cost between 53.0 and 60.5 hours; built-in capability drives total lines from 127 down to 73 while raising exit cost; the winner changes with the weight set; and a wrapper cuts the code share of exit cost from 33.5 hours to 4.0 hours while never reducing the data share at all.

The previous lesson measured how a decision would be narrated and held the decision's content
fixed: loan records would move to a continuous stream. Which tool would carry that stream was not
chosen. In the regional library network's model, this choice is made once and lives a long time;
the loan services are written in-house, but the tool carrying the stream comes from outside.

This lesson does not name candidates by product. Writing a product name would date this lesson
within five years; instead, the candidates are modeled with **descriptive features** — A, B, and
C, each a maturity profile, an ecosystem profile, and a built-in capability set. Three things get
measured: **maturity**, ecosystem, and **exit cost**. The third stands apart from the others,
because it alone can be read directly from real code.

#### Exit Cost Is Read from the Source Tree

Maturity and ecosystem are properties belonging to the candidate itself and are given from
outside. Exit cost is not: it is not a property of the candidate, but of **how the candidate
enters our own code**. That is why it can be measured.

The module set below is actually written to disk and then scanned. Numbers are read from the
files. The loan service's model has twelve modules, and each one needs a subset of seven
capabilities. If a candidate provides a needed capability **natively**, the module links to that
candidate; if it does not, the module **hand-writes** that capability and does not link to the
candidate.

**AP13: exit cost = link lines × 0.5 hours + linked modules × 2 hours + records to migrate /
50,000.** The three terms count separate jobs: rewriting the call, retesting the module, and
moving the data. Records to migrate is 1,200,000 in the model, and it is the same no matter which
candidate is chosen.

**AP14: the candidates' age, breaking-release frequency, open-defect closure time, adopter
count, documented-function ratio, and independent-implementation count are model values**; none
is taken from a real tool, and they exist to show how the metrics get weighted.

```js
// evaluation/code.mjs — model measuring candidate selection on real code. The three candidates
// are not a real product, they are a descriptive feature set. The module set is actually written
// to disk, and exit cost is measured by scanning the written files; numbers are read from files, never typed by hand.
import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";

export const CAPABILITIES = ["publish", "subscribe", "batch", "transaction", "delay", "replay", "deadletter"];

// The twelve modules in the loan service's model and the capabilities each one needs.
export const MODULES = {
  "api": ["publish", "subscribe"],
  "renewal": ["publish"],
  "overdue": ["publish", "delay"],
  "reservation": ["publish", "subscribe", "transaction"],
  "notification": ["subscribe", "delay", "deadletter"],
  "report": ["subscribe", "batch"],
  "branch-transfer": ["publish", "transaction"],
  "penalty": ["publish", "deadletter"],
  "membership": ["subscribe"],
  "catalog-bridge": ["publish", "subscribe", "replay"],
  "log": ["batch", "replay"],
  "metrics": ["subscribe", "batch"],
};

// AP14: candidates are known only by their properties. "support" is what a candidate provides
// natively; every capability it does not provide is hand-written in the module.
export const CANDIDATES = {
  A: { support: ["publish", "subscribe"], age: 11, breakingReleaseFreq: 0.4, defectCloseDays: 21,
       adopters: 9, documentedRatio: 0.95, independentImpls: 3 },
  B: { support: ["publish", "subscribe", "batch", "deadletter"], age: 6, breakingReleaseFreq: 1.1, defectCloseDays: 9,
       adopters: 38, documentedRatio: 0.92, independentImpls: 1 },
  C: { support: CAPABILITIES, age: 2, breakingReleaseFreq: 3.0, defectCloseDays: 4,
       adopters: 41, documentedRatio: 0.55, independentImpls: 1 },
};

// Produces one module as a real source file.
function moduleText(name, needed, support, clientPath, wrapperPath = null) {
  const native = needed.filter((c) => support.includes(c));
  const manual = needed.filter((c) => !support.includes(c));
  const s = [];
  if (native.length > 0) s.push(`import { client } from "${wrapperPath ?? clientPath}";`);
  for (const c of manual) {
    s.push(`function manual_${c}(records) {`);
    s.push(`  const queue = [];`);
    s.push(`  for (const r of records) queue.push({ name: "${c}", r });`);
    s.push(`  return queue;`);
    s.push(`}`);
  }
  s.push(`export async function ${name.replace(/-/g, "_")}(records) {`);
  for (const c of native) s.push(`  await client.${c}(records);`);
  for (const c of manual) s.push(`  manual_${c}(records);`);
  s.push(`  return records.length;`);
  s.push(`}`);
  return s.join("\n") + "\n";
}

// Wrapper: exposes only the capability set it is given. Writes call forwarding and error
// translation for each capability.
function wrapperText(scope) {
  const s = [`import { client as raw } from "../candidate/client.mjs";`, `export const client = {`];
  for (const c of scope) {
    s.push(`  ${c}: async (records) => {`);
    s.push(`    try { return await raw.${c}(records); }`);
    s.push(`    catch (e) { throw new Error("message broker error: " + e.message); }`);
    s.push(`  },`);
  }
  s.push(`};`);
  return s.join("\n") + "\n";
}

// The produced files are scanned: the modules linked to the candidate's client and the linking lines are counted.
function scan(root, moduleNames, extraFiles = []) {
  let linkedModules = 0, linkLines = 0, totalLines = 0, manualFuncs = 0;
  const files = [...moduleNames.map((a) => `${root}/${a}.mjs`), ...extraFiles];
  for (const path of files) {
    const lines = readFileSync(path, "utf8").trimEnd().split("\n");
    totalLines += lines.length;
    const raw = lines.some((s) => /from "\.\.\/candidate\/client\.mjs"/.test(s));
    if (raw) linkedModules += 1;
    if (raw) linkLines += lines.filter((s) => /(client|raw)\.\w+\(/.test(s)).length;
    manualFuncs += lines.filter((s) => /^function manual_/.test(s)).length;
  }
  return { linkedModules, linkLines, totalLines, manualFuncs };
}

// Produces and measures a candidate's direct use.
export function direct(root, candidate) {
  rmSync(root, { recursive: true, force: true });
  mkdirSync(`${root}/candidate`, { recursive: true });
  mkdirSync(`${root}/loan`, { recursive: true });
  writeFileSync(`${root}/candidate/client.mjs`, `export const client = {};\n`);
  for (const [name, needed] of Object.entries(MODULES))
    writeFileSync(`${root}/loan/${name}.mjs`, moduleText(name, needed, candidate.support, "../candidate/client.mjs"));
  return scan(`${root}/loan`, Object.keys(MODULES));
}

// The same candidate used behind a wrapper. A module that needs a capability the wrapper does not
// cover has to link straight to the client: that is the leak.
export function wrapped(root, candidate, scope) {
  rmSync(root, { recursive: true, force: true });
  mkdirSync(`${root}/candidate`, { recursive: true });
  mkdirSync(`${root}/shared`, { recursive: true });
  mkdirSync(`${root}/loan`, { recursive: true });
  writeFileSync(`${root}/candidate/client.mjs`, `export const client = {};\n`);
  writeFileSync(`${root}/shared/wrapper.mjs`, wrapperText(scope));
  let leaked = 0;
  for (const [name, needed] of Object.entries(MODULES)) {
    const native = needed.filter((c) => candidate.support.includes(c));
    const outside = native.filter((c) => !scope.includes(c));
    if (outside.length > 0) leaked += 1;
    const path = outside.length > 0 ? "../candidate/client.mjs" : "../shared/wrapper.mjs";
    writeFileSync(`${root}/loan/${name}.mjs`,
      moduleText(name, needed, candidate.support, "../candidate/client.mjs", path));
  }
  const wrapperLines = readFileSync(`${root}/shared/wrapper.mjs`, "utf8").trimEnd().split("\n").length;
  return { ...scan(`${root}/loan`, Object.keys(MODULES), [`${root}/shared/wrapper.mjs`]),
    leaked, wrapperLines };
}

// AP13: exit cost = linking call line x 0.5 hours + linked module x 2 hours testing
// + records to migrate / 50,000 hours. The data share is independent of the candidate.
export const RECORDS_TO_MIGRATE = 1_200_000;
export const exitCost = (o) =>
  o.linkLines * 0.5 + o.linkedModules * 2 + RECORDS_TO_MIGRATE / 50_000;
```

#### Weight Determines the Winner

The metrics are in different units: years, days, counts, ratios, hours. For them to be summable,
all are squeezed between zero and one, and a high value always means good. Everything after that
is a weighting decision, and a weight is not a technical number — it is a declaration of
preference.

```js
// evaluation/selection.mjs — maturity, ecosystem, and exit cost are weighted
import { CANDIDATES, MODULES, CAPABILITIES, direct, wrapped, exitCost, RECORDS_TO_MIGRATE }
  from "./code.mjs";

const ROOT = "./generated";
const measured = {};
for (const [name, candidate] of Object.entries(CANDIDATES)) measured[name] = direct(`${ROOT}/${name}`, candidate);

console.log(`modules ${Object.keys(MODULES).length}, capabilities ${CAPABILITIES.length}, records to migrate ${RECORDS_TO_MIGRATE}\n`);
console.log("candidate  support  linked modules  link lines  handwritten funcs  total lines  exit (hours)");
console.log("--------- -------- --------------- ----------- ------------------ ------------ -------------");
for (const [name, candidate] of Object.entries(CANDIDATES)) {
  const o = measured[name];
  console.log(`${name.padStart(9)} ${`${candidate.support.length}/7`.padStart(8)} ${String(o.linkedModules).padStart(15)} ` +
    `${String(o.linkLines).padStart(11)} ${String(o.manualFuncs).padStart(18)} ` +
    `${String(o.totalLines).padStart(12)} ${exitCost(o).toFixed(1).padStart(13)}`);
}

// Every metric is squeezed into the 0-1 range; high always means good.
const metric = {
  age: (a) => Math.min(1, a.age / 10),
  breakingReleaseFreq: (a) => 1 / (1 + a.breakingReleaseFreq),
  defectCloseDays: (a) => 1 / (1 + a.defectCloseDays / 10),
  independentImpls: (a) => Math.min(1, a.independentImpls / 3),
  adopters: (a) => Math.min(1, a.adopters / 40),
  documentedRatio: (a) => a.documentedRatio,
  coverage: (a) => a.support.length / CAPABILITIES.length,
  exit: (a, name) => 1 / (1 + exitCost(measured[name]) / 50),
  fewLines: (a, name) => 1 / (1 + measured[name].totalLines / 100),
};

const unit = Object.fromEntries(Object.keys(metric).map((k) => [k, 1]));
const WEIGHTS = {
  "maturity-weighted": { ...unit, age: 3, breakingReleaseFreq: 3, defectCloseDays: 3, independentImpls: 3 },
  "ecosystem-weighted": { ...unit, adopters: 3, documentedRatio: 3, coverage: 3 },
  "exit-weighted": { ...unit, exit: 6 },
  "development-weighted": { ...unit, coverage: 3, fewLines: 4 },
  "equal": { ...unit },
};

const score = (name, w) => {
  let t = 0, sum = 0;
  for (const [k, f] of Object.entries(metric)) { t += w[k] * f(CANDIDATES[name], name); sum += w[k]; }
  return t / sum;
};

console.log(`\nweight set               A       B       C   winner   margin`);
console.log(`--------------------- ------- ------- ------- ------- ------`);
for (const [name, w] of Object.entries(WEIGHTS)) {
  const p = Object.fromEntries(Object.keys(CANDIDATES).map((k) => [k, score(k, w)]));
  const s = Object.entries(p).sort((x, y) => y[1] - x[1]);
  console.log(`${name.padEnd(21)} ${p.A.toFixed(4).padStart(7)} ${p.B.toFixed(4).padStart(7)} ` +
    `${p.C.toFixed(4).padStart(7)} ${s[0][0].padStart(7)} ${(s[0][1] - s[1][1]).toFixed(4).padStart(6)}`);
}

// The wrapper can cover the candidates' common intersection; every capability outside the intersection is a leak.
const intersection = CAPABILITIES.filter((c) => Object.values(CANDIDATES).every((a) => a.support.includes(c)));
console.log(`\ncandidates' shared capability intersection: ${intersection.join(", ")} (${intersection.length}/7)`);
console.log(`\nwrapper scope if candidate B is chosen:`);
console.log("scope                                 wrapper lines  leaked modules  linked modules  link lines  exit (hours)  decrease");
console.log("--------------------------------------- -------------- --------------- --------------- ----------- ------------- --------");
const raw = exitCost(measured.B);
for (const scope of [intersection, ["publish", "subscribe", "batch"], CANDIDATES.B.support]) {
  const o = wrapped(`${ROOT}/wrapper`, CANDIDATES.B, scope);
  const m = exitCost(o);
  console.log(`${scope.join(",").padEnd(39)} ${String(o.wrapperLines).padStart(14)} ` +
    `${String(o.leaked).padStart(15)} ${String(o.linkedModules).padStart(15)} ${String(o.linkLines).padStart(11)} ` +
    `${m.toFixed(1).padStart(13)} ${`${((1 - m / raw) * 100).toFixed(1)}%`.padStart(8)}`);
}
const full = wrapped(`${ROOT}/wrapper`, CANDIDATES.B, CANDIDATES.B.support);
const data = RECORDS_TO_MIGRATE / 50_000;
const gain = raw - exitCost(full);
console.log(`\nexit for B: ${raw.toFixed(1)} hours unwrapped, ${exitCost(full).toFixed(1)} hours with a full-scope wrapper`);
console.log(`code share ${(raw - data).toFixed(1)} -> ${(exitCost(full) - data).toFixed(1)} hours; data share ${data.toFixed(1)} hours the same in both cases`);
console.log(`AP15: the wrapper's ${full.wrapperLines} lines require ${(full.wrapperLines * 0.1).toFixed(1)} hours of maintenance per year`);
console.log(`break-even switch probability = annual maintenance / gain = ${(full.wrapperLines * 0.1 / gain).toFixed(3)}`);
const missing = CAPABILITIES.filter((c) => !CANDIDATES.A.support.includes(c));
console.log(`had A been chosen, ${measured.A.manualFuncs} hand-written functions would have been needed; the same functions would be written ${missing.length} times behind a wrapper`);
```

```
modules 12, capabilities 7, records to migrate 1200000

candidate  support  linked modules  link lines  handwritten funcs  total lines  exit (hours)
--------- -------- --------------- ----------- ------------------ ------------ -------------
        A      2/7              11          14                 11          127          53.0
        B      4/7              12          19                  6          103          57.5
        C      7/7              12          25                  0           73          60.5

weight set               A       B       C   winner   margin
--------------------- ------- ------- ------- ------- ------
maturity-weighted      0.6763  0.5416  0.4749       A 0.1347
ecosystem-weighted     0.5563  0.6812  0.6785       B 0.0026
exit-weighted          0.5608  0.5472  0.5243       A 0.0136
development-weighted   0.5226  0.5683  0.6294       C 0.0612
equal                  0.6026  0.5928  0.5642       A 0.0098

candidates' shared capability intersection: publish, subscribe (2/7)

wrapper scope if candidate B is chosen:
scope                                 wrapper lines  leaked modules  linked modules  link lines  exit (hours)  decrease
--------------------------------------- -------------- --------------- --------------- ----------- ------------- --------
publish,subscribe                                   11               5               6          11          41.5    27.8%
publish,subscribe,batch                             15               2               3           7          33.5    41.7%
publish,subscribe,batch,deadletter                  19               0               1           4          28.0    51.3%

exit for B: 57.5 hours unwrapped, 28.0 hours with a full-scope wrapper
code share 33.5 -> 4.0 hours; data share 24.0 hours the same in both cases
AP15: the wrapper's 19 lines require 1.9 hours of maintenance per year
break-even switch probability = annual maintenance / gain = 0.064
had A been chosen, 11 hand-written functions would have been needed; the same functions would be written 5 times behind a wrapper
```

Every number in the first table is read from files written to disk; the second table is a
computation that takes those numbers as input.

#### Built-in Capability Is Both a Saving and a Bond

The first table shows a single trend running in two directions at once. As built-in capability
rises from 2/7 to 7/7, total lines drop from **127 to 73** (a 42 percent decrease) and
hand-written functions drop from **11 to 0**. In the same direction, exit cost rises from **53.0
hours to 60.5 hours** and link lines climb from 14 to 25.

Two columns are counting the same event. Every capability a candidate provides is both a
function not written **and** a bond established. With the richest candidate, no code gets
written at all; on the way out, twenty-five lines remain to be rewritten. With the narrowest
candidate, eleven functions get hand-written; those functions **travel with you** when you
leave, because they are not linked to anything. A rich tool does not leave an expensive exit
because it speeds up development — it leaves one in exact proportion to how much it does.

#### The Winner Depends on the Weights

The second table has five weight sets and **three different winners**. When maturity is
weighted, A wins; when ecosystem is weighted, B wins; when development load is weighted, C wins.
The candidates did not change; only the metrics' multipliers changed.

The margin column carries a second warning. In the ecosystem-weighted row, B beats C by a margin
of **0.0026**; in the maturity-weighted row, the margin is 0.1347 — more than fifty times as
much. In the first row, the winner is a result; in the second, it is a rounding remainder. An
evaluation table that produces only a single winner's name is incomplete; it has to write down
the margin too, because a small margin means defining one metric slightly differently would
change the winner.

In the exit-weighted row, the winner does not change. The reason shows up in the last section:
most of exit cost is independent of the candidate.

#### What the Wrapper Costs

The third table measures the **wrapper** layer put in place to cheapen the exit if the choice
turns out wrong. A wrapper exposes only the capability it is given; a module that needs a
capability outside that scope has to link straight to the tool. This is a **leak**, and it is
countable.

The three candidates' shared capability intersection is 2/7. If the wrapper is meant to be
genuinely portable, its scope can be at most this intersection — and at that scope, **5 modules
leak**, and exit cost drops from 57.5 hours to only 41.5 hours (27.8 percent). When the scope is
widened to the chosen candidate's own capability set, the leak drops to zero and exit falls to
28.0 hours (51.3 percent) — but now, because the wrapper exposes the chosen candidate's
capabilities, switching to a different candidate requires hand-writing those capabilities behind
the wrapper. The last row measures this: using a narrow candidate directly needs 11 hand-written
functions; the same functions get written **5 times** behind a wrapper.

The real limit is in the separation between the code share and the data share. The wrapper cuts
the code share from **33.5 hours to 4.0 hours**; the data share, **24.0 hours**, stays the same
in both cases, because 1,200,000 records will need to move no matter which tool is switched to.
The most the wrapper can reduce is about half of the total exit cost.

The cost itself is also a number. **AP15: every line of the wrapper needs 0.1 hours of
maintenance per year.** Nineteen lines comes to 1.9 hours a year; the gain is 29.5 hours. The
wrapper pays for itself if the annual probability of switching tools is greater than **0.064**.
This threshold ties whether the wrapper gets written not to a guess, but to a comparison.

#### Summary

- A candidate is described by maturity and ecosystem metrics, not by product name; exit cost is
  a property of how the candidate enters the code, not of the candidate itself, and is measured
  by writing a twelve-module source tree to disk and scanning it (AP13, AP14).
- As built-in capability rises from 2/7 to 7/7, total lines drop from 127 to 73 and hand-written
  functions from 11 to 0; at the same time, exit cost rises from 53.0 hours to 60.5 hours. Every
  capability provided is both a function not written and a bond established.
- Five weight sets produced three different winners. The winner is a result of the weights, not
  the candidates; the margin is 0.0026 in the ecosystem-weighted row and 0.1347 in the
  maturity-weighted row — the first margin is not a result, and the table must write down the
  margin alongside the winner.
- The wrapper's scope is limited by the candidates' shared intersection (2/7); at that scope, 5
  modules leak and exit drops by only 27.8 percent. Full scope zeroes out the leak but binds the
  wrapper to the chosen candidate.
- The wrapper cuts exit's code share from 33.5 hours to 4.0 hours and never reduces the
  24.0-hour data share at all; measured against a nineteen-line maintenance load, it pays for
  itself if the annual switch probability is greater than 0.064 (AP15).

#### Next Step

Every number in this lesson depends on a moment in time. A candidate's age, breaking-release
frequency, adopter count, and documented-function ratio are true on the day they are measured; the
weights, too, reflect that day's priorities. The decision gets made with this table, and the
table stays put — but the numbers it rests on do not. The next lesson, the course's last, turns
this into a model: a decision's basis has a validity period, a decision resting on a stale basis
has a rate of turning out wrong, and the share allocated to learning lowers that rate. Its
question is: if knowledge freshness is not a virtue but a budget allocation, how does that budget
get allocated?

### Continuous Learning Discipline

Source: https://academia.sh/en/courses/architect-role/continuous-learning-discipline

Measuring knowledge freshness as a budget allocation: tying a decision's basis to its validity period, counting the rate of decisions turning out wrong when resting on a stale basis over a 72-month run, and showing the same annual 72 hours gives a rate of 0.3958, 0.3715, and 0.2222 when distributed equally, by staleness, and stalest-first.

The previous lesson built a candidate table and produced a winner. Every cell of that table was
true on the day it was measured: the candidate's age, breaking-release frequency, adopter count,
documented-function ratio. The decision was made, the table stays put. The numbers the table
rests on do not.

This holds for every decision this course has measured. A balancing decision's weights, a
simplification's accidental-complexity share, a cost of reversal — all of them come from a
measurement, and every measurement belongs to a date. This last lesson turns that into a model:
a decision's **basis** has a validity period, the probability of being wrong rises for a
decision resting on an expired basis, and the hours set aside for learning lower that
probability. Learning is not a virtue, it is a **budget allocation**; and the allocation itself
makes more difference than the size of the budget.

#### The Basis's Validity Period

In the model, every decision rests on three **bases**. A basis is a piece of knowledge, and it
ages according to its category.

**AP16: a basis's validity period depends on its category.** A number that comes from a
measurement stays valid for six months, a tool's maturity and ecosystem profile for eighteen
months, a structural principle for a hundred and twenty months. Refresh costs diverge too:
rerunning a measurement takes one hour, re-deriving a tool's profile takes four hours, reviewing
a structural principle takes twelve hours.

**AP17: the probability of being wrong sits at the floor while a basis is fresh, and rises as
its age exceeds the validity period**; once age reaches three times the lifespan, the
probability reaches one. The floor is 0.05, meaning even a fresh basis can be wrong. A decision
is correct only when all three of its bases turn out correct.

```js
// learning/model.mjs — process model of knowledge freshness. There is no real organization,
// tool, or person. Time is not measured; months, decisions, stale bases, and wrong decisions are
// counted. Randomness comes from a generator written in-house, and the seed is visible.

// AP16: a basis's validity period depends on its category (months).
export const CATEGORY = {
  measurement: { count: 30, lifespan: 6, refreshHours: 1 },
  tool: { count: 20, lifespan: 18, refreshHours: 4 },
  structure: { count: 10, lifespan: 120, refreshHours: 12 },
};

export const MONTHS = 72, DECISIONS_PER_MONTH = 4, BASES_PER_DECISION = 3, FLOOR = 0.05;

export function generator(seed) {
  let t = seed;
  return () => ((t = (t * 1103515245 + 12345) % 2147483648) / 2147483648);
}

export function bases(random) {
  const d = [];
  for (const [cat, k] of Object.entries(CATEGORY))
    for (let i = 0; i < k.count; i++) d.push({ category: cat, age: random() * k.lifespan });
  return d;
}

// AP17: the probability of being wrong is FLOOR when a basis is fresh and rises as age exceeds
// the validity period; it reaches 1 once age reaches three times the lifespan.
export const errorProbability = (d) => {
  const lifespan = CATEGORY[d.category].lifespan;
  return FLOOR + (1 - FLOOR) * Math.min(1, Math.max(0, (d.age - lifespan) / (2 * lifespan)));
};

// Distribution strategies: split the monthly budget across categories. Shares sum to 1.
export const DISTRIBUTION = {
  "equal": () => ({ measurement: 1 / 3, tool: 1 / 3, structure: 1 / 3 }),
  "by staleness": () => {
    const h = Object.fromEntries(Object.entries(CATEGORY).map(([a, k]) => [a, k.count / k.lifespan]));
    const t = Object.values(h).reduce((x, y) => x + y, 0);
    return Object.fromEntries(Object.entries(h).map(([a, v]) => [a, v / t]));
  },
  "measurement only": () => ({ measurement: 1, tool: 0, structure: 0 }),
  "structure only": () => ({ measurement: 0, tool: 0, structure: 1 }),
};

// Strategy with no category share: spends starting from the stalest basis.
const stalestFirst = (d, budget) => {
  let remaining = budget;
  for (const x of [...d].sort((a, b) =>
      b.age / CATEGORY[b.category].lifespan - a.age / CATEGORY[a.category].lifespan)) {
    const m = CATEGORY[x.category].refreshHours;
    if (m > remaining) continue;
    remaining -= m; x.age = 0;
  }
  return remaining;
};

// Strategy that spends by category share: within each category, the oldest basis is refreshed first.
const byShare = (d, budget, share, pool) => {
  for (const cat of Object.keys(CATEGORY)) {
    pool[cat] = (pool[cat] ?? 0) + budget * share[cat];
    const m = CATEGORY[cat].refreshHours;
    const sorted = d.filter((x) => x.category === cat).sort((a, b) => b.age - a.age);
    for (const x of sorted) {
      if (m > pool[cat]) break;
      pool[cat] -= m; x.age = 0;
    }
  }
};

export function run({ strategy, budget, seed = 20260801 }) {
  const random = generator(seed);
  const d = bases(random);
  const pool = {};
  let decisions = 0, wrong = 0, staleBasis = 0, usedBasis = 0;
  for (let month = 0; month < MONTHS; month++) {
    for (const x of d) x.age += 1;
    if (budget > 0 && strategy === "stalest first") stalestFirst(d, budget);
    else if (budget > 0) byShare(d, budget, DISTRIBUTION[strategy](), pool);
    for (let i = 0; i < DECISIONS_PER_MONTH; i++) {
      let correct = 1;
      for (let j = 0; j < BASES_PER_DECISION; j++) {
        const x = d[Math.floor(random() * d.length)];
        usedBasis += 1;
        if (x.age > CATEGORY[x.category].lifespan) staleBasis += 1;
        correct *= 1 - errorProbability(x);
      }
      decisions += 1;
      if (random() > correct) wrong += 1;
    }
  }
  return { decisions, wrong, rate: wrong / decisions, staleRate: staleBasis / usedBasis,
    annualHours: budget * 12 };
}
```

#### Same Budget, Different Distribution

Six hours a month is seventy-two hours a year. The run below distributes those hours five
different ways and counts the decisions that turn out wrong across sixty bases and 288
decisions.

```js
// learning/budget.mjs — the same hourly budget distributed differently changes the rate of wrong decisions
import { CATEGORY, MONTHS, DECISIONS_PER_MONTH, FLOOR, BASES_PER_DECISION, DISTRIBUTION, run } from "./model.mjs";

const BUDGET = 6;
console.log(`months ${MONTHS}, decisions/month ${DECISIONS_PER_MONTH}, total decisions ${MONTHS * DECISIONS_PER_MONTH}, seed 20260801`);
console.log(`bases: ${Object.entries(CATEGORY).map(([a, k]) => `${a} ${k.count} (lifespan ${k.lifespan} mo, refresh ${k.refreshHours} hr)`).join("; ")}\n`);

const noBudget = run({ strategy: "equal", budget: 0 });
console.log(`budget ${BUDGET} hours/month = ${BUDGET * 12} hours/year`);
console.log("strategy           measurement  tool  structure  stale basis  wrong decisions  rate    decrease");
console.log("----------------- ------------ ----- ---------- ------------ ---------------- ------ ----------");
console.log(`${"no budget".padEnd(17)} ${"-".padStart(12)} ${"-".padStart(5)} ${"-".padStart(10)} ` +
  `${noBudget.staleRate.toFixed(4).padStart(12)} ${String(noBudget.wrong).padStart(16)} ` +
  `${noBudget.rate.toFixed(4).padStart(6)} ${"-".padStart(10)}`);
for (const name of [...Object.keys(DISTRIBUTION), "stalest first"]) {
  const r = run({ strategy: name, budget: BUDGET });
  const p = DISTRIBUTION[name] ? DISTRIBUTION[name]() : null;
  const s = (k, w) => (p ? p[k].toFixed(2) : "-").padStart(w);
  console.log(`${name.padEnd(17)} ${s("measurement", 12)} ${s("tool", 5)} ${s("structure", 10)} ` +
    `${r.staleRate.toFixed(4).padStart(12)} ${String(r.wrong).padStart(16)} ` +
    `${r.rate.toFixed(4).padStart(6)} ${`${((1 - r.rate / noBudget.rate) * 100).toFixed(1)}%`.padStart(10)}`);
}

console.log(`\nas the budget grows (strategy: by staleness):`);
console.log("hr/month  hr/year  stale basis  wrong decisions  rate    error per hour");
console.log("-------- -------- ------------ ---------------- ------ ----------------");
let previousRate = noBudget.rate, previousHours = 0;
for (const b of [0, 2, 4, 6, 12, 24, 48]) {
  const r = run({ strategy: "by staleness", budget: b });
  const margin = b === 0 ? null : (previousRate - r.rate) / ((b - previousHours) * 12);
  console.log(`${String(b).padStart(8)} ${String(b * 12).padStart(8)} ${r.staleRate.toFixed(4).padStart(12)} ` +
    `${String(r.wrong).padStart(16)} ${r.rate.toFixed(4).padStart(6)} ` +
    `${(margin === null ? "-" : margin.toExponential(2)).padStart(16)}`);
  previousRate = r.rate; previousHours = b;
}

console.log(`\nseed sensitivity (budget ${BUDGET}, strategy: by staleness):`);
console.log("seed        rate");
for (const t of [20260801, 991, 4242]) {
  const r = run({ strategy: "by staleness", budget: BUDGET, seed: t });
  console.log(`${String(t).padStart(9)} ${r.rate.toFixed(4).padStart(9)}`);
}

console.log(`\nfloor: even if every basis is fresh, error per decision = ${(1 - (1 - FLOOR) ** BASES_PER_DECISION).toFixed(4)}`);
```

```
months 72, decisions/month 4, total decisions 288, seed 20260801
bases: measurement 30 (lifespan 6 mo, refresh 1 hr); tool 20 (lifespan 18 mo, refresh 4 hr); structure 10 (lifespan 120 mo, refresh 12 hr)

budget 6 hours/month = 72 hours/year
strategy           measurement  tool  structure  stale basis  wrong decisions  rate    decrease
----------------- ------------ ----- ---------- ------------ ---------------- ------ ----------
no budget                    -     -          -       0.8461              261 0.9063          -
equal                     0.33  0.33       0.33       0.4039              114 0.3958      56.3%
by staleness              0.81  0.18       0.01       0.2662              107 0.3715      59.0%
measurement only          1.00  0.00       0.00       0.3403              150 0.5208      42.5%
structure only            0.00  0.00       1.00       0.7778              260 0.9028       0.4%
stalest first                -     -          -       0.2940               64 0.2222      75.5%

as the budget grows (strategy: by staleness):
hr/month  hr/year  stale basis  wrong decisions  rate    error per hour
-------- -------- ------------ ---------------- ------ ----------------
       0        0       0.8461              261 0.9063                -
       2       24       0.6227              206 0.7153          7.96e-3
       4       48       0.4144              138 0.4792          9.84e-3
       6       72       0.2662              107 0.3715          4.48e-3
      12      144       0.2130               67 0.2326          1.93e-3
      24      288       0.0648               49 0.1701          4.34e-4
      48      576       0.0475               47 0.1632          2.41e-5

seed sensitivity (budget 6, strategy: by staleness):
seed        rate
 20260801    0.3715
      991    0.3854
     4242    0.3715

floor: even if every basis is fresh, error per decision = 0.1426
```

All these numbers belong to this run; the generator is written in-house and the seed is visible.
The seed-sensitivity rows show the rate moving between 0.3715 and 0.3854, meaning the
comparisons do not depend on the seed.

#### The Result of the Distribution

The first table shows that where the same seventy-two hours gets put can place the
wrong-decision rate anywhere between 0.9028 and 0.2222. The gap is more than fourfold, and the
budget never changed.

The sharpest row is `structure only`. When the entire budget is spent on structural principles,
the rate is 0.9028 — only 0.4 percent better than 0.9063 with no budget at all. Because
structural principles stay valid for a hundred and twenty months, they never go stale within a
seventy-two-month run in the first place; refreshing them does not save a single decision.
Learning's most respectable-looking form changes nothing measurable in this model.

The opposite extreme is not right either, on its own. `measurement only` puts the entire budget
on the fastest-aging category and lowers the rate to 0.5208 — worse than equal distribution's
0.3958. Completely neglecting one category lets every basis in it go stale.

The `by staleness` distribution derives its shares from each category's aging speed:
measurement 0.81, tool 0.18, structure 0.01. The result, 0.3715, beats equal distribution but
is not the best. **`stalest first`** does not compute a share at all; every month it spends its
budget starting from the basis that has most exceeded its validity period, and lowers the rate
to **0.2222** — 64 wrong decisions instead of 107.

A fixed share has to spend its hour in a category even once no stale basis is left to spend it
on there; `stalest first` keeps every basis in a single queue. There is a side measure too: the
stale-basis rate is 0.2662 with `by staleness` and 0.2940 with `stalest first` — **more** stale
bases remain, yet **fewer** wrong decisions result. Counting stale bases is not enough; how stale
they are has to be counted.

#### The Budget's Limit

The second table grows the budget. Going from 24 to 48 annual hours, the error gain per hour
rises from 7.96e-3 to 9.84e-3; after that it keeps falling, dropping to 2.41e-5 on the move from
288 to 576 hours. In that last step, 288 more hours a year get spent and the rate falls by only
0.0069.

The reason is written in the last row: the floor is 0.1426. Even if every basis is fresh, that is
the probability of a decision resting on three bases turning out wrong, because even a fresh
measurement can be mistaken. A learning budget cannot lower this floor; it can only cut the
excess that comes from staleness.

The learning decision thus comes down to two numbers: at what point the budget approaches the
floor, and by what rule the available budget is distributed. The second makes more difference —
at seventy-two hours, changing the distribution rule lowers the rate from 0.3958 to 0.2222,
while growing the budget from seventy-two to a hundred and forty-four hours only takes it from
0.3715 to 0.2326.

#### Summary

- A decision's basis is an object with a validity period; the period depends on the category
  (measurement 6, tool 18, structure 120 months), and as it is exceeded, the decision's
  probability of being wrong rises from the floor toward one (AP16, AP17).
- In the seventy-two-month run, the regime with no learning budget at all got 261 of 288
  decisions wrong (rate 0.9063); refreshing only structural principles with the same budget
  lowered the rate to 0.9028, a drop of only 0.4 percent.
- The same annual 72 hours gave 0.3958 (equal), 0.3715 (by staleness), and 0.2222 (stalest
  first), depending on distribution. Learning is a budget allocation, and the distribution rule
  makes more difference than the budget itself.
- The count of stale bases alone is misleading: the strategy that gave the best result left more
  stale bases (0.2940 against 0.2662) but lowered wrong decisions from 107 to 64.
- Growing the budget has diminishing returns and stops at a floor: 0.1426, the wrongness share
  remaining even when every basis is fresh. Past 288 annual hours, every additional hour spent
  returns 2.41e-5.

#### Course Wrap-Up

| Lesson | Modeled object | Measured quantity | Comparison result |
|---|---|---|---|
| Software Architecture Definition | Decision-module mapping and dependency closure | Cost of reversal (files touched) | Body-size ranking and cost-of-reversal ranking inverted in 31 of 55 pairs |
| Architecture Levels | Decision scope and deployment-unit mapping | Units covered, uninformed units, repeated decisions | The local scheme produced 47 uninformed units and 31 conflicting implementations; the centralized scheme produced 0 conflicts but 99 consults |
| The Architect's Responsibilities | Responsibility matrix and machine-checkable constraint | Rate of decisions that lose their correspondence in code | Compliance was 25 percent under the unsupervised scheme, 88 percent under the supervised scheme |
| The Architect and Developer Relationship | The decision-maker's known import graph versus the real graph | Rate of stillborn decisions and knowledge gap (edges) | With the hands-off architect, 8 of 18 decisions were stillborn versus 0 of 18 for the hands-on architect; knowledge gap was 13.8 edges against 3.1 |
| Consulting and Coaching | Decision-transfer mode and the pattern/edge-case distinction | Correct-application rate and gain per hour of transfer | Across 168 cases, directive gave 68 percent, written rationale 85 percent, working together 87 percent; gain per hour was 3.71 against 0.55 |
| Striking Balance | Alternative-quality table and weight vector | Number of weights that could change the winner but had no written source | Across 1,771 vectors, winner regions covered 17.7 percent and 68.4 percent; weights with no written source dropped from 1 to 0 |
| Simplifying | Module graph versus requirement graph | Essential and accidental complexity share | Simplification lowered the total from 37 to 26, while touched modules rose from 13 to 16 |
| Decision Making | Decision set and dependency set | Cost of reversal and wait time | The `always-long` policy gave 58.70 person-days and 120 days; the `by-breakeven` policy gave 38.70 person-days and 55 days |
| Stakeholder Management | Stakeholder vocabulary, concern set, and statement sequence | Questions answered, approval rounds, skipped constraints | The technical narrative: 27 questions and 8 rounds; the outcome-focused narrative: 0 questions but 7 total rounds with 4 surprises; the constraint-carrying narrative: 3 rounds |
| Technology Evaluation | Candidate feature set and a twelve-module source tree | Exit cost (hours), winner by weight | Five weight sets gave three different winners; the wrapper cut the code share from 33.5 hours to 4.0 hours, the 24.0-hour data share never changed |
| Continuous Learning Discipline | Basis age and validity period | Rate of decisions turning out wrong | The same annual 72 hours gave 0.3958, 0.3715, and 0.2222 depending on distribution; the no-budget regime gave 0.9063 |

The shared shape of these eleven rows is the course's rule: **a practice's value can only be
defended by a measured difference.** In no lesson did the sentence "the architect must be
balanced," "the architect must keep the design simple," or "the architect must communicate" count as a
conclusion. Every time, the practice's product was turned into a data structure, two forms of
applying it were run on the same input, and the difference between them was written down as a
number. If the difference cannot be measured, there is no practice left to defend.

Decisions were made throughout this course: balances were struck, alternatives were eliminated,
thresholds were derived, constraints were carried to stakeholders, candidates were weighted, and
bases were refreshed. All of them share one flaw. All of them live **in the decision-maker's
memory**. The weight vector's source, why the eliminated alternative was eliminated, which
breaking point the threshold came from, what break-even probability the wrapper was written
against — none of this was ever written into code. Six months later, there is nothing to hand
someone who asks "why is this the way it is." The next course, **Architectural Decisions and
Documentation**, starts from here.

