syntaxai/tdd.md · main · src / b32_sama_v2_metrics.ts
// b32 — logic: SAMA v2 §5 core metrics emitter. Pure function over
// SamaV2Input that returns the five §5 metrics (graphDepth, fanByLayer,
// boundaryRatio, workingSetFit, violationCounts). No I/O, no clock,
// no filesystem; same source tree + same profile → identical numbers.
//
// The empirical artefact §6 of /sama/v2 requires before any later
// claim (skeleton, agent experiment, external repo audit) can be
// measured as a delta. Operational definitions live on /sama/v2 §5.
//
// Shared helpers (declaredLayer, isSamaFile, collectRelativeImports,
// resolveImport, findParseBoundaryCallSites) come from a31_sama_v2 so
// this module and b32_sama_v2_verify agree by construction — the
// Modeled-boundary check (#4) and boundaryRatio metric consume the
// same detector and cannot diverge.
import {
WORKING_SET_MAX_LOC,
WORKING_SET_MIN_LOC,
collectRelativeImports,
declaredLayer,
findParseBoundaryCallSites,
isSamaFile,
resolveImport,
type FanByLayer,
type FanSummary,
type LayerNumber,
type SamaV2Input,
type SamaV2Metrics,
type SamaV2ViolationCounts,
} from "./a31_sama_v2.ts";
import { verifySamaV2 } from "./b32_sama_v2_verify.ts";
// — graphDepth ----------------------------------------------------
//
// Longest path in the import DAG. Nodes = SAMA source files (src/*.ts
// non-test); edges = static relative-path imports between them. A
// file with no imports has depth 1. Empty graph = 0.
//
// Memoised DFS. If a cycle is encountered (the Law check would flag
// it separately), we treat the back-edge target as a terminal of
// depth 1 so the metric still terminates with a finite number.
const computeGraphDepth = (files: Map<string, string>): number => {
const samaPaths = [...files.keys()].filter(isSamaFile);
if (samaPaths.length === 0) return 0;
// Build adjacency (only edges that land on known SAMA files).
const adj = new Map<string, string[]>();
for (const path of samaPaths) {
const content = files.get(path) ?? "";
const out: string[] = [];
for (const imp of collectRelativeImports(content)) {
const resolved = resolveImport(path, imp);
if (files.has(resolved) && isSamaFile(resolved)) out.push(resolved);
}
adj.set(path, out);
}
const memo = new Map<string, number>();
const visiting = new Set<string>();
const depth = (node: string): number => {
const cached = memo.get(node);
if (cached !== undefined) return cached;
if (visiting.has(node)) return 1; // cycle: treat as terminal
visiting.add(node);
let best = 1;
for (const next of adj.get(node) ?? []) {
const d = depth(next) + 1;
if (d > best) best = d;
}
visiting.delete(node);
memo.set(node, best);
return best;
};
let max = 0;
for (const p of samaPaths) {
const d = depth(p);
if (d > max) max = d;
}
return max;
};
// — fanByLayer ----------------------------------------------------
//
// Per canonical layer L ∈ {0,1,2,3}: fan-in (count of edges arriving
// at files in L) and fan-out (count of edges leaving files in L).
// Each summary = {mean, p50, p95, max} computed over the per-file
// series within L. Empty layer = all-zero summary.
const summarize = (values: number[]): FanSummary => {
if (values.length === 0) return { mean: 0, p50: 0, p95: 0, max: 0 };
const sorted = [...values].sort((a, b) => a - b);
const sum = sorted.reduce((s, v) => s + v, 0);
const mean = sum / sorted.length;
const percentile = (frac: number): number => {
// Nearest-rank percentile: index = ceil(frac * N) - 1, clamped.
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(frac * sorted.length) - 1));
return sorted[idx]!;
};
return {
mean,
p50: percentile(0.5),
p95: percentile(0.95),
max: sorted[sorted.length - 1]!,
};
};
const computeFanByLayer = (input: SamaV2Input): FanByLayer => {
const samaPaths = [...input.files.keys()].filter(isSamaFile);
const fanOut = new Map<string, number>();
const fanIn = new Map<string, number>();
for (const p of samaPaths) {
fanOut.set(p, 0);
fanIn.set(p, 0);
}
for (const path of samaPaths) {
const content = input.files.get(path) ?? "";
for (const imp of collectRelativeImports(content)) {
const resolved = resolveImport(path, imp);
if (!fanOut.has(resolved)) continue;
fanOut.set(path, (fanOut.get(path) ?? 0) + 1);
fanIn.set(resolved, (fanIn.get(resolved) ?? 0) + 1);
}
}
const buckets: Record<LayerNumber, { in: number[]; out: number[] }> = {
0: { in: [], out: [] },
1: { in: [], out: [] },
2: { in: [], out: [] },
3: { in: [], out: [] },
};
for (const path of samaPaths) {
const decl = declaredLayer(path, input.profile);
if (!decl) continue;
buckets[decl.layer].in.push(fanIn.get(path) ?? 0);
buckets[decl.layer].out.push(fanOut.get(path) ?? 0);
}
return {
0: { fanIn: summarize(buckets[0].in), fanOut: summarize(buckets[0].out) },
1: { fanIn: summarize(buckets[1].in), fanOut: summarize(buckets[1].out) },
2: { fanIn: summarize(buckets[2].in), fanOut: summarize(buckets[2].out) },
3: { fanIn: summarize(buckets[3].in), fanOut: summarize(buckets[3].out) },
};
};
// — boundaryRatio -------------------------------------------------
//
// (parse-boundary call sites in Layer 2 files) ÷ (parse-boundary
// call sites anywhere). Uses the SAME detector as the §4.4 check.
// No boundaries anywhere → 1.0 (vacuously satisfied: there is no
// out-of-Layer-2 leak because there is no boundary at all).
//
// "Layer 2" here means the file's declaredLayer is 2. Unprefixed
// files (declaredLayer = null) count toward the denominator but
// not the numerator — that is the truthful reading of the §5
// definition.
const computeBoundaryRatio = (input: SamaV2Input): number => {
const sites = findParseBoundaryCallSites(input.files);
if (sites.length === 0) return 1.0;
let inLayer2 = 0;
for (const site of sites) {
const decl = declaredLayer(site.file, input.profile);
if (decl !== null && decl.layer === 2) inLayer2++;
}
return inLayer2 / sites.length;
};
// — workingSetFit -------------------------------------------------
//
// (source files with WORKING_SET_MIN_LOC ≤ LOC ≤ WORKING_SET_MAX_LOC)
// ÷ (total source files). Empty repo → 1.0. Test files don't count;
// the metric is about working modules, not their sibling tests.
//
// Bounds are hard-coded constants in a31_sama_v2.ts. The reasoning
// (Atomic 700-LOC headroom; sub-50 = type-only/stub) lives on
// /sama/v2 §5 — preceding the numbers, not retrofitted.
const computeWorkingSetFit = (input: SamaV2Input): number => {
const samaPaths = [...input.files.keys()].filter(isSamaFile);
if (samaPaths.length === 0) return 1.0;
let inSweetSpot = 0;
for (const p of samaPaths) {
const lines = (input.files.get(p) ?? "").split("\n").length;
if (lines >= WORKING_SET_MIN_LOC && lines <= WORKING_SET_MAX_LOC) inSweetSpot++;
}
return inSweetSpot / samaPaths.length;
};
// — violationCounts ----------------------------------------------
//
// Per-check violation count from a fresh verifier run on the same
// input. Reported even when a check passes (value = 0) — §5's
// "trailing signal: which rules agents *almost* break." The verifier
// enumerates ALL violations per check (no short-circuit), so this
// count is meaningful — not "1 if failed, 0 if passed".
const computeViolationCounts = (input: SamaV2Input): SamaV2ViolationCounts => {
const report = verifySamaV2(input);
const byId = new Map<number, number>();
for (const c of report.checks) byId.set(c.id, c.violations.length);
return {
sorted: byId.get(1) ?? 0,
architecture: byId.get(2) ?? 0,
modeledTests: byId.get(3) ?? 0,
modeledBoundary: byId.get(4) ?? 0,
atomic: byId.get(5) ?? 0,
law: byId.get(6) ?? 0,
consistency: byId.get(7) ?? 0,
};
};
// — Orchestrator --------------------------------------------------
export const computeCoreMetrics = (input: SamaV2Input): SamaV2Metrics => ({
graphDepth: computeGraphDepth(input.files),
fanByLayer: computeFanByLayer(input),
boundaryRatio: computeBoundaryRatio(input),
workingSetFit: computeWorkingSetFit(input),
violationCounts: computeViolationCounts(input),
});