Files
SirStone 9064377740 s settled from OUR code (higher = LONGER clauses), and it cannot rescue the gun
=== TASK 1: THE DIRECTION QUESTION, ANSWERED WITH A DEMONSTRATION ===
I told the user `s` controls clause length but refused to claim the DIRECTION,
because I had seen it described both ways. It is now read out of our own code -
one site per core, in the Type I branch of `tmLearnDir` (`guns/tm_pattern.nim:258`,
`guns/tsetlin.nim:224`, `tm_diag/tm_core.nim:110`):

  if pol * d > 0.0:
    if lits[lit] == 1:
      if cOut == 1:  if rand < (s-1)/s: st += 1   # toward Include, w.p. (s-1)/s
      else:          if rand < 1/s:     st -= 1   # toward Exclude, w.p. 1/s
    else:            if rand < 1/s:     st -= 1   # toward Exclude, w.p. 1/s
=> **HIGHER `s` GIVES LONGER CLAUSES.** The include step runs w.p. (s-1)/s
(rising with s); both exclude steps run w.p. 1/s (falling with s).

DEMONSTRATED (49-bit draft, planted 2-literal rule, 3000 train / 1500 eval):
  s=1.0 len 1.51 acc 100%   s=2.0 len 1.82 acc 100%   s=5.0 len 3.02 acc 100%
  s=1.5 len 1.42 acc 100%   s=3.0 len 2.19 acc 100%   s=10 len 4.04 acc 99.7%
                            s=20  len 5.17 acc 94.5%
WHY s=1.0 DEGENERATES: (s-1)/s = 0 so the include step NEVER fires while 1/s = 1
so BOTH exclude steps always fire - Type I can only remove literals, so a clause
can grow only through the Type II penalty. (On random labels that leaves 43/120
non-empty clauses vs 120/120 at s>=3.)
USABLE RANGE ~[1.5, 5]. tm_pattern uses 3.0; tsetlin uses 1.5.

=== TASK 4: WOULD `s` HELP THE SHIPPED GUN? NO - MEASURED ===
Recompiling the offline driver with -d:TM_S_DEF=<v> (source untouched) retrains
the gun end to end:
  s      mean len   verdict     warm acc   margin vs majority
  1.5     15.82     too long     32.22%      -2.03pp
  2.0     14.99     too long     34.03%      -0.22pp
  3.0*    19.17     too long     35.72%      +1.48pp   (*shipped)
  5.0     20.47     too long     34.72%      +0.47pp
Lowering `s` shrinks the clauses and makes accuracy WORSE; raising it pads them
and also loses. The shipped 3.0 is the best of the four, and **no value comes
near the healthy 3-8 band.** Combined with the settledness finding, the shape is
consistent with "NO CONSISTENT SHORT RULE EXISTS in this representation/target".
So the bottleneck is the SIGNAL - now confirmed from a THIRD independent angle
(settledness, churn trend, and clause shape). This is the measurement behind the
decision not to spend effort sweeping N or s.

=== TASK 3: AN HONEST CORRECTION TO MY OWN HYPOTHESIS ===
I predicted that random labels would produce `too long` clauses (the TM padding).
MEASURED: on this encoding noise reads as **short / `collapsed`** (mean 1.88,
median 2.0, acc 33.3%) - the TM FAILS TO COMMIT rather than padding. So "too
long" is not the noise signature, which means the shipped gun's 19.17 mean is not
explained by label noise. Worth knowing.

Adds diagnostic group 8: the clause-shape checker - full length distribution
(min/median/p10/p90/std), per-polarity and per-class breakdowns, a
`clauseShapeVerdict` against a parameterised healthy band (default 3-8),
per-BLOCK length contributions, and clause coverage (mean firing clauses,
effectiveClauses = participation ratio, top3Share). `healthLine` now appends
`shape=<mean> (<verdict>)`.
Validation: `test_tm_clause_shape` 66 checks. A planted 2-literal rule reads
`healthy` with the literals recovered exactly; per-block correctly names the
planted blocks (WALLS 43.0%, BULLETS 28.1%) and buries an irrelevant block (3.4%,
below its uniform 8.3% share); random labels read `collapsed`.
REAL READING, shipped gun: mean 19.17 / median 16.00 / p90 44.80 / max 57,
173 non-empty of 200, 27 empty => **`too long`**; coverage firing/sample 48.53
(24.3%), effectiveClauses 97.85/200, top3Share 5.3% (voting NOT concentrated);
per-block is diffuse with no dominator, EXCEPT **UNUSED 6.4%** - the always-true
negations of the never-written bits 38/39 acting as FREE PADDING, the same bug the
kit found earlier now visible as clause bloat.

Guards: test_tm_clause_shape 66 (new), test_tm_diag 48, test_tm_automata_diag 55,
diag_synthetic 17, diag_automata_validation 11, test_gun_harness 39,
test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 40,
test_rack_membership 48, test_selector_tiebreak 19, test_tm_pattern_registration 20,
test_vbullet_admit_gate 12. acceptance_offline_vs_online not run (needs a live
battle; no tm_diag dependency).
2026-09-22 21:56:06 +02:00

1448 lines
59 KiB
Nim

