Private/Resolve-GraphMapping.ps1

function Resolve-GraphMapping {
    <#
    .SYNOPSIS
        Core lookup engine behind Get-GraphMapping.
    .DESCRIPTION
        Resolves one or more GraphShell catalog entry points (Cmdlet, Endpoint, Permission,
        Module, Query) into enriched GraphShell.Mapping objects.
 
        -Cmdlet, -Endpoint, -Permission and -Module only load the small, sharded lite indexes
        generated by scripts/build-graphshell-lite-indexes.ps1 (a few hundred KB to ~2 MB,
        instead of the full ~11 MB / ~220 MB in-memory catalog). Only -Query, and the rare
        -Endpoint partial-match fallback, genuinely need a broad scan and load the full index
        (Get-GraphCatalogIndex).
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [string]$Cmdlet,
        [string]$Endpoint,
        [string]$Permission,
        [string]$Module,
        [string]$Query,
        [switch]$IncludeDetails,
        [int]$MaxQueryResults = 50
    )

    $renameIndex = Get-GraphRenameIndex
    $results = [System.Collections.Generic.List[object]]::new()

    if ($Cmdlet) {
        $results.AddRange([object[]](Resolve-GraphCmdletMapping -Cmdlet $Cmdlet -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails))
    }

    if ($Endpoint) {
        $results.AddRange([object[]](Resolve-GraphEndpointMapping -Endpoint $Endpoint -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails))
    }

    if ($Permission) {
        $results.AddRange([object[]](Resolve-GraphPermissionMapping -Permission $Permission -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails))
    }

    if ($Module) {
        $results.AddRange([object[]](Resolve-GraphModuleMapping -Module $Module -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails))
    }

    if ($Query) {
        $results.AddRange([object[]](Resolve-GraphQueryMapping -Query $Query -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails -MaxQueryResults $MaxQueryResults))
    }

    return $results
}

function Resolve-GraphCmdletMapping {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)][string]$Cmdlet,
        [object]$RenameIndex,
        [switch]$IncludeDetails
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $canonicalNames = [System.Collections.Generic.List[string]]::new()
    $canonicalNames.Add($Cmdlet)

    $aliasIndex = Get-GraphAliasIndex
    $aliasTargets = $null
    if ($aliasIndex.ByAlias.TryGetValue($Cmdlet, [ref]$aliasTargets)) {
        foreach ($target in $aliasTargets) { if (-not $canonicalNames.Contains($target)) { $canonicalNames.Add($target) } }
    }

    foreach ($name in $canonicalNames) {
        $shardKey = Get-GraphResourceShardKey -Command $name
        $shard = Get-GraphCmdletShard -Shard $shardKey
        if (-not $shard) { continue }
        $records = $null
        if ($shard.ByCommand.TryGetValue($name, [ref]$records)) {
            foreach ($record in $records) {
                $results.Add((New-GraphMappingObject -Record $record -MatchedBy 'Cmdlet' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails))
            }
        }
    }

    $renameRecord = $null
    if ($RenameIndex.ByOldCmdlet.TryGetValue($Cmdlet, [ref]$renameRecord)) {
        $newShardKey = Get-GraphResourceShardKey -Command $renameRecord.newCmdlet
        $newShard = Get-GraphCmdletShard -Shard $newShardKey
        $renamedRecords = $null
        if ($newShard -and $newShard.ByCommand.TryGetValue($renameRecord.newCmdlet, [ref]$renamedRecords)) {
            foreach ($record in $renamedRecords) {
                $obj = New-GraphMappingObject -Record $record -MatchedBy 'Cmdlet (renamed)' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails
                $obj.RenamedFrom = $Cmdlet
                $results.Add($obj)
            }
        }
    }

    return , $results.ToArray()
}

