Skip to main content

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}