RadGraph-IT 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. radgraph_it-1.0.0.dist-info/METADATA +159 -0
  2. radgraph_it-1.0.0.dist-info/RECORD +39 -0
  3. radgraph_it-1.0.0.dist-info/WHEEL +4 -0
  4. radgraph_it-1.0.0.dist-info/entry_points.txt +2 -0
  5. radgraph_it-1.0.0.dist-info/licenses/LICENSE +23 -0
  6. radgraph_it-1.0.0.dist-info/licenses/LICENSES/Third-Party.md +41 -0
  7. radgraphit/__init__.py +47 -0
  8. radgraphit/api.py +168 -0
  9. radgraphit/artifacts/__init__.py +5 -0
  10. radgraphit/artifacts/manifest.py +290 -0
  11. radgraphit/artifacts/registry.py +24 -0
  12. radgraphit/artifacts/resolver.py +200 -0
  13. radgraphit/cli.py +131 -0
  14. radgraphit/core/__init__.py +1 -0
  15. radgraphit/core/errors.py +46 -0
  16. radgraphit/core/models.py +116 -0
  17. radgraphit/inference/__init__.py +5 -0
  18. radgraphit/inference/backends/__init__.py +1 -0
  19. radgraphit/inference/backends/base.py +13 -0
  20. radgraphit/inference/backends/dygie_v2/__init__.py +11 -0
  21. radgraphit/inference/backends/dygie_v2/backend.py +32 -0
  22. radgraphit/inference/backends/dygie_v2/device.py +46 -0
  23. radgraphit/inference/backends/dygie_v2/feedforward.py +25 -0
  24. radgraphit/inference/backends/dygie_v2/loader.py +165 -0
  25. radgraphit/inference/backends/dygie_v2/model.py +101 -0
  26. radgraphit/inference/backends/dygie_v2/ner_head.py +75 -0
  27. radgraphit/inference/backends/dygie_v2/pruner.py +27 -0
  28. radgraphit/inference/backends/dygie_v2/relation_head.py +147 -0
  29. radgraphit/inference/backends/dygie_v2/span_extractor.py +51 -0
  30. radgraphit/inference/backends/dygie_v2/spans.py +20 -0
  31. radgraphit/inference/backends/dygie_v2/tokenizer_embedder.py +123 -0
  32. radgraphit/inference/backends/dygie_v2/transformer_block.py +62 -0
  33. radgraphit/inference/backends/dygie_v2/vocab.py +123 -0
  34. radgraphit/inference/decoder.py +53 -0
  35. radgraphit/inference/service.py +80 -0
  36. radgraphit/inference/tokenizer.py +60 -0
  37. radgraphit/py.typed +0 -0
  38. radgraphit/serialization/__init__.py +15 -0
  39. radgraphit/serialization/radgraph_xl.py +135 -0