function Resolve-GraphEndpointMapping {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)][string]$Endpoint,
        [object]$RenameIndex,
        [switch]$IncludeDetails
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $key = $Endpoint.Trim('/').ToLowerInvariant()
    $segment = ($key -split '/')[0]
    if (-not $segment) { $segment = '_root' }
    $segment = $segment -replace '[\\/:*?"<>|]', '_'

    $shard = Get-GraphEndpointShard -Segment $segment
    $direct = $null
    if ($shard -and $shard.ByUri.TryGetValue($key, [ref]$direct)) {
        foreach ($record in $direct) {
            $results.Add((New-GraphMappingObject -Record $record -MatchedBy 'Endpoint' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails))
        }
        return , $results.ToArray()
    }

    # Partial match fallback: scan shard-by-shard (loading and discarding one small file at a
    # time) instead of loading the full catalog, since this broader search is the exception,
    # not the common case, for -Endpoint.
    foreach ($otherSegment in (Get-GraphAllEndpointSegments)) {
        $otherShard = Get-GraphEndpointShard -Segment $otherSegment
        if (-not $otherShard) { continue }
        foreach ($entry in $otherShard.ByUri.GetEnumerator()) {
            if ($entry.Key.Contains($key)) {
                foreach ($record in $entry.Value) {
                    $results.Add((New-GraphMappingObject -Record $record -MatchedBy 'Endpoint (partial)' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails))
                }
            }
        }
    }

    return , $results.ToArray()
}

function Resolve-GraphPermissionMapping {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)][string]$Permission,
        [object]$RenameIndex,
        [switch]$IncludeDetails
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $permissionIndex = Get-GraphPermissionLiteIndex
    $records = $null
    if ($permissionIndex.ByName.TryGetValue($Permission, [ref]$records)) {
        foreach ($record in $records) {
            $obj = New-GraphMappingObject -Record $record -MatchedBy 'Permission' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails
            $obj.MatchedPermission = $Permission
            $results.Add($obj)
        }
    }

    return , $results.ToArray()
}

function Resolve-GraphModuleMapping {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)][string]$Module,
        [object]$RenameIndex,
        [switch]$IncludeDetails
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $moduleNameIndex = Get-GraphModuleNameIndex
    $moduleMatches = $moduleNameIndex.Modules | Where-Object { $_.module -like "*$Module*" -or $_.moduleName -like "*$Module*" }
    foreach ($match in $moduleMatches) {
        $shard = Get-GraphModuleShard -FileName $match.fileName
        if (-not $shard) { continue }
        foreach ($record in $shard.records) {
            $results.Add((New-GraphMappingObject -Record $record -MatchedBy 'Module' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails))
        }
    }

    return , $results.ToArray()
}

