orthonym.assembly.fragment_naming#

Note

Internal API. Names and behaviour may change between releases.

Fragment naming infrastructure with cycle-detection guard.

Provides thread-safe cycle detection to prevent infinite loops when fragment naming calls name_compound recursively. Uses a visited-SMILES set (threading.local pattern) instead of an arbitrary depth counter.

The visited set tracks which SMILES are currently being named up the call stack. If a SMILES is encountered that’s already being processed, cycle detection returns None to break the recursion. A safety-net maximum visited set size (20) prevents unbounded recursion from decomposition chains where every fragment SMILES is unique.

Usage:

from orthonym.assembly.fragment_naming import name_fragment_recursively

# Inside a naming function that needs to recursively name a sub-fragment: fragment_name = name_fragment_recursively(“CCO”) if fragment_name is None:

# Cycle detected or naming failed – use fallback …

orthonym.assembly.fragment_naming.start_naming_session()#

Initialize runtime fragment cache and visited set for a naming call.

The runtime cache stores (canonical SMILES -> name) pairs discovered during a single top-level naming call. The visited set tracks which SMILES are currently being named to detect cycles.

Only the outermost call starts a session; nested calls inherit the parent’s cache and visited set.

⚠ “Outermost” is an EXPLICIT COUNTER, not `len(visited) == 0`. Inferring it from the visited set was a latent defect with five milestones of exposure: if an exception escaped a fragment naming without discarding its SMILES, visited stayed non-empty for the rest of the thread, so is_top_level_naming never returned True again and namer.name stopped publishing general_fallback_ctx / best_effort_ctx — best-effort silently reverted to the PIN substituent vocabulary, with no error anywhere. It surfaced as a test that passed alone and failed in a 17-file run under load, i.e. it needs a real interruption, which is what made it rare and long-lived.

The counter cannot get stuck: it is decremented in end_naming_session, which every caller invokes from a finally, and it floors at 0.

orthonym.assembly.fragment_naming.end_naming_session()#

Close one naming call; the OUTERMOST one clears the session state.

Nested calls must not clear their parent’s cycle-detection state, which is why this is depth-guarded at all. But the outermost call now clears unconditionally rather than asking visited for permission — see start_naming_session for the defect that asking caused.

exception orthonym.assembly.fragment_naming.PerfBudgetExceeded#

Bases: BaseException

Raised when a per-top-level macrocycle-hang budget (_PERF_BUDGET or _ANALYSIS_CALL_BUDGET) is exhausted mid-analysis.

Derives from BaseException (NOT Exception) deliberately: the hot loops live many frames below dozens of broad except Exception: handlers on the recursive naming path, any one of which would otherwise swallow the signal and let the hang resume. As a BaseException it unwinds straight to the _budget_scope wrapper around the true-outermost name, which is the ONLY site that catches it and turns it into a clean abstain. It must never escape that boundary. (No bare except: exists in the package, so nothing between the hot loop and that boundary intercepts it.)

orthonym.assembly.fragment_naming.spend_perf_work(n=1)#

Charge n INNER-OP units (polycyclic path DFS / pairing loop; fused per-candidate substructure attempt) against _PERF_BUDGET.

Raises PerfBudgetExceeded when exhausted. A no-op when no budget is armed (a direct producer call outside any name scope keeps its exact prior behaviour), and a pure counter when measurement mode is on.

orthonym.assembly.fragment_naming.spend_analysis_call(n=1)#

Charge n EXPENSIVE-ANALYSIS-CALL units (one von-Baeyer analyze / one fused-heterocycle core-match) against _ANALYSIS_CALL_BUDGET.

Same contract as spend_perf_work — raises PerfBudgetExceeded when exhausted, no-op outside a name scope, counter in measurement mode.

orthonym.assembly.fragment_naming.disarm_hang_budgets()#

Disable both macrocycle-hang budgets for the remainder of this name scope. Called at the outermost boundary ONCE the abstain decision is made, so the descriptive/coordination-fallback finishing work (which itself re-enters the fused matcher and von-Baeyer via _classify) cannot re-trigger PerfBudgetExceeded and escape the boundary.

