fix(javascript): index repeated graph observations before final sorting

Retain stable live nodes and observation arrays while avoiding quadratic
collection rebuilds. Preserve duplicate handling and canonical output.

Fixes #1748

Assisted-by: Suika (gensokyo-skills:suika-scatter-gather-planning)
This commit is contained in:
Ysopwnable authored and GitHub committed 2026-10-11 16:16:21 +08:00
1 parent 713db4dc26
commit 6d5d71fab5
2 files changed
+162 -26

No files matched your search

@@ -6,40 +6,62 @@ import type {
ApplicationEdge,
ApplicationNode,
} from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
/** Deduplicate graph entities while retaining distinct evidence observations. */
/**
* Own live construction nodes and deduplicate their evidence observations.
* Consumers retain references but must not mutate the owned observations;
* nodes() finalizes their canonical order before graph validation/output.
*/
export class JavaScriptArtifactGraphAccumulator {
readonly #nodes = new Map<string, ApplicationNode>();
readonly #nodes = new Map<string, AccumulatedNode>();
readonly #edges = new Map<string, ApplicationEdge>();
/** Create or merge one canonical node, retaining a stable live construction reference. */
/** Merge observations into a stable live node; order is finalized by nodes(). */
addNode<Input extends DisplayableNodeInput>(input: Input): ApplicationNode {
const created = createJavaScriptApplicationNode(displayableNode(input));
const existing = this.#nodes.get(created.node_id);
if (existing === undefined) {
this.#nodes.set(created.node_id, created);
this.#nodes.set(created.node_id, {
node: created,
observationIndices: new Map(
created.observations.map((observation, index) => [
observation.observation_id,
index,
]),
),
dirty: false,
});
return created;
}
const bySemanticId = new Map(
[...existing.observations, ...created.observations].map((observation) => [
observation.observation_id,
observation,
]),
);
const observations = [...bySemanticId.values()].sort((left, right) =>
left.observation_id < right.observation_id
? -1
: left.observation_id > right.observation_id
? 1
: 0,
);
// Both factories already validated and identified these observations.
// Replacing the node retained every previous observation array through
// path/chunk/IPC indexes when multiple paths shared an entity identity.
existing.observations.length = 0;
for (const observation of observations)
existing.observations.push(observation);
return existing;
const observations = existing.node.observations;
// The first factory input can contain duplicate IDs. As before, a later
// merge retains only their last occurrences without replacing the array.
if (existing.observationIndices.size !== observations.length) {
let retained = 0;
for (const [index, observation] of observations.entries()) {
if (
existing.observationIndices.get(observation.observation_id) !== index
)
continue;
observations[retained] = observation;
existing.observationIndices.set(observation.observation_id, retained);
retained += 1;
}
observations.length = retained;
}
for (const observation of created.observations) {
const index = existing.observationIndices.get(observation.observation_id);
if (index === undefined) {
existing.observationIndices.set(
observation.observation_id,
observations.length,
);
observations.push(observation);
existing.dirty = true;
} else observations[index] = observation;
}
return existing.node;
}
/** Create or replace one canonical edge by semantic identifier. */
@@ -49,9 +71,20 @@ export class JavaScriptArtifactGraphAccumulator {
return edge;
}
/** Return all accumulated canonical nodes. */
/** Finalize observation order in place and return all canonical nodes. */
nodes(): readonly ApplicationNode[] {
return [...this.#nodes.values()];
return [...this.#nodes.values()].map((entry) => {
if (entry.dirty) {
entry.node.observations.sort((left, right) =>
compareUnicodeCodePoints(left.observation_id, right.observation_id),
);
entry.observationIndices.clear();
for (const [index, observation] of entry.node.observations.entries())
entry.observationIndices.set(observation.observation_id, index);
entry.dirty = false;
}
return entry.node;
});
}
/** Return all accumulated canonical edges. */
@@ -60,6 +93,12 @@ export class JavaScriptArtifactGraphAccumulator {
}
}
interface AccumulatedNode {
readonly node: ApplicationNode;
readonly observationIndices: Map<string, number>;
dirty: boolean;
}
/** Node fields whose display form is normalized before validation. */
interface DisplayableNodeInput {
readonly observations: readonly { readonly label: string | null }[];