Public/Get-Edge.ps1

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

function Get-Edge {
    <#
    .SYNOPSIS
        Lists and filters edges in the taxonomy graph.
    .DESCRIPTION
        Reads edges.json and returns edges matching the specified criteria.
        All string filters support wildcards. Multiple filters are AND-combined.
        With no parameters, returns all edges sorted by confidence descending.
    .PARAMETER Source
        Wildcard pattern matched against the source node ID.
    .PARAMETER Target
        Wildcard pattern matched against the target node ID.
    .PARAMETER NodeId
        Wildcard pattern matched against either source or target node ID.
        Useful for finding all edges connected to a node regardless of direction.
    .PARAMETER Type
        Wildcard pattern matched against the edge type (e.g., SUPPORTS, 'TENS*').
    .PARAMETER Status
        Edge approval status: proposed, approved, or rejected.
    .PARAMETER MinConfidence
        Minimum confidence threshold (0.0-1.0). Default: 0.0.
    .PARAMETER MaxConfidence
        Maximum confidence threshold (0.0-1.0). Default: 1.0.
    .PARAMETER Bidirectional
        When specified, returns only bidirectional ($true) or directional ($false) edges.
    .PARAMETER CrossPov
        When specified, returns only cross-POV ($true) or same-POV ($false) edges.
    .PARAMETER Strength
        Wildcard pattern matched against the edge strength (strong, moderate, weak).
    .PARAMETER Model
        Wildcard pattern matched against the model that discovered the edge.
    .PARAMETER Rationale
        Wildcard pattern matched against the edge rationale text.
    .PARAMETER DiscoveredAfter
        Returns only edges discovered on or after this date (yyyy-MM-dd).
    .PARAMETER DiscoveredBefore
        Returns only edges discovered on or before this date (yyyy-MM-dd).
    .PARAMETER SourcePov
        Filter to edges whose source node belongs to this POV.
    .PARAMETER TargetPov
        Filter to edges whose target node belongs to this POV.
    .PARAMETER Id
        Return a specific edge by its display ID (e.g., edg-00042).
    .PARAMETER Index
        Return a specific edge by its zero-based index in edges.json.
    .PARAMETER First
        Return only the first N matching edges.
    .PARAMETER RepoRoot
        Path to the repository root.
    .EXAMPLE
        Get-Edge
        # Returns all edges.
    .EXAMPLE
        Get-Edge -Source 'acc-desires-*'
        # All edges from accelerationist goal nodes.
    .EXAMPLE
        Get-Edge -NodeId 'saf-desires-001'
        # All edges connected to saf-desires-001 (source or target).
    .EXAMPLE
        Get-Edge -Type CONTRADICTS -Status approved
        # Approved contradictions.
    .EXAMPLE
        Get-Edge -CrossPov -MinConfidence 0.9
        # High-confidence cross-POV edges.
    .EXAMPLE
        Get-Edge -Rationale '*existential*' -Type 'TENS*'
        # Tension edges mentioning existential risk.
    .EXAMPLE
        Get-Edge -SourcePov safetyist -TargetPov accelerationist -Status approved
        # Approved edges from safetyist to accelerationist nodes.
    .EXAMPLE
        Get-Edge -Id edg-00042
        # Return edge by its display ID.
    .EXAMPLE
        Get-Edge -Index 42
        # Return edge at index 42.
    .EXAMPLE
        Get-Edge -Type SUPPORTS -First 10
        # First 10 SUPPORTS edges by confidence.
    .LINK
        Show-AITriadHelp
    .LINK
        Approve-Edge
    .LINK
        Set-Edge
    .LINK
        Test-EdgeDirection
    .LINK
        Invoke-EdgeDiscovery
    .LINK
        Invoke-EdgeWeightEvaluation
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Source,

        [string]$Target,

        [string]$NodeId,

        [string]$Type,

        [ValidateSet('proposed', 'approved', 'rejected', '')]
        [string]$Status,

        [ValidateRange(0.0, 1.0)]
        [double]$MinConfidence = 0.0,

        [ValidateRange(0.0, 1.0)]
        [double]$MaxConfidence = 1.0,

        [Nullable[bool]]$Bidirectional,

        [Nullable[bool]]$CrossPov,

        [string]$Strength,

        [ValidateScript({ Test-AIModelId $_ })]
        [ArgumentCompleter({ param($cmd, $param, $word) $script:ValidModelIds | Where-Object { $_ -like "$word*" } })]
        [string]$Model,

        [string]$Rationale,

        [string]$DiscoveredAfter,

        [string]$DiscoveredBefore,

        [ValidateSet('accelerationist', 'safetyist', 'skeptic', 'cross-cutting', 'situations', '')]
        [string]$SourcePov,

        [ValidateSet('accelerationist', 'safetyist', 'skeptic', 'cross-cutting', 'situations', '')]
        [string]$TargetPov,

        [ValidatePattern('^edg-\d{5}$')]
        [string]$Id,

        [int]$Index = -1,

        [int]$First = 0,

        [string]$RepoRoot = $script:RepoRoot
    )

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

    $TaxDir    = Get-TaxonomyDir
    $EdgesPath = Join-Path $TaxDir 'edges.json'

    if (-not (Test-Path $EdgesPath)) {
        Write-Fail 'No edges.json found. Run Invoke-EdgeDiscovery first.'
        return
    }

    $EdgesData = Get-Content -Raw -Path $EdgesPath | ConvertFrom-Json

    # ------------------------------------------------------------------
    # Id mode — convert edg-NNNNN to zero-based index
    # ------------------------------------------------------------------
    if ($Id) {
        $Index = [int]$Id.Substring(4) - 1
    }

    # ------------------------------------------------------------------
    # Index mode — fast return of a single edge
    # ------------------------------------------------------------------
    if ($Index -ge 0) {
        if ($Index -ge $EdgesData.edges.Count) {
            Write-Fail "Edge index $Index out of range (0-$($EdgesData.edges.Count - 1))."
            return
        }
        $E = $EdgesData.edges[$Index]
        return [PSCustomObject]@{
            PSTypeName    = 'AITriad.Edge'
            Id            = 'edg-{0:D5}' -f ($Index + 1)
            Index         = $Index
            Source        = $E.source
            Target        = $E.target
            Type          = $E.type
            Bidirectional = [bool]$E.bidirectional
            Confidence    = $E.confidence
            Weight        = if ($E.PSObject.Properties['weight']) { $E.weight } else { $null }
            Status        = $E.status
            Strength      = if ($E.PSObject.Properties['strength']) { $E.strength } else { $null }
            Rationale     = $E.rationale
            Notes         = if ($E.PSObject.Properties['notes']) { $E.notes } else { $null }
            DirectionFlag = if ($E.PSObject.Properties['direction_flag']) { $E.direction_flag } else { $null }
            DiscoveredAt  = $E.discovered_at
            Model         = $E.model
        }
    }

    # ------------------------------------------------------------------
    # Build node→POV map (only when POV-based filters are active)
    # ------------------------------------------------------------------
    $NodePovMap = $null
    if ($SourcePov -or $TargetPov -or $CrossPov -ne $null) {
        $NodePovMap = @{}
        foreach ($PovKey in @('accelerationist', 'safetyist', 'skeptic', 'situations')) {
            $FilePath = Join-Path $TaxDir "$PovKey.json"
            if (-not (Test-Path $FilePath)) { continue }
            $FileData = Get-Content -Raw -Path $FilePath | ConvertFrom-Json
            foreach ($Node in $FileData.nodes) {
                $NodePovMap[$Node.id] = $PovKey
            }
        }
    }

    # ------------------------------------------------------------------
    # Filter edges
    # ------------------------------------------------------------------
    $Results = [System.Collections.Generic.List[PSObject]]::new()
    $EdgeCount = $EdgesData.edges.Count

    for ($i = 0; $i -lt $EdgeCount; $i++) {
        $E = $EdgesData.edges[$i]

        # Source/Target/NodeId wildcard filters
        if ($Source -and $E.source -notlike $Source) { continue }
        if ($Target -and $E.target -notlike $Target) { continue }
        if ($NodeId -and ($E.source -notlike $NodeId) -and ($E.target -notlike $NodeId)) { continue }

        # Type wildcard
        if ($Type -and $E.type -notlike $Type) { continue }

        # Status exact (guard for sparse legacy edges that omit status)
        if ($Status) {
            $EdgeStatus = if ($E.PSObject.Properties['status']) { $E.status } else { $null }
            if ($EdgeStatus -ne $Status) { continue }
        }

        # Confidence range (guard for legacy edges that omit confidence — treat as 0
        # so they fall out of any positive MinConfidence filter; pass when default 0..1)
        $EdgeConfidence = if ($E.PSObject.Properties['confidence']) { [double]$E.confidence } else { 0.0 }
        if ($EdgeConfidence -lt $MinConfidence) { continue }
        if ($EdgeConfidence -gt $MaxConfidence) { continue }

        # Bidirectional filter
        if ($null -ne $Bidirectional) {
            $IsBidir = if ($E.PSObject.Properties['bidirectional']) { [bool]$E.bidirectional } else { $false }
            if ($IsBidir -ne $Bidirectional) { continue }
        }

        # Strength wildcard
        if ($Strength) {
            if ($E.PSObject.Properties['strength']) { $EStrength = $E.strength } else { $EStrength = '' }
            if ($EStrength -notlike $Strength) { continue }
        }

        # Model wildcard (3,782 legacy edges lack `model`; treat absent as empty
        # so the filter excludes them when the user asks for a specific pattern)
        if ($Model) {
            $EModel = if ($E.PSObject.Properties['model']) { $E.model } else { '' }
            if ($EModel -notlike $Model) { continue }
        }

        # Rationale wildcard (defensive — currently 100% present, but the field
        # is optional in the TS Edge interface)
        if ($Rationale) {
            $ERationale = if ($E.PSObject.Properties['rationale']) { $E.rationale } else { '' }
            if ($ERationale -notlike $Rationale) { continue }
        }

        # Date range filters (defensive guard for discovered_at)
        if ($DiscoveredAfter -or $DiscoveredBefore) {
            $EDiscoveredAt = if ($E.PSObject.Properties['discovered_at']) { $E.discovered_at } else { $null }
            if ($DiscoveredAfter -and ($null -eq $EDiscoveredAt -or $EDiscoveredAt -lt $DiscoveredAfter)) { continue }
            if ($DiscoveredBefore -and ($null -ne $EDiscoveredAt -and $EDiscoveredAt -gt $DiscoveredBefore)) { continue }
        }

        # POV-based filters
        if ($NodePovMap) {
            if ($NodePovMap.ContainsKey($E.source)) { $SPov = $NodePovMap[$E.source] } else { $SPov = 'unknown' }
            if ($NodePovMap.ContainsKey($E.target)) { $TPov = $NodePovMap[$E.target] } else { $TPov = 'unknown' }

            if ($SourcePov -and $SPov -ne $SourcePov) { continue }
            if ($TargetPov -and $TPov -ne $TargetPov) { continue }

            if ($null -ne $CrossPov) {
                $IsCross = $SPov -ne $TPov
                if ($IsCross -ne $CrossPov) { continue }
            }
        }

        # t/1197: every optional field gets a PSObject.Properties guard. The TS Edge
        # interface (taxonomy-editor/src/renderer/types/taxonomy.ts:320-334) has more
        # optional fields than the legacy data actually carries — guarding consistently
        # here prevents PropertyNotFoundException under StrictMode when older edges
        # (e.g. 3,782 pre-model-tracking entries) are read.
        $Results.Add([PSCustomObject]@{
            PSTypeName    = 'AITriad.Edge'
            Id            = 'edg-{0:D5}' -f ($i + 1)
            Index         = $i
            Source        = $E.source
            Target        = $E.target
            Type          = $E.type
            Bidirectional = if ($E.PSObject.Properties['bidirectional']) { [bool]$E.bidirectional } else { $false }
            Confidence    = if ($E.PSObject.Properties['confidence']) { $E.confidence } else { $null }
            Weight        = if ($E.PSObject.Properties['weight']) { $E.weight } else { $null }
            Status        = if ($E.PSObject.Properties['status']) { $E.status } else { $null }
            Strength      = if ($E.PSObject.Properties['strength']) { $E.strength } else { $null }
            Rationale     = if ($E.PSObject.Properties['rationale']) { $E.rationale } else { $null }
            Notes         = if ($E.PSObject.Properties['notes']) { $E.notes } else { $null }
            DirectionFlag = if ($E.PSObject.Properties['direction_flag']) { $E.direction_flag } else { $null }
            DiscoveredAt  = if ($E.PSObject.Properties['discovered_at']) { $E.discovered_at } else { $null }
            Model         = if ($E.PSObject.Properties['model']) { $E.model } else { $null }
        })

        if ($First -gt 0 -and $Results.Count -ge $First) { break }
    }

    if ($Results.Count -eq 0) {
        Write-Warning 'No edges matched the specified filters.'
        return
    }

    $Results | Sort-Object Confidence -Descending
}