orthonym.assembly.fragment_naming.rearm_hang_budgets()#

Re-arm both macrocycle-hang budgets to a FRESH ceiling.

Used by the outermost PerfBudgetExceeded boundary to BOUND a single last-resort whole-molecule rescue attempt made AFTER the main-path budget was exhausted (the _try_perf_budget_t4_rescue recovery). A fresh ceiling guarantees the rescue itself terminates: if the rescue’s own analysis re-explodes it re-raises PerfBudgetExceeded (caught by the rescue -> clean abstain) rather than hanging. Mirrors the arming enter_name_scope does on the 0->1 transition; a constant of 0 (env OFF switch) leaves the budget unarmed (None) so the corresponding spend_* stays a permanent no-op.

orthonym.assembly.fragment_naming.enter_name_scope()#

Arm the per-top-level fragment work budget AND memo cache at the outermost name.

Increments a raw name call-stack counter; on the 0 -> 1 transition (the TRUE outermost call) it (re)initialises the work budget and allocates the whole-molecule fragment memo cache. Nested name calls – including the recursion re-entry through name_compound and the isolated producer – share both. Crucially, isolated_naming_session and the nested end_naming_session never reset these, so the memo survives the whole molecule (this is what stops a giant from re-exploring the same fragment thousands of times – a per-molecule branch-cache model).

exception orthonym.assembly.fragment_naming.OptionalNamingCapExceeded#

Bases: BaseException

Raised by count_naming_pass when an optional re-naming of the molecule (naming_pass_cap) has run all the naming passes it was allowed. A BaseException like PerfBudgetExceeded, so the except Exception fallbacks inside the producers do not absorb it and it unwinds to the frame that armed the cap, which keeps the name it already has.

orthonym.assembly.fragment_naming.count_naming_pass()#

Count one naming pass (one Orthonym._name_impl call) of the current outermost name (enter_name_scope resets the count). Raises OptionalNamingCapExceeded when a cap armed by naming_pass_cap is passed. A no-op count outside any name scope.

orthonym.assembly.fragment_naming.naming_passes()#

The naming passes (count_naming_pass) the current outermost name has made so far; 0 outside any name scope.

orthonym.assembly.fragment_naming.naming_pass_cap(allowed)#

Allow the body at most allowed more naming passes: the next pass past that raises OptionalNamingCapExceeded (caught by the caller). The count is deterministic – the passes a naming makes depend on the molecule alone (the fragment memo starts empty for every outermost name) – so a capped body gives the same outcome in every process. Nested caps keep the tighter one; the previous cap is restored on exit.

orthonym.assembly.fragment_naming.name_scope_depth()#

The raw name call-stack depth (enter_name_scope): 1 inside the body of the TRUE outermost name, the only frame that owns the hang budgets, 0 outside any name.

orthonym.assembly.fragment_naming.exit_name_scope()#

Close one name scope; the outermost one disarms the budget and frees the whole-molecule memo cache.

orthonym.assembly.fragment_naming.spend_fragment_work()#

Charge one fragment-naming unit against the top-level budget.

Returns True if work may proceed, False if the budget is exhausted (the caller must abstain). Returns True when no budget is armed – e.g. a direct producer call outside any name scope – so non-name entry points keep their exact prior behaviour.

orthonym.assembly.fragment_naming.own_hang_budgets()#

Run the body with hang budgets of its own: every budget that is armed (perf_budget, analysis_budget, work_budget) starts the body at its full ceiling, and the enclosing values are put back on exit, so the body neither spends nor sees the enclosing molecule’s budgets. A disarmed budget stays disarmed; a no-op outside any name scope.

