Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/bluenamer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

from collections.abc import Iterable

from .describer import DescribedComponent, Description, describe
from .describer import DescribedComponent, Description, DescriptionTokenSummary, describe
from .engine import DEFAULT_NAMING_ENGINE, NamingEngine, NamingRequest, NamingResult
from .functional_groups import register_group_detector
from .human_descriptor import HumanDescription, describe_human
from .molecule import (
AtomBinding,
BondBinding,
Expand Down Expand Up @@ -71,7 +72,9 @@ def name_many(
"DecisionTrace",
"DescribedComponent",
"Description",
"DescriptionTokenSummary",
"FunctionalGroupMetadata",
"HumanDescription",
"NameAnalysis",
"NamingEngine",
"NamingIntent",
Expand All @@ -87,6 +90,7 @@ def name_many(
"__version__",
"analyze_smiles",
"describe",
"describe_human",
"name",
"name_many",
"name_smiles",
Expand Down
6 changes: 6 additions & 0 deletions src/bluenamer/assembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,12 @@ def post_process_name(name: str) -> str:
return _post_process_name(name)


def post_process_rewrite_rules():
"""Return shared post-processing rewrites for metadata-aware assembly paths."""

return (("post_process_name", _post_process_name),)


def assemble_name_raw(parts: AssemblyParts) -> str:
fused_ion_candidate = select_fused_ion_operation(parts)
if fused_ion_candidate is not None:
Expand Down
4 changes: 4 additions & 0 deletions src/bluenamer/assembly_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ class NameTokenBinding:
bond_ids: set[int] = field(default_factory=set)
charge_atom_ids: set[int] = field(default_factory=set)
locants: tuple[str, ...] = ()
render_order: int | None = None
match_priority: int = 0
left_context: str = ""
right_context: str = ""


@dataclass
Expand Down
5 changes: 3 additions & 2 deletions src/bluenamer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ def _cmd_batch(args: argparse.Namespace) -> int:


def _cmd_describe(args: argparse.Namespace) -> int:
description = describe_one(args.smiles)
description = describe_one(args.smiles, debugging_tokens=args.debug_tokens)
if args.json:
json.dump(description.to_dict(), sys.stdout, indent=2)
json.dump(description.to_dict(debugging_tokens=args.debug_tokens), sys.stdout, indent=2)
sys.stdout.write("\n")
else:
sys.stdout.write(str(description) + "\n")
Expand Down Expand Up @@ -133,6 +133,7 @@ def _build_parser() -> argparse.ArgumentParser:
)
p_describe.add_argument("smiles")
p_describe.add_argument("--json", action="store_true", help="emit structured Description as JSON")
p_describe.add_argument("--debug-tokens", action="store_true", help="include experimental token binding details")
p_describe.set_defaults(func=_cmd_describe)

return parser
Expand Down
32 changes: 31 additions & 1 deletion src/bluenamer/component_modifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import Literal, Protocol, overload

from .assembly_parts import AssemblyParts, SubstituentItem
from .assembly_parts import AssemblyParts, NameTokenBinding, SubstituentItem
from .formatting import strip_outer_parentheses
from .group_atom_roles import ester_or_peroxy_single_oxygen
from .locants import parse_locant
Expand Down Expand Up @@ -169,6 +169,12 @@ def add_component_n_substituents(
branch_exclude,
branch_namer,
)
emitted_tokens = _with_n_substituent_locant_token(
emitted_tokens,
loc_prefix,
single_n,
bond_ids_within(mol, {single_n, n_sub}),
)
if _use_hydrazone_suffix_modifier(parts, principal_key):
parts.principal_suffix_modifiers.append(
SubstituentItem(
Expand Down Expand Up @@ -205,6 +211,30 @@ def _charged_atoms(mol: Molecule, atom_ids: set[int]) -> set[int]:
return {atom_idx for atom_idx in atom_ids if mol.atoms[atom_idx].charge != 0}


def _with_n_substituent_locant_token(
emitted_tokens: tuple[NameTokenBinding, ...],
locant: str,
nitrogen_atom: int,
branch_bonds: set[int],
) -> tuple[NameTokenBinding, ...]:
"""Bind N-substituent locants to the principal nitrogen atom."""

locant_token = NameTokenBinding(
text=locant,
token_kind="locant",
source="n_substituent_locant",
grammar_role="n_substituent",
binding_key="prefix:n_substituent_locant",
atom_ids={nitrogen_atom},
bond_ids=set(branch_bonds),
locants=(locant,),
)
return (
locant_token,
*tuple(token for token in emitted_tokens if not (token.token_kind == "locant" and token.text == locant)),
)


def _nitrogen_substituent_name(
mol: Molecule,
nitrogen: int,
Expand Down
75 changes: 50 additions & 25 deletions src/bluenamer/component_namer.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ def collect_component_branch_substituents(
*,
name_subgraph: SubgraphNamer,
name_spiro_subgraph: SpiroSubgraphNamer,
emit_metadata: bool = True,
) -> None:
"""Collect ordinary branch and spiro substituents from the component parent."""

Expand Down Expand Up @@ -167,16 +168,26 @@ def collect_component_branch_substituents(

for n_idx in n_subs:
if n_idx not in main_set and n_idx not in principal_involved_atom_ids and n_idx not in handled_prefix_atoms:
branch_decisions = DecisionTrace()
branch_name, branch_trace, branch_tree = name_subgraph(
mol,
n_idx,
sub_exclude | main_set,
upstream_atom=c_idx,
return_trace=True,
return_tree=True,
decision_trace=branch_decisions,
)
branch_decisions = DecisionTrace() if emit_metadata else None
if emit_metadata:
branch_name, branch_trace, branch_tree = name_subgraph(
mol,
n_idx,
sub_exclude | main_set,
upstream_atom=c_idx,
return_trace=True,
return_tree=True,
decision_trace=branch_decisions,
)
else:
branch_name = name_subgraph(
mol,
n_idx,
sub_exclude | main_set,
upstream_atom=c_idx,
)
branch_trace = []
branch_tree = None
if branch_name:
branch_exclude = sub_exclude | main_set
branch_atoms = subgraph_component(mol, n_idx, branch_exclude)
Expand All @@ -187,18 +198,22 @@ def collect_component_branch_substituents(
atom_ids=branch_atoms,
bond_ids=bond_ids_within(mol, branch_atoms | {c_idx}),
charge_atom_ids=_charged_atoms(mol, branch_atoms),
emitted_tokens=graph_bound_substituent_tokens(
mol,
n_idx,
branch_atoms,
branch_name,
c_idx,
branch_exclude,
name_subgraph,
emitted_tokens=(
graph_bound_substituent_tokens(
mol,
n_idx,
branch_atoms,
branch_name,
c_idx,
branch_exclude,
name_subgraph,
)
if emit_metadata
else ()
),
trace_segments=branch_trace,
nested_decisions=decision_trace_data(branch_decisions),
substituent_tree=branch_tree,
trace_segments=branch_trace if emit_metadata else [],
nested_decisions=decision_trace_data(branch_decisions) if emit_metadata else [],
substituent_tree=branch_tree if emit_metadata else None,
)
)

Expand Down Expand Up @@ -241,9 +256,12 @@ def _shortcut_component_result(
stage: str,
role: str,
bindings: list[NameAtomBinding] | tuple[NameAtomBinding, ...] | None = None,
emit_metadata: bool = True,
) -> tuple[str, list[dict], list[dict], list[dict]]:
"""Build audited metadata for a component shortcut name."""

if not emit_metadata:
return name, [], [], []
parts = AssemblyParts(parent_length=max(1, len(component_atoms)), parent_atom_ids=set(component_atoms))
parts.name_atom_bindings = (
list(bindings)
Expand Down Expand Up @@ -280,6 +298,8 @@ def name_component(
):
"""Name one connected component or recursive component of a molecule."""

emit_metadata = return_trace or return_tree or decision_trace is not None

single_atom_name = single_atom_component_name(mol, component_atoms)
if single_atom_name:
name, bindings, token_spans, rewrite_history = _shortcut_component_result(
Expand All @@ -288,6 +308,7 @@ def name_component(
single_atom_name,
stage="shortcut",
role="single_atom_component",
emit_metadata=emit_metadata,
)
trace_decision(
decision_trace,
Expand Down Expand Up @@ -329,6 +350,7 @@ def name_component_again(next_mol: Molecule, next_atoms: set[int], is_substituen
stage="shortcut",
role=structural_parent_result.role,
bindings=structural_parent_result.bindings,
emit_metadata=emit_metadata,
)
trace_decision(
decision_trace,
Expand Down Expand Up @@ -382,6 +404,7 @@ def name_component_again(next_mol: Molecule, next_atoms: set[int], is_substituen
stage="shortcut",
role="anhydride_component",
bindings=anhydride_result.bindings,
emit_metadata=emit_metadata,
)
trace_decision(
decision_trace,
Expand Down Expand Up @@ -492,6 +515,7 @@ def name_component_again(next_mol: Molecule, next_atoms: set[int], is_substituen
state.sub_exclude,
name_subgraph=name_subgraph,
name_spiro_subgraph=name_spiro_subgraph,
emit_metadata=emit_metadata,
)
retained_fused = production_retained_fused_parent(
mol,
Expand Down Expand Up @@ -572,10 +596,11 @@ def name_component_again(next_mol: Molecule, next_atoms: set[int], is_substituen
refresh_name_atom_bindings(parts)
parts.stereo_audit_issues = list(audit_stereochemistry(mol, parts).issues)
assert_component_fully_named(mol, state.component_atoms, parts, "<component>")
name = assemble_parent_name(mol, parts, numbered_path, get_loc)
final_result = NameAssemblyResult.from_final_name(name, parts.name_atom_bindings)
parts.name_token_spans = token_span_trace_data(final_result)
assert_final_name_assembly(mol, state.component_atoms, parts, final_result)
name = assemble_parent_name(mol, parts, numbered_path, get_loc, emit_metadata=emit_metadata)
if emit_metadata:
final_result = NameAssemblyResult.from_final_name(name, parts.name_atom_bindings)
parts.name_token_spans = token_span_trace_data(final_result)
assert_final_name_assembly(mol, state.component_atoms, parts, final_result)
trace_decision(
decision_trace,
TracePhase.ASSEMBLY,
Expand Down
Loading
Loading