fix(graph): preserve grammar for question-like unknown labels

This commit is contained in:
2026-08-12 15:02:56 +01:00
parent b1914f5da7
commit fa42a2643a
2 changed files with 387 additions and 10 deletions
+106 -10
View File
@@ -115,12 +115,72 @@ function sanitizeQuestionText(question) {
.trim();
}
/**
* Detect whether a meaning string is already an interrogative (wh-question,
* yes/no question, or modal auxiliary inversion). This covers the class of
* labels that were previously interpolated raw into template frames, producing
* output like "What would clarify are the projected office savings from
* relocation realistic in this situation?".
*/
function isInterrogativeMeaning(meaning) {
const trimmed = String(meaning || "").trim().toLowerCase();
if (!trimmed) return false;
// Wh-questions (direct or indirect): who/what/where/when/how ...
if (/^(who|what|where|when|how)\b/.test(trimmed)) {
return true;
}
// Yes/no questions via subject-auxiliary inversion: the first word is a
// modal/auxiliary verb followed by a subject determiner (the / a / an / this / etc.)
// This covers "Is the …", "Are the …", "Will we …", "Does it …", etc.
const auxVerbs =
"is|are|was|were|do|does|did|have|has|had|will|would|can|could|should|may|might|must";
if (
new RegExp(
`^(?:${auxVerbs})\\s+(?:the|a|an|this|that|these|those|my|your|our|their|some|any|each|every|both|i|you|we|he|she|it|they)`,
"i",
).test(trimmed)
) {
return true;
}
// "whether" clauses — also already question-shaped
if (/^whether\b/i.test(trimmed)) return true;
return false;
}
function wrapInterrogativeForTemplate(meaning) {
const stripped = stripTrailingPunctuation(meaning).trim();
// Direct interrogatives → return unchanged (already coherent standalone questions)
if (/^(who|what|where|when|how)\b/.test(stripped.toLowerCase())) {
return stripped;
}
if (isInterrogativeMeaning(meaning)) {
return stripped;
}
return stripped;
}
function buildNeutralClarificationQuestion(meaning) {
return `What would clarify ${stripTrailingPunctuation(meaning)} in this situation?`;
const content = wrapInterrogativeForTemplate(meaning);
// If content is already interrogative (wh-), use it as-is with trailing context
if (isInterrogativeMeaning(content)) {
return `${content}?`;
}
return `What would clarify ${content} in this situation?`;
}
function buildEvidenceFallbackQuestion(meaning) {
return `What evidence would confirm or rule out ${stripTrailingPunctuation(meaning)}?`;
const content = wrapInterrogativeForTemplate(meaning);
if (isInterrogativeMeaning(content)) {
return `${content}?`;
}
return `What evidence would confirm or rule out ${content}?`;
}
function extractConstraintClarificationSubject(node) {
@@ -1172,27 +1232,52 @@ function buildQuestionFromFamily({
},
);
}
// If meaning is already interrogative, use it directly instead of wrapping
if (isInterrogativeMeaning(meaning)) {
return `${wrapInterrogativeForTemplate(meaning)}?`;
}
return `What evidence would clarify ${stripTrailingPunctuation(meaning)}?`;
}
if (questionFamily === "definition") {
// If meaning is already interrogative, use it directly
if (isInterrogativeMeaning(meaning)) {
return `${wrapInterrogativeForTemplate(meaning)}?`;
}
return `What does ${meaning} mean in this situation?`;
}
if (reasoningPattern === "comparison") {
if (selectedQuestionTemplate === "comparison_timing_basis") {
return `What evidence would clarify whether ${stripTrailingPunctuation(meaning)}?`;
const cmpContent = wrapInterrogativeForTemplate(meaning);
if (isInterrogativeMeaning(cmpContent)) {
return `${cmpContent}?`;
}
return `What evidence would clarify whether ${stripTrailingPunctuation(cmpContent)}?`;
}
if (selectedQuestionTemplate === "comparison_measurement_basis") {
return `What evidence would clarify ${stripTrailingPunctuation(meaning)}?`;
const cmpContent = wrapInterrogativeForTemplate(meaning);
if (isInterrogativeMeaning(cmpContent)) {
return `${cmpContent}?`;
}
return `What evidence would clarify ${stripTrailingPunctuation(cmpContent)}?`;
}
// Default comparison — handle interrogative meaning
if (isInterrogativeMeaning(meaning)) {
return `${wrapInterrogativeForTemplate(meaning)}?`;
}
return `What evidence would clarify ${stripTrailingPunctuation(meaning)}?`;
}
if (reasoningPattern === "contradiction") {
return investigationStrategy?.key === "contradiction_resolution"
? buildQuestionFromStrategy(investigationStrategy)
: `What fact would resolve the contradiction about ${stripTrailingPunctuation(meaning)}?`;
if (investigationStrategy?.key === "contradiction_resolution") {
return buildQuestionFromStrategy(investigationStrategy);
}
// If meaning is interrogative, use it directly
if (isInterrogativeMeaning(meaning)) {
return `${wrapInterrogativeForTemplate(meaning)}?`;
}
return `What fact would resolve the contradiction about ${stripTrailingPunctuation(meaning)}?`;
}
if (reasoningPattern === "explanation") {
@@ -1203,9 +1288,14 @@ function buildQuestionFromFamily({
return `Which option should be investigated first, and why?`;
}
return investigationStrategy
? buildQuestionFromStrategy(investigationStrategy)
: buildNeutralClarificationQuestion(meaning);
// Default (diagnosis) — handle interrogative meaning
if (investigationStrategy) {
return buildQuestionFromStrategy(investigationStrategy);
}
if (isInterrogativeMeaning(meaning)) {
return `${wrapInterrogativeForTemplate(meaning)}?`;
}
return buildNeutralClarificationQuestion(meaning);
}
export function formulateTieResolutionQuestion({ graph }) {
@@ -1590,6 +1680,12 @@ export function selectInvestigationStrategy({ node, graph, context = {} }) {
}
function buildQuestionFromStrategy(strategy) {
// If meaning is interrogative, use it directly instead of embedding in a template
const m = strategy.meaning;
if (isInterrogativeMeaning(m)) {
return `${wrapInterrogativeForTemplate(m)}?`;
}
switch (strategy.key) {
case "decision_threshold":
return strategy.actionPhrase
@@ -0,0 +1,281 @@
import { describe, expect, it } from "vitest";
import { formulateQuestion } from "@/lib/graph/question-formulator.js";
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
function makeGraphFor(node, extra = {}) {
return makeGraph({
centralStatement: extra.centralStatement || "Decision context",
nodes: [node, ...(extra.nodes || [])],
edges: extra.edges || [],
activeUnknownNodeId: node.id,
resolvedNodeIds: extra.resolvedNodeIds || [],
currentSummary: "Test summary",
});
}
/**
* Experiment 58A.4 — question formulation for pre-question-shaped labels
*
* Defect: when an unknown's label is already question or clause shaped,
* the engine interpolates it raw into a generic template frame, producing
* ungrammatical output such as "What would clarify are the projected office
* savings from relocation realistic in this situation?".
*
* The fix must generalise structurally to ANY interrogative/clause label,
* not just the specific patterns seen so far.
*/
describe("question formulation — pre-question labels (58A.4)", () => {
// ─── Helper: run formulateQuestion on an unknown and extract the question text ───
function questionFor(label, extra = {}) {
const node = makeNode({
id: "n-test",
label,
kind: "unknown",
status: "unknown",
confidence: "medium",
...extra,
});
const result = formulateQuestion({
node,
graph: makeGraphFor(node),
});
return result.question;
}
// ─── Test 1: the exact defect from 58A.3 — wh-question label ───
it("produces a grammatical question when label is a wh-question", () => {
const q = questionFor(
"Are the projected office savings from relocation realistic?",
);
// Must NOT contain template injection artefact
expect(q).not.toMatch(/what would clarify are .* in this situation/i);
// Should be grammatical — either a standalone wh-question or wrapped properly
expect(q.endsWith("?")).toBe(true);
// The core meaning must survive
expect(q.toLowerCase()).toMatch(/office savings|relocation/);
});
// ─── Test 2: clause-shaped statement label (no question mark) ───
it("produces a grammatical question when label is a declarative clause", () => {
const q = questionFor(
"The projected office savings from relocation are unrealistic",
);
// Should be grammatical question form
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).toMatch(/office savings|relocation/);
});
// ─── Test 3: yes/no question label ───
it("produces a grammatical question when label is a yes/no question", () => {
const q = questionFor("Is the budget sufficient for this project?");
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).toMatch(/budget|sufficient/);
});
// ─── Test 4: whether-clause label ───
it("produces a grammatical question when label starts with 'whether'", () => {
const q = questionFor(
"Whether the new pricing strategy will increase revenue",
);
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).toMatch(/pricing|revenue/);
});
// ─── Test 5: what-question label ───
it("produces a grammatical question when label is a what-question", () => {
const q = questionFor(
"What are the key risks of this project?",
);
expect(q.endsWith("?")).toBe(true);
// Should not double-wrap with "what would clarify"
expect(q.toLowerCase()).not.toMatch(/what would clarify what/i);
expect(q.toLowerCase()).toMatch(/key risks/);
});
// ─── Test 6: how-question label ───
it("produces a grammatical question when label is a how-question", () => {
const q = questionFor(
"How do we measure success for this initiative?",
);
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).not.toMatch(/what would clarify how/i);
expect(q.toLowerCase()).toMatch(/measure success/);
});
// ─── Test 7: why-question label ───
it("produces a grammatical question when label is a why-question", () => {
const q = questionFor(
"Why did the previous quarter underperform?",
);
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).not.toMatch(/what would clarify why/i);
expect(q.toLowerCase()).toMatch(/quarter|underperform/);
});
// ─── Test 8: who-question label ───
it("produces a grammatical question when label is a who-question", () => {
const q = questionFor(
"Who is responsible for the client onboarding process?",
);
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).not.toMatch(/what would clarify who/i);
expect(q.toLowerCase()).toMatch(/responsible|client/);
});
// ─── Test 9: when-question label ───
it("produces a grammatical question when label is a when-question", () => {
const q = questionFor(
"When does the current contract expire?",
);
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).not.toMatch(/what would clarify when/i);
expect(q.toLowerCase()).toMatch(/contract|expire/);
});
// ─── Test 10: where-question label ───
it("produces a grammatical question when label is a where-question", () => {
const q = questionFor(
"Where should the new warehouse be located?",
);
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).not.toMatch(/what would clarify where/i);
expect(q.toLowerCase()).toMatch(/warehouse|located/);
});
// ─── Test 11: non-question labels should NOT be affected ───
it("still applies template frames to declarative labels", () => {
const q = questionFor(
"Office lease exit penalty amount",
);
expect(q.endsWith("?")).toBe(true);
// For a normal declarative label, the template frame should apply
// The key is that it's grammatical, not that it uses a specific frame
expect(q.toLowerCase()).toMatch(/lease|penalty/);
});
// ─── Test 12: evidence-fallback path with pre-question label ───
it("produces grammatical output via evidence fallback for question labels", () => {
const q = questionFor(
"Are the projected office savings from relocation realistic?",
);
expect(q.endsWith("?")).toBe(true);
// The output should be a single coherent question
const questionMarks = (q.match(/\?/g) || []).length;
expect(questionMarks).toBe(1);
});
// ─── Test 13: clause without question mark — statement form ───
it("handles label shaped like a that-clause", () => {
const q = questionFor(
"That the migration will reduce operational costs by at least 20%",
);
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).toMatch(/migration|cost/);
});
// ─── Test 14: complex interrogative with embedded clause ───
it("handles label shaped like an indirect question", () => {
const q = questionFor(
"How much funding we need to complete the product launch",
);
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).toMatch(/funding|launch/);
});
// ─── Test 15: the exact output from 58A.2 template — should not recur ───
it("does not produce the 58A.2 malformed question", () => {
const q = questionFor(
"Are the projected office savings from relocation realistic?",
);
expect(q).not.toBe(
"What was the comparable state before are the projected office savings from relocation realistic?",
);
expect(q).not.toMatch(/before .* are .* realistic/);
});
// ─── Test 16: short yes/no question label ───
it("handles very short interrogative labels", () => {
const q = questionFor("Is this the right approach?");
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).toMatch(/right|approach/);
});
// ─── Test 17: label with trailing punctuation already stripped in extractMeaning ───
it("handles label that is interrogative but loses its question mark via stripTrailingPunctuation", () => {
// This tests the internal pipeline: extractMeaning strips trailing ?,
// then the meaning feeds into buildNeutralClarificationQuestion or evidence fallback
const q = questionFor(
"What is the total addressable market for this segment?",
);
expect(q.endsWith("?")).toBe(true);
const questionMarks = (q.match(/\?/g) || []).length;
expect(questionMarks).toBe(1);
});
// ─── Test 18: long complex interrogative label ───
it("handles very long interrogative labels without template doubling", () => {
const q = questionFor(
"Are the current employee satisfaction scores sufficient to justify continuing the remote work policy?",
);
expect(q.endsWith("?")).toBe(true);
expect(q.toLowerCase()).not.toMatch(/what would clarify are .* sufficient/i);
expect(q.toLowerCase()).toMatch(/satisfaction|remote/);
});
// ─── Test 19: interrogative label in definition reasoning pattern ───
it("handles interrogative labels through definition path", () => {
const node = makeNode({
id: "n-def",
label: "What does 'customer' mean in this context?",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const result = formulateQuestion({
node,
graph: makeGraphFor(node),
});
expect(result.question.endsWith("?")).toBe(true);
const questionMarks = (result.question.match(/\?/g) || []).length;
expect(questionMarks).toBe(1);
});
// ─── Test 20: interrogative label in evidence gathering pattern ───
it("handles interrogative labels through evidence path", () => {
const node = makeNode({
id: "n-evidence",
label: "What additional data do we need to validate the hypothesis?",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const result = formulateQuestion({
node,
graph: makeGraphFor(node),
});
expect(result.question.endsWith("?")).toBe(true);
const questionMarks = (result.question.match(/\?/g) || []).length;
expect(questionMarks).toBe(1);
});
});