Public/Invoke-DebateBatch.ps1

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

function Invoke-DebateBatch {
    <#
    .SYNOPSIS
        Runs a batch of debates from a config file, with per-turn progress
        written to debate-progress.json so Watch-DebateProgress can see live
        state.
    .DESCRIPTION
        Walks a JSON batch config (shape: { name, debates: [{ name, topic, ... }] })
        and runs each debate serially via Invoke-AITDebate. Threads -ProgressFile
        through to each debate so a single shared progress file tracks the
        whole batch. Continues past a single failed debate (marks it 'failed',
        runs the rest) unless -StopOnFailure is set.
 
        Designed to replace the ad-hoc shell loops that ran exp-1069 etc.
        Fixes the 3-hour silent-hang problem by giving Watch-DebateProgress a
        file to poll.
    .PARAMETER ConfigPath
        Path to the batch config JSON. Required.
    .PARAMETER OutputDirectory
        Where to write per-debate outputs and the shared debate-progress.json.
        Defaults to <batch-config-dir>/<batch-name>.
    .PARAMETER ProgressFile
        Override path for debate-progress.json. Default: <OutputDirectory>/debate-progress.json.
    .PARAMETER StopOnFailure
        Halt the batch at the first failing debate. Default: continue past failures.
    .PARAMETER Synthetic
        Mark every debate in this batch as a synthetic/fixture run (t/1812). Sets the
        process env var AI_TRIAD_SYNTHETIC_CALIBRATION=1 around the run so the debate
        engine routes their calibration entries to calibration/fixtures/ instead of
        core/, keeping the real store (and the t/1668 replication gate) clean. This is
        the batch-wide default; it can also be set by a top-level "synthetic": true in
        the batch config, and is overridden PER-DEBATE by a "synthetic" field on any
        debate entry (so a mixed batch is classified per-debate, not blanket). The
        signal is toggled per debate and restored after the run so it never leaks to a
        later real debate in the same session. Do NOT tag real experiments (e.g.
        exp-1069) — that would misroute genuine calibration data and shrink the gate's n.
    .EXAMPLE
        Invoke-DebateBatch -ConfigPath lib/debate/exp-1069-batch.json
    .EXAMPLE
        # Smoke/fixture batch — tag its calibration as synthetic:
        Invoke-DebateBatch -ConfigPath lib/debate/smoke-batch.json -Synthetic
    .EXAMPLE
        # In one terminal:
        Invoke-DebateBatch -ConfigPath lib/debate/exp-1069-batch.json
        # In another terminal:
        Watch-DebateProgress -Path <output>/debate-progress.json
    .LINK
        Show-AITriadHelp
    .LINK
        Show-TriadDialogue
    .LINK
        Invoke-AITDebate
    .LINK
        Get-AITDebate
    .LINK
        Resume-AITDebate
    .LINK
        Repair-DebateOutput
    .LINK
        Watch-DebateProgress
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateScript({ Test-Path $_ })]
        [Alias('Path')]
        [string]$ConfigPath,

        [Parameter()]
        [Alias('OutputPath')]
        [string]$OutputDirectory,

        [Parameter()]
        [string]$ProgressFile,

        [Parameter()]
        [switch]$StopOnFailure,

        [Parameter()]
        [switch]$Synthetic
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    # ── Load batch config ─────────────────────────────────────
    $Resolved = (Resolve-Path $ConfigPath).Path
    $Batch = Get-Content -Raw -Path $Resolved | ConvertFrom-Json
    if (-not $Batch.PSObject.Properties['debates']) {
        New-ActionableError `
            -Goal     'Run debate batch' `
            -Problem  "Batch config missing 'debates' array: $Resolved" `
            -Location 'Invoke-DebateBatch' `
            -NextSteps @("Add a 'debates' array to the config", 'See lib/debate/exp-1069-batch.json for an example') `
            -Throw
    }
    $Debates = @($Batch.debates)
    if ($Debates.Count -eq 0) {
        Write-Warning "Batch config has zero debates: $Resolved"
        return
    }

    $BatchName = if ($Batch.PSObject.Properties['name']) { [string]$Batch.name } else { [System.IO.Path]::GetFileNameWithoutExtension($Resolved) }

    # t/1812 — synthetic/fixture tagging. SELECTIVE + PER-DEBATE (TL p/24#148): a real
    # experiment (exp-1069) must NOT be tagged or its calibration misroutes to fixtures/
    # and shrinks the t/1668 gate's n. This is the batch-wide DEFAULT (authoritative:
    # -Synthetic switch or a top-level "synthetic": true); a per-debate "synthetic"
    # field in the config overrides it per debate in the loop below — never guessed, so
    # a mixed batch is classified per-debate, not blanket.
    $BatchSyntheticDefault = [bool]$Synthetic -or ($Batch.PSObject.Properties['synthetic'] -and [bool]$Batch.synthetic)

    # ── Resolve output dir + progress file ────────────────────
    if (-not $OutputDirectory) {
        $OutputDirectory = Join-Path (Split-Path -Parent $Resolved) $BatchName
    }
    if (-not (Test-Path $OutputDirectory)) {
        $null = New-Item -ItemType Directory -Path $OutputDirectory -Force
    }
    if (-not $ProgressFile) {
        $ProgressFile = Join-Path $OutputDirectory 'debate-progress.json'
    }

    Write-Host ''
    Write-Host "Debate batch: $BatchName ($($Debates.Count) debates)" -ForegroundColor Cyan
    Write-Host " Output: $OutputDirectory"
    Write-Host " Progress: $ProgressFile" -ForegroundColor Yellow
    Write-Host " Watch with: Watch-DebateProgress -Path '$ProgressFile'" -ForegroundColor DarkGray
    Write-Host ''

    # ── Seed progress file with all debates as 'pending' ──────
    $DebateNames = @($Debates | ForEach-Object {
        if ($_.PSObject.Properties['name']) { [string]$_.name } else { 'unnamed' }
    })
    # Use the first debate name to bootstrap the file then seed the rest
    if ($DebateNames.Count -gt 0) {
        Update-DebateProgress -Path $ProgressFile -DebateName $DebateNames[0] `
            -BatchName $BatchName -Debates $DebateNames -Fields @{ status = 'pending' }
    }

    # ── Run each debate ───────────────────────────────────────
    $Results = [System.Collections.Generic.List[PSObject]]::new()
    $BatchStart = Get-Date

    # t/1812 — process-scoped synthetic signal, toggled PER-DEBATE below and restored in
    # finally so it never leaks to a real debate later in the same session. The engine
    # reads it PER-WRITE (calibrationLogger/io.ts isSyntheticRun at the appendCalibrationLog
    # funnel), and each Invoke-AITDebate spawns an engine child that inherits it at spawn.
    $PrevSynthEnv = $env:AI_TRIAD_SYNTHETIC_CALIBRATION
    try {
    foreach ($D in $Debates) {
        # Per-debate classification: an explicit per-debate "synthetic" field wins;
        # otherwise the batch-wide default. Set right before the Invoke-AITDebate spawn
        # (child inherits env), cleared for real debates so a mixed batch never over-tags.
        $DebateSynthetic = if ($D.PSObject.Properties['synthetic']) { [bool]$D.synthetic } else { $BatchSyntheticDefault }
        if ($DebateSynthetic) { $env:AI_TRIAD_SYNTHETIC_CALIBRATION = '1' }
        else { Remove-Item Env:AI_TRIAD_SYNTHETIC_CALIBRATION -ErrorAction SilentlyContinue }

        $Name = if ($D.PSObject.Properties['name']) { [string]$D.name } else { 'unnamed' }
        Write-Host "▶ $Name" -ForegroundColor Cyan

        # Build Invoke-AITDebate params from this debate's config
        $Params = @{
            ProgressFile       = $ProgressFile
            ProgressDebateName = $Name
            ProgressBatchName  = $BatchName
            OutputDirectory    = $OutputDirectory
            Name               = $Name
        }
        if ($D.PSObject.Properties['topic'])              { $Params.Topic           = [string]$D.topic }
        if ($D.PSObject.Properties['docPath'])            { $Params.DocPath         = [string]$D.docPath }
        if ($D.PSObject.Properties['url'])                { $Params.Url             = [string]$D.url }
        if ($D.PSObject.Properties['crossCuttingId'])     { $Params.CrossCuttingNodeId = [string]$D.crossCuttingId }
        if ($D.PSObject.Properties['model'])              { $Params.Model           = [string]$D.model }
        if ($D.PSObject.Properties['rounds'])             { $Params.Rounds          = [int]$D.rounds }
        if ($D.PSObject.Properties['responseLength'])     { $Params.ResponseLength  = [string]$D.responseLength }
        if ($D.PSObject.Properties['protocol'])           { $Params.Protocol        = [string]$D.protocol }
        if ($D.PSObject.Properties['adaptiveStaging'] -and $D.adaptiveStaging) { $Params.AdaptiveStaging = $true }
        if ($D.PSObject.Properties['temperature'])        { $Params.Temperature     = [double]$D.temperature }
        if ($D.PSObject.Properties['confrontationRounds']) { $Params.ConfrontationRounds = [int]$D.confrontationRounds }
        if ($D.PSObject.Properties['argumentationRounds']) { $Params.ArgumentationRounds = [int]$D.argumentationRounds }
        if ($D.PSObject.Properties['concludingRounds'])    { $Params.ConcludingRounds    = [int]$D.concludingRounds }
        if ($D.PSObject.Properties['featureFlags'] -and $D.featureFlags) {
            # ConvertFrom-Json yields PSCustomObject; coerce to hashtable for the param
            $Flags = @{}
            foreach ($Prop in $D.featureFlags.PSObject.Properties) { $Flags[$Prop.Name] = [bool]$Prop.Value }
            $Params.FeatureFlags = $Flags
        }

        try {
            $Result = Invoke-AITDebate @Params
            $Results.Add([PSCustomObject]@{ Name = $Name; Status = 'done'; Result = $Result; Error = $null })
        } catch {
            $ErrMsg = $_.Exception.Message
            Write-Warning " ✗ $Name failed: $ErrMsg"
            $Results.Add([PSCustomObject]@{ Name = $Name; Status = 'failed'; Result = $null; Error = $ErrMsg })
            if ($StopOnFailure) {
                Write-Warning "Stopping batch due to -StopOnFailure"
                break
            }
        }
    }
    } finally {
        # Restore the pre-batch value so the synthetic signal never leaks to a later
        # real debate in the same session (t/1812).
        if ([string]::IsNullOrEmpty($PrevSynthEnv)) { Remove-Item Env:AI_TRIAD_SYNTHETIC_CALIBRATION -ErrorAction SilentlyContinue }
        else { $env:AI_TRIAD_SYNTHETIC_CALIBRATION = $PrevSynthEnv }
    }

    $BatchElapsed = ((Get-Date) - $BatchStart).TotalMinutes
    $Pass = @($Results | Where-Object { $_.Status -eq 'done' }).Count
    $Fail = @($Results | Where-Object { $_.Status -eq 'failed' }).Count

    Write-Host ''
    Write-Host ("Batch $BatchName complete: {0} done, {1} failed ({2:N1} min)" -f $Pass, $Fail, $BatchElapsed) `
        -ForegroundColor $(if ($Fail -eq 0) { 'Green' } else { 'Yellow' })

    [PSCustomObject]@{
        BatchName    = $BatchName
        ProgressFile = $ProgressFile
        Pass         = $Pass
        Fail         = $Fail
        ElapsedMin   = [Math]::Round($BatchElapsed, 2)
        Results      = @($Results)
    }
}