feat: explain deterministic unknown selection

This commit is contained in:
2026-08-02 15:27:00 +01:00
parent 5ef9710293
commit 586802950d
3 changed files with 321 additions and 25 deletions
+227 -24
View File
@@ -95,6 +95,127 @@ function classifyUnknownPriority(text) {
return matches;
}
function buildScoreContributions(
matches,
downstreamCount,
unresolvedParentUnknownCount,
) {
const contributions = [
{
rule: "downstream_dependencies",
value: downstreamCount,
weight: 4,
delta: downstreamCount * 4,
},
];
if (matches.objective) {
contributions.push({
rule: "objective_match",
value: true,
weight: 12,
delta: 12,
});
}
if (matches.actor) {
contributions.push({
rule: "actor_match",
value: true,
weight: 10,
delta: 10,
});
}
if (matches.criteria) {
contributions.push({
rule: "criteria_match",
value: true,
weight: 11,
delta: 11,
});
}
if (matches.measure) {
contributions.push({
rule: "measure_match",
value: true,
weight: 8,
delta: 8,
});
}
if (matches.terminology) {
contributions.push({
rule: "terminology_match",
value: true,
weight: 7,
delta: 7,
});
}
if (matches.constraint) {
contributions.push({
rule: "constraint_match",
value: true,
weight: 9,
delta: 9,
});
}
if (matches.pricing) {
contributions.push({
rule: "pricing_penalty",
value: true,
weight: -8,
delta: -8,
});
}
if (matches.implementation) {
contributions.push({
rule: "implementation_penalty",
value: true,
weight: -10,
delta: -10,
});
}
if (matches.optimisation) {
contributions.push({
rule: "optimisation_penalty",
value: true,
weight: -9,
delta: -9,
});
}
if (matches.speculative) {
contributions.push({
rule: "speculative_penalty",
value: true,
weight: -12,
delta: -12,
});
}
if (
matches.pricing &&
!matches.objective &&
!matches.criteria &&
!matches.actor
) {
contributions.push({
rule: "isolated_pricing_penalty",
value: true,
weight: -6,
delta: -6,
});
}
if (unresolvedParentUnknownCount > 0) {
contributions.push({
rule: "unresolved_prerequisite_penalty",
value: unresolvedParentUnknownCount,
weight: -7,
delta: unresolvedParentUnknownCount * -7,
});
}
return contributions;
}
export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
const text = collectNodeText(node);
const matches = classifyUnknownPriority(text);
@@ -105,30 +226,15 @@ export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
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;
const contributions = buildScoreContributions(
matches,
downstreamCount,
unresolvedParentUnknownCount,
);
const score = contributions.reduce(
(total, contribution) => total + contribution.delta,
0,
);
return {
nodeId: node.id,
@@ -137,6 +243,7 @@ export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
downstreamCount,
unresolvedParentUnknownCount,
matches,
contributions,
};
}
@@ -406,6 +513,102 @@ export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
};
}
export function explainUnknownSelection(graph, resolvedNodeIds = []) {
const unresolved = graph.nodes.filter(
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id),
);
if (unresolved.length === 0) {
return {
selectedNodeId: null,
selectedNodeLabel: null,
resolvedNodeIds: [...resolvedNodeIds],
candidates: [],
competitors: [],
tieBreakOrder: [
"score_desc",
"downstreamCount_desc",
"unresolvedParentUnknownCount_asc",
"label_asc",
],
summary: {
candidateCount: 0,
},
};
}
const candidates = unresolved.map((node) => ({
nodeId: node.id,
label: node.label,
...scoreUnknownCandidate(graph, node, resolvedNodeIds),
}));
candidates.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.label.localeCompare(b.label);
});
const selected = candidates[0];
const competitors = candidates.slice(1).map((candidate) => ({
nodeId: candidate.nodeId,
label: candidate.label,
score: candidate.score,
downstreamCount: candidate.downstreamCount,
unresolvedParentUnknownCount: candidate.unresolvedParentUnknownCount,
matches: candidate.matches,
contributions: candidate.contributions,
outrankedBy: {
scoreDelta: selected.score - candidate.score,
downstreamDelta: selected.downstreamCount - candidate.downstreamCount,
unresolvedPrerequisiteDelta:
candidate.unresolvedParentUnknownCount -
selected.unresolvedParentUnknownCount,
labelOrderWinner:
selected.score === candidate.score &&
selected.downstreamCount === candidate.downstreamCount &&
selected.unresolvedParentUnknownCount ===
candidate.unresolvedParentUnknownCount
? selected.label.localeCompare(candidate.label) <= 0
? selected.label
: candidate.label
: null,
},
}));
return {
selectedNodeId: selected.nodeId,
selectedNodeLabel: selected.label,
resolvedNodeIds: [...resolvedNodeIds],
tieBreakOrder: [
"score_desc",
"downstreamCount_desc",
"unresolvedParentUnknownCount_asc",
"label_asc",
],
candidates,
selected: {
nodeId: selected.nodeId,
label: selected.label,
score: selected.score,
downstreamCount: selected.downstreamCount,
unresolvedParentUnknownCount: selected.unresolvedParentUnknownCount,
matches: selected.matches,
contributions: selected.contributions,
},
competitors,
summary: {
candidateCount: candidates.length,
selectedReason: `highest_score=${selected.score}; downstream=${selected.downstreamCount}; unresolved_prerequisites=${selected.unresolvedParentUnknownCount}`,
},
};
}
// ── Apply a graph update deterministically ──
export function applyGraphUpdate(graph, update) {