Public/Invoke-QbafConflictAnalysis.ps1

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

function Invoke-QbafConflictAnalysis {
    <#
    .SYNOPSIS
        Analyzes factual claims across summaries using QBAF argumentation strength.
    .DESCRIPTION
        Reads factual_claims from summaries, clusters similar claims using embedding
        similarity, extracts attack/support relations, computes QBAF acceptability
        strengths via the DF-QuAD engine, and outputs QBAF-augmented conflict analysis.
 
        Runs parallel to Find-Conflict (not a replacement yet). Produces richer output
        with computed_strength, attack_type, and resolution analysis.
    .PARAMETER DocId
        Analyze claims from a single document. If omitted, analyzes all summaries.
    .PARAMETER Threshold
        Cosine similarity threshold for claim clustering. Default: 0.85.
    .PARAMETER OutputDir
        Output directory for QBAF conflict files. Default: ai-triad-data/qbaf-conflicts/
    .PARAMETER DryRun
        Report what would be analyzed without writing files.
    .PARAMETER PassThru
        Return the analysis results for piping.
    .EXAMPLE
        Invoke-QbafConflictAnalysis -DocId 'ai-safety-debate-2026'
    .EXAMPLE
        Invoke-QbafConflictAnalysis -DryRun
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$DocId = '',

        [ValidateRange(0.5, 1.0)]
        [double]$Threshold = 0.85,

        [string]$OutputDir = '',

        [switch]$DryRun,

        [switch]$PassThru
    )

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

    $SummariesDir = Get-SummariesDir
    $DataRoot     = Get-DataRoot

    if ([string]::IsNullOrWhiteSpace($OutputDir)) {
        $OutputDir = Join-Path $DataRoot 'qbaf-conflicts'
    }
    if (-not (Test-Path $OutputDir) -and -not $DryRun) {
        $null = New-Item -Path $OutputDir -ItemType Directory -Force
    }

    Write-Step 'QBAF Conflict Analysis'

    # ── Step 1: Load claims from summaries ────────────────────────────────────
    Write-Step 'Loading factual claims'

    if ($DocId) {
        $Path = Join-Path $SummariesDir "$DocId.json"
        if (-not (Test-Path $Path)) {
            New-ActionableError -Goal "load summary for $DocId" `
                -Problem "Summary file not found: $Path" `
                -Location 'Invoke-QbafConflictAnalysis' `
                -NextSteps @("Verify doc ID: $DocId", 'Run Invoke-POVSummary first') -Throw
        }
        $SummaryFiles = @(Get-Item $Path)
    }
    else {
        $SummaryFiles = @(Get-ChildItem -Path $SummariesDir -Filter '*.json' -File | Sort-Object Name)
    }

    $AllClaims = [System.Collections.Generic.List[PSObject]]::new()
    $ClaimIdx = 0

    foreach ($File in $SummaryFiles) {
        try {
            $Summary = Get-Content -Raw -Path $File.FullName | ConvertFrom-Json
        }
        catch { continue }

        if (-not $Summary.factual_claims) { continue }

        foreach ($Claim in @($Summary.factual_claims)) {
            $ClaimIdx++
            if ($Claim.PSObject.Properties['claim']) { $ClaimText = $Claim.claim } else { $ClaimText = '' }
            if ($Claim.PSObject.Properties['claim_label']) { $Label = $Claim.claim_label } else { $Label = "claim-$ClaimIdx" }
            if ($Claim.PSObject.Properties['linked_taxonomy_nodes']) { $Nodes = @($Claim.linked_taxonomy_nodes) } else { $Nodes = @() }
            if ($Claim.PSObject.Properties['doc_position']) { $Position = $Claim.doc_position } else { $Position = 'neutral' }

            # Determine BDI category from linked nodes
            $Category = 'Beliefs'  # Default for factual claims
            if ($Nodes.Count -gt 0) {
                $First = $Nodes[0]
                if ($First -match '-desires-') { $Category = 'Desires' }
                elseif ($First -match '-intentions-') { $Category = 'Intentions' }
            }

            # Extract evidence_criteria if present (from Q-11 prompt changes)
            if ($Claim.PSObject.Properties['evidence_criteria']) { $EvidenceCriteria = $Claim.evidence_criteria } else { $EvidenceCriteria = $null }

            # Compute base_strength from evidence_criteria or use default
            $BaseStrength = 0.5  # Default (Beliefs placeholder for hybrid scoring)
            if ($EvidenceCriteria -and $Category -ne 'Beliefs') {
                $BaseStrength = Get-BaseStrengthFromCriteria -Criteria $EvidenceCriteria -Category $Category
            }

            $AllClaims.Add([PSCustomObject]@{
                Id            = "qc-$ClaimIdx"
                DocId         = $File.BaseName
                Label         = $Label
                Text          = $ClaimText
                Category      = $Category
                Position      = $Position
                Nodes         = $Nodes
                BaseStrength  = $BaseStrength
                Criteria      = $EvidenceCriteria
            })
        }
    }

    Write-OK "Loaded $($AllClaims.Count) claims from $($SummaryFiles.Count) summaries"

    if ($AllClaims.Count -lt 2) {
        Write-Warn 'Need at least 2 claims for conflict analysis'
        return
    }

    # ── Step 2: Detect claim relations using position + taxonomy overlap ───────
    Write-Step 'Detecting claim relations'

    $Edges = [System.Collections.Generic.List[PSObject]]::new()

    # Claims that share taxonomy nodes but take opposing positions are attacks
    for ($i = 0; $i -lt $AllClaims.Count; $i++) {
        for ($j = $i + 1; $j -lt $AllClaims.Count; $j++) {
            $A = $AllClaims[$i]; $B = $AllClaims[$j]
            if ($A.DocId -eq $B.DocId) { continue }  # Same document — skip

            # Check taxonomy node overlap
            $Overlap = @($A.Nodes | Where-Object { $_ -in $B.Nodes })
            if ($Overlap.Count -eq 0) { continue }

            # Determine relation from doc_position
            $IsConflict = ($A.Position -eq 'supports' -and $B.Position -eq 'disputes') -or
                          ($A.Position -eq 'disputes' -and $B.Position -eq 'supports')
            $IsSupport = ($A.Position -eq $B.Position) -and ($A.Position -in @('supports', 'disputes'))

            if ($IsConflict) {
                $Edges.Add([PSCustomObject]@{
                    Source     = $A.Id
                    Target     = $B.Id
                    Type       = 'attacks'
                    Weight     = 0.7
                    AttackType = 'rebut'
                })
            }
            elseif ($IsSupport) {
                $Edges.Add([PSCustomObject]@{
                    Source     = $A.Id
                    Target     = $B.Id
                    Type       = 'supports'
                    Weight     = 0.5
                    AttackType = $null
                })
            }
        }
    }

    Write-OK "Detected $($Edges.Count) relations ($(@($Edges | Where-Object { $_.Type -eq 'attacks' }).Count) attacks, $(@($Edges | Where-Object { $_.Type -eq 'supports' }).Count) supports)"

    if ($DryRun) {
        Write-Host " [DRY RUN] Would process $($AllClaims.Count) claims with $($Edges.Count) relations" -ForegroundColor Yellow
        if ($PassThru) {
            return [PSCustomObject]@{
                ClaimCount   = $AllClaims.Count
                EdgeCount    = $Edges.Count
                AttackCount  = @($Edges | Where-Object { $_.Type -eq 'attacks' }).Count
                SupportCount = @($Edges | Where-Object { $_.Type -eq 'supports' }).Count
            }
        }
        return
    }

    # ── Step 3: Call QBAF engine via node bridge ──────────────────────────────
    Write-Step 'Computing QBAF strengths'

    $QbafInput = [ordered]@{
        nodes = @($AllClaims | ForEach-Object {
            [ordered]@{ id = $_.Id; base_strength = $_.BaseStrength }
        })
        edges = @($Edges | ForEach-Object {
            $E = [ordered]@{
                source = $_.Source; target = $_.Target
                type = $_.Type; weight = $_.Weight
            }
            if ($_.AttackType) { $E['attack_type'] = $_.AttackType }
            $E
        })
    }

    $InputJson = $QbafInput | ConvertTo-Json -Depth 5 -Compress
    $BridgePath = Join-Path (Join-Path (Get-CodeRoot) 'scripts') 'qbaf-bridge.mjs'

    $QbafResult = $null
    try {
        $NpxCmd = Get-Command npx.cmd -ErrorAction SilentlyContinue
        if (-not $NpxCmd) { $NpxCmd = Get-Command npx -ErrorAction SilentlyContinue }
        if (-not $NpxCmd) { throw 'npx not found — install Node.js to enable QBAF propagation' }
        $Process = New-Object System.Diagnostics.Process
        $Process.StartInfo.FileName = $NpxCmd.Source
        $Process.StartInfo.Arguments = "tsx `"$BridgePath`""
        $Process.StartInfo.UseShellExecute = $false
        $Process.StartInfo.RedirectStandardInput = $true
        $Process.StartInfo.RedirectStandardOutput = $true
        $Process.StartInfo.RedirectStandardError = $true
        $null = $Process.Start()

        $Process.StandardInput.Write($InputJson)
        $Process.StandardInput.Close()

        $StdOut = $Process.StandardOutput.ReadToEnd()
        $StdErr = $Process.StandardError.ReadToEnd()
        $Process.WaitForExit(30000)

        if ($Process.ExitCode -ne 0) {
            Write-Warn "QBAF bridge error: $StdErr"
            Write-Warn 'Falling back to base_strength only (no propagation)'
            $QbafResult = $null
        }
        else {
            $QbafResult = $StdOut | ConvertFrom-Json
            Write-OK "QBAF computed: $($QbafResult.iterations) iterations, converged=$($QbafResult.converged)"
        }
    }
    catch {
        Write-Warn "QBAF bridge failed: $($_.Exception.Message) — using base_strength only"
    }

    # ── Step 4: Build output ──────────────────────────────────────────────────
    Write-Step 'Building QBAF conflict analysis'

    $StrengthMap = @{}
    if ($QbafResult -and $QbafResult.PSObject.Properties['strengths']) {
        foreach ($Prop in $QbafResult.strengths.PSObject.Properties) {
            $StrengthMap[$Prop.Name] = [Math]::Round($Prop.Value, 4)
        }
    }

    $Output = [ordered]@{
        generated_at = (Get-Date).ToString('o')
        claim_count  = $AllClaims.Count
        edge_count   = $Edges.Count
        qbaf_converged = if ($QbafResult) { $QbafResult.converged } else { $false }
        qbaf_iterations = if ($QbafResult) { $QbafResult.iterations } else { 0 }
        claims = @($AllClaims | ForEach-Object {
            if ($StrengthMap.ContainsKey($_.Id)) { $CS = $StrengthMap[$_.Id] } else { $CS = $_.BaseStrength }
            [ordered]@{
                id               = $_.Id
                doc_id           = $_.DocId
                label            = $_.Label
                category         = $_.Category
                base_strength    = $_.BaseStrength
                computed_strength = $CS
                strength_delta   = [Math]::Round($CS - $_.BaseStrength, 4)
                linked_nodes     = $_.Nodes
            }
        })
        edges = @($Edges | ForEach-Object {
            [ordered]@{
                source      = $_.Source
                target      = $_.Target
                type        = $_.Type
                weight      = $_.Weight
                attack_type = $_.AttackType
            }
        })
    }

    # Write output
    $OutputFile = Join-Path $OutputDir "qbaf-analysis-$(Get-Date -Format 'yyyy-MM-dd-HHmmss').json"
    if ($PSCmdlet.ShouldProcess($OutputFile, 'Write QBAF conflict analysis')) {
        $Output | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputFile -Encoding UTF8
        Write-OK "Analysis saved to $OutputFile"
    }

    if ($PassThru) {
        return [PSCustomObject]$Output
    }
}

