unitbob 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ import { readPacketIndex } from "./packets.js";
2
+ // Spec 37-3, criterion 1. How wide a branch's fan-out should be.
3
+ //
4
+ // The rule this replaces said there was no ceiling at all: "an agent re-reads
5
+ // its context every turn, so splitting the work never costs more than keeping it
6
+ // together." Half right, and the wrong half was load-bearing. An agent's cost is
7
+ // the sum of its context over its turns, so splitting pulls in two directions:
8
+ //
9
+ // - the opening context is bought once per worker and re-read every turn, so
10
+ // it multiplies with the width. 26,065 tokens on the bench of 2026-08-24,
11
+ // the same to within ±370 across fifteen workers.
12
+ // - each worker's conversation is shorter, and a conversation's cost grows
13
+ // with the square of its length, so this falls with the width.
14
+ //
15
+ // There is therefore a minimum, and it is neither end. Measured on that bench,
16
+ // against what the fifteen workers actually cost:
17
+ //
18
+ // workers 1 2 3 5 8 15 20
19
+ // input 51.2M 34.6M 29.9M 27.7M 28.8M 35.6M 41.3M
20
+ //
21
+ // Fifteen was 28% over the cheapest width. One worker — which is what "the work
22
+ // fits in one context" would have said, and what the first draft of this rule
23
+ // enforced — is 85% over it, and would have run a 216-turn worker into a
24
+ // 150-turn fuse. The floor is as expensive a mistake as the ceiling.
25
+ //
26
+ // See ai/specs/37-3-fan-out-by-workload/after-2026-08-24.md in the brain repo.
27
+ // What the optimum is not a function of. Productive turns per 1,000 tokens of
28
+ // source were 7.8 on the behavioral branch and 1.4 on the structural one of the
29
+ // same run — 5.6× apart — and per assigned id, 8× apart. Bytes measure how much
30
+ // there is to read, which turns out not to be what a worker spends its turns on.
31
+ // They stay here for the printout and for the record in `fan_out`; they do not
32
+ // set the width.
33
+ const BYTES_PER_TOKEN = 4;
34
+ const WRITTEN_PER_READ = 3;
35
+ // What it is a function of. A planned case is one intent the worker has to turn
36
+ // into a written example or Scenario, and its cost in turns is a property of the
37
+ // branch, not of the project: a Gherkin Scenario needs the World, a session, a
38
+ // fixture and an assertion; a structural example calls a method.
39
+ //
40
+ // Measured 2026-08-24: 35 behavioral cases over 126 productive turns, 91
41
+ // structural cases over 65.
42
+ const TURNS_PER_CASE = { behavioral: 3.6, structural: 0.7 };
43
+ // The optimum width is the branch's productive turns over this. It comes out of
44
+ // setting the derivative of the cost above to zero, which gives
45
+ // `sqrt(2·warmup·preamble/added + warmup²)` — 31.3 on the behavioral branch of
46
+ // that run and 41.7 on the structural one, near enough to each other that one
47
+ // number carries both and the flat bottom of the curve absorbs the difference.
48
+ const TURNS_PER_WORKER = 36;
49
+ // What a worker spends before it writes anything — reading its packets, its plan
50
+ // item and its seeded facts. Measured 2026-08-24: 176 warm-up turns over eight
51
+ // behavioral workers, 202 over seven structural ones. It is per worker and does
52
+ // not divide, which is half of why width costs; it is added back here so that
53
+ // the turns this prints are the whole conversation, the thing that meets the
54
+ // 150-turn fuse.
55
+ const WARMUP_TURNS = { behavioral: 22, structural: 28 };
56
+ // Every case ends up at the same handful of widths, so the rule has to be a band
57
+ // rather than a number: anywhere from three to eight workers cost within 10% of
58
+ // the cheapest on the measured run. What the band excludes is what actually
59
+ // costs — fifteen at one end, one at the other.
60
+ const NARROWEST = 0.5;
61
+ const WIDEST = 1.5;
62
+ // How wide a branch should be, from the cases its plan intends to write.
63
+ // Returns nothing for a branch this connector has no measured cost for: a rule
64
+ // with no measurement behind it must not refuse anybody's plan.
65
+ export function branchWidth(branch, plannedCases) {
66
+ const perCase = TURNS_PER_CASE[branch];
67
+ if (perCase === undefined || plannedCases <= 0)
68
+ return undefined;
69
+ const turns = plannedCases * perCase;
70
+ const workers = Math.max(1, Math.round(turns / TURNS_PER_WORKER));
71
+ return {
72
+ branch,
73
+ planned_cases: plannedCases,
74
+ turns_each: Math.round(turns / workers) + (WARMUP_TURNS[branch] ?? 0),
75
+ workers,
76
+ fewest: Math.max(1, Math.round(workers * NARROWEST)),
77
+ most: Math.max(1, Math.ceil(workers * WIDEST)),
78
+ };
79
+ }
80
+ // What each branch's source weighs. Kept because it is the honest answer to "how
81
+ // much is there", printed before the plan exists and recorded in `fan_out` — but
82
+ // it is not what decides the width. See TURNS_PER_CASE above.
83
+ //
84
+ // `taken` narrows the count to the ids a plan actually took, which matters since
85
+ // criterion 2 let the structural branch be narrowed too: the packets are built
86
+ // from the whole assignment, before anybody chose a scope.
87
+ export function branchWorkloads(projectRoot, taken) {
88
+ const index = readPacketIndex(projectRoot);
89
+ if (!index || index.targets.length === 0)
90
+ return [];
91
+ const measured = new Map();
92
+ const unmeasured = new Map();
93
+ for (const target of index.targets) {
94
+ const ids = taken?.get(target.branch);
95
+ if (taken && !ids?.has(target.id))
96
+ continue;
97
+ const size = sizeOf(target);
98
+ // A file too large to copy still has a path and a size, and it is the
99
+ // heaviest reading on the branch — counting it as nothing would let the
100
+ // biggest sources look like the smallest.
101
+ const file = target.packet ?? target.source_file;
102
+ if (file !== undefined && size !== undefined) {
103
+ const files = measured.get(target.branch) ?? new Map();
104
+ files.set(file, size);
105
+ measured.set(target.branch, files);
106
+ }
107
+ else {
108
+ unmeasured.set(target.branch, (unmeasured.get(target.branch) ?? 0) + 1);
109
+ }
110
+ }
111
+ const branches = new Set([...measured.keys(), ...unmeasured.keys()]);
112
+ return [...branches].sort().flatMap((branch) => {
113
+ const files = measured.get(branch);
114
+ if (!files || files.size === 0)
115
+ return [];
116
+ const bytes = [...files.values()].reduce((sum, size) => sum + size, 0);
117
+ const missing = unmeasured.get(branch) ?? 0;
118
+ const withMissing = bytes + Math.round((bytes / files.size) * missing);
119
+ const read_tokens = Math.round(withMissing / BYTES_PER_TOKEN);
120
+ return [{
121
+ branch, files: files.size, bytes, unmeasured: missing,
122
+ read_tokens, work_tokens: read_tokens * (1 + WRITTEN_PER_READ),
123
+ }];
124
+ });
125
+ }
126
+ // The index is a file on the vibecoder's disk, so a size that is not a real byte
127
+ // count is treated as a size we do not have rather than as zero. Zero would
128
+ // quietly shrink the branch's average.
129
+ function sizeOf(target) {
130
+ const { bytes } = target;
131
+ return typeof bytes === 'number' && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
132
+ }
133
+ export function widthLine(width) {
134
+ return (` ${width.branch} — ${width.planned_cases} planned ` +
135
+ `${width.planned_cases === 1 ? 'case' : 'cases'}: ${width.workers} ` +
136
+ `${width.workers === 1 ? 'worker' : 'workers'} of about ${width.turns_each} turns each ` +
137
+ `(${width.fewest}–${width.most} accepted).\n`);
138
+ }
139
+ export function workloadLine(load) {
140
+ const missing = load.unmeasured === 0
141
+ ? ''
142
+ : ` plus ${load.unmeasured} ${load.unmeasured === 1 ? 'entrypoint' : 'entrypoints'} nothing resolved, ` +
143
+ 'priced at what the others average';
144
+ return (` ${load.branch} — ${load.files} ${load.files === 1 ? 'file' : 'files'}, ` +
145
+ `${load.bytes.toLocaleString('en-US')} bytes${missing}: ` +
146
+ `${load.read_tokens.toLocaleString('en-US')} tokens to read and about ` +
147
+ `${(load.work_tokens - load.read_tokens).toLocaleString('en-US')} to write.\n`);
148
+ }
@@ -84,6 +84,11 @@ export function writeSuitePackets(projectRoot, request) {
84
84
  if (bytes === undefined) {
85
85
  const refusal = refused.get(sourceFile);
86
86
  target.note = refusal.note;
87
+ // The size travels even when the contents do not, so spec 37-3 can weigh
88
+ // this entrypoint. `packet` stays unset: there is still nothing to open
89
+ // under the packets folder, and every reader keys off that, not off size.
90
+ if (refusal.bytes !== undefined)
91
+ target.bytes = refusal.bytes;
87
92
  count(notes, refusal.kind);
88
93
  continue;
89
94
  }
@@ -292,6 +297,7 @@ function copyPacket(projectRoot, sourceFile) {
292
297
  return {
293
298
  note: `${sourceFile} is ${stat.size.toLocaleString('en-US')} bytes, over the ${MAX_PACKET_BYTES.toLocaleString('en-US')}-byte packet fuse — open it at that path instead`,
294
299
  kind: 'over the packet fuse — the path travels instead',
300
+ bytes: stat.size,
295
301
  };
296
302
  }
297
303
  // The bytes we actually wrote, not the size we saw a moment ago.
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
3
3
  import { dirname, join } from 'node:path';
4
4
  import { detectStructuralRunner } from "../runner/precheck.js";
5
5
  import { assertUnitbobPath } from "./artifactPath.js";
6
+ import { branchWidth } from "./fanOut.js";
6
7
  export function workerPlanPath(projectRoot) {
7
8
  return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
8
9
  }
@@ -215,6 +216,11 @@ export function validateWorkerPlanFiles(projectRoot) {
215
216
  // The neighbours stay for the opposite reason: naming a capability the request
216
217
  // never assigned, or naming one twice, are ways a plan is wrong rather than
217
218
  // ways it is narrow. So is an empty plan for a branch that was given work.
219
+ // Measured over the ids this plan actually took, never over the whole
220
+ // assignment. The packets are built before anybody chooses a scope, and since
221
+ // spec 37-3 criterion 2 both branches may be narrowed — so weighing the whole
222
+ // assignment against the width of a narrowed plan would compare two different
223
+ // jobs and refuse the narrow one for being narrow.
218
224
  for (const [branch, expected] of expectedByBranch) {
219
225
  const items = plan.workers.filter((item) => item && typeof item === 'object' && !Array.isArray(item) && item.branch === branch);
220
226
  if (expected.length > 0 && items.length === 0)
@@ -227,9 +233,62 @@ export function validateWorkerPlanFiles(projectRoot) {
227
233
  }
228
234
  for (const id of assigned.filter((id) => !expected.includes(id)))
229
235
  errors.push(`${branch}: capability ${id} was not assigned by the request`);
236
+ const cases = items.reduce((sum, item) => sum + (Array.isArray(item.planned_cases) ? item.planned_cases.length : 0), 0);
237
+ errors.push(...fanOutErrors(branch, items.length, cases));
230
238
  }
231
239
  return errors;
232
240
  }
241
+ // Which assigned ids each branch of a plan actually took. Spec 37-3 weighs a
242
+ // plan against the work it took on, never against the whole assignment: the
243
+ // packets are built before anybody chooses a scope, and since criterion 2 both
244
+ // branches may be narrowed, so the two are different jobs.
245
+ export function takenIds(plan) {
246
+ const taken = new Map();
247
+ for (const item of Array.isArray(plan?.workers) ? plan.workers : []) {
248
+ if (!item || typeof item !== 'object' || Array.isArray(item) || !isNonEmptyString(item.branch))
249
+ continue;
250
+ const ids = taken.get(item.branch) ?? new Set();
251
+ for (const id of Array.isArray(item.capability_ids) ? item.capability_ids : []) {
252
+ if (isNonEmptyString(id))
253
+ ids.add(id);
254
+ }
255
+ taken.set(item.branch, ids);
256
+ }
257
+ return taken;
258
+ }
259
+ // Spec 37-3, criterion 1. Two things are checked, and only when the packets
260
+ // exist to measure against: that the plan says what it divided, and that it did
261
+ // not divide work that already fits in one worker.
262
+ //
263
+ // A run without packets has no measured work, and a rule with no measurement
264
+ // behind it refuses nobody — the same policy the packets themselves follow.
265
+ function fanOutErrors(branch, planned, cases) {
266
+ const width = branchWidth(branch, cases);
267
+ if (!width || planned === 0)
268
+ return [];
269
+ // A band, not a ceiling. Both ends are expensive and neither is safe: on the
270
+ // 2026-08-24 bench fifteen workers cost 28% more than the cheapest width, and
271
+ // one worker cost 85% more — and a single worker on the behavioral branch
272
+ // would have run 216 turns into a 150-turn fuse. Anywhere inside the band is
273
+ // within about a tenth of the cheapest, so this refuses only what costs.
274
+ //
275
+ // Nothing is restated in the plan to prove the coordinator did this division.
276
+ // Both halves are already in the file — the cases in `planned_cases`, the
277
+ // width as the length of the branch's slice list — so a `fan_out` record would
278
+ // be the same two numbers copied by hand, which is what spec 37-1 refused for
279
+ // `packet_paths`. The gate is the guarantee; `accept-worker-plan` prints the
280
+ // derivation next to it.
281
+ if (planned >= width.fewest && planned <= width.most)
282
+ return [];
283
+ const way = planned > width.most ? 'wide' : 'narrow';
284
+ return [
285
+ `${branch}: ${planned} slices for ${cases} planned cases is too ${way} — ` +
286
+ `${width.fewest}-${width.most} is the band, ${width.workers} the cheapest. ` +
287
+ `Each slice costs a whole opening context (26,065 tokens on that bench, re-read every turn), ` +
288
+ `and each slice fewer makes one conversation longer, which costs with the square of its ` +
289
+ `length. At ${width.workers} a worker of this branch runs about ${width.turns_each} turns.`,
290
+ ];
291
+ }
233
292
  function assignmentIds(value) {
234
293
  const assignment = value;
235
294
  if (Array.isArray(assignment?.capabilities)) {
Binary file
@@ -1,6 +1,7 @@
1
1
  import { clearRunState } from "../runner/failureDigest.js";
2
2
  import { materializeHelper } from "../files/guardrails.js";
3
3
  import { materializeBehavioralWorld } from "../files/behavioral.js";
4
+ import { branchWorkloads, workloadLine } from "../files/fanOut.js";
4
5
  import { PACKETS_DIR, writeSuitePackets } from "../files/packets.js";
5
6
  import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
6
7
  import { bddStepLoading } from "../runner/bdd.js";
@@ -279,6 +280,7 @@ export async function suitePrepare(config, args = [], deps) {
279
280
  'the request that was just replaced.\n');
280
281
  }
281
282
  actual.stdout.write(packetNotice(request.project_root, sourcePackets));
283
+ actual.stdout.write(workloadNotice(request.project_root));
282
284
  actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
283
285
  `write your answer to ${request.output_path} as a branches array — one entry per branch named above, and a branch you cannot ` +
284
286
  `finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
@@ -364,6 +366,22 @@ function packetNotice(projectRoot, packets) {
364
366
  ` (${packets.notes.join('; ')}). ` +
365
367
  `Each says why in ${where}/index.json.\n`);
366
368
  }
369
+ // Spec 37-3, criterion 1. The size of the work, printed before the plan exists,
370
+ // because that is the only moment it can decide anything: the packets are built
371
+ // from the request's entrypoints, and the entrypoints are known before the
372
+ // workers are. A number that arrives after the plan is a number the plan was
373
+ // not made from.
374
+ function workloadNotice(projectRoot) {
375
+ const loads = branchWorkloads(projectRoot);
376
+ if (loads.length === 0)
377
+ return '';
378
+ return ('\nHow much each branch has to read, over the whole assignment and before you narrow it:\n' +
379
+ loads.map(workloadLine).join('') +
380
+ 'This does not set how many workers a branch gets, and that is worth knowing before you plan: ' +
381
+ 'on the bench of 2026-08-24 the branch with three times the source spent half the turns. What ' +
382
+ 'sets the width is how many cases you intend to write, so it is decided by the plan and ' +
383
+ 'checked by `accept-worker-plan`, which prints the band it accepted.\n');
384
+ }
367
385
  // The runner's own rule for which step files it will load, in the words of the
368
386
  // side that loads them (spec ask-before-you-spend, §3.2). The same object is in `request.json`, on
369
387
  // the behavioral branch; this is the copy the coordinator sees without opening a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
21
21
  connector-owned harness, or another slice.
22
22
 
23
23
  After every owned edit, run
24
- `npx -y --loglevel=error unitbob@0.7.0 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.7.1 run-local <branch>` and inspect the machine
25
25
  report. Look only at examples or scenarios matching your owned paths or case
26
26
  markers. Do not require a green exit code from the whole branch: foreign failures
27
27
  and an already-confirmed product red do not widen your scope. Repeat the bounded