///|
fn linkage_id_order(left : String, right : String) -> Int {
left.lexical_compare(right)
}
///|
fn linkage_endpoint_available(
used_left_ids : Array[String],
used_right_ids : Array[String],
candidate : LinkCandidate,
) -> Bool {
!used_left_ids.contains(candidate.left_id) &&
!used_right_ids.contains(candidate.right_id)
}
///|
fn accept_linkage_candidate(
links : Array[AcceptedLink],
used_left_ids : Array[String],
used_right_ids : Array[String],
candidate : LinkCandidate,
) -> Unit {
links.push({
left_id: candidate.left_id,
right_id: candidate.right_id,
evidence: candidate.evidence,
})
used_left_ids.push(candidate.left_id)
used_right_ids.push(candidate.right_id)
}
///|
fn collect_unmatched_linkage_ids(
records : Array[LinkRecord],
used_ids : Array[String],
) -> Array[String] {
let unmatched : Array[String] = []
for record in records {
if !used_ids.contains(record.id) {
unmatched.push(record.id)
}
}
unmatched.sort_by(linkage_id_order)
unmatched
}
///|
fn assign_linkage_candidates(
prepared : PreparedLinkageInputs,
batch : LinkageCandidateBatch,
config : LinkageConfig,
) -> LinkageReport {
let links : Array[AcceptedLink] = []
let rejected_candidates : Array[LinkCandidate] = []
let used_left_ids : Array[String] = []
let used_right_ids : Array[String] = []
for candidate in batch.candidates {
if candidate.accepted &&
linkage_endpoint_available(used_left_ids, used_right_ids, candidate) {
accept_linkage_candidate(links, used_left_ids, used_right_ids, candidate)
} else if config.retain_rejected {
rejected_candidates.push(candidate)
}
}
rejected_candidates.sort_by(linkage_candidate_order)
let unmatched_left_ids = collect_unmatched_linkage_ids(
prepared.left,
used_left_ids,
)
let unmatched_right_ids = collect_unmatched_linkage_ids(
prepared.right,
used_right_ids,
)
let rejected_candidate_count = batch.evaluated_pair_count - links.length()
{
links,
unmatched_left_ids,
unmatched_right_ids,
rejected_candidates,
stats: {
left_input_count: prepared.left.length(),
right_input_count: prepared.right.length(),
blocked_candidate_count: batch.blocked_candidate_count,
evaluated_pair_count: batch.evaluated_pair_count,
accepted_link_count: links.length(),
rejected_candidate_count,
unmatched_left_count: unmatched_left_ids.length(),
unmatched_right_count: unmatched_right_ids.length(),
},
notices: [
"one-to-one links use deterministic greedy score order; global optimality is not claimed",
],
}
}
///|
/// Links two independent Latin-name datasets with deterministic greedy assignment.
///
/// Candidate generation is limited by shared phonetic blocking keys and the
/// configured per-left evaluation cap. A returned link records only that the
/// configured deterministic matcher accepted the pair; it is not proof of
/// real-world identity. Assignment favors higher scores, then lexical IDs, and
/// does not claim a globally optimal bipartite solution.
pub fn link_name_datasets(
left : Array[LinkRecord],
right : Array[LinkRecord],
config : LinkageConfig,
) -> Result[LinkageReport, LinkageError] {
let prepared = match prepare_linkage_inputs(left, right, config) {
Err(error) => return Err(error)
Ok(value) => value
}
let batch = match collect_linkage_candidates(prepared, config) {
Err(error) => return Err(error)
Ok(value) => value
}
Ok(assign_linkage_candidates(prepared, batch, config))
}