@@ -0,0 +1,147 @@
1
+ """Relation head — inference-only port of ``training_v2/src/dygie/relation_head.py``.
2
+
3
+ Prune to top-K spans, score every ordered pair, decode. The parameter-bearing structure
4
+ (``pruners`` / ``span_transformers`` / ``relation_feedforwards`` / ``relation_scorers`` ModuleDicts,
5
+ the null-column trick, and the optional ``span_pooling`` / ``transformer_params`` /
6
+ ``relation_context`` / ``relation_feedforward_params`` variants) is preserved exactly so the state
7
+ dict matches the checkpoint. Loss and metrics are dropped; decode returns plain
8
+ ``(s1, e1, s2, e2, label, raw_score, softmax_score)`` tuples.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+
15
+ import torch
16
+ from torch import nn
17
+
18
+ from .feedforward import FeedForward
19
+ from .pruner import Pruner
20
+ from .transformer_block import TransformerBlock
21
+ from .vocab import Vocabulary
22
+
23
+ RelationTuple = tuple[int, int, int, int, str, float, float]
24
+
25
+
26
+ class RelationHead(nn.Module):
27
+ def __init__(
28
+ self,
29
+ vocab: Vocabulary,
30
+ span_emb_dim: int,
31
+ word_emb_dim: int,
32
+ feedforward_params: dict,
33
+ spans_per_word: float,
34
+ transformer_params: dict | None = None,
35
+ use_between_context: bool = False,
36
+ relation_feedforward_params: dict | None = None,
37
+ ):
38
+ super().__init__()
39
+ self.vocab = vocab
40
+ self.spans_per_word = spans_per_word
41
+ self.namespaces = [ns for ns in vocab.get_namespaces() if ns.endswith("relation_labels")]
42
+ self.n_labels = {ns: vocab.get_vocab_size(ns) for ns in self.namespaces}
43
+ self.use_transformer = transformer_params is not None
44
+ self.use_between_context = use_between_context
45
+ between_dim = word_emb_dim if use_between_context else 0
46
+ relation_feedforward_params = relation_feedforward_params or feedforward_params
47
+
48
+ self.pruners = nn.ModuleDict()
49
+ self.span_transformers = nn.ModuleDict()
50
+ self.relation_feedforwards = nn.ModuleDict()
51
+ self.relation_scorers = nn.ModuleDict()
52
+
53
+ for ns in self.namespaces:
54
+ mention_ff = FeedForward(
55
+ span_emb_dim, feedforward_params["hidden_dims"], feedforward_params["dropout"]
56
+ )
57
+ mention_scorer = nn.Sequential(mention_ff, nn.Linear(mention_ff.output_dim, 1))
58
+ self.pruners[ns] = Pruner(mention_scorer)
59
+
60
+ if self.use_transformer:
61
+ assert transformer_params is not None
62
+ block = TransformerBlock(span_emb_dim, **transformer_params)
63
+ self.span_transformers[ns] = block
64
+ self.relation_scorers[ns] = nn.Linear(
65
+ 3 * block.output_dim + between_dim, self.n_labels[ns] - 1
66
+ )
67
+ else:
68
+ relation_ff = FeedForward(
69
+ 3 * span_emb_dim + between_dim,
70
+ relation_feedforward_params["hidden_dims"],
71
+ relation_feedforward_params["dropout"],
72
+ )
73
+ self.relation_feedforwards[ns] = relation_ff
74
+ self.relation_scorers[ns] = nn.Linear(relation_ff.output_dim, self.n_labels[ns] - 1)
75
+
76
+ @staticmethod
77
+ def _pair_embeddings(top_embeddings: torch.Tensor) -> torch.Tensor:
78
+ k = top_embeddings.size(0)
79
+ e1 = top_embeddings.unsqueeze(1).expand(k, k, -1)
80
+ e2 = top_embeddings.unsqueeze(0).expand(k, k, -1)
81
+ return torch.cat([e1, e2, e1 * e2], dim=-1)
82
+
83
+ @staticmethod
84
+ def _between_context(
85
+ word_embeddings: torch.Tensor, top_spans: list[tuple[int, int]]
86
+ ) -> torch.Tensor:
87
+ device, dtype = word_embeddings.device, word_embeddings.dtype
88
+ starts = torch.tensor([s[0] for s in top_spans], dtype=torch.long, device=device)
89
+ ends = torch.tensor([s[1] for s in top_spans], dtype=torch.long, device=device)
90
+
91
+ zero_row = word_embeddings.new_zeros(1, word_embeddings.size(-1))
92
+ prefix = torch.cat([zero_row, word_embeddings.cumsum(dim=0)], dim=0) # (num_words + 1, dim)
93
+
94
+ low = torch.minimum(ends.unsqueeze(1), ends.unsqueeze(0))
95
+ high = torch.maximum(starts.unsqueeze(1), starts.unsqueeze(0))
96
+ count = (high - low - 1).clamp(min=0)
97
+
98
+ seg_sum = prefix[high] - prefix[low + 1] # (K, K, dim)
99
+ mean = seg_sum / count.clamp(min=1).unsqueeze(-1).to(dtype)
100
+ return torch.where((count > 0).unsqueeze(-1), mean, seg_sum.new_zeros(()))
101
+
102
+ def predict(
103
+ self,
104
+ dataset: str,
105
+ spans: list[tuple[int, int]],
106
+ span_embeddings: torch.Tensor,
107
+ num_words: int,
108
+ word_embeddings: torch.Tensor,
109
+ ) -> list[RelationTuple]:
110
+ ns = f"{dataset}__relation_labels"
111
+ num_to_keep = math.ceil(num_words * self.spans_per_word)
112
+
113
+ top_embeddings, top_indices, top_scores = self.pruners[ns](span_embeddings, num_to_keep)
114
+ top_spans = [spans[i] for i in top_indices.tolist()]
115
+
116
+ if self.use_transformer:
117
+ starts = torch.tensor(
118
+ [s[0] for s in top_spans], dtype=torch.long, device=span_embeddings.device
119
+ )
120
+ top_embeddings = self.span_transformers[ns](top_embeddings, starts)
121
+
122
+ pair_embeddings = self._pair_embeddings(top_embeddings)
123
+ if self.use_between_context:
124
+ between = self._between_context(word_embeddings, top_spans)
125
+ pair_embeddings = torch.cat([pair_embeddings, between], dim=-1)
126
+
127
+ k = top_embeddings.size(0)
128
+ if self.use_transformer:
129
+ scores = self.relation_scorers[ns](pair_embeddings.view(k * k, -1)).view(k, k, -1)
130
+ else:
131
+ projected = self.relation_feedforwards[ns](pair_embeddings.view(k * k, -1))
132
+ scores = self.relation_scorers[ns](projected).view(k, k, -1)
133
+ scores = scores + top_scores.view(k, 1, 1) + top_scores.view(1, k, 1)
134
+ dummy = scores.new_zeros(k, k, 1)
135
+ scores = torch.cat([dummy, scores], dim=-1) # (K, K, n_labels), null at index 0
136
+
137
+ raw, predicted = scores.max(dim=-1)
138
+ soft, _ = torch.softmax(scores, dim=-1).max(dim=-1)
139
+
140
+ predictions: list[RelationTuple] = []
141
+ for i, j in (predicted != 0).nonzero(as_tuple=False).tolist():
142
+ label = self.vocab.get_token_from_index(int(predicted[i, j]), ns)
143
+ (s1, e1), (s2, e2) = top_spans[i], top_spans[j]
144
+ predictions.append(
145
+ (int(s1), int(e1), int(s2), int(e2), label, float(raw[i, j]), float(soft[i, j]))
146
+ )
147
+ return predictions
@@ -0,0 +1,51 @@
1
+ """Endpoint span extractor — faithful port of ``training_v2/src/dygie/span_extractor.py``.
2
+
3
+ concat(start emb, end emb, [mean-pooled interior emb if ``mean_pool``], width emb). The
4
+ ``width_embedding`` submodule name is preserved for state-dict compatibility. Inference runs the
5
+ batch_size=1 path (no batch dim, no span masking), exactly as upstream.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+ from torch import nn
12
+
13
+
14
+ class EndpointSpanExtractor(nn.Module):
15
+ def __init__(
16
+ self,
17
+ input_dim: int,
18
+ num_width_embeddings: int,
19
+ span_width_embedding_dim: int,
20
+ mean_pool: bool = False,
21
+ ):
22
+ super().__init__()
23
+ self.width_embedding = nn.Embedding(num_width_embeddings, span_width_embedding_dim)
24
+ self.mean_pool = mean_pool
25
+ self.output_dim = (3 if mean_pool else 2) * input_dim + span_width_embedding_dim
26
+
27
+ def get_output_dim(self) -> int:
28
+ return self.output_dim
29
+
30
+ def forward(self, word_embeddings: torch.Tensor, spans: torch.LongTensor) -> torch.Tensor:
31
+ """word_embeddings: (num_words, input_dim). spans: (num_spans, 2) inclusive [start, end]."""
32
+ starts, ends = spans[:, 0], spans[:, 1]
33
+ start_emb = word_embeddings[starts]
34
+ end_emb = word_embeddings[ends]
35
+ width_emb = self.width_embedding(ends - starts) # 0-indexed width (widths 1..N -> 0..N-1)
36
+ if not self.mean_pool:
37
+ return torch.cat([start_emb, end_emb, width_emb], dim=-1)
38
+ mean_emb = self._interior_mean(word_embeddings, starts, ends)
39
+ return torch.cat([start_emb, end_emb, mean_emb, width_emb], dim=-1)
40
+
41
+ @staticmethod
42
+ def _interior_mean(
43
+ word_embeddings: torch.Tensor, starts: torch.Tensor, ends: torch.Tensor
44
+ ) -> torch.Tensor:
45
+ widths = ends - starts + 1 # (num_spans,)
46
+ max_width = int(widths.max().item())
47
+ offsets = torch.arange(max_width, device=word_embeddings.device).unsqueeze(0) # (1, W)
48
+ mask = offsets < widths.unsqueeze(1) # (num_spans, W)
49
+ idx = (starts.unsqueeze(1) + offsets).clamp(max=word_embeddings.size(0) - 1)
50
+ gathered = word_embeddings[idx] * mask.unsqueeze(-1) # (num_spans, W, input_dim)
51
+ return gathered.sum(dim=1) / widths.unsqueeze(1).to(word_embeddings.dtype)
@@ -0,0 +1,20 @@
1
+ """Candidate span enumeration — the inference-relevant half of ``training_v2/src/dygie/dataset.py``.
2
+
3
+ One document is a single "sentence" spanning the whole report (v2 invariant), so enumeration is the
4
+ only piece of the dataset reader inference needs: all inclusive ``(start, end)`` spans of width
5
+ 1..``max_span_width``, ordered by ``(start, end)``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ def enumerate_spans(
12
+ words: tuple[str, ...] | list[str], max_span_width: int
13
+ ) -> list[tuple[int, int]]:
14
+ """All inclusive (start, end) spans of width 1..max_span_width, ordered by (start, end)."""
15
+ n = len(words)
16
+ spans: list[tuple[int, int]] = []
17
+ for start in range(n):
18
+ for end in range(start, min(start + max_span_width, n)):
19
+ spans.append((start, end))
20
+ return spans
@@ -0,0 +1,123 @@
1
+ """Word-level embeddings from a subword encoder — faithful port of
2
+ ``training_v2/src/dygie/tokenizer_embedder.py``.
3
+
4
+ Tokenize each word into subwords, run the transformer, mean-pool subwords back to one vector per
5
+ word. Long documents are split into non-overlapping, word-aligned chunks of at most ``max_length``
6
+ transformer positions ("context folding"), so a word's subwords are always encoded together.
7
+
8
+ The ``encoder`` submodule is built from the pinned encoder configuration without downloading its
9
+ pretrained weights: every encoder parameter is subsequently populated by the verified RadGraphIT
10
+ checkpoint through ``load_state_dict(strict=True)``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+
17
+ import torch
18
+ from torch import nn
19
+ from transformers import AutoConfig, AutoModel, AutoTokenizer
20
+
21
+ logger = logging.getLogger("radgraphit")
22
+
23
+
24
+ class MismatchedEmbedder(nn.Module):
25
+ def __init__(
26
+ self,
27
+ model_name: str,
28
+ max_length: int,
29
+ train_parameters: bool = True,
30
+ *,
31
+ revision: str | None = None,
32
+ ):
33
+ super().__init__()
34
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name, revision=revision)
35
+ encoder_config = AutoConfig.from_pretrained(model_name, revision=revision)
36
+ self.encoder = AutoModel.from_config(encoder_config) # type: ignore[no-untyped-call]
37
+ self.max_length = max_length
38
+ self.output_dim = self.encoder.config.hidden_size
39
+ if not train_parameters:
40
+ for p in self.encoder.parameters():
41
+ p.requires_grad = False
42
+
43
+ num_specials = self.tokenizer.num_special_tokens_to_add(pair=False)
44
+ budget = max_length - num_specials
45
+ if budget <= 0:
46
+ raise ValueError(
47
+ f"max_length={max_length} too small for {model_name}'s "
48
+ f"{num_specials} special tokens"
49
+ )
50
+ self._budget = budget
51
+
52
+ def get_output_dim(self) -> int:
53
+ return self.output_dim
54
+
55
+ def _word_subword_counts(self, words: list[str]) -> list[int]:
56
+ enc = self.tokenizer(words, is_split_into_words=True, add_special_tokens=False)
57
+ counts = [0] * len(words)
58
+ for w in enc.word_ids():
59
+ if w is not None:
60
+ counts[w] += 1
61
+ return counts
62
+
63
+ def _pack_chunks(self, counts: list[int]) -> list[list[int]]:
64
+ """Greedily group word indices into chunks whose total subword count fits the budget."""
65
+ chunks: list[list[int]] = []
66
+ current: list[int] = []
67
+ current_len = 0
68
+ for w, n in enumerate(counts):
69
+ n = max(n, 1)
70
+ if n > self._budget:
71
+ logger.warning(
72
+ "word %d has %d subwords > budget %d; it will be truncated by the tokenizer",
73
+ w,
74
+ n,
75
+ self._budget,
76
+ )
77
+ if current and current_len + n > self._budget:
78
+ chunks.append(current)
79
+ current, current_len = [], 0
80
+ current.append(w)
81
+ current_len += n
82
+ if current:
83
+ chunks.append(current)
84
+ return chunks
85
+
86
+ def forward(self, words: list[str]) -> torch.Tensor:
87
+ """Returns (num_words, hidden_size)."""
88
+ device = next(self.encoder.parameters()).device
89
+ counts = self._word_subword_counts(words)
90
+ chunks = self._pack_chunks(counts)
91
+
92
+ word_embeddings: list[torch.Tensor | None] = [None] * len(words)
93
+ hidden: torch.Tensor | None = None
94
+ for chunk_word_ixs in chunks:
95
+ chunk_words = [words[i] for i in chunk_word_ixs]
96
+ enc = self.tokenizer(
97
+ chunk_words,
98
+ is_split_into_words=True,
99
+ add_special_tokens=True,
100
+ truncation=True,
101
+ max_length=self.max_length,
102
+ return_tensors="pt",
103
+ )
104
+ input_ids = enc["input_ids"].to(device)
105
+ attention_mask = enc["attention_mask"].to(device)
106
+ hidden = self.encoder(
107
+ input_ids=input_ids, attention_mask=attention_mask
108
+ ).last_hidden_state[0]
109
+
110
+ word_ids = enc.word_ids(batch_index=0)
111
+ positions_by_word: dict[int, list[int]] = {}
112
+ for pos, w in enumerate(word_ids):
113
+ if w is not None:
114
+ positions_by_word.setdefault(w, []).append(pos)
115
+ for local_w, positions in positions_by_word.items():
116
+ word_embeddings[chunk_word_ixs[local_w]] = hidden[positions].mean(dim=0)
117
+
118
+ for i, emb in enumerate(word_embeddings):
119
+ if emb is None: # word tokenized to zero subwords (rare; e.g. an unknown char)
120
+ assert hidden is not None # at least one chunk always runs for a non-empty report
121
+ word_embeddings[i] = hidden.new_zeros(self.output_dim)
122
+
123
+ return torch.stack(word_embeddings, dim=0) # type: ignore[arg-type]
@@ -0,0 +1,62 @@
1
+ """Span-attention scorer body — faithful port of ``training_v2/src/dygie/transformer_block.py``.
2
+
3
+ Used by the NER / relation heads in the ``medbit_transformer`` arm in place of the plain
4
+ ``FeedForward`` scorer. Submodule names (``input_proj``, ``encoder``) are preserved for state-dict
5
+ compatibility. Spans are the "sequence"; a sinusoidal positional encoding keyed on each span's
6
+ start-word index gives attention a notion of span order/distance.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ import torch
14
+ from torch import nn
15
+
16
+
17
+ def sinusoidal_position_encoding(positions: torch.Tensor, d_model: int) -> torch.Tensor:
18
+ """positions: (N,) int64 span start indices. Returns (N, d_model)."""
19
+ div_term = torch.exp(
20
+ torch.arange(0, d_model, 2, device=positions.device, dtype=torch.float32)
21
+ * (-math.log(10000.0) / d_model)
22
+ )
23
+ angles = positions.unsqueeze(1).to(torch.float32) * div_term.unsqueeze(0) # (N, d_model/2)
24
+ pe = torch.zeros(positions.size(0), d_model, device=positions.device)
25
+ pe[:, 0::2] = torch.sin(angles)
26
+ pe[:, 1::2] = torch.cos(angles[:, : pe[:, 1::2].size(1)])
27
+ return pe
28
+
29
+
30
+ class TransformerBlock(nn.Module):
31
+ def __init__(
32
+ self,
33
+ input_dim: int,
34
+ d_model: int,
35
+ nhead: int,
36
+ num_layers: int,
37
+ dim_feedforward: int,
38
+ dropout: float,
39
+ activation: str = "gelu",
40
+ ):
41
+ super().__init__()
42
+ self.input_proj = nn.Linear(input_dim, d_model) if input_dim != d_model else nn.Identity()
43
+ layer = nn.TransformerEncoderLayer(
44
+ d_model=d_model,
45
+ nhead=nhead,
46
+ dim_feedforward=dim_feedforward,
47
+ dropout=dropout,
48
+ activation=activation,
49
+ norm_first=True,
50
+ batch_first=True,
51
+ )
52
+ self.encoder = nn.TransformerEncoder(
53
+ layer, num_layers=num_layers, enable_nested_tensor=False
54
+ )
55
+ self.d_model = d_model
56
+ self.output_dim = d_model
57
+
58
+ def forward(self, span_embeddings: torch.Tensor, start_positions: torch.Tensor) -> torch.Tensor:
59
+ x = self.input_proj(span_embeddings) + sinusoidal_position_encoding(
60
+ start_positions, self.d_model
61
+ )
62
+ return self.encoder(x.unsqueeze(0)).squeeze(0)
@@ -0,0 +1,123 @@
1
+ """Minimal label vocabulary — inference-only port of ``training_v2/src/dygie/vocab.py``.
2
+
3
+ Per-namespace string<->int maps with the null label ``""`` pinned to index 0 (so "argmax == 0"
4
+ means "no label"). Namespaces are ``"{dataset}__ner_labels"`` / ``"{dataset}__relation_labels"``;
5
+ the dataset prefix is *derived from the saved vocab*, never hard-coded, so the label schema is
6
+ whatever the checkpoint was trained with (the current v2 artifact: 6 NER classes, 3 relations).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from ....core.errors import CheckpointError
15
+
16
+ NULL_LABEL = ""
17
+ NER_SUFFIX = "__ner_labels"
18
+ RELATION_SUFFIX = "__relation_labels"
19
+
20
+
21
+ class Vocabulary:
22
+ """String<->index maps per namespace, loaded from the checkpoint's ``vocab.json``."""
23
+
24
+ def __init__(self) -> None:
25
+ self._token_to_index: dict[str, dict[str, int]] = {}
26
+ self._index_to_token: dict[str, dict[int, str]] = {}
27
+
28
+ def add_namespace(self, namespace: str) -> None:
29
+ if namespace not in self._token_to_index:
30
+ self._token_to_index[namespace] = {NULL_LABEL: 0}
31
+ self._index_to_token[namespace] = {0: NULL_LABEL}
32
+
33
+ def add_token(self, token: str, namespace: str) -> int:
34
+ self.add_namespace(namespace)
35
+ t2i = self._token_to_index[namespace]
36
+ if token not in t2i:
37
+ idx = len(t2i)
38
+ t2i[token] = idx
39
+ self._index_to_token[namespace][idx] = token
40
+ return t2i[token]
41
+
42
+ def get_token_index(self, token: str, namespace: str) -> int:
43
+ return self._token_to_index[namespace][token]
44
+
45
+ def get_token_from_index(self, index: int, namespace: str) -> str:
46
+ return self._index_to_token[namespace][index]
47
+
48
+ def get_vocab_size(self, namespace: str) -> int:
49
+ return len(self._token_to_index[namespace])
50
+
51
+ def get_namespaces(self) -> list[str]:
52
+ return list(self._token_to_index.keys())
53
+
54
+ @classmethod
55
+ def load(cls, path: str | Path) -> Vocabulary:
56
+ """Load the exact label<->index mapping saved next to the checkpoint."""
57
+ vocab_path = Path(path)
58
+ try:
59
+ data = json.loads(vocab_path.read_text(encoding="utf-8"))
60
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
61
+ raise CheckpointError(
62
+ f"Could not read a valid JSON vocabulary at {vocab_path}: {exc}"
63
+ ) from exc
64
+ if not isinstance(data, dict) or not data:
65
+ raise CheckpointError(f"vocab.json at {vocab_path} must contain a non-empty object.")
66
+
67
+ vocab = cls()
68
+ for namespace, t2i in data.items():
69
+ if not isinstance(namespace, str) or not namespace:
70
+ raise CheckpointError("Every vocab namespace must be a non-empty string.")
71
+ if not isinstance(t2i, dict) or not t2i:
72
+ raise CheckpointError(f"Vocab namespace {namespace!r} must be a non-empty object.")
73
+ if t2i.get(NULL_LABEL) != 0:
74
+ raise CheckpointError(
75
+ f"Vocab namespace {namespace!r} must map the null label '' to index 0."
76
+ )
77
+ if not all(
78
+ isinstance(token, str)
79
+ and isinstance(index, int)
80
+ and not isinstance(index, bool)
81
+ and index >= 0
82
+ for token, index in t2i.items()
83
+ ):
84
+ raise CheckpointError(
85
+ f"Vocab namespace {namespace!r} must map strings to non-negative integers."
86
+ )
87
+ indices = list(t2i.values())
88
+ if len(indices) != len(set(indices)) or sorted(indices) != list(range(len(indices))):
89
+ raise CheckpointError(
90
+ f"Vocab namespace {namespace!r} indices must be unique and contiguous from 0."
91
+ )
92
+ vocab.add_namespace(namespace)
93
+ for token, _idx in sorted(t2i.items(), key=lambda kv: kv[1]):
94
+ if token != NULL_LABEL:
95
+ vocab.add_token(token, namespace)
96
+ return vocab
97
+
98
+
99
+ def derive_dataset(vocab: Vocabulary) -> str:
100
+ """Derive the single ``dataset`` prefix from the vocab's namespaces.
101
+
102
+ The model was trained with one ``dataset`` constant (``"radgraph-it"`` for the current
103
+ artifact), which prefixes both label namespaces. We recover it from the vocab rather than
104
+ hard-coding it, and validate that the NER and relation prefixes agree, so the head lookups
105
+ ``f"{dataset}__ner_labels"`` / ``f"{dataset}__relation_labels"`` are guaranteed to hit.
106
+ """
107
+ ner_prefixes = [
108
+ ns[: -len(NER_SUFFIX)] for ns in vocab.get_namespaces() if ns.endswith(NER_SUFFIX)
109
+ ]
110
+ rel_prefixes = [
111
+ ns[: -len(RELATION_SUFFIX)] for ns in vocab.get_namespaces() if ns.endswith(RELATION_SUFFIX)
112
+ ]
113
+ if len(ner_prefixes) != 1 or len(rel_prefixes) != 1:
114
+ raise CheckpointError(
115
+ "vocab.json must contain exactly one '*__ner_labels' and one '*__relation_labels' "
116
+ f"namespace; found NER={ner_prefixes}, relation={rel_prefixes}."
117
+ )
118
+ if ner_prefixes[0] != rel_prefixes[0]:
119
+ raise CheckpointError(
120
+ f"vocab.json NER prefix {ner_prefixes[0]!r} does not match relation prefix "
121
+ f"{rel_prefixes[0]!r}; the bundle is inconsistent."
122
+ )
123
+ return ner_prefixes[0]
@@ -0,0 +1,53 @@
1
+ """Decode raw DyGIE backend output into immutable domain value objects.
2
+
3
+ The backend speaks in plain tuples (the shape upstream DyGIE ``decode`` produced); this module is
4
+ the single place that lifts those into typed :class:`ScoredEntity` / :class:`ScoredRelation`
5
+ objects, producing one :class:`ReportPrediction` per report. Keeping this separate from both the
6
+ torch backend and the serializer means the whole "raw predictions -> domain graph" step is pure
7
+ and trivially testable with synthetic tuples.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ..core.models import (
13
+ RawBackendOutput,
14
+ ReportPrediction,
15
+ ScoredEntity,
16
+ ScoredRelation,
17
+ )
18
+
19
+
20
+ def decode_report(
21
+ doc_key: str,
22
+ tokens: tuple[str, ...],
23
+ raw: RawBackendOutput,
24
+ ) -> ReportPrediction:
25
+ """Lift one report's raw backend tuples into a typed :class:`ReportPrediction`."""
26
+ entities = tuple(
27
+ ScoredEntity(
28
+ start_ix=int(start),
29
+ end_ix=int(end),
30
+ label=str(label),
31
+ raw_score=float(raw_score),
32
+ softmax_score=float(soft_score),
33
+ )
34
+ for (start, end, label, raw_score, soft_score) in raw.ner
35
+ )
36
+ relations = tuple(
37
+ ScoredRelation(
38
+ label=str(label),
39
+ source_start=int(s1),
40
+ source_end=int(e1),
41
+ target_start=int(s2),
42
+ target_end=int(e2),
43
+ raw_score=float(raw_score),
44
+ softmax_score=float(soft_score),
45
+ )
46
+ for (s1, e1, s2, e2, label, raw_score, soft_score) in raw.relations
47
+ )
48
+ return ReportPrediction(
49
+ doc_key=doc_key,
50
+ tokens=tokens,
51
+ entities=entities,
52
+ relations=relations,
53
+ )
@@ -0,0 +1,80 @@
1
+ """Inference orchestration, with no global I/O.
2
+
3
+ Ties the pure pieces together — validate input, tokenize, run the (injected) backend, decode to
4
+ domain objects, serialize to RadGraph-XL — around any :class:`InferenceBackend`. The backend is a
5
+ constructor argument, which is exactly where a fake backend is injected in tests; the service
6
+ itself never imports torch.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+
13
+ from ..core.errors import InvalidReportError
14
+ from ..core.models import InferenceBackend, ReportPrediction
15
+ from ..serialization.radgraph_xl import (
16
+ SerializationDiagnostics,
17
+ to_radgraph_xl,
18
+ to_radgraph_xl_with_diagnostics,
19
+ )
20
+ from .decoder import decode_report
21
+ from .tokenizer import tokenize_report
22
+
23
+
24
+ def normalize_reports(reports: str | Sequence[str]) -> list[str]:
25
+ """Validate and normalize the public input into a non-empty list of non-empty strings.
26
+
27
+ Accepts a single ``str`` or a ``Sequence[str]``. Rejects — deterministically, with
28
+ :class:`InvalidReportError` — anything else: the wrong type, an empty container, a non-string
29
+ element, or a string that is empty or whitespace-only. Order is preserved.
30
+
31
+ Unlike the upstream package, an empty report is never silently coerced to the string
32
+ ``"None"``; it is an error the caller must see.
33
+ """
34
+ if isinstance(reports, str):
35
+ items = [reports]
36
+ elif isinstance(reports, Sequence) and not isinstance(reports, bytes | bytearray):
37
+ items = list(reports)
38
+ else:
39
+ raise InvalidReportError(
40
+ f"reports must be a str or a sequence of str, got {type(reports).__name__}."
41
+ )
42
+
43
+ if not items:
44
+ raise InvalidReportError("reports is empty: provide at least one report.")
45
+
46
+ for index, item in enumerate(items):
47
+ if not isinstance(item, str):
48
+ raise InvalidReportError(
49
+ f"report at index {index} must be a str, got {type(item).__name__}."
50
+ )
51
+ if not item.strip():
52
+ raise InvalidReportError(f"report at index {index} is empty or whitespace-only.")
53
+ return items
54
+
55
+
56
+ class InferenceService:
57
+ """Stateless orchestration around a single backend. One report is processed at a time."""
58
+
59
+ def __init__(self, backend: InferenceBackend):
60
+ self._backend = backend
61
+
62
+ def predict_graphs(self, reports: str | Sequence[str]) -> list[ReportPrediction]:
63
+ """Validate, tokenize, infer, and decode — returning the typed domain graphs (in order)."""
64
+ items = normalize_reports(reports)
65
+ predictions: list[ReportPrediction] = []
66
+ for index, text in enumerate(items):
67
+ tokenized = tokenize_report(text)
68
+ raw = self._backend.infer(tokenized.tokens)
69
+ predictions.append(decode_report(str(index), tokenized.tokens, raw))
70
+ return predictions
71
+
72
+ def predict(self, reports: str | Sequence[str]) -> dict[str, object]:
73
+ """Return the canonical RadGraph-XL dict (the public contract)."""
74
+ return to_radgraph_xl(self.predict_graphs(reports))
75
+
76
+ def predict_with_diagnostics(
77
+ self, reports: str | Sequence[str]
78
+ ) -> tuple[dict[str, object], SerializationDiagnostics]:
79
+ """Return the RadGraph-XL dict plus internal serialization diagnostics."""
80
+ return to_radgraph_xl_with_diagnostics(self.predict_graphs(reports))