Graphical interface

An optional local GUI for exploring what the network knobs do.

rbfenet plan has some sixty knobs, and choosing among them means running it, opening the report, changing a flag, and holding the previous answer in your head. This subpackage serves a small local page that closes that loop: move a knob, see the network and its metrics, pin the run, move another knob, compare.

The design rule the whole thing hangs on is that the GUI’s state is an argv list. It defines no knob and builds no options object; rbfenetmap.gui.schema derives the form from the CLI’s own argument groups, and the filled form is serialized back to flags that rbfenetmap.cli._args.build_network_options() turns into options exactly as the command line does. So the GUI cannot drift from the CLI, and it can always show the user the precise rbfenet plan ... line that produced what they are looking at.

Standard library only – there is no extra to install and no optional dependency to probe.

Unlike rbfenetmap.viz, whose output is deliberately script-free because it is an artifact you email or attach to a ticket, this is an application and does use JavaScript.

The GUI’s form schema, derived from the CLI’s own argument parser.

This module defines no knob. It reads the same argument groups rbfenet plan assembles, and turns each argparse action into a form field. The GUI then serializes the filled form straight back to an argv list, which the CLI’s own build_network_options() and friends turn into options.

That round trip is the whole design. A GUI that kept its own list of knobs would be a second option surface, and a second option surface drifts: a flag added to the CLI would quietly not exist in the GUI, and a default moved in one place would silently disagree with the other. Here a new flag appears in the form with no change to this file, and tests/test_gui_schema.py fails until any flag deliberately left out is classified.

It also means the GUI can always show the user the exact command that produced what they are looking at, which is the point of the tool: explore in the browser, paste the rbfenet plan ... line into a job script.

Reaching into parser._action_groups is unavoidable – argparse exposes no public equivalent – and has precedent in rbfenetmap.cli._args.explicit_dests(), which does the same thing for the same reason.

rbfenetmap.gui.schema.EXPORT_KNOBS: frozenset[str] = frozenset({'design_lambda_max', 'design_lambda_min', 'design_total_ns'})

Knobs that change nothing about the planned network. The A-optimal sample allocation is read only by the Amber exporter, so moving these cannot move an edge – worth saying out loud in a tool whose whole display is the network.

rbfenetmap.gui.schema.KNOB_EXCLUSIONS: Mapping[str, str] = {'ligands': 'Input, not a knob. The session loads ligands and owns the path.', 'mapper_opt': 'Dead flag: parsed and never read at any create_mapper call site. See issue #55.', 'name_property': 'Input, not a knob. Applied at load time by the session.', 'progress': 'The GUI reports its own progress; the stderr renderer has nowhere to go.', 'weights_file': 'The page edits scoring weights inline and emits them as --weights, so the copied command shows the values that produced the network. A path to a JSON file the GUI cannot display would be a knob whose effect is invisible in the command line.', 'write_aligned': "Output plumbing. The GUI writes nothing to the user's tree on a run."}

Destinations that appear in the five shared argument groups but are deliberately not rendered as knobs, each for a stated reason. Classified rather than merely skipped: the drift test in tests/test_gui_schema.py requires every parser action to be either a field or a member of this set, so a flag added to the CLI cannot slip past unnoticed.

rbfenetmap.gui.schema.OUTPUT_DESTS: frozenset[str] = frozenset({'cost_units', 'export', 'export_dir', 'exporter_opt', 'out', 'show_rejected', 'validate_exporter'})

Destinations that exist on the plan subcommand but not in the five shared groups. Output plumbing the GUI supplies for itself. Listed so the drift test can account for every action on the real parser, not only the ones this module builds.

