orthonym.perception.structure_conservation#

Note

Internal API. Names and behaviour may change between releases.

Tier-B structure-conservation checks (Wave-8 P10 – the “E1 atom-coverage certificate” from the cross-tool audit).

Java-free, source-level structural checks that close leaks where the winning naming path emits a name for a DIFFERENT molecule than the input (atom-drop, charge-drop) and only OPSIN’s constitutional gate (namer._final_opsin_validity_gate / _self_consistency_decision) would otherwise have caught it. FAILS OPEN when no JAR is present (_validity_gate_jar_present returns False), so in a no-Java deployment these leaks ship as confidently-wrong names. The functions here are called UNCONDITIONALLY from namer.py::Orthonym._name_impl – i.e. NOT gated by self._disable_opsin_validity_gate – so they protect the raw path too.

Design principle (accuracy-first, precision-over-recall, per internal notes): every check here is scoped as NARROWLY as the evidence supports. A check that cannot be made precise (false-veto risk on a currently-correct name) is deliberately left unimplemented rather than shipped – see the docstring on partial_sat_sp3_substituent_drop for a worked example of the investigation this required (an initial, broader version of that check false-vetoed 17/25 real-corpus hexahydro-/octahydronaphthalene names before being narrowed to the exact shape it is proven safe for).

orthonym.perception.structure_conservation.input_invariants(mol)#

Java-free structural invariants of the INPUT molecule.

Returns (heavy_atom_count, dou, bond_order_sum):

  • heavy_atom_count: mol.GetNumHeavyAtoms (authoritative).

  • dou: rings + sum(bond_order - 1) over heavy-heavy bonds. A pure GRAPH invariant, deliberately NOT the classical H-count degree-of-unsaturation formula (which conflates formal charges/ radicals and would misfire on ions/radicals). Using the SUM of (order - 1) rather than a discrete count of “multiple bonds” is what makes this exact for aromatic rings too: RDKit reports an aromatic bond’s order as 1.5 (GetBondTypeAsDouble), so each contributes 0.5 – benzene’s 6 aromatic C-C bonds contribute 6*0.5=3, plus 1 ring, for the expected DoU of 4 (matching the Kekule double-bond count).

  • bond_order_sum: rounded sum of heavy-heavy bond orders – a coarse diagnostic signal, not consumed by any veto below.

Pure RDKit. No OPSIN import, no subprocess – safe to call unconditionally on the no-Java path.

orthonym.perception.structure_conservation.net_formal_charge(mol)#

Net formal charge of the molecule (Java-free, always available).

orthonym.perception.structure_conservation.looks_like_ionic_name(name)#

Coarse recognizer: does name textually encode ionic character via a recognised / charge suffix?

Deliberately LENIENT (broad match), because a false NEGATIVE here (a correctly-charged name this regex fails to recognise) only means a leak is missed – safe, per precision-over-recall. Every currently-shipping net-charged PIN this task could enumerate – the ~45 net-charged rows of benchmarks/the gold set/gold_pins.json (azanylium, silylium,…anium, …ide,…ate, bis(…ide)/bis(…ate)/bis(…ium) forms) – matches this pattern.

Cation suffixes (‘-ium’, ‘-ylium’, ‘-anium’, ‘-onium’) all end in the literal substring “ium”. Anion suffixes (‘-ide’, ‘-uide’, ‘-ate’, ‘-oate’, ‘-olate’, ‘-thiolate’, ‘-aminide’,…) all end in “ide” or “ate”. A trailing ) is allowed for a multiplied bis(…)/tris(…) form (e.g. ethane-1,2-bis(aminium)).

orthonym.perception.structure_conservation.charge_dropped(mol, name)#

True iff the input carries a nonzero NET formal charge, a charge-bearing atom is a main-group centre Orthonym’s charged routing does not fully cover (see _has_main_group_charge_centre), AND name does not textually encode ionic character (see looks_like_ionic_name).

Scope: NET charge only – a genuinely charged single ion (or an ionic assembly whose charges do not cancel). A net-ZERO zwitterion (nitro, N-oxide, amino-acid zwitterion, sulfonium/ammonium ylide,…) never reaches this check (net_formal_charge(mol) == 0 short-circuits), so the entire (large) neutral-by-charge-separation naming surface is untouched by construction. The main-group-centre guard further excludes every routine ionic centre (ammonium/choline N+, carboxylate/alkoxide O-, carbanide C-, thiolate/sulfonium S), which have dedicated charged routes – so this fires ONLY on a genuine main-group charge drop.

Callers MUST exclude an already-descriptive-fallback name (see errors.is_failure_name) before calling this – an honest abstention (‘unknown organic compound’, ‘<metal> compound (not supported)’,…) is not a “dropped charge” and must not be double-suppressed.

