use ndarray::Array3;
pub(crate) use ndarray::Array4;
use crate::{Span, Token, decode::greedy_search};
pub(crate) use crate::candle::heads::MAX_WIDTH;
pub(crate) use crate::candle::heads::count_lstm::MAX_COUNT;
pub(crate) struct ScorerOutput {
pub scores: Array4<f32>,
}
pub(crate) fn build_span_idx(num_words: usize) -> crate::candle::Result<Array3<i64>> {
let num_spans = num_words * MAX_WIDTH;
let mut data = Vec::with_capacity(num_spans * 2);
for start in 0..num_words {
for width in 0..MAX_WIDTH {
let end = start + width;
if end >= num_words {
data.extend_from_slice(&[0_i64, 0_i64]);
} else {
data.push(start as i64);
data.push(end as i64);
}
}
}
Array3::from_shape_vec((1, num_spans, 2), data)
.map_err(|e| crate::candle::GlinerCandleError::Backend(format!("build_span_idx shape: {e}")))
}
pub(crate) struct SpanFilterOptions {
pub(crate) threshold: f32,
pub(crate) flat_ner: bool,
pub(crate) dup_label: bool,
pub(crate) multi_label: bool,
}
struct DecodeContext<'a> {
text: &'a str,
words: &'a [Token],
labels: &'a [String],
scores: &'a Array4<f32>,
threshold: f32,
}
pub(crate) fn decode_span_scores(
text: &str,
words: &[Token],
labels: &[String],
scorer_out: &ScorerOutput,
pred_count: usize,
options: SpanFilterOptions,
) -> crate::candle::Result<crate::SpanOutput> {
let SpanFilterOptions {
threshold,
flat_ner,
dup_label,
multi_label,
} = options;
let scores = &scorer_out.scores;
let num_words = words.len().min(scores.shape()[1]);
let ctx = DecodeContext {
text,
words,
labels,
scores,
threshold,
};
let mut candidates: Vec<Span> = Vec::new();
for c_idx in 0..pred_count.min(MAX_COUNT) {
for start in 0..num_words {
collect_candidates_for_start(&mut candidates, &ctx, c_idx, start, num_words)?;
}
}
candidates.sort_unstable_by_key(Span::offsets);
let spans = greedy_search(&candidates, flat_ner, dup_label, multi_label);
Ok(crate::SpanOutput {
texts: vec![text.to_string()],
entities: labels.to_vec(),
spans: vec![spans],
})
}
fn collect_candidates_for_start(
candidates: &mut Vec<Span>,
ctx: &DecodeContext,
c_idx: usize,
start: usize,
num_words: usize,
) -> crate::candle::Result<()> {
for width_idx in 0..MAX_WIDTH {
let end_idx = start + width_idx;
if end_idx >= num_words {
continue;
}
let end_word = end_idx + 1; for m in 0..ctx.labels.len() {
let prob = ctx.scores[[c_idx, start, width_idx, m]];
if prob <= ctx.threshold {
continue;
}
let byte_start = ctx.words[start].start();
let byte_end = ctx.words[end_word - 1].end();
if byte_start >= byte_end {
continue;
}
let Some(raw) = ctx.text.get(byte_start..byte_end) else {
continue;
};
let surface = raw.trim();
if surface.is_empty() {
continue;
}
candidates.push(Span::new(
0,
byte_start,
byte_end,
surface.to_string(),
ctx.labels[m].clone(),
prob,
)?);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_span_scores_skips_non_char_boundary_offsets() {
use crate::Token;
let text = "\u{130}a";
let words = vec![Token::new(1, 3, "ia")];
let labels = vec!["person".to_string()];
let mut scores = ndarray::Array4::<f32>::zeros((MAX_COUNT, 1, MAX_WIDTH, 1));
scores[[0, 0, 0, 0]] = 0.9;
let out = decode_span_scores(
text,
&words,
&labels,
&ScorerOutput { scores },
1,
SpanFilterOptions {
threshold: 0.5,
flat_ner: true,
dup_label: false,
multi_label: false,
},
)
.expect("misaligned offsets must be skipped, not panic");
assert!(out.spans[0].is_empty());
}
#[test]
fn decode_span_scores_bounds_by_scores_dim_after_truncation() {
use crate::Token;
let text = "one two three";
let words = vec![
Token::new(0, 3, "one"),
Token::new(4, 7, "two"),
Token::new(8, 13, "three"),
];
let labels = vec!["person".to_string()];
let mut scores = ndarray::Array4::<f32>::zeros((MAX_COUNT, 2, MAX_WIDTH, 1));
scores[[0, 1, 0, 0]] = 0.9;
let out = decode_span_scores(
text,
&words,
&labels,
&ScorerOutput { scores },
1,
SpanFilterOptions {
threshold: 0.5,
flat_ner: true,
dup_label: false,
multi_label: false,
},
)
.expect("truncated scores must bound the decode loop");
assert_eq!(out.spans[0].len(), 1);
assert_eq!(out.spans[0][0].text(), "two");
}
#[test]
fn build_span_idx_zero_pads_overflow() {
let idx = build_span_idx(2).expect("build_span_idx must not fail");
assert_eq!(idx.shape(), &[1, 2 * MAX_WIDTH, 2]);
assert_eq!((idx[[0, 0, 0]], idx[[0, 0, 1]]), (0, 0));
assert_eq!((idx[[0, 1, 0]], idx[[0, 1, 1]]), (0, 1));
assert_eq!((idx[[0, 2, 0]], idx[[0, 2, 1]]), (0, 0));
assert_eq!((idx[[0, MAX_WIDTH, 0]], idx[[0, MAX_WIDTH, 1]]), (1, 1));
}
#[test]
fn decode_span_scores_drops_below_threshold_candidates() {
use crate::Token;
let text = "Ada Lovelace";
let words = vec![Token::new(0, 3, "ada"), Token::new(4, 12, "lovelace")];
let labels = vec!["person".to_string()];
let scores = ndarray::Array4::<f32>::zeros((MAX_COUNT, 2, MAX_WIDTH, 1));
let out = decode_span_scores(
text,
&words,
&labels,
&ScorerOutput { scores },
1,
SpanFilterOptions {
threshold: 0.5,
flat_ner: true,
dup_label: false,
multi_label: false,
},
)
.expect("decode must not error on all-below-threshold scores");
assert!(out.spans[0].is_empty());
}
}