unitbob 0.7.1 → 0.7.2

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.
@@ -3,7 +3,6 @@ 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";
7
6
  export function workerPlanPath(projectRoot) {
8
7
  return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
9
8
  }
@@ -233,62 +232,9 @@ export function validateWorkerPlanFiles(projectRoot) {
233
232
  }
234
233
  for (const id of assigned.filter((id) => !expected.includes(id)))
235
234
  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));
238
235
  }
239
236
  return errors;
240
237
  }
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
- }
292
238
  function assignmentIds(value) {
293
239
  const assignment = value;
294
240
  if (Array.isArray(assignment?.capabilities)) {
Binary file
@@ -1,7 +1,6 @@
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";
5
4
  import { PACKETS_DIR, writeSuitePackets } from "../files/packets.js";
6
5
  import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
7
6
  import { bddStepLoading } from "../runner/bdd.js";
@@ -280,7 +279,6 @@ export async function suitePrepare(config, args = [], deps) {
280
279
  'the request that was just replaced.\n');
281
280
  }
282
281
  actual.stdout.write(packetNotice(request.project_root, sourcePackets));
283
- actual.stdout.write(workloadNotice(request.project_root));
284
282
  actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
285
283
  `write your answer to ${request.output_path} as a branches array — one entry per branch named above, and a branch you cannot ` +
286
284
  `finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
@@ -366,22 +364,6 @@ function packetNotice(projectRoot, packets) {
366
364
  ` (${packets.notes.join('; ')}). ` +
367
365
  `Each says why in ${where}/index.json.\n`);
368
366
  }
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
- }
385
367
  // The runner's own rule for which step files it will load, in the words of the
386
368
  // side that loads them (spec ask-before-you-spend, §3.2). The same object is in `request.json`, on
387
369
  // 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.1",
3
+ "version": "0.7.2",
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.1 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.7.2 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
@@ -1,148 +0,0 @@
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
- }