Add graph-audited name assembly, retained fused parent/ion support, and expanAded role-aware nomenclature coverage - #10
Conversation
Adds three layers of testing on top of the existing round-trip suite.
## Layer 1 — hand-curated diverse corpus (default run)
`tests/fixtures/diverse_corpus.csv` ships ~100 SMILES across alkanes,
alkenes, alkynes, aromatics, heterocycles, alcohols, ketones, acids,
esters, amines/amides, sugars, fatty acids and multi-functional cases.
`tests/integration/test_diverse_corpus.py` asserts:
- `name(smiles)` must never raise — errors must surface as
`result.error`, not exceptions. Concrete regression guard for the
no-raise contract introduced in PR2.
- Naming pass rate (`name != ""`) stays above 95%. Drops surface the
offending SMILES + category in the failure message.
- OPSIN round-trip match rate is *measured and printed* (not asserted)
whenever py2opsin + Java are available, with a sanity floor of
"at least one match" to detect total pipeline breakage.
## Layer 2 — hypothesis fuzz tests (default run, fuzz marker)
`tests/fuzz/test_smiles_strategies.py` defines small SMILES
sub-grammar strategies (linear/branched alkanes, haloalkanes,
functionalised chains, monocycles, substituted benzenes) and asserts
engine invariants over 200 random examples each:
- Never raises on grammar-valid SMILES (rdkit-parseable pre-filter).
- Deterministic across repeated calls (name/error/rules_hit stable).
- Single and batch calls agree on per-row output.
- `name_many` preserves input order.
OPSIN round-trip is not asserted in fuzz — the namer doesn't yet
round-trip arbitrary structures, and the curated corpus carries the
roundtrip metric instead.
## Layer 3 — opt-in HF Datasets pulls (dataset + slow markers)
`tests/datasets/test_pubchem_sample.py` and `test_qm9_sample.py` pull
~200 SMILES from public mirrors and report naming + OPSIN match rates.
Both call `pytest.importorskip("datasets")` so they don't break
collection without the `[datasets]` extra, and skip cleanly when no
mirror is reachable. Marked `dataset`/`slow`; excluded from the default
`pytest -m "not slow and not dataset"` CI run.
Tunable via env: `BLUENAMER_DATASET_SAMPLE_N`, `BLUENAMER_DATASET_SEED`,
`BLUENAMER_QM9_DATASET`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## describe(smiles) → Description
New `bluenamer.describer.describe(smiles)` walks the existing
`DecisionTrace` + `trace_segments` and renders a deterministic,
multi-paragraph natural-language explanation of how the name was built:
parse stats, perception, principal-group priority, parent selection,
numbering, assembly, the name pieces each trace segment contributed,
and the Blue Book rules invoked.
Deterministic by construction — no LLM in the loop. Useful for
explainability views and for generating (SMILES, name, description)
training tuples.
CLI subcommand:
bluenamer describe "CC(=O)Nc1ccccc1"
bluenamer describe "CC(=O)Nc1ccccc1" --json
## Output types: super easy to use
NamingResult, OpsinCheck and Description now all support the obvious
Python protocols, so callers rarely need to reach for accessors:
result = name("CCO")
str(result) # 'ethanol'
bool(result) # True
repr(result) # NamingResult(name='ethanol', smiles='CCO', ok=True)
result.to_dict() # JSON-friendly dict, ready for dataset rows
check = result.opsin_check # may be None
if check: # bool == matched
...
str(check) # 'matched' | 'mismatched' | ...
check.to_dict() # serialisable
d = describe("CCO")
str(d) # full prose
d.summary # first paragraph
d.to_dict() # serialisable
The CLI is collapsed onto these — `result.to_dict(include_trace=...)`
replaces the previous ad-hoc serializer.
## Cleanups
- Drop the `describe_smiles(s)` wrapper (callers use `str(describe(s))`).
- Drop a dead regex (`_NAME_TERM_HINT`) in describer.
- Drop the `summary` field on `Description` in favour of a property.
- Simplify `_render_assembly` branches and tighten `_trace_segment_lines`.
- Inline the duplicated CLI-to-JSON helper.
## Tests
`tests/integration/test_describer.py` — 8 tests covering the rendered
output shape, the empty-name fallback, determinism, the CLI plain and
JSON modes. Public API tests get a `test_naming_result_is_easy_to_use`
case exercising str/bool/repr/to_dict.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## HTTP service (`bluenamer.web`)
New subpackage gated behind the `[web]` extra (importing it without
fastapi installed raises a clear hint). FastAPI app with four routes:
- `GET /healthz` → liveness + version
- `POST /name` → `NamingResult.to_dict()`
- `POST /batch` → list[NamingResult.to_dict()], preserves order
- `POST /describe` → `Description.to_dict()`
Pydantic v2 request models, plain dict responses (callers see exactly
the same shape they get from the Python API's `.to_dict()` — no extra
schema to learn).
Run locally:
uvicorn bluenamer.web.app:app --host 0.0.0.0 --port 8000
# or
python -m bluenamer.web --host 0.0.0.0 --port 8000
## Container image
Multi-stage `Dockerfile`:
- builder: python:3.12-slim, builds against rdkit/fastapi/py2opsin
manylinux wheels into /install
- runtime: python:3.12-slim + openjdk-17-jre-headless (so
`verify_opsin=True` works inside the container), copies /install,
runs `python -m bluenamer.web` as a non-root user, exposes 8000
Includes a `HEALTHCHECK` and a `.dockerignore` to keep build context
small. `docker/compose.yaml` for the common local-dev path.
## CI
New `docker` job in `.github/workflows/ci.yml`: builds the image with
buildx, runs it, polls `/healthz` for 60s, fails with container logs if
it doesn't come up. Gated behind the existing `lint` job.
## Tests
`tests/integration/test_web_app.py` — 7 tests on a FastAPI TestClient:
healthz, name (plain + with trace), batch (order preserved), describe,
422 on bad payload, no-raise contract on bad SMILES. Skipped cleanly
when the `[web]` extra is not installed. `[dev]` now pulls fastapi +
httpx so CI runs them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
python:3.12-slim is now Debian Trixie-based, where openjdk-17-jre-headless is no longer available (only openjdk-21-*). Switch to default-jre-headless so we follow whatever JRE the base image ships and survive future base-image moves. OPSIN only needs Java >=8 so the version bump is safe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Fuzz roundtrip property The hypothesis suite now asserts the strictest correctness invariant the namer has: an OPSIN-parseable name must canonicalise back to the input SMILES. ``test_opsin_roundtrip_never_silently_mismatches`` asserts against ``status == "mismatched"`` specifically — the other non-matched statuses (`name_unparseable`, empty name) are acceptable as namer incompleteness; a *mismatched* round-trip is always a bug because it means we produced a name OPSIN parses to a *different* molecule. Runs 40 examples per session (JVM startup dominates per-call cost), gated by ``opsin_available()`` so it skips cleanly when py2opsin or Java are missing. CI installs both via the [dev] extra, so this runs on every PR. ## opsin_available() — public helper Promoted from two private helpers to a single public predicate so test code (and downstream users) can branch on capability without reaching into ``_try_import_py2opsin`` / ``_java_available``. Exported at top level: ``from bluenamer import opsin_available``. ## Dataset tests — one HF dataset, no plumbing QM9: dropped the candidate-list loop and the `BLUENAMER_QM9_DATASET` override. Single source of truth (`yairschiff/qm9`). PubChem stays on `jablonkagroup/pubchem-smiles-molecular-formula`. Both files lose ~30 lines: the fixture, the naming-rate test, and the opsin-match-rate test are now ~5 lines each. The opsin test counts statuses into a single dict and skips when the env doesn't have a JRE, instead of running the namer twice and tracking five separate counters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous 10% floor was a "did we totally break things" tripwire; it cannot detect coverage regressions. PubChem is the headline target for this namer, so anything below 90% is treated as a regression that needs investigation, not just rate drift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the pipeline-narration describer with a structure-driven one adapted from an earlier prototype at 0e53b772d1. The previous output narrated the namer's internal phases ("Perception identified amide ...", "Parent selection chose a 2-atom acyclic chain ..."). Useful for debugging the namer, but not what a chemist actually wants. The new output describes the *structure* in chemistry vocabulary: The molecule is built around a 2-atom carbon chain. Within that parent framework, all parent-chain bonds are single. Attached to the parent is a hydroxy group at position 1. For reconstruction, number the parent as follows: 1-2 single. — deterministic graph facts a reader could use to redraw the molecule. ## API surface - ``describe(smiles) -> Description`` — unchanged signature. - ``Description`` now exposes ``text``, ``name`` (handy header from the namer) and ``facts: tuple[DescriptionFacts, ...]`` instead of the old ``paragraphs``/``components``/``rules_hit``. - ``DescriptionFacts`` is the new structured shape: ``parent_summary``, ``parent_detail``, ``heteroatoms``, ``unsaturation``, ``stereochemistry``, ``substituents``, ``connectivity`` — each a tuple of phrases. - ``str(d)`` returns ``d.text``; ``d.to_dict()`` is JSON-serialisable. - ``DescribedComponent`` (PR4 artefact, internal-only) is dropped; ``DescriptionFacts`` replaces it. ## Adapter notes The reference was written against the pre-refactor codebase. Adapted imports to current module layout: ``read_smiles`` / ``get_connected_components`` from ``graph_io``; ``parse_locant`` from ``locants``; ``number_parent`` from ``numbering``; ``name_subgraph`` from ``namer``. ``select_principal_parent`` now returns a ``ParentSelection`` dataclass, so the unpacking-via-tuple call style is replaced with attribute access. ## Tests + CLI + web - ``tests/integration/test_describer.py`` rewritten: 11 tests asserting structural prose, heteroatom positions, reconstruction connectivity, determinism, empty SMILES, CLI plain + JSON. - ``tests/integration/test_web_app.py``: ``/describe`` test updated to the new payload shape (``text`` + ``facts`` instead of ``paragraphs``). - README example updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Why
Naming output can drift with the rdkit upgrade cycle (aromaticity,
kekulisation, H-count perception). The pre-existing
test_charged_ammonio_substituent_keeps_all_n_ligands_explicit xfail
was a one-off symptom. This adds broad detection.
## Golden corpus
- tests/fixtures/diverse_corpus.golden.json — frozen
(SMILES → expected name) pairs for every entry in
diverse_corpus.csv, generated against rdkit 2025.03.6 locally.
- tests/integration/test_corpus_golden.py — strict assertion plus a
size sanity check. Divergences are surfaced as a single failure
listing every (smiles, category, expected, got) row, so reviewers
see the full delta at once.
- scripts/regenerate_goldens.py — helper for intentional resets.
The test is gated by a new `golden` pytest marker. It is **excluded**
from the default suite so an rdkit upgrade in `[dev]` does not make
the standard CI matrix red overnight.
## rdkit-compat CI job
.github/workflows/ci.yml grows a new `rdkit-compat` matrix that pins
rdkit to each of {2024.03.6, 2024.09.6, 2025.03.6, 2025.09.5,
2026.3.2} on py3.12 and runs `pytest -m golden`. Failures pinpoint
which corpus entries depend on rdkit. fail-fast: false so we get the
full divergence picture, not the first version that breaks.
Future direction: as the namer is hardened to be rdkit-invariant,
divergent goldens are fixed and the matrix turns green across all
versions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fiers for azido, diazo, diazonio, hydrazone, aldehyde/ring aldehyde hydrazone, and hydrazine. Routed perception.py through those roles before the legacy azido/hydrazone/hydrazine checks.
…plate for acyclic C=N-N=C
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Free Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login. Comment |
|
@sourcery-ai review |
|
Hi @r-fedorov! 👋 You are not authorized to use Sourcery in this repository. Please ask your team admin to give you a seat. |
|
@sourcery-ai review |
|
Sorry, we are unable to review this pull request The GitHub API does not allow us to fetch diffs exceeding 20000 lines |
…sets' into merge_try
…origin/merge_try
Stacks on PR3. Adds the `describe()` natural-language renderer and
polishes the typed result objects so they are obvious to use.
Walks the existing `DecisionTrace` / `trace_segments` and renders a
deterministic multi-paragraph prose explanation: parse stats →
perception → principal-group priority → parent selection → numbering
→ assembly → per-segment contributions → Blue Book rules invoked.
```python
from bluenamer import describe
d = describe("CC(=O)Nc1ccccc1")
print(d) # prose
d.rules_hit # ('P-44', 'P-45', 'P-41', 'P-61', 'P-67')
d.components[0] # DescribedComponent(phase='parse', text='RDKit parsed ...')
```
No LLM in the loop — same input → same output, safe in dataset
pipelines. Plus CLI:
```bash
bluenamer describe "CC(=O)Nc1ccccc1"
bluenamer describe "CC(=O)Nc1ccccc1" --json
```
The user feedback was that the typed objects need to be obvious. They now all support the natural Python protocols:
```python
result = name("CCO")
str(result) # 'ethanol'
bool(result) # True
repr(result) # NamingResult(name='ethanol', smiles='CCO', ok=True)
result.to_dict() # JSON-friendly, ready for a CSV/JSONL row
check = result.opsin_check # OpsinCheck | None
if check: ... # bool == matched
str(check) # 'matched' / 'mismatched' / ...
check.to_dict() # serialisable
d = describe("CCO")
str(d) # full prose
d.summary # first paragraph
d.to_dict() # serialisable
```
The CLI's previous ad-hoc serializer is collapsed onto these.
- Drop the `describe_smiles` wrapper (callers use `str(describe(s))`).
- Drop a dead regex (`_NAME_TERM_HINT`) in describer.
- Replace the `summary` field on `Description` with a property.
- Simplify `_render_assembly` and tighten `_trace_segment_lines`.
- CLI uses the dataclass `to_dict` methods instead of a duplicate helper.
- [x] `pytest tests/integration/test_describer.py src/bluenamer/tests/test_public_api.py` → 23 passed.
- [x] `pytest -m "not slow and not dataset"` → 158 passed.
- [x] `ruff check src/bluenamer tests` + `ruff format --check` clean.
- [x] Manual CLI: `bluenamer name CCO`, `bluenamer describe CCO`, `bluenamer describe CC(=O)Nc1ccccc1 --json`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Add a deterministic natural-language description API for naming explanations and make result objects easier to consume programmatically and via the CLI.
New Features:
- Introduce a describe(smiles) API that returns a structured Description with prose, components, and rule metadata.
- Expose Description and DescribedComponent types, along with describe(), from the public bluenamer package.
- Add a bluenamer CLI describe subcommand with optional JSON output for description results.
Enhancements:
- Make NamingResult and OpsinCheck ergonomically usable via str/bool/repr and JSON-friendly to_dict() helpers.
- Refine natural-language rendering of naming decisions into paragraphs and per-phase components, including trace segment summaries.
- Update the CLI to use the dataclass to_dict methods instead of a custom serializer.
Documentation:
- Document the new describe API and its usage in the README.
Tests:
- Add integration tests for the describer API and CLI describe subcommand.
- Extend public API tests to cover the new NamingResult ergonomics and JSON round-tripping.
…o origin/merge_try
… origin/merge_try Follow-up to PR3/PR5 review. Two changes: New property `test_opsin_roundtrip_never_silently_mismatches` asserts the strictest correctness invariant the namer has: an OPSIN-parseable name must canonicalise back to the input SMILES. It asserts against `status == \"mismatched\"` specifically — the other non-matched statuses (\`name_unparseable\`, empty name) are acceptable as namer incompleteness; a *mismatched* round-trip is always a bug because it means we produced a name OPSIN parses to a *different* molecule. Runs 40 examples per session (JVM startup dominates per-call cost), gated by `opsin_available()` so it skips cleanly when py2opsin or Java are missing. CI installs both via the `[dev]` extra, so this runs on every PR. Promoted from two private helpers (`_try_import_py2opsin`, `_java_available`) to a single public predicate. Exported at top level: `from bluenamer import opsin_available`. Test code (and downstream users) can branch on capability without reaching into privates. QM9: dropped the candidate-list loop and the `BLUENAMER_QM9_DATASET` override env. Single dataset (\`yairschiff/qm9\`). PubChem unchanged on \`jablonkagroup/pubchem-smiles-molecular-formula\`. Both files lose ~30 lines: the fixture, the naming-rate test, and the opsin-match-rate test are now ~5 lines each. The opsin test counts statuses into a single dict and skips when the env doesn't have a JRE, instead of running the namer twice and tracking five separate counters. - [x] `pytest tests/fuzz` → all pass; roundtrip test skips cleanly without Java. - [x] `pytest -m \"not slow and not dataset\"` → 165 passed. - [x] `ruff check` + `ruff format --check` clean. - [x] On CI (where Java is present), the new roundtrip property will run 40 examples per session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Add an OPSIN round-trip fuzz property and simplify dataset-based integration tests while exposing an OPSIN availability helper. New Features: - Expose an opsin_available helper at the top-level bluenamer API to detect OPSIN and Java availability. - Introduce a fuzz test asserting that OPSIN-parseable names round-trip back to the original SMILES without mismatches. Enhancements: - Simplify QM9 and PubChem dataset sampling tests to use a single fixed dataset identifier each, with clearer sampling and status aggregation. - Streamline OPSIN match-rate reporting in dataset tests by counting result statuses in a single dictionary and skipping cleanly when OPSIN or Java are unavailable. Tests: - Refine QM9 and PubChem naming-rate assertions to use the truthiness of naming results and print condensed rate summaries.
…nto origin/merge_try Builds out broad detection for naming output that drifts with the RDKit upgrade cycle. The existing `test_charged_ammonio_substituent_keeps_all_n_ligands_explicit` xfail was a one-off symptom — this PR adds the infrastructure to find all of them. - `tests/fixtures/diverse_corpus.golden.json` — frozen `(SMILES → expected name)` pairs for every entry in `diverse_corpus.csv`. Generated against rdkit 2025.03.6 locally; treat as the contract. - `tests/integration/test_corpus_golden.py` — strict equality test. Divergences are listed as a single failure showing every `(smiles, category, expected, got)` row, so reviewers see the full delta at once. - `scripts/regenerate_goldens.py` — helper for intentional resets when the namer or rdkit pin changes. The test is gated by a new `golden` pytest marker and is **excluded** from the default `pytest` suite so an rdkit bump in `[dev]` doesn't make the standard CI matrix red overnight. New matrix job in `.github/workflows/ci.yml` that pins rdkit to each of - `2024.03.6` - `2024.09.6` - `2025.03.6` - `2025.09.5` - `2026.3.2` on py3.12 and runs only `pytest -m golden`. `fail-fast: false` so we get the full divergence picture, not the first version that breaks. Expect this job to be **red on at least some matrix rows initially** — that's the point. Each red row is an action item: a SMILES whose name depends on rdkit's perception logic. As the namer is hardened, divergent goldens disappear and the matrix turns green across all versions. - [x] `pytest -m golden` → 2 passed on rdkit 2025.03.6 (local). - [x] `pytest -m "not slow and not dataset and not golden"` → 168 passed (same as PR7). - [x] `ruff check` + `ruff format --check` clean over `src/`, `tests/`, `scripts/`. - [ ] CI `rdkit-compat`: will reveal which entries (if any) drift across rdkit 2024–2026. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Add a golden-name regression suite and RDKit version-matrix CI job to detect and manage naming drift across RDKit releases. New Features: - Introduce a golden corpus integration test that compares SMILES-to-name output against a frozen JSON fixture. - Add a helper script to regenerate the golden corpus from the current naming engine and RDKit version. Enhancements: - Tag the golden regression test with a dedicated pytest marker and exclude it from the default fast test selection in both config and README usage docs. CI: - Add an rdkit-compat CI job that runs the golden regression suite against a matrix of pinned RDKit versions to surface version-dependent naming differences.
…r branch)" removing the describer functionality from the main branch, as it is not yet ready for production use. This reverts commit fc7ada9.
Revert "refactor(describer): structure-driven prose (port from earlie…
This PR introduces a major auditability and correctness upgrade to BlueNamer’s naming pipeline.
Names are now assembled with atom, bond, charge, locant, token-span, and rewrite-history metadata, allowing the final rendered name to be audited against the molecular graph before it is returned.
On top of that infrastructure, this PR adds several new chemistry role models and production-gated renderers, including retained fused derivative support, fused N-oxide/N-ium templates, charge-pair classification, audited Von Baeyer polycycle descriptors, nitrogen-chain roles, hypervalent/oxoacid roles, peroxy-carbonyl roles, and graph-bound special component names.
The PR also expands round-trip and unit test coverage significantly and updates evaluation scripts to produce more useful failure analysis.
Major changes
Typed, graph-bound name assembly
This PR adds src/bluenamer/name_assembly.py, which introduces typed objects for final name assembly and rewrite tracking.
New concepts include:
NameAssemblyResult
NameRewriteRule
NameFragment
GraphRole
RendererTemplate
NameTokenSpan
final token-span binding
final assembly audits
rewrite history with before/after text, changed token counts, edit spans, ownership, and source metadata
assemble_name() now delegates through assemble_name_result(), preserving bindings through post-processing instead of treating final text as opaque.
AssemblyParts now carries additional metadata:
NameTokenBinding
charge_atom_ids
emitted_tokens
front_modifier_charge_atom_ids
principal_suffix_modifiers
relative_stereo_prefixes
name_token_spans
name_rewrite_history
This lets downstream analysis inspect exactly which graph atoms and bonds contributed to each visible token in the final name.
Metadata-preserving post-processing
Legacy string replacements in assembler.py are moved into LEGACY_POSTPROCESS_LITERAL_REPLACEMENTS, making the rewrite inventory explicit.
Post-processing now updates binding terms and emitted token metadata, instead of only changing the rendered name. The assembly pipeline records rewrite operations so audits can determine whether a token was preserved, rewritten, absorbed, or generated by grammar.
This is especially important for retained/common-name conversions such as:
methanoic acid → formic acid
ethanoyl → acetyl
phenylmethyl → benzyl
methanehydrazine → methylhydrazine
azacycloalkane retained names such as azetidine/pyrrolidine/piperidine
Final assembly audit
Component naming now performs final-name audits after assembly.
The audit checks that:
consumed atoms are represented;
consumed bonds are represented;
charged atoms have explicit charge bindings;
concrete binding terms still appear in the final rendered text unless intentionally absorbed;
lexical final-name tokens are bound to graph metadata or marked as grammar scope.
Shortcut paths such as single-atom components, structural replacement parents, and anhydrides now return graph-bound metadata and are audited like normal component names.
Graph-bound special component names
special_cases.py is heavily expanded and converted from string-only helpers to typed special-name results.
Special component renderers now return SpecialComponentName objects with bindings where applicable.
New or upgraded graph-bound special cases include:
biphenyl from graph-proven benzene ring pair detection;
phosphane-borane zwitterions;
sulfonium ylides;
hydroxyurea;
oxoacid parents;
oxoacid esters;
organophosphinic acids;
sulfoxide parents;
diazo/lambda heteroring parents;
simple azine parents;
homonuclear chain parents;
central parent hydrides.
The previous special_component_names rule/config path is removed, so retained special components are no longer applied as blind final string replacements.
Charge-pair role classification
A new charge_pair_roles.py module classifies formal charge-pair patterns before rendering.
Supported template roles include:
diazonium_azanide
n_oxide
phosphane_borane_zwitterion
sulfonium_ylide_single_bond
terminal_chalcogenide_heteroarenium
Unsupported/high-risk roles include:
sulfur/carbanion resonance charge pairs on non-single bonds;
generic adjacent formal charge pairs without a registered safe template;
P/B charge pairs without a safe template.
Each role can produce a role certificate and template audit, recording represented atoms, bonds, and formal charges.
Parent charge handling for positive oxygen
Parent charge suffix handling is extended from positive nitrogen-only cases to positive nitrogen and oxygen cases.
The new helper positive_parent_ium_charges() allows parent atoms with positive N or O charge to receive ium-style suffix handling, while preserving the existing azonia/replacement-prefix exclusions.
This enables cases such as positive oxygen parent charge naming.
Retained fused parent grammar snapshot and production gate
This PR adds a local parser grammar snapshot and parser resource JSON data.
New data includes:
parser_grammar_snapshot.json
parser_xml_resources/*.json
retained_fused_graph_templates.json
fused_ion_templates.json
fused_emission_examples.json
New runtime modules include:
grammar_snapshot_data.py
retained_fused_templates.py
retained_fused_production.py
fused_topology.py
fused_ion_templates.py
The retained fused system distinguishes between:
parser-visible tokens;
audit-only retained fused parents;
production-safe retained fused derivatives.
Production retained fused derivative naming is gated to a conservative subset of neutral retained fused parents and safe substituent/principal group classes.
Production-safe parents include classes such as:
naphthalene
quinoline
isoquinoline
naphthyridines
quinazoline
quinoxaline
cinnoline
phthalazine
Audit-only parents include classes such as:
azulene
phenalene
acenaphthylene
fluoranthene
perimidine
pteridine
indole-related indicated-H systems
Fused ion template rendering
The new fused ion template registry supports production-ready retained fused ion operations.
Production-ready examples include:
quinolin-1-ium
isoquinolin-2-ium
quinoline 1-oxide
isoquinoline 2-oxide
The renderer consumes generic parent-charge and oxido substituent fragments and replaces them with a single graph-bound fused-ion binding when a production-ready template is matched.
This prevents names such as quinoline N-oxides from being represented as unrelated oxido substituents plus parent charge suffixes.
Audited Von Baeyer and polycycle handling
Polycycle handling is made more conservative.
The previous fallback that synthesized a tricyclo descriptor when no audited descriptor was present is removed. A polycyclic parent without an audited descriptor now raises:
ValueError("polycyclic parent has no audited descriptor")
New Von Baeyer support includes:
candidate generation;
descriptor body construction;
bridge classification;
secondary bridge handling;
audited numbering;
descriptor reconstruction audit;
integration into ring parent selection.
This is a deliberate fail-closed behavior change.
Relative and small-ring stereochemistry
Assembly now supports relative stereo prefixes in addition to ordinary stereochemical features.
Small-ring stereochemistry support is added through small_ring_stereo.py, and parent assembly now adds scoped relative ring stereo features where appropriate.
Hydrazone E/Z handling is also refined so unlocanted (E) / (Z) descriptors are only emitted for supported hydrazone suffix classes.
Nitrogen-chain and hydrazone role expansion
The PR adds a large nitrogen role model in nitrogen_roles.py.
New or expanded roles include:
hydrazone chains;
acid-derived hydrazones;
amidinohydrazones;
azines;
azido/diazo roles;
hydrazinyl and hydrazinylamino prefixes;
terminal N3 substituent roles;
imino prefix handling.
namer_rules.json is updated with new functional-group entries and family membership for:
nitrile oxide;
ring nitrile oxide;
thioaldehyde;
ring thioaldehyde;
imino prefix;
aldehyde amidinohydrazone;
ring aldehyde amidinohydrazone;
diazenylamino;
aminodiazenyl;
hydrazinylamino;
diazenyl.
Hypervalent, heterocumulene, oxoacid, and peroxy-carbonyl roles
The PR adds new graph-role modules for difficult heteroatom chemistry:
hypervalent_roles.py
heterocumulene_roles.py
oxoacid_roles.py
oxoacid_templates.py
peroxy_carbonyl_roles.py
These modules classify central atoms, oxygen ligands, oxido/oxo/hydroxy/alkoxy/peroxy patterns, unsupported templates, and graph-bound role certificates.
heteroatom_substituent_specs.py and heteroatom_subgraphs.py are expanded to render central oxo substituents and hypervalent substituent classes more accurately.
heteroatom_substituents.json adds central oxo substituent classes such as:
oxophosphanyl
dihydroxyphosphoryl
phosphonato
dioxophosphanyl
sulfinyl
sulfonyl
sulfo
sulfonato
seleninyl
selenonyl
tellanyl lambda forms
Resonance-aware verification
opsin_verify.py now uses resonance_compare.canonical_smiles() and equivalent_smiles() instead of raw RDKit CanonSmiles() equality.
The new resonance_compare.py module allows selected resonance-equivalent structures, especially sulfur ylide resonance forms, to compare as equivalent.
This should reduce false negatives in OPSIN round-trip verification where graph forms differ but represent the same accepted resonance model.
Evaluation scripts and failure analysis
The example evaluation scripts are updated to use a shared examples/utils.py standardizer.
examples/utils.py performs:
RDKit cleanup;
normalization;
reionization;
uncharging;
tautomer canonicalization;
suppression of noisy RDKit/OPSIN warnings.
find_small_failures.py now:
uses standardized molecule comparison;
accepts resonance-equivalent SMILES through equivalent_smiles();
buckets failures by chemical class;
counts failure substrings;
writes small_failures.csv.
New ZINC22 evaluation scripts are added:
examples/test_opsin_mac_ZINC22.py
examples/test_opsin_mac_ZINC22_light.py
These scripts sample ZINC22 molecules, convert SMILES → BlueNamer name → OPSIN SMILES, collect batch and single OPSIN errors, and write detailed failure CSVs.
Test coverage
The test suite is substantially expanded.
Coverage areas include:
name token binding;
final assembly audits;
metadata-preserving post-processing;
component shortcut auditing;
retained fused graph templates;
parser grammar snapshot validation;
fused topology routes;
fused ion templates;
quinoline/isoquinoline N-oxide rendering;
retained fused derivative gates;
charge-pair roles;
phosphane-borane zwitterions;
hypervalent center roles;
oxoacid parent and ester roles;
peroxy-carbonyl roles;
nitrogen chain roles;
hydrazone and azine roles;
audited Von Baeyer descriptors;
fail-closed polycycle behavior;
sulfur ylide resonance comparison;
chalcogen imide rendering;
charged sulfur/selenium substituents;
cyclic peroxy ester naming.
Round-trip tests are also expanded for fused parents and role-model cases.