Private/Get-TaxonomyHealthData.ps1

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

function Get-TaxonomyHealthData {
    <#
    .SYNOPSIS
        Computes taxonomy health metrics by scanning all summaries against the taxonomy.
    .DESCRIPTION
        Builds a comprehensive health report by:
        1. Indexing every taxonomy node with a citation counter
        2. Scanning all summary JSONs to count node citations, track stances,
           and aggregate unmapped concepts
        3. Deriving orphan nodes, most/least cited, stance variance,
           coverage balance, and cross-cutting reference health
    .PARAMETER GraphMode
        When set, also computes graph-structural health metrics from edges.json.
    .PARAMETER RepoRoot
        Path to the repository root. Defaults to $script:RepoRoot.
    #>

    [CmdletBinding()]
    param(
        [switch]$GraphMode,
        [string]$RepoRoot = $script:RepoRoot
    )

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

    # ── 1. Build node index from $script:TaxonomyData ─────────────────────────
    $NodeIndex = @{}    # keyed by node id
    $PovNames  = @('accelerationist', 'safetyist', 'skeptic', 'situations')

    foreach ($PovKey in $PovNames) {
        $Entry = $script:TaxonomyData[$PovKey]
        if (-not $Entry) { continue }
        foreach ($Node in $Entry.nodes) {
            if ($PovKey -eq 'situations') { $NodeCategory = 'Situations' }
            elseif ($Node.PSObject.Properties['category']) { $NodeCategory = $Node.category }
            else { $NodeCategory = '' }
            if ($Node.PSObject.Properties['description']) { $NodeDescription = $Node.description } else { $NodeDescription = '' }
            $NodeIndex[$Node.id] = @{
                POV              = $PovKey
                Category         = $NodeCategory
                Label            = $Node.label
                Description      = $NodeDescription
                Citations        = 0
                DocIds           = [System.Collections.Generic.List[string]]::new()
                Stances          = [System.Collections.Generic.List[string]]::new()
            }
        }
    }

    # ── 2. Read TAXONOMY_VERSION ───────────────────────────────────────────────
    $VersionFile = Get-VersionFile
    if (Test-Path $VersionFile) {
        $TaxonomyVersion = (Get-Content $VersionFile -Raw).Trim()
    } else { $TaxonomyVersion = 'unknown' }

    # ── 3. Scan every summaries/*.json ─────────────────────────────────────────
    $SummariesDir = Get-SummariesDir
    $SourcesDir   = Get-SourcesDir

    if (-not (Test-Path $SummariesDir)) {
        throw "Summaries directory not found: $SummariesDir"
    }

    $SummaryFiles = Get-ChildItem -Path $SummariesDir -Filter '*.json' -File
    $UnmappedAgg  = @{}   # lowercased concept → aggregation object
    $SummaryStats = [System.Collections.Generic.List[PSObject]]::new()

    foreach ($File in $SummaryFiles) {
        try {
            $Summary = Get-Content -Raw -Path $File.FullName | ConvertFrom-Json
        }
        catch {
            Write-Warning "Get-TaxonomyHealthData: failed to parse $($File.Name): $_"
            continue
        }

        $DocId         = $Summary.doc_id
        $DocKeyPoints  = 0
        $DocClaims     = 0
        $DocUnmapped   = 0

        # Scan pov_summaries for key_points
        foreach ($PovName in @('accelerationist', 'safetyist', 'skeptic')) {
            $PovData = $Summary.pov_summaries.$PovName
            if (-not $PovData -or -not $PovData.key_points) { continue }

            foreach ($Point in $PovData.key_points) {
                $DocKeyPoints++
                $NodeId = $Point.taxonomy_node_id
                if (-not $NodeId) { continue }

                if ($NodeIndex.ContainsKey($NodeId)) {
                    $NodeIndex[$NodeId].Citations++
                    if ($DocId -notin $NodeIndex[$NodeId].DocIds) {
                        $NodeIndex[$NodeId].DocIds.Add($DocId)
                    }
                    if ($Point.stance) {
                        $NodeIndex[$NodeId].Stances.Add($Point.stance)
                    }
                }
            }
        }

        # Aggregate unmapped_concepts
        if ($Summary.unmapped_concepts) {
            foreach ($Concept in $Summary.unmapped_concepts) {
                $DocUnmapped++
                if ($Concept.PSObject.Properties['concept']) { $ConceptText = $Concept.concept } else { $ConceptText = "$Concept" }
                $NormKey = ($ConceptText -replace '\s+', ' ').Trim().ToLower()
                if (-not $NormKey) { continue }
                if ($Concept.PSObject.Properties['suggested_pov'])      { $SugPov = $Concept.suggested_pov }      else { $SugPov = $null }
                if ($Concept.PSObject.Properties['suggested_category']) { $SugCat = $Concept.suggested_category } else { $SugCat = $null }
                if (-not $UnmappedAgg.ContainsKey($NormKey)) {
                    $UnmappedAgg[$NormKey] = @{
                        Concept           = $ConceptText
                        NormalizedKey     = $NormKey
                        Frequency         = 0
                        SuggestedPov      = $SugPov
                        SuggestedCategory = $SugCat
                        ContributingDocs  = [System.Collections.Generic.List[string]]::new()
                        Reasons           = [System.Collections.Generic.List[string]]::new()
                    }
                }
                $UnmappedAgg[$NormKey].Frequency++
                if ($DocId -notin $UnmappedAgg[$NormKey].ContributingDocs) {
                    $UnmappedAgg[$NormKey].ContributingDocs.Add($DocId)
                }
                if ($Concept.PSObject.Properties['reason']) { $ReasonText = $Concept.reason } else { $ReasonText = $null }
                if ($ReasonText -and $ReasonText -notin $UnmappedAgg[$NormKey].Reasons) {
                    $UnmappedAgg[$NormKey].Reasons.Add($ReasonText)
                }
            }
        }

        # Count factual claims
        if ($Summary.factual_claims) {
            $DocClaims = @($Summary.factual_claims).Count
        }

        # Load title from metadata if available
        $Title = $null
        $MetaPath = Join-Path (Join-Path $SourcesDir $DocId) 'metadata.json'
        if (Test-Path $MetaPath) {
            try {
                $Meta  = Get-Content -Raw -Path $MetaPath | ConvertFrom-Json
                $Title = $Meta.title
            }
            catch { }
        }

        $SummaryStats.Add([PSCustomObject]@{
            DocId         = $DocId
            Title         = $Title
            KeyPoints     = $DocKeyPoints
            FactualClaims = $DocClaims
            UnmappedCount = $DocUnmapped
        })
    }

    # ── 4. Derive metrics ──────────────────────────────────────────────────────

    # Node citations sorted
    $AllNodes = @($NodeIndex.GetEnumerator() | ForEach-Object {
        [PSCustomObject]@{
            Id        = $_.Key
            POV       = $_.Value.POV
            Category  = $_.Value.Category
            Label     = $_.Value.Label
            Citations = $_.Value.Citations
            DocIds    = $_.Value.DocIds.ToArray()
        }
    })

    $OrphanNodes = @($AllNodes | Where-Object { $_.Citations -eq 0 })
    $MostCited   = @($AllNodes | Where-Object { $_.POV -ne 'situations' } |
                      Sort-Object Citations -Descending | Select-Object -First 10)
    $LeastCited  = @($AllNodes | Where-Object { $_.POV -ne 'situations' -and $_.Citations -gt 0 } |
                      Sort-Object Citations | Select-Object -First 10)

    # Unmapped concepts sorted by frequency
    $UnmappedSorted = @($UnmappedAgg.Values |
        Sort-Object { $_.Frequency } -Descending |
        ForEach-Object {
            [PSCustomObject]@{
                Concept           = $_.Concept
                NormalizedKey     = $_.NormalizedKey
                Frequency         = $_.Frequency
                SuggestedPov      = $_.SuggestedPov
                SuggestedCategory = $_.SuggestedCategory
                ContributingDocs  = $_.ContributingDocs.ToArray()
                Reasons           = $_.Reasons.ToArray()
            }
        })

    # ── Semantic deduplication of unmapped concepts (t/181) ──────────
    # Clusters semantically similar unmapped concepts using embedding cosine similarity.
    # Merges clusters: representative gets summed frequency + unioned contributing docs.
    $SIM_THRESHOLD = 0.75  # Cosine similarity threshold for clustering

    if ($UnmappedSorted.Count -gt 1) {
        $embeddings = Get-TextEmbedding -Texts @($UnmappedSorted.Concept)
        if ($null -ne $embeddings) {
            # Cosine similarity function
            function Get-CosineSim([double[]]$a, [double[]]$b) {
                $dot = 0.0; $na = 0.0; $nb = 0.0
                for ($k = 0; $k -lt $a.Length; $k++) {
                    $dot += $a[$k] * $b[$k]
                    $na += $a[$k] * $a[$k]
                    $nb += $b[$k] * $b[$k]
                }
                $denom = [Math]::Sqrt($na) * [Math]::Sqrt($nb)
                if ($denom -eq 0) { return 0 }
                return $dot / $denom
            }

            # Single-linkage clustering
            $clusterId = @{}  # index → cluster representative index
            for ($i = 0; $i -lt $UnmappedSorted.Count; $i++) { $clusterId[$i] = $i }

            $vecKeys = @($embeddings.Keys | Sort-Object { [int]$_ })
            for ($i = 0; $i -lt $UnmappedSorted.Count; $i++) {
                $vecI = $embeddings["$i"]
                if (-not $vecI) { continue }
                for ($j = $i + 1; $j -lt $UnmappedSorted.Count; $j++) {
                    $vecJ = $embeddings["$j"]
                    if (-not $vecJ) { continue }
                    if ($clusterId[$i] -eq $clusterId[$j]) { continue }  # already same cluster

                    $sim = Get-CosineSim ([double[]]$vecI) ([double[]]$vecJ)
                    if ($sim -ge $SIM_THRESHOLD) {
                        # Merge: assign j's cluster to i's cluster representative
                        $oldCluster = $clusterId[$j]
                        $newCluster = $clusterId[$i]
                        for ($m = 0; $m -lt $UnmappedSorted.Count; $m++) {
                            if ($clusterId[$m] -eq $oldCluster) { $clusterId[$m] = $newCluster }
                        }
                    }
                }
            }

            # Group by cluster and merge
            $clusters = @{}
            for ($i = 0; $i -lt $UnmappedSorted.Count; $i++) {
                $rep = $clusterId[$i]
                if (-not $clusters.ContainsKey($rep)) { $clusters[$rep] = @() }
                $clusters[$rep] += $i
            }

            $mergedCount = 0
            $dedupedList = [System.Collections.Generic.List[PSObject]]::new()
            foreach ($entry in $clusters.GetEnumerator()) {
                $indices = $entry.Value
                if ($indices.Count -eq 1) {
                    $dedupedList.Add($UnmappedSorted[$indices[0]])
                    continue
                }

                # Pick representative: highest frequency, then longest description
                $members = @($indices | ForEach-Object { $UnmappedSorted[$_] })
                $rep = $members | Sort-Object { $_.Frequency } -Descending |
                                  Sort-Object { $_.Concept.Length } -Descending |
                                  Select-Object -First 1

                # Merge metadata from all members
                $allDocs = [System.Collections.Generic.HashSet[string]]::new()
                $allReasons = [System.Collections.Generic.List[string]]::new()
                $totalFreq = 0
                foreach ($m in $members) {
                    $totalFreq += $m.Frequency
                    foreach ($d in $m.ContributingDocs) { [void]$allDocs.Add($d) }
                    foreach ($r in $m.Reasons) {
                        if ($r -and $r -notin $allReasons) { $allReasons.Add($r) }
                    }
                }

                $merged = [PSCustomObject]@{
                    Concept           = $rep.Concept
                    NormalizedKey     = $rep.NormalizedKey
                    Frequency         = $totalFreq
                    SuggestedPov      = $rep.SuggestedPov
                    SuggestedCategory = $rep.SuggestedCategory
                    ContributingDocs  = @($allDocs)
                    Reasons           = @($allReasons)
                    ClusterSize       = $indices.Count
                }
                $dedupedList.Add($merged)
                $mergedCount += ($indices.Count - 1)
            }

            $UnmappedSorted = @($dedupedList | Sort-Object { $_.Frequency } -Descending)
            Write-Verbose "Semantic dedup: merged $mergedCount duplicates, $($UnmappedSorted.Count) unique concepts remain"
        }
    }

    $StrongCandidates = @($UnmappedSorted | Where-Object { $_.Frequency -ge 3 })

    # Stance variance per node
    $AlignedFamily  = @('strongly_aligned', 'aligned')
    $OpposedFamily  = @('strongly_opposed', 'opposed')

    $StanceVariance = @{}
    $HighVarianceNodes = [System.Collections.Generic.List[PSObject]]::new()

    foreach ($Entry in $NodeIndex.GetEnumerator()) {
        $Id      = $Entry.Key
        $Stances = $Entry.Value.Stances
        if ($Stances.Count -eq 0) { continue }

        $Distribution = @{}
        foreach ($S in $Stances) {
            if (-not $Distribution.ContainsKey($S)) { $Distribution[$S] = 0 }
            $Distribution[$S]++
        }

        $HasAligned = @($Stances | Where-Object { $_ -in $AlignedFamily }).Count -gt 0
        $HasOpposed = @($Stances | Where-Object { $_ -in $OpposedFamily }).Count -gt 0
        $HighVariance = $HasAligned -and $HasOpposed

        $Info = [PSCustomObject]@{
            Id            = $Id
            POV           = $Entry.Value.POV
            Label         = $Entry.Value.Label
            TotalStances  = $Stances.Count
            Distribution  = $Distribution
            HighVariance  = $HighVariance
        }

        $StanceVariance[$Id] = $Info
        if ($HighVariance) {
            $HighVarianceNodes.Add($Info)
        }
    }

    # Coverage balance — node counts per POV per category
    $Categories = @('Beliefs', 'Desires', 'Intentions')
    $CoverageBalance = @{}

    foreach ($PovKey in @('accelerationist', 'safetyist', 'skeptic')) {
        $CoverageBalance[$PovKey] = @{}
        foreach ($Cat in $Categories) {
            $Count = @($AllNodes | Where-Object { $_.POV -eq $PovKey -and $_.Category -eq $Cat }).Count
            $CoverageBalance[$PovKey][$Cat] = $Count
        }
    }

    # Cross-cutting reference health
    $CcNodes = @($AllNodes | Where-Object { $_.POV -eq 'situations' })
    $CcReferenced = @($CcNodes | Where-Object { $_.Citations -gt 0 })
    $CcOrphaned   = @($CcNodes | Where-Object { $_.Citations -eq 0 })

    $CrossCuttingHealth = @{
        TotalNodes     = $CcNodes.Count
        Referenced     = $CcReferenced
        ReferencedCount = $CcReferenced.Count
        Orphaned       = $CcOrphaned
        OrphanedCount  = $CcOrphaned.Count
    }

    # ── TaxoAdapt mapping density signals (POV-normalized) ─────────────────────
    $DensitySignals = [System.Collections.Generic.List[PSObject]]::new()

    # Build parent→children map from raw taxonomy data
    $ChildrenMap = @{}  # parent_id → list of child IDs
    foreach ($PovKey in @('accelerationist', 'safetyist', 'skeptic')) {
        $Entry = $script:TaxonomyData[$PovKey]
        if (-not $Entry) { continue }
        foreach ($Node in $Entry.nodes) {
            if ($Node.PSObject.Properties['parent_id'] -and $Node.parent_id) {
                if (-not $ChildrenMap.ContainsKey($Node.parent_id)) {
                    $ChildrenMap[$Node.parent_id] = [System.Collections.Generic.List[string]]::new()
                }
                $ChildrenMap[$Node.parent_id].Add($Node.id)
            }
        }
    }

    # Signal 1: Leaf node density — parents with too many direct children → depth expansion
    $DepthExpandThreshold = 8
    foreach ($ParentId in $ChildrenMap.Keys) {
        $ChildCount = $ChildrenMap[$ParentId].Count
        if ($ChildCount -ge $DepthExpandThreshold -and $NodeIndex.ContainsKey($ParentId)) {
            $ParentInfo = $NodeIndex[$ParentId]
            $DensitySignals.Add([PSCustomObject][ordered]@{
                signal     = 'depth_expand'
                node_id    = $ParentId
                pov        = $ParentInfo.POV
                category   = $ParentInfo.Category
                label      = $ParentInfo.Label
                metric     = $ChildCount
                detail     = "$ParentId has $ChildCount direct children (threshold: $DepthExpandThreshold)"
            })
        }
    }

    # Signal 2: Unmapped concept rate per POV×category branch → width expansion
    # Counts how many unmapped concepts were suggested for each POV×category
    $UnmappedByBranch = @{}
    foreach ($UC in $UnmappedSorted) {
        $Key = "$($UC.SuggestedPov)|$($UC.SuggestedCategory)"
        if (-not $UnmappedByBranch.ContainsKey($Key)) { $UnmappedByBranch[$Key] = 0 }
        $UnmappedByBranch[$Key] += $UC.Frequency
    }

    $WidthExpandThreshold = 5  # total frequency of unmapped concepts in a branch
    foreach ($Branch in $UnmappedByBranch.GetEnumerator()) {
        if ($Branch.Value -ge $WidthExpandThreshold) {
            $Parts = $Branch.Key -split '\|'
            $BranchPov = $Parts[0]; $BranchCat = $Parts[1]
            $DensitySignals.Add([PSCustomObject][ordered]@{
                signal     = 'width_expand'
                node_id    = $null
                pov        = $BranchPov
                category   = $BranchCat
                label      = "$BranchPov/$BranchCat"
                metric     = $Branch.Value
                detail     = "$BranchPov/$BranchCat has $($Branch.Value) unmapped concept frequency (threshold: $WidthExpandThreshold)"
            })
        }
    }

    # Signal 3: POV-normalized coverage imbalance
    # Compare each POV×category against the MEAN across POVs (not absolute counts)
    $PovKeys = @('accelerationist', 'safetyist', 'skeptic')
    foreach ($Cat in $Categories) {
        $Counts = @($PovKeys | ForEach-Object { $CoverageBalance[$_][$Cat] })
        $Mean = ($Counts | Measure-Object -Average).Average
        if ($Mean -eq 0) { continue }

        foreach ($Pov in $PovKeys) {
            $Count = $CoverageBalance[$Pov][$Cat]
            $Ratio = $Count / $Mean
            # Flag if a POV is below 60% of the mean (under-represented) or above 160% (over-represented)
            if ($Ratio -lt 0.6) {
                $DensitySignals.Add([PSCustomObject][ordered]@{
                    signal     = 'pov_imbalance_under'
                    node_id    = $null
                    pov        = $Pov
                    category   = $Cat
                    label      = "$Pov/$Cat"
                    metric     = [Math]::Round($Ratio, 2)
                    detail     = "$Pov has $Count nodes in $Cat vs mean $([Math]::Round($Mean, 1)) ($([Math]::Round($Ratio * 100))% of mean)"
                })
            }
            elseif ($Ratio -gt 1.6) {
                $DensitySignals.Add([PSCustomObject][ordered]@{
                    signal     = 'pov_imbalance_over'
                    node_id    = $null
                    pov        = $Pov
                    category   = $Cat
                    label      = "$Pov/$Cat"
                    metric     = [Math]::Round($Ratio, 2)
                    detail     = "$Pov has $Count nodes in $Cat vs mean $([Math]::Round($Mean, 1)) ($([Math]::Round($Ratio * 100))% of mean) — expansion here would INCREASE imbalance"
                })
            }
        }
    }

    # Summary-level statistics
    $TotalKeyPoints  = ($SummaryStats | Measure-Object -Property KeyPoints -Sum).Sum
    $TotalClaims     = ($SummaryStats | Measure-Object -Property FactualClaims -Sum).Sum
    $TotalUnmapped   = ($SummaryStats | Measure-Object -Property UnmappedCount -Sum).Sum
    if ($SummaryStats.Count -gt 0) {
        $AvgKeyPoints = [math]::Round($TotalKeyPoints / $SummaryStats.Count, 1)
    } else { $AvgKeyPoints = 0 }

    $MaxDoc = $SummaryStats | Sort-Object { $_.KeyPoints } -Descending | Select-Object -First 1
    $MinDoc = $SummaryStats | Sort-Object { $_.KeyPoints } | Select-Object -First 1

    $SummaryStatsResult = @{
        TotalDocs        = $SummaryStats.Count
        TotalKeyPoints   = $TotalKeyPoints
        TotalClaims      = $TotalClaims
        TotalUnmapped    = $TotalUnmapped
        AvgKeyPoints     = $AvgKeyPoints
        MaxKeyPointsDoc  = $MaxDoc
        MinKeyPointsDoc  = $MinDoc
        PerDoc           = $SummaryStats.ToArray()
    }

    # ── 5. Graph health metrics (when -GraphMode) ──────────────────────────────
    $GraphHealth = $null
    if ($GraphMode) {
        $TaxDir   = Get-TaxonomyDir
        $EdgesPath = Join-Path $TaxDir 'edges.json'

        if (-not (Test-Path $EdgesPath)) {
            Write-Warning "Get-TaxonomyHealthData: edges.json not found — GraphMode metrics unavailable"
        }
        else {
            $EdgesData    = Get-Content -Raw -Path $EdgesPath | ConvertFrom-Json
            $ApprovedEdges = @($EdgesData.edges | Where-Object { $_.status -eq 'approved' })

            # Build POV lookup for each node
            $NodePovLookup = @{}
            foreach ($PovKey in $PovNames) {
                $Entry = $script:TaxonomyData[$PovKey]
                if (-not $Entry) { continue }
                foreach ($Node in $Entry.nodes) {
                    $NodePovLookup[$Node.id] = $PovKey
                }
            }

            # ── Echo chamber score per POV ──
            # Ratio of SUPPORTS to CONTRADICTS edges within the same POV
            $EchoChamberScores = @{}
            foreach ($PovKey in @('accelerationist', 'safetyist', 'skeptic')) {
                $SamePovSupports    = 0
                $SamePovContradicts = 0
                foreach ($Edge in $ApprovedEdges) {
                    $SPov = $NodePovLookup[$Edge.source]
                    $TPov = $NodePovLookup[$Edge.target]
                    if ($SPov -eq $PovKey -and $TPov -eq $PovKey) {
                        if ($Edge.type -eq 'SUPPORTS')    { $SamePovSupports++ }
                        if ($Edge.type -eq 'CONTRADICTS') { $SamePovContradicts++ }
                    }
                }
                if ($SamePovContradicts -gt 0) {
                    $EchoRatio = [Math]::Round($SamePovSupports / $SamePovContradicts, 2)
                } else {
                    if ($SamePovSupports -gt 0) { $EchoRatio = [double]::PositiveInfinity } else { $EchoRatio = 0.0 }
                }
                $EchoChamberScores[$PovKey] = [ordered]@{
                    SamePovSupports    = $SamePovSupports
                    SamePovContradicts = $SamePovContradicts
                    Ratio              = $EchoRatio
                }
            }

            # ── Cross-POV connectivity ──
            $CrossPovEdgeCount = 0
            $TotalEdgeCount    = $ApprovedEdges.Count
            foreach ($Edge in $ApprovedEdges) {
                $SPov = $NodePovLookup[$Edge.source]
                $TPov = $NodePovLookup[$Edge.target]
                if ($SPov -and $TPov -and $SPov -ne $TPov) {
                    $CrossPovEdgeCount++
                }
            }
            if ($TotalEdgeCount -gt 0) {
                $CrossPovPct = [Math]::Round(($CrossPovEdgeCount / $TotalEdgeCount) * 100, 1)
            } else { $CrossPovPct = 0.0 }

            # ── Edge orphans (nodes with 0 edges) ──
            $EdgedNodes = [System.Collections.Generic.HashSet[string]]::new()
            foreach ($Edge in $ApprovedEdges) {
                [void]$EdgedNodes.Add($Edge.source)
                [void]$EdgedNodes.Add($Edge.target)
            }
            $EdgeOrphans = @($NodePovLookup.Keys | Where-Object { -not $EdgedNodes.Contains($_) } | Sort-Object)

            # ── Hub concentration (Gini coefficient of degree distribution) ──
            $DegreeMap = @{}
            foreach ($NId in $NodePovLookup.Keys) { $DegreeMap[$NId] = 0 }
            foreach ($Edge in $ApprovedEdges) {
                if ($DegreeMap.ContainsKey($Edge.source)) { $DegreeMap[$Edge.source]++ }
                if ($DegreeMap.ContainsKey($Edge.target)) { $DegreeMap[$Edge.target]++ }
            }
            $Degrees = @($DegreeMap.Values | Sort-Object)
            $N = $Degrees.Count
            $GiniCoeff = 0.0
            if ($N -gt 0) {
                $SumDiff = 0.0
                $SumAll  = 0.0
                for ($i = 0; $i -lt $N; $i++) {
                    $SumAll += $Degrees[$i]
                    for ($j = 0; $j -lt $N; $j++) {
                        $SumDiff += [Math]::Abs($Degrees[$i] - $Degrees[$j])
                    }
                }
                if ($SumAll -gt 0) {
                    $GiniCoeff = [Math]::Round($SumDiff / (2 * $N * $SumAll), 4)
                }
            }

            # ── Missing edge type pairs ──
            # Cross-POV node pairs with SUPPORTS but no CONTRADICTS
            $CrossPovSupports    = [System.Collections.Generic.HashSet[string]]::new()
            $CrossPovContradicts = [System.Collections.Generic.HashSet[string]]::new()
            foreach ($Edge in $ApprovedEdges) {
                $SPov = $NodePovLookup[$Edge.source]
                $TPov = $NodePovLookup[$Edge.target]
                if ($SPov -and $TPov -and $SPov -ne $TPov) {
                    if ($Edge.source -lt $Edge.target) { $PairKey = "$($Edge.source)|$($Edge.target)" } else { $PairKey = "$($Edge.target)|$($Edge.source)" }
                    if ($Edge.type -eq 'SUPPORTS')    { [void]$CrossPovSupports.Add($PairKey) }
                    if ($Edge.type -eq 'CONTRADICTS') { [void]$CrossPovContradicts.Add($PairKey) }
                }
            }
            $MissingContradicts = @($CrossPovSupports | Where-Object { -not $CrossPovContradicts.Contains($_) })

            # ── Echo chamber nodes (many SUPPORTS, 0 cross-POV CONTRADICTS) ──
            $NodeCrossPovContradicts = @{}
            $NodeSupportsCount      = @{}
            foreach ($Edge in $ApprovedEdges) {
                $SPov = $NodePovLookup[$Edge.source]
                $TPov = $NodePovLookup[$Edge.target]
                if ($Edge.type -eq 'SUPPORTS') {
                    if (-not $NodeSupportsCount.ContainsKey($Edge.source)) { $NodeSupportsCount[$Edge.source] = 0 }
                    $NodeSupportsCount[$Edge.source]++
                }
                if ($Edge.type -eq 'CONTRADICTS' -and $SPov -ne $TPov) {
                    if (-not $NodeCrossPovContradicts.ContainsKey($Edge.source)) { $NodeCrossPovContradicts[$Edge.source] = 0 }
                    if (-not $NodeCrossPovContradicts.ContainsKey($Edge.target)) { $NodeCrossPovContradicts[$Edge.target] = 0 }
                    $NodeCrossPovContradicts[$Edge.source]++
                    $NodeCrossPovContradicts[$Edge.target]++
                }
            }
            $EchoChamberNodes = @($NodeSupportsCount.Keys | Where-Object {
                $NodeSupportsCount[$_] -ge 3 -and
                (-not $NodeCrossPovContradicts.ContainsKey($_) -or $NodeCrossPovContradicts[$_] -eq 0)
            } | Sort-Object { $NodeSupportsCount[$_] } -Descending)

            if ($Degrees.Count -gt 0) { $MaxDeg = $Degrees[-1] } else { $MaxDeg = 0 }
            if ($Degrees.Count -gt 0) { $MedDeg = $Degrees[[Math]::Floor($Degrees.Count / 2)] } else { $MedDeg = 0 }
            $GraphHealth = [ordered]@{
                EchoChamberScores    = $EchoChamberScores
                CrossPovConnectivity = [ordered]@{
                    CrossPovEdges = $CrossPovEdgeCount
                    TotalEdges    = $TotalEdgeCount
                    Percentage    = $CrossPovPct
                }
                EdgeOrphans          = $EdgeOrphans
                EdgeOrphanCount      = $EdgeOrphans.Count
                HubConcentration     = [ordered]@{
                    GiniCoefficient = $GiniCoeff
                    MaxDegree       = $MaxDeg
                    MedianDegree    = $MedDeg
                }
                MissingEdgeTypePairs = [ordered]@{
                    SupportsNoContradicts = $MissingContradicts
                    Count                 = $MissingContradicts.Count
                }
                EchoChamberNodes     = $EchoChamberNodes
                EchoChamberNodeCount = $EchoChamberNodes.Count
            }
        }
    }

    # ── 6. Return hashtable ────────────────────────────────────────────────────
    return @{
        TaxonomyVersion   = $TaxonomyVersion
        SummaryCount      = $SummaryStats.Count
        GeneratedAt       = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')
        NodeCitations     = $AllNodes
        OrphanNodes       = $OrphanNodes
        MostCited         = $MostCited
        LeastCited        = $LeastCited
        UnmappedConcepts  = $UnmappedSorted
        StrongCandidates  = $StrongCandidates
        StanceVariance    = $StanceVariance
        HighVarianceNodes = $HighVarianceNodes.ToArray()
        CoverageBalance   = $CoverageBalance
        CrossCuttingHealth = $CrossCuttingHealth
        SummaryStats      = $SummaryStatsResult
        GraphHealth       = $GraphHealth
        DensitySignals    = $DensitySignals.ToArray()
    }
}