Architecture#
This page is for people who change the code. How it works is the short version for users. Here one SMILES is followed through the code, the parts are named, and the names that mislead new readers are explained. Where a rule lives is on the Rule index, and how to make a change is in Contributing.
Functions and classes are given by name, not by line number, so that you can find them with git grep -n "def name_tiered" -- src. Line numbers change in every release.
Five facts to start from#
There is no single naming path. Several families of code can build a name for one molecule. They are tried in a fixed order, and the first name that passes the checks is the one that is returned. Two families can both be right and spell the name differently.
The tiers are not four code paths.
pin,valid,completeandbest-effortrun the same code with different flags. The flags are read in many places, so the same function can write a different name at a different tier.A producer can be wrong, and the checks are what makes the engine safe. A name is returned only after OPSIN has read it back and the InChIKeys agree, with a few named exceptions. When a check rejects a name, the class that produced it is excluded and the next one is tried.
The same pipeline runs again on parts of the molecule. A substituent or a fragment is named by calling the naming code recursively, so the function you are reading may be working on a piece of your molecule, not on the whole of it.
A comment that says “the one place where X is decided” is a claim to test. A function can be well documented and still never run for the molecules you care about. Before you edit shows how to count calls.
One call, step by step#
name_compound(smiles, ...) one Orthonym per call
Orthonym.name() four wrappers, then the body
_name_with_pin_promotion PIN tier: a second run if no name
isotope decorator, wildcard check
_name_impl()
ClassFirstRouter.dispatch() first class that fits (outer table)
class handler -> rules/... a name, or None: the next class
class "general" None on purpose: general pipeline
_perceive(), _classify() features, principal group, parent
composer.assemble_name()
dispatch_inner() first handler that succeeds
handler -> assembly/... calls rules/... to build the name
_final_stereo_check, _final_grammar_check
_final_opsin_validity_gate a rejection excludes the class
rescues only if a failure label would ship
_finish() offers, round-trip selection, exit
Orthonym.name_tiered() adds tier, source and gate fields
1. The entry: name_compound and Orthonym#
name_compound builds one Orthonym and calls its name method, or name_with_confidence when you ask for the confidence. Orthonym.name_tiered calls name inside and then derives the provenance row (_tier_row_and_pin_form). A Chem.Mol is turned into a SMILES string first (orthonym.input_mol), and from then on the engine works with the string.
When name_compound is called while another naming is already running, which is how recursion happens, it builds a fresh Orthonym and looks the call up in a memo (cached_nested_call). A fresh instance does not have the flags of the outer call, so the flags travel in context variables (see Tiers are flags).
2. Orthonym.name: four wrappers and a body#
name carries four decorators, outermost first.
Wrapper |
What it does |
|---|---|
|
For the caller’s own call only. When the input is a metal drawing that has another drawing with the same standard InChIKey (the ionic drawing of a covalent one, the bonded drawing of an organometal cation drawn beside its anion, or the drawing without the dative bonds), names the drawing as written and the other drawing, each as a call of its own, and keeps the better name ( |
|
Names the charge forms of your input ( |
|
When a name written in the book’s spelling leaves the molecule unnamed at a wider tier, names it again with the older spelling ( |
|
Counts how deep the naming is ( |
The body then runs, in this order: the isotope decorator (decorate_isotopic_name in rules/isotopes.py, for an input with isotopes), a check for a wildcard atom, _name_impl, the checks, the rescues and _finish.
At the default tier name has one more step in front. _name_with_pin_promotion runs name once; when that returns no name, and only then, it runs it a second time with the best-effort ring-substituent producers allowed, and keeps the second name only if it passes the same gates. A name from the second run is labelled pin_unverified, because a breadth producer built it.
3. Class routing: the outer table#
_name_impl parses the SMILES, removes the charges of a large zwitterion that counts as neutral, and asks ClassFirstRouter.dispatch (routing/dispatcher.py) for a class. The router walks DISPATCH_TABLE (routing/dispatch_table.py) in priority order and returns the first class whose predicate says yes. The class handler then runs. When it returns None, _name_impl goes on with the next higher priority that matches. The table is listed on the Rule index.
Four things in this table surprise readers.
The
tiernumber in an entry is a label. The docstring of the router speaks of two-tier evaluation, but the code is one loop over all entries sorted by priority.tiermarks whether a predicate needs only the molecule (1) or perceived features (2), and is not used to decide anything.Most handlers are shims. The function in
dispatch_table.pyimports the real code fromrules/inside its body and calls it. Searchrules/for the code, not the table.A predicate often runs the naming function. For most classes the predicate calls the same function as the handler and asks whether it returns a name (for example
name_mononuclear_hydride). The class “can name this” exactly when its namer builds a name.The last class,
general, returnsNoneon purpose. It means “no special class fits”:_name_implthen runs the general pipeline inline, becauserouting/must not import the composer.
When a gate rejects the name of a class later on, that class is added to _excluded_dispatch_classes and the routing is run again without it. This is how a molecule ends up named by the second-best class.
4. The general pipeline#
For a molecule no special class took, _name_impl runs three steps.
_perceivefinds the functional groups (SMARTS patterns inperception/functional_groups.py), the ring systems (perception/rings.py), the multiple bonds and the stereocentres, and fills aMolecularFeaturesobject. Stereo goes throughassign_stereochemistryinperception/stereo.py. Do not call RDKit’s CIP labeller directly:assign_stereochemistrychooses the most accurate labeller that is available._classifypicks the principal group (get_principal_groupinrules/seniority.py), finds out whether the molecule is polyfunctional, and chooses ring or chain as the parent (select_parentinrules/parent_selection.py, the P-44 rule).composer.assemble_namebuilds the name. A few inline branches in_assemble_name_impl(amidines, hydrazides) go first. Thendispatch_inner(assembly/inner_dispatch.py) walksINNER_DISPATCH_TABLE. Unlike the outer table, this loop stops at the first handler that applies and succeeds: a handler that returnsNonehands the molecule to the next one. The last entry,general_acyclic, is a catch-all and handles what is left.
The inner handlers live in assembly/handlers/ and call the nomenclature rules in rules/, the helpers of assembly/naming_utils.py (locants, enclosing marks, alphanumerical order) and the substituent namers. --dump-tree prints the handler that built a name. The handlers are listed with their files on the Rule index.
5. Checks after a producer has returned#
Still inside name, and only at the top level of the call, the name goes through three checks.
_final_stereo_checkadds stereodescriptors that a producer left out, for the few producers whose numbering it can trust. For the others it only logs: a missing descriptor is better than one at a guessed locant._final_grammar_checkchecks the name against a model of OPSIN’s grammar and repairs it only when the repaired name reads back._final_opsin_validity_gatehas OPSIN read the name back and compares the structure with the input (called SELF-01 in comments). Some name classes that OPSIN cannot read are let through by named carve-outs, and the provenance row says so (gate_outcome,carveout:<class>). At the default tier it also lets through a PIN spelling OPSIN rejects when a twin spelling of the same structure reads back to the input’s full InChIKey (validation/twin_spellings.py,gate_outcometwin_verified:<rules>), and a poly-anhydride name when the acids it cites, read by OPSIN, build the input (validation/anhydride_acids.py,gate_outcomeacid_split_verified:<rule>). The gate also reads OPSIN’s warnings: a name that OPSIN reads as ambiguous (APPEARS_AMBIGUOUS) is withdrawn, after one retry with every locant cited (validation/opsin_ambiguity.py,gate_retry.py).
When the gate rejects a name, _retry_cascade_on_gate_rejection excludes the class that produced it and dispatches again. The atom-coverage check of the checking page is a separate certificate (E1, validation/e1_certificate.py). It decides for names of the general engine.
6. Rescues#
If the result is now a failure label, up to seven rescues get a turn. They are optional: each one either returns a name that passes the gate or declines.
Rescue |
Runs when |
|---|---|
|
The general engine is on ( |
|
|
a second call of |
Any tier, with the gate on: the decomposition engine as a last resort. |
|
|
|
|
|
|
|
|
_apply_trivial_fallback runs at every tier. What it does depends on --trivial and on the last-resort table of retained names.
A budget that runs out raises PerfBudgetExceeded, which derives from BaseException, so a broad except Exception does not catch it. It unwinds to _budget_scope, which declines cleanly (at best-effort after one last rescue).
7. The single exit: _finish#
Every way out of name goes through _finish; a test parses the code to keep it that way. In _finish:
the exact-match list for metal tetrapyrrole complexes can replace the name (
_coordination_retained_name);a coverage audit records a verdict, and in its
vetomode declines an incomplete name (the default isshadow, which only records);the winner becomes one
Offer(assembly/offer_pool.py); atbest-effort, if the winner does not already read back exactly,_maybe_append_t4_floor_offeradds the output of the last-resort namer as a second offer, and_maybe_append_relative_stereo_offeradds, when the gate withdrew a name with lowercaserandsfor the whole molecule, itscis/transform for a monocycle’s pair of stereocentres as a third (rules/relative_stereo.py);_select_rt_passing_offer_namereturns the first offer that is complete and reads back;_exit_round_trip_checkchecks the name once more, and_maybe_prefix_order_fallbackis the last resort of the best-effort tier;outside
name, atbest-effort,assembly/default_tier_offer.pyoffers a molecule that is still without a name the row of a default-tier engine, when that row is a name the gate withdrew because OPSIN cannot read its stereodescriptors (self_consistency_constitution_only):default_tier_name_scopedecoratesname(soname_compoundandname_with_confidenceagree) andname_tiered_with_offerdoes the same for the row;with
binding_proofset toauditorenforce, a proof that every part of the name maps onto atoms is checked.
The pool of offers holds the winner and at most two more names, the floor and the cis/trans offer. assembly/candidate_pool.py is a different thing; see the glossary.
8. The row: name_tiered#
name_tiered calls name, then _tier_row_and_pin_form combines what the producers and gates recorded into the row of the provenance page. The recording is done with context variables in metrics/provenance.py; record_source sets the source value. At the default tier the emission rule (_default_tier_emits) is applied inside name itself, so that name_compound and name_tiered agree: the row derived for that decision is the row name_tiered returns.
producer is recorded another way, on purpose. metrics/producer_trail.py keeps, for the length of one name_tiered call, a map from each string a producer returned to the id of that producer. A call site passes its result through noted("<id>", result, mol), a step that turns one name into another passes the label on with carried(name_in, name_out), and the row reads the entry of the name that ships. The map is not a provenance variable: a write there is replayed by the memos and can make a call unstorable, so the record stays out of reach of the memos and the work budgets. Each helper returns its argument unchanged and cannot raise. The first producer to return a string keeps it, which is the innermost one, and the strict twin of the best-effort tier records nothing. tests/unit/metrics/test_producer_trail_static.py fails on a call of a producer or a rewriting step that is neither wrapped nor on its list of reasoned exceptions.
Tiers are flags#
--emit-tier becomes constructor arguments of Orthonym (_emit_tier_flags in cli.py is the one table that maps one to the other):
Tier |
|
|
|
|
|---|---|---|---|---|
|
no |
no |
no |
no |
|
yes |
no |
no |
no |
|
yes |
yes |
no |
no |
|
yes |
yes |
yes |
no |
|
yes |
yes |
yes |
yes |
The top-level name publishes each flag in a context variable (general_fallback_ctx, allow_aromatic_general_ctx, best_effort_ctx, full_coverage_ctx, all in metrics/provenance.py), because recursion builds a fresh Orthonym and a fresh instance would lose the flags. Code far from namer.py reads the variable it needs. best_effort_ctx alone is read in more than twenty files. pin_promotion_var (assembly/memo.py) marks the second run of the promotion step and keeps its memo entries apart from the first run’s.
What this means for a change:
A producer that is used by every tier can write a different name at each. The
sourcevaluepin_pathnames a code path, not a result: at the default tier cis-decalin gets(4as,8as)-decahydronaphthalenefrom thepin_pathwith tierpin_unverified.Test a change at the tiers it can reach.
pinis the byte-identical default, and a change that is meant for the wider tiers must leave it as it was.There is no
if tier == ...to search for. Search for the context variables and for the instance attributes_general_fallback,_general_fallback_unverified,_allow_aromatic_generaland_full_coverage.
The producer families#
Family |
Where |
Runs |
|---|---|---|
Class handlers |
|
Always, first |
General pipeline |
|
Always, when no class took the molecule |
Decomposition |
|
As the class |
General engine |
|
For the whole molecule at |
Universal namer (“T4”) |
|
For the whole molecule at |
Promotion re-run |
|
Default tier, when the first run has no name |
Substituent namers |
|
In every family, for every part of a molecule that becomes a prefix |
Because the families do not partition the molecules, a molecule can be named by two of them, and the spelling can differ. Two examples, each from a direct call of the producers:
Molecule |
Strict path (the name the user gets) |
Universal namer |
|---|---|---|
ethyl lactate, |
|
|
paracetamol, |
|
|
Which spelling is returned is decided by the order of the families and by which name survives the checks, not by a rule that compares them. When you fix a rule in one family, find out whether another family writes the same part of the name, and fix it there too or write down that you did not. The Rule index lists the rules that are written in more than one place.
Ring systems show the overlap most: a bridged, spiro or large polycyclic system can be named by the ring modules of rules/, by the general engine and by the universal namer.
Recursion and budgets#
Naming a molecule names its parts, and naming a part calls the same entry points. Four mechanisms keep this finite and let the code know where it is:
name_scope_depth()andis_top_level_naming()(assembly/fragment_naming.py) say where the code is.name_scope_depth()counts nestedname()calls, and 1 is the caller’s own call.is_top_level_naming()says whether the code is naming a whole molecule or a piece of one; the isolated sessions that some rescues open reset it, so the two are not the same test. The checks of section 5 and most rescues run only at the top level. A function that behaves differently for a piece of a molecule usually asks one of these.A set of SMILES currently being named (
name_fragment_recursively) breaks cycles.Per-molecule work budgets (
spend_perf_work,spend_analysis_call) stop a molecule whose analysis explodes, as inPerfBudgetExceededabove.Scoped memos (
assembly/memo.py) keep the names of parts for the length of one call. Where the name of a part depends on the tier, the key carries the tier flags.
Before you edit a function#
Find out which code named your molecule, and whether the code you are about to change runs at all. Four ways, from cheap to precise.
The row.
orthonym "SMILES" --provenanceshowstier,source,producerandgate_outcome.produceris the id of the code that built the name (inner:tier_a_ring,engine:general,table:coordination; the provenance page lists them),nullwhen no name ships.sourceis coarse:pin_path,general_engine,trivial_retained,t4_floororabstain. On a decline it can still name the last producer that ran.The classes.
orthonym "SMILES" --dispatch-statsprints, on the error stream, how often each outer class and each inner handler matched. The counts include the parts of the molecule.Orthonym.get_dispatch_statsandget_inner_dispatch_statsgive the same in Python.The tree.
orthonym "SMILES" --dump-treeprints the parts of the name. Itsclass_idis the inner handler that built the name (ester_family,general_acyclic), orcoarse_fallbackwhen a class of the outer table did.A call count. Profile a few molecules of your class and look for the function:
import cProfile, pstats
from orthonym import name_compound
smiles = ["CC(C)Cc1ccc(cc1)[C@@H](C)C(=O)O", "CCOC(=O)C(C)O", "CC(C)(C)CC(=O)O"]
pr = cProfile.Profile()
pr.enable()
for s in smiles:
name_compound(s)
pr.disable()
for (path, line, func), row in pstats.Stats(pr).stats.items():
if func in {"alpha_sort_key", "compare_locant_sets", "name_ketene"}:
print(row[1], path.split("orthonym/")[-1], func)
Check the recipe against functions you know run before you trust a zero. A function that is imported inside another function (the shims do this) is still counted, because the profiler counts calls and not imports. To spy by patching a module attribute you must patch it in the module that calls it, since a function imported with from x import f keeps the old reference.
The three vocabularies below are easy to confuse. The producer of the row uses the first two: outer: or inner: followed by the id, with a class id in lower case.
Where it shows |
What the ids are |
|---|---|
|
Outer classes ( |
|
Inner handlers: |
|
Labels of the coverage score: |
Names that mislead#
Name |
What it is |
|---|---|
|
The inner handler for polyfunctional molecules, multiple esters and esters, tried in that order. It names plain acids, alcohols and thiols as well ( |
|
The ring cascade, a long handler. It tries complex rings, polycycles, partly saturated rings, heterocycles and benzene, picks among the candidates ( |
|
In the outer table: a pass-through that returns |
|
Assembles a name from parts. The inner dispatch runs inside it. |
|
|
|
Von Baeyer namers for two rings, for three rings, and the general analysis of a cage. Several modules use each of them, |
|
The universal namer of the |
|
A |
|
Coarse. It records the last producer that wrote a value, and on a decline it can name a producer that ran and failed. Do not use it to count producers: |
|
The candidate names of one naming call. |
|
Whole finished names that compete in |
|
In comments: SELF-01 is the OPSIN read-back of |
|
The enum of the outer classes. The prefix is historical. |
“Phase 158”, “v25 G1”, “WSB-03” |
Tags in comments that name the internal work item that added the code. They are not rules, and the notes they refer to are not in the repository. The internals pages strip the leading tag of a docstring. |
|
A line number in a text conversion of the Blue Book that is not in the repository. Find the rule by its P-number in the Blue Book instead. |
|
Not Blue Book ids. P-31.1.4 has sub-rules |
Citing the Blue Book#
The authority is the IUPAC 2013 Recommendations (“the Blue Book”). When you cite a rule in code or in a test, give the section that the book defines and the criterion inside it, and read the whole paragraph: the sentence that decides is often the last one. A rule number copied from a comment may be wrong; look it up.
If you search a text or OCR conversion of the book, expect a few traps. Headings can carry markup (### **P-14.3.4.2**), so a pattern anchored on P-14.3.4. misses them. In some conversions the hyphen of P-16 is a double quote and spaces are exclamation marks, so a search for P-16.5 finds nothing. Whole chapter headings can be missing (P-2, P-4 and P-7). Figures and ring-numbering diagrams are the least reliable part; check them against the web edition.
Where the data lives#
The naming tables are Python modules in src/orthonym/data/, not JSON files. The list, with what each holds, is on the Rule index. Retained names that are written by hand take precedence over the tables imported from OPSIN’s resource files (data/opsin_imports/); data/__init__.py merges them.
Where the tests live#
tests/unit/ mirrors the packages (rules/, assembly/, perception/, routing/, namer/), with many older tests directly in tests/unit/. Two things to know before you write one:
The suite turns the OPSIN validity gate off by default. A plain
name_compoundcall in a test shows what the strict path builds, which is not always what a user gets. A test that depends on the gate carries@pytest.mark.opsin_gate.tests/support/holds helpers that check the round trip independently of the engine (rt_assert.py), and helpers for the default tier and the best-effort tier (default_tier.py,pin_tiers.py).
Some tests read the text of source files, for example to check that a function is not called from a module. If you rename or move a function, find them with git grep -l -E "getsource|read_text" -- tests and run them.