fix: handle unjustified unknown selection ties
This commit is contained in:
+200
-62
@@ -216,6 +216,119 @@ function buildScoreContributions(
|
||||
return contributions;
|
||||
}
|
||||
|
||||
function getMeaningfulSemanticContributions(contributions = []) {
|
||||
return contributions
|
||||
.filter(
|
||||
(contribution) =>
|
||||
contribution.rule !== "downstream_dependencies" &&
|
||||
contribution.rule !== "unresolved_prerequisite_penalty" &&
|
||||
contribution.delta !== 0,
|
||||
)
|
||||
.map((contribution) => ({
|
||||
rule: contribution.rule,
|
||||
delta: contribution.delta,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildCandidateDisplayOrder(candidates) {
|
||||
return [...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);
|
||||
});
|
||||
}
|
||||
|
||||
function semanticSignature(candidate) {
|
||||
return JSON.stringify(
|
||||
getMeaningfulSemanticContributions(candidate.contributions),
|
||||
);
|
||||
}
|
||||
|
||||
function classifyCandidateOrdering(candidates) {
|
||||
const displayOrder = buildCandidateDisplayOrder(candidates);
|
||||
const best = displayOrder[0] ?? null;
|
||||
if (!best) {
|
||||
return {
|
||||
displayOrder,
|
||||
best: null,
|
||||
leadingCandidates: [],
|
||||
status: "no_candidates",
|
||||
tieType: "none",
|
||||
usedAlphabeticalOrdering: false,
|
||||
reason: "No unresolved unknown candidates remain.",
|
||||
};
|
||||
}
|
||||
|
||||
const topScoreCandidates = displayOrder.filter(
|
||||
(candidate) => candidate.score === best.score,
|
||||
);
|
||||
|
||||
if (topScoreCandidates.length === 1) {
|
||||
return {
|
||||
displayOrder,
|
||||
best,
|
||||
leadingCandidates: [best],
|
||||
status: "selected",
|
||||
tieType: "none",
|
||||
usedAlphabeticalOrdering: false,
|
||||
reason: `Clear winner by total score (${best.score}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const topStructuralCandidates = topScoreCandidates.filter(
|
||||
(candidate) =>
|
||||
candidate.downstreamCount === best.downstreamCount &&
|
||||
candidate.unresolvedParentUnknownCount ===
|
||||
best.unresolvedParentUnknownCount,
|
||||
);
|
||||
|
||||
if (topStructuralCandidates.length === 1) {
|
||||
return {
|
||||
displayOrder,
|
||||
best,
|
||||
leadingCandidates: [best],
|
||||
status: "selected",
|
||||
tieType: "structural_tie",
|
||||
usedAlphabeticalOrdering: false,
|
||||
reason:
|
||||
"Score tie was resolved by downstream dependency count or prerequisite ordering.",
|
||||
};
|
||||
}
|
||||
|
||||
const topSemanticSignature = semanticSignature(best);
|
||||
const semanticPeers = topStructuralCandidates.filter(
|
||||
(candidate) => semanticSignature(candidate) === topSemanticSignature,
|
||||
);
|
||||
|
||||
if (semanticPeers.length !== topStructuralCandidates.length) {
|
||||
return {
|
||||
displayOrder,
|
||||
best: null,
|
||||
leadingCandidates: topStructuralCandidates,
|
||||
status: "ambiguous",
|
||||
tieType: "semantic_tie",
|
||||
usedAlphabeticalOrdering: false,
|
||||
reason:
|
||||
"Leading candidates remain tied after score and structural checks, but differ in semantic contribution patterns.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
displayOrder,
|
||||
best: null,
|
||||
leadingCandidates: topStructuralCandidates,
|
||||
status: "ambiguous",
|
||||
tieType: "complete_unresolved_tie",
|
||||
usedAlphabeticalOrdering: false,
|
||||
reason: "No justified distinction between leading unknowns.",
|
||||
};
|
||||
}
|
||||
|
||||
export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
|
||||
const text = collectNodeText(node);
|
||||
const matches = classifyUnknownPriority(text);
|
||||
@@ -490,21 +603,36 @@ export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
|
||||
...scoreUnknownCandidate(graph, node, resolvedNodeIds),
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
const selection = classifyCandidateOrdering(
|
||||
scoredCandidates.map(({ node, ...candidate }) => ({
|
||||
...candidate,
|
||||
node,
|
||||
})),
|
||||
);
|
||||
|
||||
const best = scoredCandidates[0];
|
||||
if (selection.status === "ambiguous") {
|
||||
return {
|
||||
selectedNode: null,
|
||||
status: "ambiguous",
|
||||
tieType: selection.tieType,
|
||||
tiedCandidateIds: selection.leadingCandidates.map(
|
||||
(candidate) => candidate.nodeId,
|
||||
),
|
||||
displayOrder: selection.displayOrder.map((candidate) => candidate.nodeId),
|
||||
reason: selection.reason,
|
||||
};
|
||||
}
|
||||
|
||||
const best = selection.best;
|
||||
if (!best) return null;
|
||||
|
||||
return {
|
||||
selectedNode: {
|
||||
nodeId: best.node.id,
|
||||
label: best.node.label,
|
||||
},
|
||||
status: "selected",
|
||||
tieType: selection.tieType,
|
||||
nodeId: best.node.id,
|
||||
label: best.node.label,
|
||||
score: best.score,
|
||||
@@ -522,7 +650,10 @@ export function explainUnknownSelection(graph, resolvedNodeIds = []) {
|
||||
return {
|
||||
selectedNodeId: null,
|
||||
selectedNodeLabel: null,
|
||||
status: "no_candidates",
|
||||
tieType: "none",
|
||||
resolvedNodeIds: [...resolvedNodeIds],
|
||||
tiedCandidateIds: [],
|
||||
candidates: [],
|
||||
competitors: [],
|
||||
tieBreakOrder: [
|
||||
@@ -543,68 +674,75 @@ export function explainUnknownSelection(graph, resolvedNodeIds = []) {
|
||||
...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,
|
||||
},
|
||||
}));
|
||||
const selection = classifyCandidateOrdering(candidates);
|
||||
const orderedCandidates = selection.displayOrder;
|
||||
const selected = selection.best;
|
||||
const competitors = orderedCandidates
|
||||
.filter((candidate) => candidate.nodeId !== selected?.nodeId)
|
||||
.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) - candidate.score,
|
||||
downstreamDelta:
|
||||
(selected?.downstreamCount ?? candidate.downstreamCount) -
|
||||
candidate.downstreamCount,
|
||||
unresolvedPrerequisiteDelta:
|
||||
candidate.unresolvedParentUnknownCount -
|
||||
(selected?.unresolvedParentUnknownCount ??
|
||||
candidate.unresolvedParentUnknownCount),
|
||||
labelOrderWinner:
|
||||
selected &&
|
||||
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,
|
||||
selectedNodeId: selected?.nodeId ?? null,
|
||||
selectedNodeLabel: selected?.label ?? null,
|
||||
status: selection.status,
|
||||
tieType: selection.tieType,
|
||||
resolvedNodeIds: [...resolvedNodeIds],
|
||||
tiedCandidateIds: selection.leadingCandidates.map(
|
||||
(candidate) => candidate.nodeId,
|
||||
),
|
||||
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,
|
||||
},
|
||||
alphabeticalUsedAsReasoning: false,
|
||||
candidates: orderedCandidates,
|
||||
selected: selected
|
||||
? {
|
||||
nodeId: selected.nodeId,
|
||||
label: selected.label,
|
||||
score: selected.score,
|
||||
downstreamCount: selected.downstreamCount,
|
||||
unresolvedParentUnknownCount: selected.unresolvedParentUnknownCount,
|
||||
matches: selected.matches,
|
||||
contributions: selected.contributions,
|
||||
}
|
||||
: null,
|
||||
competitors,
|
||||
summary: {
|
||||
candidateCount: candidates.length,
|
||||
selectedReason: `highest_score=${selected.score}; downstream=${selected.downstreamCount}; unresolved_prerequisites=${selected.unresolvedParentUnknownCount}`,
|
||||
candidateCount: orderedCandidates.length,
|
||||
selectedReason: selected
|
||||
? `highest_score=${selected.score}; downstream=${selected.downstreamCount}; unresolved_prerequisites=${selected.unresolvedParentUnknownCount}`
|
||||
: selection.reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user