rbfenetmap.gui.schema.PIPELINE_KNOBS: frozenset[str] = frozenset({'adaptive_batch_size', 'adaptive_initial_neighbors', 'align', 'align_min_atoms', 'align_reference', 'cbfe_atom_weight', 'cbfe_base_cost', 'charge_change_policy', 'compat', 'consistency', 'core_rmsd_threshold', 'distance_threshold', 'explicit_edge', 'intermediate_beta', 'intermediate_generator', 'intermediate_max_cycle', 'intermediate_max_dist', 'intermediate_max_subgraph_dist', 'intermediate_min_link_score', 'intermediate_pose_attempts', 'intermediate_pose_rmsd_factor', 'intermediate_seed', 'intermediates', 'intermediates_per_gap', 'jobs', 'mapper', 'match_selection', 'max_intermediate_gaps', 'max_intermediates', 'max_softcore_atoms', 'max_softcore_fraction', 'mcs_timeout', 'min_core_atoms', 'min_mcs_fraction', 'pair_evaluation', 'pair_strategy', 'planner', 'prefilter', 'prefilter_k', 'prefilter_min_tanimoto', 'ring_policy', 'scorer', 'weights'})

Knobs that apply whatever planner is chosen, because they are consumed before or after selection rather than by the planner: input preparation, mapping, the soft-core feasibility policy, candidate generation (core/pairs.py), counterpoised pricing (core/cbfe.py), the post-selection consistency pass (core/consistency.py), intermediate generation, and the operational knobs.

rbfenetmap.gui.schema.PLANNER_KNOBS: Mapping[str, frozenset[str]] = {'complete': frozenset({'allow_disconnected', 'banned_edge', 'n_edges'}), 'explicit': frozenset({'allow_disconnected', 'banned_edge', 'explicit_edge', 'n_edges'}), 'mst': frozenset({'allow_disconnected', 'banned_edge', 'cbfe', 'cluster_bridges', 'cluster_by', 'cycle_coverage_mode', 'edge_direction', 'edges_per_ligand', 'forced_edge', 'hub', 'max_cycle_size', 'max_diameter', 'min_cycle_coverage', 'n_edges', 'selection_objective'}), 'optimal': frozenset({'allow_disconnected', 'banned_edge', 'cbfe', 'design', 'design_candidate_factor', 'design_refine', 'edge_direction', 'edges_per_ligand', 'forced_edge', 'n_edges'}), 'redundant-mst': frozenset({'allow_disconnected', 'banned_edge', 'cbfe', 'cluster_bridges', 'cluster_by', 'cycle_coverage_mode', 'edge_direction', 'edges_per_ligand', 'forced_edge', 'hub', 'max_cycle_size', 'max_diameter', 'min_cycle_coverage', 'n_edges', 'n_redundancy', 'selection_objective'}), 'star': frozenset({'allow_disconnected', 'banned_edge', 'hub', 'hub_selection', 'n_edges'})}

Which selection knobs each built-in planner actually reads, as CLI destinations.

Transcribed from what each planner module touches on its options argument. Advisory: it drives a “this planner ignores that” badge, and nothing depends on it being complete. That matters because the alternative is worse – star, explicit and complete silently no-op some fourteen network flags today, and only --design and --cbfe are refused out loud.

rbfenetmap.gui.schema.WIDGETS: tuple[str, ...] = ('text', 'int', 'float', 'bool', 'choice', 'plugin', 'repeatable', 'path', 'path_list', 'tristate')

Every widget kind a field may carry. Closed so the page can switch on it exhaustively, and so a new argparse action type fails the schema test rather than rendering as a text box that quietly mangles its value.

rbfenetmap.gui.schema.inactive_dests(planner)[source]

Return the knobs planner will silently ignore.

Parameters:

planner (str) – A planner plugin name.

Returns:

Sorted destinations. Empty for a planner not in PLANNER_KNOBS – an unknown, probably third-party, planner gets no claims made about it rather than having every knob declared inactive.

Return type:

tuple of str

Notes

Advisory. The pipeline refuses only two of these out loud, --design and --cbfe, through the planner’s own check_design_support and check_cbfe_support. The rest are accepted and then not read, which is exactly the failure a form can prevent and a command line cannot.

rbfenetmap.gui.schema.knob_parser()[source]

Return a throwaway parser carrying exactly the knobs rbfenet plan accepts.

Assembled from the same five public group builders rbfenetmap.cli.main.build_parser() uses, so the flags, defaults, choices and help text are the CLI’s, not a copy of them.

Returns:

