From f74fe6de1004b1de750c2220d3c8b0b24045dfcd Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 8 Oct 2026 17:48:53 +0100 Subject: [PATCH] fix(focused-investigation): suppress incompatible question strategies --- .../focused-investigation/formulate/route.js | 56 +++- lib/graph/focused-investigation.js | 109 +++++++- lib/graph/question-formulator.js | 57 +++- ...used-investigation-formulate-route.test.js | 186 +++++++++++++ .../focused-investigation-boundaries.test.js | 34 +++ tests/graph/question-formulator.test.js | 246 ++++++++++++++++++ 6 files changed, 672 insertions(+), 16 deletions(-) create mode 100644 tests/app/api/focused-investigation-formulate-route.test.js diff --git a/app/api/focused-investigation/formulate/route.js b/app/api/focused-investigation/formulate/route.js index 521adca..11b6b1a 100644 --- a/app/api/focused-investigation/formulate/route.js +++ b/app/api/focused-investigation/formulate/route.js @@ -1,6 +1,13 @@ -import { formulateQuestionForTarget } from "@/lib/graph/focused-investigation"; +import { getProvider, getProviderModelName } from "@/lib/llm/provider"; +import { + assessStrategyCompatibility, + formulateQuestionForTarget, + isStrategyQuestionTemplateCompatible, +} from "@/lib/graph/focused-investigation"; import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js"; +const STRATEGY_ASSESSMENT_TIMEOUT_MS = 10000; + async function post(request) { try { const body = await request.json(); @@ -19,18 +26,59 @@ async function post(request) { ); } - const result = formulateQuestionForTarget({ + const proposed = formulateQuestionForTarget({ situationGraph: body.situationGraph, targetNodeId: body.targetNodeId, }); - if (!result.success) { + if (!proposed.success) { return Response.json( - { success: false, error: result.error }, + { success: false, error: proposed.error }, { status: 400 }, ); } + let result = proposed; + if (proposed.investigationStrategy) { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + STRATEGY_ASSESSMENT_TIMEOUT_MS, + ); + let strategyCompatibilityVerdict = { + strategyKey: proposed.investigationStrategy.key, + relevance: "unavailable", + }; + + try { + const assessment = await assessStrategyCompatibility({ + provider: getProvider(), + modelName: getProviderModelName(), + targetLabel: body.situationGraph.nodes.find((node) => node.id === body.targetNodeId)?.label, + targetDescription: body.situationGraph.nodes.find((node) => node.id === body.targetNodeId)?.description, + centralStatement: body.situationGraph.centralStatement, + proposedStrategy: proposed.investigationStrategy, + signal: controller.signal, + }); + if (assessment.success) strategyCompatibilityVerdict = assessment.verdict; + } catch { + // A timed-out or unavailable assessment is explicitly non-authorizing. + } finally { + clearTimeout(timeout); + } + + result = formulateQuestionForTarget({ + situationGraph: body.situationGraph, + targetNodeId: body.targetNodeId, + strategyCompatibilityVerdict, + strategyTemplateCompatibility: + strategyCompatibilityVerdict.relevance === "compatible" && + !isStrategyQuestionTemplateCompatible(proposed.investigationStrategy) + ? "incompatible" + : undefined, + }); + } + return Response.json({ success: true, targetNodeId: result.targetNodeId, diff --git a/lib/graph/focused-investigation.js b/lib/graph/focused-investigation.js index d1cc7a0..063fb86 100644 --- a/lib/graph/focused-investigation.js +++ b/lib/graph/focused-investigation.js @@ -1,4 +1,7 @@ -import { formulateQuestion } from "@/lib/graph/question-formulator.js"; +import { + formulateQuestion, + isStrategyQuestionTemplateCompatible, +} from "@/lib/graph/question-formulator.js"; const FOCUSED_ANSWER_SCHEMA_FIELDS = [ "targetNodeId", @@ -35,6 +38,16 @@ export const focusedDeconstructJsonSchema = { additionalProperties: false, }; +export const strategyCompatibilityAssessmentJsonSchema = { + type: "object", + properties: { + strategyKey: { type: "string" }, + relevance: { type: "string", enum: ["compatible", "incompatible"] }, + }, + required: ["strategyKey", "relevance"], + additionalProperties: false, +}; + const FORBIDDEN_GRAPH_MUTATION_FIELDS = [ "addedNodes", "updatedNodes", @@ -49,14 +62,19 @@ const FORBIDDEN_GRAPH_MUTATION_FIELDS = [ /** * Formulate a question for the explicitly user-selected unresolved node. * - * Input: { situationGraph, targetNodeId } + * Input: { situationGraph, targetNodeId, strategyCompatibilityVerdict? } * Validates that target exists, is an unknown, and is not resolved. * Calls formulateQuestion with only factual explicit-user-targeting context. * Does NOT invoke selectActiveUnknownCandidate, determineGraphBackedQuestion, * global ranking, or global recommendation. * Does NOT mutate activeUnknownNodeId, selectedQuestion, SituationGraph, or resolvedNodeIds. */ -export function formulateQuestionForTarget({ situationGraph, targetNodeId }) { +export function formulateQuestionForTarget({ + situationGraph, + targetNodeId, + strategyCompatibilityVerdict, + strategyTemplateCompatibility, +}) { const nodesById = new Map(situationGraph?.nodes?.map((n) => [n.id, n]) || []); const targetNode = nodesById.get(targetNodeId); @@ -80,6 +98,12 @@ export function formulateQuestionForTarget({ situationGraph, targetNodeId }) { _explicitTargetNodeId: targetNodeId, _explicitTargetLabel: targetNode.label, _explicitTargetDescription: targetNode.description, + ...(strategyCompatibilityVerdict === undefined + ? {} + : { strategyCompatibilityVerdict }), + ...(strategyTemplateCompatibility === undefined + ? {} + : { strategyTemplateCompatibility }), }; const result = formulateQuestion({ node: targetNode, graph: situationGraph, context }); @@ -89,6 +113,7 @@ export function formulateQuestionForTarget({ situationGraph, targetNodeId }) { targetNodeId, question: result.question, strategy: result.strategy ?? null, + investigationStrategy: result.investigationStrategy, reasoningPattern: result.reasoningPattern, reasoningPatternReason: result.reasoningPatternReason, reason: result.reason, @@ -99,6 +124,84 @@ export function formulateQuestionForTarget({ situationGraph, targetNodeId }) { }; } +export function buildStrategyCompatibilityAssessmentPrompt({ + targetLabel, + targetDescription, + centralStatement, + proposedStrategy, +}) { + return `Assess whether one already-proposed investigation strategy is relevant to one explicitly selected uncertainty. + +Return exactly one JSON object and JSON only. + +You may only assess the relevance of the supplied proposed strategy to the supplied selected uncertainty. +Do not choose another uncertainty. Do not propose another strategy. Do not write the final question. Do not decide the user's conclusion. Do not mutate or recommend changes to any graph. + +Return "compatible" only when this exact strategy would help investigate this exact uncertainty. Return "incompatible" when it would not. + +Selected uncertainty: +- label: ${targetLabel} +- description: ${targetDescription} + +Case context: +- central statement: ${centralStatement} + +Proposed strategy: +- strategyKey: ${proposedStrategy.key} +- strategy meaning: ${proposedStrategy.meaning} + +Return exactly: +{"strategyKey":"${proposedStrategy.key}","relevance":"compatible" | "incompatible"}`; +} + +export function validateStrategyCompatibilityVerdict(result, proposedStrategy) { + if (!result || typeof result !== "object" || Array.isArray(result)) return null; + const keys = Object.keys(result); + if ( + keys.length !== 2 || + !keys.includes("strategyKey") || + !keys.includes("relevance") || + typeof result.strategyKey !== "string" || + result.strategyKey !== proposedStrategy?.key || + (result.relevance !== "compatible" && result.relevance !== "incompatible") + ) { + return null; + } + return { strategyKey: result.strategyKey, relevance: result.relevance }; +} + +export async function assessStrategyCompatibility({ + provider, + modelName, + targetLabel, + targetDescription, + centralStatement, + proposedStrategy, + signal, +}) { + const prompt = buildStrategyCompatibilityAssessmentPrompt({ + targetLabel, + targetDescription, + centralStatement, + proposedStrategy, + }); + const wrapper = await provider.generateReconstruction( + prompt, + modelName, + strategyCompatibilityAssessmentJsonSchema, + { signal }, + ); + const verdict = validateStrategyCompatibilityVerdict( + wrapper?.response, + proposedStrategy, + ); + return verdict + ? { success: true, verdict } + : { success: false, status: "invalid" }; +} + +export { isStrategyQuestionTemplateCompatible }; + /** * Build a focused answer deconstruction prompt for one explicitly user-chosen investigation. */ diff --git a/lib/graph/question-formulator.js b/lib/graph/question-formulator.js index 20e323a..2814738 100644 --- a/lib/graph/question-formulator.js +++ b/lib/graph/question-formulator.js @@ -1198,6 +1198,37 @@ function constrainStrategyToReasoningPattern(strategy, reasoningPattern) { return allowedStrategies.includes(strategy.key) ? strategy : null; } +function applyStrategyCompatibilityVerdict(proposedStrategy, verdict) { + // No verdict is the backward-compatible path. A supplied verdict can only + // preserve this exact proposal or suppress it; it cannot select a substitute. + if (verdict === undefined) return proposedStrategy; + if (!proposedStrategy || !verdict || typeof verdict !== "object" || Array.isArray(verdict)) { + return null; + } + + const keys = Object.keys(verdict); + if ( + keys.length !== 2 || + !keys.includes("strategyKey") || + !keys.includes("relevance") || + typeof verdict.strategyKey !== "string" || + (verdict.relevance !== "compatible" && verdict.relevance !== "incompatible") || + verdict.strategyKey !== proposedStrategy.key + ) { + return null; + } + + return verdict.relevance === "compatible" ? proposedStrategy : null; +} + +export function isStrategyQuestionTemplateCompatible(strategy) { + if (!strategy) return true; + return !( + strategy.key === "baseline_reconstruction" && + isWhetherPropositionMeaning(strategy.meaning) + ); +} + function selectQuestionFamily({ node, graph, @@ -2047,7 +2078,7 @@ export function formulateQuestion({ node, graph, context = {} }) { } } - const investigationStrategy = selectInvestigationStrategy({ + const proposedInvestigationStrategy = selectInvestigationStrategy({ node, graph, context: { @@ -2055,12 +2086,20 @@ export function formulateQuestion({ node, graph, context = {} }) { reasoningPattern: reasoningPatternSelection.pattern, }, }); + const investigationStrategy = applyStrategyCompatibilityVerdict( + proposedInvestigationStrategy, + context.strategyCompatibilityVerdict, + ); + const templateCompatibleStrategy = + context.strategyTemplateCompatibility === "incompatible" + ? null + : investigationStrategy; const questionFamilySelection = selectQuestionFamily({ node, graph, reasoningPattern: reasoningPatternSelection.pattern, - investigationStrategy, + investigationStrategy: templateCompatibleStrategy, }); let question = buildQuestionFromFamily({ @@ -2069,7 +2108,7 @@ export function formulateQuestion({ node, graph, context = {} }) { reasoningPattern: reasoningPatternSelection.pattern, questionFamily: questionFamilySelection.family, selectedQuestionTemplate: questionFamilySelection.template, - investigationStrategy, + investigationStrategy: templateCompatibleStrategy, }); question = sanitizeQuestionText(question); @@ -2080,11 +2119,11 @@ export function formulateQuestion({ node, graph, context = {} }) { if ( !validateFormulatedQuestion( question, - investigationStrategy?.meaning || fallbackMeaning, + templateCompatibleStrategy?.meaning || fallbackMeaning, ) ) { question = sanitizeQuestionText( - (investigationStrategy && + (templateCompatibleStrategy && isClaimLikeUnknown( node, normaliseText( @@ -2112,11 +2151,11 @@ export function formulateQuestion({ node, graph, context = {} }) { return { question, - reason: investigationStrategy - ? `Formulated from graph context using the ${investigationStrategy.key} investigation strategy.` + reason: templateCompatibleStrategy + ? `Formulated from graph context using the ${templateCompatibleStrategy.key} investigation strategy.` : "Formulated as a neutral clarification question because no narrower investigation strategy clearly applied.", - strategy: investigationStrategy?.key ?? null, - investigationStrategy, + strategy: templateCompatibleStrategy?.key ?? null, + investigationStrategy: templateCompatibleStrategy, reasoningPattern: reasoningPatternSelection.pattern, reasoningPatternReason: reasoningPatternSelection.reason, questionFamily: questionFamilySelection.family, diff --git a/tests/app/api/focused-investigation-formulate-route.test.js b/tests/app/api/focused-investigation-formulate-route.test.js new file mode 100644 index 0000000..0978793 --- /dev/null +++ b/tests/app/api/focused-investigation-formulate-route.test.js @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeGraph, makeNode } from "@/lib/graph/schema.js"; + +const generateReconstruction = vi.fn(); + +vi.mock("@/lib/llm/provider", () => ({ + getProvider: () => ({ generateReconstruction }), + getProviderModelName: () => "test-model", +})); + +vi.mock("@/lib/supabase/api-auth.js", () => ({ + withAuthenticatedApi: (handler) => handler, +})); + +function makeRecruitmentGraph() { + const target = makeNode({ + id: "recruitment-target", + label: "Staffing certainty gap", + description: + "Whether additional skilled staff needed for expansion can be recruited", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + return makeGraph({ + centralStatement: + "We need to compare our current baseline against the comparable state before staffing is secured for expansion.", + currentSummary: "Test summary", + nodes: [target], + edges: [], + activeUnknownNodeId: target.id, + resolvedNodeIds: [], + }); +} + +function makeHistoricalGraph() { + const target = makeNode({ + id: "historical-target", + label: "Baseline conversion rate", + description: + "Need the previous baseline because the change cannot be assessed without it.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + return makeGraph({ + centralStatement: "Assess the change against the comparable prior baseline.", + currentSummary: "Test summary", + nodes: [target], + edges: [], + activeUnknownNodeId: target.id, + resolvedNodeIds: [], + }); +} + +function makeRequest(graph, targetNodeId = graph.nodes[0].id) { + return new Request("http://localhost/api/focused-investigation/formulate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ situationGraph: graph, targetNodeId }), + }); +} + +describe("POST /api/focused-investigation/formulate semantic compatibility", () => { + beforeEach(() => { + generateReconstruction.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("suppresses an assessor-incompatible strategy without changing the selected target or graph", async () => { + const graph = makeRecruitmentGraph(); + const snapshot = JSON.stringify(graph); + generateReconstruction.mockResolvedValue({ + response: { + strategyKey: "baseline_reconstruction", + relevance: "incompatible", + }, + }); + const { POST } = await import("@/app/api/focused-investigation/formulate/route.js"); + + const response = await POST(makeRequest(graph)); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.targetNodeId).toBe("recruitment-target"); + expect(json.strategy).toBeNull(); + expect(json.question).toBe( + "What evidence would clarify whether additional skilled staff needed for expansion can be recruited?", + ); + expect(JSON.stringify(graph)).toBe(snapshot); + expect(generateReconstruction).toHaveBeenCalledTimes(1); + }); + + it("does not let a compatible semantic verdict authorize a grammatically incompatible baseline template", async () => { + const graph = makeRecruitmentGraph(); + generateReconstruction.mockResolvedValue({ + response: { + strategyKey: "baseline_reconstruction", + relevance: "compatible", + }, + }); + const { POST } = await import("@/app/api/focused-investigation/formulate/route.js"); + + const response = await POST(makeRequest(graph)); + const json = await response.json(); + + expect(json.strategy).toBeNull(); + expect(json.question.toLowerCase()).not.toContain("before whether"); + expect(json.question).toContain("What evidence would clarify whether"); + }); + + it("preserves a compatible historical strategy when its template is suitable", async () => { + const graph = makeHistoricalGraph(); + generateReconstruction.mockResolvedValue({ + response: { + strategyKey: "baseline_reconstruction", + relevance: "compatible", + }, + }); + const { POST } = await import("@/app/api/focused-investigation/formulate/route.js"); + + const response = await POST(makeRequest(graph)); + const json = await response.json(); + + expect(json.strategy).toBe("baseline_reconstruction"); + expect(json.question).toContain("What was the comparable state before"); + }); + + it("treats invalid or mismatched assessment output as non-authorizing", async () => { + const graph = makeRecruitmentGraph(); + generateReconstruction.mockResolvedValue({ + response: { strategyKey: "evidence_gathering", relevance: "compatible" }, + }); + const { POST } = await import("@/app/api/focused-investigation/formulate/route.js"); + + const response = await POST(makeRequest(graph)); + const json = await response.json(); + + expect(json.strategy).toBeNull(); + expect(json.question).toContain("What evidence would clarify whether"); + }); + + it("treats provider unavailability as non-authorizing fallback", async () => { + const graph = makeRecruitmentGraph(); + generateReconstruction.mockRejectedValue( + Object.assign(new Error("provider unavailable"), { code: "PROVIDER_UNAVAILABLE" }), + ); + const { POST } = await import("@/app/api/focused-investigation/formulate/route.js"); + + const response = await POST(makeRequest(graph)); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.strategy).toBeNull(); + expect(json.question).toContain("What evidence would clarify whether"); + }); + + it("aborts the one assessment invocation at the route deadline and falls back", async () => { + vi.useFakeTimers(); + const graph = makeRecruitmentGraph(); + let observedSignal; + generateReconstruction.mockImplementation((_, __, ___, { signal }) => { + observedSignal = signal; + return new Promise((_, reject) => { + signal.addEventListener("abort", () => { + const error = new Error("assessment aborted"); + error.name = "AbortError"; + reject(error); + }, { once: true }); + }); + }); + const { POST } = await import("@/app/api/focused-investigation/formulate/route.js"); + + const responsePromise = POST(makeRequest(graph)); + await vi.advanceTimersByTimeAsync(10000); + const response = await responsePromise; + const json = await response.json(); + + expect(observedSignal.aborted).toBe(true); + expect(json.strategy).toBeNull(); + expect(json.question).toContain("What evidence would clarify whether"); + }); +}); \ No newline at end of file diff --git a/tests/graph/focused-investigation-boundaries.test.js b/tests/graph/focused-investigation-boundaries.test.js index e811ccc..ffb2525 100644 --- a/tests/graph/focused-investigation-boundaries.test.js +++ b/tests/graph/focused-investigation-boundaries.test.js @@ -65,6 +65,40 @@ describe("formulateQuestionForTarget — explicit node selection", () => { expect(JSON.stringify(graph)).toBe(snapshot); }); + it("forwards an ephemeral incompatible compatibility verdict without changing the selected target or graph", () => { + const unknown = makeNode({ + id: "historical-target", + label: "Earlier comparable state", + description: "Whether the prior staffing level was sufficient for expansion", + kind: "unknown", + status: "unknown", + }); + const graph = makeGraph({ + centralStatement: + "Compare the prior staffing baseline with the level needed before the expansion.", + currentSummary: "initial summary", + nodes: [unknown], + edges: [], + activeUnknownNodeId: unknown.id, + resolvedNodeIds: [], + }); + const snapshot = JSON.stringify(graph); + + const result = formulateQuestionForTarget({ + situationGraph: graph, + targetNodeId: unknown.id, + strategyCompatibilityVerdict: { + strategyKey: "baseline_reconstruction", + relevance: "incompatible", + }, + }); + + expect(result.success).toBe(true); + expect(result.targetNodeId).toBe(unknown.id); + expect(result.strategy).toBeNull(); + expect(JSON.stringify(graph)).toBe(snapshot); + }); + it("does not invoke global selection (selectActiveUnknownCandidate / determineGraphBackedQuestion)", () => { const graph = makeTestGraph(); const result = formulateQuestionForTarget({ situationGraph: graph, targetNodeId: "a" }); diff --git a/tests/graph/question-formulator.test.js b/tests/graph/question-formulator.test.js index 41a32e2..fa3136e 100644 --- a/tests/graph/question-formulator.test.js +++ b/tests/graph/question-formulator.test.js @@ -1614,6 +1614,252 @@ describe("60B.75 — real decision detection for sufficiency question", () => { }); }); +// ── Experiment 3 Iteration 3 — Deterministic Reproduction Recovery ────────── +// Hypothesis: A Whether ... uncertainty passed into baseline_reconstruction +// interpolates into a template requiring a noun phrase, producing malformed output. +// Fixed input: "Whether additional skilled staff needed for expansion can be recruited" + +describe("Experiment 3 — Whether proposition in baseline reconstruction", () => { + it("suppresses the reproduced malformed baseline template at the focused-investigation compatibility boundary", () => { + const unknown = makeNode({ + id: "n-experiment3-staffing", + label: "Staffing certainty gap", + description: + "Whether additional skilled staff needed for expansion can be recruited", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraph({ + centralStatement: + "We need to compare our current baseline against the comparable state before staffing is secured for expansion.", + nodes: [unknown], + edges: [], + activeUnknownNodeId: unknown.id, + resolvedNodeIds: [], + currentSummary: "Test summary", + }); + + const proposed = formulateQuestion({ node: unknown, graph }); + + // Preserve the deterministic reproduction that identified the bug. + expect(proposed.strategy).toBe("baseline_reconstruction"); + expect(proposed.question).toBe( + "What was the comparable state before whether additional skilled staff needed for expansion can be recruited?", + ); + + const corrected = formulateQuestion({ + node: unknown, + graph, + context: { + strategyCompatibilityVerdict: { + strategyKey: "baseline_reconstruction", + relevance: "compatible", + }, + strategyTemplateCompatibility: "incompatible", + }, + }); + + expect(corrected.strategy).toBeNull(); + expect(corrected.question).toBe( + "What evidence would clarify whether additional skilled staff needed for expansion can be recruited?", + ); + expect(corrected.question.toLowerCase()).not.toContain("before whether"); + }); +}); + +// ── Experiment 14 — Deterministic Compatibility Verdict Contract ─────────── + +describe("Experiment 14 — strategy compatibility verdict", () => { + function makeBaselineWhetherFixture() { + const unknown = makeNode({ + id: "n-experiment14-staffing", + label: "Staffing certainty gap", + description: + "Whether additional skilled staff needed for expansion can be recruited", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const graph = makeGraphFor(unknown, { + centralStatement: + "We need to compare our current baseline against the comparable state before staffing is secured for expansion.", + }); + return { unknown, graph }; + } + + it("preserves existing strategy and question when no verdict is supplied", () => { + const { unknown, graph } = makeBaselineWhetherFixture(); + const result = formulateQuestion({ node: unknown, graph }); + + expect(result.strategy).toBe("baseline_reconstruction"); + expect(result.question).toBe( + "What was the comparable state before whether additional skilled staff needed for expansion can be recruited?", + ); + }); + + it("permits the matching proposed strategy when the verdict is compatible", () => { + const { unknown, graph } = makeBaselineWhetherFixture(); + const result = formulateQuestion({ + node: unknown, + graph, + context: { + strategyCompatibilityVerdict: { + strategyKey: "baseline_reconstruction", + relevance: "compatible", + }, + }, + }); + + expect(result.strategy).toBe("baseline_reconstruction"); + expect(result.question).toBe( + "What was the comparable state before whether additional skilled staff needed for expansion can be recruited?", + ); + }); + + it("suppresses an incompatible proposed strategy and uses the existing evidence path", () => { + const { unknown, graph } = makeBaselineWhetherFixture(); + const graphSnapshot = JSON.stringify(graph); + const result = formulateQuestion({ + node: unknown, + graph, + context: { + strategyCompatibilityVerdict: { + strategyKey: "baseline_reconstruction", + relevance: "incompatible", + }, + }, + }); + + expect(result.strategy).toBeNull(); + expect(result.question).toBe( + "What evidence would clarify whether additional skilled staff needed for expansion can be recruited?", + ); + expect(JSON.stringify(graph)).toBe(graphSnapshot); + }); + + it("does not let invalid or mismatched verdicts authorise a proposed strategy", () => { + const { unknown, graph } = makeBaselineWhetherFixture(); + const invalid = formulateQuestion({ + node: unknown, + graph, + context: { + strategyCompatibilityVerdict: { + strategyKey: "baseline_reconstruction", + relevance: "unknown", + }, + }, + }); + const mismatched = formulateQuestion({ + node: unknown, + graph, + context: { + strategyCompatibilityVerdict: { + strategyKey: "evidence_gathering", + relevance: "compatible", + }, + }, + }); + + expect(invalid.strategy).toBeNull(); + expect(mismatched.strategy).toBeNull(); + expect(invalid.question).toContain("What evidence would clarify whether"); + expect(mismatched.question).toContain("What evidence would clarify whether"); + }); + + it("allows a compatible historical strategy for a coherent Whether-proposition", () => { + const unknown = makeNode({ + id: "n-experiment14-historical", + label: "Earlier comparable state", + description: "Whether the prior staffing level was sufficient for expansion", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const graph = makeGraphFor(unknown, { + centralStatement: + "Compare the prior staffing baseline with the level needed before the expansion.", + }); + const result = formulateQuestion({ + node: unknown, + graph, + context: { + strategyCompatibilityVerdict: { + strategyKey: "baseline_reconstruction", + relevance: "compatible", + }, + }, + }); + + expect(result.strategy).toBe("baseline_reconstruction"); + }); +}); + +// ── Experiment 6 — Graph-Context Causal Comparison ───────────────────────────── +// Hypothesis: baseline_reconstruction strategy on the recruitment unknown is driven +// by historical-baseline vocabulary in graph-level context (centralStatement), not +// in the recruitment uncertainty itself. Removing that vocabulary should prevent +// the specific baseline-selection route while preserving all node-level input. + +describe("Experiment 6 — Isolate graph-context influence on strategy selection", () => { + it("removing baseline vocabulary from centralStatement changes strategy away from baseline_reconstruction", () => { + // ── shared open-question fixture (identical in both variants) ── + const openQuestion = makeNode({ + id: "n-experiment3-staffing", + label: "Staffing certainty gap", + description: + "Whether additional skilled staff needed for expansion can be recruited", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + // ── Variant A — baseline context present (existing fixture) ── + const graphA = makeGraph({ + centralStatement: + "We need to compare our current baseline against the comparable state before staffing is secured for expansion.", + nodes: [openQuestion], + edges: [], + activeUnknownNodeId: openQuestion.id, + resolvedNodeIds: [], + currentSummary: "Test summary", + }); + + // ── Variant B — baseline context absent (neutral wording) ── + const graphB = makeGraph({ + centralStatement: + "Current staffing levels are being reviewed as we prepare for the planned expansion.", + nodes: [openQuestion], + edges: [], + activeUnknownNodeId: openQuestion.id, + resolvedNodeIds: [], + currentSummary: "Test summary", + }); + + // ── Execute both variants ── + const resultA = formulateQuestion({ node: openQuestion, graph: graphA }); + const resultB = formulateQuestion({ node: openQuestion, graph: graphB }); + + // Record for report + console.log("[Exp6-A] strategy:", resultA.strategy, "| question:", resultA.question); + console.log("[Exp6-B] strategy:", resultB.strategy, "| question:", resultB.question); + + // Causal contrast: baseline_reconstruction requires BOTH graph-level AND node-level + // baseline vocabulary to match the first (strict) selection path. + // Variant A must select baseline_reconstruction. + expect(resultA.strategy).toBe("baseline_reconstruction"); + + // Variant B must NOT select baseline_reconstruction (graph-level baseline removed). + expect(resultB.strategy).not.toBe("baseline_reconstruction"); + + // The malformed "before whether" output must disappear in Variant B. + expect(resultB.question.toLowerCase()).not.toContain( + "before whether", + ); + }); +}); + // ── 60B.80 — sufficiency re-selection after proposal application (no false State B) ──── describe("60B.80 — no false State B after proposal adds unresolved material factor", () => {