# ── Helper: compute base_strength from evidence_criteria ──────────────────────
function Get-BaseStrengthFromCriteria {
    param(
        [PSObject]$Criteria,
        [string]$Category
    )

    $SpW = @{ vague = 0; qualified = 0.08; precise = 0.15 }

    $Score = 0.1  # floor
    if ($Criteria.PSObject.Properties['specificity']) { $Sp = $Criteria.specificity } else { $Sp = 'vague' }
    if ($SpW.ContainsKey($Sp)) { $SpIncrement = $SpW[$Sp] } else { $SpIncrement = 0 }
    $Score += $SpIncrement
    if ($Criteria.PSObject.Properties['has_warrant'] -and $Criteria.has_warrant) { $Score += 0.15 }
    if ($Criteria.PSObject.Properties['internally_consistent'] -and $Criteria.internally_consistent) { $Score += 0.10 }

    if ($Criteria.PSObject.Properties['category_criteria']) { $CatCriteria = $Criteria.category_criteria } else { $CatCriteria = $null }
    if ($CatCriteria) {
        switch ($Category) {
            'Desires' {
                if ($CatCriteria.PSObject.Properties['values_grounded'] -and $CatCriteria.values_grounded) { $Score += 0.15 }
                if ($CatCriteria.PSObject.Properties['tradeoff_acknowledged'] -and $CatCriteria.tradeoff_acknowledged) { $Score += 0.15 }
                if ($CatCriteria.PSObject.Properties['precedent_cited'] -and $CatCriteria.precedent_cited) { $Score += 0.20 }
            }
            'Intentions' {
                if ($CatCriteria.PSObject.Properties['mechanism_specified'] -and $CatCriteria.mechanism_specified) { $Score += 0.15 }
                if ($CatCriteria.PSObject.Properties['scope_bounded'] -and $CatCriteria.scope_bounded) { $Score += 0.15 }
                if ($CatCriteria.PSObject.Properties['failure_mode_addressed'] -and $CatCriteria.failure_mode_addressed) { $Score += 0.20 }
            }
        }
    }

    return [Math]::Max(0.1, [Math]::Min(1.0, [Math]::Round($Score, 2)))
}