// 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): 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(); 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(); const visiting = new Set(); 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(); const fanIn = new Map(); 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 = { 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(); 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), });