/** * v0.34 W7 — per-op graph-traversal metrics. * * Pure-function module — fully unit-testable, no engine needed. * * Metrics: * - nodeSetJaccard: |A ∩ B| / |A ∪ B| over (file, line, symbol) tuples. * Right metric for code_blast / code_flow node sets (NOT page-slug * Jaccard — that's wrong for graph ops, per Codex finding #3 from the * plan-review). * - depthGroupStability: 1 if every node lands in the same depth bucket * across runs; degraded by 1/N per displaced node where N = |union|. * - truncationMatch: 1 if both runs hit the same truncation enum value, * 0 otherwise. Discrete; reported alongside Jaccard. * - adjustedRandIndex: cluster-membership stability for code_cluster_get * via Adjusted Rand Index. Returns [-1, 1]; 1 = identical clusterings, * 0 = no agreement beyond chance, negative = worse than chance. v0.34.1 * consumer (when clusters ship). */ export interface GraphNode { symbol: string; /** Optional location coords for tighter dedup. */ file?: string; line?: number; } /** Canonicalize a node to a hashable key for set ops. */ function nodeKey(n: GraphNode): string { return `${n.symbol}::${n.file ?? ''}::${n.line ?? ''}`; } /** * Set-Jaccard over node tuples. Returns NaN on empty union (degenerate * case — caller decides how to score "both runs returned nothing"). */ export function nodeSetJaccard(a: readonly GraphNode[], b: readonly GraphNode[]): number { const aSet = new Set(a.map(nodeKey)); const bSet = new Set(b.map(nodeKey)); if (aSet.size === 0 && bSet.size === 0) return Number.NaN; let intersection = 0; for (const k of aSet) if (bSet.has(k)) intersection += 1; const union = aSet.size + bSet.size - intersection; return union === 0 ? 0 : intersection / union; } /** * Did every node land in the same depth bucket across the two runs? A * node that moved from depth 1 → depth 2 between runs counts as one * displaced node. Returns 1 - (displaced / union). Returns 1.0 when * both runs are empty. */ export function depthGroupStability( a: ReadonlyArray<{ depth: number; nodes: readonly GraphNode[] }>, b: ReadonlyArray<{ depth: number; nodes: readonly GraphNode[] }>, ): number { const aMap = new Map(); const bMap = new Map(); for (const g of a) for (const n of g.nodes) aMap.set(nodeKey(n), g.depth); for (const g of b) for (const n of g.nodes) bMap.set(nodeKey(n), g.depth); const union = new Set([...aMap.keys(), ...bMap.keys()]); if (union.size === 0) return 1.0; let displaced = 0; for (const k of union) { const da = aMap.get(k); const db = bMap.get(k); if (da !== db) displaced += 1; } return 1 - displaced / union.size; } /** * Boolean truncation-enum match. 1 if both runs hit the same truncation * value (none / max_nodes / depth_cap / both); 0 otherwise. */ export function truncationMatch( a: string | undefined, b: string | undefined, ): number { return (a ?? 'none') === (b ?? 'none') ? 1 : 0; } /** * Adjusted Rand Index for two clusterings of the same item set. * * `assignmentA` and `assignmentB` are arrays of cluster labels indexed * by item position. Items must appear in the same order across both * inputs (caller is responsible for the ordering). * * Returns a value in [-1, 1]: * 1 = identical clusterings * 0 = clusterings agree no more than chance * <0 = clusterings agree less than chance (rare) * * Implementation: standard pairwise-agreement formulation. O(n²) on * pair counts but n is typically <500 chunks so this is fine. */ export function adjustedRandIndex( assignmentA: readonly (string | number)[], assignmentB: readonly (string | number)[], ): number { if (assignmentA.length !== assignmentB.length) { throw new Error('adjustedRandIndex: assignmentA and assignmentB must have equal length'); } const n = assignmentA.length; if (n < 2) return 1.0; // Build contingency table. const labelsA = new Map(); const labelsB = new Map(); for (let i = 0; i < n; i++) { labelsA.set(assignmentA[i]!, (labelsA.get(assignmentA[i]!) ?? 0) + 1); labelsB.set(assignmentB[i]!, (labelsB.get(assignmentB[i]!) ?? 0) + 1); } // Co-occurrence count. const coKey = (a: string | number, b: string | number) => `${a}${b}`; const co = new Map(); for (let i = 0; i < n; i++) { const k = coKey(assignmentA[i]!, assignmentB[i]!); co.set(k, (co.get(k) ?? 0) + 1); } // Pairwise sums. const choose2 = (k: number) => (k * (k - 1)) / 2; let sumCo = 0; for (const v of co.values()) sumCo += choose2(v); let sumA = 0; for (const v of labelsA.values()) sumA += choose2(v); let sumB = 0; for (const v of labelsB.values()) sumB += choose2(v); const totalPairs = choose2(n); if (totalPairs === 0) return 1.0; const expectedIndex = (sumA * sumB) / totalPairs; const maxIndex = (sumA + sumB) / 2; const denominator = maxIndex - expectedIndex; if (denominator === 0) return 0; return (sumCo - expectedIndex) / denominator; } /** * Combined v0.34 result-shape compare. Returns an object with all * applicable metrics for the given op tool. The caller picks which * metric to report. */ export interface CodeWalkResultShape { result?: string; depth_groups?: Array<{ depth: number; nodes: GraphNode[] }>; truncation?: string; } export interface CodeWalkComparison { jaccard: number; depth_stability: number; truncation_match: number; } export function compareCodeWalk( a: CodeWalkResultShape, b: CodeWalkResultShape, ): CodeWalkComparison { const aNodes = (a.depth_groups ?? []).flatMap((g) => g.nodes); const bNodes = (b.depth_groups ?? []).flatMap((g) => g.nodes); return { jaccard: nodeSetJaccard(aNodes, bNodes), depth_stability: depthGroupStability(a.depth_groups ?? [], b.depth_groups ?? []), truncation_match: truncationMatch(a.truncation, b.truncation), }; }