## tm_diag/diagnostics.nim — THE DIAGNOSTICS KIT (Task 2, six groups). ## ## Everything runs OFFLINE: a trained `TmMachine` plus a labelled sample set. ## No battles, no harness, no Java. The six groups are: ## 1. pre-flight DATA checks (dataChecks, shuffledLabelControl) ## 2. clause introspection (clauseInfo, clauseSummary, topClauses) ## 3. per-feature contribution (featureContributions, deadInputs, ...) ## 4. accuracy diagnostics (accuracyDiagnostics) ## 5. learning curve (learningCurve) ## 6. ablation hooks (ablateDropBlock, ablateScrambleFeature, ...) ## ## A `DiagSample` carries the LITERAL vector (positive literals [0..nBits), ## negations [nBits..2*nBits)) exactly as the shipped TM stores it, plus the ## true label. This keeps the kit usable both on the reference core and on the ## literal vectors captured from the real gun. import std/[math, strformat, algorithm] import feature_spec import tm_core export feature_spec, tm_core type DiagSample* = object lits*: seq[uint8] label*: int order*: int ## optional capture/time index (for post-hoc curves) # ── sample construction ────────────────────────────────────────────────────── proc makeSample*(nBits: int, rawBits: openArray[int], label: int, order = 0): DiagSample = ## Expand `nBits` raw bits into the pos/neg literal vector. doAssert rawBits.len >= nBits, "rawBits.len (" & $rawBits.len & ") < nBits (" & $nBits & ")" result.lits = newSeq[uint8](2 * nBits) for i in 0..= 0 and l < nClasses: inc result.classCounts[l] var maj = 0 for c in 0.. 0: result.classCounts[c].float / result.n.float else: 0.0 if result.classCounts[c] > result.classCounts[maj]: maj = c result.majorityClass = maj result.majorityShare = result.classShares[maj] result.majorityAccuracy = result.majorityShare result.maxShare = result.classShares[maj] for c in 0.. threshold: result.overThreshold = true result.flags.add &"MAJORITY HEAVY: class {c} = " & &"{result.classCounts[c]}/{result.n} = " & &"{result.classShares[c]*100.0:.1f}% > {threshold*100.0:.0f}%" proc shuffledLabelControl*(tmpl: TmMachine, samples: openArray[DiagSample], epochs = 1, seed = 4242'u64): tuple[acc, majority: float, n, nClasses: int, trainedObs: int] = ## PIPELINE LEAK TEST: retrain a fresh copy on SHUFFLED labels. A correct ## pipeline cannot beat the majority baseline on shuffled labels; a large ## margin means information is leaking (features from the future, duplicated ## samples, ...). var rng = seedRng(seed xor 0xabcdef12'u64) var shuf = newSeq[DiagSample](samples.len) for i, s in samples: shuf[i] = s var labels = newSeq[int](samples.len) for i in 0.. maxLen: maxLen = inf.length if inf.votes > 0: inc result.firedAtLeastOnce if inf.polarity > 0: inc result.posFired else: inc result.negFired else: inc result.neverFired result.maxLength = maxLen result.lengthHist = newSeq[int](maxLen + 1) for inf in infos: if inf.length > 0: inc result.lengthHist[inf.length] result.meanLength = if result.nonEmpty > 0: lenSum.float / result.nonEmpty.float else: 0.0 type ClassClauseBalance* = object cls*: int empty*: int nonEmpty*: int posFired*: int negFired*: int neverFired*: int proc clauseBalanceByClass*(infos: openArray[ClauseInfo], nClasses: int): seq[ClassClauseBalance] = ## Positive/negative firing balance PER CLASS, plus empty and never-fired ## counts. A healthy class should use both polarities; a class with 0 firing ## positive clauses has no readable rule. result = newSeq[ClassClauseBalance](nClasses) for c in 0..= nClasses: continue if inf.length == 0: inc result[inf.cls].empty continue inc result[inf.cls].nonEmpty if inf.votes == 0: inc result[inf.cls].neverFired elif inf.polarity > 0: inc result[inf.cls].posFired else: inc result[inf.cls].negFired proc topClauses*(infos: openArray[ClauseInfo], k = 10, minVotes = 1): seq[ClauseInfo] = ## The clauses that actually fire, ranked by vote count (ties: longer first, ## then class/index). for inf in infos: if inf.votes >= minVotes and inf.length > 0: result.add inf result.sort(proc(a, b: ClauseInfo): int = result = cmp(b.votes, a.votes) if result == 0: result = cmp(b.length, a.length) if result == 0: result = cmp(a.cls, b.cls) if result == 0: result = cmp(a.index, b.index)) if result.len > k: result.setLen(k) proc topClausesByPolarity*(infos: openArray[ClauseInfo], polarity: int, k = 10): seq[ClauseInfo] = ## Firing clauses of one polarity only (positive = the rule-encoding clauses, ## negative = the discriminators), ranked by votes. var filtered: seq[ClauseInfo] for inf in infos: if inf.polarity == polarity: filtered.add inf result = topClauses(filtered, k) proc necessaryLiterals*(m: TmMachine, samples: openArray[DiagSample], cls: int): seq[int] = ## The literals common to EVERY positive-polarity clause of `cls` that fires ## at least once. A converged multiclass TM pads its clauses with redundant ## literals; the intersection strips the padding and recovers the class rule ## (e.g. the planted `A AND B`). var first = true for cl in 0.. mx: mx = c.weighted let cut = relThreshold * mx for c in contribs: if c.appearances == 0 or c.weighted < cut: result.add c.bit proc neverUsedInputs*(contribs: openArray[FeatureContribution]): seq[int] = ## Strict form: raw bits that never appear in a clause that casts a vote. for c in contribs: if c.appearances == 0: result.add c.bit proc constantInputs*(samples: openArray[DiagSample], nBits: int): seq[int] = ## Raw bits with zero variance across the sample set (information-free even if ## a clause happens to include them). if samples.len == 0: return for b in 0..= 0 and s.label < m.nClasses and p >= 0 and p < m.nClasses: inc result.confusion[s.label][p] inc result.predCounts[p] if p == s.label: inc result.correct result.acc = if result.n > 0: result.correct.float / result.n.float else: 0.0 var rowSum = newSeq[int](m.nClasses) for c in 0.. rowSum[maj]: maj = c result.majorityClass = maj result.majorityShare = if result.n > 0: rowSum[maj].float / result.n.float else: 0.0 result.majorityBaseline = result.majorityShare result.margin = result.acc - result.majorityBaseline for c in 0.. 0: result.confusion[c][c].float / rowSum[c].float else: 0.0 result.perClassPrecision[c] = if result.predCounts[c] > 0: result.confusion[c][c].float / result.predCounts[c].float else: 0.0 result.predMajorityShare = if result.n > 0: result.predCounts[maj].float / result.n.float else: 0.0 # ───────────────────────────────────────────────────────────────────────────── # GROUP 5 — learning curve # ───────────────────────────────────────────────────────────────────────────── type LearningCurve* = object points*: seq[int] ## samples seen accs*: seq[float] ## accuracy on the (held-out) eval set trend*: string proc classifyTrend*(accs: seq[float], eps = 0.02): string = if accs.len < 3: return "insufficient" let first = accs[0] let last = accs[^1] var mx = first var mxIdx = 0 for i, a in accs: if a > mx: mx = a mxIdx = i if mx - last > eps and mxIdx > 0 and mxIdx < accs.len - 1: return "rising-then-falling (noise fitting)" if last - first > eps: return "rising" if first - last > eps: return "falling" "flat" proc learningCurve*(tmpl: TmMachine, train, eval: openArray[DiagSample], nPoints = 10, seed = 777'u64): LearningCurve = ## Train a fresh machine on growing prefixes of `train` and score each prefix ## on `eval`, so fast vs slow adaptation is directly visible. var m = tmpl m.resetMachine(seed) let np = max(2, nPoints) let step = max(1, train.len div np) var seen = 0 var i = 0 while i < np: let upto = min(train.len, (i + 1) * step) var k = seen while k < upto: m.trainSample(train[k].lits, train[k].label) inc k seen = upto var correct = 0 for s in eval: if m.predictClass(s.lits) == s.label: inc correct result.points.add upto result.accs.add (if eval.len > 0: correct.float / eval.len.float else: 0.0) inc i if upto >= train.len: while i < np: result.points.add train.len result.accs.add result.accs[^1] inc i break result.trend = classifyTrend(result.accs) # ───────────────────────────────────────────────────────────────────────────── # GROUP 6 — ablation hooks # ───────────────────────────────────────────────────────────────────────────── type AblationResult* = object name*: string kind*: string ## "baseline" | "drop-block" | "scramble-feature" baselineAcc*: float ablatedAcc*: float delta*: float ## ablated - baseline proc ablateAdd*(a, b: AblationResult): AblationResult = ## Accumulate ablations across folds/fixtures. result.name = a.name result.kind = a.kind let n = 2.0 result.baselineAcc = (a.baselineAcc + b.baselineAcc) / n result.ablatedAcc = (a.ablatedAcc + b.ablatedAcc) / n result.delta = (a.delta + b.delta) / n proc dropBits(s: DiagSample, nBits, first, count: int): DiagSample = result = s result.lits = s.lits for b in first.. the block does not earn its bits. ## Pass `baselineAcc` (from ablateBaseline) to skip recomputing it per block. doAssert blockIdx >= 0 and blockIdx < spec.blocks.len let b = spec.blocks[blockIdx] var tr = newSeq[DiagSample](train.len) for i, s in train: tr[i] = dropBits(s, spec.nBits, b.first, b.count) var ev = newSeq[DiagSample](eval.len) for i, s in eval: ev[i] = dropBits(s, spec.nBits, b.first, b.count) let base = if baselineAcc >= 0.0: baselineAcc else: evalAcc(trainModel(tmpl, train, epochs, seed), eval) let abl = evalAcc(trainModel(tmpl, tr, epochs, seed), ev) AblationResult(name: b.name, kind: "drop-block", baselineAcc: base, ablatedAcc: abl, delta: abl - base) proc ablateScrambleFeature*(tmpl: TmMachine, train, eval: openArray[DiagSample], spec: FeatureSpec, bit: int, epochs = 1, seed = 777'u64, baselineAcc = -1.0): AblationResult = ## Retrain from scratch with ONE raw bit permuted across samples in both train ## and eval. A negative delta => the bit is genuinely informative. var rng = seedRng(seed xor 0x5bd1e995'u64) var tr = newSeq[DiagSample](train.len) for i, s in train: tr[i] = s var ev = newSeq[DiagSample](eval.len) for i, s in eval: ev[i] = s scrambleBit(tr, spec.nBits, bit, rng) scrambleBit(ev, spec.nBits, bit, rng) let base = if baselineAcc >= 0.0: baselineAcc else: evalAcc(trainModel(tmpl, train, epochs, seed), eval) let abl = evalAcc(trainModel(tmpl, tr, epochs, seed), ev) AblationResult(name: spec.describe(bit), kind: "scramble-feature", baselineAcc: base, ablatedAcc: abl, delta: abl - base) proc ablateDropAllBlocks*(tmpl: TmMachine, train, eval: openArray[DiagSample], spec: FeatureSpec, epochs = 1, seed = 777'u64): seq[AblationResult] = for i in 0..