orthonym.assembly.retained_substitution#

Note

Internal API. Names and behaviour may change between releases.

a phase — Continuous Triviality Controller (P2; AUTONOM-derived tree visitor).

Implements /02/03 via a pure functional transform NameTreeNode -> NameTreeNode':
  • Depth-first leaves-first traversal (Pitfall 4 avoidance: rewrite children BEFORE the parent’s swap-decision so the parent sees the post-swap children).

  • Per-Type runtime dispatch (;..3): Type 1 unconditional, Type 2a principal-group-bound, Type 2b SMARTS closed-list, Type 2c per-entry-override-or-Type-3, Type 3 bare-only + locant_context.

  • Multiplier-feedback after every swap (;): re-derive the di<->bis multiplier on the swapped node via get_multiplier_prefix (which consults is_complex_substituent) — never a static per-entry flag.

  • Re-alphabetization of the node whose prefixes changed, applied when that node is re-emitted : _alphabetize_prefixes (NOT a sibling-reorder at the swapped node’s own level — the leaves-first walk re-alphabetizes a parent when the parent is visited).

  • runtime OPSIN-RT cache (;; memoized per (post_swap_subtree_str, pre_swap_canon) in a plain dict). On RT mismatch: silently keep systematic form + emit ControllerEvent(kind='swap_reject_rt_unsafe').

CRITICAL serializer invariant (#2 fix, reviews iter 1): the Pass-2 serializer short-circuits on a non-None fragment_legacy (name_tree_to_string.py:96) and IGNORES node.prefixes. So ANY rewrite that changes parent_stem (the swap) OR changes a child (new_prefixes != node.prefixes) MUST set fragment_legacy=None, or the serializer renders the stale legacy string and discards the rewrite. The helper _replace_preserving_or_resetting_legacy encodes the conditional: reset when a child changed, preserve when nothing changed.

SMILES recovery is a 3-path cascade (#3 fix, reviews iter 1): an earlier hint-based reverse-mapping path was REMOVED — the per-atom locant hint is NOT a field of the frozen NameTreeNode (name_tree.py:90-103); it lives on NamingResult (name_tree.py:128) and is never forwarded onto nodes, so the lookup always returned None and that path was dead code. Recovery therefore starts at token-match (labelled Path B for cascade continuity), then reverse-OPSIN (Path C), then bail (Path D).

Recovery reach is bounded (#4, reviews iter 1): match_token_atoms_in_mol cannot map a specific IR node to a specific physical duplicate fragment; for duplicate substituents it picks a deterministic-but-arbitrary match. The recovered fragment SMILES is correct for true duplicates (same canonical SMILES), but recovery can MISS for nested substituents — so controller_fired_count may trail controller_reach_count even for seed members. Plan-04 reports BOTH counts (internal notes) and never claims reach the recovery cannot deliver.

Thread-safety (#6 REJECTED, reviews iter 1): the OPSIN-RT cache is a plain per-instance dict. The benchmark (benchmark_multi_corpus.py) is single-threaded over rows (max_workers=1 timeout wrapper at:191; serial rows at:446; no –threads arg) and each OpsinOracle belongs to one Orthonym instance — no lock needed.

Module boundary preserved per: this module is the ONLY new code in the assembly package; name_tree.py, name_tree_to_string.py, naming_utils.py stay UNTOUCHED.

class orthonym.assembly.retained_substitution.ControllerEvent(kind, canonical_smiles, seed_entry_name, substitution_type, rt_oracle_result, p_section_cite)#

Bases: object

a phase diagnostic event (RESEARCH section 4.5).

kind: str#
canonical_smiles: str | None#
seed_entry_name: str | None#
substitution_type: str | None#
rt_oracle_result: bool | None#
p_section_cite: str | None#
class orthonym.assembly.retained_substitution.OpsinOracle(opsin_jar=None)#

Bases: object

runtime OPSIN-RT cache (+ RESEARCH R-02 + Pitfall 5).

Plain per-instance dict cache keyed by (post_swap_subtree_str, pre_swap_canon). #6 REJECTED (reviews iter 1): no lock needed — the benchmark is single-threaded over rows.

rt_safe(pre_swap_canon, post_swap_subtree_str)#

internal notes T2: parse post_swap_subtree_str via OPSIN, canonicalize, compare to pre_swap_canon. Cached per (post_swap_subtree_str, pre_swap_canon).

name_to_smiles(name)#

Path C reverse-OPSIN helper. Returns canonical SMILES or None on failure.

A DEFINITIVE result (a real SMILES, OPSIN’s rejection, or a canonicalisation failure on OPSIN’s output) is cached; a transient invocation failure (timeout / OSError) is NOT cached (, code review 2026-06-02), so a one-off failure never poisons later lookups of the same name.

name_to_opsin_smiles(name)#

OPSIN’s own SMILES of name, byte for byte as OPSIN wrote it (-r -osmi), or None when OPSIN rejected the name or could not be consulted.

name_to_smiles returns RDKit’s canonical SMILES of this string, which is RDKit’s reading written back by RDKit; a check that must read the name’s structure as OPSIN built it with another toolkit (namer. _lone_pair_configuration_verified) needs the string itself. Shares the parse with name_to_smiles (cached on the same definitive outcomes; a transient failure is not cached).

parse_status(name)#

Three-valued OPSIN parse outcome for the validity gate .

Returns one of:
  • "parsed" — OPSIN accepted the name (emitted SMILES);

  • "rejected" — OPSIN ran and definitively rejected it (no SMILES);

  • "unavailable" — the parse could not be performed (no JAR / timeout / OSError).

"unavailable" is NOT a rejection: it says nothing about the name. The old name_to_smiles is not None check collapsed the two . What a caller does with it is the caller’s decision: the validity gate treats it as “unverified” and fails CLOSED (TRIAGE g7 C01, 2026-09-27; it used to fail open and ship the unverified candidate, a wrong molecule when the candidate was wrong), after _one_shot’s retry ladder has given a slow OPSIN its chance.

Only definitive outcomes are cached, so a transient failure never poisons a later lookup of the same name .

orthonym.assembly.retained_substitution.apply_triviality_controller(tree, mol, principal_group, opsin_oracle=None, *, enabled=False)#

a phase entry point (internal notes +).

  • When enabled=False: returns the input tree UNCHANGED (Stage A default-OFF invariant per).

  • When enabled=True + is_coarse_node(tree): returns the input tree UNCHANGED (coarse passthrough).

  • Otherwise: depth-first leaves-first walk; per-Type dispatch; rewrite-on-match + RT-gate.