beadcyte 0.4.0

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +661 -0
  3. package/README.md +386 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/bin/beadcyte.mjs +60 -0
  6. package/package.json +77 -0
  7. package/src/changelog-cli.mjs +105 -0
  8. package/src/changelog.mjs +196 -0
  9. package/src/cli.mjs +577 -0
  10. package/src/estimator.mjs +314 -0
  11. package/src/format.mjs +22 -0
  12. package/src/history-walk.mjs +170 -0
  13. package/src/index.mjs +5 -0
  14. package/src/mutate.mjs +193 -0
  15. package/src/projects.mjs +120 -0
  16. package/src/provenance.mjs +75 -0
  17. package/src/review-hours.mjs +117 -0
  18. package/src/roster-path.mjs +24 -0
  19. package/src/scheduler.mjs +424 -0
  20. package/src/serve.mjs +411 -0
  21. package/src/server-state.mjs +105 -0
  22. package/src/ship.mjs +178 -0
  23. package/src/stage-waits.mjs +69 -0
  24. package/src/start.mjs +111 -0
  25. package/src/stop.mjs +66 -0
  26. package/src/velocity.mjs +209 -0
  27. package/src/web/App.vue +691 -0
  28. package/src/web/app.css +54 -0
  29. package/src/web/assets/favicon.svg +12 -0
  30. package/src/web/avatar.ts +53 -0
  31. package/src/web/bead-detail.ts +240 -0
  32. package/src/web/changelog-view.ts +41 -0
  33. package/src/web/components/BeadDrawer.vue +1883 -0
  34. package/src/web/components/BeadSubGraph.vue +326 -0
  35. package/src/web/components/BeadSubGraphOverlay.vue +192 -0
  36. package/src/web/components/BeadTooltip.vue +516 -0
  37. package/src/web/components/BeadcyteMark.vue +64 -0
  38. package/src/web/components/BeadsGantt.vue +2125 -0
  39. package/src/web/components/BeadsGrid.vue +468 -0
  40. package/src/web/components/BeadsIncytes.vue +567 -0
  41. package/src/web/components/BeadsMine.vue +325 -0
  42. package/src/web/components/BeadsTable.vue +335 -0
  43. package/src/web/components/ChangelogOverlay.vue +198 -0
  44. package/src/web/components/ContextMenu.vue +386 -0
  45. package/src/web/components/ControlsPanel.vue +476 -0
  46. package/src/web/components/CostTrend.vue +206 -0
  47. package/src/web/components/FilterPopover.vue +245 -0
  48. package/src/web/components/GroupProgress.vue +274 -0
  49. package/src/web/components/LoadMeter.vue +144 -0
  50. package/src/web/components/MineRow.vue +28 -0
  51. package/src/web/components/OptionsMenu.vue +825 -0
  52. package/src/web/components/PriorityChip.vue +105 -0
  53. package/src/web/components/ScoreStrip.vue +131 -0
  54. package/src/web/components/SearchPalette.vue +210 -0
  55. package/src/web/components/ShipTrend.vue +510 -0
  56. package/src/web/components/ShortcutsOverlay.vue +164 -0
  57. package/src/web/components/Term.vue +177 -0
  58. package/src/web/components/Toast.vue +50 -0
  59. package/src/web/components/TriageMeters.vue +426 -0
  60. package/src/web/components/TypeChip.vue +96 -0
  61. package/src/web/components/Walkthrough.vue +209 -0
  62. package/src/web/components/WhatIfPanel.vue +206 -0
  63. package/src/web/components/WipBullets.vue +191 -0
  64. package/src/web/components/filter-option.ts +9 -0
  65. package/src/web/composables/url-codec.ts +136 -0
  66. package/src/web/composables/useBeadTooltip.ts +148 -0
  67. package/src/web/composables/useKeyboard.ts +97 -0
  68. package/src/web/composables/useLiveRefresh.ts +69 -0
  69. package/src/web/composables/useTheme.ts +125 -0
  70. package/src/web/composables/useUrlState.ts +208 -0
  71. package/src/web/controls-scope.ts +83 -0
  72. package/src/web/cost.ts +251 -0
  73. package/src/web/dep-headings.ts +62 -0
  74. package/src/web/economics.ts +440 -0
  75. package/src/web/env.d.ts +85 -0
  76. package/src/web/frontier.ts +208 -0
  77. package/src/web/gantt-viewport.ts +99 -0
  78. package/src/web/highlights.ts +124 -0
  79. package/src/web/index.html +46 -0
  80. package/src/web/insights.ts +107 -0
  81. package/src/web/keybindings.ts +200 -0
  82. package/src/web/load-meter.ts +72 -0
  83. package/src/web/main.ts +20 -0
  84. package/src/web/markdown.ts +14 -0
  85. package/src/web/mine.ts +137 -0
  86. package/src/web/mutations.ts +21 -0
  87. package/src/web/person.ts +102 -0
  88. package/src/web/projects-text.ts +15 -0
  89. package/src/web/projects.ts +188 -0
  90. package/src/web/refresh.ts +47 -0
  91. package/src/web/search.ts +50 -0
  92. package/src/web/shortcuts.ts +113 -0
  93. package/src/web/status-filter.ts +48 -0
  94. package/src/web/store.ts +1378 -0
  95. package/src/web/style-audit.mjs +346 -0
  96. package/src/web/styles-alt.css +111 -0
  97. package/src/web/styles-ported.css +270 -0
  98. package/src/web/subgraph.ts +362 -0
  99. package/src/web/table.ts +201 -0
  100. package/src/web/theme.ts +88 -0
  101. package/src/web/tokens.css +168 -0
  102. package/src/web/triage.ts +914 -0
  103. package/src/web/view-model.ts +717 -0
  104. package/src/web/walkthrough.ts +133 -0
  105. package/src/web/watchlist.ts +47 -0
  106. package/src/web/whatif.ts +291 -0
  107. package/src/web/window.ts +73 -0
  108. package/src/web/wip.ts +83 -0
