Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ program
.option("--no-color", "Disable colored output")
.option("--output-format <format>", "Output format: text (default) or json", "text")
.option("--verbose", "Show detailed output from each agent")
.option("--whitespace-insensitive", "Ignore whitespace differences in convergence comparison")
.option("--retry", "Re-run only failed/timed-out agents from the last run")
.action(async (promptArg: string | undefined, opts) => {
const testTimeout = parseInt(opts.testTimeout, 10);
Expand Down Expand Up @@ -100,6 +101,7 @@ program
verbose: opts.verbose ?? false,
outputFormat: opts.outputFormat,
retry: true,
whitespaceInsensitive: opts.whitespaceInsensitive ?? false,
});
return;
}
Expand Down Expand Up @@ -131,6 +133,7 @@ program
scoring: opts.scoring,
verbose: opts.verbose ?? false,
outputFormat: opts.outputFormat,
whitespaceInsensitive: opts.whitespaceInsensitive ?? false,
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export async function retry(opts: RunOptions): Promise<void> {
}

// Phase 5: Convergence analysis on full merged set
const convergence = analyzeConvergence(mergedAgents, opts.threshold);
const convergence = analyzeConvergence(mergedAgents, opts.threshold, opts.whitespaceInsensitive);

// Phase 6: Recommendation
const { recommended: weightedRec, scores } = recommend(mergedAgents, testResults, convergence);
Expand Down Expand Up @@ -346,7 +346,7 @@ export async function run(opts: RunOptions): Promise<void> {
}

// Phase 4: Convergence analysis
const convergence = analyzeConvergence(agents, opts.threshold);
const convergence = analyzeConvergence(agents, opts.threshold, opts.whitespaceInsensitive);

// Phase 5: Recommendation
const { recommended: weightedRec, scores } = recommend(agents, testResults, convergence);
Expand Down
11 changes: 9 additions & 2 deletions src/scoring/convergence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ import { pairwiseSimilarity } from "./diff-parser.js";
* Agents are clustered by diff similarity using single-linkage clustering
* with a 0.5 similarity threshold.
*/
export function analyzeConvergence(agents: AgentResult[], threshold = 0.3): ConvergenceGroup[] {
export function analyzeConvergence(
agents: AgentResult[],
threshold = 0.3,
whitespaceInsensitive = false,
): ConvergenceGroup[] {
const completed = agents.filter((a) => a.status === "success" && a.diff.length > 0);

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

// Compute pairwise diff similarity
const similarities = pairwiseSimilarity(completed.map((a) => ({ id: a.id, diff: a.diff })));
const similarities = pairwiseSimilarity(
completed.map((a) => ({ id: a.id, diff: a.diff })),
whitespaceInsensitive,
);

// Single-linkage clustering: merge agents with similarity >= threshold
const clusters = clusterAgents(
Expand Down
59 changes: 59 additions & 0 deletions src/scoring/diff-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,65 @@ describe("diffSimilarity", () => {
it("returns 1 for two empty diffs", () => {
assert.equal(diffSimilarity("", ""), 1);
});

it("treats reformatted code as different by default", () => {
const diffA = `diff --git a/a.ts b/a.ts
--- a/a.ts
+++ b/a.ts
@@ -1 +1 @@
+if (x) { return true; }`;
const diffB = `diff --git a/a.ts b/a.ts
--- a/a.ts
+++ b/a.ts
@@ -1 +1 @@
+if (x) { return true; }`;
const sim = diffSimilarity(diffA, diffB);
assert.ok(sim < 1, "default mode should see whitespace differences");
});

it("whitespace-insensitive mode treats reformatted code as identical", () => {
const diffA = `diff --git a/a.ts b/a.ts
--- a/a.ts
+++ b/a.ts
@@ -1 +1 @@
+if (x) { return true; }`;
const diffB = `diff --git a/a.ts b/a.ts
--- a/a.ts
+++ b/a.ts
@@ -1 +1 @@
+if (x) { return true; }`;
assert.equal(diffSimilarity(diffA, diffB, true), 1);
});

it("whitespace-insensitive mode normalizes indentation differences", () => {
const diffA = `diff --git a/a.ts b/a.ts
--- a/a.ts
+++ b/a.ts
@@ -1 +1 @@
+ const x = 1;
+ const y = 2;`;
const diffB = `diff --git a/a.ts b/a.ts
--- a/a.ts
+++ b/a.ts
@@ -1 +1 @@
+ const x = 1;
+ const y = 2;`;
assert.equal(diffSimilarity(diffA, diffB, true), 1);
});

it("whitespace-insensitive mode still detects real code differences", () => {
const diffA = `diff --git a/a.ts b/a.ts
--- a/a.ts
+++ b/a.ts
@@ -1 +1 @@
+const x = 1;`;
const diffB = `diff --git a/a.ts b/a.ts
--- a/a.ts
+++ b/a.ts
@@ -1 +1 @@
+const y = 2;`;
assert.equal(diffSimilarity(diffA, diffB, true), 0);
});
});

describe("pairwiseSimilarity", () => {
Expand Down
21 changes: 17 additions & 4 deletions src/scoring/diff-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,23 @@ export function parseDiff(diff: string): DiffFile[] {
return files;
}

function normalizeLine(line: string, whitespaceInsensitive: boolean): string {
const trimmed = line.trim();
return whitespaceInsensitive ? trimmed.replace(/\s+/g, " ") : trimmed;
}

/**
* Compute similarity between two diffs using Jaccard similarity
* on the set of added lines. Returns 0-1 where 1 = identical changes.
*
* When whitespaceInsensitive is true, interior whitespace is collapsed
* before comparison so formatting-only differences don't lower scores.
*/
export function diffSimilarity(diffA: string, diffB: string): number {
export function diffSimilarity(
diffA: string,
diffB: string,
whitespaceInsensitive = false,
): number {
const filesA = parseDiff(diffA);
const filesB = parseDiff(diffB);

Expand All @@ -68,12 +80,12 @@ export function diffSimilarity(diffA: string, diffB: string): number {

for (const f of filesA) {
for (const line of f.addedLines) {
setA.add(`${f.path}:${line.trim()}`);
setA.add(`${f.path}:${normalizeLine(line, whitespaceInsensitive)}`);
}
}
for (const f of filesB) {
for (const line of f.addedLines) {
setB.add(`${f.path}:${line.trim()}`);
setB.add(`${f.path}:${normalizeLine(line, whitespaceInsensitive)}`);
}
}

Expand All @@ -95,14 +107,15 @@ export function diffSimilarity(diffA: string, diffB: string): number {
*/
export function pairwiseSimilarity(
agents: Array<{ id: number; diff: string }>,
whitespaceInsensitive = false,
): Map<string, number> {
const matrix = new Map<string, number>();

for (let i = 0; i < agents.length; i++) {
for (let j = i + 1; j < agents.length; j++) {
const a = agents[i]!;
const b = agents[j]!;
const sim = diffSimilarity(a.diff, b.diff);
const sim = diffSimilarity(a.diff, b.diff, whitespaceInsensitive);
matrix.set(`${a.id}-${b.id}`, sim);
}
}
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface RunOptions {
scoring: "weighted" | "copeland";
outputFormat: "text" | "json";
retry?: boolean;
whitespaceInsensitive?: boolean;
}

export interface AgentResult {
Expand Down
Loading