# 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,
    [switch]$Detail = $false,   # @S152 owner#32: per-item L1-presence - print each MISSed denominator item by name
    [switch]$Ack = $false,      # @S152 B4: acknowledge current strikes -> resets the GOODHART-WARN latch baseline
    [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; $warnBase = -1; $histOld = @()
    if (Test-Path $statePath) {
        try {
            $stOld = Get-Content $statePath -Raw -Encoding UTF8 | ConvertFrom-Json
            $lastStrikes = [int]$stOld.strikes_total
            if ($stOld.PSObject.Properties.Name -contains 'warn_baseline') { $warnBase = [int]$stOld.warn_baseline }
            if ($stOld.PSObject.Properties.Name -contains 'history') { $histOld = @($stOld.history) }
        } catch { }
    }
    # B4 @S152 LATCH: WARN compares against a PERSISTED warn_baseline that only moves on -Ack.
    # Old behavior compared to the immediately-previous run then overwrote state -> the
    # measuring run CONSUMED its own warning (S152 eval S6: audit re-ran mfe minutes later,
    # WARN already silent). Migration: no warn_baseline key yet -> seed from pre-B4
    # strikes_total (old file) or from the current count on a truly fresh state; the seed
    # itself never fires (baseline opens the ledger, it is not a rise).
    if ($warnBase -lt 0) { $warnBase = if ($lastStrikes -ge 0) { $lastStrikes } else { $strikesTotal } }
    if ($Ack) { $warnBase = $strikesTotal }
    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 ($Ack) {
        Write-Host "   (ACK: warn-baseline set to $strikesTotal - future WARN fires only when strikes exceed this)"
    } elseif ($strikesTotal -gt $warnBase -and $fits) {
        Write-Host "   GOODHART-WARN (LATCHED): strikes rose ($warnBase -> $strikesTotal) while set still FITS -> coverage may LIE. Repeats every run until acknowledged via -Ack."
    } else {
        Write-Host "   (rule: if strikes rise across runs while coverage stays high -> the score lies. warn-baseline=$warnBase 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 {
        # B4 @S152: history[] = append-unbounded BY DESIGN (absence-of-entry = never-happened
        # stays a safe read; if a FIFO cap is ever added, eviction MUST be explicit -
        # log-BOUNDED design-note dede7ec5). ack_strikes persists the WARN-latch baseline.
        $histNew = @($histOld) + ,(@{ at = $Today.ToString('yyyy-MM-dd HH:mm'); strikes_total = $strikesTotal; rca_entries = $rcaCount })
        $st = [ordered]@{ strikes_total = $strikesTotal; rca_entries = $rcaCount; at = $Today.ToString('yyyy-MM-dd'); warn_baseline = $warnBase; history = $histNew }
        $json = (($st | ConvertTo-Json -Depth 5) -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++ }
            elseif ($Detail) {
                # per-item L1-presence probe (owner #32 @S152): name the missing item so the
                # reinject lane acts per-item instead of re-deriving the matcher by hand.
                $short = if ($item.Length -gt 100) { $item.Substring(0, 100) } else { $item }
                Write-Host ("        MISS: {0}" -f $short)
            }
        }
        $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
