From 153876f357e76d3900455941ec1a042d1f2a750b Mon Sep 17 00:00:00 2001 From: YeonwooSung Date: Fri, 9 Oct 2026 21:53:22 +0900 Subject: [PATCH 1/4] fix(server): bound GLiFormer decoded text by characters Refs #378 The other bullets are untouched. --- .../adapters/gliformer/relation_decoding.py | 64 +++++++-- .../gliformer/structuring_decoding.py | 51 +++++-- .../adapters/test_gliformer_span_decoding.py | 134 ++++++++++++++++++ 3 files changed, 229 insertions(+), 20 deletions(-) diff --git a/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py b/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py index 952b9eb0c..0588dbb43 100644 --- a/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py +++ b/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py @@ -10,14 +10,15 @@ passing cells with array operations and returns the same relations, in the same order, with the same scores. It also bounds each document's relations: at most ``max_relations`` of them, whose heads and tails together span at -most ``max_words`` words, and no more than the document's allowance affords. +most ``max_words`` words and ``max_chars`` characters, and no more than the +document's allowance affords. Past a bound, a document keeps the best-first prefix: the highest scores first, and among equal scores the package's own order. """ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import Any import numpy as np @@ -31,6 +32,9 @@ # Words of head and tail text across one document's relations. Each relation # copies both endpoints' text, and endpoints can be long. MAX_RELATION_WORDS = 262144 +# Characters of head and tail text across one document's relations. This +# bounds one document's decoded text when a single token is very long (#378). +MAX_RELATION_CHARS = 1_048_576 # Allowance units per kept relation (building it) and per scored cell. RELATION_UNITS = 2 _CELLS_PER_UNIT = 1024 @@ -42,6 +46,7 @@ def make_relation_decode( *, max_relations: int = MAX_RELATIONS, max_words: int = MAX_RELATION_WORDS, + max_chars: int = MAX_RELATION_CHARS, ) -> Callable[..., Any]: """A replacement for ``gliformer.tasks.joint_relex.decoder.JointRelexDecoder.decode``. @@ -50,6 +55,7 @@ def make_relation_decode( unflatten_by_batch_origin: The package's helper of that name. max_relations: Most relations per document. max_words: Most head and tail words across a document's relations. + max_chars: Most characters of decoded head and tail text per document. Returns: The ``decode`` method. @@ -128,6 +134,7 @@ def decode( allowance=row_allowance(bn), max_relations=max_relations, max_words=max_words, + max_chars=max_chars, ) ) @@ -156,6 +163,7 @@ def _row_relations( allowance: Allowance | None, max_relations: int, max_words: int, + max_chars: int = MAX_RELATION_CHARS, ) -> list[dict]: """One document row's relation triples, in the package's order (pair, then type). @@ -195,13 +203,25 @@ def _row_relations( if pairs.size == 0: return [] - # Endpoint widths, for the text bound; ids outside the decoded list - # resolve to empty endpoints, as in the package. + # Endpoint word and character widths, for the text bounds. Ids outside the + # decoded list resolve to empty endpoints, as in the package. + tokens = None if texts is None or source_batch_idx >= len(texts) else texts[source_batch_idx] widths = np.asarray([span.end - span.start + 1 for span in entities] + [0], dtype=np.int64) - head_words = widths[np.where((heads >= 0) & (heads < len(entities)), heads, len(entities))] - tail_words = widths[np.where((tails >= 0) & (tails < len(entities)), tails, len(entities))] + char_widths = np.asarray( + [_span_chars(tokens, span.start, span.end) for span in entities] + [0], + dtype=np.int64, + ) + head_at = np.where((heads >= 0) & (heads < len(entities)), heads, len(entities)) + tail_at = np.where((tails >= 0) & (tails < len(entities)), tails, len(entities)) limit = max_relations if allowance is None else min(max_relations, allowance.affordable(RELATION_UNITS)) - keep = _best_first(scores, head_words + tail_words, limit, max_words) + keep = _best_first( + scores, + widths[head_at] + widths[tail_at], + limit, + max_words, + char_widths[head_at] + char_widths[tail_at], + max_chars, + ) if allowance is not None: allowance.spend(RELATION_UNITS * int(keep.size)) @@ -238,15 +258,37 @@ def endpoint(entity_id: int) -> dict[str, Any]: return triples -def _best_first(scores: np.ndarray, words: np.ndarray, limit: int, max_words: int) -> np.ndarray: +def _span_chars(tokens: Sequence[str] | None, start: int, end: int) -> int: + """Characters of one span's decoded text. + + The package copies ``" ".join(tokens[start:end + 1])``. With no tokens it + copies nothing, so the character cap does not apply. + """ + if not tokens or start >= len(tokens): + return 0 + span = tokens[start : end + 1] + if not span: + return 0 + return sum(len(token) for token in span) + len(span) - 1 + + +def _best_first( + scores: np.ndarray, + words: np.ndarray, + limit: int, + max_words: int, + chars: np.ndarray, + max_chars: int, +) -> np.ndarray: """Positions of the kept relations, in their original order. Relations are taken best score first (stable), and taking stops at the - first one that would exceed ``limit`` relations or ``max_words`` words. + first one that would exceed ``limit`` relations, ``max_words`` words, or + ``max_chars`` characters of decoded text. """ - if scores.size <= limit and int(words.sum()) <= max_words: + if scores.size <= limit and int(words.sum()) <= max_words and int(chars.sum()) <= max_chars: return np.arange(scores.size) ranked = np.argsort(-scores, kind="stable")[:limit] - fits = np.cumsum(words[ranked]) <= max_words + fits = (np.cumsum(words[ranked]) <= max_words) & (np.cumsum(chars[ranked]) <= max_chars) taken = ranked[: int(np.argmin(fits)) if not fits.all() else ranked.size] return np.sort(taken) diff --git a/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py b/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py index dfbe6cf07..cf4f5fe32 100644 --- a/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py +++ b/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py @@ -21,7 +21,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import Any import numpy as np @@ -41,6 +41,10 @@ # the text alone grows with spans x document length. Flat single-label # removal keeps disjoint spans per slot: at most 100 slots x 2048 words. MAX_RECORD_WORDS = 262144 +# Characters of field text one document's records may hold after overlap +# removal. This bounds one document's decoded text when a single token is +# very long (#378): the word cap counts that token as one word. +MAX_RECORD_CHARS = 1_048_576 def make_structuring_decode( @@ -49,6 +53,7 @@ def make_structuring_decode( *, max_spans: int = MAX_RECORD_SPANS, max_words: int = MAX_RECORD_WORDS, + max_chars: int = MAX_RECORD_CHARS, ) -> Callable[..., Any]: """A replacement for ``gliformer.tasks.structuring.decoder.StructuringDecoder.decode``. @@ -57,6 +62,7 @@ def make_structuring_decode( unflatten_by_batch_origin: The package's helper of that name. max_spans: Most field spans per document, across its record slots. max_words: Most words of field text per document, across its slots. + max_chars: Most characters of decoded field text per document. Returns: The ``decode`` method. @@ -145,6 +151,8 @@ def decode( kept = _within_word_limit( [(anchor_idx, self.greedy_search(spans, flat_ner, multi_label)) for anchor_idx, spans in slot_spans], max_words, + tokens=None if texts is None or text_idx >= len(texts) else texts[text_idx], + max_chars=max_chars, ) for anchor_idx, spans in kept: fields = self._spans_to_fields(spans, texts, text_idx) @@ -283,27 +291,52 @@ def slot_spans(position: int, cut: np.float32 | None) -> tuple[np.ndarray, np.nd return result -def _within_word_limit(slots: list[tuple[int, list[Any]]], max_words: int) -> list[tuple[int, list[Any]]]: - """Drop the lowest-scoring kept fields until their text fits ``max_words``. +def _span_chars(tokens: Sequence[str] | None, start: int, end: int) -> int: + """Characters of one span's decoded text. + + The package copies ``" ".join(tokens[start:end + 1])``. With no tokens it + copies nothing, so the character cap does not apply. + """ + if not tokens or start >= len(tokens): + return 0 + span = tokens[start : end + 1] + if not span: + return 0 + return sum(len(token) for token in span) + len(span) - 1 + + +def _within_word_limit( + slots: list[tuple[int, list[Any]]], + max_words: int, + *, + tokens: Sequence[str] | None = None, + max_chars: int = MAX_RECORD_CHARS, +) -> list[tuple[int, list[Any]]]: + """Drop the lowest-scoring kept fields until their text fits both bounds. Fields are ranked best score first, and among equal scores by slot and - then position; the best ones whose words add up to at most ``max_words`` - stay, in their original order. + then position. Taking stops at the first field that would exceed + ``max_words`` words or ``max_chars`` characters of decoded text. The ones + taken stay in their original order. """ words = sum(span.end - span.start + 1 for _, spans in slots for span in spans) - if words <= max_words: + chars = sum(_span_chars(tokens, span.start, span.end) for _, spans in slots for span in spans) + if words <= max_words and chars <= max_chars: return slots ranked = sorted( ((slot, index, span) for slot, (_, spans) in enumerate(slots) for index, span in enumerate(spans)), key=lambda entry: -entry[2].score, ) keep: set[tuple[int, int]] = set() - used = 0 + used_words = 0 + used_chars = 0 for slot, index, span in ranked: width = span.end - span.start + 1 - if used + width > max_words: + span_chars = _span_chars(tokens, span.start, span.end) + if used_words + width > max_words or used_chars + span_chars > max_chars: break - used += width + used_words += width + used_chars += span_chars keep.add((slot, index)) return [ (anchor_idx, [span for index, span in enumerate(spans) if (slot, index) in keep]) diff --git a/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py b/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py index 7a3aa0bc2..512ab048e 100644 --- a/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py +++ b/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py @@ -504,6 +504,64 @@ def test_record_text_is_bounded_per_document() -> None: assert structuring_decoding._within_word_limit(slots, 50) == [(0, []), (1, [])] +def test_record_text_is_bounded_by_characters() -> None: + span = upstream.Span + # One word each. The long token is under the word cap and over the character cap. + long = span(start=0, end=0, entity_type="a", score=0.9) + short = span(start=1, end=1, entity_type="b", score=0.4) + slots = [(0, [long]), (1, [short])] + tokens = ["x" * 30, "ok"] + assert structuring_decoding.MAX_RECORD_CHARS == 1_048_576 + assert structuring_decoding._within_word_limit(slots, 10, tokens=tokens, max_chars=40) is slots + # Best first is the 30-character token. It does not fit in 20, so taking stops + # and the later short span is dropped too, as an over-word span would be. + assert structuring_decoding._within_word_limit(slots, 10, tokens=tokens, max_chars=20) == [(0, []), (1, [])] + # A normal short span still passes; the long token ranked after it does not. + passing = [ + (0, [span(start=1, end=1, entity_type="b", score=0.9)]), + (1, [span(start=0, end=0, entity_type="a", score=0.4)]), + ] + kept = structuring_decoding._within_word_limit(passing, 10, tokens=tokens, max_chars=10) + assert [(slot, [(item.start, item.end) for item in spans]) for slot, spans in kept] == [(0, [(1, 1)]), (1, [])] + # Decoded text joins tokens with spaces, so two short words can still exceed the character cap. + spaced = [(0, [span(start=0, end=1, entity_type="a", score=0.9)])] + assert structuring_decoding._within_word_limit(spaced, 10, tokens=["abcd", "ef"], max_chars=6) == [(0, [])] + assert structuring_decoding._within_word_limit(spaced, 10, tokens=["abcd", "ef"], max_chars=7) is spaced + + +def _record_fields(records: list) -> list[dict]: + return [field for group in records[0] for record in group for field in record] + + +def test_decoded_record_text_is_bounded_by_characters() -> None: + decode = structuring_decoding.make_structuring_decode( + upstream.Span, + structuring_decoder.unflatten_by_batch_origin, + max_words=100, + max_chars=10, + ) + decoder = structuring_decoder.StructuringDecoder(SimpleNamespace()) + + def output(short_logit: float, long_logit: float) -> SimpleNamespace: + return SimpleNamespace( + structuring_logits=torch.tensor([[[short_logit, long_logit]]]), + structuring_field_logits=torch.tensor([[[6.0], [6.0]]]), + structuring_span_idx=torch.tensor([[[0, 0], [1, 1]]]), + structuring_span_mask=torch.tensor([[True, True]]), + structuring_batch_origin=torch.arange(1), + batch_size=1, + ) + + long_text = "z" * 30 + # The long token is one word, so the word cap would keep it. Ranked first, it + # misses the character cap and taking stops, dropping the short span too. + assert _record_fields(decode(decoder, output(2.0, 6.0), threshold=0.1, texts=[["ok", long_text]])) == [] + fields = _record_fields(decode(decoder, output(6.0, 2.0), threshold=0.1, texts=[["ok", long_text]])) + assert [field["text"] for field in fields] == ["ok"] + fields = _record_fields(decode(decoder, output(2.0, 6.0), threshold=0.1, texts=[["ok", "yo"]])) + assert [field["text"] for field in fields] == ["ok", "yo"] + + def test_fully_nested_records_stay_within_the_text_bound() -> None: spans = [(i, 8190 - i) for i in range(4095)] count = len(spans) @@ -518,7 +576,9 @@ def test_fully_nested_records_stay_within_the_text_bound() -> None: records = _STRUCTURING.decode(output, threshold=0.1, flat_ner=False, texts=[[f"w{i}" for i in range(8191)]]) fields = [field for group in records[0] for record in group for field in record] words = sum(field["end"] - field["start"] + 1 for field in fields) + chars = sum(len(field["text"]) for field in fields) assert 0 < words <= structuring_decoding.MAX_RECORD_WORDS + assert 0 < chars <= structuring_decoding.MAX_RECORD_CHARS def test_record_spans_within_an_allowance_keep_each_slots_best_spans() -> None: @@ -850,6 +910,80 @@ def test_relation_text_is_bounded_per_document() -> None: assert 0 < len(capped) < len(full) +def test_relation_text_is_bounded_by_characters() -> None: + span = upstream.Span + long = span(start=0, end=0, entity_type="a", score=1.0) + short = span(start=1, end=1, entity_type="b", score=1.0) + entities = [long, short] + texts = [["y" * 30, "ok"]] + assert relation_decoding.MAX_RELATION_CHARS == 1_048_576 + + def rows(scores: list[float], *, max_chars: int, max_words: int = 10, row_texts: list[list[str]] = texts) -> list: + return relation_decoding._row_relations( + _RELATIONS, + torch.tensor([[score] for score in scores], dtype=torch.float32), + torch.tensor([[0, 1], [1, 1]]), + None, + 0.5, + {0: "links"}, + None, + entities, + row_texts, + 0, + allowance=None, + max_relations=10, + max_words=max_words, + max_chars=max_chars, + ) + + # 30 + 2 characters and 2 words, then 2 + 2. Under both caps. + both = rows([0.9, 0.6], max_chars=40) + assert [(item["head"]["text"], item["tail"]["text"]) for item in both] == [("y" * 30, "ok"), ("ok", "ok")] + # Best relation is two words (under the word cap) and 32 characters. It does not + # fit in 20, so taking stops and the later short relation is dropped too. + assert rows([0.9, 0.6], max_chars=20) == [] + # A normal short relation still passes; the long one ranked after it does not. + kept = rows([0.6, 0.9], max_chars=10) + assert [(item["head"]["text"], item["tail"]["text"]) for item in kept] == [("ok", "ok")] + # Joined tokens count the space: "abcd ef" is 7 characters and 2 words. + wide = [span(start=0, end=1, entity_type="a", score=1.0)] + spaced = relation_decoding._row_relations( + _RELATIONS, + torch.tensor([[0.9]], dtype=torch.float32), + torch.tensor([[0, 0]]), + None, + 0.5, + {0: "links"}, + None, + wide, + [["abcd", "ef"]], + 0, + allowance=None, + max_relations=10, + max_words=10, + max_chars=6, + ) + assert spaced == [] + fitting = relation_decoding._row_relations( + _RELATIONS, + torch.tensor([[0.9]], dtype=torch.float32), + torch.tensor([[0, 0]]), + None, + 0.5, + {0: "links"}, + None, + wide, + [["abcd", "ef"]], + 0, + allowance=None, + max_relations=10, + max_words=10, + max_chars=14, + ) + assert fitting[0]["head"]["text"] == "abcd ef" + assert len(fitting[0]["head"]["text"]) + len(fitting[0]["tail"]["text"]) == 14 + + def test_relations_within_an_allowance_keep_the_best_first_prefix() -> None: output = _relation_output(3, rows=1, pairs=60, types=6) kwargs = {"threshold": 0.1, "texts": [[f"w{i}" for i in range(30)]]} From 815c8276a5e1b83273b0e2581fc08d197fdead6c Mon Sep 17 00:00:00 2001 From: YeonwooSung Date: Fri, 9 Oct 2026 23:51:29 +0900 Subject: [PATCH 2/4] fix(server): size the GLiFormer character cap from the word cap The character total was walked even when the word cap had already rejected the output. Token text is now read only for the best-first prefix that still fits in the word budget. 1_048_576 is four characters a word, so ordinary words hit it before the word cap. The cap is the word cap times 2048, the document length those bounds were measured on. Refs #378 --- .../adapters/gliformer/relation_decoding.py | 98 ++++++++++++++----- .../gliformer/structuring_decoding.py | 28 ++++-- .../adapters/test_gliformer_span_decoding.py | 62 +++++++++++- 3 files changed, 155 insertions(+), 33 deletions(-) diff --git a/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py b/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py index 0588dbb43..2a925502e 100644 --- a/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py +++ b/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py @@ -32,9 +32,12 @@ # Words of head and tail text across one document's relations. Each relation # copies both endpoints' text, and endpoints can be long. MAX_RELATION_WORDS = 262144 -# Characters of head and tail text across one document's relations. This -# bounds one document's decoded text when a single token is very long (#378). -MAX_RELATION_CHARS = 1_048_576 +# Characters of that head and tail text (#378). Same rule as the record cap: +# the word cap counts a token as one word, and four characters a word rejects +# ordinary text first. 2048 is the measured document length in words, used as +# the most characters one ordinary token may take. Shorter tokens reach the +# word cap first. +MAX_RELATION_CHARS = MAX_RELATION_WORDS * 2048 # Allowance units per kept relation (building it) and per scored cell. RELATION_UNITS = 2 _CELLS_PER_UNIT = 1024 @@ -203,25 +206,43 @@ def _row_relations( if pairs.size == 0: return [] - # Endpoint word and character widths, for the text bounds. Ids outside the - # decoded list resolve to empty endpoints, as in the package. + # Endpoint word widths are arithmetic on the span bounds. Character widths + # read token text, so they are filled for every endpoint only when the + # word total still fits. When the word cap already rejects the row, text + # is read only for the best-first prefix that fits in the word budget. tokens = None if texts is None or source_batch_idx >= len(texts) else texts[source_batch_idx] widths = np.asarray([span.end - span.start + 1 for span in entities] + [0], dtype=np.int64) - char_widths = np.asarray( - [_span_chars(tokens, span.start, span.end) for span in entities] + [0], - dtype=np.int64, - ) head_at = np.where((heads >= 0) & (heads < len(entities)), heads, len(entities)) tail_at = np.where((tails >= 0) & (tails < len(entities)), tails, len(entities)) + word_cost = widths[head_at] + widths[tail_at] limit = max_relations if allowance is None else min(max_relations, allowance.affordable(RELATION_UNITS)) - keep = _best_first( - scores, - widths[head_at] + widths[tail_at], - limit, - max_words, - char_widths[head_at] + char_widths[tail_at], - max_chars, - ) + word_total = int(word_cost.sum()) if word_cost.size else 0 + chars: np.ndarray | None = None + char_of: Callable[[int], int] | None = None + if pairs.size <= limit and word_total <= max_words: + char_widths = np.asarray( + [_span_chars(tokens, span.start, span.end) for span in entities] + [0], + dtype=np.int64, + ) + chars = char_widths[head_at] + char_widths[tail_at] + else: + cache: dict[int, int] = {} + + def char_of(index: int) -> int: + total = 0 + for entity_id in (int(heads[index]), int(tails[index])): + cached = cache.get(entity_id) + if cached is None: + if 0 <= entity_id < len(entities): + entity = entities[entity_id] + cached = _span_chars(tokens, entity.start, entity.end) + else: + cached = 0 + cache[entity_id] = cached + total += cached + return total + + keep = _best_first(scores, word_cost, limit, max_words, chars, max_chars, char_of=char_of) if allowance is not None: allowance.spend(RELATION_UNITS * int(keep.size)) @@ -277,18 +298,49 @@ def _best_first( words: np.ndarray, limit: int, max_words: int, - chars: np.ndarray, + chars: np.ndarray | None, max_chars: int, + *, + char_of: Callable[[int], int] | None = None, ) -> np.ndarray: """Positions of the kept relations, in their original order. Relations are taken best score first (stable), and taking stops at the first one that would exceed ``limit`` relations, ``max_words`` words, or ``max_chars`` characters of decoded text. + + ``chars`` is the per-relation character cost when it is already known. + When the word total is over the cap, pass ``chars=None`` and ``char_of`` + so token text is read only for the prefix that still fits in the word + budget. """ - if scores.size <= limit and int(words.sum()) <= max_words and int(chars.sum()) <= max_chars: - return np.arange(scores.size) + count = int(scores.size) + if count == 0: + return np.arange(0) + word_total = int(words.sum()) + if count <= limit and word_total <= max_words: + if chars is None: + if char_of is None: + msg = "char_of is required when chars is omitted" + raise ValueError(msg) + chars = np.fromiter((char_of(index) for index in range(count)), dtype=np.int64, count=count) + if int(chars.sum()) <= max_chars: + return np.arange(count) ranked = np.argsort(-scores, kind="stable")[:limit] - fits = (np.cumsum(words[ranked]) <= max_words) & (np.cumsum(chars[ranked]) <= max_chars) - taken = ranked[: int(np.argmin(fits)) if not fits.all() else ranked.size] - return np.sort(taken) + if ranked.size == 0: + return ranked + word_fits = np.cumsum(words[ranked]) <= max_words + word_count = int(np.argmin(word_fits)) if not bool(word_fits.all()) else int(ranked.size) + if word_count == 0: + return np.empty(0, dtype=ranked.dtype) + prefix = ranked[:word_count] + if chars is None: + if char_of is None: + msg = "char_of is required when chars is omitted" + raise ValueError(msg) + prefix_chars = np.fromiter((char_of(int(index)) for index in prefix), dtype=np.int64, count=word_count) + else: + prefix_chars = chars[prefix] + char_fits = np.cumsum(prefix_chars) <= max_chars + kept = int(np.argmin(char_fits)) if not bool(char_fits.all()) else word_count + return np.sort(prefix[:kept]) diff --git a/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py b/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py index cf4f5fe32..a1ff95e42 100644 --- a/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py +++ b/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py @@ -41,10 +41,15 @@ # the text alone grows with spans x document length. Flat single-label # removal keeps disjoint spans per slot: at most 100 slots x 2048 words. MAX_RECORD_WORDS = 262144 -# Characters of field text one document's records may hold after overlap -# removal. This bounds one document's decoded text when a single token is -# very long (#378): the word cap counts that token as one word. -MAX_RECORD_CHARS = 1_048_576 +# Characters of that field text (#378). The word cap counts every token as +# one word, so it does not bound one very long token. Decoded text joins +# tokens with spaces. Four characters a word, which is what 1_048_576 was, +# rejects ordinary words while the word count is still under the cap. +# 2048 is the document length in words from the measurements above, used +# here as the most characters one ordinary token may take. A document of +# shorter tokens reaches the word cap first. One token longer than that +# is the case this bound exists for. +MAX_RECORD_CHARS = MAX_RECORD_WORDS * 2048 def make_structuring_decode( @@ -318,11 +323,16 @@ def _within_word_limit( then position. Taking stops at the first field that would exceed ``max_words`` words or ``max_chars`` characters of decoded text. The ones taken stay in their original order. + + When the word total is already over ``max_words``, token text is read + only for the best-first prefix that still fits in that word budget. The + tail the word cap rejects is not walked. """ words = sum(span.end - span.start + 1 for _, spans in slots for span in spans) - chars = sum(_span_chars(tokens, span.start, span.end) for _, spans in slots for span in spans) - if words <= max_words and chars <= max_chars: - return slots + if words <= max_words: + chars = sum(_span_chars(tokens, span.start, span.end) for _, spans in slots for span in spans) + if chars <= max_chars: + return slots ranked = sorted( ((slot, index, span) for slot, (_, spans) in enumerate(slots) for index, span in enumerate(spans)), key=lambda entry: -entry[2].score, @@ -332,8 +342,10 @@ def _within_word_limit( used_chars = 0 for slot, index, span in ranked: width = span.end - span.start + 1 + if used_words + width > max_words: + break span_chars = _span_chars(tokens, span.start, span.end) - if used_words + width > max_words or used_chars + span_chars > max_chars: + if used_chars + span_chars > max_chars: break used_words += width used_chars += span_chars diff --git a/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py b/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py index 512ab048e..981a57d38 100644 --- a/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py +++ b/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py @@ -511,7 +511,7 @@ def test_record_text_is_bounded_by_characters() -> None: short = span(start=1, end=1, entity_type="b", score=0.4) slots = [(0, [long]), (1, [short])] tokens = ["x" * 30, "ok"] - assert structuring_decoding.MAX_RECORD_CHARS == 1_048_576 + assert structuring_decoding.MAX_RECORD_CHARS == structuring_decoding.MAX_RECORD_WORDS * 2048 assert structuring_decoding._within_word_limit(slots, 10, tokens=tokens, max_chars=40) is slots # Best first is the 30-character token. It does not fit in 20, so taking stops # and the later short span is dropped too, as an over-word span would be. @@ -529,6 +529,37 @@ def test_record_text_is_bounded_by_characters() -> None: assert structuring_decoding._within_word_limit(spaced, 10, tokens=["abcd", "ef"], max_chars=7) is spaced +def test_ordinary_words_reach_the_word_cap_before_the_character_cap() -> None: + """Eight-letter words are ordinary. A full word cap of them, plus the joining spaces, fits.""" + ordinary = 8 + words = structuring_decoding.MAX_RECORD_WORDS + joined = words * ordinary + (words - 1) + assert joined < structuring_decoding.MAX_RECORD_CHARS + assert relation_decoding.MAX_RELATION_CHARS == relation_decoding.MAX_RELATION_WORDS * 2048 + + +class _TracingTokens(list[str]): + def __init__(self, items: list[str]) -> None: + super().__init__(items) + self.reads: list[object] = [] + + def __getitem__(self, item: object) -> object: + self.reads.append(item) + return super().__getitem__(item) # type: ignore[index] + + +def test_word_cap_rejection_does_not_read_the_rejected_tail() -> None: + span = upstream.Span + tokens = _TracingTokens(["ok", "z" * 100_000]) + slots = [ + (0, [span(start=0, end=0, entity_type="a", score=0.9)]), + (1, [span(start=1, end=1, entity_type="b", score=0.1)]), + ] + kept = structuring_decoding._within_word_limit(slots, 1, tokens=tokens, max_chars=10**9) + assert [(slot, [(item.start, item.end) for item in spans]) for slot, spans in kept] == [(0, [(0, 0)]), (1, [])] + assert not any(isinstance(read, slice) and read.start == 1 for read in tokens.reads) + + def _record_fields(records: list) -> list[dict]: return [field for group in records[0] for record in group for field in record] @@ -916,7 +947,7 @@ def test_relation_text_is_bounded_by_characters() -> None: short = span(start=1, end=1, entity_type="b", score=1.0) entities = [long, short] texts = [["y" * 30, "ok"]] - assert relation_decoding.MAX_RELATION_CHARS == 1_048_576 + assert relation_decoding.MAX_RELATION_CHARS == relation_decoding.MAX_RELATION_WORDS * 2048 def rows(scores: list[float], *, max_chars: int, max_words: int = 10, row_texts: list[list[str]] = texts) -> list: return relation_decoding._row_relations( @@ -984,6 +1015,33 @@ def rows(scores: list[float], *, max_chars: int, max_words: int = 10, row_texts: assert len(fitting[0]["head"]["text"]) + len(fitting[0]["tail"]["text"]) == 14 +def test_relation_word_cap_does_not_read_the_rejected_endpoint() -> None: + span = upstream.Span + tokens = _TracingTokens(["ok", "z" * 100_000]) + entities = [ + span(start=0, end=0, entity_type="a", score=1.0), + span(start=1, end=1, entity_type="b", score=1.0), + ] + rows = relation_decoding._row_relations( + _RELATIONS, + torch.tensor([[0.9], [0.6]], dtype=torch.float32), + torch.tensor([[0, 0], [1, 1]]), + None, + 0.5, + {0: "links"}, + None, + entities, + [tokens], + 0, + allowance=None, + max_relations=10, + max_words=2, + max_chars=10**9, + ) + assert [(item["head"]["text"], item["tail"]["text"]) for item in rows] == [("ok", "ok")] + assert not any(isinstance(read, slice) and read.start == 1 for read in tokens.reads) + + def test_relations_within_an_allowance_keep_the_best_first_prefix() -> None: output = _relation_output(3, rows=1, pairs=60, types=6) kwargs = {"threshold": 0.1, "texts": [[f"w{i}" for i in range(30)]]} From 9af60559de190b9872186d81ac5bfe8cce25b691 Mon Sep 17 00:00:00 2001 From: YeonwooSung Date: Sat, 10 Oct 2026 05:19:10 +0900 Subject: [PATCH 3/4] fix(server): bound GLiFormer tokens at 32 characters each Refs #378 --- .../adapters/gliformer/relation_decoding.py | 9 ++++----- .../gliformer/structuring_decoding.py | 12 +++++------ .../adapters/test_gliformer_span_decoding.py | 20 +++++++++++++++---- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py b/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py index 2a925502e..e26e4c8e1 100644 --- a/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py +++ b/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py @@ -33,11 +33,10 @@ # copies both endpoints' text, and endpoints can be long. MAX_RELATION_WORDS = 262144 # Characters of that head and tail text (#378). Same rule as the record cap: -# the word cap counts a token as one word, and four characters a word rejects -# ordinary text first. 2048 is the measured document length in words, used as -# the most characters one ordinary token may take. Shorter tokens reach the -# word cap first. -MAX_RELATION_CHARS = MAX_RELATION_WORDS * 2048 +# 32 characters per word, so ordinary text hits the word cap first while a +# long token is bounded. Many ordinary English words are longer than 16 +# characters, so 32 is used instead of 16: it is the top of that range. +MAX_RELATION_CHARS = MAX_RELATION_WORDS * 32 # Allowance units per kept relation (building it) and per scored cell. RELATION_UNITS = 2 _CELLS_PER_UNIT = 1024 diff --git a/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py b/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py index a1ff95e42..83aeb14e3 100644 --- a/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py +++ b/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py @@ -43,13 +43,11 @@ MAX_RECORD_WORDS = 262144 # Characters of that field text (#378). The word cap counts every token as # one word, so it does not bound one very long token. Decoded text joins -# tokens with spaces. Four characters a word, which is what 1_048_576 was, -# rejects ordinary words while the word count is still under the cap. -# 2048 is the document length in words from the measurements above, used -# here as the most characters one ordinary token may take. A document of -# shorter tokens reaches the word cap first. One token longer than that -# is the case this bound exists for. -MAX_RECORD_CHARS = MAX_RECORD_WORDS * 2048 +# tokens with spaces. 32 characters per word keeps normal text on the word +# cap first. Many ordinary English words are longer than 16 characters, so +# 32 is used instead of 16: it is the top of that range. A token longer +# than 32 characters is what this bound is for. +MAX_RECORD_CHARS = MAX_RECORD_WORDS * 32 def make_structuring_decode( diff --git a/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py b/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py index 981a57d38..6f6441148 100644 --- a/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py +++ b/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py @@ -511,7 +511,7 @@ def test_record_text_is_bounded_by_characters() -> None: short = span(start=1, end=1, entity_type="b", score=0.4) slots = [(0, [long]), (1, [short])] tokens = ["x" * 30, "ok"] - assert structuring_decoding.MAX_RECORD_CHARS == structuring_decoding.MAX_RECORD_WORDS * 2048 + assert structuring_decoding.MAX_RECORD_CHARS == structuring_decoding.MAX_RECORD_WORDS * 32 assert structuring_decoding._within_word_limit(slots, 10, tokens=tokens, max_chars=40) is slots # Best first is the 30-character token. It does not fit in 20, so taking stops # and the later short span is dropped too, as an over-word span would be. @@ -530,12 +530,24 @@ def test_record_text_is_bounded_by_characters() -> None: def test_ordinary_words_reach_the_word_cap_before_the_character_cap() -> None: - """Eight-letter words are ordinary. A full word cap of them, plus the joining spaces, fits.""" + """An 8-letter word still hits the word cap first (8 < 32). + + A token longer than 32 characters is what the character cap is for. + """ ordinary = 8 + assert ordinary < 32 words = structuring_decoding.MAX_RECORD_WORDS + # A full word cap of 8-letter words, plus the joining spaces, stays under the character cap. joined = words * ordinary + (words - 1) assert joined < structuring_decoding.MAX_RECORD_CHARS - assert relation_decoding.MAX_RELATION_CHARS == relation_decoding.MAX_RELATION_WORDS * 2048 + assert ( + relation_decoding.MAX_RELATION_WORDS * ordinary + (relation_decoding.MAX_RELATION_WORDS - 1) + < relation_decoding.MAX_RELATION_CHARS + ) + # The same number of tokens, each longer than 32 characters, exceeds the character cap first. + assert words * (32 + 1) > structuring_decoding.MAX_RECORD_CHARS + assert relation_decoding.MAX_RELATION_WORDS * (32 + 1) > relation_decoding.MAX_RELATION_CHARS + assert relation_decoding.MAX_RELATION_CHARS == relation_decoding.MAX_RELATION_WORDS * 32 class _TracingTokens(list[str]): @@ -947,7 +959,7 @@ def test_relation_text_is_bounded_by_characters() -> None: short = span(start=1, end=1, entity_type="b", score=1.0) entities = [long, short] texts = [["y" * 30, "ok"]] - assert relation_decoding.MAX_RELATION_CHARS == relation_decoding.MAX_RELATION_WORDS * 2048 + assert relation_decoding.MAX_RELATION_CHARS == relation_decoding.MAX_RELATION_WORDS * 32 def rows(scores: list[float], *, max_chars: int, max_words: int = 10, row_texts: list[list[str]] = texts) -> list: return relation_decoding._row_relations( From e553805035642452944de8e51bb8df08227febf9 Mon Sep 17 00:00:00 2001 From: YeonwooSung Date: Sat, 10 Oct 2026 23:17:05 +0900 Subject: [PATCH 4/4] docs(server): describe the GLiFormer character cap as a document total Refs #378 --- .../sie_server/adapters/gliformer/relation_decoding.py | 9 +++++---- .../adapters/gliformer/structuring_decoding.py | 10 ++++------ .../tests/adapters/test_gliformer_span_decoding.py | 9 +++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py b/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py index e26e4c8e1..f4613a5b6 100644 --- a/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py +++ b/packages/sie_server/src/sie_server/adapters/gliformer/relation_decoding.py @@ -32,10 +32,11 @@ # Words of head and tail text across one document's relations. Each relation # copies both endpoints' text, and endpoints can be long. MAX_RELATION_WORDS = 262144 -# Characters of that head and tail text (#378). Same rule as the record cap: -# 32 characters per word, so ordinary text hits the word cap first while a -# long token is bounded. Many ordinary English words are longer than 16 -# characters, so 32 is used instead of 16: it is the top of that range. +# Characters of that head and tail text (#378). Same rule as the record cap. +# The constant is MAX_RELATION_WORDS * 32: 32 characters per counted word, +# including the joining space. The bound is per document, so text averaging +# under 31 characters a word reaches the word cap first, and text made of +# very long tokens reaches this cap. MAX_RELATION_CHARS = MAX_RELATION_WORDS * 32 # Allowance units per kept relation (building it) and per scored cell. RELATION_UNITS = 2 diff --git a/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py b/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py index 83aeb14e3..34584aee9 100644 --- a/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py +++ b/packages/sie_server/src/sie_server/adapters/gliformer/structuring_decoding.py @@ -41,12 +41,10 @@ # the text alone grows with spans x document length. Flat single-label # removal keeps disjoint spans per slot: at most 100 slots x 2048 words. MAX_RECORD_WORDS = 262144 -# Characters of that field text (#378). The word cap counts every token as -# one word, so it does not bound one very long token. Decoded text joins -# tokens with spaces. 32 characters per word keeps normal text on the word -# cap first. Many ordinary English words are longer than 16 characters, so -# 32 is used instead of 16: it is the top of that range. A token longer -# than 32 characters is what this bound is for. +# Characters of that field text (#378). The constant is MAX_RECORD_WORDS * 32: +# 32 characters per counted word, including the joining space. The bound is +# per document, so text averaging under 31 characters a word reaches the word +# cap first, and text made of very long tokens reaches this cap. MAX_RECORD_CHARS = MAX_RECORD_WORDS * 32 diff --git a/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py b/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py index 6f6441148..b94f77427 100644 --- a/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py +++ b/packages/sie_server/tests/adapters/test_gliformer_span_decoding.py @@ -530,12 +530,13 @@ def test_record_text_is_bounded_by_characters() -> None: def test_ordinary_words_reach_the_word_cap_before_the_character_cap() -> None: - """An 8-letter word still hits the word cap first (8 < 32). + """A document of short words reaches the word cap before the character cap. - A token longer than 32 characters is what the character cap is for. + Eight letters plus the joining space average under 31 characters a word, + so that document stays under the character cap. Words averaging more than + 32 characters hit the character cap first. """ ordinary = 8 - assert ordinary < 32 words = structuring_decoding.MAX_RECORD_WORDS # A full word cap of 8-letter words, plus the joining spaces, stays under the character cap. joined = words * ordinary + (words - 1) @@ -544,7 +545,7 @@ def test_ordinary_words_reach_the_word_cap_before_the_character_cap() -> None: relation_decoding.MAX_RELATION_WORDS * ordinary + (relation_decoding.MAX_RELATION_WORDS - 1) < relation_decoding.MAX_RELATION_CHARS ) - # The same number of tokens, each longer than 32 characters, exceeds the character cap first. + # The same number of words, averaging more than 32 characters, exceeds the character cap first. assert words * (32 + 1) > structuring_decoding.MAX_RECORD_CHARS assert relation_decoding.MAX_RELATION_WORDS * (32 + 1) > relation_decoding.MAX_RELATION_CHARS assert relation_decoding.MAX_RELATION_CHARS == relation_decoding.MAX_RELATION_WORDS * 32