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 afinally, 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
visitedfor permission — seestart_naming_sessionfor the defect that asking caused.
- exception orthonym.assembly.fragment_naming.PerfBudgetExceeded#
Bases:
BaseExceptionRaised when a per-top-level macrocycle-hang budget (
_PERF_BUDGETor_ANALYSIS_CALL_BUDGET) is exhausted mid-analysis.Derives from
BaseException(NOTException) deliberately: the hot loops live many frames below dozens of broadexcept Exception:handlers on the recursive naming path, any one of which would otherwise swallow the signal and let the hang resume. As aBaseExceptionit unwinds straight to the_budget_scopewrapper around the true-outermostname, which is the ONLY site that catches it and turns it into a clean abstain. It must never escape that boundary. (No bareexcept: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
nINNER-OP units (polycyclic path DFS / pairing loop; fused per-candidate substructure attempt) against_PERF_BUDGET.Raises
PerfBudgetExceededwhen exhausted. A no-op when no budget is armed (a direct producer call outside anynamescope keeps its exact prior behaviour), and a pure counter when measurement mode is on.
- orthonym.assembly.fragment_naming.spend_analysis_call(n=1)#
Charge
nEXPENSIVE-ANALYSIS-CALL units (one von-Baeyeranalyze/ one fused-heterocycle core-match) against_ANALYSIS_CALL_BUDGET.Same contract as
spend_perf_work— raisesPerfBudgetExceededwhen 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-triggerPerfBudgetExceededand escape the boundary.
- orthonym.assembly.fragment_naming.rearm_hang_budgets()#
Re-arm both macrocycle-hang budgets to a FRESH ceiling.
Used by the outermost
PerfBudgetExceededboundary to BOUND a single last-resort whole-molecule rescue attempt made AFTER the main-path budget was exhausted (the_try_perf_budget_t4_rescuerecovery). A fresh ceiling guarantees the rescue itself terminates: if the rescue’s own analysis re-explodes it re-raisesPerfBudgetExceeded(caught by the rescue -> clean abstain) rather than hanging. Mirrors the armingenter_name_scopedoes on the 0->1 transition; a constant of 0 (env OFF switch) leaves the budget unarmed (None) so the correspondingspend_*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
namecall-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. Nestednamecalls – including the recursion re-entry throughname_compoundand the isolated producer – share both. Crucially,isolated_naming_sessionand the nestedend_naming_sessionnever 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:
BaseExceptionRaised by
count_naming_passwhen an optional re-naming of the molecule (naming_pass_cap) has run all the naming passes it was allowed. ABaseExceptionlikePerfBudgetExceeded, so theexcept Exceptionfallbacks 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_implcall) of the current outermostname(enter_name_scoperesets the count). RaisesOptionalNamingCapExceededwhen a cap armed bynaming_pass_capis passed. A no-op count outside anynamescope.
- orthonym.assembly.fragment_naming.naming_passes()#
The naming passes (
count_naming_pass) the current outermostnamehas made so far; 0 outside anynamescope.
- orthonym.assembly.fragment_naming.naming_pass_cap(allowed)#
Allow the body at most
allowedmore naming passes: the next pass past that raisesOptionalNamingCapExceeded(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 outermostname) – 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
namecall-stack depth (enter_name_scope): 1 inside the body of the TRUE outermostname, the only frame that owns the hang budgets, 0 outside anyname.
- orthonym.assembly.fragment_naming.exit_name_scope()#
Close one
namescope; 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
namescope – so non-nameentry 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 anynamescope.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
namethat 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 raisesPerfBudgetExceededto the outermostnameas 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 bynamer’s recovery-lane producer:name_t4_completeis conceptually a fresh whole-molecule naming, but the recovery lane invokes it mid-namewithsession_depth >= 1, so its recursion consumes the sharedMAX_NAMING_DEPTHbudget 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=Oand 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-LEVELnameand so needs the fresh cache a true top-level call gets fromenter_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 hitMAX_NAMING_DEPTHfrom the elevated session floor and was memoized as arecursion_depth_fallbackSKIP) 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 theCOP(=O)(C=C(F)F)C=C(F)Fdrop (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