feat(processes): select round-robin by terminal so the list is not one flow repeated

Ranking was `sort by length` alone, so the top of the list was one behaviour
described many ways: eleven of the top fourteen processes on the reporting
repo were four entry points crossed with three terminals of the SAME
date-window utility cluster. Genuine call chains, but a reader learns one
thing from fourteen entries, and the repo's own domain flows sat below them.

Selection now round-robins across TERMINALS, deepest first. Depth still orders
within a terminal and still leads the list; what changes is that no terminal
takes a second slot until every other has had a first.

Keying on the entry point was tried first and made it worse — many files
declare a `main`, so each was a distinct entry that round-robin then awarded
its own slot, and `Main -> AlignWindowEnd` went from one row to eight. The
repetition was never in where a flow starts.

Measured on that repo: distinct terminals in the top 20 went 3 -> 20, and its
domain flows (`ReconcilePositions -> ...`) moved into the top 4%.

Two things this deliberately does not claim. The reported cause — ranking
rewarding fan-in, promoting chains ending in widely-called helpers — measured
FALSE: those terminals have one caller each (`alignWindowStart` 1,
`validateSymbol` 1). A fan-in discount was implemented against that hypothesis,
measured, and reverted for moving nothing. And a business flow still cannot be
a process in its own right: the walk only emits at a leaf, at max depth, or on
a cycle, so a flow whose meaningful endpoint calls onward survives only as
whatever leaf it bottoms out in. Both are recorded in the code so neither
reads as settled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-06 21:14:00 +03:00
parent 85800fc4e2
commit e4bfff7f08
2 changed files with 143 additions and 4 deletions

View file

@ -135,10 +135,63 @@ export const processProcesses = async (
70,
);
// Step 4: Limit to max processes (prioritize longer traces)
const limitedTraces = endpointDeduped
.sort((a, b) => b.length - a.length)
.slice(0, cfg.maxProcesses);
// Step 4: Limit to max processes — deepest first, but ROUND-ROBIN across
// TERMINALS (R2-3).
//
// Ranking was `sort by length` alone, and the top of that list was one
// behaviour described many ways: measured on the reporting repo, eleven of
// the top fourteen processes were four entry points crossed with three
// terminals of the same date-window utility cluster — `Handle ->
// AlignWindowEnd`, `Main -> AlignWindowStart`, `ProcessSymbol ->
// ResolveGridIntervalMs`. Genuine call chains, but a reader learns one thing
// from fourteen entries.
//
// Keyed on the TERMINAL, not the entry point. Keying on the entry was tried
// first and made it worse — many files declare a `main`, so each was a
// distinct entry that round-robin then awarded its own slot, and
// `Main -> AlignWindowEnd` went from one row to eight. The repetition was
// never in where a flow starts; it is in where flows pile up.
//
// Depth still orders within a terminal and still leads the list, since
// insertion order here is deepest-first. What changes is that no terminal
// takes a second slot until every other has had a first. Measured: distinct
// terminals in the top 20 went 3 -> 20, and the repo's own domain flows
// (`ReconcilePositions -> ...`) moved into the top 4%.
//
// Two things this does NOT do, recorded so neither reads as settled. The
// reported cause — ranking rewarding fan-in, promoting chains ending in
// widely-called helpers — measured FALSE: those terminals have one caller
// each (`alignWindowStart` 1, `validateSymbol` 1). A fan-in discount was
// implemented against that hypothesis and moved nothing. And a business flow
// still cannot be a process in its own right: `traceFromEntryPoint` only
// emits at a leaf, at max depth, or on a cycle, so a flow whose meaningful
// endpoint calls onward is never a candidate — it survives here only as
// whatever leaf it happens to bottom out in. Fixing that means teaching the
// walk what a sink is (I/O, route handler, external call), which is a change
// to what a process IS rather than to how processes are ranked.
const tracesByTerminal = new Map<string, string[][]>();
for (const trace of [...endpointDeduped].sort((a, b) => b.length - a.length)) {
const terminalId = trace[trace.length - 1];
if (terminalId === undefined) continue;
const existing = tracesByTerminal.get(terminalId);
if (existing === undefined) tracesByTerminal.set(terminalId, [trace]);
else existing.push(trace);
}
// Insertion order is deepest-trace-first, so the round-robin visits terminals
// in that order too and depth still leads the list.
const limitedTraces: string[][] = [];
for (let round = 0; limitedTraces.length < cfg.maxProcesses; round++) {
let addedAny = false;
for (const traces of tracesByTerminal.values()) {
const trace = traces[round];
if (trace === undefined) continue;
limitedTraces.push(trace);
addedAny = true;
if (limitedTraces.length >= cfg.maxProcesses) break;
}
if (!addedAny) break;
}
onProgress?.(`Creating ${limitedTraces.length} process nodes...`, 80);

