feat: multi-thread experimental apparatus (RTO.A1)
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
This commit is contained in:
+116
-4
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user