perf(mro): replace O(n³) C3 merge loop with O(n²) head-pointer algorithm (#1316)

* perf(mro): replace O(n³) C3 merge loop with O(n²) head-pointer algorithm

The C3 linearization merge loop used Array.shift() (O(n) per call) and
Array.indexOf() for tail membership checks (O(n) per scan), producing
O(n³) total complexity across deep single-inheritance chains. A 2000-class
chain took ~43s, exceeding the 15s test timeout.

Replace with:
- Uint32Array head pointers (O(1) advance, no array mutation)
- Pre-computed tail-count Map (O(1) membership check, decremented on
  head advance)

The deep-chain test now completes in ~2s.

Closes #1309

* fix(mro): address review findings for C3 merge optimization

- Add test for C3 merge-conflict inconsistency (non-cyclic): classic
  A(X,Y) + B(Y,X) → C(A,B) incompatible ordering, assert fallback to
  BFS ancestors
- Clarify tailCount decrement comment to state the invariant explicitly
- Move deep-chain performance test to dedicated describe('performance')
  block (was incorrectly nested under 'cyclic inheritance')
This commit is contained in:
Christian C. Berclaz 2026-05-04 11:49:24 +02:00 • committed by GitHub
parent e92328d474
commit 7be1a5a72d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 58 additions and 18 deletions

View file

@ -157,19 +157,27 @@ export function c3Linearize(
// Add the direct parents list as the final sequence
const sequences = [...parentLinearizations, [...directParents]];
const heads = new Uint32Array(sequences.length); // head pointer per sequence
const result: string[] = [];
// Tail-count map: how many sequences contain this id at index > head.
// O(1) membership check replaces O(n) indexOf scans.
const tailCount = new Map<string, number>();
for (const seq of sequences) {
for (let i = 1; i < seq.length; i++) {
tailCount.set(seq[i], (tailCount.get(seq[i]) ?? 0) + 1);
}
}
let remaining = sequences.reduce((n, s) => n + s.length, 0);
let inconsistent = false;
while (sequences.some((s) => s.length > 0)) {
// Find a good head: one that doesn't appear in the tail of any other sequence
while (remaining > 0) {
let head: string | null = null;
for (const seq of sequences) {
if (seq.length === 0) continue;
const candidate = seq[0];
const inTail = sequences.some(
(other) => other.length > 1 && other.indexOf(candidate, 1) !== -1,
);
if (!inTail) {
for (let si = 0; si < sequences.length; si++) {
if (heads[si] >= sequences[si].length) continue;
const candidate = sequences[si][heads[si]];
if ((tailCount.get(candidate) ?? 0) === 0) {
head = candidate;
break;
}
@ -182,10 +190,19 @@ export function c3Linearize(
result.push(head);
// Remove the chosen head from all sequences
for (const seq of sequences) {
if (seq.length > 0 && seq[0] === head) {
seq.shift();
// Advance head pointers past the chosen head; update tail counts
for (let si = 0; si < sequences.length; si++) {
if (heads[si] >= sequences[si].length) continue;
if (sequences[si][heads[si]] === head) {
heads[si]++;
remaining--;
// promoted was in this sequence's active tail; now it's the new head — remove from tailCount
if (heads[si] < sequences[si].length) {
const promoted = sequences[si][heads[si]];
const prev = tailCount.get(promoted)!;
if (prev <= 1) tailCount.delete(promoted);
else tailCount.set(promoted, prev - 1);
}
}
}
}

View file

@ -540,10 +540,35 @@ describe('computeMRO', () => {
expect(result).toBeDefined();
});
it('returns null for C3 merge-conflict inconsistency (non-cyclic)', () => {
// Classic incompatible ordering: A(X,Y) and B(Y,X) → C(A,B) is unresolvable
const graph = createKnowledgeGraph();
addClass(graph, 'X', 'python');
addClass(graph, 'Y', 'python');
addClass(graph, 'A', 'python');
addClass(graph, 'B', 'python');
addClass(graph, 'C', 'python');
addExtends(graph, 'A', 'X');
addExtends(graph, 'A', 'Y');
addExtends(graph, 'B', 'Y');
addExtends(graph, 'B', 'X');
addExtends(graph, 'C', 'A');
addExtends(graph, 'C', 'B');
addMethod(graph, 'X', 'foo');
const result = computeMRO(graph);
expect(result).toBeDefined();
// C3 fails for C — falls back to BFS ancestors
const entryC = result.entries.find((e) => e.className === 'C');
expect(entryC).toBeDefined();
// BFS fallback still produces an MRO (just not C3-ordered)
expect(entryC!.mro.length).toBeGreaterThanOrEqual(2);
});
});
// ---- Performance (deep chains) -------------------------------------------
describe('performance', () => {
it('handles very deep single-inheritance chain without stack overflow', () => {
// Chain of 2000 classes: C0 ← C1 ← C2 ← ... ← C1999
// The iterative c3Linearize handles this without blowing the stack.
// (The recursive version overflows at ~1K–5K levels depending on platform.)
const graph = createKnowledgeGraph();
const DEPTH = 2000;
for (let i = 0; i < DEPTH; i++) {
@ -552,12 +577,10 @@ describe('computeMRO', () => {
for (let i = 1; i < DEPTH; i++) {
addExtends(graph, `C${i}`, `C${i - 1}`);
}
// Add a method on the root so MRO produces an entry
addMethod(graph, 'C0', 'baseMethod');
const result = computeMRO(graph);
expect(result).toBeDefined();
// The deepest class should have all ancestors in its MRO
const deepest = result.entries.find((e) => e.className === `C${DEPTH - 1}`);
if (deepest) {
expect(deepest.mro.length).toBe(DEPTH - 1);