feat: evaluate deterministic cross-branch corroboration
This commit is contained in:
@@ -149,6 +149,40 @@ Deterministic propagation rules now enforce:
|
|||||||
- status changes do not raise confidence on their own
|
- status changes do not raise confidence on their own
|
||||||
- parent resolution still requires the separate completion rule
|
- parent resolution still requires the separate completion rule
|
||||||
|
|
||||||
|
## Cross-branch corroboration
|
||||||
|
|
||||||
|
The next confidence experiment adds deterministic branch interaction checks without changing the graph model.
|
||||||
|
|
||||||
|
The engine now distinguishes between:
|
||||||
|
|
||||||
|
- **multiple evidence**: more than one branch exists
|
||||||
|
- **independent corroboration**: distinct resolved branches support the same parent without sharing the same evidence key
|
||||||
|
- **duplicate evidence**: the same evidence key appears through multiple branches and must not be double-counted
|
||||||
|
- **conflicting evidence**: branches support incompatible positions, such as `recognised correctly` vs `recognised incorrectly`
|
||||||
|
|
||||||
|
Deterministic branch rules:
|
||||||
|
|
||||||
|
- corroboration only counts when branches are distinct and their evidence sources differ
|
||||||
|
- duplicate evidence groups never count as corroboration
|
||||||
|
- conflicts cap conclusion confidence and prevent a higher confidence upgrade
|
||||||
|
- independent branches remain interaction-neutral
|
||||||
|
|
||||||
|
Additional diagnostics now expose:
|
||||||
|
|
||||||
|
- `corroboratingBranchCount`
|
||||||
|
- `conflictingBranchCount`
|
||||||
|
- `duplicateEvidenceCount`
|
||||||
|
- `independentBranchCount`
|
||||||
|
- `interactionSummary`
|
||||||
|
- `confidenceAdjustmentReason`
|
||||||
|
|
||||||
|
Observed effect:
|
||||||
|
|
||||||
|
- independent corroboration can raise `evidenceConfidence`
|
||||||
|
- duplicate evidence produces no extra confidence increase
|
||||||
|
- conflicting evidence lowers or caps `conclusionConfidence`
|
||||||
|
- completeness rules still dominate whether a parent may become highly justified
|
||||||
|
|
||||||
Example progression:
|
Example progression:
|
||||||
|
|
||||||
- parent before: `unknown`, `medium`
|
- parent before: `unknown`, `medium`
|
||||||
|
|||||||
+179
-4
@@ -463,6 +463,129 @@ function confidenceFromAssessment(assessment) {
|
|||||||
return assessment?.conclusionConfidence ?? "medium";
|
return assessment?.conclusionConfidence ?? "medium";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unique(values = []) {
|
||||||
|
return [...new Set(values.filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchEvidenceKeys(node) {
|
||||||
|
return unique([...(node?.evidenceIds || []), node?.value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedMeaningfulTokens(aText, bText) {
|
||||||
|
const stop = new Set([
|
||||||
|
"the",
|
||||||
|
"and",
|
||||||
|
"for",
|
||||||
|
"that",
|
||||||
|
"this",
|
||||||
|
"with",
|
||||||
|
"from",
|
||||||
|
"because",
|
||||||
|
"need",
|
||||||
|
"unknown",
|
||||||
|
"possible",
|
||||||
|
]);
|
||||||
|
const a = splitSemanticTokens(aText).filter((token) => !stop.has(token));
|
||||||
|
const b = splitSemanticTokens(bText).filter((token) => !stop.has(token));
|
||||||
|
return [...new Set(a.filter((token) => b.includes(token)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchConflictSignature(node) {
|
||||||
|
return normaliseText(
|
||||||
|
`${node?.label || ""} ${node?.description || ""} ${node?.value || ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchesConflict(aNode, bNode) {
|
||||||
|
const aText = branchConflictSignature(aNode);
|
||||||
|
const bText = branchConflictSignature(bNode);
|
||||||
|
const oppositePolarity =
|
||||||
|
(aText.includes("correctly") && bText.includes("incorrectly")) ||
|
||||||
|
(aText.includes("incorrectly") && bText.includes("correctly")) ||
|
||||||
|
aNode?.status === "contradicted" ||
|
||||||
|
bNode?.status === "contradicted";
|
||||||
|
|
||||||
|
if (!oppositePolarity) return false;
|
||||||
|
|
||||||
|
return sharedMeaningfulTokens(aText, bText).length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateBranchInteractions({ parentNode, graph }) {
|
||||||
|
const directBranches = findDirectChildUnknowns(graph, parentNode.id).filter(
|
||||||
|
(node) => ["resolved", "provisional", "contradicted"].includes(node.status),
|
||||||
|
);
|
||||||
|
const duplicateEvidenceGroups = [];
|
||||||
|
const conflictingBranches = [];
|
||||||
|
const corroboratingBranches = [];
|
||||||
|
const duplicateBranchIds = new Set();
|
||||||
|
const conflictingBranchIds = new Set();
|
||||||
|
|
||||||
|
const evidenceGroups = new Map();
|
||||||
|
for (const branch of directBranches) {
|
||||||
|
for (const evidenceKey of branchEvidenceKeys(branch)) {
|
||||||
|
const ids = evidenceGroups.get(evidenceKey) || [];
|
||||||
|
ids.push(branch.id);
|
||||||
|
evidenceGroups.set(evidenceKey, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [evidenceKey, branchIds] of evidenceGroups.entries()) {
|
||||||
|
if (branchIds.length > 1) {
|
||||||
|
duplicateEvidenceGroups.push({
|
||||||
|
evidenceKey,
|
||||||
|
branchIds: unique(branchIds),
|
||||||
|
});
|
||||||
|
for (const id of branchIds) duplicateBranchIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < directBranches.length; index += 1) {
|
||||||
|
for (let inner = index + 1; inner < directBranches.length; inner += 1) {
|
||||||
|
const aNode = directBranches[index];
|
||||||
|
const bNode = directBranches[inner];
|
||||||
|
if (branchesConflict(aNode, bNode)) {
|
||||||
|
conflictingBranches.push([aNode.id, bNode.id]);
|
||||||
|
conflictingBranchIds.add(aNode.id);
|
||||||
|
conflictingBranchIds.add(bNode.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aEvidence = branchEvidenceKeys(aNode);
|
||||||
|
const bEvidence = branchEvidenceKeys(bNode);
|
||||||
|
const sharesEvidence = aEvidence.some((key) => bEvidence.includes(key));
|
||||||
|
if (
|
||||||
|
!sharesEvidence &&
|
||||||
|
aNode.status === "resolved" &&
|
||||||
|
bNode.status === "resolved"
|
||||||
|
) {
|
||||||
|
corroboratingBranches.push([aNode.id, bNode.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const interactionBranchIds = new Set([
|
||||||
|
...duplicateBranchIds,
|
||||||
|
...conflictingBranchIds,
|
||||||
|
...corroboratingBranches.flat(),
|
||||||
|
]);
|
||||||
|
const independentBranches = directBranches
|
||||||
|
.map((branch) => branch.id)
|
||||||
|
.filter((id) => !interactionBranchIds.has(id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
corroboratingBranches,
|
||||||
|
conflictingBranches,
|
||||||
|
duplicateEvidenceGroups,
|
||||||
|
independentBranches,
|
||||||
|
interactionSummary: {
|
||||||
|
corroboratingBranchCount: corroboratingBranches.length,
|
||||||
|
conflictingBranchCount: conflictingBranches.length,
|
||||||
|
duplicateEvidenceCount: duplicateEvidenceGroups.length,
|
||||||
|
independentBranchCount: independentBranches.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function upsertProposalNodeUpdate(proposalSnapshot, update) {
|
function upsertProposalNodeUpdate(proposalSnapshot, update) {
|
||||||
const existing = proposalSnapshot.updatedNodes.find(
|
const existing = proposalSnapshot.updatedNodes.find(
|
||||||
(candidate) => candidate.nodeId === update.nodeId,
|
(candidate) => candidate.nodeId === update.nodeId,
|
||||||
@@ -585,6 +708,13 @@ function computeParentProgressState(graph, parentNode) {
|
|||||||
(child) => child.status !== "resolved",
|
(child) => child.status !== "resolved",
|
||||||
).length;
|
).length;
|
||||||
const beforeAssessment = getNodeConfidenceAssessment(parentNode);
|
const beforeAssessment = getNodeConfidenceAssessment(parentNode);
|
||||||
|
const interactions = evaluateBranchInteractions({ parentNode, graph });
|
||||||
|
const corroborationCount =
|
||||||
|
interactions.interactionSummary.corroboratingBranchCount;
|
||||||
|
const duplicateEvidenceCount =
|
||||||
|
interactions.interactionSummary.duplicateEvidenceCount;
|
||||||
|
const conflictingBranchCount =
|
||||||
|
interactions.interactionSummary.conflictingBranchCount;
|
||||||
|
|
||||||
if (totalChildren === 0) {
|
if (totalChildren === 0) {
|
||||||
const nextAssessment = {
|
const nextAssessment = {
|
||||||
@@ -625,18 +755,32 @@ function computeParentProgressState(graph, parentNode) {
|
|||||||
confidenceCapReason = "no_resolved_direct_children";
|
confidenceCapReason = "no_resolved_direct_children";
|
||||||
} else if (resolvedCount < totalChildren) {
|
} else if (resolvedCount < totalChildren) {
|
||||||
nextAssessment = {
|
nextAssessment = {
|
||||||
evidenceConfidence: "high",
|
evidenceConfidence: corroborationCount > 0 ? "high" : "medium",
|
||||||
completenessStatus: "partial",
|
completenessStatus: "partial",
|
||||||
conclusionConfidence: "medium",
|
conclusionConfidence: "medium",
|
||||||
};
|
};
|
||||||
confidenceCapReason = "unresolved_direct_children_cap_conclusion";
|
confidenceCapReason =
|
||||||
|
conflictingBranchCount > 0
|
||||||
|
? "conflicting_branches_cap_conclusion"
|
||||||
|
: duplicateEvidenceCount > 0
|
||||||
|
? "duplicate_evidence_no_extra_confidence"
|
||||||
|
: corroborationCount > 0
|
||||||
|
? "independent_corroboration_with_incomplete_parent"
|
||||||
|
: "unresolved_direct_children_cap_conclusion";
|
||||||
} else {
|
} else {
|
||||||
nextAssessment = {
|
nextAssessment = {
|
||||||
evidenceConfidence: "high",
|
evidenceConfidence: "high",
|
||||||
completenessStatus: "complete",
|
completenessStatus: "complete",
|
||||||
conclusionConfidence: "high",
|
conclusionConfidence: conflictingBranchCount > 0 ? "low" : "high",
|
||||||
};
|
};
|
||||||
confidenceCapReason = null;
|
confidenceCapReason =
|
||||||
|
conflictingBranchCount > 0
|
||||||
|
? "conflicting_branches_cap_conclusion"
|
||||||
|
: duplicateEvidenceCount > 0
|
||||||
|
? "duplicate_evidence_no_extra_confidence"
|
||||||
|
: corroborationCount > 0
|
||||||
|
? "independent_corroboration_supported_conclusion"
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedChildren.length === totalChildren) {
|
if (resolvedChildren.length === totalChildren) {
|
||||||
@@ -652,6 +796,7 @@ function computeParentProgressState(graph, parentNode) {
|
|||||||
resolvedDirectChildren: resolvedCount,
|
resolvedDirectChildren: resolvedCount,
|
||||||
unresolvedDirectChildren: unresolvedCount,
|
unresolvedDirectChildren: unresolvedCount,
|
||||||
contradictoryDirectChildren: contradictoryChildren.length,
|
contradictoryDirectChildren: contradictoryChildren.length,
|
||||||
|
branchInteractions: interactions,
|
||||||
confidenceCapReason,
|
confidenceCapReason,
|
||||||
reason:
|
reason:
|
||||||
"All direct child unknowns are resolved, so the parent can now resolve deterministically.",
|
"All direct child unknowns are resolved, so the parent can now resolve deterministically.",
|
||||||
@@ -671,6 +816,7 @@ function computeParentProgressState(graph, parentNode) {
|
|||||||
resolvedDirectChildren: resolvedCount,
|
resolvedDirectChildren: resolvedCount,
|
||||||
unresolvedDirectChildren: unresolvedCount,
|
unresolvedDirectChildren: unresolvedCount,
|
||||||
contradictoryDirectChildren: contradictoryChildren.length,
|
contradictoryDirectChildren: contradictoryChildren.length,
|
||||||
|
branchInteractions: interactions,
|
||||||
confidenceCapReason,
|
confidenceCapReason,
|
||||||
reason:
|
reason:
|
||||||
"At least one direct child has been progressed, so the parent becomes provisional but remains unresolved until all direct children are resolved.",
|
"At least one direct child has been progressed, so the parent becomes provisional but remains unresolved until all direct children are resolved.",
|
||||||
@@ -689,6 +835,7 @@ function computeParentProgressState(graph, parentNode) {
|
|||||||
resolvedDirectChildren: resolvedCount,
|
resolvedDirectChildren: resolvedCount,
|
||||||
unresolvedDirectChildren: unresolvedCount,
|
unresolvedDirectChildren: unresolvedCount,
|
||||||
contradictoryDirectChildren: contradictoryChildren.length,
|
contradictoryDirectChildren: contradictoryChildren.length,
|
||||||
|
branchInteractions: interactions,
|
||||||
confidenceCapReason,
|
confidenceCapReason,
|
||||||
reason: "No direct child progress exists yet for the parent.",
|
reason: "No direct child progress exists yet for the parent.",
|
||||||
};
|
};
|
||||||
@@ -807,6 +954,19 @@ export function propagateResolvedChildEvidence({
|
|||||||
resolvedDirectChildren: progressState.resolvedDirectChildren,
|
resolvedDirectChildren: progressState.resolvedDirectChildren,
|
||||||
unresolvedDirectChildren: progressState.unresolvedDirectChildren,
|
unresolvedDirectChildren: progressState.unresolvedDirectChildren,
|
||||||
contradictoryDirectChildren: progressState.contradictoryDirectChildren,
|
contradictoryDirectChildren: progressState.contradictoryDirectChildren,
|
||||||
|
corroboratingBranchCount:
|
||||||
|
progressState.branchInteractions.interactionSummary
|
||||||
|
.corroboratingBranchCount,
|
||||||
|
conflictingBranchCount:
|
||||||
|
progressState.branchInteractions.interactionSummary
|
||||||
|
.conflictingBranchCount,
|
||||||
|
duplicateEvidenceCount:
|
||||||
|
progressState.branchInteractions.interactionSummary
|
||||||
|
.duplicateEvidenceCount,
|
||||||
|
independentBranchCount:
|
||||||
|
progressState.branchInteractions.interactionSummary
|
||||||
|
.independentBranchCount,
|
||||||
|
interactionSummary: progressState.branchInteractions.interactionSummary,
|
||||||
confidenceCapReason: progressState.confidenceCapReason,
|
confidenceCapReason: progressState.confidenceCapReason,
|
||||||
parentResolved: progressState.parentResolved,
|
parentResolved: progressState.parentResolved,
|
||||||
reason: progressState.reason,
|
reason: progressState.reason,
|
||||||
@@ -846,6 +1006,11 @@ export function propagateResolvedChildEvidence({
|
|||||||
resolvedDirectChildren: firstEvent?.resolvedDirectChildren ?? 0,
|
resolvedDirectChildren: firstEvent?.resolvedDirectChildren ?? 0,
|
||||||
unresolvedDirectChildren: firstEvent?.unresolvedDirectChildren ?? 0,
|
unresolvedDirectChildren: firstEvent?.unresolvedDirectChildren ?? 0,
|
||||||
contradictoryDirectChildren: firstEvent?.contradictoryDirectChildren ?? 0,
|
contradictoryDirectChildren: firstEvent?.contradictoryDirectChildren ?? 0,
|
||||||
|
corroboratingBranchCount: firstEvent?.corroboratingBranchCount ?? 0,
|
||||||
|
conflictingBranchCount: firstEvent?.conflictingBranchCount ?? 0,
|
||||||
|
duplicateEvidenceCount: firstEvent?.duplicateEvidenceCount ?? 0,
|
||||||
|
independentBranchCount: firstEvent?.independentBranchCount ?? 0,
|
||||||
|
interactionSummary: firstEvent?.interactionSummary ?? null,
|
||||||
confidenceCapReason: firstEvent?.confidenceCapReason ?? null,
|
confidenceCapReason: firstEvent?.confidenceCapReason ?? null,
|
||||||
ancestorPropagationStoppedReason,
|
ancestorPropagationStoppedReason,
|
||||||
affectedAncestorIds: [...affectedAncestorIds],
|
affectedAncestorIds: [...affectedAncestorIds],
|
||||||
@@ -1915,6 +2080,11 @@ export function applyValidatedProposal({
|
|||||||
const affectedAncestorIds = propagationResult.affectedAncestorIds;
|
const affectedAncestorIds = propagationResult.affectedAncestorIds;
|
||||||
const nextSelectedSibling = propagationResult.nextSelectedSibling;
|
const nextSelectedSibling = propagationResult.nextSelectedSibling;
|
||||||
const parentResolved = propagationResult.parentResolved;
|
const parentResolved = propagationResult.parentResolved;
|
||||||
|
const corroboratingBranchCount = propagationResult.corroboratingBranchCount;
|
||||||
|
const conflictingBranchCount = propagationResult.conflictingBranchCount;
|
||||||
|
const duplicateEvidenceCount = propagationResult.duplicateEvidenceCount;
|
||||||
|
const independentBranchCount = propagationResult.independentBranchCount;
|
||||||
|
const interactionSummary = propagationResult.interactionSummary;
|
||||||
const propagationReason = propagationResult.reason;
|
const propagationReason = propagationResult.reason;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -2048,6 +2218,11 @@ export function applyValidatedProposal({
|
|||||||
resolvedDirectChildren,
|
resolvedDirectChildren,
|
||||||
unresolvedDirectChildren,
|
unresolvedDirectChildren,
|
||||||
contradictoryDirectChildren,
|
contradictoryDirectChildren,
|
||||||
|
corroboratingBranchCount,
|
||||||
|
conflictingBranchCount,
|
||||||
|
duplicateEvidenceCount,
|
||||||
|
independentBranchCount,
|
||||||
|
interactionSummary,
|
||||||
confidenceCapReason,
|
confidenceCapReason,
|
||||||
ancestorPropagationStoppedReason,
|
ancestorPropagationStoppedReason,
|
||||||
affectedAncestorIds,
|
affectedAncestorIds,
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ function buildUpdateDiagnostics({
|
|||||||
resolvedDirectChildren,
|
resolvedDirectChildren,
|
||||||
unresolvedDirectChildren,
|
unresolvedDirectChildren,
|
||||||
contradictoryDirectChildren,
|
contradictoryDirectChildren,
|
||||||
|
corroboratingBranchCount,
|
||||||
|
conflictingBranchCount,
|
||||||
|
duplicateEvidenceCount,
|
||||||
|
independentBranchCount,
|
||||||
|
interactionSummary,
|
||||||
confidenceCapReason,
|
confidenceCapReason,
|
||||||
ancestorPropagationStoppedReason,
|
ancestorPropagationStoppedReason,
|
||||||
affectedAncestorIds,
|
affectedAncestorIds,
|
||||||
@@ -183,6 +188,11 @@ function buildUpdateDiagnostics({
|
|||||||
resolvedDirectChildren: resolvedDirectChildren ?? 0,
|
resolvedDirectChildren: resolvedDirectChildren ?? 0,
|
||||||
unresolvedDirectChildren: unresolvedDirectChildren ?? 0,
|
unresolvedDirectChildren: unresolvedDirectChildren ?? 0,
|
||||||
contradictoryDirectChildren: contradictoryDirectChildren ?? 0,
|
contradictoryDirectChildren: contradictoryDirectChildren ?? 0,
|
||||||
|
corroboratingBranchCount: corroboratingBranchCount ?? 0,
|
||||||
|
conflictingBranchCount: conflictingBranchCount ?? 0,
|
||||||
|
duplicateEvidenceCount: duplicateEvidenceCount ?? 0,
|
||||||
|
independentBranchCount: independentBranchCount ?? 0,
|
||||||
|
interactionSummary: interactionSummary ?? null,
|
||||||
confidenceCapReason: confidenceCapReason ?? null,
|
confidenceCapReason: confidenceCapReason ?? null,
|
||||||
ancestorPropagationStoppedReason: ancestorPropagationStoppedReason ?? null,
|
ancestorPropagationStoppedReason: ancestorPropagationStoppedReason ?? null,
|
||||||
affectedAncestorIds: affectedAncestorIds ?? [],
|
affectedAncestorIds: affectedAncestorIds ?? [],
|
||||||
@@ -471,6 +481,11 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
|||||||
resolvedDirectChildren: 0,
|
resolvedDirectChildren: 0,
|
||||||
unresolvedDirectChildren: 0,
|
unresolvedDirectChildren: 0,
|
||||||
contradictoryDirectChildren: 0,
|
contradictoryDirectChildren: 0,
|
||||||
|
corroboratingBranchCount: 0,
|
||||||
|
conflictingBranchCount: 0,
|
||||||
|
duplicateEvidenceCount: 0,
|
||||||
|
independentBranchCount: 0,
|
||||||
|
interactionSummary: null,
|
||||||
confidenceCapReason: null,
|
confidenceCapReason: null,
|
||||||
ancestorPropagationStoppedReason: null,
|
ancestorPropagationStoppedReason: null,
|
||||||
affectedAncestorIds: [],
|
affectedAncestorIds: [],
|
||||||
@@ -552,6 +567,11 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
|||||||
unresolvedDirectChildren: applicationResult.unresolvedDirectChildren,
|
unresolvedDirectChildren: applicationResult.unresolvedDirectChildren,
|
||||||
contradictoryDirectChildren:
|
contradictoryDirectChildren:
|
||||||
applicationResult.contradictoryDirectChildren,
|
applicationResult.contradictoryDirectChildren,
|
||||||
|
corroboratingBranchCount: applicationResult.corroboratingBranchCount,
|
||||||
|
conflictingBranchCount: applicationResult.conflictingBranchCount,
|
||||||
|
duplicateEvidenceCount: applicationResult.duplicateEvidenceCount,
|
||||||
|
independentBranchCount: applicationResult.independentBranchCount,
|
||||||
|
interactionSummary: applicationResult.interactionSummary,
|
||||||
confidenceCapReason: applicationResult.confidenceCapReason,
|
confidenceCapReason: applicationResult.confidenceCapReason,
|
||||||
ancestorPropagationStoppedReason:
|
ancestorPropagationStoppedReason:
|
||||||
applicationResult.ancestorPropagationStoppedReason,
|
applicationResult.ancestorPropagationStoppedReason,
|
||||||
@@ -616,6 +636,11 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
|||||||
resolvedDirectChildren: 0,
|
resolvedDirectChildren: 0,
|
||||||
unresolvedDirectChildren: 0,
|
unresolvedDirectChildren: 0,
|
||||||
contradictoryDirectChildren: 0,
|
contradictoryDirectChildren: 0,
|
||||||
|
corroboratingBranchCount: 0,
|
||||||
|
conflictingBranchCount: 0,
|
||||||
|
duplicateEvidenceCount: 0,
|
||||||
|
independentBranchCount: 0,
|
||||||
|
interactionSummary: null,
|
||||||
confidenceCapReason: null,
|
confidenceCapReason: null,
|
||||||
ancestorPropagationStoppedReason: null,
|
ancestorPropagationStoppedReason: null,
|
||||||
affectedAncestorIds: [],
|
affectedAncestorIds: [],
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ describe("confidence propagation", () => {
|
|||||||
expect(parent.status).toBe("provisional");
|
expect(parent.status).toBe("provisional");
|
||||||
expect(parent.confidence).toBe("medium");
|
expect(parent.confidence).toBe("medium");
|
||||||
expect(parent.confidenceAssessment).toEqual({
|
expect(parent.confidenceAssessment).toEqual({
|
||||||
evidenceConfidence: "high",
|
evidenceConfidence: "medium",
|
||||||
completenessStatus: "partial",
|
completenessStatus: "partial",
|
||||||
conclusionConfidence: "medium",
|
conclusionConfidence: "medium",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyValidatedProposal,
|
||||||
|
evaluateBranchInteractions,
|
||||||
|
} from "@/lib/graph/apply-proposal.js";
|
||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function makeParentWithBranches(children) {
|
||||||
|
const parent = makeNode({
|
||||||
|
id: "n-parent",
|
||||||
|
label: "Explanation for why revenue increased while cash fell",
|
||||||
|
description:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
return makeGraph({
|
||||||
|
centralStatement: "Revenue increased while cash fell.",
|
||||||
|
nodes: [
|
||||||
|
parent,
|
||||||
|
...children.map((child) => ({ ...child, parentId: parent.id })),
|
||||||
|
],
|
||||||
|
edges: children.map((child, index) =>
|
||||||
|
makeEdge({
|
||||||
|
id: `e-${index + 1}`,
|
||||||
|
fromNodeId: child.id,
|
||||||
|
toNodeId: parent.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: `${child.label} feeds the parent.`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
activeUnknownNodeId: children[0]?.id ?? null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "cross-branch corroboration fixture",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResolvedChild(id, label, value, extra = {}) {
|
||||||
|
return makeNode({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description: label,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "resolved",
|
||||||
|
confidence: "medium",
|
||||||
|
value,
|
||||||
|
evidenceIds: extra.evidenceIds ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUnknownBranch(id, label, description, extra = {}) {
|
||||||
|
return makeNode({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
kind: "unknown",
|
||||||
|
status: extra.status ?? "unknown",
|
||||||
|
confidence: extra.confidence ?? "medium",
|
||||||
|
evidenceIds: extra.evidenceIds ?? [],
|
||||||
|
value: extra.value ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("evaluateBranchInteractions", () => {
|
||||||
|
it("detects corroborating independent branches", () => {
|
||||||
|
const graph = makeParentWithBranches([
|
||||||
|
makeResolvedChild("n-a", "Debtor balance increased", "bank-statement-a", {
|
||||||
|
evidenceIds: ["bank-statement-a"],
|
||||||
|
}),
|
||||||
|
makeResolvedChild(
|
||||||
|
"n-b",
|
||||||
|
"Cash receipts were delayed",
|
||||||
|
"receipts-ledger-b",
|
||||||
|
{ evidenceIds: ["receipts-ledger-b"] },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const parentNode = graph.nodes.find((node) => node.id === "n-parent");
|
||||||
|
|
||||||
|
const result = evaluateBranchInteractions({ parentNode, graph });
|
||||||
|
|
||||||
|
expect(result.interactionSummary.corroboratingBranchCount).toBe(1);
|
||||||
|
expect(result.interactionSummary.duplicateEvidenceCount).toBe(0);
|
||||||
|
expect(result.interactionSummary.conflictingBranchCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects duplicate evidence instead of corroboration", () => {
|
||||||
|
const graph = makeParentWithBranches([
|
||||||
|
makeResolvedChild(
|
||||||
|
"n-a",
|
||||||
|
"Bank statement shows increased debtor balance",
|
||||||
|
"same-bank",
|
||||||
|
{
|
||||||
|
evidenceIds: ["same-bank"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
makeResolvedChild(
|
||||||
|
"n-b",
|
||||||
|
"Delayed receipts also cite the bank statement",
|
||||||
|
"same-bank",
|
||||||
|
{
|
||||||
|
evidenceIds: ["same-bank"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const parentNode = graph.nodes.find((node) => node.id === "n-parent");
|
||||||
|
|
||||||
|
const result = evaluateBranchInteractions({ parentNode, graph });
|
||||||
|
|
||||||
|
expect(result.interactionSummary.duplicateEvidenceCount).toBe(1);
|
||||||
|
expect(result.interactionSummary.corroboratingBranchCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects conflicting branches", () => {
|
||||||
|
const graph = makeParentWithBranches([
|
||||||
|
makeResolvedChild("n-a", "Revenue recognised correctly", "correctly"),
|
||||||
|
makeNode({
|
||||||
|
id: "n-b",
|
||||||
|
label: "Revenue recognised incorrectly",
|
||||||
|
description: "Revenue recognised incorrectly",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "contradicted",
|
||||||
|
confidence: "medium",
|
||||||
|
value: "incorrectly",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const parentNode = graph.nodes.find((node) => node.id === "n-parent");
|
||||||
|
|
||||||
|
const result = evaluateBranchInteractions({ parentNode, graph });
|
||||||
|
|
||||||
|
expect(result.interactionSummary.conflictingBranchCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cross-branch corroboration effects", () => {
|
||||||
|
function applyToGraph(children, resolvedIds, contradictedIds = []) {
|
||||||
|
const graph = makeParentWithBranches(children);
|
||||||
|
return applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-anchor",
|
||||||
|
label: "Update anchor",
|
||||||
|
description:
|
||||||
|
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "low",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
...resolvedIds.map((id) => ({
|
||||||
|
nodeId: id,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: `answer:${id}`,
|
||||||
|
reason: "resolved child",
|
||||||
|
})),
|
||||||
|
...contradictedIds.map((id) => ({
|
||||||
|
nodeId: id,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "contradicted",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: `contradiction:${id}`,
|
||||||
|
reason: "contradicted child",
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: resolvedIds,
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
|
},
|
||||||
|
previousQuestion: "What evidence would clarify this branch?",
|
||||||
|
answer: "deterministic branch update",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("independent corroboration increases justified confidence without reaching high on incomplete parent", () => {
|
||||||
|
const result = applyToGraph(
|
||||||
|
[
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-a",
|
||||||
|
"Debtor balance increased",
|
||||||
|
"Debtor balance increased",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-b",
|
||||||
|
"Cash receipts delayed",
|
||||||
|
"Cash receipts delayed",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-c",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-d",
|
||||||
|
"Whether the two observations reflect different timing",
|
||||||
|
"Whether the two observations reflect different timing",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["n-a", "n-b"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.interactionSummary?.corroboratingBranchCount).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(result.interactionSummary?.duplicateEvidenceCount).toBe(0);
|
||||||
|
expect(result.parentConfidenceAfter).toBe("medium");
|
||||||
|
expect(result.confidenceCapReason).toBe(
|
||||||
|
"independent_corroboration_with_incomplete_parent",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicate evidence does not increase confidence", () => {
|
||||||
|
const result = applyToGraph(
|
||||||
|
[
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-a",
|
||||||
|
"Bank statement shows increased debtor balance",
|
||||||
|
"Bank statement shows increased debtor balance",
|
||||||
|
{ evidenceIds: ["same-bank"] },
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-b",
|
||||||
|
"Delayed receipts also cite the bank statement",
|
||||||
|
"Delayed receipts also cite the bank statement",
|
||||||
|
{ evidenceIds: ["same-bank"] },
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-c",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["n-a", "n-b"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.interactionSummary?.duplicateEvidenceCount).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(result.interactionSummary?.corroboratingBranchCount).toBe(0);
|
||||||
|
expect(result.confidenceCapReason).toBe(
|
||||||
|
"duplicate_evidence_no_extra_confidence",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conflicting evidence caps confidence", () => {
|
||||||
|
const result = applyToGraph(
|
||||||
|
[
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-a",
|
||||||
|
"Revenue recognised correctly",
|
||||||
|
"Revenue recognised correctly",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-b",
|
||||||
|
"Revenue recognised incorrectly",
|
||||||
|
"Revenue recognised incorrectly",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-c",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["n-a"],
|
||||||
|
["n-b"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.interactionSummary?.conflictingBranchCount).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(result.conclusionConfidenceAfter).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("independent branches stay interaction-neutral", () => {
|
||||||
|
const result = applyToGraph(
|
||||||
|
[
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-a",
|
||||||
|
"Marketing campaign changed traffic",
|
||||||
|
"Marketing campaign changed traffic",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-b",
|
||||||
|
"Equipment maintenance occurred",
|
||||||
|
"Equipment maintenance occurred",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["n-a"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.interactionSummary?.independentBranchCount).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -257,7 +257,7 @@ describe("upward propagation", () => {
|
|||||||
expect(result.parentConfidenceBefore).toBe("medium");
|
expect(result.parentConfidenceBefore).toBe("medium");
|
||||||
expect(result.parentConfidenceAfter).toBe("medium");
|
expect(result.parentConfidenceAfter).toBe("medium");
|
||||||
expect(result.evidenceConfidenceBefore).toBe("medium");
|
expect(result.evidenceConfidenceBefore).toBe("medium");
|
||||||
expect(result.evidenceConfidenceAfter).toBe("high");
|
expect(result.evidenceConfidenceAfter).toBe("medium");
|
||||||
expect(result.completenessBefore).toBe("empty");
|
expect(result.completenessBefore).toBe("empty");
|
||||||
expect(result.completenessAfter).toBe("partial");
|
expect(result.completenessAfter).toBe("partial");
|
||||||
expect(result.conclusionConfidenceBefore).toBe("low");
|
expect(result.conclusionConfidenceBefore).toBe("low");
|
||||||
@@ -287,7 +287,7 @@ describe("upward propagation", () => {
|
|||||||
status: "provisional",
|
status: "provisional",
|
||||||
confidence: "medium",
|
confidence: "medium",
|
||||||
confidenceAssessment: {
|
confidenceAssessment: {
|
||||||
evidenceConfidence: "high",
|
evidenceConfidence: "medium",
|
||||||
completenessStatus: "partial",
|
completenessStatus: "partial",
|
||||||
conclusionConfidence: "medium",
|
conclusionConfidence: "medium",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user