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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +661 -0
- package/README.md +386 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/beadcyte.mjs +60 -0
- package/package.json +77 -0
- package/src/changelog-cli.mjs +105 -0
- package/src/changelog.mjs +196 -0
- package/src/cli.mjs +577 -0
- package/src/estimator.mjs +314 -0
- package/src/format.mjs +22 -0
- package/src/history-walk.mjs +170 -0
- package/src/index.mjs +5 -0
- package/src/mutate.mjs +193 -0
- package/src/projects.mjs +120 -0
- package/src/provenance.mjs +75 -0
- package/src/review-hours.mjs +117 -0
- package/src/roster-path.mjs +24 -0
- package/src/scheduler.mjs +424 -0
- package/src/serve.mjs +411 -0
- package/src/server-state.mjs +105 -0
- package/src/ship.mjs +178 -0
- package/src/stage-waits.mjs +69 -0
- package/src/start.mjs +111 -0
- package/src/stop.mjs +66 -0
- package/src/velocity.mjs +209 -0
- package/src/web/App.vue +691 -0
- package/src/web/app.css +54 -0
- package/src/web/assets/favicon.svg +12 -0
- package/src/web/avatar.ts +53 -0
- package/src/web/bead-detail.ts +240 -0
- package/src/web/changelog-view.ts +41 -0
- package/src/web/components/BeadDrawer.vue +1883 -0
- package/src/web/components/BeadSubGraph.vue +326 -0
- package/src/web/components/BeadSubGraphOverlay.vue +192 -0
- package/src/web/components/BeadTooltip.vue +516 -0
- package/src/web/components/BeadcyteMark.vue +64 -0
- package/src/web/components/BeadsGantt.vue +2125 -0
- package/src/web/components/BeadsGrid.vue +468 -0
- package/src/web/components/BeadsIncytes.vue +567 -0
- package/src/web/components/BeadsMine.vue +325 -0
- package/src/web/components/BeadsTable.vue +335 -0
- package/src/web/components/ChangelogOverlay.vue +198 -0
- package/src/web/components/ContextMenu.vue +386 -0
- package/src/web/components/ControlsPanel.vue +476 -0
- package/src/web/components/CostTrend.vue +206 -0
- package/src/web/components/FilterPopover.vue +245 -0
- package/src/web/components/GroupProgress.vue +274 -0
- package/src/web/components/LoadMeter.vue +144 -0
- package/src/web/components/MineRow.vue +28 -0
- package/src/web/components/OptionsMenu.vue +825 -0
- package/src/web/components/PriorityChip.vue +105 -0
- package/src/web/components/ScoreStrip.vue +131 -0
- package/src/web/components/SearchPalette.vue +210 -0
- package/src/web/components/ShipTrend.vue +510 -0
- package/src/web/components/ShortcutsOverlay.vue +164 -0
- package/src/web/components/Term.vue +177 -0
- package/src/web/components/Toast.vue +50 -0
- package/src/web/components/TriageMeters.vue +426 -0
- package/src/web/components/TypeChip.vue +96 -0
- package/src/web/components/Walkthrough.vue +209 -0
- package/src/web/components/WhatIfPanel.vue +206 -0
- package/src/web/components/WipBullets.vue +191 -0
- package/src/web/components/filter-option.ts +9 -0
- package/src/web/composables/url-codec.ts +136 -0
- package/src/web/composables/useBeadTooltip.ts +148 -0
- package/src/web/composables/useKeyboard.ts +97 -0
- package/src/web/composables/useLiveRefresh.ts +69 -0
- package/src/web/composables/useTheme.ts +125 -0
- package/src/web/composables/useUrlState.ts +208 -0
- package/src/web/controls-scope.ts +83 -0
- package/src/web/cost.ts +251 -0
- package/src/web/dep-headings.ts +62 -0
- package/src/web/economics.ts +440 -0
- package/src/web/env.d.ts +85 -0
- package/src/web/frontier.ts +208 -0
- package/src/web/gantt-viewport.ts +99 -0
- package/src/web/highlights.ts +124 -0
- package/src/web/index.html +46 -0
- package/src/web/insights.ts +107 -0
- package/src/web/keybindings.ts +200 -0
- package/src/web/load-meter.ts +72 -0
- package/src/web/main.ts +20 -0
- package/src/web/markdown.ts +14 -0
- package/src/web/mine.ts +137 -0
- package/src/web/mutations.ts +21 -0
- package/src/web/person.ts +102 -0
- package/src/web/projects-text.ts +15 -0
- package/src/web/projects.ts +188 -0
- package/src/web/refresh.ts +47 -0
- package/src/web/search.ts +50 -0
- package/src/web/shortcuts.ts +113 -0
- package/src/web/status-filter.ts +48 -0
- package/src/web/store.ts +1378 -0
- package/src/web/style-audit.mjs +346 -0
- package/src/web/styles-alt.css +111 -0
- package/src/web/styles-ported.css +270 -0
- package/src/web/subgraph.ts +362 -0
- package/src/web/table.ts +201 -0
- package/src/web/theme.ts +88 -0
- package/src/web/tokens.css +168 -0
- package/src/web/triage.ts +914 -0
- package/src/web/view-model.ts +717 -0
- package/src/web/walkthrough.ts +133 -0
- package/src/web/watchlist.ts +47 -0
- package/src/web/whatif.ts +291 -0
- package/src/web/window.ts +73 -0
- 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
|
+
}
|