Public/Invoke-OrgClaimMatching.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Invoke-OrgClaimMatching { <# .SYNOPSIS Stages 2+3 of t/1553 — match Stage 1 org stance claims to taxonomy nodes via embedding cosine, then aggregate to proposed ADVOCATES_FOR / OPPOSES edges. .DESCRIPTION Reads organization_stance_claims.json (Stage 1 output), computes canonical_proposition embeddings via embed_taxonomy.py batch-encode, and does top-K cosine search against the taxonomy-node embedding set. Aggregates matches per (org, node, polarity) and emits a proposed edge when either: - MinAgreement or more INDEPENDENT claims agree (independence: distinct doc-families — see -FamilyKey rule below), OR - one claim's cosine ≥ MatchThreshold. polarity=asserts → ADVOCATES_FOR; polarity=opposes → OPPOSES. Every proposal lands with status='proposed' via Import-OrganizationEdge. Zero auto-approval (t/1553 AC #5). Existing (org, node, type) tuples in any status are treated as covered and skipped, unless -Force. The 0.67/0.33 embedding variant (t/524 grid-search optimum) is the expected input. Generate with: python scripts/embed_taxonomy.py generate \ --field-weights 0.67,0.33,0,0,0 \ --output <path-to-embeddings-orgstance-6733.json> Confidence NOT used in gating (CL decision, t/1553#14): the 0.9-1.0 extraction_confidence band is non-discriminating, so it's dropped from Stage 3. Per CL's calibration note (t/1553#12): document-family variants (RMF playbook, CAISI variants, etc.) are ONE independence unit for the MinAgreement rule. Family key: strip trailing '-YYYY(-N)?' from source_id. Per t/1553#18: the return object carries a .NegationSlice list with the top-K matches for every claim whose proposition contains a negated deontic — embeddings are weak at negation, so these are the highest-risk slice for landing on directional-opposite nodes. .PARAMETER ClaimsPath Stage 1 claims JSON. Defaults to <taxonomy-dir>/organization_stance_claims.json. .PARAMETER EmbeddingsPath 0.67/0.33 field-weight embedding set (the t/524 variant). Required. .PARAMETER MatchThreshold Single-claim cosine floor for high-precision proposal. Default: 0.60 (t/1553 stipulated). .PARAMETER MinAgreement Minimum independent claims agreeing on (node, polarity) to propose without meeting -MatchThreshold. Default: 2 (t/1553 stipulated). .PARAMETER PerOrgCap Maximum proposed edges written per organization per run. Default: 20. .PARAMETER TopK Top matches to keep per claim. Default: 5. .PARAMETER Org Restrict to specific org id(s). .PARAMETER WriteProposals Write proposed edges via Import-OrganizationEdge. Without this switch, the cmdlet reports what it would emit but does not touch the edge store — always default to dry-run for review. .PARAMETER Force Emit proposals even when a (org, node, type) tuple already exists in the edge store. Never touches approved/disputed rows unless combined with a manual delete — this only overrides the has-any-status skip. .EXAMPLE Invoke-OrgClaimMatching -EmbeddingsPath ../ai-triad-data/taxonomy/Origin/embeddings-orgstance-6733.json .EXAMPLE Invoke-OrgClaimMatching -EmbeddingsPath ...-6733.json -WriteProposals -Org org-014 .LINK Show-AITriadHelp .LINK Invoke-OrgDerivedCampScores .LINK Invoke-OrgPublishedSeeding .LINK Invoke-OrgStanceExtraction .LINK Get-Organization #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter()] [string]$ClaimsPath, [Parameter(Mandatory)] [string]$EmbeddingsPath, [Parameter()] [ValidateRange(0.0, 1.0)] [double]$MatchThreshold = 0.60, [Parameter()] [ValidateRange(1, 20)] [int]$MinAgreement = 2, [Parameter()] [ValidateRange(1, 100)] [int]$PerOrgCap = 20, [Parameter()] [ValidateRange(1, 20)] [int]$TopK = 5, [Parameter()] [string[]]$Org, [Parameter()] [switch]$WriteProposals, [Parameter()] [switch]$Force ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ── Load claims ────────────────────────────────────────────────────── if (-not $ClaimsPath) { $ClaimsPath = Join-Path (Get-TaxonomyDir) 'organization_stance_claims.json' } if (-not (Test-Path $ClaimsPath)) { throw (New-ActionableError ` -Goal 'Match org claims to nodes' ` -Problem "Claims file not found: $ClaimsPath" ` -Location 'Invoke-OrgClaimMatching' ` -NextSteps @('Run Invoke-OrgStanceExtraction first', 'Or pass -ClaimsPath explicitly')) } $claimStore = Get-Content $ClaimsPath -Raw | ConvertFrom-Json $claims = if ($claimStore.PSObject.Properties['claims']) { @($claimStore.claims) } else { @() } if ($Org -and $Org.Count -gt 0) { $wanted = @($Org) $claims = @($claims | Where-Object { [string]$_.org_id -in $wanted }) } $totalClaims = @($claims).Count Write-Verbose "Claims after -Org filter: $totalClaims" if ($totalClaims -eq 0) { Write-Host 'No claims match the filter — nothing to do.' return [PSCustomObject]@{ Proposed = 0; ProposalsWouldEmit = 0; ClaimsProcessed = 0 } } # ── Load embeddings (nodes only — skip policies/conflicts) ─────────── if (-not (Test-Path $EmbeddingsPath)) { throw (New-ActionableError ` -Goal 'Load embeddings' ` -Problem "Embeddings file not found: $EmbeddingsPath" ` -Location 'Invoke-OrgClaimMatching' ` -NextSteps @('Regenerate with python scripts/embed_taxonomy.py generate --field-weights 0.67,0.33,0,0,0 --output <path>')) } $embStore = Get-Content $EmbeddingsPath -Raw | ConvertFrom-Json if (-not $embStore.PSObject.Properties['nodes']) { throw "Embeddings file has no 'nodes' property: $EmbeddingsPath" } # Filter to pov nodes (acc/saf/skp-BDI-NNN). Skip policies (pol-*), # conflicts (conflict-*), and cross-cutting sit/cc rows. $nodeIds = [System.Collections.Generic.List[string]]::new() $nodeVecs = [System.Collections.Generic.List[double[]]]::new() foreach ($p in $embStore.nodes.PSObject.Properties) { $id = [string]$p.Name if ($id -notmatch '^(acc|saf|skp)-(beliefs|desires|intentions)-\d+$') { continue } $node = $p.Value # Node values are structured records with .vector (weighted composite). if (-not $node.PSObject.Properties['vector']) { continue } $vec = @($node.vector | ForEach-Object { [double]$_ }) $nodeIds.Add($id) $nodeVecs.Add([double[]]$vec) } Write-Verbose "Node embeddings loaded: $($nodeIds.Count)" # ── Batch-encode claim propositions via embed_taxonomy.py ──────────── $repoRoot = $script:RepoRoot $embScript = Join-Path $repoRoot 'scripts' 'embed_taxonomy.py' $encodeInput = [System.Collections.Generic.List[PSObject]]::new() for ($ei = 0; $ei -lt $totalClaims; $ei++) { $c = $claims[$ei] $key = "$($c.org_id)::$($c.source_id)::$ei" $encodeInput.Add([PSCustomObject]@{ id = $key; text = [string]$c.canonical_proposition }) } $stdin = ($encodeInput | ConvertTo-Json -Compress -Depth 4) Write-Verbose "Batch-encoding $($encodeInput.Count) claim propositions..." $global:LASTEXITCODE = 0 $encoded = $stdin | & python $embScript batch-encode 2>$null $exit = if (Test-Path variable:LASTEXITCODE) { $LASTEXITCODE } else { 0 } if ($exit -ne 0 -or -not $encoded) { throw (New-ActionableError ` -Goal 'Batch-encode claim propositions' ` -Problem 'embed_taxonomy.py batch-encode failed' ` -Location 'Invoke-OrgClaimMatching' ` -NextSteps @('Verify python + sentence-transformers install', "Try: python $embScript encode 'test string'")) } $claimVecs = ($encoded | Out-String | ConvertFrom-Json) # ── Top-K cosine per claim ─────────────────────────────────────────── $matchPerClaim = [System.Collections.Generic.List[PSObject]]::new() $doubleMarkPattern = '(?i)(\b(oppose|reject|resist|against|least promising)\b|\b(ought|should|must|need|shall|may)\s+not\b)' $negationSlice = [System.Collections.Generic.List[PSObject]]::new() $familyKeyPattern = '-(?<year>\d{4})(-\d+)?$' for ($i = 0; $i -lt $totalClaims; $i++) { $claim = $claims[$i] $key = "$($claim.org_id)::$($claim.source_id)::$i" if (-not $claimVecs.PSObject.Properties[$key]) { Write-Warning "Missing embedding for $key — skipping" continue } $qv = @($claimVecs.$key | ForEach-Object { [double]$_ }) $qvArr = [double[]]$qv # Cosine — vectors from batch-encode are L2-normalized, node vectors # from generate are also normalized. So dot product = cosine. $scores = [System.Collections.Generic.List[PSObject]]::new() for ($j = 0; $j -lt $nodeIds.Count; $j++) { $nv = $nodeVecs[$j] $s = 0.0 for ($k = 0; $k -lt $qvArr.Length; $k++) { $s += $qvArr[$k] * $nv[$k] } $scores.Add([PSCustomObject]@{ NodeId = $nodeIds[$j]; Score = $s }) } $top = @($scores | Sort-Object -Property Score -Descending | Select-Object -First $TopK) $sourceId = [string]$claim.source_id $family = $sourceId -replace $familyKeyPattern, '' $matchPerClaim.Add([PSCustomObject]@{ OrgId = [string]$claim.org_id SourceId = $sourceId Family = $family Polarity = [string]$claim.polarity Proposition = [string]$claim.canonical_proposition Top = @($top) Best = $top[0] }) # Negation slice (t/1553#18): flag any claim whose proposition matches # the double-mark pattern, regardless of polarity. Embeddings are weak # at negation so these are the top-K rows CL wants to eyeball. if ([string]$claim.canonical_proposition -match $doubleMarkPattern) { $negationSlice.Add([PSCustomObject]@{ OrgId = [string]$claim.org_id SourceId = $sourceId Polarity = [string]$claim.polarity Proposition = [string]$claim.canonical_proposition Top = @($top) }) } } # ── Existing edges to skip ─────────────────────────────────────────── $existingKeys = @{} try { $edgeStore = Get-OrganizationEdgesStore if ($edgeStore -and $edgeStore.PSObject.Properties['edges']) { foreach ($e in @($edgeStore.edges)) { if (-not $e.PSObject.Properties['source'] -or -not $e.PSObject.Properties['target'] -or -not $e.PSObject.Properties['type']) { continue } $k = "$([string]$e.source)::$([string]$e.target)::$([string]$e.type)" $existingKeys[$k] = $true } } } catch { Write-Verbose "No edge store readable (fresh install?) — treating all tuples as new" } # ── Aggregate to proposals ─────────────────────────────────────────── # Bucket key: (org, node, polarity). For each bucket, dedup by family, # then apply MinAgreement / MatchThreshold rules. $buckets = @{} foreach ($m in $matchPerClaim) { # Only the BEST node per claim participates in aggregation # (top-K rows are retained for reporting; aggregation uses the argmax). $bkey = "$($m.OrgId)::$($m.Best.NodeId)::$($m.Polarity)" if (-not $buckets.ContainsKey($bkey)) { $buckets[$bkey] = [System.Collections.Generic.List[PSObject]]::new() } $buckets[$bkey].Add($m) } $proposals = [System.Collections.Generic.List[PSObject]]::new() foreach ($bkey in $buckets.Keys) { $items = $buckets[$bkey] $orgId = ($items[0]).OrgId $nodeId = ($items[0]).Best.NodeId $polarity = ($items[0]).Polarity $edgeType = if ($polarity -eq 'asserts') { 'ADVOCATES_FOR' } else { 'OPPOSES' } # Independence: family-dedup $families = @($items | ForEach-Object { $_.Family } | Sort-Object -Unique) $independence = $families.Count # Gate: # (a) independent-claims rule: >= MinAgreement DISTINCT families, # (b) single-claim threshold: any item's Best.Score >= MatchThreshold. $bestScore = ($items | Measure-Object -Property { $_.Best.Score } -Maximum).Maximum $meetsA = $independence -ge $MinAgreement $meetsB = $bestScore -ge $MatchThreshold if (-not ($meetsA -or $meetsB)) { continue } # Skip existing $existingCheckKey = "$orgId::$nodeId::$edgeType" if (-not $Force -and $existingKeys.ContainsKey($existingCheckKey)) { continue } # Rationale + source_refs bundle $reason = if ($meetsA -and $meetsB) { 'multi-claim-agreement+high-cosine' } elseif ($meetsA) { 'multi-claim-agreement' } else { 'high-cosine' } $summary = "match_basis=$reason families=$independence best_cosine={0:N3} (t/1553 Stage 3)" -f $bestScore $srcRefs = @($items | ForEach-Object { $_.SourceId } | Sort-Object -Unique) $proposals.Add([PSCustomObject]@{ OrgId = $orgId NodeId = $nodeId EdgeType = $edgeType Polarity = $polarity Reason = $reason BestCosine = $bestScore Independent = $independence ClaimCount = $items.Count SourceRefs = @($srcRefs) Rationale = $summary Items = @($items) }) } # ── Per-org cap (top by best cosine) ───────────────────────────────── $capped = [System.Collections.Generic.List[PSObject]]::new() $dropped = [System.Collections.Generic.List[PSObject]]::new() foreach ($grp in ($proposals | Group-Object -Property OrgId)) { $ordered = @($grp.Group | Sort-Object -Property BestCosine -Descending) $keep = $ordered | Select-Object -First $PerOrgCap $skip = $ordered | Select-Object -Skip $PerOrgCap foreach ($p in $keep) { $capped.Add($p) } foreach ($p in $skip) { $dropped.Add($p) } } # ── Emit or dry-run report ────────────────────────────────────────── $written = 0 $failed = [System.Collections.Generic.List[string]]::new() if ($WriteProposals) { foreach ($p in $capped) { $target = "$($p.OrgId)->$($p.NodeId) [$($p.EdgeType)]" if ($PSCmdlet.ShouldProcess($target, 'Propose org→node edge')) { try { $null = Import-OrganizationEdge -InputObject ([PSCustomObject]@{ source = $p.OrgId target = $p.NodeId type = $p.EdgeType rationale = $p.Rationale source_refs = @($p.SourceRefs) status = 'proposed' discovered_at = (Get-Date).ToString('yyyy-MM-dd') }) -Confirm:$false $written++ } catch { $failed.Add("$($p.OrgId)->$($p.NodeId): $($_.Exception.Message)") Write-Warning "Failed to write proposal $($p.OrgId)->$($p.NodeId): $($_.Exception.Message)" } } } } Write-Host "" if ($WriteProposals) { Write-Host "Claims: $totalClaims | Proposals: $($capped.Count) (wrote $written) | Dropped by cap: $($dropped.Count) | Negation-slice: $($negationSlice.Count)" } else { Write-Host "Claims: $totalClaims | Proposals (dry-run): $($capped.Count) | Dropped by cap: $($dropped.Count) | Negation-slice: $($negationSlice.Count)" } [PSCustomObject]@{ ClaimsProcessed = $totalClaims ProposalsWouldEmit = $capped.Count Proposed = $written DroppedByCap = @($dropped) Proposals = @($capped) NegationSlice = @($negationSlice) Failed = @($failed) EmbeddingsPath = $EmbeddingsPath } } |