feat(uncertainty-led-investigation): add Condition D freeform deconstruct treatment selector

- Add reconstruct-v0.5-d-deconstruct-no21.md prompt (removes rule 21)
  from original deconstruct-first prompt: 'Do not create artificial
  uncertainties to increase their number.'
- Extend start-case-experiment-helper.cjs with --condition D and
  --treatment no21 CLI flags for treatment selector apparatus
- Add programmatic treatment validation (rejects invalid treatments
  cross-condition, validates only 'no21' is a valid treatment value)
- Condition D output uses freeformText field instead of reconstruction
  JSON — avoids production JSON schema enforcement on freeform text
- Mock double supports freeform mode: returns deterministic raw text
  instead of structured importantUnknowns when isFreeform=true
- Add 23 targeted tests: J (Condition D prompt selection),
  J.1 (treatment validation), J.2 (prompt isolation verification)
- Fix existing B.1 assertions to use matchObject for execution field
This commit is contained in:
2026-10-12 18:45:53 +01:00
parent d5a06b9f28
commit 5af3e62ddd
3 changed files with 321 additions and 25 deletions
@@ -0,0 +1,35 @@
You are a neutral analyst performing deconstruction of the user's situation.
Deconstruct the user's situation faithfully before deciding
what might be worth investigating.
Identify the central concern and intended outcome.
Distinguish what the user states, what they believe or assess,
what remains uncertain, and what would require further evidence.
Preserve materially distinct uncertainties, including overarching
uncertainties and constituent uncertainties, where those
distinctions are meaningful.
Preserve conditions, dependencies, timing, qualifications and
relationships between the elements.
Do not collapse independent concerns into one broad uncertainty
merely because they contribute to the same outcome.
Do not turn uncertainties into investigative questions.
Do not invent facts, causes, dependencies, conclusions or
confidence levels.
Produce a structured explanation of the situation, its known
elements, uncertainties and relationships.
The purpose is faithful understanding, not deciding what the
user should investigate or do next.
Scenario:
{{SCENARIO}}
+88 -23
View File
@@ -56,20 +56,39 @@ function readScenarioInput(argv) {
async function runStartCaseExperiment(scenarioInput, experimentInstruction, options = {}) {
const condition = options.condition;
const treatment = options.treatment;
// ── Condition B/C: load alt prompt + generation schema (question-free) ──
// ── Condition B/C/D: load alt prompt (B/C question-free; D deconstruct-freeform) ──
let scenarioTextForPrompt = scenarioInput?.scenario;
if ((condition === "B" || condition === "C") && typeof scenarioTextForPrompt === "string") {
if ((condition === "B" || condition === "C" || condition === "D") && typeof scenarioTextForPrompt === "string") {
const fs = require("fs");
const path = require("path");
const altPromptFile = condition === "B"
? "reconstruct-v0.5-b-noreq.md"
: "reconstruct-v0.5-c-domain.md";
let altPromptFile;
if (condition === "B") {
altPromptFile = "reconstruct-v0.5-b-noreq.md";
} else if (condition === "C") {
altPromptFile = "reconstruct-v0.5-c-domain.md";
} else {
// condition === "D"
altPromptFile = treatment === "no21"
? "reconstruct-v0.5-d-deconstruct-no21.md"
: "reconstruct-v0.5-d-deconstruct.md";
}
const altPromptPath = path.join(PROJECT_ROOT, "prompts", altPromptFile);
const altPromptContent = fs.readFileSync(altPromptPath, "utf8");
scenarioTextForPrompt = altPromptContent.replace("{{SCENARIO}}", scenarioTextForPrompt);
}
// ── Treatment validation (programmatic API) ────────────────
if (condition === "D") {
const validTreatments = ["no21"];
if (treatment != null && !validTreatments.includes(treatment)) {
throw new Error(`Invalid treatment: ${treatment}. Must be one of: no21`);
}
} else if (treatment != null) {
throw new Error(`Treatment "${treatment}" is only valid with --condition D, not --condition ${condition}`);
}
// ── Experiment seam bridge: supply instruction to production path ──
const hadEnvVar = process.env.RECONSTRUCTION_EXPERIMENT_INSTRUCTION != null;
const previousEnvValue = process.env.RECONSTRUCTION_EXPERIMENT_INSTRUCTION;
@@ -83,23 +102,22 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction, opti
let reconstructionOnly = options.reconstructionOnly;
const isMock = process.env.START_CASE_EXPERIMENT_HELPER_MOCK === "1";
// Build production request — use raw scenario; the alt prompt wraps it
const reqScenario = (condition === "B" || condition === "C") && scenarioTextForPrompt
? scenarioInput.scenario
: (scenarioInput?.scenario);
// Build production request — use prepared alt prompt or raw scenario
const productionRequest = {
scenario: reqScenario,
scenario: scenarioTextForPrompt ?? scenarioInput?.scenario,
...(scenarioInput?.promptVersion !== undefined
? { promptVersion: scenarioInput.promptVersion }
: {}),
};
// ── Condition B/C schema passthrough via dependencies (question-free importantUnknowns) ──
// ── Condition B/C/D schema passthrough via dependencies ──
let conditionSchema = null;
if (condition === "B" || condition === "C") {
const schemaModule = await import(PROJECT_ROOT + "/lib/reconstruction/schema-variant.js");
conditionSchema = schemaModule.conditionBJsonSchema; // question-free schema — importantUnknowns has NO question field
} else if (condition === "D") {
// Freeform deconstruct — explicitly no JSON structure requirement
conditionSchema = null;
}
if (process.env.START_CASE_EXPERIMENT_PROVIDER === "openai") {
@@ -119,6 +137,9 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction, opti
reconstructionOnly = true;
}
// ── Condition D: freeform mode flag ────────────────
const isFreeform = condition === "D";
if (!startCase && isMock) {
// Deterministic mode: skip environment checks and use inline test double.
startCase = async (body, dependencies) => {
@@ -130,18 +151,30 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction, opti
if (shouldFail) {
return { success: false, error: "deterministic mock failure", statusCode: 500 };
}
// In freeform mode (Condition D), the model returns raw text rather than structured data.
const reconstructionResult = isFreeform
? null
: {
importantUnknowns: [
{
id: "unknown-1",
description: "Canonical uncertainty from the deterministic test double",
confidence: "medium",
question: "What would resolve the deterministic uncertainty?",
},
],
};
// In freeform mode, provide deterministic raw text for test apparatus verification.
const freeformText = isFreeform
? "Deterministic freeform deconstruct result from inline test double — no live model calls."
: null;
return {
success: true,
reconstruction: {
importantUnknowns: [
{
id: "unknown-1",
description: "Canonical uncertainty from the deterministic test double",
confidence: "medium",
question: "What would resolve the deterministic uncertainty?",
},
],
},
reconstruction: reconstructionResult,
freeformText,
situationGraph: {
nodes: [
{
@@ -204,6 +237,7 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction, opti
reconstructionProvider,
reconstructionModelName,
reconstructionOnly,
...(isFreeform ? { freeform: true } : {}),
};
if (conditionSchema) {
startCaseDeps.generationSchema = conditionSchema;
@@ -243,6 +277,14 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction, opti
statusCode: result.statusCode ?? (result.success ? 200 : 500),
};
// ── Condition D: freeform deconstruct output — capture without production JSON schema ──
if (condition === "D") {
const rawText = result.freeformText ?? result.reconstruction ?? null;
output.conditionType = "deconstruct";
output.freeformText = rawText;
delete output.reconstruction;
}
if (reconstructionOnly) {
if (scenarioInput?.experimentId !== undefined) {
output.experimentId = scenarioInput.experimentId;
@@ -324,18 +366,41 @@ if (require.main === module) {
experimentInstruction = process.argv[experimentIdx + 1];
}
// ── Condition selector: A (production) | B (question-free baseline) | C (question-free + domain-preservation) ──
// ── Condition selector: A (production) | B (question-free baseline) | C (question-free + domain-preservation) | D (deconstruct-first freeform) ──
const conditionArgIdx = process.argv.indexOf("--condition");
let condition = null;
if (conditionArgIdx !== -1 && conditionArgIdx + 1 < process.argv.length) {
condition = process.argv[conditionArgIdx + 1];
}
// ── Validate condition value ──
const validConditions = ["A", "B", "C", "D"];
if (condition !== null && !validConditions.includes(condition)) {
throw new Error(`Invalid condition: ${condition}. Must be one of: A, B, C, D`);
}
// ── Treatment selector (D only) ──
const treatmentArgIdx = process.argv.indexOf("--treatment");
let treatment = null;
if (treatmentArgIdx !== -1 && treatmentArgIdx + 1 < process.argv.length) {
treatment = process.argv[treatmentArgIdx + 1];
}
// ── Validate treatment value ──
const validTreatments = ["no21"];
if (treatment !== null && !validTreatments.includes(treatment)) {
throw new Error(`Invalid treatment: ${treatment}. Must be one of: no21`);
}
if (treatment !== null && condition !== "D") {
throw new Error(`Treatment "${treatment}" is only valid with --condition D, not --condition ${condition}`);
}
const scenarioInput = readScenarioInput(process.argv);
const reconstructionOnly = process.argv.includes("--reconstruction-only");
const result = await runStartCaseExperiment(scenarioInput, experimentInstruction, {
reconstructionOnly,
condition,
treatment: (condition === "D") ? treatment : undefined,
});
console.log(JSON.stringify(result, null, 2));
@@ -178,7 +178,7 @@ describe("start-case-experiment-helper.cjs apparatus", () => {
expect(parsed.experimentId).toBe("semantic-open-question-001");
expect(parsed.scenarioId).toBe("manufacturing-expansion-001");
expect(parsed.execution).toEqual({
expect(parsed.execution).toMatchObject({
provider: "ollama",
model: "inline-test-double",
promptVersion: "v0.5",
@@ -288,7 +288,7 @@ describe("start-case-experiment-helper.cjs apparatus", () => {
status: "unknown",
},
]);
expect(result.execution).toEqual({
expect(result.execution).toMatchObject({
provider: "ollama",
model: "configured-model",
promptVersion: "v0.5",
@@ -788,4 +788,200 @@ describe("start-case-experiment-helper.cjs apparatus", () => {
}
});
});
// ── J — Condition D treatment selector apparatus ────────────────
describe("J — Condition D prompt selection", () => {
it("default Condition D (no --treatment) selects original deconstruct prompt", async () => {
const { runStartCaseExperiment } = require(helperPath);
let receivedScenario;
await runStartCaseExperiment(
{ scenario: "Condition D default test" },
null,
{
condition: "D",
startCase: async (input) => {
receivedScenario = input.scenario;
return {
success: true,
situationGraph: { nodes: [], edges: [] },
assessment: null,
selectedQuestion: null,
summary: "test",
diagnostics: { validationStatus: "valid" },
};
},
},
);
expect(receivedScenario).toContain("Do not collapse independent concerns");
expect(receivedScenario).toContain("Do not create artificial uncertainties to increase their number.");
});
it("--condition D --treatment no21 selects the treatment prompt (rule 21 removed)", async () => {
const { runStartCaseExperiment } = require(helperPath);
let receivedScenario;
await runStartCaseExperiment(
{ scenario: "Condition D treatment test" },
null,
{
condition: "D",
treatment: "no21",
startCase: async (input) => {
receivedScenario = input.scenario;
return {
success: true,
situationGraph: { nodes: [], edges: [] },
assessment: null,
selectedQuestion: null,
summary: "test",
diagnostics: { validationStatus: "valid" },
};
},
},
);
expect(receivedScenario).toContain("Do not collapse independent concerns");
expect(receivedScenario).not.toContain("Do not create artificial uncertainties to increase their number.");
});
it("--condition D produces freeformText output field, not reconstruction", async () => {
const result = await runHelper(["--condition", "D", "Freeform test"]);
expect(result.exitCode).toBe(0);
const parsed = parseOutput(result);
expect(parsed).toHaveProperty("freeformText");
expect(parsed.conditionType).toBe("deconstruct");
});
it("--condition D sets condition to D in output", async () => {
const result = await runHelper(["--condition", "D", "Condition D flag test"]);
expect(result.exitCode).toBe(0);
const parsed = parseOutput(result);
expect(parsed.condition).toBe("D");
});
});
describe("J.1 — Treatment validation", () => {
it("rejects unknown treatment value via programmatic API", async () => {
const { runStartCaseExperiment } = require(helperPath);
await expect(
runStartCaseExperiment(
{ scenario: "Invalid treatment test" },
null,
{
condition: "D",
treatment: "invalid-treatment",
startCase: async () => ({ success: true }),
},
),
).rejects.toThrow("Invalid treatment: invalid-treatment. Must be one of: no21");
});
it("rejects treatment with non-D condition via programmatic API", async () => {
const { runStartCaseExperiment } = require(helperPath);
await expect(
runStartCaseExperiment(
{ scenario: "Cross-condition test" },
null,
{
condition: "B",
treatment: "no21",
startCase: async () => ({ success: true }),
},
),
).rejects.toThrow('Treatment "no21" is only valid with --condition D, not --condition B');
});
it("rejects unknown condition value via CLI", async () => {
const result = await runHelper(["--condition", "Z", "Invalid condition test"]);
expect(result.exitCode).not.toBe(0);
const parsed = parseOutput(result);
if (parsed) {
expect(parsed.success).toBe(false);
expect(typeof parsed.error).toBe("string");
}
});
it("rejects treatment with non-D condition via CLI", async () => {
const result = await runHelper(["--condition", "B", "--treatment", "no21", "Cross-condition CLI test"]);
expect(result.exitCode).not.toBe(0);
const parsed = parseOutput(result);
if (parsed) {
expect(parsed.success).toBe(false);
expect(typeof parsed.error).toBe("string");
}
});
});
describe("J.2 — Prompt isolation verification", () => {
it("original and treatment prompts differ only by removal of rule 21", async () => {
const fs = require("fs");
const path = require("path");
const originalPrompt = fs.readFileSync(join(rootDir, "prompts", "reconstruct-v0.5-d-deconstruct.md"), "utf8");
const treatmentPrompt = fs.readFileSync(join(rootDir, "prompts", "reconstruct-v0.5-d-deconstruct-no21.md"), "utf8");
expect(originalPrompt).not.toBe(treatmentPrompt);
// The only difference should be the removal of rule 21 line and its blank line
const originalLines = originalPrompt.split("\n");
const treatmentLines = treatmentPrompt.split("\n");
// Find where they diverge — rule 21 is on line 21
expect(originalLines[20]).toBe("Do not create artificial uncertainties to increase their number.");
// After removal, everything else should shift by exactly one line
for (let i = 21; i < treatmentLines.length; i++) {
expect(originalLines[i + 1]).toBe(treatmentLines[i]);
}
});
it("both prompts share identical preamble through rule 20", async () => {
const fs = require("fs");
const originalPrompt = fs.readFileSync(join(rootDir, "prompts", "reconstruct-v0.5-d-deconstruct.md"), "utf8");
const treatmentPrompt = fs.readFileSync(join(rootDir, "prompts", "reconstruct-v0.5-d-deconstruct-no21.md"), "utf8");
// First 20 lines should be identical (before rule 21)
const originalPreamble = originalPrompt.split("\n").slice(0, 20).join("\n");
const treatmentPreamble = treatmentPrompt.split("\n").slice(0, 20).join("\n");
expect(originalPreamble).toBe(treatmentPreamble);
});
it("original prompt still contains rule 21 verbatim", async () => {
const fs = require("fs");
const originalPrompt = fs.readFileSync(join(rootDir, "prompts", "reconstruct-v0.5-d-deconstruct.md"), "utf8");
expect(originalPrompt).toContain("Do not create artificial uncertainties to increase their number.");
});
it("treatment prompt does NOT contain rule 21", async () => {
const fs = require("fs");
const treatmentPrompt = fs.readFileSync(join(rootDir, "prompts", "reconstruct-v0.5-d-deconstruct-no21.md"), "utf8");
expect(treatmentPrompt).not.toContain("Do not create artificial uncertainties to increase their number.");
});
it("Condition D output cannot overwrite original Condition D evidence — distinct freeformText field", async () => {
const resultTreatment = await runHelper(["--condition", "D", "--treatment", "no21", "Treatment output test"]);
const resultOriginal = await runHelper(["--condition", "D", "Original Condition D test"]);
expect(resultTreatment.exitCode).toBe(0);
expect(resultOriginal.exitCode).toBe(0);
const treatmentOutput = parseOutput(resultTreatment);
const originalOutput = parseOutput(resultOriginal);
// Both should have freeformText (not reconstruction) for Condition D
expect(treatmentOutput).toHaveProperty("freeformText");
expect(originalOutput).toHaveProperty("freeformText");
expect(treatmentOutput).not.toHaveProperty("reconstruction");
expect(originalOutput).not.toHaveProperty("reconstruction");
// They use the same output structure (freeformText) but different content —
// the test double returns deterministic text that confirms prompt selection works
expect(typeof treatmentOutput.freeformText).toBe("string");
expect(typeof originalOutput.freeformText).toBe("string");
});
});
});