orthonym.perception.structure_conservation.partial_sat_sp3_substituent_drop(mol, name)#

R12 spillover (2026-07-17, W8-P1/R12 adversarial-verify workflow; closed here per W8-P10).

⚠ This veto’s ORIGINAL premise no longer holds, and the rewrite is the point. It used to reason: rules.polycyclics.name_partially_saturated_- carbocycle “NEVER bakes a saturated (sp3)-ring substituent into its own bare parent name – by construction, its _partial_sat_substituent_prefix helper explicitly skips every atom in sat_set”, so the ONLY route for an sp3-ring substituent was the composer’s enrichment pass, and name == bare alongside an sp3-ring off-ring substituent was therefore a deterministic proof of drop.

The hand-off that premise described was itself the defect. It derived a SECOND numbering (placing tetralin’s 2-methyl at locant 1) and it split one substituent set across two prefix formatters (2-methyl-6-methyl- where

requires 2,6-dimethyl-). Both faults are fixed by making the

producer the single speller of every ring substituent prefix, so bare NOW encodes sp3-ring substituents, and the old test would false-veto every correct name in the class.

Detection, restated for the current construction and still structural rather than heuristic: the producer publishes the off-ring atoms its name actually spells (PartialSatName.spelled_offring_atoms – prefixes plus the exocyclic atoms of any principal-characteristic-group suffix such as -2-carboxylic acid / -2-ol). An off-ring heavy atom on an sp3 ring position that is in NEITHER that set NOR the enrichment the shipped name added is unaccounted for, and that is a drop. Subtracting the published set is not a loosening: before it was subtracted, this veto suppressed the very names that carry those atoms – every ring-COOH member of the class emitted unknown organic compound while bare already held the correct 1,2,3,4-tetrahydronaphthalene-2-carboxylic acid.

Scope guard (precision-over-recall): restricted to the exact “fully-aromatic-ring + fully-saturated-ring” (tetralin-class) shape – every ring atom must be EITHER aromatic OR in sat_set. An initial, broader version of this check (using sat_set alone, without this guard) FALSE-VETOED 17 of 25 real hits on a corpus sweep of benchmark_multi_corpus_results.csv – all of them mixed alkene/hydro systems (hexahydro-/octahydronaphthalene, hydroazulene,…) whose substituents are, in fact, already correctly spelled by _partial_sat_substituent_prefix and which this function must not second-guess. Narrowing to the clean two-ring aromatic/saturated split eliminated every false positive found (0/91 on the combined synthetic + corpus validation set) while still catching the 3 documented target leaks plus 2 additional real-corpus leaks the narrower check newly surfaced.

IUPAC cite: / (partially saturated carbocycles; detachable-prefix placement); mirrors the existing R12 fusion-atom veto inside name_partially_saturated_carbocycle itself (ring-FUSION atom substituents), which this function does NOT duplicate (that path already returns None – bare – for those cases, so this function is a no-op on them).

orthonym.perception.structure_conservation.fused_ring_atom_drop(mol, name)#

True iff name is a bare (unsubstituted, optionally stereo-prefixed) monocyclic cycloalkane/cycloalkene name for a molecule whose rings span 2+ SSSR rings sharing at least one atom (fused, bridged, or spiro).

Such a name is provably wrong: a single “cyclo<stem>ane” parent cannot represent a second ring’s atoms, regardless of what those atoms are (carbocyclic or heteroatom-containing). See module-level comment above for the full design record.

orthonym.perception.structure_conservation.oxo_ene_valence_illegal(name)#

-A source-level (Java-free) valence guard.

True iff a ring carbon is cited as BOTH a ring double-bond terminus AND an oxo/-one carbon – a five-bond carbon (the caffeine-class 2,4-dioxo-...-1,7-diene invalid name). Pure string parse of the emitted von-Baeyer locants; no RDKit/OPSIN. Runs unconditionally downstream so best-effort is safe even when the OPSIN RT gate is unavailable (no jar). Fail-closed on the cumulated-carbon edge (an oxo carbon truly cannot also hold a ring double bond).

M4: SCOPE-AWARE. The collision must be within ONE numbering scope –

the old whole-string union falsely flagged an oxo cited inside a substituent (or in a different salt component) against a parent-ring ene, suppressing 33 valid salt/decorated names. Split into salt words then substituent scopes (_scope_substrings()) and test each independently; the caffeine-class parent-scope collision still fires, and every recovered name stays OPSIN-round-trip gated downstream so 0-wrong is unchanged.

The components of a adduct are separate compounds, each with its own numbering, the Blue Book, “Names are formed by citing the names of individual compounds in the order of the formula connected by long (em) dashes”), so the em dash separates scopes exactly as the space between salt words does: the ‘6-en’ of one component and the ‘3-oxo’ of the next are not one carbon (PubChem-1M row: five ketone / enamine components, each valid).