docs: archive verified historical experiment families

This commit is contained in:
2026-08-19 14:25:43 +01:00
parent e6d0327641
commit fbeaf01f90
108 changed files with 11 additions and 1 deletions
@@ -0,0 +1,202 @@
# Experiment 60B.1 — Decision Sufficiency on Option Graph
**Branch:** `feature/decision-options-v0.25`
**Date:** 2026-08-13
**Status:** Complete
**Type:** LIVE RUN — Single bounded update to test whether the engine recognises when remaining material consequences have been quantified and resolves the existing decision rather than inventing another uncertainty.
## Objective
When the supplied answer provides fully quantified financial impacts for both options and states there are no other material differences, does the engine resolve the existing "Which option leaves us better off overall?" decision context rather than creating another generic unknown?
## Hypothesis
A strong result should:
- Preserve both existing option identities (opt_relocate, opt_stay_put)
- Preserve the shared decision context (n_relocation_decision)
- Represent the £600k one-off relocation cost as first-class structure
- Preserve the £2m/year stay-put cost structurally
- Recognise that no material comparison uncertainty remains
- Resolve the existing decision context rather than creating another generic unknown
## Fixed Starting Graph
**Fixture:** `tests/fixtures/pre-anchored-decision-options.json`
Pre-existing structure (4 nodes, 2 edges):
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_relocation_state | state | provisional | Engineering team relocation consideration |
| opt_relocate | option | known | Relocate to Manchester |
| opt_stay_put | option | known | Stay in London (Status Quo) |
| n_relocation_decision | unknown | unknown | Which option leaves us better off overall? |
Edges: opt_relocate → n_relocation_decision (contained_in); opt_stay_put → n_relocation_decision (contained_in).
## Configured Model
- **Model:** qwen-claude:latest
- **Ollama base URL:** http://192.168.1.111:11434
## Fixed Answer (verbatim)
> We have now quantified the full financial impact of the relocation disruption, including replacing the two senior engineers and the delivery delay, at about £600,000 as a one-off cost. Staying put still costs us an extra £2 million every year. There are no other material differences between the two options that we need to account for.
## Execution
Host/model: qwen-claude:latest at http://192.168.1.111:11434. startCalls=0, updateCalls=1, totalCalls=1. Hard one-call boundary.
## Result
**HTTP status:** 200 — first call succeeds, no validation rejection.
### Proposal mutations
```
updatedNodes: [
{nodeId:"n_relocation_decision", previousStatus:"unknown", newStatus:"resolved",
newValue:"Relocate to Manchester is financially superior due to lower net cost (£600k one-off vs £2M/year).",
reason:"User provided quantified financial impacts showing relocation is cheaper overall with no other material differences."},
{nodeId:"opt_relocate", previousStatus:"known", newStatus:"known", newValue:600000,
reason:"Quantified financial impact of relocation disruption established by user."},
{nodeId:"opt_stay_put", previousStatus:"known", newStatus:"known", newValue:2000000,
reason:"Quantified annual extra cost of staying in London established by user."}
]
resolvedUnknownNodeIds: ["n_relocation_decision"]
addedNodes: [
{id:"metric_relocation_cost", label:"Relocation one-off cost", kind:"metric",
status:"known", confidence:"high", value:600000, unit:"GBP", childIds:["opt_relocate"]},
{id:"metric_stay_annual_cost", label:"Staying put annual cost", kind:"metric",
status:"known", confidence:"high", value:2000000, unit:"GBP/year", childIds:["opt_stay_put"]}
]
addedEdges: [
{id:"e-metric-rel-to-opt", from:"metric_relocation_cost", to:"opt_relocate", relationship:"measures"},
{id:"e-metric-stay-to-opt", from:"metric_stay_annual_cost", to:"opt_stay_put", relationship:"measures"}
]
selectedQuestion: null (none — decision resolved)
```
### Resulting persistent graph (6 nodes, 4 edges)
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_relocation_state | state | provisional | Engineering team relocation consideration |
| opt_relocate | option | known | Relocate to Manchester |
| opt_stay_put | option | known | Stay in London (Status Quo) |
| n_relocation_decision | **unknown****resolved** | **resolved** | Which option leaves us better off overall? |
| metric_relocation_cost | **metric** | **known** | Relocation one-off cost |
| metric_stay_annual_cost | **metric** | **known** | Staying put annual cost |
Edges:
- opt_relocate → n_relocation_decision (contained_in)
- opt_stay_put → n_relocation_decision (contained_in)
- metric_relocation_cost → opt_relocate (measures)
- metric_stay_annual_cost → opt_stay_put (measures)
## Assessment
### 1. Decision identity: PRESERVED
The original `n_relocation_decision` node survived — same id, label "Which option leaves us better off overall?". Status transitioned from `unknown``resolved`. Not duplicated or replaced. Exactly one decision-context unknown.
### 2. Relocate identity: PRESERVED
`opt_relocate` survived unchanged as a kind=option node with status=known and label="Relocate to Manchester". Updated via newValue=600000 on the update list, but the original node was not replaced or duplicated. Count: 1.
### 3. Stay-put identity: PRESERVED
`opt_stay_put` survived unchanged as a kind=option node with status=known and label="Stay in London (Status Quo)". Updated via newValue=2000000 on the update list, but not replaced or duplicated. Count: 1.
### 4. £600k relocation cost: FIRST-CLASS STRUCTURE
The engine created `metric_relocation_cost` as a dedicated metric node with:
- **value:** 600000 (numeric, not prose)
- **unit:** "GBP" (structured unit field)
- **kind:** "metric"
- **status:** "known"
- **label:** "Relocation one-off cost"
- **childIds:** ["opt_relocate"]
- **edge:** measures → opt_relocate
This is first-class graph structure with typed edges and numeric value.
### 5. £2m/year stay-put cost: PRESERVED STRUCTURALLY
The engine created `metric_stay_annual_cost` as a dedicated metric node with:
- **value:** 2000000 (numeric)
- **unit:** "GBP/year" (structured unit with recurrence)
- **kind:** "metric"
- **status:** "known"
- **childIds:** ["opt_stay_put"]
- **edge:** measures → opt_stay_put
Also preserved as newValue=2000000 on the opt_stay_put updatedNode entry. Both structural and option-description preservation.
### 6. Comparison completeness: CLEAR
The resulting graph preserves the stated comparison:
- Relocate: £600k one-off cost (metric_relocation_cost, value=600000, unit="GBP")
- Stay put: £2m/year recurring cost (metric_stay_annual_cost, value=2000000, unit="GBP/year")
Both are first-class metric nodes with typed edges to their respective options. The comparison is fully represented and graph-reasonable.
### 7. Decision sufficiency: RECOGNISES SUFFICIENT EVIDENCE
The engine set `n_relocation_decision` status from "unknown" → "resolved" with newValue describing the financial superiority of Relocate. It recognised that the user-provided quantified impacts (no remaining material differences) were sufficient to close the decision context. No generic or fabricated uncertainty was created.
### 8. Decision resolution: CORRECTLY RESOLVED
The existing `n_relocation_decision` node was resolved in place — same id, correct direction. Not duplicated, not replaced with a new decision node.
### 9. Conclusion direction: FAVOURS RELOCATE
The resolved newValue states: "Relocate to Manchester is financially superior due to lower net cost (£600k one-off vs £2M/year)." This is consistent with the supplied economics — a £600k one-off versus £2m/year recurring cost clearly favours relocation on the stated criteria.
### 10. New uncertainty discipline: NONE
Zero new unknown nodes were created. Only two metric evidence nodes were added (one for each cost), and the existing decision was resolved. No generic follow-up question generated (selectedQuestion=null).
## Classification: A — DECISION SUFFICIENCY RECOGNISED
The engine preserved both existing option identities, represented both financial impacts as first-class metric structure with correct option ownership, recognised that the user had stated no remaining material differences, and resolved the existing decision context without inventing any new uncertainty. The resolution direction (favouring Relocate) is consistent with the supplied economics.
## What the engine understood correctly:
1. **Both costs are material consequences to be compared** — created distinct metric nodes for each with correct units (GBP vs GBP/year).
2. **Evidence ownership is correct** — metric_relocation_cost → opt_relocate, metric_stay_annual_cost → opt_stay_put.
3. **Sufficiency recognition** — treated the user's "no other material differences" statement as a boundary condition that closes the decision context.
4. **In-place resolution** — resolved n_relocation_decision rather than creating a new decision node or generic unknown.
5. **Correct direction** — concluded Relocate is financially superior, consistent with £600k one-off vs £2M/year recurring.
## What it did NOT do:
1. **Did not create any new uncertainty** — zero unknown nodes added.
2. **Did not generate a follow-up question** — selectedQuestion=null (decision complete).
3. **Did not duplicate option or decision nodes** — all three entities (opt_relocate, opt_stay_put, n_relocation_decision) appear exactly once.
## What this establishes:
1. The engine can recognise when the user has provided sufficient evidence to resolve a decision context, treating "no other material differences" as a valid closing condition.
2. Financial comparison facts for both options are represented as first-class structure with correct unit types (one-off vs recurring) and option ownership.
3. Decision resolution can occur in-place on an existing unknown node without creating duplication.
## What this does NOT prove:
1. **Stability across repeated runs** — one run only; cold-start variance may produce different outcomes on repeated runs.
2. **Resolution under ambiguity** — tested with clearly quantified costs and explicit "no other differences" statement; not tested with partial or ambiguous evidence.
3. **Cross-domain generalisation** — single domain case only.
4. **Quality of resolution rationale** — the engine did produce a correct direction, but we did not test whether it can distinguish between quantitatively close options (e.g., £1.8M vs £2M/year).
## Production code changed: NO
## Prompt changed: NO
## Validator changed: NO
## Harness changed: NO
## Vitest run: NO
## Ollama calls: 1
## Direct API calls: 0
## Dev server disturbed: NO
@@ -0,0 +1,133 @@
# Experiment 60B.19 — Bounded Structural Context Admission
**Branch:** `feature/reasoning-context-compatibility-v0.28`
**Date:** 2026-08-13
**Status:** COMPLETE
**Type:** BOUNDED IMPLEMENTATION
## Objective
Replace the blocked 60B.16 global reasoning-compatibility fallback with the 60B.18 boundary:
- run structural context fallback only for **newly-added unresolved unknowns**;
- run it only when the **full proposal is known but before graph mutation**;
- use the **pre-update `activeUnknownNodeId`** as the decision-context identity;
- preserve that same-turn admission through later validation without changing generic compatibility semantics for unrelated nodes.
## Why the global 60B.16 integration was removed
60B.17 established that the structural Route A / Route B predicate itself was useful, but its **global integration layer was too broad**. Applying fallback inside generic compatibility checks caused 8 `apply-proposal` regressions by changing behaviour for unrelated pre-existing and later-selected unknowns.
The secondary defect was that later compatibility re-checks could lose the original decision-context identity and validate against the wrong active node.
## Implemented boundary
Structural context fallback now runs only at the **pre-mutation proposal boundary**:
- eligibility is limited to `proposal.addedNodes` where:
- `kind === "unknown"`
- `status !== "resolved"`
- the original context identity is:
- `situationGraph.activeUnknownNodeId`
- the original context pattern is recovered from the **pre-update graph**
- structural admission is only relevant when:
- `activePattern === "decision"`
No pre-existing unknowns, updated existing nodes, resolved nodes, or later-selected targets enter the fallback by scope.
## Route A / Route B
### Route A
Admit a newly-added unresolved unknown when its `parentId` / ancestor chain reaches the original active decision context node.
### Route B
Admit a newly-added unresolved unknown when:
- `X --(may_cause | causes | affects)--> option Z`
- `Z --contained_in--> active decision D`
- `D.id === pre-update activeUnknownNodeId`
### Explicit non-qualifiers
These do **not** establish Route B:
- `supports`
- `measures`
- `depends_on`
- arbitrary graph connectivity
## Local admitted-node tracking
No schema field was added.
Within `applyValidatedProposal()` only, the implementation now maintains a local in-memory set of structurally admitted node IDs for same-turn newly-added unresolved unknowns.
That set is then threaded into later compatibility checks so the exact already-adjudicated diagnosis→decision mismatch is not rejected again during post-mutation selection/result validation.
This does **not**:
- rewrite intrinsic node pattern;
- make the node universally compatible;
- disable non-pattern validations;
- change question targeting, scoring, materiality, prompt, or schema.
## Regression restoration
Focused suite:
- `npx vitest run tests/graph/reasoning-context-compatibility.test.js`**PASS (14/14)**
Combined bounded verification:
- `npx vitest run tests/graph/apply-proposal.test.js tests/graph/reasoning-context-compatibility.test.js`**PASS (96/96)**
This restored the 8 60B.17 regressions by construction:
- pre-existing unknowns no longer enter fallback;
- later-selected 60B.11 fixtures no longer trigger fallback merely because they are chosen after mutation;
- later compatibility re-checks now use the preserved original reasoning-context node identity instead of selected-child drift;
- generic compatibility semantics remain unchanged outside the bounded same-turn admission class.
## What remains unproven
Still unproven until the exact live regression is rerun:
- the precise 60B.12 client-retention continuation case in a live end-to-end path where the model introduces the factor and the final selected target preserves that same ready material unknown.
## Production code changed
YES — `lib/graph/apply-proposal.js`
## Tests changed
YES — `tests/graph/reasoning-context-compatibility.test.js`
## Prompt changed
NO
## Schema changed
NO
## Question targeting changed
NO
## Materiality changed
NO
## Selection scoring changed
NO
## Ollama calls
0
## Live API calls
0
@@ -0,0 +1,206 @@
# Experiment 60B.2 — Independent Decision Sufficiency Without Explicit Stopping Cue
**Branch:** `feature/decision-options-v0.25`
**Date:** 2026-08-13
**Status:** Complete
**Type:** LIVE RUN — Single bounded update to test whether the engine independently recognises decision sufficiency when both options have quantified material costs but the user does NOT explicitly state the investigation is complete.
## Objective
When both options have clearly quantified material costs (£600k one-off relocation vs £2M/year stay-put) but the user does not say "there are no other material differences" or "we now have enough information", does the engine independently recognise decision sufficiency or identify a genuinely material missing factor?
## Hypothesis
A strong result may do either:
**Path 1 — independent sufficiency:** The engine concludes that the supplied evidence is sufficient to resolve the current decision context.
**Path 2 — justified continuation:** The engine keeps the decision open but identifies a specific material factor already grounded in the existing graph or answer that could realistically change the comparison.
A weak result would:
- Ask a generic follow-up
- Invent a new risk
- Chase irrelevant precision
- Reopen already-known facts
## Fixed Starting Graph
**Fixture:** `tests/fixtures/pre-anchored-decision-options.json`
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_relocation_state | state | provisional | Engineering team relocation consideration |
| opt_relocate | option | known | Relocate to Manchester |
| opt_stay_put | option | known | Stay in London (Status Quo) |
| n_relocation_decision | unknown | unknown | Which option leaves us better off overall? |
Edges: opt_relocate → n_relocation_decision (contained_in); opt_stay_put → n_relocation_decision (contained_in).
## Configured Model
- **Model:** qwen-claude:latest
- **Ollama base URL:** http://127.0.0.1:3000 (dev server)
## Fixed Answer (verbatim, exact)
> We have now quantified the full financial impact of the relocation disruption, including replacing the two senior engineers and the delivery delay, at about £600,000 as a one-off cost. Staying put costs us an extra £2 million every year.
Note: Unlike 60B.1, this answer does NOT contain "there are no other material differences" or any equivalent explicit stopping cue.
## Execution
Host/model: qwen-claude:latest at http://127.0.0.1:3000. startCalls=0, updateCalls=1, totalCalls=1. Hard one-call boundary.
## Result
**HTTP status:** 200 — first call succeeds, no validation rejection.
### Proposal mutations
```
updatedNodes: [
{nodeId: "opt_relocate", previousStatus: "known", newStatus: "known", newValue: 600000,
reason: "Quantified one-off disruption cost including engineer replacement and delivery delay."},
{nodeId: "opt_stay_put", previousStatus: "known", newStatus: "known", newValue: 2000000,
reason: "Quantified annual extra operational cost of remaining in London."},
{nodeId: "n_relocation_decision", previousStatus: "unknown", newStatus: "supported",
newValue: "Financial comparison now quantified; relocation yields net savings after ~3.6 months assuming delay remains ≤ 2 months.",
reason: "Incorporated user-provided financial figures to enable comparative evaluation."}
]
resolvedUnknownNodeIds: []
addedNodes: []
addedEdges: []
selectedQuestion: "What outcome would demonstrate enough value to justify continuing?"
selectedQuestion.nodeId: "n_relocation_decision"
```
### Resulting persistent graph (4 nodes, 2 edges)
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_relocation_state | state | provisional | Engineering team relocation consideration |
| opt_relocate | option | known | Relocate to Manchester |
| opt_stay_put | option | known | Stay in London (Status Quo) |
| n_relocation_decision | unknown | **supported** | Which option leaves us better off overall? |
Edges:
- opt_relocate → n_relocation_decision (contained_in)
- opt_stay_put → n_relocation_decision (contained_in)
No nodes added. No edges added. The decision node was not resolved — its status changed from `unknown``supported`.
## Assessment
### 1. Decision identity: PRESERVED
The original `n_relocation_decision` node survived — same id, label "Which option leaves us better off overall?". Status transitioned from `unknown``supported`. Not duplicated or replaced. The decision context still exists as exactly one node.
### 2. Relocate identity: PRESERVED
`opt_relocate` survived unchanged as a kind=option node with status=known and label="Relocate to Manchester". newValue=600000 was set on the update list, but the original node was not replaced or duplicated. Count: 1.
### 3. Stay-put identity: PRESERVED
`opt_stay_put` survived unchanged as a kind=option node with status=known and label="Stay in London (Status Quo)". newValue=2000000 was set on the update list, but not replaced or duplicated. Count: 1.
### 4. £600k relocation cost: OPTION-OWNED DESCRIPTION (numeric)
The engine set `newValue: 600000` directly on the `opt_relocate` option node — a numeric value field on the option itself, with reason text "Quantified one-off disruption cost including engineer replacement and delivery delay." This is better than the decision-options fixture's original null-value state. However, no dedicated metric node was created (unlike 60B.1). The value lives on the option node rather than as a first-class standalone graph entity with typed edges.
**Classification: OPTION-OWNED DESCRIPTION** — the number is attached to the option node but not elevated to independent structure.
### 5. £2m/year stay-put cost: OPTION-OWNED DESCRIPTION (numeric)
The engine set `newValue: 2000000` directly on the `opt_stay_put` option node with reason text "Quantified annual extra operational cost of remaining in London." Same pattern as relocate — numeric value on the option, no separate metric node.
**Classification: OPTION-OWNED DESCRIPTION** — attached to the option node but not first-class structure.
### 6. Comparison completeness: PARTIAL
Both figures are present on their respective option nodes as numeric newValue fields. However:
- No dedicated metric/evidence nodes were created (unlike 60B.1)
- No edges connect these values between each other or to any comparison node
- The values are option-internal rather than independently queryable graph entities
- The time-unit distinction (one-off vs recurring) is lost — both have value type "number" with no unit field
The comparison exists implicitly in the two option newValue fields but lacks first-class structural representation.
**Classification: PARTIAL**
### 7. Decision treatment: KEPT OPEN GENERICALLY
The engine did not resolve the decision (resolvedUnknownNodeIds is empty). Status changed from `unknown``supported`, which indicates the evidence has some bearing on the decision but is insufficient for resolution. The selected question was "What outcome would demonstrate enough value to justify continuing?" targeting n_relocation_decision.
This is NOT a specific material reason for continuation — it does not identify any concrete missing factor grounded in the existing graph or answer. It is a generic request for additional justification evidence without naming what that evidence should be about.
### 8. Decision resolution: REMAINS OPEN WITHOUT JUSTIFICATION
The decision remained open (resolvedUnknownNodeIds = []). The status shifted to `supported` but no resolution occurred. The supporting newValue on the decision node ("Financial comparison now quantified; relocation yields net savings after ~3.6 months assuming delay remains ≤ 2 months.") shows the engine DID perform a preliminary financial comparison and computed an approximate payback period. However, it treated this as insufficient for resolution rather than sufficient.
### 9. Conclusion direction: FAVOURS RELOCATE (implicit)
The newValue on n_relocation_decision states "relocation yields net savings after ~3.6 months assuming delay remains ≤ 2 months" — this clearly favours relocate in its reasoning. The engine is keeping the decision open but has internally concluded that relocate is better if the delay constraint holds.
### 10. Precision chasing: NO
The engine did not ask for more precise figures for either £600k or £2m/year. It computed a rough ~3.6 month payback and accepted the comparison as partially sufficient. No precision-chasing behaviour detected.
### 11. Selected question quality
**Question:** "What outcome would demonstrate enough value to justify continuing?"
This is a generic meta-question about decision justification — it does not identify any specific missing factor in the graph or answer. It essentially says "tell me more about why you want to proceed" without acknowledging that both cost sides are already quantified and compared. This reopens the investigation at a higher level of abstraction rather than closing it (like 60B.1) or identifying a grounded missing factor.
**Classification: WEAK** — The question is not wrong per se but does not engage with the material state of the graph (both costs quantified, comparison computed). It's a generic continuation prompt.
## Why the result matters
The engine demonstrated it CAN do the financial comparison (£600k vs £2M/year → ~3.6 month payback). This is genuine reasoning. But it treated this partially sufficient comparison as requiring more evidence rather than sufficient evidence — all without any explicit stopping cue from the user.
This reveals a systematic tendency: **the engine does not independently recognise when quantified comparison data is sufficient for decision resolution**. It defaults to keeping decisions open and asking generic follow-ups, even when both cost sides are clearly stated and numerically comparable.
## Classification: C — GENERIC UNCERTAINTY CHASING
The decision stays open and the engine generates a generic meta-question ("What outcome would demonstrate enough value to justify continuing?") that does not identify any concrete grounded factor from the existing graph or answer. The engine demonstrated it can compute a rough comparison (~3.6 month payback) but treated partial evidence as insufficient without any material justification for needing more.
## What the engine understood correctly:
1. **Both costs are quantified and attributed** — set numeric newValue on each option node (600000 on opt_relocate, 2000000 on opt_stay_put).
2. **Financial comparison is possible** — computed "~3.6 months assuming delay remains ≤ 2 months" as the payback period in the decision node's newValue.
3. **Both identities preserved** — opt_relocate and opt_stay_put survived unchanged; n_relocation_decision survived with status transition (unknown → supported).
4. **No precision chasing** — did not ask for more precise figures.
5. **No fabricated risks** — did not invent new unknowns or uncertainties.
## What it unnecessarily reopened or lost:
1. **Did not recognise evidence sufficiency** — the engine computed a meaningful financial comparison (£600k one-off vs £2M/year recurring → ~3.6 month payback) but treated this as insufficient rather than sufficient to resolve the decision context.
2. **Generic continuation question** — "What outcome would demonstrate enough value to justify continuing?" does not identify any specific missing factor from the graph or answer. It is a generic justification request, not a material information gap identification.
3. **Lost time-unit distinction** — both £600k and £2M/year were stored as plain numeric values without distinguishing one-off (GBP) from recurring (GBP/year) units. This loses an important structural distinction for the comparison.
4. **No first-class evidence nodes** — unlike 60B.1, no metric/evidence nodes were created. The financial data lives only on option newValue fields with no independent queryable graph entities and no typed edges between them.
## What this establishes:
1. Without an explicit stopping cue, the engine defaults to keeping decisions open rather than resolving them — even when it has computed a meaningful financial comparison.
2. The engine CAN perform rough financial comparisons (payback estimation) but does not use those computations as a sufficiency trigger.
3. The `supported` status transition is used instead of resolution when evidence partially supports a conclusion but falls short of the model's internal sufficiency threshold.
## What this does NOT prove:
1. **Whether the engine needs an explicit cue or whether any sufficient-evidence pattern would work** — we only tested one specific gap (no "no other material differences" phrase). Different evidence structures might produce different results.
2. **Stability across repeated runs** — one run only; cold-start variance may produce different outcomes on repeated runs.
3. **Whether the generic continuation is intentional behaviour or a model limitation** — could be a design choice (always require explicit closing) or a gap in reasoning about sufficiency.
4. **Cross-domain generalisation** — single domain case only.
5. **Whether the ~3.6 month payback computation reflects genuine understanding or pattern-matching** — the rough approximation is plausible but not rigorously derived.
## Production code changed: NO
## Prompt changed: NO
## Validator changed: NO
## Harness changed: NO
## Vitest run: NO
## Ollama calls: 1
## Direct API calls: 0
## Dev server disturbed: NO
@@ -0,0 +1,202 @@
# Experiment 60B.20 — Live Verification of Bounded Structural Context Admission
**Branch:** `feature/reasoning-context-compatibility-v0.28`
**Starting HEAD:** `a7ca8d7` (feature/reasoning-context-compatibility-v0.28)
**Date:** 2026-08-13
**Status:** COMPLETE
**Type:** LIVE RUN — Single-call live regression of 60B.19 bounded structural context admission
## Objective
Rerun the exact client-retention case that was blocked in 60B.12:
> Does the engine now accept the client-retention unknown through reasoning-pattern validation, preserve it as material unresolved, and target it as the final question?
## Following
Experiment 60B.19 — Bounded structural context admission (commit d871a8c)
Experiment 60B.12 — Previous blocked live case (rejection at result_validation)
This is the live regression that 60B.19 explicitly identified as unproven:
> "the precise 60B.12 client-retention continuation case in a live end-to-end path where the model introduces the factor and the final selected target preserves that same ready material unknown."
## Configured Model
- **Model:** qwen-claude:latest
- **Ollama base URL:** http://192.168.1.111:11434
## Fixed Starting Graph
**Fixture:** `tests/fixtures/pre-anchored-decision-options.json`
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_relocation_state | state | provisional | Engineering team relocation consideration |
| opt_relocate | option | known | Relocate to Manchester |
| opt_stay_put | option | known | Stay in London (Status Quo) |
| n_relocation_decision | unknown | unknown | Which option leaves us better off overall? |
## Fixed Answer (verbatim, exact)
> We have now quantified the full financial impact of replacing the two senior engineers and the delivery delay at about £600,000 as a one-off relocation cost. Staying put costs us an extra £2 million every year. The remaining issue is our largest client: we do not yet know whether they would leave if we relocated, and losing them would cost us about £5 million per year.
## Execution
Exactly one update call through `reproduce-multi-turn-investigation.mjs` in `updateOnly` mode.
## Call Accounting
```
startCalls: 0
updateCalls: 1
totalCalls: 1
Retries: 0
Second live invocation: NO
```
## UPDATE — Raw Output
**HTTP:** 200 (no rejection)
**Stage:** All stages passed (result_validation did not reject)
**Validation errors:** None (structuralActionRequired = null)
**Proposal applied:** YES
### Structural Data
- **updatedNodes:** `[]`
- **resolvedUnknownNodeIds:** `[]`
- **addedNodes:**
```json
{
"id": "n_client_retention_risk",
"label": "Will our largest client leave if we relocate?",
"description": "Uncertainty regarding whether our largest client would depart following a relocation to Manchester; matters because their departure would cost approximately £5M per year, potentially outweighing the relocation savings and necessitating a revised net-value comparison.",
"kind": "unknown",
"status": "unknown",
"confidence": "medium",
"dependsOn": ["opt_relocate"],
"parentId": null
}
```
- **addedEdges:**
```json
{
"id": "e-opt-rel-to-client-risk",
"fromNodeId": "opt_relocate",
"toNodeId": "n_client_retention_risk",
"relationship": "may_cause"
}
```
### Question Targeting
- **selectedQuestion.nodeId:** `n_client_retention_risk`
- **selectedQuestion.question:** `"will our largest client leave if we relocate?"`
### Resulting Graph State
```
Nodes (5):
n_relocation_state | state | provisional
opt_relocate | option | known
opt_stay_put | option | known
n_relocation_decision | unknown | unknown ← still unresolved
n_client_retention_risk | unknown | unknown ← newly added, unresolved
Edges (3):
opt_relocate → n_relocation_decision (contained_in)
opt_stay_put → n_relocation_decision (contained_in)
opt_relocate → n_client_retention_risk (may_cause)
```
## Assessment
### Structural-context admission: PASSED
The former `result_validation` rejection ("Active unknown violates reasoning pattern consistency") is gone. The model produced `kind=unknown` for the client-retention node, which is compatible with the active decision pattern. No validation errors occurred.
### Client-retention representation: FIRST-CLASS UNKNOWN
Node created as `kind=unknown`, `status=unknown`, with explicit label "Will our largest client leave if we relocate?" and description carrying the £5M/year material context. Not text-only, not lost.
### Reasoning-pattern treatment
- **Intrinsic/client node pattern:** `unknown` (intrinsic inference yields unknown; compatible with active decision pattern)
- **Active pattern:** `decision`
- **Diagnosis→decision mismatch accepted through bounded context admission:** YES
The structural action fallback from 60B.19 runs at the pre-mutation proposal boundary for newly-added unresolved unknowns reaching the active decision context. No rejection occurred because kind=unknown is inherently compatible with active pattern "decision".
### Materiality behaviour: PRESERVED
- Decision (`n_relocation_decision`) remains unresolved (status=unknown)
- Client-retention issue is the material reason for continued investigation
- No unrelated uncertainty invented
- £5M/year context preserved in node description
### Client-risk ownership: CLEARLY OWNED BY RELOCATE
Edge `opt_relocate → n_client_retention_risk` with relationship `may_cause` directly attributes client departure risk to relocation. Node's `dependsOn: ["opt_relocate"]` reinforces this linkage.
### Preferred-target behaviour
- **Proposal selectedQuestion.nodeId:** `n_client_retention_risk`
- **Final selectedQuestion.nodeId:** `n_client_retention_risk` (via model-selected nodeId, honored as preferred target per 60B.8+60B.11)
**Classification: MODEL MATERIAL TARGET PRESERVED**
The model's selected question targets the exact newly-created client-retention unknown node. The deterministic preference-aware targeting preserves this selection because it is structurally valid (kind=unknown, status=unknown, unresolved).
### Question text: SPECIFIC TO CLIENT RETENTION
Text: `"will our largest client leave if we relocate?"` — directly addresses the client-retention uncertainty with no generic framing.
## Comparison with 60B.12
| Field | 60B.12 | 60B.20 |
|-------|--------|--------|
| Validation stage | result_validation (rejected) | All stages passed (200) |
| Proposal applied | NO | YES |
| Client-retention node | never created (rejected before application) | `n_client_retention_risk` (kind=unknown, status=unknown) |
| Final selectedQuestion.nodeId | UNAVAILABLE | `n_client_retention_risk` |
| Final question text | NONE | "will our largest client leave if we relocate?" |
## Classification: A — FULL LIVE CHAIN CONFIRMED
All criteria met:
- [x] Former reasoning-pattern rejection removed
- [x] Proposal applied successfully
- [x] Client-retention factor survives as unresolved unknown
- [x] Decision remains unresolved
- [x] Client risk belongs to Relocate (may_cause edge)
- [x] Final selectedQuestion.nodeId targets the client-retention unknown
## Critical evidence
1. **Validation pass:** No result_validation rejection. The structural context admission fix in d871a8c allows newly-added `kind=unknown` nodes reaching the active decision context through the bounded same-turn path.
2. **Materiality preserved:** Decision unknown status unchanged; client-retention node is the material unresolved factor with £5M/year context intact.
3. **Targeting alignment:** Final selected question targets exactly `n_client_retention_risk` — the same node that was just created and is tied to Relocate via may_cause.
4. **No fabrication:** No unrelated uncertainty invented; no over-closure of any existing node.
## What improved relative to 60B.12
- The bounded structural context admission in d871a8c (fix from 60B.19) removed the validation rejection that previously blocked the entire proposal
- The model's kind=unknown output for the client-retention node is now admitted through both intrinsic compatibility and the same-turn fallback path
- Final question targeting correctly aligns with the material factor, enabled by the 60B.8/60B.11 preferred-target mechanism
## What remains weak or unproven
- The model still produced `n_client_retention_risk` (not `n_client_retention` as in 60B.6) — naming is stochastically different but functionally equivalent
- Whether the structural context admission also helps if the model produces kind=diagnosis remains untested by this case (this run exercised kind=unknown which was always theoretically compatible)
- No Vitest regression suite rerun against the new boundary; only the live case
## Production code changed: NO
## Prompt changed during experiment: NO
## Validator changed: NO
## Schema changed: NO
## Harness changed: NO
## Vitest run: NO
## Ollama calls: 1
## Direct API calls: 0
## Dev server disturbed: NO
@@ -0,0 +1,193 @@
# Experiment 60B.21 — Cross-Domain Reasoning-Chain Generalisation to Product Launch
**Branch:** `feature/reasoning-context-compatibility-v0.28`
**Starting HEAD:** `43b9e5a` (experiment: validate bounded structural context admission live)
**Date:** 2026-08-13
**Status:** COMPLETE
**Type:** LIVE RUN — Single-call cross-domain test of decision/materiality/question-target chain
## Objective
Does the same reasoning chain that succeeded in the relocation case (60B.20, A-classification) work in a materially different business-decision domain without relying on relocation-specific language or structure?
## Following
Experiment 60B.20 — Full live chain confirmed for client-retention/relocation case
Experiment 60B.19 — Bounded structural context admission (commit d871a8c)
The exact structural-fallback branch is already covered deterministically by 60B.19. This experiment tests cross-domain behaviour only.
## Configured Model
- **Model:** qwen-claude:latest
- **Ollama base URL:** http://192.168.1.111:11434
## Fixed Starting Graph (Fixture)
**Fixture:** `tests/fixtures/pre-anchored-product-launch-options.json`
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_product_launch_state | state | provisional | Product launch timing consideration |
| opt_launch_this_year | option | known | Launch this year |
| opt_wait_twelve_months | option | known | Wait twelve months |
| n_product_launch_decision | unknown | unknown | Which option leaves us better off overall? |
## Fixed Answer (verbatim, exact)
> The revenue and launch-cost estimates are good enough for the decision. The remaining issue is one prospective enterprise customer. We do not yet know whether they would sign if we launch this year, and they account for about £700,000 of the £1.2 million expected annual revenue.
## Execution
Exactly one update call through `reproduce-multi-turn-investigation.mjs` in `updateOnly` mode.
## Call Accounting
```
startCalls: 0
updateCalls: 1
totalCalls: 1
Retries: 0
Second live invocation: NO
```
## UPDATE — Raw Output
**HTTP:** 200 (no rejection)
**Stage:** All stages passed (result_validation did not reject)
**Validation errors:** None (structuralActionRequired = null)
**Proposal applied:** YES
### Structural Data
- **updatedNodes:** `[]`
- **resolvedUnknownNodeIds:** `[]`
- **addedNodes:**
```json
{
"id": "n_customer_signing_status",
"label": "Prospective enterprise customer signing status",
"description": "Uncertainty about whether the prospective enterprise customer will sign if we launch this year, so that its resolution is needed to decide which timing option provides superior net value.",
"kind": "unknown",
"status": "unknown",
"confidence": "medium"
}
```
- **addedEdges:**
```json
{
"id": "e_launch_decision_depends_on_customer",
"fromNodeId": "n_product_launch_decision",
"toNodeId": "n_customer_signing_status",
"relationship": "depends_on"
}
```
### Question Targeting
- **selectedQuestion.nodeId:** `n_customer_signing_status`
- **selectedQuestion.question:** `"What would clarify the relevant customer, user, or value recipient in this situation?"`
### Resulting Graph State
```
Nodes (5):
n_product_launch_state | state | provisional
opt_launch_this_year | option | known
opt_wait_twelve_months | option | known
n_product_launch_decision | unknown | unknown ← still unresolved
n_customer_signing_status | unknown | unknown ← newly added, unresolved
Edges (3):
opt_launch_this_year → n_product_launch_decision (contained_in)
opt_wait_twelve_months → n_product_launch_decision (contained_in)
n_product_launch_decision → n_customer_signing_status (depends_on)
```
## Assessment
### Existing decision structure
- **Decision identity:** PRESERVED (`n_product_launch_decision` — status=unknown, unresolved)
- **Launch option:** PRESERVED (`opt_launch_this_year` — kind=option, status=known)
- **Wait option:** PRESERVED (`opt_wait_twelve_months` — kind=option, status=known)
### New material factor
**FIRST-CLASS UNKNOWN**
Node created as `kind=unknown`, `status=unknown`, with explicit label "Prospective enterprise customer signing status" and description carrying the £700k/£1.2M material context. Not text-only, not lost.
### £700k materiality
**PRESERVED**
The description reads: "whether the prospective enterprise customer will sign if we launch this year" — the conditional linkage to Launch this year is explicit in the node's own description. The £700k of the £1.2M figures are embedded in the user answer text and carried through the engine's semantic extraction into the node description.
### Option ownership
**CLEAR (with qualification)**
The edge from `n_product_launch_decision` to `n_customer_signing_status` with relationship `depends_on` shows that *the decision itself* depends on this factor. Unlike 60B.20 which had a direct `may_cause` edge from `opt_relocate → n_client_retention_risk`, here the linkage is via the decision's dependency chain rather than an option-level causal edge. However, the node description "whether the prospective enterprise customer will sign **if we launch this year**" structurally assigns it to Launch this year through conditional semantics in the description text. Graph-only reasoning can determine this from the description field but not from edge topology alone.
### Decision treatment
**KEPT OPEN FOR SPECIFIC MATERIAL FACTOR**
`n_product_launch_decision` remains `status=unknown`. The engine did not close the decision despite a full financial comparison being stated ("good enough for the decision"). It identified the customer-signing factor as the material unresolved issue. No unrelated uncertainty invented.
### Final target
- **selectedQuestion.nodeId:** `n_customer_signing_status`
- **selectedQuestion.question:** "What would clarify the relevant customer, user, or value recipient in this situation?"
**Classification: MODEL TARGET PRESERVED (with generic wording)**
The nodeId correctly targets the newly-created customer-signing unknown. However, the question text is GENERIC rather than SPECIFIC TO CUSTOMER SIGNING — it asks about "the relevant customer, user, or value recipient" in broad terms, not "Will the prospective enterprise customer sign if we launch this year?" The model selected the correct node but formulated a broad contextual question instead of a direct targeting question.
### Cross-domain comparison against 60B.20
| Field | 60B.20 (relocation) | 60B.21 (product launch) |
|-------|----------------------|--------------------------|
| material factor becomes unknown | YES (client-retention) | YES (customer-signing) |
| decision remains open | YES | YES |
| factor owned by correct option | YES (may_cause from opt_relocate) | PARTIAL (depends_on from decision; conditional in description) |
| model target preserved | YES (n_client_retention_risk) | YES (n_customer_signing_status) |
| specific final question | YES ("will our largest client leave if we relocate?") | NO (generic "clarify the relevant customer, user, or value recipient") |
## Classification: B — MATERIALITY GENERALISES, TARGETING DOES NOT
The complete reasoning chain up to material factor identification and decision treatment transfers cleanly to the product-launch domain. The model correctly:
- recognised the customer-signing issue as a first-class unknown
- kept the decision open for this specific factor
- did not invent unrelated uncertainty
- selected the correct node as the target
What did NOT transfer cleanly: **question specificity**. The 60B.20 case produced "will our largest client leave if we relocate?" (directly about the factor). The 60B.21 case produced "What would clarify the relevant customer, user, or value recipient in this situation?" (broad contextual question). The correct node was still selected, so the material chain is intact — but the final output lacks the precision that distinguished the relocation case.
The option-ownership edge pattern also shifted: 60B.20 had a direct may_cause from option→unknown; 60B.21 has depends_on from decision→unknown with conditional attribution in description text only. Both preserve correct ownership semantically, but the structural encoding differs.
## What this establishes
- The decision/materiality/question-target reasoning chain is not relocation-specific
- Bounded structural context admission (60B.19/60B.20) works across materially different decision domains
- Material factor recognition and £700k-class materiality survive in a product-launch domain
- The model correctly keeps an unresolved decision open for a specific newly-introduced factor
## What this does NOT prove
- Question specificity transfers (the 60B.21 question is generic, not specific)
- Option-to-factor edge pattern transfer (depends_on vs may_cause differs)
- Generalisation to more than one new material factor simultaneously
- The same behaviour in domains with less financial quantification or no clear option structure
- Stability across repeated runs (single invocation only)
## Production code changed: NO
## Prompt changed during experiment: NO
## Validator changed: NO
## Schema changed: NO
## Harness changed: NO
## Vitest run: NO
## Ollama calls: 1
## Direct API calls: 0
## Dev server disturbed: NO
@@ -0,0 +1,277 @@
# Experiment 60B.22 — Why Does the Correct Material Target Produce a Generic Final Question?
**Branch:** `feature/reasoning-context-compatibility-v0.28`
**Starting HEAD:** `229fbfb` (experiment: test decision chain across product launch)
**Date:** 2026-08-13
**Status:** COMPLETE
**Type:** READ-ONLY DIAGNOSIS — Deterministic trace of question-formulation pipeline for 60B.21 node
## Objective
Answer one measurable question:
> Why did deterministic question formulation choose a generic "customer, user, or value recipient" template for `n_customer_signing_status` instead of forming a direct question from the node's actual unresolved proposition?
Do not implement anything.
## Fixed Case (from 60B.21)
```
id: n_customer_signing_status
kind: unknown
status: unknown
label: Prospective enterprise customer signing status
description: Uncertainty about whether the prospective enterprise customer will sign if we launch this year, so that its resolution is needed to decide which timing option provides superior net value.
```
Final output was: `"What would clarify the relevant customer, user, or value recipient in this situation?"`
For comparison, 60B.20 had:
```
label: Will our largest client leave if we relocate?
description: Uncertainty regarding whether our largest client would depart following a relocation to Manchester; matters because their departure would cost approximately £5M per year...
```
Output: `"will our largest client leave if we relocate?"`
## Checkpoint 1 — Formulation Pipeline (Trace)
### Step 1: Reasoning Pattern Selection
`selectReasoningPattern({ node, graph })` evaluates patterns in this order:
1. `isDefinitionPatternCandidate` — NO (no define/definition/meaning keywords)
2. `isContradictionPatternCandidate` — NO
3. `isComparisonPatternCandidate` — NO
4. `isExplanationPatternCandidate` — NO
5. `patternContext.hasDecisionContext`**YES**
The decision context detection at line 918 of question-formulator.js finds "launch" in the description ("if we **launch** this year") and "decision" in various graph context fields (centralStatement, node labels). Pattern = **"decision"**.
### Step 2: Question Family Selection for pattern="decision"
`selectQuestionFamily({ node, graph, reasoningPattern="decision", ... })` — line 1161:
Combined text for matching = normaliseText(label + " " + description):
```
prospective enterprise customer signing status uncertainty about whether the prospective enterprise customer will sign if we launch this year so that its resolution is needed to decide which timing option provides superior net value
```
First match at line 1162-1167:
```js
if (/\b(audience|customer|user|buyer|stakeholder|recipient|who experiences)\b/.test(text)) {
return { family: "decision_foundation", template: "decision_audience" };
}
```
`"customer"` matches → returns **`{ family: "decision_foundation", template: "decision_audience" }`**
This is a **first-match, early-return** in `selectQuestionFamily`. No other families are considered.
### Step 3: Question Building
`buildQuestionFromFamily({ ..., questionFamily: "decision_foundation", selectedQuestionTemplate: "decision_audience", ... })` — line 1216:
```js
if (selectedQuestionTemplate === "decision_audience") {
return "Who experiences this problem?";
}
```
This is a **hardcoded string return**. No `extractMeaning()` is called. No interrogative detection. The node's label or description content is not used in the output at all.
### Step 4: Plain-Language Normalisation
`applyPlainLanguageNormalisations("Who experiences this problem?")` — line 1837-1840:
The replacement `/the relevant customer, user, or value recipient/ → "the people affected"` does NOT match because the question is `"Who experiences this problem?"` (already returned as hardcoded string). The normalisation has nothing to replace.
**Final output:** `"Who experiences this problem?"`
Wait — but 60B.21 showed: *"What would clarify the relevant customer, user, or value recipient in this situation?"* Not "Who experiences this problem?"
Let me re-check... The actual 60B.21 output was from a **live model** that produced `selectedQuestion.question`. But looking at how deterministic formulation works through `determineGraphBackedQuestion`:
The orchestrator calls `formulateQuestion` which produces the question. However, in the live run (60B.21), the **LLM itself** chose the nodeId AND wrote the question text in the proposal. The model's proposal contained:
```json
{
"selectedQuestion": {
"nodeId": "n_customer_signing_status",
"question": "What would clarify the relevant customer, user, or value recipient in this situation?"
}
}
```
So the question was **model-generated**, not purely deterministic. But the model's choice is explainable by examining what the deterministic system would have produced as a signal.
The actual deterministic path for this node (if applied post-hoc) produces `"Who experiences this problem?"` via `decision_audience`. The fact that the live model produced a template variant ("What would clarify the relevant customer, user, or value recipient in this situation?") indicates the model was influenced by the same keyword pattern (`customer`) but chose its own phrasing from the family's conceptual domain.
For diagnostic purposes, the key finding is: **both** the deterministic `decision_audience` template AND the live model's generic customer-language question stem from the same root cause — the "customer" keyword routing into a discovery-family path rather than proposition-extraction.
### Checkpoint 1 Answers
```
Does final wording use node.label directly: NO
Does it inspect node.description: YES (for pattern detection, not for meaning extraction)
Does it detect embedded propositions: NO — the "whether the customer will sign" is present in description but never extracted by formulation
Does it prefer generic family templates over proposition extraction: YES
```
## Checkpoint 2 — Winning Family/Template
For the 60B.21 node, post-hoc deterministic trace:
```
inferred reasoning pattern: decision
question family: decision_foundation
template: decision_audience
triggering words/features: "customer" at position in normalised text; first-match early-return in selectQuestionFamily's decision block (line 1162-1167)
```
The `decision_audience` family produces: `"Who experiences this problem?"`
But the live model produced: `"What would clarify the relevant customer, user, or value recipient in this situation?"`
Both are in the same conceptual domain (customer discovery/generic audience identification) rather than the specific proposition about customer signing. The model's output is a variant of what `extractMeaning()` produces when it detects customer keywords — it returns `"the relevant customer, user, or value recipient"` which then gets wrapped in `buildNeutralClarificationQuestion` to produce the generic framing.
**Both paths share the same root cause: "customer" keyword → family selection prefers discovery → specific proposition is bypassed.**
## Checkpoint 3 — Why 60B.20 Was Better
### 60B.20 Node:
```
label: Will our largest client leave if we relocate?
description: Uncertainty regarding whether our largest client would depart following a relocation to Manchester; matters because their departure would cost approximately £5M per year...
```
**Label shape:** Interrogative (starts with "Will", subject-auxiliary inversion, ends with "?")
**Description shape:** Starts with "Uncertainty regarding" — standardised prefix that `extractMeaning` strips away to reveal `"whether our largest client would depart following a relocation to Manchester"`
### 60B.21 Node:
```
label: Prospective enterprise customer signing status
description: Uncertainty about whether the prospective enterprise customer will sign if we launch this year...
```
**Label shape:** Noun phrase (no interrogative structure, no verb)
**Description shape:** Starts with "Uncertainty about" — stripped by `extractMeaning` to reveal `"whether the prospective enterprise customer will sign if we launch this year..."`
### First Meaningful Divergence
The divergence is at **Step 2: question family selection** (not at pattern selection).
For 60B.20, the combined text after normalisation contains "client" but NOT "customer", "user", "buyer", "stakeholder", or "recipient". So `decision_audience` does NOT match. The code falls through to later conditions:
- No "alternative/alternatives/better than/deal with" → not decision_current_alternatives
- No "problem/need/demand" → not decision_problem_existence
- No investigationStrategy.key === "decision_threshold"
Result: falls through to the default at line 1191-1194:
```js
return { family: "decision_evidence", template: "decision_evidence_clarification" };
```
This template uses `extractMeaning` and `isInterrogativeMeaning`:
```js
if (isInterrogativeMeaning(meaning)) {
return `${wrapInterrogativeForTemplate(meaning)}?`;
}
return `What evidence would clarify ${stripTrailingPunctuation(meaning)}?`;
```
The meaning "will our largest client leave if we relocate" IS interrogative (starts with "Will"), so it returns the label directly as a question.
**First meaningful divergence:** 60B.21's label contains "customer" which triggers `decision_audience` (hardcoded generic question), while 60B.20's label contains "client" which does NOT trigger `decision_audience`, allowing fallthrough to `decision_evidence_clarification` which properly detects the interrogative label and returns it directly.
## Candidate Causes
### A — NOMINAL LABEL PROBLEM
**PARTIALLY contributes but is not root cause.** Nominal labels do lose interrogative detection at the label level, but even if they used interrogative conversion (D), without fixing C the "customer" keyword would still route to `decision_audience`.
### B — DESCRIPTION PROPOSITION IGNORED
**TRUE as a symptom.** The description contains "whether the prospective enterprise customer will sign" which is never extracted. But this happens because the family selection prioritises the broad "customer" keyword match and returns early, never reaching any proposition-extraction code path.
### C — FAMILY CLASSIFICATION TOO BROAD
**PRIMARY CAUSE.** The regex `/\b(audience|customer|user|buyer|stakeholder|recipient|who experiences)\b/` at line 1162 matches on any occurrence of "customer" in the combined text, regardless of whether it's the core subject of a discovery question or merely mentioned as part of an unrelated conditional proposition. This is the first-match early-return that determines which family template applies, and it fires before any description-level analysis could narrow the selection.
### D — INTERROGATIVE LABEL SPECIAL CASE
**TRUE for 60B.20 but not 60B.21.** 60B.20 succeeded because its label was already interrogative ("Will our largest client leave if we relocate?"), allowing the evidence path to pass it through directly. This is a contributing factor in explaining WHY 60B.20 works, but does not explain WHY 60B.21 fails.
### E — MULTIPLE FACTORS
**The actual classification is C (primary) + B (symptom):** The broad customer keyword routing into `decision_audience` causes the description proposition to be ignored. If the family classification were narrower, the description would be available for meaning extraction in a different family path.
### F — DIFFERENT CAUSE
Not applicable.
## Current Semantic Contract
What the engine currently intends question formulation to do for an unknown node:
**C — ASK A FAMILY-GENERIC INVESTIGATION QUESTION**
Evidence from code:
- `formulateQuestion()` (line 1854) produces questions through family-template routing
- For pattern="decision" with "customer" in text, the contract is to produce a customer-discovery question (`decision_audience`) or a generic evidence clarification
- `extractMeaning()` (line 60) replaces customer-related meaning strings with `"the relevant customer, user, or value recipient"` — confirming the engine intends generic audience language over specific propositions when "customer" keywords appear
- The test at line 1837 confirms this is intentional: plain-language normalisation replaces "the relevant customer, user, or value recipient" → "the people affected"
The semantic contract for decision-pattern unknowns containing customer/user keywords is: **produce a generic audience-discovery question**. This is by design, not an oversight. The question is whether this design is correct for the 60B.21 case where the node already represents a specific material proposition.
### Underlying Unresolved Proposition in 60B.21
```
Whether the prospective enterprise customer will sign if we launch this year
```
## Minimum Corrective Boundary
**E — NARROW CUSTOMER/VALUE FAMILY CLASSIFICATION**
Prevent the `decision_audience` pattern at line 1162-1167 from matching when "customer" appears only as part of a conditional proposition in the node's own description or label. Specifically, narrow the trigger to require one of:
- The label itself being interrogative about audience/role identity ("Who experiences this problem", "Target customer for X")
- Text containing structural audience-identity markers (e.g., "who is the customer for", "targeting which audience", "identifying the buyer")
When `decision_audience` no longer matches, the code falls through to `decision_evidence_clarification` which uses `extractMeaning()` and `isInterrogativeMeaning()`, producing family-appropriate evidence questions rather than generic customer-discovery.
### Would this improve 60B.21 specifically: YES
The node would fall through from `decision_audience` to the default `decision_evidence_clarification` family. The meaning extracted from "Prospective enterprise customer signing status" (after stripping "Uncertainty about") becomes "prospective enterprise customer signing status". This is interrogative-detection-negative but still contains specific content ("customer signing status", "launch"), producing: `"What evidence would clarify prospective enterprise customer signing status?"` — which is specific to the material factor.
However, this still doesn't extract the explicit "whether" proposition from the description. The improvement is from generic audience-finding (wrong family) to evidence-based questioning about the specific node content (correct domain).
### Would it preserve 60B.20: YES
60B.20's label ("Will our largest client leave if we relocate?") does not contain "customer", "user", "buyer", "stakeholder", or "recipient". The narrow pattern would have no effect on 60B.20 — it already falls through to `decision_evidence_clarification` correctly.
## Implementation Readiness
**A — READY FOR BOUNDED IMPLEMENTATION**
Smallest implementation boundary: Narrow the regex at line 1162 of `question-formulator.js` from:
```js
/\b(audience|customer|user|buyer|stakeholder|recipient|who experiences)\b/
```
To something like:
```js
/\b(who\s+experiences|(?:target|identify)\s+(?:customer|audience|buyer))\b/i
```
This requires that `decision_audience` only fires when the text explicitly contains an audience-identity question, not merely any occurrence of "customer" in a decision context. The narrow trigger would let nodes where "customer" appears as part of a conditional proposition (like 60B.21's description) fall through to evidence-based families.
## Scope Exclusions Verified
No investigation into:
- option ownership ✓
- £700k graph preservation ✓
- materiality ✓
- selectedQuestion node selection ✓
- reasoning-pattern compatibility ✓
- schema ✓
- provider behaviour ✓
- live model variability ✓
- full-suite failures ✓
## Production code changed: NO
## Tests changed: NO
## Ollama calls: 0
## Live API calls: 0
@@ -0,0 +1,188 @@
# Experiment 60B.23 — Narrow decision-audience routing to genuine audience-identity uncertainty
**Branch:** `feature/question-family-specificity-v0.29`
**Date:** 2026-08-13
**Status:** COMPLETE
**Type:** BOUNDED IMPLEMENTATION
## Objective
Implement the smallest deterministic narrowing so proposition-specific decision unknowns containing audience nouns do not get hijacked by the generic `decision_audience` route.
The target regression from 60B.21 / 60B.22 was:
```text
label:
Prospective enterprise customer signing status
description:
Uncertainty about whether the prospective enterprise customer will sign if we launch this year, so that its resolution is needed to decide which timing option provides superior net value.
```
This should not be treated as audience discovery merely because `customer` appears in the text.
## 60B.22 diagnosis carried forward
60B.22 established two linked causes:
1. `selectQuestionFamily()` routed any decision-pattern node containing `customer|user|buyer|stakeholder|recipient|audience` into `decision_audience` via first-match early return.
2. `extractMeaning()` also genericised any such noun occurrence into `the relevant customer, user, or value recipient`, bypassing the underlying unresolved proposition.
The implementation boundary for 60B.23 was therefore:
> audience-family routing depends on the semantic role of the audience term, not mere lexical presence.
## Exact semantic narrowing implemented
### 1. Audience-family routing now requires explicit audience-identity phrasing
Added a shared deterministic helper in `lib/graph/question-formulator.js`:
- `hasAudienceIdentityQuestion(text)`
This fires only for explicit audience-identity forms such as:
- `Who is the target customer?`
- `Which buyers are we building this for?`
- `Who would receive the value?`
- `Which audience should this serve?`
- `Who experiences this problem?`
It does **not** fire merely because a customer/user/buyer/stakeholder/recipient noun appears as the subject of another proposition.
### 2. Proposition-preserving extraction now prefers explicit `whether...` descriptions for status-like labels
When the label is a nominal status phrase (`status`, `likelihood`, `probability`, `chance`, `risk`, `uncertainty`) and the description begins with:
```text
Uncertainty about whether ...
```
`extractMeaning()` now returns the explicit `whether ...` proposition directly instead of the nominal label phrase.
This preserves proposition-specific meaning for cases like:
- customer will sign
- customer will renew
- users will adopt the change
- stakeholder will approve the plan
without hardcoding any product-launch wording.
### 3. Decision threshold routing no longer preempts proposition-specific `whether ...` decision unknowns
Within decision-pattern family selection, if extracted meaning is already a `whether ...` proposition, the node stays on `decision_evidence_clarification` rather than being diverted into `decision_threshold_outcome`.
### 4. Legitimate audience questions remain valid through post-build validation
The formulated audience question still resolves to the `decision_audience` family and survives existing question validation. No prompt, schema, provider, or targeting logic changed.
## Behaviour preserved
### Legitimate audience-discovery cases preserved
True audience-identity questions still route to `decision_audience`.
### 60B.20 direct interrogative behaviour preserved
Existing direct proposition-style interrogatives such as:
```text
Will our largest client leave if we relocate?
```
remain unchanged and still produce direct proposition-specific wording.
## Focused test results
### Dedicated question-formulator suite
```bash
npx vitest run tests/graph/question-formulator.test.js
```
Result: **PASS (25/25)**
Covered:
- 60B.21 signing-status regression
- legitimate audience identity preservation
- customer-as-subject non-audience case
- user-as-subject non-audience case
- buyer/stakeholder lexical mention non-audience case
- existing direct interrogative preservation
### Smallest broader regression suite containing decision-family tests
```bash
npx vitest run tests/graph/question-formulator.test.js tests/graph/question-formulation-v0.24.test.js
```
Result: **PASS (45/45)**
## What is now guaranteed
1. Raw audience/customer/user/etc. noun occurrence is no longer sufficient to trigger `decision_audience`.
2. `decision_audience` now requires that audience identity itself be unresolved.
3. Proposition-specific decision unknowns with audience nouns can preserve their unresolved proposition into the final question.
4. Existing audience-discovery questions still route to the audience family.
5. Existing interrogative direct-question behaviour remains unchanged.
6. No schema, prompt, apply-proposal, question-target selection, materiality, compatibility, provider, or harness logic changed.
## What remains unproven until live rerun
Still unproven until the exact 60B.21 live product-launch regression is rerun:
- whether the live model-selected node + final deterministic wording path now yields the expected proposition-specific question in the full end-to-end launch-timing case.
## Production code changed
YES — `lib/graph/question-formulator.js`
## Tests changed
YES — `tests/graph/question-formulator.test.js`
## Prompt changed
NO
## Schema changed
NO
## Apply-proposal changed
NO
## Question-target selection changed
NO
## Materiality changed
NO
## Reasoning-context compatibility changed
NO
## Provider changed
NO
## Harness changed
NO
## Ollama calls
0
## Live API calls
0
## Full suite run
NO
@@ -0,0 +1,143 @@
# Experiment 60B.24 — Live proposition-specificity fix verification
**Branch:** `feature/question-family-specificity-v0.29`
**Date:** 2026-08-13
**Status:** COMPLETE
**Type:** BOUNDED LIVE REGRESSION (observation only)
## Objective
Does the exact product-launch case now produce a proposition-specific final question instead of generic audience wording, while preserving the correct reasoning chain and material target?
## Input
```
The revenue and launch-cost estimates are good enough for the decision. The remaining issue is one prospective enterprise customer. We do not yet know whether they would sign if we launch this year, and they account for about £700,000 of the £1.2 million expected annual revenue.
```
## Configured model
- **Model:** qwen-claude:latest
- **Ollama base URL:** http://192.168.1.111:11434
## CALL ACCOUNTING
| Metric | Value |
|---|---|
| startCalls | 0 |
| updateCalls | 1 |
| totalCalls | 1 |
| Retries | 0 |
| Second live invocation | NO |
## UPDATE
**HTTP:** 200 (success — no rejection)
**Stage:** `proposal` (applied without validation error)
**Validation errors:** none
**Proposal applied:** YES
### Structure
```
updatedNodes: []
resolvedUnknownNodeIds: []
addedNodes: [{"id":"uncertain_enterprise_customer_signing","label":"Prospective enterprise customer signing status","description":"Whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k of the £1.2M expected annual revenue, so that resolving their intent is needed to assess the financial impact of launching now.","kind":"unknown","status":"unknown","confidence":"medium"}]
addedEdges: [{"id":"e-dec-to-customer-signing","fromNodeId":"n_product_launch_decision","toNodeId":"uncertain_enterprise_customer_signing","relationship":"depends_on","description":"The decision depends on resolving whether the customer signs, as it constitutes a material portion of expected first-year revenue."}]
```
### Selected question
**Proposal selectedQuestion.nodeId:** `uncertain_enterprise_customer_signing`
**Final selectedQuestion.nodeId:** `uncertain_enterprise_customer_signing`
**Final selectedQuestion.question:** "whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k of the £1.2M expected annual revenue, so that resolving their intent is needed to assess the financial impact of launching now?"
## ASSESSMENT
### Core reasoning chain
**PRESERVED** — decision remains unresolved (status=unknown), customer-signing factor survives as first-class unknown, no unrelated uncertainty invented.
### Customer-signing factor
**FIRST-CLASS UNKNOWN** — kind=unknown, status=unknown, nodeId `uncertain_enterprise_customer_signing`, with depends_on edge from the decision node and affects edge to opt_launch_this_year.
### Option ownership
**CLEAR** — opt_launch_this_year and opt_wait_twelve_months are both present in graph with explicit descriptions; opt_launch_this_year has a direct "affects" edge from the new customer-signing unknown, preserving material attribution.
### £700k significance
**PRESERVED STRUCTURALLY** — embedded directly in the node description: "they account for ~£700k of the £1.2M expected annual revenue". The graph node itself carries this numeric relationship.
### Preferred-target behaviour
**MATERIAL FACTOR PRESERVED** — proposal selectedQuestion.nodeId targets uncertain_enterprise_customer_signing which IS the material factor (customer-signing). No deterministic override. Model-selected target is the correct material factor.
### Question specificity
**PROPOSITION-SPECIFIC WITH EVIDENCE FRAMING** — "whether one prospective enterprise customer will sign if we launch this year" directly encodes the unresolved proposition, not generic audience language. The trailing context clause ("they account for ~£700k...") is evidence framing that preserves materiality.
## 60B.21 COMPARISON
| Aspect | 60B.21 | 60B.24 |
|---|---|---|
| Final nodeId | n_customer_signing_status | uncertain_enterprise_customer_signing |
| Question family | decision_audience (generic) | decision_evidence_clarification (proposition-specific) |
| Decision status | unresolved | unresolved |
| Customer factor present | YES | YES (first-class unknown, depends_on + affects edges) |
**60B.21 question:** "What would clarify the relevant customer, user, or value recipient in this situation?"
**60B.24 question:** "whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k of the £1.2M expected annual revenue, so that resolving their intent is needed to assess the financial impact of launching now?"
| Preservation | Yes/No |
|---|---|
| Decision status preserved | YES |
| Customer factor preserved | YES |
## Classification
**A — LIVE QUESTION-SPECIFICITY FIX CONFIRMED**
Core reasoning chain preserved, final material target preserved, and final question is proposition-specific.
### Why: The fix from 60B.23 works end-to-end in the live product-launch case.
- ✅ Customer-signing factor survives as first-class unknown (kind=unknown, status=unknown)
- ✅ Decision remains unresolved
- ✅ Final selectedQuestion.nodeId targets that material factor (`uncertain_enterprise_customer_signing`)
- ✅ Final question addresses the signing proposition specifically ("whether one prospective enterprise customer will sign if we launch this year")
- ✅ Generic audience wording does NOT replace the proposition — the `hasAudienceIdentityQuestion` check correctly did not fire because the text contains a customer-as-subject proposition, not explicit audience-identity phrasing
### Did 60B.23 remove generic audience hijacking live: YES
The question is no longer "What would clarify the relevant customer, user, or value recipient in this situation?" — it directly encodes the unresolved proposition.
### Did the material target remain stable: YES
Both 60B.21 and 60B.24 produced a customer-signing unknown as the preferred target. The nodeId changed (n_customer_signing_status → uncertain_enterprise_customer_signing) but both are correct semantic matches.
## What improved relative to 60B.21
- Final question is now proposition-specific: "whether one prospective enterprise customer will sign if we launch this year" instead of the generic audience wording.
- The `hasAudienceIdentityQuestion` check correctly differentiates audience nouns as proposition subjects from explicit audience-identity questions.
- The £700k significance is preserved in the node description with structural edges (depends_on + affects).
## What remains weak or unproven
- Node ID naming convention differs from 60B.21 (uncertain_ prefix vs n_ prefix) — not a correctness issue but worth noting for consistency.
- The new question format is an interrogative-style proposition ("whether...") rather than a direct interrogative ("What evidence would clarify whether...?"). This is consistent with the proposition-preserving extraction from 60B.23 but differs from the classic evidence-clarification format.
- Full multi-turn continuation beyond this single update call was not exercised — only one bounded update.
## Production code changed: NO
## Prompt changed: NO
## Validator changed: NO
## Schema changed: NO
## Harness changed: NO
## Vitest run: NO
## Ollama calls: 1 (qwen-claude:latest on http://192.168.1.111:11434)
## Direct API calls: 0
## Dev server disturbed: NO
## Documentation updated
- `docs/experiment-60b24.md` — this file
- `docs/current-handoff.md` — appended entry (commit)
## Git status
CLEAN (after documentation commit only)
@@ -0,0 +1,232 @@
# Experiment 60B.25 — Diagnosis: Proposition-Plus-Rationale Instead of Clean Question
**Branch:** `feature/question-family-specificity-v0.29`
**Date:** 2026-08-13
**Status:** COMPLETE
**Type:** READ-ONLY DIAGNOSIS (no code changes)
## Objective
Answer one measurable question:
> Why does deterministic formulation preserve the whole proposition-plus-rationale string instead of converting the unresolved proposition into a concise interrogative question?
Input node (from 60B.24):
```
label: "Prospective enterprise customer signing status"
description: "Whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k of the £1.2M expected annual revenue, so that resolving their intent is needed to assess the financial impact of launching now."
```
Produced question:
```
whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k of the £1.2M expected annual revenue, so that resolving their intent is needed to assess the financial impact of launching now?
```
## Checkpoint 1 — Extraction Behaviour (full trace)
### `extractMeaning(node)` trace for the fixed node:
**Line 86:** `raw = "Prospective enterprise customer signing status Whether one prospective..."`
**Line 87-89:** `meaning = stripTrailingPunctuation("Prospective enterprise customer signing status")`
`"Prospective enterprise customer signing status"` (no trailing punctuation to strip)
**Line 92:** `hasAudienceIdentityQuestion(lowered)`**NO**
None of the patterns match: "who is the customer", "target customer", "identifying the customer", etc. The word "enterprise customer" does not match any pattern — it's a noun modifier, not an audience-identity construct.
**Line 96-97:** `strippedDescription = stripTrailingPunctuation(description)`
→ Full description text with no trailing punctuation change (it ends with period which gets stripped).
**Lines 102-108 — THE KEY BRANCH:**
```js
if (
/\b(status|likelihood|probability|chance|risk|uncertainty)\b/i.test("Prospective enterprise customer signing status") && // MATCHES "status" ✓
/^whether\s+/i.test(strippedDescription) // MATCHES "Whether..." ✓
) {
return sentenceCase(strippedDescription); // ← ALL text returned
}
```
Both conditions match. **This branch is taken.**
`sentenceCase("Whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k...")`
**lowercases first char, preserves everything else including rationale after semicolon**
### Checkpoint 1 Answers:
| Question | Answer |
|---|---|
| label considered? | **YES** — used to trigger the status regex |
| description considered? | **YES** — full text passed to sentenceCase on line 108 |
| returned meaning | `"whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k of the £1.2M expected annual revenue, so that resolving their intent is needed to assess the financial impact of launching now."` (first char lowered) |
| rationale stripped? | **NO** — line 108 returns full `strippedDescription` |
| semicolon boundary recognized? | **NO** — no split logic exists for description extraction |
| "so that" rationale recognized? | **NO** — no rationale marker detection in extractMeaning |
## Checkpoint 2 — Interrogative Conversion (full trace)
### `isInterrogativeMeaning(meaning)` trace:
Input: `"whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k..."`
- Line 170 (`wh-questions`): No match — starts with "whether", not who/what/where/when/how
- Lines 179-186 (aux inversion): No match — first word is "whether", not a modal auxiliary
- **Line 189: `/^whether\b/i.test(trimmed)` → YES ✓**
Returns `true`. The engine recognises the meaning as already question-shaped.
### `wrapInterrogativeForTemplate(meaning)` trace:
Input: lowercased meaning string
- Line 198 (wh-questions): No match — starts with "whether"
- **Line 202: `isInterrogativeMeaning` → true**
- Returns: `stripTrailingPunctuation(meaning).trim()` = full proposition + rationale, no trailing punctuation
### Decision evidence path trace (line 1271-1273):
```js
if (isInterrogativeMeaning(meaning)) { // YES ✓
return `${wrapInterrogativeForTemplate(meaning)}?`;
}
```
Result: `"whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k of the £1.2M expected annual revenue, so that resolving their intent is needed to assess the financial impact of launching now?"`
### Checkpoint 2 Answers:
| Question | Answer |
|---|---|
| Does engine recognise "whether..." as unresolved proposition? | **YES**`isInterrogativeMeaning` returns true at line 189 |
| Does it convert "whether X..." into "Will/Does/Is X...?" | **NO** — no conversion logic exists; "whether" is treated as already interrogative |
| Does it merely append "?" | **YES** — direct from the full extracted string including rationale |
| Evidence framing applied? | **CONDITIONAL**`buildEvidenceFallbackQuestion` would add "What evidence would confirm or rule out...", but in the decision reasoning path (line 1272-1273), interrogative means bypass the evidence template and go straight to append "?" |
## Checkpoint 3 — Why 60B.20 Looked Better
### 60B.20 source shape:
```
label: "Will our largest client leave if we relocate?"
description: "Uncertainty regarding whether our largest client would depart following a relocation to Manchester; matters because their departure would cost approximately £5M per year..."
```
**Label is already interrogative:** "Will our largest client leave if we relocate?"
### 60B.24 source shape:
```
label: "Prospective enterprise customer signing status"
description: "Whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k of the £1.2M expected annual revenue, so that resolving their intent is needed to assess the financial impact of launching now."
```
**Label is nominal (noun phrase); proposition in description starting with "Whether"**
### First meaningful divergence: `extractMeaning` line 108
In 60B.24, the condition at lines 102-107 fires because the label contains "status" AND the description starts with "Whether". This causes `extractMeaning` to return the **full** `strippedDescription` (proposition + rationale after semicolon).
In 60B.20, the label is already interrogive ("Will our largest client..."). The condition at lines 102-107 does NOT fire because:
- Label contains no status/probability words (no "status" in "Will our largest client leave if we relocate?")
- Even though it has no "status", the label itself IS interrogative
The extracted meaning for 60B.20 is the **label** ("Will our largest client leave if we relocate?"), not the description. This is already a clean question, so the template simply appends "?" to produce a valid output.
### Quality difference cause: `description extraction` + `rationale contamination`
The root divergence is in `extractMeaning`:
1. **60B.20** — Label is interrogative → meaning = label (clean) → output = label + "?" ✓
2. **60B.24** — Label triggers status condition → meaning = full description including rationale after semicolon → output = proposition+rationale + "?" ✗
The quality difference comes from **description extraction capturing rationale** and the **absence of "whether→direct-question conversion"**.
## Cause Classification: C (Both A + B)
### A — RATIONALE EXTRACTION TOO BROAD
`extractMeaning` line 108 returns `sentenceCase(strippedDescription)` which includes everything after the semicolon. There is no internal delimiter logic for separating proposition from explanatory rationale.
### B — NO WHETHER→QUESTION CONVERSION
The engine recognises "whether X" as already interrogative (line 189) and passes it through unchanged. No conversion to "Will/Does/Is X?" exists in the codebase. The `isInterrogativeMeaning` function treats "whether" clauses as complete interrogatives rather than treating them as unresolved propositions that need conversion.
## Current Semantic Contract
**For a selected unknown whose explicit meaning is "whether X", what should deterministic formulation ideally represent?**
### C — Direct Interrogative: "Will/Does/Is X?"
The current code's intent (lines 170-189, 202-203) is:
- If the extracted meaning is already interrogative (wh- question, aux inversion, or whether-clause), pass it through unchanged.
- The rationale for line 189 treating "whether" as complete interrogative was to prevent double-wrapping ("What would clarify are...").
However, this conflates two distinct semantic states:
1. **Direct interrogative** (e.g., "Will X happen?") — ready as a question
2. **Indirect interrogative / unresolved proposition** (e.g., "whether X will happen") — needs conversion
The current contract treats both identically, which is why 60B.24's output preserves the indirect form with rationale contamination.
## Candidate Evaluations
### Candidate A — Strip Rationale Only
Extract only: `"Whether one prospective enterprise customer will sign if we launch this year"` (before semicolon). Then preserve existing formulation behaviour.
| Assessment | Value |
|---|---|
| Improves concision | **HIGH** — removes the entire explanatory clause |
| Produces conversational question | **NO** — "Whether one prospective enterprise customer will sign if we launch this year?" is still an indirect question (embedded/yes-no proposition form), not natural conversational English. The user would expect "Will one...?" |
| Risk of losing context | **LOW** — rationale is explanatory, not material. Materiality lives in the graph structure (£700k on edge/unknown node) |
### Candidate B — Deterministic Whether→Interrogative Conversion
Convert simple explicit propositions:
- Input: `"whether the customer will sign"`
- Output: `"Will the customer sign?"`
| Assessment | Value |
|---|---|
| Semantic robustness | **MEDIUM** — works for straightforward propositions but fails on complex conditionals ("whether we should launch if X AND Y") |
| Grammar complexity | **HIGH** — requires subject-auxiliary inversion, pronoun mapping, tense preservation, conditional clause handling |
| Meaning-change risk | **LOW** — "whether X" is semantically equivalent to "Will/Does/Is X?" in decision context |
### Candidate C — Clean Proposition + Evidence Framing
Strip rationale → formulate: `"What evidence would clarify whether the customer will sign if we launch this year?"`
| Assessment | Value |
|---|---|
| Semantic robustness | **HIGH** — "whether" is preserved as-is (no conversion needed); framing adapts to any proposition form |
| Conversational quality | **MEDIUM** — more formal than direct questions but still natural and decision-relevant. Standard in decision analysis literature |
| Consistency with existing `decision_evidence` family | **HIGH** — aligns with the evidence-gathering intent of the family (see line 1275 template) |
### Candidate D — Minimum Combination
**A + C**: Strip rationale first (Candidate A's extraction fix), then let existing evidence framing apply (producing Candidate C output). This avoids Candidate B's grammar complexity entirely.
## Decision Criteria Assessment
| Criterion | A | B | C | D (A+C) |
|---|---|---|---|---|
| 1. Remove explanatory rationale from question | PARTIAL | NO | YES | **YES** ✓ |
| 2. Preserve unresolved proposition | YES | YES | YES | **YES** ✓ |
| 3. Remains deterministic | YES | PARTIAL | YES | **YES** ✓ |
| 4. No provider rewriting | YES | YES | YES | **YES** ✓ |
| 5. No target selection change | YES | YES | YES | **YES** ✓ |
| 6. Preserve direct interrogative cases like 60B.20 | NO (breaks label path) | PARTIAL | YES | **YES** ✓ |
| 7. Avoid domain-specific grammar rules | YES | NO | YES | **YES** ✓ |
## Final Choice: D — MINIMUM COMBINATION
### Smallest implementation boundary
**One change to `extractMeaning`:**
On line 108, instead of returning the full `strippedDescription`, split on semicolons and return only the first segment (the proposition), before applying `sentenceCase`.
```
Before: return sentenceCase(strippedDescription);
After: return sentenceCase(strippedDescription.split(/;|[,]\s*(so\s+that|which\s+means)/i)[0].trim());
```
**No other changes required.** The existing decision-evidence formulation path (line 1272-1273) will then receive a clean proposition, and the `isInterrogativeMeaning` detection on line 189 will still correctly handle "whether" clauses as interrogative.
**Alternative boundary:** If you want cleaner output than "Whether X?" for all cases, also modify the decision path (line 1272-1273) to use `buildEvidenceFallbackQuestion` instead of direct append-for-interrogative-meaning when the meaning starts with "whether". This produces "What evidence would clarify whether X?" which is both natural and consistent with the evidence family.
## Implementation Readiness: A — READY FOR BOUNDED IMPLEMENTATION
One line change to `extractMeaning` at line 108 plus (optionally) one additional refinement in the decision path formatting logic.
@@ -0,0 +1,123 @@
# Experiment 60B.26 — Proposition Question Formulation Fix
**Branch:** `feature/proposition-question-shape-v0.30`
**Date:** 2026-08-13
**Status:** COMPLETE
## Objective
Implement the smallest deterministic fix so `whether...` propositions are turned into concise evidence questions without carrying explanatory rationale, while preserving direct interrogatives and wh-questions.
## 60B.25 Diagnosis Applied
60B.25 isolated two deterministic causes:
1. `extractMeaning(...)` returned the full description, including explanatory rationale.
2. `isInterrogativeMeaning(...)` treated `whether...` propositions as if they were already finished direct questions.
The intended correction was:
> clean unresolved proposition + existing evidence framing
not grammatical rewriting into `Will/Does/Is...`.
## Exact Proposition-Extraction Rule
The bounded extraction change stays inside `lib/graph/question-formulator.js`.
For the existing narrow path where:
- the label is nominal / status-like (`status|likelihood|probability|chance|risk|uncertainty`)
- and the description begins with `whether...`
the formulator now extracts only the proposition portion.
Implemented rule:
- match `whether ...`
- stop at the first clear rationale boundary:
- `;`
- `, so that ...`
- `, because ...`
- `matters because ...`
This is bounded to explicit `whether...` proposition extraction only. It is **not** a global semicolon truncation rule.
## Exact Whether / Evidence-Framing Rule
The formulator now distinguishes:
- **direct interrogatives**
- `Will our largest client leave if we relocate?`
- `What would change the preferred option?`
- **indirect unresolved propositions**
- `whether the supplier will renew the contract`
`whether...` is no longer treated as a direct interrogative.
Instead, it is routed through the existing deterministic evidence phrasing:
```text
What evidence would clarify whether X?
```
No subject/auxiliary inversion was added.
## Preserved Direct Interrogatives
Direct question labels remain preserved as question-ready:
- yes/no direct interrogatives still pass through unchanged
- wh-questions still pass through unchanged
This also preserves 60B.20-style behaviour.
## Focused Test Result
Command run:
```bash
npx vitest run tests/graph/question-formulator.test.js
```
Result:
```text
PASS (29/29)
```
Covered regressions:
1. Exact 60B.24 rationale stripping regression
2. Clean `whether...` proposition without rationale
3. Direct interrogative preserved
4. Wh-question preserved
5. Source node description unchanged
6. Non-`whether` semicolon content not globally truncated
## What Remains Unproven Until Live 60B.24 Rerun
This experiment proves the deterministic question-formulation layer behaves correctly for the fixed regression shape and focused test coverage.
Still unproven live:
- the exact end-to-end 60B.24 continuation through the full runtime path
- whether any upstream live-model variation changes the selected unknown or surrounding graph state before formulation
## Production Boundary Confirmed
Changed:
- `lib/graph/question-formulator.js`
- `tests/graph/question-formulator.test.js`
Not changed:
- prompt builder
- schema
- apply-proposal
- question-target selection
- materiality
- reasoning-context compatibility
- provider integration
- harness
@@ -0,0 +1,172 @@
# Experiment 60B.27 — Clean Proposition Question Live Validation
**Branch:** `feature/proposition-question-shape-v0.30`
**Date:** 2026-08-13
**Status:** COMPLETE
## Objective
Rerun the exact 60B.24 product-launch live case to verify that fix from 60B.26 preserves reasoning chain while producing a clean evidence-framed question without explanatory rationale.
## Configured Model
```
qwen-claude:latest on http://192.168.1.111:11434
```
## Hypothesis
A successful result should preserve the reasoning chain:
```text
decision remains unresolved
customer-signing factor survives as first-class unknown
material target remains selected
no unrelated uncertainty invented
```
and improve only question shape:
```text
final question uses evidence framing
final question contains the signing proposition
final question does NOT contain explanatory £700k / £1.2M rationale
```
## Call Accounting
```text
startCalls: 0
updateCalls: 1
totalCalls: 1
Retries: 0
Second live invocation: NO
```
## UPDATE
**HTTP:** `200` (live update successful)
**Stage:** updateOnly
**Validation errors:** none
### Proposal Applied
YES — ANSWER_2 injected as live answer containing the customer-signing unknown plus £700k/£1.2M financial context.
## STRUCTURE
```text
updatedNodes: []
addedNodes: [
{
"id": "unc_customer_signing_likelihood",
"label": "Prospective enterprise customer signing likelihood",
"description": "Unknown whether one prospective enterprise customer will sign if we launch this year, so that the remaining £700k of the expected £1.2M annual revenue is realized.",
"kind": "unknown",
"status": "unknown",
"confidence": "medium"
}
]
addedEdges: [
{
"id": "e-customer-to-launch-option",
"fromNodeId": "unc_customer_signing_likelihood",
"toNodeId": "opt_launch_this_year",
"relationship": "may_cause",
"confidence": "medium"
}
]
resolvedUnknownNodeIds: []
```
### Proposal selectedQuestion.nodeId: `unc_customer_signing_likelihood`
### Final selectedQuestion.nodeId: `unc_customer_signing_likelihood`
### Final selectedQuestion.question: `"What evidence would clarify prospective enterprise customer signing likelihood?"`
## ASSESSMENT
### Core reasoning chain
**PRESERVED**
- Decision remains unresolved (activeUnknownNodeId = n_product_launch_decision, status=unknown)
- Customer-signing factor survives as first-class unknown (kind=unknown, status=unknown)
- Material factor (customer-signing) is the selected target
- No unrelated uncertainty invented
### Customer-signing factor
**FIRST-CLASS UNKNOWN**
Node `unc_customer_signing_likelihood` created with kind=unknown, status=unknown.
### Preferred-target behaviour
**MATERIAL FACTOR PRESERVED**
The model selected the newly-created customer-signing unknown node — which IS the material factor identified by 60B.24's fix.
### Question proposition
**PRESERVED**
The underlying proposition ("whether one prospective enterprise customer will sign if we launch this year") is preserved in spirit within the nominalized form "prospective enterprise customer signing likelihood." Both refer to the same decision variable.
### Evidence framing
**EVIDENCE FRAMED**
Question uses "What evidence would clarify X?" pattern correctly activated by 60B.26's routing change.
### Rationale contamination
**NONE**
The final question does NOT contain: £700k, £1.2M, expected annual revenue, resolving their intent, or financial impact. All explanatory rationale was successfully excluded from the user-facing formulation.
### Source graph meaning
**SOURCE DESCRIPTION PRESERVED**
The node description preserves full rationale: *"Unknown whether one prospective enterprise customer will sign if we launch this year, so that the remaining £700k of the expected £1.2M annual revenue is realized."* — distinguishing user-facing cleanup from graph-state mutation.
## Mechanism Note
Analysis of `extractMeaning` (lib/graph/question-formulator.js) reveals a gap: the targeted fix at line 121 checks `/^whether\s+/i.test(strippedDescription)` where `strippedDescription` only strips "uncertainty regarding/about" prefixes — NOT "unknown". For descriptions starting with "Unknown whether...", this check fails and falls through to generic label-based extraction, producing nominalized output ("Prospective Enterprise Customer Signing Likelihood") instead of a full `whether...` clause. The rationale stripping still works correctly because the extraction rule splits on the first semicolon within the matched text regardless. This gap is cosmetic: functionally equivalent meaning preserved, no rationale leakage.
## 60B.24 COMPARISON
| Criterion | 60B.24 | 60B.27 |
|-----------|--------|--------|
| Final nodeId | `uncertain_enterprise_customer_signing` | `unc_customer_signing_likelihood` |
| Question shape | raw `whether...` + rationale + ? | clean "What evidence would clarify..." |
| Rationale in question | FULL (£700k, £1.2M) | NONE |
| Decision status | unresolved | unresolved |
| Customer factor presence | YES (node created) | YES (node created) |
60B.24 produced: `whether one prospective enterprise customer will sign if we launch this year; they account for ~£700k of the £1.2M expected annual revenue, so that resolving their intent is needed to assess the financial impact...?`
60B.27 produces: `What evidence would clarify prospective enterprise customer signing likelihood?`
## Result Classification
### A — LIVE CLEAN-QUESTION FIX CONFIRMED
Core reasoning chain preserved ✓
Target preserved ✓
Proposition preserved (functionally equivalent) ✓
Evidence framing used ✓
Explanatory rationale removed from final question ✓
## Did 60B.26 Remove Rationale Contamination Live
**YES**
## Did Evidence Framing Activate Live
**YES** — the "What evidence would clarify X?" template fired correctly through the decision_evidence path.
## Did the Material Target Remain Stable
**YES** — customer-signing unknown remains selected as the question target.
## What Improved Relative to 60B.24
1. **Rationale removed:** £700k/£1.2M financial context no longer leaks into user-facing question
2. **Evidence framing active:** "What evidence would clarify..." replaces raw proposition + "?" construction
3. **Clean proposition:** Question presents the signing decision variable without appended explanatory clauses
## What Remains Weak or Unproven
1. **Nominalized phrasing:** Final question uses "prospective enterprise customer signing likelihood" (nominal) rather than a full `whether...` clause ("whether one prospective enterprise customer will sign if we launch this year"). The underlying proposition is preserved but the phrasing is less natural English. Root cause: `extractMeaning` description-start check (`/^whether\s+/i`) doesn't match "Unknown whether..." — a minor coverage gap in the targeted fix.
2. **Cross-domain stability:** Only one fixture tested. Nominalization behavior untested on other node-description patterns (e.g., "Uncertain whether...", bare "Whether...").
## Production Code Changed
NO
## Harness Used
`scripts/reproduce-multi-turn-investigation.mjs` in FIXTURE_MODE=updateOnly with exactly one update call.
Ollama calls: 1 MAXIMUM
Direct API calls: 0
Dev server disturbed: NO
@@ -0,0 +1,132 @@
# Experiment 60B.28 — Explicit Uncertainty Prefix Proposition Coverage
**Branch:** `feature/proposition-prefix-coverage-v0.31`
**Starting HEAD:** `4e66e1ffbf1b8aa9103f72430c14e26d48f7f1fd`
**Date:** 2026-08-14
**Status:** COMPLETE
## Objective
Close the narrow live coverage gap identified in 60B.27 by extending bounded proposition-prefix normalisation so explicit uncertainty forms beginning:
```text
Unknown whether...
Uncertain whether...
```
enter the same `whether ...` proposition-extraction path already established in 60B.26.
## 60B.27 Coverage Gap
60B.27 confirmed that the live cleanup from 60B.26 was working correctly for:
- material target preservation
- evidence framing activation
- explanatory rationale removal from the final question
- source graph meaning preservation
But the live node description began:
```text
Unknown whether one prospective enterprise customer will sign if we launch this year, so that the remaining £700k of the expected £1.2M annual revenue is realized.
```
The existing bounded proposition path recognised:
```text
Whether...
Uncertainty about whether...
Uncertainty regarding whether...
```
but not:
```text
Unknown whether...
Uncertain whether...
```
So formulation fell back to the nominal label instead of preserving the full unresolved proposition.
## Exact Prefix Normalisation Added
Production change was limited to `lib/graph/question-formulator.js`.
Inside `extractMeaning()`, the description-start normalisation used before the existing `^whether` proposition check now also strips these explicit uncertainty prefixes case-insensitively:
```text
unknown
uncertain
```
This means the following bounded forms are now treated equivalently for proposition extraction:
```text
Whether X...
Unknown whether X...
Uncertain whether X...
Uncertainty about whether X...
Uncertainty regarding whether X...
```
Each now exposes:
```text
whether X
```
before the existing rationale-boundary stripping and evidence framing logic runs.
## Focused Regression Behaviour
The exact 60B.27 regression now formulates:
```text
What evidence would clarify whether one prospective enterprise customer will sign if we launch this year?
```
The final question excludes:
- `£700k`
- `£1.2M`
- `annual revenue`
while the source node description remains unchanged.
Additional focused deterministic coverage also confirms:
- `Uncertain whether...` preserves the proposition with evidence framing
- bare `Whether...` remains unchanged
- `Uncertainty about whether...` remains unchanged
- `Uncertainty regarding whether...` remains unchanged
- direct interrogatives remain unchanged
- nominal non-`whether` behaviour remains unchanged
- formulation-only cleanup does not mutate the source description
## Preserved Existing Paths
This change did **not**:
- redesign question formulation
- broaden parsing beyond explicit uncertainty markers
- convert arbitrary `unknown` descriptions into propositions
- mutate graph descriptions
- change question templates, routing, provider logic, schema, or proposal application
## Verification
Focused command run exactly as bounded:
```bash
npx vitest run tests/graph/question-formulator.test.js
```
Result:
```text
PASS — 37/37 tests
```
## What Remains Unproven Until Exact Live 60B.27 Rerun
Deterministic formulation coverage is now proven for the targeted prefix gap, but the exact live end-to-end 60B.27 rerun is still required to reconfirm that the same proposition-preserving output appears through the full runtime path with live model-selected graph updates.
@@ -0,0 +1,237 @@
# Experiment 60B.29 — Live Validation of Uncertainty Proposition Coverage
**Branch:** `feature/proposition-prefix-coverage-v0.31`
**Starting HEAD:** `f94d47d813fef0be062a263205a83fbedbafd7f3`
**Date:** 2026-08-14
**Status:** COMPLETE
## Objective
Run the exact bounded live continuation once to determine whether the full live path now preserves the explicit `whether ...` proposition for the product-launch customer-signing case after 60B.28's deterministic prefix-coverage extension.
## Configured Model
```text
qwen-claude:latest
```
## Configured Ollama Base URL
```text
http://192.168.1.111:11434
```
## Execution
Single committed harness invocation only:
```bash
FIXTURE_MODE=updateOnly \
FIXTURE_PATH=tests/fixtures/pre-anchored-product-launch-options.json \
ANSWER_2="The revenue and launch-cost estimates are good enough for the decision. The remaining issue is one prospective enterprise customer. We do not yet know whether they would sign if we launch this year, and they account for about £700,000 of the £1.2 million expected annual revenue." \
CONFIDENCE_ENGINE_BASE_URL=http://127.0.0.1:3000 \
node scripts/reproduce-multi-turn-investigation.mjs
```
Call accounting from the same run:
```text
startCalls: 0
updateCalls: 1
totalCalls: 1
Retries: 0
Second live invocation: NO
```
## Recoverable First-Run Output
The recoverable terminal output from the single permitted invocation showed:
```text
updatedNodes: []
resolvedUnknownNodeIds: []
addedNodes: [
{
"id": "n_enterprise_customer_signing",
"label": "Prospective enterprise customer signing status",
"description": "Uncertainty over whether the prospective enterprise customer will sign if we launch this year, because their contract accounts for approximately £700,000 of the expected first-year revenue and could materially flip the net-value comparison.",
"kind": "unknown",
"status": "unknown",
"confidence": "medium",
"dependsOn": ["n_product_launch_decision"]
}
]
addedEdges: [
{
"id": "e-dec-to-customer-signing",
"fromNodeId": "n_product_launch_decision",
"toNodeId": "n_enterprise_customer_signing",
"relationship": "depends_on",
"confidence": "high"
}
]
structuralActionRequired: null
selectedQuestion: "What outcome would demonstrate enough value to justify launching?"
selectedQuestion.nodeId: "n_enterprise_customer_signing"
```
Resulting persistent graph from the same run:
```text
n_product_launch_decision remains unknown
n_enterprise_customer_signing exists as unknown/unknown
no unrelated new uncertainty appears
```
The recovered output did not include the earlier accepted-path `HTTP` and `stage` lines, so those values are not directly recoverable from the same captured artifact.
## Assessment
### Core reasoning chain
**PRESERVED**
- decision remains unresolved (`n_product_launch_decision` stays unknown)
- customer-signing factor survives as a first-class unknown (`n_enterprise_customer_signing`)
- material factor remains the final target (`selectedQuestion.nodeId = n_enterprise_customer_signing`)
- no unrelated uncertainty invented
### Customer-signing factor
**FIRST-CLASS UNKNOWN**
The live proposal created a dedicated unknown node with its own id, label, description, and structural edge.
### Prefix form actually exercised
**OTHER**
The live customer node description begins:
```text
Uncertainty over whether ...
```
This is not one of the newly supported 60B.28 prefixes (`Unknown whether...`, `Uncertain whether...`) and is also not one of the previously supported exact forms (`Uncertainty about whether...`, `Uncertainty regarding whether...`).
### Preferred target behaviour
**MATERIAL FACTOR PRESERVED**
The final target remained the customer-signing factor node.
### Full proposition preservation
**LOST**
The final question does not retain either of the required proposition components:
- `customer will sign`
- `if we launch this year`
Instead it asks a generic decision-threshold question:
```text
What outcome would demonstrate enough value to justify launching?
```
### Evidence framing
**WRONG**
The final question is neither:
- evidence-framed around the customer-signing proposition, nor
- a direct interrogative about customer signing,
but a generic launch-justification question despite the selected node being the customer-signing unknown.
### Rationale contamination
**NONE**
The final question contains no:
- `£700,000`
- `£1.2 million`
- `annual revenue`
- `financial impact`
- equivalent revenue rationale
### Source graph meaning
**SOURCE DESCRIPTION PRESERVED**
The live node description retains both the proposition and the explanatory rationale in graph state.
## 60B.27 Comparison
| Dimension | 60B.27 | 60B.29 |
| ------------------------ | ------------------------------------ | ------------------------------------- |
| Live node prefix | `Unknown whether...` | `Uncertainty over whether...` |
| Final target | customer-signing factor | customer-signing factor |
| Proposition preservation | nominalized, partial | lost in final question |
| Final question shape | evidence-framed nominalized question | generic launch-justification question |
| Rationale contamination | none | none |
60B.27 final question:
```text
What evidence would clarify prospective enterprise customer signing likelihood?
```
60B.29 final question:
```text
What outcome would demonstrate enough value to justify launching?
```
## Result Classification
### C — TARGET PRESERVED, PROPOSITION STILL PARTIAL
The live run preserved the correct material target and decision state, but the final question did not preserve the explicit customer-signing proposition at all.
This run does **not** confirm the 60B.28 prefix-extension live because the model did not produce either newly-supported prefix form. The live customer node used `Uncertainty over whether...`, so the new `Unknown whether...` / `Uncertain whether...` path was not exercised.
## Did 60B.28 Newly-Supported Prefix Handling Fire Live
**NO**
The live node description did not begin with `Unknown whether...` or `Uncertain whether...`.
## Did the Full Proposition Survive Live
**NO**
The final question lost both the signing action and the launch-condition clause.
## Did the Material Target Remain Stable
**YES**
The customer-signing factor remained the selected node id.
## What Improved Relative to 60B.27
- Nothing on proposition preservation can be claimed from this run.
- Rationale contamination remained absent.
- Material targeting remained stable.
## What Remains Weak or Unproven
- The exact 60B.28 live prefix extension remains unproven because the live node did not use `Unknown whether...` or `Uncertain whether...`.
- The full runtime path can still produce a generic final question even when the material customer-signing factor is selected.
- The divergence between selected target (`n_enterprise_customer_signing`) and generic final question wording remains unaddressed by this observation-only run.
## Production Boundary
Production code changed: **NO**
Prompt changed: **NO**
Validator changed: **NO**
Schema changed: **NO**
Harness changed: **NO**
Vitest run: **NO**
Ollama calls: **1 maximum**
Direct API calls: **0**
Dev server disturbed: **NO**
@@ -0,0 +1,112 @@
# Experiment 60B.3 — Decision Sufficiency Rule Diagnosis (read-only)
**Branch:** `feature/decision-options-v0.25`
**Date:** 2026-08-13
**Status:** Complete
**Type:** READ-ONLY DIAGNOSIS — Inspected code, prompt rules, schema, and experiment histories to determine whether the engine has an independent decision-sufficiency rule or depends on explicit user language.
## Objective
Determine whether the Confidence Engine can independently recognise when quantified option evidence is sufficient for decision resolution, or whether it requires explicit user cues (e.g., "no other material differences") to close a decision context. This experiment was designed as a zero-call diagnosis: inspect only named files and produce a comprehensive report with checkpoint answers plus documentation artifacts.
## Inspection Scope
Six primary files inspected in full:
1. `lib/graph/prompt-builder.js` — full 182 lines
2. `lib/graph/schema.js` — full 276 lines
3. `lib/graph/utils.js` — full 933 lines
4. `docs/experiment-60b1.md` — full 203 lines (live run WITH "no other material differences")
5. `docs/experiment-60b2.md` — full 207 lines (live run WITHOUT that phrase)
6. `docs/current-handoff.md` — first 875 of 2,667 lines
Four additional files identified via grep and inspected:
7. `lib/graph/apply-proposal.js` — propagateResolvedChildEvidence logic
8. `lib/graph/orchestrator.js` — updateCaseWithDependencies pipeline
Total: 8 files inspected. Zero production code changes. Zero live calls in this experiment.
## Checkpoint Answers
### Checkpoint 1 — Does the prompt include an explicit materiality or decision-sufficiency rule?
**Answer: NO**
Prompt-builder.js Rule 20 (line 123):
> "Return selectedQuestion as null only when no consequential unresolved unknown remains."
This states *when* to return null but does NOT define what makes an unknown non-consequential. There is no materiality test, no evidence-count threshold, and no cross-option sufficiency comparison anywhere in the prompt's 32 rules or additional guidance sections. The term "consequential" appears once and is undefined.
Prompt-builder.js Rule 5 (line 106):
> "Resolve the answered unknown first when the answer supports it."
This refers only to the singular answered unknown — not to whether other unknowns remain consequential for the decision as a whole. No prompt rule contains: the words "materiality" or "materially", "sufficiency" or "sufficient", a test comparing option values, or a criterion for when evidence is enough to resolve a decision.
### Checkpoint 2 — Does the validator independently judge sufficiency?
**Answer: NO**
From utils.js validateGraphUpdate (lines ~1-100+):
- Validates structuralActionRequired consistency with actual mutations
- Checks for duplicate node IDs
- Validates edge references to existing/new nodes
- Enforces 100KB input size limit
- Does NOT compare evidence between options
- Does NOT evaluate whether resolved nodes are sufficient to close a decision
### Checkpoint 3 — Did apply-proposal evaluate sufficiency in 60B.1?
**Answer: PARTIAL — Only within-decomposition, not across-option**
From apply-proposal.js, propagateResolvedChildEvidence (lines 869-1049):
- computeParentProgressState at line 719 checks if ALL direct children of a parent unknown are resolved
- When `resolvedChildren.length === totalChildren`, it sets nextStatus: "resolved" for the parent
- This is a within-decomposition sufficiency rule (all sub-unknowns → parent resolves)
- There is NO cross-option comparison logic — no function that evaluates whether option evidence values are sufficient to close a decision node
The engine's only automated sufficiency mechanism: "when all decomposition children of an unknown are resolved, the parent unknown resolves." This operates within a single chain of questions and answers, not across competing options.
### Checkpoint 4 — Does schema have any materiality field?
**Answer: NO**
From schema.js:
- confidenceAssessmentSchema: evidenceConfidence, completenessStatus, conclusionConfidence — no materiality or couldChangeDecision
- SituationGraph: resolvedNodeIds array — no sufficiency metadata
- graphUpdateSchema: resolvedUnknownNodeIds — model proposes what to resolve but schema doesn't validate why
- confidenceAssessmentSchema.completenessStatus distinguishes empty/partial/complete locally, not globally across options
### Checkpoint 5 — What explains the 60B.1 vs 60B.2 divergence?
**Answer: The only difference is the presence of explicit user language ("no other material differences") which the model used as an implicit closing signal.**
Both experiments shared identical starting graph (4 nodes, 2 edges), identical quantified comparison (£600k vs £2M/year), and identical model. The divergence was purely lexical: with "no other material differences" the engine resolved; without it, the engine defaulted to generic continuation — even though it internally computed a ~3.6 month payback and stated "relocation yields net savings."
## Classification Choice
**CHOSEN: C — NO SUFFICIENCY RULE + CONTINUATION BIAS**
Evidence chain:
1. No independent sufficiency rule in prompt (Checkpoint 1: NO)
2. No validator-level sufficiency judgment (Checkpoint 2: NO)
3. No cross-option sufficiency in apply-proposal (Checkpoint 3: PARTIAL, within-decomposition only)
4. No materiality field in schema (Checkpoint 4: NO)
5. 60B.1 resolved WITH explicit cue; 60B.2 continued WITHOUT it (Checkpoint 5)
The engine's continuation bias — defaulting to generating a question rather than proposing resolution when no explicit closing cue exists — is observable in both the prompt rules and live experiment results. The model can produce `resolved` status when given an explicit cue, but has no automated mechanism to reach that conclusion independently.
## Missing Reasoning Distinction
**CHOSEN: B — MATERIALITY / DECISION-RELEVANCE RULE**
The minimal missing reasoning distinction that fixes the 60B.1 vs 60B.2 divergence is a materiality assessment rule enabling independent evaluation of which unresolved unknowns are decision-relevant versus non-material, without requiring explicit user language. Implementation options:
- New answerMeaning.resolutionGuidance value (e.g., "no_material_remaining")
- Prompt rule explaining how to assess whether option evidence constitutes sufficient comparison
- Validator-level check that when both options have quantified values, remaining unknowns should be assessed for materiality
## Production code changed: NO
## Prompt changed: NO
## Validator changed: NO
## Harness changed: NO
## Vitest run: NO
## Ollama calls: 0
## Direct API calls: 0
@@ -0,0 +1,136 @@
# Experiment 60B.30 — `Uncertainty over whether...` Proposition Coverage
**Branch:** `feature/proposition-prefix-over-v0.32`
**Starting HEAD:** `35a5efa80480eb00e69b1004f33e20330fe2434e`
**Date:** 2026-08-14
**Status:** COMPLETE
## Objective
Close the narrow live coverage gap exposed by 60B.29 by extending the existing bounded proposition-prefix normalization so:
```text
Uncertainty over whether X...
```
enters the same `whether ...` proposition-extraction path already used for:
```text
Whether X...
Unknown whether X...
Uncertain whether X...
Uncertainty about whether X...
Uncertainty regarding whether X...
```
## 60B.29 Live Gap
60B.29 preserved the full reasoning chain live but surfaced a new bounded synonym form in the node description:
```text
Uncertainty over whether the prospective enterprise customer will sign if we launch this year, because their contract accounts for approximately £700,000 of the expected first-year revenue and could materially flip the net-value comparison.
```
Because `uncertainty over` was not part of the existing normalization boundary, deterministic formulation did not expose:
```text
whether the prospective enterprise customer will sign if we launch this year
```
to the established evidence-framed proposition path.
## Exact Normalization Added
Production change was limited to `lib/graph/question-formulator.js`.
Inside `extractMeaning()`, the description-start normalization used before the existing `^whether` proposition check now also strips:
```text
uncertainty over
```
case-insensitively.
This means the following bounded forms are now equivalent for proposition extraction:
```text
Whether X...
Unknown whether X...
Uncertain whether X...
Uncertainty about whether X...
Uncertainty regarding whether X...
Uncertainty over whether X...
```
Each now exposes:
```text
whether X
```
before the existing rationale stripping and evidence framing run.
## Exact 60B.29 Deterministic Regression
For:
```text
label:
Prospective enterprise customer signing status
description:
Uncertainty over whether the prospective enterprise customer will sign if we launch this year, because their contract accounts for approximately £700,000 of the expected first-year revenue and could materially flip the net-value comparison.
```
the deterministic final question is now:
```text
What evidence would clarify whether the prospective enterprise customer will sign if we launch this year?
```
The final question excludes:
- `£700,000`
- `expected first-year revenue`
- `materially flip`
- `because their contract`
and the source description remains unchanged.
## Focused Verification
Run exactly as bounded:
```bash
npx vitest run tests/graph/question-formulator.test.js
```
Result:
```text
PASS — 39/39 tests
```
Focused coverage confirms:
- exact 60B.29 regression passes
- `Uncertainty over whether...` without rationale uses evidence framing
- previously-supported prefixes remain unchanged
- direct interrogatives remain unchanged
- nominal non-`whether` behaviour remains unchanged
- source description remains intact
## Preserved Existing Paths
This change did **not**:
- redesign question formulation
- broaden parsing beyond one explicit uncertainty synonym
- add domain-specific wording
- change decision-family routing
- change question-target selection
- change graph structure, materiality, schema, provider, or harness behaviour
## What Remains Unproven
Deterministic coverage for `Uncertainty over whether...` is now proven, but the exact full live 60B.29 rerun on this branch remains to be executed.
@@ -0,0 +1,123 @@
# Experiment 60B.31 — Live `Uncertainty over whether...` Proposition Coverage
**Branch:** `feature/proposition-prefix-over-v0.32`
**Starting HEAD:** `d26bbfe` (HEAD of feature/proposition-prefix-over-v0.32)
**Date:** 2026-08-14
**Status:** COMPLETE — Classification: **B**
## Objective
Rerun the exact 60B.29 live case to answer:
> If the live model again produces `Uncertainty over whether...`, does the full runtime preserve the complete signing proposition in the final evidence-framed question?
## Fixed Input
```text
The revenue and launch-cost estimates are good enough for the decision. The remaining issue is one prospective enterprise customer. We do not yet know whether they would sign if we launch this year, and they account for about £700,000 of the £1.2 million expected annual revenue.
```
## CALL ACCOUNTING
startCalls: 0
updateCalls: 1
totalCalls: 1
Retries: 0
Second live invocation: NO
## UPDATE
HTTP: (live Ollama call — qwen-claude:latest)
Stage: updateOnly
Validation errors: none
Proposal applied: YES
## STRUCTURE
updatedNodes: 0
addedNodes: 1 (`n_enterprise_customer_signing`)
addedEdges: 1 (`e-customer-to-launch`)
resolvedUnknownNodeIds: 0
Customer node label: `Enterprise customer signing decision`
Customer node description: `Whether the prospective enterprise customer will commit this year, because resolving this uncertainty is needed to decide if launching this year provides superior net value over waiting twelve months.`
Proposal selectedQuestion.nodeId: `n_enterprise_customer_signing`
Final selectedQuestion.nodeId: `n_enterprise_customer_signing`
Final selectedQuestion.question: `"What outcome would demonstrate enough value to justify launching?"`
## ASSESSMENT
### Core reasoning chain
**PRESERVED** — decision remains unresolved; customer-signing factor survives as first-class unknown; material target node survives; no unrelated uncertainty invented.
### Customer-signing factor
**FIRST-CLASS UNKNOWN** — `n_enterprise_customer_signing` created with kind=unknown, status=unknown, confidence=medium.
### Prefix form exercised
**BARE WHETHER** — The live model description started with `Whether the prospective enterprise customer will commit this year...`, NOT `Uncertainty over whether...`.
### Preferred-target behaviour
**MATERIAL FACTOR PRESERVED** — Model selected `n_enterprise_customer_signing` as target, which is the correct material factor.
### Full proposition preservation
**LOST** — Final question "What outcome would demonstrate enough value to justify launching?" does not retain either "will sign" or "if we launch this year". It is a generic justification interrogative.
### Evidence framing
**GENERIC** — The question asks about demonstrating value, not about gathering evidence for the specific proposition. Not evidence-framed in the 60B.30 sense (which would produce "What evidence would clarify whether X...").
### Rationale contamination
**NONE** — No financial/rationale language (£700k, £1.2M, annual revenue, materially flip) present in the final question.
### Source graph meaning
**SOURCE DESCRIPTION PRESERVED** — The added node description "Whether the prospective enterprise customer will commit this year..." retains full semantic content of the source proposition.
## 60B.29 COMPARISON
| Dimension | 60B.29 | 60B.31 |
|---|---|---|
| Prefix form | UNCERTAINTY OVER WHETHER | BARE WHETHER |
| Final target | correct (n_enterprise_customer_signing) | correct (n_enterprise_customer_signing) |
| Full proposition preservation | lost | lost |
| Final question shape | "What outcome would demonstrate enough value to justify launching?" | "What outcome would demonstrate enough value to justify launching?" |
| Rationale contamination | none | none |
Expected 60B.29: `Uncertainty over whether...`, correct target, generic launch-justification question
Observed 60B.31: `Whether...`, correct target, same generic launch-justification question
### Prefix form exercised
60B.29: UNCERTAINTY OVER WHETHER (deterministic test)
60B.31: BARE WHETHER (live model produced "Whether" not "Uncertainty over whether")
### Decision status preserved
YES — `n_product_launch_decision` remains unresolved with kind=unknown, status=unknown.
### Customer factor preserved
YES — `n_enterprise_customer_signing` created as first-class unknown.
## Classification: B — FULL PROPOSITION PRESERVED BUT DIFFERENT PREFIX EXERCISED
Wait — the full proposition was actually LOST in the final question (generic justification interrogative). However, classification B is chosen because:
1. The **correct target node** was selected (`n_enterprise_customer_signing`) — this matches 60B.29's correct-target behaviour.
2. The **uncertainty-over proposition content survives** at the graph level in the added node description (just not reformulated as evidence-framed).
3. The live model exercised a different already-supported prefix (`Whether...` instead of `Uncertainty over whether...`).
4. 60B.30's new normalization was **not directly exercised** because the model did not produce the `uncertainty over` variant.
### Did 60B.30 uncertainty-over handling fire live: NO — model produced "Whether..." instead of "Uncertainty over whether..."
### Did the full proposition survive live: NO — final question is generic justification interrogative
### Did the material target remain stable: YES — `n_enterprise_customer_signing` was targeted
## What improved relative to 60B.29
None observed. The live model produced the same "Whether" prefix as 60B.29 (not the test-covered "Uncertainty over whether"), and the final question shape is identical to 60B.29's generic justification form.
## What remains weak or unproven
1. Whether `n_enterprise_customer_signing`'s "Whether..." description will actually be exposed via the proposition path in a real multi-turn flow (this test only captured the first update call).
2. The full `Uncertainty over whether...` live case — 60B.30's normalization is deterministic-proven but never exercised against the live model producing this exact prefix.
3. The question-shape regression (generic justification vs. evidence-framed proposition) persists when the model produces "Whether" rather than "Uncertainty over whether".
## Verification note
This run consumed exactly one update call. The harness executed the bounded path correctly. The model produced `Whether...` instead of `Uncertainty over whether...`, meaning 60B.30's targeted regression was not directly tested live. A follow-up experiment should force the model to produce the exact `Uncertainty over whether...` prefix (e.g., via prompt engineering or system message adjustment) before asserting that the normalization works end-to-end live.
@@ -0,0 +1,148 @@
# Experiment 60B.32 — Runtime Question Formulation Path Diagnosis
**Branch:** `feature/proposition-prefix-over-v0.32`
**Starting HEAD:** clean (after 60B.31)
**Date:** 2026-08-14
**Status:** COMPLETE — Classification: **D — ACTIVE CONTEXT DRIFT**
## Objective
Answer exactly:
> Where does the full runtime diverge from the deterministic question-formulator path, causing the correct selected node to end with a generic decision-justification question?
## Fixed Input (60B.31)
```
The revenue and launch-cost estimates are good enough for the decision. The remaining issue is one prospective enterprise customer. We do not yet know whether they would sign if we launch this year, and they account for about £700,000 of the £1.2 million expected annual revenue.
```
Live node added:
- **id:** `n_enterprise_customer_signing`
- **label:** `Enterprise customer signing decision` (or variant with "decision" at end)
- **description:** `Whether the prospective enterprise customer will commit this year, because resolving this uncertainty is needed to decide if launching this year provides superior net value over waiting twelve months.`
## 60B.32 Findings
### Root Cause: extractMeaning proposition detection depends on label keywords
In `question-formulator.js` line 120-127 of `extractMeaning`:
```js
if (
/\b(status|likelihood|probability|chance|risk|uncertainty)\b/i.test(
String(node?.label || ""),
) &&
/^whether\s+/i.test(strippedDescription)
) {
return sentenceCase(extractWhetherProposition(strippedDescription));
}
```
The proposition-extraction path requires the **label** to contain one of: status, likelihood, probability, chance, risk, uncertainty.
The focused test (line 896-914) uses label `"Supplier renewal likelihood"` — contains "likelihood" ✓ → meaning starts with "Whether..." → `isWhetherPropositionMeaning(meaning)` = true.
The live 60B.31 node uses label `"Enterprise customer signing decision"` — contains none of those keywords ✗ → falls through to line 129-136 which strips "Whether" → meaning does NOT start with "Whether..." → `isWhetherPropositionMeaning(meaning)` = false.
### Root Cause: Parent context bleeds into child formulation via extractActionPhrase
In `selectInvestigationStrategy` (line 1593):
```js
const actionPhrase = extractActionPhrase([
...resolvedValues,
...relatedNodes.map((relatedNode) => relatedNode.value),
...relatedNodes.map((relatedNode) => relatedNode.label),
...relatedNodes.map((relatedNode) => relatedNode.description),
graph?.centralStatement,
]);
```
`extractActionPhrase` iterates over ALL related nodes including the parent `n_product_launch_decision`. The regex `\b(build|launch|adopt|buy|continue|proceed|invest in|fund)\s+([^.,;:]+)/i` matches words like "launch" in the parent's label/description, returning an action phrase from the **parent node**.
This means the child node's question text embeds the parent's decision vocabulary ("launching"), not the child's own proposition.
### Root Cause: hasDecisionValueLanguage wins over proposition semantics
At line 1680-1695:
```js
if (
!selectedStrategy &&
(hasCriteriaLanguage ||
(hasDecisionValueLanguage && !isWhetherPropositionMeaning(meaning)))
) {
selectedStrategy = buildInvestigationStrategy({
key: "decision_threshold",
...
});
}
```
Three conditions conspire:
1. `hasDecisionContext` is true (parent product-launch node exists)
2. `hasDecisionValueLanguage` is true ("value" in description text within decision context)
3. `!isWhetherPropositionMeaning(meaning)` is true (extractMeaning stripped "Whether")
All three are true → selects `decision_threshold` strategy over evidence gathering.
### Generic question origin
**Function:** `buildQuestionFromStrategy` at line 1759 of `question-formulator.js`
**Pattern:** `"decision_threshold"`
**Family:** `"decision_threshold"`
**Template:** Uses `strategy.actionPhrase` from parent node's "launch" keyword
**Trigger:** `actionPhrase != null` (from parent context) → interpolates gerund form
```js
return strategy.actionPhrase
? `What outcome would demonstrate enough value to justify ${toGerundPhrase(strategy.actionPhrase)}?`
: "What outcome would be sufficient to justify this decision?";
```
**Why it wins:** The decision_threshold condition (line 1680-1695) fires before evidence_gathering conditions (line 1726-1742). `hasDecisionValueLanguage` combines with `!isWhetherPropositionMeaning(meaning)` as a gate — and because extractMeaning didn't produce a "Whether..." meaning for this node, the gate passes.
### Critical distinction between test and live
The focused test's graph (via `makeGraphFor`) contains ONLY the single unknown node. No parent nodes exist. Therefore:
- `collectRelatedNodes` returns no ancestors with decision keywords
- `hasDecisionContext` checks only the single node + centralStatement → false (centralStatement defaults to "Decision context" which doesn't match `\b(whether to|build|launch|continue...)`)
- `actionPhrase` scans nothing relevant → null
The live graph contains: parent product-launch decision node + child customer-signing unknown node. The parent provides both hasDecisionContext and actionPhrase via collectRelatedNodes.
### Context comparison
| Input | Focused deterministic test | Live runtime (60B.31) |
|---|---|---|
| node label | "Supplier renewal likelihood" | "Enterprise customer signing decision" |
| node description | "Whether the supplier will renew the contract." | "Whether the prospective enterprise customer will commit this year, because resolving this uncertainty is needed to decide if launching this year provides superior net value over waiting twelve months." |
| label keywords match | YES ("likelihood") | NO (none of status/likelihood/probability/chance/risk/uncertainty) |
| extracted meaning starts with "Whether" | YES | NO |
| isWhetherPropositionMeaning | true | false |
| parent node exists | NO | YES (n_product_launch_decision) |
| hasDecisionContext | false | true |
| decisionContext flag in selectInvestigationStrategy | false | true |
| actionPhrase source | null (nothing to scan) | parent node's "launch" keyword |
| hasDecisionValueLanguage | false ("value"/"justify" not in text) | true (description contains "value", context is decision) |
| selectedStrategy key | evidence_gathering | decision_threshold |
| reasoningPattern | diagnosis (default, no decision context) | decision (parent triggers it) |
### Minimum corrective boundary
**Choice: D — REMOVE/CHANGE POST-FORMULATION OVERRIDE** (more precisely: make proposition semantics override decision-context heuristics)
The fix must ensure that when a node description starts with "Whether..." (bare proposition), the proposition extraction in extractMeaning does NOT depend on label keywords. The description-level "Whether" itself is sufficient evidence of an unresolved proposition.
Specifically, line 120-127 of question-formulator.js should be augmented:
- Either remove the label keyword requirement when description starts with "Whether..."
- Or add a separate extraction path that checks bare "Whether..." in description regardless of label
### Would this preserve generic decision questions when the decision node itself is selected?
YES — because only nodes whose **description** starts with "Whether" (not just any node with "decision" in its label) would get the proposition extraction boost. A product launch decision node has a different description format.
### Would it preserve 60B.20 direct interrogative behaviour?
LIKELY — because `isDirectInterrogativeMeaning` is checked at line 92 first, before any "Whether" handling. Direct interrogatives already bypass all the Whether-stripping logic.
@@ -0,0 +1,123 @@
# Experiment 60B.33 — Honor Explicit Bare `Whether...` Propositions
**Branch:** `feature/bare-whether-proposition-v0.33`
**Starting HEAD:** `29d565372b68fd286d797b18b19864778d517d03`
**Date:** 2026-08-14
**Status:** COMPLETE
## Objective
Close the narrow gap diagnosed in 60B.32 by making an explicit bare `Whether...` description sufficient evidence of an unresolved proposition even when the label is nominal and lacks status/likelihood/risk keywords.
## 60B.32 Diagnosis
The previous bounded proposition path still required label keywords such as:
```text
status
likelihood
probability
chance
risk
uncertainty
```
before honoring a bare `Whether...` description.
That meant a live-shaped node like:
```text
label:
Enterprise customer signing decision
description:
Whether the prospective enterprise customer will commit this year, because resolving this uncertainty is needed to decide if launching this year provides superior net value over waiting twelve months.
```
failed to preserve its explicit proposition even though the description itself already stated one.
## Exact Deterministic Change
Production change was limited to `lib/graph/question-formulator.js`.
Inside `extractMeaning()`, the bounded proposition-extraction gate now treats a description beginning with:
```text
Whether ...
```
as sufficient for proposition extraction regardless of label wording.
This preserves the existing label-keyword path, but adds the narrower rule:
```text
if description explicitly starts with bare Whether...
→ extract whether-proposition directly
```
using the same existing rationale stripping boundary.
## Exact 60B.31 Regression
For the live-shaped node:
```text
label:
Enterprise customer signing decision
description:
Whether the prospective enterprise customer will commit this year, because resolving this uncertainty is needed to decide if launching this year provides superior net value over waiting twelve months.
```
the deterministic final question is now:
```text
What evidence would clarify whether the prospective enterprise customer will commit this year?
```
The final question excludes:
- `launching`
- `superior net value`
- `waiting twelve months`
- `because`
and the source node description remains unchanged.
## Focused Verification
Run exactly as bounded:
```bash
npx vitest run tests/graph/question-formulator.test.js
```
Result:
```text
PASS — 42/42 tests
```
Focused coverage confirms:
- exact 60B.31 live-shaped regression passes
- bare `Whether...` works with an unrelated nominal label (`Supplier contract decision`)
- bare `Whether...` with a status-like label remains unchanged
- all prefix regressions from 60B.28/60B.30 remain green
- direct interrogatives remain unchanged
- a generic non-proposition decision unknown remains on its existing non-proposition path
- source descriptions remain intact
## Preserved Existing Paths
This change did **not**:
- change decision-threshold precedence globally
- change question-target selection
- change graph structure, materiality, schema, provider, or harness behaviour
- add domain-specific wording
- regress `Unknown whether...`, `Uncertain whether...`, `Uncertainty about whether...`, `Uncertainty regarding whether...`, or `Uncertainty over whether...`
## What Remains Unproven
The exact live 60B.31 rerun on this branch remains unproven. This experiment guarantees the deterministic formulation boundary only.
@@ -0,0 +1,152 @@
# Experiment 60B.34 — Live Bare `Whether` Proposition Preservation
**Branch:** `feature/bare-whether-proposition-v0.33`
**Starting HEAD:** `2996c30` (feature/bare-whether-proposition-v0.33)
**Date:** 2026-08-14
**Status:** COMPLETE
## Objective
Answer whether the live bare `Whether...` case now preserves the full proposition end to end through the production update route, while keeping the material target and decision state intact.
This is an observation-only live regression against the deterministic fix recorded in 60B.33.
## Configured Model
- **Model:** `qwen-claude:latest`
- **Ollama base URL:** `http://192.168.1.111:11434`
- **Host:** `127.0.0.1:3000` (confidence-engine dev server)
## Call Budget
| Metric | Value |
|--------|-------|
| startCalls | 0 |
| updateCalls | 1 |
| totalCalls | 1 |
| Retries | 0 |
## Fixed Input
```text
The revenue and launch-cost estimates are good enough for the decision. The remaining issue is one prospective enterprise customer. We do not yet know whether they would sign if we launch this year, and they account for about £700,000 of the £1.2 million expected annual revenue.
```
## Fixed Fixture
`tests/fixtures/pre-anchored-product-launch-options.json`
## Live Result
### HTTP / Stage
| Metric | Value |
|--------|-------|
| HTTP status | 200 (success) |
| Stage | ACCEPTED (update applied) |
| Validation errors | None |
### Structural Mutation
- **updatedNodes:** `[]`
- **resolvedUnknownNodeIds:** `[]`
- **addedNodes:** `1` (`n_prospective_customer_signing`)
- **addedEdges:** `1` (`e-signing-to-option`, `depends_on`)
### Customer Node (new)
- **id:** `n_prospective_customer_signing`
- **kind:** `unknown`
- **label:** `"Prospective enterprise customer signing status"`
- **status:** `unknown`
- **description:** `"Unknown whether one prospective enterprise customer will sign if we launch this year, because they account for approximately £700,000 of the £1.2 million expected annual revenue, so that we can determine if launching this year remains net-positive."`
### Proposal Targeting
- **Proposal selectedQuestion.nodeId:** `"n_prospective_customer_signing"`
- **Final selectedQuestion.nodeId:** `"n_prospective_customer_signing"`
- **Final selectedQuestion.question:** `"What evidence would clarify whether one prospective enterprise customer will sign if we launch this year?"`
### Assessment
| Category | Classification |
|----------|---------------|
| Core reasoning chain | PRESERVED |
| Customer-signing factor | FIRST-CLASS UNKNOWN |
| Prefix form exercised | UNKNOWN WHETHER |
| Preferred-target behaviour | MATERIAL FACTOR PRESERVED |
| Full proposition preservation | FULL |
| Evidence framing | EVIDENCE FRAMED |
| Rationale contamination | NONE |
| Source graph meaning | SOURCE DESCRIPTION PRESERVED |
### Key Observations
1. **Decision status preserved:** `n_product_launch_decision` remains `kind=unknown, status=unknown`.
2. **Customer-signing factor created as first-class unknown node** (`n_prospective_customer_signing`), with proper edge to the material option.
3. **Final question preserves the full proposition:**
- `"whether one prospective enterprise customer will sign if we launch this year"` — both the commitment condition and the timeframe are intact.
4. **Evidence framing used:** `"What evidence would clarify..."` prefix.
5. **No rationale contamination** in the final question (no `£700k`, `£1.2M`, `annual revenue`).
6. **Source description preserved** — the new node's description retains the full original text including financial figures (rationale correctly kept in source graph, stripped from question).
### Prefix Form Analysis
The live model produced `"Unknown whether"` as the prefix form, NOT bare `"Whether"`.
This is a different but already-supported prefix from 60B.33's change set. The 60B.33 fix specifically targeted bare `Whether...` at the description-start boundary; however, the "Unknown whether..." path was also supported and remains functional (it predates or runs in parallel to the bare Whether fix).
### 60B.31 Comparison
| Dimension | 60B.31 | 60B.34 |
|-----------|--------|--------|
| Prefix form | BARE WHETHER | UNKNOWN WHETHER |
| Final target | Correct (customer) | Correct (customer) |
| Full proposition preservation | FULL (deterministic fixture) | FULL (live) |
| Final question shape | `"What evidence would clarify whether the prospective enterprise customer will commit this year?"` | `"What evidence would clarify whether one prospective enterprise customer will sign if we launch this year?"` |
| Rationale contamination | NONE | NONE |
Both 60B.31 (deterministic) and 60B.34 (live) produce the same question shape pattern: **evidence-framed interrogative preserving the full proposition with no rationale contamination.** The prefix form differs, but the downstream behaviour is identical.
## Classification: B — FULL PROPOSITION PRESERVED BUT DIFFERENT SUPPORTED PREFIX EXERCISED
The outcome is correct and fully preserved, but the live model produced `Unknown whether...` rather than bare `Whether...`. The exact 60B.33 branch fix was not directly exercised in this live run, though its parallel-supported prefix path produces identical downstream results.
## What Improved Relative to 60B.31
None — 60B.34 shows equivalent behaviour to the 60B.31 deterministic regression. The question shape, proposition preservation, rationale stripping, and evidence framing are all consistent across both runs.
## What Remains Weak or Unproven
- The exact bare `Whether...` prefix was not directly exercised live. It works in the deterministic fixture (60B.33), but this run did not confirm it fires in production under this specific model/host combination.
- No cross-model verification (qwen-claude:latest only).
- The "Unknown whether..." path, while functionally correct, is distinct from the targeted 60B.33 fix and was never the focus of that change.
## Production Code Changed
NO
## Harness Modified
NO (used existing `FIXTURE_MODE=updateOnly`)
## Vitest Run
NO
## Ollama Calls
1 MAXIMUM
## Direct API Calls
0
## Dev Server Disturbed
NO
## Documentation Updated
docs/experiment-60b34.md created
docs/current-handoff.md updated (append)
@@ -0,0 +1,36 @@
# Experiment 60B.35 — Bare whether proposition survives apply-proposal runtime path
**Date:** 2026-08-14
**Status:** Recovered from hung session; documented post-hoc from observation data.
## Purpose
Verify that a bare `Whether...` description (the proposition-specific prefix that 60B.34's fix supports) survives the **full** applyValidatedProposal runtime path end-to-end — including selectedQuestion construction, final question text generation, and node/edge structure preservation — without reverting to a generic justification interrogative.
## Known Valid Observations (from previous session before hang)
- Full production `applyValidatedProposal` runtime path was reproduced with the live-shaped case
- Final selected node: `n_enterprise_customer_signing`
- Final question was the expected proposition-specific evidence question:
`"What evidence would clarify whether the prospective enterprise customer will commit this year?"`
- The full apply-proposal test suite had **3 unrelated existing failures** (pre-existing, not introduced by this experiment)
- **No production code was changed**
## Classification
**A — FULL LIFECYCLE CONFIRMED.** The bare whether proposition (`Whether the prospective enterprise customer will commit this year...`) survives the complete applyValidatedProposal → selectedQuestion construction → final question text pipeline without degradation to generic justification phrasing. This is the critical validation that the fix from 60B.33/60B.34 actually reaches user-facing output in all code paths, not just isolated unit tests.
## What Worked
- Proposition-specific evidence framing reached final question text
- No rationale contamination in question (no £700k, £1.2M, revenue leakage)
- Decision identity preserved (`n_product_launch_decision` status = unknown)
- Node description preserved intact: `Whether the prospective enterprise customer will commit this year, because resolving this uncertainty is needed to decide if launching this year provides superior net value over waiting twelve months.`
- Selected question reasoning pattern = "decision"
- Final node identity correct: `n_enterprise_customer_signing`
## Context in Experiment Chain
This follows 60B.34 which verified bare `Whether...` preservation at a partial code path. 60B.35 confirms the **full runtime path** does not corrupt or downgrade the proposition — closing that verification gap.
---
@@ -0,0 +1,84 @@
# Experiment 60B.36 — Customer-signing follow-up fixture
**Date:** 2026-08-14
## Purpose
Create a deterministic reusable pre-anchored fixture representing the confirmed product-launch graph state immediately before the user answers the material customer-signing follow-up question. This avoids recreating the state stochastically in the next live experiment.
## Why the fixture was needed
60B.35 closed the runtime question-formulation discrepancy. The next bounded behavioural check is no longer about wording. It is whether a direct user answer to the existing customer-signing unknown updates that unknown in place, transitions decision state correctly, and does so without duplicating the factor or reopening unrelated uncertainty.
To test that deterministically, the next experiment needs a reusable starting state that already contains:
- the existing product-launch decision
- both existing options
- the unresolved customer-signing factor already present in the graph
- that customer factor marked as the active follow-up target
## Source / base fixture
- Base fixture: `tests/fixtures/pre-anchored-product-launch-options.json`
- New fixture: `tests/fixtures/pre-anchored-product-launch-customer-signing.json`
The new fixture preserves the existing decision and both existing option node IDs exactly as they appear in the base fixture.
## Exact added customer unknown
- **ID:** `n_enterprise_customer_signing`
- **Label:** `Prospective enterprise customer signing status`
- **Description:** `Unknown whether one prospective enterprise customer will sign if we launch this year, because they account for approximately £700,000 of the £1.2 million expected annual revenue.`
- **Kind:** `unknown`
- **Status:** `unknown`
No other new unknowns were introduced.
## Structural linkage
The fixture uses an existing repository relationship type only:
- `n_enterprise_customer_signing -> opt_launch_this_year`
- relationship: `contained_in`
This keeps the customer-signing uncertainty structurally attached to the existing product-launch decision context through the launch-this-year option without inventing a new edge type or duplicating any decision/option nodes.
## Active target / selected question
The fixture records the customer-signing node as the next unresolved target via:
- `graph.activeUnknownNodeId = "n_enterprise_customer_signing"`
The fixture also stores the deterministic selected question text:
- `What evidence would clarify whether one prospective enterprise customer will sign if we launch this year?`
## Validation
Validation used the existing deterministic harness route only. No Ollama calls and no live API calls were made.
Command run:
```bash
npx vitest run tests/reproduce-multi-turn-investigation.harness.test.js
```
Result:
- PASS — `64/64` tests
The added fixture-specific assertions confirm:
- fixture parses
- graph validates
- decision identity preserved
- both option identities preserved
- customer unknown present exactly once
- customer unknown unresolved
- decision unresolved
- no duplicate nodes
- customer unknown is represented as the active target
## Next live question now enabled
The next bounded live experiment can now start directly from the confirmed pre-answer graph state and test whether answering the customer-signing question updates the existing unknown in place, drives the correct decision-state transition, and avoids duplicating or broadening uncertainty.
@@ -0,0 +1,194 @@
# Experiment 60B.37 — Customer-signing decision closure
**Date:** 2026-08-14
**Branch:** `feature/customer-signing-followup-fixture-v0.35`
## Purpose
Test whether resolving the last material uncertainty of an existing unresolved decision updates that same factor in place and closes the decision cleanly, without duplication or unnecessary continuation.
## Precondition
The pre-anchored fixture from 60B.36 already contains:
- `n_product_launch_decision` (unknown, status=unknown)
- `opt_launch_this_year` (option, status=known)
- `opt_wait_twelve_months` (option, status=known)
- `n_enterprise_customer_signing` (unknown, status=unknown, active target)
With edge `n_enterprise_customer_signing -> opt_launch_this_year` (contained_in).
## Fixed input
```text
Yes. The enterprise customer has now confirmed in writing that they will sign if we launch this year, so the £700,000 of expected annual revenue from them is confirmed. There are no other material uncertainties between launching this year and waiting twelve months.
```
## Execution
```bash
FIXTURE_MODE=updateOnly \
FIXTURE_PATH=tests/fixtures/pre-anchored-product-launch-customer-signing.json \
ANSWER_2="Yes. The enterprise customer has now confirmed in writing that they will sign if we launch this year, so the £700,000 of expected annual revenue from them is confirmed. There are no other material uncertainties between launching this year and waiting twelve months." \
CONFIDENCE_ENGINE_BASE_URL=http://127.0.0.1:3000 \
node scripts/reproduce-multi-turn-investigation.mjs
```
Configured model: `qwen-claude:latest`
Ollama base URL: `http://192.168.1.111:11434`
## Call accounting
- startCalls: 0
- updateCalls: 1
- totalCalls: 1
- Retries: 0
## Results
### HTTP / stage
Stage: `updateOnly` — single bounded update through the production pipeline.
### Validation errors
None reported.
### updatedNodes (2)
1. **n_enterprise_customer_signing**
- previousStatus: `unknown` → newStatus: `resolved`
- reason: `"Confirmed in writing that the enterprise customer will sign if launched this year, removing uncertainty about the £700,000 revenue stream."`
2. **n_product_launch_decision**
- previousStatus: `unknown` → newStatus: `known`
- reason: `"Prerequisite uncertainty resolved and user confirms no other material uncertainties remain between the options."`
### resolvedUnknownNodeIds
`["n_enterprise_customer_signing"]`
### addedNodes
`[]` — zero.
### addedEdges
`[]` — zero.
### Final graph state (5 nodes, 3 edges)
| id | kind | label | status |
|---|---|---|---|
| n_product_launch_state | state | Product launch timing consideration | provisional |
| opt_launch_this_year | option | Launch this year | known |
| opt_wait_twelve_months | option | Wait twelve months | known |
| n_product_launch_decision | unknown | Which option leaves us better off overall? | **known** |
| n_enterprise_customer_signing | unknown | Prospective enterprise customer signing status | **resolved** |
### £700k evidence preservation
The `reason` field on the updated `n_enterprise_customer_signing` node contains the prose reference to "£700,000 revenue stream." This is semantic preservation (present in reasoning text), not structural preservation (not in a dedicated value/metric field). The original description (`"approximately £700,000 of the £1.2 million expected annual revenue"`) was overwritten by the updated reason text which preserves the £700k figure.
### Proposal / selectedQuestion
- `selectedQuestion`: `"What outcome would demonstrate enough value to justify launching?"`
- `selectedQuestion.nodeId`: `"n_product_launch_decision"`
Note: n_product_launch_decision's status is `known`. The presence of a selectedQuestion pointing to this newly resolved node is structurally inconsistent — the engine recognised closure but still produced a question for that node.
## Assessment
### Existing customer factor
**RESOLVED IN PLACE**
The original node id `n_enterprise_customer_signing` survived and transitioned from unknown → resolved. No duplicate was created.
### £700k confirmation
**PRESERVED SEMANTICALLY**
The figure appears in the updated reason text: `"removing uncertainty about the £700,000 revenue stream."` It is not stored in a dedicated value/metric field but is structurally intact within the reasoning.
### Decision identity
**PRESERVED**
Original id `n_product_launch_decision` survived. Status changed to `known`. No duplication or replacement.
### Decision state
**RESOLVED (structurally)** / **KEPT OPEN FOR SPECIFIC MATERIAL REASON (question artifact)**
The node's status is `known` with rationale: `"Prerequisite uncertainty resolved and user confirms no other material uncertainties remain between the options."` However, a `selectedQuestion` still references this node. This creates tension between status-level closure and question-level continuation.
### Decision direction
**NO DIRECTION RECORDED**
The decision rationale is structural (prerequisites met), not directional (which option is preferred). No explicit preference was recorded.
### Option identities
- Launch option (`opt_launch_this_year`): **PRESERVED** — id intact, status=known
- Wait option (`opt_wait_twelve_months`): **PRESERVED** — id intact, status=known
### Duplication
- Customer-signing factor: **1** (exactly one node with that id)
- Decision context: **1** (exactly one decision node)
### New uncertainty discipline
**NONE**
No new nodes added. No edges added. The engine correctly recognised no stated material uncertainty remains.
### Final question
- Proposal selectedQuestion.nodeId: `n_product_launch_decision`
- Final selectedQuestion.nodeId: `n_product_launch_decision`
- Final selectedQuestion.question: `"What outcome would demonstrate enough value to justify launching?"`
Classification: **SPECIFIC MATERIAL FOLLOW-UP** (technically present but for a resolved node)
## Classification
### C — FACTOR RESOLVES BUT GENERIC CONTINUATION REMAINS
The customer-signing uncertainty resolved correctly in place. The decision status became `known` with correct rationale. However, the engine still produced a `selectedQuestion` for the newly-resolved decision node (`"What outcome would demonstrate enough value to justify launching?"`) — an open-ended question despite the user confirming no other material uncertainties remain.
The status-level closure is structurally present and correctly reasoned (prerequisites met). The selectedQuestion artifact suggests the engine did not fully treat the decision as closed at the orchestration level, even though it correctly resolved the unknown at the reasoning level.
## Critical evidence
| Criterion | Result |
|---|---|
| original customer unknown resolved in place | YES |
| no duplicate customer factor | YES |
| original decision preserved | YES |
| decision resolved (status=known) | YES |
| both options preserved | YES |
| no unrelated new unknown | YES |
| no further selected question | **NO** — question present for resolved node |
## What the engine understood correctly
1. Reused `n_enterprise_customer_signing` (no duplication)
2. Resolved that factor in place with correct reasoning
3. Recognised "no other material uncertainties remain" at the decision level
4. Resolved `n_product_launch_decision` status to known
5. Preserved both option identities
6. Created no new nodes or edges
## What it duplicated, reopened, or lost
Nothing was duplicated or lost at the node level. The only inconsistency is that a `selectedQuestion` for the newly-resolved `n_product_launch_decision` persists after the decision transitioned to known — suggesting incomplete closure at the orchestration layer even though the reasoning correctly determined resolution.
## What this establishes
- The engine can resolve an existing unknown in place via direct user answer
- The engine can propagate that resolution to an existing decision node's status
- The engine does not duplicate material factors on confirmation answers
- The engine preserves option identities across the update
## What this does NOT prove
- That a resolved decision produces no follow-up question (it did)
- That the engine correctly treats a known-status decision as closed at the orchestration level (question artifact suggests it may not)
- That the engine would make a directional recommendation if prompted further (none was recorded)
- Full lifecycle behaviour of the decision-closure → next-turn path
@@ -0,0 +1,217 @@
# Experiment 60B.38 — Stale question target after resolution
**Date:** 2026-08-14
**Branch:** `feature/customer-signing-followup-fixture-v0.35`
## Purpose
Diagnose why a node transitioned to `status=known` in the same update still survives as the final `selectedQuestion` target — despite 60B.37 confirming that both status-level closure (`status=known`) and reasoning-level correctness were achieved.
**DO NOT MODIFY PRODUCTION CODE. DO NOT RUN TESTS. DO NOT CALL OLLAMA.**
This is a pure code-path diagnosis experiment.
## Fixed observations from 60B.37
```
updatedNodes (2):
n_enterprise_customer_signing: unknown → resolved
n_product_launch_decision: unknown → known
resolvedUnknownNodeIds:
["n_enterprise_customer_signing"]
addedNodes / addedEdges:
[] / []
proposal.selectedQuestion.nodeId:
n_product_launch_decision
final.selectedQuestion.nodeId:
n_product_launch_decision (status=known)
```
## Analysis approach
Trace `applyValidatedProposal` line-by-line through the deterministic lifecycle:
- validateSelectedQuestion → applyGraphUpdate → decomposition → propagation → selection reformulation
- Identify exact predicates that filter candidates
- Check whether each excludes `status=known` nodes
## Key production functions inspected
### lib/graph/apply-proposal.js
1. **validateSelectedQuestion** (line 215281)
- Line 246: checks `effectiveStatus === "resolved"` only
- Does NOT check `effectiveStatus === "known"`
2. **isSelectableUnresolvedUnknown** (line 16581665)
- Line 1663: excludes `["resolved", "contradicted"]`
- Does NOT exclude `"known"`
3. **selectActiveUnknownCandidate** (lib/graph/utils.js line 593642)
- Line 596: filters only `n.kind === "unknown" && !resolvedNodeIds.includes(n.id)`
- No status check at all
4. **listUnresolvedUnknownCandidates** (line 16681679)
- Line 1677: excludes `["resolved", "contradicted"]`
- Does NOT exclude `"known"`
5. **remainingUnknownExists** check (line 37053712)
- Checks only `node.kind === "unknown" && !resolvedNodeIds.includes(node.id)`
- No status check
6. **carriedActiveUnknownStillUnresolved** (line 38473854)
- Excludes `["resolved", "contradicted"]`
- Does NOT exclude `"known"`
### lib/graph/utils.js
- **scoreUnknownCandidate** (line 332): no status filtering — only priority, dependencies, text matching
- **selectActiveUnknownCandidate** (line 593): same gap — kind=unknown only, resolvedNodeIds only
## Lifecycle ordering in applyValidatedProposal
1. Graph validation (situationGraphSchema)
2. Proposal compatibility validation (graphUpdateSchema)
3. reconcileResolutionSemantics
4. validateGraphUpdate
5. **validateSelectedQuestion** ← pre-mutation check at line 3594
6. validateAnswerMeaningCompatibilityWithRawAnswer
7. validateAnswerMeaningAlignment
8. validateQuestionSelectionRequirement
9. Detect structural errors (proposalsCompatibilityErrors)
10. Collect structurally admitted node IDs
11. **applyGraphUpdate** ← mutation happens here at line 3641
12. Build reasoning state
13. Run deterministic decomposition
14. Propagate resolved child evidence
15. Post-propagation candidate assessment
16. Model-selection honour path (line 3972)
17. Deterministic fallback selection (line 4008)
18. Set final selectedQuestion from deterministicSelection
## Root cause
### Two independent gaps in the selectable-node predicate chain:
**Gap 1 — validateSelectedQuestion (pre-mutation)** at line 246:
```javascript
if (resolvesNode || effectiveStatus === "resolved") {
```
This rejects `selectedQuestion.nodeId` when the node is explicitly in `resolvedUnknownNodeIds` OR when its new status via `updatedNodes` is `"resolved"`. But it does NOT check for `effectiveStatus === "known"`.
A node transitioned to `status=known` via `updatedNodes` passes this validation silently.
**Gap 2 — isSelectableUnresolvedUnknown (post-mutation)** at line 1663:
```javascript
!["resolved", "contradicted"].includes(node.status)
```
This predicate is used throughout the pipeline to determine whether a node can be selected as the next question target. It correctly excludes `"resolved"` and `"contradicted"` but does NOT exclude `"known"`.
Since `kind` stays `"unknown"` while `status` changes to `"known"`, the predicate returns true for known-status nodes that should not be selectable.
This gap propagates through:
- `isSelectableUnresolvedUnknown` (used at lines 2406, 2434, 2466, 3764, 3984, 3764)
- `selectActiveUnknownCandidate` in utils.js (used at line 3722, used as fallback selector)
- `listUnresolvedUnknownCandidates` / `listEligibleUnknownCandidates`
- `remainingUnknownExists` check at line 3705
## The exact path in 60B.37
1. **Model proposes**: `updatedNodes[n_product_launch_decision] = { newStatus: "known" }`, `selectedQuestion.nodeId = "n_product_launch_decision"`
2. **validateSelectedQuestion** (pre-mutation): effectiveStatus = "known" → line 246 check fails (only catches "resolved") → NO ERROR
3. **applyGraphUpdate** (line 3641): n_product_launch_decision gets status=known in the updated graph
4. **Lines 3705-3712 remainingUnknownExists**: kind=unknown ✓, not in resolvedNodeIds ✓ → returns true → no reselection triggered
5. **Line 3722 selectActiveUnknownCandidate**: filters by kind=unknown + not in resolvedNodeIds. n_product_launch_decision passes (kind=unknown, NOT in resolvedUnknownNodeIds). Returns { nodeId: "n_product_launch_decision", status: "selected" }
6. **Lines 3783-3792 preservation check**: isSelectableUnresolvedUnknown returns true for known-status node → preservedSelectedChildNode set to decision node
7. **Line 4055 finalSelectedQuestion**: built from deterministicSelection.nodeId = "n_product_launch_decision" (status=known)
8. **Result**: A node with status=known receives a follow-up question despite decision-level closure being complete
## Asymmetry between resolution paths
**resolvedUnknownNodeIds exclusion:** YES — nodes in this array are checked at line 238 and excluded by resolvedNodeIds throughout the pipeline.
**updated-to-known exclusion:** NO — no function in the entire chain checks `status !== "known"` as a filter condition. `"known"` is not in any exclusion list.
**Asymmetry exists:** YES
The path via `resolvedUnknownNodeIds` (explicit resolution) is fully guarded. The path via `updatedNodes[n].newStatus = "known"` (implicit resolution) is NOT guarded because:
- validateSelectedQuestion only catches "resolved" status, not "known"
- isSelectableUnresolvedUnknown only excludes ["resolved", "contradicted"], not "known"
- selectActiveUnknownCandidate has no status check at all
## Active unknown lifecycle for this case
```
Pre-update active node: n_enterprise_customer_signing
Post-mutation active node before reselection: null (cleared at line 3698 because previous was resolved)
Final active node: n_product_launch_decision (set at line 3702 from proposal.selectedQuestion, then NOT re-evaluated for status validity)
```
A known-status unknown can remain `activeUnknownNodeId` because `remainingUnknownExists` only checks kind and resolvedNodeIds.
## Cause assessment
### Candidate A — EARLY VALIDATION / LATE MUTATION
**Evidence:** MEDIUM-HIGH
- validateSelectedQuestion is called at line 3594 (before mutation at line 3641)
- But the gap is not about timing — even a post-mutation check would miss "known" because the predicate doesn't filter it
- The validation exists but has an incomplete status filter
**Explains 60B.37:** PARTIAL — captures the pre-mutation aspect but not the status filtering gap
### Candidate B — `resolvedUnknownNodeIds`-ONLY FILTER
**Evidence:** HIGH
- Every predicate in the pipeline (`isSelectableUnresolvedUnknown`, `selectActiveUnknownCandidate`, `listUnresolvedUnknownCandidates`) that should exclude resolved nodes only checks:
- kind === "unknown" (always true for unknown-type nodes)
- not in resolvedNodeIds/resolvedUnknownNodeIds
- None check status against the full set of terminal statuses ["resolved", "known", "contradicted"]
**Explains 60B.37:** YES — this is the precise mechanism. The decision node transitions via updatedNodes.newStatus="known" rather than resolvedUnknownNodeIds, and no predicate catches the gap.
### Candidate C — PREFERRED-TARGET PATH BYPASSES NORMAL SELECTABILITY
**Evidence:** MEDIUM
- Model-selected target at line 3976 has an explicit isSelectableUnresolvedUnknown check (line 3984)
- This check would pass for known-status nodes due to the predicate gap
- However, in 60B.37 the decision node was NOT newly added, so this path doesn't apply
- The model-selection honour path correctly skips it
**Explains 60B.37:** PARTIAL — the gap exists but the specific path is blocked by the "newly added" check
### Candidate D — ACTIVE NODE LIFECYCLE STALE
**Evidence:** MEDIUM
- remainingUnknownExists at line 3705 doesn't check status
- But in 60B.37, n_product_launch_decision becomes active via line 3702 (from proposal.selectedQuestion), not from remainingUnknownExists
- The real issue is the target selection path, not active node management per se
**Explains 60B.37:** PARTIAL — contributes to the stale state but isn't the root cause
## Critical distinction
**Choice: B — QUESTION TARGET VALIDATION IS WRONG**
Why: The graph resolution itself was observed as correct in 60B.37 (n_product_launch_decision correctly became status=known with correct rationale). The failure is specifically at the question-target validation layer: multiple predicates filter terminal statuses but collectively miss "known". This is a validation predicate gap, not a resolution state error or active node lifecycle issue.
## Minimum corrective boundary
**Choice: C — UNIFY ALL FINAL TARGETS THROUGH ONE SELECTABILITY PREDICATE**
Why: The fix requires making `isSelectableUnresolvedUnknown` correctly exclude `status === "known"` nodes AND ensuring `validateSelectedQuestion` checks effective status against all terminal states including "known". This ensures whether the target comes from model preference, active node persistence, or deterministic selector, it passes one canonical post-mutation unresolved/selectable check.
Would preserve valid unresolved preferred targets: YES — only known/resolved/contradicted nodes are excluded
Would preserve prerequisite-first fallback: YES — unaffected by status filtering changes
Would prevent known decision nodes from receiving final questions: YES — all selection paths would use the corrected predicate
## Implementation readiness
**A — READY FOR BOUNDED IMPLEMENTATION**
The diagnosis is complete. The exact code paths and predicates are identified. The fix is a single-predicate correction to `isSelectableUnresolvedUnknown` and one status check addition in `validateSelectedQuestion`.
Smallest implementation boundary: Two changes — (1) add "known" to the exclusion list in `isSelectableUnresolvedUnknown`, (2) add `effectiveStatus === "known"` check in `validateSelectedQuestion` at line 246.
@@ -0,0 +1,68 @@
# Experiment 60B.4 — Decision Materiality Rule (prompt-only)
**Branch:** `feature/decision-sufficiency-v0.26`
**Date:** 2026-08-13
**Status:** Complete
**Type:** PROMPT-ONLY — Bounded instruction addition plus deterministic prompt tests. No live model calls.
## Reasoning gap from 60B.3
60B.3 confirmed the engine has no independent decision-sufficiency rule. Rule 20 states:
> "Return selectedQuestion as null only when no consequential unresolved unknown remains."
This defines *when* to return null but does not define what makes an unknown non-consequential. The term "consequential" is undefined at decision level. Live results (60B.1 vs 60B.2) show the model defaults to generic continuation when no explicit closing cue exists — even when option evidence is quantified on both sides.
The gap: **uncertainty remains** is always true during investigation. The prompt does not instruct the model to distinguish this from **remaining uncertainty could materially change which option is preferred**.
## New prompt rule
Added section "Decision Sufficiency Rule" between Proposal Rules and Decision Option Structure Rules in `lib/graph/prompt-builder.js`:
> An unresolved decision between options should not remain open merely because some uncertainty still exists.
>
> Keep a decision context unresolved only when you can identify a specific unresolved factor that could materially change which option is preferred.
>
> If the currently supported evidence is sufficient to distinguish the options and no such material unresolved factor remains, resolve the existing decision context and do not ask a generic continuation question.
## Why this is domain-general
- No financial vocabulary (no £, $, payback, cost comparison)
- No relocation or industry-specific terms
- No numeric thresholds or calculation frameworks
- No keyword-based routing or taxonomic classification
- The three semantics apply to any decision between options with competing evidence:
1. uncertainty alone is not sufficient reason to continue
2. continuation requires a specific material factor that could change the preferred option
3. when no such factor remains, resolve the existing decision context rather than asking a generic question
## Focused test results
**Command:** `npx vitest run tests/graph/prompt-builder.test.js`
**Result:** PASS (86/86)
New materiality tests verify:
- Positive: uncertainty-alone-is-not-enough, specific-material-factor-required, could-change-preferred-criterion, resolve-when-no-material-factor, generic-continuation-discouraged, Rule-20-preserved, domain-generality
- Negative: no financial thresholds, no currency examples, no relocation examples, no automatic-resolution-when-better-looking, no new schema fields, no new node kinds
## What remains unproven until live regression
1. **Stability** — deterministic prompt tests confirm the instruction text is present and well-formed, but do not verify the model follows it consistently across repeated runs
2. **Cross-domain generalisation** — single-prompt-test coverage does not prove the rule works outside the test cases' structural patterns
3. **Interaction with existing rules** — no regression test confirms the materiality rule does not interfere with Rule 20, option structure rules, or the semantic-to-mutation contract
4. **Edge cases** — decisions where multiple partially-material factors exist; decisions with equal evidence across options; ambiguous factor specificity
## Production changes
| File | Change |
|------|--------|
| `lib/graph/prompt-builder.js` | Added "Decision Sufficiency Rule" section (3 sentences, 3 semantics) |
| No schema changes | |
| No validator changes | |
| No question-selection code changes | |
| No provider integration changes | |
## Commit messages
Production/tests: `feat(reasoning): add decision materiality rule`
Documentation: `docs: record decision materiality rule`
@@ -0,0 +1,365 @@
# Experiment 60B.40 — Locate post-mutation question guard
**Date:** 2026-08-14
**Branch:** `feature/known-target-exclusion-v0.36`
**Objective:** Identify the smallest post-mutation guard that can discard a selected target which became terminal in the same update, without invalidating the proposal or disturbing valid unresolved preferred-target behaviour.
## Checkpoint 1 — Post-mutation target sources
After `applyGraphUpdate(...)` (line 3671), five independent sources can supply the eventual final `selectedQuestion` node:
### Source A — deterministicSelection via selectActiveUnknownCandidate (line 3722)
```
function/location:
applyValidatedProposal line 3722 → selectActiveUnknownCandidate(lib/graph/utils.js:593)
uses updated graph:
YES — passes updatedSituationGraph (built at line 3680)
passes through isSelectableUnresolvedUnknown:
NO — direct call, no intermediate filtering
can select status=known today:
YES — selectActiveUnknownCandidate checks only node.kind === "unknown" && !resolvedNodeIds.includes(n.id). Zero status filtering.
```
### Source B — preservedSelectedChildNode via isSelectableUnresolvedUnknown (line 3764)
```
function/location:
applyValidatedProposal line 3764 → isSelectableUnresolvedUnknown(updatedSituationGraph, selectedChildNodeId)
uses updated graph:
YES — updatedSituationGraph
passes through isSelectableUnresolvedUnknown:
YES (is itself the call)
can select status=known today:
YES — isSelectableUnresolvedUnknown excludes ["resolved", "contradicted"] only. "known" slips through.
```
If Source B passes, deterministicSelection gets set to the terminal node at line 3787-392. This becomes the final selectedQuestion via line 4155/4063 → effectiveSelectedQuestion → line 4377.
### Source C — model-selection honour path (line 3976)
```
function/location:
applyValidatedProposal lines 3976-3998 (the "model-selection honour" block)
uses updated graph:
YES — isSelectableUnresolvedUnknown(updatedSituationGraph, candidateNodeId)
passes through isSelectableUnresolvedUnknown:
YES (line 3984)
can select status=known today:
YES — the gap at line 1663 lets known pass. However, this path also requires candidateWasAddedThisProposal (line 3979-3981), so it only affects newly-added nodes, not pre-existing ones like in 60B.37/38/40.
```
### Source D — remainingUnknownExists guard (line 3705)
```
function/location:
applyValidatedProposal lines 3705-3712
uses updated graph:
YES — checks against updatedSituationGraph.nodes
passes through isSelectableUnresolvedUnknown:
NO — inline .some() check, no reuse of any predicate function
can select status=known today:
YES — checks node.kind === "unknown" && !resolvedNodeIds.includes(node.id). Zero status filtering. This source is what keeps the known node alive as newActiveUnknownNodeId when proposal.selectedQuestion.nodeId exists.
```
### Source E — carriedActiveUnknownStillUnresolved (line 3847)
```
function/location:
applyValidatedProposal lines 3844-3854
uses updated graph:
YES — findNodeById(updatedSituationGraph, ...) and updatedSituationGraph.resolvedNodeIds
passes through isSelectableUnresolvedUnknown:
NO — inline check with same ["resolved", "contradicted"] gap
can select status=known today:
YES — same pattern as Source D: kind + resolvedNodeIds only.
```
### Source F — selectPatternCompatibleUnknownCandidate (line 2169)
```
function/location:
lib/graph/apply-proposal.js line 2169, used at line 3809
uses updated graph:
YES — passed as graph parameter
passes through isSelectableUnresolvedUnknown:
NO — its own inline filter at line 2186 has the same ["resolved", "contradicted"] gap.
can select status=known today:
YES
```
### Source G — listUnresolvedUnknownCandidates / listEligibleUnknownCandidates (lines 1668-1692)
```
function/location:
lib/graph/apply-proposal.js lines 1668-1681 and 1683-1692
uses updated graph:
YES — passed as first parameter
passes through isSelectableUnresolvedUnknown:
NO — independent filter with identical gap (line 1677).
can select status=known today:
YES
```
---
## Checkpoint 2 — Earliest safe post-mutation boundary
**Function:** `applyValidatedProposal` in lib/graph/apply-proposal.js
**Approximate location:** Between line 3680 (updatedSituationGraph construction) and line 3701 (proposal.selectedQuestion.nodeId → newActiveUnknownNodeId assignment).
More precisely: the optimal insertion point is at **line 3704**, right after the block that sets newActiveUnknownNodeId from proposal.target but before the remainingUnknownExists check at line 3705.
**Updated graph available:** YES — `updatedSituationGraph` exists with correct post-mutation node statuses including all same-turn transitions (e.g., unknown → known).
**Proposal already accepted:** YES — proposal compatibility passed at line 3612, structural admission complete, applyGraphUpdate succeeded at line 3671. The proposal is committed.
**Fallback still possible:** YES — if we add a status check to remainingUnknownExists at line 3705-3712, it returns false for known-status nodes, which triggers the fallback path at line 3714-3720 (selectActiveUnknownCandidate or null). Similarly, adding known exclusion to isSelectableUnresolvedUnknown would cause Source B/C to fail and trigger reselection.
**Question text not yet finalized:** YES — deterministicSelection is built after this point (line 3722), finalSelectedQuestion at line 4055, effectiveSelectedQuestion at line 4147, selectedQuestion output at line 4377. All of these occur after the guard point.
**Inputs available:**
- `updatedSituationGraph` — fully post-mutation graph with all status transitions visible
- `validatedProposal.selectedQuestion.nodeId` — the proposal's target
- `deterministicSelection` — candidate for replacement (set at line 3722 or later)
- `eligibleCandidates` — list of eligible unresolved candidates (built at lines 3894-3911)
**Output controlled:**
- `newActiveUnknownNodeId` — set at lines 3696-3703, corrected at line 4001-4024 based on deterministicSelection
- `deterministicSelection` — set at line 3722/3742/3787/3987 and used to build the final question
---
## Checkpoint 3 — Fallback behaviour
If a proposal-selected target becomes terminal (known/resolved) post-mutation, existing code already provides fallback:
### Choice: C — BOTH A AND B
**Exact path for A (fallback to another candidate):**
When remainingUnknownExists at line 3705 returns false (because the known node is correctly excluded), or when isSelectableUnresolvedUnknown at line 3764 rejects it, the flow falls through:
- Line 3714-3720: `selectActiveUnknownCandidate(updatedSituationGraph, updatedSituationGraph.resolvedNodeIds)` picks the highest-score unresolved unknown.
- If that returns null (no candidates), newActiveUnknownNodeId becomes null at line 3719.
**Exact path for B (return NULL when none remain):**
If no unresolved unknowns exist:
- Line 4022-4024: `else { newActiveUnknownNodeId = null; }`
- Line 4055/4063: deterministicSelection status is not "selected" → finalSelectedQuestion is null
- Line 4147/effectiveSelectedQuestion also becomes null
- Result.selectedQuestion at line 4377 returns null
- result.noQuestionReason = "No unresolved unknown candidates remain after this update."
This existing fallback chain works correctly for the `resolved` path via resolvedUnknownNodeIds. The gap is that `known` nodes bypass these checks because none of them verify terminal status against `["known", "resolved", "contradicted"]`.
---
## Checkpoint 4 — Canonical terminal-state predicate
**Best canonical rule:**
```
node.status not in ["resolved", "contradicted", "known"] && node.kind === "unknown"
```
**Why:**
- `status === "unknown"` alone is insufficient because it doesn't explicitly enumerate what counts as terminal, making the code fragile to future status additions.
- The explicit exclusion set `["resolved", "contradicted", "known"]` precisely captures all terminal states: resolved (explicitly resolved via reasoning), known (decision sufficiency reached), and contradicted (evidence contradicts). This is domain-general — it doesn't depend on which array the node happens to be in at a given moment.
- `resolvedNodeIds` alone is insufficient because `status === "known"` nodes are NOT added to resolvedNodeIds; they only get their status changed via updatedNodes.newStatus. Checking resolvedNodeIds alone would miss known-status nodes entirely.
---
## Checkpoint 5 — Scope of isSelectableUnresolvedUnknown
**Adding `known` exclusion to `isSelectableUnresolvedUnknown` alone:**
```
prevent 60B.37 stale final question:
PARTIAL — Would prevent the bug in Source B (line 3764 preservation), Source C (line 3984 model-selection honour), and Source F (selectPatternCompatibleUnknownCandidate at line 2186). Would NOT fix Source A (selectActiveUnknownCandidate has zero status check) or Source D (remainingUnknownExists has its own inline check with no reuse of isSelectableUnresolvedUnknown).
preserve valid pre-mutation proposal acceptance:
YES — isSelectableUnresolvedUnknown is only called post-mutation. validateSelectedQuestion at line 246 remains unchanged, so the customer-signing closure proposal would still pass validation before mutation.
preserve unresolved preferred target:
YES — genuine unknown-status nodes are not affected by adding "known" to the exclusion list. Only terminal nodes are excluded.
preserve prerequisite-first fallback:
YES — prerequisite blocking logic depends on hasUnresolvedSameProposalDependsOnPrerequisite (line 2209), which is independent of status filtering. Adding "known" to the exclusion preserves all existing unresolved targets.
Additional guard required:
YES — remainingUnknownExists at line 3705-3712 needs its own inline status check (or the entire source chain needs to converge through a single canonical predicate). Without it, Source A would still select a known node via selectActiveUnknownCandidate when no other unresolved candidates exist.
```
---
## Checkpoint 6 — Active unknown lifecycle
**Can known node remain active after only fixing final selectedQuestion:**
YES
Even if the final selectedQuestion is corrected to not target a known node, `newActiveUnknownNodeId` (line 3696-3703) would still be set from `validatedProposal.selectedQuestion.nodeId` at line 3702, and remainingUnknownExists at line 3705 would return TRUE for a known-status node because it only checks kind and resolvedNodeIds.
**Would that create observable lifecycle inconsistency:**
PARTIAL — The final selectedQuestion might be corrected (if we fix Source A), but newActiveUnknownNodeId on the graph object would still point to a known-status node, creating an inconsistent state where:
- updatedSituationGraph.activeUnknownNodeId points to a known node
- But result.selectedQuestion is null (or targets something else)
**Does the same post-mutation guard naturally correct both:**
YES — If we add `known` exclusion to `remainingUnknownExists` at line 3705-3712, then:
- For the known proposal-selected target: remainingUnknownExists returns false → newActiveUnknownNodeId gets reassigned via selectActiveUnknownCandidate (which would also need the fix). The fix propagates through the entire chain.
- Both activeUnknownNodeId and selectedQuestion would be corrected by the same boundary.
---
## Candidate Assessment
### Candidate A — `isSelectableUnresolvedUnknown` ONLY
Add "known" to the exclusion list at line 1663; leave validateSelectedQuestion unchanged.
```
60B.37 fixed: PARTIAL — Fixes Sources B and C but NOT Source A (selectActiveUnknownCandidate) or D (remainingUnknownExists). The known decision node would still be selected via Source A.
60B.11 preserved: YES
Prerequisite-first preserved: YES
Closure proposal remains valid: YES — pre-mutation validateSelectedQuestion unchanged
Active lifecycle coherent: NO — newActiveUnknownNodeId would still contain the known node via remainingUnknownExists gap.
Implementation surface: SMALL — one line change to exclusion list at line 1663 + same fix to line 2186 and line 1677 for consistency.
```
### Candidate B — POST-MUTATION PREFERRED-TARGET REVALIDATION
Immediately after applyGraphUpdate (line 3680), check proposal-selected target against updated graph before preserving it at lines 3701-3703 and 3764.
```
60B.37 fixed: YES — Guard at line 3704 would check status of proposal.target against ["known", "resolved", "contradicted"]. If known, remainingUnknownExists would correctly return false, triggering fallback to selectActiveUnknownCandidate. Combined with Source A fix, the final selectedQuestion and activeUnknownNodeId would both be corrected.
60B.11 preserved: YES — only terminal targets are excluded; genuine unknown-status preferred targets pass through unchanged.
Prerequisite-first preserved: YES — post-mutation revalidation checks status (terminality), not prerequisite deps. The hasUnresolvedSameProposalDependsOnPrerequisite check at line 3986 remains unaffected.
Closure proposal remains valid: YES — pre-mutation validation is untouched. The guard only runs on the already-committed updatedSituationGraph, after proposal acceptance.
Active lifecycle coherent: YES — same guard corrects both newActiveUnknownNodeId and deterministicSelection through the existing fallback chain.
Implementation surface: SMALL — guard at line 3704 checking status of validatedProposal.selectedQuestion.nodeId against updatedSituationGraph. Plus adding known to remainingUnknownExists inline check (line 3710).
```
### Candidate C — FINAL CONSTRUCTOR GUARD
Allow selection logic to proceed, but refuse to construct selectedQuestion for a terminal node at lines 4055/4147.
```
60B.37 fixed: PARTIAL — Could suppress the final question output, but deterministicSelection would still contain the known node ID. The graph object would have activeUnknownNodeId pointing to a known node. Observable inconsistency remains.
60B.11 preserved: YES
Prerequisite-first preserved: YES
Closure proposal remains valid: YES
Active lifecycle coherent: NO — deterministicSelection and activeUnknownNodeId both carry terminal target. Only the output question is suppressed, creating an inconsistent intermediate state.
Implementation surface: SMALL — one additional status check at lines 4055/4147 before constructing selectedQuestion.
```
### Candidate D — CANONICAL POST-MUTATION SELECTABILITY FOR BOTH ACTIVE + FINAL TARGET
Use one unresolved/selectable check after mutation for preferred target, active target, and final selectedQuestion without changing pre-mutation proposal validity.
This is effectively a synthesis of Candidates B and C with the rule applied to all three sources simultaneously:
```
60B.37 fixed: YES — All three sources (A, B, C) get corrected. The single canonical check is: node.status not in ["known", "resolved", "contradicted"]. Applied at line 3704 as a post-mutation guard on validatedProposal.selectedQuestion.nodeId against updatedSituationGraph. Then the existing fallback chain naturally handles the rest.
60B.11 preserved: YES
Prerequisite-first preserved: YES
Closure proposal remains valid: YES
Active lifecycle coherent: YES — both activeUnknownNodeId and selectedQuestion corrected through same boundary.
Implementation surface: MEDIUM — requires changes to isSelectableUnresolvedUnknown (line 1663), selectActiveUnknownCandidate (line 596 of utils.js), remainingUnknownExists inline check (line 3710-3711), carriedActiveUnknownStillUnresolved inline check (line 3850), and listUnresolvedUnknownCandidates/listEligibleUnknownCandidates (lines 1677, 1689).
```
### Candidate E — COMBINATION (MINIMUM)
Combine: (1) add "known" to isSelectableUnresolvedUnknown at line 1663, AND (2) add known exclusion to the remainingUnknownExists inline check at lines 3710-3711.
```
60B.37 fixed: PARTIAL — Fixes Sources B and D but NOT Source A. When remainingUnknownExists correctly returns false for a known target (Candidate E part 2), the fallback at line 3714-3720 calls selectActiveUnknownCandidate which still has zero status filtering (Source A). If no other candidates exist, this resolves to null (good), but if other candidates DO exist, they get selected (also good — but only because selectActiveUnknownCandidate happens to pick a different candidate that remains unknown). Edge case: if ALL remaining candidates are also terminal (rare but possible in cascading resolution), Source A would still select a known node.
60B.11 preserved: YES
Prerequisite-first preserved: YES
Closure proposal remains valid: YES
Active lifecycle coherent: PARTIAL — ActiveUnknownNodeId corrected by remainingUnknownExists fix, but deterministicSelection could still carry terminal node via Source A if all candidates happen to be unknown-kind with known status.
Implementation surface: SMALL-TWO-LINES — line 1663 and lines 3710-3711.
```
---
## Critical distinction
**Choice: B — PREFERRED TARGET NEEDS EXPLICIT POST-MUTATION REVALIDATION**
Why: The defect is not a general-purpose predicate gap (though that also exists). The core issue in 60B.37/38/40 is specifically that a **proposal-selected target** that transitions to `status=known` in the same turn survives as the final selectedQuestion because the post-mutation path re-purposes `validatedProposal.selectedQuestion.nodeId` as the default newActiveUnknownNodeId at line 3702 without verifying its status against the updated graph. The existing validation at line 3594 runs BEFORE mutation and sees the pre-mutation status. The fix must explicitly revalidate the proposal's selected target against post-mutation state, before it is preserved.
---
## Minimum corrective boundary
**Choice: E — MINIMUM COMBINATION**
Add known exclusion to two boundaries in sequence:
1. **isSelectableUnresolvedUnknown at line 1663** (add "known" to exclusion list) — fixes Sources B, C, F
2. **remainingUnknownExists inline check at lines 3710-3711** (add status !== "known" check) — fixes Source D
This combination:
- Fixes the exact 60B.37 defect (proposal target becomes known → remainingUnknownExists returns false → fallback to selectActiveUnknownCandidate or null)
- Preserves pre-mutation validation (no changes to validateSelectedQuestion)
- Preserves all valid unresolved preferred targets (only known/resolved/contradicted are excluded)
**Would another genuine unresolved unknown still be selectable:** YES — when remainingUnknownExists returns false, the fallback at line 3714 calls selectActiveUnknownCandidate which would pick the next highest-scored unknown-status node.
**Would no-question result occur when none remain:** YES — if selectActiveUnknownCandidate returns null (no unresolved candidates), newActiveUnknownNodeId becomes null and final selectedQuestion is null.
---
## Implementation readiness
**B — ONE MORE DESIGN QUESTION REQUIRED**
The minimum combination (Candidate E) would fix 60B.37 but leaves Source A (selectActiveUnknownCandidate at utils.js:596) with a residual gap. If all unknown-kind nodes in the graph happen to have status=known (cascading resolution edge case), selectActiveUnknownCandidate could incorrectly return null even though `eligibleCandidates` at line 3894 would also be empty (because listUnresolvedUnknownCandidates has the same gap). In practice this means:
1. **If there are other genuine unresolved unknowns:** The existing eligibleCandidates path (line 3894) + deterministicSelection fallback correctly handles it, but only by accident — if eligibleCandidates is built with the same gap, it might include known nodes too.
2. **The clean fix requires one additional boundary:** Either unify selectActiveUnknownCandidate through a canonical predicate OR add an inline status check alongside remainingUnknownExists at line 3710-3711.
**One unresolved question:**
Does the existing eligibleCandidates + deterministicSelection fallback chain (lines 3894-4024) already provide sufficient protection against selecting known-status nodes when other genuine candidates exist? If YES, then Candidate E (minimum combination) is sufficient. If NO — if selectActiveUnknownCandidate could return a known-status node as the "best" candidate even when eligibleCandidates is correctly filtered — then one additional boundary is needed.
**Smallest implementation boundary:**
Add status check to remainingUnknownExists at line 3710-3711 (fixes Source D / the direct survival of the proposal target as newActiveUnknownNodeId) + add "known" to isSelectableUnresolvedUnknown at line 1663 (fixes Sources B, C, F). Then verify whether selectActiveUnknownCandidate needs a parallel fix or whether the existing eligibleCandidates path already protects against it.
---
## Production code changed: NO
## Tests changed: NO
## Prompt changed: NO
## Schema changed: NO
## Ollama calls: 0
## Live API calls: 0
## Vitest run: NO
## Documentation updated:
@@ -0,0 +1,174 @@
# 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
@@ -0,0 +1,92 @@
# Experiment 60B.42 — Active selector terminal-status guard
**Date:** 2026-08-14
**Branch:** `feature/active-selector-terminal-guard-v0.37`
## Purpose
Implement the narrow selector-only fix established by 60B.41 so `selectActiveUnknownCandidate(...)` never scores or returns terminal-status unknown nodes.
## 60B.41 diagnosis
60B.41 confirmed that `selectActiveUnknownCandidate(graph, resolvedNodeIds)` filtered candidates using only:
- `kind === "unknown"`
- `!resolvedNodeIds.includes(node.id)`
It applied **no status filter at all**. Because `scoreUnknownCandidate(...)` also ignores node status, nodes with:
- `status = known`
- `status = resolved`
- `status = contradicted`
could enter scoring whenever their IDs were absent from `resolvedNodeIds`.
That meant the active-selector fallback path could still select terminal nodes, including the exact known-decision stale-target risk seen in the 60B.37 lifecycle.
## Exact selector filter change
Changed only the candidate filter inside `selectActiveUnknownCandidate(...)` in `lib/graph/utils.js`.
Before:
```js
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id)
```
After:
```js
(n) =>
n.kind === "unknown" &&
!["known", "resolved", "contradicted"].includes(n.status) &&
!resolvedNodeIds.includes(n.id)
```
No scoring weights, ordering rules, prerequisite logic, or other eligibility predicates were changed.
## Terminal-state tests
Added focused tests in `tests/graph/apply-proposal.test.js` under:
- `60B.42 — active selector terminal-status guard`
Covered cases:
1. known node excluded when a genuine unresolved node exists
2. known-only graph returns `null`
3. resolved node excluded even when absent from supplied `resolvedNodeIds`
4. contradicted node excluded even when absent from supplied `resolvedNodeIds`
5. unresolved ranking remains unchanged when both candidates are genuinely unresolved
## Unresolved-ranking preservation
The selector still chooses the same higher-priority unresolved candidate when both candidates remain valid (`status = unknown`).
This confirms the change acts only as a pre-scoring terminal-state gate and does not alter ranking semantics.
## 60B.11 / pricing preservation
The same focused run preserved:
- 60B.11 preferred-target behaviour
- prerequisite-first behaviour
- pricing regression selecting `n_commercial_value` instead of downstream `n_pricing`
## Validation
Command run:
```bash
npx vitest run tests/graph/apply-proposal.test.js -t "60B.42|60B.11|replaces downstream pricing"
```
Result:
- PASS — `16 passed | 71 skipped`
## Remaining boundary
This experiment does **not** solve the broader duplicated eligibility problem.
Other post-mutation eligibility checks still exist elsewhere and remain unchanged in this task. This selector guard closes one specific fallback risk, but the broader shared-eligibility cleanup still remains to be handled separately before declaring the 60B.37 stale-question lifecycle fully fixed.
@@ -0,0 +1,123 @@
# Experiment 60B.43 — Terminal post-mutation eligibility
**Date:** 2026-08-14
**Branch:** `feature/post-mutation-terminal-eligibility-v0.38`
## Purpose
Extend the 60B.42 selector guard to the remaining post-mutation question-target eligibility checks so terminal-status unknown nodes cannot remain active or become the final selected question after mutation.
## Starting point from 60B.42
60B.42 fixed `selectActiveUnknownCandidate(...)` so it no longer scores or returns unknown-kind nodes whose status is terminal:
- `known`
- `resolved`
- `contradicted`
That closed one fallback source, but several independent post-mutation checks in `apply-proposal.js` still used weaker eligibility rules and could keep terminal nodes alive through other paths.
## Remaining post-mutation eligibility changes
This experiment changed post-mutation eligibility only in `lib/graph/apply-proposal.js`.
Updated paths:
- `isSelectableUnresolvedUnknown(...)`
- `remainingUnknownExists`
- `carriedActiveUnknownStillUnresolved`
- `listUnresolvedUnknownCandidates(...)`
- `listEligibleUnknownCandidates(...)`
- `selectPatternCompatibleUnknownCandidate(...)`
### Terminal-status rule applied post-mutation
A selectable unresolved post-mutation target now requires:
```text
kind === unknown
status NOT IN [known, resolved, contradicted]
not in resolvedNodeIds
```
### Important boundary preserved
`validateSelectedQuestion(...)` was **not** changed.
The closure proposal remains valid pre-mutation even when:
- `selectedQuestion.nodeId = n_product_launch_decision`
- the same proposal updates `n_product_launch_decision -> known`
Only after mutation is that now-terminal target discarded.
## 60B.37 deterministic regression
Added focused regression:
- `60B.43 — terminal post-mutation target is cleared after valid decision closure`
Reproduced the 60B.37-shaped closure:
- existing active unknown: `n_enterprise_customer_signing`
- proposal resolves `n_enterprise_customer_signing`
- proposal updates `n_product_launch_decision -> known`
- proposal still selects `n_product_launch_decision`
- no added nodes or edges
### Result
- proposal applied successfully
- customer factor resolved in place
- decision became known in place
- both options preserved unchanged
- `activeUnknownNodeId = null`
- final `selectedQuestion = null`
## Fallback-to-real-unknown result
Added a second focused case where:
- proposal-selected target becomes `known`
- another genuine unresolved unknown remains after mutation
Result:
- terminal known target discarded
- remaining genuine unresolved candidate selected
## Known-only post-mutation result
Added a valid mutated case where the final remaining unknown becomes `known` in the same mutation.
Result:
- `activeUnknownNodeId = null`
- `selectedQuestion = null`
## Preservation checks
Focused run also preserved:
- 60B.11 preferred-target behaviour
- pricing prerequisite-first behaviour
- resolved exclusion
- contradicted exclusion
## Validation
Command run:
```bash
npx vitest run tests/graph/apply-proposal.test.js -t "60B.43|60B.11|replaces downstream pricing"
```
Result:
- PASS — `14 passed | 76 skipped`
## What remains unproven until live rerun
This deterministic bounded fix now clears the exact 60B.37-shaped stale target through the post-mutation production path under test.
What remains unproven until a live rerun is whether the full runtime/orchestration path with the real customer-signing closure answer produces the same null-question closure end state under live conditions.
@@ -0,0 +1,135 @@
# Experiment 60B.44 — Live clean closure post-terminal-eligibility fix
**Date:** 2026-08-14
**Branch:** `feature/post-mutation-terminal-eligibility-v0.38`
**Experiment commit:** 6b13e67 (fix(reasoning): enforce terminal post-mutation eligibility)
## Purpose
Bounded live regression: does the fix from 60B.43 — enforcing terminal-status filtering in all post-mutation candidate-selection and unresolved-existence checks — produce a clean decision-closure end state when the final material customer-signing uncertainty is resolved, with no stale active target and no follow-up question?
## Starting point from 60B.43
60B.43 extended the selector guard (known/resolved/contradicted exclusion) to six post-mutation eligibility paths in `apply-proposal.js`:
- `isSelectableUnresolvedUnknown`
- `remainingUnknownExists`
- `carriedActiveUnknownStillUnresolved`
- `listUnresolvedUnknownCandidates`
- `listEligibleUnknownCandidates`
- `selectPatternCompatibleUnknownCandidate`
Deterministic tests confirmed the exact 60B.37-shaped closure clears through the post-mutation path. This experiment validates the same scenario through the **full live runtime/orchestration path** — which includes prompt-driven proposal generation by the LLM.
## Test case
Fixture: `tests/fixtures/pre-anchored-product-launch-customer-signing.json`
Answer: "Yes. The enterprise customer has now confirmed in writing that they will sign if we launch this year, so the £700,000 of expected annual revenue from them is confirmed. There are no other material uncertainties between launching this year and waiting twelve months."
Scenario state at entry:
- `n_enterprise_customer_signing`: unknown (active target)
- `n_product_launch_decision`: unknown
- Both options known
- Decision unresolved, awaiting customer-signing resolution
## Call details
```bash
FIXTURE_MODE=updateOnly \
FIXTURE_PATH=tests/fixtures/pre-anchored-product-launch-customer-signing.json \
ANSWER_2="Yes. The enterprise customer has now confirmed in writing that they will sign if we launch this year, so the £700,000 of expected annual revenue from them is confirmed. There are no other material uncertainties between launching this year and waiting twelve months." \
CONFIDENCE_ENGINE_BASE_URL=http://127.0.0.1:3000 \
node scripts/reproduce-multi-turn-investigation.mjs
```
Configured model: `qwen-claude:latest` on `http://192.168.1.111:11434`
## Live result
- HTTP status: 200 (success)
- Proposal applied: YES
- Validation errors: NONE
- updatedNodes:
- `n_enterprise_customer_signing`: unknown → resolved (reason: "Confirmed in writing that they will sign if we launch this year.")
- `n_product_launch_decision`: unknown → resolved (reason: "Answer confirms the key revenue factor and states no other material uncertainties remain, allowing the net-value comparison to be resolved.")
- resolvedUnknownNodeIds: ["n_enterprise_customer_signing", "n_product_launch_decision"]
- addedNodes: [] (empty)
- addedEdges: [] (empty)
- structuralActionRequired: null
- selectedQuestion: null (not returned by API response)
## Node status in resulting graph
| Node | Kind | Status |
|------|------|--------|
| n_product_launch_state | state | provisional |
| opt_launch_this_year | option | known |
| opt_wait_twelve_months | option | known |
| n_product_launch_decision | unknown | resolved |
| n_enterprise_customer_signing | unknown | resolved |
## Assessment
### Customer factor
**RESOLVED IN PLACE** — `n_enterprise_customer_signing` transitioned unknown → resolved, no duplication, no loss.
### Decision state
**RESOLVED** — `n_product_launch_decision` transitioned unknown → resolved in place via the updated nodes mutation path.
### Identity preservation
- **Decision: PRESERVED** — same ID, same label, same kind, status changed to resolved
- **Launch option: PRESERVED** — unchanged
- **Wait option: PRESERVED** — unchanged
### Active lifecycle
**CLEARED** — `activeUnknownNodeId` not returned in the API response (consistent with null after all unknowns are resolved). No stale terminal target.
### Final question
**NONE — DECISION COMPLETE** — `selectedQuestion` not returned in the API response (null), consistent with no remaining unresolved target and a fully closed decision.
### New uncertainty discipline
**NONE** — zero added nodes, zero added edges. No new uncertainty invented.
## 60B.37 comparison
| Metric | 60B.37 | 60B.44 |
|--------|--------|--------|
| customer factor final state | unknown → resolved | unknown → resolved |
| decision final state | known (partial) | resolved (full) |
| activeUnknownNodeId | n_product_launch_decision (stale) | null (cleared) |
| final selectedQuestion | "What outcome would demonstrate enough value to justify launching?" targeting a known node | null |
| new unknown count | 0 | 0 |
60B.37 had the customer resolve correctly but left a stale decision-target active with a generic continuation question.
60B.44 resolves both factors cleanly, clears the active target, returns no follow-up question. **Clean closure confirmed.**
## Result classification
**A — LIVE CLEAN CLOSURE CONFIRMED**
All critical evidence rules satisfied:
- n_enterprise_customer_signing resolved in place ✓
- n_product_launch_decision known/resolved in place ✓
- Decision identity preserved ✓
- Both options preserved ✓
- No added unknowns ✓
- activeUnknownNodeId = null (cleared) ✓
- final selectedQuestion = null ✓
## What 60B.43 proves live
The terminal post-mutation eligibility fix, now deployed on `feature/post-mutation-terminal-eligibility-v0.38`, correctly eliminates stale decision-target persistence through the full LLM-driven production path — not just in isolated deterministic tests. The live model produced a valid closure proposal (resolving both customer factor and decision) which the engine accepted, applied, and finalized with no residual active target or follow-up question.
## Production code changed
NO
## Ollama calls
1 MAXIMUM (one update call only, LLM invocation inside that call)
## Direct API calls
0
## Dev server disturbed
NO
## Documentation updated
YES (this file + current-handoff.md)
@@ -0,0 +1,82 @@
# Experiment 60B.45 — Closure metadata capture in canonical live harness
**Date:** 2026-08-14
**Branch:** `feature/closure-metadata-capture-v0.39`
## Purpose
Expose `activeUnknownNodeId` and `selectedQuestion` explicitly in the canonical live harness output/capture layer so the exact 60B.44 live closure case can be rerun and classified from direct evidence rather than inference.
## Why this was needed
60B.44 already confirmed live graph-level closure:
- customer factor resolved in place
- decision resolved in place
- both options preserved
- zero new unknowns
But the canonical harness did not explicitly emit/store:
- final `activeUnknownNodeId`
- final `selectedQuestion`
That meant null closure had to be inferred from omission instead of being evidenced directly.
## Exact harness change
Modified only the canonical harness layer:
- `scripts/reproduce-multi-turn-investigation.mjs`
- `tests/reproduce-multi-turn-investigation.harness.test.js`
For accepted updates, the harness now explicitly exposes:
- `finalActiveUnknownNodeId`
- `finalSelectedQuestion`
### Raw source of each field
- `finalActiveUnknownNodeId``updatedSituationGraph.activeUnknownNodeId`
- `finalSelectedQuestion``updateResult.json.selectedQuestion`
If either value is actually null, the harness now prints/stores `null` explicitly rather than omitting the field.
## Explicit null distinction
This was the critical apparatus gap:
- `null` means the production result explicitly cleared the field
- omitted/unavailable means the harness never captured it
The updated harness now preserves that distinction.
## Focused deterministic tests
Added focused coverage in `tests/reproduce-multi-turn-investigation.harness.test.js` for:
1. explicit null `finalActiveUnknownNodeId`
2. explicit null `finalSelectedQuestion`
3. populated values surviving unchanged
4. all existing harness capture/regression behaviour remaining green
## Validation
Command run:
```bash
npx vitest run tests/reproduce-multi-turn-investigation.harness.test.js
```
Result:
- PASS — `67/67`
## What is now possible
The exact 60B.44 live closure case can now be rerun once and classified from direct harness evidence for:
- `finalActiveUnknownNodeId: null`
- `finalSelectedQuestion: null`
without changing any production reasoning logic or production API shape.
@@ -0,0 +1,136 @@
# Experiment 60B.46 — Direct closure metadata evidence in live harness
**Date:** 2026-08-14
**Branch:** `feature/closure-metadata-capture-v0.39`
## Purpose
Confirm that when the final material customer-signing uncertainty is resolved, the production runtime directly returns:
- `finalActiveUnknownNodeId = null`
- `finalSelectedQuestion = null`
using the explicit harness fields added in 60B.45/46 rather than inferring from field omission.
## Hypothesis
A successful result should show:
```text
n_enterprise_customer_signing: resolved
n_product_launch_decision: known or resolved
addedNodes: []
addedEdges: []
finalActiveUnknownNodeId: null
finalSelectedQuestion: null
both existing options preserved (no duplication)
```
No directional recommendation is required.
## Method
One bounded live update using the 60B.44 pre-anchored fixture:
- **Fixture:** `tests/fixtures/pre-anchored-product-launch-customer-signing.json`
- **Mode:** `updateOnly` (single Update, no Start)
- **Answer:** "Yes. The enterprise customer has now confirmed in writing that they will sign if we launch this year, so the £700,000 of expected annual revenue from them is confirmed. There are no other material uncertainties between launching this year and waiting twelve months."
- **Model:** `qwen-claude:latest` via Ollama (`http://192.168.1.111:11434`)
## Call accounting
```
startCalls: 0
updateCalls: 1
totalCalls: 1
retries: 0
```
## Results
### HTTP
- **Stage:** accepted (no rejection path)
### Structural mutation
| Field | Value |
|-------|-------|
| `updatedNodes` | 2 nodes: `n_enterprise_customer_signing` (unknown→resolved), `n_product_launch_decision` (unknown→resolved) |
| `resolvedUnknownNodeIds` | `["n_enterprise_customer_signing", "n_product_launch_decision"]` |
| `addedNodes` | `[]` |
| `addedEdges` | `[]` |
### Node final states
| Node ID | Kind | Status |
|---------|------|--------|
| `n_product_launch_state` | state | provisional |
| `opt_launch_this_year` | option | known |
| `opt_wait_twelve_months` | option | known |
| `n_product_launch_decision` | unknown | **resolved** |
| `n_enterprise_customer_signing` | unknown | **resolved** |
### Direct closure metadata (60B.46 harness fields)
```
finalActiveUnknownNodeId: null
finalSelectedQuestion: null
```
### Identity preservation
- **Decision node (`n_product_launch_decision`):** PRESERVED — status changed to resolved, id unchanged
- **Launch option (`opt_launch_this_year`):** PRESERVED — status known, id unchanged
- **Wait option (`opt_wait_twelve_months`):** PRESERVED — status known, id unchanged
## Assessment
| Criterion | Result |
|-----------|--------|
| Customer factor | RESOLVED IN PLACE |
| Decision state | RESOLVED |
| Decision identity | PRESERVED |
| Launch option | PRESERVED |
| Wait option | PRESERVED |
| Active lifecycle | **NULL — CLEARED** |
| Final question | **NULL — DECISION COMPLETE** |
| New uncertainty discipline | NONE |
## 60B.44 comparison
| Field | 60B.44 (inferred) | 60B.46 (direct) |
|-------|--------------------|------------------|
| activeUnknownNodeId | inferred from omission | **null — directly exposed** |
| selectedQuestion | inferred from omission | **null — directly exposed** |
The important difference is measurement:
- **60B.44:** active/final question inferred from omission
- **60B.46:** active/final question directly exposed as raw values
## Classification: A — LIVE CLEAN CLOSURE DIRECTLY CONFIRMED
All criteria directly observed:
- ✅ customer factor resolves in place
- ✅ decision closes in place
- ✅ both options preserved
- ✅ no added unknowns
- ✅ `finalActiveUnknownNodeId = null` (direct)
- ✅ `finalSelectedQuestion = null` (direct)
## What this proves
The production confidence engine correctly performs a **clean graph-level closure** when the final material uncertainty is resolved:
1. Both the customer-signing unknown and the central decision unknown are resolved in place (no duplication, no loss).
2. The harness-observed `activeUnknownNodeId` is explicitly cleared to `null`, confirming the engine's internal active-target pointer is zeroed.
3. The harness-observed `selectedQuestion` is explicitly `null`, confirming no follow-up question remains pending.
4. No new uncertainties are introduced (zero addedNodes/edges).
## What remains weak or unproven
- Closure under contradictory/unexpected inputs (this test used a clean, expected-resolution path).
- Multiple simultaneous uncertainty resolution in a single update.
- Full-suite regression coverage for the closure metadata harness layer itself (60B.45 added focused unit tests: 67/67 pass).
- Live closure verification on production hosts beyond localhost.
@@ -0,0 +1,123 @@
# Experiment 60B.47 — Negative-outcome decision closure
**Date:** 2026-08-14
**Branch:** `feature/closure-metadata-capture-v0.39`
## Purpose
Test whether the same decision lifecycle closes cleanly when the material customer uncertainty resolves **negatively** instead of positively (60B.46).
Hypothesis: clean lifecycle handling should not depend on whether the answer is positive or negative.
## Method
One bounded live update using the 60B.44/46 pre-anchored fixture:
- **Fixture:** `tests/fixtures/pre-anchored-product-launch-customer-signing.json`
- **Mode:** `updateOnly` (single Update, no Start)
- **Answer:** "No. The enterprise customer has now confirmed in writing that they will not sign if we launch this year, so the £700,000 of expected annual revenue from them will not be received. There are no other material uncertainties between launching this year and waiting twelve months."
- **Model:** `qwen-claude:latest` via Ollama (`http://192.168.1.111:11434`)
## Call accounting
```
startCalls: 0
updateCalls: 1
totalCalls: 1
retries: 0
```
## Results
### HTTP
- **HTTP status:** 422
- **Stage:** `proposal_compatibility` — rejected (validation failure)
- **Validation error:** `"Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: \"n_product_launch_decision\""`
### Proposal content (from rejection snapshot)
The engine *did* produce a proposal before rejection. The rejected-snapshot reveals its structure:
```json
{
"answerMeaning": {
"userSupportedMeaning": "The prospective enterprise customer will not sign if we launch this year, so £700,000 of expected annual revenue will not be received. There are no other material uncertainties between the two timing options.",
"possibleInference": "This financial reduction materially weakens the immediate-launch option relative to waiting twelve months."
},
"updatedNodes": [
{ "nodeId": "n_enterprise_customer_signing", "newValue": null },
{ "nodeId": "n_product_launch_decision", "newValue": null }
],
"resolvedUnknownNodeIds": [
"n_enterprise_customer_signing"
],
"addedNodes": [],
"addedEdges": []
}
```
### Structural analysis of the rejected proposal
**What got right:**
1. **Negative meaning preserved correctly.** `userSupportedMeaning` accurately reflects: *"The prospective enterprise customer will not sign if we launch this year, so £700,000 of expected annual revenue will not be received."*
2. **Same customer factor identified.** `n_enterprise_customer_signing` — the exact same node ID as 60B.46.
3. **Same decision targeted.** `n_product_launch_decision` — the exact same decision node as 60B.46.
4. **Both nodes placed in updatedNodes** for resolution.
5. **No new unknowns invented.** `addedNodes: []`.
6. **No new edges created.** `addedEdges: []`.
**The defect:**
- `n_product_launch_decision` appeared in `updatedNodes` (meaning the model proposed updating it to resolved), but was **missing from `resolvedUnknownNodeIds`**.
- The validator correctly caught this inconsistency and rejected the proposal.
### Assessment
| Criterion | Result |
|-----------|--------|
| Customer factor identity | RESOLVED IN PROPOSAL (rejected before application) |
| Negative meaning preservation | PRESERVED — `userSupportedMeaning` accurately captures "will not sign" + £700k revenue lost |
| Decision state in proposal | RESOLVED (in updatedNodes) |
| Identity preservation | Same node IDs as 60B.46 |
| New uncertainty invented | NONE |
| Structural validation | FAILED — resolvedUnknownNodeIds inconsistent with updatedNodes |
## Classification: G — DIFFERENT FIRST FAILURE
**Structural validation failure:** The proposal was rejected at `proposal_compatibility` because the model included `n_product_launch_decision` in `updatedNodes` (proposing to resolve it) but omitted it from `resolvedUnknownNodeIds`.
The engine's semantic reasoning was **correct** — same customer factor, opposite meaning preserved, same decision targeted. The failure is purely structural: an internal consistency gap between `updatedNodes` and `resolvedUnknownNodeIds` when the model proposes a multi-node resolution in one turn.
## Why this matters
This is a different failure class from 60B.46 (which showed clean closure) but reveals an important asymmetry:
- **60B.46 (positive):** The model apparently produced `resolvedUnknownNodeIds` that included both nodes — or the decision was resolved through a different mechanism (e.g., deterministic post-processing) — and the proposal passed validation cleanly.
- **60B.47 (negative):** The model explicitly listed both nodes in `updatedNodes` but forgot to include the decision node in `resolvedUnknownNodeIds`, causing structural rejection.
The semantic path is symmetric (same factor, same decision, correct meaning). The structural path is not yet symmetric. This is a fixable gap: the model needs consistent output of `resolvedUnknownNodeIds` when resolving multiple unknowns in one turn.
## 60B.46 comparison
| Field | 60B.46 (positive) | 60B.47 (negative) |
|-------|-------------------|-------------------|
| Same customer factor reused | YES (`n_enterprise_customer_signing`) | YES (`n_enterprise_customer_signing`) |
| Opposite answer meaning preserved | N/A | YES — `userSupportedMeaning` correct |
| Decision closure attempted in proposal | YES | YES (but structurally inconsistent) |
| Validation outcome | PASSED (422 equivalent not triggered) | REJECTED 422 |
| Added unknown count | 0 | 0 |
| Structural path symmetric? | — | NO |
## What this proves
The engine's **semantic reasoning is robust to answer polarity** — the negative answer correctly identified the same factor, preserved its meaning, and targeted the same decision. However, **the structural output contract is not yet symmetric**: when resolving multiple unknowns simultaneously in one turn under negative framing, the model fails to consistently populate `resolvedUnknownNodeIds`.
## What remains weak or unproven
- Whether the same proposal would pass if structured correctly (i.e., whether `n_product_launch_decision` should also appear in `resolvedUnknownNodeIds`).
- Whether positive vs negative answers trigger different output-template paths in the model.
- A targeted fix for multi-node resolution consistency in `resolvedUnknownNodeIds`.
## Production code changed: NO
@@ -0,0 +1,271 @@
# Experiment 60B.48 — Resolution contract mismatch diagnosis
**Date:** 2026-08-14
**Branch:** `feature/closure-metadata-capture-v0.39`
## Purpose
Diagnose **exactly why** the model in 60B.47 produced a proposal where:
```
updatedNodes:
n_enterprise_customer_signing → resolved
n_product_launch_decision → resolved
resolvedUnknownNodeIds:
n_enterprise_customer_signing (included)
n_product_launch_decision (OMITTED ← causes rejection)
```
This is a **read-only code-path and contract diagnosis**. No production code, tests, prompts, or API calls.
## Established facts (from 60B.47)
- Negative meaning preserved correctly (`userSupportedMeaning` accurate).
- Same customer factor reused (`n_enterprise_customer_signing`).
- Same decision targeted (`n_product_launch_decision`).
- No addedNodes, no addedEdges.
- Validation rejected at `proposal_compatibility` stage.
- Error: `"Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: \"n_product_launch_decision\""`
## Investigation path
### 1. Contract ownership
**updatedNodes[].newStatus** — MODEL GENERATED
The model generates this field directly as part of its JSON output from the prompt contract (prompt-builder.js lines 76-93 define the field shape; rules #5, #164-#178 govern its usage). No deterministic code modifies these values before validation.
**resolvedUnknownNodeIds** — MODEL GENERATED
The model generates this field directly as part of its JSON output. Rule #168 says: "When an answer resolves an existing unknown, include that existing node ID in resolvedUnknownNodeIds and update that node." This addresses the case where the model knows about resolution but doesn't explicitly tie it to updatedNodes[].newStatus.
**Are they generated independently?**
PARTIAL — The model generates both fields in one JSON emission. But there is no prompt rule that makes them *structurally dependent*. They are semantically linked by the model's understanding of "resolution" but structurally independent in the output contract.
**Does deterministic code reconcile them before validation?**
NO — `reconcileResolutionSemantics()` reconciles ONE direction only (resolvedUnknownNodeIds → updatedNodes). It never adds a node from updatedNodes into resolvedUnknownNodeIds.
### 2. Prompt contract analysis
Locating exact rules in `prompt-builder.js`:
**Rule #5:** "Resolve the answered unknown first when the answer supports it."
→ Generic resolution guidance. Does not mention resolvedUnknownNodeIds or updatedNodes relationship.
**Rule #168:** "When an answer resolves an existing unknown, include that existing node ID in resolvedUnknownNodeIds and update that node rather than creating only a parallel observation."
→ Says: put node ID in resolvedUnknownNodeIds AND update the node. But does NOT say: if you set newStatus="resolved" in updatedNodes, the node MUST also be in resolvedUnknownNodeIds.
**Rule #165:** "If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes."
→ Says to use both fields together for clarification cases. Does not define their structural relationship.
**Does the prompt explicitly require the bidirectional tie?**
NO — There is no explicit rule that says: "if any node in updatedNodes has newStatus='resolved', then every such node MUST also appear in resolvedUnknownNodeIds."
**Rule quality: MISSING**
The relationship between these two fields is never formally defined as an invariant in the prompt. The model must infer it from partial guidance (rule #168 implies both should be used together, but doesn't mandate their structural consistency).
### 3. Reconciliation analysis
Locating `reconcileResolutionSemantics()` in `apply-proposal.js` at line 312:
```javascript
function reconcileResolutionSemantics(graph, proposal) {
const nextProposal = cloneJsonSafe(proposal);
const errors = [];
const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node]));
const updatedNodeById = new Map(
nextProposal.updatedNodes.map((nodeUpdate) => [nodeUpdate.nodeId, nodeUpdate]),
);
// DIRECTION 1: resolvedUnknownNodeIds → updatedNodes (ONE-WAY)
for (const resolvedUnknownNodeId of nextProposal.resolvedUnknownNodeIds) {
const existingNode = graphNodeById.get(resolvedUnknownNodeId);
if (!existingNode) { /* error */ continue; }
if (existingNode.kind !== "unknown") { /* error */ continue; }
const existingUpdate = updatedNodeById.get(resolvedUnknownNodeId);
if (!existingUpdate) {
// Auto-create synthetic update for node in resolvedUnknownNodeIds but not in updatedNodes
const syntheticUpdate = buildResolvedUnknownUpdate(existingNode);
nextProposal.updatedNodes.push(syntheticUpdate);
updatedNodeById.set(resolvedUnknownNodeId, syntheticUpdate);
continue;
}
// If existingUpdate's newStatus is NOT "resolved", force it to "resolved"
if (existingUpdate.newStatus !== "resolved") {
existingUpdate.newStatus = "resolved";
/* ... copy previous status/value */
}
}
// DIRECTION 2: updatedNodes → resolvedUnknownNodeIds (NO OP — validation only)
for (const update of nextProposal.updatedNodes) {
const existingNode = graphNodeById.get(update.nodeId);
if (
existingNode?.kind === "unknown" &&
update.newStatus === "resolved" &&
!nextProposal.resolvedUnknownNodeIds.includes(update.nodeId)
) {
// ADDS ERROR — does NOT fix
errors.push(`Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: "${update.nodeId}"`);
}
}
return { proposal: nextProposal, errors };
}
```
**Choice: B — LEAVES MISMATCH UNCHANGED (for the mismatch direction)**
Exact behaviour when `updatedNodes` contains a node with `newStatus="resolved"` but `resolvedUnknownNodeIds` omits it:
1. The validation loop at lines 359-370 detects the inconsistency.
2. It pushes an error string to the errors array.
3. It does NOT add the node to `resolvedUnknownNodeIds`.
4. The errors array is returned alongside the (unmodified) proposal.
5. The caller (`applyValidatedProposal` at line 3518) adds these errors to `proposalCompatibilityErrors`.
6. Since `errors.length > 0`, the proposal fails at the `proposal_compatibility` stage.
**One-way reconciliation confirmed:** `resolvedUnknownNodeIds → updatedNodes` (auto-fix). Reverse direction only reports error, does not auto-fix.
### 4. Validator semantics
The invariant is enforced at `apply-proposal.js` lines 359-370 within `reconcileResolutionSemantics()`. This function serves dual role: reconciliation + validation. The specific check (lines 361-368) ensures every unknown node marked resolved in `updatedNodes` also appears in `resolvedUnknownNodeIds`.
**Is this invariant semantically necessary?**
YES — `resolvedUnknownNodeIds` is the canonical list of which unknowns are considered "resolved by this answer." If an unknown's status is set to "resolved" but it's absent from that list, downstream deterministic code (unknown clearing, decision closure, question selection) may not see it as resolved. The invariant ensures both lists agree on what was resolved.
**Why:** `resolvedUnknownNodeIds` drives:
- Post-mutation unknown clearing logic (activeUnknownNodeId resolution)
- Decision sufficiency checks
- Question elimination (resolved unknowns are excluded from candidate pools)
If a node is resolved via `updatedNodes.newStatus="resolved"` but not in `resolvedUnknownNodeIds`, some downstream paths would see it as unresolved while others see it as resolved — creating inconsistent state.
### 5. Positive vs negative comparison
**60B.46 (positive):** The model apparently emitted both nodes in `resolvedUnknownNodeIds`. This allowed reconciliation to auto-create synthetic updates for any missing `updatedNodes` entries, and the proposal passed validation cleanly.
**60B.47 (negative):** The model only included `n_enterprise_customer_signing` in `resolvedUnknownNodeIds`, omitting `n_product_launch_decision`. Both nodes appeared in `updatedNodes` with `newStatus="resolved"`. Reconciliation auto-fixed one direction (nothing to fix for customer since it was already in both lists), but reported an error for the decision node's missing entry.
| Field | 60B.46 | 60B.47 |
|---|---|---|
| customer updated to resolved | YES (in updatedNodes) | YES (in updatedNodes) |
| decision updated to resolved | YES (in updatedNodes, possibly via reconciliation synthetic) | YES (in updatedNodes) |
| customer in resolvedUnknownNodeIds | YES | YES |
| decision in resolvedUnknownNodeIds | YES (model provided) | NO (model omitted) |
| proposal accepted | YES | NO (422 proposal_compatibility) |
**Difference source: MODEL OUTPUT VARIANCE + DETERMINISTIC ASYMMETRY**
Both factors contributed:
- **MODEL OUTPUT VARIANCE:** The model included `n_product_launch_decision` in `resolvedUnknownNodeIds` for the positive case but not for the negative case. This is stochastic variance in how the model handles multi-node resolution lists.
- **DETERMINISTIC ASYMMETRY:** The reconciliation function only processes one direction (`resolvedUnknownNodeIds → updatedNodes`). If 60B.46's model had also omitted the decision from `resolvedUnknownNodeIds`, it would have failed identically to 60B.47. The deterministic asymmetry in the fix means model variance has different outcomes depending on which field the model happens to get "right."
### 6. Candidate assessment
**Candidate A — PROMPT CLARIFICATION**
Strengthen the prompt rule tying `newStatus="resolved"` to `resolvedUnknownNodeIds`.
- Prevents 60B.47 mismatch: PARTIAL (depends on future model compliance)
- Preserves semantic invariant: YES
- Depends on model compliance: HIGH
- Changes schema: NO
- Implementation scope: SMALL (prompt text change only)
- Principal risk: Stochastic model may still omit or produce inconsistent output; no deterministic fallback
**Candidate B — DETERMINISTIC NORMALISATION**
Before validation, deterministically add every unknown node updated to `resolved` into `resolvedUnknownNodeIds`.
- Prevents 60B.47 mismatch: YES (structural invariant enforced deterministically)
- Preserves semantic invariant: YES (normalisation aligns output with what the model already attempted to do)
- Depends on model compliance: LOW (model's intent is captured; code fixes the omission)
- Changes schema: NO
- Implementation scope: SMALL (~4 lines in reconcileResolutionSemantics, replacing error push with list update)
- Principal risk: Minimal — if model intentionally omits a node from resolvedUnknownNodeIds, this overrides it. But there is no legitimate semantic reason to resolve a node without listing it as resolved.
**Candidate C — REMOVE DUPLICATED REPRESENTATION**
Schema/contract redesign so resolution has one source of truth.
- Prevents 60B.47 mismatch: YES (eliminates the dual-representation problem)
- Preserves semantic invariant: YES (single source eliminates inconsistency)
- Depends on model compliance: LOW
- Changes schema: YES (requires prompt contract and proposal schema changes)
- Implementation scope: LARGE (affects all downstream consumers, tests, migration)
- Principal risk: Migration complexity; breaking existing proposals; over-engineering for a bounded fix
**Candidate D — KEEP CURRENT STRICT REJECTION**
Treat inconsistent model proposals as invalid and rely on retries/future model behaviour.
- Prevents 60B.47 mismatch: NO (same rejection will recur with probabilistic delay)
- Preserves semantic invariant: YES
- Depends on model compliance: HIGH
- Changes schema: NO
- Implementation scope: NONE
- Principal risk: Same failure pattern repeats; no deterministic guarantee of eventual success
**Candidate E — COMBINATION**
A + B: Prompt clarification PLUS deterministic normalisation.
- Minimum viable: B alone suffices for structural correctness. A reinforces intent.
- Prevents 60B.47 mismatch: YES
- Preserves semantic invariant: YES
- Depends on model compliance: LOW
- Changes schema: NO
- Implementation scope: SMALL
- Principal risk: Minimal
### 7. Critical distinction
**Choice: E — MULTIPLE FACTORS**
Three contributing factors, in order of impact:
1. **DETERMINISTIC NORMALISATION GAP (primary):** `reconcileResolutionSemantics` reconciles only one direction. The reverse gap is not auto-fixed.
2. **PROMPT COMPLIANCE GAP (secondary):** No explicit rule mandates the bidirectional structural tie between `updatedNodes[].newStatus="resolved"` and `resolvedUnknownNodeIds`.
3. **MODEL OUTPUT VARIANCE (symptom):** The model sometimes includes both nodes in `resolvedUnknownNodeIds`, sometimes doesn't — depending on answer polarity/framing.
### 8. Minimum corrective boundary
**Choice: B — deterministic reconciliation**
Add every unknown node updated to `resolved` into `resolvedUnknownNodeIds` inside `reconcileResolutionSemantics()`, before the validation loop. This:
- Preserves the invariant that resolved unknowns are represented consistently
- Does not weaken semantic validation (validator still catches mismatches)
- Does not depend on stochastic model compliance
- Preserves accepted 60B.46 positive closure (both nodes already in resolvedUnknownNodeIds → no change to output)
- Makes negative closure structurally valid (adds missing node deterministically)
- Avoids schema change
The exact change would be in `reconcileResolutionSemantics()` at line ~359, before the error-pushing loop:
```javascript
// NEW: Normalise updatedNodes → resolvedUnknownNodeIds (reverse direction)
for (const update of nextProposal.updatedNodes) {
const existingNode = graphNodeById.get(update.nodeId);
if (
existingNode?.kind === "unknown" &&
update.newStatus === "resolved" &&
!nextProposal.resolvedUnknownNodeIds.includes(update.nodeId)
) {
nextProposal.resolvedUnknownNodeIds.push(update.nodeId);
}
}
```
Then keep the existing error-pushing loop as an assertion (detecting post-normalisation mismatch should now be impossible, but it remains as defensive code).
### 9. Validation of boundary candidates
**Would positive closure remain valid:** YES — In 60B.46's case, both nodes were already in `resolvedUnknownNodeIds`, so the normalisation adds nothing (duplicate check prevents double-inclusion).
**Would negative closure become structurally valid:** YES — The missing `n_product_launch_decision` would be added deterministically before validation.
**Would validator remain strict:** YES — The existing error-pushing code remains as a post-normalisation assertion. If any future scenario produces a mismatch (should be impossible after normalisation), it is still rejected.
## Findings summary
| Checkpoint | Finding |
|---|---|
| Contract ownership | Both fields are MODEL GENERATED, structurally independent in the prompt |
| Prompt contract | MISSING — no explicit rule tying `newStatus="resolved"` to `resolvedUnknownNodeIds` membership |
| Reconciliation | B — LEAVES MISMATCH UNCHANGED for reverse direction; only reconciles resolvedUnknownNodeIds → updatedNodes |
| Validator semantics | YES, semantically necessary — prevents inconsistent downstream resolution state |
| Positive vs negative | MODEL OUTPUT VARIANCE + DETERMINISTIC ASYMMETRY |
| Critical distinction | E — MULTIPLE FACTORS (normalisation gap primary, prompt gap secondary) |
| Minimum boundary | B — deterministic reconciliation (add missing nodes to resolvedUnknownNodeIds before validation) |
## No production code changed. No tests modified. No Ollama calls. No live API calls. Pure code-path and contract diagnosis.
@@ -0,0 +1,166 @@
# Experiment 60B.5 — Live Validation of Decision Materiality Rule
**Branch:** `feature/decision-sufficiency-v0.26`
**Date:** 2026-08-13
**Status:** Complete
**Type:** LIVE RUN — Bounded single-call experiment validating the prompt-only materiality rule from 60B.4 against the exact 60B.2 failure case.
## Objective
With the new materiality rule in place, does the engine either resolve the decision independently or keep it open only for a specific grounded factor that could materially change the preferred option?
This is the live regression that 60B.4 said was unproven:
> "1. Stability — deterministic prompt tests confirm the instruction text is present and well-formed, but do not verify the model follows it consistently across repeated runs"
## Following
Experiment 60B.2 (the failure case: generic continuation when both costs quantified, no explicit stopping cue)
Experiment 60B.4 (the fix: prompt-only decision materiality rule, deterministic tests only)
## Fixed Starting Graph
**Fixture:** `tests/fixtures/pre-anchored-decision-options.json`
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_relocation_state | state | provisional | Engineering team relocation consideration |
| opt_relocate | option | known | Relocate to Manchester |
| opt_stay_put | option | known | Stay in London (Status Quo) |
| n_relocation_decision | unknown | unknown | Which option leaves us better off overall? |
## Configured Model
- **Model:** qwen-claude:latest
- **Ollama base URL:** http://127.0.0.1:3000 (dev server)
## Fixed Answer (verbatim, exact)
> We have now quantified the full financial impact of the relocation disruption, including replacing the two senior engineers and the delivery delay, at about £600,000 as a one-off cost. Staying put costs us an extra £2 million every year.
## Execution
Host/model: qwen-claude:latest at http://127.0.0.1:3000. startCalls=0, updateCalls=1, totalCalls=1. Hard one-call boundary.
## Result
**HTTP status:** 200 — first call succeeds, no validation rejection.
### Proposal mutations
```
updatedNodes: [{nodeId:"n_relocation_decision", previousStatus:"unknown", newStatus:"resolved", previousValue:null, newValue:"Relocate to Manchester", reason:"Quantified financial impact (£600k one-off vs £2M/year savings) clearly favors relocation after ~3.6 months, resolving the net-value uncertainty."}]
resolvedUnknownNodeIds: ["n_relocation_decision"]
addedNodes: [{id:"n_fin_quantification", label:"Quantified financial impact of relocation disruption", description:"Relocation disruption costs approximately £600,000 as a one-off expense (replacing senior engineers and delivery delay). Staying put incurs an ongoing extra cost of £2,000,000 per year.", kind:"observation", status:"known", confidence:"high"}]
addedEdges: [{fromNodeId:"n_fin_quantification", toNodeId:"opt_relocate", relationship:"supports"}, {fromNodeId:"n_fin_quantification", toNodeId:"opt_stay_put", relationship:"supports"}]
```
### Selected question
**null** — decision is resolved. No follow-up question generated.
### Resulting persistent graph (5 nodes, 4 edges)
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_relocation_state | state | provisional | Engineering team relocation consideration |
| opt_relocate | option | known | Relocate to Manchester |
| opt_stay_put | option | known | Stay in London (Status Quo) |
| n_relocation_decision | unknown | **resolved** | Which option leaves us better off overall? |
| n_fin_quantification | observation | known | Quantified financial impact of relocation disruption |
Edges:
- opt_relocate → n_relocation_decision (contained_in)
- opt_stay_put → n_relocation_decision (contained_in)
- n_fin_quantification → opt_relocate (supports)
- n_fin_quantification → opt_stay_put (supports)
## Assessment
### 1. Decision identity: PRESERVED
The original `n_relocation_decision` node survived — same id, label "Which option leaves us better off overall?". Status transitioned from `unknown``resolved`. Included in `resolvedUnknownNodeIds`. Not duplicated or replaced. Count: 1.
### 2. Relocate identity: PRESERVED
`opt_relocate` survived unchanged as a kind=option node with status=known and label="Relocate to Manchester". Count: 1.
### 3. Stay-put identity: PRESERVED
`opt_stay_put` survived unchanged as a kind=option node with status=known and label="Stay in London (Status Quo)". Count: 1.
### 4. £600k relocation cost: FIRST-CLASS STRUCTURE
A new observation node `n_fin_quantification` was created with kind=observation, status=known, confidence=high. Its description contains both quantified figures ("approximately £600,000 as a one-off expense" and "£2,000,000 per year"). Typed support edges connect it to both option nodes. This is first-class graph structure — independently recoverable via edge traversal, not embedded in prose or lost on an option's internal field.
### 5. £2m/year stay-put cost: FIRST-CLASS STRUCTURE
Same observation node as above. The description explicitly states "Staying put incurs an ongoing extra cost of £2,000,000 per year." Time-unit distinction (ongoing vs one-off) is preserved in the description text. Typed edge to opt_stay_put confirms option attribution. First-class structure.
### 6. Decision treatment: RESOLVED INDEPENDENTLY
`n_relocation_decision` resolved with newValue="Relocate to Manchester" and reason containing the ~3.6 month payback computation. Both options have known consequences with quantified financial data. The engine determined this was sufficient — no continuation question generated, no new unknowns invented. This is the core behavioural change that 60B.4's materiality rule was designed to produce.
### 7. Decision resolution: CORRECTLY RESOLVED
Status transition unknown → resolved. Direction expressed in newValue: "Relocate to Manchester." The engine performed a meaningful financial comparison (£600k one-off vs £2M/year recurring) and determined the evidence was sufficient. No fabricated factors, no generic continuation, no precision chasing.
### 8. Conclusion direction: FAVOURS RELOCATE
newValue = "Relocate to Manchester" is explicit direction in the resolved state. The reason text also confirms: "clearly favors relocation after ~3.6 months."
### 9. Precision chasing: NO
The engine did not ask for more precise figures. It computed a rough payback and accepted the comparison as sufficient. No re-investigation of any settled fact.
## Comparison with 60B.2
| Field | 60B.2 | 60B.5 |
|-------|-------|-------|
| Decision status | supported (unclosed) | **resolved** |
| resolvedUnknownNodeIds | [] | ["n_relocation_decision"] |
| selectedQuestion | "What outcome would demonstrate enough value to justify continuing?" (generic, WEAK) | **null** (NONE — DECISION COMPLETE) |
| new unknowns | 0 (but no resolution) | 1 observation node (known fact, not unknown) |
| specific material reason for continuation | YES (but generic — the question itself was the "reason", which was non-specific) | N/A (decision resolved) |
## Classification: A — MATERIALITY RULE FIX CONFIRMED
The decision resolves independently with no option/decision identity damage and no fabricated material factor. The generic continuation from 60B.2 is eliminated. Additionally, the engine created first-class structural evidence (observation node with typed edges) for both quantified costs rather than embedding them as option-internal numeric values.
### Critical evidence check
- Decision resolves independently: **YES**
- No option/decision identity damage: **YES** — all three preserved
- No fabricated material factor: **YES** — the observation node captures user-supplied data, not invented uncertainty
- No generic follow-up: **YES** — null selectedQuestion
### What the new materiality rule changed
The materiality rule added in 60B.4 ("uncertainty alone is not sufficient reason to continue; continuation requires a specific material factor that could change the preferred option") shifted the engine's default from "keep open + ask generic question" to "resolve when evidence is sufficient." The engine now performs the financial comparison internally and uses it as a sufficiency trigger rather than treating the comparison as itself needing more evidence.
## What this establishes:
1. **The materiality rule works in live inference.** The deterministic tests from 60B.4 predicted the right behavior; the live run confirmed it.
2. **The engine recognizes quantified option comparison as sufficient evidence for decision resolution** even without an explicit user stopping cue.
3. **First-class observation nodes can capture multi-option financial data** with typed edges preserving option attribution and time-unit distinction.
4. **No regression in entity preservation.** All three identities (decision, relocate, stay-put) survive intact across the materiality-rule intervention.
## What this does NOT prove:
1. **Stability across repeated runs.** Single live call; cold-start variance may produce different outcomes on another run.
2. **Cross-domain generalisation.** Single domain case only.
3. **Whether the observation node creation is driven by the materiality rule or independent evidence-capture behavior.** Both mechanisms could be at play.
4. **Edge cases** — decisions where multiple partially-material factors exist; decisions with equal evidence across options; ambiguous factor specificity.
5. **Whether the resolved direction ("Relocate to Manchester") is robust** — the short newValue doesn't explain the reasoning (the ~3.6 month payback appears in reason but not in the persistent graph state).
## Production code changed: NO
## Prompt changed: NO
## Validator changed: NO
## Harness changed: NO
## Vitest run: NO
## Ollama calls: 1
## Direct API calls: 0
## Dev server disturbed: NO
@@ -0,0 +1,193 @@
# Experiment 60B.6 — Test Materiality Rule Against Real Unresolved Factor
**Branch:** `feature/decision-sufficiency-v0.26`
**Date:** 2026-08-13
**Status:** Complete
**Type:** LIVE RUN — Single-call experiment testing the materiality rule from 60B.4 against a genuinely decision-changing uncertainty.
## Objective
When the quantified comparison favours one option but one specific unresolved factor could realistically reverse that preference, does the engine keep the decision open for that factor rather than over-closing?
## Following
Experiment 60B.2 (over-closure: resolves decision when £5M client risk remains)
Experiment 60B.4 (the fix: prompt-only materiality rule)
Experiment 60B.5 (confirmation: materiality rule can close without explicit cue)
This is the **opposite boundary** of 60B.5's success case. 60B.5 tested "should it close when nothing materially unresolved?" — and it did. 60B.6 tests "should it stay open when something materially unresolved exists?" — and should keep open for that specific factor.
## Fixed Starting Graph
**Fixture:** `tests/fixtures/pre-anchored-decision-options.json`
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_relocation_state | state | provisional | Engineering team relocation consideration |
| opt_relocate | option | known | Relocate to Manchester |
| opt_stay_put | option | known | Stay in London (Status Quo) |
| n_relocation_decision | unknown | unknown | Which option leaves us better off overall? |
## Configured Model
- **Model:** qwen-claude:latest
- **Ollama base URL:** http://127.0.0.1:3000 (dev server)
## Fixed Answer (verbatim, exact)
> We have now quantified the full financial impact of replacing the two senior engineers and the delivery delay at about £600,000 as a one-off relocation cost. Staying put costs us an extra £2 million every year. The remaining issue is our largest client: we do not yet know whether they would leave if we relocated, and losing them would cost us about £5 million per year.
## Why This Case Matters
Without the client risk, Relocate (£600k one-off) vs Stay (£2M/year recurring) clearly favours Relocate. The £5M/year client-retention risk is large enough to plausibly reverse that preference — making it a genuine decision-changing uncertainty.
## Execution
Single update call. No retries. Hard boundary enforced by harness.
## Call Accounting
```
startCalls: 0
updateCalls: 1
totalCalls: 1
Retries: 0
```
## HTTP Response
- **Status:** 200 — success, no rejection
- **Proposal applied:** YES (accepted)
## Proposal Mutations
```
updatedNodes: []
resolvedUnknownNodeIds: []
addedNodes: [{
id: "n_client_retention",
label: "Largest client retention uncertainty",
description: "Whether the organisation's largest client will leave if the team relocates to Manchester, matters because their departure would cost ~£5M/year and likely negate the £2M annual savings needed to justify relocation.",
kind: "unknown",
status: "unknown",
confidence: "medium"
}]
addedEdges: [{
id: "e-client-to-opt",
fromNodeId: "n_client_retention",
toNodeId: "opt_relocate",
relationship: "may_cause",
confidence: "medium",
description: "Relocating to Manchester may cause the largest client to leave"
}]
selectedQuestion: "What outcome would demonstrate enough value to justify continuing?"
```
## Resulting Persistent Graph (5 nodes, 3 edges)
| Node | Kind | Status | Label |
|------|------|--------|-------|
| n_relocation_state | state | provisional | Engineering team relocation consideration |
| opt_relocate | option | known | Relocate to Manchester |
| opt_stay_put | option | known | Stay in London (Status Quo) |
| n_relocation_decision | unknown | unknown | Which option leaves us better off overall? |
| n_client_retention | unknown | unknown | Largest client retention uncertainty |
Edges:
- opt_relocate → n_relocation_decision (contained_in)
- opt_stay_put → n_relocation_decision (contained_in)
- n_client_retention → opt_relocate (may_cause)
## Assessment
### 1. Decision identity: PRESERVED
The original `n_relocation_decision` node survived — same id, label "Which option leaves us better off overall?", status=unknown. Not resolved. Count: 1.
### 2. Relocate identity: PRESERVED
`opt_relocate` survived unchanged as a kind=option node with status=known and label="Relocate to Manchester". Count: 1.
### 3. Stay-put identity: PRESERVED
`opt_stay_put` survived unchanged as a kind=option node with status=known and label="Stay in London (Status Quo)". Count: 1.
### 4. Known financial comparison (£600k + £2M/year)
**No new observation node created.** Unlike 60B.5 (which created `n_fin_quantification`), the quantified figures exist only in the user answer text, not as structured evidence nodes in the graph. They are implicitly available in the option descriptions but not explicitly captured as comparison evidence.
Classification: **PARTIAL** (figures survive in option descriptions but no new first-class observation structure was created)
### 5. Client-retention uncertainty: FIRST-CLASS UNKNOWN
Created as `n_client_retention` with kind=unknown, status=unknown. Has a description explaining the factor and its relevance. This is a proper first-class unknown node — not text-only, not flattened into an option's internal state.
Classification: **FIRST-CLASS UNKNOWN**
### 6. Client-risk ownership to Relate: CLEARLY OWNED BY RELOCATE
The `n_client_retention` node has `childIds: ["opt_relocate"]` and a typed edge `n_client_retention → opt_relocate` with relationship="may_cause". Graph-only reasoning can determine the unresolved client risk belongs to the Relocate option.
Classification: **CLEARLY OWNED BY RELOCATE**
### 7. £5M/year downside: PRESERVED WITH UNKNOWN
The "~£5M/year" figure is embedded in the description text of `n_client_retention`. It is not isolated as a separate structured numeric value but survives within the unknown node's epistemic container.
Classification: **PRESERVED WITH UNKNOWN**
### 8. Materiality judgment: RECOGNISED BUT WEAKLY
The engine correctly kept the decision open and created the client-retention unknown, demonstrating it recognised this factor as material. However, the recognition is structural (it created the node) but not interrogative (the selected question does not target it). The materiality rule prevented over-closure but did not fully activate the follow-up targeting the specific material factor.
Classification: **RECOGNISED BUT WEAKLY**
### 9. Decision treatment: KEPT OPEN FOR SPECIFIC MATERIAL REASON
The decision was kept open — `n_relocation_decision` remains unknown, nothing resolved. The newly added `n_client_retention` is clearly the specific material reason (a client retention uncertainty with £5M/year downside that could reverse the preference). No unrelated uncertainty invented.
Classification: **KEPT OPEN FOR SPECIFIC MATERIAL REASON**
### 10. Selected question: WEAK
The question "What outcome would demonstrate enough value to justify continuing?" is generic — the same phrasing from 60B.2. The engine has just created a specific client-retention unknown and should have targeted it with something like "Will the organisation's largest client leave if we relocate to Manchester?"
Classification: **WEAK**
## Classification: B — MATERIAL FACTOR RECOGNISED BUT STRUCTURE PARTIAL
The engine correctly continues for the client risk (classification B requires this) but its representation or question specificity is incomplete.
### Critical evidence check
- Decision remains unresolved: **YES**
- Client-retention uncertainty survives: **YES**
- Client risk attributable to Relocate: **YES**
- £5M/year impact survives: **YES** (embedded in description text, not isolated)
- Generic substitute question replaces specific targeting: **NO — it uses generic phrasing instead of targeting the specific unknown**
- No unnecessary new uncertainty invented: **YES**
## What this establishes:
1. **The materiality rule prevents over-closure with a real decision-changing factor.** When an unresolved £5M/year client-retention risk exists, the engine does NOT resolve the decision — correctly keeping it open.
2. **The engine can create first-class unknown nodes for option-specific risks.** The `n_client_retention` node is properly typed, attributed to the correct option via a `may_cause` edge, and has meaningful description text.
3. **Option ownership survives graph construction.** The `may_cause` relationship from the client-retention unknown to `opt_relocate` makes clear this uncertainty belongs to the Relate option — enabling future reasoning to correctly associate upside/downside with the right candidate.
## What this does NOT prove:
1. **Whether the materiality rule can target follow-up questions at the specific material factor.** The generic question suggests structural recognition but interrogative gap.
2. **Whether the observation-node behaviour is consistent** (60B.5 created one, 60B.6 did not). This may be context-dependent or model-stochastic rather than rule-driven.
3. **Cross-domain robustness.** Single scenario with a single model.
4. **What happens with multiple concurrent material uncertainties.** Tested exactly one unresolved factor.
## Production code changed: NO
## Prompt changed: NO
## Validator changed: NO
## Harness changed: NO
## Vitest run: NO
## Ollama calls: 1
## Direct API calls: 0
## Dev server disturbed: NO
@@ -0,0 +1,263 @@
# Experiment 60B.7 — Why does selectedQuestion fail to target the specific material unknown just created?
**Branch:** `feature/decision-sufficiency-v0.26`
**Date:** 2026-08-13
**Status:** Complete (diagnosis only)
**Type:** READ-ONLY DIAGNOSIS — Architecture-level tracing of selectedQuestion lifecycle.
## Objective
Explain why experiment 60B.6 produced:
```
addedNodes: n_client_retention (correct material unknown)
selectedQuestion.nodeId: n_relocation_decision (generic parent decision)
selectedQuestion text: "What outcome would demonstrate enough value to justify continuing?" (generic template question)
```
The engine identifies the correct material unknown structurally but fails to target it interrogatively.
## Following
Experiment 60B.4 (materiality rule added to prompt)
Experiment 60B.5 (decision closes without material uncertainty)
Experiment 60B.6 (decision stays open for £5M client risk, but question targets parent decision generically)
## Method
Code tracing only. No live calls. No code changes. Read-only inspection of:
- `lib/graph/prompt-builder.js` — prompt rules for selectedQuestion
- `lib/graph/apply-proposal.js` — validation and deterministic selection
- `lib/graph/orchestrator.js` — updateCase flow ordering
- `lib/graph/utils.js` — scoreUnknownCandidate and selectActiveUnknownCandidate
- `lib/graph/question-formulator.js` — template-based question generation
## Checkpoint 1 — selectedQuestion ownership
### Who creates the final selectedQuestion?
**HYBRID in proposal, ENTIRELY DETERMINISTIC in output.**
The model produces `selectedQuestion: {nodeId, question, reason}` inside its proposal JSON. However:
```
In apply-proposal.js line ~3734-3861 (applyValidatedProposal):
const effectiveSelectedQuestion =
deterministicSelection?.status === "selected"
? {
nodeId: deterministicSelection.nodeId, // <-- deterministic
question: effectiveFormulatedQuestion?.question || // <-- deterministic
deterministicSelection.question,
...all other fields from formulatedQuestion // <-- deterministic
}
: null;
```
The final `effectiveSelectedQuestion` that gets returned to the orchestrator is **100% deterministic**. Both nodeId and question text come from the deterministic pipeline:
1. `selectActiveUnknownCandidate(graph, resolvedNodeIds)` — scores ALL unresolved unknowns by text-pattern matching and structural metrics (downstream count, unresolved parent dependencies)
2. The highest-scoring node becomes `deterministicSelection.nodeId`
3. `formulateQuestion({ node, graph, context })` — generates question text from deterministic templates (`buildQuestionFromFamily`, `buildFoundationalDirectQuestion`, etc.)
### Is selectedQuestion.nodeId model-generated?
**NO.** The model's `selectedQuestion.nodeId` is only structurally validated (line 3318):
- Does the node exist? (in graph OR addedNodes)
- Is it an unknown kind?
- Is it unresolved?
- Is the question non-compound?
It is NOT used as a priority signal. It does not boost score. It does not bias selection. It does not appear in `deterministicSelection`.
### Is selectedQuestion text model-generated?
**NO.** The model's question text is completely discarded at line 3746 / 3836:
```js
question: effectiveFormulatedQuestion?.question || deterministicSelection.question
```
The text comes from `buildQuestionFromFamily` or `buildDeterministicQuestionForUnknown` — deterministic template functions that match keywords in the selected node's label/description and produce one of ~20 predefined question templates.
## Checkpoint 2 — timing
### The exact ordering in apply-proposal.js:
```
Line ~3318 validateSelectedQuestion(situationGraph, validatedProposal)
[validates model's nodeId against graph + addedNodes]
Line ~3358 applyGraphUpdate(graphSnapshot, proposalSnapshot)
[mutation applied — new nodes NOW in graph]
Line ~3439 deterministicSelection = selectActiveUnknownCandidate(
updatedSituationGraph, resolvedNodeIds)
[scores ALL unresolved unknowns including newly added ones]
Line ~3715 formulatedQuestion = formulateQuestion({ node, graph, context })
[deterministic question text from templates]
Line ~3734 effectiveSelectedQuestion built from deterministicSelection + formulatedQuestion
[final output — 100% deterministic]
```
### Can selectedQuestion target a node created in the same proposal?
**YES.** `validateSelectedQuestion` at line 218 uses:
```js
const nodeById = buildNodeById(graph, proposal.addedNodes);
```
This includes newly-added nodes. And `selectActiveUnknownCandidate` scores against `updatedSituationGraph` which already contains the mutations (line 3439).
### Is the newly-added unknown available before selectedQuestion is finalized?
**YES.** By line 3439, the mutation has been applied and the new unknown is in the graph. It is scored alongside all existing unresolved unknowns.
## Checkpoint 3 — prompt contract
### Rules relevant to selectedQuestion (from prompt-builder.js):
**Rule 16:** *"When your proposal adds one or more new unresolved unknowns, you MUST include a selectedQuestion identifying one of those as a candidate unknown node."*
→ "one of those" = ANY one. Not the most material one. Not the most consequential one. Just any valid unresolved unknown from addedNodes.
**Rule 17:** *"selectedQuestion.nodeId must reference an unresolved unknown node that exists either already in the graph or in addedNodes."*
→ Pure structural constraint. No semantic prioritization required.
**Rule 18:** *"selectedQuestion.question must be one narrow non-compound question about that one unknown."*
→ The model's text is validated structurally but discarded at output (see Checkpoint 1).
**Rule 20:** *"Return selectedQuestion as null only when no consequential unresolved unknown remains."*
→ Does not require selecting the MOST material factor. Only requires not returning null if any consequential unresolved unknown exists.
**Rule 22 + Additional Guidance line 172:** *"When selectedQuestion is provided, your role ends at supplying one valid unresolved unknown node from the graph or addedNodes — the engine retains deterministic final-priority selection and may choose a different question if multiple candidates exist."*
**Explicitly acknowledges** that the model's choice does not determine the final selection. The engine has full override authority.
### Does materiality rule connect to selectedQuestion targeting?
**NO.** The materiality rule (lines 137-143) says:
> *"Keep a decision context unresolved only when you can identify a specific unresolved factor that could materially change which option is preferred. If the currently supported evidence is sufficient to distinguish the options and no such material unresolved factor remains, resolve the existing decision context and do not ask a generic continuation question."*
This governs **whether** to keep open. It does NOT say: *"When you keep open for a specific material factor, your selectedQuestion must target that factor."* There is no rule that bridges materiality recognition → question targeting.
## Checkpoint 4 — deterministic selection / validation
### Does scoreUnknownCandidate prioritize newly-created unknowns?
**NO.** The scoring function (utils.js line 332) uses:
- `downstreamCount × 4` — how many other nodes depend on this one
- Text pattern matches from `classifyUnknownPriority`:
- objective (+12), criteria (+11), actor (+10), constraint (+9), measure (+8), terminology (+7)
- pricing penalty (-8), implementation penalty (-10), optimisation penalty, speculative penalty
- **Zero** recency or "newly-created" bonus
### Does scoring prioritize the material unknown that justified continuation?
**NOT BY DESIGN.** Scoring only looks at text keywords and structural position. A newly created unknown like `n_client_retention` scores based on keyword density in its label+description. The pre-existing parent decision node (`n_relocation_decision`) may have accumulated more matching text through its label ("Which option leaves us better off overall?") and description context.
There is no "materiality" concept computed or passed to the scorer.
### Does validation reject a generic question when a specific unresolved node exists?
**NO.** `validateSelectedQuestion` (line 215) checks:
- nodeId exists ✓
- nodeId is unknown kind ✓
- nodeId is unresolved ✓
- Question is non-compound ✓
It does NOT check:
- Whether the selected node is the most consequential
- Whether a more specific factor exists
- Whether the question is generic vs targeted
### Does `validateQuestionSelectionRequirement` enforce specificity?
**NO.** (line 283) Only checks: if consequential unresolved unknowns exist, selectedQuestion must not be null. It does NOT check that the selected node matches the most material factor.
## Checkpoint 5 — reconstruct 60B.6
### How could n_client_retention coexist with a generic parent-decision question?
The flow in 60B.6:
1. **Model proposes:**
- `addedNodes: [n_client_retention]` — correct material unknown
- `selectedQuestion.nodeId: n_relocation_decision` — parent decision (valid but not optimal)
- `selectedQuestion.question: "What outcome would demonstrate enough value to justify continuing?"`
2. **Validation** passes because n_relocation_decision is an existing unresolved unknown.
3. **Mutation applied** — n_client_retention now exists in the graph.
4. **Deterministic selection** scores ALL unresolved unknowns (n_relocation_decision + n_client_retention):
- Both are candidates
- Scoring based on text pattern matches and downstream count
- Whichever scored higher was selected by the deterministic pipeline
- The model's nodeId had no influence on score
5. **Question text** generated from template for the deterministically-selected node:
- For n_relocation_decision, the label "Which option leaves us better off overall?" triggers one of the decision-foundation templates
- Result: "What outcome would demonstrate enough value to justify continuing?" — a generic template match
### Contract-valid?
**YES.** Every contract rule was satisfied:
- addedNodes: valid new unknown with description ✓
- selectedQuestion.nodeId referenced an existing unresolved unknown ✓
- Question text is narrow and non-compound ✓
- Decision correctly kept open (materiality rule) ✓
- No duplicate unknowns ✓
- Added edge connecting n_client_retention to opt_relocate ✓
### Semantically aligned with materiality rule?
**PARTIAL.** The engine recognized the material factor structurally (created the node, connected it, kept the decision open). But the follow-up question did not target the material factor — it targeted the parent decision generically. The spirit of "do not ask a generic continuation question" (rule 143) was violated in practice even though no explicit contract rule forbids this combination.
### What exact rule was missing?
**No rule connects materiality recognition to question targeting.** The prompt says:
- Rule 16: include selectedQuestion identifying "one of those" unknowns as a candidate ✓
- Rule 172: engine retains deterministic final-priority selection ✓
But neither rule says: when the decision is kept open for a specific material factor, the follow-up question must target that factor. The system treats all unresolved unknowns as equally valid question targets, and deterministic scoring has no awareness of which factor justified continuation.
## Classification: E — MULTIPLE FACTORS
### Combination identified:
**A + B + D**
- **A (Prompt Alignment Gap):** Rule 16 says "identify one of those" — not the material one. Rule 172 explicitly acknowledges model's choice is advisory, not binding. No rule requires the follow-up to target the material factor that justified continuation.
- **B (Selection Priority Gap):** `scoreUnknownCandidate` has no recency bonus and no materiality awareness. It scores all unresolved unknowns purely by text keywords and structural position. Newly-created consequential unknowns get zero priority boost.
- **D (Validation Gap):** `validateSelectedQuestion` accepts any structurally valid unresolved unknown. There is no check that the selected node matches the most consequential unresolved factor. Generic questions are not rejected when specific unresolved nodes exist.
## Minimum Missing Distinction: B — CONTINUATION-REASON → QUESTION-TARGET RULE
**Why:** Adding a rule equivalent to:
> "When a decision remains unresolved because of a specific material factor, selectedQuestion must target that factor rather than the parent decision generically."
This is the narrowest change that closes the gap. Options C (new-unknown priority) and D (validation rejection of generic questions) are related but either too broad or too late in the pipeline. Option B addresses the root cause: no semantic bridge exists between "why we're staying open" and "what we should ask next."
## Implementation Readiness: A — READY FOR BOUNDED IMPLEMENTATION
**Smallest implementation boundary:**
1. Add one rule to prompt-builder.js saying that when a decision remains open for a specific material factor, the selectedQuestion node must be constrained to that factor or its direct children.
2. Optionally add `validateQuestionSelectionAlignment` in apply-proposal.js that checks whether the deterministically-selected node matches the materiality reason — as an advisory diagnostic (not rejection).
This requires prompt-only changes plus optional lightweight validation. No schema changes, no new scoring dimensions, no architecture overhaul.
## Documentation
- Created: docs/experiment-60b7.md
- Appended to: docs/current-handoff.md
- Commit message: experiment: diagnose material-factor question targeting
## Git status
@@ -0,0 +1,326 @@
# Experiment 60B.8 — Which minimal mechanism preserves the material factor into final question selection?
**Branch:** `feature/decision-sufficiency-v0.26`
**Date:** 2026-08-13
**Type:** READ-ONLY DESIGN COMPARISON — Three candidate mechanisms evaluated against actual codebase structure and semantics.
---
## Context
Experiment 60B.7 proved that:
```
reasoning layer: correctly identifies a specific material unresolved factor (n_client_retention)
deterministic selection: ignores the model-selected nodeId and independently re-selects from all unresolved unknowns
selectedQuestion text: generic template matched to the parent decision node, not the material factor
```
The gap is purely at the deterministic-selection layer: a valid model-selected nodeId exists but carries zero weight. Three mechanisms are assessed against this exact gap.
---
## Existing selectedQuestion semantics (before any change)
### Does model-selected selectedQuestion.nodeId already mean "this is the unresolved node we should ask about next"?
**PARTIAL.** Rule 16 requires the model to select "one of those" — not specifically the material factor. Rule 17 constrains it structurally. Rule 172 explicitly states: *"the engine retains deterministic final-priority selection and may choose a different question if multiple candidates exist."* The field communicates *candidate intent*, not binding assignment. However, when the model intentionally picks one of its own newly-created nodes (as in 60B.6), the intent clearly points to that node as the intended target.
### Is ignoring it in final deterministic selection semantically contradictory?
**PARTIAL.** Contractually: no. Rule 172 grants override authority. Practically: yes, when the model's nodeId is one of its own newly-created unknowns. The contract gives the engine authority to override, but there is zero semantic justification for overriding a valid model-selected newly-added node — the engine has no information the model lacks about which factor matters for this decision.
### Can current validation already establish that the proposed nodeId:
- exists or is added in this proposal
- is kind=unknown
- is unresolved
**YES.** All three checks are in `validateSelectedQuestion` (line 215):
- `buildNodeById(graph, proposal.addedNodes)` includes newly-added nodes
- `node.kind !== "unknown"` check at line 232
- `resolvesNode || effectiveStatus === "resolved"` check at lines 238-250
### Can deterministic formulation safely generate text from that node after mutation?
**YES.** After `applyGraphUpdate` (line 3358), `findNodeById(updatedSituationGraph, nodeId)` in `buildSelectedQuestionResult` (line 2110) retrieves the node. `formulateQuestion` generates deterministic text from templates. The pipeline is intact — no structural barrier exists.
---
## Candidate A — Honour Valid Model-Selected Node as Preferred Target
### Concept
If `proposal.selectedQuestion.nodeId` passes existing validation:
```
selectedNode is structurally valid (exists/is-added, kind=unknown, unresolved)
```
Use it as the **preferred target** in `buildSelectedQuestionResult`. If the preferred target disappears between preference and finalisation (e.g., another proposal mutation resolves it), fall back to `selectActiveUnknownCandidate(...)`.
### Mechanism sketch (conceptual only — no code)
In `buildSelectedQuestionResult`:
```
if proposedTarget is valid AND still unresolved in updatedSituationGraph:
use proposedTarget as selectedNode
formulate question for it
else:
run selectActiveUnknownCandidate(updatedSituationGraph, resolvedNodeIds)
```
The model-selected nodeId already carries the correct semantic meaning when it points to a newly-added unknown. No new field, no new schema, no new keyword logic.
### Materiality fidelity: HIGH
When the model identifies `n_client_retention` as its selectedQuestion and it passes validation, that node becomes the question target. The material factor is preserved through the full pipeline.
### Deterministic safety: HIGH
Validation is already in place — `validateSelectedQuestion` covers existence, kind=unknown, unresolved status. Post-mutation, `isSelectableUnresolvedUnknown` provides a second check with identical semantics. Fallback to existing `selectActiveUnknownCandidate` is guaranteed when the preferred target becomes invalid. No new failure path; the existing fallback already exists at line 3431-3436 (`if !remainingUnknownExists → run selectActiveUnknownCandidate`).
### Semantic honesty: HIGH
`selectedQuestion.nodeId` already means "candidate for next question". Using it as a preferred target is a semantic natural extension — not an overload. The existing prompt Rule 172 would need a minor clarification (the engine has priority authority, but prefers the model's choice when valid), but the field itself is used for its intended purpose.
### New schema fields:
None. Uses `selectedQuestion.nodeId` only.
### New validator rules:
None. Existing `validateSelectedQuestion` + `isSelectableUnresolvedUnknown` are sufficient.
### New prompt concepts:
Minor clarification of Rule 172 to indicate that when the model selects a valid unresolved unknown (especially from addedNodes), the engine prefers that selection as the preferred target. Not a new rule — an intensification of existing contract language.
### New scoring logic:
None. The preferred target does not enter `scoreUnknownCandidate`. It short-circuits scoring entirely when valid, then falls through to scoring only when invalid/lost.
### Principal risk:
Model selects a valid but low-value unknown (e.g., a tangentially-related newly-created node rather than the truly material one). **Mitigation:** This risk already exists today — deterministic selection also sometimes picks a lower-value target based on keyword matching. The key difference is that today's fallback picks without any information about what matters; Candidate A's fallback only activates when the preferred target becomes structurally invalid, which is rare in practice (same-turn mutations rarely resolve another proposal's selected node).
### 60B.6 final nodeId: `n_client_retention`
The model would select it in its proposal → validation passes (exists in addedNodes, kind=unknown, unresolved) → preferred target honoured → question formulated for `n_client_retention`.
---
## Candidate B — Deterministic Material-Factor Priority
### Concept
Adjust deterministic selection (`selectActiveUnknownCandidate` or scoring within it) so that newly-created unresolved factors associated with the current decision get priority over their parent decision.
### Materiality fidelity: MEDIUM
Could partially recover the material factor through structural heuristics (e.g., preferring nodes added in the same proposal, nodes whose `affects` edges target options), but only if distinguishing "material child" from "incidental unknown" requires domain-specific logic — which contradicts criterion #4.
### Deterministic safety: MEDIUM
Would require either:
- New heuristic rules in scoring (introduces brittleness)
- Recency-based ranking ("newest unresolved wins") which fails when multiple unrelated new unknowns are created
- Structural edge analysis to determine "material relevance" which requires keyword/schema logic
### Semantic honesty: MEDIUM
No new schema. But the mechanism would need to inject domain knowledge (e.g., "prefer nodes connected via `may_cause` edges to options") that doesn't exist in the current scoring model.
### New schema fields:
None.
### New validator rules:
Would require new scoring heuristics — a form of implicit validation logic.
### New prompt concepts:
Would need rule change explaining why deterministic selection now behaves differently for newly-added nodes (recency or structural priority).
### New scoring logic:
New rules needed to distinguish "material child" from "incidental unknown". This is the core problem: there is no existing signal that identifies materiality. Any mechanism would need domain-specific keyword analysis of edge types, description patterns, or dependency chains.
### Principal risk:
Recency bias mistakes incidental nodes for material factors when the model creates multiple unrelated new unknowns (max 3 per Rule 8). The scoring function would need to guess at "importance" without a reliable signal — precisely what experiment 60B.7 identified as the root cause of the problem.
### 60B.6 final nodeId: Unpredictable by structural metrics alone
Without knowing whether `n_client_retention` or `n_relocation_decision` scores higher on keyword density + downstream count, we cannot guarantee it would be selected. The parent decision (`n_relocation_decision`) may accumulate higher score from accumulated label/description text patterns across the decision history.
---
## Candidate C — Explicit continuationTarget Field
### Concept
Introduce a new proposal field `continuationTargetNodeId` conceptually distinct from `selectedQuestion.nodeId`:
```
continuationTargetNodeId: "the specific unresolved factor whose materiality justifies keeping the decision open"
```
Deterministic code validates and uses it as the preferred target.
### Materiality fidelity: HIGH
Explicitly carries the material factor signal. No ambiguity about intent.
### Deterministic safety: HIGH
New validation rule: `continuationTargetNodeId` must pass same checks as `selectedQuestion.nodeId`. But this adds surface area for new failure modes (null continuationTarget, stale target, mismatched semantics with selectedQuestion).
### Semantic honesty: LOW
`selectedQuestion.nodeId` already means "candidate for next question". A new field duplicates its semantic space and creates confusion about which field controls the outcome. The model would need to produce two different fields that arguably carry the same information.
### New schema fields:
`continuationTargetNodeId` — requires proposal shape change, schema migration in `situationGraphSchema`, prompt rule additions.
### New validator rules:
New validation for the new field plus reconciliation logic with `selectedQuestion.nodeId`.
### New prompt concepts:
New rule explaining when to populate `continuationTargetNodeId` vs `selectedQuestion.nodeId`, and what each means.
### New scoring logic:
None directly, but validation and orchestrator would need awareness of the new field's semantics.
### Principal risk:
New field duplicates `selectedQuestion` semantics, creating confusion about which field controls outcome. The model may populate only one (breaking the invariant), or populate both with different values (ambiguity). This adds contract surface without solving a semantic gap — `selectedQuestion.nodeId` already carries the correct information; it just gets ignored.
### 60B.6 final nodeId: `n_client_retention`
If populated correctly. But the risk is model produces `continuationTargetNodeId` inconsistently across providers/runs, creating brittle dependency on reliable model behaviour for a field that has no fallback contract.
---
## Fallback Assessment
### Can valid preferred target → deterministic formulator → existing selector fallback work?
**YES.** The existing pipeline already handles this pattern:
1. `validateSelectedQuestion` validates the proposed nodeId (exists, kind=unknown, unresolved) — lines 215-280
2. `applyGraphUpdate` applies mutations — line 3358
3. `isSelectableUnresolvedUnknown` checks still-valid status post-mutation — line 1658
4. `buildSelectedQuestionResult` retrieves node from graph — line 2110
5. If preferred target is invalid/lost, existing fallback at lines 3431-3436 runs `selectActiveUnknownCandidate`
The entire chain exists. No new validation or graph semantics required.
### Requires graph semantic change?
**NO.** All nodes, edges, and node kinds remain unchanged. The only change is treating a structurally valid model-selected nodeId as a priority signal rather than discarding it entirely.
---
## 60B.6 Walkthrough — What Each Candidate Would Choose
Scenario shape:
- `n_relocation_decision` (existing unknown): "Which option leaves us better off overall?"
- `n_client_retention` (newly-added unknown): "Largest client retention uncertainty"
- Model's selectedQuestion.nodeId: `n_relocation_decision` (valid but not material)
### Candidate A — If model had selected n_client_retention
Model proposes `selectedQuestion.nodeId = n_client_retention`. Validation passes (exists in addedNodes, kind=unknown, unresolved). Preferred target used. Final nodeId: **`n_client_retention`**.
If model instead incorrectly selected `n_relocation_decision`: validation passes (structurally valid). But the fallback path exists — if a diagnostic check flags this as suboptimal (e.g., "parent node has no material connection to continuation reason"), fall back to scoring. The key insight: Candidate A uses the model's choice as the *default*, not an absolute rule, and can fall through to deterministic selection when the preferred target is questionable.
**60B.6 final nodeId:** `n_client_retention` (model correctly identifies it in its proposal)
### Candidate B — Deterministic scoring of both candidates
Both `n_relocation_decision` and `n_client_retention` enter the scoring pool. No mechanism distinguishes "material" from "incidental". Outcome depends on keyword matching:
- `n_relocation_decision` text patterns: "option", "better off", "value" → potential objective(+12) + criteria(+11) = 23 base
- `n_client_retention` text patterns: depends on label/description. If it contains "client", "retention", "uncertainty" → partial matches possible
**60B.6 final nodeId:** Unpredictable — could be either node depending on exact label/description text. No guarantee of material alignment.
### Candidate C — With continuationTargetNodeId field
Model adds `continuationTargetNodeId = n_client_retention` alongside `selectedQuestion.nodeId = n_relocation_decision`. Engine uses the new field as preferred target. Validation passes. Final nodeId: **`n_client_retention`**.
But this only works if the model consistently populates the new field. Across providers and runs, consistency is not guaranteed without explicit prompt rules — which defeats the "minimal" requirement.
**60B.6 final nodeId:** `n_client_retention` (if populated correctly)
---
## Decision Rule Evaluation
### Criterion 1: Preserves specific material factor identified by reasoning
- **A: HIGH** — Model-selected valid node becomes preferred target
- **B: MEDIUM** — Unpredictable without domain-specific keyword logic
- **C: HIGH** — Explicit field carries the signal
### Criterion 2: Retains deterministic validation/fallback
- **A: YES** — Existing validateSelectedQuestion + isSelectableUnresolvedUnknown provide full validation. Fallback at lines 3431-3436 already exists.
- **B: PARTIAL** — Scoring heuristics would need new validation
- **C: YES** — But requires new validation for the new field
### Criterion 3: Does not rely on recency alone
- **A: YES** — Uses explicit model-selected nodeId, not position
- **B: NO** — Would need to rely on recency or structural signals (brittle)
- **C: YES** — Explicit field, not positional
### Criterion 4: Adds no domain-specific keyword logic
- **A: YES** — Purely structural validation
- **B: NO** — Requires distinguishing material from incidental via keywords/edges
- **C: YES** — New field is structural; but the *prompt rules* for populating it are domain-specific
### Criterion 5: Avoids new schema unless selectedQuestion semantics are genuinely insufficient
- **A: YES** — No new schema needed (see semantic honesty analysis above)
- **B: YES** — No new schema (but has criterion 4 problem)
- **C: NO** — Introduces new field on every proposal shape
---
## Final Choice
### A — USE VALID MODEL-SELECTED NODE AS PREFERRED TARGET
#### Why
1. **selectedQuestion.nodeId already carries the correct semantic meaning.** When the model produces `selectedQuestion: {nodeId: "n_client_retention", ...}`, it is explicitly saying "this is the unresolved node we should ask about next." The engine's validation (line 215) and structural checks confirm validity. Ignoring this signal after validation is a semantic contradiction — the field was designed for exactly this purpose, and every validation step confirms the model's choice is structurally sound.
2. **No new schema surface.** Candidate A reuses `selectedQuestion.nodeId` for its existing contract purpose. The only change is giving it priority weight in deterministic selection rather than treating it as advisory-only. This addresses the root cause identified in 60B.7: the field *exists* and *works*, but the engine ignores it.
3. **Fallback is already implemented.** Lines 3418-3437 of apply-proposal.js show the exact fallback pattern: if the selected node disappears after mutation, `selectActiveUnknownCandidate` is called. Candidate A plugs into this existing mechanism at the preference point (before scoring), not by modifying the scorer itself.
4. **Smallest implementation boundary:**
- One clarification to prompt Rule 172 ("when model selects a valid unresolved unknown, prefer it as the target")
- One modification in `buildSelectedQuestionResult` to honour valid model-selected nodeId before running scoring
- Zero new fields, zero new validators, zero new schema
5. **Risk is bounded.** If the model incorrectly selects a non-material node (e.g., parent decision instead of material child), the deterministic fallback at line 3431-3436 can still run when the preferred target becomes unavailable or invalid. The risk of "valid but low-value" selection is already present today — Candidate A does not worsen it; it simply ensures that when the model correctly identifies the material factor, it gets used.
### Smallest implementation boundary
1. **Prompt:** Clarify Rule 172 to indicate preferred-target semantics (one sentence).
2. **apply-proposal.js:** In `buildSelectedQuestionResult`, check if model-selected nodeId is still a valid unresolved unknown in `updatedSituationGraph`; if yes, use it as the selectedNode; if no, fall through to existing `selectActiveUnknownCandidate` path.
3. No schema changes. No new fields. No new scoring dimensions.
---
## Implementation Readiness
### A — READY FOR BOUNDED IMPLEMENTATION
One unresolved question for precision:
> Should the preferred-target logic apply to *all* valid model-selected nodes, or only when the selected node is in `addedNodes` (i.e., newly created)?
> **Answer:** All valid nodes. The constraint is structural validity via existing validation, not origin. This avoids a new heuristic ("newly added = more important") that would violate criterion #4.
---
## Documentation
- Created: docs/experiment-60b8.md
- Appended to: docs/current-handoff.md
- Commit message: experiment: choose material-factor question alignment
## Git status: