1use 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 pub base_dir: PathBuf,
35 pub tokenizer: PathBuf,
37 pub epochs: usize,
38 pub lr: f64,
39 pub batch: usize,
40 pub max_len: usize,
41 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
61pub type MultiHeadTagger = HrmTagger;
66
67pub 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#[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
82pub 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 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
106fn 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 pub train_acc_a: f64,
138 pub train_acc_b: f64,
139 pub dev_examples: usize,
141 pub dev_acc_a: f64,
143 pub dev_acc_b: f64,
144 pub dev_acc_a_spans: f64,
147}
148
149pub 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 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 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 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 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
270fn 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 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
311pub 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#[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 pub belief: f32,
343}
344
345pub struct TunedTagger {
347 model: MultiHeadTagger,
348 tok: Tokenizer,
349 labels_a: Vec<String>,
350 device: Device,
351 max_len: usize,
352 relations: Option<(crate::relation_train::BiaffineHead, VocabularySpace)>,
355}
356
357impl TunedTagger {
358 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 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 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 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 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 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 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 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; 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 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; }
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), }
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
557fn 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 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 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 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 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
650fn 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 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 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 assert_eq!(enc.labels_a[31], IGNORE);
751 }
752
753 #[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 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 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 #[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 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 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 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 #[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 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 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 #[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 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 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 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 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 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 let kept = super::merge_contiguous(vec![p(10, 16, "org"), p(17, 23, "org")], text);
926 assert_eq!(kept.len(), 2);
927 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}