Skip to content

Commit ac5458a

Browse files
that-github-userunknownclaude
authored
Improve convergence analysis with diff-content comparison (#17)
- Add diff parser: extract added/removed lines per file from unified diffs - Compute Jaccard similarity on added lines for pairwise agent comparison - Single-linkage clustering groups agents by diff similarity (threshold 0.3) - Convergence score now combines group ratio (50%) + avg diff similarity (50%) - 10 new tests for diff parsing, similarity, pairwise comparison - Updated convergence tests to use realistic diff content Closes #4 Co-authored-by: unknown <that-github-user@github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8437f9f commit ac5458a

4 files changed

Lines changed: 366 additions & 62 deletions

File tree

src/scoring/convergence.test.ts

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,34 @@ import { describe, it } from "node:test";
33
import type { AgentResult } from "../types.js";
44
import { analyzeConvergence, recommend } from "./convergence.js";
55

6+
const DIFF_A = `diff --git a/a.ts b/a.ts
7+
--- a/a.ts
8+
+++ b/a.ts
9+
@@ -1 +1 @@
10+
+const x = 1;`;
11+
12+
const DIFF_A_VARIANT = `diff --git a/a.ts b/a.ts
13+
--- a/a.ts
14+
+++ b/a.ts
15+
@@ -1 +1 @@
16+
+const x = 1;
17+
+const extra = true;`;
18+
19+
const DIFF_B = `diff --git a/b.ts b/b.ts
20+
--- a/b.ts
21+
+++ b/b.ts
22+
@@ -1 +1 @@
23+
+const y = 2;`;
24+
625
function makeAgent(overrides: Partial<AgentResult> & { id: number }): AgentResult {
726
return {
827
worktree: `/tmp/agent-${overrides.id}`,
928
status: "success",
1029
exitCode: 0,
1130
duration: 5000,
1231
output: "",
13-
diff: "some diff",
14-
filesChanged: ["src/index.ts"],
32+
diff: DIFF_A,
33+
filesChanged: ["a.ts"],
1534
linesAdded: 10,
1635
linesRemoved: 5,
1736
...overrides,
@@ -33,44 +52,41 @@ describe("analyzeConvergence", () => {
3352
assert.deepEqual(result, []);
3453
});
3554

36-
it("groups agents that changed the same files", () => {
55+
it("groups agents with similar diffs together", () => {
3756
const agents = [
38-
makeAgent({ id: 1, filesChanged: ["a.ts", "b.ts"] }),
39-
makeAgent({ id: 2, filesChanged: ["a.ts", "b.ts"] }),
40-
makeAgent({ id: 3, filesChanged: ["c.ts"] }),
57+
makeAgent({ id: 1, diff: DIFF_A, filesChanged: ["a.ts"] }),
58+
makeAgent({ id: 2, diff: DIFF_A, filesChanged: ["a.ts"] }),
59+
makeAgent({ id: 3, diff: DIFF_B, filesChanged: ["b.ts"] }),
4160
];
4261
const groups = analyzeConvergence(agents);
4362

4463
assert.equal(groups.length, 2);
45-
assert.deepEqual(groups[0]!.agents, [1, 2]);
46-
assert.ok(groups[0]!.similarity > groups[1]!.similarity);
47-
assert.deepEqual(groups[1]!.agents, [3]);
64+
// Agents 1,2 have identical diffs — should be in the same group
65+
const largestGroup = groups[0]!;
66+
assert.ok(largestGroup.agents.includes(1));
67+
assert.ok(largestGroup.agents.includes(2));
68+
assert.ok(largestGroup.similarity > groups[1]!.similarity);
4869
});
4970

50-
it("handles file order differences", () => {
51-
const agents = [
52-
makeAgent({ id: 1, filesChanged: ["b.ts", "a.ts"] }),
53-
makeAgent({ id: 2, filesChanged: ["a.ts", "b.ts"] }),
54-
];
71+
it("clusters agents with identical diffs", () => {
72+
const agents = [makeAgent({ id: 1, diff: DIFF_A }), makeAgent({ id: 2, diff: DIFF_A })];
5573
const groups = analyzeConvergence(agents);
5674

5775
assert.equal(groups.length, 1);
58-
assert.deepEqual(groups[0]!.agents, [1, 2]);
59-
assert.equal(groups[0]!.similarity, 1);
76+
assert.deepEqual(groups[0]!.agents.sort(), [1, 2]);
6077
});
6178

62-
it("labels strong consensus at 80%+", () => {
79+
it("labels strong consensus correctly", () => {
6380
const agents = [
64-
makeAgent({ id: 1, filesChanged: ["a.ts"] }),
65-
makeAgent({ id: 2, filesChanged: ["a.ts"] }),
66-
makeAgent({ id: 3, filesChanged: ["a.ts"] }),
67-
makeAgent({ id: 4, filesChanged: ["a.ts"] }),
68-
makeAgent({ id: 5, filesChanged: ["b.ts"] }),
81+
makeAgent({ id: 1, diff: DIFF_A }),
82+
makeAgent({ id: 2, diff: DIFF_A }),
83+
makeAgent({ id: 3, diff: DIFF_A }),
84+
makeAgent({ id: 4, diff: DIFF_A }),
85+
makeAgent({ id: 5, diff: DIFF_B, filesChanged: ["b.ts"] }),
6986
];
7087
const groups = analyzeConvergence(agents);
7188

7289
assert.ok(groups[0]!.description.includes("Strong consensus"));
73-
assert.ok(groups[1]!.description.includes("Divergent"));
7490
});
7591
});
7692

@@ -82,8 +98,8 @@ describe("recommend", () => {
8298

8399
it("prefers agents that pass tests", () => {
84100
const agents = [
85-
makeAgent({ id: 1, linesAdded: 20, linesRemoved: 10 }),
86-
makeAgent({ id: 2, linesAdded: 5, linesRemoved: 2 }),
101+
makeAgent({ id: 1, diff: DIFF_A, linesAdded: 20, linesRemoved: 10 }),
102+
makeAgent({ id: 2, diff: DIFF_B, linesAdded: 5, linesRemoved: 2, filesChanged: ["b.ts"] }),
87103
];
88104
const tests = [
89105
{ agentId: 1, passed: true },
@@ -96,9 +112,15 @@ describe("recommend", () => {
96112

97113
it("prefers agents in larger convergence group when tests are equal", () => {
98114
const agents = [
99-
makeAgent({ id: 1, filesChanged: ["a.ts"], linesAdded: 10, linesRemoved: 5 }),
100-
makeAgent({ id: 2, filesChanged: ["a.ts"], linesAdded: 10, linesRemoved: 5 }),
101-
makeAgent({ id: 3, filesChanged: ["b.ts"], linesAdded: 10, linesRemoved: 5 }),
115+
makeAgent({ id: 1, diff: DIFF_A, filesChanged: ["a.ts"], linesAdded: 10, linesRemoved: 5 }),
116+
makeAgent({ id: 2, diff: DIFF_A, filesChanged: ["a.ts"], linesAdded: 10, linesRemoved: 5 }),
117+
makeAgent({
118+
id: 3,
119+
diff: DIFF_B,
120+
filesChanged: ["b.ts"],
121+
linesAdded: 10,
122+
linesRemoved: 5,
123+
}),
102124
];
103125
const tests = [
104126
{ agentId: 1, passed: true },
@@ -114,12 +136,11 @@ describe("recommend", () => {
114136

115137
it("prefers smaller diffs as tiebreaker", () => {
116138
const agents = [
117-
makeAgent({ id: 1, filesChanged: ["a.ts"], linesAdded: 50, linesRemoved: 20 }),
118-
makeAgent({ id: 2, filesChanged: ["a.ts"], linesAdded: 5, linesRemoved: 2 }),
139+
makeAgent({ id: 1, diff: DIFF_A, linesAdded: 50, linesRemoved: 20 }),
140+
makeAgent({ id: 2, diff: DIFF_A, linesAdded: 5, linesRemoved: 2 }),
119141
];
120142
const convergence = analyzeConvergence(agents);
121143

122-
// No test results — convergence is equal, so diff size decides
123144
assert.equal(recommend(agents, [], convergence), 2);
124145
});
125146
});

src/scoring/convergence.ts

Lines changed: 99 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,124 @@
11
import type { AgentResult, ConvergenceGroup } from "../types.js";
2+
import { pairwiseSimilarity } from "./diff-parser.js";
23

34
/**
4-
* Analyze convergence across agent results by comparing which files
5-
* each agent changed. Agents that changed the same set of files are
6-
* grouped together — a larger group = higher confidence.
5+
* Analyze convergence across agent results using two levels:
6+
* 1. File-level: which files did each agent change?
7+
* 2. Diff-level: how similar are the actual changes? (Jaccard on added lines)
8+
*
9+
* Agents are clustered by diff similarity using single-linkage clustering
10+
* with a 0.5 similarity threshold.
711
*/
812
export function analyzeConvergence(agents: AgentResult[]): ConvergenceGroup[] {
913
const completed = agents.filter((a) => a.status === "success" && a.diff.length > 0);
1014

1115
if (completed.length === 0) return [];
1216

13-
// Group by file-change fingerprint
14-
const fingerprints = new Map<string, number[]>();
15-
16-
for (const agent of completed) {
17-
const key = [...agent.filesChanged].sort().join("|");
18-
const group = fingerprints.get(key) ?? [];
19-
group.push(agent.id);
20-
fingerprints.set(key, group);
21-
}
22-
23-
// Convert to convergence groups, sorted by size (largest first)
24-
const groups: ConvergenceGroup[] = [];
17+
// Compute pairwise diff similarity
18+
const similarities = pairwiseSimilarity(completed.map((a) => ({ id: a.id, diff: a.diff })));
19+
20+
// Single-linkage clustering: merge agents with similarity >= threshold
21+
const SIMILARITY_THRESHOLD = 0.3;
22+
const clusters = clusterAgents(
23+
completed.map((a) => a.id),
24+
similarities,
25+
SIMILARITY_THRESHOLD,
26+
);
27+
28+
// Convert clusters to convergence groups
29+
const groups: ConvergenceGroup[] = clusters.map((cluster) => {
30+
// Average similarity within cluster
31+
let totalSim = 0;
32+
let pairs = 0;
33+
for (let i = 0; i < cluster.length; i++) {
34+
for (let j = i + 1; j < cluster.length; j++) {
35+
const key = `${Math.min(cluster[i]!, cluster[j]!)}-${Math.max(cluster[i]!, cluster[j]!)}`;
36+
totalSim += similarities.get(key) ?? 0;
37+
pairs++;
38+
}
39+
}
40+
const avgSimilarity = pairs > 0 ? totalSim / pairs : 1;
41+
const groupRatio = cluster.length / completed.length;
42+
43+
// Collect files changed by this cluster
44+
const filesSet = new Set<string>();
45+
for (const agentId of cluster) {
46+
const agent = completed.find((a) => a.id === agentId);
47+
if (agent) {
48+
for (const f of agent.filesChanged) filesSet.add(f);
49+
}
50+
}
2551

26-
for (const [key, agentIds] of fingerprints) {
27-
const files = key.split("|").filter(Boolean);
28-
const similarity = agentIds.length / completed.length;
52+
// Combine file-level ratio with diff-level similarity for final score
53+
const similarity = groupRatio * 0.5 + avgSimilarity * 0.5;
2954

3055
let description: string;
31-
if (similarity >= 0.8) {
32-
description = `Strong consensus — ${agentIds.length}/${completed.length} agents changed the same files`;
33-
} else if (similarity >= 0.5) {
34-
description = `Moderate agreement — ${agentIds.length}/${completed.length} agents took a similar approach`;
56+
if (groupRatio >= 0.8 && avgSimilarity >= 0.5) {
57+
description = `Strong consensus — ${cluster.length}/${completed.length} agents made similar changes`;
58+
} else if (groupRatio >= 0.5 || avgSimilarity >= 0.5) {
59+
description = `Moderate agreement — ${cluster.length}/${completed.length} agents took a similar approach`;
3560
} else {
36-
description = `Divergent approach — ${agentIds.length}/${completed.length} agents went a different direction`;
61+
description = `Divergent approach — ${cluster.length}/${completed.length} agents went a different direction`;
3762
}
3863

39-
groups.push({
40-
agents: agentIds,
64+
return {
65+
agents: cluster,
4166
similarity,
42-
filesChanged: files,
67+
filesChanged: [...filesSet].sort(),
4368
description,
44-
});
45-
}
69+
};
70+
});
4671

4772
groups.sort((a, b) => b.similarity - a.similarity);
48-
4973
return groups;
5074
}
5175

76+
/**
77+
* Single-linkage clustering. Two agents are in the same cluster if
78+
* ANY pair within the cluster has similarity >= threshold.
79+
*/
80+
function clusterAgents(
81+
agentIds: number[],
82+
similarities: Map<string, number>,
83+
threshold: number,
84+
): number[][] {
85+
// Union-Find
86+
const parent = new Map<number, number>();
87+
for (const id of agentIds) parent.set(id, id);
88+
89+
function find(x: number): number {
90+
let root = x;
91+
while (parent.get(root) !== root) root = parent.get(root)!;
92+
parent.set(x, root); // path compression
93+
return root;
94+
}
95+
96+
function union(a: number, b: number): void {
97+
const ra = find(a);
98+
const rb = find(b);
99+
if (ra !== rb) parent.set(ra, rb);
100+
}
101+
102+
// Merge agents with similarity >= threshold
103+
for (const [key, sim] of similarities) {
104+
if (sim >= threshold) {
105+
const [a, b] = key.split("-").map(Number) as [number, number];
106+
union(a, b);
107+
}
108+
}
109+
110+
// Collect clusters
111+
const clusters = new Map<number, number[]>();
112+
for (const id of agentIds) {
113+
const root = find(id);
114+
const cluster = clusters.get(root) ?? [];
115+
cluster.push(id);
116+
clusters.set(root, cluster);
117+
}
118+
119+
return [...clusters.values()];
120+
}
121+
52122
/**
53123
* Recommend the best agent based on test results and convergence.
54124
* Priority: passing tests > convergence group size > smaller diff.
@@ -61,7 +131,6 @@ export function recommend(
61131
const completed = agents.filter((a) => a.status === "success" && a.diff.length > 0);
62132
if (completed.length === 0) return null;
63133

64-
// Score each agent
65134
const scores = new Map<number, number>();
66135

67136
for (const agent of completed) {
@@ -83,7 +152,6 @@ export function recommend(
83152
scores.set(agent.id, score);
84153
}
85154

86-
// Return highest-scoring agent
87155
let bestId: number | null = null;
88156
let bestScore = -1;
89157
for (const [id, score] of scores) {

0 commit comments

Comments
 (0)