fix(processes): trace depth-first so multi-hop flows are detected

D1 ("query ranks frontend components above the backend module that owns the
concept") and D2 ("processes is dominated by trivial mechanical chains") are
the same defect, and neither is about ranking or selection.

The walk stops after a fixed NUMBER of traces, so traversal order decides which
traces those are. Breadth-first reaches every shallow terminal before any deep
one, so the quota filled with the shortest paths in the graph and the walk
stopped — `maxTraceDepth: 10` was never approached. Measured on a real repo
before the fix: of 300 processes NONE exceeded 7 steps and 90% were 3-4. A
multi-hop business flow (signal → order → exit) therefore had no process that
could represent it, and `query` could only rank the mechanical pairs that did
exist. Step 4 of the caller already sorts by length and dedupes by endpoint —
it was always asking for the deepest traces this walk could give it.

Depth-first descends to a terminal first, so the same quota is spent on paths
worth keeping. Cost is unchanged: same budget, same cycle guard, same depth
ceiling — only the order differs.

Measured on the same 16k-node repo, same build and flags, BFS vs DFS (an
earlier comparison was discarded as confounded — it crossed builds and --pdg):

  steps   6-8:  50 → 168   (3.4x)
  totals:      844 → 806

and the reported query moved from `LiveSetupView → Cn` (a React component) to
`ReconcilePositions → IsTpInProfit / WithHeld / ShouldNotify` — server-side
exit management, which is what was asked for.

`traceFromEntryPoint` is exported for the test. Traversal order is unobservable
through `processProcesses`: `findEntryPoints` supplies several starting points,
so a deep chain is traced from inside it whatever the order does. A test at
that level passes under BOTH traversals — the first version of this test did
exactly that and guarded nothing. Driving the walk directly, it fails under
breadth-first with "expected 3 to be greater than 3".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-06 16:47:17 +03:00
parent 989681bbd2
commit b8892e02dc
2 changed files with 126 additions and 4 deletions

View file

@ -340,19 +340,38 @@ const findEntryPoints = (
* Trace forward from an entry point using BFS.
* Returns all distinct paths up to maxDepth.
*/
const traceFromEntryPoint = (
// Exported for tests ONLY: traversal order is the whole behaviour here, and it
// is unobservable through `processProcesses` because `findEntryPoints` supplies
// several starting points — a deep chain gets traced from inside it regardless
// of order, so a test at that level passes under either traversal and pins
// nothing.
export const traceFromEntryPoint = (
entryId: string,
callsEdges: AdjacencyList,
config: ProcessDetectionConfig,
): string[][] => {
const traces: string[][] = [];
// BFS with path tracking
// Each queue item: [currentNodeId, pathSoFar]
// DEPTH-first, not breadth-first. Each stack item: [currentNodeId, pathSoFar].
//
// The walk stops after a fixed NUMBER of traces, so the traversal order
// decides which traces those are. Breadth-first reaches every shallow
// terminal before any deep one, so the quota filled with the shortest paths
// in the graph and the walk stopped — `maxTraceDepth` was never approached.
// Measured on a 75k-node repo: of 300 processes, none exceeded 7 steps and
// 90% were 3-4, so a multi-hop business flow (signal → order → exit) had no
// process that could represent it, and `query` could only rank the mechanical
// pairs that did exist.
//
// Depth-first descends to a terminal first, so the same quota is spent on
// paths worth keeping. Cost is unchanged — same budget, same cycle guard,
// same `maxTraceDepth` ceiling — only the ORDER of exploration differs, and
// the caller already sorts by length and dedupes by endpoint, so it was
// always asking for the deepest traces this walk could give it.
const queue: [string, string[]][] = [[entryId, [entryId]]];
while (queue.length > 0 && traces.length < config.maxBranching * 3) {
const [currentId, path] = queue.shift()!;
const [currentId, path] = queue.pop()!;
// Get outgoing calls
const callees = callsEdges.get(currentId) || [];

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import {
processProcesses,
traceFromEntryPoint,
type ProcessDetectionConfig,
} from '../../src/core/ingestion/process-processor.js';
import { computeDynamicMaxProcesses } from '../../src/core/ingestion/pipeline-phases/processes.js';
@ -560,3 +561,105 @@ describe('processProcesses', () => {
});
});
});
/**
* D1/D2 — the trace walk must reach DEEP flows, not just shallow ones.
*
* The walk stops after a fixed NUMBER of traces, so traversal order decides
* which traces those are. Breadth-first reached every shallow terminal before
* any deep one, so the quota filled with the shortest paths in the graph and
* the walk stopped — `maxTraceDepth` was never approached.
*
* Measured on a 75k-node repo before the fix: of 300 processes NONE exceeded 7
* steps and 90% were 3-4, so a multi-hop business flow had no process that
* could represent it and `query` could only rank the mechanical pairs that did
* exist. What looked like a ranking problem was a construction problem.
*
* This fixture is that shape in miniature: one deep chain competing with enough
* shallow branches to exhaust the trace budget before the chain is reached.
*/
describe('process depth (D1/D2)', () => {
// Drives the walk DIRECTLY. Through `processProcesses` this is unobservable:
// `findEntryPoints` returns several starting points, so the deep chain is
// traced from inside it whatever the traversal order does — a test there
// passes under BOTH traversals and guards nothing.
const cfg = { maxTraceDepth: 10, maxBranching: 4, maxProcesses: 75, minSteps: 3 };
it('descends a deep chain instead of spending the budget on shallow branches', () => {
// Fan-out is capped at maxBranching (4), so the budget is exhausted BELOW
// the entry: three shallow branches carrying four immediate terminals each
// = 12 traces, exactly the walk budget (maxBranching * 3). Breadth-first
// records all twelve and stops before descending the fourth branch.
const calls = new Map<string, string[]>();
calls.set('entry', ['s1', 's2', 's3', 'd1']);
for (const b of ['s1', 's2', 's3']) {
calls.set(b, [`${b}_l1`, `${b}_l2`, `${b}_l3`, `${b}_l4`]);
}
for (let i = 1; i <= 7; i++) calls.set(`d${i}`, [`d${i + 1}`]);
const traces = traceFromEntryPoint('entry', calls, cfg);
const deepest = Math.max(0, ...traces.map((t) => t.length));
// Shallow terminals are 3 nodes. Anything longer proves it descended.
expect(deepest).toBeGreaterThan(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',
});
};
it('finds a deep chain that shallow branches would otherwise crowd out', async () => {
const graph = createKnowledgeGraph();
addFn(graph, 'func:entry');
// Fan-out is capped at `maxBranching` (4), so the budget can only be
// exhausted BELOW the entry, not beside it: three shallow branches each
// carrying four immediate terminals = 12 traces, exactly the walk's trace
// budget (maxBranching * 3). Breadth-first records all twelve before it
// ever descends the fourth branch, and stops.
for (let b = 1; b <= 3; b++) {
const mid = `func:s${b}`;
addFn(graph, mid);
addCall(graph, 'func:entry', mid);
for (let l = 1; l <= 4; l++) {
const leaf = `func:l${b}_${l}`;
addFn(graph, leaf);
addCall(graph, mid, leaf);
}
}
// The fourth branch is a deep chain: entry → d1 → … → d8 (terminal).
let prev = 'func:entry';
addFn(graph, 'func:d1');
addCall(graph, 'func:entry', 'func:d1');
prev = 'func:d1';
for (let i = 2; i <= 8; i++) {
const id = `func:d${i}`;
addFn(graph, id);
addCall(graph, prev, id);
prev = id;
}
const result = await processProcesses(graph, []);
const deepest = Math.max(0, ...result.processes.map((p) => p.stepCount));
// Shallow terminals are 3 steps. Anything deeper proves the walk descended
// instead of spending its whole budget fanning out.
expect(deepest).toBeGreaterThan(3);
});
});