Public/Get-NodeTestingRecord.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Get-NodeTestingRecord { <# .SYNOPSIS Read-only projection of the debate-tested record on POV nodes (Phase 2 of t/1523; ticket t/1579). .DESCRIPTION Reads `graph_attributes.debate_tested` off every loaded POV node and emits pipeline-composable [NodeTestingRecord] objects. Never recomputes tier or sort_key locally — the TypeScript writer at lib/debate/debateTested.ts is the single source of truth. Use Update-NodeTestingRecord -RecomputeOnly to refresh those two fields when constants change. Nodes with no `debate_tested` field emit as tier='untested', SortKey=0, Engagements=0, Stale=$false so users can spot the never-tested bucket in a single sort. Staleness (t/1579 AC #2): a node is Stale when the current SHA-256 of its description differs from the recorded `description_hash`. The design also specifies an embedding-cosine cosmetic-edit exemption (COSMETIC_EDIT_SIMILARITY_THRESHOLD, 0.98) — v1 implements the hash check only and treats the embedding fallback as a follow-up (documented deviation: false-positive stale flags for cosmetic edits are surfaced, not silenced; safer than silently hiding an actual drift). Deficit sort (t/1588 alignment with severeTestScheduler.ts): testing_priority = importance * deficit importance = 0.25*degree + 0.15*crux_density + 0.25*policy_linkage + 0.20*doctrinal_anchor + 0.15*usage deficit = untested 1.0 / cited 0.7 / stale 0.6 / contested 0.4 / well_tested 0.1 Signal sources (post-t/1588, mirroring severeTestScheduler.ts computeNodeImportance so PS + TS are one formula, one signal set): degree = children.length + situation_refs.length + conflict_ids.length (structural) crux_density = per-node reference count from aggregated-cruxes.json (via Get-CruxLinkCount) policy_linkage = graph_attributes.policy_actions.length doctrinal_anchor = node.doctrinally_anchored ? 1.0 : 0 usage = debate_refs.length Each signal is normalized across the batch via divide-by-max → [0,1]. If every node in the run reads zero on every signal, a single at-end warning surfaces so a zero-priority ranking is visibly not the real answer. .PARAMETER Pov Filter to a single camp (acc / saf / skp). Default: all camps. .PARAMETER Category Filter by BDI category (belief / desire / intention). Default: all. .PARAMETER Tier Filter by tier (untested / cited / contested / well_tested). Default: all. .PARAMETER SortBy Debate-Tested (default): descending by SortKey (well_tested and strongly evidenced first). Deficit: descending by testing_priority (the work queue for future debates). .PARAMETER Top Return only the top N after sort. Default: emit all. .PARAMETER Stale Return only nodes whose current description hash differs from the recorded hash. .PARAMETER OutputPath Optional path to write the emitted records as a JSON array with camelCase field names matching the TS NodeTestingRecord interface in lib/debate/debateTested.ts (t/1588 Phase A). When set, the cmdlet writes the sorted/filtered result to disk AND returns the same records to the pipeline. Field names in the JSON: nodeId, pov, category, label, tier, sortKey, engagements, challenges, held, weakened, lastTested, refined, stale, challengerCamps, importance, deficit, testingPriority. .OUTPUTS [NodeTestingRecord] .EXAMPLE Get-NodeTestingRecord -Pov saf -Tier well_tested .EXAMPLE Get-NodeTestingRecord -SortBy Deficit -Top 20 .EXAMPLE Get-NodeTestingRecord -Stale .EXAMPLE # t/1588 Phase A — write JSON for the TS severeTestScheduler to consume Get-NodeTestingRecord -SortBy Deficit -OutputPath ./testing-records.json .LINK Update-NodeTestingRecord .LINK Get-Tax .LINK Get-TaxonomyHealth #> [CmdletBinding()] param( [Parameter()] [ValidateSet('acc', 'saf', 'skp')] [string]$Pov, [Parameter()] [ValidateSet('belief', 'desire', 'intention')] [string]$Category, [Parameter()] [ValidateSet('untested', 'cited', 'contested', 'well_tested')] [string]$Tier, [Parameter()] [ValidateSet('Debate-Tested', 'Deficit')] [string]$SortBy = 'Debate-Tested', [Parameter()] [ValidateRange(1, [int]::MaxValue)] [int]$Top, [Parameter()] [switch]$Stale, [Parameter()] [string]$OutputPath ) Set-StrictMode -Version Latest $povFullByShort = @{ acc = 'accelerationist'; saf = 'safetyist'; skp = 'skeptic' } $categoryTitle = @{ belief = 'Beliefs'; desire = 'Desires'; intention = 'Intentions' } $taxArgs = @{} if ($Pov) { $taxArgs['POV'] = $povFullByShort[$Pov] } $nodes = @(Get-Tax @taxArgs) if ($Category) { $wantedCategory = $categoryTitle[$Category] $nodes = @($nodes | Where-Object { $_.Category -eq $wantedCategory }) } $sha256 = [System.Security.Cryptography.SHA256]::Create() try { $results = [System.Collections.Generic.List[NodeTestingRecord]]::new() # t/1588: raw structural signals captured per-node, keyed by NodeId, # normalized-and-weighted in a second pass after all nodes are seen # (severeTestScheduler.ts:computeNodeImportance normalizes divide-by-max # across the batch — PS mirrors that so both sides agree byte-for-byte). $rawSignals = @{} $cruxLinks = if ($SortBy -eq 'Deficit') { Get-CruxLinkCount } else { @{} } foreach ($n in $nodes) { $ntr = [NodeTestingRecord]::new() $ntr.NodeId = [string]$n.Id $ntr.Pov = [string]$n.POV $ntr.Category = [string]$n.Category $ntr.Label = [string]$n.Label $ntr.Tier = 'untested' $ntr.SortKey = 0.0 $ntr.Engagements = 0 $ntr.Challenges = 0 $ntr.Held = 0 $ntr.Weakened = 0 $ntr.LastTested = '' $ntr.Refined = $false $ntr.Stale = $false $ntr.ChallengerCamps = @() $ga = $n.GraphAttributes $hasDT = $null -ne $ga -and $ga.PSObject.Properties['debate_tested'] -and $null -ne $ga.debate_tested if ($hasDT) { $dt = $ga.debate_tested if ($dt.PSObject.Properties['tier']) { $ntr.Tier = [string]$dt.tier } if ($dt.PSObject.Properties['sort_key']) { $ntr.SortKey = [double]$dt.sort_key } if ($dt.PSObject.Properties['engagements']) { $ntr.Engagements = [int]$dt.engagements } if ($dt.PSObject.Properties['challenges']) { $ntr.Challenges = [int]$dt.challenges } if ($dt.PSObject.Properties['held']) { $ntr.Held = [int]$dt.held } if ($dt.PSObject.Properties['weakened']) { $ntr.Weakened = [int]$dt.weakened } if ($dt.PSObject.Properties['last_tested']) { $ntr.LastTested = [string]$dt.last_tested } if ($dt.PSObject.Properties['revisions']) { foreach ($r in @($dt.revisions)) { if ($r.PSObject.Properties['held_since'] -and $null -ne $r.held_since) { $ntr.Refined = $true; break } } } if ($dt.PSObject.Properties['record']) { $camps = [System.Collections.Generic.List[string]]::new() foreach ($e in @($dt.record)) { if ($e.PSObject.Properties['strongest_attack_encountered'] -and $null -ne $e.strongest_attack_encountered -and $e.strongest_attack_encountered.PSObject.Properties['challenger_camp']) { $c = [string]$e.strongest_attack_encountered.challenger_camp if ($c -and -not $camps.Contains($c)) { $camps.Add($c) } } } $ntr.ChallengerCamps = $camps.ToArray() } # Stale: hash mismatch. Cosmetic-edit embedding exemption # deferred to a follow-up; false-positive stale on cosmetic # edits is documented, safer than silent negatives. if ($dt.PSObject.Properties['description_hash']) { $recorded = [string]$dt.description_hash if ($recorded) { # Compare current hash to recorded hash. An empty current # Description is a REAL drift (delete-into-empty), so we # deliberately do not guard on $n.Description here — # per CL review t/1579#4. $bytes = [System.Text.Encoding]::UTF8.GetBytes([string]$n.Description) $hashBytes = $sha256.ComputeHash($bytes) $current = 'sha256:' + (-join ($hashBytes | ForEach-Object { $_.ToString('x2') })) if ($current -ne $recorded) { $ntr.Stale = $true } } } } # Deficit-sort raw signals — capture per-node structural counts # here; normalize + weight after the loop so we can divide-by-max # across the whole batch (matches severeTestScheduler.ts semantics). # All property reads guarded by PSObject.Properties to survive both # the typed [TaxonomyNode] path and any synthetic PSCustomObject # tests hand in via a mocked Get-Tax. if ($SortBy -eq 'Deficit') { $childCount = 0 if ($n.PSObject.Properties['Children'] -and $n.Children) { $childCount = @($n.Children).Count } $sitCount = 0 if ($n.PSObject.Properties['SituationRefs'] -and $n.SituationRefs) { $sitCount = @($n.SituationRefs).Count } $confCount = 0 if ($n.PSObject.Properties['ConflictIds'] -and $n.ConflictIds) { $confCount = @($n.ConflictIds).Count } $polCount = 0 if ($ga -and $ga.PSObject.Properties['policy_actions'] -and $null -ne $ga.policy_actions) { $polCount = @($ga.policy_actions).Count } $usageCount = 0 if ($n.PSObject.Properties['DebateRefs'] -and $n.DebateRefs) { $usageCount = @($n.DebateRefs).Count } $doctrinal = 0.0 if ($n.PSObject.Properties['DoctrinallyAnchored'] -and $n.DoctrinallyAnchored) { $doctrinal = 1.0 } $cruxCount = 0 $nid = [string]$n.Id if ($cruxLinks.ContainsKey($nid)) { $cruxCount = [int]$cruxLinks[$nid] } $rawSignals[$nid] = [PSCustomObject]@{ Degree = $childCount + $sitCount + $confCount CruxDensity = $cruxCount PolicyLinkage = $polCount DoctrinalAnchor = $doctrinal Usage = $usageCount } } $results.Add($ntr) } # Second pass: normalize signals across the batch (divide-by-max, same # as severeTestScheduler.ts normalize()) and apply the t/1588 weights. if ($SortBy -eq 'Deficit') { $maxDegree = 0.0; $maxCrux = 0.0; $maxPolicy = 0.0; $maxUsage = 0.0 foreach ($nid in $rawSignals.Keys) { $s = $rawSignals[$nid] if ($s.Degree -gt $maxDegree) { $maxDegree = [double]$s.Degree } if ($s.CruxDensity -gt $maxCrux) { $maxCrux = [double]$s.CruxDensity } if ($s.PolicyLinkage -gt $maxPolicy) { $maxPolicy = [double]$s.PolicyLinkage } if ($s.Usage -gt $maxUsage) { $maxUsage = [double]$s.Usage } } # Match TS Math.max(...values, 1) — avoids divide-by-zero when # every raw signal is 0. Doctrinal is already 0/1 so no norm. $maxDegree = [Math]::Max($maxDegree, 1) $maxCrux = [Math]::Max($maxCrux, 1) $maxPolicy = [Math]::Max($maxPolicy, 1) $maxUsage = [Math]::Max($maxUsage, 1) $hasAnyImportanceSignal = $false foreach ($ntr in $results) { $s = $rawSignals[$ntr.NodeId] $normDegree = [double]$s.Degree / $maxDegree $normCrux = [double]$s.CruxDensity / $maxCrux $normPolicy = [double]$s.PolicyLinkage / $maxPolicy $normUsage = [double]$s.Usage / $maxUsage $normDoc = [double]$s.DoctrinalAnchor # already 0/1 $imp = (0.25 * $normDegree) + (0.15 * $normCrux) + (0.25 * $normPolicy) + (0.20 * $normDoc) + (0.15 * $normUsage) $effectiveTier = if ($ntr.Stale) { 'stale' } else { $ntr.Tier } $def = switch ($effectiveTier) { 'untested' { 1.0 } 'cited' { 0.7 } 'stale' { 0.6 } 'contested' { 0.4 } 'well_tested' { 0.1 } default { 0.0 } } $ntr.Importance = $imp $ntr.Deficit = $def $ntr.TestingPriority = $imp * $def if ($imp -gt 0) { $hasAnyImportanceSignal = $true } } } # Filters (post-projection so tier/stale bits are populated) $filtered = $results if ($Tier) { $filtered = [System.Collections.Generic.List[NodeTestingRecord]]::new() foreach ($x in $results) { if ($x.Tier -eq $Tier) { $filtered.Add($x) } } } if ($Stale) { $staleOnly = [System.Collections.Generic.List[NodeTestingRecord]]::new() foreach ($x in $filtered) { if ($x.Stale) { $staleOnly.Add($x) } } $filtered = $staleOnly } # Sort $sorted = if ($SortBy -eq 'Deficit') { @($filtered | Sort-Object -Property TestingPriority -Descending) } else { @($filtered | Sort-Object -Property SortKey -Descending) } if ($SortBy -eq 'Deficit' -and -not $hasAnyImportanceSignal) { Write-Warning "Get-NodeTestingRecord -SortBy Deficit: no node in the result set carries any of the five importance signals (degree = children+situation_refs+conflict_ids, crux_density from aggregated-cruxes.json, policy_linkage from graph_attributes.policy_actions, doctrinal_anchor, usage = debate_refs). TestingPriority = 0 for every node — this ordering reflects only deficit, not importance." } if ($Top) { $sorted = @($sorted | Select-Object -First $Top) } # t/1588 Phase A — optional JSON emit for the TS severeTestScheduler. # camelCase field names match the TS NodeTestingRecord interface at # lib/debate/debateTested.ts. Written AFTER filters/sort/top so the # TS consumer receives the same set the pipeline emits. if ($OutputPath) { $jsonRecords = foreach ($x in $sorted) { [ordered]@{ nodeId = $x.NodeId pov = $x.Pov category = $x.Category label = $x.Label tier = $x.Tier sortKey = $x.SortKey engagements = $x.Engagements challenges = $x.Challenges held = $x.Held weakened = $x.Weakened lastTested = $x.LastTested refined = $x.Refined stale = $x.Stale challengerCamps = @($x.ChallengerCamps) importance = $x.Importance deficit = $x.Deficit testingPriority = $x.TestingPriority } } # -AsArray so a single-record result serializes as [{...}], not {...}. $json = @($jsonRecords) | ConvertTo-Json -Depth 6 -AsArray Set-Content -Path $OutputPath -Value $json -Encoding utf8NoBOM Write-Verbose "Get-NodeTestingRecord: wrote $(@($sorted).Count) records to $OutputPath" } $sorted } finally { $sha256.Dispose() } } |