View file

@ -663,3 +663,89 @@ describe('process depth (D1/D2)', () => {
expect(deepest).toBeGreaterThan(3);
});
});
describe('process selection diversity (R2-3)', () => {
const addFn = (graph: ReturnType<typeof createKnowledgeGraph>, id: string): void => {
graph.addNode({
id,
label: 'Function',
properties: { name: id.split(':')[1], filePath: 'src/a.ts', startLine: 1, endLine: 2 },
});
};
const addCall = (
graph: ReturnType<typeof createKnowledgeGraph>,
from: string,
to: string,
): void => {
graph.addRelationship({
id: `rel:${from}->${to}`,
sourceId: from,
targetId: to,
type: 'CALLS',
confidence: 1,
reason: 'test',
});
};
// The shape that crowded the reporting repo's list: many entry points whose
// deepest chains all bottom out in the SAME utility, plus a shorter flow
// ending somewhere of its own. Ranking on depth alone hands every slot to
// the first group and the reader learns one thing many times.
it('does not let one terminal take every slot', async () => {
const graph = createKnowledgeGraph();
// Six entry points, each with a 5-node chain into one shared utility.
addFn(graph, 'func:sharedUtil');
for (let e = 1; e <= 6; e++) {
let prev = `func:entry${e}`;
addFn(graph, prev);
for (let i = 1; i <= 3; i++) {
const mid = `func:e${e}_m${i}`;
addFn(graph, mid);
addCall(graph, prev, mid);
prev = mid;
}
addCall(graph, prev, 'func:sharedUtil');
}
// One shorter, distinct flow — the "business flow" analogue.
addFn(graph, 'func:ownEntry');
addFn(graph, 'func:ownMid');
addFn(graph, 'func:ownTerminal');
addCall(graph, 'func:ownEntry', 'func:ownMid');
addCall(graph, 'func:ownMid', 'func:ownTerminal');
const result = await processProcesses(graph, [], undefined, { maxProcesses: 4 });
const terminals = result.processes.map((p) => p.terminalId);
const sharedCount = terminals.filter((t) => t === 'func:sharedUtil').length;
// Under depth-only ranking every one of the four slots goes to a
// five-node chain ending in sharedUtil.
expect(sharedCount).toBeLessThan(terminals.length);
expect(new Set(terminals).size).toBeGreaterThan(1);
});
it('keeps a shorter flow with its own terminal rather than a fifth duplicate', async () => {
const graph = createKnowledgeGraph();
addFn(graph, 'func:sharedUtil');
for (let e = 1; e <= 6; e++) {
let prev = `func:entry${e}`;
addFn(graph, prev);
for (let i = 1; i <= 3; i++) {
const mid = `func:e${e}_m${i}`;
addFn(graph, mid);
addCall(graph, prev, mid);
prev = mid;
}
addCall(graph, prev, 'func:sharedUtil');
}
addFn(graph, 'func:ownEntry');
addFn(graph, 'func:ownMid');
addFn(graph, 'func:ownTerminal');
addCall(graph, 'func:ownEntry', 'func:ownMid');
addCall(graph, 'func:ownMid', 'func:ownTerminal');
const result = await processProcesses(graph, [], undefined, { maxProcesses: 4 });
expect(result.processes.map((p) => p.terminalId)).toContain('func:ownTerminal');
});
});