uni_xervo/traits/nlp.rs
1//! Structured natural-language analysis types and traits.
2//!
3//! The trait is designed around multi-head cascade models (e.g.
4//! `dragonscale-ai/kniv-deberta-nlp-base-en-xsmall`) that produce
5//! token-level POS / NER / DEP tags, sentence segmentation, optional SRL
6//! frames, and optional dialog-act classification in a single forward pass.
7
8use crate::error::Result;
9use crate::traits::ModelInfo;
10use async_trait::async_trait;
11use bitflags::bitflags;
12
13bitflags! {
14 /// Selects which NLP heads a caller wants populated.
15 ///
16 /// Implementations that support a head only do its post-processing
17 /// (label decoding, dependency tree reconstruction, SRL frame
18 /// assembly) when its flag is set — this saves measurable wall time
19 /// on hot ingest paths where, for example, only NER is needed.
20 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21 pub struct NlpTasks: u32 {
22 /// Part-of-speech tagging.
23 const POS = 1 << 0;
24 /// Named entity recognition.
25 const NER = 1 << 1;
26 /// Dependency parsing.
27 const DEP = 1 << 2;
28 /// Semantic role labeling.
29 const SRL = 1 << 3;
30 /// Sentence-level classification (e.g. dialog acts).
31 const CLS = 1 << 4;
32 /// Universal-Dependencies morphological feature bundles, per word.
33 ///
34 /// kniv v7 only.
35 const MORPH = 1 << 5;
36 /// Lemmatisation, per word. kniv v7 only.
37 const LEMMA = 1 << 6;
38 /// Per-sentence sentiment (positive / negative / neutral). kniv v7 only.
39 const SENTIMENT = 1 << 7;
40 /// Coreference: mention clusters with span boundaries. kniv v7 only.
41 const COREF = 1 << 8;
42 /// Typed entities — the request schema's entity types, or the model's
43 /// coarse types — grouped across their mentions. kniv v7 only.
44 const ENTITIES = 1 << 9;
45 /// Entity-to-entity facts against the request schema's relations, each
46 /// with polarity, modality, holder and time. Implies
47 /// [`ENTITIES`](Self::ENTITIES). kniv v7 only.
48 const FACTS = 1 << 10;
49 /// Convenience: the five heads of the v1 cascade (POS / NER / DEP /
50 /// SRL / CLS).
51 const CASCADE = Self::POS.bits() | Self::NER.bits() | Self::DEP.bits()
52 | Self::SRL.bits() | Self::CLS.bits();
53 /// Convenience: every per-word and per-sentence linguistic head, with
54 /// no entity or fact extraction.
55 const LINGUISTIC = Self::CASCADE.bits() | Self::MORPH.bits()
56 | Self::LEMMA.bits() | Self::SENTIMENT.bits();
57 /// Convenience: all currently-defined heads.
58 const ALL = Self::LINGUISTIC.bits() | Self::COREF.bits()
59 | Self::ENTITIES.bits() | Self::FACTS.bits();
60 }
61}
62
63impl NlpTasks {
64 /// Normalise implied flags: [`FACTS`](Self::FACTS) implies
65 /// [`ENTITIES`](Self::ENTITIES).
66 #[must_use]
67 pub fn expanded(self) -> Self {
68 if self.contains(Self::FACTS) {
69 self | Self::ENTITIES
70 } else {
71 self
72 }
73 }
74}
75
76/// One NLP analysis request: an input plus the heads to populate.
77///
78/// `#[non_exhaustive]`: build one with [`NlpRequest::new`] (or
79/// [`NlpRequest::from_turns`]) and chain the `with_*` setters, so later
80/// releases can add inputs without breaking callers.
81///
82/// ```
83/// use uni_xervo::traits::{NlpRequest, NlpTasks};
84/// let req = NlpRequest::new("Ada Lovelace was born in London.", NlpTasks::NER);
85/// assert_eq!(req.tasks, NlpTasks::NER);
86/// ```
87#[derive(Debug, Clone)]
88#[non_exhaustive]
89pub struct NlpRequest<'a> {
90 /// Input prose. Borrowed so batch callers don't have to clone.
91 ///
92 /// kniv v7 splits it into paragraphs at blank lines (`"\n\n"`). Ignored
93 /// when [`turns`](Self::turns) is set.
94 pub text: &'a str,
95 /// Which heads to populate. Unset flags result in empty fields on the
96 /// corresponding [`NlpResult`] entries.
97 pub tasks: NlpTasks,
98 /// A conversation to analyse instead of [`text`](Self::text): one
99 /// `(speaker, text)` [`Turn`] per message, oldest first.
100 ///
101 /// Each turn is its own unit — sentences never cross a turn — and its
102 /// speaker becomes a `"speaker"` entity, so relations can be anchored on
103 /// who said what. Models that do not support conversations ignore it.
104 pub turns: Option<&'a [Turn<'a>]>,
105 /// The relations and entity types to extract, for
106 /// [`NlpTasks::ENTITIES`] / [`NlpTasks::FACTS`].
107 ///
108 /// Zero-shot: the schema is supplied per request and scored against the
109 /// whole window. `None` leaves facts empty and falls back to the model's
110 /// own coarse entity types.
111 pub schema: Option<&'a ExtractionSchema>,
112 /// Per-request overrides of the model's shipped decoding thresholds.
113 ///
114 /// `None` uses the model's `calibration.json` as published, which is
115 /// what the reported quality figures were measured with.
116 pub calibration: Option<&'a NlpCalibrationOverrides>,
117}
118
119impl<'a> NlpRequest<'a> {
120 /// A request over prose.
121 #[must_use]
122 pub fn new(text: &'a str, tasks: NlpTasks) -> Self {
123 Self {
124 text,
125 tasks,
126 turns: None,
127 schema: None,
128 calibration: None,
129 }
130 }
131
132 /// A request over conversation turns.
133 #[must_use]
134 pub fn from_turns(turns: &'a [Turn<'a>], tasks: NlpTasks) -> Self {
135 Self {
136 text: "",
137 tasks,
138 turns: Some(turns),
139 schema: None,
140 calibration: None,
141 }
142 }
143
144 /// Attach conversation turns, which take precedence over
145 /// [`text`](Self::text).
146 #[must_use]
147 pub fn with_turns(mut self, turns: &'a [Turn<'a>]) -> Self {
148 self.turns = Some(turns);
149 self
150 }
151
152 /// Attach the extraction schema.
153 #[must_use]
154 pub fn with_schema(mut self, schema: &'a ExtractionSchema) -> Self {
155 self.schema = Some(schema);
156 self
157 }
158
159 /// Attach calibration overrides.
160 #[must_use]
161 pub fn with_calibration(mut self, calibration: &'a NlpCalibrationOverrides) -> Self {
162 self.calibration = Some(calibration);
163 self
164 }
165}
166
167/// One turn of a conversation passed as [`NlpRequest::turns`].
168#[derive(Debug, Clone, Copy)]
169pub struct Turn<'a> {
170 /// Who is speaking, e.g. `"user"` or `"assistant"`. Turns by the same
171 /// speaker share one `"speaker"` entity.
172 pub speaker: &'a str,
173 /// What they said.
174 pub text: &'a str,
175}
176
177impl<'a> Turn<'a> {
178 /// A turn.
179 #[must_use]
180 pub fn new(speaker: &'a str, text: &'a str) -> Self {
181 Self { speaker, text }
182 }
183}
184
185/// The relations and entity types to extract from one request.
186///
187/// Schema label embeddings are computed once and cached per exact label list,
188/// so a stable schema costs nothing after the first request.
189#[derive(Debug, Clone, Default, PartialEq, Eq)]
190pub struct ExtractionSchema {
191 /// Relations to score for every ordered entity pair. Empty means no
192 /// facts are extracted.
193 pub relations: Vec<LabelSpec>,
194 /// Entity types to extract. `None` falls back to the model's coarse
195 /// types (kniv v7: `LOC`, `MISC`, `NUM`, `ORG`, `PER`, `TIME`).
196 pub entity_types: Option<Vec<LabelSpec>>,
197 /// How many relations to keep per entity pair. `None` uses the model's
198 /// calibrated `rel_top` (kniv v7: 4).
199 pub top_per_pair: Option<usize>,
200}
201
202/// One schema label: a name, and a description that tells the model what it
203/// means and which side of a relation is the head.
204#[derive(Debug, Clone, Default, PartialEq, Eq)]
205pub struct LabelSpec {
206 /// The label as a reader would name it, e.g. `"lives in"`. This is the
207 /// string reported back on [`NlpFact::relation`] / [`NlpEntity::ty`].
208 pub name: String,
209 /// A short gloss, e.g. `"the place where the head resides"`.
210 pub description: Option<String>,
211}
212
213impl LabelSpec {
214 /// A label with no description.
215 #[must_use]
216 pub fn name(name: impl Into<String>) -> Self {
217 Self {
218 name: name.into(),
219 description: None,
220 }
221 }
222
223 /// A label with a description.
224 #[must_use]
225 pub fn described(name: impl Into<String>, description: impl Into<String>) -> Self {
226 Self {
227 name: name.into(),
228 description: Some(description.into()),
229 }
230 }
231}
232
233/// Structured output of an NLP analysis call.
234///
235/// Field population depends on the [`NlpTasks`] flags in the request and on
236/// the model's [`NlpModel::supported_tasks`]. Unrequested or unsupported
237/// fields are empty (`Vec::new()`).
238///
239/// This struct is `#[non_exhaustive]`: construct it via [`NlpResult::default`]
240/// and assign fields, so future heads can be added without breaking callers.
241#[derive(Debug, Clone, Default)]
242#[non_exhaustive]
243pub struct NlpResult {
244 /// Token sequence with optional POS / NER / DEP annotations.
245 pub tokens: Vec<NlpToken>,
246 /// Sentence boundaries (always populated when any head is requested).
247 pub sentences: Vec<NlpSentence>,
248 /// SRL frames. Empty unless [`NlpTasks::SRL`] was requested and supported.
249 pub frames: Vec<SrlFrame>,
250 /// Sentence-level classifications. Empty unless [`NlpTasks::CLS`] was
251 /// requested and supported.
252 pub speech_acts: Vec<SpeechAct>,
253 /// Merged named-entity spans, a BIO-collapsed view of the per-token
254 /// [`NlpToken::ner`] tags. Empty unless [`NlpTasks::NER`] was requested
255 /// and supported. See [`NerEntity`].
256 pub entities: Vec<NerEntity>,
257 /// The full per-window analysis, for models that analyse whole windows
258 /// of words rather than one token sequence (kniv v7).
259 ///
260 /// One entry per window, in input order; the fields above are a
261 /// flattened, v1-shaped projection of the same content, so callers that
262 /// only read them keep working. Empty for models without a window
263 /// pipeline.
264 pub windows: Vec<NlpWindow>,
265}
266
267/// One analysis window: a run of whole sentences of up to the model's word
268/// budget, analysed in a single pass.
269///
270/// Windows never cross a paragraph or a turn, and a sentence is never split
271/// across two windows. Every index in this struct — heads, spans, mention
272/// positions — is a **word** index into [`tokens`](Self::tokens), local to
273/// the window.
274#[derive(Debug, Clone, Default)]
275#[non_exhaustive]
276pub struct NlpWindow {
277 /// The window's words, in order.
278 pub tokens: Vec<String>,
279 /// How many leading words fit in the encoder's subword budget.
280 ///
281 /// Words from this index on are reported in [`tokens`](Self::tokens) but
282 /// carry no analysis. Equal to `tokens.len()` for a window that fits.
283 pub analysed_words: usize,
284 /// The window's sentences.
285 pub sentences: Vec<WindowSentence>,
286 /// Per-word analysis, one entry per analysed word (so
287 /// `words.len() == analysed_words`).
288 pub words: Vec<WordAnalysis>,
289 /// Named entities as `(type, start, end)` half-open word spans, using
290 /// the model's own NER vocabulary (kniv v7: OntoNotes).
291 pub ner: Vec<WordSpan>,
292 /// Semantic-role frames, one per predicate — every predicate, not only
293 /// verbs.
294 pub srl: Vec<WindowFrame>,
295 /// Coreference clusters: each cluster is a list of **inclusive**
296 /// `(start, end)` word spans.
297 pub coref: Vec<Vec<(usize, usize)>>,
298 /// Typed entities, grouped across their mentions. In a conversation the
299 /// speakers come first, in order of first appearance.
300 pub entities: Vec<NlpEntity>,
301 /// Facts between entities, scored against the request schema's
302 /// relations.
303 pub facts: Vec<NlpFact>,
304}
305
306/// A labelled half-open `[start, end)` span of words.
307#[derive(Debug, Clone, Default, PartialEq, Eq)]
308pub struct WordSpan {
309 /// The label, e.g. an entity type or a semantic role.
310 pub label: String,
311 /// First word index, inclusive.
312 pub start: usize,
313 /// Last word index, exclusive.
314 pub end: usize,
315}
316
317/// One sentence of an [`NlpWindow`].
318#[derive(Debug, Clone, Default)]
319#[non_exhaustive]
320pub struct WindowSentence {
321 /// First word index, inclusive.
322 pub start: usize,
323 /// Last word index, exclusive.
324 pub end: usize,
325 /// Which input unit (paragraph, or turn) this sentence came from.
326 pub unit: usize,
327 /// The speaker of that unit, for a conversation.
328 pub speaker: Option<String>,
329 /// Dialogue acts above threshold. Multi-label: a sentence can be both
330 /// an answer and a question.
331 pub cls: Vec<String>,
332 /// Per-class dialogue-act probabilities, parallel to
333 /// [`NlpLabelMaps::cls`].
334 pub cls_scores: Vec<f32>,
335 /// Sentiment label, e.g. `"positive"`.
336 pub sentiment: Option<String>,
337 /// Whether the sentence ends within [`NlpWindow::analysed_words`]. A
338 /// truncated sentence's analysis is partial.
339 pub whole: bool,
340}
341
342/// Per-word linguistic analysis.
343#[derive(Debug, Clone, Default)]
344#[non_exhaustive]
345pub struct WordAnalysis {
346 /// UD UPOS tag.
347 pub pos: Option<String>,
348 /// UD morphological feature bundle, e.g.
349 /// `"Mood=Ind|Number=Sing|Tense=Past"`. `"_"` when the word has none.
350 pub morph: Option<String>,
351 /// The lemma.
352 pub lemma: Option<String>,
353 /// Dependency head: `0` is the sentence root, otherwise the 1-based word
354 /// index of the head within the window. `None` when DEP was not
355 /// requested.
356 pub head: Option<usize>,
357 /// UD dependency relation to [`head`](Self::head).
358 pub deprel: Option<String>,
359}
360
361/// One semantic-role frame of an [`NlpWindow`].
362#[derive(Debug, Clone, Default)]
363#[non_exhaustive]
364pub struct WindowFrame {
365 /// Word index of the predicate.
366 pub predicate: usize,
367 /// Argument spans, excluding the predicate itself.
368 pub args: Vec<WordSpan>,
369}
370
371/// One typed entity of an [`NlpWindow`], grouped across its mentions.
372#[derive(Debug, Clone, Default)]
373#[non_exhaustive]
374pub struct NlpEntity {
375 /// The entity type: a name from [`ExtractionSchema::entity_types`] when
376 /// one was given, else the model's coarse type, or `"speaker"` for a
377 /// conversation participant.
378 pub ty: String,
379 /// Surface text of the entity's first mention; for a speaker, the
380 /// speaker name.
381 pub text: String,
382 /// Whether this entity is a conversation participant rather than a
383 /// mention in the text.
384 pub speaker: bool,
385 /// Surface text of each mention, parallel to [`spans`](Self::spans).
386 pub mentions: Vec<String>,
387 /// Half-open `[start, end)` word spans of the mentions. For a speaker,
388 /// the turn-start positions where they begin a turn.
389 pub spans: Vec<(usize, usize)>,
390}
391
392/// One extracted fact: a relation between two entities, with its attributes.
393#[derive(Debug, Clone, Default)]
394#[non_exhaustive]
395pub struct NlpFact {
396 /// Index into [`NlpWindow::entities`] of the head (subject).
397 pub head: usize,
398 /// Index into [`NlpWindow::entities`] of the tail (object).
399 pub tail: usize,
400 /// The relation name, as given in [`ExtractionSchema::relations`].
401 pub relation: String,
402 /// Calibrated margin over the model's keep threshold. Always `> 0` for a
403 /// reported fact; larger is surer.
404 ///
405 /// Not a probability. Map it to one with `sigmoid` if a confidence is
406 /// wanted, and tune a relation's threshold through
407 /// [`NlpCalibrationOverrides::rel_offsets`] rather than by filtering on
408 /// the margin.
409 pub margin: f32,
410 /// Whether the fact is asserted or denied.
411 pub polarity: FactPolarity,
412 /// How the fact is held: asserted, planned, merely questioned, and so
413 /// on.
414 pub modality: FactModality,
415 /// Index into [`NlpWindow::entities`] of whoever holds this belief, when
416 /// the model found one — the speaker for `"I live in Lisbon"`, the
417 /// reporter for `"Leon says I work at Northstar"`.
418 pub holder: Option<usize>,
419 /// Index into [`NlpWindow::entities`] of a temporal entity anchoring the
420 /// fact. Only ever set when the schema has temporal entity types.
421 pub time: Option<usize>,
422}
423
424/// Whether a [`NlpFact`] is asserted or denied.
425#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
426pub enum FactPolarity {
427 /// The fact is claimed to hold.
428 #[default]
429 Positive,
430 /// The fact is claimed not to hold — `"I don't work there anymore"`.
431 Negative,
432}
433
434impl FactPolarity {
435 /// The reference runtime's lowercase name.
436 #[must_use]
437 pub fn as_str(self) -> &'static str {
438 match self {
439 Self::Positive => "positive",
440 Self::Negative => "negative",
441 }
442 }
443}
444
445/// How a [`NlpFact`] is held by its holder.
446///
447/// Only [`Asserted`](Self::Asserted) and [`Reported`](Self::Reported) are
448/// claims about the world; the rest are intentions, questions and
449/// suppositions, and consolidating them into current state is a mistake.
450#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
451pub enum FactModality {
452 /// Stated as fact.
453 #[default]
454 Asserted,
455 /// Conditional or counterfactual — `"if I moved to Lisbon"`.
456 Hypothetical,
457 /// An intention with a plan — `"next month I'm joining Northstar"`.
458 Planned,
459 /// A wish — `"I'd love to live in Lisbon"`.
460 Desired,
461 /// Asked, not stated — `"do you live in Lisbon?"`.
462 Questioned,
463 /// Attributed to someone else — `"Leon says I work at Northstar"`.
464 Reported,
465}
466
467impl FactModality {
468 /// The reference runtime's lowercase name.
469 #[must_use]
470 pub fn as_str(self) -> &'static str {
471 match self {
472 Self::Asserted => "asserted",
473 Self::Hypothetical => "hypothetical",
474 Self::Planned => "planned",
475 Self::Desired => "desired",
476 Self::Questioned => "questioned",
477 Self::Reported => "reported",
478 }
479 }
480}
481
482/// One token in an [`NlpResult`].
483///
484/// Tokens are subword units; several may belong to one word (see
485/// [`word_index`](NlpToken::word_index)). Index-based references elsewhere
486/// (e.g. [`DepLink::head`], [`SrlRole::span`]) are always *token* indices into
487/// [`NlpResult::tokens`], not word indices.
488#[derive(Debug, Clone, Default)]
489#[non_exhaustive]
490pub struct NlpToken {
491 /// Surface form of the token.
492 pub text: String,
493 /// UTF-8 byte offset of the token start in the original input text.
494 pub start: usize,
495 /// UTF-8 byte offset of the token end (exclusive).
496 pub end: usize,
497 /// POS tag (typically Universal Dependencies tagset). `None` if POS not requested.
498 pub pos: Option<String>,
499 /// Named-entity type (e.g. `"PERSON"`, `"ORG"`). `None` outside any entity span.
500 pub ner: Option<String>,
501 /// Dependency-parse head and relation label. `None` if DEP not requested.
502 pub dep: Option<DepLink>,
503 /// Index of the word this subword token belongs to.
504 ///
505 /// Dense and monotonically non-decreasing over [`NlpResult::tokens`]:
506 /// consecutive tokens that form one word share a `word_index`, and each new
507 /// word increments it by one starting at `0`. Lets consumers regroup
508 /// subwords into words without parsing tokenizer metaspace markers.
509 pub word_index: usize,
510}
511
512/// One sentence in an [`NlpResult`].
513#[derive(Debug, Clone, Default)]
514#[non_exhaustive]
515pub struct NlpSentence {
516 /// Inclusive `[first, last]` token indices into [`NlpResult::tokens`].
517 pub token_range: (usize, usize),
518 /// UTF-8 byte offset of the sentence start in the original text.
519 pub start: usize,
520 /// UTF-8 byte offset of the sentence end (exclusive).
521 pub end: usize,
522}
523
524/// A dependency-parse arc attached to an [`NlpToken`].
525#[derive(Debug, Clone, Default, PartialEq, Eq)]
526#[non_exhaustive]
527pub struct DepLink {
528 /// Syntactic head as a 0-based index into [`NlpResult::tokens`].
529 ///
530 /// `Some(i)` points at the head token; `None` means this token attaches to
531 /// the sentence root (it has no head). The index is global across the whole
532 /// [`NlpResult`], not chunk- or sentence-local.
533 pub head: Option<usize>,
534 /// Relation label (e.g. `"nsubj"`, `"obj"`, `"amod"`).
535 pub relation: String,
536}
537
538/// One semantic-role-labeling frame: a predicate and its argument spans.
539#[derive(Debug, Clone, Default)]
540#[non_exhaustive]
541pub struct SrlFrame {
542 /// 0-based index into [`NlpResult::tokens`] of the predicate token.
543 ///
544 /// The predicate token is never itself included in [`roles`](SrlFrame::roles).
545 pub predicate_token: usize,
546 /// Predicate sense identifier when known (e.g. `"buy.01"`).
547 pub predicate_sense: Option<String>,
548 /// Argument spans, excluding the predicate token.
549 pub roles: Vec<SrlRole>,
550}
551
552/// One argument span of an [`SrlFrame`].
553#[derive(Debug, Clone, Default, PartialEq, Eq)]
554#[non_exhaustive]
555pub struct SrlRole {
556 /// Inclusive `[first, last]` 0-based token indices into [`NlpResult::tokens`].
557 ///
558 /// Both endpoints are inclusive; a single-token argument has `first == last`.
559 pub span: (usize, usize),
560 /// Role label (e.g. `"ARG0"`, `"ARG1"`, `"ARGM-TMP"`).
561 pub label: String,
562}
563
564/// One sentence-level classification result.
565#[derive(Debug, Clone, Default)]
566#[non_exhaustive]
567pub struct SpeechAct {
568 /// Index into [`NlpResult::sentences`].
569 pub sentence_index: usize,
570 /// Class label (e.g. `"STATEMENT"`, `"QUESTION"`, `"GREETING"`).
571 ///
572 /// Equals the label of the argmax of [`scores`](SpeechAct::scores).
573 pub label: String,
574 /// Provider-reported confidence in `[0.0, 1.0]`.
575 ///
576 /// Equals `scores[argmax]` — the probability of the winning [`label`](SpeechAct::label).
577 pub confidence: f32,
578 /// Full per-class softmax over the model's dialog-act vocabulary.
579 ///
580 /// Parallel to [`NlpLabelMaps::cls`]: `scores[i]` is the probability of the
581 /// class named `cls[i]`, and the values sum to ~1.0. Empty when the model
582 /// does not expose a distribution. Lets consumers do thresholded
583 /// multi-label gating instead of relying on the top-1 [`label`](SpeechAct::label).
584 pub scores: Vec<f32>,
585}
586
587/// One merged named-entity span, a BIO-collapsed view of per-token NER tags.
588///
589/// Produced by collapsing the per-token [`NlpToken::ner`] BIO tags: a `B-` (or
590/// orphan `I-`) tag opens a span, matching `I-` tags extend it, and an `O` tag
591/// or a label change closes it. Spans never include the `"O"` outside-tag.
592#[derive(Debug, Clone, Default, PartialEq, Eq)]
593#[non_exhaustive]
594pub struct NerEntity {
595 /// Surface text of the entity, spanning the original input bytes.
596 pub text: String,
597 /// Entity type without the BIO prefix (e.g. `"PERSON"`, `"GPE"`).
598 pub label: String,
599 /// Inclusive `[first, last]` 0-based token indices into [`NlpResult::tokens`].
600 pub token_span: (usize, usize),
601 /// Half-open `[start, end)` UTF-8 byte offsets into the original input text.
602 pub char_span: (usize, usize),
603}
604
605/// The label vocabularies a model decodes against, one list per head.
606///
607/// Each vector is indexed by the model's internal class id, so a decoded class
608/// id `k` for a head maps to `labels.<head>[k]`. In particular
609/// [`cls`](NlpLabelMaps::cls) is parallel to [`SpeechAct::scores`]. Exposing
610/// these lets consumers map labels back to ids without embedding a parallel
611/// copy of the model's `label_maps.json`.
612#[derive(Debug, Clone, Default)]
613#[non_exhaustive]
614pub struct NlpLabelMaps {
615 /// Part-of-speech tag vocabulary.
616 pub pos: Vec<String>,
617 /// Named-entity BIO tag vocabulary.
618 pub ner: Vec<String>,
619 /// Dependency relation vocabulary.
620 pub deprel: Vec<String>,
621 /// Semantic-role BIO tag vocabulary.
622 pub srl: Vec<String>,
623 /// Dialog-act / sentence-classification vocabulary, parallel to
624 /// [`SpeechAct::scores`].
625 pub cls: Vec<String>,
626 /// Morphological feature-bundle vocabulary. Empty for models without a
627 /// morphology head.
628 pub morph: Vec<String>,
629 /// Lemmatiser vocabulary. kniv v7 predicts an edit script
630 /// (`"case|strip|append"`) rather than the lemma itself, so these are
631 /// scripts, not words.
632 pub lemma: Vec<String>,
633 /// Sentiment vocabulary. Empty for models without a sentiment head.
634 pub sentiment: Vec<String>,
635 /// Coarse entity-type vocabulary used by the default entity head, i.e.
636 /// what [`NlpEntity::ty`] carries when the request has no
637 /// [`ExtractionSchema::entity_types`].
638 pub ent_type: Vec<String>,
639}
640
641/// The decoding thresholds a model ships in `calibration.json`.
642///
643/// These are not hyperparameters of the graphs; they were tuned on dev data
644/// and are what the published quality figures were measured with. Override
645/// them per request via [`NlpRequest::calibration`] only against a labelled
646/// sample.
647#[derive(Debug, Clone, Default, PartialEq)]
648#[non_exhaustive]
649pub struct NlpCalibration {
650 /// Per-relation offsets, by relation name. A fact is kept when its raw
651 /// margin exceeds `-offset`.
652 pub rel_offsets: std::collections::HashMap<String, f32>,
653 /// The offset applied to relations the model was not calibrated for,
654 /// as `-unseen_offset`.
655 pub unseen_offset: f32,
656 /// A global offset added to every relation threshold.
657 pub offset: f32,
658 /// How many facts to keep per entity pair.
659 pub rel_top: usize,
660 /// Subtracted from entity-type span margins.
661 pub span_offset: f32,
662 /// Added to the "no antecedent" coreference score. Higher links less.
663 pub coref_link_bias: f32,
664 /// Added to the `O` column of the default entity head.
665 pub ent_o_bias: f32,
666 /// How coreference groups entity mentions: `"none"`, `"compatible"` or
667 /// `"all"`. Both kniv v7 models ship `"none"` — chains merge mentions
668 /// too eagerly and cost end-to-end relation F1.
669 pub entity_coref: String,
670}
671
672/// Per-request overrides of [`NlpCalibration`]. Unset fields keep the
673/// model's shipped value.
674#[derive(Debug, Clone, Default, PartialEq)]
675#[non_exhaustive]
676pub struct NlpCalibrationOverrides {
677 /// Replaces individual entries of [`NlpCalibration::rel_offsets`],
678 /// leaving the rest as shipped.
679 pub rel_offsets: std::collections::HashMap<String, f32>,
680 /// Overrides [`NlpCalibration::unseen_offset`].
681 pub unseen_offset: Option<f32>,
682 /// Overrides [`NlpCalibration::span_offset`].
683 pub span_offset: Option<f32>,
684 /// Overrides [`NlpCalibration::coref_link_bias`].
685 pub coref_link_bias: Option<f32>,
686 /// Overrides [`NlpCalibration::ent_o_bias`].
687 pub ent_o_bias: Option<f32>,
688 /// Overrides [`NlpCalibration::entity_coref`].
689 pub entity_coref: Option<String>,
690}
691
692impl NlpCalibration {
693 /// This calibration with `overrides` applied.
694 #[must_use]
695 pub fn with_overrides(&self, overrides: &NlpCalibrationOverrides) -> Self {
696 let mut out = self.clone();
697 for (name, value) in &overrides.rel_offsets {
698 out.rel_offsets.insert(name.clone(), *value);
699 }
700 if let Some(v) = overrides.unseen_offset {
701 out.unseen_offset = v;
702 }
703 if let Some(v) = overrides.span_offset {
704 out.span_offset = v;
705 }
706 if let Some(v) = overrides.coref_link_bias {
707 out.coref_link_bias = v;
708 }
709 if let Some(v) = overrides.ent_o_bias {
710 out.ent_o_bias = v;
711 }
712 if let Some(v) = &overrides.entity_coref {
713 out.entity_coref = v.clone();
714 }
715 out
716 }
717}
718
719/// A multi-head structured-NLP model.
720///
721/// # Canonical default model
722///
723/// The reference implementation is
724/// [`dragonscale-ai/kniv-deberta-nlp-base-en-xsmall`](https://huggingface.co/dragonscale-ai/kniv-deberta-nlp-base-en-xsmall):
725/// a DeBERTa-v3-xsmall multi-head cascade producing POS / NER / DEP / SRL / CLS
726/// in a single forward pass. It will ship as the canonical `nlp/default`
727/// catalog alias once the `local/onnx` provider gains an `NlpModel`
728/// implementation in a follow-up release.
729///
730/// Tagsets used by the reference model:
731/// - POS: Universal Dependencies English EWT (17 classes)
732/// - NER: OntoNotes (18 entity types)
733/// - DEP: Universal Dependencies English EWT
734/// - SRL: PropBank (42 classes)
735/// - CLS: 8 dialog acts (internal dataset)
736///
737/// Language: English only. Maximum input: 128 tokens — provider-side
738/// chunking is the implementation's responsibility; the trait contract
739/// keeps token offsets stable in whole-document UTF-8 byte coordinates
740/// regardless of internal chunking.
741#[async_trait]
742pub trait NlpModel: ModelInfo {
743 /// Analyze a batch of texts, returning one [`NlpResult`] per request.
744 ///
745 /// # Errors
746 /// Returns an error if a requested task is not in
747 /// [`supported_tasks`](NlpModel::supported_tasks), or if the provider
748 /// fails internally.
749 async fn analyze(&self, requests: Vec<NlpRequest<'_>>) -> Result<Vec<NlpResult>>;
750
751 /// Heads this model can populate. Callers may request a subset via
752 /// [`NlpRequest::tasks`].
753 fn supported_tasks(&self) -> NlpTasks;
754
755 /// The label vocabularies this model decodes against, if it exposes them.
756 ///
757 /// Returns `Some` for models with a fixed, introspectable label set (e.g.
758 /// the ONNX cascade), letting callers map labels to class ids and read
759 /// [`SpeechAct::scores`] against [`NlpLabelMaps::cls`]. The default is
760 /// `None` for models that do not expose vocabularies (e.g. remote or mock).
761 fn label_maps(&self) -> Option<&NlpLabelMaps> {
762 None
763 }
764
765 /// The decoding thresholds this model ships, if it has any.
766 ///
767 /// `Some` for kniv v7, whose `calibration.json` is what
768 /// [`NlpRequest::calibration`] overrides.
769 fn calibration(&self) -> Option<&NlpCalibration> {
770 None
771 }
772
773 /// Optional warmup hook. The default is a no-op.
774 ///
775 /// # Errors
776 /// Returns an error if the underlying model fails to initialize.
777 async fn warmup(&self) -> Result<()> {
778 Ok(())
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use std::sync::Arc;
786
787 #[test]
788 fn nlp_model_is_dyn_safe() {
789 fn _accept(_: Arc<dyn NlpModel>) {}
790 }
791
792 #[test]
793 fn nlp_tasks_all_includes_every_head() {
794 let all = NlpTasks::ALL;
795 for flag in [
796 NlpTasks::POS,
797 NlpTasks::NER,
798 NlpTasks::DEP,
799 NlpTasks::SRL,
800 NlpTasks::CLS,
801 NlpTasks::MORPH,
802 NlpTasks::LEMMA,
803 NlpTasks::SENTIMENT,
804 NlpTasks::COREF,
805 NlpTasks::ENTITIES,
806 NlpTasks::FACTS,
807 ] {
808 assert!(all.contains(flag), "{flag:?} missing from NlpTasks::ALL");
809 }
810 }
811
812 #[test]
813 fn facts_imply_entities() {
814 assert!(NlpTasks::FACTS.expanded().contains(NlpTasks::ENTITIES));
815 assert!(!NlpTasks::POS.expanded().contains(NlpTasks::ENTITIES));
816 }
817
818 #[test]
819 fn calibration_overrides_only_touch_set_fields() {
820 let base = crate::traits::NlpCalibration {
821 unseen_offset: -1.5,
822 span_offset: -2.0,
823 rel_top: 4,
824 entity_coref: "none".to_string(),
825 ..Default::default()
826 };
827 let overrides = crate::traits::NlpCalibrationOverrides {
828 span_offset: Some(-1.0),
829 ..Default::default()
830 };
831 let merged = base.with_overrides(&overrides);
832 assert_eq!(merged.span_offset, -1.0);
833 assert_eq!(merged.unseen_offset, -1.5);
834 assert_eq!(merged.rel_top, 4);
835 assert_eq!(merged.entity_coref, "none");
836 }
837}