Files
solution-erp/scripts/mfe-eval.ps1

306 lines
17 KiB
PowerShell

# mfe-eval.ps1 - Harness-16 Memory-Fidelity-EVAL (MFE) - S93 (2026-06-29)
#
# WHAT THIS IS (and is NOT):
# MFE = COVERAGE / RETENTION eval: when hot-memory (always-loaded Tier-1) is
# provisioned, how much of the REQUIRED governance set actually FITS, and (per
# role) how much is still CARRIED in the persistent diary. It answers a question
# the %-print (size-by-length) cannot: provisioned != remembered != applied.
# This is DISTINCT from H6.7 "memoryDelta-routing-fidelity" (the right delta
# landing in the right agent-memory). DO NOT conflate the two senses.
#
# NON-NEGOTIABLES (Harness-11 / Harness-16):
# (1) NO-API : Select-String + byte/file parse ONLY. NEVER calls a model.
# (2) READ-ONLY on token_governor : MFE consumes the budget caps (single-source),
# NEVER writes/auto-tunes them (owner-authority floor, budget.json role_boundary_note).
# (3) PS 5.1, ASCII-only script body (gotcha #30). Target files read -Encoding UTF8.
# (4) Exit 0 always : a measure-and-report tool, NOT a build gate.
#
# DETERMINISTIC ($0) block (Branches: lead Coverage + age-band + Goodhart; sub per-role coverage).
# Branch-A judge (recall/apply) = SCAFFOLD only (-Judge) : numbers MEANINGLESS until
# sample-questions mature AND an independent cross-session judge runs (see honest_caveats).
#
# Usage:
# powershell.exe -ExecutionPolicy Bypass -File scripts\mfe-eval.ps1 # all tiers
# powershell.exe -ExecutionPolicy Bypass -File scripts\mfe-eval.ps1 -Tier lead
# powershell.exe -ExecutionPolicy Bypass -File scripts\mfe-eval.ps1 -Tier sub
# powershell.exe -ExecutionPolicy Bypass -File scripts\mfe-eval.ps1 -Judge # show judge scaffold (no scoring)
param(
[string]$RepoRoot = "$PSScriptRoot\..",
[ValidateSet('lead','sub','all')] [string]$Tier = 'all',
[switch]$Judge = $false,
[datetime]$Today = (Get-Date)
)
$ErrorActionPreference = 'Stop'
$CHECK = [char]0x2705 # green check glyph, kept as code-point (ASCII body, gotcha #30)
# ---------------------------------------------------------------------------
# resolve paths + load single-source config
# ---------------------------------------------------------------------------
$RepoRoot = (Resolve-Path $RepoRoot).Path
$budgetPath = Join-Path $RepoRoot '.claude\agent-memory\memory-budget.json'
if (-not (Test-Path $budgetPath)) { Write-Host "memory-budget.json not found: $budgetPath"; exit 0 }
$budget = Get-Content $budgetPath -Raw -Encoding UTF8 | ConvertFrom-Json
$cfg = $budget.mfe
if ($null -eq $cfg) { Write-Host "memory-budget.json missing 'mfe' config block (Harness-16)"; exit 0 }
$leadCap = [int]$budget.token_governor.tier1_hotfeed_tokens.lead_tokens # LIVE-read, never hardcode
$memCap = [int]$budget.token_governor.tier1_hotfeed_tokens.memory_sub_tokens
$marksPath = Join-Path $RepoRoot '.claude\governance\ACTIVE-MARKS.md'
$ledgerPath = Join-Path $RepoRoot 'docs\governance\error-ledger.md'
$gotchaPath = Join-Path $RepoRoot 'docs\gotchas.md'
$statePath = Join-Path $RepoRoot $cfg.state_file
function Read-Utf8([string]$p) { if (Test-Path $p) { return Get-Content $p -Encoding UTF8 } else { return @() } }
function Bytes([string]$s) { return [Text.Encoding]::UTF8.GetByteCount($s) }
function Cells([string]$line) {
# split a markdown table row into inner cells (drop leading/trailing empties)
$parts = $line.Trim().Trim('|') -split '\|'
return ($parts | ForEach-Object { $_.Trim() })
}
function Remove-Diacritics([string]$t) {
$n = $t.Normalize([Text.NormalizationForm]::FormD)
$sb = New-Object Text.StringBuilder
foreach ($c in $n.ToCharArray()) {
if ([Globalization.CharUnicodeInfo]::GetUnicodeCategory($c) -ne [Globalization.UnicodeCategory]::NonSpacingMark) { [void]$sb.Append($c) }
}
return $sb.ToString().ToLowerInvariant()
}
function Content-Words([string]$s, [string[]]$stop) {
$folded = Remove-Diacritics $s
$words = $folded -split '[^a-z0-9]+' | Where-Object { $_ -and $_.Length -ge 2 -and ($stop -notcontains $_) }
return ($words | Select-Object -Unique)
}
Write-Host ""
Write-Host "=== MFE (Memory-Fidelity-EVAL) - Harness-16 - $($Today.ToString('yyyy-MM-dd')) ==="
Write-Host " (coverage/retention eval; NOT memoryDelta-routing-fidelity H6.7)"
Write-Host " lead hot-feed cap (live) = $leadCap tok | mem-sub cap = $memCap tok"
Write-Host ""
# ===========================================================================
# LEAD TIER : must-remember denominator -> Coverage(fit) + age-band + Goodhart
# ===========================================================================
function Measure-Lead {
# --- Source A: Active marks (status Active-High / Active) ---
$marks = @(); $markBytes = 0; $markDates = @()
$inSec = $false
foreach ($ln in (Read-Utf8 $marksPath)) {
if ($ln -match '^##\s') { $inSec = ($ln -match 'ACTIVE') ; continue } # ACTIVE-HIGH + ACTIVE; skips MEDIUM/SUPERSEDED
if ($inSec -and $ln -match '^\|\s*`(RC-[a-z0-9-]+)`') {
$rid = $matches[1]
$c = Cells $ln
$status = $c[$c.Count-1]
if ($status -match 'Active-High' -or $status -match 'Active\b') {
$marks += $rid; $markBytes += (Bytes $ln)
if ($rid -match '-(\d{2})-(\d{2})-(\d{4})-\d{2}-\d{2}-\d{2}$') {
$markDates += [datetime]::new([int]$matches[3],[int]$matches[2],[int]$matches[1])
}
}
}
}
# --- Source B: error-ledger Active-Guards + AS-table + RCA strikes ---
$guards = @(); $guardBytes = 0; $guardCounters = @{}
$asIds = @(); $asGotchaRefs = New-Object System.Collections.Generic.HashSet[string]
$asBytes = 0; $strikesTotal = 0; $rcaCount = 0
$section = ''
foreach ($ln in (Read-Utf8 $ledgerPath)) {
if ($ln -match '^##\s') {
if ($ln -match 'Active-Guards') { $section = 'guards' }
elseif ($ln -match 'L\.a') { $section = 'as' }
else { $section = '' }
continue
}
if ($ln -match '^### (E-\d+)') { $rcaCount++ ; continue }
if ($section -eq 'guards' -and $ln -match '^\|' -and $ln -match [regex]::Escape($CHECK)) {
$c = Cells $ln
if ($c.Count -ge 5 -and $c[0] -notmatch '^-+$' -and $c[0] -ne 'Guard') {
$net = $c[$c.Count-1]
if ($net -notmatch '^\s*-' -and $net -notmatch 'retire') {
$g = $c[0]; $guards += $g; $guardBytes += (Bytes $ln); $guardCounters[$g] = $c[1]
if ($c[3] -match '(\d+)') { $strikesTotal += [int]$matches[1] } # Strikes col
}
}
}
if ($section -eq 'as' -and $ln -match '^\|\s*(AS-\d+)\b') {
$asIds += $matches[1]; $asBytes += (Bytes $ln)
foreach ($m in [regex]::Matches($ln, '#(\d+)')) { [void]$asGotchaRefs.Add($m.Groups[1].Value) }
}
}
# --- Source C: recurring gotchas ---
$reTokens = @($cfg.recurrence_tokens)
$allGotchas = 0; $recurGotchaNums = @(); $recurBytes = 0
$lines = Read-Utf8 $gotchaPath
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '^### (\d+)\.') {
$allGotchas++
$num = $matches[1]
$blk = $lines[$i]
for ($j = $i+1; $j -lt $lines.Count -and $lines[$j] -notmatch '^### '; $j++) { $blk += "`n" + $lines[$j] }
$folded = Remove-Diacritics $blk
$isRecur = $false
foreach ($t in $reTokens) { if ($folded.Contains((Remove-Diacritics $t))) { $isRecur = $true; break } }
if ($isRecur) { $recurGotchaNums += $num; $recurBytes += (Bytes ($lines[$i])) }
}
}
# --- DEDUP (merge_guard_to_as): collapse gotchas/guards that an AS-row references ---
$merge = [bool]$cfg.merge_guard_to_as
$netRecur = @($recurGotchaNums | Where-Object { -not $asGotchaRefs.Contains($_) })
$netGuards = @()
foreach ($g in $guards) {
$ctr = [string]$guardCounters[$g]
$absorbed = $false
if ($merge) {
if ($ctr -match 'AS-\d+') { $absorbed = $true }
else { foreach ($m in [regex]::Matches($ctr, '#(\d+)')) { if ($asGotchaRefs.Contains($m.Groups[1].Value)) { $absorbed = $true; break } } }
}
if (-not $absorbed) { $netGuards += $g }
}
$denomCount = $marks.Count + $asIds.Count + $netGuards.Count + $netRecur.Count
$denomBytes = $markBytes + $asBytes + $guardBytes + $recurBytes
$tokLow = [math]::Round($denomBytes / 4.0)
$tokHigh = [math]::Round($denomBytes / 3.0)
$pctHigh = if ($leadCap -gt 0) { [math]::Round(100.0 * $tokHigh / $leadCap, 2) } else { 0 }
$fits = ($tokHigh -le $leadCap)
Write-Host "[LEAD] must-remember denominator (deduped, merge_guard_to_as=$merge):"
Write-Host " marks(Active+High)=$($marks.Count) AS-classes=$($asIds.Count) net-extra-guards=$($netGuards.Count) net-extra-recurring-gotchas=$($netRecur.Count)"
Write-Host " => DENOMINATOR = $denomCount items (raw before dedup: marks $($marks.Count) + AS $($asIds.Count) + guards $($guards.Count) + recurring $($recurGotchaNums.Count))"
Write-Host ""
Write-Host "[LEAD] Coverage-FIT (does the set FIT the hot-feed cap?):"
Write-Host " span bytes=$denomBytes => ~$tokLow - $tokHigh tok (RANGE, see caveat) / cap $leadCap tok = $pctHigh% worst-case"
if ($fits) { Write-Host " FIT = PASS (worst-case fits; headroom huge - must-remember set is tiny vs cap)" }
else { Write-Host " FIT = OVER (worst-case exceeds cap) -> decision: INCREASE budget (lack-of-SPACE case)" }
Write-Host " CAVEAT: char/4 is NOT real tokens; VN-diacritic ~3.0-3.5 byte/tok => byte/4 = upper-bound headroom."
Write-Host " Reported as a RANGE [bytes/4 .. bytes/3.0]; FIT uses bytes/3.0 (worst). Real count inside the band."
Write-Host ""
# --- age-band : FLAG old-but-still-required, NEVER cut (mark RC-...10-29-11) ---
$oldN = 0
foreach ($d in $markDates) { if (($Today - $d).TotalDays -gt 30) { $oldN++ } }
Write-Host "[LEAD] age-band (FLAG only - age=false-proxy, mark RC-...10-29-11):"
Write-Host " marks with parseable date=$($markDates.Count); >30d old but still Active=$oldN -> KEPT (status-driven, age-blind)"
Write-Host " (guards/gotchas carry session-refs not dates -> age = status-driven only; drop on status-change, never by age)"
Write-Host ""
# --- Goodhart anchor : print recurring-error reality next to coverage ---
$lastStrikes = -1
if (Test-Path $statePath) { try { $lastStrikes = [int]((Get-Content $statePath -Raw -Encoding UTF8 | ConvertFrom-Json).strikes_total) } catch { } }
Write-Host "[LEAD] Goodhart anchor (NO self-grading - real recurring-error signal):"
Write-Host " error-ledger: strikes_total=$strikesTotal RCA_entries=$rcaCount AS-classes=$($asIds.Count)"
if ($lastStrikes -ge 0 -and $strikesTotal -gt $lastStrikes -and $fits) {
Write-Host " GOODHART-WARN: strikes rose ($lastStrikes -> $strikesTotal) while set still FITS -> coverage may LIE (set 'fits' but errors recur = not actually applied)"
} else {
Write-Host " (rule: if strikes rise across runs while coverage stays high -> the score lies. last_run_strikes=$lastStrikes)"
}
# persist strikes for cross-run compare (MFE state only; does NOT touch token_governor)
# Write LF + UTF8-no-BOM explicitly (erratum EOL 2026-07-16 lo #6 point-of-generation floor, S129):
# PS5.1 Set-Content -Encoding UTF8 wrote BOM+CRLF -> worktree CRLF churn AND a 3-byte BOM in the
# committed blob. Reader at line 193 pins -Encoding UTF8 in the SAME commit (no BOM-sniff dependency).
try {
$st = @{ strikes_total = $strikesTotal; rca_entries = $rcaCount; at = $Today.ToString('yyyy-MM-dd') }
$json = (($st | ConvertTo-Json) -replace "`r`n", "`n") + "`n"
[System.IO.File]::WriteAllText($statePath, $json, (New-Object System.Text.UTF8Encoding($false)))
} catch { }
Write-Host ""
}
# ===========================================================================
# SUB TIER : per-role coverage (denominator from role .md, numerator from diary)
# ===========================================================================
function Measure-Sub {
$stop = @($cfg.sub_denominator.stop_list)
$anchors = @($cfg.sub_denominator.denom_header_anchors)
$minWords = [int]$cfg.sub_denominator.content_word_min
$roleFiles = Get-ChildItem (Join-Path $RepoRoot '.claude\agents\*.md') -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne 'README.md' }
Write-Host "[SUB] per-role memory coverage (denominator = role-file anti-patterns/baseline; numerator = carried in diary):"
Write-Host " match = exact-id OR >=$minWords distinct content-words after stop-list (leading-verb-only = false-max, defeated)"
Write-Host ""
foreach ($rf in $roleFiles) {
$role = $rf.BaseName
$rlines = Read-Utf8 $rf.FullName
# denominator: numbered '^N.' lines UNDER a denom-anchored '##' header, + a NEVER:/scope line
$denom = @(); $inDenom = $false
foreach ($ln in $rlines) {
if ($ln -match '^##\s') {
$inDenom = $false
foreach ($a in $anchors) { if ($ln -match [regex]::Escape($a)) { $inDenom = $true; break } }
continue
}
if ($inDenom -and $ln -match '^\s*\d+\.\s+(.+)') { $denom += $matches[1] }
if ($ln -match 'NEVER' -and $ln -match ':') { $denom += ($ln -replace '.*NEVER\s*:?\s*','') }
}
$denom = @($denom | Where-Object { $_ -and $_.Trim().Length -gt 3 })
# diary (THE agent diary - NOT project/user MEMORY.md)
$diaryPath = Join-Path $RepoRoot ".claude\agent-memory\$role\MEMORY.md"
if (-not (Test-Path $diaryPath)) {
Write-Host (" {0,-22} N/A (no persistent diary -> ephemeral/unspawned)" -f $role)
continue
}
$diaryRaw = (Read-Utf8 $diaryPath) -join "`n"
$diaryFold = Remove-Diacritics $diaryRaw
$hit = 0
foreach ($item in $denom) {
$words = Content-Words $item $stop
$matched = $false
if ($words.Count -ge $minWords) {
$found = 0
foreach ($w in $words) { if ($diaryFold -match ('(?<![a-z0-9])' + [regex]::Escape($w) + '(?![a-z0-9])')) { $found++ } }
if ($found -ge $minWords) { $matched = $true }
} elseif ($words.Count -ge 1) {
# all-verb / thin item: require the single content word present
if ($diaryFold -match ('(?<![a-z0-9])' + [regex]::Escape($words[0]) + '(?![a-z0-9])')) { $matched = $true }
}
if ($matched) { $hit++ }
}
$den = $denom.Count
if ($den -eq 0) {
# 0 denominator = NOT-EXTRACTABLE (role-file uses prose, no numbered/NEVER block) -> N/A, NOT '0% retention'
Write-Host (" {0,-22} N/A (no numbered anti-pattern / NEVER block in role-file to extract)" -f $role)
continue
}
$pct = [math]::Round(100.0 * $hit / $den)
$flag = if ($pct -lt 60) { " <- LOW (real retention signal, not a target-miss)" } else { "" }
Write-Host (" {0,-22} {1,3}/{2,-3} = {3,3}% (MEASURED, not target-then-force){4}" -f $role, $hit, $den, $pct, $flag)
}
Write-Host ""
Write-Host "[SUB-WORKFLOW] N/A by design: fan-out workflow agents (hmw.js sub-<role>-<i>) are EPHEMERAL"
Write-Host " (no persistent diary; their only memory channel is the lead-injected memory-pack -> source-vs-cap is meaningless)."
Write-Host ""
}
# ===========================================================================
# JUDGE scaffold (Branch A) : -Judge only. SCAFFOLD - numbers MEANINGLESS.
# ===========================================================================
function Show-JudgeScaffold {
$sqPath = Join-Path $RepoRoot $cfg.sample_questions
Write-Host "[JUDGE-SCAFFOLD] Branch-A recall/apply (OPT-IN, cadence-gated, NO-API at scaffold level):"
if (Test-Path $sqPath) {
$sq = Get-Content $sqPath -Raw -Encoding UTF8 | ConvertFrom-Json
Write-Host " sample-questions loaded: $($sq.questions.Count) (seeded $($sq.seeded_date), anchored by stable-id)"
Write-Host " scorer = EMPTY (no answers recorded). recalled/applied = (none) ; meaningful = FALSE"
} else {
Write-Host " sample-questions file NOT found: $sqPath"
}
Write-Host " HONEST: a same-session self-grade is MEANINGLESS (the agent just read the answers)."
Write-Host " Real numbers need (a) sample-questions matured in age AND (b) an independent"
Write-Host " cross-session / different-model judge. Until then this is plumbing-smoke only."
Write-Host ""
}
# ===========================================================================
# dispatch
# ===========================================================================
if ($Tier -eq 'lead' -or $Tier -eq 'all') { Measure-Lead }
if ($Tier -eq 'sub' -or $Tier -eq 'all') { Measure-Sub }
if ($Judge) { Show-JudgeScaffold }
Write-Host "=== MFE done (deterministic block; READ-ONLY on budget; exit 0) ==="
exit 0