Files
confidence-engine/docs/archive/experiments/decision-closure-integration/experiment-60b41.md
T

11 KiB

Experiment 60B.41 — Does selectActiveUnknownCandidate need its own known-status guard?

Date: 2026-08-14 Branch: feature/known-target-exclusion-v0.36 Objective: Determine whether selectActiveUnknownCandidate must independently exclude status = known (and other terminal states) for the 60B.37 closure path to be correct and for fallback selection to remain semantically sound.


Checkpoint 1 — Exact selector contract

function: selectActiveUnknownCandidate(graph, resolvedNodeIds)
location: lib/graph/utils.js:593-642
candidate source: graph.nodes (all nodes in the graph)

kind filter: node.kind === "unknown"
status filter: NONE — zero status filtering. The inline filter is:
  (n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id)
resolvedNodeIds filter: !resolvedNodeIds.includes(n.id)

other eligibility filter: none — purely kind + resolvedNodeIds
scoring happens after filtering: YES — scoreUnknownCandidate runs on the already-filtered unresolved set at line 603

Scoring function analysis (scoreUnknownCandidate, utils.js:332-361):

  • collectNodeText(node) — node label/description text only
  • classifyUnknownPriority(text) — keyword classification on text
  • findDependentNodes(graph, node.id).length — downstream edge count
  • countIncomingUnknownDependencies(graph, node.id, resolvedNodeIds) — upstream dep count

None of these inspect node.status. A node's status field is completely invisible to scoring.

Can status=known enter scoring: YES
Can status=resolved enter scoring if absent from resolvedNodeIds: YES (theoretically possible via a bug in caller, but practically blocked by caller passing the correct resolvedNodeIds)
Can status=contradicted enter scoring if absent from resolvedNodeIds: YES (same theoretical possibility as resolved)

Checkpoint 2 — 60B.37 fallback reconstruction

Scenario: n_product_launch_decision becomes terminal (status=known) during the same mutation turn. No other genuine unresolved unknown remains in the graph. The post-mutation guard correctly discards it as proposal target, then the fallback path runs:

selectActiveUnknownCandidate(updatedSituationGraph, resolvedNodeIds)

Candidates seen:

  • All kind === "unknown" nodes that are NOT in resolvedNodeIds
  • n_product_launch_decision has kind === "unknown" and is NOT in resolvedNodeIds (known-status nodes use updatedNodes.newStatus, not resolvedUnknownNodeIds)
  • Therefore n_product_launch_decision appears as the sole candidate

Would n_product_launch_decision still qualify: YES — passes both filters: kind="unknown" ✓, not in resolvedNodeIds ✓

Would it be returned: YES — with no other candidates to compete against, it scores highest by default (only candidate). Without terminal-status filtering, its status is invisible to scoring and classification.

Would final selectedQuestion become non-null again: YES — newActiveUnknownNodeId would be set to the known node's ID at line 3716-3719, and this would propagate through deterministicSelection → finalSelectedQuestion → result.selectedQuestion, recreating the 60B.37 stale-target bug exactly.


Checkpoint 3 — Genuine fallback case

Existing test/case: reproduce-multi-turn-investigation.harness.test.js:1388 (pre-anchored product-launch customer-signing fixture)

  • Nodes: n_product_launch_decision (kind=unknown, status=unknown), n_enterprise_customer_signing (kind=unknown, status=unknown)
  • This is a genuine two-candidate scenario

Remaining unresolved candidate: n_enterprise_customer_signing (status=unknown, kind=unknown, not resolved)

Would known-status exclusion affect it: NO — this node has status === "unknown", so adding terminal-status filtering to the selector would still let it pass all filters. Its scoring is identical because status doesn't enter scoring logic.

Would prerequisite-first ordering change: NO — prerequisite blocking depends on hasUnresolvedSameProposalDependsOnPrerequisite (apply-proposal.js:2209) which checks node.kind membership in proposal.dependsOn. This is independent of node status. No known-status exclusion could alter prerequisite-first ordering because it operates at the kind+resolved boundary, not the prerequisite boundary.


Checkpoint 4 — Duplicated eligibility logic

Choice: PARTIAL — OVERLAPPING BUT DIFFERENT CONTRACTS

Why: isSelectableUnresolvedUnknown and selectActiveUnknownCandidate share the same intent (find unresolved unknown nodes) but differ in their terminal-state handling: the predicate excludes ["resolved", "contradicted"] while the selector has zero status filtering. However, they also serve different operational contexts — the predicate validates a single node ID by reference (used for preservation checks), while the selector enumerates and ranks all candidates from the graph. listUnresolvedUnknownCandidates shares the predicate's exclusion list. carriedActiveUnknownStillUnresolved mirrors the predicate's pattern inline. None of these functions treat "known" as terminal, creating a systematic gap across all five locations rather than a pure duplication.


Checkpoint 5 — Canonical rule placement

Candidate A — PATCH SELECTOR ONLY

Add terminal-status exclusion directly inside selectActiveUnknownCandidate.

