syntaxai/tdd.md · main · src / b32_sama_v2_metrics.ts

b32_sama_v2_metrics.ts 221 lines · 8325 bytes raw
// 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),
});