Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,32 @@ curl -X POST localhost:8000/describe -H 'content-type: application/json' \

OpenAPI docs are served at `http://localhost:8000/docs`.


## Human-like description

OpenBlue can generate uncanny human-like description.
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
```python

from bluenamer import describe_human

d = describe_human("CN1C=NC2=C1C(=O)N(C(=O)N2C)C")
print(d.text)

""" Processed SMILES: Cn1cnc2c1c(=O)n(C)c(=O)n2C
Atom ids in that SMILES: C{0}n{1}1c{2}n{3}c{4}2c{5}1c{6}(=O{7})n{8}(C{13})c{9}(=O{10})n{11}2C{12}

The molecule is named 2,4,7-trimethyl-2,4,7,9-tetraazabicyclo[4.3.0]nona-1(6),8-diene-3,5-dione.

The molecule is built around a 9-membered bicyclic [4.3.0] heteroskeleton.
Within that parent framework, there is nitrogen at positions 2 (atom id 11), 4 (atom id 8), 7 (atom id 1), and 9 (atom id 3).
Within that parent framework, there is a double bond between position 1 (atom id 4) and position 6 (atom id 5) and a double bond between position 8 (atom id 2) and position 9 (atom id 3).
The principal characteristic feature is oxo groups at positions 3 (atom id 9) and 5 (atom id 6).
Attached to this framework are methyl groups at positions 2 (atom id 11), 4 (atom id 8), and 7 (atom id 1). """

```



## Debugging

Token binding metadata is currently available through the assembly decision trace when `include_trace=True`.
Expand Down
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