Not fit for parsing a real command line: it has no subcommands and none of the output flags. It exists to be walked.

Return type:

argparse.ArgumentParser

rbfenetmap.gui.schema.plan_schema()[source]

Return everything the GUI needs to render the knob form, as JSON-ready data.

Returns:

groups

One entry per argparse argument group, in the order rbfenet plan declares them, each with a title and a list of fields.

plugins

Every built-in plugin by kind, with availability and missing requirements.

scorer_weights

Default weight tables for the three configurable scorers.

planner_knobs, pipeline_knobs, export_knobs

The advisory relevance tables, so the page can grey out a knob the chosen planner will ignore.

compat_pins

Destinations each --compat level pins, which it refuses to be combined with.

exclusions

Destinations deliberately not offered, and why.

Return type:

dict

rbfenetmap.gui.schema.to_argv(values)[source]

Serialize filled form values into rbfenet plan flags.

Only what differs from the default is emitted, so the copied command line is the short one a person would have written rather than sixty flags most of which say nothing.

Parameters:

values (Mapping) – Destination to value. A destination absent from the mapping takes its default and emits nothing.

Returns:

The knob flags alone. The caller prepends plan and the input and output flags; those are the session’s business, not the form’s.

Return type:

list of str

Raises:

ValueError – If values names a destination that is not a knob. Loud rather than ignored: a silently dropped value would produce a command line that does not reproduce the network shown beside it, which is the one promise this module exists to keep.

Memoized mapping, so a knob can be moved without re-running the MCS searches.

The measurement this module is built on: over the shipped Tyk2 set – sixteen ligands, a hundred and twenty pairs, eight jobs – a full rbfenet plan takes about 2.1 s, and it takes about 2.1 s for every one of the twenty-one variants in the published matrix, whatever the planner or the selection knobs. The same run with --cbfe all, which skips mapping entirely, takes 0.5 s. Mapping is the cost, and mapping is the one stage that does not care which planner runs afterwards.

So the GUI wraps the mapper rather than reaching into the pipeline. build_network() already accepts a mapper instance, which makes CachingMapper a plugin like any other and needs no change to core. Moving a selection knob then re-runs the repair and the scorer – pure Python, and cheap – while the FindMCS calls come back from a dict.

The cache is keyed on molblocks rather than on ligand names, for the reason rbfenetmap.io.networkio gives for embedding molblocks instead of file paths: an AtomMapping is indices into a particular atom ordering, and is meaningless against a molecule that has been re-read into a different one. A name is not an identity; the atom block is.

class rbfenetmap.gui.cache.CachingMapper(wrapped, cache=None, *, should_cancel=None)[source]

Bases: AbstractMapper

A mapper that remembers what it has already mapped, and can be cancelled.

Parameters:
  • wrapped (AbstractMapper) – The real mapper. Every miss is delegated to it verbatim.

  • cache (MappingCache, optional) – Shared store. A fresh in-memory one is made if omitted.

  • should_cancel (callable, optional) – Polled before each pair. Returning True raises RunCancelled.

Notes

name is set on the instance to the wrapped mapper’s, shadowing the class attribute. That is what keeps a cached run indistinguishable from an uncached one: the name is recorded on every AtomMapping as its method and is serialized into the network JSON, so a CachingMapper that reported its own name would make every planned network say it was mapped by something that is not a mapping algorithm at all.

supports_pair(source, target)[source]

Delegate the cheap pre-check; it is not worth caching.

Parameters:
Return type:

bool

map_pair(source, target, options)[source]

Return the correspondence, from the cache when it is there.

Raises:
Parameters:
Return type:

AtomMapping

class rbfenetmap.gui.cache.MappingCache(path=None)[source]

Bases: object

Atom mappings kept by pair, mapper and mapping options.

Parameters:

path (Path, optional) – JSON file to load on construction and write on save(). None keeps the cache in memory for the life of the process.

hits, misses

Lookup counters, so the GUI can say why a run was fast.

Type:

int

Notes

Thread-safe. evaluate_pairs() maps pairs across a thread pool, so several lookups and stores are genuinely concurrent.

