Summary
The parallel pipeline silently drops CALLS edges. On a 122-file Next.js/TypeScript repo it loses 21% of the call graph (519 -> 408), and the default configuration is the broken one.
You already know the two pipelines disagree — d9ea73f says so:
a new deliberately-RED repro_seq_parallel_equivalence tracks the remaining OPEN bug that the sequential and parallel pipelines systematically disagree (~3459 USAGE / ~1666 WRITES lines on xfs) -- separate code paths, fix deferred.
I could not find a GitHub issue for it, so here is one — with the exact failure rule, a public corpus that reproduces it, and the file-count threshold below which it is invisible.
tests/repro/repro_parallel_determinism.c:54 hardcodes RPD_CORPUS "/Users/martinvogel/perf-bench/linux/fs/xfs" and SKIPs when absent, so CI never runs it. The header notes synthetic corpora failed to reproduce. Both problems are solved below.
Version: v0.9.0, macOS arm64.
The failure rule
An edge is dropped if and only if the target is a JSX component imported through a tsconfig paths alias.
I classified all 519 sequential CALLS edges by import style x usage. The confusion matrix is perfect:
| import style |
usage |
kept |
lost |
loss |
@/ tsconfig alias |
JSX |
0 |
122 |
100% |
@/ tsconfig alias |
call |
115 |
0 |
0% |
relative ./ |
JSX |
37 |
0 |
0% |
relative ./ |
call |
4 |
0 |
0% |
| external (npm) |
call |
14 |
0 |
0% |
Alias-imported function calls resolve fine. Relative-imported JSX resolves fine. Only the intersection fails. The parallel import_map heuristic appears to apply alias mapping to call expressions but not to JSX element references.
Concrete case — src/app/(dashboard)/_components/dashboard-kpis.tsx:
import { KpiCard } from "@/components/ui/kpi-card"; // alias import
...
<KpiCard ... /> // rendered at lines 32, 40, 47
sequential: DashboardKpis -> KpiCard (strategy=unique_name)
parallel: (no edge)
USAGE does not compensate. So on a default index, "who renders <KpiCard>?" silently returns nothing. For a React/Next.js codebase, where most of the call graph is JSX composition through aliases, this removes the majority of the component graph.
It is a code-path split, not a race
Do not look for a lock. I fingerprinted the full CALLS edge set (src,tgt,line,strategy -> sha256) across 21 runs (workers 1,2,3,4,6,8,12 x 3):
| workers |
CALLS (3 runs) |
CALLS fingerprint |
Routes |
self-loops |
| 1 |
519 519 519 |
30eff468830d627a |
1 |
0 |
| 2 |
408 408 408 |
d704b71f31522558 |
5 |
8 |
| 3 |
408 408 408 |
d704b71f31522558 |
5 |
8 |
| 4 |
408 408 408 |
d704b71f31522558 |
5 |
8 |
| 6 |
408 408 408 |
d704b71f31522558 |
5 |
8 |
| 8 |
408 408 408 |
d704b71f31522558 |
5 |
8 |
| 12 |
408 408 408 |
d704b71f31522558 |
5 |
8 |
Every parallel run is bit-identical, at every worker count. Two workers is already fully broken; twelve is no worse. A data race cannot produce that. This is consistent with pipeline.c:1336-1337 dispatching to two separate implementations:
? run_parallel_pipeline(p, ctx, files, file_count, worker_count, &t)
: run_sequential_pipeline(p, ctx, files, file_count, &t);
Nothing resource-related helps, which fits: at workers=8, all of CBM_TS_TYPE_BUDGET=100000000, CBM_MEM_BUDGET_MB=12000, CBM_RETAIN_TOTAL_MB=8000, CBM_WALK_DEFS_MAX=1000000, and CBM_INDEX_SUPERVISOR=0 still yield exactly 408.
Threshold: the parallel path engages at 51 File nodes
Bisected: 50 files -> sequential path, 51 files -> parallel path. Below 51 the bug is invisible.
This is almost certainly why the synthetic corpora in the test header failed to reproduce. A small fixture will pass and look fixed. Any regression test must exceed 51 files.
Minimal shape: one Consumer.tsx importing three things — a JSX component via alias, a JSX component via relative path, and a plain function via alias — plus >=48 padding files.
| files |
workers |
AliasCard (JSX+alias) |
RelCard (JSX+rel) |
aliasHelper (call+alias) |
| <=52 |
1 or 8 |
unique_name |
lsp_ts_jsx_import |
lsp_ts_import |
| >=53 |
1 |
unique_name |
lsp_ts_jsx_import |
lsp_ts_import |
| >=53 |
8 |
LOST |
import_map |
import_map |
Public corpus: this repository reproduces it
No local checkout needed — the tool reproduces the divergence on its own source:
git clone https://github.com/DeusData/codebase-memory-mcp
for w in 1 8; do
CBM_WORKERS=$w codebase-memory-mcp cli index_repository \
--repo-path ./codebase-memory-mcp --mode full --name "si-$w"
codebase-memory-mcp cli query_graph --project "si-$w" \
--query "MATCH ()-[r:CALLS]->() RETURN count(*)"
done
| workers |
CALLS (3 runs) |
| 1 |
35534 / 35534 / 35534 |
| 8 |
35510 / 35510 / 35510 |
Deterministic, 24 edges lost, on a corpus already present in CI. This can replace the hardcoded RPD_CORPUS.
Fabricated edges and nodes (parallel only)
519 -> 408 decomposes as 391 survive, 128 lost, 17 fabricated.
8 phantom self-loops (f -> f), all strategy=callee_suffix, confidence=0.50, candidates=0. Every callee is an unresolvable builtin/library member call — templateCache.get, formData.get, request.headers.get, response.headers.get, promptMap.get, Drawer.Handle, test.use. The resolver finds zero candidates and still emits an edge, pointing at the enclosing function itself. This sets Function.recursive = true on 7 functions in a repo with zero recursive functions (all with self_recursive: false).
Fix: on an empty candidate set, emit no edge. The README already states this invariant for the Perl resolver ("unresolved receivers emit no edge (zero-edge guarantee)"); the TS path does not honor it.
4 fabricated Route nodes (/api/sendText, /api/sessions, /api/sessions/:name, /api/:session/auth/qr) with empty source and empty file_path. These are outbound URLs passed to a local helper: request("POST", "/api/sendText", payload). Sequentially this resolves to a normal CALLS edge and creates no Route. In parallel the call goes unresolved and a heuristic promotes the path-like string literal into a Route the project supposedly serves.
Mode does not interact
Constant -111 CALLS penalty in every mode. Route fabrication and self-loops appear in all three.
| mode |
workers=1 |
workers=8 |
| fast |
431 |
320 |
| moderate |
431 |
320 |
| full |
519 |
408 |
Sequential is not a perfect oracle either
Worth knowing before you "fix" parallel by making it match sequential: parallel actually corrects 9 edges that sequential gets wrong.
Sequential's lsp_ts_namespace collapses multiple call sites sharing a method name into a single edge, keeping the first target and the last line. In src/actions/appointments.ts, appointmentRepository.create (L58) and reminderRepository.create (L62, L63) collapse into ONE edge pointing at appointment-repository.create at line 63 — wrong receiver, wrong line — and L58 is dropped. Parallel resolves both correctly. Same pattern for findAll in src/app/(dashboard)/page.tsx:31,32.
So the correct framing is: sequential is correct for JSX/alias resolution; parallel is correct for same-named-method receiver disambiguation. Neither is a superset. Converging the paths should take the union, not simply adopt one.
Two adjacent traps
CBM_INDEX_SINGLE_THREAD is strict-parsed as "1". =true is accepted without error and silently does nothing:
CBM_INDEX_SINGLE_THREAD=1 -> CALLS=519 (also overrides CBM_WORKERS=8)
CBM_INDEX_SINGLE_THREAD=true -> CALLS=408 (silently ignored)
Re-indexing an existing project is a silent no-op. index_repository --mode full on an unchanged repo returns the existing graph without re-resolving. Re-indexing a workers=8 project (408) with CBM_WORKERS=1 still yields 408; you must delete_project first. Anyone trying the single-threaded workaround on an already-corrupted index will see no change and conclude it does not work.
Impact
trace_path is the flagship feature, and under the default configuration it silently omits ~21% of call relationships on real TypeScript — including essentially the entire JSX component graph. There is no signal to the user: node counts are identical, nothing is logged, and the bad edges carry confidence: 0.95 (import_map), so confidence cannot be used to detect a degraded graph either.
Suggestions
- Fix alias resolution for JSX element references on the parallel path (the whole 21%).
- On
candidates=0, emit no edge — kills the phantom self-loops, the false recursive flags, and the fabricated Routes.
- Unpin
RPD_CORPUS; this repository reproduces it. Ensure any regression fixture exceeds 51 files.
- Accept
true/yes/on for CBM_INDEX_SINGLE_THREAD, or error on unparseable values.
- Until the paths converge, consider defaulting to sequential or logging that the parallel graph is known-lossy. Serial costs 0.43s vs 0.33s on 122 files.
Separate, lower severity
SEMANTICALLY_RELATED is nondeterministic across identical runs at every worker count including 1 (observed 18-22 on repeated full indexes of one tree). All structural edges and node counts are bit-stable. This is not the 519-vs-408 bug and looks like #998, whose fix (#1014) appears not to have shipped in v0.9.0.
Summary
The parallel pipeline silently drops CALLS edges. On a 122-file Next.js/TypeScript repo it loses 21% of the call graph (519 -> 408), and the default configuration is the broken one.
You already know the two pipelines disagree —
d9ea73fsays so:I could not find a GitHub issue for it, so here is one — with the exact failure rule, a public corpus that reproduces it, and the file-count threshold below which it is invisible.
tests/repro/repro_parallel_determinism.c:54hardcodesRPD_CORPUS "/Users/martinvogel/perf-bench/linux/fs/xfs"andSKIPs when absent, so CI never runs it. The header notes synthetic corpora failed to reproduce. Both problems are solved below.Version: v0.9.0, macOS arm64.
The failure rule
An edge is dropped if and only if the target is a JSX component imported through a tsconfig
pathsalias.I classified all 519 sequential CALLS edges by import style x usage. The confusion matrix is perfect:
@/tsconfig alias@/tsconfig alias././Alias-imported function calls resolve fine. Relative-imported JSX resolves fine. Only the intersection fails. The parallel
import_mapheuristic appears to apply alias mapping to call expressions but not to JSX element references.Concrete case —
src/app/(dashboard)/_components/dashboard-kpis.tsx:USAGEdoes not compensate. So on a default index, "who renders<KpiCard>?" silently returns nothing. For a React/Next.js codebase, where most of the call graph is JSX composition through aliases, this removes the majority of the component graph.It is a code-path split, not a race
Do not look for a lock. I fingerprinted the full CALLS edge set (
src,tgt,line,strategy-> sha256) across 21 runs (workers 1,2,3,4,6,8,12 x 3):30eff468830d627ad704b71f31522558d704b71f31522558d704b71f31522558d704b71f31522558d704b71f31522558d704b71f31522558Every parallel run is bit-identical, at every worker count. Two workers is already fully broken; twelve is no worse. A data race cannot produce that. This is consistent with
pipeline.c:1336-1337dispatching to two separate implementations:Nothing resource-related helps, which fits: at workers=8, all of
CBM_TS_TYPE_BUDGET=100000000,CBM_MEM_BUDGET_MB=12000,CBM_RETAIN_TOTAL_MB=8000,CBM_WALK_DEFS_MAX=1000000, andCBM_INDEX_SUPERVISOR=0still yield exactly 408.Threshold: the parallel path engages at 51 File nodes
Bisected: 50 files -> sequential path, 51 files -> parallel path. Below 51 the bug is invisible.
This is almost certainly why the synthetic corpora in the test header failed to reproduce. A small fixture will pass and look fixed. Any regression test must exceed 51 files.
Minimal shape: one
Consumer.tsximporting three things — a JSX component via alias, a JSX component via relative path, and a plain function via alias — plus >=48 padding files.unique_namelsp_ts_jsx_importlsp_ts_importunique_namelsp_ts_jsx_importlsp_ts_importimport_mapimport_mapPublic corpus: this repository reproduces it
No local checkout needed — the tool reproduces the divergence on its own source:
Deterministic, 24 edges lost, on a corpus already present in CI. This can replace the hardcoded
RPD_CORPUS.Fabricated edges and nodes (parallel only)
519 -> 408 decomposes as 391 survive, 128 lost, 17 fabricated.
8 phantom self-loops (
f -> f), allstrategy=callee_suffix, confidence=0.50, candidates=0. Every callee is an unresolvable builtin/library member call —templateCache.get,formData.get,request.headers.get,response.headers.get,promptMap.get,Drawer.Handle,test.use. The resolver finds zero candidates and still emits an edge, pointing at the enclosing function itself. This setsFunction.recursive = trueon 7 functions in a repo with zero recursive functions (all withself_recursive: false).Fix: on an empty candidate set, emit no edge. The README already states this invariant for the Perl resolver ("unresolved receivers emit no edge (zero-edge guarantee)"); the TS path does not honor it.
4 fabricated Route nodes (
/api/sendText,/api/sessions,/api/sessions/:name,/api/:session/auth/qr) with emptysourceand emptyfile_path. These are outbound URLs passed to a local helper:request("POST", "/api/sendText", payload). Sequentially this resolves to a normal CALLS edge and creates no Route. In parallel the call goes unresolved and a heuristic promotes the path-like string literal into a Route the project supposedly serves.Mode does not interact
Constant -111 CALLS penalty in every mode. Route fabrication and self-loops appear in all three.
Sequential is not a perfect oracle either
Worth knowing before you "fix" parallel by making it match sequential: parallel actually corrects 9 edges that sequential gets wrong.
Sequential's
lsp_ts_namespacecollapses multiple call sites sharing a method name into a single edge, keeping the first target and the last line. Insrc/actions/appointments.ts,appointmentRepository.create(L58) andreminderRepository.create(L62, L63) collapse into ONE edge pointing atappointment-repository.createat line 63 — wrong receiver, wrong line — and L58 is dropped. Parallel resolves both correctly. Same pattern forfindAllinsrc/app/(dashboard)/page.tsx:31,32.So the correct framing is: sequential is correct for JSX/alias resolution; parallel is correct for same-named-method receiver disambiguation. Neither is a superset. Converging the paths should take the union, not simply adopt one.
Two adjacent traps
CBM_INDEX_SINGLE_THREADis strict-parsed as"1".=trueis accepted without error and silently does nothing:Re-indexing an existing project is a silent no-op.
index_repository --mode fullon an unchanged repo returns the existing graph without re-resolving. Re-indexing a workers=8 project (408) withCBM_WORKERS=1still yields 408; you mustdelete_projectfirst. Anyone trying the single-threaded workaround on an already-corrupted index will see no change and conclude it does not work.Impact
trace_pathis the flagship feature, and under the default configuration it silently omits ~21% of call relationships on real TypeScript — including essentially the entire JSX component graph. There is no signal to the user: node counts are identical, nothing is logged, and the bad edges carryconfidence: 0.95(import_map), so confidence cannot be used to detect a degraded graph either.Suggestions
candidates=0, emit no edge — kills the phantom self-loops, the falserecursiveflags, and the fabricated Routes.RPD_CORPUS; this repository reproduces it. Ensure any regression fixture exceeds 51 files.true/yes/onforCBM_INDEX_SINGLE_THREAD, or error on unparseable values.Separate, lower severity
SEMANTICALLY_RELATEDis nondeterministic across identical runs at every worker count including 1 (observed 18-22 on repeated full indexes of one tree). All structural edges and node counts are bit-stable. This is not the 519-vs-408 bug and looks like #998, whose fix (#1014) appears not to have shipped in v0.9.0.