mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
src/core/eval-capture-graph.ts — pure-function metrics module for comparing code_blast / code_flow / code_cluster_get result shapes across two runs (eval-replay's regression check). Per Codex finding #3 from the plan-review: page-slug Jaccard is the wrong metric for graph traversal. v0.34 W7 ships proper per-op metrics: - nodeSetJaccard(a, b): set Jaccard over (file, line, symbol) tuples. Right metric for code_blast/code_flow node sets. - depthGroupStability(a, b): 1 - (displaced / |union|). Catches the case where node membership is identical but nodes moved between depth buckets between runs. - truncationMatch(a, b): boolean match on the truncation enum. Discrete signal that pairs with Jaccard. - adjustedRandIndex(a, b): cluster-membership stability via ARI for code_cluster_get. v0.34.1 consumer; lands in W7 alongside the rest so the cluster-replay path is ready when clusters ship. - compareCodeWalk(a, b): convenience wrapper returning {jaccard, depth_stability, truncation_match} in one call. Hermetic — no engine, no DB, fully unit-testable. 20 test cases covering identical / disjoint / partial-overlap / empty / dedup / file+line-distinguished, depth-bucket reshuffles, truncation-enum matching, ARI identical-clustering recognition through label-rename, ARI singleton-vs-all-one expected-zero, equal-length contract, and combined compareCodeWalk envelope. Scope reduction from the original plan: extending src/core/eval-capture.ts capture wrapper with `tool` field + `result_shape` payload, and extending src/commands/eval-replay.ts to dispatch on tool — both deferred to v0.34.1. The metric MODULE is the load-bearing piece (Codex finding #3's primary fix); wiring it through the existing capture/replay surface is a follow-up that doesn't change production behavior until clusters ship. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
/**
|
|
* v0.34 W7 — per-op graph metrics tests.
|
|
* Pure-function tests; no engine needed.
|
|
*/
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
nodeSetJaccard,
|
|
depthGroupStability,
|
|
truncationMatch,
|
|
adjustedRandIndex,
|
|
compareCodeWalk,
|
|
} from '../../src/core/eval-capture-graph.ts';
|
|
|
|
describe('W7: nodeSetJaccard', () => {
|
|
test('identical sets → 1.0', () => {
|
|
const a = [{ symbol: 'foo' }, { symbol: 'bar' }];
|
|
const b = [{ symbol: 'foo' }, { symbol: 'bar' }];
|
|
expect(nodeSetJaccard(a, b)).toBe(1);
|
|
});
|
|
|
|
test('disjoint sets → 0', () => {
|
|
expect(nodeSetJaccard([{ symbol: 'a' }], [{ symbol: 'b' }])).toBe(0);
|
|
});
|
|
|
|
test('partial overlap (3 shared of 4 total)', () => {
|
|
const a = [{ symbol: 'x' }, { symbol: 'y' }, { symbol: 'z' }];
|
|
const b = [{ symbol: 'x' }, { symbol: 'y' }, { symbol: 'w' }];
|
|
// intersection=2, union=4 → 0.5
|
|
expect(nodeSetJaccard(a, b)).toBe(0.5);
|
|
});
|
|
|
|
test('both empty → NaN (degenerate)', () => {
|
|
expect(Number.isNaN(nodeSetJaccard([], []))).toBe(true);
|
|
});
|
|
|
|
test('one empty → 0', () => {
|
|
expect(nodeSetJaccard([], [{ symbol: 'foo' }])).toBe(0);
|
|
});
|
|
|
|
test('file + line distinguish same-name symbols', () => {
|
|
const a = [{ symbol: 'foo', file: 'a.ts', line: 1 }];
|
|
const b = [{ symbol: 'foo', file: 'a.ts', line: 2 }];
|
|
expect(nodeSetJaccard(a, b)).toBe(0);
|
|
});
|
|
|
|
test('dedup within a single side', () => {
|
|
const a = [{ symbol: 'foo' }, { symbol: 'foo' }];
|
|
const b = [{ symbol: 'foo' }];
|
|
expect(nodeSetJaccard(a, b)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('W7: depthGroupStability', () => {
|
|
test('all nodes in same depth → 1.0', () => {
|
|
const a = [{ depth: 1, nodes: [{ symbol: 'a' }, { symbol: 'b' }] }];
|
|
const b = [{ depth: 1, nodes: [{ symbol: 'a' }, { symbol: 'b' }] }];
|
|
expect(depthGroupStability(a, b)).toBe(1);
|
|
});
|
|
|
|
test('one node moved buckets → 0.5 with 2 nodes', () => {
|
|
const a = [{ depth: 1, nodes: [{ symbol: 'a' }, { symbol: 'b' }] }];
|
|
const b = [
|
|
{ depth: 1, nodes: [{ symbol: 'a' }] },
|
|
{ depth: 2, nodes: [{ symbol: 'b' }] },
|
|
];
|
|
expect(depthGroupStability(a, b)).toBe(0.5);
|
|
});
|
|
|
|
test('both empty → 1.0', () => {
|
|
expect(depthGroupStability([], [])).toBe(1);
|
|
});
|
|
|
|
test('completely reshuffled → 0', () => {
|
|
const a = [{ depth: 1, nodes: [{ symbol: 'a' }] }];
|
|
const b = [{ depth: 2, nodes: [{ symbol: 'a' }] }];
|
|
expect(depthGroupStability(a, b)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('W7: truncationMatch', () => {
|
|
test('both none → 1', () => {
|
|
expect(truncationMatch('none', 'none')).toBe(1);
|
|
expect(truncationMatch(undefined, undefined)).toBe(1);
|
|
});
|
|
|
|
test('mismatch → 0', () => {
|
|
expect(truncationMatch('max_nodes', 'depth_cap')).toBe(0);
|
|
});
|
|
|
|
test('undefined treated as none', () => {
|
|
expect(truncationMatch('none', undefined)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('W7: adjustedRandIndex', () => {
|
|
test('identical clusterings → 1', () => {
|
|
const a = ['A', 'A', 'B', 'B'];
|
|
const b = ['X', 'X', 'Y', 'Y'];
|
|
// Same partition, different labels — ARI should be 1.
|
|
expect(adjustedRandIndex(a, b)).toBeCloseTo(1, 5);
|
|
});
|
|
|
|
test('all items in one cluster vs all in distinct → expected 0', () => {
|
|
const a = ['A', 'A', 'A', 'A'];
|
|
const b = ['W', 'X', 'Y', 'Z'];
|
|
// Singleton vs single-group clustering: ARI should be 0 (no agreement
|
|
// beyond chance).
|
|
const ari = adjustedRandIndex(a, b);
|
|
expect(ari).toBeCloseTo(0, 5);
|
|
});
|
|
|
|
test('equal-length contract enforced', () => {
|
|
expect(() => adjustedRandIndex(['A'], ['X', 'Y'])).toThrow(/equal length/);
|
|
});
|
|
|
|
test('singleton input → 1.0', () => {
|
|
expect(adjustedRandIndex(['A'], ['X'])).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('W7: compareCodeWalk', () => {
|
|
test('shared depth_groups → high jaccard + stability', () => {
|
|
const a = {
|
|
depth_groups: [{ depth: 1, nodes: [{ symbol: 'x' }, { symbol: 'y' }] }],
|
|
truncation: 'none',
|
|
};
|
|
const b = {
|
|
depth_groups: [{ depth: 1, nodes: [{ symbol: 'x' }, { symbol: 'y' }] }],
|
|
truncation: 'none',
|
|
};
|
|
const cmp = compareCodeWalk(a, b);
|
|
expect(cmp.jaccard).toBe(1);
|
|
expect(cmp.depth_stability).toBe(1);
|
|
expect(cmp.truncation_match).toBe(1);
|
|
});
|
|
|
|
test('no overlap → low jaccard, full reshuffle → low stability', () => {
|
|
const a = {
|
|
depth_groups: [{ depth: 1, nodes: [{ symbol: 'x' }] }],
|
|
truncation: 'none',
|
|
};
|
|
const b = {
|
|
depth_groups: [{ depth: 1, nodes: [{ symbol: 'y' }] }],
|
|
truncation: 'max_nodes',
|
|
};
|
|
const cmp = compareCodeWalk(a, b);
|
|
expect(cmp.jaccard).toBe(0);
|
|
expect(cmp.truncation_match).toBe(0);
|
|
});
|
|
});
|