For the components of a adduct: each is a compound of its own , the Blue Book, “Names are formed by citing the names of individual compounds”), named by a nested name that otherwise shares the whole assembly’s budgets, so a mixture of four drug-size macrocycles ran out of the 500 analysis calls one compound gets (PubChem 1M: 296, 102, 65 and a fourth component that tripped the budget, abstaining the whole drawing). Still bounded – each component by the ceilings of one compound – and a trip inside the body raises PerfBudgetExceeded to the outermost name as before.

orthonym.assembly.fragment_naming.isolated_naming_session(reset_cache=False)#

Run a nested naming as if it were a fresh TOP-LEVEL call.

Saves the current session state (session_depth + cache + visited), resets to a clean depth-0 session, and restores it on exit. Used by namer’s recovery-lane producer: name_t4_complete is conceptually a fresh whole-molecule naming, but the recovery lane invokes it mid-name with session_depth >= 1, so its recursion consumes the shared MAX_NAMING_DEPTH budget from an elevated floor and a deep substituent hits the cap prematurely – it then DEGRADES to an abstention where a standalone call names the molecule completely (measured: CC(=O)NCN(C)N=O and the in-scope suppressed cohort). Isolating the session gives the full depth-0 budget, exactly as a direct call gets. Restores on exit so the enclosing session continues unperturbed. Never raises out of the restore.

reset_cache (CQ5 Task 1, default False = the T4-producer behaviour below): ALSO save the whole-molecule fragment memo cache, install a fresh empty one for the isolated body, and restore the original on exit. The default keeps the cache LIVE (see the giant-hang note below); the opt-in is for the best-effort clean fall-through, which simulates a fresh TOP-LEVEL name and so needs the fresh cache a true top-level call gets from enter_name_scope. WHY it matters: the memo caches (canonical SMILES -> name), and the comment below calls that “a context-free pure function” – but a cached entry for a substituent the PRIMARY pass could not name (it hit MAX_NAMING_DEPTH from the elevated session floor and was memoized as a recursion_depth_fallback SKIP) is NOT context-free: the skip is a function of the depth budget at cache time, not of the SMILES alone. Resetting the session depth alone (below) gives the isolated body the full recursion budget, but with the poisoned skip still in the shared cache the deep substituent is read back as unnameable and the good name is never produced – exactly the COP(=O)(C=C(F)F)C=C(F)F drop (RISK 4). A fresh cache lets the isolated body re-derive those fragments from the depth-0 budget.

orthonym.assembly.fragment_naming.speculative_fragment_naming()#

Run a nested naming whose ONLY product is its return value.

For a caller that names a prefix merely to SORT by it (a (g) key built before the real assembly runs): every piece of per-molecule fragment state the nested call could leave behind is put back on exit – the fragment memo cache (the body works on a copy, so it still reads what is cached but its writes are dropped; the cache is not context-free, see isolated_naming_session), the depth counters and the three work budgets (they still bound the body, so a runaway still stops, but the spend is not charged to the real naming).

orthonym.assembly.fragment_naming.get_naming_depth()#

Get current recursion depth proxy for fragment naming.

Returns the size of the visited set, which represents how many fragments are currently being named up the call stack.

Returns:

Number of fragments currently being named (0 = top-level).

Return type:

int

orthonym.assembly.fragment_naming.is_top_level_naming()#

Check if we’re at the top level (not inside any recursive naming).

Returns:

True if no fragments are currently being named.

Return type:

bool

orthonym.assembly.fragment_naming.name_fragment_recursively(smiles, style='pin', **_kwargs)#

Name a molecular fragment with cycle-detection guard.

Uses a visited-SMILES set to detect and break circular recursion. Before naming a fragment, checks if its canonical SMILES is already being processed up the call stack. If yes (cycle detected), returns a cached name or None.

A safety-net maximum visited set size (_MAX_VISITED_SIZE=20) prevents unbounded recursion from decomposition chains where every fragment SMILES is unique (different capping produces different SMILES).

SMILES is canonicalized before processing to ensure consistent keys.

Parameters:

smiles (str) – SMILES string of the fragment to name.

Returns:

IUPAC name if successful, None if cycle detected or naming fails.

Return type:

str | None