function Resolve-GraphQueryMapping {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)][string]$Query,
        [object]$RenameIndex,
        [switch]$IncludeDetails,
        [int]$MaxQueryResults = 50
    )

    $results = [System.Collections.Generic.List[object]]::new()

    # Field weights: an exact command-name hit is worth much more than a module-name hit, so a
    # handful of generic module matches cannot outrank a precise command/endpoint match.
    $weightCommand = 12.0
    $weightEndpoint = 8.0
    $weightAliasVariant = 6.0
    $weightPermission = 7.0
    $weightModule = 3.0
    $synonymMultiplier = 0.6
    $exactPhraseBonus = 20.0

    $catalog = Get-GraphCatalogIndex
    $synonymIndex = Get-GraphSynonymIndex

    $queryWords = ConvertTo-GraphSearchWords -Text $Query
    if ($queryWords.Count -eq 0) { return , $results.ToArray() }
    $normalizedQuery = ($queryWords -join ' ')

    # Group tokens into "concepts": one per distinct word the user actually typed, each carrying
    # its own weight-1.0 primary word plus weight-0.6 synonym-expanded variants. Scoring later
    # takes the *best* match within a single concept (so a word and its own synonyms never stack)
    # but *sums* across different concepts (so a two-word query like "group membership" correctly
    # scores higher when a record matches both distinct ideas, not just whichever one is best).
    # A synonym variant that happens to equal another word the user already typed is dropped from
    # this concept: that word already has its own dedicated concept, so keeping it here would let
    # the same field match count twice under two different concepts.
    $queryWordSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($word in $queryWords) { if ($word.Length -ge 3) { [void]$queryWordSet.Add($word) } }

    $concepts = [System.Collections.Generic.List[object]]::new()
    foreach ($word in $queryWords) {
        if ($word.Length -lt 3) { continue }
        $conceptTokens = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $conceptTokens[$word] = 1.0
        $synonyms = $null
        if ($synonymIndex.ByTerm.TryGetValue($word, [ref]$synonyms)) {
            foreach ($synonym in $synonyms) {
                foreach ($synonymWord in (ConvertTo-GraphSearchWords -Text $synonym)) {
                    if ($conceptTokens.ContainsKey($synonymWord)) { continue }
                    if ($queryWordSet.Contains($synonymWord) -and $synonymWord -ne $word) { continue }
                    $conceptTokens[$synonymWord] = $synonymMultiplier
                }
            }
        }
        $concepts.Add($conceptTokens)
    }
    if ($concepts.Count -eq 0) { return , $results.ToArray() }

    # Build (once per session) an inverted word -> record-ids index per field, since only -Query
    # needs it. Scoring a query then only touches records that actually contain a matching word
    # instead of re-scanning all ~31k catalog records for every call, which is what made the
    # previous per-record/per-token/per-word triple loop scale so poorly.
    if (-not $script:GraphQueryInvertedIndex) {
        $comparer = [System.StringComparer]::OrdinalIgnoreCase
        $invCommand = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new($comparer)
        $invEndpoint = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new($comparer)
        $invAliasVariant = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new($comparer)
        $invModule = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new($comparer)

        # Inlined rather than routed through a scriptblock helper: invoking a scriptblock (`&`)
        # once per record/field (~125k times across the whole catalog) has enough per-call
        # overhead on its own to noticeably slow down this one-time index build.
        foreach ($record in $catalog.Records) {
            $id = $record.id

            foreach ($word in (ConvertTo-GraphSearchWords -Text $record.command)) {
                $list = $null
                if (-not $invCommand.TryGetValue($word, [ref]$list)) { $list = [System.Collections.Generic.List[string]]::new(); $invCommand[$word] = $list }
                $list.Add($id)
            }
            foreach ($word in (ConvertTo-GraphSearchWords -Text $record.uri)) {
                $list = $null
                if (-not $invEndpoint.TryGetValue($word, [ref]$list)) { $list = [System.Collections.Generic.List[string]]::new(); $invEndpoint[$word] = $list }
                $list.Add($id)
            }
            foreach ($word in (ConvertTo-GraphSearchWords -Text (($record.aliases + $record.variants) -join ' '))) {
                $list = $null
                if (-not $invAliasVariant.TryGetValue($word, [ref]$list)) { $list = [System.Collections.Generic.List[string]]::new(); $invAliasVariant[$word] = $list }
                $list.Add($id)
            }
            foreach ($word in (ConvertTo-GraphSearchWords -Text "$($record.module) $($record.moduleName)")) {
                $list = $null
                if (-not $invModule.TryGetValue($word, [ref]$list)) { $list = [System.Collections.Generic.List[string]]::new(); $invModule[$word] = $list }
                $list.Add($id)
            }
        }

        $script:GraphQueryInvertedIndex = [pscustomobject]@{
            Command      = $invCommand
            Endpoint     = $invEndpoint
            AliasVariant = $invAliasVariant
            Module       = $invModule
        }
    }
    $inverted = $script:GraphQueryInvertedIndex

    # best[id] = double[4] holding, per field (Command, Endpoint, AliasVariant, Module), the SUM
    # across concepts of each concept's own best token contribution for that field. Summing
    # across distinct concepts is what makes multi-word queries like "group membership" score a
    # record that matches both ideas higher than one that only matches a single idea; keeping
    # only the best *within* a concept is what stops a word and its own synonyms from stacking.
    $fieldNames = @('Command', 'Endpoint', 'AliasVariant', 'Module')
    $fieldWeights = @($weightCommand, $weightEndpoint, $weightAliasVariant, $weightModule)
    $best = [System.Collections.Generic.Dictionary[string, double[]]]::new([System.StringComparer]::OrdinalIgnoreCase)

    # IDF-style dampening: a word that appears in a large fraction of the catalog's records for a
    # given field (e.g. "identity", "management", "role") carries almost no discriminative value
    # and, left unweighted, would both dominate ranking (drowning out precise command/endpoint
    # matches under a flood of low-relevance module hits) and cost a lot of time updating tens of
    # thousands of candidate scores for a near-zero contribution. Standard log-scaled IDF
    # (normalized to the catalog size) fixes both, without hardcoding any specific word.
    $catalogSize = [double]$catalog.Records.Count
    $logCatalogSize = [Math]::Log10($catalogSize)
    $idfNegligibleThreshold = 0.3

    foreach ($concept in $concepts) {
        $conceptBest = [System.Collections.Generic.Dictionary[string, double[]]]::new([System.StringComparer]::OrdinalIgnoreCase)

        foreach ($tokenEntry in $concept.GetEnumerator()) {
            $token = $tokenEntry.Key
            $tokenWeight = $tokenEntry.Value

            # (candidate word, match-weight) pairs: exact match, plus simple plural/singular variants.
            $candidates = [System.Collections.Generic.List[object]]::new()
            $candidates.Add(@{ Word = $token; MatchWeight = 1.0 })
            if ($token.Length -ge 3) {
                $candidates.Add(@{ Word = ($token + 's'); MatchWeight = 0.7 })
                if ($token.EndsWith('s') -and $token.Length -gt 3) {
                    $candidates.Add(@{ Word = $token.Substring(0, $token.Length - 1); MatchWeight = 0.7 })
                }
            }

            for ($fieldIndex = 0; $fieldIndex -lt $fieldNames.Count; $fieldIndex++) {
                $fieldIndex_index = $fieldIndex
                $fieldIndexDict = $inverted.($fieldNames[$fieldIndex_index])
                $fieldWeight = $fieldWeights[$fieldIndex_index]

                foreach ($candidate in $candidates) {
                    $ids = $null
                    if (-not $fieldIndexDict.TryGetValue($candidate.Word, [ref]$ids)) { continue }
                    $documentFrequency = [double]$ids.Count
                    if ($documentFrequency -le 0) { continue }
                    $idfNorm = [Math]::Log10($catalogSize / $documentFrequency) / $logCatalogSize
                    if ($idfNorm -lt $idfNegligibleThreshold) { continue }
                    $contribution = $fieldWeight * $tokenWeight * $candidate.MatchWeight * $idfNorm
                    foreach ($id in $ids) {
                        $scoreArray = $null
                        if (-not $conceptBest.TryGetValue($id, [ref]$scoreArray)) {
                            $scoreArray = [double[]]::new(4)
                            $conceptBest[$id] = $scoreArray
                        }
                        if ($contribution -gt $scoreArray[$fieldIndex_index]) { $scoreArray[$fieldIndex_index] = $contribution }
                    }
                }
            }
        }

        foreach ($entry in $conceptBest.GetEnumerator()) {
            $scoreArray = $null
            if (-not $best.TryGetValue($entry.Key, [ref]$scoreArray)) {
                $scoreArray = [double[]]::new(4)
                $best[$entry.Key] = $scoreArray
            }
            for ($i = 0; $i -lt 4; $i++) { $scoreArray[$i] += $entry.Value[$i] }
        }
    }

    $scores = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($entry in $best.GetEnumerator()) {
        $total = 0.0
        foreach ($value in $entry.Value) { $total += $value }
        if ($total -gt 0) { $scores[$entry.Key] = $total }
    }

    # Exact-phrase bonus only needs to check records that already matched at least one word, not
    # the entire catalog.
    if ($normalizedQuery.Length -ge 5) {
        foreach ($id in @($scores.Keys)) {
            $record = $catalog.ById[$id]
            if ($record.command.ToLowerInvariant().Contains($normalizedQuery) -or $record.uri.ToLowerInvariant().Contains($normalizedQuery)) {
                $scores[$id] += $exactPhraseBonus
            }
        }
    }

    $permissionIndex = Get-GraphPermissionLiteIndex

    # Build (once per session) a word -> permission-name inverted index, mirroring the field
    # index above, so scoring only touches permissions that actually contain a matching word
    # instead of re-tokenizing and re-scanning all permission names on every query.
    if (-not $script:GraphPermissionWordIndex) {
        $permWordIndex = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($permissionName in $permissionIndex.Names) {
            foreach ($word in (ConvertTo-GraphSearchWords -Text $permissionName)) {
                $list = $null
                if (-not $permWordIndex.TryGetValue($word, [ref]$list)) {
                    $list = [System.Collections.Generic.List[string]]::new()
                    $permWordIndex[$word] = $list
                }
                $list.Add($permissionName)
            }
        }
        $script:GraphPermissionWordIndex = $permWordIndex
    }
    $permissionWordIndex = $script:GraphPermissionWordIndex

    # Same concept-scoped best-then-sum approach as the field scan: within one concept, keep only
    # the single best permission-based contribution per record (so a resource area with many
    # near-duplicate permission scopes -- e.g. 10+ Teams app permission variants such as
    # TeamsAppInstallation.ReadForChat/.ReadForTeam/.ReadForUser/... -- cannot stack); sum across
    # distinct concepts so a record matching two different query ideas via permissions still
    # scores higher than one matching only one.
    $permissionBest = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($concept in $concepts) {
        $conceptPermissionBest = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::OrdinalIgnoreCase)

        foreach ($tokenEntry in $concept.GetEnumerator()) {
            $token = $tokenEntry.Key
            $tokenWeight = $tokenEntry.Value
            $candidates = [System.Collections.Generic.List[object]]::new()
            $candidates.Add(@{ Word = $token; MatchWeight = 1.0 })
            if ($token.Length -ge 3) {
                $candidates.Add(@{ Word = ($token + 's'); MatchWeight = 0.7 })
                if ($token.EndsWith('s') -and $token.Length -gt 3) {
                    $candidates.Add(@{ Word = $token.Substring(0, $token.Length - 1); MatchWeight = 0.7 })
                }
            }

            # Avoid bonusing the same permission twice for the same token via multiple candidate
            # variants (e.g. both the exact word and its plural form matching).
            $permissionsBonusedThisToken = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

            foreach ($candidate in $candidates) {
                $permissionNames = $null
                if (-not $permissionWordIndex.TryGetValue($candidate.Word, [ref]$permissionNames)) { continue }
                foreach ($permissionName in $permissionNames) {
                    if (-not $permissionsBonusedThisToken.Add($permissionName)) { continue }

                    # Same IDF-style dampening as the field-word scan above: a permission that
                    # underlies a very large slice of the catalog (e.g. Directory.Read.All) is a
                    # weak, low-value signal and should not flood the candidate set.
                    $recordsForPermission = $permissionIndex.ByName[$permissionName]
                    $documentFrequency = [double]$recordsForPermission.Count
                    if ($documentFrequency -le 0) { continue }
                    $idfNorm = [Math]::Log10($catalogSize / $documentFrequency) / $logCatalogSize
                    if ($idfNorm -lt $idfNegligibleThreshold) { continue }

                    $bonus = $weightPermission * $tokenWeight * $candidate.MatchWeight * $idfNorm
                    foreach ($record in $recordsForPermission) {
                        $current = 0.0
                        if ($conceptPermissionBest.TryGetValue($record.id, [ref]$current)) {
                            if ($bonus -gt $current) { $conceptPermissionBest[$record.id] = $bonus }
                        }
                        else {
                            $conceptPermissionBest[$record.id] = $bonus
                        }
                    }
                }
            }
        }

        foreach ($entry in $conceptPermissionBest.GetEnumerator()) {
            if ($permissionBest.ContainsKey($entry.Key)) { $permissionBest[$entry.Key] += $entry.Value } else { $permissionBest[$entry.Key] = $entry.Value }
        }
    }

    foreach ($entry in $permissionBest.GetEnumerator()) {
        if ($scores.ContainsKey($entry.Key)) { $scores[$entry.Key] += $entry.Value } else { $scores[$entry.Key] = $entry.Value }
    }

    if ($scores.Count -eq 0) { return , $results.ToArray() }

    # Deterministic ordering: score desc, then v1.0 before beta (so a beta duplicate never
    # outranks its v1.0 counterpart on a tie), then command name, then id -- never relies on
    # dictionary enumeration order. Built with a plain loop (not a `| ForEach-Object` pipeline,
    # which has significant per-item overhead at this scale) before a single Sort-Object call.
    $rankedList = [System.Collections.Generic.List[object]]::new($scores.Count)
    foreach ($entry in $scores.GetEnumerator()) {
        $record = $catalog.ById[$entry.Key]
        $rankedList.Add([pscustomobject]@{
                Id       = $entry.Key
                Score    = $entry.Value
                Record   = $record
                IsBeta   = ($record.apiVersion -eq 'beta')
                DedupKey = (($record.command -replace 'Beta', '') + '|' + $record.uri)
            })
    }
    $ranked = $rankedList | Sort-Object -Property @{Expression = 'Score'; Descending = $true }, @{Expression = 'IsBeta'; Descending = $false }, @{Expression = { $_.Record.command }; Descending = $false }, 'Id'

    # Collapse near-duplicate v1.0/beta pairs of the same operation so they do not both occupy
    # top-N slots; keep only the highest-scored representative of each (command-without-Beta,
    # endpoint) pair.
    $seenDedupKeys = [System.Collections.Generic.HashSet[string]]::new()
    $emitted = 0
    foreach ($entry in $ranked) {
        if ($emitted -ge $MaxQueryResults) { break }
        if (-not $seenDedupKeys.Add($entry.DedupKey)) { continue }
        $obj = New-GraphMappingObject -Record $entry.Record -MatchedBy 'Query' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails
        $results.Add($obj)
        $emitted++
    }

    return , $results.ToArray()
}