60B.37 safe: YES — The fallback candidate would exclude known/resolved/contradicted, preventing stale target re-selection.
Can return terminal nodes elsewhere: YES — isSelectableUnresolvedUnknown (line 1663), remainingUnknownExists inline (line 3710-3711), listUnresolvedUnknownCandidates (line 1677), carriedActiveUnknownStillUnresolved (line 3850) all have the same gap.
Preserves existing scoring: YES — status filtering is applied before scoring; nodes that already pass kind+resolved filters retain their scores unchanged. Adding one more filter cannot change relative ordering.
Semantic-drift risk: MEDIUM — fixes only one of five locations; other gaps remain silently active.
Implementation scope: SMALL — one line change inside the existing filter at utils.js:596.

Candidate B — REUSE CANONICAL PREDICATE

Make selector candidate eligibility equivalent to isSelectableUnresolvedUnknown or a shared helper with the same terminal-state semantics.

60B.37 safe: YES — Same correctness as Candidate A, but also fixes Sources D, E, F, G identified in 60B.40.
Can return terminal nodes elsewhere: NO — all five locations converge on the same canonical rule.
Preserves existing scoring: YES — filtering scope expands uniformly; no node's relative score changes.
Semantic-drift risk: LOW — eliminates the systematic gap across all paths, establishing a single source of truth for unresolved unknown eligibility.
Implementation scope: MEDIUM — requires changes to utils.js (selector) AND apply-proposal.js (remainingUnknownExists, carriedActiveUnknownStillUnresolved, listUnresolvedUnknownCandidates), plus updating isSelectableUnresolvedUnknown to include "known".

Candidate C — LEAVE SELECTOR UNCHANGED

Rely on callers/eligible-candidate chains to protect it.

60B.37 safe: PARTIAL — Would work only if remainingUnknownExists at line 3710-3711 is also fixed AND no other code path reaches the selector with a known candidate in its filter set. But the 60B.40 analysis (Sources D, F, G) shows multiple inline checks also have the gap.
Can return terminal nodes elsewhere: YES — Sources B (isSelectableUnresolvedUnknown), F (selectPatternCompatibleUnknownCandidate), and G (listUnresolvedUnknownCandidates) all pass known-status through.
Preserves existing scoring: LIKELY — unchanged selector preserves current behavior; risk is in unguarded callers, not the selector itself.
Semantic-drift risk: HIGH — relies on fragile assumption that callers always provide correct filtered input. No defense-in-depth.
Implementation scope: SMALL (selector side) / LARGE (to actually fix — would require fixing all callers).

Critical distinction

Choice: D — SHARED ELIGIBILITY CONTRACT IS REQUIRED

Why: The gap (known not treated as terminal) exists across five independent locations with identical filtering logic. Fixing only one is a band-aid; the remaining four continue to silently accept known-status nodes as eligible unresolved unknowns. A shared predicate eliminates the systematic inconsistency at its root rather than treating each symptom individually.


Minimum implementation model

Choice: A — add terminal-status filter to selectActiveUnknownCandidate

Why: For the specific question of this experiment (does 60B.41 require a fix to the selector itself?), the answer is definitively YES. The selector MUST independently exclude terminal statuses because:

  1. It has zero status filtering today — the only filters are kind and resolvedNodeIds
  2. Known-status nodes bypass resolvedNodeIds (they use updatedNodes.newStatus, not resolvedUnknownNodeIds)
  3. No caller guarantees filtered input before reaching the selector
  4. Adding status !== "known" && status !== "resolved" && status !== "contradicted" to the filter prevents 60B.37 without affecting any genuine unresolved candidate

Would genuine unresolved fallback still work: YES — genuine unknown-status nodes pass all filters unchanged. Their scoring is identical (status doesn't enter scoring). Prerequisite-first ordering is unaffected.

Would prerequisite-first scoring remain unchanged: YES — filtering adds a gate before scoring, not during it. No node's score or rank changes; only the candidate set shrinks by removing terminal nodes that would have been invisible to scoring anyway.

Would valid closure proposals remain accepted: YES — validateSelectedQuestion (pre-mutation validation) is untouched. The filter only applies post-mutation selection. A customer-signing closure proposal that was valid pre-mutation still passes all filters post-mutation because the node's status hasn't changed.


Implementation readiness

Choice: A — READY FOR BOUNDED IMPLEMENTATION

The question is answered definitively. The selector must add terminal-status filtering. One unresolved follow-on question remains for separate treatment: whether isSelectableUnresolvedUnknown and other predicate functions also need "known" added to their exclusion lists (they do, but that is a scope decision beyond 60B.41).

Smallest implementation boundary: Add status filter to selectActiveUnknownCandidate at utils.js:596. Change line 596 from:

(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id)

to:

(n) => n.kind === "unknown" && 
       !["known", "resolved", "contradicted"].includes(n.status) && 
       !resolvedNodeIds.includes(n.id)

Production code changed: NO Tests changed: NO Prompt changed: NO Schema changed: NO Ollama calls: 0 Live API calls: 0 Vitest run: NO