## tm_diag/diagnostics.nim — THE DIAGNOSTICS KIT (Tasks 2 + 5).
##
## Everything runs OFFLINE: a trained `TmMachine` plus a labelled sample set.
## No battles, no harness, no Java. The eight 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, ...)
## 7. AUTOMATA level (Task 5) (settledness, clauseDiversity, churnTrace,
## voteDisagreement, stateHistogram,
## perInputConfidence, healthLine,
## automataDiagnostics)
## 8. CLAUSE SHAPE (Task 2/4) (clauseShapeVerdict, lengthStats,
## blockLengthContributions, clauseCoverage,
## clauseShapeDiagnostics, shapeLine,
## formatClauseShapeReport)
##
## 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, strutils]
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..<nBits:
let v = uint8(if rawBits[i] != 0: 1 else: 0)
result.lits[i] = v
result.lits[i + nBits] = 1'u8 - v
result.label = label
result.order = order
proc makeSample*(nBits: int, rawBits: openArray[uint8], label: int,
order = 0): DiagSample =
result.lits = newSeq[uint8](2 * nBits)
for i in 0..<nBits:
let v = uint8(if rawBits[i] != 0'u8: 1 else: 0)
result.lits[i] = v
result.lits[i + nBits] = 1'u8 - v
result.label = label
result.order = order
proc machineFromTeams*(nBits, nClasses, nClauses, nStates: int, sValue: float,
teams: openArray[seq[int16]]): TmMachine =
## Build an introspectable machine around an externally-trained clause set
## (e.g. the shipped tm_pattern gun's `teams`).
result = newMachine(nBits, nClasses, nClauses, nStates, sValue)
doAssert teams.len == nClasses
for c in 0..<nClasses:
result.teams[c] = teams[c]
# ── shared training / scoring helpers (used by every group below) ────────────
proc shuffledIndices(n: int, rng: var TmRng): seq[int] =
result = newSeq[int](n)
for i in 0..<n: result[i] = i
for i in countdown(n - 1, 1):
let j = int(rng.nextU64() mod uint64(i + 1))
swap(result[i], result[j])
proc trainModel*(tmpl: TmMachine, samples: openArray[DiagSample],
epochs = 1, seed = 777'u64): TmMachine =
## Fresh machine, `epochs` shuffled passes over `samples`.
result = tmpl
result.resetMachine(seed)
for _ in 0..<epochs:
let idx = shuffledIndices(samples.len, result.rng)
for k in idx:
result.trainSample(samples[k].lits, samples[k].label)
proc evalAcc*(m: TmMachine, samples: openArray[DiagSample]): float =
var c = 0
for s in samples:
if m.predictClass(s.lits) == s.label: inc c
if samples.len == 0: 0.0 else: c.float / samples.len.float
# ─────────────────────────────────────────────────────────────────────────────
# GROUP 1 — pre-flight DATA checks
# ─────────────────────────────────────────────────────────────────────────────
type
DataCheck* = object
n*: int
nClasses*: int
classCounts*: seq[int]
classShares*: seq[float]
majorityClass*: int
majorityShare*: float
majorityAccuracy*: float
maxShare*: float
overThreshold*: bool
threshold*: float
flags*: seq[string]
proc dataChecks*(labels: openArray[int], nClasses: int,
threshold = 0.30): DataCheck =
## Per-class label counts/shares, the majority-class share (which is also the
## majority-class accuracy baseline), and a loud flag when any class exceeds
## `threshold` of the samples — a too-large class compromises the accuracy
## test.
result.threshold = threshold
result.nClasses = nClasses
result.n = labels.len
result.classCounts = newSeq[int](nClasses)
result.classShares = newSeq[float](nClasses)
for l in labels:
if l >= 0 and l < nClasses: inc result.classCounts[l]
var maj = 0
for c in 0..<nClasses:
result.classShares[c] =
if result.n > 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..<nClasses:
if result.classShares[c] > 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..<samples.len: labels[i] = samples[i].label
for i in countdown(samples.len - 1, 1):
let j = int(rng.nextU64() mod uint64(i + 1))
swap(labels[i], labels[j])
for i in 0..<samples.len: shuf[i].label = labels[i]
let m = trainModel(tmpl, shuf, epochs, seed)
result.acc = evalAcc(m, shuf)
result.majority = dataChecks(labels, tmpl.nClasses).majorityShare
result.n = samples.len
result.nClasses = tmpl.nClasses
result.trainedObs = epochs * samples.len
# ─────────────────────────────────────────────────────────────────────────────
# GROUP 2 — clause introspection
# ─────────────────────────────────────────────────────────────────────────────
type
ClauseInfo* = object
cls*: int
index*: int
polarity*: int
length*: int
lits*: seq[int]
votes*: int ## samples in which the clause fires (casts a vote)
text*: string ## describeClause rendering
ClauseSummary* = object
totalClauses*: int
emptyClauses*: int
nonEmpty*: int
firedAtLeastOnce*: int
neverFired*: int
posFired*: int
negFired*: int
lengthHist*: seq[int] ## index = clause length, value = count (non-empty)
meanLength*: float
maxLength*: int
## ── clause-SHAPE distribution (Task 2, group 8) ──
minLength*: int ## shortest non-empty clause
medianLength*: float ## p50 of the non-empty length distribution
p10Length*: float
p90Length*: float
stdLength*: float
posMeanLength*: float ## positive-polarity clauses only
negMeanLength*: float ## negative-polarity clauses only
perClassMeanLength*: seq[float]
perClassMedianLength*: seq[float]
perClassHist*: seq[seq[int]]
emptyFraction*: float ## emptyClauses / totalClauses
proc percentile*(sorted: openArray[int], p: float): float =
## Linear-interpolation percentile (numpy default) over an ASCENDING array.
## Empty -> 0.0; p is clamped to [0,1].
if sorted.len == 0: return 0.0
let pp = clamp(p, 0.0, 1.0)
let rank = pp * float(sorted.len - 1)
let lo = int(floor(rank))
let hi = int(ceil(rank))
if lo == hi: return sorted[lo].float
sorted[lo].float + (rank - float(lo)) * (sorted[hi] - sorted[lo]).float
type
LengthStats* = object
n*: int
minL*: int
maxL*: int
mean*: float
median*: float
p10*: float
p90*: float
stdDev*: float
hist*: seq[int]
proc lengthStats*(lengths: openArray[int]): LengthStats =
## Full distribution of clause lengths: n, min/max, mean, median, p10/p90,
## standard deviation and a histogram (index = length).
result.n = lengths.len
if result.n == 0:
result.minL = 0
result.maxL = 0
return
var sorted = newSeq[int](lengths.len)
for i, l in lengths: sorted[i] = l
sorted.sort()
result.minL = sorted[0]
result.maxL = sorted[^1]
var sum = 0
for l in sorted: sum += l
result.mean = sum.float / result.n.float
result.median = percentile(sorted, 0.5)
result.p10 = percentile(sorted, 0.10)
result.p90 = percentile(sorted, 0.90)
var varSum = 0.0
for l in sorted:
let d = l.float - result.mean
varSum += d * d
result.stdDev = sqrt(varSum / result.n.float)
result.hist = newSeq[int](result.maxL + 1)
for l in sorted: inc result.hist[l]
proc clauseInfo*(m: TmMachine, samples: openArray[DiagSample],
spec: FeatureSpec): seq[ClauseInfo] =
for c in 0..<m.nClasses:
for cl in 0..<m.nClauses:
let ls = m.clauseLits(c, cl)
var votes = 0
for s in samples:
if m.clauseFires(c, cl, s.lits): inc votes
result.add ClauseInfo(cls: c, index: cl,
polarity: (if cl < m.half: 1 else: -1), length: ls.len, lits: ls,
votes: votes, text: spec.describeClause(ls, c))
proc clauseSummary*(infos: openArray[ClauseInfo]): ClauseSummary =
var maxLen = 0
var lenSum = 0
var posSum = 0
var posN = 0
var negSum = 0
var negN = 0
var lengths: seq[int]
var posLengths, negLengths: seq[int]
var nClasses = 0
for inf in infos:
if inf.cls + 1 > nClasses: nClasses = inf.cls + 1
var classLengths = newSeq[seq[int]](nClasses)
for inf in infos:
inc result.totalClauses
if inf.cls >= 0 and inf.cls < nClasses:
if inf.length > 0: classLengths[inf.cls].add inf.length
if inf.length == 0:
inc result.emptyClauses
else:
inc result.nonEmpty
lenSum += inf.length
lengths.add inf.length
if inf.length > maxLen: maxLen = inf.length
if inf.polarity > 0:
posSum += inf.length; inc posN; posLengths.add inf.length
else:
negSum += inf.length; inc negN; negLengths.add 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
let stats = lengthStats(lengths)
result.minLength = stats.minL
result.medianLength = stats.median
result.p10Length = stats.p10
result.p90Length = stats.p90
result.stdLength = stats.stdDev
result.posMeanLength = if posN > 0: posSum.float / posN.float else: 0.0
result.negMeanLength = if negN > 0: negSum.float / negN.float else: 0.0
result.emptyFraction =
if result.totalClauses > 0:
result.emptyClauses.float / result.totalClauses.float
else: 0.0
result.perClassMeanLength = newSeq[float](nClasses)
result.perClassMedianLength = newSeq[float](nClasses)
result.perClassHist = newSeq[seq[int]](nClasses)
for c in 0..<nClasses:
let cs = lengthStats(classLengths[c])
result.perClassMeanLength[c] = cs.mean
result.perClassMedianLength[c] = cs.median
result.perClassHist[c] = cs.hist
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: result[c].cls = c
for inf in infos:
if inf.cls < 0 or inf.cls >= 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..<m.half: # positive polarity only
let ls = m.clauseLits(cls, cl)
if ls.len == 0: continue
var fires = false
for s in samples:
if m.clauseFires(cls, cl, s.lits):
fires = true
break
if not fires: continue
if first:
result = ls
first = false
else:
var keep: seq[int]
for l in result:
if l in ls: keep.add l
result = keep
result.sort()
# ─────────────────────────────────────────────────────────────────────────────
# GROUP 8 — CLAUSE SHAPE (Task 2 of the clause-shape checker)
# length distribution + healthy-band verdict + per-block length contribution
# + coverage / voting concentration.
# ─────────────────────────────────────────────────────────────────────────────
proc clauseShapeVerdict*(summ: ClauseSummary, healthyLo = 3.0,
healthyHi = 8.0, collapsedMax = 2.0): string =
## Judge the mean non-empty clause length against a HEALTHY BAND.
##
## For a problem with ~50 input bits a healthy mean is roughly 3-8 literals:
## a rule that needs ~19 conditions to fire is almost certainly fitting noise,
## and a mean of 1-2 means the clauses learned almost nothing (they are
## essentially single-literal stubs). The band is a PARAMETER of the call so a
## caller can retune it per encoding.
##
## Returns one of: "healthy" | "too long" | "collapsed" | "short" | "n/a".
## mean > healthyHi -> too long
## mean < collapsedMax -> collapsed
## collapsedMax <= mean < healthyLo -> short (below band, not yet collapsed)
## healthyLo <= mean <= healthyHi -> healthy
if summ.nonEmpty == 0: return "n/a"
let mean = summ.meanLength
if mean > healthyHi: "too long"
elif mean < collapsedMax: "collapsed"
elif mean < healthyLo: "short"
else: "healthy"
proc lengthHistText*(hist: openArray[int]): string =
## One line per length, e.g. `len 3: 12 ####`.
var mx = 0
for c in hist:
if c > mx: mx = c
for l in 0..<hist.len:
if hist[l] == 0: continue
let bar = "#".repeat(min(40, hist[l] * 40 div max(1, mx)))
result.add &" len {l:>3}: {hist[l]:>6} {bar}\n"
type
BlockLengthContribution* = object
name*: string
first*: int
count*: int
totalLits*: int ## literals from this block across the clauses
meanPerClause*: float ## totalLits / number of counted clauses
share*: float ## totalLits / all literals across counted clauses
clausesUsing*: int ## clauses with >= 1 literal from this block
perClauseMax*: int ## most literals this block contributes to one clause
proc blockLengthContributions*(m: TmMachine, spec: FeatureSpec,
skipEmpty = true): seq[BlockLengthContribution] =
## Per input BLOCK (walls / us / motion / bullets from the `FeatureSpec`): how
## many literals does it contribute on average across clauses?
##
## A block that contributes many literals to EVERY clause is dominating the
## model; a block contributing none is dead (the block-level view of the dead
## INPUT list). `skipEmpty = true` (default) averages over non-empty clauses
## only, so empty clauses do not dilute the shares.
result = newSeq[BlockLengthContribution](spec.blocks.len)
for i, b in spec.blocks:
result[i].name = b.name
result[i].first = b.first
result[i].count = b.count
var nClauses = 0
var total = 0
for c in 0..<m.nClasses:
for cl in 0..<m.nClauses:
let ls = m.clauseLits(c, cl)
if skipEmpty and ls.len == 0: continue
inc nClauses
var perBlock = newSeq[int](spec.blocks.len)
for lit in ls:
let raw = if lit < m.nBits: lit else: lit - m.nBits
let bi = spec.blockOf(raw)
if bi >= 0:
inc perBlock[bi]
inc total
for i in 0..<spec.blocks.len:
result[i].totalLits += perBlock[i]
if perBlock[i] > 0: inc result[i].clausesUsing
if perBlock[i] > result[i].perClauseMax:
result[i].perClauseMax = perBlock[i]
for i in 0..<spec.blocks.len:
result[i].meanPerClause =
if nClauses > 0: result[i].totalLits.float / nClauses.float else: 0.0
result[i].share = if total > 0: result[i].totalLits.float / total.float else: 0.0
type
ClauseCoverage* = object
samples*: int
totalClauses*: int
meanFiringClauses*: float ## mean non-empty firing clauses per sample
meanFiringFraction*: float ## ... / totalClauses
effectiveClauses*: float ## participation ratio of the vote counts
top3Share*: float ## share of all fires from the 3 busiest clauses
firingClauseCount*: int ## clauses that fire at least once
proc clauseCoverage*(m: TmMachine, samples: openArray[DiagSample]):
ClauseCoverage =
## How many clauses are actually needed to cover the cases?
##
## `meanFiringFraction` is the fraction of clauses that fire on any given
## sample. `effectiveClauses` is the participation ratio
## `(sum v)^2 / sum v^2` of the per-clause fire counts: if only 3 clauses cast
## most of the votes, it reads ~3 regardless of the configured count.
result.totalClauses = m.nClasses * m.nClauses
result.samples = samples.len
if samples.len == 0 or result.totalClauses == 0: return
var counts = newSeq[int](result.totalClauses)
var fireSum = 0.0
for s in samples:
var firing = 0
for c in 0..<m.nClasses:
for cl in 0..<m.nClauses:
if m.clauseFires(c, cl, s.lits):
inc firing
inc counts[c * m.nClauses + cl]
fireSum += firing.float
result.meanFiringClauses = fireSum / samples.len.float
result.meanFiringFraction = result.meanFiringClauses / result.totalClauses.float
var totalVotes = 0
var sqVotes = 0.0
for v in counts:
if v > 0: inc result.firingClauseCount
totalVotes += v
sqVotes += v.float * v.float
if sqVotes > 0.0:
result.effectiveClauses = totalVotes.float * totalVotes.float / sqVotes
var sorted = counts
sorted.sort(SortOrder.Descending)
let k = min(3, sorted.len)
var top = 0
for i in 0..<k: top += sorted[i]
result.top3Share = if totalVotes > 0: top.float / totalVotes.float else: 0.0
type
ClauseShapeDiag* = object
summary*: ClauseSummary
verdict*: string
blocks*: seq[BlockLengthContribution]
coverage*: ClauseCoverage
healthyLo*: float
healthyHi*: float
collapsedMax*: float
proc clauseShapeDiagnostics*(m: TmMachine, samples: openArray[DiagSample],
spec: FeatureSpec, healthyLo = 3.0,
healthyHi = 8.0, collapsedMax = 2.0,
skipEmpty = true): ClauseShapeDiag =
## The one-call clause-shape readout: length distribution + healthy-band
## verdict + per-block length contribution + coverage/concentration.
result.summary = clauseSummary(clauseInfo(m, samples, spec))
result.healthyLo = healthyLo
result.healthyHi = healthyHi
result.collapsedMax = collapsedMax
result.verdict = clauseShapeVerdict(result.summary, healthyLo, healthyHi,
collapsedMax)
result.blocks = blockLengthContributions(m, spec, skipEmpty)
result.coverage = clauseCoverage(m, samples)
proc shapeLine*(d: ClauseShapeDiag): string =
## One-line clause-shape summary, in the same style as `healthLine`.
let s = d.summary
&"shape mean={s.meanLength:.2f} med={s.medianLength:.1f} " &
&"p10={s.p10Length:.1f} p90={s.p90Length:.1f} max={s.maxLength} " &
&"({d.verdict}) | coverage={d.coverage.meanFiringFraction*100:.1f}%/sample " &
&"eff={d.coverage.effectiveClauses:.1f}/{d.coverage.totalClauses} " &
&"top3={d.coverage.top3Share*100:.0f}%"
proc formatClauseShapeReport*(d: ClauseShapeDiag): string =
## Full clause-shape readout: distribution, per-polarity / per-class means,
## per-block contribution and coverage.
let s = d.summary
result.add &"shape: mean={s.meanLength:.2f} median={s.medianLength:.2f} " &
&"p10={s.p10Length:.2f} p90={s.p90Length:.2f} std={s.stdLength:.2f} " &
&"min={s.minLength} max={s.maxLength} nonEmpty={s.nonEmpty} " &
&"empty={s.emptyClauses}/{s.totalClauses} " &
&"({d.verdict}, healthy band {d.healthyLo:.0f}-{d.healthyHi:.0f})\n"
result.add &" polarity: posMean={s.posMeanLength:.2f} negMean={s.negMeanLength:.2f}\n"
result.add " per-class mean/median:"
for c in 0..<s.perClassMeanLength.len:
result.add &" c{c}={s.perClassMeanLength[c]:.2f}/{s.perClassMedianLength[c]:.1f}"
result.add "\n"
result.add " length histogram:\n" & lengthHistText(s.lengthHist)
result.add " per-block literal contribution (share of all clause literals):\n"
for b in d.blocks:
result.add &" {b.name:<28} mean/clause={b.meanPerClause:5.2f} " &
&"share={b.share*100:5.1f}% clausesUsing={b.clausesUsing:<5} max={b.perClauseMax}\n"
result.add &" coverage: firing/sample={d.coverage.meanFiringClauses:.2f} " &
&"({d.coverage.meanFiringFraction*100:.1f}%) effectiveClauses=" &
&"{d.coverage.effectiveClauses:.2f}/{d.coverage.totalClauses} " &
&"top3Share={d.coverage.top3Share*100:.1f}% " &
&"firingClauses={d.coverage.firingClauseCount}\n"
# ─────────────────────────────────────────────────────────────────────────────
# GROUP 3 — per-feature contribution + DEAD-INPUT LIST
# ─────────────────────────────────────────────────────────────────────────────
type
FeatureContribution* = object
bit*: int
name*: string
appearances*: int ## times the bit appears in a clause that casts a vote
weighted*: float ## sum of clause vote-shares (1/nCasting per fire)
proc featureContributions*(m: TmMachine, samples: openArray[DiagSample],
spec: FeatureSpec): seq[FeatureContribution] =
## For every raw bit: how often its positive or negated literal appears in a
## clause that ACTUALLY CASTS A VOTE (non-empty + fires on a sample), weighted
## by that clause's share of the sample's casting votes.
result = newSeq[FeatureContribution](m.nBits)
for b in 0..<m.nBits:
result[b].bit = b
result[b].name = spec.describe(b)
var allLits = newSeq[seq[int]](m.nClasses * m.nClauses)
for c in 0..<m.nClasses:
for cl in 0..<m.nClauses:
allLits[c * m.nClauses + cl] = m.clauseLits(c, cl)
for s in samples:
var casting: seq[int]
for k in 0..<allLits.len:
let ls = allLits[k]
if ls.len == 0: continue
var fires = true
for lit in ls:
if s.lits[lit] == 0'u8:
fires = false
break
if fires: casting.add k
if casting.len == 0: continue
let w = 1.0 / float(casting.len)
for k in casting:
for lit in allLits[k]:
let b = if lit < m.nBits: lit else: lit - m.nBits
inc result[b].appearances
result[b].weighted += w
proc deadInputs*(contribs: openArray[FeatureContribution],
relThreshold = 0.05): seq[int] =
## THE DEAD-INPUT LIST. A bit is dead if it never appears in a voting clause
## (`appearances == 0`) OR its weighted vote-share contribution is below
## `relThreshold` x the largest contribution.
##
## The relative form is the practical one: a CONVERGED multiclass TM keeps
## redundant literals inside otherwise-correct clauses (each fires rarely, so
## its vote share is tiny). Strict `appearances == 0` therefore misses bits
## that are effectively dead; pass `relThreshold = 0.0` for the strict list.
var mx = 0.0
for c in contribs:
if c.weighted > 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..<nBits:
let v0 = samples[0].lits[b]
var constant = true
for i in 1..<samples.len:
if samples[i].lits[b] != v0:
constant = false
break
if constant: result.add b
proc rankedInputs*(contribs: seq[FeatureContribution]): seq[FeatureContribution] =
## Most valuable first.
result = contribs
result.sort(proc(a, b: FeatureContribution): int =
result = cmp(b.weighted, a.weighted)
if result == 0: result = cmp(b.appearances, a.appearances)
if result == 0: result = cmp(a.bit, b.bit))
# ─────────────────────────────────────────────────────────────────────────────
# GROUP 4 — accuracy diagnostics
# ─────────────────────────────────────────────────────────────────────────────
type
AccDiag* = object
n*: int
correct*: int
acc*: float
confusion*: seq[seq[int]] ## [true][pred]
majorityClass*: int
majorityShare*: float
majorityBaseline*: float
margin*: float ## acc - majority baseline (pp as fraction)
perClassRecall*: seq[float]
perClassPrecision*: seq[float]
predCounts*: seq[int]
predMajorityShare*: float ## how often the model predicts the majority class
proc accuracyDiagnostics*(m: TmMachine,
samples: openArray[DiagSample]): AccDiag =
result.confusion = newSeq[seq[int]](m.nClasses)
for c in 0..<m.nClasses: result.confusion[c] = newSeq[int](m.nClasses)
result.predCounts = newSeq[int](m.nClasses)
result.perClassRecall = newSeq[float](m.nClasses)
result.perClassPrecision = newSeq[float](m.nClasses)
result.n = samples.len
for s in samples:
let p = m.predictClass(s.lits)
if s.label >= 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..<m.nClasses:
for p in 0..<m.nClasses: rowSum[c] += result.confusion[c][p]
var maj = 0
for c in 0..<m.nClasses:
if rowSum[c] > 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..<m.nClasses:
result.perClassRecall[c] =
if rowSum[c] > 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..<first + count:
result.lits[b] = 0'u8
result.lits[b + nBits] = 1'u8
proc scrambleBit(samples: var seq[DiagSample], nBits, bit: int,
rng: var TmRng) =
let n = samples.len
if n < 2: return
var vals = newSeq[uint8](n)
for i in 0..<n: vals[i] = samples[i].lits[bit]
for i in countdown(n - 1, 1):
let j = int(rng.nextU64() mod uint64(i + 1))
swap(vals[i], vals[j])
for i in 0..<n:
samples[i].lits[bit] = vals[i]
samples[i].lits[bit + nBits] = 1'u8 - vals[i]
proc ablateDropBlock*(tmpl: TmMachine, train, eval: openArray[DiagSample],
spec: FeatureSpec, blockIdx: int, epochs = 1,
seed = 777'u64, baselineAcc = -1.0): AblationResult =
## Retrain from scratch with one whole block held at 0 (feature removed) and
## report the accuracy delta. ~zero => 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..<spec.blocks.len:
result.add ablateDropBlock(tmpl, train, eval, spec, i, epochs, seed)
proc ablateBaseline*(tmpl: TmMachine, train, eval: openArray[DiagSample],
epochs = 1, seed = 777'u64): AblationResult =
AblationResult(name: "baseline", kind: "baseline",
baselineAcc: evalAcc(trainModel(tmpl, train, epochs, seed), eval),
ablatedAcc: evalAcc(trainModel(tmpl, train, epochs, seed), eval),
delta: 0.0)
# ─────────────────────────────────────────────────────────────────────────────
# GROUP 7 — automata-level metrics
# settledness / clause diversity / churn / vote disagreement
# plus the state histogram, the per-input confidence table and a health line.
#
# STATE CONVENTIONS — derived from the ACTUAL code (tm_core.nim and
# guns/tm_pattern.nim), not from the textbook formulation:
# * every clause owns one automaton per literal (2 * nBits of them);
# * states are int16 in [-nStates, nStates];
# * INCLUDE iff state > 0, EXCLUDE iff state <= 0 (tmEval / clauseLits);
# * both cores initialise every state to 0 ("the Exclude boundary"), so the
# flip boundary sits BETWEEN state 0 (Exclude) and state 1 (Include) — the
# middle of the range.
# * commitment(st) = |st| / nStates in [0, 1]: 0 exactly on the boundary,
# 1 at either extreme. It is symmetric for the two sides.
# ─────────────────────────────────────────────────────────────────────────────
proc stateCommitment*(nStates, st: int): float {.inline.} =
## Normalised distance of an automaton state from its flip boundary, in
## [0, 1]. 0 == on the boundary (state 0), 1 == at either extreme (+/-nStates).
min(abs(st).float / max(1, nStates).float, 1.0)
proc stateIncluded*(st: int): bool {.inline.} =
## The kit's include predicate, matching `tmEval` / `clauseLits` exactly.
st > 0
# ── 1. SETTLEDNESS ───────────────────────────────────────────────────────────
type
ClauseSettledness* = object
cls*: int
index*: int
polarity*: int ## +1 positive clauses, -1 negative clauses
nAutomata*: int
meanCommitment*: float
settledFraction*: float
SettlednessResult* = object
threshold*: float
nAutomata*: int
overallMean*: float
overallSettledFraction*: float
clauses*: seq[ClauseSettledness]
proc settledness*(m: TmMachine, threshold = 0.5): SettlednessResult =
## Per clause and overall: the mean automaton `commitment` and the fraction of
## automata whose commitment is >= `threshold`. A clause full of automata
## parked near the boundary is wavering noise; one with automata pushed to an
## extreme has committed to a rule.
result.threshold = threshold
var sum = 0.0
var settled = 0
for c in 0..<m.nClasses:
for cl in 0..<m.nClauses:
let base = cl * m.nLiterals
var csum = 0.0
var csettled = 0
for lit in 0..<m.nLiterals:
let cm = stateCommitment(m.nStates, int(m.teams[c][base + lit]))
csum += cm
if cm >= threshold: inc csettled
result.clauses.add ClauseSettledness(
cls: c, index: cl,
polarity: (if cl < m.half: 1 else: -1),
nAutomata: m.nLiterals,
meanCommitment: csum / m.nLiterals.float,
settledFraction: csettled.float / m.nLiterals.float)
sum += csum
settled += csettled
inc result.nAutomata, m.nLiterals
result.overallMean =
if result.nAutomata > 0: sum / result.nAutomata.float else: 0.0
result.overallSettledFraction =
if result.nAutomata > 0: settled.float / result.nAutomata.float else: 0.0
proc settlednessTrend*(early, late: SettlednessResult): string =
## Classify whether settledness ROSE between two snapshots (the healthy sign).
let d = late.overallMean - early.overallMean
if d > 0.05: "rising"
elif d < -0.05: "falling"
else: "flat"
# ── state histogram (the raw picture behind settledness) ─────────────────────
type
StateHistogram* = object
nStates*: int
nBins*: int
binLo*: seq[int] ## inclusive low state observed in the bin
binHi*: seq[int] ## inclusive high state observed in the bin
counts*: seq[int]
total*: int
proc stateHistogram*(m: TmMachine, nBins = 9): StateHistogram =
## Distribution of every automaton state across [-nStates, nStates].
let nb = max(1, nBins)
let span = 2 * m.nStates + 1
result.nStates = m.nStates
result.nBins = nb
result.counts = newSeq[int](nb)
result.binLo = newSeq[int](nb)
result.binHi = newSeq[int](nb)
for b in 0..<nb:
result.binLo[b] = high(int)
result.binHi[b] = low(int)
for c in 0..<m.nClasses:
for i in 0..<m.teams[c].len:
let st = int(m.teams[c][i])
var bin = ((st + m.nStates) * nb) div span
if bin < 0: bin = 0
if bin >= nb: bin = nb - 1
inc result.counts[bin]
if st < result.binLo[bin]: result.binLo[bin] = st
if st > result.binHi[bin]: result.binHi[bin] = st
inc result.total
for b in 0..<nb:
if result.counts[b] == 0:
result.binLo[b] = 0
result.binHi[b] = 0
proc histogramText*(h: StateHistogram): string =
## One line per bin, e.g. `[-64..-57] 1234 #####`.
for b in 0..<h.nBins:
let bar = "#".repeat(min(40, h.counts[b] div max(1, h.total div 200 + 1)))
result.add &" [{h.binLo[b]:>4}..{h.binHi[b]:>4}] {h.counts[b]:>8} {bar}\n"
# ── per-input automata confidence table ──────────────────────────────────────
type
InputConfidence* = object
bit*: int
name*: string
nAutomata*: int
meanCommitment*: float
settledFraction*: float
constant*: bool ## zero variance across the sample set (information-free)
proc perInputConfidence*(m: TmMachine, spec: FeatureSpec,
samples: openArray[DiagSample],
threshold = 0.5): seq[InputConfidence] =
## For every raw bit: the mean commitment of the automata of its positive
## literal and its negation, across every clause and class. Inputs the TM is
## confident about sit high; a bit whose automata are all parked at the
## boundary (e.g. a constant input) sits near 0. `constant` flags zero-variance
## bits so a high commitment on a never-written input is not mistaken for
## learning (the shipped gun's bits 38/39 are the canonical example).
result = newSeq[InputConfidence](m.nBits)
var consts: seq[int]
if samples.len > 0:
for b in 0..<m.nBits:
let v0 = samples[0].lits[b]
var cst = true
for i in 1..<samples.len:
if samples[i].lits[b] != v0:
cst = false
break
if cst: consts.add b
for b in 0..<m.nBits:
result[b].bit = b
result[b].name = spec.describe(b)
result[b].constant = b in consts
var sum = 0.0
var settled = 0
var n = 0
for c in 0..<m.nClasses:
for cl in 0..<m.nClauses:
let base = cl * m.nLiterals
let cmPos = stateCommitment(m.nStates, int(m.teams[c][base + b]))
let cmNeg = stateCommitment(m.nStates,
int(m.teams[c][base + b + m.nBits]))
sum += cmPos + cmNeg
if cmPos >= threshold: inc settled
if cmNeg >= threshold: inc settled
inc n, 2
result[b].nAutomata = n
result[b].meanCommitment = if n > 0: sum / n.float else: 0.0
result[b].settledFraction = if n > 0: settled.float / n.float else: 0.0
proc rankedInputConfidence*(conf: seq[InputConfidence]): seq[InputConfidence] =
## Least confident first — the inputs whose automata are still undecided.
result = conf
result.sort(proc(a, b: InputConfidence): int =
result = cmp(a.meanCommitment, b.meanCommitment)
if result == 0: result = cmp(a.bit, b.bit))
# ── 2. CLAUSE DIVERSITY ──────────────────────────────────────────────────────
type
DiversityResult* = object
jaccardPos*: float ## mean pairwise Jaccard among positive clauses
jaccardNeg*: float ## ... among negative clauses
jaccardOverall*: float ## pooled over both polarities
nPosClauses*: int
nNegClauses*: int
nPairs*: int
posValid*: bool
negValid*: bool
valid*: bool
skipEmpty*: bool
proc jaccard*(a, b: openArray[int]): float =
## |a n b| / |a u b| for two sorted, unique literal sets. Two empty sets are
## identical (1.0); an empty against a non-empty set share nothing (0.0).
if a.len == 0 and b.len == 0: return 1.0
if a.len == 0 or b.len == 0: return 0.0
var i = 0
var j = 0
var inter = 0
while i < a.len and j < b.len:
if a[i] == b[j]: inc inter; inc i; inc j
elif a[i] < b[j]: inc i
else: inc j
let uni = a.len + b.len - inter
if uni == 0: 1.0 else: inter.float / uni.float
proc polarityDiversity(m: TmMachine, polarity: int, skipEmpty: bool):
tuple[sum: float, npairs, nclauses: int] =
var sets: seq[seq[int]]
let cl0 = if polarity > 0: 0 else: m.half
let cl1 = if polarity > 0: m.half else: m.nClauses
for c in 0..<m.nClasses:
for cl in cl0..<cl1:
let ls = m.clauseLits(c, cl)
if skipEmpty and ls.len == 0: continue
sets.add ls
result.nclauses = sets.len
for i in 0..<sets.len:
for j in (i + 1)..<sets.len:
result.sum += jaccard(sets[i], sets[j])
inc result.npairs
proc clauseDiversity*(m: TmMachine, skipEmpty = true): DiversityResult =
## Mean pairwise Jaccard overlap of the INCLUDED-literal sets, within the same
## polarity (positive vs positive, negative vs negative). Low-to-moderate is
## healthy; near 1.0 means the clauses are the same rule wearing 50 hats;
## near 0 with few samples usually means memorising individual ticks. Empty
## clauses carry no rule and are skipped by default (`skipEmpty`).
result.skipEmpty = skipEmpty
let p = polarityDiversity(m, 1, skipEmpty)
let n = polarityDiversity(m, -1, skipEmpty)
result.nPosClauses = p.nclauses
result.nNegClauses = n.nclauses
result.posValid = p.npairs > 0
result.negValid = n.npairs > 0
result.jaccardPos = if p.npairs > 0: p.sum / p.npairs.float else: 0.0
result.jaccardNeg = if n.npairs > 0: n.sum / n.npairs.float else: 0.0
let pairs = p.npairs + n.npairs
result.nPairs = pairs
result.valid = pairs > 0
result.jaccardOverall =
if pairs > 0: (p.sum + n.sum) / pairs.float else: 0.0
# ── 3. CHURN ─────────────────────────────────────────────────────────────────
type
ChurnResult* = object
samples*: int
epochs*: int
nAutomata*: int
nClauses*: int
totalFlips*: int
totalClauseChanges*: int
flipRate*: float ## automaton flips per training sample (fraction)
flipRatePer100*: float ## same, x100 (readable)
clauseChangeRate*: float ## clause-set changes per training sample
clauseChangePer100*: float
flipFirst*: float
flipLast*: float
clauseFirst*: float
clauseLast*: float
flipTrend*: string
clauseTrend*: string
windowSize*: int
flipWindows*: seq[float] ## mean per-sample flip rate per window
clauseWindows*: seq[float]
trained*: TmMachine
proc binMeans*(s: seq[float], window: int): seq[float] =
if s.len == 0: return
let w = max(1, window)
var i = 0
while i < s.len:
let j = min(s.len, i + w)
var sum = 0.0
for k in i..<j: sum += s[k]
result.add sum / (j - i).float
i = j
proc firstLastMeans*(s: seq[float]): tuple[first, last: float] =
if s.len == 0: return (0.0, 0.0)
let q = max(1, s.len div 5)
var a = 0.0
var b = 0.0
for i in 0..<q: a += s[i]
for i in (s.len - q)..<s.len: b += s[i]
(a / q.float, b / q.float)
proc classifyChurnTrend*(s: seq[float]): string =
## "falling" is the healthy sign; "flat"/"rising" at a high rate is
## fidgeting; "frozen" means it never moved at all.
if s.len < 4: return "insufficient"
let (first, last) = firstLastMeans(s)
if first <= 1e-12 and last <= 1e-12: return "frozen"
if first <= 1e-12: return "rising"
let ratio = last / first
if ratio < 0.6: "falling"
elif ratio > 1.6: "rising"
else: "flat"
proc churnTrace*(tmpl: TmMachine, samples: openArray[DiagSample],
epochs = 1, seed = 777'u64, window = 100,
shuffle = true): ChurnResult =
## Train a fresh machine while measuring, per training sample, both churn
## levels: the fraction of AUTOMATA crossing the flip boundary, and the
## fraction of CLAUSES whose included-literal set changed. `shuffle = false`
## replays the samples in their given (temporal) order once per epoch, which
## mirrors a live gun training as bullets resolve.
result.epochs = max(1, epochs)
result.samples = samples.len
result.windowSize = max(1, window)
var m = tmpl
m.resetMachine(seed)
result.nAutomata = m.nClasses * m.nClauses * m.nLiterals
result.nClauses = m.nClasses * m.nClauses
if result.nAutomata == 0:
result.trained = m
return
var prevInc = newSeq[uint8](result.nAutomata)
for c in 0..<m.nClasses:
let team = m.teams[c]
let gbase = c * m.nClauses * m.nLiterals
for i in 0..<team.len:
prevInc[gbase + i] = uint8(team[i] > 0)
var flipSeries = newSeq[float]()
var clauseSeries = newSeq[float]()
for _ in 0..<result.epochs:
let idx =
if shuffle: shuffledIndices(samples.len, m.rng)
else: (block:
var id = newSeq[int](samples.len)
for i in 0..<samples.len: id[i] = i
id)
for k in idx:
m.trainSample(samples[k].lits, samples[k].label)
var flips = 0
var clauseChanges = 0
for c in 0..<m.nClasses:
let team = m.teams[c]
let gbase = c * m.nClauses * m.nLiterals
for cl in 0..<m.nClauses:
var changed = false
let base = cl * m.nLiterals
let gb = gbase + base
for lit in 0..<m.nLiterals:
let nowInc = uint8(team[base + lit] > 0)
if nowInc != prevInc[gb + lit]:
inc flips
changed = true
prevInc[gb + lit] = nowInc
if changed: inc clauseChanges
result.totalFlips += flips
result.totalClauseChanges += clauseChanges
flipSeries.add flips.float / result.nAutomata.float
clauseSeries.add clauseChanges.float / result.nClauses.float
result.trained = m
let nObs = flipSeries.len
if nObs > 0:
result.flipRate = result.totalFlips.float /
(nObs.float * result.nAutomata.float)
result.clauseChangeRate = result.totalClauseChanges.float /
(nObs.float * result.nClauses.float)
result.flipRatePer100 = result.flipRate * 100.0
result.clauseChangePer100 = result.clauseChangeRate * 100.0
let ff = firstLastMeans(flipSeries)
let cf = firstLastMeans(clauseSeries)
result.flipFirst = ff.first
result.flipLast = ff.last
result.clauseFirst = cf.first
result.clauseLast = cf.last
result.flipTrend = classifyChurnTrend(flipSeries)
result.clauseTrend = classifyChurnTrend(clauseSeries)
result.flipWindows = binMeans(flipSeries, result.windowSize)
result.clauseWindows = binMeans(clauseSeries, result.windowSize)
# ── 4. VOTE DISAGREEMENT ─────────────────────────────────────────────────────
type
VoteDisagreementResult* = object
overall*: float
perClass*: seq[float]
perClassSamples*: seq[int]
nPairs*: int ## (class, sample) observations that had a vote
proc voteDisagreement*(m: TmMachine, samples: openArray[DiagSample]):
VoteDisagreementResult =
## For each class, over the samples where it casts at least one clause vote:
## the fraction of FIRING clauses whose own polarity disagrees with the sign
## of the class's total vote. A class whose clauses always pull together has
## disagreement ~0; a fuzzy class boundary or too few clauses makes it high.
## A tied class vote (mixed signs) counts every firing clause as disagreeing.
result.perClass = newSeq[float](m.nClasses)
result.perClassSamples = newSeq[int](m.nClasses)
var classSum = newSeq[float](m.nClasses)
for c in 0..<m.nClasses:
for s in samples:
var vote = 0.0
var firing: seq[int]
for cl in 0..<m.nClauses:
let o = m.tmEval(m.teams[c], s.lits, cl, learning = false)
if o == 1'u8:
vote += m.tmPolarity(cl)
firing.add cl
if firing.len == 0: continue
let majSign = if vote > 0.0: 1 elif vote < 0.0: -1 else: 0
var disagree = 0
for cl in firing:
let pol = if m.tmPolarity(cl) > 0.0: 1 else: -1
if pol != majSign: inc disagree
classSum[c] += disagree.float / firing.len.float
inc result.perClassSamples[c]
inc result.nPairs
var totalSum = 0.0
var totalN = 0
for c in 0..<m.nClasses:
if result.perClassSamples[c] > 0:
result.perClass[c] = classSum[c] / result.perClassSamples[c].float
totalSum += result.perClass[c]
inc totalN
result.overall = if totalN > 0: totalSum / totalN.float else: 0.0
# ── the convenience driver + the one-line health summary ─────────────────────
type
AutomataDiag* = object
machine*: TmMachine
settledness*: SettlednessResult
diversity*: DiversityResult
churn*: ChurnResult
disagreement*: VoteDisagreementResult
histogram*: StateHistogram
inputConfidence*: seq[InputConfidence]
shape*: ClauseShapeDiag
summary*: string
proc settlednessVerdict*(s: SettlednessResult): string =
if s.overallMean >= 0.65: "settled"
elif s.overallMean >= 0.40: "settling"
else: "wavering"
proc diversityVerdict*(d: DiversityResult): string =
if not d.valid: "n/a"
elif d.jaccardOverall < 0.15: "diverse"
elif d.jaccardOverall <= 0.70: "moderate"
else: "redundant"
proc churnVerdict*(c: ChurnResult): string =
case c.flipTrend
of "falling": "settling"
of "rising": "fidgeting (rising)"
of "frozen": "frozen"
of "insufficient", "not-measured": c.flipTrend
else:
if c.flipRatePer100 > 5.0: "fidgeting" else: "quiet"
proc disagreementVerdict*(d: VoteDisagreementResult): string =
if d.overall < 0.15: "coherent"
elif d.overall < 0.35: "fuzzy"
else: "fragmented"
proc healthLine*(d: AutomataDiag): string =
## ONE line to glance at: settledness / diversity / churn trend / disagreement
## / CLAUSE SHAPE, each with its own verdict word. The shape clause is included
## only when the shape readout was computed (a manually-built `AutomataDiag`
## without `shape` leaves it out).
result =
&"settledness={d.settledness.overallMean:.3f} ({settlednessVerdict(d.settledness)}) | " &
&"diversity={d.diversity.jaccardOverall:.3f} ({diversityVerdict(d.diversity)}) | " &
&"churn={d.churn.flipRatePer100:.3f}/100 {d.churn.flipTrend} " &
&"({churnVerdict(d.churn)}) | " &
&"disagreement={d.disagreement.overall:.3f} " &
&"({disagreementVerdict(d.disagreement)})"
if d.shape.summary.totalClauses > 0:
result.add &" | shape={d.shape.summary.meanLength:.2f} ({d.shape.verdict})"
proc automataDiagnostics*(tmpl: TmMachine, samples: openArray[DiagSample],
spec: FeatureSpec, epochs = 1, seed = 777'u64,
settleThreshold = 0.5, nHistBins = 9,
window = 100, measureChurn = true): AutomataDiag =
## Train a fresh machine on `samples` (measuring churn along the way unless
## `measureChurn` is false) and compute every automata-level metric on it.
if measureChurn:
result.churn = churnTrace(tmpl, samples, epochs, seed, window)
result.machine = result.churn.trained
else:
result.machine = trainModel(tmpl, samples, epochs, seed)
result.churn.epochs = max(1, epochs)
result.churn.samples = samples.len
result.churn.trained = result.machine
result.churn.flipTrend = "not-measured"
result.churn.clauseTrend = "not-measured"
result.settledness = settledness(result.machine, settleThreshold)
result.diversity = clauseDiversity(result.machine)
result.disagreement = voteDisagreement(result.machine, samples)
result.histogram = stateHistogram(result.machine, nHistBins)
result.inputConfidence = perInputConfidence(result.machine, spec, samples,
settleThreshold)
result.shape = clauseShapeDiagnostics(result.machine, samples, spec)
result.summary = healthLine(result)
proc automataVerdict*(d: AutomataDiag): string =
## One word for the whole trajectory: settling / fidgeting / collapsed / other.
if d.churn.flipTrend == "falling" and d.settledness.overallMean >= 0.40:
"settling"
elif d.churn.flipTrend in ["flat", "rising"] and d.churn.flipRatePer100 > 5.0:
"fidgeting"
elif d.settledness.overallMean < 0.25 and d.churn.flipTrend == "frozen":
"collapsed"
elif d.disagreement.overall > 0.35 and d.settledness.overallMean < 0.40:
"collapsed"
elif d.settledness.overallMean >= 0.65 and d.churn.flipTrend == "frozen":
"converged-static"
else:
"mixed"
proc formatAutomataReport*(d: AutomataDiag): string =
## The full readout: health line, verdict, state histogram, per-input
## confidence (least confident first) and per-class disagreement.
result.add "health: " & d.summary & "\n"
result.add "verdict: " & automataVerdict(d) & "\n"
result.add &"automata: n={d.settledness.nAutomata} settled>={d.settledness.threshold:.2f} " &
&"frac={d.settledness.overallSettledFraction:.3f}\n"
result.add &"churn: flips/sample={d.churn.flipRate:.6f} ({d.churn.flipRatePer100:.3f}/100, " &
&"{d.churn.flipTrend}: {d.churn.flipFirst:.6f}->{d.churn.flipLast:.6f}) " &
&"clause-change/sample={d.churn.clauseChangeRate:.6f} " &
&"({d.churn.clauseChangePer100:.3f}/100, {d.churn.clauseTrend})\n"
result.add &"diversity: pos={d.diversity.jaccardPos:.3f} " &
&"neg={d.diversity.jaccardNeg:.3f} overall={d.diversity.jaccardOverall:.3f} " &
&"(pairs={d.diversity.nPairs}, posClauses={d.diversity.nPosClauses}, " &
&"negClauses={d.diversity.nNegClauses})\n"
result.add "state histogram:\n" & histogramText(d.histogram)
result.add "per-input confidence (least confident first):\n"
let ranked = rankedInputConfidence(d.inputConfidence)
for i in 0..<min(12, ranked.len):
let tag = if ranked[i].constant: " CONST" else: ""
result.add &" bit{ranked[i].bit:>2} {ranked[i].name:<28} " &
&"meanCommit={ranked[i].meanCommitment:.3f} settled={ranked[i].settledFraction:.3f}{tag}\n"
var cbits: seq[int]
for ic in d.inputConfidence:
if ic.constant: cbits.add ic.bit
result.add &"constant inputs (zero variance): {cbits}\n"
result.add "disagreement per class:"
for c in 0..<d.disagreement.perClass.len:
result.add &" c{c}={d.disagreement.perClass[c]:.3f}"
result.add "\n"
if d.shape.summary.totalClauses > 0:
result.add formatClauseShapeReport(d.shape)