A failed mapping is cached too. A pair no MCS search can relate is precisely the pair that costs the full --mcs-timeout to fail, every time, and precisely the one whose answer will not change when a planner knob moves.

save()[source]

Write the cache to path, atomically. A no-op with no path set.

Written through a temporary file in the same directory and then renamed, so an interrupted save leaves the previous cache intact instead of a half-written file that the next load would discard.

Return type:

None

get(key)[source]

Return the cached result for key, or None on a miss.

Parameters:

key (str)

Return type:

AtomMapping | MappingError | None

put(key, result)[source]

Store result under key.

Parameters:
Return type:

None

clear()[source]

Drop every entry and reset the counters.

Return type:

None

exception rbfenetmap.gui.cache.RunCancelled[source]

Bases: Exception

Raised inside a mapper to abandon a planning run the user has cancelled.

Deliberately not a MappingError. build_candidate() catches that one and turns it into a mapper_failed rejection, so a cancellation spelled that way would not stop the run at all – it would quietly produce a network in which every pair not yet reached looks infeasible, which is far worse than not stopping.

Notes

Cancellation takes effect within one --mcs-timeout. The pool that maps pairs in parallel waits for its in-flight searches on the way out, and an FindMCS call already inside RDKit cannot be interrupted from Python. What this stops is every pair that has not started yet, which on a large set is nearly all of them.

One browser session: the loaded ligands, the runs, and the pinned comparisons.

A run is started from a filled form, evaluated on a worker thread, and polled. Threads rather than a synchronous handler because mapping is quadratic in the ligand count – past a thousand pairs a plan takes long enough that a blocking request would look like a hung browser, and there would be nowhere to put a cancel button.

Everything a run needs beyond build_network() is borrowed from the CLI rather than rebuilt. Ligand loading and alignment come from rbfenetmap.cli.commands._load() and the scorer from rbfenetmap.cli.commands._make_scorer(), both of which take the same argparse.Namespace this module already has. That matters: alignment deliberately sits outside the pipeline, so a GUI that skipped it would leave --align doing nothing while still printing it in the command line it offers to copy.

class rbfenetmap.gui.session.PlanRun(run_id, values, argv)[source]

Bases: object

One planning attempt, and whatever it has produced so far.

Parameters:
id
Type:

str

values

The form as submitted.

Type:

dict

argv

The full rbfenet argument list this run is equivalent to.

Type:

list of str

state
Type:

{“running”, “done”, “error”, “cancelled”}

done, total

Candidate pairs mapped, and how many there are to map. Under pair_evaluation="adaptive" total is a ceiling the run may stop short of.

Type:

int

error
Type:

str or None

network
Type:

Network or None

metrics

Exactly what rbfenet diagnose --format json reports, so a pinned comparison reads against the published variant matrix without translation.

Type:

dict or None

svg
Type:

str or None

property command: str

The run as a copy-pasteable shell command.

cancel()[source]

Ask the run to stop. Takes effect within one --mcs-timeout.

Return type:

None

as_dict(*, include_svg=True)[source]

Serialize for the browser. The network itself is never sent whole.

Parameters:

include_svg (bool)

Return type:

dict[str, Any]

report_html()[source]

Render the full self-contained report, once, on demand.

Deliberately not produced with the run. On the sixteen-ligand Tyk2 set the report is over two megabytes of inlined SVG, and generating one per knob change is exactly what would make the tool feel slow. The network diagram and the metrics are what the live panel needs; this is a button.

Return type:

str

class rbfenetmap.gui.session.PlanSession(ligands=None, *, name_property='_Name', cache_dir=None)[source]

Bases: object

The ligands, the mapping cache, the runs and the pins behind one served page.

Parameters:
  • ligands (Sequence[Path], optional) – Initial ligand files. May also be set later from the browser.

  • name_property (str) – Molecule property ligand names are read from.

  • cache_dir (Path, optional) – Where the mapping cache is persisted between sessions. None keeps it in memory.

Notes

One run at a time. A second start cancels the first rather than queueing it: the user has moved a knob, which means they are no longer interested in the answer to the previous question, and two concurrent runs would compete for the same worker threads and double the peak memory of the mapping stage.

