Private/Get-DebateTestedTier.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

# ─────────────────────────────────────────────────────────────────────────────
# DebateTested pure tier/sort-key computation — faithful port of the TypeScript
# single writer at `lib/debate/debateTested.ts` (t/1579 AC #10).
#
# The TS `computeTierAndSortKey` is the canonical implementation; this PS port
# exists because `Update-NodeTestingRecord -RecomputeOnly` needs to sweep 1000+
# nodes per constant-change, and subprocess-per-node into tsx would be
# infeasible. Cross-language consistency is enforced by the shared fixture at
# `tests/fixtures/tier-sort-key-cases.json` — both PS Pester and TS vitest
# should consume it so any drift fails both sides at once.
#
# If you change semantics here, update lib/debate/debateTested.ts in the same
# commit and add the case to the fixture. Any single-side change is a bug.
# ─────────────────────────────────────────────────────────────────────────────

$script:DebateTestedDefaults = @{
    SEVERE_ATTACK_THRESHOLD             = 0.5
    WELL_TESTED_MIN_CHALLENGES          = 5
    WELL_TESTED_MIN_DEBATES             = 2
    EVIDENCE_SATURATION                 = 5
    COSMETIC_EDIT_SIMILARITY_THRESHOLD  = 0.98
}

$script:DebateTestedVerdictWeights = @{
    held             = 1.0
    refined_held     = 1.0
    refined_pending  = 0.6
    refined_rejected = 0.0
    open             = 0.25
    weakened         = -0.5
    cited            = 0.0
}

function Get-DebateTestedTier {
    <#
    .SYNOPSIS
        Pure recompute of {tier, sort_key} for a Debate-Tested record + revisions.
    .DESCRIPTION
        Faithful port of TypeScript computeTierAndSortKey from
        lib/debate/debateTested.ts. Given an ordered record[] of DebateTestedEntry
        objects (raw PSCustomObject from ConvertFrom-Json is fine) and a
        revisions[] of DebateTestedRevision objects, returns a hashtable with
        keys 'tier' and 'sort_key' identical to what the TS single writer
        would produce. Never mutates inputs.
 
        Tier rules:
          - Empty record → 'untested', sort_key 0.
          - No entries above SEVERE_ATTACK_THRESHOLD → 'cited' (all-cited
            verdicts) or 'contested' (any non-cited).
          - ≥ MIN_CHALLENGES challenging entries from ≥ MIN_DEBATES distinct
            debates AND most recent held verdict comes after most recent
            weakened (or a revision has held_since=true) → 'well_tested'.
          - Otherwise → 'contested'.
 
        Sort key = tier_rank + clamp(sum(attack_strength * verdict_weight) /
        EVIDENCE_SATURATION, 0, 0.99), rounded to 2 decimals.
    .PARAMETER Record
        Array of DebateTestedEntry objects. Each must have (at minimum)
        debate_id (string), verdict (string), strongest_attack_encountered
        (object with .strength, or null).
    .PARAMETER Revisions
        Array of DebateTestedRevision objects. Each must have held_since
        (boolean or null).
    .PARAMETER Constants
        Optional hashtable override of any of SEVERE_ATTACK_THRESHOLD,
        WELL_TESTED_MIN_CHALLENGES, WELL_TESTED_MIN_DEBATES,
        EVIDENCE_SATURATION. Missing keys use $script:DebateTestedDefaults.
    .OUTPUTS
        Hashtable with keys 'tier' (untested|cited|contested|well_tested)
        and 'sort_key' (double, rounded to 2 decimals).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]]$Record,

        [Parameter(Mandatory)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]]$Revisions,

        [Parameter()]
        [hashtable]$Constants
    )

    Set-StrictMode -Version Latest

    # ConvertFrom-Json can hand us $null when the field is absent — coerce
    # both inputs to empty arrays so the loop / .Count usage below is safe.
    if ($null -eq $Record)    { $Record    = @() }
    if ($null -eq $Revisions) { $Revisions = @() }

    $c = @{}
    foreach ($k in $script:DebateTestedDefaults.Keys) { $c[$k] = $script:DebateTestedDefaults[$k] }
    if ($Constants) {
        foreach ($k in $Constants.Keys) { $c[$k] = $Constants[$k] }
    }

    if (@($Record).Count -eq 0) {
        return @{ tier = 'untested'; sort_key = 0.0 }
    }

    $challenges           = 0
    $challengeDebateIds   = [System.Collections.Generic.HashSet[string]]::new()
    $evidence             = 0.0

    foreach ($entry in $Record) {
        $attackStrength = 0.0
        $isChallenged   = $false
        if ($entry.PSObject.Properties['strongest_attack_encountered'] -and
            $null -ne $entry.strongest_attack_encountered) {
            $sae = $entry.strongest_attack_encountered
            if ($sae.PSObject.Properties['strength']) {
                $attackStrength = [double]$sae.strength
                if ($attackStrength -ge [double]$c.SEVERE_ATTACK_THRESHOLD) {
                    $isChallenged = $true
                }
            }
        }
        if ($isChallenged) {
            $challenges++
            $null = $challengeDebateIds.Add([string]$entry.debate_id)
        }

        $weight = _Get-DebateTestedVerdictWeight -Verdict ([string]$entry.verdict) -Revisions $Revisions
        $evidence += $attackStrength * $weight
    }

    if ($challenges -eq 0) {
        $allCited = $true
        foreach ($e in $Record) {
            if ([string]$e.verdict -ne 'cited') { $allCited = $false; break }
        }
        $tier = if ($allCited) { 'cited' } else { 'contested' }
    } elseif ($challenges -ge [int]$c.WELL_TESTED_MIN_CHALLENGES -and
              $challengeDebateIds.Count -ge [int]$c.WELL_TESTED_MIN_DEBATES -and
              (_Test-DebateTestedWellTestedOutcome -Record $Record -Revisions $Revisions)) {
        $tier = 'well_tested'
    } else {
        $tier = 'contested'
    }

    $tierRank        = _Get-DebateTestedTierRank -Tier $tier
    $clamped         = $evidence / [double]$c.EVIDENCE_SATURATION
    if ($clamped -lt 0)     { $clamped = 0.0 }
    if ($clamped -gt 0.99)  { $clamped = 0.99 }
    $sortKey         = [Math]::Round(($tierRank + $clamped) * 100) / 100

    return @{ tier = $tier; sort_key = $sortKey }
}

