Skip to main content

steeldb/
tagger_train.rs

1//! **Step 2: tagger finetuning in Rust** (candle) — the port of the reference `spo_tagger.py::train`.
2//!
3//! Trains the multi-head tagger whose design is fixed in [`crate::tagger_data`]:
4//!   * **Head A** — BIO span typing over the spec's *semantic* facets + structural kinds (dims 1, 3, 4)
5//!   * **Head B** — the 4-way epistemic reading (dim 5), which is also the infon polarity `i` the
6//!     Dempster-Shafer layer consumes
7//!
8//! The shared encoder is finetuned, not frozen: the pretrained tensors are named `bert.*`, so building
9//! the model under `vb.pp("bert")` lets the checkpoint load straight into the trainable `VarMap`, and the
10//! two new heads simply have no counterpart in the file (loaded per-tensor, missing names skipped).
11//!
12//! Loss is token-level cross-entropy on both heads with an ignore mask: padding and sub-token
13//! continuations that carry no label are marked `-100` and dropped by gathering valid positions before
14//! the CE, which candle's `cross_entropy` does not do for us.
15//!
16//! Head C (the biaffine relation scorer) is deliberately *not* here — it needs span pooling over the
17//! encoder output, so it lands as a second stage once Head A's spans are reliable.
18
19use crate::tagger_data::{head_a_labels, head_b_labels, to_bio, to_epistemic, TaggerExample};
20use crate::vocabulary::VocabularySpace;
21use candle_core::{DType, Device, IndexOp, Tensor, D};
22use candle_nn::{loss, ops::softmax, AdamW, Linear, Module, Optimizer, ParamsAdamW, VarBuilder, VarMap};
23use crate::hrm::{HrmConfig, HrmTagger};
24use candle_transformers::models::bert::Config as BertConfig;
25use serde::Serialize;
26use std::path::{Path, PathBuf};
27use tokenizers::Tokenizer;
28
29const IGNORE: i64 = -100;
30
31#[derive(Debug, Clone)]
32pub struct TrainConfig {
33    /// HF snapshot dir holding `config.json` + `model.safetensors` (e.g. bert-tiny).
34    pub base_dir: PathBuf,
35    /// a `tokenizer.json` compatible with the base's vocab
36    pub tokenizer: PathBuf,
37    pub epochs: usize,
38    pub lr: f64,
39    pub batch: usize,
40    pub max_len: usize,
41    /// weight on Head B's loss relative to Head A
42    pub lambda_b: f64,
43    pub seed: u64,
44}
45
46impl Default for TrainConfig {
47    fn default() -> Self {
48        TrainConfig {
49            base_dir: PathBuf::new(),
50            tokenizer: PathBuf::new(),
51            epochs: 8,
52            lr: 5e-4,
53            batch: 8,
54            max_len: 128,
55            lambda_b: 0.5,
56            seed: 0xC0FFEE,
57        }
58    }
59}
60
61/// The tagger is the reference HRM architecture: BERT **embeddings only** + the two-timescale reasoning
62/// core + independent heads ([`crate::hrm`]). Earlier revisions of this file used BERT's full encoder with
63/// heads attached, which is a different model — capacity there comes from stacked layers, whereas the
64/// reference gets it from recurrent refinement at ~4M parameters.
65pub type MultiHeadTagger = HrmTagger;
66
67/// Build the HRM config from a HuggingFace base `config.json` (only the embedding-table shapes are used;
68/// the encoder settings are irrelevant because the encoder is discarded).
69pub fn hrm_config_from(bert: &BertConfig) -> HrmConfig {
70    HrmConfig::bert_tiny(bert.vocab_size, bert.hidden_size, bert.max_position_embeddings, bert.type_vocab_size)
71}
72
73/// One encoded example: token ids, attention mask, and per-token targets for both heads.
74#[derive(Debug, Clone)]
75pub struct Encoded {
76    pub ids: Vec<u32>,
77    pub attn: Vec<u32>,
78    pub labels_a: Vec<i64>,
79    pub labels_b: Vec<i64>,
80}
81
82/// Tokenize + project char-span labels onto tokens, padding to `max_len`. Reuses the offset projection
83/// already proven in [`crate::tagger_data`], so alignment semantics are identical between data
84/// generation and training.
85pub fn encode(spec: &VocabularySpace, tok: &Tokenizer, ex: &TaggerExample, max_len: usize) -> Result<Encoded, String> {
86    let enc = tok.encode(ex.text.as_str(), true).map_err(|e| format!("tokenize: {e}"))?;
87    let offsets: Vec<(usize, usize)> = enc.get_offsets().to_vec();
88    let mut ids: Vec<u32> = enc.get_ids().to_vec();
89    let mut labels_a = to_bio(spec, &ex.spans, &offsets);
90    let mut labels_b = to_epistemic(&ex.spans, &offsets);
91    // specials report (0,0) offsets → already IGNORE from the projections
92    let n = ids.len().min(max_len);
93    ids.truncate(n);
94    labels_a.truncate(n);
95    labels_b.truncate(n);
96    let mut attn = vec![1u32; n];
97    while ids.len() < max_len {
98        ids.push(0);
99        attn.push(0);
100        labels_a.push(IGNORE);
101        labels_b.push(IGNORE);
102    }
103    Ok(Encoded { ids, attn, labels_a, labels_b })
104}
105
106/// Cross-entropy over only the positions whose target isn't `IGNORE`. candle's `cross_entropy` has no
107/// ignore-index, so valid positions are gathered first; returns `None` when a batch has no labels.
108fn masked_ce(logits: &Tensor, labels: &[i64], device: &Device) -> candle_core::Result<Option<Tensor>> {
109    let keep: Vec<u32> = labels.iter().enumerate().filter(|(_, l)| **l != IGNORE).map(|(i, _)| i as u32).collect();
110    if keep.is_empty() {
111        return Ok(None);
112    }
113    let (b, t, c) = logits.dims3()?;
114    let flat = logits.reshape((b * t, c))?;
115    let idx = Tensor::from_vec(keep.clone(), keep.len(), device)?;
116    let picked = flat.index_select(&idx, 0)?;
117    let tgt: Vec<u32> = keep.iter().map(|&i| labels[i as usize] as u32).collect();
118    let tgt = Tensor::from_vec(tgt, keep.len(), device)?;
119    Ok(Some(loss::cross_entropy(&picked, &tgt)?))
120}
121
122#[derive(Debug, Clone, Serialize)]
123pub struct EpochReport {
124    pub epoch: usize,
125    pub loss_a: f64,
126    pub loss_b: f64,
127    pub total: f64,
128}
129
130#[derive(Debug, Clone, Serialize)]
131pub struct TrainReport {
132    pub examples: usize,
133    pub labels_a: usize,
134    pub labels_b: usize,
135    pub epochs: Vec<EpochReport>,
136    /// token-level accuracy of Head A on the training set after the final epoch
137    pub train_acc_a: f64,
138    pub train_acc_b: f64,
139    /// held-out examples (never trained on)
140    pub dev_examples: usize,
141    /// the numbers that indicate generalisation rather than memorisation
142    pub dev_acc_a: f64,
143    pub dev_acc_b: f64,
144    /// Head A accuracy over only the tokens that carry an entity label (not `O`) — the metric that
145    /// matters, since `O` dominates token-level accuracy and inflates it
146    pub dev_acc_a_spans: f64,
147}
148
149/// Finetune Head A + Head B. Returns the report; the trained weights are left in `varmap` for saving.
150pub fn train(
151    spec: &VocabularySpace,
152    examples: &[TaggerExample],
153    cfg: &TrainConfig,
154) -> Result<(VarMap, TrainReport, Vec<String>), String> {
155    if examples.is_empty() {
156        return Err("no training examples".into());
157    }
158    let device = Device::Cpu;
159    let bert_cfg: BertConfig = serde_json::from_slice(
160        &std::fs::read(cfg.base_dir.join("config.json")).map_err(|e| format!("base config.json: {e}"))?,
161    )
162    .map_err(|e| format!("parse base config: {e}"))?;
163    let tok = Tokenizer::from_file(&cfg.tokenizer).map_err(|e| format!("tokenizer: {e}"))?;
164
165    let labels_a = head_a_labels(spec);
166    let n_a = labels_a.len();
167    let n_b = head_b_labels().len();
168
169    // trainable params live in the VarMap; pretrained `bert.*` tensors are loaded into it per-name so the
170    // two fresh heads (absent from the checkpoint) are simply left at their init.
171    let varmap = VarMap::new();
172    let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
173    let hrm_cfg = hrm_config_from(&bert_cfg);
174    let model = HrmTagger::new(vb, &hrm_cfg, n_a, n_b).map_err(|e| format!("build model: {e}"))?;
175    {
176        // Only the EMBEDDING table is pretrained; the HRM core and heads train from scratch. The reference
177        // takes `BertModel.from_pretrained(base).embeddings` and discards the encoder, so encoder tensors in
178        // the checkpoint simply have no counterpart here.
179        let weights = cfg.base_dir.join("model.safetensors");
180        let pre = candle_core::safetensors::load(&weights, &device).map_err(|e| format!("load {}: {e}", weights.display()))?;
181        let mut vm = varmap.clone();
182        let mut loaded = 0usize;
183        for (name, t) in pre.iter().filter(|(n, _)| n.starts_with("bert.embeddings.")) {
184            if vm.set_one(name, t).is_ok() {
185                loaded += 1;
186            }
187        }
188        if loaded == 0 {
189            return Err("no pretrained embedding tensors matched (expected `bert.embeddings.*`)".into());
190        }
191        eprintln!("loaded {loaded} pretrained embedding tensors; HRM core + heads train from scratch");
192    }
193
194    let all: Vec<Encoded> = examples.iter().filter_map(|e| encode(spec, &tok, e, cfg.max_len).ok()).collect();
195    if all.len() < 5 {
196        return Err("too few examples to split train/dev".into());
197    }
198    // deterministic 80/20 holdout by stride, so every generator case lands in both halves
199    let mut encoded: Vec<Encoded> = Vec::new();
200    let mut dev: Vec<Encoded> = Vec::new();
201    for (i, e) in all.into_iter().enumerate() {
202        if i % 5 == 4 {
203            dev.push(e);
204        } else {
205            encoded.push(e);
206        }
207    }
208
209    let mut opt = AdamW::new(varmap.all_vars(), ParamsAdamW { lr: cfg.lr, ..Default::default() })
210        .map_err(|e| format!("optimizer: {e}"))?;
211    let mut epochs_out = Vec::new();
212
213    // deterministic shuffling without an rng dependency (LCG over indices)
214    let mut order: Vec<usize> = (0..encoded.len()).collect();
215    let mut seed = cfg.seed;
216    let mut next = move || {
217        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
218        (seed >> 33) as usize
219    };
220
221    for epoch in 1..=cfg.epochs {
222        for i in (1..order.len()).rev() {
223            order.swap(i, next() % (i + 1));
224        }
225        let (mut sum_a, mut sum_b, mut steps) = (0.0f64, 0.0f64, 0usize);
226        for chunk in order.chunks(cfg.batch.max(1)) {
227            let bs = chunk.len();
228            let ids: Vec<u32> = chunk.iter().flat_map(|&i| encoded[i].ids.clone()).collect();
229            let attn: Vec<u32> = chunk.iter().flat_map(|&i| encoded[i].attn.clone()).collect();
230            let la: Vec<i64> = chunk.iter().flat_map(|&i| encoded[i].labels_a.clone()).collect();
231            let lb: Vec<i64> = chunk.iter().flat_map(|&i| encoded[i].labels_b.clone()).collect();
232            let ids_t = Tensor::from_vec(ids, (bs, cfg.max_len), &device).map_err(|e| e.to_string())?;
233            let attn_t = Tensor::from_vec(attn, (bs, cfg.max_len), &device).map_err(|e| e.to_string())?;
234            let (log_a, log_b) = model.forward(&ids_t, &attn_t, true).map_err(|e| format!("forward: {e}"))?;
235            let ce_a = masked_ce(&log_a, &la, &device).map_err(|e| e.to_string())?;
236            let ce_b = masked_ce(&log_b, &lb, &device).map_err(|e| e.to_string())?;
237            let (Some(ce_a), Some(ce_b)) = (ce_a, ce_b) else { continue };
238            let total = (&ce_a + (ce_b.affine(cfg.lambda_b, 0.0).map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
239            opt.backward_step(&total).map_err(|e| format!("backward: {e}"))?;
240            sum_a += ce_a.to_scalar::<f32>().map_err(|e| e.to_string())? as f64;
241            sum_b += ce_b.to_scalar::<f32>().map_err(|e| e.to_string())? as f64;
242            steps += 1;
243        }
244        let d = steps.max(1) as f64;
245        epochs_out.push(EpochReport {
246            epoch,
247            loss_a: round4(sum_a / d),
248            loss_b: round4(sum_b / d),
249            total: round4((sum_a + cfg.lambda_b * sum_b) / d),
250        });
251    }
252
253    let (acc_a, acc_b, _) = accuracy(&model, &encoded, cfg, &device).map_err(|e| e.to_string())?;
254    let (dev_a, dev_b, dev_span) = accuracy(&model, &dev, cfg, &device).map_err(|e| e.to_string())?;
255    let report = TrainReport {
256        examples: encoded.len(),
257        labels_a: n_a,
258        labels_b: n_b,
259        epochs: epochs_out,
260        train_acc_a: round4(acc_a),
261        train_acc_b: round4(acc_b),
262        dev_examples: dev.len(),
263        dev_acc_a: round4(dev_a),
264        dev_acc_b: round4(dev_b),
265        dev_acc_a_spans: round4(dev_span),
266    };
267    Ok((varmap, report, labels_a))
268}
269
270/// Token-level accuracy on labelled positions for both heads.
271fn accuracy(model: &MultiHeadTagger, encoded: &[Encoded], cfg: &TrainConfig, device: &Device) -> candle_core::Result<(f64, f64, f64)> {
272    let (mut ok_a, mut ok_b, mut n_a, mut n_b) = (0usize, 0usize, 0usize, 0usize);
273    let (mut ok_span, mut n_span) = (0usize, 0usize);
274    for e in encoded {
275        let ids = Tensor::from_vec(e.ids.clone(), (1, cfg.max_len), device)?;
276        let attn = Tensor::from_vec(e.attn.clone(), (1, cfg.max_len), device)?;
277        let (la, lb) = model.forward(&ids, &attn, false)?;
278        let pa = softmax(&la.i(0)?, D::Minus1)?.argmax(D::Minus1)?.to_vec1::<u32>()?;
279        let pb = softmax(&lb.i(0)?, D::Minus1)?.argmax(D::Minus1)?.to_vec1::<u32>()?;
280        for (i, &t) in e.labels_a.iter().enumerate() {
281            if t != IGNORE {
282                n_a += 1;
283                if pa[i] as i64 == t {
284                    ok_a += 1;
285                }
286                if t != 0 {
287                    // labelled (non-`O`) tokens only: `O` dominates and inflates plain accuracy
288                    n_span += 1;
289                    if pa[i] as i64 == t {
290                        ok_span += 1;
291                    }
292                }
293            }
294        }
295        for (i, &t) in e.labels_b.iter().enumerate() {
296            if t != IGNORE {
297                n_b += 1;
298                if pb[i] as i64 == t {
299                    ok_b += 1;
300                }
301            }
302        }
303    }
304    Ok((
305        ok_a as f64 / n_a.max(1) as f64,
306        ok_b as f64 / n_b.max(1) as f64,
307        ok_span as f64 / n_span.max(1) as f64,
308    ))
309}
310
311/// Persist the finetuned tagger: weights plus the label spaces needed to decode it.
312pub fn save(varmap: &VarMap, labels_a: &[String], out_dir: &Path) -> Result<(), String> {
313    std::fs::create_dir_all(out_dir).map_err(|e| e.to_string())?;
314    varmap.save(out_dir.join("tagger.safetensors")).map_err(|e| format!("save weights: {e}"))?;
315    let meta = serde_json::json!({
316        "head_a_labels": labels_a,
317        "head_b_labels": head_b_labels(),
318        "ignore_index": IGNORE,
319    });
320    std::fs::write(out_dir.join("tagger.json"), serde_json::to_vec_pretty(&meta).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
321    Ok(())
322}
323
324fn round4(v: f64) -> f64 {
325    (v * 10000.0).round() / 10000.0
326}
327
328
329// ── inference: load a tuned checkpoint and tag real text ────────────────────────────────────────
330
331/// A span predicted by the tuned tagger: byte offsets, its facet (Head A) and epistemic reading
332/// (Head B, majority vote over the span's tokens → the infon polarity `i`).
333#[derive(Debug, Clone, Serialize, PartialEq)]
334pub struct PredictedSpan {
335    pub start: usize,
336    pub end: usize,
337    pub facet: String,
338    pub text: String,
339    pub negated: bool,
340    pub hedged: bool,
341    /// the Dempster-Shafer belief level implied by the epistemic reading (±1 / ±0.5)
342    pub belief: f32,
343}
344
345/// A tuned tagger ready for inference: weights + the label spaces they were trained with.
346pub struct TunedTagger {
347    model: MultiHeadTagger,
348    tok: Tokenizer,
349    labels_a: Vec<String>,
350    device: Device,
351    max_len: usize,
352    /// Head C (step 5) + the spec that supplies its type mask. When present, projection emits bound
353    /// relation tokens (`rel/<name>/+` on the actor side, `rel/<name>/-` on the target side).
354    relations: Option<(crate::relation_train::BiaffineHead, VocabularySpace)>,
355}
356
357impl TunedTagger {
358    /// Load from a `save()` directory (`tagger.safetensors` + `tagger.json`) plus the base config and a
359    /// tokenizer. The label space comes from the checkpoint, never from a hardcoded list, so a model
360    /// trained on a different corpus's spec decodes correctly.
361    pub fn load(dir: &Path, base_dir: &Path, tokenizer: &Path, max_len: usize) -> Result<TunedTagger, String> {
362        let meta: serde_json::Value =
363            serde_json::from_slice(&std::fs::read(dir.join("tagger.json")).map_err(|e| format!("tagger.json: {e}"))?)
364                .map_err(|e| format!("parse tagger.json: {e}"))?;
365        let labels_a: Vec<String> = meta
366            .get("head_a_labels")
367            .and_then(|v| v.as_array())
368            .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
369            .ok_or("tagger.json missing head_a_labels")?;
370        let n_b = head_b_labels().len();
371        let bert_cfg: BertConfig =
372            serde_json::from_slice(&std::fs::read(base_dir.join("config.json")).map_err(|e| format!("base config: {e}"))?)
373                .map_err(|e| format!("parse base config: {e}"))?;
374        let device = Device::Cpu;
375        let weights = dir.join("tagger.safetensors");
376        let vb = unsafe {
377            VarBuilder::from_mmaped_safetensors(&[weights.clone()], DType::F32, &device)
378                .map_err(|e| format!("load {}: {e}", weights.display()))?
379        };
380        let model = HrmTagger::new(vb, &hrm_config_from(&bert_cfg), labels_a.len(), n_b).map_err(|e| format!("build model: {e}"))?;
381        let tok = Tokenizer::from_file(tokenizer).map_err(|e| format!("tokenizer: {e}"))?;
382        Ok(TunedTagger { model, tok, labels_a, device, max_len, relations: None })
383    }
384
385    pub fn labels(&self) -> &[String] {
386        &self.labels_a
387    }
388
389    /// Attach the trained biaffine relation head (Head C) so projection binds arguments and emits
390    /// dimension-2 tokens. `dir` is the checkpoint directory holding `relations.safetensors`.
391    pub fn enable_relations(&mut self, dir: &Path, spec: &VocabularySpace) -> Result<(), String> {
392        let path = dir.join("relations.safetensors");
393        if !path.exists() {
394            return Err(format!("{} not found — run step 5 first", path.display()));
395        }
396        let vb = unsafe {
397            VarBuilder::from_mmaped_safetensors(&[path.clone()], DType::F32, &self.device)
398                .map_err(|e| format!("load {}: {e}", path.display()))?
399        };
400        let hidden = self.model.hidden_size();
401        let head = crate::relation_train::BiaffineHead::new(vb, 2 * hidden, spec.relation_facets.len() + 1)
402            .map_err(|e| format!("build head C: {e}"))?;
403        self.relations = Some((head, spec.clone()));
404        Ok(())
405    }
406
407    pub fn has_relations(&self) -> bool {
408        self.relations.is_some()
409    }
410
411    /// Project a sentence into a [`Situation`] for the roaring index — the bridge from the tuned tagger to
412    /// ingest. Emits the six Vocabulary-Space dimensions this tagger can produce:
413    ///   * **dim 1** typed entity tokens (`org/raytheon`) from Head A's facets
414    ///   * **dim 3** `geo/…` / `time/…` via the deterministic loci normalisers
415    ///   * **dim 4** `qty/…` bucket URIs via the units canonicaliser
416    ///   * **dim 5** `state/negated` / `state/hedged` from a STATE cue span
417    ///
418    /// **Negation scope**: an epistemic cue scopes its clause, so a `state/negated` cue sets negative
419    /// infon polarity on the *other* tokens of the sentence, not merely on the cue. That is what makes
420    /// `Bel`/`Pl` meaningful — "Raytheon does not manufacture Aegis" must not assert `org/raytheon`
421    /// positively about that relation.
422    pub fn project(&self, sentence: &str) -> Result<crate::projector::Situation, String> {
423        use crate::dimensions;
424        let spans = self.tag(sentence)?;
425        let mut tokens: Vec<String> = Vec::new();
426        let mut numbers: Vec<(String, f64)> = Vec::new();
427        // clause-level epistemic state from any STATE cue in the sentence
428        let (mut negated, mut hedged) = (false, false);
429        for sp in spans.iter().filter(|s| s.facet == "state") {
430            negated |= sp.negated;
431            hedged |= sp.hedged;
432        }
433        if negated || hedged {
434            tokens.push(dimensions::state_uri(negated, hedged).to_string());
435        }
436        for sp in &spans {
437            match sp.facet.as_str() {
438                "state" => {}
439                "qty" => {
440                    if let Some((field, si)) = crate::units::parse_quantity(&sp.text) {
441                        if let Some(u) = dimensions::qty_uri(&field, si) {
442                            tokens.push(u);
443                        }
444                        numbers.push((field, si));
445                    }
446                }
447                "time" => tokens.push(dimensions::time_uri(&sp.text).unwrap_or_else(|| format!("time/{}", crate::projector::slug(&sp.text)))),
448                "geo" => tokens.push(dimensions::geo_uri(&sp.text)),
449                facet => tokens.push(dimensions::entity_uri(facet, &sp.text)),
450            }
451        }
452        // dim 2: bind arguments with Head C. Polarity marks the argument SIDE — `+` actor, `-` target —
453        // and a compound token also records which entity filled each role, mirroring the structured
454        // projector's `rel/<name>/+/<facet>/<value>` form so both broad and specific queries work.
455        if let Some((head, spec)) = self.relations.as_ref() {
456            if let Ok(pairs) = self.bind_relations(sentence, &spans, head, spec) {
457                for (name, h, t) in pairs {
458                    tokens.push(dimensions::rel_uri(&name, dimensions::Role::Actor));
459                    tokens.push(dimensions::rel_uri(&name, dimensions::Role::Target));
460                    tokens.push(format!("rel/{}/+/{}", crate::projector::slug(&name), dimensions::entity_uri(&h.facet, &h.text)));
461                    tokens.push(format!("rel/{}/-/{}", crate::projector::slug(&name), dimensions::entity_uri(&t.facet, &t.text)));
462                }
463            }
464        }
465        tokens.sort();
466        tokens.dedup();
467        // the clause's polarity applies to every projected token (the cue scopes the assertion)
468        let level = dimensions::belief_level(negated, hedged);
469        let beliefs = if level == 1.0 { Vec::new() } else { tokens.iter().map(|t| (t.clone(), level)).collect() };
470        Ok(crate::projector::Situation { tokens, display: vec![sentence.to_string()], numbers, beliefs })
471    }
472
473
474    /// Tag a sentence → typed spans with epistemic polarity. BIO decoding merges `B-X` + following
475    /// `I-X`; each span's epistemic class is the majority vote of its tokens.
476    pub fn tag(&self, text: &str) -> Result<Vec<PredictedSpan>, String> {
477        let enc = self.tok.encode(text, true).map_err(|e| format!("tokenize: {e}"))?;
478        let n = enc.get_ids().len().min(self.max_len);
479        let ids: Vec<u32> = enc.get_ids()[..n].to_vec();
480        let offsets: Vec<(usize, usize)> = enc.get_offsets()[..n].to_vec();
481        let attn = vec![1u32; n];
482        let ids_t = Tensor::from_vec(ids, (1, n), &self.device).map_err(|e| e.to_string())?;
483        let attn_t = Tensor::from_vec(attn, (1, n), &self.device).map_err(|e| e.to_string())?;
484        let (la, lb) = self.model.forward(&ids_t, &attn_t, false).map_err(|e| format!("forward: {e}"))?;
485        let pa = softmax(&la.i(0).map_err(|e| e.to_string())?, D::Minus1)
486            .and_then(|t| t.argmax(D::Minus1))
487            .and_then(|t| t.to_vec1::<u32>())
488            .map_err(|e| e.to_string())?;
489        let pb_probs = softmax(&lb.i(0).map_err(|e| e.to_string())?, D::Minus1).map_err(|e| e.to_string())?;
490        let pb = pb_probs.argmax(D::Minus1).and_then(|t| t.to_vec1::<u32>()).map_err(|e| e.to_string())?;
491        // per-token distribution over the 4 epistemic classes, for the STATE-span constraint below
492        let pb_dist: Vec<Vec<f32>> = pb_probs.to_vec2::<f32>().map_err(|e| e.to_string())?;
493
494        let mut out: Vec<PredictedSpan> = Vec::new();
495        let mut cur: Option<(String, usize, usize, Vec<u32>, Vec<Vec<f32>>)> = None; // (kind, start, end, votes, dists)
496        let flush = |cur: &mut Option<(String, usize, usize, Vec<u32>, Vec<Vec<f32>>)>, out: &mut Vec<PredictedSpan>, text: &str| {
497            if let Some((kind, s, e, votes, dists)) = cur.take() {
498                // CONSISTENCY CONSTRAINT: a STATE span *is* the epistemic cue, so "asserted" is not a
499                // possible reading of it. Restrict the choice to the non-asserted classes, summing the
500                // span's token distributions — the same masking idea Head C applies to type-invalid pairs.
501                let ep = if kind.eq_ignore_ascii_case("state") && !dists.is_empty() {
502                    let mut best = (1usize, f32::NEG_INFINITY);
503                    for c in 1..4usize {
504                        let score: f32 = dists.iter().map(|d| d.get(c).copied().unwrap_or(0.0)).sum();
505                        if score > best.1 {
506                            best = (c, score);
507                        }
508                    }
509                    best.0 as u32
510                } else {
511                    majority(&votes)
512                };
513                let (negated, hedged) = match ep {
514                    0 => (false, false),
515                    1 => (false, true),
516                    2 => (true, true),
517                    _ => (true, false),
518                };
519                out.push(PredictedSpan {
520                    start: s,
521                    end: e,
522                    facet: kind.to_lowercase(),
523                    text: text[s..e].to_string(),
524                    negated,
525                    hedged,
526                    belief: crate::dimensions::belief_level(negated, hedged),
527                });
528            }
529        };
530        for i in 0..n {
531            let (ts, te) = offsets[i];
532            if te <= ts {
533                continue; // special token
534            }
535            let label = self.labels_a.get(pa[i] as usize).map(|s| s.as_str()).unwrap_or("O");
536            if let Some(kind) = label.strip_prefix("B-") {
537                flush(&mut cur, &mut out, text);
538                cur = Some((kind.to_string(), ts, te, vec![pb[i]], vec![pb_dist[i].clone()]));
539            } else if let Some(kind) = label.strip_prefix("I-") {
540                match cur.as_mut() {
541                    Some((k, _, e, votes, dists)) if k == kind => {
542                        *e = te;
543                        votes.push(pb[i]);
544                        dists.push(pb_dist[i].clone());
545                    }
546                    _ => flush(&mut cur, &mut out, text), // orphan I- → drop
547                }
548            } else {
549                flush(&mut cur, &mut out, text);
550            }
551        }
552        flush(&mut cur, &mut out, text);
553        Ok(merge_contiguous(snap_to_words(out, text), text))
554    }
555}
556
557/// Snap span edges out to enclosing word boundaries. Sub-word tokenisation plus a scarce `I-` class makes
558/// a tagger clip mid-word ("Aegis" → "Ae", "Thales" → "Tha"); the reference implementation notes the same
559/// failure and snaps spans to word boundaries. Purely a decode-time correction over char offsets — it
560/// cannot invent a span, only complete the word one already covers.
561fn snap_to_words(spans: Vec<PredictedSpan>, text: &str) -> Vec<PredictedSpan> {
562    let b = text.as_bytes();
563    let alnum = |i: usize| -> bool { (b[i] as char).is_alphanumeric() };
564    spans
565        .into_iter()
566        .map(|mut sp| {
567            // walk start left while the previous byte continues a word
568            while sp.start > 0 && text.is_char_boundary(sp.start - 1) && alnum(sp.start - 1) && alnum(sp.start.min(b.len() - 1)) {
569                sp.start -= 1;
570            }
571            // walk end right while the next byte continues a word
572            while sp.end < b.len() && text.is_char_boundary(sp.end) && alnum(sp.end) {
573                sp.end += 1;
574            }
575            while sp.end < b.len() && !text.is_char_boundary(sp.end) {
576                sp.end += 1;
577            }
578            sp.text = text[sp.start..sp.end].to_string();
579            sp
580        })
581        .collect()
582}
583
584impl TunedTagger {
585    /// Score every type-valid ordered span pair with Head C and return the argmax relations
586    /// (`(name, head_span, tail_span)`), skipping `none`.
587    fn bind_relations(
588        &self,
589        sentence: &str,
590        spans: &[PredictedSpan],
591        head: &crate::relation_train::BiaffineHead,
592        spec: &VocabularySpace,
593    ) -> Result<Vec<(String, PredictedSpan, PredictedSpan)>, String> {
594        use crate::tagger_data::{pair_mask, LabeledSpan};
595        // entity spans only: a relation binds participants, not epistemic cues
596        let ents: Vec<&PredictedSpan> = spans.iter().filter(|s| s.facet != "state").collect();
597        if ents.len() < 2 || spec.relation_facets.is_empty() {
598            return Ok(Vec::new());
599        }
600        let enc = self.tok.encode(sentence, true).map_err(|e| format!("tokenize: {e}"))?;
601        let n = enc.get_ids().len().min(self.max_len);
602        let ids = Tensor::from_vec(enc.get_ids()[..n].to_vec(), (1, n), &self.device).map_err(|e| e.to_string())?;
603        let attn = Tensor::from_vec(vec![1u32; n], (1, n), &self.device).map_err(|e| e.to_string())?;
604        let hidden = self.model.hidden(&ids, &attn, false).map_err(|e| format!("encode: {e}"))?;
605        let offsets: Vec<(usize, usize)> = enc.get_offsets()[..n].to_vec();
606        let reps: Vec<Tensor> = ents
607            .iter()
608            .map(|sp| {
609                let ls = LabeledSpan { start: sp.start, end: sp.end, facet: sp.facet.clone(), surface: sp.text.clone(), negated: sp.negated, hedged: sp.hedged };
610                crate::relation_train::pool_span(&hidden, &offsets, &ls)
611            })
612            .collect::<candle_core::Result<Vec<_>>>()
613            .map_err(|e| format!("pool: {e}"))?;
614        let stacked = Tensor::stack(&reps, 0).map_err(|e| e.to_string())?;
615        let logits = head.forward(&stacked).map_err(|e| format!("head C: {e}"))?;
616        let mask = pair_mask(spec);
617        let n_rel = spec.relation_facets.len() + 1;
618        let mut out = Vec::new();
619        for i in 0..ents.len() {
620            for j in 0..ents.len() {
621                if i == j {
622                    continue;
623                }
624                let row = logits.i((i, j)).map_err(|e| e.to_string())?;
625                let allow: Vec<f32> = (0..n_rel)
626                    .map(|c| {
627                        if crate::relation_train::type_allowed(spec, &mask, &ents[i].facet, &ents[j].facet, c) {
628                            0.0
629                        } else {
630                            f32::NEG_INFINITY
631                        }
632                    })
633                    .collect();
634                let allow = Tensor::from_vec(allow, n_rel, &self.device).map_err(|e| e.to_string())?;
635                let cls = softmax(&(row + allow).map_err(|e| e.to_string())?, D::Minus1)
636                    .and_then(|t| t.argmax(D::Minus1))
637                    .and_then(|t| t.to_scalar::<u32>())
638                    .map_err(|e| e.to_string())? as usize;
639                if cls > 0 {
640                    if let Some(r) = spec.relation_facets.get(cls - 1) {
641                        out.push((r.name.clone(), ents[i].clone(), ents[j].clone()));
642                    }
643                }
644            }
645        }
646        Ok(out)
647    }
648}
649
650/// Merge spans of the same facet that are **directly contiguous** in the text (no gap). Sub-word pieces of
651/// one word can each be predicted `B-`, which BIO decoding correctly treats as separate spans — e.g.
652/// "Aegis" tokenised as `Ae`+`gis` becomes two spans. Only zero-gap neighbours are merged, so two distinct
653/// adjacent entities ("Boeing Airbus") are never fused.
654fn merge_contiguous(spans: Vec<PredictedSpan>, text: &str) -> Vec<PredictedSpan> {
655    let mut out: Vec<PredictedSpan> = Vec::with_capacity(spans.len());
656    for sp in spans {
657        match out.last_mut() {
658            Some(prev) if prev.facet == sp.facet && prev.end == sp.start => {
659                prev.end = sp.end;
660                prev.text = text[prev.start..prev.end].to_string();
661                // a negated/hedged piece makes the whole merged span so
662                prev.negated |= sp.negated;
663                prev.hedged |= sp.hedged;
664                prev.belief = crate::dimensions::belief_level(prev.negated, prev.hedged);
665            }
666            _ => out.push(sp),
667        }
668    }
669    out
670}
671
672fn majority(v: &[u32]) -> u32 {
673    let mut counts = [0usize; 8];
674    for &x in v {
675        if (x as usize) < counts.len() {
676            counts[x as usize] += 1;
677        }
678    }
679    counts.iter().enumerate().max_by_key(|(_, c)| **c).map(|(i, _)| i as u32).unwrap_or(0)
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use crate::tagger_data::{Case, LabeledSpan, RelationLabel};
686    use crate::vocabulary::{EntityFacet, RelationFacet};
687
688    fn spec() -> VocabularySpace {
689        VocabularySpace {
690            version: 1,
691            corpus: "t".into(),
692            entity_facets: vec![
693                EntityFacet { name: "org".into(), parent: None, description: "companies".into(), examples: vec![], structural: false },
694                EntityFacet { name: "system".into(), parent: None, description: "platforms".into(), examples: vec![], structural: false },
695            ],
696            relation_facets: vec![RelationFacet { name: "develops".into(), head: "org".into(), tail: "system".into() }],
697            gazetteer: vec![],
698            metrics: None,
699        }
700    }
701
702    fn ex(text: &str, a: (usize, usize), b: (usize, usize), negated: bool) -> TaggerExample {
703        TaggerExample {
704            text: text.into(),
705            spans: vec![
706                LabeledSpan { start: a.0, end: a.1, facet: "org".into(), surface: text[a.0..a.1].into(), negated, hedged: false },
707                LabeledSpan { start: b.0, end: b.1, facet: "system".into(), surface: text[b.0..b.1].into(), negated, hedged: false },
708            ],
709            relations: vec![RelationLabel { head: 0, tail: 1, name: "develops".into() }],
710            case: if negated { Case::Negated } else { Case::Normal },
711        }
712    }
713
714    fn base_dirs() -> Option<(PathBuf, PathBuf)> {
715        let home = std::env::var("HOME").ok()?;
716        let g = |p: &str| glob_first(&format!("{home}/{p}"));
717        let base = g(".cache/huggingface/hub/models--google--bert_uncased_L-2_H-128_A-2/snapshots/*")?;
718        let tokdir = g(".cache/huggingface/hub/models--bert-base-uncased/snapshots/*")?;
719        let tok = tokdir.join("tokenizer.json");
720        if base.join("model.safetensors").exists() && tok.exists() {
721            Some((base, tok))
722        } else {
723            None
724        }
725    }
726
727    fn glob_first(pat: &str) -> Option<PathBuf> {
728        let (dir, _) = pat.rsplit_once('/')?;
729        std::fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).map(|e| e.path()).find(|p| p.is_dir())
730    }
731
732    #[test]
733    fn encoding_projects_labels_onto_tokens() {
734        let Some((_, tok_path)) = base_dirs() else {
735            eprintln!("skip: no cached bert");
736            return;
737        };
738        let tok = Tokenizer::from_file(&tok_path).unwrap();
739        let s = spec();
740        let e = ex("Boeing develops the MQ-28 aircraft.", (0, 6), (20, 25), false);
741        let enc = encode(&s, &tok, &e, 32).unwrap();
742        assert_eq!(enc.ids.len(), 32);
743        assert_eq!(enc.attn.iter().filter(|&&a| a == 1).count(), tok.encode(e.text.as_str(), true).unwrap().get_ids().len());
744        let labels = head_a_labels(&s);
745        // at least one B-ORG and one B-SYSTEM landed
746        let named: Vec<&str> = enc.labels_a.iter().filter(|&&l| l != IGNORE && l != 0).map(|&l| labels[l as usize].as_str()).collect();
747        assert!(named.contains(&"B-ORG"), "got {named:?}");
748        assert!(named.contains(&"B-SYSTEM"), "got {named:?}");
749        // padding is ignored, not class 0
750        assert_eq!(enc.labels_a[31], IGNORE);
751    }
752
753    /// The real test of a training loop: it must actually fit. Overfitting a handful of examples is the
754    /// minimum bar — if loss doesn't fall and accuracy doesn't climb, gradients aren't flowing.
755    #[test]
756    fn training_overfits_a_tiny_set() {
757        let Some((base, tok)) = base_dirs() else {
758            eprintln!("skip: no cached bert-tiny");
759            return;
760        };
761        let s = spec();
762        let data = vec![
763            ex("Boeing develops the MQ-28 aircraft.", (0, 6), (20, 25), false),
764            ex("Airbus develops the A400M transport.", (0, 6), (20, 25), false),
765            ex("Saab develops the Gripen fighter jet.", (0, 4), (18, 24), false),
766            ex("Thales develops the Sonar array system.", (0, 6), (20, 25), false),
767            ex("Embraer develops the KC-390 airlifter.", (0, 7), (21, 27), false),
768            ex("Lockheed does not develop the F-35 jet.", (0, 8), (30, 34), true),
769        ];
770        let cfg = TrainConfig { base_dir: base, tokenizer: tok, epochs: 60, lr: 3e-3, batch: 6, max_len: 32, ..Default::default() };
771        let (varmap, rep, labels) = train(&s, &data, &cfg).expect("train");
772        eprintln!("{}", serde_json::to_string_pretty(&rep).unwrap());
773        // derive, don't hardcode: O + BIO x (semantic facets + structural kinds)
774        let expect = 1 + 2 * (s.taggable_facets().len() + crate::tagger_data::STRUCTURAL_KINDS.len());
775        assert_eq!(labels.len(), expect);
776        let first = rep.epochs.first().unwrap().total;
777        let last = rep.epochs.last().unwrap().total;
778        assert!(last < first, "loss must decrease: {first} → {last}");
779        assert!(rep.train_acc_a > 0.8, "Head A should overfit 3 examples (got {})", rep.train_acc_a);
780        assert!(rep.train_acc_b > 0.8, "Head B should overfit 3 examples (got {})", rep.train_acc_b);
781        // round-trip the checkpoint
782        let dir = std::env::temp_dir().join(format!("steeldb-tagger-{}", std::process::id()));
783        save(&varmap, &labels, &dir).unwrap();
784        assert!(dir.join("tagger.safetensors").exists() && dir.join("tagger.json").exists());
785        let _ = std::fs::remove_dir_all(&dir);
786    }
787
788    /// Round-trip: train, save, reload via `TunedTagger`, and tag text. Verifies the checkpoint is usable
789    /// and that BIO decoding + epistemic voting produce coherent spans (not just that training ran).
790    #[test]
791    fn tuned_tagger_round_trips_and_tags() {
792        let Some((base, tok)) = base_dirs() else {
793            eprintln!("skip: no cached bert-tiny");
794            return;
795        };
796        let s = spec();
797        let data = vec![
798            ex("Boeing develops the MQ-28 aircraft.", (0, 6), (20, 25), false),
799            ex("Airbus develops the A400M transport.", (0, 6), (20, 25), false),
800            ex("Saab develops the Gripen fighter jet.", (0, 4), (18, 24), false),
801            ex("Thales develops the Sonar array system.", (0, 6), (20, 25), false),
802            ex("Embraer develops the KC-390 airlifter.", (0, 7), (21, 27), false),
803            ex("Lockheed does not develop the F-35 jet.", (0, 8), (30, 34), true),
804        ];
805        let cfg = TrainConfig { base_dir: base.clone(), tokenizer: tok.clone(), epochs: 60, lr: 3e-3, batch: 6, max_len: 32, ..Default::default() };
806        let (varmap, _rep, labels) = train(&s, &data, &cfg).expect("train");
807        let dir = std::env::temp_dir().join(format!("steeldb-tagger-rt-{}", std::process::id()));
808        save(&varmap, &labels, &dir).unwrap();
809
810        let tt = TunedTagger::load(&dir, &base, &tok, 32).expect("load tuned");
811        assert_eq!(tt.labels().len(), labels.len());
812        // a memorised sentence must decode to its spans with the right facets
813        let spans = tt.tag("Boeing develops the MQ-28 aircraft.").expect("tag");
814        eprintln!("TAGGED: {spans:?}");
815        assert!(spans.iter().any(|p| p.facet == "org" && p.text.contains("Boeing")), "got {spans:?}");
816        assert!(spans.iter().any(|p| p.facet == "system"), "got {spans:?}");
817        // the negated training sentence must carry negative belief on its spans
818        let neg = tt.tag("Lockheed does not develop the F-35 jet.").expect("tag");
819        assert!(neg.iter().any(|p| p.negated && p.belief < 0.0), "negation must set belief<0: {neg:?}");
820        // spans are well-formed: non-empty, ordered, inside the text
821        for w in spans.windows(2) {
822            assert!(w[0].end <= w[1].start, "spans must not overlap: {spans:?}");
823        }
824        let _ = std::fs::remove_dir_all(&dir);
825    }
826
827    /// End-to-end: tuned tagger → Situation → roaring index → typed-facet wildcard + DS belief.
828    #[test]
829    fn projection_reaches_the_bitmap_with_polarity() {
830        let Some((base, tok)) = base_dirs() else {
831            eprintln!("skip: no cached bert-tiny");
832            return;
833        };
834        let s = spec();
835        let data = vec![
836            ex("Boeing develops the MQ-28 aircraft.", (0, 6), (20, 25), false),
837            ex("Airbus develops the A400M transport.", (0, 6), (20, 25), false),
838            ex("Saab develops the Gripen fighter jet.", (0, 4), (18, 24), false),
839            ex("Thales develops the Sonar array system.", (0, 6), (20, 25), false),
840            ex("Embraer develops the KC-390 airlifter.", (0, 7), (21, 27), false),
841            ex("Lockheed does not develop the F-35 jet.", (0, 8), (30, 34), true),
842        ];
843        let cfg = TrainConfig { base_dir: base.clone(), tokenizer: tok.clone(), epochs: 60, lr: 3e-3, batch: 6, max_len: 32, ..Default::default() };
844        let (varmap, _r, labels) = train(&s, &data, &cfg).expect("train");
845        let dir = std::env::temp_dir().join(format!("steeldb-proj-{}", std::process::id()));
846        save(&varmap, &labels, &dir).unwrap();
847        let tt = TunedTagger::load(&dir, &base, &tok, 32).expect("load");
848
849        // project a memorised sentence: typed tokens must be hierarchical facet URIs, not flat `ent/`
850        let sit = tt.project("Boeing develops the MQ-28 aircraft.").expect("project");
851        eprintln!("PROJECTED: {:?}", sit.tokens);
852        assert!(sit.tokens.iter().any(|t| t.starts_with("org/")), "typed entity token expected: {:?}", sit.tokens);
853        assert!(sit.beliefs.is_empty(), "asserted sentence carries default +1 polarity");
854
855        // ingest into a corpus and query by FACET WILDCARD — only possible because URIs are typed
856        let mut corpus = crate::db::Corpus::new_incremental("t", vec!["text".into()], crate::projector::CorpusKind::Csv);
857        corpus.add_situation_polar(sit.tokens.clone(), sit.display.clone(), sit.numbers.clone(), sit.beliefs.clone());
858        let hits = corpus.query("org/*", 10);
859        assert_eq!(hits.count, 1, "facet wildcard must match the typed token");
860
861        let _ = std::fs::remove_dir_all(&dir);
862    }
863
864    /// A STATE span is the epistemic cue itself, so "asserted" must be unrepresentable for it — Head A and
865    /// Head B cannot disagree about whether a cue is a cue.
866    #[test]
867    fn state_spans_never_decode_as_asserted() {
868        let Some((base, tok)) = base_dirs() else {
869            eprintln!("skip: no cached bert-tiny");
870            return;
871        };
872        let s = spec();
873        // one negated + one hedged example so both non-asserted classes are learnable
874        let mut neg = ex("Lockheed does not develop the F-35 jet.", (0, 8), (30, 34), true);
875        neg.spans.push(LabeledSpan { start: 9, end: 17, facet: "state".into(), surface: "does not".into(), negated: true, hedged: false });
876        neg.spans.sort_by_key(|x| x.start);
877        let mut hedge = ex("Boeing may develop the MQ-28 aircraft.", (0, 6), (23, 28), false);
878        hedge.spans.push(LabeledSpan { start: 7, end: 10, facet: "state".into(), surface: "may".into(), negated: false, hedged: true });
879        hedge.spans.iter_mut().for_each(|x| { if x.facet != "state" { x.hedged = true; } });
880        hedge.spans.sort_by_key(|x| x.start);
881        // the dev split needs at least five examples; duplicate the two cases so both classes are learnable
882        let data = vec![neg.clone(), hedge.clone(), neg.clone(), hedge.clone(), neg.clone(), hedge];
883        let cfg = TrainConfig { base_dir: base.clone(), tokenizer: tok.clone(), epochs: 60, lr: 3e-3, batch: 6, max_len: 32, ..Default::default() };
884        let (varmap, _r, labels) = train(&s, &data, &cfg).expect("train");
885        let dir = std::env::temp_dir().join(format!("steeldb-state-{}", std::process::id()));
886        save(&varmap, &labels, &dir).unwrap();
887        let tt = TunedTagger::load(&dir, &base, &tok, 32).expect("load");
888        for text in ["Lockheed does not develop the F-35 jet.", "Boeing may develop the MQ-28 aircraft."] {
889            for sp in tt.tag(text).expect("tag").iter().filter(|s| s.facet == "state") {
890                assert!(sp.negated || sp.hedged, "a state cue decoded as asserted: {sp:?}");
891                assert!(sp.belief < 1.0, "asserted belief on a cue span: {sp:?}");
892            }
893        }
894        let _ = std::fs::remove_dir_all(&dir);
895    }
896
897    #[test]
898    fn snapping_completes_clipped_words() {
899        let text = "Thales supplies the Aegis system.";
900        let p = |s: usize, e: usize, f: &str| PredictedSpan {
901            start: s, end: e, facet: f.into(), text: text[s..e].into(), negated: false, hedged: false, belief: 1.0,
902        };
903        // clipped first wordpiece → snapped to the whole word
904        let out = super::snap_to_words(vec![p(0, 3, "org")], text);
905        assert_eq!(out[0].text, "Thales");
906        let out2 = super::snap_to_words(vec![p(20, 22, "system")], text);
907        assert_eq!(out2[0].text, "Aegis");
908        // an already-complete span is unchanged, and snapping never crosses a non-word char
909        let out3 = super::snap_to_words(vec![p(0, 6, "org")], text);
910        assert_eq!(out3[0].text, "Thales");
911        assert_eq!(out3[0].end, 6);
912    }
913
914    #[test]
915    fn contiguous_subword_spans_merge_but_distinct_entities_do_not() {
916        let text = "Aegis and Boeing Airbus";
917        let p = |s: usize, e: usize, f: &str| PredictedSpan {
918            start: s, end: e, facet: f.into(), text: text[s..e].into(), negated: false, hedged: false, belief: 1.0,
919        };
920        // "Ae" + "gis" are contiguous sub-words → one span
921        let merged = super::merge_contiguous(vec![p(0, 2, "system"), p(2, 5, "system")], text);
922        assert_eq!(merged.len(), 1);
923        assert_eq!(merged[0].text, "Aegis");
924        // "Boeing" + "Airbus" are separated by a space → stay distinct
925        let kept = super::merge_contiguous(vec![p(10, 16, "org"), p(17, 23, "org")], text);
926        assert_eq!(kept.len(), 2);
927        // negation on any piece propagates to the merged span
928        let mut a = p(0, 2, "system");
929        let mut b = p(2, 5, "system");
930        b.negated = true;
931        a.belief = 1.0;
932        let m2 = super::merge_contiguous(vec![a, b], text);
933        assert_eq!(m2.len(), 1);
934        assert!(m2[0].negated && m2[0].belief < 0.0);
935    }
936}