Skip to content

Commit 366ef18

Browse files
committed
fix: Precomputed carbon substituent bond/charge scopes in _carbon_substituent_tokens.
Added shared stereo_descriptors.py for R/S, E/Z, cis/trans token sets. Replaced local hardcoded stereo sets with the shared constants. Narrowed renderer-stereo priority so it only wins for stereo tokens and compact stereo locant tokens. Removed redundant set copies in _bond_stereo_tokens. Made test_absolute_stereo_tokens_bind_to_stereocenter_atoms order-independent.
1 parent effc569 commit 366ef18

5 files changed

Lines changed: 69 additions & 22 deletions

File tree

src/bluenamer/name_assembly.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .assembly_parts import AssemblyParts, NameAtomBinding, NameTokenBinding
1010
from .molecule import Molecule
1111
from .name_bindings import ensure_name_atom_binding_tokens, postprocess_name_atom_bindings
12+
from .stereo_descriptors import is_searchable_stereo_token
1213

1314

1415
@dataclass(frozen=True)
@@ -1051,7 +1052,7 @@ def _native_token_is_searchable(token: str, token_binding: NameTokenBinding | No
10511052
if not token:
10521053
return False
10531054
if token_binding is not None and token_binding.token_kind == "stereo":
1054-
return token in {"r", "s", "e", "z", "cis", "trans"}
1055+
return is_searchable_stereo_token(token)
10551056
return len(token) >= 2 or token.isdigit() or "," in token or token in _ELEMENT_LOCANT_TOKENS
10561057

10571058

@@ -1720,7 +1721,7 @@ def _token_span_from_native_binding_group(
17201721
for binding_idx, token_binding in matches
17211722
if token_binding.source == "renderer_stereo"
17221723
]
1723-
if stereo_matches:
1724+
if stereo_matches and _should_prioritize_renderer_stereo(text, stereo_matches):
17241725
matches = stereo_matches
17251726
atoms: set[int] = set()
17261727
bonds: set[int] = set()
@@ -1765,6 +1766,18 @@ def _token_span_from_native_binding_group(
17651766
)
17661767

17671768

1769+
def _should_prioritize_renderer_stereo(text: str, matches: list[tuple[int, NameTokenBinding]]) -> bool:
1770+
"""Return whether a span is owned by an explicit stereo-renderer token."""
1771+
1772+
token_text = text.lower()
1773+
for _binding_idx, token_binding in matches:
1774+
if token_binding.token_kind == "stereo" and is_searchable_stereo_token(token_text):
1775+
return True
1776+
if token_binding.token_kind == "locant" and token_binding.grammar_role in {"absolute_stereo", "bond_stereo"}:
1777+
return True
1778+
return False
1779+
1780+
17681781
def _collapse_metadata(values: list[str], *, default: str) -> str:
17691782
values = [value for value in values if value]
17701783
if not values:

src/bluenamer/name_bindings.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .nomenclature import RULES
88
from .principal_suffixes import render_principal_suffix
99
from .rules import bonds, stems
10+
from .stereo_descriptors import ABSOLUTE_STEREO_DESCRIPTORS, BOND_STEREO_DESCRIPTORS
1011

1112

1213
def refresh_name_atom_bindings(parts: AssemblyParts) -> list[NameAtomBinding]:
@@ -43,7 +44,7 @@ def refresh_name_atom_bindings(parts: AssemblyParts) -> list[NameAtomBinding]:
4344
)
4445
)
4546
for locant, descriptor in parts.stereo_features:
46-
if descriptor not in {"R", "S"} or not locant:
47+
if descriptor not in ABSOLUTE_STEREO_DESCRIPTORS or not locant:
4748
continue
4849
atom_idx = parts.parent_atom_ids_by_locant.get(str(locant))
4950
if atom_idx is None:
@@ -59,7 +60,7 @@ def refresh_name_atom_bindings(parts: AssemblyParts) -> list[NameAtomBinding]:
5960
)
6061
)
6162
for locant, descriptor in parts.stereo_features:
62-
if descriptor not in {"E", "Z"} or not locant:
63+
if descriptor not in BOND_STEREO_DESCRIPTORS or not locant:
6364
continue
6465
_bond_locants, atom_ids, bond_ids = _bond_stereo_graph_scope(parts, str(locant))
6566
if not atom_ids:
@@ -69,10 +70,10 @@ def refresh_name_atom_bindings(parts: AssemblyParts) -> list[NameAtomBinding]:
6970
stage="assembly",
7071
role="bond_stereo",
7172
term=descriptor,
72-
atom_ids=set(atom_ids),
73-
bond_ids=set(bond_ids),
73+
atom_ids=atom_ids,
74+
bond_ids=bond_ids,
7475
locants=(str(locant),),
75-
emitted_tokens=_bond_stereo_tokens(str(locant), descriptor, set(atom_ids), set(bond_ids)),
76+
emitted_tokens=_bond_stereo_tokens(str(locant), descriptor, atom_ids, bond_ids),
7677
)
7778
)
7879
bindings.extend(preserved_assembly_stereo)
@@ -540,8 +541,8 @@ def _bond_stereo_tokens(
540541
source="renderer_stereo",
541542
grammar_role="bond_stereo",
542543
binding_key="assembly:bond_stereo",
543-
atom_ids=set(atom_ids),
544-
bond_ids=set(bond_ids),
544+
atom_ids=atom_ids,
545+
bond_ids=bond_ids,
545546
locants=(locant,),
546547
),
547548
NameTokenBinding(
@@ -552,8 +553,8 @@ def _bond_stereo_tokens(
552553
source="renderer_stereo",
553554
grammar_role="bond_stereo",
554555
binding_key="assembly:bond_stereo",
555-
atom_ids=set(atom_ids),
556-
bond_ids=set(bond_ids),
556+
atom_ids=atom_ids,
557+
bond_ids=bond_ids,
557558
locants=(locant,),
558559
),
559560
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Shared stereochemical descriptor tokens used by renderers and metadata."""
2+
3+
ABSOLUTE_STEREO_DESCRIPTORS = frozenset({"R", "S"})
4+
BOND_STEREO_DESCRIPTORS = frozenset({"E", "Z"})
5+
RELATIVE_STEREO_DESCRIPTORS = frozenset({"cis", "trans"})
6+
7+
SEARCHABLE_STEREO_TOKENS = frozenset(
8+
descriptor.lower()
9+
for descriptor in (
10+
*ABSOLUTE_STEREO_DESCRIPTORS,
11+
*BOND_STEREO_DESCRIPTORS,
12+
*RELATIVE_STEREO_DESCRIPTORS,
13+
)
14+
)
15+
16+
17+
def is_searchable_stereo_token(text: str) -> bool:
18+
"""Return whether a renderer-emitted stereo token may be matched directly."""
19+
20+
return text.lower() in SEARCHABLE_STEREO_TOKENS

src/bluenamer/substituent_tokens.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .assembly_parts import NameTokenBinding
77
from .formatting import strip_outer_parentheses
88
from .molecule import Molecule
9+
from .stereo_descriptors import ABSOLUTE_STEREO_DESCRIPTORS, RELATIVE_STEREO_DESCRIPTORS
910
from .trace_helpers import bond_ids_within
1011

1112
BranchNamer = Callable[..., str]
@@ -36,9 +37,12 @@ def _carbon_substituent_tokens(mol: Molecule, atom_ids: set[int], term: str) ->
3637
if not term_text:
3738
return ()
3839
stereo_atoms = {idx for idx in atom_ids if mol.atoms[idx].raw_stereo and not mol.atoms[idx].stereo}
40+
substituent_bond_ids = bond_ids_within(mol, atom_ids)
41+
substituent_charge_atom_ids = {idx for idx in atom_ids if mol.atoms[idx].charge != 0}
42+
stereo_bond_ids = bond_ids_within(mol, stereo_atoms) if len(stereo_atoms) >= 2 else set()
3943
tokens = []
4044
for token_text in _lexical_tokens(term_text):
41-
if token_text.lower() in {"cis", "trans"} and len(stereo_atoms) >= 2:
45+
if token_text.lower() in RELATIVE_STEREO_DESCRIPTORS and len(stereo_atoms) >= 2:
4246
tokens.append(
4347
NameTokenBinding(
4448
text=token_text,
@@ -49,7 +53,7 @@ def _carbon_substituent_tokens(mol: Molecule, atom_ids: set[int], term: str) ->
4953
grammar_role="relative_stereo",
5054
binding_key="prefix:relative_stereo",
5155
atom_ids=set(stereo_atoms),
52-
bond_ids=bond_ids_within(mol, stereo_atoms),
56+
bond_ids=set(stereo_bond_ids),
5357
)
5458
)
5559
continue
@@ -61,8 +65,8 @@ def _carbon_substituent_tokens(mol: Molecule, atom_ids: set[int], term: str) ->
6165
grammar_role="carbon_substituent",
6266
binding_key="prefix:carbon_substituent",
6367
atom_ids=set(atom_ids),
64-
bond_ids=bond_ids_within(mol, atom_ids),
65-
charge_atom_ids={idx for idx in atom_ids if mol.atoms[idx].charge != 0},
68+
bond_ids=set(substituent_bond_ids),
69+
charge_atom_ids=set(substituent_charge_atom_ids),
6670
)
6771
)
6872
return tuple(tokens)
@@ -72,7 +76,7 @@ def _embedded_absolute_stereo_tokens(mol: Molecule, center: int, term_text: str)
7276
"""Return graph-bound tokens for directly rendered non-parent R/S descriptors."""
7377

7478
descriptor = mol.atoms[center].stereo
75-
if descriptor not in {"R", "S"}:
79+
if descriptor not in ABSOLUTE_STEREO_DESCRIPTORS:
7680
return ()
7781
if not re.search(rf"\({re.escape(descriptor)}\)-", term_text):
7882
return ()

src/bluenamer/tests/test_public_api.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,22 @@ def test_absolute_stereo_tokens_bind_to_stereocenter_atoms():
114114
assembly = [step for step in result.decisions if step.decision == "assembled component name"][-1]
115115
token_spans = assembly.data["name_token_spans"]
116116

117-
assert [(token["text"], token["atoms"], token["token_kind"], token["source"]) for token in token_spans[:4]] == [
118-
("3", [4], "locant", "renderer_stereo"),
119-
("R", [4], "stereo", "renderer_stereo"),
120-
("5", [7], "locant", "renderer_stereo"),
121-
("R", [7], "stereo", "renderer_stereo"),
117+
expected_tokens = [
118+
("3", 4, "locant", "renderer_stereo"),
119+
("R", 4, "stereo", "renderer_stereo"),
120+
("5", 7, "locant", "renderer_stereo"),
121+
("R", 7, "stereo", "renderer_stereo"),
122122
]
123-
assert all(token["confidence"] != "fallback" for token in token_spans[:4])
123+
for text, atom, token_kind, source in expected_tokens:
124+
token = next(
125+
token
126+
for token in token_spans
127+
if token["text"] == text
128+
and token["atoms"] == [atom]
129+
and token["token_kind"] == token_kind
130+
and token["source"] == source
131+
)
132+
assert token["confidence"] != "fallback"
124133

125134

126135
def test_naming_errors_become_result_error_not_exception():

0 commit comments

Comments
 (0)