@@ -0,0 +1,914 @@
1
+ // triage.ts — a local reimplementation of the scoring bv documents for
2
+ // --robot-triage.
3
+ //
4
+ // bv's triage combines eight normalized signals over the bead dependency
5
+ // graph into a single 0..1 score used to rank actionable work:
6
+ //
7
+ // score = 0.22·pagerank + 0.20·betweenness + 0.13·blocker_ratio
8
+ // + 0.05·staleness + 0.10·priority_boost + 0.10·time_to_impact
9
+ // + 0.10·urgency + 0.10·risk
10
+ //
11
+ // The weights above are lifted from actual bv output (see
12
+ // __fixtures__/bv-triage.json for a captured sample from this repo). The
13
+ // per-signal computations are best-effort reproductions of bv's documented
14
+ // behaviour — numerical parity is a *goal* (see triage.test.mjs cross-
15
+ // check), not a contract; if bv changes its formula the ordering will
16
+ // remain sensible but individual values may drift.
17
+ //
18
+ // Provenance: written independently, in TypeScript, from bv's documented
19
+ // behaviour and the captured output above. bv's Go source was offered as a
20
+ // reference only after this file existed, and only to check parity; no code
21
+ // was transcribed from it. bv is MIT with an additional rider (bp-67g.61).
22
+ //
23
+ // How close we actually are was measured for bp-67g.23 and is recorded in
24
+ // SIGNAL_FIDELITY below, signal by signal. Four of eight match bv; three
25
+ // diverge and are bp-67g.37's problem; one (risk) is only partly
26
+ // computable. Read that table before trusting a number out of here — and
27
+ // note that a WEIGHT is not what a signal can contribute, because two of
28
+ // the norms are bounded well below 1.
29
+ //
30
+ // Pure, no I/O — consumers wrap this in a Pinia getter over raw beads.
31
+
32
+ import type { Bead } from "./store";
33
+
34
+ // ── weights ───────────────────────────────────────────────────────────────
35
+
36
+ export const TRIAGE_WEIGHTS = {
37
+ pagerank: 0.22,
38
+ betweenness: 0.20,
39
+ blocker_ratio: 0.13,
40
+ staleness: 0.05,
41
+ priority_boost: 0.10,
42
+ time_to_impact: 0.10,
43
+ urgency: 0.10,
44
+ risk: 0.10,
45
+ } as const;
46
+
47
+ export type TriageSignal = keyof typeof TRIAGE_WEIGHTS;
48
+
49
+ const MS_PER_DAY = 86_400_000;
50
+
51
+ /** A bead untouched this long is maximally stale. bv's horizon. */
52
+ const STALENESS_HORIZON_DAYS = 30;
53
+
54
+ /** Depth beyond this stops adding to time-to-impact. */
55
+ const MAX_CRITICAL_PATH_DEPTH = 10;
56
+
57
+ /** Time-to-impact's time factor is measured against a working day. */
58
+ const MINUTES_PER_WORKDAY = 480;
59
+
60
+ /** Half-life of the urgency decay term, in days. */
61
+ const URGENCY_HALF_LIFE_DAYS = 7;
62
+
63
+ /**
64
+ * Assumed minutes of work per bead when no estimate is passed in. 240 is
65
+ * half a working day, which is what our estimator currently returns for
66
+ * almost every bead (bp-67g.24).
67
+ */
68
+ const DEFAULT_ESTIMATE_MINUTES = 240;
69
+
70
+ /**
71
+ * Labels that declare urgency, and what each is worth. Substring matched,
72
+ * so `priority::critical` and `critical` both hit.
73
+ */
74
+ const URGENCY_LABELS: ReadonlyArray<{ match: string; weight: number }> = [
75
+ { match: "critical", weight: 1.0 },
76
+ { match: "blocker", weight: 1.0 },
77
+ { match: "urgent", weight: 0.8 },
78
+ { match: "hotfix", weight: 0.8 },
79
+ { match: "asap", weight: 0.6 },
80
+ ];
81
+
82
+ const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
83
+
84
+ /**
85
+ * What each weight is actually worth, and how faithful our computation is.
86
+ *
87
+ * A weight states what a signal COULD contribute. Two of ours cannot reach
88
+ * it, because their norms are bounded well below 1 — so the weight column
89
+ * in the drawer was promising 0.10 where 0.026 was the ceiling. This is the
90
+ * table that keeps that honest, and it is checked against bv in
91
+ * triage.test.mjs rather than asserted here.
92
+ *
93
+ * `fidelity`:
94
+ * "faithful" verified equal to bv on the captured fixture
95
+ * "partial" we compute part of bv's model; the rest is unavailable
96
+ * "local" deliberately ours, and known to differ from bv
97
+ *
98
+ * Measured for bp-67g.23 against __fixtures__/bv-triage.json and a live
99
+ * `bv --robot-triage` on a 577-bead repo. The three "local" entries are
100
+ * bp-67g.37's problem, not this table's.
101
+ */
102
+ export const SIGNAL_FIDELITY: Record<
103
+ TriageSignal,
104
+ { max: number; fidelity: "faithful" | "partial" | "local"; note?: string }
105
+ > = {
106
+ pagerank: {
107
+ max: 1,
108
+ fidelity: "local",
109
+ note: "same max-normalisation as bv, but our PageRank orders beads differently (bp-67g.37)",
110
+ },
111
+ betweenness: { max: 1, fidelity: "faithful" },
112
+ blocker_ratio: { max: 1, fidelity: "faithful" },
113
+ staleness: { max: 1, fidelity: "faithful" },
114
+ priority_boost: { max: 1, fidelity: "faithful" },
115
+ time_to_impact: {
116
+ max: 1,
117
+ fidelity: "partial",
118
+ note: "depth is ours; the time factor uses one repo-wide estimate, not per bead (bp-67g.24)",
119
+ },
120
+ urgency: { max: 1, fidelity: "faithful" },
121
+ risk: {
122
+ max: 0.8,
123
+ fidelity: "partial",
124
+ note: "cross-repo risk has nothing to measure in a single-repo workspace, so 0.20 of the composite is unclaimed",
125
+ },
126
+ };
127
+
128
+ /**
129
+ * The most this signal can contribute to a score — weight x achievable max,
130
+ * not weight alone. `risk` declares 0.10 and can contribute 0.002.
131
+ */
132
+ export function achievableContribution(signal: TriageSignal): number {
133
+ return TRIAGE_WEIGHTS[signal] * SIGNAL_FIDELITY[signal].max;
134
+ }
135
+
136
+ // ── what each signal measures, in the words of the code below ─────────────
137
+ // Rendered on the signal names in the drawer (bp-67g.47). Built from the
138
+ // same constants the norm functions use — the horizon, the half-life, the
139
+ // workday, the depth cap, the urgency labels — so a change to any of them
140
+ // reaches the sentence without anyone remembering to edit prose. Change a
141
+ // formula, change its sentence here, in the same commit: that is the rule.
142
+
143
+ export function explainSignal(signal: TriageSignal): string {
144
+ switch (signal) {
145
+ case "pagerank":
146
+ return "How much of the dependency graph flows through this bead: PageRank over the blocks edges, so a bead that unblocks beads which themselves unblock many others scores high. Scaled so the largest value among this repo's beads is 1.";
147
+ case "betweenness":
148
+ return "How often this bead sits on the shortest dependency path between two other beads (Brandes' betweenness), scaled so the largest value in the repo is 1. A bottleneck between two clusters of work scores high.";
149
+ case "blocker_ratio":
150
+ return "How many beads this one directly blocks, against the busiest blocker in the repo. A count of direct edges, not the length of the chain behind them.";
151
+ case "staleness":
152
+ return `Days since the bead was last updated, as a share of a ${STALENESS_HORIZON_DAYS}-day horizon: untouched for ${STALENESS_HORIZON_DAYS} days or more reads 1.`;
153
+ case "priority_boost":
154
+ return "Priority as a boost: P0 reads 1.0 and P4 reads 0.0, in quarter steps. A deferred bead reads 0 whatever its priority.";
155
+ case "time_to_impact":
156
+ return `0.7 × how deep the chain of live work this bead releases is (its height among open dependents, against ${MAX_CRITICAL_PATH_DEPTH}) plus 0.3 × how short the work is against a ${MINUTES_PER_WORKDAY / 60}-hour day. The work length is one repo-wide estimate, not this bead's own. Short work at the head of a long chain scores high.`;
157
+ case "urgency":
158
+ return `An urgency label scores its weight (${URGENCY_LABELS.map((u) => `${u.match} ${u.weight}`).join(", ")}), plus an age term rising toward 0.5 the longer the bead stays unresolved, with a ${URGENCY_HALF_LIFE_DAYS}-day half-life from creation. Capped at 1.`;
159
+ case "risk":
160
+ return "0.30 × fan variance (how unevenly depended-on this bead's own blockers are) + 0.30 × activity churn (comments and edits relative to its age) + 0.20 × status risk (blocked is worst, blocked and idle worse; in progress only once it stops moving). The remaining 0.20, cross-repo risk, has nothing to measure in one repo and is left unclaimed, so risk tops out at 0.8. A closed bead reads 0.";
161
+ }
162
+ }
163
+
164
+ /** "0.22·pagerank + 0.20·betweenness + …", from the weights themselves. */
165
+ export function describeComposite(): string {
166
+ return (Object.keys(TRIAGE_WEIGHTS) as TriageSignal[])
167
+ .map((k) => `${TRIAGE_WEIGHTS[k].toFixed(2)}·${k}`)
168
+ .join(" + ");
169
+ }
170
+
171
+ /** The headline score, for the drawer's "triage score" label. */
172
+ export function explainScore(): string {
173
+ return `A weighted sum of eight signals, each scaled 0–1 first: ${describeComposite()}. The percentage is that sum; the breakdown below shows what each signal contributed and how it is measured.`;
174
+ }
175
+
176
+ // ── shapes ────────────────────────────────────────────────────────────────
177
+
178
+ export interface TriageBreakdown {
179
+ pagerank: number;
180
+ betweenness: number;
181
+ blocker_ratio: number;
182
+ staleness: number;
183
+ priority_boost: number;
184
+ time_to_impact: number;
185
+ urgency: number;
186
+ risk: number;
187
+ pagerank_norm: number;
188
+ betweenness_norm: number;
189
+ blocker_ratio_norm: number;
190
+ staleness_norm: number;
191
+ priority_boost_norm: number;
192
+ time_to_impact_norm: number;
193
+ urgency_norm: number;
194
+ risk_norm: number;
195
+ }
196
+
197
+ export interface TriageResult {
198
+ score: number;
199
+ breakdown: TriageBreakdown;
200
+ }
201
+
202
+ // ── main entry point ──────────────────────────────────────────────────────
203
+
204
+ /**
205
+ * Compute triage for every bead in the passed array. Brandes' betweenness
206
+ * is O(V·E) worst case, but over typed arrays with dangling sources skipped
207
+ * it measures ~5ms in node on a 600-bead graph (bp-67g.25: it was ~67ms
208
+ * there and ~300ms in the browser before), so the first drawer open no
209
+ * longer stalls on it.
210
+ */
211
+ export function computeTriage(
212
+ beads: Bead[],
213
+ opts: { now?: Date; estimateMinutes?: number } = {},
214
+ ): Map<string, TriageResult> {
215
+ const now = (opts.now ?? new Date()).getTime();
216
+ // Minutes of work assumed per bead, for time_to_impact's time factor.
217
+ // A single figure rather than per-bead because our estimator currently
218
+ // returns one for almost everything (bp-67g.24); when that is fixed this
219
+ // wants to become a per-bead lookup.
220
+ const estimateMinutes = opts.estimateMinutes ?? DEFAULT_ESTIMATE_MINUTES;
221
+ const byId = new Map(beads.map((b) => [b.id, b] as const));
222
+
223
+ // Adjacency: for each bead, who does it BLOCK (outgoing) and who blocks it
224
+ // (incoming). Non-`blocks` deps are ignored — the graph is only the
225
+ // work-ordering DAG.
226
+ const outgoing = new Map<string, string[]>();
227
+ const incoming = new Map<string, string[]>();
228
+ for (const b of beads) {
229
+ outgoing.set(b.id, []);
230
+ incoming.set(b.id, []);
231
+ }
232
+ for (const b of beads) {
233
+ for (const d of b.dependencies ?? []) {
234
+ if (d.type !== "blocks") continue;
235
+ const blockerId = d.depends_on_id;
236
+ if (!byId.has(blockerId)) continue;
237
+ // "b depends on blockerId" ⇒ blockerId BLOCKS b ⇒ edge blockerId → b.
238
+ outgoing.get(blockerId)!.push(b.id);
239
+ incoming.get(b.id)!.push(blockerId);
240
+ }
241
+ }
242
+
243
+ // Only OPEN beads participate meaningfully in triage ranking. Closed
244
+ // beads still exist in the graph (their edges matter for readiness) but
245
+ // shouldn't get a score of their own — the caller can filter them out
246
+ // downstream. We compute a score for everything and let consumers decide.
247
+ const rawPageRank = pageRank(beads, outgoing);
248
+ const rawBetweenness = betweennessCentrality(beads, outgoing);
249
+
250
+ // Blocker ratio: how much this bead unblocks, in direct-degree terms.
251
+ const rawBlockerRatio = new Map<string, number>();
252
+ for (const b of beads) {
253
+ rawBlockerRatio.set(b.id, outgoing.get(b.id)!.length);
254
+ }
255
+
256
+ // Longest blocking chain ending at each bead — height in the DAG, which
257
+ // is what time_to_impact's depth term needs. A bead with no blocking
258
+ // edges at all is 0; anything in the graph is 1 + its deepest blocker.
259
+ const depth = graphHeights(beads, byId);
260
+
261
+ // In-degree per bead, for risk's fan-variance term.
262
+ const inDegree = new Map<string, number>();
263
+ for (const b of beads) inDegree.set(b.id, incoming.get(b.id)!.length);
264
+
265
+ // Global maxima for normalization (avoid divide-by-zero when the graph
266
+ // has no signal on a dimension).
267
+ const maxPageRank = maxOf(rawPageRank);
268
+ const maxBetweenness = maxOf(rawBetweenness);
269
+ const maxBlockerRatio = maxOf(rawBlockerRatio);
270
+
271
+ const out = new Map<string, TriageResult>();
272
+ for (const b of beads) {
273
+ const pagerank_norm = safeDiv(rawPageRank.get(b.id) ?? 0, maxPageRank);
274
+ const betweenness_norm = safeDiv(
275
+ rawBetweenness.get(b.id) ?? 0,
276
+ maxBetweenness,
277
+ );
278
+ const blocker_ratio_norm = safeDiv(
279
+ rawBlockerRatio.get(b.id) ?? 0,
280
+ maxBlockerRatio,
281
+ );
282
+ const staleness_norm = stalenessNorm(b, now);
283
+ const priority_boost_norm = priorityBoostNorm(b);
284
+ const time_to_impact_norm = timeToImpactNorm(
285
+ depth.get(b.id) ?? 0,
286
+ estimateMinutes,
287
+ );
288
+ const urgency_norm = urgencyNorm(b, now);
289
+ const risk_norm = riskNorm(b, now, incoming, inDegree);
290
+
291
+ const pagerank = pagerank_norm * TRIAGE_WEIGHTS.pagerank;
292
+ const betweenness = betweenness_norm * TRIAGE_WEIGHTS.betweenness;
293
+ const blocker_ratio = blocker_ratio_norm * TRIAGE_WEIGHTS.blocker_ratio;
294
+ const staleness = staleness_norm * TRIAGE_WEIGHTS.staleness;
295
+ const priority_boost =
296
+ priority_boost_norm * TRIAGE_WEIGHTS.priority_boost;
297
+ const time_to_impact =
298
+ time_to_impact_norm * TRIAGE_WEIGHTS.time_to_impact;
299
+ const urgency = urgency_norm * TRIAGE_WEIGHTS.urgency;
300
+ const risk = risk_norm * TRIAGE_WEIGHTS.risk;
301
+
302
+ const score =
303
+ pagerank + betweenness + blocker_ratio + staleness +
304
+ priority_boost + time_to_impact + urgency + risk;
305
+
306
+ out.set(b.id, {
307
+ score,
308
+ breakdown: {
309
+ pagerank,
310
+ betweenness,
311
+ blocker_ratio,
312
+ staleness,
313
+ priority_boost,
314
+ time_to_impact,
315
+ urgency,
316
+ risk,
317
+ pagerank_norm,
318
+ betweenness_norm,
319
+ blocker_ratio_norm,
320
+ staleness_norm,
321
+ priority_boost_norm,
322
+ time_to_impact_norm,
323
+ urgency_norm,
324
+ risk_norm,
325
+ },
326
+ });
327
+ }
328
+ return out;
329
+ }
330
+
331
+ // ── signals ───────────────────────────────────────────────────────────────
332
+
333
+ // ── graph indexing ────────────────────────────────────────────────────────
334
+ //
335
+ // Both graph signals ran over Map<string, …> keyed by bead id, with a plain
336
+ // array as the BFS queue and queue.shift() as the dequeue — O(n) per shift.
337
+ // On a 579-bead repo that was ~300ms in the browser (bp-67g.25), 90% of the
338
+ // triage pipeline, all of it on the first drawer open. Indexing beads to
339
+ // integers once and running both over typed arrays with a head-pointer queue
340
+ // is the same arithmetic in the same order, so the results are unchanged to
341
+ // the last bit; only the bookkeeping is cheaper.
342
+
343
+ interface IndexedGraph {
344
+ n: number;
345
+ /** Compressed out-adjacency: out-neighbours of i are adj[outStart[i]..outStart[i+1]). */
346
+ outStart: Int32Array;
347
+ adj: Int32Array;
348
+ }
349
+
350
+ function indexGraph(beads: Bead[], outgoing: Map<string, string[]>): IndexedGraph {
351
+ const n = beads.length;
352
+ const index = new Map<string, number>();
353
+ beads.forEach((b, i) => index.set(b.id, i));
354
+ const outStart = new Int32Array(n + 1);
355
+ const adjList: number[] = [];
356
+ for (let i = 0; i < n; i++) {
357
+ outStart[i] = adjList.length;
358
+ for (const w of outgoing.get(beads[i].id) ?? []) {
359
+ const j = index.get(w);
360
+ if (j !== undefined) adjList.push(j);
361
+ }
362
+ }
363
+ outStart[n] = adjList.length;
364
+ return { n, outStart, adj: Int32Array.from(adjList) };
365
+ }
366
+
367
+ /**
368
+ * Standard PageRank with damping factor 0.85, iterated to convergence
369
+ * (tolerance 1e-6 or 1000 iterations). Edges point blocker → dependent so
370
+ * PR flows toward downstream work — high PR ≈ many things depend on this.
371
+ */
372
+ function pageRank(
373
+ beads: Bead[],
374
+ outgoing: Map<string, string[]>,
375
+ ): Map<string, number> {
376
+ const { n, outStart, adj } = indexGraph(beads, outgoing);
377
+ const damping = 0.85;
378
+ const tol = 1e-6;
379
+ const base = (1 - damping) / Math.max(1, n);
380
+ let rank = new Float64Array(n).fill(1 / Math.max(1, n));
381
+ let next = new Float64Array(n);
382
+
383
+ for (let iter = 0; iter < 1000; iter++) {
384
+ next.fill(base);
385
+
386
+ // Dangling mass — the rank held by beads that block nothing — has to be
387
+ // redistributed, not dropped. This loop used to `continue` past those
388
+ // beads, which silently deleted their share every iteration: the rank
389
+ // vector stopped summing to 1 and drained toward whichever beads had
390
+ // incoming edges. On a bead graph that is most of the graph (a bead that
391
+ // blocks nothing is the normal case), so the leak was the dominant term
392
+ // and it changed the ORDERING, not just the scale — the one signal still
393
+ // disagreeing with bv after bp-67g.37's other fixes.
394
+ let dangling = 0;
395
+ for (let v = 0; v < n; v++) {
396
+ const start = outStart[v];
397
+ const end = outStart[v + 1];
398
+ if (end === start) {
399
+ dangling += rank[v];
400
+ continue;
401
+ }
402
+ const share = (damping * rank[v]) / (end - start);
403
+ for (let e = start; e < end; e++) next[adj[e]] += share;
404
+ }
405
+ if (dangling !== 0) {
406
+ const add = (damping * dangling) / Math.max(1, n);
407
+ for (let v = 0; v < n; v++) next[v] += add;
408
+ }
409
+
410
+ // L2, matching the tolerance's scale: an L1 sum over hundreds of beads
411
+ // hits a fixed tolerance much later than the values have settled.
412
+ let delta = 0;
413
+ for (let v = 0; v < n; v++) {
414
+ const d = next[v] - rank[v];
415
+ delta += d * d;
416
+ }
417
+ [rank, next] = [next, rank];
418
+ if (Math.sqrt(delta) < tol) break;
419
+ }
420
+ const out = new Map<string, number>();
421
+ beads.forEach((b, i) => out.set(b.id, rank[i]));
422
+ return out;
423
+ }
424
+
425
+ /**
426
+ * Betweenness centrality via Brandes' algorithm. O(V·E). Counts the number
427
+ * of shortest source→target paths (over all pairs) that pass through each
428
+ * node.
429
+ *
430
+ * A source with no outgoing edges starts no path, so its BFS would visit
431
+ * itself and contribute nothing; skipping it is exact, not an approximation,
432
+ * and on a bead graph — where most beads block nothing — it skips most of
433
+ * the work. Predecessor lists are kept as a linked list over one preallocated
434
+ * buffer (each edge is a predecessor edge at most once per source), so a
435
+ * source costs no allocation beyond the first.
436
+ */
437
+ function betweennessCentrality(
438
+ beads: Bead[],
439
+ outgoing: Map<string, string[]>,
440
+ ): Map<string, number> {
441
+ const { n, outStart, adj } = indexGraph(beads, outgoing);
442
+ const m = adj.length;
443
+ const cb = new Float64Array(n);
444
+ const dist = new Int32Array(n);
445
+ const sigma = new Float64Array(n);
446
+ const delta = new Float64Array(n);
447
+ const stack = new Int32Array(n);
448
+ const queue = new Int32Array(n);
449
+ const predHead = new Int32Array(n);
450
+ const predNext = new Int32Array(Math.max(1, m));
451
+ const predNode = new Int32Array(Math.max(1, m));
452
+
453
+ for (let s = 0; s < n; s++) {
454
+ if (outStart[s + 1] === outStart[s]) continue;
455
+ dist.fill(-1);
456
+ sigma.fill(0);
457
+ delta.fill(0);
458
+ predHead.fill(-1);
459
+ let pc = 0;
460
+ let qHead = 0;
461
+ let qTail = 0;
462
+ let sp = 0;
463
+ dist[s] = 0;
464
+ sigma[s] = 1;
465
+ queue[qTail++] = s;
466
+ while (qHead < qTail) {
467
+ const v = queue[qHead++];
468
+ stack[sp++] = v;
469
+ const dv = dist[v];
470
+ for (let e = outStart[v]; e < outStart[v + 1]; e++) {
471
+ const w = adj[e];
472
+ if (dist[w] < 0) {
473
+ dist[w] = dv + 1;
474
+ queue[qTail++] = w;
475
+ }
476
+ if (dist[w] === dv + 1) {
477
+ sigma[w] += sigma[v];
478
+ predNode[pc] = v;
479
+ predNext[pc] = predHead[w];
480
+ predHead[w] = pc;
481
+ pc++;
482
+ }
483
+ }
484
+ }
485
+ while (sp > 0) {
486
+ const w = stack[--sp];
487
+ for (let p = predHead[w]; p !== -1; p = predNext[p]) {
488
+ const v = predNode[p];
489
+ delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w]);
490
+ }
491
+ if (w !== s) cb[w] += delta[w];
492
+ }
493
+ }
494
+ const out = new Map<string, number>();
495
+ beads.forEach((b, i) => out.set(b.id, cb[i]));
496
+ return out;
497
+ }
498
+
499
+ /**
500
+ * Priority → 0..1 boost. P0 → 1.0, P4 → 0.0. Deferred beads get 0.
501
+ */
502
+ function priorityBoostNorm(b: Bead): number {
503
+ if (b.status === "deferred") return 0;
504
+ const p = b.priority ?? 4;
505
+ return Math.max(0, (4 - p) / 4);
506
+ }
507
+
508
+ /**
509
+ * Days since a bead was last touched, against a fixed 30-day horizon.
510
+ *
511
+ * ABSOLUTE, not relative to the stalest bead in the repo. The relative
512
+ * version this replaces divided by the population maximum, which had two
513
+ * consequences and both were wrong: the stalest bead always scored 1.0
514
+ * however recently it was touched (in one captured fixture every bead had
515
+ * been updated within three hours and they all scored 0.93-1.00), and the
516
+ * number meant nothing outside its own repo.
517
+ *
518
+ * 30 days is bv's horizon and there is no reason to differ. A bead idle
519
+ * two weeks now reads 0.47, which is a statement about the bead rather
520
+ * than about its neighbours.
521
+ */
522
+ function stalenessNorm(b: Bead, now: number): number {
523
+ if (!b.updated_at) return 0.5; // unknown — moderate rather than extreme
524
+ const days = (now - +new Date(b.updated_at)) / MS_PER_DAY;
525
+ if (!Number.isFinite(days)) return 0.5;
526
+ return clamp01(days / STALENESS_HORIZON_DAYS);
527
+ }
528
+
529
+ /**
530
+ * Time-to-impact — how quickly finishing this converts into downstream
531
+ * unblocks. Depth dominates because it is the structural part:
532
+ *
533
+ * 0.7 · (depth / 10) + 0.3 · (1 − minutes / 480)
534
+ *
535
+ * `depth` is the longest chain of blockers ending at this bead, so a bead
536
+ * deep in a chain releases more when it lands. The time factor rewards
537
+ * short work against an 8-hour day.
538
+ *
539
+ * This used to be the constant 0.2625, which was that formula evaluated at
540
+ * depth 0 with a 60-minute estimate — the value bv returns for a leaf. It
541
+ * was a constant here because we never computed depth, so a tenth of the
542
+ * declared weight bought exactly nothing. With depth wired up it spans
543
+ * 0.15-0.43 on a real 577-bead repo.
544
+ */
545
+ function timeToImpactNorm(depth: number, estimateMinutes: number): number {
546
+ const depthNorm = clamp01(depth / MAX_CRITICAL_PATH_DEPTH);
547
+ const timeFactor = clamp01(1 - estimateMinutes / MINUTES_PER_WORKDAY);
548
+ return depthNorm * 0.7 + timeFactor * 0.3;
549
+ }
550
+
551
+ /**
552
+ * Urgency — declared urgency plus the pressure of an unresolved bead
553
+ * ageing. Label first, then a decay term that grows toward 0.5 with a
554
+ * 7-day half-life, capped at 1.0.
555
+ *
556
+ * Deliberately keyed on `created_at` (how long this has been outstanding)
557
+ * where staleness is keyed on `updated_at` (how long since anyone touched
558
+ * it). Two different questions; the previous implementation multiplied
559
+ * staleness by priority, which made urgency a function of the two signals
560
+ * either side of it and left it unable to reach past 0.5 on any repo.
561
+ */
562
+ function urgencyNorm(b: Bead, now: number): number {
563
+ let score = 0;
564
+ for (const label of b.labels ?? []) {
565
+ const lower = label.toLowerCase();
566
+ const hit = URGENCY_LABELS.find((u) => lower.includes(u.match));
567
+ if (hit) {
568
+ score += hit.weight;
569
+ break;
570
+ }
571
+ }
572
+ if (b.created_at) {
573
+ const age = (now - +new Date(b.created_at)) / MS_PER_DAY;
574
+ if (Number.isFinite(age) && age > 0) {
575
+ score += 0.5 * (1 - Math.exp(-age / URGENCY_HALF_LIFE_DAYS));
576
+ }
577
+ }
578
+ return clamp01(score);
579
+ }
580
+
581
+ /**
582
+ * Risk — a composite of four volatility signals, weighted 0.30 / 0.30 /
583
+ * 0.20 / 0.20. Replaces a constant 0.02, which gave a tenth of the
584
+ * declared weight a reachable ceiling of 0.002 and two distinct values
585
+ * across an entire repo. The composite takes 89 distinct values over the
586
+ * same 215 open beads.
587
+ *
588
+ * cross-repo risk is absent rather than zero-weighted: every bead in a
589
+ * `bd` workspace is in one repo, so the term has nothing to measure. Its
590
+ * 0.20 is left unclaimed rather than redistributed, so a composite here is
591
+ * directly comparable with one that does have cross-repo edges.
592
+ */
593
+ function riskNorm(
594
+ b: Bead,
595
+ now: number,
596
+ incoming: Map<string, string[]>,
597
+ inDegree: Map<string, number>,
598
+ ): number {
599
+ // A landed bead has no risk of not landing. status_risk already says 0
600
+ // here, but activity_churn does not look at status, so a busy bead would
601
+ // keep carrying risk after it closed. bv never hits this case — it drops
602
+ // closed issues before scoring — so the behaviour is ours to define, and
603
+ // zero is the only sensible answer. Deferred is deliberately NOT included:
604
+ // bv does score deferred beads, and a deferred bead genuinely is at risk.
605
+ if (b.status === "closed" || b.status === "tombstone") return 0;
606
+ return (
607
+ 0.3 * fanVariance(b, incoming, inDegree) +
608
+ 0.3 * activityChurn(b, now) +
609
+ 0.2 * statusRisk(b, now)
610
+ );
611
+ }
612
+
613
+ /**
614
+ * How unevenly popular this bead's blockers are, as a coefficient of
615
+ * variation over their in-degrees, normalised against CV 2. Depending on
616
+ * one heavily-depended-on thing and one obscure thing is a riskier shape
617
+ * than depending on two similar things. Needs at least two blockers to
618
+ * mean anything.
619
+ */
620
+ function fanVariance(
621
+ b: Bead,
622
+ incoming: Map<string, string[]>,
623
+ inDegree: Map<string, number>,
624
+ ): number {
625
+ const degrees = (incoming.get(b.id) ?? []).map((id) => inDegree.get(id) ?? 0);
626
+ if (degrees.length < 2) return 0;
627
+ const mean = degrees.reduce((a, d) => a + d, 0) / degrees.length;
628
+ if (mean === 0) return 0;
629
+ const variance =
630
+ degrees.reduce((a, d) => a + (d - mean) ** 2, 0) / degrees.length;
631
+ return clamp01(Math.sqrt(variance) / mean / 2);
632
+ }
633
+
634
+ /**
635
+ * Edit and comment traffic relative to age. A bead being argued over is
636
+ * less settled than one written once and left alone.
637
+ */
638
+ function activityChurn(b: Bead, now: number): number {
639
+ if (!b.created_at) return 0;
640
+ const ageDays = Math.max(1, (now - +new Date(b.created_at)) / MS_PER_DAY);
641
+ if (!Number.isFinite(ageDays)) return 0;
642
+ const commentsPerDay = (b.comment_count ?? 0) / ageDays;
643
+ // How much of its life the bead has been touched over: a bead updated
644
+ // once at creation reads 0, one still being edited reads near 1.
645
+ let updateRecency = 0;
646
+ if (b.created_at && b.updated_at && ageDays > 1) {
647
+ const span = (+new Date(b.updated_at) - +new Date(b.created_at)) / MS_PER_DAY;
648
+ if (Number.isFinite(span) && span > 0) updateRecency = span / ageDays;
649
+ }
650
+ return clamp01(commentsPerDay * 0.6 + updateRecency * 0.4);
651
+ }
652
+
653
+ /**
654
+ * Risk carried by the status itself, and by how long the bead has sat in
655
+ * it. Blocked is the worst place to be, and blocked-and-untouched is worse.
656
+ * In-progress is only risky once it stops moving.
657
+ */
658
+ function statusRisk(b: Bead, now: number): number {
659
+ const idleDays = b.updated_at
660
+ ? (now - +new Date(b.updated_at)) / MS_PER_DAY
661
+ : 0;
662
+ const ageDays = b.created_at
663
+ ? (now - +new Date(b.created_at)) / MS_PER_DAY
664
+ : 0;
665
+ switch (b.status) {
666
+ case "blocked":
667
+ return idleDays > 7 ? 0.9 : 0.7;
668
+ case "in_progress":
669
+ case "in_review":
670
+ if (idleDays > 14) return 0.8; // moving once, not any more
671
+ if (idleDays > 7) return 0.4;
672
+ return 0.1;
673
+ case "open":
674
+ case "pinned":
675
+ return ageDays > 30 ? 0.3 : 0.1;
676
+ default:
677
+ // closed, deferred, tombstoned — out of play, no risk to carry.
678
+ return 0;
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Longest chain of work this bead RELEASES — the depth of live dependents
684
+ * below it. Finishing something at the head of a long chain unblocks more
685
+ * than finishing a leaf, which is what time_to_impact's depth term is for
686
+ * ("higher depth = more impact when completed").
687
+ *
688
+ * Two deliberate choices, both about meaning rather than range:
689
+ *
690
+ * - DOWNSTREAM, not upstream. The chain of things waiting on you is the
691
+ * impact of finishing; the chain of things you are waiting on is a
692
+ * different fact, and `blocker_ratio` already carries a piece of it.
693
+ * - CLOSED dependents are pruned. A dependent that is already closed was
694
+ * not waiting on this bead, so counting it inflates the impact of
695
+ * finishing. Pruning costs range — on a 577-bead repo it takes depth
696
+ * from 5 distinct values to 3 — and is still right.
697
+ *
698
+ * A bead with no live blocking edges either way is not in the graph and
699
+ * scores 0, which is what separates "isolated" from "head of a chain".
700
+ *
701
+ * Iterative over a topological order rather than recursive, so a cycle in
702
+ * the dependency data cannot blow the stack; beads inside a cycle keep
703
+ * whatever height their acyclic ancestors gave them.
704
+ */
705
+ function graphHeights(beads: Bead[], byId: Map<string, Bead>): Map<string, number> {
706
+ const isLive = (id: string) => byId.get(id)?.status !== "closed";
707
+ const down = new Map<string, string[]>(); // blocker -> things it releases
708
+ const up = new Map<string, string[]>();
709
+ for (const b of beads) {
710
+ down.set(b.id, []);
711
+ up.set(b.id, []);
712
+ }
713
+ for (const b of beads) {
714
+ if (!isLive(b.id)) continue; // a closed dependent was not waiting
715
+ for (const d of b.dependencies ?? []) {
716
+ if (d.type !== "blocks") continue;
717
+ if (!byId.has(d.depends_on_id)) continue;
718
+ down.get(d.depends_on_id)!.push(b.id);
719
+ up.get(b.id)!.push(d.depends_on_id);
720
+ }
721
+ }
722
+
723
+ const height = new Map<string, number>();
724
+ const pending = new Map<string, number>();
725
+ const queue: string[] = [];
726
+ for (const b of beads) {
727
+ const deg = up.get(b.id)!.length;
728
+ pending.set(b.id, deg);
729
+ const isolated = deg === 0 && down.get(b.id)!.length === 0;
730
+ height.set(b.id, isolated ? 0 : deg === 0 ? 1 : 0);
731
+ if (deg === 0) queue.push(b.id);
732
+ }
733
+ for (let i = 0; i < queue.length; i++) {
734
+ const id = queue[i];
735
+ for (const next of down.get(id) ?? []) {
736
+ height.set(next, Math.max(height.get(next) ?? 0, (height.get(id) ?? 0) + 1));
737
+ const left = (pending.get(next) ?? 0) - 1;
738
+ pending.set(next, left);
739
+ if (left === 0) queue.push(next);
740
+ }
741
+ }
742
+
743
+ // The queue walked blocker -> dependent, so `height` currently holds the
744
+ // chain ABOVE each node. Depth-below is the same walk from the other end:
745
+ // take the maximum height reachable downstream and subtract our own.
746
+ const reach = new Map<string, number>(height);
747
+ for (let i = queue.length - 1; i >= 0; i--) {
748
+ const id = queue[i];
749
+ for (const next of down.get(id) ?? []) {
750
+ reach.set(id, Math.max(reach.get(id) ?? 0, reach.get(next) ?? 0));
751
+ }
752
+ }
753
+ const below = new Map<string, number>();
754
+ for (const b of beads) {
755
+ const h = height.get(b.id) ?? 0;
756
+ below.set(b.id, h === 0 ? 0 : (reach.get(b.id) ?? h) - h);
757
+ }
758
+ return below;
759
+ }
760
+
761
+ // ── population statistics ─────────────────────────────────────────────────
762
+
763
+ export interface SignalPopulation {
764
+ signal: TriageSignal;
765
+ /** Distinct values across the population, to 4dp. */
766
+ distinct: number;
767
+ stdev: number;
768
+ /** How many of the population have a non-zero value for this signal. */
769
+ nonzero: number;
770
+ max: number;
771
+ /** 90th percentile of the norm across the population. */
772
+ p90: number;
773
+ /**
774
+ * True when the signal cannot separate beads ON THIS REPO: three or fewer
775
+ * distinct values, or a spread small enough that it is a constant with
776
+ * rounding on it.
777
+ *
778
+ * Distinct from SIGNAL_FIDELITY, which is about whether we agree with bv.
779
+ * A signal can be perfectly faithful and still flat here — betweenness is
780
+ * non-zero on 1 of 216 open beads on a real 577-bead repo, so it carries
781
+ * the second-largest weight and separates nothing. That is a property of
782
+ * shallow dependency graphs, not a defect, but a reader comparing two
783
+ * beads deserves to know which axes are doing no work.
784
+ */
785
+ degenerate: boolean;
786
+ }
787
+
788
+ /**
789
+ * Per-signal spread across a set of beads — the denominator for "is this
790
+ * axis telling me anything". Cheap: one pass over an already-computed
791
+ * triage map, ~4ms for 216 beads.
792
+ */
793
+ export function signalPopulation(
794
+ ids: Iterable<string>,
795
+ triage: Map<string, TriageResult>,
796
+ ): SignalPopulation[] {
797
+ const idList = [...ids];
798
+ return (Object.keys(TRIAGE_WEIGHTS) as TriageSignal[]).map((signal) => {
799
+ const vals: number[] = [];
800
+ for (const id of idList) {
801
+ const t = triage.get(id);
802
+ if (t) vals.push(t.breakdown[`${signal}_norm`]);
803
+ }
804
+ const n = Math.max(1, vals.length);
805
+ const mean = vals.reduce((a, x) => a + x, 0) / n;
806
+ const stdev = Math.sqrt(
807
+ vals.reduce((a, x) => a + (x - mean) ** 2, 0) / n,
808
+ );
809
+ const distinct = new Set(vals.map((v) => v.toFixed(4))).size;
810
+ const sorted = [...vals].sort((a, b) => a - b);
811
+ return {
812
+ signal,
813
+ distinct,
814
+ stdev,
815
+ nonzero: vals.filter((v) => v > 1e-9).length,
816
+ max: vals.length ? Math.max(...vals) : 0,
817
+ // What "high" looks like for this signal here. The absolute scales
818
+ // are meaningful but narrow in practice — nothing on a healthy repo
819
+ // is 30 days stale — so a bar read against the weight alone always
820
+ // looks empty. This is the comparative marker that makes it readable.
821
+ p90: sorted.length
822
+ ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.9))]
823
+ : 0,
824
+ degenerate: distinct <= 3 || stdev < 0.01,
825
+ };
826
+ });
827
+ }
828
+
829
+ // ── helpers ───────────────────────────────────────────────────────────────
830
+
831
+ function maxOf(m: Map<string, number>): number {
832
+ let max = 0;
833
+ for (const v of m.values()) if (v > max) max = v;
834
+ return max;
835
+ }
836
+
837
+ function safeDiv(num: number, denom: number): number {
838
+ if (denom === 0) return 0;
839
+ return num / denom;
840
+ }
841
+
842
+ // ── score population ──────────────────────────────────────────────────────
843
+ //
844
+ // "18.1%" is unreadable without knowing what is typical, and the composite
845
+ // score is severely right-skewed on a real repo (bp-67g.28: min 5.8%, median
846
+ // 11.5%, max 36.7% over 216 open beads), so a bare percentage hides that
847
+ // 18.1% is the top 7%. The drawer draws the population the score came from.
848
+ //
849
+ // OPEN beads only, by design: closed beads still carry a score, and
850
+ // including them would put every live bead in the top decile of a mostly-
851
+ // dead field. Cheap — ~0.4ms over the already-computed triage map on 579
852
+ // beads — and read only from the drawer, so triage itself stays lazy.
853
+
854
+ export interface ScorePopulation {
855
+ /** Composite scores of the population, ascending. */
856
+ scores: number[];
857
+ n: number;
858
+ min: number;
859
+ max: number;
860
+ /** Quartiles of `scores`: the value at the 25th, 50th and 75th percentile. */
861
+ p25: number;
862
+ p50: number;
863
+ p75: number;
864
+ /** Counts per equal-width bin across [min, max]. */
865
+ histogram: number[];
866
+ }
867
+
868
+ export function scorePopulation(
869
+ ids: readonly string[],
870
+ triage: Map<string, TriageResult>,
871
+ bins = 40,
872
+ ): ScorePopulation {
873
+ const scores: number[] = [];
874
+ for (const id of ids) {
875
+ const t = triage.get(id);
876
+ if (t) scores.push(t.score);
877
+ }
878
+ scores.sort((a, b) => a - b);
879
+ const n = scores.length;
880
+ const min = scores[0] ?? 0;
881
+ const max = scores[n - 1] ?? 0;
882
+ const span = max - min || 1;
883
+ const histogram = new Array<number>(bins).fill(0);
884
+ for (const s of scores) {
885
+ histogram[Math.min(bins - 1, Math.floor(((s - min) / span) * bins))]++;
886
+ }
887
+ const at = (p: number) => (n ? scores[Math.min(n - 1, Math.floor(p * n))] : 0);
888
+ return { scores, n, min, max, p25: at(0.25), p50: at(0.5), p75: at(0.75), histogram };
889
+ }
890
+
891
+ /** Share of the population scoring at or below `score`, 0..1. */
892
+ export function percentileOf(pop: ScorePopulation, score: number): number {
893
+ const { scores } = pop;
894
+ if (!scores.length) return 0;
895
+ let lo = 0;
896
+ let hi = scores.length;
897
+ while (lo < hi) {
898
+ const mid = (lo + hi) >> 1;
899
+ if (scores[mid] <= score) lo = mid + 1;
900
+ else hi = mid;
901
+ }
902
+ return lo / scores.length;
903
+ }
904
+
905
+ /**
906
+ * The percentile as a reader says it: "top 7% of 216 open beads". Always the
907
+ * top-N form, so a median bead reads "top 50%" rather than switching vocabulary
908
+ * halfway down the distribution; never below "top 1%".
909
+ */
910
+ export function describeRank(pop: ScorePopulation, score: number): string {
911
+ if (!pop.n) return "no open beads to compare against";
912
+ const above = Math.max(1, Math.round((1 - percentileOf(pop, score)) * 100));
913
+ return `top ${above}% of ${pop.n} open bead${pop.n === 1 ? "" : "s"}`;
914
+ }