perf(javascript): defer ambiguity expansion in version comparisons (#2962)

perf(javascript): avoid quadratic temporary matching state

Repeated source groups can allocate candidates that later identity matches
discard. Retain the original ambiguous groups until all tiers finish, expand
only unmatched survivors, and append to locally owned key buckets in place.

Constraint: Preserve matching priority, full ambiguity, and public output.
Directive: Do not re-match residual groups that become unique after later tiers.
Tested: 59 relevant tests, npm run check, and test discovery on current main.
Tested: CLI benchmarks and full-suite failure comparison on the recorded base.
Not-tested: Full suite on the updated main; native Linux and Windows workflows.
This commit is contained in:
Shenrui Ma 2026-10-14 02:52:13 +08:00 committed by GitHub
parent 0e6eca4085
commit 388b907c92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 314 additions and 27 deletions

View File

@ -77,15 +77,23 @@ export const matchJavaScriptApplicationVersions = (
): ApplicationVersionMatchingProjection => {
const left = new Map(leftNodes.map((node) => [node.node_id, node]));
const right = new Map(rightNodes.map((node) => [node.node_id, node]));
const leftCandidates = new Map<string, Set<string>>();
const rightCandidates = new Map<string, Set<string>>();
const pairs: ApplicationVersionNodePair[] = [];
const ambiguousGroups: AmbiguousGroup[] = [];
for (const tier of matchTiers())
applyTier({ left, right, pairs, leftCandidates, rightCandidates, tier });
applyTier({ left, right, pairs, ambiguousGroups, tier });
const remainingLeft = sortedNodes(left.values());
const remainingRight = sortedNodes(right.values());
const leftIds = new Set(remainingLeft.map(({ node_id: id }) => id));
const rightIds = new Set(remainingRight.map(({ node_id: id }) => id));
const leftCandidates = new Map<string, Set<string>>();
const rightCandidates = new Map<string, Set<string>>();
// Later tiers can pair members of an ambiguous group. Expand only survivors,
// without pairing groups that became unique after those later matches.
for (const group of ambiguousGroups) {
const leftGroup = group.left.filter((node) => left.has(node.node_id));
if (leftGroup.length === 0) continue;
const rightGroup = group.right.filter((node) => right.has(node.node_id));
if (rightGroup.length === 0) continue;
recordCandidates(leftGroup, rightGroup, leftCandidates, rightCandidates);
}
return {
pairs: pairs.sort((a, b) =>
compareUnicodeCodePoints(
@ -95,17 +103,21 @@ export const matchJavaScriptApplicationVersions = (
),
unmatchedLeft: remainingLeft,
unmatchedRight: remainingRight,
candidatesForLeft: filteredCandidates(leftCandidates, rightIds),
candidatesForRight: filteredCandidates(rightCandidates, leftIds),
candidatesForLeft: sortedCandidates(leftCandidates),
candidatesForRight: sortedCandidates(rightCandidates),
};
};
interface AmbiguousGroup {
readonly left: readonly ApplicationNode[];
readonly right: readonly ApplicationNode[];
}
interface TierContext {
readonly left: Map<string, ApplicationNode>;
readonly right: Map<string, ApplicationNode>;
readonly pairs: ApplicationVersionNodePair[];
readonly leftCandidates: Map<string, Set<string>>;
readonly rightCandidates: Map<string, Set<string>>;
readonly ambiguousGroups: AmbiguousGroup[];
readonly tier: MatchTier;
}
@ -114,15 +126,12 @@ const applyTier = (context: TierContext): void => {
const rightByKey = nodesByKey(context.right.values(), context.tier.key);
for (const key of [...leftByKey.keys()].sort(compareUnicodeCodePoints)) {
const leftGroup = leftByKey.get(key) ?? [];
const rightGroup = rightByKey.get(key) ?? [];
if (rightGroup.length === 0) continue;
recordCandidates(
leftGroup,
rightGroup,
context.leftCandidates,
context.rightCandidates,
);
if (leftGroup.length !== 1 || rightGroup.length !== 1) continue;
const rightGroup = rightByKey.get(key);
if (rightGroup === undefined) continue;
if (leftGroup.length !== 1 || rightGroup.length !== 1) {
context.ambiguousGroups.push({ left: leftGroup, right: rightGroup });
continue;
}
const leftNode = leftGroup[0];
const rightNode = rightGroup[0];
if (leftNode === undefined || rightNode === undefined) continue;
@ -144,7 +153,9 @@ const nodesByKey = (
for (const node of nodes) {
const key = keyFor(node);
if (key === null) continue;
output.set(key, [...(output.get(key) ?? []), node]);
const group = output.get(key);
if (group === undefined) output.set(key, [node]);
else group.push(node);
}
return output;
};
@ -169,17 +180,14 @@ const recordCandidates = (
}
};
const filteredCandidates = (
const sortedCandidates = (
candidates: ReadonlyMap<string, ReadonlySet<string>>,
retained: ReadonlySet<string>,
): ReadonlyMap<string, readonly string[]> =>
new Map(
[...candidates.entries()].flatMap(([nodeId, values]) => {
const filtered = [...values]
.filter((value) => retained.has(value))
.sort(compareUnicodeCodePoints);
return filtered.length === 0 ? [] : [[nodeId, filtered] as const];
}),
[...candidates.entries()].map(([nodeId, values]) => [
nodeId,
[...values].sort(compareUnicodeCodePoints),
]),
);
const nodeIdentityKey = (node: ApplicationNode): string =>

View File

@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import {
artifactEvidence,
completeCoverage,
fixtureNode,
} from "./javascriptApplicationGraph.fixture.js";
import {
createJavaScriptApplicationGraph,
type JavaScriptApplicationGraph,
} from "./javascriptApplicationGraph.js";
import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js";
const ARTIFACT_SHA = "a".repeat(64);
const moduleNode = (name: string, source: string, structure?: string) =>
fixtureNode(
"javascript-module",
{
strategy: "canonical-path",
stability: "artifact-version",
artifact_sha256: ARTIFACT_SHA,
path: `${name}.js`,
},
name,
{
source_sha256: source.repeat(64),
...(structure === undefined
? {}
: {
structural_fingerprint_sha256: structure.repeat(64),
structural_fingerprint_algorithm: "fixture",
}),
},
artifactEvidence(ARTIFACT_SHA, `${name}.js`),
);
const graph = (nodes: ApplicationNode[]): JavaScriptApplicationGraph =>
createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids: nodes.map(({ node_id }) => node_id),
nodes,
edges: [],
coverage: completeCoverage,
limitations: [],
});
const compare = (
left: JavaScriptApplicationGraph,
right: JavaScriptApplicationGraph,
) =>
compareJavaScriptApplicationVersions({
left: {
evidenceId: `ev_${"1".repeat(64)}`,
rootArtifactSha256: ARTIFACT_SHA,
graph: left,
},
right: {
evidenceId: `ev_${"2".repeat(64)}`,
rootArtifactSha256: ARTIFACT_SHA,
graph: right,
},
leftNativeEvidence: [],
rightNativeEvidence: [],
});
describe("application version matching", () => {
it("does not revisit a source group that becomes unique after identity matching", () => {
const shared = moduleNode("shared", "1");
const leftOnly = moduleNode("left", "1");
const rightOnly = moduleNode("right", "1");
const result = compare(
graph([shared, leftOnly]),
graph([shared, rightOnly]),
);
expect(result.matching).toMatchObject({
exact_node_identity: 1,
exact_module_source_digest: 0,
ambiguous: 2,
unmatched: 0,
});
expect(
result.items.find(({ left_node_id }) => left_node_id === shared.node_id),
).toMatchObject({
right_node_id: shared.node_id,
match: { status: "matched", basis: "exact-node-identity" },
});
expect(
result.items.find(
({ left_node_id }) => left_node_id === leftOnly.node_id,
),
).toMatchObject({
status: "unknown",
right_node_id: null,
match: {
status: "ambiguous",
candidate_right_node_ids: [rightOnly.node_id],
},
});
expect(
result.items.find(
({ right_node_id }) => right_node_id === rightOnly.node_id,
),
).toMatchObject({
status: "unknown",
left_node_id: null,
match: {
status: "ambiguous",
candidate_left_node_ids: [leftOnly.node_id],
},
});
});
it("unions all unresolved source and structural candidates without forcing pairs", () => {
const a = moduleNode("a", "1", "a");
const b = moduleNode("b", "1", "a");
const c = moduleNode("c", "2", "a");
const x = moduleNode("x", "1", "a");
const y = moduleNode("y", "1", "b");
const z = moduleNode("z", "3", "a");
const left = graph([a, b, c]);
const right = graph([x, y, z]);
const result = compare(left, right);
expect(result.summary).toEqual({
unchanged: 0,
added: 0,
removed: 0,
changed: 0,
unknown: 6,
});
expect(result.matching).toMatchObject({ ambiguous: 6, unmatched: 0 });
for (const [node, candidates] of [
[a, [x, y, z]],
[b, [x, y, z]],
[c, [x, z]],
] as const) {
expect(
result.items.find(({ left_node_id }) => left_node_id === node.node_id)
?.match,
).toEqual({
status: "ambiguous",
basis: "none",
confidence: "unknown",
candidate_left_node_ids: [],
candidate_right_node_ids: candidates
.map(({ node_id }) => node_id)
.sort(),
});
}
for (const [node, candidates] of [
[x, [a, b, c]],
[y, [a, b]],
[z, [a, b, c]],
] as const) {
expect(
result.items.find(({ right_node_id }) => right_node_id === node.node_id)
?.match,
).toEqual({
status: "ambiguous",
basis: "none",
confidence: "unknown",
candidate_left_node_ids: candidates
.map(({ node_id }) => node_id)
.sort(),
candidate_right_node_ids: [],
});
}
// Permute the already validated graph so matching sees the changed order.
// Compare the full result, including item, comparison and change-graph IDs.
expect(
compare(
{ ...left, nodes: [...left.nodes].reverse() },
{ ...right, nodes: [...right.nodes].reverse() },
),
).toEqual(result);
});
});

View File

@ -0,0 +1,98 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"pairs repeated factory bodies by identity when application versions retain a shared bundle",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-version-matching-");
const factoryCount = 32;
const factories = Array.from(
{ length: factoryCount },
(_, index) => `${index}: function() {}`,
).join(",");
const bundle = `globalThis.webpackChunkApp.push([[1], {${factories}}]);`;
const versions = await Promise.all(
[1, 2].map(async (version) => {
const application = join(root, String(version));
await mkdir(application);
await writeFile(
join(application, "package.json"),
JSON.stringify({ name: "shared-bundle", version: `${version}.0.0` }),
);
await writeFile(join(application, "chunk.js"), bundle);
const response = await cli.run({
arguments: ["analyze-javascript-application", application, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
return parseEvidence(response.json);
}),
);
const [left, right] = versions;
if (left === undefined || right === undefined)
throw new Error("Missing application version Evidence");
const analyses = versions.map(({ normalized_result }) =>
javascriptApplicationAnalysisResultSchema.parse(normalized_result),
);
const factoryNodes = analyses.map(({ graph }) => {
expect(graph.coverage.status).toBe("complete");
const nodes = graph.nodes.filter(
({ kind, observations }) =>
kind === "javascript-module" &&
observations.some(
({ properties }) => properties.module_key !== undefined,
),
);
expect(nodes).toHaveLength(factoryCount);
const sourceDigests = nodes.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.source_sha256),
);
expect(sourceDigests.every((digest) => typeof digest === "string")).toBe(
true,
);
expect(new Set(sourceDigests).size).toBe(1);
return nodes;
});
const inputPath = join(root, "comparison.json");
await writeFile(inputPath, JSON.stringify({ left, right }));
const response = await cli.run({
arguments: ["compare-application-versions", inputPath, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
const result = applicationVersionComparisonResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
expect(result.left.root_artifact_sha256).not.toBe(
result.right.root_artifact_sha256,
);
expect(result.matching.ambiguous).toBe(0);
expect(result.coverage.status).toBe("complete-within-inputs");
expect(factoryNodes[0]?.map(({ node_id }) => node_id)).toEqual(
factoryNodes[1]?.map(({ node_id }) => node_id),
);
for (const node of factoryNodes[0] ?? []) {
expect(
result.items.find(({ left_node_id }) => left_node_id === node.node_id),
).toMatchObject({
status: "unchanged",
right_node_id: node.node_id,
match: {
status: "matched",
basis: "exact-node-identity",
candidate_left_node_ids: [],
candidate_right_node_ids: [],
},
});
}
},
);