From 25a88c5fc3e64cd2a166a0c17048bf1cf5407d23 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 18 Aug 2026 10:39:16 +0100 Subject: [PATCH] feat: multi-thread experimental apparatus (RTO.A1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fixture-only apparatus for representing multiple concurrent open investigation items within a fixed case context. New scenario 'multi-thread' exposes: - A fixed central situation statement and case summary (product-launch timing decision, drawn from existing pre-anchored-product-launch data) - Three open investigation items — none compulsory: enterprise customer signing probability, competitor timing, financial viability comparison - One engine recommendation (mt-ent-customer-signing, ordered first) - User selection of any item; chosen item becomes visually primary while others remain visible as context - Experimental state isolated in _experimental / _experimentalState — never aliases production graph fields --- lib/mocks/scenarios.js | 120 +++++++++- .../multi-thread-apparatus.test.js | 221 ++++++++++++++++++ 2 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 tests/experimental/multi-thread-apparatus.test.js diff --git a/lib/mocks/scenarios.js b/lib/mocks/scenarios.js index 3a2f547..e3fa068 100644 --- a/lib/mocks/scenarios.js +++ b/lib/mocks/scenarios.js @@ -308,6 +308,84 @@ var diagnosisTurns = [ } ]; +/* ── Experimental multi-thread apparatus (RTO.A1) ──── */ +/** + * FIXTURE-ONLY — not production reasoning. + * Represents a curated set of open investigation threads for a + * product-launch timing decision, allowing user-directed selection + * of which thread becomes visually primary. + * + * The central situation statement is drawn from the existing + * pre-anchored-product-launch-options.json scenario data. + */ + +var multiThreadCase = { + centralStatement: "We are evaluating two product-launch timing options: launching the new software product this year or waiting twelve months.", + caseSummary: "An organisation holds a nearly-ready software product and must choose between launching within the current year (with ~£300k immediate cost) or delaying launch by twelve months (forgoing first-year revenue while avoiding near-term costs). One large enterprise customer's potential contract could significantly influence the timing decision. The situation is unresolved with multiple legitimate investigation pathways.", +}; + +var multiThreadItems = [ + { + id: "mt-ent-customer-signing", + label: "Enterprise customer signing probability", + description: "The likelihood, negotiation stage, and targeted signing date for the large enterprise client whose potential contract represents a significant portion of expected revenue.", + recommendationOrder: 1, + questionFrame: "What outcome would demonstrate enough value to justify launching a software product now?", + }, + { + id: "mt-competitor-timing", + label: "Competitor timing / first-mover consequences", + description: "Whether competitors are actively developing similar products and how soon they might release them, and what the market share impact would be if we lose the first-mover window.", + recommendationOrder: 2, + questionFrame: "What evidence would clarify whether competitors are actively developing similar products and how soon they might release them?", + }, + { + id: "mt-financial-comparison", + label: "Financial viability of launching now versus waiting", + description: "The exact monetary value of the potential enterprise contract relative to the £300k launch cost, projected cash-flow impact, and net present value comparison across the two timing options.", + recommendationOrder: 3, + questionFrame: "What evidence would clarify the exact percentage of total projected revenue attributable to the enterprise customer?", + }, +]; + +var multiThreadTurn = { + centralStatement: multiThreadCase.centralStatement, + situationGraph: { + nodes: [ + mkN("mt-state-1", multiThreadCase.caseSummary, { kind: "state", status: "provisional", confidence: "medium" }), + mkN("mt-opt-1", "Launch this year: capture ~£1.2M revenue now, incur ~£300k cost",{ kind: "observation", status: "known", confidence: "high" }), + mkN("mt-opt-2", "Wait twelve months: avoid £300k cost, forgo first-year revenue",{ kind: "observation", status: "known", confidence: "medium" }), + ], + edges: [mkE("mt-e-1","mt-opt-1","mt-state-1"), mkE("mt-e-2","mt-opt-2","mt-state-1")], + activeUnknownNodeId: null, + resolvedNodeIds: [], + }, + active: null, + question: null, + noQReason: "Multi-thread apparatus — no single active unknown. Choose an investigation thread.", + summary: multiThreadCase.caseSummary + " | 3 open investigation items available. Engine recommendation marked.", + + /* Experimental presentation state — NOT production graph fields */ + _experimental: { + scenario: "multi-thread", + caseContext: multiThreadCase, + availableThreads: multiThreadItems, + recommendedThreadId: "mt-ent-customer-signing", + selectedThreadId: null, + apparatusMode: "selection", + }, +}; + +/* Re-export situationGraph on the fixture itself for backwards compat */ +Object.defineProperty(multiThreadTurn, "nodes", { get() { return this.situationGraph.nodes; } }); +Object.defineProperty(multiThreadTurn, "edges", { get() { return this.situationGraph.edges; } }); +Object.defineProperty(multiThreadTurn, "resolved", { get() { return this.situationGraph.resolvedNodeIds; } }); + +/* Export the experimental scenario data */ +export var MULTI_THREAD_FIXTURE = multiThreadTurn; +export var MULTI_THREAD_CASE = multiThreadCase; +export var MULTI_THREAD_ITEMS = multiThreadItems; + /* ── Registry ─────────────────────────────────────── */ var SCENARIOS = { @@ -322,13 +400,46 @@ var SCENARIOS = { "long": { turns: longTurns, label: "Long investigation (market entry)", centralStatement: longTurns[0].centralStatement }, "complete": { turns: completeTurns, label: "Complete investigation", centralStatement: completeTurns[0].centralStatement }, "diagnosis": { turns: diagnosisTurns, label: "Diagnosis (churn)", centralStatement: diagnosisTurns[0].centralStatement }, + "multi-thread": { scenario: multiThreadTurn, label: "[E] Multi-thread apparatus (RTO.A1)", centralStatement: multiThreadCase.centralStatement }, }; /* ── Build a fixture for a named scenario at a given turn index ─ */ export function buildScenarioFixture(scenarioName, turnIdx) { var s = SCENARIOS[scenarioName]; - if (!s || !s.turns) return null; + if (!s) return null; + + /* Experimental multi-thread — does not follow the turns model */ + if (s.scenario && s.scenario._experimental) { + var mt = s.scenario; + return { + success: true, + situationGraph: { + centralStatement: mt.centralStatement, + currentSummary: mt.summary, + nodes: mt.nodes, + edges: mt.edges, + activeUnknownNodeId: mt.active, + resolvedNodeIds: mt.resolved + }, + selectedQuestion: mt.question || null, + noQuestionReason: mt.noQReason, + newlySurfacedNodeIds: [], + diagnostics: { + promptVersion: "v0.4", + modelName: "mock-ollama", + responseDurationMs: 0, + validationStatus: "valid", + nodeCount: mt.nodes.length, + edgeCount: mt.edges.length, + investigationStrategy: { key: "multi_thread_experiment" }, + unknownSelectionExplanation: null + }, + _experimentalState: mt._experimental + }; + } + + if (!s.turns) return null; var t = s.turns[Math.min(turnIdx, s.turns.length - 1)]; return { success: true, @@ -361,11 +472,12 @@ export function buildScenarioFixture(scenarioName, turnIdx) { export var AVAILABLE_SCENARIOS = []; for (var key in SCENARIOS) { if (SCENARIOS.hasOwnProperty(key)) { + var entry = SCENARIOS[key]; AVAILABLE_SCENARIOS.push({ key: key, - label: SCENARIOS[key].label, - centralStatement: SCENARIOS[key].centralStatement, - turnCount: SCENARIOS[key].turns.length + label: entry.label, + centralStatement: entry.centralStatement, + turnCount: entry.turns ? entry.turns.length : 1 // experimental scenarios show as single-turn }); } } diff --git a/tests/experimental/multi-thread-apparatus.test.js b/tests/experimental/multi-thread-apparatus.test.js new file mode 100644 index 0000000..40e70d4 --- /dev/null +++ b/tests/experimental/multi-thread-apparatus.test.js @@ -0,0 +1,221 @@ +/** + * RTO.A1 — Multi-thread apparatus verification. + * + * Validates that the experimental fixture can deterministically represent: + * - a fixed case understanding + * - three open investigation items (none compulsory) + * - one engine recommendation + * - user selection of any item + * - chosen-item becomes visually primary in presentation + * - other items remain visible as context + * - no claim that production reasoning ownership changed + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { + MULTI_THREAD_FIXTURE, + MULTI_THREAD_CASE, + MULTI_THREAD_ITEMS, + buildScenarioFixture, +} from "@/lib/mocks/scenarios.js"; + +describe("RTO.A1 multi-thread apparatus", () => { + /* Reset shared experimental state between tests */ + beforeEach(() => { + const exp = MULTI_THREAD_FIXTURE._experimental; + if (exp) exp.selectedThreadId = null; + }); + describe("fixed case representation", () => { + it("exposes a case summary without implying a single active unknown", () => { + expect(MULTI_THREAD_FIXTURE.centralStatement).toBeDefined(); + expect(MULTI_THREAD_FIXTURE._experimental.caseContext.caseSummary.length).toBeGreaterThan(0); + expect(MULTI_THREAD_FIXTURE.active).toBeNull(); + }); + + it("uses the existing launch-vs-wait central statement", () => { + expect(MULTI_THREAD_CASE.centralStatement).toContain("launching"); + expect(MULTI_THREAD_CASE.centralStatement).toContain("waiting twelve months"); + }); + }); + + describe("open investigation items", () => { + it("represents exactly three open items", () => { + expect(MULTI_THREAD_ITEMS.length).toBe(3); + }); + + it("each item has an id, label and description", () => { + MULTI_THREAD_ITEMS.forEach((item) => { + expect(item.id).toBeDefined(); + expect(item.label).toBeDefined(); + expect(item.description).toBeDefined(); + }); + }); + + it("none is compulsory — all items are selectable", () => { + MULTI_THREAD_ITEMS.forEach((item) => { + // Items lack a `compulsory` flag; selection is always open + expect(item.compulsory).not.toBe(true); + }); + }); + + it("items map to the three investigation areas from RTO.01", () => { + const labels = MULTI_THREAD_ITEMS.map((i) => i.label.toLowerCase()); + expect(labels).toContainEqual(expect.stringContaining("enterprise customer")); + expect(labels).toContainEqual(expect.stringContaining("competitor timing")); + expect(labels).toContainEqual(expect.stringContaining("financial viability")); + }); + }); + + describe("engine recommendation", () => { + it("marks exactly one item as the engine recommendation", () => { + const recommended = MULTI_THREAD_FIXTURE._experimental.recommendedThreadId; + expect(recommended).toBe("mt-ent-customer-signing"); + + const matched = MULTI_THREAD_ITEMS.find( + (i) => i.id === recommended + ); + expect(matched).toBeDefined(); + }); + + it("the recommendation is the first item by ordering", () => { + const ordered = [...MULTI_THREAD_ITEMS].sort( + (a, b) => a.recommendationOrder - b.recommendationOrder + ); + expect(ordered[0].id).toBe("mt-ent-customer-signing"); + }); + }); + + describe("experimental user selection", () => { + it("allows selection of any available thread", () => { + const exp = MULTI_THREAD_FIXTURE._experimental; + // Initially no selection + expect(exp.selectedThreadId).toBeNull(); + + // Simulate user selecting the second item + exp.selectedThreadId = "mt-competitor-timing"; + expect(exp.selectedThreadId).toBe("mt-competitor-timing"); + + // Verify it is still a valid available thread + const found = MULTI_THREAD_ITEMS.find( + (i) => i.id === exp.selectedThreadId + ); + expect(found).toBeDefined(); + expect(found.label).toContain("Competitor timing"); + }); + + it("changing selection does not affect other items", () => { + const exp = MULTI_THREAD_FIXTURE._experimental; + const othersBefore = MULTI_THREAD_ITEMS.map((i) => ({ + id: i.id, + recommended: i.id === exp.recommendedThreadId, + })); + + // Switch to third item + exp.selectedThreadId = "mt-financial-comparison"; + expect(exp.selectedThreadId).toBe("mt-financial-comparison"); + + const othersAfter = MULTI_THREAD_ITEMS.map((i) => ({ + id: i.id, + recommended: i.id === exp.recommendedThreadId, + })); + + // Structure is preserved — only selectedThreadId changed + expect(othersBefore).toEqual(othersAfter); + }); + }); + + describe("chosen-thread primary state", () => { + it("marks the selected item as visually primary when set", () => { + const exp = MULTI_THREAD_FIXTURE._experimental; + const targetId = "mt-ent-customer-signing"; + exp.selectedThreadId = targetId; + + const primaryItem = MULTI_THREAD_ITEMS.find( + (i) => i.id === targetId + ); + expect(primaryItem).toBeDefined(); + }); + + it("other items remain visible as case-file context", () => { + const exp = MULTI_THREAD_FIXTURE._experimental; + exp.selectedThreadId = "mt-competitor-timing"; + + // All three items are still present — none were removed + expect(MULTI_THREAD_ITEMS.length).toBe(3); + + // The non-selected items are still available + const remaining = MULTI_THREAD_ITEMS.filter( + (i) => i.id !== exp.selectedThreadId + ); + expect(remaining.length).toBe(2); + }); + }); + + describe("production boundary", () => { + it("does not set production activeUnknownNodeId semantics", () => { + const graph = MULTI_THREAD_FIXTURE.situationGraph; + expect(graph.activeUnknownNodeId).toBeNull(); + }); + + it("keeps _experimental separate from situationGraph", () => { + const exp = MULTI_THREAD_FIXTURE._experimental; + expect(exp.apparatusMode).toBe("selection"); + expect(exp.selectedThreadId).toBeNull(); + // The experimental state is NOT in situationGraph + expect(MULTI_THREAD_FIXTURE.situationGraph.exp).toBeUndefined(); + }); + }); + + describe("buildScenarioFixture integration", () => { + it("returns the multi-thread fixture for scenario 'multi-thread'", () => { + const fixture = buildScenarioFixture("multi-thread", 0); + expect(fixture).not.toBeNull(); + expect(fixture.success).toBe(true); + expect(fixture._experimentalState).toBeDefined(); + expect(fixture._experimentalState.availableThreads.length).toBe(3); + expect(fixture._experimentalState.recommendedThreadId).toBe("mt-ent-customer-signing"); + }); + + it("preserves the case context through buildScenarioFixture", () => { + const fixture = buildScenarioFixture("multi-thread", 0); + expect(fixture.situationGraph.centralStatement).toBe( + MULTI_THREAD_CASE.centralStatement + ); + }); + + it("does not return a production question when multi-thread is active", () => { + const fixture = buildScenarioFixture("multi-thread", 0); + expect(fixture.selectedQuestion).toBeNull(); + expect(fixture.noQuestionReason).toContain("Multi-thread"); + }); + + it("returns null for unknown scenario name", () => { + expect(buildScenarioFixture("nonexistent-scenario", 0)).toBeNull(); + }); + + it("returns null when scenario has no turns or scenario field", () => { + // This should not break — SCENARIOS entries without either field + const oldEntry = { someField: "value" }; + expect(buildScenarioFixture("__nonexistent__", 0)).toBeNull(); + }); + }); + + describe("scenario fixture structure correctness", () => { + it("includes nodes and edges in the situation graph", () => { + const g = MULTI_THREAD_FIXTURE.situationGraph; + expect(g.nodes.length).toBeGreaterThan(0); + expect(g.edges.length).toBeGreaterThan(0); + }); + + it("nodes contain valid observation kind entries", () => { + const observationNodes = MULTI_THREAD_FIXTURE.situationGraph.nodes.filter( + (n) => n.kind === "observation" + ); + expect(observationNodes.length).toBeGreaterThanOrEqual(2); + }); + + it("includes the experimental scenario identifier", () => { + expect(MULTI_THREAD_FIXTURE._experimental.scenario).toBe("multi-thread"); + }); + }); +});