Skip to content

Commit b9961ca

Browse files
authored
Merge pull request #16 from lamalab-org/dev
fix: stereo descriptor atom binding
2 parents 00258ed + cfdded4 commit b9961ca

9 files changed

Lines changed: 385 additions & 19 deletions

src/bluenamer/assembly_parts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class AssemblyParts:
111111
parent_atom_symbols_by_locant: dict[str, str] = field(default_factory=dict)
112112
parent_atom_charges_by_locant: dict[str, int] = field(default_factory=dict)
113113
parent_bond_orders_by_locants: dict[tuple[str, str], int] = field(default_factory=dict)
114+
parent_bond_ids_by_locants: dict[tuple[str, str], int] = field(default_factory=dict)
114115
name_atom_bindings: list[NameAtomBinding] = field(default_factory=list)
115116
name_token_spans: list[dict] = field(default_factory=list)
116117
name_rewrite_history: list[dict] = field(default_factory=list)

src/bluenamer/component_modifiers.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .nomenclature import RULES
1111
from .perception import PerceivedGroup
1212
from .subgraph_tools import subgraph_component
13+
from .substituent_tokens import graph_bound_substituent_tokens
1314
from .trace_helpers import add_substituent_trace, bond_ids_within, decision_trace_data
1415

1516

@@ -156,8 +157,18 @@ def add_component_n_substituents(
156157
mol, single_n, n_sub, sub_exclude, branch_namer, branch_decisions
157158
)
158159
if branch_name:
159-
branch_atoms = subgraph_component(mol, n_sub, sub_exclude | {single_n})
160+
branch_exclude = sub_exclude | {single_n}
161+
branch_atoms = subgraph_component(mol, n_sub, branch_exclude)
160162
nested_decisions = decision_trace_data(branch_decisions)
163+
emitted_tokens = graph_bound_substituent_tokens(
164+
mol,
165+
n_sub,
166+
branch_atoms,
167+
branch_name,
168+
single_n,
169+
branch_exclude,
170+
branch_namer,
171+
)
161172
if _use_hydrazone_suffix_modifier(parts, principal_key):
162173
parts.principal_suffix_modifiers.append(
163174
SubstituentItem(
@@ -166,6 +177,7 @@ def add_component_n_substituents(
166177
atom_ids=branch_atoms,
167178
bond_ids=bond_ids_within(mol, branch_atoms | {single_n}),
168179
charge_atom_ids=_charged_atoms(mol, branch_atoms),
180+
emitted_tokens=emitted_tokens,
169181
trace_segments=branch_trace,
170182
nested_decisions=nested_decisions,
171183
substituent_tree=branch_tree,
@@ -181,6 +193,7 @@ def add_component_n_substituents(
181193
_charged_atoms(mol, branch_atoms),
182194
branch_trace,
183195
nested_decisions,
196+
emitted_tokens,
184197
substituent_tree=branch_tree,
185198
)
186199
n_idx_global += 1

src/bluenamer/name_assembly.py

Lines changed: 54 additions & 4 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)
@@ -922,14 +923,17 @@ def _native_token_spans(
922923
if binding.stage == "hydro" and binding.role == "indicated_hydrogen":
923924
spans.extend(_indicated_hydrogen_native_token_spans(search_text, binding_idx, binding))
924925
continue
926+
if binding.stage == "assembly" and binding.role in {"absolute_stereo", "bond_stereo"}:
927+
spans.extend(_absolute_stereo_native_token_spans(search_text, binding_idx, binding))
928+
continue
925929
if not any(_is_locant_search_token(token_binding) for token_binding in binding.emitted_tokens):
926930
ordered_spans = _ordered_native_token_spans(text, search_text, binding_idx, binding)
927931
if ordered_spans:
928932
spans.extend(ordered_spans)
929933
continue
930934
for token_binding in binding.emitted_tokens:
931935
token = token_binding.text.strip().lower()
932-
if not _native_token_is_searchable(token):
936+
if not _native_token_is_searchable(token, token_binding):
933937
continue
934938
pos = 0
935939
for found in _native_token_occurrences(text, search_text, token_binding, pos):
@@ -961,6 +965,30 @@ def _indicated_hydrogen_native_token_spans(
961965
return spans
962966

963967

968+
def _absolute_stereo_native_token_spans(
969+
search_text: str,
970+
binding_idx: int,
971+
binding: NameAtomBinding,
972+
) -> list[tuple[int, int, int, NameTokenBinding]]:
973+
locant_token = next((token for token in binding.emitted_tokens if token.token_kind == "locant"), None)
974+
stereo_token = next((token for token in binding.emitted_tokens if token.token_kind == "stereo"), None)
975+
if locant_token is None or stereo_token is None:
976+
return []
977+
locant_text = locant_token.text.lower()
978+
stereo_text = stereo_token.text.lower()
979+
pattern = f"{locant_text}{stereo_text}"
980+
spans: list[tuple[int, int, int, NameTokenBinding]] = []
981+
pos = 0
982+
while True:
983+
found = search_text.find(pattern, pos)
984+
if found < 0:
985+
break
986+
spans.append((found, found + len(locant_text), binding_idx, locant_token))
987+
spans.append((found + len(locant_text), found + len(pattern), binding_idx, stereo_token))
988+
pos = found + 1
989+
return spans
990+
991+
964992
def _ordered_native_token_spans(
965993
text: str,
966994
search_text: str,
@@ -973,7 +1001,7 @@ def _ordered_native_token_spans(
9731001
cursor = 0
9741002
for token_binding in binding.emitted_tokens:
9751003
token = token_binding.text.strip().lower()
976-
if not _native_token_is_searchable(token):
1004+
if not _native_token_is_searchable(token, token_binding):
9771005
continue
9781006
found = next(iter(_native_token_occurrences(text, search_text, token_binding, cursor)), -1)
9791007
if found < 0:
@@ -1020,8 +1048,12 @@ def _is_standalone_locant_span(text: str, start: int, end: int) -> bool:
10201048
return (not before or before in "-,( ") and (not after or after in "-,)' ")
10211049

10221050

1023-
def _native_token_is_searchable(token: str) -> bool:
1024-
return bool(token) and (len(token) >= 2 or token.isdigit() or "," in token or token in _ELEMENT_LOCANT_TOKENS)
1051+
def _native_token_is_searchable(token: str, token_binding: NameTokenBinding | None = None) -> bool:
1052+
if not token:
1053+
return False
1054+
if token_binding is not None and token_binding.token_kind == "stereo":
1055+
return is_searchable_stereo_token(token)
1056+
return len(token) >= 2 or token.isdigit() or "," in token or token in _ELEMENT_LOCANT_TOKENS
10251057

10261058

10271059
def _direct_binding_spans(text: str, bindings: tuple[NameAtomBinding, ...]) -> list[tuple[int, int, int]]:
@@ -1684,6 +1716,13 @@ def _token_span_from_native_binding_group(
16841716
end: int,
16851717
matches: list[tuple[int, NameTokenBinding]],
16861718
) -> NameTokenSpan:
1719+
stereo_matches = [
1720+
(binding_idx, token_binding)
1721+
for binding_idx, token_binding in matches
1722+
if token_binding.source == "renderer_stereo"
1723+
]
1724+
if stereo_matches and _should_prioritize_renderer_stereo(text, stereo_matches):
1725+
matches = stereo_matches
16871726
atoms: set[int] = set()
16881727
bonds: set[int] = set()
16891728
charges: set[int] = set()
@@ -1727,6 +1766,17 @@ def _token_span_from_native_binding_group(
17271766
)
17281767

17291768

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+
for _binding_idx, token_binding in matches:
1773+
if token_binding.token_kind == "stereo" and is_searchable_stereo_token(token_binding.text):
1774+
return True
1775+
if token_binding.token_kind == "locant" and token_binding.grammar_role in {"absolute_stereo", "bond_stereo"}:
1776+
return True
1777+
return False
1778+
1779+
17301780
def _collapse_metadata(values: list[str], *, default: str) -> str:
17311781
values = [value for value in values if value]
17321782
if not values:

src/bluenamer/name_bindings.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@
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]:
1314
"""Populate structured bindings for the current assembly parts."""
1415

16+
preserved_assembly_stereo = [
17+
binding
18+
for binding in parts.name_atom_bindings
19+
if binding.stage == "assembly" and binding.role in {"relative_stereo", "small_ring_stereo"}
20+
]
1521
bindings: list[NameAtomBinding] = []
1622
if parts.parent_atom_ids:
1723
bindings.append(
@@ -37,6 +43,40 @@ def refresh_name_atom_bindings(parts: AssemblyParts) -> list[NameAtomBinding]:
3743
emitted_tokens=_hydro_operation_tokens(operation),
3844
)
3945
)
46+
for locant, descriptor in parts.stereo_features:
47+
if descriptor not in ABSOLUTE_STEREO_DESCRIPTORS or not locant:
48+
continue
49+
atom_idx = parts.parent_atom_ids_by_locant.get(str(locant))
50+
if atom_idx is None:
51+
continue
52+
bindings.append(
53+
NameAtomBinding(
54+
stage="assembly",
55+
role="absolute_stereo",
56+
term=f"{locant}{descriptor}",
57+
atom_ids={atom_idx},
58+
locants=(str(locant),),
59+
emitted_tokens=_absolute_stereo_tokens(str(locant), descriptor, atom_idx),
60+
)
61+
)
62+
for locant, descriptor in parts.stereo_features:
63+
if descriptor not in BOND_STEREO_DESCRIPTORS or not locant:
64+
continue
65+
_bond_locants, atom_ids, bond_ids = _bond_stereo_graph_scope(parts, str(locant))
66+
if not atom_ids:
67+
continue
68+
bindings.append(
69+
NameAtomBinding(
70+
stage="assembly",
71+
role="bond_stereo",
72+
term=descriptor,
73+
atom_ids=atom_ids,
74+
bond_ids=bond_ids,
75+
locants=(str(locant),),
76+
emitted_tokens=_bond_stereo_tokens(str(locant), descriptor, atom_ids, bond_ids),
77+
)
78+
)
79+
bindings.extend(preserved_assembly_stereo)
4080
if parts.front_modifiers:
4181
bindings.append(
4282
NameAtomBinding(
@@ -328,6 +368,8 @@ def _operation_emitted_tokens(binding: NameAtomBinding) -> tuple[NameTokenBindin
328368
return _locanted_operation_tokens(binding, charge_from_binding=True)
329369
if binding.stage == "charge" or binding.role == "parent_charge":
330370
return _charge_operation_tokens(binding)
371+
if binding.stage == "assembly" and binding.role == "relative_stereo":
372+
return _relative_stereo_tokens(binding)
331373
if binding.stage == "assembly" and "stereo" in binding.role:
332374
return _locanted_operation_tokens(binding)
333375
if binding.stage == "modifier":
@@ -430,6 +472,114 @@ def _hydro_operation_tokens(operation) -> tuple[NameTokenBinding, ...]:
430472
return tuple(tokens)
431473

432474

475+
def _absolute_stereo_tokens(locant: str, descriptor: str, atom_idx: int) -> tuple[NameTokenBinding, ...]:
476+
"""Return native tokens for an absolute stereochemical descriptor."""
477+
478+
return (
479+
NameTokenBinding(
480+
text=locant,
481+
token_kind="locant",
482+
ownership="exact",
483+
confidence="exact",
484+
source="renderer_stereo",
485+
grammar_role="absolute_stereo",
486+
binding_key="assembly:absolute_stereo",
487+
atom_ids={atom_idx},
488+
locants=(locant,),
489+
),
490+
NameTokenBinding(
491+
text=descriptor,
492+
token_kind="stereo",
493+
ownership="exact",
494+
confidence="exact",
495+
source="renderer_stereo",
496+
grammar_role="absolute_stereo",
497+
binding_key="assembly:absolute_stereo",
498+
atom_ids={atom_idx},
499+
locants=(locant,),
500+
),
501+
)
502+
503+
504+
def _bond_stereo_graph_scope(parts: AssemblyParts, locant: str) -> tuple[tuple[str, ...], set[int], set[int]]:
505+
"""Return the graph scope for an E/Z descriptor rendered at a parent locant."""
506+
507+
for locant_pair, order in parts.parent_bond_orders_by_locants.items():
508+
if locant not in locant_pair or order != 2:
509+
continue
510+
atom_ids = {
511+
atom_idx
512+
for pair_locant in locant_pair
513+
if (atom_idx := parts.parent_atom_ids_by_locant.get(str(pair_locant))) is not None
514+
}
515+
if len(atom_ids) != 2:
516+
continue
517+
bond_id = parts.parent_bond_ids_by_locants.get(locant_pair)
518+
return (
519+
tuple(str(pair_locant) for pair_locant in locant_pair),
520+
atom_ids,
521+
{bond_id} if bond_id is not None else set(),
522+
)
523+
atom_idx = parts.parent_atom_ids_by_locant.get(locant)
524+
return (locant,), {atom_idx} if atom_idx is not None else set(), set()
525+
526+
527+
def _bond_stereo_tokens(
528+
locant: str,
529+
descriptor: str,
530+
atom_ids: set[int],
531+
bond_ids: set[int],
532+
) -> tuple[NameTokenBinding, ...]:
533+
"""Return native tokens for an E/Z bond stereochemical descriptor."""
534+
535+
return (
536+
NameTokenBinding(
537+
text=locant,
538+
token_kind="locant",
539+
ownership="exact",
540+
confidence="exact",
541+
source="renderer_stereo",
542+
grammar_role="bond_stereo",
543+
binding_key="assembly:bond_stereo",
544+
atom_ids=atom_ids,
545+
bond_ids=bond_ids,
546+
locants=(locant,),
547+
),
548+
NameTokenBinding(
549+
text=descriptor,
550+
token_kind="stereo",
551+
ownership="exact",
552+
confidence="exact",
553+
source="renderer_stereo",
554+
grammar_role="bond_stereo",
555+
binding_key="assembly:bond_stereo",
556+
atom_ids=atom_ids,
557+
bond_ids=bond_ids,
558+
locants=(locant,),
559+
),
560+
)
561+
562+
563+
def _relative_stereo_tokens(binding: NameAtomBinding) -> tuple[NameTokenBinding, ...]:
564+
"""Return native token metadata for relative cis/trans descriptors."""
565+
566+
return (
567+
NameTokenBinding(
568+
text=binding.term,
569+
token_kind="stereo",
570+
ownership="exact",
571+
confidence="exact",
572+
source="renderer_stereo",
573+
grammar_role=binding.role,
574+
binding_key=f"{binding.stage}:{binding.role}",
575+
atom_ids=set(binding.atom_ids),
576+
bond_ids=set(binding.bond_ids),
577+
charge_atom_ids=set(binding.charge_atom_ids),
578+
locants=tuple(binding.locants),
579+
),
580+
)
581+
582+
433583
def _locanted_emitted_tokens(
434584
emitted_tokens: tuple[NameTokenBinding, ...],
435585
locants: tuple[str, ...],

src/bluenamer/parent_pipeline.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ def build_parent_parts(
144144
neighbor_locant = str(get_loc(neighbor_idx))
145145
bond = mol.get_bond(atom_idx, neighbor_idx)
146146
if bond is not None:
147-
parts.parent_bond_orders_by_locants[tuple(sorted((locant, neighbor_locant)))] = bond.order
147+
locant_pair = tuple(sorted((locant, neighbor_locant)))
148+
parts.parent_bond_orders_by_locants[locant_pair] = bond.order
149+
parts.parent_bond_ids_by_locants[locant_pair] = bond.idx
148150
return parts
149151

150152

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

0 commit comments

Comments
 (0)