feat: prioritise follow-up questions by information value
This commit is contained in:
+38
-12
@@ -2,8 +2,10 @@ import { describeGraph } from "./builder.js";
|
|||||||
import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
|
import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
|
||||||
import {
|
import {
|
||||||
applyGraphUpdate,
|
applyGraphUpdate,
|
||||||
|
buildDeterministicQuestionForUnknown,
|
||||||
detectDuplicateNodeIds,
|
detectDuplicateNodeIds,
|
||||||
findAffectedNodes,
|
findAffectedNodes,
|
||||||
|
scoreUnknownCandidate,
|
||||||
selectActiveUnknownCandidate,
|
selectActiveUnknownCandidate,
|
||||||
validateGraphReferences,
|
validateGraphReferences,
|
||||||
validateGraphUpdate,
|
validateGraphUpdate,
|
||||||
@@ -195,6 +197,20 @@ function validateSelectedQuestion(graph, proposal) {
|
|||||||
errors.push("selectedQuestion must be a single non-compound question");
|
errors.push("selectedQuestion must be a single non-compound question");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedNodeIds = [
|
||||||
|
...(graph.resolvedNodeIds || []),
|
||||||
|
...(proposal.resolvedUnknownNodeIds || []),
|
||||||
|
];
|
||||||
|
const candidateScore = scoreUnknownCandidate(
|
||||||
|
{
|
||||||
|
...graph,
|
||||||
|
nodes: [...graph.nodes, ...(proposal.addedNodes || [])],
|
||||||
|
edges: [...graph.edges, ...(proposal.addedEdges || [])],
|
||||||
|
},
|
||||||
|
node,
|
||||||
|
resolvedNodeIds,
|
||||||
|
);
|
||||||
|
|
||||||
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
|
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,22 +587,32 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
|
|||||||
)?.nodeId ?? null;
|
)?.nodeId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const deterministicSelection = selectActiveUnknownCandidate(
|
||||||
validatedProposal.selectedQuestion?.nodeId &&
|
updatedSituationGraph,
|
||||||
newActiveUnknownNodeId !== validatedProposal.selectedQuestion.nodeId
|
updatedSituationGraph.resolvedNodeIds,
|
||||||
) {
|
);
|
||||||
return {
|
|
||||||
success: false,
|
if (deterministicSelection?.nodeId) {
|
||||||
stage: "proposal_compatibility",
|
newActiveUnknownNodeId = deterministicSelection.nodeId;
|
||||||
errors: [
|
|
||||||
`activeUnknownNodeId and selectedQuestion.nodeId disagree: "${newActiveUnknownNodeId}" vs "${validatedProposal.selectedQuestion.nodeId}"`,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
|
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
|
||||||
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
|
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
|
||||||
|
|
||||||
|
const finalSelectedQuestion = deterministicSelection
|
||||||
|
? {
|
||||||
|
nodeId: deterministicSelection.nodeId,
|
||||||
|
question:
|
||||||
|
deterministicSelection.question ||
|
||||||
|
buildDeterministicQuestionForUnknown(
|
||||||
|
updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === deterministicSelection.nodeId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
reason: deterministicSelection.reason,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
const resultGraphValidation = situationGraphSchema.safeParse(
|
const resultGraphValidation = situationGraphSchema.safeParse(
|
||||||
updatedSituationGraph,
|
updatedSituationGraph,
|
||||||
);
|
);
|
||||||
@@ -636,7 +662,7 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
|
|||||||
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
|
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
|
||||||
previousActiveUnknownNodeId,
|
previousActiveUnknownNodeId,
|
||||||
newActiveUnknownNodeId,
|
newActiveUnknownNodeId,
|
||||||
selectedQuestion: validatedProposal.selectedQuestion,
|
selectedQuestion: finalSelectedQuestion,
|
||||||
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
|
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
|
||||||
graphReferenceValidation: resultReferenceValidation,
|
graphReferenceValidation: resultReferenceValidation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,15 +101,16 @@ The JSON object must contain exactly these top-level fields:
|
|||||||
13a. For every new unknown node, include at least one added edge that connects it to an existing updated/resolved node or to a newly added non-unknown node introduced from the answer.
|
13a. For every new unknown node, include at least one added edge that connects it to an existing updated/resolved node or to a newly added non-unknown node introduced from the answer.
|
||||||
14. Do not invent evidence.
|
14. Do not invent evidence.
|
||||||
15. Do not create unsupported causal edges.
|
15. Do not create unsupported causal edges.
|
||||||
16. Select exactly one new active unknown in selectedQuestion when any consequential unresolved unknown exists.
|
16. If consequential unresolved unknowns exist, selectedQuestion may identify one valid candidate unknown, but the engine will deterministically choose final priority after validation.
|
||||||
17. selectedQuestion.nodeId must reference an unresolved unknown node that exists either already in the graph or in addedNodes.
|
17. selectedQuestion.nodeId must reference an unresolved unknown node that exists either already in the graph or in addedNodes.
|
||||||
18. selectedQuestion.question must be one narrow non-compound question about that one unknown.
|
18. selectedQuestion.question must be one narrow non-compound question about that one unknown.
|
||||||
19. Return selectedQuestion as null only when no consequential unresolved unknown remains.
|
19. Do not prioritise downstream implementation, pricing, optimisation, or speculative branches ahead of prerequisite definitions, actors, success criteria, constraints, measures, or terminology.
|
||||||
20. Use empty arrays when there are no changes in a category.
|
20. Return selectedQuestion as null only when no consequential unresolved unknown remains.
|
||||||
21. Never return null array entries.
|
21. Use empty arrays when there are no changes in a category.
|
||||||
22. Never use unknown enum values.
|
22. Never return null array entries.
|
||||||
23. Do not change existing IDs.
|
23. Never use unknown enum values.
|
||||||
24. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal.
|
24. Do not change existing IDs.
|
||||||
|
25. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal.
|
||||||
|
|
||||||
## Additional Guidance
|
## Additional Guidance
|
||||||
- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes.
|
- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes.
|
||||||
@@ -117,6 +118,7 @@ The JSON object must contain exactly these top-level fields:
|
|||||||
- If the answer creates a more specific decision situation, add the smallest set of new nodes and edges needed to represent that situation and only its most consequential unknowns.
|
- If the answer creates a more specific decision situation, add the smallest set of new nodes and edges needed to represent that situation and only its most consequential unknowns.
|
||||||
- If you add a new unknown, do not leave it floating: connect it with an added edge to the relevant decision/context node created or updated from the answer.
|
- If you add a new unknown, do not leave it floating: connect it with an added edge to the relevant decision/context node created or updated from the answer.
|
||||||
- If you add a new unknown, its description must do two jobs in one sentence: what is unknown, and why resolving it matters for the case.
|
- If you add a new unknown, its description must do two jobs in one sentence: what is unknown, and why resolving it matters for the case.
|
||||||
|
- Treat selectedQuestion as a candidate only; the engine will apply deterministic information-value scoring after validation.
|
||||||
- If the answer does not justify a change, return empty arrays for every category.
|
- If the answer does not justify a change, return empty arrays for every category.
|
||||||
|
|
||||||
## Example Constraint Reminder
|
## Example Constraint Reminder
|
||||||
|
|||||||
+234
-29
@@ -5,7 +5,171 @@
|
|||||||
* and these utilities apply them safely.
|
* and these utilities apply them safely.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { situationNodeSchema, situationEdgeSchema, situationGraphSchema } from "./schema.js";
|
import {
|
||||||
|
situationNodeSchema,
|
||||||
|
situationEdgeSchema,
|
||||||
|
situationGraphSchema,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
function normaliseText(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNodeText(node) {
|
||||||
|
return `${node?.label || ""} ${node?.description || ""}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function countIncomingUnknownDependencies(graph, nodeId, resolvedNodeIds) {
|
||||||
|
const resolvedSet = new Set(resolvedNodeIds || []);
|
||||||
|
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
||||||
|
const incoming = new Set();
|
||||||
|
|
||||||
|
for (const dependencyId of nodesById.get(nodeId)?.dependsOn || []) {
|
||||||
|
const dependencyNode = nodesById.get(dependencyId);
|
||||||
|
if (dependencyNode?.kind === "unknown" && !resolvedSet.has(dependencyId)) {
|
||||||
|
incoming.add(dependencyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (edge.toNodeId !== nodeId) continue;
|
||||||
|
const dependencyNode = nodesById.get(edge.fromNodeId);
|
||||||
|
if (
|
||||||
|
dependencyNode?.kind === "unknown" &&
|
||||||
|
!resolvedSet.has(edge.fromNodeId)
|
||||||
|
) {
|
||||||
|
incoming.add(edge.fromNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return incoming.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyUnknownPriority(text) {
|
||||||
|
const normalised = normaliseText(text);
|
||||||
|
|
||||||
|
const matches = {
|
||||||
|
objective:
|
||||||
|
/\b(objective|goal|outcome|value|problem|job to be done|benefit|commercial value)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
actor:
|
||||||
|
/\b(customer|user|buyer|actor|stakeholder|audience|recipient)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
criteria:
|
||||||
|
/\b(success criteria|success threshold|threshold|decision criteria|criterion|justify|sufficient)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
measure:
|
||||||
|
/\b(metric|measure|measurable|roi|demand|evidence|signal|proof)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
terminology: /\b(define|definition|meaning|means|term|terminology)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
constraint:
|
||||||
|
/\b(constraint|limit|budget|deadline|requirement|regulation)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
pricing: /\b(price|pricing|price point|subscription|charge|pay for)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
implementation:
|
||||||
|
/\b(implementation|build approach|architecture|stack|feature|technical design)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
optimisation:
|
||||||
|
/\b(optimisation|optimi[sz]ation|improve|efficiency|performance|scale)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
speculative:
|
||||||
|
/\b(maybe|possible|optional|future branch|nice to have|slogan|colour|color|ui)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
|
||||||
|
const text = collectNodeText(node);
|
||||||
|
const matches = classifyUnknownPriority(text);
|
||||||
|
const downstreamCount = findDependentNodes(graph, node.id).length;
|
||||||
|
const unresolvedParentUnknownCount = countIncomingUnknownDependencies(
|
||||||
|
graph,
|
||||||
|
node.id,
|
||||||
|
resolvedNodeIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
let score = downstreamCount * 4;
|
||||||
|
|
||||||
|
if (matches.objective) score += 12;
|
||||||
|
if (matches.actor) score += 10;
|
||||||
|
if (matches.criteria) score += 11;
|
||||||
|
if (matches.measure) score += 8;
|
||||||
|
if (matches.terminology) score += 7;
|
||||||
|
if (matches.constraint) score += 9;
|
||||||
|
|
||||||
|
if (matches.pricing) score -= 8;
|
||||||
|
if (matches.implementation) score -= 10;
|
||||||
|
if (matches.optimisation) score -= 9;
|
||||||
|
if (matches.speculative) score -= 12;
|
||||||
|
|
||||||
|
if (
|
||||||
|
matches.pricing &&
|
||||||
|
!matches.objective &&
|
||||||
|
!matches.criteria &&
|
||||||
|
!matches.actor
|
||||||
|
) {
|
||||||
|
score -= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
score -= unresolvedParentUnknownCount * 7;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeId: node.id,
|
||||||
|
label: node.label,
|
||||||
|
score,
|
||||||
|
downstreamCount,
|
||||||
|
unresolvedParentUnknownCount,
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeterministicQuestionForUnknown(node) {
|
||||||
|
const text = normaliseText(collectNodeText(node));
|
||||||
|
|
||||||
|
if (
|
||||||
|
/\b(success criteria|success threshold|threshold|decision criteria|criterion)\b/.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return `What outcome would define success for ${node.label}?`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/\b(customer|user|buyer|actor|stakeholder|audience|recipient)\b/.test(text)
|
||||||
|
) {
|
||||||
|
return `Who is the key actor or customer for ${node.label}?`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/\b(define|definition|meaning|means|term|terminology|value)\b/.test(text)
|
||||||
|
) {
|
||||||
|
return `How should ${node.label} be defined for this decision?`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/\b(metric|measure|measurable|roi|demand|evidence|signal|proof)\b/.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return `What evidence or measure would resolve ${node.label}?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `What would resolve ${node.label}?`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Validate that all edge references point to existing nodes ──
|
// ── Validate that all edge references point to existing nodes ──
|
||||||
|
|
||||||
@@ -15,16 +179,22 @@ export function validateGraphReferences(graph) {
|
|||||||
|
|
||||||
for (const node of graph.nodes) {
|
for (const node of graph.nodes) {
|
||||||
if (node.parentId !== null && !nodeIds.has(node.parentId)) {
|
if (node.parentId !== null && !nodeIds.has(node.parentId)) {
|
||||||
errors.push(`Node "${node.id}" references parentId "${node.parentId}" which does not exist`);
|
errors.push(
|
||||||
|
`Node "${node.id}" references parentId "${node.parentId}" which does not exist`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const cid of node.childIds) {
|
for (const cid of node.childIds) {
|
||||||
if (!nodeIds.has(cid)) {
|
if (!nodeIds.has(cid)) {
|
||||||
errors.push(`Node "${node.id}" references childIds "${cid}" which does not exist`);
|
errors.push(
|
||||||
|
`Node "${node.id}" references childIds "${cid}" which does not exist`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const dep of node.dependsOn) {
|
for (const dep of node.dependsOn) {
|
||||||
if (!nodeIds.has(dep)) {
|
if (!nodeIds.has(dep)) {
|
||||||
errors.push(`Node "${node.id}" depends on "${dep}" which does not exist`);
|
errors.push(
|
||||||
|
`Node "${node.id}" depends on "${dep}" which does not exist`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const aff of node.affects) {
|
for (const aff of node.affects) {
|
||||||
@@ -36,10 +206,14 @@ export function validateGraphReferences(graph) {
|
|||||||
|
|
||||||
for (const edge of graph.edges) {
|
for (const edge of graph.edges) {
|
||||||
if (!nodeIds.has(edge.fromNodeId)) {
|
if (!nodeIds.has(edge.fromNodeId)) {
|
||||||
errors.push(`Edge "${edge.id}" references non-existent fromNodeId "${edge.fromNodeId}"`);
|
errors.push(
|
||||||
|
`Edge "${edge.id}" references non-existent fromNodeId "${edge.fromNodeId}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!nodeIds.has(edge.toNodeId)) {
|
if (!nodeIds.has(edge.toNodeId)) {
|
||||||
errors.push(`Edge "${edge.id}" references non-existent toNodeId "${edge.toNodeId}"`);
|
errors.push(
|
||||||
|
`Edge "${edge.id}" references non-existent toNodeId "${edge.toNodeId}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +254,12 @@ export function detectDuplicateEdges(edges) {
|
|||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
const key = `${edge.fromNodeId}->${edge.toNodeId}:${edge.relationship}`;
|
const key = `${edge.fromNodeId}->${edge.toNodeId}:${edge.relationship}`;
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) {
|
||||||
duplicates.push({ edgeId: edge.id, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId, relationship: edge.relationship });
|
duplicates.push({
|
||||||
|
edgeId: edge.id,
|
||||||
|
fromNodeId: edge.fromNodeId,
|
||||||
|
toNodeId: edge.toNodeId,
|
||||||
|
relationship: edge.relationship,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
}
|
}
|
||||||
@@ -91,7 +270,9 @@ export function detectDuplicateEdges(edges) {
|
|||||||
// ── Find all nodes that depend on a given node (transitive) ──
|
// ── Find all nodes that depend on a given node (transitive) ──
|
||||||
|
|
||||||
export function findDependentNodes(graph, nodeId) {
|
export function findDependentNodes(graph, nodeId) {
|
||||||
const direct = graph.nodes.filter((n) => n.dependsOn.includes(nodeId)).map((n) => n.id);
|
const direct = graph.nodes
|
||||||
|
.filter((n) => n.dependsOn.includes(nodeId))
|
||||||
|
.map((n) => n.id);
|
||||||
const affected = new Set(direct);
|
const affected = new Set(direct);
|
||||||
|
|
||||||
// Also propagate through edges where the relationship is depends_on
|
// Also propagate through edges where the relationship is depends_on
|
||||||
@@ -124,10 +305,14 @@ export function findDependentNodes(graph, nodeId) {
|
|||||||
export function findAffectedNodes(graph, nodeId) {
|
export function findAffectedNodes(graph, nodeId) {
|
||||||
// Direct effects: two sources
|
// Direct effects: two sources
|
||||||
// 1. Nodes that depend on this node (they list it in their dependsOn)
|
// 1. Nodes that depend on this node (they list it in their dependsOn)
|
||||||
const directFromDepends = graph.nodes.filter((n) => n.id !== nodeId && n.dependsOn.includes(nodeId)).map((n) => n.id);
|
const directFromDepends = graph.nodes
|
||||||
|
.filter((n) => n.id !== nodeId && n.dependsOn.includes(nodeId))
|
||||||
|
.map((n) => n.id);
|
||||||
|
|
||||||
// 2. Targets of the node's affects relationships (this node directly affects them)
|
// 2. Targets of the node's affects relationships (this node directly affects them)
|
||||||
const myAffectedTargets = new Set(graph.nodes.find((n) => n.id === nodeId)?.affects || []);
|
const myAffectedTargets = new Set(
|
||||||
|
graph.nodes.find((n) => n.id === nodeId)?.affects || [],
|
||||||
|
);
|
||||||
|
|
||||||
// Merge: also add edge targets where this node is the source
|
// Merge: also add edge targets where this node is the source
|
||||||
for (const edge of graph.edges) {
|
for (const edge of graph.edges) {
|
||||||
@@ -147,7 +332,11 @@ export function findAffectedNodes(graph, nodeId) {
|
|||||||
if (!current || !affected.has(current)) continue;
|
if (!current || !affected.has(current)) continue;
|
||||||
|
|
||||||
for (const node of graph.nodes) {
|
for (const node of graph.nodes) {
|
||||||
if (node.id !== nodeId && !affected.has(node.id) && (node.dependsOn.includes(current) || node.affects.includes(current))) {
|
if (
|
||||||
|
node.id !== nodeId &&
|
||||||
|
!affected.has(node.id) &&
|
||||||
|
(node.dependsOn.includes(current) || node.affects.includes(current))
|
||||||
|
) {
|
||||||
affected.add(node.id);
|
affected.add(node.id);
|
||||||
queue.push(node.id);
|
queue.push(node.id);
|
||||||
}
|
}
|
||||||
@@ -184,26 +373,37 @@ export function resolveUnknownNode(graph, nodeId, newStatus, newValue, reason) {
|
|||||||
export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
|
export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
|
||||||
// Skip already resolved nodes
|
// Skip already resolved nodes
|
||||||
const unresolved = graph.nodes.filter(
|
const unresolved = graph.nodes.filter(
|
||||||
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id)
|
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (unresolved.length === 0) return null;
|
if (unresolved.length === 0) return null;
|
||||||
|
|
||||||
// Prioritise: critical unknowns first, then those that are depended upon most
|
const scoredCandidates = unresolved.map((node) => ({
|
||||||
const dependencyCount = unresolved.map((n) => {
|
node,
|
||||||
const deps = findDependentNodes(graph, n.id).length;
|
...scoreUnknownCandidate(graph, node, resolvedNodeIds),
|
||||||
const importanceOrder = { critical: 3, important: 2, supporting: 1, incidental: 0 };
|
}));
|
||||||
const impScore = importanceOrder[n.confidence] || 0;
|
|
||||||
return { node: n, score: deps * 2 + impScore };
|
scoredCandidates.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
if (b.downstreamCount !== a.downstreamCount) {
|
||||||
|
return b.downstreamCount - a.downstreamCount;
|
||||||
|
}
|
||||||
|
if (a.unresolvedParentUnknownCount !== b.unresolvedParentUnknownCount) {
|
||||||
|
return a.unresolvedParentUnknownCount - b.unresolvedParentUnknownCount;
|
||||||
|
}
|
||||||
|
return a.node.label.localeCompare(b.node.label);
|
||||||
});
|
});
|
||||||
|
|
||||||
dependencyCount.sort((a, b) => b.score - a.score);
|
const best = scoredCandidates[0];
|
||||||
|
|
||||||
// Return the highest-scoring unresolved unknown
|
|
||||||
const best = dependencyCount[0];
|
|
||||||
if (!best) return null;
|
if (!best) return null;
|
||||||
|
|
||||||
return { nodeId: best.node.id, label: best.node.label, score: best.score };
|
return {
|
||||||
|
nodeId: best.node.id,
|
||||||
|
label: best.node.label,
|
||||||
|
score: best.score,
|
||||||
|
question: buildDeterministicQuestionForUnknown(best.node),
|
||||||
|
reason: `Selected for highest information value (score ${best.score}) with ${best.downstreamCount} downstream dependency node(s) and ${best.unresolvedParentUnknownCount} unresolved prerequisite unknown(s).`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Apply a graph update deterministically ──
|
// ── Apply a graph update deterministically ──
|
||||||
@@ -232,10 +432,14 @@ export function applyGraphUpdate(graph, update) {
|
|||||||
// Validate added edges reference existing or new nodes
|
// Validate added edges reference existing or new nodes
|
||||||
for (const edge of update.addedEdges) {
|
for (const edge of update.addedEdges) {
|
||||||
if (!allNodeIds.has(edge.fromNodeId)) {
|
if (!allNodeIds.has(edge.fromNodeId)) {
|
||||||
errors.push(`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`);
|
errors.push(
|
||||||
|
`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!allNodeIds.has(edge.toNodeId)) {
|
if (!allNodeIds.has(edge.toNodeId)) {
|
||||||
errors.push(`Added edge references non-existent toNodeId: "${edge.toNodeId}"`);
|
errors.push(
|
||||||
|
`Added edge references non-existent toNodeId: "${edge.toNodeId}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +489,9 @@ export function applyGraphUpdate(graph, update) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add resolved node IDs
|
// Add resolved node IDs
|
||||||
const newResolved = [...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds])];
|
const newResolved = [
|
||||||
|
...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds]),
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -320,10 +526,10 @@ export function validateGraphUpdate(graph, update) {
|
|||||||
|
|
||||||
// Reject updates with no meaningful change
|
// Reject updates with no meaningful change
|
||||||
const statusChanged = update.updatedNodes.some(
|
const statusChanged = update.updatedNodes.some(
|
||||||
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus
|
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus,
|
||||||
);
|
);
|
||||||
const valueChanged = update.updatedNodes.some(
|
const valueChanged = update.updatedNodes.some(
|
||||||
(u) => u.previousValue !== null && u.newValue !== u.previousValue
|
(u) => u.previousValue !== null && u.newValue !== u.previousValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasMeaningfulChange =
|
const hasMeaningfulChange =
|
||||||
@@ -345,4 +551,3 @@ export function validateGraphUpdate(graph, update) {
|
|||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
return { valid: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -546,7 +546,10 @@ describe("applyValidatedProposal", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
||||||
expect(result.selectedQuestion).toEqual(proposal.selectedQuestion);
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||||
|
expect(result.selectedQuestion?.question).toContain(
|
||||||
|
"Commercial value definition",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects more than 3 added unknowns", () => {
|
it("rejects more than 3 added unknowns", () => {
|
||||||
@@ -699,4 +702,92 @@ describe("applyValidatedProposal", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.newActiveUnknownNodeId).toBe(result.selectedQuestion?.nodeId);
|
expect(result.newActiveUnknownNodeId).toBe(result.selectedQuestion?.nodeId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces downstream pricing question with higher-value commercial-value question", () => {
|
||||||
|
const { graph, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const proposal = {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-commercial-value",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need commercial value definition because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-pricing",
|
||||||
|
label: "Target price point",
|
||||||
|
description:
|
||||||
|
"Need a price point because revenue assumptions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-commercial-value"],
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-build-decision",
|
||||||
|
label: "Build Confidence Engine decision",
|
||||||
|
description: "Decision introduced by the answer.",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: ids.complaintRateUnknown,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: "Decision whether to build Confidence Engine",
|
||||||
|
reason: "The answer resolves the original context unknown.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-build-commercial-value",
|
||||||
|
fromNodeId: "n-build-decision",
|
||||||
|
toNodeId: "n-commercial-value",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "The decision depends on defining commercial value.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: "e-commercial-value-pricing",
|
||||||
|
fromNodeId: "n-commercial-value",
|
||||||
|
toNodeId: "n-pricing",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "Pricing depends on commercial value definition.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: "e-build-pricing",
|
||||||
|
fromNodeId: "n-build-decision",
|
||||||
|
toNodeId: "n-pricing",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "low",
|
||||||
|
description: "The decision also references pricing assumptions.",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-pricing",
|
||||||
|
question: "What is the target price point?",
|
||||||
|
reason: "Model chose a downstream leaf.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||||
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
||||||
|
"price",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -582,14 +582,89 @@ describe("lib/graph/orchestrator startCase", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.selectedQuestion).toEqual({
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||||
nodeId: "n-commercial-value",
|
|
||||||
question: "How should commercial value be defined for this decision?",
|
|
||||||
reason: "Consequential unresolved uncertainty remains.",
|
|
||||||
});
|
|
||||||
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deterministically prioritises customer value over pricing follow-up", async () => {
|
||||||
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
|
const provider = {
|
||||||
|
generateReconstruction: vi.fn().mockResolvedValue(
|
||||||
|
makeProposal({
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-value",
|
||||||
|
label: "Customer value",
|
||||||
|
description:
|
||||||
|
"Need customer value because purchase decisions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-price",
|
||||||
|
label: "Target price point",
|
||||||
|
description:
|
||||||
|
"Need a target price point because revenue assumptions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-value"],
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-decision",
|
||||||
|
label: "Build Confidence Engine decision",
|
||||||
|
description: "Decision introduced by the answer.",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
addedEdges: [
|
||||||
|
{
|
||||||
|
id: "e-decision-value",
|
||||||
|
fromNodeId: "n-decision",
|
||||||
|
toNodeId: "n-value",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "The decision depends on customer value.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e-value-price",
|
||||||
|
fromNodeId: "n-value",
|
||||||
|
toNodeId: "n-price",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "Pricing depends on customer value.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e-decision-price",
|
||||||
|
fromNodeId: "n-decision",
|
||||||
|
toNodeId: "n-price",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "low",
|
||||||
|
description: "The decision references pricing assumptions.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-price",
|
||||||
|
question: "What is the price point?",
|
||||||
|
reason: "Model chose pricing.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateCase(makeUpdateRequest(), {
|
||||||
|
provider,
|
||||||
|
config: MOCK_CONFIG,
|
||||||
|
applyProposal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe("n-value");
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to proposal-only mode", async () => {
|
it("defaults to proposal-only mode", async () => {
|
||||||
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
const applyValidatedProposal = vi.fn();
|
const applyValidatedProposal = vi.fn();
|
||||||
|
|||||||
@@ -107,5 +107,8 @@ describe("buildGraphUpdatePrompt", () => {
|
|||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
"selectedQuestion.question must be one narrow non-compound question",
|
"selectedQuestion.question must be one narrow non-compound question",
|
||||||
);
|
);
|
||||||
|
expect(prompt).toContain(
|
||||||
|
"the engine will deterministically choose final priority after validation",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+363
-98
@@ -3,6 +3,7 @@ import {
|
|||||||
validateGraphReferences,
|
validateGraphReferences,
|
||||||
detectDuplicateNodeIds,
|
detectDuplicateNodeIds,
|
||||||
detectDuplicateEdges,
|
detectDuplicateEdges,
|
||||||
|
scoreUnknownCandidate,
|
||||||
findDependentNodes,
|
findDependentNodes,
|
||||||
findAffectedNodes,
|
findAffectedNodes,
|
||||||
resolveUnknownNode,
|
resolveUnknownNode,
|
||||||
@@ -28,8 +29,18 @@ function makeTestGraph() {
|
|||||||
// n4 is an unknown not depended on
|
// n4 is an unknown not depended on
|
||||||
// n5 is an unknown depended upon by n3 indirectly
|
// n5 is an unknown depended upon by n3 indirectly
|
||||||
|
|
||||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "depends_on" });
|
const e1 = makeEdge({
|
||||||
const e2 = makeEdge({ id: "e2", fromNodeId: n3.id, toNodeId: n1.id, relationship: "supports" });
|
id: "e1",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
});
|
||||||
|
const e2 = makeEdge({
|
||||||
|
id: "e2",
|
||||||
|
fromNodeId: n3.id,
|
||||||
|
toNodeId: n1.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
return makeGraph({
|
return makeGraph({
|
||||||
centralStatement: "Test graph",
|
centralStatement: "Test graph",
|
||||||
@@ -55,7 +66,9 @@ describe("validateGraphReferences", () => {
|
|||||||
graph.nodes[0].parentId = "nonexistent-parent";
|
graph.nodes[0].parentId = "nonexistent-parent";
|
||||||
const result = validateGraphReferences(graph);
|
const result = validateGraphReferences(graph);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("nonexistent-parent"))).toBe(true);
|
expect(result.errors.some((e) => e.includes("nonexistent-parent"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects invalid childIds reference", () => {
|
it("detects invalid childIds reference", () => {
|
||||||
@@ -84,7 +97,7 @@ describe("validateGraphReferences", () => {
|
|||||||
graph.edges[0].fromNodeId = "ghost-node";
|
graph.edges[0].fromNodeId = "ghost-node";
|
||||||
const result = validateGraphReferences(graph);
|
const result = validateGraphReferences(graph);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true);
|
expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects edge referencing non-existent toNodeId", () => {
|
it("detects edge referencing non-existent toNodeId", () => {
|
||||||
@@ -154,8 +167,18 @@ describe("detectDuplicateEdges", () => {
|
|||||||
it("detects duplicate edge (same from, to, relationship)", () => {
|
it("detects duplicate edge (same from, to, relationship)", () => {
|
||||||
const n1 = makeNode({ id: "n1", label: "A" });
|
const n1 = makeNode({ id: "n1", label: "A" });
|
||||||
const n2 = makeNode({ id: "n2", label: "B" });
|
const n2 = makeNode({ id: "n2", label: "B" });
|
||||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
const e1 = makeEdge({
|
||||||
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
id: "e1",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
const e2 = makeEdge({
|
||||||
|
id: "e2",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
const dups = detectDuplicateEdges([e1, e2]);
|
const dups = detectDuplicateEdges([e1, e2]);
|
||||||
expect(dups.length).toBe(1);
|
expect(dups.length).toBe(1);
|
||||||
@@ -164,8 +187,18 @@ describe("detectDuplicateEdges", () => {
|
|||||||
it("allows same nodes with different relationship types", () => {
|
it("allows same nodes with different relationship types", () => {
|
||||||
const n1 = makeNode({ id: "n1", label: "A" });
|
const n1 = makeNode({ id: "n1", label: "A" });
|
||||||
const n2 = makeNode({ id: "n2", label: "B" });
|
const n2 = makeNode({ id: "n2", label: "B" });
|
||||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
const e1 = makeEdge({
|
||||||
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "weakens" });
|
id: "e1",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
const e2 = makeEdge({
|
||||||
|
id: "e2",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "weakens",
|
||||||
|
});
|
||||||
|
|
||||||
const dups = detectDuplicateEdges([e1, e2]);
|
const dups = detectDuplicateEdges([e1, e2]);
|
||||||
expect(dups.length).toBe(0);
|
expect(dups.length).toBe(0);
|
||||||
@@ -174,8 +207,18 @@ describe("detectDuplicateEdges", () => {
|
|||||||
it("detects reversed direction as different edge", () => {
|
it("detects reversed direction as different edge", () => {
|
||||||
const n1 = makeNode({ id: "n1", label: "A" });
|
const n1 = makeNode({ id: "n1", label: "A" });
|
||||||
const n2 = makeNode({ id: "n2", label: "B" });
|
const n2 = makeNode({ id: "n2", label: "B" });
|
||||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
const e1 = makeEdge({
|
||||||
const e2 = makeEdge({ id: "e2", fromNodeId: n2.id, toNodeId: n1.id, relationship: "supports" });
|
id: "e1",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
const e2 = makeEdge({
|
||||||
|
id: "e2",
|
||||||
|
fromNodeId: n2.id,
|
||||||
|
toNodeId: n1.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
const dups = detectDuplicateEdges([e1, e2]);
|
const dups = detectDuplicateEdges([e1, e2]);
|
||||||
expect(dups.length).toBe(0);
|
expect(dups.length).toBe(0);
|
||||||
@@ -270,7 +313,14 @@ describe("findAffectedNodes (transitive)", () => {
|
|||||||
|
|
||||||
it("handles empty graph", () => {
|
it("handles empty graph", () => {
|
||||||
// build a minimal graph without triggering schema validation for this edge case
|
// build a minimal graph without triggering schema validation for this edge case
|
||||||
const graph = { centralStatement: "Empty", nodes: [], edges: [], resolvedNodeIds: [], currentSummary: "", activeUnknownNodeId: null };
|
const graph = {
|
||||||
|
centralStatement: "Empty",
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "",
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
};
|
||||||
const affected = findAffectedNodes(graph, "any-node");
|
const affected = findAffectedNodes(graph, "any-node");
|
||||||
expect(affected.length).toBe(0);
|
expect(affected.length).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -279,7 +329,13 @@ describe("findAffectedNodes (transitive)", () => {
|
|||||||
describe("resolveUnknownNode", () => {
|
describe("resolveUnknownNode", () => {
|
||||||
it("returns success for valid node id", () => {
|
it("returns success for valid node id", () => {
|
||||||
const graph = makeTestGraph();
|
const graph = makeTestGraph();
|
||||||
const result = resolveUnknownNode(graph, "n4", "resolved", "Confirmed", "User confirmed");
|
const result = resolveUnknownNode(
|
||||||
|
graph,
|
||||||
|
"n4",
|
||||||
|
"resolved",
|
||||||
|
"Confirmed",
|
||||||
|
"User confirmed",
|
||||||
|
);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.newStatus).toBe("resolved");
|
expect(result.newStatus).toBe("resolved");
|
||||||
expect(result.reason).toBe("User confirmed");
|
expect(result.reason).toBe("User confirmed");
|
||||||
@@ -287,7 +343,13 @@ describe("resolveUnknownNode", () => {
|
|||||||
|
|
||||||
it("returns error for non-existent node", () => {
|
it("returns error for non-existent node", () => {
|
||||||
const graph = makeTestGraph();
|
const graph = makeTestGraph();
|
||||||
const result = resolveUnknownNode(graph, "ghost-node", "resolved", null, "reason");
|
const result = resolveUnknownNode(
|
||||||
|
graph,
|
||||||
|
"ghost-node",
|
||||||
|
"resolved",
|
||||||
|
null,
|
||||||
|
"reason",
|
||||||
|
);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain("not found");
|
expect(result.error).toContain("not found");
|
||||||
});
|
});
|
||||||
@@ -297,13 +359,25 @@ describe("resolveUnknownNode", () => {
|
|||||||
// n5 depends on... actually let's set up properly
|
// n5 depends on... actually let's set up properly
|
||||||
graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A
|
graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A
|
||||||
graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B
|
graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B
|
||||||
const result = resolveUnknownNode(graph, "n4", "resolved", "Yes", "Clarified");
|
const result = resolveUnknownNode(
|
||||||
|
graph,
|
||||||
|
"n4",
|
||||||
|
"resolved",
|
||||||
|
"Yes",
|
||||||
|
"Clarified",
|
||||||
|
);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks previous status and value", () => {
|
it("tracks previous status and value", () => {
|
||||||
const graph = makeTestGraph();
|
const graph = makeTestGraph();
|
||||||
const result = resolveUnknownNode(graph, "n4", "known", "confirmed_value", "Evidence found");
|
const result = resolveUnknownNode(
|
||||||
|
graph,
|
||||||
|
"n4",
|
||||||
|
"known",
|
||||||
|
"confirmed_value",
|
||||||
|
"Evidence found",
|
||||||
|
);
|
||||||
expect(result.previousStatus).toBe("unknown");
|
expect(result.previousStatus).toBe("unknown");
|
||||||
expect(result.newValue).toBe("confirmed_value");
|
expect(result.newValue).toBe("confirmed_value");
|
||||||
});
|
});
|
||||||
@@ -313,7 +387,11 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
it("returns null when no unresolved unknowns", () => {
|
it("returns null when no unresolved unknowns", () => {
|
||||||
// makeTestGraph nodes default to kind "observation", not "unknown"
|
// makeTestGraph nodes default to kind "observation", not "unknown"
|
||||||
// Create explicit unknown-kind nodes for this test
|
// Create explicit unknown-kind nodes for this test
|
||||||
const nUnknown = makeNode({ id: "n-unk-x", label: "Unknown X", kind: "unknown" });
|
const nUnknown = makeNode({
|
||||||
|
id: "n-unk-x",
|
||||||
|
label: "Unknown X",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
const graph = makeGraph({
|
const graph = makeGraph({
|
||||||
centralStatement: "Test",
|
centralStatement: "Test",
|
||||||
nodes: [nUnknown],
|
nodes: [nUnknown],
|
||||||
@@ -349,9 +427,21 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prioritises nodes with more dependents", () => {
|
it("prioritises nodes with more dependents", () => {
|
||||||
const unknownA = makeNode({ id: "unknown-a", label: "Unknown A", kind: "unknown" });
|
const unknownA = makeNode({
|
||||||
const unknownB = makeNode({ id: "unknown-b", label: "Unknown B", kind: "unknown" });
|
id: "unknown-a",
|
||||||
const dependent = makeNode({ id: "dep", label: "Dependent", kind: "state" });
|
label: "Unknown A",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
|
const unknownB = makeNode({
|
||||||
|
id: "unknown-b",
|
||||||
|
label: "Unknown B",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
|
const dependent = makeNode({
|
||||||
|
id: "dep",
|
||||||
|
label: "Dependent",
|
||||||
|
kind: "state",
|
||||||
|
});
|
||||||
|
|
||||||
dependent.dependsOn.push("unknown-a");
|
dependent.dependsOn.push("unknown-a");
|
||||||
|
|
||||||
@@ -370,7 +460,11 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
|
|
||||||
it("returns one candidate (not array)", () => {
|
it("returns one candidate (not array)", () => {
|
||||||
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
|
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
|
||||||
const nUnknown = makeNode({ id: "n-unk", label: "Pending", kind: "unknown" });
|
const nUnknown = makeNode({
|
||||||
|
id: "n-unk",
|
||||||
|
label: "Pending",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
const graph = makeGraph({
|
const graph = makeGraph({
|
||||||
centralStatement: "Test",
|
centralStatement: "Test",
|
||||||
nodes: [n1, nUnknown],
|
nodes: [n1, nUnknown],
|
||||||
@@ -386,6 +480,145 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
expect(result.label).toBeDefined();
|
expect(result.label).toBeDefined();
|
||||||
expect(result.score).toBeDefined();
|
expect(result.score).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("commercial value wins over pricing", () => {
|
||||||
|
const commercialValue = makeNode({
|
||||||
|
id: "n-commercial-value",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need to define commercial value because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const pricing = makeNode({
|
||||||
|
id: "n-pricing",
|
||||||
|
label: "Target price point",
|
||||||
|
description:
|
||||||
|
"Need a target price point because revenue assumptions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-commercial-value"],
|
||||||
|
});
|
||||||
|
const decision = makeNode({
|
||||||
|
id: "n-decision",
|
||||||
|
label: "Build decision",
|
||||||
|
description: "Decision context",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-commercial-value", "n-pricing"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Build decision",
|
||||||
|
nodes: [commercialValue, pricing, decision],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: pricing.id,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = selectActiveUnknownCandidate(graph, []);
|
||||||
|
expect(result.nodeId).toBe("n-commercial-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("customer value wins over UI colour", () => {
|
||||||
|
const customerValue = makeNode({
|
||||||
|
id: "n-customer-value",
|
||||||
|
label: "Customer value",
|
||||||
|
description:
|
||||||
|
"Need to know customer value because adoption depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const uiColour = makeNode({
|
||||||
|
id: "n-ui-colour",
|
||||||
|
label: "UI colour",
|
||||||
|
description: "Need a UI colour because presentation choices remain open.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "low",
|
||||||
|
});
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Value question",
|
||||||
|
nodes: [customerValue, uiColour],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = selectActiveUnknownCandidate(graph, []);
|
||||||
|
expect(result.nodeId).toBe("n-customer-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("success criteria wins over marketing slogan", () => {
|
||||||
|
const successCriteria = makeNode({
|
||||||
|
id: "n-success-criteria",
|
||||||
|
label: "Success criteria",
|
||||||
|
description:
|
||||||
|
"Need success criteria because the decision requires a threshold.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const slogan = makeNode({
|
||||||
|
id: "n-slogan",
|
||||||
|
label: "Marketing slogan",
|
||||||
|
description: "Need a slogan because messaging is undecided.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "low",
|
||||||
|
});
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Threshold question",
|
||||||
|
nodes: [successCriteria, slogan],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = selectActiveUnknownCandidate(graph, []);
|
||||||
|
expect(result.nodeId).toBe("n-success-criteria");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("penalises unknowns with unresolved parent unknowns", () => {
|
||||||
|
const parentUnknown = makeNode({
|
||||||
|
id: "n-parent",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need commercial value definition because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const childUnknown = makeNode({
|
||||||
|
id: "n-child",
|
||||||
|
label: "Target price point",
|
||||||
|
description: "Need price point because revenue assumptions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-parent"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Dependency ordering",
|
||||||
|
nodes: [parentUnknown, childUnknown],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentScore = scoreUnknownCandidate(graph, parentUnknown, []);
|
||||||
|
const childScore = scoreUnknownCandidate(graph, childUnknown, []);
|
||||||
|
expect(parentScore.score).toBeGreaterThan(childScore.score);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applyGraphUpdate", () => {
|
describe("applyGraphUpdate", () => {
|
||||||
@@ -405,7 +638,7 @@ describe("applyGraphUpdate", () => {
|
|||||||
const result = applyGraphUpdate(graph, update);
|
const result = applyGraphUpdate(graph, update);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.nodes.length).toBe(graph.nodes.length + 1);
|
expect(result.nodes.length).toBe(graph.nodes.length + 1);
|
||||||
expect(result.nodes.some(n => n.id === "n-new")).toBe(true);
|
expect(result.nodes.some((n) => n.id === "n-new")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies status updates correctly", () => {
|
it("applies status updates correctly", () => {
|
||||||
@@ -413,14 +646,16 @@ describe("applyGraphUpdate", () => {
|
|||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
previousValue: null,
|
newStatus: "resolved",
|
||||||
newValue: "confirmed",
|
previousValue: null,
|
||||||
reason: "Answered by user",
|
newValue: "confirmed",
|
||||||
}],
|
reason: "Answered by user",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
@@ -430,7 +665,7 @@ describe("applyGraphUpdate", () => {
|
|||||||
const result = applyGraphUpdate(graph, update);
|
const result = applyGraphUpdate(graph, update);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
const updatedNode = result.nodes.find(n => n.id === "n4");
|
const updatedNode = result.nodes.find((n) => n.id === "n4");
|
||||||
expect(updatedNode.status).toBe("resolved");
|
expect(updatedNode.status).toBe("resolved");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,12 +674,14 @@ describe("applyGraphUpdate", () => {
|
|||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "ghost-node",
|
{
|
||||||
previousStatus: null,
|
nodeId: "ghost-node",
|
||||||
newStatus: "known",
|
previousStatus: null,
|
||||||
reason: "test",
|
newStatus: "known",
|
||||||
}],
|
reason: "test",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -453,7 +690,7 @@ describe("applyGraphUpdate", () => {
|
|||||||
|
|
||||||
const result = applyGraphUpdate(graph, update);
|
const result = applyGraphUpdate(graph, update);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true);
|
expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes requested edges", () => {
|
it("removes requested edges", () => {
|
||||||
@@ -472,12 +709,16 @@ describe("applyGraphUpdate", () => {
|
|||||||
const result = applyGraphUpdate(graph, update);
|
const result = applyGraphUpdate(graph, update);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.edges.length).toBe(graph.edges.length - 1);
|
expect(result.edges.length).toBe(graph.edges.length - 1);
|
||||||
expect(result.edges.some(e => e.id === edgeIdToRemove)).toBe(false);
|
expect(result.edges.some((e) => e.id === edgeIdToRemove)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds edges and updates node dependsOn/affects", () => {
|
it("adds edges and updates node dependsOn/affects", () => {
|
||||||
const graph = makeTestGraph();
|
const graph = makeTestGraph();
|
||||||
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n4", relationship: "supports" });
|
const newEdge = makeEdge({
|
||||||
|
fromNodeId: "n1",
|
||||||
|
toNodeId: "n4",
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
@@ -492,11 +733,11 @@ describe("applyGraphUpdate", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
// Check the edge was added
|
// Check the edge was added
|
||||||
expect(result.edges.some(e => e.id === newEdge.id)).toBe(true);
|
expect(result.edges.some((e) => e.id === newEdge.id)).toBe(true);
|
||||||
|
|
||||||
// Check node relationship arrays updated
|
// Check node relationship arrays updated
|
||||||
const fromNode = result.nodes.find(n => n.id === "n1");
|
const fromNode = result.nodes.find((n) => n.id === "n1");
|
||||||
const toNode = result.nodes.find(n => n.id === "n4");
|
const toNode = result.nodes.find((n) => n.id === "n4");
|
||||||
expect(fromNode.childIds).toContain("n4");
|
expect(fromNode.childIds).toContain("n4");
|
||||||
expect(toNode.dependsOn).toContain("n1");
|
expect(toNode.dependsOn).toContain("n1");
|
||||||
});
|
});
|
||||||
@@ -542,14 +783,16 @@ describe("applyGraphUpdate", () => {
|
|||||||
const update = {
|
const update = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [],
|
updatedNodes: [],
|
||||||
addedEdges: [{
|
addedEdges: [
|
||||||
id: "e-new",
|
{
|
||||||
fromNodeId: "missing-node",
|
id: "e-new",
|
||||||
toNodeId: "n1",
|
fromNodeId: "missing-node",
|
||||||
relationship: "supports",
|
toNodeId: "n1",
|
||||||
confidence: "medium",
|
relationship: "supports",
|
||||||
description: "bad edge",
|
confidence: "medium",
|
||||||
}],
|
description: "bad edge",
|
||||||
|
},
|
||||||
|
],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
affectedNodeIds: [],
|
affectedNodeIds: [],
|
||||||
@@ -583,12 +826,14 @@ describe("applyGraphUpdate", () => {
|
|||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
addedNodes: [newNode],
|
addedNodes: [newNode],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
reason: "Multiple ops test",
|
newStatus: "resolved",
|
||||||
}],
|
reason: "Multiple ops test",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })],
|
addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })],
|
||||||
removedEdgeIds: [graph.edges[0]?.id || ""],
|
removedEdgeIds: [graph.edges[0]?.id || ""],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
@@ -622,14 +867,16 @@ describe("validateGraphUpdate", () => {
|
|||||||
|
|
||||||
const result = validateGraphUpdate(graph, {
|
const result = validateGraphUpdate(graph, {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n1",
|
{
|
||||||
previousStatus: null,
|
nodeId: "n1",
|
||||||
newStatus: null,
|
previousStatus: null,
|
||||||
previousValue: null,
|
newStatus: null,
|
||||||
newValue: null,
|
previousValue: null,
|
||||||
reason: "No change test",
|
newValue: null,
|
||||||
}],
|
reason: "No change test",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -637,7 +884,7 @@ describe("validateGraphUpdate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("no meaningful"))).toBe(true);
|
expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate node IDs in additions", () => {
|
it("rejects duplicate node IDs in additions", () => {
|
||||||
@@ -661,12 +908,14 @@ describe("validateGraphUpdate", () => {
|
|||||||
|
|
||||||
const result = validateGraphUpdate(graph, {
|
const result = validateGraphUpdate(graph, {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "ghost-node",
|
{
|
||||||
previousStatus: null,
|
nodeId: "ghost-node",
|
||||||
newStatus: "known",
|
previousStatus: null,
|
||||||
reason: "test",
|
newStatus: "known",
|
||||||
}],
|
reason: "test",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -681,12 +930,14 @@ describe("validateGraphUpdate", () => {
|
|||||||
|
|
||||||
const result = validateGraphUpdate(graph, {
|
const result = validateGraphUpdate(graph, {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "known",
|
previousStatus: "unknown",
|
||||||
reason: "Confirmed",
|
newStatus: "known",
|
||||||
}],
|
reason: "Confirmed",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -710,7 +961,9 @@ describe("validateGraphUpdate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("100KB") || e.includes("exceeds"))).toBe(true);
|
expect(
|
||||||
|
result.errors.some((e) => e.includes("100KB") || e.includes("exceeds")),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty errors array for valid update", () => {
|
it("returns empty errors array for valid update", () => {
|
||||||
@@ -738,17 +991,23 @@ describe("update lifecycle integration", () => {
|
|||||||
|
|
||||||
// Create a meaningful update
|
// Create a meaningful update
|
||||||
const newNode = makeNode({ id: "n-new", label: "New Discovery" });
|
const newNode = makeNode({ id: "n-new", label: "New Discovery" });
|
||||||
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n-new", relationship: "supports" });
|
const newEdge = makeEdge({
|
||||||
|
fromNodeId: "n1",
|
||||||
|
toNodeId: "n-new",
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
// Validate first
|
// Validate first
|
||||||
const validationResult = validateGraphUpdate(graph, {
|
const validationResult = validateGraphUpdate(graph, {
|
||||||
addedNodes: [newNode],
|
addedNodes: [newNode],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
reason: "Answered via follow-up question",
|
newStatus: "resolved",
|
||||||
}],
|
reason: "Answered via follow-up question",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [newEdge],
|
addedEdges: [newEdge],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
@@ -759,12 +1018,14 @@ describe("update lifecycle integration", () => {
|
|||||||
// Apply
|
// Apply
|
||||||
const applyResult = applyGraphUpdate(graph, {
|
const applyResult = applyGraphUpdate(graph, {
|
||||||
addedNodes: [newNode],
|
addedNodes: [newNode],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
reason: "Answered via follow-up question",
|
newStatus: "resolved",
|
||||||
}],
|
reason: "Answered via follow-up question",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [newEdge],
|
addedEdges: [newEdge],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
@@ -786,7 +1047,9 @@ describe("update lifecycle integration", () => {
|
|||||||
|
|
||||||
const invalidUpdate = {
|
const invalidUpdate = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{ nodeId: "ghost-node", newStatus: "known", reason: "test" }],
|
updatedNodes: [
|
||||||
|
{ nodeId: "ghost-node", newStatus: "known", reason: "test" },
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -806,12 +1069,14 @@ describe("update lifecycle integration", () => {
|
|||||||
|
|
||||||
applyGraphUpdate(graph, {
|
applyGraphUpdate(graph, {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
reason: "Test preserve",
|
newStatus: "resolved",
|
||||||
}],
|
reason: "Test preserve",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
|
|||||||
Reference in New Issue
Block a user