///|
fn parse_int_field(
value : StringView,
field : String,
line_no : Int,
) -> Result[Int, String] {
let trimmed = value.trim(chars=" \t\r")
Ok(@strconv.from_str(trimmed)) catch {
_ => Err("line \{line_no}: invalid integer in \{field}: '\{trimmed}'")
}
}
///|
fn parse_double_field(
value : StringView,
field : String,
line_no : Int,
) -> Result[Double, String] {
let trimmed = value.trim(chars=" \t\r")
Ok(@strconv.from_str(trimmed)) catch {
_ => Err("line \{line_no}: invalid double in \{field}: '\{trimmed}'")
}
}
///|
fn normalize_line_end(line : StringView) -> String {
line.trim(chars=" \r").to_owned()
}
///|
fn parse_tsv_fields(
source : String,
expected : Int,
label : String,
) -> Result[Array[Array[String]], String] {
let rows : Array[Array[String]] = []
let mut line_no = 0
for raw_line in source.split("\n") {
line_no += 1
let line = normalize_line_end(raw_line)
if line.is_empty() || line.get_char(0) == Some('#') {
continue
}
let fields = line
.split("\t")
.to_array()
.map(fn(field) { field.trim(chars=" \r").to_owned() })
if fields.length() < expected {
return Err(
"line \{line_no}: expected at least \{expected} TSV fields for \{label}, got \{fields.length()}",
)
}
rows.push(fields)
}
Ok(rows)
}
///|
fn unique_positive_cutoffs(cutoffs : Array[Int]) -> Array[Int] {
let seen : Map[Int, Unit] = Map([])
let normalized : Array[Int] = []
for cutoff in cutoffs {
if cutoff <= 0 || seen.contains(cutoff) {
continue
}
seen[cutoff] = ()
normalized.push(cutoff)
}
normalized.sort_by(Int::compare)
if normalized.is_empty() {
default_cutoffs()
} else {
normalized
}
}
///|
fn metric_name(prefix : String, cutoff : Int) -> String {
"\{prefix}@\{cutoff}"
}
///|
fn gain_of(relevance : Int, gain_scheme : GainScheme) -> Double {
if relevance <= 0 {
0.0
} else {
match gain_scheme {
Linear => Double::from_int(relevance)
Exp2 => @math.pow(2.0, Double::from_int(relevance)) - 1.0
}
}
}
///|
fn reciprocal_rank(index : Int) -> Double {
1.0 / Double::from_int(index + 1)
}
///|
fn to_ratio(numerator : Int, denominator : Int) -> Double {
if denominator <= 0 {
0.0
} else {
Double::from_int(numerator) / Double::from_int(denominator)
}
}
///|
fn group_qrels(items : Array[JudgedDoc]) -> Map[String, Array[JudgedDoc]] {
let grouped : Map[String, Array[JudgedDoc]] = Map([])
for item in items {
let bucket = grouped.get_or_init(item.query_id, fn() { [] })
bucket.push(item)
}
grouped
}
///|
fn group_runs(items : Array[RetrievedDoc]) -> Map[String, Array[RetrievedDoc]] {
let grouped : Map[String, Array[RetrievedDoc]] = Map([])
for item in items {
let bucket = grouped.get_or_init(item.query_id, fn() { [] })
bucket.push(item)
}
for _, bucket in grouped {
bucket.sort_by(fn(a, b) {
let by_score = b.score.compare(a.score)
if by_score == 0 {
a.doc_id.compare(b.doc_id)
} else {
by_score
}
})
}
grouped
}
///|
fn build_relevance_map(items : Array[JudgedDoc]) -> Map[String, Int] {
let relevance : Map[String, Int] = Map([])
for item in items {
relevance.update(item.doc_id, fn(existing) {
match existing {
Some(current) => Some(Int::max(current, item.relevance))
None => Some(item.relevance)
}
})
}
relevance
}
///|
fn build_query_relevance_map(items : Array[JudgedDoc]) -> Map[String, Int] {
let relevance : Map[String, Int] = Map([])
for item in items {
let key = "\{item.query_id}:\{item.doc_id}"
relevance.update(key, fn(existing) {
match existing {
Some(current) => Some(Int::max(current, item.relevance))
None => Some(item.relevance)
}
})
}
relevance
}
///|
fn sorted_query_ids(
qrels : Map[String, Array[JudgedDoc]],
runs : Map[String, Array[RetrievedDoc]],
) -> Array[String] {
let seen : Map[String, Unit] = Map([])
let ids : Array[String] = []
for query_id, _ in qrels {
if !seen.contains(query_id) {
seen[query_id] = ()
ids.push(query_id)
}
}
for query_id, _ in runs {
if !seen.contains(query_id) {
seen[query_id] = ()
ids.push(query_id)
}
}
ids.sort()
ids
}