orthonym.assembly.universal_substituent#
Note
Internal API. Names and behaviour may change between releases.
Phase B.2 – the unconditional recursive substitutive namer core.
name_universal_substitutive(mol) is a NEW, self-contained T4/best-effort
producer: it NEVER declines a hard branch. Where the existing recursive
substituent namer (assembly/substituent_enumerator.py) hits an unnameable
fragment and does return None / emits the 'substituent' sentinel –
which fails the WHOLE enclosing candidate closed – this module re-enters
ITSELF on the branch subgraph and always bottoms out at a valid (possibly
ugly, non-PIN) systematic token: a simple leaf prefix, a von-Baeyer/’a’-
replacement skeleton, or a deeper recursive call. There is no depth cap.
Architecture (independent implementation, no depth cap – a bounded recursive substituent namer):
name(component) = parent(spine) + Sum(name(branch_i) rendered as -yl)
Termination (the shrinking-atom-set argument, verbatim from that research):
every recursive call is handed a component that is a PROPER SUBSET of its
caller’s component – the caller’s spine is non-empty (>=1 atom) and is
removed before any branch is computed, and branch components are pairwise
disjoint (each BFS is bounded by a monotonically GROWING exclude set built
from already-claimed atoms). So along any recursion path the component size
strictly decreases every call; recursion cannot be infinite. Neither
recursion here could otherwise cost unbounded WORK on a pathological input (a
depth>=1 local decline is not enough on its own, exactly
the failure mode this module must NOT reproduce), so this module adds an
explicit in-algorithm atom/node WORK BUDGET (_Budget, charged by
component size at every call) that fails CLOSED – returns None – when
exceeded. This is a counter, not a signal/timeout: it can never hang.
Coverage-by-construction (the 0-wrong carry): every _ComponentResult
carries covers – the exact set of heavy-atom indices it accounts for –
and the top-level entry point asserts the union of the flat binding list
equals the FULL heavy-atom set of the input before returning anything. Any
gap voids the candidate (None), never a partial name.
Scope of THIS module (be honest about what is built vs. deferred, per the
task brief): parent selection covers acyclic chains (own longest-path/tree
walk), single rings (own minimal cyclo-‘a’-replacement numbering) and
polycyclic ring systems (reusing rules.vonbaeyer_universal.
analyze_cage_universal + rules.polycyclic._build_parent_with_unsaturation
for the parent TEXT only – NOT general_engine’s substituent-recursion
tail, which is exactly the declining machinery this module replaces).
Isotopes, wildcard atoms and multi-fragment inputs are OUT OF SCOPE and void
the whole call (None). Radicals are OUT OF SCOPE for MORE than one
free-valence centre multi-site / diradicals / radical ions); a
SINGLE monovalent/divalent/trivalent centre is IN SCOPE for _build_ctx
(A2, C2) so name_universal_substituent_prefix can cite it as a
-yl/-ylidene/-ylidyne substituent prefix – see that function’s
docstring and t4_coverage._best_effort_candidate’s radical branch, the
only wired caller. name_universal_substitutive (the whole-molecule entry
point) still has NO suffix logic for that centre and will name the
constitution as if it were fully saturated – harmless (the caller’s OPSIN
-r gate rejects the mismatch) but never a rescue on its own. Indicated
hydrogen is also OUT OF SCOPE for this task (deferred – no witness in the
B2b brief requires it) and voids nothing on its own: this module simply does
not special-case it, which is a pre-existing gap (unrelated to charge), not
something this task introduces or worsens.
Task B2b (this round) LIFTS the blanket per-atom-charge void and replaces it
with real charge PERCEPTION, reusing assembly.general_engine’s existing
charge-suffix primitives (_charge_suffix_text / _zwitterion_suffix_plan
/ _elide_before_ionic_suffix) so charge is spelled the SAME way as the
rest of the codebase, never reinvented. This is backstopped by a general
RAW-formal-charge void guard (fix a performance pass below) so that a charge the reused
primitives cannot spell always VOIDS the whole call rather than being spelled
wrong. Scope, precisely (fail-closed on everything else – an unspellable
charge VOIDS the whole call, never mis-names):
A GENUINE ionic centre (
perception.ions.get_ion_sites, which already excludes INTERNAL charges – nitro, N-oxide, azide, diazo – these are not “ionic centers” needing a suffix) is only ever resolved when it sits on the SPINE atoms of some level of the recursion (top-level parent or a substituent branch’s own local parent) – never as a bare prefix. Every leaf-shortcut (_leaf_shortcut) BAILS on any component containing a genuinely-charged atom (other than the dedicated nitro shape below), so a charged atom always falls through to the generic spine machinery, which forces it onto SOME level’s spine (the branch-discovery/forced-root argument: a component shrinks every recursive call, and a size-1 component always makes its own sole atom the spine).Net-charged, single-sign species (a plain cation or anion, e.g. a quaternary ammonium or a skeletal carbanion):
general_engine. _charge_suffix_texton whichever spine holds the site(s) – SAME scope as general_engine’s own P5 layer, so e.g. an FG anion (carboxylate, alkoxide, phenolate, sulfonate,…) or an FG cation (diazonium, acylium) is OUT OF SCOPE and VOIDS (general_engine’s own charged-species PIN path owns those, not this general/best-effort producer).Net-0 zwitterions with BOTH ionic centres SKELETAL to the SAME spine ,
general_engine._zwitterion_suffix_plan(..., allow_fg_anion= False)): resolved the same way. The FG-anchored “-olate” branch , an anion hanging off a characteristic group not itself on the spine) is explicitly DISABLED (allow_fg_anion=False) – this producer has no functional-group-suffix layer to hold an atom out of substituent discovery for it, so it is deferred, not built. A carboxylate-anchored zwitterion (amino-acid style, e.g. glycine) therefore VOIDS – carboxylate is out of scope for BOTH the skeletal and the -olate branch of the reused primitive (it is owned by the charged-lever’s dedicated ester-anion-word mechanism, a different, specialized producer this module does not invoke).Nitro (
C-N(+)(=O)[O-], internal charge) is spelled directly as a dedicated, charge-and-bond-order-VALIDATED leaf shortcut (_nitro_shortcut) exactly likecarboxy/cyano– never via the genuine-ion machinery above. Its nitrogen is also excluded from ordinary chain-spine CONTINUATION (_is_nitro_rootin_tree_neighbors) so it can never be mis-threaded into a replacement-nomenclature chain as if it were an ordinary (uncharged, standard-valence) heteroatom – that would silently drop the charge information a plain “aza” locant carries no trace of.Any OTHER shape a genuine ionic centre can take (multiply-charged, mixed cation+anion under a nonzero net charge, more than one cation/anion pair, an ionic centre split across two different spine levels,…) is out of scope for the reused primitives and VOIDS the whole call – a final top-level assertion (mirroring the atom-coverage assertion) checks that EVERY genuine ionic-centre atom index was actually resolved by some level’s suffix, not merely trusted to be, before returning a name.
Fix a performance pass (task-review + a review adversarial, both on `` – see
internal notes
task-B2b-fixround1-findings.md`): the four bullets above governed only the
GENUINE ionic centres get_ion_sites reports. But get_ion_sites
STRIPS INTERNAL and semipolar charges (N-oxide, azide, diazo,
nitrone, nitrile oxide, nitronate, aci-nitro, nitrate ester, thionitro, and
the charge-drawn S/P-oxides), so those charged atoms were invisible to BOTH
_resolve_spine_charge and the charge-coverage assertion – while the
spine builders thread atoms by ELEMENT SYMBOL ONLY, with no formal-charge
check, and happily absorbed such an atom into the skeleton AS IF NEUTRAL,
yielding a coverage-complete name of a DIFFERENT molecule (all measured
mis-naming on ; nitro was the sole exception, kept out of the
chain via ``_is_nitro_root, but its O atoms could still be shredded as a
BFS seed / branch root, so polynitro mis-named too). The fix is ONE general
RAW-formal-charge void guard at the top-level entry point: VOID the whole
result whenever any atom carries a nonzero raw formal charge that was NOT
consumed by (a) the genuine-ion suffix machinery
(cation_sites | anion_sites, asserted resolved) or (b) a /
internal centre actually rendered by a dedicated internal-charge leaf
(nitro/azido/diazo/…, tracked as
_ComponentResult.internal_atoms). get_ion_sites lists every
nonzero-charge atom then removes the internal set, so
cation_sites | anion_sites is EXACTLY the non-internal raw-charged atoms
– the guard therefore fires precisely on the internal charges, closing the
whole class (N-oxide/azide/diazo/nitrone/… and polynitro shredding) in one
set-membership check WITHOUT building any of their nomenclature (they
correctly VOID = abstain; the floor degrades, never mis-names).
Gating: the constitution producers here are pure, but _resolve_floor_stereo
(the stereo floor) DOES gate its own stereo candidates through
validation.reconstruct.verify_or_none (full-InChIKey round-trip, fail-closed)
and returns the stereo-blind plain name when none round-trips — so a wrong or
unparseable stereo descriptor (incl. the offered relative cis/trans)
degrades to abstain, never ships. (M2: was previously worded “PURE PRODUCER,
never calls verify_or_none” — that was true before the stereo floor gained rungs
1/1b/1c.)
- orthonym.assembly.universal_substituent.DEFAULT_ATOM_WORK_BUDGET = 20000#
Generous for any real organic molecule (a dev split’s largest is well under a few hundred heavy atoms with modest branch nesting); a pathological giant with deep alternating branch nesting re-charges the same atoms once per ancestor call, so this trips in bounded, FAST (no I/O, no subprocess) Python work long before it would hang. This is the CALLER-TUNABLE cumulative recursive-work budget – it is NOT, by itself, a safety ceiling on raw atom count; see
_MAX_ATOMS_FOR_PERCEPTIONbelow.
- class orthonym.assembly.universal_substituent.UniversalResult(name, bindings, covers, acyloxy_leaf_fired=False, prefix_order_fallback=False)#
Bases:
objectPublic result: a name string + the atom->token coverage binding.
- name: str#
- bindings: Tuple[Tuple[str, FrozenSet[int]], ...]#
- covers: FrozenSet[int]#
- acyloxy_leaf_fired: bool = False#
- prefix_order_fallback: bool = False#
- orthonym.assembly.universal_substituent.name_universal_substitutive(mol, atom_work_budget=20000, force_vonbaeyer_spiro=False, acyloxy_leaf=True, prefix_order_fallback=False)#
Name mol unconditionally, or return
None(void – never partial).prefix_order_fallback(default False: byte-identical behaviour) arms the last stereo rung_prefix_order_fallback(see there); onlyt4_coverage.name_prefix_order_fallbacksets it.Nonehappens for exactly six reasons, all fail-closed:a scope guard (isotopes / radicals / wildcard atoms / multi-fragment input) – explicitly out of scope for this task, never silently mis-named;
the work budget trips on a pathological input;
the atom-coverage assertion finds a gap (should not happen given the construction, but is asserted rather than trusted);
a genuine ionic centre (see module docstring’s charge-scope paragraph, Task B2b) that cannot be faithfully expressed by the reused
general_enginecharge-suffix primitives on any level’s spine – an FG anion/cation, a mixed-sign net-charged species, a multiply-charged centre, or an ionic centre split across two different spine levels are all out of scope and void rather than ship a name that omits the charge (the top-level charge-coverage assertion, symmetric to the atom-coverage one, catches this even if a lower-level construction bug would otherwise have missed it);a INTERNAL / semipolar charge (N-oxide, azide, diazo, nitrone, nitrile oxide, nitronate, aci-nitro, nitrate ester, thionitro, charge-drawn S/P-oxide, or a shredded polynitro) –
get_ion_sitesstrips these, so they never reach reason 4’s machinery; the top-level RAW-formal-charge void guard (fix a performance pass; see module docstring’s fix-a performance pass paragraph) voids any atom whose nonzero raw formal charge was NOT consumed by the genuine-ion suffix machinery or a rendered_nitro_shortcut, so an internal charge this module cannot spell VOIDS rather than being absorbed into the skeleton as if neutral (a coverage-complete name of a DIFFERENT molecule – the whole defect this fix round closes);ANY other unexpected exception (fix a performance pass, finding 2). This is a pure
Optional-contracted producer: it must never raise. Measured holes this closes: a spine longer than 9,999 atoms reachesdata/chain_names.py’s “chain length outside supported range (1-9999)”ValueErrorvia_build_parent_with_unsaturation(now moot in practice below_MAX_ATOMS_FOR_PERCEPTION, but kept as defense in depth rather than relying on that cap alone), and a caller who passes a hugeatom_work_budgetre-enables an unbounded Python call-stack depth (RecursionError) that the normal (default-budget) recursion depth (~200, arithmetically safe against Python’s ~1000 default limit) would never reach. Caught here rather than narrowly patched at each raise site, because a pure producer’s contract is “never raise”, not “never raise for the causes I’ve already found”. NOTE: this broad catch cannot help against a process-level crash (segfault) – see_MAX_ATOMS_FOR_PERCEPTIONfor that measured, separate hole.
- orthonym.assembly.universal_substituent.name_universal_substituent_prefix(mol, frag_atoms, attach_idx, bond_order=1, atom_work_budget=20000)#
Name a BRANCH subgraph as a
-yl/-ylidene/-ylidynesubstituent prefix over the WHOLE-molecule perception context, orNone(void).Task B3 (the branch-fallback wiring): where the existing recursive substituent namer (
substituent_enumerator.name_substituent) declines a hard branch, this re-enters the SAME unconditional recursive machinery on just that branch and renders a covering-ylprefix, cited at the branch’s own attachment locant.attach_idxis the atom WITHINfrag_atomsthat bonds to the parent;bond_orderis that attachment bond’s order (1/2/3 -> yl/ylidene/ylidyne).Fail-closed, exactly like
name_universal_substitutive: returnsNone(never a partial or a mis-cover) whenever the whole-molecule scope guards trip, the branch is not a subset of the heavy-atom set, the attach atom is not in the branch, the recursive namer voids, or the branch-restricted coverage / charge assertions (the analogues of the whole-molecule ones, scoped tofrag_atoms) fail. The caller gates the returned string through the existing E1 + round-trip net (0-wrong preserved); this is a PURE producer and never ships.