Public/Invoke-BDIWeightAssignment.ps1

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

function Invoke-BDIWeightAssignment {
    <#
    .SYNOPSIS
        Assigns confidence (Beliefs) and priority (Desires) to taxonomy nodes.
    .DESCRIPTION
        Implements the multi-signal formulas from docs/weighted-bdi-proposal.md:
 
        BELIEF CONFIDENCE (0.10–0.95):
          base(epistemic_type, falsifiability)
          + evidence_boost(source_doc_count, +0.05/doc, cap +0.15)
          + debate_boost(debate_ref_count, +0.03/ref, cap +0.10)
          + edge_boost(supports - attacks, range -0.05 to +0.05)
 
        DESIRE PRIORITY (1–5):
          5 = doctrinal boundary (from POVER_INFO)
          4 = root-level (no parent)
          3 = mid-tree (has parent + children)
          2 = leaf (has parent, no children)
 
        Reads source_evidence_index.json for evidence counts and edges.json
        for edge balance. Writes results back to taxonomy JSON files with
        history entries.
    .PARAMETER POV
        One or more POVs to process. Default: all three.
    .PARAMETER DryRun
        Show computed values without writing to files.
    .PARAMETER DoctrinalBoundaryMap
        Hashtable mapping POV name to array of Desire node IDs that are
        doctrinal boundaries (priority 5). If omitted, uses semantic matching
        against POVER_INFO boundary strings.
    .EXAMPLE
        Invoke-BDIWeightAssignment
    .EXAMPLE
        Invoke-BDIWeightAssignment -DryRun
    .EXAMPLE
        Invoke-BDIWeightAssignment -POV accelerationist
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [ValidateSet('accelerationist', 'safetyist', 'skeptic')]
        [string[]]$POV = @('accelerationist', 'safetyist', 'skeptic'),

        [switch]$DryRun,

        [hashtable]$DoctrinalBoundaryMap
    )

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

    $TaxDir = Get-TaxonomyDir
    $Today  = Get-Date -Format 'yyyy-MM-dd'

    # ── Load source evidence index ────────────────────────────────────────
    Write-Host 'Loading data...' -ForegroundColor Cyan
    $SeiPath = Join-Path $TaxDir 'source_evidence_index.json'
    $SourceDocCounts = @{}  # nodeId → count of unique doc_ids
    if (Test-Path $SeiPath) {
        $Sei = Get-Content $SeiPath -Raw | ConvertFrom-Json -AsHashtable
        foreach ($NodeId in $Sei.Keys) {
            $Entry = $Sei[$NodeId]
            $DocIds = [System.Collections.Generic.HashSet[string]]::new(
                [System.StringComparer]::OrdinalIgnoreCase)
            $Facts = if ($Entry.ContainsKey('facts') -and $Entry['facts']) { @($Entry['facts']) } else { @() }
            foreach ($Fact in $Facts) {
                if ($null -ne $Fact -and $Fact -is [hashtable] -and $Fact.ContainsKey('doc_id') -and $Fact['doc_id']) {
                    $null = $DocIds.Add($Fact['doc_id'])
                }
            }
            $KeyPts = if ($Entry.ContainsKey('keyPoints') -and $Entry['keyPoints']) { @($Entry['keyPoints']) } else { @() }
            foreach ($Kp in $KeyPts) {
                if ($null -ne $Kp -and $Kp -is [hashtable] -and $Kp.ContainsKey('doc_id') -and $Kp['doc_id']) {
                    $null = $DocIds.Add($Kp['doc_id'])
                }
            }
            $SourceDocCounts[$NodeId] = $DocIds.Count
        }
        Write-Host " Source evidence: $($Sei.Count) nodes indexed" -ForegroundColor Gray
    } else {
        Write-Warning "source_evidence_index.json not found — evidence boost will be 0"
    }

    # ── Load edges ────────────────────────────────────────────────────────
    $EdgesPath = Join-Path $TaxDir 'edges.json'
    $SupportsReceived = @{}  # nodeId → count
    $AttacksReceived  = @{}  # nodeId → count
    if (Test-Path $EdgesPath) {
        $EdgesRaw = Get-Content $EdgesPath -Raw | ConvertFrom-Json
        $Edges = if ($EdgesRaw.PSObject.Properties['edges']) { @($EdgesRaw.edges) } else { @($EdgesRaw) }
        foreach ($Edge in @($Edges)) {
            if ($Edge.PSObject.Properties['status'] -and $Edge.status -eq 'rejected') { continue }
            $Type = if ($Edge.PSObject.Properties['type']) { $Edge.type } else { $null }
            if (-not $Type) { continue }
            $Target = if ($Edge.PSObject.Properties['target']) { $Edge.target } else { $null }
            $Source = if ($Edge.PSObject.Properties['source']) { $Edge.source } else { $null }
            if (-not $Target) { continue }
            if ($Type -eq 'SUPPORTS') {
                $SupportsReceived[$Target] = ($SupportsReceived[$Target] ?? 0) + 1
                if ($Edge.PSObject.Properties['bidirectional'] -and $Edge.bidirectional -and $Source) {
                    $SupportsReceived[$Source] = ($SupportsReceived[$Source] ?? 0) + 1
                }
            } elseif ($Type -in 'CONTRADICTS', 'WEAKENS') {
                $AttacksReceived[$Target] = ($AttacksReceived[$Target] ?? 0) + 1
                if ($Edge.PSObject.Properties['bidirectional'] -and $Edge.bidirectional -and $Source) {
                    $AttacksReceived[$Source] = ($AttacksReceived[$Source] ?? 0) + 1
                }
            }
        }
        Write-Host " Edges: $(@($Edges).Count) loaded" -ForegroundColor Gray
    } else {
        Write-Warning "edges.json not found — edge boost will be 0"
    }

    # ── Doctrinal boundary mapping ────────────────────────────────────────
    # For now, root-level Desires with labels matching POVER_INFO boundary
    # themes get priority 5. Manual override via -DoctrinalBoundaryMap.
    $DocBoundaryIds = @{}  # pov → HashSet of node IDs
    if ($DoctrinalBoundaryMap) {
        foreach ($Pov_ in $DoctrinalBoundaryMap.Keys) {
            $DocBoundaryIds[$Pov_] = [System.Collections.Generic.HashSet[string]]::new(
                [string[]]@($DoctrinalBoundaryMap[$Pov_]), [System.StringComparer]::OrdinalIgnoreCase)
        }
    }
    # If no map provided, doctrinal boundaries default to empty (tree position only)

    # ── Process each POV ──────────────────────────────────────────────────
    $TotalBeliefs = 0; $TotalDesires = 0; $TotalIntentions = 0

    foreach ($PovName in $POV) {
        $FilePath = Join-Path $TaxDir "$PovName.json"
        if (-not (Test-Path $FilePath)) {
            Write-Warning "Taxonomy file not found: $FilePath"
            continue
        }

        $Data = Get-Content $FilePath -Raw | ConvertFrom-Json
        $Nodes = @($Data.nodes)
        Write-Host "`n── $PovName ($($Nodes.Count) nodes) ──" -ForegroundColor Cyan

        $BeliefCount = 0; $DesireCount = 0; $IntentionCount = 0
        if ($DocBoundaryIds.ContainsKey($PovName)) {
            $BoundarySet = $DocBoundaryIds[$PovName]
        } else {
            $BoundarySet = [System.Collections.Generic.HashSet[string]]::new()
        }

        foreach ($Node in $Nodes) {
            if (-not $Node -or -not $Node.PSObject.Properties['id']) { continue }
            $NodeId = $Node.id
            $Category = if ($Node.PSObject.Properties['category']) { $Node.category } else { $null }
            if (-not $Category) { continue }

            if ($Category -eq 'Beliefs') {
                # ── Belief confidence ─────────────────────────────────
                $GA = if ($Node.PSObject.Properties['graph_attributes'] -and $Node.graph_attributes) { $Node.graph_attributes } else { $null }
                $EpistemicType = if ($GA -and $GA.PSObject.Properties['epistemic_type']) { $GA.epistemic_type } else { $null }
                $Falsifiability = if ($GA -and $GA.PSObject.Properties['falsifiability']) { $GA.falsifiability } else { $null }

                # Base score
                $Base = switch ($EpistemicType) {
                    'empirical_claim' {
                        switch ($Falsifiability) {
                            'high'   { 0.80 }
                            'medium' { 0.70 }
                            'low'    { 0.60 }
                            default  { 0.70 }
                        }
                    }
                    'predictive'       { 0.40 }
                    'interpretive_lens' { 0.50 }
                    'definitional'     { 0.50 }
                    default            { 0.50 }
                }

                # Boosts
                $SrcCount = $SourceDocCounts[$NodeId] ?? 0
                $EvidenceBoost = [Math]::Min(0.15, $SrcCount * 0.05)

                $DebateRefCount = 0
                if ($Node.PSObject.Properties['debate_refs'] -and $Node.debate_refs) {
                    $DebateRefCount = @($Node.debate_refs).Count
                }
                $DebateBoost = [Math]::Min(0.10, $DebateRefCount * 0.03)

                $Sup = $SupportsReceived[$NodeId] ?? 0
                $Att = $AttacksReceived[$NodeId] ?? 0
                $EdgeBoost = [Math]::Min(0.05, $Sup * 0.02) - [Math]::Min(0.05, $Att * 0.02)

                $Raw = $Base + $EvidenceBoost + $DebateBoost + $EdgeBoost
                $Confidence = [Math]::Round([Math]::Max(0.10, [Math]::Min(0.95, $Raw)), 2)

                if (-not $DryRun) {
                    $Node | Add-Member -NotePropertyName 'confidence' -NotePropertyValue $Confidence -Force
                    $Node | Add-Member -NotePropertyName 'confidence_history' -NotePropertyValue @(
                        [ordered]@{ date = $Today; value = $Confidence; delta = 0; reason = 'Initial multi-signal assignment' }
                    ) -Force
                    Add-ChangeHistoryEntry -Node $Node -Action 'modified' -Fields @('confidence', 'confidence_history')
                }

                $BeliefCount++
                Write-Verbose " $NodeId [$EpistemicType/$Falsifiability] base=$Base +ev=$EvidenceBoost +deb=$DebateBoost +edge=$EdgeBoost → $Confidence"

            } elseif ($Category -eq 'Desires') {
                # ── Desire priority ───────────────────────────────────
                $IsDoctrinal = $BoundarySet.Contains($NodeId)
                $HasParent = $Node.PSObject.Properties['parent_id'] -and $Node.parent_id
                $ChildCount = if ($Node.PSObject.Properties['children']) { @($Node.children).Count } else { 0 }
                if ($IsDoctrinal) {
                    $Priority = 5
                    $Reason = 'Initial assignment: doctrinal boundary'
                } elseif (-not $HasParent) {
                    $Priority = 4
                    $Reason = 'Initial assignment: root-level Desire'
                } elseif ($ChildCount -gt 0) {
                    $Priority = 3
                    $Reason = 'Initial assignment: mid-tree Desire'
                } else {
                    $Priority = 2
                    $Reason = 'Initial assignment: leaf Desire'
                }

                Write-Verbose " $NodeId priority=$Priority ($Reason)"

                if (-not $DryRun) {
                    $Node | Add-Member -NotePropertyName 'priority' -NotePropertyValue $Priority -Force
                    $Node | Add-Member -NotePropertyName 'priority_history' -NotePropertyValue @(
                        [ordered]@{ date = $Today; value = $Priority; delta = 0; reason = $Reason }
                    ) -Force
                    Add-ChangeHistoryEntry -Node $Node -Action 'modified' -Fields @('priority', 'priority_history')
                }

                $DesireCount++

            } elseif ($Category -eq 'Intentions') {
                # ── Intention operationality ──────────────────────────
                # Formula: clamp(tree_base + falsifiability_mod + situation_bonus, 1, 5)
                # Tree base: leaf=4, mid-tree=3, root=2 (inverted from Desires)
                $HasParent = $false
                if ($Node.PSObject.Properties['parent_id'] -and $Node.parent_id) { $HasParent = $true }
                $IsLeaf = $true
                if ($Node.PSObject.Properties['children']) {
                    foreach ($_ in $Node.children) { $IsLeaf = $false; break }
                }
                if ($IsLeaf) {
                    $TreeBase = 4  # leaf — actionable
                } elseif ($HasParent) {
                    $TreeBase = 3  # mid-tree — organizing
                } else {
                    $TreeBase = 2  # root — abstract
                }

                $GA = if ($Node.PSObject.Properties['graph_attributes'] -and $Node.graph_attributes) { $Node.graph_attributes } else { $null }
                $Fals = if ($GA -and $GA.PSObject.Properties['falsifiability']) { $GA.falsifiability } else { $null }
                $FalsMod = switch ($Fals) { 'high' { 1 } 'low' { -1 } default { 0 } }

                $SitBonus = 0
                if ($Node.PSObject.Properties['situation_refs']) {
                    foreach ($_ in $Node.situation_refs) { $SitBonus = 1; break }
                }

                $Operationality = [Math]::Max(1, [Math]::Min(5, $TreeBase + $FalsMod + $SitBonus))

                $TreeLabel = switch ($TreeBase) { 4 { 'leaf' } 3 { 'mid-tree' } 2 { 'root' } }
                $Reason = "Initial assignment: $TreeLabel Intention"
                if ($FalsMod -ne 0) { $Reason += " (falsifiability $(if ($FalsMod -gt 0) { '+1' } else { '-1' }))" }
                if ($SitBonus -gt 0) { $Reason += ' (situation grounded)' }

                Write-Verbose " $NodeId operationality=$Operationality ($Reason)"

                if (-not $DryRun) {
                    $Node | Add-Member -NotePropertyName 'operationality' -NotePropertyValue $Operationality -Force
                    $Node | Add-Member -NotePropertyName 'operationality_history' -NotePropertyValue @(
                        [ordered]@{ date = $Today; value = $Operationality; delta = 0; reason = $Reason }
                    ) -Force
                    Add-ChangeHistoryEntry -Node $Node -Action 'modified' -Fields @('operationality', 'operationality_history')
                }

                $IntentionCount++
            }
        }

        Write-Host " Beliefs: $BeliefCount confidence scores assigned" -ForegroundColor Green
        Write-Host " Desires: $DesireCount priorities assigned" -ForegroundColor Green
        Write-Host " Intentions: $IntentionCount operationality scores assigned" -ForegroundColor Green

        $TotalBeliefs += $BeliefCount
        $TotalDesires += $DesireCount
        $TotalIntentions += $IntentionCount

        # Write back
        if (-not $DryRun -and $PSCmdlet.ShouldProcess("$PovName.json", 'Write confidence + priority + operationality')) {
            $Data | ConvertTo-Json -Depth 20 | Set-Content -Path $FilePath -Encoding UTF8
            Write-Host " Saved $PovName.json" -ForegroundColor Green
        }
    }

    # ── Summary ───────────────────────────────────────────────────────────
    Write-Host "`n=== SUMMARY ===" -ForegroundColor Cyan
    Write-Host " Belief nodes: $TotalBeliefs confidence scores"
    Write-Host " Desire nodes: $TotalDesires priorities"
    Write-Host " Intention nodes: $TotalIntentions operationality scores"
    if ($DryRun) { Write-Host " (DRY RUN — no files written)" -ForegroundColor Yellow }
}