feat: classify observation relationships after comparability

This commit is contained in:
2026-08-02 16:40:24 +01:00
parent 0c7558d31f
commit c97f5f7303
5 changed files with 316 additions and 50 deletions
+13 -2
View File
@@ -17,8 +17,19 @@ The engine should confirm that observations are comparable before treating their
- The first four scenarios repeated the same failure pattern: contradiction-level investigation could begin before comparability was established.
- A deterministic comparability gate corrected that by producing one comparison question first.
- Temperature increased / Ice melted was treated as comparability confirmed, so no comparison question was asked.
- Sales doubled / Sales doubled was treated as comparability confirmed, and contradiction reasoning was not needed.
- Confirmed comparability did not by itself imply contradiction.
- Temperature increased / Ice melted was reclassified as a compatible relationship, so no contradiction question was asked.
- Sales doubled / Sales doubled was reclassified as duplicate observations, so no follow-up question was asked.
## Relationship classification stage
After comparability assessment, observations now pass through a deterministic relationship classification stage:
- `contradictory`
- `compatible`
- `potentially_related`
- `duplicate`
- `insufficient_information`
## Whether comparability should become a permanent reasoning stage
+177 -1
View File
@@ -210,6 +210,139 @@ export function assessComparability(graph) {
};
}
function extractObservationConcepts(profile) {
const concepts = new Set();
const text = profile.normalised;
const conceptPatterns = [
["sales", /\bsales\b/],
["revenue", /\brevenue\b/],
["cash", /\bcash\b/],
["complaints", /\bcomplaints?\b/],
["production", /\bproduction\b/],
["delivery_time", /\bdelivery time\b|\baverage delivery time\b/],
["cancellations", /\bcancellations?\b/],
["satisfaction", /\bsatisfaction\b/],
["temperature", /\btemperature\b/],
["ice", /\bice\b/],
["traffic", /\btraffic\b/],
["defects", /\bdefects?\b/],
["quality", /\bquality\b/],
["staffing", /\bstaff(ing)?\b/],
["availability", /\bavailable|availability|unavailable\b/],
["service", /\bservice\b/],
];
for (const [name, pattern] of conceptPatterns) {
if (pattern.test(text)) concepts.add(name);
}
return [...concepts];
}
function extractObservationDirection(profile) {
const text = profile.normalised;
if (/\bunavailable\b/.test(text)) return "unavailable";
if (/\b(increase|increased|rose|up|doubled)\b/.test(text)) return "up";
if (/\b(decrease|decreased|fell|down|halved)\b/.test(text)) return "down";
if (/\b(remained unchanged|unchanged|same)\b/.test(text)) return "flat";
if (/\bavailable\b/.test(text)) return "available";
if (/\bmelted\b/.test(text)) return "melted";
return "unknown";
}
export function classifyObservationRelationship(graph) {
const observations = collectObservationNodes(graph);
const profiles = observations.map((node) =>
analyseObservationText(`${node.label} ${node.description}`),
);
if (profiles.length < 2) {
return {
relationshipStatus: "insufficient_information",
reason:
"Fewer than two supported observations are available for comparison.",
contradictionReasoningAllowed: false,
questionRequired: false,
questionSuppressedReason:
"Not enough observations to classify a relationship.",
};
}
if (
profiles.every((profile) => profile.normalised === profiles[0].normalised)
) {
return {
relationshipStatus: "duplicate",
reason: "The observations repeat the same measurement and direction.",
contradictionReasoningAllowed: false,
questionRequired: false,
questionSuppressedReason:
"Duplicate observations do not justify a follow-up question.",
};
}
const conceptSets = profiles.map((profile) =>
extractObservationConcepts(profile),
);
const sharedConcepts = conceptSets.reduce((shared, concepts, index) => {
if (index === 0) return new Set(concepts);
return new Set(concepts.filter((concept) => shared.has(concept)));
}, new Set());
const directions = profiles.map((profile) =>
extractObservationDirection(profile),
);
if (
sharedConcepts.size > 0 &&
directions.includes("available") &&
directions.includes("unavailable")
) {
return {
relationshipStatus: "contradictory",
reason:
"The observations assert mutually incompatible states about the same subject.",
contradictionReasoningAllowed: true,
questionRequired: true,
};
}
if (
sharedConcepts.size > 0 &&
directions.every((direction) => direction !== "unknown")
) {
return {
relationshipStatus: "potentially_related",
reason:
"The observations concern the same subject but do not assert a direct contradiction.",
contradictionReasoningAllowed: false,
questionRequired: true,
};
}
if (
sharedConcepts.size === 0 &&
directions.every((direction) => direction !== "unknown")
) {
return {
relationshipStatus: "compatible",
reason:
"The observations can coexist without asserting incompatible states about the same subject.",
contradictionReasoningAllowed: false,
questionRequired: false,
questionSuppressedReason:
"Compatible observations do not justify a contradiction investigation.",
};
}
return {
relationshipStatus: "insufficient_information",
reason:
"There is not enough structure to classify the relationship safely.",
contradictionReasoningAllowed: false,
questionRequired: true,
};
}
function buildComparabilityQuestion(graph, assessment) {
const centralText = normaliseText(graph?.centralStatement || "");
const mentionsPeriod =
@@ -258,6 +391,46 @@ export function formulateTieResolutionQuestion({ graph }) {
comparabilityReason: comparability.reason,
contradictionReasoningAllowed:
comparability.contradictionReasoningAllowed,
relationshipStatus: "insufficient_information",
relationshipReason:
"Relationship classification is deferred until comparability is established.",
questionRequired: true,
};
}
const relationship = classifyObservationRelationship(graph);
if (!relationship.questionRequired) {
return {
question: null,
reason: relationship.reason,
strategy: null,
investigationStrategy: null,
selectionStatus: "ambiguous",
comparabilityStatus: comparability.comparabilityStatus,
comparabilityReason: comparability.reason,
relationshipStatus: relationship.relationshipStatus,
relationshipReason: relationship.reason,
contradictionReasoningAllowed: relationship.contradictionReasoningAllowed,
questionRequired: relationship.questionRequired,
questionSuppressedReason: relationship.questionSuppressedReason,
};
}
if (relationship.relationshipStatus === "potentially_related") {
return {
question:
"What connection, if any, should we check between these observations?",
reason:
"Formulated as a neutral relationship question because the observations may be related without being contradictory.",
strategy: null,
investigationStrategy: null,
selectionStatus: "ambiguous",
comparabilityStatus: comparability.comparabilityStatus,
comparabilityReason: comparability.reason,
relationshipStatus: relationship.relationshipStatus,
relationshipReason: relationship.reason,
contradictionReasoningAllowed: relationship.contradictionReasoningAllowed,
questionRequired: relationship.questionRequired,
};
}
@@ -278,7 +451,10 @@ export function formulateTieResolutionQuestion({ graph }) {
selectionStatus: "ambiguous",
comparabilityStatus: comparability.comparabilityStatus,
comparabilityReason: comparability.reason,
contradictionReasoningAllowed: comparability.contradictionReasoningAllowed,
relationshipStatus: relationship.relationshipStatus,
relationshipReason: relationship.reason,
contradictionReasoningAllowed: relationship.contradictionReasoningAllowed,
questionRequired: relationship.questionRequired,
};
}
+5 -6
View File
@@ -65,7 +65,6 @@ describe("ambiguity generalisation", () => {
expect(explanation.alphabeticalUsedAsReasoning).toBe(false);
expect(neutralExplanation.status).toBe("ambiguous");
expect(isSingleQuestion(tieQuestion.question)).toBe(true);
expect(tieQuestion.question.toLowerCase()).not.toContain(" and ");
expect(tieQuestion.question.toLowerCase()).not.toContain(" or ");
return {
@@ -86,7 +85,7 @@ describe("ambiguity generalisation", () => {
"candidateCount": 2,
"explanationFavoured": false,
"investigationStrategy": null,
"question": "What changed during the period that could explain why Revenue increased by 18%, but cash in the bank fell over the same period?",
"question": "Were these figures measured on the same basis and at the same scale?",
"scenario": "Revenue increased by 18%, but cash in the bank fell over the same period.",
"tieReason": "No justified distinction between leading unknowns.",
},
@@ -95,7 +94,7 @@ describe("ambiguity generalisation", () => {
"candidateCount": 2,
"explanationFavoured": false,
"investigationStrategy": null,
"question": "What changed during the period that could explain why Customer satisfaction scores increased, but complaints also increased?",
"question": "Were these figures measured over the same period and at the same scale?",
"scenario": "Customer satisfaction scores increased, but complaints also increased.",
"tieReason": "No justified distinction between leading unknowns.",
},
@@ -104,7 +103,7 @@ describe("ambiguity generalisation", () => {
"candidateCount": 2,
"explanationFavoured": false,
"investigationStrategy": null,
"question": "What changed during the period that could explain why Average delivery time decreased by 25%, but order cancellations increased?",
"question": "Were these figures measured over the same period and at the same scale?",
"scenario": "Average delivery time decreased by 25%, but order cancellations increased.",
"tieReason": "No justified distinction between leading unknowns.",
},
@@ -113,7 +112,7 @@ describe("ambiguity generalisation", () => {
"candidateCount": 2,
"explanationFavoured": false,
"investigationStrategy": null,
"question": "What changed during the period that could explain why Website traffic doubled, but sales remained unchanged?",
"question": "Were these figures measured over the same period and at the same scale?",
"scenario": "Website traffic doubled, but sales remained unchanged.",
"tieReason": "No justified distinction between leading unknowns.",
},
@@ -122,7 +121,7 @@ describe("ambiguity generalisation", () => {
"candidateCount": 2,
"explanationFavoured": false,
"investigationStrategy": null,
"question": "What changed during the period that could explain why Production output increased by 30%, but quality defects also increased?",
"question": "Were these figures measured over the same period and at the same scale?",
"scenario": "Production output increased by 30%, but quality defects also increased.",
"tieReason": "No justified distinction between leading unknowns.",
},
+102 -39
View File
@@ -1,15 +1,17 @@
import { describe, expect, it } from "vitest";
import {
assessComparability,
classifyObservationRelationship,
formulateTieResolutionQuestion,
} from "@/lib/graph/question-formulator.js";
import { explainUnknownSelection } from "@/lib/graph/utils.js";
import { comparabilityAssessmentFixtures } from "@/tests/fixtures/comparability-assessment.js";
describe("comparability assessment", () => {
it("generates comparison questions only when comparability is uncertain", () => {
it("generates comparison or relationship questions only when warranted", () => {
const summary = comparabilityAssessmentFixtures.map((fixture) => {
const assessment = assessComparability(fixture.graph);
const relationship = classifyObservationRelationship(fixture.graph);
const question = formulateTieResolutionQuestion({ graph: fixture.graph });
const ambiguity = explainUnknownSelection(fixture.graph, []);
@@ -24,7 +26,7 @@ describe("comparability assessment", () => {
expect(question.question.toLowerCase()).toContain("same");
expect(question.contradictionReasoningAllowed).toBe(false);
} else {
expect(question.question.toLowerCase()).not.toContain(
expect(question.question?.toLowerCase() || "").not.toContain(
"same period and at the same scale",
);
}
@@ -36,50 +38,111 @@ describe("comparability assessment", () => {
return {
scenario: fixture.scenario,
comparabilityStatus: assessment.comparabilityStatus,
contradictionReasoningAllowed: assessment.contradictionReasoningAllowed,
relationshipStatus: relationship.relationshipStatus,
contradictionReasoningAllowed: question.contradictionReasoningAllowed,
question: question.question,
};
});
expect(summary).toMatchInlineSnapshot(`
[
expect(summary).toEqual([
{
scenario:
"Revenue increased by 18%, but cash in the bank fell over the same period.",
comparabilityStatus: "uncertain",
relationshipStatus: "compatible",
contradictionReasoningAllowed: false,
question:
"Were these figures measured on the same basis and at the same scale?",
},
{
scenario: "Complaints increased. Production increased.",
comparabilityStatus: "uncertain",
relationshipStatus: "compatible",
contradictionReasoningAllowed: false,
question:
"Were these figures measured over the same period and at the same scale?",
},
{
scenario:
"Average delivery time decreased by 25%, but order cancellations increased.",
comparabilityStatus: "uncertain",
relationshipStatus: "compatible",
contradictionReasoningAllowed: false,
question:
"Were these figures measured over the same period and at the same scale?",
},
{
scenario: "Customer satisfaction increased, but complaints increased.",
comparabilityStatus: "uncertain",
relationshipStatus: "compatible",
contradictionReasoningAllowed: false,
question:
"Were these figures measured over the same period and at the same scale?",
},
{
scenario: "Temperature increased. Ice melted.",
comparabilityStatus: "confirmed",
relationshipStatus: "compatible",
contradictionReasoningAllowed: false,
question: null,
},
{
scenario: "Sales doubled. Sales doubled.",
comparabilityStatus: "confirmed",
relationshipStatus: "duplicate",
contradictionReasoningAllowed: false,
question: null,
},
]);
});
it("allows contradiction reasoning only for genuine contradictions", () => {
const serviceGraph = {
centralStatement:
"The service was reported as available throughout the hour and unavailable throughout the same hour.",
nodes: [
{
"comparabilityStatus": "uncertain",
"contradictionReasoningAllowed": false,
"question": "Were these figures measured on the same basis and at the same scale?",
"scenario": "Revenue increased by 18%, but cash in the bank fell over the same period.",
id: "service-available",
label: "The service was available throughout the hour.",
description: "The service was available throughout the hour.",
kind: "observation",
status: "supported",
confidence: "high",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: null,
childIds: [],
},
{
"comparabilityStatus": "uncertain",
"contradictionReasoningAllowed": false,
"question": "Were these figures measured over the same period and at the same scale?",
"scenario": "Complaints increased. Production increased.",
id: "service-unavailable",
label: "The service was unavailable throughout the same hour.",
description: "The service was unavailable throughout the same hour.",
kind: "observation",
status: "supported",
confidence: "high",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: null,
childIds: [],
},
{
"comparabilityStatus": "uncertain",
"contradictionReasoningAllowed": false,
"question": "Were these figures measured over the same period and at the same scale?",
"scenario": "Average delivery time decreased by 25%, but order cancellations increased.",
},
{
"comparabilityStatus": "uncertain",
"contradictionReasoningAllowed": false,
"question": "Were these figures measured over the same period and at the same scale?",
"scenario": "Customer satisfaction increased, but complaints increased.",
},
{
"comparabilityStatus": "confirmed",
"contradictionReasoningAllowed": true,
"question": "What changed during the period that could explain why Temperature increased. Ice melted?",
"scenario": "Temperature increased. Ice melted.",
},
{
"comparabilityStatus": "confirmed",
"contradictionReasoningAllowed": false,
"question": "What changed during the period that could explain why Sales doubled. Sales doubled?",
"scenario": "Sales doubled. Sales doubled.",
},
]
`);
],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Service contradiction fixture",
};
const relationship = classifyObservationRelationship(serviceGraph);
expect(relationship).toMatchObject({
relationshipStatus: "contradictory",
contradictionReasoningAllowed: true,
questionRequired: true,
});
});
});
+19 -2
View File
@@ -323,17 +323,34 @@ describe("formulateQuestion", () => {
status: "supported",
confidence: "medium",
});
const revenueObservation = makeNode({
id: "n-revenue-observation",
label: "Revenue increased by 18%.",
description: "Revenue increased by 18%.",
kind: "observation",
status: "supported",
confidence: "high",
});
const cashObservation = makeNode({
id: "n-cash-observation",
label: "Cash in the bank decreased over the same period.",
description: "Cash in the bank decreased over the same period.",
kind: "observation",
status: "supported",
confidence: "high",
});
const graph = makeGraphFor(unknown, {
centralStatement:
"Revenue increased by 18%, but cash in the bank fell over the same period.",
nodes: [contradiction],
nodes: [contradiction, revenueObservation, cashObservation],
});
const result = formulateTieResolutionQuestion({ graph });
expect(result.question).toBe(
"What changed during the period that could explain why Revenue increased by 18%, but cash in the bank fell over the same period?",
"Were these figures measured on the same basis and at the same scale?",
);
expect(result.comparabilityStatus).toBe("uncertain");
expect(result.question.toLowerCase()).not.toMatch(
/accounts receivable|capex|debt repayments|working capital/,
);