Files
confidence-engine/lib/graph/apply-proposal.js
T

644 lines
19 KiB
JavaScript

import { describeGraph } from "./builder.js";
import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
import {
applyGraphUpdate,
detectDuplicateNodeIds,
findAffectedNodes,
selectActiveUnknownCandidate,
validateGraphReferences,
validateGraphUpdate,
} from "./utils.js";
function cloneJsonSafe(value) {
return JSON.parse(JSON.stringify(value));
}
function zodIssuesToErrors(error) {
return (
error?.issues?.map((issue) => {
const path = issue.path?.length ? `${issue.path.join(".")}: ` : "";
return `${path}${issue.message}`;
}) ?? ["Validation failed"]
);
}
function collectDuplicateEdgeIds(edges) {
const counts = new Map();
for (const edge of edges) {
counts.set(edge.id, (counts.get(edge.id) ?? 0) + 1);
}
return [...counts.entries()]
.filter(([, count]) => count > 1)
.map(([edgeId, count]) => ({ edgeId, count }));
}
function normaliseText(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function buildNodeById(graph, addedNodes = []) {
return new Map(
[...graph.nodes, ...addedNodes].map((node) => [node.id, node]),
);
}
function isCompoundQuestion(question) {
if (typeof question !== "string") return false;
const trimmed = question.trim();
if (!trimmed) return false;
const questionMarks = (trimmed.match(/\?/g) || []).length;
if (questionMarks > 1) return true;
if (/\?\s*(and|or)\b/i.test(trimmed)) return true;
if (/\b(and|or)\b[^?]{0,60}\?/i.test(trimmed) && /,/.test(trimmed))
return true;
return false;
}
function validateAddedUnknowns(graph, proposal) {
const errors = [];
const addedUnknowns = proposal.addedNodes.filter(
(node) => node.kind === "unknown",
);
if (addedUnknowns.length > 3) {
errors.push(
`Proposal adds too many unknown nodes: ${addedUnknowns.length} (maximum 3)`,
);
}
const unresolvedExistingUnknowns = graph.nodes.filter(
(node) =>
node.kind === "unknown" &&
!proposal.resolvedUnknownNodeIds.includes(node.id),
);
const seenAddedUnknownMeanings = new Map();
const answerDerivedNodeIds = new Set([
...proposal.updatedNodes.map((update) => update.nodeId),
...proposal.resolvedUnknownNodeIds,
...proposal.addedNodes
.filter((node) => node.kind !== "unknown")
.map((node) => node.id),
]);
for (const unknownNode of addedUnknowns) {
const meaningKeys = [
normaliseText(unknownNode.label),
normaliseText(unknownNode.description),
].filter(Boolean);
for (const meaningKey of meaningKeys) {
if (seenAddedUnknownMeanings.has(meaningKey)) {
errors.push(
`Proposal adds duplicate unknown meaning: "${unknownNode.label}"`,
);
break;
}
seenAddedUnknownMeanings.set(meaningKey, unknownNode.id);
}
for (const existingUnknown of unresolvedExistingUnknowns) {
const existingMeaningKeys = [
normaliseText(existingUnknown.label),
normaliseText(existingUnknown.description),
].filter(Boolean);
if (meaningKeys.some((key) => existingMeaningKeys.includes(key))) {
errors.push(
`Proposal adds a node duplicating unresolved unknown: "${existingUnknown.id}"`,
);
break;
}
}
if (
unknownNode.description.trim() === unknownNode.label.trim() ||
!/\b(because|matters|important|needed|relevant|so that|to determine|to decide)\b/i.test(
unknownNode.description,
)
) {
errors.push(
`New unknown must include why it matters in its description: "${unknownNode.id}"`,
);
}
const connectedEdge = proposal.addedEdges.find(
(edge) =>
(edge.fromNodeId === unknownNode.id &&
answerDerivedNodeIds.has(edge.toNodeId)) ||
(edge.toNodeId === unknownNode.id &&
answerDerivedNodeIds.has(edge.fromNodeId)),
);
if (!connectedEdge) {
errors.push(
`New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`,
);
}
}
return errors;
}
function validateSelectedQuestion(graph, proposal) {
const errors = [];
const selectedQuestion = proposal.selectedQuestion;
const nodeById = buildNodeById(graph, proposal.addedNodes);
if (selectedQuestion == null) {
return { errors, selectedQuestionNodeId: null };
}
const node = nodeById.get(selectedQuestion.nodeId);
if (!node) {
errors.push(
`selectedQuestion references missing node: "${selectedQuestion.nodeId}"`,
);
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
}
if (node.kind !== "unknown") {
errors.push(
`selectedQuestion must reference an unknown node: "${selectedQuestion.nodeId}"`,
);
}
const resolvesNode = proposal.resolvedUnknownNodeIds.includes(
selectedQuestion.nodeId,
);
const updatedStatus = proposal.updatedNodes.find(
(update) => update.nodeId === selectedQuestion.nodeId,
)?.newStatus;
const effectiveStatus = updatedStatus ?? node.status;
if (resolvesNode || effectiveStatus === "resolved") {
errors.push(
`selectedQuestion must reference an unresolved node: "${selectedQuestion.nodeId}"`,
);
}
if (
graph.activeUnknownNodeId &&
proposal.resolvedUnknownNodeIds.includes(graph.activeUnknownNodeId) &&
selectedQuestion.nodeId === graph.activeUnknownNodeId
) {
errors.push(
`selectedQuestion cannot reselect the previous resolved unknown: "${selectedQuestion.nodeId}"`,
);
}
if (isCompoundQuestion(selectedQuestion.question)) {
errors.push("selectedQuestion must be a single non-compound question");
}
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
}
function validateQuestionSelectionRequirement(graph, proposal) {
const addedConsequentialUnknowns = proposal.addedNodes.filter(
(node) => node.kind === "unknown" && node.status !== "resolved",
);
if (
proposal.selectedQuestion == null &&
addedConsequentialUnknowns.length > 0
) {
return [
"selectedQuestion is required when consequential unresolved unknowns remain after resolving the answered unknown",
];
}
return [];
}
function buildResolvedUnknownUpdate(node) {
return {
nodeId: node.id,
previousStatus: node.status ?? null,
newStatus: "resolved",
previousValue: node.value ?? null,
newValue: node.value ?? null,
reason:
"Resolved because the proposal explicitly marked this unknown as resolved.",
};
}
function reconcileResolutionSemantics(graph, proposal) {
const nextProposal = cloneJsonSafe(proposal);
const errors = [];
const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node]));
const updatedNodeById = new Map(
nextProposal.updatedNodes.map((nodeUpdate) => [
nodeUpdate.nodeId,
nodeUpdate,
]),
);
for (const resolvedUnknownNodeId of nextProposal.resolvedUnknownNodeIds) {
const existingNode = graphNodeById.get(resolvedUnknownNodeId);
if (!existingNode) {
errors.push(
`Resolved unknown must reference an existing node: "${resolvedUnknownNodeId}"`,
);
continue;
}
if (existingNode.kind !== "unknown") {
errors.push(
`Resolved unknown must reference an existing unknown node: "${resolvedUnknownNodeId}"`,
);
continue;
}
const existingUpdate = updatedNodeById.get(resolvedUnknownNodeId);
if (!existingUpdate) {
const syntheticUpdate = buildResolvedUnknownUpdate(existingNode);
nextProposal.updatedNodes.push(syntheticUpdate);
updatedNodeById.set(resolvedUnknownNodeId, syntheticUpdate);
continue;
}
if (existingUpdate.newStatus !== "resolved") {
existingUpdate.newStatus = "resolved";
if (existingUpdate.previousStatus == null) {
existingUpdate.previousStatus = existingNode.status ?? null;
}
if (existingUpdate.previousValue === undefined) {
existingUpdate.previousValue = existingNode.value ?? null;
}
}
}
for (const update of nextProposal.updatedNodes) {
const existingNode = graphNodeById.get(update.nodeId);
if (
existingNode?.kind === "unknown" &&
update.newStatus === "resolved" &&
!nextProposal.resolvedUnknownNodeIds.includes(update.nodeId)
) {
errors.push(
`Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: "${update.nodeId}"`,
);
}
}
return {
proposal: nextProposal,
errors,
};
}
function validateSemanticDuplicateUnknowns(graph, proposal) {
const errors = [];
const unresolvedUnknowns = graph.nodes.filter(
(node) =>
node.kind === "unknown" &&
!proposal.resolvedUnknownNodeIds.includes(node.id),
);
for (const addedNode of proposal.addedNodes) {
const addedTexts = [
normaliseText(addedNode.label),
normaliseText(addedNode.description),
].filter(Boolean);
for (const unresolvedUnknown of unresolvedUnknowns) {
const unresolvedTexts = [
normaliseText(unresolvedUnknown.label),
normaliseText(unresolvedUnknown.description),
].filter(Boolean);
const duplicatesMeaning = addedTexts.some((text) =>
unresolvedTexts.includes(text),
);
if (!duplicatesMeaning) continue;
const linkedToUnknown = proposal.addedEdges.some(
(edge) =>
(edge.fromNodeId === addedNode.id &&
edge.toNodeId === unresolvedUnknown.id) ||
(edge.toNodeId === addedNode.id &&
edge.fromNodeId === unresolvedUnknown.id),
);
const updatedUnknown = proposal.updatedNodes.some(
(update) => update.nodeId === unresolvedUnknown.id,
);
if (!linkedToUnknown && !updatedUnknown) {
errors.push(
`Proposal adds a node duplicating unresolved unknown meaning without linking or resolving it: "${unresolvedUnknown.id}"`,
);
}
}
}
return errors;
}
function buildAffectedNodeIds(graph, proposal) {
const affected = new Set(proposal.affectedNodeIds ?? []);
for (const update of proposal.updatedNodes ?? []) {
affected.add(update.nodeId);
for (const nodeId of findAffectedNodes(graph, update.nodeId)) {
affected.add(nodeId);
}
}
for (const nodeId of proposal.resolvedUnknownNodeIds ?? []) {
affected.add(nodeId);
for (const affectedNodeId of findAffectedNodes(graph, nodeId)) {
affected.add(affectedNodeId);
}
}
return [...affected];
}
function buildChangesApplied(proposal, affectedNodeIds) {
return {
addedNodeCount: proposal.addedNodes.length,
addedUnknownCount: proposal.addedNodes.filter(
(node) => node.kind === "unknown",
).length,
updatedNodeCount: proposal.updatedNodes.length,
addedEdgeCount: proposal.addedEdges.length,
removedEdgeCount: proposal.removedEdgeIds.length,
resolvedUnknownCount: proposal.resolvedUnknownNodeIds.length,
affectedNodeCount: affectedNodeIds.length,
};
}
export function applyValidatedProposal({ situationGraph, proposal }) {
const graphValidation = situationGraphSchema.safeParse(situationGraph);
const proposalValidation = graphUpdateSchema.safeParse(proposal);
const existingGraphReferenceValidation = graphValidation.success
? validateGraphReferences(situationGraph)
: null;
const existingDuplicateNodeIds = graphValidation.success
? detectDuplicateNodeIds(situationGraph.nodes)
: [];
const existingDuplicateEdgeIds = graphValidation.success
? collectDuplicateEdgeIds(situationGraph.edges)
: [];
if (
!graphValidation.success ||
!existingGraphReferenceValidation?.valid ||
existingDuplicateNodeIds.length > 0 ||
existingDuplicateEdgeIds.length > 0
) {
return {
success: false,
stage: "graph_validation",
errors: [
...(!graphValidation.success
? zodIssuesToErrors(graphValidation.error)
: []),
...(!existingGraphReferenceValidation?.valid
? existingGraphReferenceValidation.errors
: []),
...existingDuplicateNodeIds.map(
({ nodeId, count }) =>
`Graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
),
...existingDuplicateEdgeIds.map(
({ edgeId, count }) =>
`Graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
),
],
};
}
if (!proposalValidation.success) {
return {
success: false,
stage: "proposal_compatibility",
errors: zodIssuesToErrors(proposalValidation.error),
};
}
const reconciledProposal = reconcileResolutionSemantics(
situationGraph,
proposalValidation.data,
);
const validatedProposal = reconciledProposal.proposal;
const proposalCompatibilityErrors = [];
proposalCompatibilityErrors.push(...reconciledProposal.errors);
const proposalGraphValidation = validateGraphUpdate(
situationGraph,
validatedProposal,
);
if (!proposalGraphValidation.valid) {
proposalCompatibilityErrors.push(...proposalGraphValidation.errors);
}
const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id));
const reachableNodeIds = new Set([
...situationGraph.nodes.map((node) => node.id),
...validatedProposal.addedNodes.map((node) => node.id),
]);
const addedEdgeDuplicateIds = collectDuplicateEdgeIds(
validatedProposal.addedEdges,
);
proposalCompatibilityErrors.push(
...addedEdgeDuplicateIds.map(
({ edgeId, count }) =>
`Proposal contains duplicate added edge ID: "${edgeId}" (${count} occurrences)`,
),
);
for (const edge of validatedProposal.addedEdges) {
if (existingEdgeIds.has(edge.id)) {
proposalCompatibilityErrors.push(
`Cannot add edge with duplicate ID: "${edge.id}"`,
);
}
if (!reachableNodeIds.has(edge.fromNodeId)) {
proposalCompatibilityErrors.push(
`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`,
);
}
if (!reachableNodeIds.has(edge.toNodeId)) {
proposalCompatibilityErrors.push(
`Added edge references non-existent toNodeId: "${edge.toNodeId}"`,
);
}
}
const removedEdgeIds = new Set(validatedProposal.removedEdgeIds);
for (const edgeId of removedEdgeIds) {
if (!existingEdgeIds.has(edgeId)) {
proposalCompatibilityErrors.push(
`Cannot remove non-existent edge: "${edgeId}"`,
);
}
}
const combinedNodeDuplicates = detectDuplicateNodeIds([
...situationGraph.nodes,
...validatedProposal.addedNodes,
]);
proposalCompatibilityErrors.push(
...combinedNodeDuplicates.map(
({ nodeId, count }) =>
`Proposal would produce duplicate node ID: "${nodeId}" (${count} occurrences)`,
),
);
proposalCompatibilityErrors.push(
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal),
);
proposalCompatibilityErrors.push(
...validateAddedUnknowns(situationGraph, validatedProposal),
);
const selectedQuestionValidation = validateSelectedQuestion(
situationGraph,
validatedProposal,
);
proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
proposalCompatibilityErrors.push(
...validateQuestionSelectionRequirement(situationGraph, validatedProposal),
);
if (proposalCompatibilityErrors.length > 0) {
return {
success: false,
stage: "proposal_compatibility",
errors: proposalCompatibilityErrors,
};
}
const graphSnapshot = cloneJsonSafe(situationGraph);
const proposalSnapshot = cloneJsonSafe(validatedProposal);
const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null;
const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot);
const applied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
if (!applied.success) {
return {
success: false,
stage: "application",
errors: applied.errors,
};
}
const updatedSituationGraph = {
...graphSnapshot,
nodes: applied.nodes,
edges: applied.edges,
resolvedNodeIds: applied.resolvedNodeIds,
};
const activeUnknownWasResolved =
previousActiveUnknownNodeId != null &&
updatedSituationGraph.resolvedNodeIds.includes(previousActiveUnknownNodeId);
let newActiveUnknownNodeId = previousActiveUnknownNodeId;
if (activeUnknownWasResolved) {
newActiveUnknownNodeId = null;
}
if (validatedProposal.selectedQuestion?.nodeId) {
newActiveUnknownNodeId = validatedProposal.selectedQuestion.nodeId;
}
const remainingUnknownExists =
newActiveUnknownNodeId != null &&
updatedSituationGraph.nodes.some(
(node) =>
node.id === newActiveUnknownNodeId &&
node.kind === "unknown" &&
!updatedSituationGraph.resolvedNodeIds.includes(node.id),
);
if (!remainingUnknownExists) {
newActiveUnknownNodeId =
selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
)?.nodeId ?? null;
}
if (
validatedProposal.selectedQuestion?.nodeId &&
newActiveUnknownNodeId !== validatedProposal.selectedQuestion.nodeId
) {
return {
success: false,
stage: "proposal_compatibility",
errors: [
`activeUnknownNodeId and selectedQuestion.nodeId disagree: "${newActiveUnknownNodeId}" vs "${validatedProposal.selectedQuestion.nodeId}"`,
],
};
}
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
const resultGraphValidation = situationGraphSchema.safeParse(
updatedSituationGraph,
);
const resultReferenceValidation = resultGraphValidation.success
? validateGraphReferences(updatedSituationGraph)
: null;
const resultDuplicateNodeIds = resultGraphValidation.success
? detectDuplicateNodeIds(updatedSituationGraph.nodes)
: [];
const resultDuplicateEdgeIds = resultGraphValidation.success
? collectDuplicateEdgeIds(updatedSituationGraph.edges)
: [];
if (
!resultGraphValidation.success ||
!resultReferenceValidation?.valid ||
resultDuplicateNodeIds.length > 0 ||
resultDuplicateEdgeIds.length > 0
) {
return {
success: false,
stage: "result_validation",
errors: [
...(!resultGraphValidation.success
? zodIssuesToErrors(resultGraphValidation.error)
: []),
...(!resultReferenceValidation?.valid
? resultReferenceValidation.errors
: []),
...resultDuplicateNodeIds.map(
({ nodeId, count }) =>
`Updated graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
),
...resultDuplicateEdgeIds.map(
({ edgeId, count }) =>
`Updated graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
),
],
};
}
return {
success: true,
updatedSituationGraph,
graphUpdate: validatedProposal,
affectedNodeIds,
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
previousActiveUnknownNodeId,
newActiveUnknownNodeId,
selectedQuestion: validatedProposal.selectedQuestion,
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
graphReferenceValidation: resultReferenceValidation,
};
}