diff --git a/lib/graph/focused-investigation.js b/lib/graph/focused-investigation.js index 81c8499..b2ab079 100644 --- a/lib/graph/focused-investigation.js +++ b/lib/graph/focused-investigation.js @@ -97,10 +97,10 @@ Required top-level fields: Field rules (semantic contract): - targetNodeId must be included as a string identifying this investigation node - observations: only meaning directly supported by what the user's answer states. Do not strengthen implications into observations. -- uncertainties: only things the answer explicitly leaves unknown or unclear. Preserve uncertainty at the narrowest scope justified by the answer: when the answer establishes one factor but provides no evidence about what else may matter, keep the remaining uncertainty broad rather than inventing specific additional factors, deficits, causes, requirements, or interventions. +- uncertainties: must be a JSON array containing exactly one string — the single nearest unresolved relationship exposed by this answer. This is the specific gap between what the answer established and what remains unknown right here. Do NOT widen the frontier: identify only the one thing that must be understood before you can know what to ask after it. Do NOT include everything else that might matter, future constraints, broader capability questions, other branches of the investigation, or possible remedies. The uncertainty must be narrow enough that one user answer could materially clarify it. Use ordinary language that a capable person with no specialist vocabulary can understand immediately. If the uncertainty needs abstract phrases, management jargon, specialist terminology, or several concepts joined together to express it, break the reasoning down again before returning it. Simple wording of an over-composed idea is still a failure: first ask "what is the smallest thing we actually do not know yet?" then express that one thing simply. - assumptions: what unstated proposition does the user's answer itself rely upon for it to make sense? Include only when such a proposition is genuinely attributable to the user's reasoning. The boundary is narrow: attribute only propositions that the user's answer would cease to make sense if they were false. Do NOT import plausible interpretations from the wider investigation context, scenario framing, domain relevance, strategic implications, or model-generated analysis into this field — those belong in uncertainties, relationships (where permitted), or possibleFollowUpQuestions. Do NOT connect a factual statement the user makes to a broader capability or constraint concept unless the user explicitly links them. Example: answering "I only have bank account access" to a question about delegation constraints does NOT assume that "delegation feasibility is contingent upon banking access" — it only states a fact about access, and connecting that fact to delegation feasibility is your own scenario-level inference, not a user-held assumption. If the user's answer does not contain or rely upon an identifiable assumption, return assumptions: []. Do NOT require verbatim copying from the user's answer; paraphrasing is allowed only when the reasoning genuinely relies on it. - relationships: only connections that the user's answer directly establishes between items. Co-mentioned facts do not by themselves create causal, constraint, or dependency relationships. If a relationship is only plausible, omit it rather than assert it. -- possibleFollowUpQuestions: questions that investigate genuinely unresolved areas exposed by this answer. Before formulating each follow-up, check whether the question tests a proposition (e.g., "there is a deficit", "X is required", "intervention Y should happen") against the current epistemic state or assumes it as already established. If an explanation, deficit, dependency, cause, intervention, recommendation, or solution has not been established by prior evidence, phrase the question so it tests whether that proposition is true rather than assuming it — verify the unresolved fact before seeking remedy. Prefer questions that identify what remains unknown, distinguish competing explanations, test whether a suspected factor actually matters, clarify scope, or identify what evidence would change the investigation. Do not jump to implementation details unless the answer has already established that intervention as the relevant next issue. +- possibleFollowUpQuestions: must be a JSON array containing exactly one string — your single best follow-up question. Example shape: ["one question"]. This question must directly investigate the single uncertainty returned in uncertainties (uncertainties[0] → possibleFollowUpQuestions[0]): one unresolved proposition mapped to one question designed to clarify it. The question must not introduce a second unresolved issue, must not broaden beyond the uncertainty it is meant to resolve, and must not contain more than one investigative step. Do not provide alternatives, a roadmap, or questions that belong after this one has been answered. A later question must be generated only after the current question has been answered and deconstructed. Do not ask about consequences, expansion, requirements, interventions, or other branches until the immediate unresolved relationship has been clarified. Those may become later questions after new evidence is obtained. Ask only what the Engine has earned the right to ask now. Each epistemic step waits its turn — do not combine steps that should happen in sequence across multiple turns: one question that investigates one thing only, never a bundle of future reasoning joined together. Before formulating, check whether the question tests a proposition against the current epistemic state: if an explanation, deficit, dependency, cause, intervention, recommendation, or solution has not been established by prior evidence, phrase the question so it tests whether that proposition is true rather than assuming it — verify the unresolved fact before seeking remedy. Prefer questions that identify what remains unknown, distinguish competing explanations, test whether a suspected factor actually matters, clarify scope, or identify what evidence would change the investigation. Do not jump to implementation details unless the answer has already established that intervention as the relevant next issue. Use ordinary language that a capable person with no specialist vocabulary can understand immediately. If the question needs abstract phrases, management jargon, specialist terminology, or several concepts joined together to express it, break the reasoning down again before returning it. Simple wording of an over-composed idea is still a failure: first ask "what is the smallest thing we actually do not know yet?" then express that one thing simply. - cross-field ownership: preserve who or what owns each proposition. When a statement expresses the user's comfort, willingness, threshold, belief, uncertainty, preference, or judgement, keep it attached to that stance — do not elevate it into an objective requirement, capability fact, or situational constraint. Focused case context: diff --git a/tests/graph/focused-frontier-prompt-builder.mjs b/tests/graph/focused-frontier-prompt-builder.mjs new file mode 100644 index 0000000..a6f830b --- /dev/null +++ b/tests/graph/focused-frontier-prompt-builder.mjs @@ -0,0 +1,212 @@ +/** + * Durable narrow frontier prompt builders (EXP42). + * + * Two prompt-building functions that share one frontier-rule source, + * one context formatter, and one JSON-only anchoring instruction. + * + * This file is experimental apparatus only. It is NOT production code. + */ + +// ── shared constants ─────────────────────────────────────────────────────── + +const FRONTIER_RULES = `Identify the single nearest thing that is still unknown because of this answer. + +Find the smallest thing we actually do not know yet. + +Return exactly one uncertainty. + +Return exactly one follow-up question. + +The question must directly investigate that uncertainty. + +Do not jump to what might matter later. + +Do not move to another branch before this one is understood. + +Do not assume an explanation, deficit, cause, requirement, intervention, or solution that has not been established. + +If a proposition has not been established, test whether it is true rather than assuming it. + +One question must investigate one thing only. + +Use ordinary language a capable non-expert can understand immediately.`; + +const JSON_ONLY_INSTRUCTION = `Return exactly one JSON object. +Return JSON only. +Do not include prose, markdown, headings, commentary, or explanation outside the JSON object.`; + +function formatContext({ centralStatement, targetLabel, targetDescription, question, answer }) { + return [ + "Central situation:", + centralStatement, + "", + "Focused target:", + targetLabel, + "", + "Target description:", + targetDescription, + "", + "Question asked:", + question, + "", + "User answer:", + answer, + ].join("\n"); +} + +// ── builders ─────────────────────────────────────────────────────────────── + +/** + * Build the minimal (two-field) frontier prompt. + * Semantic workload: one uncertainty + one follow-up question. + */ +export function buildMinimalFrontierPrompt({ centralStatement, targetLabel, targetDescription, question, answer }) { + return [ + JSON_ONLY_INSTRUCTION, + "", + "Required top-level fields:", + "- uncertainties", + "- possibleFollowUpQuestions", + "", + "Return this exact shape:", + '{"uncertainties": ["one uncertainty"], "possibleFollowUpQuestions": ["one follow-up question"]}', + "", + FRONTIER_RULES, + "", + "Focused case context:", + formatContext({ centralStatement, targetLabel, targetDescription, question, answer }), + ].join("\n"); +} + +/** + * Build the observation-grounded frontier prompt. + * Semantic workload: observations + one uncertainty + one follow-up question. + */ +export function buildObservationFrontierPrompt({ centralStatement, targetLabel, targetDescription, question, answer }) { + return [ + JSON_ONLY_INSTRUCTION, + "", + "Required top-level fields:", + "- observations", + "- uncertainties", + "- possibleFollowUpQuestions", + "", + "Return this exact shape:", + '{"observations": ["one or more directly supported observations"], "uncertainties": ["one uncertainty"], "possibleFollowUpQuestions": ["one follow-up question"]}', + "", + `observations: only meaning directly supported by what the user's answer states. Do not strengthen implications into observations.`, + "", + FRONTIER_RULES, + "", + "Focused case context:", + formatContext({ centralStatement, targetLabel, targetDescription, question, answer }), + ].join("\n"); +} + +/** + * Build the relationship-frontier prompt. + * Semantic workload: relationships + one uncertainty + one follow-up question. + */ +export function buildRelationshipFrontierPrompt({ centralStatement, targetLabel, targetDescription, question, answer }) { + return [ + JSON_ONLY_INSTRUCTION, + "", + "Required top-level fields:", + "- relationships", + "- uncertainties", + "- possibleFollowUpQuestions", + "", + "Return this exact shape:", + '{"relationships": ["one or more directly established relationships"], "uncertainties": ["one uncertainty"], "possibleFollowUpQuestions": ["one follow-up question"]}', + "", + `relationships: only connections that the user's answer directly establishes between items. Co-mentioned facts do not by themselves create causal, constraint, or dependency relationships. If a relationship is only plausible, omit it rather than assert it.`, + "", + FRONTIER_RULES, + "", + "Focused case context:", + formatContext({ centralStatement, targetLabel, targetDescription, question, answer }), + ].join("\n"); +} + +/** + * Build the assumption-frontier prompt. + * Semantic workload: assumptions + one uncertainty + one follow-up question. + */ +export function buildAssumptionFrontierPrompt({ centralStatement, targetLabel, targetDescription, question, answer }) { + return [ + JSON_ONLY_INSTRUCTION, + "", + "Required top-level fields:", + "- assumptions", + "- uncertainties", + "- possibleFollowUpQuestions", + "", + "Return this exact shape:", + '{"assumptions": ["zero or more genuinely user-held assumptions"], "uncertainties": ["one uncertainty"], "possibleFollowUpQuestions": ["one follow-up question"]}', + "", + `assumptions: what unstated proposition does the user's answer itself rely upon for it to make sense? Attribute only when such a proposition is genuinely attributable to the user's reasoning. The boundary is narrow: attribute only propositions that the user's answer would cease to make sense if they were false. Do NOT import plausible interpretations from the wider investigation context, scenario framing, domain relevance, strategic implications, or model-generated analysis into this field — those belong in uncertainties, other structured fields where permitted, or possibleFollowUpQuestions. Do NOT connect a factual statement the user makes to a broader capability or constraint concept unless the user explicitly links them. Example: answering "I only have bank account access" to a question about delegation constraints does NOT assume that "delegation feasibility is contingent upon banking access" — it only states a fact about access, and connecting that fact to delegation feasibility is your own scenario-level inference, not a user-held assumption. If the user's answer does not contain or rely upon an identifiable assumption, return assumptions: []. Do NOT require verbatim copying from the user's answer; paraphrasing is allowed only when the reasoning genuinely relies on it.`, + "", + FRONTIER_RULES, + "", + "Focused case context:", + formatContext({ centralStatement, targetLabel, targetDescription, question, answer }), + ].join("\n"); +} + +/** + * Build the observation+assumption-frontier prompt. + * Semantic workload: observations + assumptions + one uncertainty + one follow-up question. + */ +export function buildObservationAssumptionFrontierPrompt({ centralStatement, targetLabel, targetDescription, question, answer }) { + return [ + JSON_ONLY_INSTRUCTION, + "", + "Required top-level fields:", + "- observations", + "- assumptions", + "- uncertainties", + "- possibleFollowUpQuestions", + "", + "Return this exact shape:", + '{"observations": ["one or more directly supported observations"], "assumptions": ["zero or more genuinely user-held assumptions"], "uncertainties": ["one uncertainty"], "possibleFollowUpQuestions": ["one follow-up question"]}', + "", + `observations: only meaning directly supported by what the user's answer states. Do not strengthen implications into observations.`, + "", + `assumptions: what unstated proposition does the user's answer itself rely upon for it to make sense? Attribute only when such a proposition is genuinely attributable to the user's reasoning. The boundary is narrow: attribute only propositions that the user's answer would cease to make sense if they were false. Do NOT import plausible interpretations from the wider investigation context, scenario framing, domain relevance, strategic implications, or model-generated analysis into this field — those belong in uncertainties, other structured fields where permitted, or possibleFollowUpQuestions. Do NOT connect a factual statement the user makes to a broader capability or constraint concept unless the user explicitly links them. Example: answering "I only have bank account access" to a question about delegation constraints does NOT assume that "delegation feasibility is contingent upon banking access" — it only states a fact about access, and connecting that fact to delegation feasibility is your own scenario-level inference, not a user-held assumption. If the user's answer does not contain or rely upon an identifiable assumption, return assumptions: []. Do NOT require verbatim copying from the user's answer; paraphrasing is allowed only when the reasoning genuinely relies on it.`, + "", + FRONTIER_RULES, + "", + "Focused case context:", + formatContext({ centralStatement, targetLabel, targetDescription, question, answer }), + ].join("\n"); +} + +/** + * Build the observation+strict-assumption-frontier prompt (EXP49). + * Semantic workload: observations + assumptions + one uncertainty + one follow-up question. + * Differs from buildObservationAssumptionFrontierPrompt only in the assumption rule, + * which adds an explicit attribution boundary for co-mentioned / contrasted / juxtaposed facts. + */ +export function buildObservationStrictAssumptionFrontierPrompt({ centralStatement, targetLabel, targetDescription, question, answer }) { + return [ + JSON_ONLY_INSTRUCTION, + "", + "Required top-level fields:", + "- observations", + "- assumptions", + "- uncertainties", + "- possibleFollowUpQuestions", + "", + "Return this exact shape:", + '{"observations": ["one or more directly supported observations"], "assumptions": ["zero or more genuinely user-held assumptions"], "uncertainties": ["one uncertainty"], "possibleFollowUpQuestions": ["one follow-up question"]}', + "", + `observations: only meaning directly supported by what the user's answer states. Do not strengthen implications into observations.`, + "", + `assumptions: what unstated proposition does the user's answer itself rely upon for it to make sense? Attribute only when such a proposition is genuinely attributable to the user's reasoning. The boundary is narrow: attribute only propositions that the user's answer would cease to make sense if they were false. Do NOT import plausible interpretations from the wider investigation context, scenario framing, domain relevance, strategic implications, or model-generated analysis into this field — those belong in uncertainties, other structured fields where permitted, or possibleFollowUpQuestions. Co-mentioned, contrasted, or juxtaposed facts do not by themselves establish a user-held assumption. Do not invent a proposition merely to explain why two facts can both be true. A proposition belongs in assumptions only if the user's answer itself depends on that proposition for its meaning and would cease to make sense, or materially lose its intended reasoning, if the proposition were false. Otherwise return assumptions: []. If the user's answer does not contain or rely upon an identifiable assumption, return assumptions: []. Do NOT require verbatim copying from the user's answer; paraphrasing is allowed only when the reasoning genuinely relies on it.`, + "", + FRONTIER_RULES, + "", + "Focused case context:", + formatContext({ centralStatement, targetLabel, targetDescription, question, answer }), + ].join("\n"); +} diff --git a/tests/graph/focused-frontier-prompt-builder.test.mjs b/tests/graph/focused-frontier-prompt-builder.test.mjs new file mode 100644 index 0000000..a25d9d0 --- /dev/null +++ b/tests/graph/focused-frontier-prompt-builder.test.mjs @@ -0,0 +1,1072 @@ +/** + * Deterministic verification of focused frontier prompt builders (EXP42). + * Zero live calls. No provider, no Ollama, no graph logic. + */ + +import { describe, it, expect } from "vitest"; +import { + buildMinimalFrontierPrompt, + buildObservationFrontierPrompt, + buildRelationshipFrontierPrompt, + buildAssumptionFrontierPrompt, + buildObservationAssumptionFrontierPrompt, + buildObservationStrictAssumptionFrontierPrompt, +} from "./focused-frontier-prompt-builder.mjs"; + +// ── fixtured case (single fixed dummy) ───────────────────────────────────── + +const CASE = { + centralStatement: + "A business owner seeks to delegate routine operational tasks but lacks clarity on scope.", + targetLabel: "Team capability constraints on task handoff boundaries", + targetDescription: + "The extent to which current skill levels and bandwidth dictate the limit of delegable tasks.", + question: "What was the comparable state before capability constraints?", + answer: "She handles weekly supplier payments herself without checking for six months.", +}; + +// ── helpers ──────────────────────────────────────────────────────────────── + +function countOccurrences(str, sub) { + let n = 0; + let i = 0; + while ((i = str.indexOf(sub, i)) !== -1) { + n++; + i += sub.length; + } + return n; +} + +// ── 1. Both builders are deterministic ───────────────────────────────────── + +describe("determinism", () => { + it("buildMinimalFrontierPrompt produces byte-identical output on repeated calls", () => { + const a = buildMinimalFrontierPrompt(CASE); + const b = buildMinimalFrontierPrompt(CASE); + expect(a).toBe(b); + }); + + it("buildObservationFrontierPrompt produces byte-identical output on repeated calls", () => { + const a = buildObservationFrontierPrompt(CASE); + const b = buildObservationFrontierPrompt(CASE); + expect(a).toBe(b); + }); +}); + +// ── 2. Shared case material is identical ─────────────────────────────────── + +describe("shared context", () => { + it("both prompts contain the exact centralStatement", () => { + const minimal = buildMinimalFrontierPrompt(CASE); + const obs = buildObservationFrontierPrompt(CASE); + expect(minimal).toContain(CASE.centralStatement); + expect(obs).toContain(CASE.centralStatement); + }); + + it("both prompts contain the exact targetLabel", () => { + const minimal = buildMinimalFrontierPrompt(CASE); + const obs = buildObservationFrontierPrompt(CASE); + expect(minimal).toContain(CASE.targetLabel); + expect(obs).toContain(CASE.targetLabel); + }); + + it("both prompts contain the exact targetDescription", () => { + const minimal = buildMinimalFrontierPrompt(CASE); + const obs = buildObservationFrontierPrompt(CASE); + expect(minimal).toContain(CASE.targetDescription); + expect(obs).toContain(CASE.targetDescription); + }); + + it("both prompts contain the exact question", () => { + const minimal = buildMinimalFrontierPrompt(CASE); + const obs = buildObservationFrontierPrompt(CASE); + expect(minimal).toContain(CASE.question); + expect(obs).toContain(CASE.question); + }); + + it("both prompts contain the exact answer", () => { + const minimal = buildMinimalFrontierPrompt(CASE); + const obs = buildObservationFrontierPrompt(CASE); + expect(minimal).toContain(CASE.answer); + expect(obs).toContain(CASE.answer); + }); +}); + +// ── 3. Frontier rules are one shared source ──────────────────────────────── + +describe("shared frontier rules", () => { + const MINIMAL = buildMinimalFrontierPrompt(CASE); + const OBSERVATION = buildObservationFrontierPrompt(CASE); + + it("minimal prompt contains the shared frontier rule text", () => { + // Extract a unique anchor phrase that only appears in the frontier rules block + expect(MINIMAL).toContain("Identify the single nearest thing that is still unknown because of this answer."); + }); + + it("observation prompt contains the shared frontier rule text", () => { + expect(OBSERVATION).toContain("Identify the single nearest thing that is still unknown because of this answer."); + }); + + it("the frontier rule line appears exactly once in each prompt", () => { + const anchor = "Identify the single nearest thing that is still unknown because of this answer."; + expect(countOccurrences(MINIMAL, anchor)).toBe(1); + expect(countOccurrences(OBSERVATION, anchor)).toBe(1); + }); + + it("both prompts share the same frontier rule text (byte-identical slice)", () => { + const extractFrontierRules = (prompt) => { + const startMarker = "Identify the single nearest thing that is still unknown because of this answer."; + const endMarker = "Use ordinary language a capable non-expert can understand immediately."; + const startIdx = prompt.indexOf(startMarker); + const endIdx = prompt.indexOf(endMarker) + endMarker.length; + return prompt.slice(startIdx, endIdx); + }; + + expect(extractFrontierRules(MINIMAL)).toBe(extractFrontierRules(OBSERVATION)); + }); +}); + +// ── 4. Minimal prompt contains no excluded semantic fields ───────────────── + +describe("minimal prompt — exclusion check", () => { + const MINIMAL = buildMinimalFrontierPrompt(CASE); + + it("does NOT mention observations (as a field)", () => { + expect(MINIMAL).not.toContain('"observations"'); + expect(MINIMAL).not.toContain("observations:"); + }); + + it("does NOT mention assumptions", () => { + expect(MINIMAL).not.toContain("assumptions"); + }); + + it("does NOT mention relationships", () => { + expect(MINIMAL).not.toContain("relationships"); + }); + + it("does NOT mention targetNodeId", () => { + expect(MINIMAL).not.toContain("targetNodeId"); + }); +}); + +// ── 5. Observation prompt contains only the added semantic job ───────────── + +describe("observation prompt — inclusion check", () => { + const OBS = buildObservationFrontierPrompt(CASE); + + it("contains observations field in required fields", () => { + expect(OBS).toContain("- observations"); + }); + + it("does NOT mention assumptions", () => { + expect(OBS).not.toContain("assumptions"); + }); + + it("does NOT mention relationships", () => { + expect(OBS).not.toContain("relationships"); + }); + + it("does NOT mention targetNodeId", () => { + expect(OBS).not.toContain("targetNodeId"); + }); +}); + +// ── 6. JSON anchoring exists in both ─────────────────────────────────────── + +describe("JSON-only anchoring", () => { + const MINIMAL = buildMinimalFrontierPrompt(CASE); + const OBSERVATION = buildObservationFrontierPrompt(CASE); + + it('both contain "Return exactly one JSON object."', () => { + expect(MINIMAL).toContain("Return exactly one JSON object."); + expect(OBSERVATION).toContain("Return exactly one JSON object."); + }); + + it('both contain "Return JSON only."', () => { + expect(MINIMAL).toContain("Return JSON only."); + expect(OBSERVATION).toContain("Return JSON only."); + }); + + it("both prohibit prose outside JSON", () => { + const proseProhibition = "Do not include prose, markdown, headings, commentary, or explanation outside the JSON object."; + expect(MINIMAL).toContain(proseProhibition); + expect(OBSERVATION).toContain(proseProhibition); + }); +}); + +// ── 7. Exact shapes are present ──────────────────────────────────────────── + +describe("exact output shapes", () => { + it("minimal prompt contains the exact two-field shape", () => { + const MINIMAL = buildMinimalFrontierPrompt(CASE); + expect(MINIMAL).toContain('"uncertainties": ["one uncertainty"]'); + expect(MINIMAL).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + }); + + it("observation prompt contains the exact three-field shape", () => { + const OBS = buildObservationFrontierPrompt(CASE); + expect(OBS).toContain('"observations": ["one or more directly supported observations"]'); + expect(OBS).toContain('"uncertainties": ["one uncertainty"]'); + expect(OBS).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + }); +}); + +// ── 8. Only intended semantic workload differs ───────────────────────────── + +describe("semantic workload control", () => { + const MINIMAL = buildMinimalFrontierPrompt(CASE); + const OBSERVATION = buildObservationFrontierPrompt(CASE); + + it("both share identical JSON-only instruction text", () => { + expect(MINIMAL).toContain("Return exactly one JSON object."); + expect(OBSERVATION).toContain("Return exactly one JSON object."); + expect(MINIMAL).toContain("Return JSON only."); + expect(OBSERVATION).toContain("Return JSON only."); + }); + + it("both share identical shared context formatter output", () => { + // Both contain the same case material — verify by checking they contain + // each of the five shared fields identically. + expect(MINIMAL).toContain(CASE.centralStatement); + expect(OBSERVATION).toContain(CASE.centralStatement); + expect(MINIMAL).toContain(CASE.targetLabel); + expect(OBSERVATION).toContain(CASE.targetLabel); + expect(MINIMAL).toContain(CASE.question); + expect(OBSERVATION).toContain(CASE.question); + }); + + it("observation prompt alone adds the observations field", () => { + // The observation-specific text should be present only in OBS + expect(OBSERVATION).toContain("- observations"); + expect(OBSERVATION).toContain('"observations": ["one or more directly supported observations"]'); + expect(OBSERVATION).toContain("Do not strengthen implications into observations"); + }); + + it("observation prompt does NOT add assumptions, relationships, or targetNodeId", () => { + expect(OBSERVATION).not.toContain("assumptions"); + expect(OBSERVATION).not.toContain("relationships"); + expect(OBSERVATION).not.toContain("targetNodeId"); + }); +}); + +// ── 9. Deterministic verification of relationship-frontier builder (EXP44) ─ + +describe("relationship frontier prompt builder — determinism", () => { + it("buildRelationshipFrontierPrompt produces byte-identical output on repeated calls", () => { + const a = buildRelationshipFrontierPrompt(CASE); + const b = buildRelationshipFrontierPrompt(CASE); + expect(a).toBe(b); + }); +}); + +describe("relationship frontier — shared context formatter", () => { + it("shares the same centralStatement as minimal builder", () => { + const rel = buildRelationshipFrontierPrompt(CASE); + const min = buildMinimalFrontierPrompt(CASE); + expect(rel).toContain(CASE.centralStatement); + expect(min).toContain(CASE.centralStatement); + }); + + it("shares the same targetLabel as minimal builder", () => { + const rel = buildRelationshipFrontierPrompt(CASE); + const min = buildMinimalFrontierPrompt(CASE); + expect(rel).toContain(CASE.targetLabel); + expect(min).toContain(CASE.targetLabel); + }); + + it("shares the same targetDescription as minimal builder", () => { + const rel = buildRelationshipFrontierPrompt(CASE); + const min = buildMinimalFrontierPrompt(CASE); + expect(rel).toContain(CASE.targetDescription); + expect(min).toContain(CASE.targetDescription); + }); + + it("shares the same question as minimal builder", () => { + const rel = buildRelationshipFrontierPrompt(CASE); + const min = buildMinimalFrontierPrompt(CASE); + expect(rel).toContain(CASE.question); + expect(min).toContain(CASE.question); + }); + + it("shares the same answer as minimal builder", () => { + const rel = buildRelationshipFrontierPrompt(CASE); + const min = buildMinimalFrontierPrompt(CASE); + expect(rel).toContain(CASE.answer); + expect(min).toContain(CASE.answer); + }); +}); + +describe("relationship frontier — shared frontier rules", () => { + it("contains the shared frontier rule text", () => { + const REL = buildRelationshipFrontierPrompt(CASE); + expect(REL).toContain("Identify the single nearest thing that is still unknown because of this answer."); + }); + + it("the frontier rule line appears exactly once", () => { + const REL = buildRelationshipFrontierPrompt(CASE); + expect(countOccurrences(REL, "Identify the single nearest thing that is still unknown because of this answer.")).toBe(1); + }); + + it("shares the same frontier rule text as minimal builder", () => { + const extractFrontierRules = (prompt) => { + const startMarker = "Identify the single nearest thing that is still unknown because of this answer."; + const endMarker = "Use ordinary language a capable non-expert can understand immediately."; + const startIdx = prompt.indexOf(startMarker); + const endIdx = prompt.indexOf(endMarker) + endMarker.length; + return prompt.slice(startIdx, endIdx); + }; + + const REL = buildRelationshipFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(extractFrontierRules(REL)).toBe(extractFrontierRules(MIN)); + }); +}); + +describe("relationship frontier — shared JSON-only instruction", () => { + it('contains "Return exactly one JSON object."', () => { + const REL = buildRelationshipFrontierPrompt(CASE); + expect(REL).toContain("Return exactly one JSON object."); + }); + + it('contains "Return JSON only."', () => { + const REL = buildRelationshipFrontierPrompt(CASE); + expect(REL).toContain("Return JSON only."); + }); + + it("prohibits prose outside JSON", () => { + const REL = buildRelationshipFrontierPrompt(CASE); + expect(REL).toContain("Do not include prose, markdown, headings, commentary, or explanation outside the JSON object."); + }); +}); + +describe("relationship frontier — presence and absence checks", () => { + const REL = buildRelationshipFrontierPrompt(CASE); + + it("contains relationships field in required fields", () => { + expect(REL).toContain("- relationships"); + }); + + it("does NOT mention observations (as a field)", () => { + expect(REL).not.toContain('"observations"'); + expect(REL).not.toContain("observations:"); + }); + + it("does NOT mention assumptions", () => { + expect(REL).not.toContain("assumptions"); + }); + + it("does NOT mention targetNodeId", () => { + expect(REL).not.toContain("targetNodeId"); + }); +}); + +describe("relationship frontier — exact JSON shape", () => { + const REL = buildRelationshipFrontierPrompt(CASE); + + it('shows "relationships": ["one or more directly established relationships"]', () => { + expect(REL).toContain('"relationships": ["one or more directly established relationships"]'); + }); + + it('shows "uncertainties": ["one uncertainty"]', () => { + expect(REL).toContain('"uncertainties": ["one uncertainty"]'); + }); + + it('shows "possibleFollowUpQuestions": ["one follow-up question"]', () => { + expect(REL).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + }); +}); + +describe("relationship frontier — relationship rule present exactly once", () => { + const REL = buildRelationshipFrontierPrompt(CASE); + + it("contains the relationship rule verbatim", () => { + expect(REL).toContain("only connections that the user's answer directly establishes between items. Co-mentioned facts do not by themselves create causal, constraint, or dependency relationships."); + }); + + it("the relationship rule appears exactly once", () => { + const ruleText = "relationships: only connections that the user's answer directly establishes between items."; + expect(countOccurrences(REL, ruleText)).toBe(1); + }); +}); + +// ── assumption frontier prompt builder (EXP45) ─────────────────────────── + +describe("assumption frontier prompt builder — determinism", () => { + it("buildAssumptionFrontierPrompt produces byte-identical output on repeated calls", () => { + const a = buildAssumptionFrontierPrompt(CASE); + const b = buildAssumptionFrontierPrompt(CASE); + expect(a).toBe(b); + }); +}); + +describe("assumption frontier — shared context formatter", () => { + it("shares the same centralStatement as minimal builder", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(ASSUMPTION).toContain(CASE.centralStatement); + expect(MIN).toContain(CASE.centralStatement); + }); + + it("shares the same targetLabel as minimal builder", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(ASSUMPTION).toContain(CASE.targetLabel); + expect(MIN).toContain(CASE.targetLabel); + }); + + it("shares the same targetDescription as minimal builder", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(ASSUMPTION).toContain(CASE.targetDescription); + expect(MIN).toContain(CASE.targetDescription); + }); + + it("shares the same question as minimal builder", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(ASSUMPTION).toContain(CASE.question); + expect(MIN).toContain(CASE.question); + }); + + it("shares the same answer as minimal builder", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(ASSUMPTION).toContain(CASE.answer); + expect(MIN).toContain(CASE.answer); + }); +}); + +describe("assumption frontier — shared frontier rules", () => { + it("contains the shared frontier rule text", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + expect(ASSUMPTION).toContain("Identify the single nearest thing that is still unknown because of this answer."); + }); + + it("the frontier rule line appears exactly once", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + expect(countOccurrences(ASSUMPTION, "Identify the single nearest thing that is still unknown because of this answer.")).toBe(1); + }); + + it("shares the same frontier rule text as minimal builder", () => { + const extractFrontierRules = (prompt) => { + const startMarker = "Identify the single nearest thing that is still unknown because of this answer."; + const endMarker = "Use ordinary language a capable non-expert can understand immediately."; + const startIdx = prompt.indexOf(startMarker); + const endIdx = prompt.indexOf(endMarker) + endMarker.length; + return prompt.slice(startIdx, endIdx); + }; + + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(extractFrontierRules(ASSUMPTION)).toBe(extractFrontierRules(MIN)); + }); +}); + +describe("assumption frontier — shared JSON-only instruction", () => { + it('contains "Return exactly one JSON object."', () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + expect(ASSUMPTION).toContain("Return exactly one JSON object."); + }); + + it('contains "Return JSON only."', () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + expect(ASSUMPTION).toContain("Return JSON only."); + }); + + it("prohibits prose outside JSON", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + expect(ASSUMPTION).toContain("Do not include prose, markdown, headings, commentary, or explanation outside the JSON object."); + }); +}); + +describe("assumption frontier — presence and absence checks", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + + it("contains assumptions field in required fields", () => { + expect(ASSUMPTION).toContain("- assumptions"); + }); + + it("does NOT mention observations (as a field)", () => { + expect(ASSUMPTION).not.toContain('"observations"'); + expect(ASSUMPTION).not.toContain("observations:"); + }); + + it("does NOT mention relationships", () => { + expect(ASSUMPTION).not.toContain("relationships"); + }); + + it("does NOT mention targetNodeId", () => { + expect(ASSUMPTION).not.toContain("targetNodeId"); + }); +}); + +describe("assumption frontier — exact JSON shape", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + + it('shows "assumptions": ["zero or more genuinely user-held assumptions"]', () => { + expect(ASSUMPTION).toContain('"assumptions": ["zero or more genuinely user-held assumptions"]'); + }); + + it('shows "uncertainties": ["one uncertainty"]', () => { + expect(ASSUMPTION).toContain('"uncertainties": ["one uncertainty"]'); + }); + + it('shows "possibleFollowUpQuestions": ["one follow-up question"]', () => { + expect(ASSUMPTION).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + }); +}); + +describe("assumption frontier — assumption rule present exactly once", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + + it("contains the assumption rule verbatim", () => { + expect(ASSUMPTION).toContain("what unstated proposition does the user's answer itself rely upon for it to make sense?"); + }); + + it("the assumption rule appears exactly once", () => { + const ruleText = "assumptions: what unstated proposition does the user's answer itself rely upon for it to make sense?"; + expect(countOccurrences(ASSUMPTION, ruleText)).toBe(1); + }); +}); + +describe("assumption frontier — empty assumptions explicitly permitted", () => { + const ASSUMPTION = buildAssumptionFrontierPrompt(CASE); + + it("prompt text contains the phrase 'assumptions: []' allowing empty arrays", () => { + expect(ASSUMPTION).toContain("return assumptions: []"); + }); +}); + +// ── EXP46 — observation+assumption frontier prompt builder ──────────────── + +describe("observation+assumption frontier prompt builder — determinism (req 1)", () => { + it("buildObservationAssumptionFrontierPrompt produces byte-identical output on repeated calls", () => { + const a = buildObservationAssumptionFrontierPrompt(CASE); + const b = buildObservationAssumptionFrontierPrompt(CASE); + expect(a).toBe(b); + }); + + it("all existing builders remain deterministic (req 2)", () => { + for (const builder of [buildMinimalFrontierPrompt, buildObservationFrontierPrompt, buildRelationshipFrontierPrompt, buildAssumptionFrontierPrompt]) { + const a = builder(CASE); + const b = builder(CASE); + expect(a).toBe(b); + } + }); +}); + +describe("observation+assumption frontier — shared context formatter (req 3)", () => { + it("shares the same centralStatement as minimal builder", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(OAF).toContain(CASE.centralStatement); + expect(MIN).toContain(CASE.centralStatement); + }); + + it("shares the same targetLabel as minimal builder", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(OAF).toContain(CASE.targetLabel); + expect(MIN).toContain(CASE.targetLabel); + }); + + it("shares the same targetDescription as minimal builder", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(OAF).toContain(CASE.targetDescription); + expect(MIN).toContain(CASE.targetDescription); + }); + + it("shares the same question as minimal builder", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(OAF).toContain(CASE.question); + expect(MIN).toContain(CASE.question); + }); + + it("shares the same answer as minimal builder", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(OAF).toContain(CASE.answer); + expect(MIN).toContain(CASE.answer); + }); +}); + +describe("observation+assumption frontier — shared frontier rules (req 4)", () => { + it("contains the shared frontier rule text", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + expect(OAF).toContain("Identify the single nearest thing that is still unknown because of this answer."); + }); + + it("the frontier rule line appears exactly once", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + expect(countOccurrences(OAF, "Identify the single nearest thing that is still unknown because of this answer.")).toBe(1); + }); + + it("shares the same frontier rule text as minimal builder", () => { + const extractFrontierRules = (prompt) => { + const startMarker = "Identify the single nearest thing that is still unknown because of this answer."; + const endMarker = "Use ordinary language a capable non-expert can understand immediately."; + const startIdx = prompt.indexOf(startMarker); + const endIdx = prompt.indexOf(endMarker) + endMarker.length; + return prompt.slice(startIdx, endIdx); + }; + + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(extractFrontierRules(OAF)).toBe(extractFrontierRules(MIN)); + }); +}); + +describe("observation+assumption frontier — shared JSON-only instruction (req 5)", () => { + it('contains "Return exactly one JSON object."', () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + expect(OAF).toContain("Return exactly one JSON object."); + }); + + it('contains "Return JSON only."', () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + expect(OAF).toContain("Return JSON only."); + }); + + it("prohibits prose outside JSON", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + expect(OAF).toContain("Do not include prose, markdown, headings, commentary, or explanation outside the JSON object."); + }); +}); + +describe("observation+assumption frontier — observation rule reused unchanged (req 6)", () => { + it("contains the observation rule verbatim", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + expect(OAF).toContain("only meaning directly supported by what the user's answer states. Do not strengthen implications into observations."); + }); + + it("the observation rule appears exactly once", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const ruleText = "observations: only meaning directly supported by what the user's answer states."; + expect(countOccurrences(OAF, ruleText)).toBe(1); + }); +}); + +describe("observation+assumption frontier — assumption rule reused unchanged (req 7)", () => { + it("contains the assumption rule verbatim", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + expect(OAF).toContain("what unstated proposition does the user's answer itself rely upon for it to make sense?"); + }); + + it("the assumption rule appears exactly once", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const ruleText = "assumptions: what unstated proposition does the user's answer itself rely upon for it to make sense?"; + expect(countOccurrences(OAF, ruleText)).toBe(1); + }); + + it("contains empty-assumptions guidance verbatim", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + expect(OAF).toContain("return assumptions: []"); + }); +}); + +describe("observation+assumption frontier — presence checks (req 8, 9)", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + + it("contains observations field in required fields (req 8)", () => { + expect(OAF).toContain("- observations"); + }); + + it('shows "observations": ["one or more directly supported observations"] (req 8)', () => { + expect(OAF).toContain('"observations": ["one or more directly supported observations"]'); + }); + + it("contains assumptions field in required fields (req 9)", () => { + expect(OAF).toContain("- assumptions"); + }); + + it('shows "assumptions": ["zero or more genuinely user-held assumptions"] (req 9)', () => { + expect(OAF).toContain('"assumptions": ["zero or more genuinely user-held assumptions"]'); + }); +}); + +describe("observation+assumption frontier — absence checks (req 10, 11)", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + + it("does NOT mention relationships as a field (req 10)", () => { + expect(OAF).not.toContain('"relationships"'); + expect(OAF).not.toContain("- relationships"); + }); + + it("does NOT mention targetNodeId (req 11)", () => { + expect(OAF).not.toContain("targetNodeId"); + }); +}); + +describe("observation+assumption frontier — exact JSON shape present (req 12)", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + + it('shows all four required fields', () => { + expect(OAF).toContain('"observations": ["one or more directly supported observations"]'); + expect(OAF).toContain('"assumptions": ["zero or more genuinely user-held assumptions"]'); + expect(OAF).toContain('"uncertainties": ["one uncertainty"]'); + expect(OAF).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + }); +}); + +describe("observation+assumption frontier — empty assumptions permitted (req 13)", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + + it("prompt text contains guidance allowing empty assumptions array", () => { + expect(OAF).toContain("return assumptions: []"); + }); +}); + +// ── EXP49 — observation+strict-assumption frontier prompt builder ─────── + +describe("observation+strict-assumption frontier prompt builder — determinism (req 1)", () => { + it("buildObservationStrictAssumptionFrontierPrompt produces byte-identical output on repeated calls", () => { + const a = buildObservationStrictAssumptionFrontierPrompt(CASE); + const b = buildObservationStrictAssumptionFrontierPrompt(CASE); + expect(a).toBe(b); + }); + + it("all existing builders remain deterministic (req 2)", () => { + for (const builder of [buildMinimalFrontierPrompt, buildObservationFrontierPrompt, buildRelationshipFrontierPrompt, buildAssumptionFrontierPrompt, buildObservationAssumptionFrontierPrompt]) { + const a = builder(CASE); + const b = builder(CASE); + expect(a).toBe(b); + } + }); +}); + +describe("observation+strict-assumption frontier — shared context formatter (req 3)", () => { + it("shares the same centralStatement as minimal builder", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(SAF).toContain(CASE.centralStatement); + expect(MIN).toContain(CASE.centralStatement); + }); + + it("shares the same targetLabel as minimal builder", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(SAF).toContain(CASE.targetLabel); + expect(MIN).toContain(CASE.targetLabel); + }); + + it("shares the same targetDescription as minimal builder", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(SAF).toContain(CASE.targetDescription); + expect(MIN).toContain(CASE.targetDescription); + }); + + it("shares the same question as minimal builder", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(SAF).toContain(CASE.question); + expect(MIN).toContain(CASE.question); + }); + + it("shares the same answer as minimal builder", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(SAF).toContain(CASE.answer); + expect(MIN).toContain(CASE.answer); + }); +}); + +describe("observation+strict-assumption frontier — shared frontier rules (req 4)", () => { + it("contains the shared frontier rule text", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + expect(SAF).toContain("Identify the single nearest thing that is still unknown because of this answer."); + }); + + it("the frontier rule line appears exactly once", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + expect(countOccurrences(SAF, "Identify the single nearest thing that is still unknown because of this answer.")).toBe(1); + }); + + it("shares the same frontier rule text as minimal builder", () => { + const extractFrontierRules = (prompt) => { + const startMarker = "Identify the single nearest thing that is still unknown because of this answer."; + const endMarker = "Use ordinary language a capable non-expert can understand immediately."; + const startIdx = prompt.indexOf(startMarker); + const endIdx = prompt.indexOf(endMarker) + endMarker.length; + return prompt.slice(startIdx, endIdx); + }; + + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + const MIN = buildMinimalFrontierPrompt(CASE); + expect(extractFrontierRules(SAF)).toBe(extractFrontierRules(MIN)); + }); +}); + +describe("observation+strict-assumption frontier — shared JSON-only instruction (req 5)", () => { + it('contains "Return exactly one JSON object."', () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + expect(SAF).toContain("Return exactly one JSON object."); + }); + + it('contains "Return JSON only."', () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + expect(SAF).toContain("Return JSON only."); + }); + + it("prohibits prose outside JSON", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + expect(SAF).toContain("Do not include prose, markdown, headings, commentary, or explanation outside the JSON object."); + }); +}); + +describe("observation+strict-assumption frontier — observation rule reused unchanged (req 6)", () => { + it("contains the observation rule verbatim", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + expect(SAF).toContain("only meaning directly supported by what the user's answer states. Do not strengthen implications into observations."); + }); + + it("the observation rule appears exactly once", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + const ruleText = "observations: only meaning directly supported by what the user's answer states."; + expect(countOccurrences(SAF, ruleText)).toBe(1); + }); +}); + +describe("observation+strict-assumption frontier — presence checks (req 7, 8)", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + + it("contains observations field in required fields", () => { + expect(SAF).toContain("- observations"); + }); + + it('shows "observations": ["one or more directly supported observations"]', () => { + expect(SAF).toContain('"observations": ["one or more directly supported observations"]'); + }); + + it("contains assumptions field in required fields", () => { + expect(SAF).toContain("- assumptions"); + }); + + it('shows "assumptions": ["zero or more genuinely user-held assumptions"]', () => { + expect(SAF).toContain('"assumptions": ["zero or more genuinely user-held assumptions"]'); + }); +}); + +describe("observation+strict-assumption frontier — exact JSON shape present (req 9)", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + + it('shows all four required fields', () => { + expect(SAF).toContain('"observations": ["one or more directly supported observations"]'); + expect(SAF).toContain('"assumptions": ["zero or more genuinely user-held assumptions"]'); + expect(SAF).toContain('"uncertainties": ["one uncertainty"]'); + expect(SAF).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + }); +}); + +describe("observation+strict-assumption frontier — absence checks (req 10, 11)", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + + it("does NOT mention relationships as a field", () => { + expect(SAF).not.toContain('"relationships"'); + expect(SAF).not.toContain("- relationships"); + }); + + it("does NOT mention targetNodeId", () => { + expect(SAF).not.toContain("targetNodeId"); + }); +}); + +describe("observation+strict-assumption frontier — empty assumptions permitted", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + + it("prompt text contains guidance allowing empty assumptions array", () => { + expect(SAF).toContain("return assumptions: []"); + }); +}); + +describe("observation+strict-assumption frontier — no observation→assumption dependency", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + + it("does NOT contain instruction to derive assumptions from observations", () => { + expect(SAF).not.toContain("derive assumptions"); + expect(SAF).not.toContain("base assumptions"); + expect(SAF).not.toContain("constrain assumptions"); + }); +}); + +// ── EXP49 — same workload and structure control ────────────────────────── + +describe("observation+strict-assumption vs observation+assumption — same workload and structure", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + + it("both contain the same observations field", () => { + expect(OAF).toContain("- observations"); + expect(SAF).toContain("- observations"); + expect(OAF).toContain('"observations": ["one or more directly supported observations"]'); + expect(SAF).toContain('"observations": ["one or more directly supported observations"]'); + }); + + it("both contain the same assumptions field", () => { + expect(OAF).toContain("- assumptions"); + expect(SAF).toContain("- assumptions"); + expect(OAF).toContain('"assumptions": ["zero or more genuinely user-held assumptions"]'); + expect(SAF).toContain('"assumptions": ["zero or more genuinely user-held assumptions"]'); + }); + + it("both contain the same uncertainties field", () => { + expect(OAF).toContain('"uncertainties": ["one uncertainty"]'); + expect(SAF).toContain('"uncertainties": ["one uncertainty"]'); + }); + + it("both contain the same possibleFollowUpQuestions field", () => { + expect(OAF).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + expect(SAF).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + }); + + it("both have identical JSON shape", () => { + expect(OAF).toContain('"observations": ["one or more directly supported observations"]'); + expect(SAF).toContain('"observations": ["one or more directly supported observations"]'); + expect(OAF).toContain('"assumptions": ["zero or more genuinely user-held assumptions"]'); + expect(SAF).toContain('"assumptions": ["zero or more genuinely user-held assumptions"]'); + expect(OAF).toContain('"uncertainties": ["one uncertainty"]'); + expect(SAF).toContain('"uncertainties": ["one uncertainty"]'); + expect(OAF).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + expect(SAF).toContain('"possibleFollowUpQuestions": ["one follow-up question"]'); + }); + + it("both share the same context formatter output", () => { + for (const key of Object.keys(CASE)) { + expect(OAF).toContain(CASE[key]); + expect(SAF).toContain(CASE[key]); + } + }); + + it("both contain identical observation rule", () => { + const obsRule = "observations: only meaning directly supported by what the user's answer states. Do not strengthen implications into observations."; + expect(OAF).toContain(obsRule); + expect(SAF).toContain(obsRule); + expect(countOccurrences(OAF, "observations: only meaning directly supported by what the user's answer states.")).toBe(1); + expect(countOccurrences(SAF, "observations: only meaning directly supported by what the user's answer states.")).toBe(1); + }); + + it("both contain identical frontier rules", () => { + const extractFrontierRules = (prompt) => { + const startMarker = "Identify the single nearest thing that is still unknown because of this answer."; + const endMarker = "Use ordinary language a capable non-expert can understand immediately."; + const startIdx = prompt.indexOf(startMarker); + const endIdx = prompt.indexOf(endMarker) + endMarker.length; + return prompt.slice(startIdx, endIdx); + }; + expect(extractFrontierRules(OAF)).toBe(extractFrontierRules(SAF)); + }); + + it("both contain identical JSON-only instructions", () => { + expect(OAF).toContain("Return exactly one JSON object."); + expect(SAF).toContain("Return exactly one JSON object."); + expect(OAF).toContain("Return JSON only."); + expect(SAF).toContain("Return JSON only."); + }); + + it("both contain the same required top-level field list", () => { + for (const field of ["- observations", "- assumptions", "- uncertainties", "- possibleFollowUpQuestions"]) { + expect(OAF).toContain(field); + expect(SAF).toContain(field); + } + }); + + it("neither prompt contains relationships", () => { + expect(OAF).not.toContain("- relationships"); + expect(SAF).not.toContain("- relationships"); + }); + + it("neither prompt contains targetNodeId", () => { + expect(OAF).not.toContain("targetNodeId"); + expect(SAF).not.toContain("targetNodeId"); + }); +}); + +// ── EXP49 — only intended rule difference ──────────────────────────────── + +describe("observation+strict-assumption vs observation+assumption — only rule difference", () => { + const OAF = buildObservationAssumptionFrontierPrompt(CASE); + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + + it("SAF contains the strict attribution boundary text", () => { + expect(SAF).toContain("Co-mentioned, contrasted, or juxtaposed facts do not by themselves establish a user-held assumption."); + }); + + it("OAF does NOT contain the strict attribution boundary text", () => { + expect(OAF).not.toContain("Co-mentioned, contrasted, or juxtaposed facts do not by themselves establish a user-held assumption."); + }); + + it("restoring the original assumption rule in SAF reconstructs OAF byte-for-byte", () => { + const originalRule = "assumptions: what unstated proposition does the user's answer itself rely upon for it to make sense? Attribute only when such a proposition is genuinely attributable to the user's reasoning. The boundary is narrow: attribute only propositions that the user's answer would cease to make sense if they were false. Do NOT import plausible interpretations from the wider investigation context, scenario framing, domain relevance, strategic implications, or model-generated analysis into this field — those belong in uncertainties, other structured fields where permitted, or possibleFollowUpQuestions. Do NOT connect a factual statement the user makes to a broader capability or constraint concept unless the user explicitly links them. Example: answering \"I only have bank account access\" to a question about delegation constraints does NOT assume that \"delegation feasibility is contingent upon banking access\" — it only states a fact about access, and connecting that fact to delegation feasibility is your own scenario-level inference, not a user-held assumption. If the user's answer does not contain or rely upon an identifiable assumption, return assumptions: []. Do NOT require verbatim copying from the user's answer; paraphrasing is allowed only when the reasoning genuinely relies on it."; + const strictRule = SAF.match(/assumptions:.*reasoning genuinely relies on it\./s)?.[0]; + expect(strictRule).toBeDefined(); + const reconstructed = SAF.replace(strictRule, originalRule); + expect(reconstructed).toBe(OAF); + }); + + it("difference is the strict assumption-attribution boundary only", () => { + // Extract everything except the two assumption rules and compare structure + const extractAssumptionRule = (prompt) => prompt.match(/assumptions:.*reasoning genuinely relies on it\./s)?.[0]; + expect(extractAssumptionRule(OAF)).not.toBe(extractAssumptionRule(SAF)); + + // Remove the assumption rule from both and verify they match + const removeAssumptionRule = (prompt) => prompt.replace(/assumptions:.*reasoning genuinely relies on it\./s, ""); + expect(removeAssumptionRule(OAF)).toBe(removeAssumptionRule(SAF)); + }); +}); + +// ── EXP49 — strict boundary present ────────────────────────────────────── + +describe("observation+strict-assumption frontier — strict boundary assertions", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + + it("contains co-mentioned facts boundary", () => { + expect(SAF).toContain("Co-mentioned"); + }); + + it("contains contrasted facts boundary", () => { + expect(SAF).toContain("contrasted"); + }); + + it("contains juxtaposed facts boundary", () => { + expect(SAF).toContain("juxtaposed"); + }); + + it("contains 'do not invent a proposition merely to explain'", () => { + expect(SAF).toContain("Do not invent a proposition merely to explain why two facts can both be true."); + }); + + it("contains answer-dependence test", () => { + expect(SAF).toContain("the user's answer itself depends on that proposition for its meaning"); + }); + + it("contains 'assumptions: []' fallback", () => { + expect(SAF).toContain("Otherwise return assumptions: []"); + }); +}); + +// ── EXP49 — no case contamination ──────────────────────────────────────── + +describe("observation+strict-assumption frontier — no case-specific language", () => { + const SAF = buildObservationStrictAssumptionFrontierPrompt(CASE); + // Extract just the assumption rule portion (starts with "assumptions:" and ends before "Identify") + const ruleMatch = SAF.match(/assumptions:.*?rely on it\./s); + const strictRule = ruleMatch?.[0] ?? ""; + + it("does NOT mention 'supplier payments' in the assumption rule", () => { + expect(strictRule).not.toContain("supplier payments"); + }); + + it("does NOT mention 'formal training' in the assumption rule", () => { + expect(strictRule).not.toContain("formal training"); + }); + + it("does NOT mention 'delegation' in the assumption rule", () => { + expect(strictRule).not.toContain("delegation"); + }); + + it("does NOT mention 'reliable judgment' in the assumption rule", () => { + expect(strictRule).not.toContain("reliable judgment"); + }); +}); diff --git a/tests/graph/live-focused-deconstruct-experiment-helper.mjs b/tests/graph/live-focused-deconstruct-experiment-helper.mjs index 8421051..990c919 100644 --- a/tests/graph/live-focused-deconstruct-experiment-helper.mjs +++ b/tests/graph/live-focused-deconstruct-experiment-helper.mjs @@ -10,31 +10,22 @@ * Relies on environment variables OLLAMA_BASE_URL and OLLAMA_MODEL being set. */ +import dotenv from "dotenv"; import { buildFocusedDeconstructPrompt, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation.js"; import { getProvider } from "@/lib/llm/provider.js"; +// Load configured OLLAMA_MODEL / OLLAMA_BASE_URL (same mechanism as all known-good live tests) +dotenv.config({ path: ".env.local" }); + +// ── shared execution seam (extracted from existing body) ──────────────── + /** - * Run one live focused-deconstruction experiment. + * Execute a focused-deconstruct prompt through the canonical live machinery. * - * @param {object} params - * @param {string} params.targetNodeId - the target node ID under investigation - * @param {string} params.targetLabel - label of the target node - * @param {string} params.targetDescription - description of the target node - * @param {string} params.centralStatement - the case's central statement - * @param {string} params.question - the exact real production question - * @param {string} params.answer - the exact real production answer - * @param {object} [params.provider] - optional injected provider (for test isolation) - * @returns {object} validated result + timing details + * Caller supplies: prompt, targetNodeId, optional injected provider. + * This function owns: provider resolution, model invocation, timing, validation, result construction. */ -export async function runLiveFocusedDeconstructExperiment(params) { - const { targetNodeId, targetLabel, targetDescription, centralStatement, question, answer, provider } = params; - - // Production path: real prompt builder (never copies prompt logic) - const prompt = buildFocusedDeconstructPrompt({ - targetLabel, targetDescription, centralStatement, question, answer, - }); - - // Provider: use injected (test) or real (production) +async function _executeFocusedPrompt({ prompt, targetNodeId, provider }) { const actualProvider = provider ?? getProvider(); const ollamaModel = process.env.OLLAMA_MODEL; @@ -44,7 +35,6 @@ export async function runLiveFocusedDeconstructExperiment(params) { const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); const elapsedMs = Date.now() - startedAt; - // Production path: real schema validator (never copies schema logic) const validationErrors = validateFocusedDeconstructSchema(raw); if (validationErrors.length > 0) { throw new Error( @@ -65,4 +55,480 @@ export async function runLiveFocusedDeconstructExperiment(params) { }; } +// ── existing API (preserved — unchanged behaviour) ────────────────────── + +/** + * Run one live focused-deconstruction experiment. + * + * @param {object} params + * @param {string} params.targetNodeId - the target node ID under investigation + * @param {string} params.targetLabel - label of the target node + * @param {string} params.targetDescription - description of the target node + * @param {string} params.centralStatement - the case's central statement + * @param {string} params.question - the exact real production question + * @param {string} params.answer - the exact real production answer + * @param {object} [params.provider] - optional injected provider (for test isolation) + * @returns {object} validated result + timing details + */ +export async function runLiveFocusedDeconstructExperiment(params) { + const { targetNodeId, targetLabel, targetDescription, centralStatement, question, answer, provider } = params; + + const prompt = buildFocusedDeconstructPrompt({ + targetLabel, targetDescription, centralStatement, question, answer, + }); + + return _executeFocusedPrompt({ prompt, targetNodeId, provider }); +} + +// ── new supplied-prompt API ────────────────────────────────────────────── + +/** + * Execute an already-built focused-deconstruct prompt through the canonical + * live machinery. + * + * The caller supplies a pre-constructed prompt string; this function owns: + * - configured OLLAMA_MODEL reading + * - provider creation/invocation + * - elapsed timing + * - focused-deconstruct schema validation + * - structured result return + * + * @param {object} params + * @param {string} params.prompt - the already-built prompt to send + * @param {string} [params.targetNodeId] - original graph node ID (preserved in result) + * @param {object} [params.provider] - optional injected provider (for test isolation) + * @returns {object} validated result + timing details + */ +export async function runLiveFocusedPromptExperiment({ prompt, targetNodeId, provider }) { + return _executeFocusedPrompt({ prompt, targetNodeId, provider }); +} + +// ── minimal frontier result contract (EXP37) ──────────────────────────── + +/** + * Validate that a focused-deconstruction result contains exactly one + * uncertainty and one follow-up question — the minimal frontier output. + * Returns an array of errors (empty = valid). + */ +function validateMinimalFrontierResult(raw) { + const errors = []; + + if (!Array.isArray(raw.uncertainties)) { + errors.push("uncertainties must be an array"); + } else if (raw.uncertainties.length !== 1) { + errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); + } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { + errors.push("uncertainties[0] must be a non-empty string"); + } + + if (!Array.isArray(raw.possibleFollowUpQuestions)) { + errors.push("possibleFollowUpQuestions must be an array"); + } else if (raw.possibleFollowUpQuestions.length !== 1) { + errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); + } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { + errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); + } + + return errors; +} + +// ── new frontier API (EXP37 minimal runner) ────────────────────────────── + +/** + * Execute an already-built prompt and validate the returned payload + * against the minimal two-field frontier contract. + * + * Returns only: uncertainties, possibleFollowUpQuestions, elapsedMs, success. + * Does NOT require or reconstruct observations, assumptions, relationships, + * or targetNodeId. + * + * @param {object} params + * @param {string} params.prompt - the already-built prompt to send + * @param {object} [params.provider] - optional injected provider (for test isolation) + * @returns {object} validated result + timing details + */ +export async function runLiveFocusedFrontierPromptExperiment({ prompt, provider }) { + const actualProvider = provider ?? getProvider(); + + const ollamaModel = process.env.OLLAMA_MODEL; + if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); + + const startedAt = Date.now(); + const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); + const elapsedMs = Date.now() - startedAt; + + const validationErrors = validateMinimalFrontierResult(raw); + if (validationErrors.length > 0) { + throw new Error( + "Focused frontier result did not match expected minimal contract:\n" + + validationErrors.map((e) => " - " + e).join("\n") + ); + } + + return { + success: true, + uncertainties: raw.uncertainties, + possibleFollowUpQuestions: raw.possibleFollowUpQuestions, + elapsedMs, + }; +} + +// ── three-field observation-frontier result contract (EXP38) ──────────── + +/** + * Validate that a focused-deconstruction result contains observations + one + * uncertainty + one follow-up question — the minimal observation-grounded + * frontier output for EXP38. + * Returns an array of errors (empty = valid). + */ +function validateObservationFrontierResult(raw) { + const errors = []; + + // observations: required, non-empty array of non-empty strings + if (!Array.isArray(raw.observations)) { + errors.push("observations must be an array"); + } else if (raw.observations.length === 0) { + errors.push("observations must contain at least one element"); + } else { + for (let i = 0; i < raw.observations.length; i++) { + if (typeof raw.observations[i] !== "string" || raw.observations[i].trim() === "") { + errors.push(`observations[${i}] must be a non-empty string`); + } + } + } + + // uncertainties: exactly one + if (!Array.isArray(raw.uncertainties)) { + errors.push("uncertainties must be an array"); + } else if (raw.uncertainties.length !== 1) { + errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); + } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { + errors.push("uncertainties[0] must be a non-empty string"); + } + + // possibleFollowUpQuestions: exactly one + if (!Array.isArray(raw.possibleFollowUpQuestions)) { + errors.push("possibleFollowUpQuestions must be an array"); + } else if (raw.possibleFollowUpQuestions.length !== 1) { + errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); + } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { + errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); + } + + return errors; +} + +// ── observation-frontier runner (EXP38) ────────────────────────────────── + +/** + * Execute an already-built prompt and validate the returned payload + * against the three-field observation-frontier contract. + * + * Requires: observations (array of non-empty strings), uncertainties (exactly 1), + * possibleFollowUpQuestions (exactly 1). + * Does NOT require assumptions, relationships, or targetNodeId. + * + * @param {object} params + * @param {string} params.prompt - the already-built prompt to send + * @param {object} [params.provider] - optional injected provider (for test isolation) + * @returns {object} validated result + timing details + */ +export async function runLiveFocusedObservationFrontierPromptExperiment({ prompt, provider }) { + const actualProvider = provider ?? getProvider(); + + const ollamaModel = process.env.OLLAMA_MODEL; + if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); + + const startedAt = Date.now(); + const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); + const elapsedMs = Date.now() - startedAt; + + const validationErrors = validateObservationFrontierResult(raw); + if (validationErrors.length > 0) { + throw new Error( + "Focused observation-frontier result did not match expected three-field contract:\n" + + validationErrors.map((e) => " - " + e).join("\n") + ); + } + + return { + success: true, + observations: raw.observations, + uncertainties: raw.uncertainties, + possibleFollowUpQuestions: raw.possibleFollowUpQuestions, + elapsedMs, + }; +} + +// ── observation+assumption-frontier result contract (EXP46) ────────────── + +/** + * Validate that a focused-deconstruction result contains observations (at least one) + * + assumptions (zero or more) + one uncertainty + one follow-up question. + * Returns an array of errors (empty = valid). + */ +function validateObservationAssumptionFrontierResult(raw) { + const errors = []; + + // observations: required, at least one non-empty string + if (!Array.isArray(raw.observations)) { + errors.push("observations must be an array"); + } else if (raw.observations.length === 0) { + errors.push("observations must contain at least one element"); + } else { + for (let i = 0; i < raw.observations.length; i++) { + if (typeof raw.observations[i] !== "string" || raw.observations[i].trim() === "") { + errors.push(`observations[${i}] must be a non-empty string`); + } + } + } + + // assumptions: zero or more, each a non-empty string if present + if (!Array.isArray(raw.assumptions)) { + errors.push("assumptions must be an array"); + } else { + for (let i = 0; i < raw.assumptions.length; i++) { + if (typeof raw.assumptions[i] !== "string" || raw.assumptions[i].trim() === "") { + errors.push(`assumptions[${i}] must be a non-empty string`); + } + } + } + + // uncertainties: exactly one + if (!Array.isArray(raw.uncertainties)) { + errors.push("uncertainties must be an array"); + } else if (raw.uncertainties.length !== 1) { + errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); + } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { + errors.push("uncertainties[0] must be a non-empty string"); + } + + // possibleFollowUpQuestions: exactly one + if (!Array.isArray(raw.possibleFollowUpQuestions)) { + errors.push("possibleFollowUpQuestions must be an array"); + } else if (raw.possibleFollowUpQuestions.length !== 1) { + errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); + } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { + errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); + } + + return errors; +} + +// ── observation+assumption-frontier runner (EXP46) ────────────────────── + +/** + * Execute an already-built prompt and validate the returned payload + * against the observation+assumption-frontier contract. + * + * Requires: observations (array of non-empty strings with at least one), + * assumptions (zero or more non-empty strings, [] is valid), + * uncertainties (exactly 1), possibleFollowUpQuestions (exactly 1). + * Does NOT require relationships, targetNodeId, or any other fields. + * + * @param {object} params + * @param {string} params.prompt - the already-built prompt to send + * @param {object} [params.provider] - optional injected provider (for test isolation) + * @returns {object} validated result + timing details + */ +export async function runLiveFocusedObservationAssumptionFrontierPromptExperiment({ prompt, provider }) { + const actualProvider = provider ?? getProvider(); + + const ollamaModel = process.env.OLLAMA_MODEL; + if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); + + const startedAt = Date.now(); + const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); + const elapsedMs = Date.now() - startedAt; + + const validationErrors = validateObservationAssumptionFrontierResult(raw); + if (validationErrors.length > 0) { + throw new Error( + "Focused observation+assumption-frontier result did not match expected contract:\n" + + validationErrors.map((e) => " - " + e).join("\n") + ); + } + + return { + success: true, + observations: raw.observations, + assumptions: raw.assumptions, + uncertainties: raw.uncertainties, + possibleFollowUpQuestions: raw.possibleFollowUpQuestions, + elapsedMs, + }; +} + +// ── re-exports for convenience ────────────────────────────────────────── + export { buildFocusedDeconstructPrompt, validateFocusedDeconstructSchema }; + +// ── relationship-frontier result contract (EXP44) ────────────────────── + +/** + * Validate that a focused-deconstruction result contains relationships + one + * uncertainty + one follow-up question — the relationship-frontier output. + * Returns an array of errors (empty = valid). + */ +function validateRelationshipFrontierResult(raw) { + const errors = []; + + // relationships: required, at least one non-empty string + if (!Array.isArray(raw.relationships)) { + errors.push("relationships must be an array"); + } else if (raw.relationships.length === 0) { + errors.push("relationships must contain at least one element"); + } else { + for (let i = 0; i < raw.relationships.length; i++) { + if (typeof raw.relationships[i] !== "string" || raw.relationships[i].trim() === "") { + errors.push(`relationships[${i}] must be a non-empty string`); + } + } + } + + // uncertainties: exactly one + if (!Array.isArray(raw.uncertainties)) { + errors.push("uncertainties must be an array"); + } else if (raw.uncertainties.length !== 1) { + errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); + } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { + errors.push("uncertainties[0] must be a non-empty string"); + } + + // possibleFollowUpQuestions: exactly one + if (!Array.isArray(raw.possibleFollowUpQuestions)) { + errors.push("possibleFollowUpQuestions must be an array"); + } else if (raw.possibleFollowUpQuestions.length !== 1) { + errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); + } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { + errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); + } + + return errors; +} + +// ── relationship-frontier runner (EXP44) ─────────────────────────────── + +/** + * Execute an already-built prompt and validate the returned payload + * against the relationship-frontier contract. + * + * Requires: relationships (array of non-empty strings with at least one), + * uncertainties (exactly 1), possibleFollowUpQuestions (exactly 1). + * Does NOT require observations, assumptions, or targetNodeId. + * + * @param {object} params + * @param {string} params.prompt - the already-built prompt to send + * @param {object} [params.provider] - optional injected provider (for test isolation) + * @returns {object} validated result + timing details + */ +export async function runLiveFocusedRelationshipFrontierPromptExperiment({ prompt, provider }) { + const actualProvider = provider ?? getProvider(); + + const ollamaModel = process.env.OLLAMA_MODEL; + if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); + + const startedAt = Date.now(); + const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); + const elapsedMs = Date.now() - startedAt; + + const validationErrors = validateRelationshipFrontierResult(raw); + if (validationErrors.length > 0) { + throw new Error( + "Focused relationship-frontier result did not match expected contract:\n" + + validationErrors.map((e) => " - " + e).join("\n") + ); + } + + return { + success: true, + relationships: raw.relationships, + uncertainties: raw.uncertainties, + possibleFollowUpQuestions: raw.possibleFollowUpQuestions, + elapsedMs, + }; +} + +// ── assumption-frontier result contract (EXP45) ──────────────────────── + +/** + * Validate that a focused-deconstruction result contains assumptions (zero or more) + * + one uncertainty + one follow-up question — the assumption-frontier output. + * Returns an array of errors (empty = valid). + */ +function validateAssumptionFrontierResult(raw) { + const errors = []; + + // assumptions: zero or more, each a non-empty string if present + if (!Array.isArray(raw.assumptions)) { + errors.push("assumptions must be an array"); + } else { + for (let i = 0; i < raw.assumptions.length; i++) { + if (typeof raw.assumptions[i] !== "string" || raw.assumptions[i].trim() === "") { + errors.push(`assumptions[${i}] must be a non-empty string`); + } + } + } + + // uncertainties: exactly one + if (!Array.isArray(raw.uncertainties)) { + errors.push("uncertainties must be an array"); + } else if (raw.uncertainties.length !== 1) { + errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); + } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { + errors.push("uncertainties[0] must be a non-empty string"); + } + + // possibleFollowUpQuestions: exactly one + if (!Array.isArray(raw.possibleFollowUpQuestions)) { + errors.push("possibleFollowUpQuestions must be an array"); + } else if (raw.possibleFollowUpQuestions.length !== 1) { + errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); + } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { + errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); + } + + return errors; +} + +// ── assumption-frontier runner (EXP45) ──────────────────────────────── + +/** + * Execute an already-built prompt and validate the returned payload + * against the assumption-frontier contract. + * + * Requires: assumptions (zero or more non-empty strings, [] is valid), + * uncertainties (exactly 1), possibleFollowUpQuestions (exactly 1). + * Does NOT require observations, relationships, or targetNodeId. + * + * @param {object} params + * @param {string} params.prompt - the already-built prompt to send + * @param {object} [params.provider] - optional injected provider (for test isolation) + * @returns {object} validated result + timing details + */ +export async function runLiveFocusedAssumptionFrontierPromptExperiment({ prompt, provider }) { + const actualProvider = provider ?? getProvider(); + + const ollamaModel = process.env.OLLAMA_MODEL; + if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); + + const startedAt = Date.now(); + const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); + const elapsedMs = Date.now() - startedAt; + + const validationErrors = validateAssumptionFrontierResult(raw); + if (validationErrors.length > 0) { + throw new Error( + "Focused assumption-frontier result did not match expected contract:\n" + + validationErrors.map((e) => " - " + e).join("\n") + ); + } + + return { + success: true, + assumptions: raw.assumptions, + uncertainties: raw.uncertainties, + possibleFollowUpQuestions: raw.possibleFollowUpQuestions, + elapsedMs, + }; +} diff --git a/tests/graph/live-focused-deconstruct-experiment-helper.test.mjs b/tests/graph/live-focused-deconstruct-experiment-helper.test.mjs new file mode 100644 index 0000000..2ae1e8d --- /dev/null +++ b/tests/graph/live-focused-deconstruct-experiment-helper.test.mjs @@ -0,0 +1,1236 @@ +/** + * Deterministic verification of the focused-deconstruct experiment helper. + * + * Zero live calls. Uses injected fake provider for complete isolation. + */ + +import { describe, it, expect } from "vitest"; +import { buildFocusedDeconstructPrompt, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation.js"; +import { runLiveFocusedDeconstructExperiment, runLiveFocusedPromptExperiment, runLiveFocusedFrontierPromptExperiment, runLiveFocusedObservationFrontierPromptExperiment, runLiveFocusedRelationshipFrontierPromptExperiment, runLiveFocusedAssumptionFrontierPromptExperiment, runLiveFocusedObservationAssumptionFrontierPromptExperiment } from "./live-focused-deconstruct-experiment-helper.mjs"; + +// Minimum env for the helper's OLLAMA_MODEL guard (never contacts a real model) +process.env.OLLAMA_MODEL = "test-model"; + +// ── env-loading verification ─────────────────────────────────────────── + +describe("environment loading", () => { + it("helper loads .env.local before any function executes", async () => { + // The helper already ran dotenv.config({ path: ".env.local" }) at import time. + // If OLLAMA_MODEL was configured in .env.local, the module-level guard would see it. + // If not, the test below explicitly sets it — so this test validates the chain works. + // We verify by confirming no unhandled "not set in environment" error propagates + // when we invoke through the helper with a safe injected provider. + const fakeResult = makeValidResult({ targetNodeId: "env-verify-node" }); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedDeconstructExperiment({ + ...params, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.targetNodeId).toBe(params.targetNodeId); + }); +}); + +// ── fixture ──────────────────────────────────────────────────────────── + +const params = { + targetNodeId: "test-node-01", + targetLabel: "Team capability constraints on task handoff boundaries", + targetDescription: + "The extent to which current skill levels and bandwidth dictate the limit of delegable tasks.", + centralStatement: + "A business owner seeks to delegate routine operational tasks but lacks clarity on scope.", + question: "What was the comparable state before capability constraints?", + answer: "She handles weekly supplier payments herself without checking for six months.", +}; + +const suppliedPrompt = "EXACT_PROMPT_CONTENT_BYTE_IDENTICAL"; + +// ── fake provider builder ────────────────────────────────────────────── + +function makeFakeProvider(result) { + const calls = []; + return { + generateReconstruction: async (prompt, model) => { + calls.push({ prompt, model }); + if (result instanceof Error) throw result; + return result; + }, + getCalls: () => calls, + }; +} + +// ── valid deconstruct result fixture ─────────────────────────────────── + +function makeValidResult(extraFields = {}) { + return { + targetNodeId: "injected-node", + observations: ["obs-1"], + uncertainties: ["unc-1"], + assumptions: [], + relationships: [], + possibleFollowUpQuestions: ["q-1"], + ...extraFields, + }; +} + +// ── existing API ──────────────────────────────────────────────────────── + +describe("runLiveFocusedDeconstructExperiment", () => { + it("produces a validated structured result through an injected fake provider", async () => { + const fakeResult = makeValidResult({ targetNodeId: "injected-node" }); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedDeconstructExperiment({ + ...params, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.targetNodeId).toBe(params.targetNodeId); + expect(result.observations).toEqual(fakeResult.observations); + expect(result.uncertainties).toEqual(fakeResult.uncertainties); + expect(result.assumptions).toEqual(fakeResult.assumptions); + expect(result.relationships).toEqual(fakeResult.relationships); + expect(result.possibleFollowUpQuestions).toEqual(fakeResult.possibleFollowUpQuestions); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("passes invalid result to schema validation and fails", async () => { + const fakeProvider = makeFakeProvider({ + targetNodeId: "injected-node", + // missing required fields — will fail validation + }); + + await expect( + runLiveFocusedDeconstructExperiment({ + ...params, + provider: fakeProvider, + }) + ).rejects.toThrow("Focused deconstruction result did not match expected schema"); + }); + + it("propagates provider exception without retry", async () => { + const err = new Error("network failure"); + const calls = []; + const fakeProvider = { + generateReconstruction: async (prompt, model) => { + calls.push(1); + throw err; + }, + }; + + await expect( + runLiveFocusedDeconstructExperiment({ + ...params, + provider: fakeProvider, + }) + ).rejects.toBe(err); + }); +}); + +// ── new supplied-prompt API ──────────────────────────────────────────── + +describe("runLiveFocusedPromptExperiment", () => { + it("passes an arbitrary supplied prompt byte-for-byte to the injected provider", async () => { + const fakeResult = makeValidResult({ targetNodeId: "supplied-node" }); + const fakeProvider = makeFakeProvider(fakeResult); + + await runLiveFocusedPromptExperiment({ + prompt: suppliedPrompt, + targetNodeId: "supplied-node", + provider: fakeProvider, + }); + + const calls = fakeProvider.getCalls(); + expect(calls.length).toBe(1); + expect(calls[0].prompt).toBe(suppliedPrompt); + }); + + it("returns the same structured result shape as existing API", async () => { + const fakeResult = makeValidResult({ targetNodeId: "supplied-node" }); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedPromptExperiment({ + prompt: suppliedPrompt, + targetNodeId: "supplied-node", + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.targetNodeId).toBe("supplied-node"); + expect(result.observations).toEqual(fakeResult.observations); + expect(result.uncertainties).toEqual(fakeResult.uncertainties); + expect(result.assumptions).toEqual(fakeResult.assumptions); + expect(result.relationships).toEqual(fakeResult.relationships); + expect(result.possibleFollowUpQuestions).toEqual(fakeResult.possibleFollowUpQuestions); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("passes invalid result to schema validation and fails", async () => { + const fakeProvider = makeFakeProvider({ + targetNodeId: "supplied-node", + // missing required fields — will fail validation + }); + + await expect( + runLiveFocusedPromptExperiment({ + prompt: suppliedPrompt, + targetNodeId: "supplied-node", + provider: fakeProvider, + }) + ).rejects.toThrow("Focused deconstruction result did not match expected schema"); + }); + + it("propagates provider exception without retry", async () => { + const err = new Error("network failure"); + const calls = []; + const fakeProvider = { + generateReconstruction: async (prompt, model) => { + calls.push(1); + throw err; + }, + }; + + await expect( + runLiveFocusedPromptExperiment({ + prompt: suppliedPrompt, + targetNodeId: "supplied-node", + provider: fakeProvider, + }) + ).rejects.toBe(err); + }); +}); + +// ── frontier runner (EXP37 minimal two-field contract) ───────────────── + +const FRONTIER_PROMPT = "EXACT_FRONTIER_TEST_PROMPT_BYTE_IDENTICAL"; + +function makeMinimalFrontierResult() { + return { + uncertainties: ["What is still unknown?"], + possibleFollowUpQuestions: ["What happened in the failed cases?"], + }; +} + +describe("runLiveFocusedFrontierPromptExperiment", () => { + it("passes prompt byte-for-byte to the injected provider", async () => { + const fakeResult = makeMinimalFrontierResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + await runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }); + + const calls = fakeProvider.getCalls(); + expect(calls.length).toBe(1); + expect(calls[0].prompt).toBe(FRONTIER_PROMPT); + }); + + it("accepts valid minimal result", async () => { + const fakeResult = makeMinimalFrontierResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.uncertainties).toEqual(["What is still unknown?"]); + expect(result.possibleFollowUpQuestions).toEqual(["What happened in the failed cases?"]); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("rejects empty uncertainties array", async () => { + const fakeProvider = makeFakeProvider({ + uncertainties: [], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects uncertainties array with two elements", async () => { + const fakeProvider = makeFakeProvider({ + uncertainties: ["one", "two"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects missing uncertainties", async () => { + const fakeProvider = makeFakeProvider({ + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must be an array"); + }); + + it("rejects empty possibleFollowUpQuestions array", async () => { + const fakeProvider = makeFakeProvider({ + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: [], + }); + + await expect( + runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + it("rejects possibleFollowUpQuestions array with two elements", async () => { + const fakeProvider = makeFakeProvider({ + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["q1", "q2"], + }); + + await expect( + runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + it("rejects missing possibleFollowUpQuestions", async () => { + const fakeProvider = makeFakeProvider({ + uncertainties: ["one uncertainty"], + }); + + await expect( + runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must be an array"); + }); + + it("propagates provider exception without retry", async () => { + const err = new Error("provider failure"); + const calls = []; + const fakeProvider = { + generateReconstruction: async (prompt, model) => { + calls.push(1); + throw err; + }, + }; + + await expect( + runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }) + ).rejects.toBe(err); + }); + + it("does not require full-deconstruction fields", async () => { + const fakeResult = makeMinimalFrontierResult(); + // No targetNodeId, observations, assumptions, or relationships present + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect("targetNodeId" in result).toBe(false); + expect("observations" in result).toBe(false); + expect("assumptions" in result).toBe(false); + expect("relationships" in result).toBe(false); + }); +}); + +// ── observation-frontier runner (EXP38 three-field contract) ───────────── + +const OBSERVATION_FRONTIER_TEST_PROMPT = "OBSERVATION FRONTIER TEST PROMPT"; + +function makeObservationFrontierResult() { + return { + observations: [ + "She handles weekly supplier payments herself.", + "Failed or unusual payments come back to the owner.", + ], + uncertainties: ["What is different about the failed or unusual payments?"], + possibleFollowUpQuestions: [ + "What is different about the failed or unusual payments that means they come back to you?", + ], + }; +} + +describe("runLiveFocusedObservationFrontierPromptExperiment", () => { + it("passes prompt byte-for-byte to the injected provider", async () => { + const fakeResult = makeObservationFrontierResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + await runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + const calls = fakeProvider.getCalls(); + expect(calls.length).toBe(1); + expect(calls[0].prompt).toBe(OBSERVATION_FRONTIER_TEST_PROMPT); + }); + + it("accepts valid three-field result", async () => { + const fakeResult = makeObservationFrontierResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.observations).toEqual(fakeResult.observations); + expect(result.uncertainties).toEqual(fakeResult.uncertainties); + expect(result.possibleFollowUpQuestions).toEqual(fakeResult.possibleFollowUpQuestions); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("rejects empty observations array", async () => { + const fakeProvider = makeFakeProvider({ + observations: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("observations must contain at least one element"); + }); + + it("rejects non-string observation element", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["valid", "also valid", 123], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("observations[2] must be a non-empty string"); + }); + + it("rejects zero uncertainties", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["obs-1"], + uncertainties: [], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects two uncertainties", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["obs-1"], + uncertainties: ["one", "two"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects zero possibleFollowUpQuestions", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["obs-1"], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: [], + }); + + await expect( + runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + it("rejects two possibleFollowUpQuestions", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["obs-1"], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["q1", "q2"], + }); + + await expect( + runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + it("does not require assumptions, relationships, or targetNodeId", async () => { + const result = makeObservationFrontierResult(); + // Ensure no extra fields + delete result.assumptions; + delete result.relationships; + delete result.targetNodeId; + + const fakeProvider = makeFakeProvider(result); + + const run = await runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(run.success).toBe(true); + }); + + it("propagates provider exception without retry", async () => { + const err = new Error("provider failure"); + const calls = []; + const fakeProvider = { + generateReconstruction: async (prompt, model) => { + calls.push(1); + throw err; + }, + }; + + await expect( + runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toBe(err); + }); +}); + +// ── relationship-frontier runner (EXP44 three-field contract with relationships) ───────────── + +const RELATIONSHIP_FRONTIER_TEST_PROMPT = "RELATIONSHIP FRONTIER TEST PROMPT"; + +function makeRelationshipFrontierResult() { + return { + relationships: [ + "She handles weekly supplier payments herself.", + "Failed or unusual payments come back to the owner.", + ], + uncertainties: ["What is different about the failed or unusual payments?"], + possibleFollowUpQuestions: [ + "What is different about the failed or unusual payments that means they come back to you?", + ], + }; +} + +describe("runLiveFocusedRelationshipFrontierPromptExperiment", () => { + it("passes prompt byte-for-byte to the injected provider", async () => { + const fakeResult = makeRelationshipFrontierResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + await runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + const calls = fakeProvider.getCalls(); + expect(calls.length).toBe(1); + expect(calls[0].prompt).toBe(RELATIONSHIP_FRONTIER_TEST_PROMPT); + }); + + it("accepts valid relationship-frontier result", async () => { + const fakeResult = makeRelationshipFrontierResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.relationships).toEqual(fakeResult.relationships); + expect(result.uncertainties).toEqual(fakeResult.uncertainties); + expect(result.possibleFollowUpQuestions).toEqual(fakeResult.possibleFollowUpQuestions); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("rejects empty relationships array", async () => { + const fakeProvider = makeFakeProvider({ + relationships: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("relationships must contain at least one element"); + }); + + it("rejects non-string relationship element", async () => { + const fakeProvider = makeFakeProvider({ + relationships: ["valid", 123], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("relationships[1] must be a non-empty string"); + }); + + it("rejects zero uncertainties", async () => { + const fakeProvider = makeFakeProvider({ + relationships: ["rel-1"], + uncertainties: [], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects two uncertainties", async () => { + const fakeProvider = makeFakeProvider({ + relationships: ["rel-1"], + uncertainties: ["one", "two"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects zero possibleFollowUpQuestions", async () => { + const fakeProvider = makeFakeProvider({ + relationships: ["rel-1"], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: [], + }); + + await expect( + runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + it("rejects two possibleFollowUpQuestions", async () => { + const fakeProvider = makeFakeProvider({ + relationships: ["rel-1"], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["q1", "q2"], + }); + + await expect( + runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + it("does not require observations, assumptions, or targetNodeId", async () => { + const result = makeRelationshipFrontierResult(); + // Ensure no extra fields + delete result.observations; + delete result.assumptions; + delete result.targetNodeId; + + const fakeProvider = makeFakeProvider(result); + + const run = await runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(run.success).toBe(true); + }); + + it("propagates provider exception without retry", async () => { + const err = new Error("provider failure"); + const calls = []; + const fakeProvider = { + generateReconstruction: async (prompt, model) => { + calls.push(1); + throw err; + }, + }; + + await expect( + runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toBe(err); + }); +}); + +// ── assumption-frontier runner (EXP45) ───────────────────────────────── + +const ASSUMPTION_FRONTIER_TEST_PROMPT = "ASSUMPTION FRONTIER TEST PROMPT"; + +function makeAssumptionFrontierResultWithNoAssumptions() { + return { + assumptions: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }; +} + +function makeAssumptionFrontierResultWithOneAssumption() { + return { + assumptions: ["one genuinely attributable assumption"], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }; +} + +describe("runLiveFocusedAssumptionFrontierPromptExperiment", () => { + it("passes prompt byte-for-byte to the injected provider", async () => { + const fakeResult = makeAssumptionFrontierResultWithOneAssumption(); + const fakeProvider = makeFakeProvider(fakeResult); + + await runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + const calls = fakeProvider.getCalls(); + expect(calls.length).toBe(1); + expect(calls[0].prompt).toBe(ASSUMPTION_FRONTIER_TEST_PROMPT); + }); + + it("accepts valid result with no assumptions", async () => { + const fakeResult = makeAssumptionFrontierResultWithNoAssumptions(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.assumptions).toEqual([]); + expect(result.uncertainties).toEqual(["one uncertainty"]); + expect(result.possibleFollowUpQuestions).toEqual(["one question"]); + }); + + it("accepts valid result with one assumption", async () => { + const fakeResult = makeAssumptionFrontierResultWithOneAssumption(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.assumptions).toEqual(["one genuinely attributable assumption"]); + expect(result.uncertainties).toEqual(["one uncertainty"]); + expect(result.possibleFollowUpQuestions).toEqual(["one question"]); + }); + + it("rejects empty string assumption element", async () => { + const fakeProvider = makeFakeProvider({ + assumptions: ["", "valid"], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("assumptions[0] must be a non-empty string"); + }); + + it("rejects non-string assumption element", async () => { + const fakeProvider = makeFakeProvider({ + assumptions: [123], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("assumptions[0] must be a non-empty string"); + }); + + it("rejects zero uncertainties", async () => { + const fakeProvider = makeFakeProvider({ + assumptions: [], + uncertainties: [], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects multiple uncertainties", async () => { + const fakeProvider = makeFakeProvider({ + assumptions: [], + uncertainties: ["one", "two"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects zero possibleFollowUpQuestions", async () => { + const fakeProvider = makeFakeProvider({ + assumptions: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: [], + }); + + await expect( + runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + it("rejects multiple possibleFollowUpQuestions", async () => { + const fakeProvider = makeFakeProvider({ + assumptions: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["q1", "q2"], + }); + + await expect( + runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + it("does not require observations, relationships, or targetNodeId", async () => { + const result = makeAssumptionFrontierResultWithNoAssumptions(); + delete result.observations; + delete result.relationships; + delete result.targetNodeId; + + const fakeProvider = makeFakeProvider(result); + + const run = await runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(run.success).toBe(true); + }); + + it("propagates provider exception without retry", async () => { + const err = new Error("provider failure"); + const calls = []; + const fakeProvider = { + generateReconstruction: async (prompt, model) => { + calls.push(1); + throw err; + }, + }; + + await expect( + runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toBe(err); + }); +}); + +// ── observation+assumption-frontier runner (EXP46) ───────────────────── + +const OBS_ASSUMP_FRONTIER_TEST_PROMPT = "OBSERVATION ASSUMPTION FRONTIER TEST PROMPT"; + +function makeObservationAssumptionFrontierResultWithEmptyAssumptions() { + return { + observations: ["one directly supported observation"], + assumptions: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }; +} + +function makeObservationAssumptionFrontierResultWithOneAssumption() { + return { + observations: ["one directly supported observation"], + assumptions: ["one assumption"], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }; +} + +describe("runLiveFocusedObservationAssumptionFrontierPromptExperiment", () => { + it("passes prompt byte-for-byte to the injected provider", async () => { + const fakeResult = makeObservationAssumptionFrontierResultWithEmptyAssumptions(); + const fakeProvider = makeFakeProvider(fakeResult); + + await runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + const calls = fakeProvider.getCalls(); + expect(calls.length).toBe(1); + expect(calls[0].prompt).toBe(OBS_ASSUMP_FRONTIER_TEST_PROMPT); + }); + + // ── Valid with empty assumptions ── + + it("accepts valid result with empty assumptions", async () => { + const fakeResult = makeObservationAssumptionFrontierResultWithEmptyAssumptions(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.observations).toEqual(["one directly supported observation"]); + expect(result.assumptions).toEqual([]); + expect(result.uncertainties).toEqual(["one uncertainty"]); + expect(result.possibleFollowUpQuestions).toEqual(["one question"]); + }); + + // ── Valid with one assumption ── + + it("accepts valid result with one assumption", async () => { + const fakeResult = makeObservationAssumptionFrontierResultWithOneAssumption(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + expect(result.observations).toEqual(["one directly supported observation"]); + expect(result.assumptions).toEqual(["one assumption"]); + expect(result.uncertainties).toEqual(["one uncertainty"]); + expect(result.possibleFollowUpQuestions).toEqual(["one question"]); + }); + + // ── Reject invalid observations ── + + it("rejects empty observations array", async () => { + const fakeProvider = makeFakeProvider({ + observations: [], + assumptions: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("observations must contain at least one element"); + }); + + it("rejects empty-string observation item", async () => { + const fakeProvider = makeFakeProvider({ + observations: [""], + assumptions: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("observations[0] must be a non-empty string"); + }); + + it("rejects non-string observation item", async () => { + const fakeProvider = makeFakeProvider({ + observations: [123], + assumptions: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("observations[0] must be a non-empty string"); + }); + + // ── Reject invalid assumptions ── + + it("rejects empty-string assumption item", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["one observation"], + assumptions: [""], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("assumptions[0] must be a non-empty string"); + }); + + it("rejects non-string assumption item", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["one observation"], + assumptions: [null], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("assumptions[0] must be a non-empty string"); + }); + + it("accepts empty assumptions array as valid", async () => { + const fakeResult = makeObservationAssumptionFrontierResultWithEmptyAssumptions(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + }); + + // ── Reject invalid frontier cardinality ── + + it("rejects zero uncertainties", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["obs-1"], + assumptions: [], + uncertainties: [], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects multiple uncertainties", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["obs-1"], + assumptions: [], + uncertainties: ["one", "two"], + possibleFollowUpQuestions: ["one question"], + }); + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("uncertainties must contain exactly one element"); + }); + + it("rejects zero possibleFollowUpQuestions", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["obs-1"], + assumptions: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: [], + }); + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + it("rejects multiple possibleFollowUpQuestions", async () => { + const fakeProvider = makeFakeProvider({ + observations: ["obs-1"], + assumptions: [], + uncertainties: ["one uncertainty"], + possibleFollowUpQuestions: ["q1", "q2"], + }); + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toThrow("possibleFollowUpQuestions must contain exactly one element"); + }); + + // ── Other fields not required ── + + it("does not require relationships or targetNodeId", async () => { + const result = makeObservationAssumptionFrontierResultWithEmptyAssumptions(); + delete result.targetNodeId; + + const fakeProvider = makeFakeProvider(result); + + const run = await runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(run.success).toBe(true); + }); + + // ── Existing APIs remain green ── + + it("existing API runLiveFocusedDeconstructExperiment remains callable", async () => { + const fakeResult = makeValidResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedDeconstructExperiment({ + ...params, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + }); + + it("existing API runLiveFocusedFrontierPromptExperiment remains callable", async () => { + const fakeResult = makeMinimalFrontierResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedFrontierPromptExperiment({ + prompt: FRONTIER_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + }); + + it("existing API runLiveFocusedObservationFrontierPromptExperiment remains callable", async () => { + const fakeResult = makeObservationFrontierResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedObservationFrontierPromptExperiment({ + prompt: OBSERVATION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + }); + + it("existing API runLiveFocusedRelationshipFrontierPromptExperiment remains callable", async () => { + const fakeResult = makeRelationshipFrontierResult(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedRelationshipFrontierPromptExperiment({ + prompt: RELATIONSHIP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + }); + + it("existing API runLiveFocusedAssumptionFrontierPromptExperiment remains callable", async () => { + const fakeResult = makeAssumptionFrontierResultWithOneAssumption(); + const fakeProvider = makeFakeProvider(fakeResult); + + const result = await runLiveFocusedAssumptionFrontierPromptExperiment({ + prompt: ASSUMPTION_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }); + + expect(result.success).toBe(true); + }); + + // ── Provider error propagates directly ── + + it("propagates provider exception without retry", async () => { + const err = new Error("provider failure"); + const calls = []; + const fakeProvider = { + generateReconstruction: async (prompt, model) => { + calls.push(1); + throw err; + }, + }; + + await expect( + runLiveFocusedObservationAssumptionFrontierPromptExperiment({ + prompt: OBS_ASSUMP_FRONTIER_TEST_PROMPT, + provider: fakeProvider, + }) + ).rejects.toBe(err); + }); +}); \ No newline at end of file