diff --git a/gitnexus/src/core/ingestion/process-processor.ts b/gitnexus/src/core/ingestion/process-processor.ts index a0c740a58..44a35ccf9 100644 --- a/gitnexus/src/core/ingestion/process-processor.ts +++ b/gitnexus/src/core/ingestion/process-processor.ts @@ -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(); + 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); diff --git a/gitnexus/test/unit/process-processor.test.ts b/gitnexus/test/unit/process-processor.test.ts index ed5806916..6563daaa0 100644 --- a/gitnexus/test/unit/process-processor.test.ts +++ b/gitnexus/test/unit/process-processor.test.ts @@ -663,3 +663,89 @@ describe('process depth (D1/D2)', () => { expect(deepest).toBeGreaterThan(3); }); }); + +describe('process selection diversity (R2-3)', () => { + const addFn = (graph: ReturnType, 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, + 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'); + }); +});