Files
confidence-engine/docs/experiment-60b41.md
T

175 lines
11 KiB
Markdown

# 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:
```js
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id)
```
to:
```js
(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