Files
google--langextract/tests/fuzzy_alignment_cases_test.py
wehub-resource-sync 76d991c447
Auto Update PR / update-prs (push) Has been cancelled
CI / format-check (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / live-api-tests (push) Has been cancelled
CI / plugin-integration-test (push) Has been cancelled
CI / ollama-integration-test (push) Has been cancelled
CI / test-fork-pr (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:37:14 +08:00

657 lines
18 KiB
Python

# Copyright 2025 Google LLC.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Correctness oracle tests for fuzzy alignment.
These planted-span cases serve as regression tests before and after
performance changes to _fuzzy_align_extraction. Each case asserts
exact token_interval, char_interval, and matched substring.
"""
import random
import warnings
from absl.testing import absltest
from absl.testing import parameterized
from langextract import prompt_validation
from langextract import resolver as resolver_lib
from langextract.core import data
from langextract.core import tokenizer as tokenizer_lib
_WORD_POOL = [
"patient",
"diagnosed",
"with",
"diabetes",
"hypertension",
"medication",
"prescribed",
"daily",
"chronic",
"condition",
"treatment",
"history",
"symptoms",
"blood",
"pressure",
"glucose",
"insulin",
"kidney",
"liver",
"cardiac",
"pulmonary",
"neurological",
"assessment",
"examination",
"laboratory",
"results",
"normal",
"elevated",
"decreased",
"follow",
"appointment",
"scheduled",
"monitor",
"progress",
"clinical",
"evaluation",
"imaging",
"therapy",
"dosage",
"adverse",
"reaction",
"prognosis",
"referral",
"discharge",
"admission",
"surgery",
"recovery",
"emergency",
"outpatient",
"inpatient",
"consultation",
"diagnosis",
"pathology",
"specimen",
"biopsy",
"cultures",
"antibiotics",
"analgesic",
"sedation",
"ventilation",
"intubation",
"catheter",
"drainage",
"infusion",
]
def _generate_source(n, seed=42):
"""Generates deterministic source text from _WORD_POOL."""
rng = random.Random(seed)
return " ".join(rng.choice(_WORD_POOL) for _ in range(n))
def _plant_span(source, target, pos):
"""Inserts target tokens at pos in source."""
words = source.split()
target_words = target.split()
p = min(pos, len(words))
words[p : p + len(target_words)] = target_words
return " ".join(words)
def _plant_gapped(source, tokens, start, gap):
"""Inserts tokens at intervals of (gap+1) starting at start."""
words = source.split()
for i, token in enumerate(tokens):
p = min(start + i * (gap + 1), len(words) - 1)
words[p] = token
return " ".join(words)
def _run(
source,
extraction_text,
tokenizer,
aligner,
algorithm="lcs",
token_offset=0,
char_offset=0,
):
"""Runs fuzzy alignment using the requested algorithm and returns the result."""
tokenized = tokenizer.tokenize(source)
source_tokens = [
source[t.char_interval.start_pos : t.char_interval.end_pos].lower()
for t in tokenized.tokens
]
extraction = data.Extraction(
extraction_class="entity", extraction_text=extraction_text
)
if algorithm == "lcs":
source_tokens_norm = [
resolver_lib._normalize_token(t) for t in source_tokens
]
return aligner._lcs_fuzzy_align_extraction(
extraction=extraction,
source_tokens_norm=source_tokens_norm,
tokenized_text=tokenized,
token_offset=token_offset,
char_offset=char_offset,
tokenizer_impl=tokenizer,
)
return aligner._fuzzy_align_extraction(
extraction=extraction,
source_tokens=source_tokens,
tokenized_text=tokenized,
token_offset=token_offset,
char_offset=char_offset,
tokenizer_impl=tokenizer,
)
_BASE_200 = _generate_source(200, seed=42)
_PLANTED = _plant_span(_BASE_200, "metformin hydrochloride tablet", 50)
_GAPPED = _plant_gapped(
_generate_source(200, seed=99),
["metformin", "hydrochloride", "tablet"],
start=40,
gap=3,
)
_PLANTED_POSITIVE_CASES = (
dict(
testcase_name="contiguous_lcs",
algorithm="lcs",
source=_PLANTED,
extraction_text="metformin hydrochloride tablet",
expect_token_interval=(50, 53),
expect_char_interval=(451, 481),
expect_substring="metformin hydrochloride tablet",
),
dict(
testcase_name="contiguous_legacy",
algorithm="legacy",
source=_PLANTED,
extraction_text="metformin hydrochloride tablet",
expect_token_interval=(50, 53),
expect_char_interval=(451, 481),
expect_substring="metformin hydrochloride tablet",
),
dict(
testcase_name="fuzzy_stemming_lcs",
algorithm="lcs",
source=_PLANTED,
extraction_text="metformins hydrochlorides tablets",
expect_token_interval=(50, 53),
expect_char_interval=(451, 481),
expect_substring="metformin hydrochloride tablet",
),
dict(
testcase_name="fuzzy_stemming_legacy",
algorithm="legacy",
source=_PLANTED,
extraction_text="metformins hydrochlorides tablets",
expect_token_interval=(50, 53),
expect_char_interval=(451, 481),
expect_substring="metformin hydrochloride tablet",
),
dict(
testcase_name="gapped_lcs",
algorithm="lcs",
source=_GAPPED,
extraction_text="metformin hydrochloride tablet",
expect_token_interval=(40, 49),
expect_char_interval=(371, 461),
expect_substring=(
"metformin pulmonary antibiotics assessment"
" hydrochloride hypertension pressure with tablet"
),
),
dict(
testcase_name="gapped_legacy",
algorithm="legacy",
source=_GAPPED,
extraction_text="metformin hydrochloride tablet",
expect_token_interval=(40, 49),
expect_char_interval=(371, 461),
expect_substring=(
"metformin pulmonary antibiotics assessment"
" hydrochloride hypertension pressure with tablet"
),
),
)
class FuzzyAlignmentCasesTest(parameterized.TestCase):
"""Planted-span oracle tests for the fuzzy aligners."""
def setUp(self):
super().setUp()
self._tokenizer = tokenizer_lib.RegexTokenizer()
self._aligner = resolver_lib.WordAligner()
resolver_lib._normalize_token.cache_clear()
@parameterized.named_parameters(*_PLANTED_POSITIVE_CASES)
def test_planted_positive(
self,
algorithm,
source,
extraction_text,
expect_token_interval,
expect_char_interval,
expect_substring,
):
"""Both algorithms agree on the planted oracle spans."""
result = _run(
source,
extraction_text,
self._tokenizer,
self._aligner,
algorithm=algorithm,
)
self.assertIsNotNone(result)
self.assertEqual(result.alignment_status, data.AlignmentStatus.MATCH_FUZZY)
self.assertEqual(
(
result.token_interval.start_index,
result.token_interval.end_index,
),
expect_token_interval,
)
self.assertEqual(
(result.char_interval.start_pos, result.char_interval.end_pos),
expect_char_interval,
)
matched = source[
result.char_interval.start_pos : result.char_interval.end_pos
]
self.assertEqual(matched, expect_substring)
@parameterized.named_parameters(
dict(testcase_name="lcs", algorithm="lcs"),
dict(testcase_name="legacy", algorithm="legacy"),
)
def test_planted_negative(self, algorithm):
"""Tokens absent from the source produce no alignment."""
result = _run(
_BASE_200,
"warfarin coumadin anticoagulant",
self._tokenizer,
self._aligner,
algorithm=algorithm,
)
self.assertIsNone(result)
class LcsBestSpanTest(parameterized.TestCase):
"""Unit tests for the pure LCS DP helper."""
@parameterized.named_parameters(
dict(
testcase_name="repeated_token_min_span",
source=["a", "a", "b"],
extraction=["a", "b"],
expect=(2, 1, 2),
),
dict(
testcase_name="single_token_reuse_forbidden",
source=["a"],
extraction=["a", "a"],
expect=(1, 0, 0),
),
dict(
testcase_name="contiguous",
source=["x", "a", "b", "c", "y"],
extraction=["a", "b", "c"],
expect=(3, 1, 3),
),
dict(
testcase_name="gapped",
source=["a", "x", "b", "y", "c"],
extraction=["a", "b", "c"],
expect=(3, 0, 4),
),
dict(
testcase_name="negative",
source=["a", "b", "c"],
extraction=["x", "y", "z"],
expect=(0, -1, -1),
),
dict(
testcase_name="tie_break_earliest_start",
source=["a", "b", "c", "a", "b", "c"],
extraction=["a", "b", "c"],
expect=(3, 0, 2),
),
dict(
testcase_name="empty_source",
source=[],
extraction=["a"],
expect=(0, -1, -1),
),
dict(
testcase_name="empty_extraction",
source=["a"],
extraction=[],
expect=(0, -1, -1),
),
)
def test_best_lcs_span(self, source, extraction, expect):
result = resolver_lib._best_lcs_span(source, extraction)
self.assertEqual((result.matches, result.start, result.end), expect)
class LcsAcceptanceGateTest(parameterized.TestCase):
"""Tests for the coverage + density acceptance gate."""
@parameterized.named_parameters(
dict(
testcase_name="perfect_coverage",
matches=3,
start=0,
end=2,
ext_len=3,
expect=True,
),
dict(
testcase_name="density_ok_on_gapped",
matches=3,
start=0,
end=8,
ext_len=3,
expect=True,
),
dict(
testcase_name="density_too_sparse",
matches=3,
start=0,
end=9,
ext_len=3,
expect=False,
),
dict(
testcase_name="coverage_below_ceil",
matches=2,
start=0,
end=2,
ext_len=3,
expect=False,
),
dict(
testcase_name="ceil_boundary",
matches=3,
start=0,
end=2,
ext_len=4,
expect=True,
),
dict(
testcase_name="zero_matches",
matches=0,
start=-1,
end=-1,
ext_len=3,
expect=False,
),
)
def test_accept_lcs_match(self, matches, start, end, ext_len, expect):
span = resolver_lib.LcsSpan(matches=matches, start=start, end=end)
self.assertEqual(resolver_lib._accept_lcs_match(span, ext_len), expect)
class LcsFuzzyAlignmentEdgeCasesTest(absltest.TestCase):
"""LCS-specific regression tests on _lcs_fuzzy_align_extraction."""
def setUp(self):
super().setUp()
self._tokenizer = tokenizer_lib.RegexTokenizer()
self._aligner = resolver_lib.WordAligner()
resolver_lib._normalize_token.cache_clear()
def test_repeated_token_selects_min_span(self):
"""With "a a b" and extraction "a b" the second "a" is chosen."""
result = _run(
"a a b",
"a b",
self._tokenizer,
self._aligner,
algorithm="lcs",
)
self.assertIsNotNone(result)
self.assertEqual(
(
result.token_interval.start_index,
result.token_interval.end_index,
),
(1, 3),
)
self.assertEqual(
(result.char_interval.start_pos, result.char_interval.end_pos),
(2, 5),
)
def test_density_gate_rejects_over_sparse(self):
"""A 3-token extraction scattered over a 10-token span fails density."""
source = (
"metformin alpha beta gamma delta epsilon zeta hydrochloride eta tablet"
)
result = _run(
source,
"metformin hydrochloride tablet",
self._tokenizer,
self._aligner,
algorithm="lcs",
)
self.assertIsNone(result)
def test_algorithm_switch_legacy_rejects_lcs_accepts(self):
source = "alpha beta gamma"
extraction_text = "alpha beta gamma delta"
lcs_result = _run(
source,
extraction_text,
self._tokenizer,
self._aligner,
algorithm="lcs",
)
self.assertIsNotNone(lcs_result)
self.assertEqual(
(
lcs_result.token_interval.start_index,
lcs_result.token_interval.end_index,
),
(0, 3),
)
legacy_result = _run(
source,
extraction_text,
self._tokenizer,
self._aligner,
algorithm="legacy",
)
self.assertIsNone(legacy_result)
def test_sparse_max_match_falls_back_to_dense_submatch(self):
"""Sparse k=m span failing density should not hide a denser k=m-1 span."""
# Source: one leading extraction token, long noise, then a dense cluster
# of the remaining extraction tokens. The 4-of-4 span is too sparse to
# pass density, but the 3-of-4 dense suffix should still be accepted.
source = (
"alpha noise noise noise noise noise noise noise noise noise noise"
" beta gamma delta"
)
result = _run(
source,
"alpha beta gamma delta",
self._tokenizer,
self._aligner,
algorithm="lcs",
)
self.assertIsNotNone(result)
self.assertEqual(
(
result.token_interval.start_index,
result.token_interval.end_index,
),
(11, 14),
)
def test_offsets_are_propagated(self):
"""Nonzero token and char offsets are added to the returned intervals."""
result = _run(
"metformin hydrochloride tablet",
"metformin hydrochloride tablet",
self._tokenizer,
self._aligner,
algorithm="lcs",
token_offset=10,
char_offset=100,
)
self.assertIsNotNone(result)
self.assertEqual(
(
result.token_interval.start_index,
result.token_interval.end_index,
),
(10, 13),
)
self.assertEqual(
(result.char_interval.start_pos, result.char_interval.end_pos),
(100, 130),
)
class PositionalCallCompatTest(absltest.TestCase):
"""Old positional call shapes must not break after adding new params."""
def test_align_extractions_positional(self):
"""align_extractions(..., threshold, accept_lesser, tokenizer) still binds correctly."""
aligner = resolver_lib.WordAligner()
extraction = data.Extraction(
extraction_class="med", extraction_text="aspirin"
)
source = "patient takes aspirin daily"
groups = aligner.align_extractions(
[[extraction]],
source,
0, # token_offset
0, # char_offset
"\u241F", # delim
True, # enable_fuzzy_alignment
0.75, # fuzzy_alignment_threshold
True, # accept_match_lesser
None, # tokenizer_impl
)
self.assertLen(groups, 1)
self.assertEqual(
groups[0][0].alignment_status,
data.AlignmentStatus.MATCH_EXACT,
)
def test_alignment_policy_positional(self):
"""AlignmentPolicy(True, 0.75, True) preserves old 3-arg shape."""
policy = prompt_validation.AlignmentPolicy(True, 0.75, True)
self.assertTrue(policy.enable_fuzzy_alignment)
self.assertEqual(policy.fuzzy_alignment_threshold, 0.75)
self.assertTrue(policy.accept_match_lesser)
self.assertEqual(policy.fuzzy_alignment_algorithm, "lcs")
def test_alignment_policy_rejects_fourth_positional(self):
"""New fields cannot be passed positionally."""
with self.assertRaises(TypeError):
prompt_validation.AlignmentPolicy(True, 0.75, True, "legacy")
class ParameterValidationTest(parameterized.TestCase):
"""Parameter validation at the alignment boundary."""
def setUp(self):
super().setUp()
self._aligner = resolver_lib.WordAligner()
self._extraction = data.Extraction(
extraction_class="med", extraction_text="aspirin"
)
@parameterized.named_parameters(
dict(
testcase_name="out_of_range_threshold",
kwargs={"fuzzy_alignment_threshold": 1.5},
),
dict(
testcase_name="out_of_range_min_density",
kwargs={"fuzzy_alignment_min_density": -0.1},
),
dict(
testcase_name="invalid_algorithm",
kwargs={"fuzzy_alignment_algorithm": "bogus"},
),
)
def test_invalid_params_raise(self, kwargs):
with self.assertRaises(ValueError):
self._aligner.align_extractions(
[[self._extraction]],
"patient takes aspirin",
**kwargs,
)
def test_disabled_fuzzy_skips_param_validation(self):
"""Bogus fuzzy params are ignored when fuzzy alignment is disabled."""
self._aligner.align_extractions(
[[self._extraction]],
"patient takes aspirin",
enable_fuzzy_alignment=False,
fuzzy_alignment_algorithm="bogus",
fuzzy_alignment_min_density=-0.1,
)
def test_disabled_fuzzy_skips_deprecation_warning(self):
"""Legacy algorithm selector is silent when fuzzy alignment is off."""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
self._aligner.align_extractions(
[[self._extraction]],
"patient takes aspirin",
enable_fuzzy_alignment=False,
fuzzy_alignment_algorithm="legacy",
)
deprecations = [
w for w in caught if issubclass(w.category, DeprecationWarning)
]
self.assertEmpty(deprecations)
def test_legacy_dispatch_warns(self):
"""Legacy algorithm emits DeprecationWarning at the dispatch site."""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
self._aligner.align_extractions(
[[self._extraction]],
"patient takes aspirin",
fuzzy_alignment_algorithm="legacy",
)
deprecations = [
w for w in caught if issubclass(w.category, DeprecationWarning)
]
self.assertLen(deprecations, 1)
self.assertIn("legacy", str(deprecations[0].message))
if __name__ == "__main__":
absltest.main()