///|
/// Explain how a candidate score was accumulated.
pub(all) struct CandidateExplanation {
occurrences : Int
score : Int
average_weight : Int
first_position : Int
last_position : Int
label : String
} derive(Debug, Eq, ToJson)
///|
/// A ranked candidate paired with its evidence.
pub(all) struct RankedCandidate {
candidate : Candidate
explanation : CandidateExplanation
} derive(Debug, Eq, ToJson)
///|
/// Aggregate matches into ranked, explainable candidates.
pub fn rank_candidates(
matches : ArrayView[LexiconMatch],
) -> Array[RankedCandidate] {
let grouped : Map[String, RankedCandidate] = Map([])
for hit in matches {
let key = entry_key({
keyword: hit.keyword,
label: hit.label,
weight: hit.weight,
})
match grouped.get(key) {
Some(old) => {
let explanation = old.explanation
let occurrences = explanation.occurrences + 1
let score = explanation.score + hit.weight
let span_count = old.candidate.span_count + 1
grouped[key] = {
candidate: {
text: old.candidate.text,
label: old.candidate.label,
score,
span_count,
},
explanation: {
occurrences,
score,
average_weight: score / occurrences,
first_position: if hit.start < explanation.first_position {
hit.start
} else {
explanation.first_position
},
last_position: if hit.end > explanation.last_position {
hit.end
} else {
explanation.last_position
},
label: explanation.label,
},
}
}
None =>
grouped[key] = {
candidate: {
text: hit.keyword,
label: hit.label,
score: hit.weight,
span_count: 1,
},
explanation: {
occurrences: 1,
score: hit.weight,
average_weight: hit.weight,
first_position: hit.start,
last_position: hit.end,
label: hit.label,
},
}
}
}
let output : Array[RankedCandidate] = []
for _, candidate in grouped {
output.push(candidate)
}
sort_ranked_candidates(output)
}
///|
/// Keep the highest-ranked candidates up to a caller-supplied limit.
pub fn rank_candidates_top(
matches : ArrayView[LexiconMatch],
limit : Int,
) -> Array[RankedCandidate] {
limit_ranked_candidates(rank_candidates(matches), limit)
}
///|
/// Return the evidence for one candidate identity.
pub fn explain_candidate(
matches : ArrayView[LexiconMatch],
keyword : String,
label : String,
) -> CandidateExplanation? {
for ranked in rank_candidates(matches) {
if ranked.candidate.text == keyword && ranked.candidate.label == label {
return Some(ranked.explanation)
}
}
None
}
///|
/// Return a stable TSV report suitable for audit artifacts.
pub fn candidate_report_tsv(candidates : ArrayView[RankedCandidate]) -> String {
let output = StringBuilder()
output.write_string(
"keyword\tlabel\tscore\toccurrences\taverage_weight\tfirst\tlast\n",
)
for ranked in candidates {
let item = ranked.candidate
let evidence = ranked.explanation
output.write_string(
"\{escape_dictionary_field(item.text)}\t\{escape_dictionary_field(item.label)}\t\{item.score}\t\{evidence.occurrences}\t\{evidence.average_weight}\t\{evidence.first_position}\t\{evidence.last_position}\n",
)
}
output.to_string()
}
///|
/// Return the score contribution of one match.
pub fn candidate_contribution(hit : LexiconMatch) -> Candidate {
{ text: hit.keyword, label: hit.label, score: hit.weight, span_count: 1 }
}
///|
/// Return candidates with a minimum accumulated score.
pub fn candidates_at_least(
candidates : ArrayView[RankedCandidate],
minimum_score : Int,
) -> Array[RankedCandidate] {
[
for item in candidates if item.candidate.score >= minimum_score => item
]
}
///|
/// Return candidates whose evidence contains at least this many occurrences.
pub fn candidates_with_occurrences(
candidates : ArrayView[RankedCandidate],
minimum_occurrences : Int,
) -> Array[RankedCandidate] {
[
for item in candidates if item.explanation.occurrences >=
minimum_occurrences => item
]
}
///|
/// Return true when ranking order is deterministic and non-increasing.
pub fn ranked_candidates_are_ordered(
candidates : ArrayView[RankedCandidate],
) -> Bool {
for index in 1.. 0 {
return false
}
}
true
}
///|
fn sort_ranked_candidates(
candidates : Array[RankedCandidate],
) -> Array[RankedCandidate] {
candidates.sort_by(compare_ranked)
candidates
}
///|
fn limit_ranked_candidates(
candidates : ArrayView[RankedCandidate],
limit : Int,
) -> Array[RankedCandidate] {
let output : Array[RankedCandidate] = []
let safe_limit = if limit < 0 { 0 } else { limit }
for item in candidates {
if output.length() >= safe_limit {
break
}
output.push(item)
}
output
}
///|
fn compare_ranked(left : RankedCandidate, right : RankedCandidate) -> Int {
if left.candidate.score != right.candidate.score {
right.candidate.score - left.candidate.score
} else if left.explanation.occurrences != right.explanation.occurrences {
right.explanation.occurrences - left.explanation.occurrences
} else if left.candidate.span_count != right.candidate.span_count {
right.candidate.span_count - left.candidate.span_count
} else {
left.candidate.text.compare(right.candidate.text)
}
}