Public/Invoke-OrgDerivedCampScores.ps1

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

function Invoke-OrgDerivedCampScores {
    <#
    .SYNOPSIS
        Stage 5 (R2 rollup) of the t/1553 org→node pipeline — compute per-camp
        derived alignment scores from approved org→POV-BDI edges.
    .DESCRIPTION
        For each organization, counts approved ADVOCATES_FOR and OPPOSES edges
        targeting POV-BDI nodes (acc/saf/skp-{beliefs|desires|intentions}-NNN)
        and produces a per-camp record:
 
            advocates_C = |{approved ADVOCATES_FOR edges: org → camp-C BDI node}|
            opposes_C = |{approved OPPOSES edges: org → camp-C BDI node}|
            n_C = advocates_C + opposes_C
            net_ratio_C = (advocates_C − opposes_C) / n_C [∈ -1, 1]
                        = $null when n_C == 0 [three-state]
 
        Denominator is WITHIN-camp (per CL t/1560#2 — hand scores sum >1
        because camp-spanning orgs are real; an across-camp share would
        silently reinterpret the field). Edge weights are unweighted 1.0
        (org edges deliberately carry no confidence fields).
 
        Only `status='approved'` edges enter the authoritative field
        (CL t/1560#2, TL t/1560#4). Proposed rows go to the -RunReport
        artifact as a companion `candidate` distribution — never into
        `organizations.json`. This is the honesty rule: the derived label
        must not launder unreviewed matcher output as human-approved data.
 
        `sit-*` situation-node edges are excluded from R2 v1 (t/1560#2
        Q2 ii-α) — those carry three camp interpretations and need a
        separate rollup instrument. R2 v1 is POV-BDI only.
 
        Default is dry-run: compute + report distribution, no writes.
        Only -Write actually calls Import-Organization to persist the
        derived block back into organizations.json.
 
    .PARAMETER OrgEdgesPath
        Path to organization_edges.json. Defaults to <taxonomy-dir>/organization_edges.json.
    .PARAMETER OrgsPath
        Path to organizations.json. Defaults to the store's canonical path.
    .PARAMETER OrgId
        Restrict to specific org id(s). Default: all orgs.
    .PARAMETER Write
        Persist the derived block back into organizations.json via
        Import-Organization. Without this, the cmdlet reports what it
        would write but touches nothing.
    .PARAMETER InputEdgesSha
        Data-repo commit SHA the edges were read from. Recorded in provenance.
        Optional; if omitted, provenance carries an empty string.
    .OUTPUTS
        [PSCustomObject] with:
          - Approved — per-org, per-camp counts (what would land)
          - Candidate — per-org, per-camp counts including proposed rows
          - Distribution — { acc, saf, skp } histogram of net_ratio buckets (approved-only)
          - Written — number of orgs whose record was updated (0 if dry-run)
          - InputEdges — total edges read
          - EdgesAfterFilter — edges matching approved + POV-BDI target
    .EXAMPLE
        # Dry-run: see what would land, no writes
        Invoke-OrgDerivedCampScores
    .EXAMPLE
        # Persist to organizations.json, tagging provenance with edge-set SHA
        Invoke-OrgDerivedCampScores -Write -InputEdgesSha 62663d3d
    .LINK
        Show-AITriadHelp
    .LINK
        Invoke-OrgClaimMatching
    .LINK
        Invoke-OrgPublishedSeeding
    .LINK
        Invoke-OrgStanceExtraction
    .LINK
        Get-Organization
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter()]
        [string]$OrgEdgesPath,

        [Parameter()]
        [string]$OrgsPath,

        [Parameter()]
        [string[]]$OrgId,

        [Parameter()]
        [switch]$Write,

        [Parameter()]
        [string]$InputEdgesSha = ''
    )

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

    # ── Locate data ──────────────────────────────────────────────────────
    $taxDir = Get-TaxonomyDir
    if (-not $OrgEdgesPath) { $OrgEdgesPath = Join-Path $taxDir 'organization_edges.json' }
    if (-not (Test-Path $OrgEdgesPath)) {
        throw (New-ActionableError `
            -Goal 'Compute derived camp scores' `
            -Problem "Organization edges file not found: $OrgEdgesPath" `
            -Location 'Invoke-OrgDerivedCampScores' `
            -NextSteps @('Run Invoke-OrgPublishedSeeding + Invoke-OrgClaimMatching first',
                         'Or pass -OrgEdgesPath explicitly'))
    }

    $edgeStore = Get-Content $OrgEdgesPath -Raw | ConvertFrom-Json
    $edges = if ($edgeStore.PSObject.Properties['edges']) { @($edgeStore.edges) } else { @() }
    $totalEdges = @($edges).Count
    Write-Verbose "Input edges: $totalEdges"

    $orgStore = Get-OrganizationsStore -Force
    $orgs = if ($orgStore.PSObject.Properties['organizations']) { @($orgStore.organizations) } else { @() }
    if ($OrgId -and $OrgId.Count -gt 0) {
        $wanted = @($OrgId)
        $orgs = @($orgs | Where-Object { [string]$_.id -in $wanted })
    }
    if (@($orgs).Count -eq 0) {
        Write-Warning 'No organizations to process (empty store or -OrgId filtered out all).'
        return [PSCustomObject]@{
            Approved = @{}; Candidate = @{}; Distribution = @{}
            Written = 0; InputEdges = $totalEdges; EdgesAfterFilter = 0
        }
    }

    # ── Filter edges ─────────────────────────────────────────────────────
    $povBdiPattern = '^(acc|saf|skp)-(beliefs|desires|intentions)-\d+$'
    $campEdgeTypes = @('ADVOCATES_FOR', 'OPPOSES')

    # If -OrgId was passed, mirror the filter on the edge source so
    # Approved/Candidate output is restricted too — not just orgs iteration.
    $orgFilter = if ($OrgId -and $OrgId.Count -gt 0) { @($OrgId) } else { $null }

    $keep = [System.Collections.Generic.List[PSObject]]::new()
    foreach ($e in $edges) {
        if (-not $e.PSObject.Properties['source'] -or
            -not $e.PSObject.Properties['target'] -or
            -not $e.PSObject.Properties['type']) { continue }
        $type = [string]$e.type
        if ($campEdgeTypes -notcontains $type) { continue }
        $target = [string]$e.target
        if ($target -notmatch $povBdiPattern) { continue }
        if ($orgFilter -and ([string]$e.source -notin $orgFilter)) { continue }
        $keep.Add($e)
    }
    $edgesFiltered = @($keep).Count
    Write-Verbose "Edges after filter (POV-BDI target + camp type): $edgesFiltered"

    # ── Aggregate per (org, camp, provenance-stratum) ─────────────────────
    # Two strata: approved (authoritative) + all (approved + proposed = candidate).
    # Excludes rejected/disputed by design — rejected is telemetry, not signal.
    $approvedByOrg  = @{}
    $candidateByOrg = @{}
    foreach ($e in $keep) {
        $src    = [string]$e.source
        $type   = [string]$e.type
        $target = [string]$e.target
        $status = if ($e.PSObject.Properties['status']) { [string]$e.status } else { 'approved' }
        if ($status -eq 'rejected' -or $status -eq 'disputed') { continue }
        $camp   = $target.Substring(0, 3)  # acc/saf/skp

        if (-not $candidateByOrg.ContainsKey($src)) { $candidateByOrg[$src] = _newCampCounts }
        if ($type -eq 'ADVOCATES_FOR') { $candidateByOrg[$src][$camp].Advocates++ }
        else                            { $candidateByOrg[$src][$camp].Opposes++ }

        if ($status -eq 'approved') {
            if (-not $approvedByOrg.ContainsKey($src)) { $approvedByOrg[$src] = _newCampCounts }
            if ($type -eq 'ADVOCATES_FOR') { $approvedByOrg[$src][$camp].Advocates++ }
            else                            { $approvedByOrg[$src][$camp].Opposes++ }
        }
    }

    foreach ($map in @($approvedByOrg, $candidateByOrg)) {
        foreach ($org in @($map.Keys)) {
            foreach ($camp in 'acc','saf','skp') {
                $rec = $map[$org][$camp]
                $rec.N = $rec.Advocates + $rec.Opposes
                if ($rec.N -gt 0) {
                    $rec.NetRatio = ($rec.Advocates - $rec.Opposes) / [double]$rec.N
                }
                # else leave NetRatio as $null — three-state semantics.
            }
        }
    }

    # ── Distribution histogram (approved only) ────────────────────────────
    $distribution = @{
        acc = _newBuckets
        saf = _newBuckets
        skp = _newBuckets
    }
    foreach ($org in @($orgs)) {
        $orgIdStr = [string]$org.id
        if ($approvedByOrg.ContainsKey($orgIdStr)) {
            foreach ($camp in 'acc','saf','skp') {
                $rec = $approvedByOrg[$orgIdStr][$camp]
                if ($null -eq $rec.NetRatio) {
                    $distribution[$camp].no_data++
                } elseif ($rec.NetRatio -le -0.5) {
                    $distribution[$camp].strong_neg++
                } elseif ($rec.NetRatio -lt 0) {
                    $distribution[$camp].mild_neg++
                } elseif ($rec.NetRatio -eq 0) {
                    $distribution[$camp].neutral++
                } elseif ($rec.NetRatio -lt 0.5) {
                    $distribution[$camp].mild_pos++
                } else {
                    $distribution[$camp].strong_pos++
                }
            }
        } else {
            $distribution.acc.no_data++
            $distribution.saf.no_data++
            $distribution.skp.no_data++
        }
    }

    # ── Optional write ────────────────────────────────────────────────────
    $written = 0
    $failed  = [System.Collections.Generic.List[string]]::new()
    if ($Write) {
        $today = (Get-Date).ToString('yyyy-MM-dd')
        $mod   = Get-Module -Name AITriad
        $ver   = if ($mod) { $mod.Version.ToString() } else { '0.0.0' }
        $cmdletVersion = "Invoke-OrgDerivedCampScores@v$ver"

        foreach ($org in @($orgs)) {
            $orgIdStr = [string]$org.id
            # Emit even for orgs with 0 approved camp edges — three-state
            # semantics require "computed, no data" (all-null n=0) to be
            # visibly distinct from "never computed" (field absent).
            $approvedRec = if ($approvedByOrg.ContainsKey($orgIdStr)) { $approvedByOrg[$orgIdStr] } else { _newCampCounts }
            # Finalize n_C on the fresh block if no approved edges exist for this org.
            foreach ($camp in 'acc','saf','skp') {
                $r = $approvedRec[$camp]
                $r.N = $r.Advocates + $r.Opposes
            }

            $derivedField = [PSCustomObject]@{
                acc = _toJsonRec $approvedRec['acc']
                saf = _toJsonRec $approvedRec['saf']
                skp = _toJsonRec $approvedRec['skp']
                provenance = [PSCustomObject]@{
                    computed_at            = $today
                    cmdlet_version         = $cmdletVersion
                    input_edges_sha        = $InputEdgesSha
                    included_status_filter = @('approved')
                    edge_count             = ($approvedRec['acc'].N + $approvedRec['saf'].N + $approvedRec['skp'].N)
                }
            }

            # Full-record upsert per Import-Organization's contract.
            $incoming = _cloneOrgPSObject $org
            Add-Member -InputObject $incoming -MemberType NoteProperty `
                -Name 'pov_alignment_derived' -Value $derivedField -Force

            if ($PSCmdlet.ShouldProcess($orgIdStr, "Persist pov_alignment_derived")) {
                try {
                    $null = Import-Organization -InputObject $incoming -Confirm:$false
                    $written++
                } catch {
                    $failed.Add("${orgIdStr}: $($_.Exception.Message)")
                    Write-Warning "Failed to persist $orgIdStr : $($_.Exception.Message)"
                }
            }
        }
    }

    Write-Host ''
    if ($Write) {
        Write-Host "Input edges: $totalEdges | After filter: $edgesFiltered | Orgs updated: $written / $(@($orgs).Count) | Failed: $(@($failed).Count)"
    } else {
        Write-Host "Input edges: $totalEdges | After filter: $edgesFiltered | Orgs with approved data: $($approvedByOrg.Count) | Dry-run (no writes)"
    }

    [PSCustomObject]@{
        Approved         = $approvedByOrg
        Candidate        = $candidateByOrg
        Distribution     = $distribution
        Written          = $written
        InputEdges       = $totalEdges
        EdgesAfterFilter = $edgesFiltered
        Failed           = @($failed)
    }
}

function _newCampCounts {
    @{
        acc = [PSCustomObject]@{ Advocates = 0; Opposes = 0; N = 0; NetRatio = $null }
        saf = [PSCustomObject]@{ Advocates = 0; Opposes = 0; N = 0; NetRatio = $null }
        skp = [PSCustomObject]@{ Advocates = 0; Opposes = 0; N = 0; NetRatio = $null }
    }
}

function _newBuckets {
    @{ strong_neg = 0; mild_neg = 0; neutral = 0; mild_pos = 0; strong_pos = 0; no_data = 0 }
}

function _toJsonRec {
    param($Rec)
    [PSCustomObject]@{
        advocates = [int]$Rec.Advocates
        opposes   = [int]$Rec.Opposes
        n         = [int]$Rec.N
        net_ratio = $Rec.NetRatio  # null when n=0; double when n>0
    }
}

function _cloneOrgPSObject {
    # PSCustomObject shallow clone — enough for Import-Organization which
    # replaces the whole record. Deep clone would matter only if we
    # mutated nested collections, which we don't; we only add the sibling
    # pov_alignment_derived field.
    param($Src)
    $dst = [PSCustomObject]@{}
    foreach ($p in $Src.PSObject.Properties) {
        Add-Member -InputObject $dst -MemberType NoteProperty -Name $p.Name -Value $p.Value -Force
    }
    $dst
}