function _Get-DebateTestedTierRank {
    param([Parameter(Mandatory)][string]$Tier)
    Set-StrictMode -Version Latest
    switch ($Tier) {
        'untested'    { return 0 }
        'cited'       { return 1 }
        'contested'   { return 2 }
        'well_tested' { return 3 }
        default       { throw "Unknown tier: $Tier" }
    }
}

function _Get-DebateTestedVerdictWeight {
    param(
        [Parameter(Mandatory)][string]$Verdict,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Revisions
    )
    Set-StrictMode -Version Latest

    if ($Verdict -eq 'refined') {
        $revCount = @($Revisions).Count
        if ($revCount -eq 0) { return $script:DebateTestedVerdictWeights['refined_pending'] }
        $latest = $Revisions[$revCount - 1]
        if ($latest.PSObject.Properties['held_since']) {
            if ($latest.held_since -eq $true)  { return $script:DebateTestedVerdictWeights['refined_held'] }
            if ($latest.held_since -eq $false) { return $script:DebateTestedVerdictWeights['refined_rejected'] }
        }
        return $script:DebateTestedVerdictWeights['refined_pending']
    }

    if ($script:DebateTestedVerdictWeights.ContainsKey($Verdict)) {
        return $script:DebateTestedVerdictWeights[$Verdict]
    }
    return 0.0
}

function _Test-DebateTestedWellTestedOutcome {
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Record,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Revisions
    )
    Set-StrictMode -Version Latest

    $lastHeldIdx = -1
    $lastWeakIdx = -1
    for ($i = 0; $i -lt @($Record).Count; $i++) {
        $v = [string]$Record[$i].verdict
        if ($v -eq 'held')     { $lastHeldIdx = $i }
        if ($v -eq 'weakened') { $lastWeakIdx = $i }
    }

    if ($lastWeakIdx -gt $lastHeldIdx) { return $false }

    $hasRecentHeld = $lastHeldIdx -ge 0
    $hasHeldRevision = $false
    foreach ($r in $Revisions) {
        if ($r.PSObject.Properties['held_since'] -and $r.held_since -eq $true) {
            $hasHeldRevision = $true; break
        }
    }
    return ($hasRecentHeld -or $hasHeldRevision)
}