set_ligands(paths, *, name_property=None)[source]

Point the session at a new ligand set and load it immediately.

Parameters:
  • paths (Sequence[str or Path]) – Files, directories, or shell-style patterns, in any mixture – the same latitude --ligands has once a shell has been through it.

  • name_property (str, optional)

Returns:

names, n_ligands and the paths as resolved – resolved rather than as given, so a pattern shows the browser which files it actually matched.

Return type:

dict

start(values)[source]

Validate values, then plan in the background.

Raises:

ValueError

For anything that can be judged without the ligands: an unparseable flag, a --compat level contradicting a knob it pins, a star strategy with no hub, a knob out of range, an edge both forced and banned. Raised here rather than on the worker thread so the browser is told at once instead of having to poll to discover that the run it just started was never going to work.

The checks that need the ligand count – an edge budget too small to span them, most of all – can only run once they are loaded, so those still surface on the run as state == "error".

Parameters:

values (dict[str, Any])

Return type:

PlanRun

cancel(run_id)[source]

Cancel a run by id, if it is still going.

Parameters:

run_id (str)

Return type:

None

pin(run_id, label=None)[source]

Keep a finished run’s numbers for comparison.

Only the metrics, the command and the form are kept; the network is left on the run it came from. A pin is something to read a table row from, and holding every pinned network would grow the session without bound over an afternoon’s exploring.

Parameters:
  • run_id (str)

  • label (str | None)

Return type:

dict[str, Any]

unpin(run_id)[source]

Drop a pin.

Parameters:

run_id (str)

Return type:

None

state()[source]

The whole session, for a browser that has just connected or reconnected.

Return type:

dict[str, Any]

rbfenetmap.gui.session.expand_ligand_paths(paths)[source]

Resolve ligand inputs the way a shell would before --ligands sees them.

Parameters:

paths (Sequence[str or Path]) – Files, directories, or glob patterns.

Returns:

Sorted matches for each pattern, and every non-pattern passed through untouched.

Return type:

list of Path

Raises:

FileNotFoundError – If a pattern matches nothing. A pattern that silently contributes no ligands is worse than one that fails: the run would go ahead over whatever else was listed and quietly plan a network across the wrong set.

Notes

rbfenet plan --ligands data/*.mol2 works because the shell expands the pattern into sixteen arguments before argparse ever runs. There is no shell behind a text box, so without this a pasted pattern reaches load_ligands() verbatim and fails as a missing file.

Directories are left alone rather than expanded here. load_ligands already scans them, non-recursively and by suffix, and duplicating that rule would be a second answer to which files count as molecules.

A small local HTTP server for the knob explorer.

Standard library only. There are eight endpoints and one page, which a framework would make marginally pleasanter to write at the cost of a runtime dependency, an extra to install, an autodoc_mock_imports entry, and a module that tests/test_smoke.py’s unconditional import walk would trip over in the default CI job.

Bound to the loopback interface unless told otherwise, and it says so loudly when told otherwise: it reads whatever ligand path the form names, with the privileges of whoever started it, and it is not written to face a network.

rbfenetmap.gui.server.build_server(session, *, host='127.0.0.1', port=8765)[source]

Create the server without starting it.

Parameters:
  • session (PlanSession)

  • host (str)

  • port (int) – 0 asks the operating system for a free one, which is what the tests use.

Return type:

http.server.ThreadingHTTPServer

rbfenetmap.gui.server.serve(ligands=None, *, host='127.0.0.1', port=8765, name_property='_Name', cache_dir=None, open_browser=True)[source]

Serve the knob explorer until interrupted.

Parameters:
  • ligands (Sequence[Path], optional) – Loaded at startup. Omit to choose a file from the page instead.

  • host (str) – Loopback by default. Any other value is a deliberate choice to expose a server that reads local files on request, and is warned about.

  • port (int)

  • name_property (str)

  • cache_dir (Path, optional) – Persists the mapping cache between sessions, which is what makes the second launch fast rather than only the second run.

  • open_browser (bool)

Return type:

None