Skip to content

Migrate/internal merge - #8

Merged
f-utro merged 38 commits into
mainfrom
migrate/internal-merge
Sep 3, 2026
Merged

Migrate/internal merge#8
f-utro merged 38 commits into
mainfrom
migrate/internal-merge

Conversation

@aritra90

@aritra90 aritra90 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Description

Added new capabilities to existing apps and adds QuVINE, the quantum-enabled graph embedding framework along with updated documentation and demonstrations.

Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🎨 Code style update (formatting, renaming)
  • ♻️ Refactoring (no functional changes)
  • ⚡ Performance improvement
  • ✅ Test update
  • 🔧 Configuration change
  • 🔨 Build/CI update

Related Issue(s)

Fixes #
Closes #
Related to #

Changes Made

Component(s) Affected

  • QProfiler
  • QSage
  • Data Generation
  • Embeddings
  • Evaluation
  • Learning (ML/QML models)
  • Visualization
  • CLI/Apps
  • Documentation
  • Tests
  • CI/CD
  • Other:

Testing

Test Configuration

  • Python version:
  • Operating System:
  • QBioCode version:

Tests Performed

  • Unit tests pass locally
  • Integration tests pass locally
  • Manual testing performed
  • Added new tests for new functionality

Test Details

# Example test code or commands used

Screenshots/Output (if applicable)

Checklist

Code Quality

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have removed any debugging code and print statements

Documentation

  • I have updated the documentation accordingly
  • I have updated docstrings for new/modified functions
  • I have added/updated examples if needed
  • I have updated the CHANGELOG (if applicable)

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

Dependencies

  • I have checked that no new dependencies are added unnecessarily
  • If new dependencies are added, I have documented why they are needed
  • I have updated requirements.txt or setup.py if needed

Breaking Changes

  • This PR introduces breaking changes (if yes, describe below)
  • I have updated the migration guide (if applicable)

Breaking Changes Description

Additional Notes

Reviewer Notes


For Maintainers

  • PR title follows conventional commit format
  • Labels are appropriate
  • Milestone is set (if applicable)
  • Documentation is updated
  • Tests are adequate
  • CI/CD passes

Aritra Bose and others added 30 commits August 28, 2026 14:23
…he reuse

Machine-learning correctness:
- QProfiler fit a *fresh* MinMaxScaler on X_test, normalizing the test set with
  test-set statistics. This both used held-out information and trained/evaluated
  the model under different transforms. Add qbiocode.scale_train_test (one scaler
  fit on train, applied to test) and use it. QProfiler metrics from before this
  commit are not comparable to metrics after it.
- train_test_split had no random_state, so `seed` was silently ignored for
  splitting and iterations were irreproducible -- despite an inline comment
  claiming the splits were seed-based. Use random_state = seed + iter.

Embedding crashes:
- get_embeddings(n_components=None), the documented default, raised
  TypeError: '<=' not supported between 'NoneType' and 'int'. Default to
  X_train.shape[1] as documented.
- `spectral` raised AttributeError: SpectralEmbedding has no 'transform' on every
  run. Fit transductively over combined train+test rows and slice back; only
  feature structure participates, so no label leakage is introduced.

Quantum sessions and caching:
- Close Qiskit Runtime sessions in a finally block in embed.pqk and
  learning.compute_pqk; an exception previously leaked the session.
- PQK projection cache filenames omitted encoding/entanglement/reps/primitive, so
  re-running with a different feature map into the same pqk_projection_dir
  silently reloaded and reported the previous run's projections. Key the cache on
  a digest of those parameters, and validate cached width (3 x feat_dimension) in
  addition to row count.

Also thread n_neighbors from config through to get_embeddings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tier the dependency lists and make pyproject.toml the only place project
metadata is declared. Adopts internal's requirements/ layout, corrected.

Dependency tiers (requirements/):
  requirements-base.txt    core runtime -- the single source of truth, read by
                           pyproject.toml via [tool.setuptools.dynamic]
  requirements-quvine.txt  the new [quvine] extra
  requirements-docs.txt    the [docs] extra
  requirements.txt         everything, for development environments

The root requirements.txt becomes a pointer to requirements/requirements.txt so
the `pip install -r requirements.txt` documented in CONTRIBUTING.md,
docs/source/installation.md, ISMB_2026.rst and the PR template keeps working.

Fixes carried in this commit:

* Three dependencies were imported but never declared. pyyaml is imported by
  utils/generate_qml_configs.py and apps/qprofiler/qprofiler_batchmode.py (which
  backs the qprofiler-batch console script); matplotlib by five modules; joblib
  by evaluation/model_run.py and qprofiler_batchmode.py. Installs only worked
  because seaborn and optuna pulled them in transitively.

* MANIFEST.in referenced apps/qprofiler/configs, which does not exist -- the
  configs live under qbiocode/apps/qprofiler/configs -- so the sdist shipped no
  Hydra config for QProfiler. packages.find likewise carried an apps* include
  glob matching no package.

* pandoc was declared as a pip dependency of [docs]. Pandoc is a system binary;
  the PyPI distribution of that name does not provide it. The platform install
  commands are documented in requirements-docs.txt instead.

* setup.py's 189 lines of duplicated metadata are replaced by internal's 33-line
  shim that passes no arguments. The old version re-declared name, version,
  dependencies, extras, classifiers and entry points, and split runtime from
  docs dependencies by scanning requirements.txt for a "# Documentation" comment.

* [all] is now "qbiocode[apps,quvine,docs,dev]" rather than a hand-maintained
  copy of every other extra, which nothing kept honest.

* tensorflow is dropped. Nothing in qbiocode/, the tutorials or the docs imports
  TensorFlow or Keras; the only reference left was a stale autodoc_mock_imports
  entry. compute_autoencoder.py, the one module that could have needed it, is
  written against PyTorch. Removes roughly 600 MB from a default install.

* .gitignore matched data/ and results/ unanchored, excluding a directory of
  either name at any depth -- including docs/source/tutorials/QProfiler/data/,
  which holds the committed .h5ad and sc_binary/*.csv fixtures the published
  notebooks read. Since git cannot re-include a file under an excluded
  directory, those fixtures were addable only because they were already tracked.
  Both patterns are now anchored to the repository root.

* Stopped tracking generated QProfiler output under docs/source/tutorials/
  QProfiler/ (ModelResults.csv, RawDataEvaluation.csv, results.pkl, and 500 KB
  of pqk_projections/*.npy). The notebooks write these and read them back in the
  same run, and nbsphinx_execute = 'never' means the docs build never executes
  them. The cached projections were additionally unreachable after Phase 1's
  PQK cache-key fix, their filenames predating the feature-map fingerprint.

The [quvine] extra is all-or-nothing, as decided: one extra carrying the whole
QuVINE dependency set. setuptools<81 is pinned inside it because node2vec
imports the pkg_resources module that setuptools>=81 removed, and gensim>=4.4
because older gensim needs scipy.triu (removed in scipy 1.13) and would force
numpy<2.0, conflicting with qiskit-machine-learning==0.9.0.

The quvine console script and its package-data entry are deliberately NOT added
here -- qbiocode.apps.quvine does not exist until Phase 3, and installing an
entry point for a missing module would leave a broken `quvine` command.

tests/test_requirements_consistency.py (10 tests) enforces the invariants: the
quvine/docs extras match their requirements files, dynamic dependencies point at
the shipped base file, build tooling stays out of the runtime list, [all] stays a
union, no pandoc, no tensorflow, and setup.py declares no metadata.

Verified: 23 tests pass; python -m build produces 31 unconditional Requires-Dist
entries with no setuptools and no tensorflow; the sdist ships
requirements/requirements-base.txt; the wheel ships the qprofiler configs and
exactly three entry points; pip install --dry-run '.[all]' resolves every tier
(exit 0); qprofiler/qsage/qprofiler-batch --help all exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings QuVINE (Quantum View-based Network Embeddings) and the graph-complexity
metrics over from the internal repository: 61 modules under
qbiocode/apps/quvine/, qbiocode/evaluation/graph_evaluation.py exposed as
qbiocode.evaluate_graph, and docs/source/apps/quvine.rst wired into the apps
toctree.

The decision to ship one all-or-nothing [quvine] extra rather than a plain
dependency shapes everything else here. `import qbiocode` and every classical
embedding must keep working with the extra absent, so:

* qbiocode/apps/quvine/_deps.py resolves each optional dependency through
  require_module() and raises QuvineDependencyError -- naming the method, the
  extra, the pip command and the missing distribution -- instead of a bare
  ModuleNotFoundError traceback. It subclasses ImportError so the Phase 4
  routing probe (`except ImportError`) keeps working. Wired into walks/ctqw.py,
  walks/dtqw.py, baselines/node2vec.py and embedding/word2vec.py.

* api/__init__.py is lazy. It eagerly imported core/config/sgns/targets, so
  reaching the stdlib-only resolve_method dragged in omegaconf and the rest of
  the extra; that only appeared to work because hydra-core pulls omegaconf in
  transitively. The CLI likewise imports embed at its point of use, so --help
  and --list-methods run on a bare install.

Four defects fixed in the port rather than carried over:

* The five fused names (quvine_fused, quvine, fused, ...) were handled inside
  embed() but absent from the alias tables, so resolve_method raised KeyError
  and the CLI rejected its own default --method. Fused is now a first-class
  kind in api/aliases.py, list_methods() reports 83 names, and the CLI validates
  through resolve_method so its message and the dispatchable set cannot drift.

* walks/, corpus/, utils/ and configs/ had no __init__.py, so
  packages.find skipped them: the modules imported fine from a source checkout
  but were missing from a built wheel. Verified by importing them out of an
  unpacked wheel.

* torch was declared in both the base set and the extra. qbiocode.embeddings
  imports it eagerly (ConvAutoencoder), so a bare install already needs it --
  a missing torch is a broken install, not a missing extra, and the [quvine]
  hint would be wrong advice. Now base-only.

* word2vec.py advised `pip install gensim==4.3.0 scipy==1.11.0` on failure, a
  downgrade that forces numpy<2.0 and conflicts with
  qiskit-machine-learning==0.9.0. Replaced with the extra; the unused
  GENSIM_AVAILABLE flag went with it.

Two false claims corrected: quvine/__init__.py and docs/source/apps/quvine.rst
both said QuVINE was "available on a plain install with no extra install step".
The docs Tutorials section links the tutorials index rather than the QuVINE
notebook, which arrives in Phase 7 -- a :doc: role pointing at a file that does
not exist yet would fail the `sphinx-build -W` run added in Phase 8.

pyproject.toml gains the `quvine` console script and the quvine configs
package-data, both deliberately deferred from Phase 2 until the module existed.

tests/test_quvine_packaging.py (18 tests) locks the invariants down: a
subprocess with every [quvine] module blocked still imports qbiocode and
resolves method names; the _deps table matches requirements-quvine.txt; an AST
scan (including require_module string arguments, which an import scan misses)
finds no undeclared third-party import under qbiocode/; every quvine directory
is a package; every listed method resolves and every resolvable kind has a
dispatch branch; and the CLI's default method is one the CLI accepts.

41 tests pass. Wheel ships 66 quvine files, the packaged config.yaml,
graph_evaluation.py and 4 console scripts; sdist metadata has 31 unconditional
Requires-Dist with torch present and no setuptools leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_embeddings("quvine_rwr", X_train, X_test, n_components=8) now behaves
exactly like get_embeddings("pca", ...) -- same call shape, same (Z_train,
Z_test) return, any of the 83 QuVINE method names accepted, reachable from
QProfiler's existing embeddings: config list.

Public surface:
  - SKLEARN_METHODS / QUVINE_HEADLINE_METHODS / QUVINE_METHODS name what is
    available; QUVINE_METHODS is empty rather than raising when the [quvine]
    extra is absent.
  - is_transductive(name) reports whether a method sees test features at embed
    time, and get_embeddings emits one UserWarning per call for those methods
    saying that test features shape the geometry while test labels never do.
  - Boundary validation: unknown names, non-string embedding, and non-integer /
    zero / negative / too-wide n_components all raise ValueError naming the
    parameter, the value and the accepted set, with close-name suggestions.

Three defects had to be fixed before any registry method could run at all.

qbiocode/apps/quvine/data/ was never committed to the internal repository -- an
unanchored `data/` rule in its .gitignore matched the directory at every depth,
so it appears nowhere in that history. embedding/quantum_filters.py imports it,
and through it the adapter module that builds the registry, so all 69 registry
methods died with ModuleNotFoundError. data/subgraph.py is reimplemented from
its two call sites (bounded-radius ego-net expansion is well determined), which
restores the registry; the four modules that remain absent gate only Pipeline's
on-disk loading and the synthetic benchmark generators, and now raise
QuvineDataUnavailableError naming the module and the feature. Pipeline imports
again.

Optional dependencies were resolved at import time in walks/ctqw.py,
walks/dtqw.py and baselines/node2vec.py, so failures were attributed to
whichever module was imported first: an RWR-only run blamed CTQW's hiperwalk,
and node2vec -- swallowed by baselines/__init__.py's except ImportError: pass --
left run_node2vec unbound and took netmf, appnp and graphgps down with it. All
three resolve at call time now and each names its own dependency; a test asserts
structurally that no module under apps/quvine calls require_module at module
scope. GraphGPS's hand-rolled torch-geometric message goes through the same
helper.

The registry printed to stdout (✓ netmf: 0.00 minutes) on every call and logged
a full traceback for a failure it returned to its caller to re-raise. Progress
goes through logging, the traceback is available at DEBUG, and the cause travels
on MethodResult.exception so api.core chains it.

tests/test_embedding_layer.py: 29 tests. 70 pass in total.
qbiocode/apps/quvine/data/ is absent from the internal repository and from its
entire history: an unanchored `data/` rule in that repository's .gitignore
matched the directory at every depth, so `git add` never picked it up. The
source was recovered from the working tree the vendoring was done in
(NetMed/QBC/QBioCode at merge-base 83192c4), which is byte-identical to the
internal tree apart from license headers and two `except:` widenings -- so this
is the same vintage as the rest of the ported app, not a newer or older copy.

Six modules, all needing nothing beyond the base install (numpy, pandas,
networkx, scipy):

  data_loader             graph and GWAS-table loading
  prepare                 sparsification, LCC extraction, node subsampling
  sparsify                edge sparsification primitives
  subgraph                bounded-radius ego-nets, degree-matched subsampling
  random_graphs           13 synthetic generators
  random_graphs_extended  11 more, incl. SBM variants and expander-like graphs

This restores three surfaces that were dead:

  - All 69 registry methods. embedding/quantum_filters.py imports data.subgraph
    at module scope and baselines/adapters.py imports quantum_filters, so the
    whole registry raised ModuleNotFoundError.
  - qbiocode.apps.quvine.Pipeline, which imports data_loader and prepare at
    module scope.
  - All 15 synthetic graph families in reproducibility.graph_generator, which
    imports the two random_graphs modules inside SyntheticGraphGenerator's
    constructor. Verified: each generates a non-empty graph and writes it.

The QuvineDataUnavailableError / require_data_module stopgap added with Phase 4
is removed and pipeline.py and graph_generator.py revert to their upstream form
(both are now byte-identical to their pre-Phase-4 state). The reimplemented
data/subgraph.py is replaced by the original.

Two deliberate departures from the recovered source:
  - graph_complexity.py is not carried over. Nothing imports it and
    qbiocode.evaluate_graph supersedes it.
  - expand_neighborhood filtered roots absent from the graph only for
    radius >= 1; at radius=0 it returned them verbatim and could hand back
    nodes the graph does not contain. Roots are now filtered first.

data/__init__.py re-exports the 37 public names; MANIFEST.in ships the
generators' README in the sdist. External's .gitignore anchors the rule as
`/data/`, so this class of silent omission cannot recur here.

74 tests pass.
Three failure shapes, each of which shipped and each of which is worse than an
exception:

1. A fabricated value returned as a measurement. Link-prediction AUC reported 0.5
   (the score of a random ranker) for an undefined metric; modularity and
   path_length_ratio reported 0.0 (a real finding) when undefined; the ten
   quantum-advantage metrics reported 0.0 for an empty graph, including the
   rankable composite score; netmf returned np.random.randn(n, dim) * 0.01 when
   its factorization failed, which the registry scored as a result. All now
   report nan or raise, and summarize_link_prediction_results aggregates
   nan-aware with per-metric n_defined_* counts so one undefined method no
   longer erases the ones that succeeded.

2. A silent no-op reported as success. get_optimizer("L_BFGS_B") used `==` where
   it meant `=`, so it built an optimizer, discarded it, and raised
   UnboundLocalError for an option two public functions advertise;
   get_feature_map returned its own argument for an unknown name;
   scale_train_test returned unscaled data for a mistyped scaler; QProfiler
   tested `'True' in args['scaling']`, which accepted 'MinMaxScalerTrue' and
   crashed on `scaling: true`; compute_pqk accepted a `primitive` that changed
   only the cache fingerprint; QSage aliased its metric list and then sorted it
   in place, reordering the columns used to slice the input frame.

3. An error attributed to the wrong cause. Dead `except ImportError: X = None`
   sentinels turned an actionable ImportError into "'NoneType' object is not
   callable"; broad handlers reported a bug in their own body as a degenerate
   graph. Handlers are narrowed to nx.NetworkXException / ArpackError /
   LinAlgError / parser errors; the few that must stay broad name
   type(e).__name__ and keep the traceback at DEBUG.

Validation moves to the boundary: get_embeddings coerces and shape-checks its
matrices, the new public check_embedding_name lets a caller validate a whole
embeddings list before any work begins, compute_pqk runs eleven checks before
touching the filesystem, model_run names the available models instead of raising
KeyError inside a joblib worker, and all three CLIs reject bad arguments before
creating an output directory.

tests/test_error_contracts.py adds 43 tests pinning this behaviour. Suite: 115
passed, 2 skipped. CHANGELOG records every number-changing fix under a
results-change warning.
import qbiocode wrote 27 entries into the global plt.rcParams, because
visualize_correlation.py assigned them at module scope and the package imports
it. Every unrelated figure the caller drew afterwards came out in Arial with no
top spine and a 600-dpi savefig default, with nothing in the call stack to
attribute it to. Those settings are now PUBLICATION_STYLE, applied per call
through plt.rc_context in a public wrapper so they are restored on the exception
path too, with publication_style() for callers who want the same look.

plot_results_correlation derived two of its three output paths with
re.sub(".pdf", "_heatmap.pdf", path). For any extension but .pdf the pattern did
not match, so the derived path equalled the original: passing corr.png left one
file where three were requested, the scatter overwritten by the clustered heatmap
and that by the non-clustered one, with nothing to say two had been lost. The
unescaped . was also a wildcard, so out/spdf_x.pdf became
out/_heatmap.pdf_x_heatmap.pdf. QSage.plot_results had the same defect and forced
.pdf regardless of the request. Both use os.path.splitext now.

plt.show() was unconditional at nine sites across three modules, hanging a batch
run under a GUI backend and warning under Agg; each is guarded on the backend
being able to display. analyze.py saved *after* showing, so a batch run stalled
with nothing written until someone closed a window, and wrote three fixed
filenames into the cwd. Every plt.close() is now plt.close(fig) on the figure
actually drawn, the touched functions use the explicit fig/ax API, and all three
functions return their figures so a notebook can compose without recomputing.

No matplotlib.use("Agg") guard: forcing a backend at import is the same hidden
global mutation this removes, and matplotlib already falls back on its own.

tests/test_plotting_hygiene.py adds 17 tests, including a subprocess check that
importing the package leaves rcParams untouched and an AST guard that fails if
any module regains import-time matplotlib styling. Suite: 132 passed, 2 skipped.
Ports internal's four QuVINE notebooks, commits the fixtures they read, and
replaces the copy-pasted path-derivation snippet each notebook carried with one
documented resolver.

New: qbiocode.tutorial_data_path()
  Every single-cell notebook derived a repository root as
  dirname(dirname(abspath(qbiocode.__file__))). That yields site-packages for a
  normal install, so the derived fixture path pointed nowhere; the failure then
  surfaced as anndata's "file not found", naming neither the QBC_DATA override
  nor the directories that had been tried. Each copy also knew only one of the
  four fixture directories in the tree.

  tutorial_data_path() searches $QBC_DATA first, then every fixture directory of
  a source checkout -- locating that checkout both from the installed package
  and by walking up from the cwd, so it resolves for an editable install, for a
  normal install used inside a clone, and for a notebook run from its own
  subdirectory. A miss raises FileNotFoundError listing every directory tried
  and the three ways to fix it. When QBC_DATA is set but lacks the file, the
  file is returned from wherever it was found *and* a WARNING names both
  directories: resolving quietly against a directory the caller did not name is
  how a stale fixture gets read for a whole session.

Notebooks ported (each declares `pip install "qbiocode[quvine]"` up front):
  tutorial/QuVINE/example_quvine.ipynb          -- re-executed, 11/11 cells
  tutorial/QuVINE/quvine_sc_t_vs_mono.ipynb
  tutorial/QProfiler/sc_binary_quvine_2x2_qprofiler.ipynb  (+ docs mirror)
  docs/source/tutorials/QuVINE/quvine_sc_cd4_vs_cd8.ipynb

Fixtures: three pbmc5k .h5ad files under tutorial/QuVINE/datasets/ (13 MB).
  Two of them are the provenance of the sc_binary/*.csv matrices QProfiler's
  single-cell tutorial already shipped and cannot be regenerated without the raw
  10x matrix plus scanpy/leidenalg. pbmc5k_small_cd4_vs_cd8.h5ad was NOT
  duplicated -- the tracked copy under tutorial/QProfiler/data/ is
  byte-identical and tutorial_data_path() finds it from either tree.

Fixed:
  - Four notebooks imported distributions that do not exist: `from apps.qprofiler
    import ...` (docs example_qprofiler), `from apps.sage.sage import ...` (docs
    qsage), and `import qprofiler.qprofiler` (both QPL_example copies). Each was
    an unconditional ModuleNotFoundError -- the same defect already fixed in
    apps/qprofiler/cli.py.
  - Eight notebooks pinned kernels that exist only on their author's machine
    (venv, .env, venv_quvine, qbc-pkg); an unresolvable kernelspec fails
    execution under nbclient/nbsphinx rather than falling back. All now declare
    the standard python3 kernel.
  - The Quantum Ensemble tutorial link was a 404: tutorials.md linked a page
    Sphinx never built, because the notebook lived only under tutorial/ with no
    toctree entry. Mirrored into docs/source/tutorials/QEnsemble/ (with the
    helper_functions.py it imports) and added to the toctree. Every gallery link
    now resolves to a built page and every built page is in the toctree.
  - sc_binary_qprofiler.ipynb carried a stale warning that the PQK cache ignores
    pqk_args. True before this release, no longer -- the key now includes the
    feature-map fingerprint. Corrected rather than deleted, since the purge is
    still worth keeping for a self-contained run.
  - Both copies of sc_binary_qprofiler.ipynb now state at the top that their
    outputs predate the train/test contamination fix and are not comparable to a
    fresh run of the same config.

Docs: tutorials.md gains a QuVINE section as §4 (with the QProfiler-2x2 notebook
as a subsection), renumbering Ensemble/QPL/PQK-OV to 5-7, and keeps external's
QProfiler-on-single-cell-with-PQK subsection that internal had deleted.
apps/quvine.rst now links both new notebooks directly. .gitignore's
tutorial/**/data/ rule gains a comment explaining that git honours the index
over it, which is the only reason the four tracked fixtures under it survive.

Not done, deliberately:
  - quvine_sc_t_vs_mono, quvine_sc_cd4_vs_cd8 and the two 2x2 copies could not
    be re-executed here: they read .h5ad and anndata is not installable in this
    offline environment. They ship with internal's outputs.
  - sc_binary_qprofiler.ipynb diverged on both sides after the merge base;
    internal's 22-cell version adds a paired Cohen's d_z forest plot and a
    cross-task complexity section. Left unmerged -- merging needs re-execution,
    and stitching outputs from two different runs would misrepresent them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… extras

Sphinx configuration
--------------------
conf.py built every path from ".": the sys.path entries, better_apidoc's
template directory and its output directory. Those are correct only when
sphinx-build is invoked from docs/, because conf.py is exec'd with cwd set to
wherever the build started. Running `sphinx-build docs/source docs/build/html`
from the repository root -- what most editor integrations do -- silently
produced a build with no autodoc templates and API pages written elsewhere.
All four are now derived from os.path.abspath(__file__).

release was hardcoded to '0.0.1' while qbiocode/version.py declares '0.1.0', so
the published docs labelled themselves a version that was never released. It is
now parsed out of version.py with a regex rather than by importing the package,
so it stays correct even when the docs build cannot import qbiocode.

autodoc_mock_imports listed tensorflow, which appears nowhere in the tree, and
omitted every [quvine] dependency, so a docs build without the extra could not
import the QuVINE modules it was documenting. omegaconf mattered most: it backs
QuVINE's config loading, so api/config, api/core, api/sgns, main, pipeline and
utils/io were all unimportable. Base dependencies are deliberately not mocked --
mocking them would hide a broken install behind a clean docs build.

run_apidoc swallowed everything with a bare `except Exception: pass`, so a
missing better_apidoc, a template error or an import failure produced no output
and the build carried on against whatever pages happened to be committed. A
missing better_apidoc is now logged at info level (the committed pages are the
intended fallback); any other failure is a Sphinx warning, so -W fails on it.

API reference
-------------
Nothing in any toctree pointed at api/modules, so every generated page was an
orphan and the API reference was reachable only by guessing a URL.
api_overview.rst now roots it with a hidden toctree entry, and gained sections
for the API added in this release: Graph Embeddings (QuVINE) and a Preprocessing
subsection for scale_train_test.

The 23 committed pages came from two different generators -- 15 better_apidoc
--separate, 8 plain sphinx-apidoc -- and there was no page for qbiocode.apps at
all, so QuVINE, QProfiler and QSage were absent from the API docs entirely,
along with qbiocode.evaluation.graph_evaluation. Regenerated from the real
package tree: 26 pages, 128 automodule targets, covering all 128 importable
modules under qbiocode/. The --separate per-module pages are no longer
committed; run_apidoc regenerates them at build time with --force.

Build output and deployment
---------------------------
docs/Makefile sets BUILDDIR = build and CI uploads docs/build/html/, but ~250
files of rendered HTML (plus a .doctrees cache, 54 MB) were committed under
docs/_build/html/ -- a second copy no tool wrote to, updated by hand. Removed
from source control; both directories are gitignored.

Replaced by a deploy-docs job that publishes to the gh-pages branch that
ibm.github.io/QBioCode actually serves. It reuses the artifact the docs job
already built (needs: docs), writes .nojekyll so Pages does not discard
Sphinx's _static/, _images/, _sources/ and _modules/, and is gated on
`github.event_name == 'push' && github.ref == 'refs/heads/main'` so no pull
request -- including one from a fork -- can publish to the live site.
peaceiris/actions-gh-pages is used rather than actions/deploy-pages because the
latter needs the repository's Pages source flipped to "GitHub Actions"; pushing
the branch leaves the existing setting working.

The docs job's "Build documentation" step no longer sets continue-on-error, so
broken docs now fail CI. The artifact upload is likewise no longer allowed to
fail silently, since deploy-docs consumes it. The lint job's black/isort/mypy
steps keep continue-on-error -- the tree is not fully formatted, and that is
separate work.

Installation docs
-----------------
README.md and installation.md documented only [apps] and [all]. Both now carry
the full extras matrix, and installation.md gains a QuVINE section covering why
the extra is all-or-nothing, what keeps working without it, the actual
QuvineDependencyError message, the setuptools<81 pin (node2vec imports
pkg_resources) and the brew install cmake prerequisite for ripser on macOS.

Tests
-----
tests/test_docs_structure.py, 17 static checks that keep the above from
regressing silently. It parses the sources and ci.yml instead of running
sphinx-build, so it passes without the [docs] extra installed: every toctree
entry names an existing document, no document is orphaned (honouring an
explicit :orphan: / orphan: true), every automodule target is a real module, the
new public modules each have a page, conf.py is anchored on __file__ and agrees
with version.py, every optional QuVINE import is mocked, no rendered HTML is
tracked, and the deploy job is gated and writes .nojekyll. The omegaconf mock
gap was found by this test, not by review.

Not verified here
-----------------
No Sphinx build was run: sphinx and better_apidoc cannot be installed in this
offline environment. Everything above was checked statically -- conf.py exec'd
from three different working directories with a stubbed sphinx.util.logging to
confirm identical results, all 128 automodule targets and 30 autosummary
targets resolved against the real tree, every toctree entry and cross-reference
role resolved by hand. The claim that run_apidoc's silent except was masking a
real failure is a hypothesis, not a confirmed diagnosis.

149 passed, 2 skipped.
The existing tests call functions. These run the package the way a user does --
through the console scripts, a fresh interpreter, a real sphinx-build, a real
QProfiler run -- because that is the only place several of this release's
defects were reachable at all.

The first of them justified the tier on its first run. `_resolve_scaling`
unwrapped single-element sequences behind `isinstance(value, list)`, and Hydra
hands the config over as `omegaconf.ListConfig`: a `Sequence`, but not a `list`
subclass. Every dict-based unit test passed, and the shipped config's own
`scaling: ['True']` raised `ValueError: Unrecognized scaling ['True']` on the
real CLI path. The test is now on `Sequence` and the docstring names the trap.

Running Sphinx for real found four broken references that reading the sources
had not: a never-committed `qml_multiomics.png`, a `:ref:` in the `docname:Title`
form that cannot resolve without `autosectionlabel`, a config link still
pointing outside the docs tree from before the move into `qbiocode/`, and a
notebook self-link using a GitHub anchor slug rather than an nbsphinx one. Plus
13 docutils warnings: `|V|` and `|E|` read as RST substitution references, and
ten short section underlines.

The build is verified locally now and emits no structural warnings. It is
deliberately not run under `-W`: 83 docstring-formatting warnings remain across
about twenty modules, and reformatting every docstring in the tree is a separate
piece of work. `test_docs_build.py` asserts the structural warning classes are
empty instead, which is the contract that can regress silently.

Also here: link-prediction splitting no longer reseeds the caller's global RNG,
`pytest` no longer needs pytest-cov to start, and `slow`/`requires_quantum` are
registered and deselected by default.

322 passed, 2 skipped, 5 xfailed. The xfails are strict and carry reasons: five
notebooks are genuinely truncated because they need a quantum backend, a long
ensemble sweep, or anndata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…file

The `test` job installs `.[dev]`, which brings the QuVINE dependencies along, so
it can never detect the one property the `[quvine]` extra exists for. The new
`install-matrix` job has a bare `pip install -e .` leg and a `.[quvine]` leg;
each imports the package, runs all three console scripts, and runs the suite
with `-rs` so the log shows what skipped. Verified locally by making gensim,
hiperwalk, node2vec, torch_geometric, community and ripser unimportable: 317
passed, 1 skipped, 5 xfailed -- the QuVINE tests skip rather than fail, and
`import qbiocode` leaks none of the six. (omegaconf is deliberately not on that
list: hydra-core is a base dependency and requires it, so it is present in a
bare install too.)

That run also found a documentation defect. Both `qbiocode/embeddings/__init__.py`
and the changelog claimed `QUVINE_METHODS` is empty without the extra. It lists
all 83 names, because resolving a name is stdlib-only -- so discovery and the
"unknown embedding" error message work in a bare environment, and only running a
method raises. The docs understated the behaviour; corrected in both places.

No lock file is committed. The one inherited from the development repository was
a `pip freeze` from macOS/arm64 under Python 3.12.4, presented as "the validated
environment" while the matrix is three OSes x three Python versions -- wrong on
eight of nine combinations, authoritative-looking on all nine. It had also
outlived its own dependency set: it still pinned tensorflow==2.21.0 and
keras==3.15.0, the ~600 MB dependency removed earlier in this release, and
covered neither the docs tier nor scanpy/anndata/igraph/leidenalg. Copying it
across would have silently undone that removal. The tiered requirements files
are the single source of truth; `requirements/requirements.txt` documents the
four commands that produce a lock in the environment that will use it, and the
path is gitignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Building the docs and classifying every warning -- rather than grepping for
the ones that looked important -- turned up seven defects that the previous
pass missed. Six of them slipped through because test_docs_build.py matched
warnings by kind and none of them matched a listed kind.

Fixed in the pages:
- background.md ended with a `---` transition inside the level-3 "Video
  Resources" subsection followed only by a closing paragraph. docutils
  rejects that outright ("Transition must be child of <document> or
  <section>"); the paragraph is a page footer, so it is now a {seealso}
  admonition and needs no transition.
- installation.md linked docs/CONDA_SUBMISSION.md as ../CONDA_SUBMISSION.md.
  The file exists but sits above docs/source/ and is not part of the doc set,
  so MyST resolved it as an unknown source document. Now links GitHub.
- installation.md fenced a Colab cell as `python`, but `!git clone` and `%cd`
  are IPython magics the Python lexer cannot tokenise. The ipython3 lexer
  would handle them and ships with IPython, which is not a declared docs
  dependency, so the block is fenced as `text`.
- workshops/ISMB_2025.rst indented two nested bullet lists past their parent
  item's text with no blank line: docutils opened block quotes and then hit
  an unexpected unindent, and neither rendered as a list.
  workshops/ISMB_2026.rst had a paragraph indented one space too far.

Fixed in the API pages and config:
- napoleon_use_ivar = True. A Google-style `Attributes:` section collided
  with `:undoc-members:` on every dataclass, describing each field twice --
  fourteen "duplicate object description" warnings from MethodMetadata and
  MethodResult alone.
- Twelve docs/source/api/*.rst pages documented a package's re-exported
  names twice, once per submodule section and again in the trailing "Module
  contents" block, which made the short names ambiguous ("more than one
  target found for cross-reference"). That block now renders the package
  docstring only, with a comment recording why so a better_apidoc
  regeneration does not silently reintroduce it. Packages whose __all__ names
  are not all submodule re-exports keep :members: -- qbiocode itself and
  qbiocode.apps.quvine, checked programmatically rather than by eye.

Guard: test_docs_build.py now also fails on any docutils or MyST warning
reported against a file under docs/source/, since a hand-written page that
emits a markup warning does not render as intended. Docstring warnings keep
their exemption, as does myst.xref_missing for `module-*` anchors -- those
are registered through the Python domain, which MyST's local-id check cannot
see, and were confirmed present in the rendered HTML. Replaying the pre-fix
build log through the new check yields nine offenders and the post-fix log
yields none.

Build: 90 warnings -> 43, with the structural set empty. Of the 43, 33 are
docstring-formatting nits in qbiocode/ (out of scope, which is why the build
still is not run under -W) and 10 are the verified-false-positive
myst.xref_missing reports.

Suite: 326 passed, 2 skipped, 2 deselected, 5 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 1 fixed the train/test split but only half of reproducibility. The
integration suite caught the rest: two runs at seed 7 disagreed on one row --
embeddings=none, iteration=2, model=dt -- with accuracy 0.889 against 0.944.
Reproduced twice.

qprofiler sets np.random.seed(args['seed']) in the parent process, but model_run
fans the models out with joblib, whose loky workers are fresh interpreters
seeded from OS entropy. Every estimator left at random_state=None therefore drew
a different random state on each run. DecisionTreeClassifier permutes the
features before choosing a split, so a tie between two equally-good splits broke
either way; on a 60-sample dataset that is one whole test sample. Verified
directly: 200 fits at random_state=None give {0.889, 0.944}, at random_state=7
give {0.889}.

model_run now fills in random_state=seed for every model whose function accepts
one, deciding by signature so nb -- which has none -- is left alone rather than
crashing on an unexpected keyword. A random_state set in the config still wins.
The _opt grid-search variants had no random_state parameter at all and now take
one, threaded into both the searched estimator and the refit. compute_xgb was
missing one despite subsample=0.5, and create_xgb_model in the QPL pipeline
ignored its own seed argument while every sibling create_*_model passed it.

Seeding the global RNG inside the worker is the floor, not the mechanism: joblib
batches tasks, so how far an earlier task advanced a shared stream depends on
timing. The worker seeding is kept for randomness that has no random_state to
set -- compute_qnn's initial weights come from algorithm_globals.random, which
never crossed the process boundary either.

Tests assert the recorded parameters rather than the metrics, on purpose:
whether an unseeded estimator changes its answer depends on there being a tie to
break, so a metric comparison passes or fails by luck -- which is why the
end-to-end test caught this intermittently. The new ast guard walks every
estimator construction in the package; replayed against the pre-fix tree it
reports eight offenders, against this one none.
Eighteen `pytest.importorskip` calls guarded *first-party* modules. That is not
a guard, it is an off switch: a skip and a pass are the same colour in a summary
line. Injecting one `ImportError` at the top of qbiocode/utils/qutils.py turned
32 assertions across test_error_contracts.py into skips and the suite still
reported green -- compute_pqk, qprofiler and gat all import that module
transitively, so a single fault cascaded through four unrelated test classes.
The same fault now aborts collection.

Five more guarded matplotlib, networkx and pyyaml, all mandatory in
requirements-base.txt, and nbformat/nbclient, which were declared nowhere at
all -- so the 21 notebook tests skipped in any environment that happened not to
have them, including both CI install-matrix legs.

- tests/test_error_contracts.py, test_plotting_hygiene.py,
  test_split_reproducibility.py: import the nine first-party modules at module
  scope. Via `importlib.import_module`, not `from X import Y`: several of these
  packages re-export a function or a string under the same name as the submodule
  (`qbiocode.learning.compute_pqk` is both), so `from` binds whichever the
  parent package happens to expose at that moment -- order-dependent, and wrong
  for four of the nine.
- test_split_reproducibility.py's guard claimed the link-prediction module
  "requires the [quvine] extra". It does not; it imports on a bare install. Six
  tests were conditional on a false premise.
- nbformat/nbclient: declared in the [dev] extra and installed explicitly by
  both install-matrix legs, then imported directly.
- tests/test_suite_hygiene.py: three new tests so none of this comes back --
  no first-party module may be reached through importorskip, every guarded
  module must be genuinely optional (declared in an extra, not in base), and
  the one documented exception (the tomli shim for Python 3.10) must still be
  referenced by something.

The three remaining importorskip calls are the legitimate ones: tomli, gensim
([quvine]) and sphinx ([docs]).
Both surfaced while executing tutorial/QProfiler/example_qprofiler.ipynb, which
has never had committed outputs.

**NMF cannot transform a leakage-free test split.** Fitting the MinMaxScaler on
train+test bounded both splits to [0, 1], so NMF.transform never saw a negative.
Fitting it on train alone -- the protocol this migration introduced -- leaves the
test split free to fall below the training minimum, and NMF is defined only on
non-negative input. Every shipped config runs `embeddings: ['pca', 'nmf',
'none']` with `scaling: ['True']`, so the leakage fix turned a silently-wrong
result into `ValueError: Negative values in data passed to X in NMF`. The test
matrix is now clipped at zero for NMF only, and the clip reports how many entries
it touched and how far out they were -- scaling the two splits together again is
the leak, and a silent clip would hide a test set outside the training range.

**folder_path did not resolve outside a directory named QBioCode.** `dir_home =
re.sub('QBioCode.*', 'QBioCode', os.getcwd())` only lands when the current
directory really sits under one literally called `QBioCode`. It does not for the
GitHub source zip (`QBioCode-main`) or a lowercase clone, and every shipped
config writes folder_path relative to the checkout root, so the tutorial failed
with `tutorial/QProfiler` doubled in the path. `_resolve_input_folder` now tries
the path as given, then the derived root, then each ancestor of the current
directory -- and the error names all three when none resolves.

Tests: 5 for the NMF clip in test_leakage_contract.py (including a guard that the
fixture really does produce out-of-range test values, or it proves nothing), and
5 for path resolution in test_error_contracts.py.
The site built successfully with 50 warnings, and a docs build that warns
successfully is a docs build nobody reads the output of. Every one of these was
a real rendering defect: docutils cannot parse the construct, so it drops or
mangles it and the published API page shows something other than what the
docstring says.

The recurring cause is a list that starts on the line immediately after its
lead-in colon. RST absorbs it into the preceding paragraph, so the bullets
render as literal "-" and "1." characters in a run-together sentence, and the
first wrapped continuation line then reads as a stray indented block quote --
which is the "Unexpected indentation" / "Block quote ends without a blank line"
pair that accounted for most of the fifty. Same fix in nine docstrings.

The rest were individual:

  - random_graphs_extended: the title underline was one character short of the
    title, so the module heading rendered as body text.
  - link_prediction, compute_qensemble: |u - v| and |<train|test>|^2 are RST
    substitution syntax. Sphinx reported an undefined substitution and dropped
    the line. They are formulae, so they are inline literals now.
  - hyperparameter_loader: an unindented JSON sample parsed as section titles
    and definition lists. It is sample data, so it is a literal block.
  - random_graphs, gcn_mf: a displayed formula and a code line indented under
    prose need an explicit :: literal block, not bare indentation.
  - walks/base: a numpydoc "-------" underline stranded inside a Google-style
    docstring.
  - ranking, dataset_evaluation, fuse: malformed Returns sections. napoleon
    splits a Returns line at its first colon into type and description, so
    "Dictionary with fused embeddings:" became the return *type*.
  - sage: a dict-shape sketch was fenced as python, and pygments failed on the
    R2 superscript. It is not Python -- <trained model> is a placeholder -- so
    it is a text block.
  - make_blobs: a footnote nothing cited, reported as unreferenced and rendered
    as a dangling [1].

The ten myst "local id not found" warnings were false but not harmless: the
markdown links to automodule anchors do resolve (the ids are in the rendered
HTML), yet myst cannot verify them, so they were noise that a real broken link
would have hidden in. They are py:mod cross-references now, which the Python
domain does check -- so a genuinely broken one fails the build.

That leaves one warning, and it is not ours: sphinx-autodoc-typehints resolves
annotations by importing the modules they name, and pydantic's dataclass
internals annotate against _typeshed, a typing-only stub that never exists at
runtime. Exactly that subtype is suppressed in conf.py, with the reason next to
it, rather than anything broader.

With zero warnings reachable, docs/Makefile now passes -W, so any new warning
fails make html locally and in CI. tests/integration/test_docs_build.py drops
its structural-warning allowlist and its thirty-docstring exemption for a flat
assertion that the build emits nothing, plus a check that -W stays in
SPHINXOPTS -- otherwise the gate could be removed without a single test going
red.
`pip install -e .` and `pip install qbiocode` are the two things this needs to
get right, so both were run for real: a scratch venv resolved all 31 base
dependencies (130 packages, no conflicts), and the built wheel installed,
imported from site-packages, and answered `--help` on all four console scripts.
`[quvine]` resolves too, backing setuptools down to 80.10.2 to satisfy both its
own `<81` pin -- node2vec imports the `pkg_resources` that 81 removed -- and
qiskit-machine-learning's `>=40.1`.

What that exercise found:

**The sdist was 62 MB, and its size depended on shell history.**
`recursive-include tutorial *.ipynb *.png *.csv *.pkl` globs the working tree,
not the index. `tutorial/**/data/` and `tutorial/**/*.png` are gitignored
notebook output with four fixtures force-added as exceptions, so on a checkout
where the data-generation notebook had been run once the sweep packaged 239
untracked CSVs -- 111 MB of them, 123 MB unpacked. The same commit built to
either 9 MB or 62 MB depending on who built it. The four fixtures are named
individually now; the sdist is 9.4 MB.

**`py.typed` was declared as package data and has never existed.** setuptools
shipped nothing, so the bug was latent -- but the repair anyone would reach for
is to create the file, and that claim would be false. `py.typed` tells a
consumer's type checker the annotations here are complete and authoritative,
while `disallow_untyped_defs = false` says they are not; downstream users would
get confident errors derived from `Any`. The declaration is gone, with the
reason recorded next to it.

Both are pinned by tests/integration/test_distribution_contents.py, which builds
a real sdist and wheel (`--no-isolation`, so no network) and asserts the
invariant that generalizes: **the sdist contains only files git tracks.** That
catches run artifacts, editor backups, a stray virtualenv and a downloaded
dataset in one assertion, and needs no updating as the tree grows -- unlike a
size cap. Verified to have teeth by restoring the old MANIFEST line and watching
it fail on those 239 CSVs. Alongside it: the wheel carries the two config YAMLs
its apps read at startup and no tests, docs or notebooks; the sdist carries the
requirements file `[tool.setuptools.dynamic]` reads dependencies from at build
time; and the dependency metadata is non-empty with no build-time setuptools
leaked into it, which is what the old 189-line `setup.py` used to produce.

`twine check` passes on both artifacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects of the same family: each returned a plausible-looking result
where it should have refused, or refused what it documents.

`__init__` sliced a metadata list naming *both* `BestParams_GridSearch` and
`Model_Parameters`. QProfiler writes exactly one -- `model_evaluation.py`
branches on `args["grid_search"]` -- so construction from a real results table
raised `KeyError: "['BestParams_GridSearch'] not in index"` with grid search
off, and the mirror-image error with it on. There was no configuration in
which QSage could read its own documented input, and the error named a column
the user had never heard of instead of the mismatch.

`predict` forwarded the caller's frame to a fitted estimator, but
`train_sub_sages` appends a derived `SLGH` column after splitting. Passing
exactly the columns the docstring asks for produced sklearn's "Feature names
seen at fit time, yet now missing: - SLGH", blaming the caller for a column
the class invents. It is now recomputed here, so a stale value cannot reach
the estimator either.

`predict` also took `.predict(...)[0]` internally, ranking on whichever row
sorted first and discarding the rest with nothing in the output saying so.
That is easy to reach by accident: complexity features are measured on the
*embedded* data, so one dataset contributes a separate row per (embedding,
iteration) and the obvious `results_df[features].drop_duplicates()` yields
several. A multi-row input is now refused, and the message says how to get to
one row.

The QSage tutorial ran none of its cells; it now runs all nine against a
committed 15-dataset QProfiler table, predicts per embedding, and asserts a
save/reload round-trip reproduces the whole prediction table. Its picks are
mostly wrong, and the notebook says so rather than tuning the fixture until
the demo flatters the method -- 15 datasets demonstrate the mechanism, not a
fitted surrogate, so the text points the reader at `r2` first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three log lines sat above `_validate_config` and indexed `args['n_jobs']`,
`args['model']` and `args['backend']` directly. A config missing any of them
died with a bare KeyError raised from inside a logging statement -- the exact
"error attributed to the wrong thing" that `_validate_config` exists to
replace, and it named one missing key where the validator names all of them at
once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committed with 0 of 8 code cells run. `nbsphinx_execute = 'never'`, so it
published as a page of empty cells. No source changed -- it needed running,
not fixing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_the_sdist_ships_only_files_git_tracks` failed with 231 strays, and
MANIFEST.in was not the cause: building the same commit from a clean copy gave
285 members and 0 strays. setuptools *unions* the previous
`qbiocode.egg-info/SOURCES.txt` into each new sdist rather than recomputing it,
so a checkout that ever built with a broader MANIFEST.in keeps shipping files
MANIFEST.in no longer names -- here, 231 CSVs of notebook run output. A release
built locally would have published them.

The fixture now builds from a staged copy of the working tree with `*.egg-info`
and build residue excluded. Deliberately not a clean `git archive`: MANIFEST.in
globs the *working tree*, so run output under `tutorial/**/data/` has to stay
visible or the test proves nothing. That is a second, independent
builder-dependence channel, and the stale-metadata one was masking it
entirely. CONTRIBUTING now documents the `rm -rf dist build *.egg-info` this
implies for local builds; CI is immune by construction, since a fresh
`actions/checkout` has no metadata to be stale.

`test_every_guarded_module_is_actually_optional` failed on the same commit
because that test's own `importorskip('build')` claimed the `[dev]` extra
provides `build`, which was untrue -- so the guard could only ever skip. `build`
is now in `[dev]`, and the hygiene rule reads pyproject's extras as well as the
tiered requirements files: `quvine` and `docs` are mirrored between the two,
but `dev` and `apps` exist only as extras, so reading just the files made a
genuinely optional dependency look undeclared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xgboost and torch each vendor their own copy of libomp under the same
install name, so importing both maps two independent LLVM OpenMP runtimes
into one process. The first to initialise claims the process-wide runtime
state, and the second dies when it opens a parallel region:

    Segmentation fault: 11
    EXC_BAD_ACCESS (KERN_INVALID_ADDRESS at 0x580)
    libomp.dylib  __kmp_suspend_initialize_thread
    libomp.dylib  __kmp_launch_worker

qbiocode forced the losing order. qbiocode/__init__.py imports .embeddings
before .learning, and .embeddings eagerly imported ConvAutoencoder, whose
first line is `import torch` -- so torch's runtime was installed first in
every process that imported the package, and every XGBoost fit that
followed was a segfault. That took out QPL's qpl_xgb arm, compute_xgb, and
so any QProfiler run configured with an XGBoost model. There is no Python
traceback and nothing to catch, so it surfaced only as DeadKernelError
from a notebook and a silent exit 139 from a script, and it looked like a
failure in the quantum step because the other four QPL learners had
already printed.

Measured, fitting an XGBClassifier: xgboost alone is fine, xgboost then
torch is fine, torch then xgboost segfaults. Turning parallelism down does
not help -- neither n_jobs=1 on the estimator nor on the surrounding
search -- because the fault is inside the OpenMP runtime, below joblib.
Only the import order or disabling OpenMP outright avoids it.

So: ConvAutoencoder resolves through a lazy module __getattr__, and
`import qbiocode` no longer loads torch at all. Nothing in the package
uses that class, so the eager import bought nothing. And the package body
now calls preload_openmp_libraries() before any submodule import, which
initialises xgboost's runtime first and keeps a caller's later
`import torch` safe; it warns if torch was already imported first, since
by then no ordering can help.

qbiocode/utils/_openmp.py records the measurements, because the next
person to see this will have a bare SIGSEGV and no other evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All seven tuned learners took one keyword per tunable hyperparameter, each
defaulting to [], and handed all of them to GridSearchCV unconditionally.
Any config naming a subset therefore died inside sklearn on whichever
parameter it had left alone:

    ValueError: Parameter grid for parameter 'colsample_bytree' need to be
    a non-empty sequence, got: []

The message names a parameter the user never wrote and points at sklearn
rather than at the config, which is the opposite of the useful direction.
It also made a deliberately small grid inexpressible: trimming a demo
config down to two parameters was indistinguishable from corrupting it,
which is why the QPL tutorial shipped a 2430-combination XGBoost grid
costing over six hours.

build_param_grid keeps only the values actually supplied, leaving every
unmentioned hyperparameter at the estimator's own default -- which is what
"not tuned" should mean. When nothing at all was supplied it raises a
message naming the config block to add and the grid_search: False opt-out,
rather than blaming an arbitrary parameter.

Two related fixes came with it. A bare string is wrapped rather than
searched character by character: `max_features: sqrt` in YAML was
previously searched as ['s', 'q', 'r', 't'], four invalid values that
produced no error and a meaningless best_params_. And the [] defaults are
now None -- a shared mutable default is a hazard whether or not this code
happened to mutate it.

compute_xgb_opt also now reports a bootstrap grid instead of silently
doubling. XGBoost has no bootstrap parameter, but its sklearn wrapper
accepts unknown keyword arguments without complaint, so the shipped
tutorial config was never an error; it just searched twice as many
combinations, every duplicate returning the same model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notebook could not be run. Measured on a laptop, its two compute cells
cost roughly nine hours: the quantum cell about 2.8h (32 requested
datasets at 10-15 features, and PQK simulation cost is exponential in the
qubit count, which equals the feature count under embeddings: ['none'] --
6 qubits 6.3s per 100 samples, 15 qubits 138.3s), and the classical cell
about 6.4h (a 2430-combination grid x 5-fold CV, 265s per dataset and
iteration). Cut to 4 datasets at 6-8 features and a 16-combination grid,
the whole notebook now executes in 270s and shows the same behaviour. The
sizing comment in the dataset cell carries the measurements so the next
person widening it knows what it costs.

Three things were also simply wrong.

It invoked compute_pqk while its own overview described compute_qpl. PQK
fits a single SVC and reports one row named 'pqk'; QPL projects through
the same feature map and fits five classical learners, reporting qpl_svc,
qpl_rf, qpl_xgb, qpl_mlp and qpl_lr. The five-learner comparison is what
the notebook is about, so the config was the thing that was wrong. A
qpl_args block had to come with it: model_run reads args[method + "_args"],
so a pqk_args block is invisible to model: ['qpl'] and QPL silently fell
back to its signature defaults while the config appeared to ask otherwise.

The quantum-advantage cell hard-coded qml_models as ['pqk_lr', 'pqk_svc',
'pqk_rf', 'pqk_mlp', 'pqk_xgb'] -- the five QPL learner names wearing the
PQK prefix, a set neither implementation can produce. Nothing ever
matched, so the win rate printed 0.0% however well the quantum models did.
It now reads the names out of the results by prefix, and reports 50.0%.

rf.yaml was fully configured and loaded by nothing, which is why nobody
noticed its folder_path pointed at output from a different notebook --
gitignored, committed nowhere, so from a fresh clone the run died on a
folder that had never existed. It is now the second tuned classical
baseline, alongside XGBoost, which is what makes the quantum comparison
worth making.

Smaller: the notebook is idempotent now (ModelResults.csv is appended to,
so a second pass silently doubled every row), and the plotting cell's
shared output_dir/tag moved out of an `else:` branch that left the next
cell raising NameError on exactly the run where you want the message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notebook was 9 of 10 code cells executed, and nbsphinx_execute is
'never', so it published as a partly-empty page. It needed running, not
fixing: all 10 cells now execute end to end with no source changes.

Its KNOWN_TRUNCATED entry claimed "its import cell needs anndata, which is
not installable in the environment this test suite was written in".
anndata 0.13.3 and scanpy 1.12.4 are both installed and both import fine,
so the reason was false and the strict xfail was hiding a notebook that
only ever needed to be run.

Also fixes a reversed mirror direction: the tutorial/ copy of
QEnsemble_example_blobs.ipynb said it was "mirrored under tutorial/",
pointing at itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sc_binary_quvine_2x2_qprofiler.ipynb had 7 of 8 code cells run in both trees.
The unexecuted cell was the notebook's conclusion -- the 2x2 comparison of
classical vs QuVINE embeddings against classical vs quantum models -- so with
nbsphinx_execute = 'never' the published page ended just before its result.

Run: 8/8 cells, 0 errors, 3 figures, 228s, 36 result rows (4 embeddings x 3
models x 3 iterations). Both copies carry identical outputs.

Its two KNOWN_TRUNCATED entries in test_notebook_execution.py are removed, so
test_no_notebook_is_half_executed now enforces the completed state rather than
recording the truncation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notebook had 0 of 7 code cells executed, so its docs page published the
source with nothing under it, and it could not simply be run to fix that:

- folder_path was 'tutorial/QProfiler/data/ld_data', a repository-relative path.
  _resolve_input_folder searches every ancestor of the cwd, so from the docs copy
  it resolved to the *tutorial* tree's data -- both copies read one input -- and
  from an installed package it resolved to nothing. It is now 'data/ld_data',
  the directory the notebook's own first cell generates next to itself, which is
  correct from either tree.
- The two configs/config.yaml copies had drifted: different model lists,
  embeddings, n_jobs and credential comments, so the same notebook behaved
  differently depending on which tree you opened. They are now byte-identical and
  sized for a tutorial (embeddings ['none','pca'], 8 models, n_jobs 1, iter 2),
  with the full ten-model list kept as a comment for a real sweep.
- xgb_args and gridsearch_xgb_args were absent, so xgb ran on _model_args'
  empty-dict fallback with nothing documenting what it can tune. Both blocks are
  now present.
- Replaced ax.set_xticklabels(ax.get_xticklabels(), rotation=45, ha='right')
  with ax.tick_params(axis='x', rotation=45). Setting tick labels on an axis
  whose tick locations are not fixed warns in matplotlib >= 3.5 and misattaches
  the labels if the locator picks a different tick count.

Run: 7/7 cells, 0 errors, 7 figures, 80s, in a clean sandbox from empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six notebooks were re-executed earlier in this release with MPLBACKEND=Agg in
the environment. Inside an ipykernel the backend that produces inline output is
module://matplotlib_inline.backend_inline, which captures plt.show() as an
image/png display output; Agg makes plt.show() a silent no-op. The cells ran,
reported success, and emitted no figure at all -- and because
nbsphinx_execute = 'never', the published pages rendered code and printed text
with every plot gone. test_no_notebook_is_half_executed stayed green throughout,
since the cells really were executed.

Caught by diffing image/png counts against the pre-existing commits:
quvine_sc_cd4_vs_cd8.ipynb had 4 figures at c19b5ac^ and 0 after.

Re-run under the default inline backend, figures intact:
  QPL_example.ipynb            4 figures (both trees)
  qsage.ipynb                  7 figures (both trees)
  quvine_sc_t_vs_mono.ipynb    2 figures
  quvine_sc_cd4_vs_cd8.ipynb   4 figures

Every git-tracked notebook was audited against origin/main to bound the damage
to these six.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
embed.pqk's data_map_func ended in `return float(coeff)`. Qiskit calls a
data_map_func with symbolic parameters while it constructs the circuit --
PauliFeatureMap.pauli_block does

    params = ParameterVector("_", length=len(pauli_string))
    time = self._data_map_func(np.asarray(params))

-- so float() raised "Parameter expression with unbound parameters {...} is not
numeric" before anything executed. data_map=True is the default, making pqk()
unusable for Z, ZZ and P alike.

It went unnoticed because compute_pqk held a second, already-fixed copy of the
same function: the QProfiler `pqk` model worked while calling pqk() directly --
what tutorial/PQK - OV.ipynb does -- did not.

Both copies now call one shared qutils.unit_coefficient_data_map, which returns
a float for numeric input and the unevaluated ParameterExpression for symbolic
input, so the two PQK paths cannot diverge again. The now-unused
`from functools import reduce` import is dropped from both modules.

Also replaces the bare KeyError from get_backend_session's unchecked
args["seed"] / args["shots"] with a ValueError naming the missing keys, what
each is for, and the keys that were supplied -- this is how the notebook's
failure first presented once the data map worked.

tests/test_feature_map_data_map.py: numeric contract, symbolic input at three
vector lengths, all four (encoding, entanglement) pairs built through
get_feature_map, a pqk() round trip on the statevector simulator, and the
missing-seed message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QEnsemble_example_blobs.ipynb had 10 of 15 code cells executed in both trees.
The unrun cells were the xgb, qcosine, qensemble and qensemble_random_unitary
arms plus the post-processing that compares them -- so the published page
stopped immediately before the comparison the tutorial exists to make.

Two defects kept it there:

- Every arm is guarded against experiments/predictions.pkl with
  `method not in predictions[dataset_name].keys()`. A method that ran but
  produced nothing is also a key, so the committed cache's empty xgb_gs frame
  (0 rows, recorded in an environment without XGBoost) counted as done and was
  never retried. The notebook printed "XGBoost grid search results are empty
  ... Skipping XGBoost" on an install where xgboost is in requirements-base and
  importable. The guard is now needs_run(predictions, dataset_name, method,
  rerun), a helper_functions.py addition that treats an empty cached result as
  absent.

- helper_functions.py had re.sub('\ ', '_', metric) -- an invalid escape
  sequence, a SyntaxWarning today and a SyntaxError in a future Python. It is
  now re.sub(' ', '_', metric), the same regex.

Run: 15/15 cells, 0 errors, 3 figures, 41s. xgb_gs fits its 486 candidates over
3 folds and xgb its 90 rows, and the post-processing now reports XGBoost against
every quantum arm with significance tests. predictions.pkl and the three
Blob_max_median_*.pdf figures carry the completed comparison. The cheap arms
recompute on a fresh run; the quantum arms still come from the cache, which is
what makes the notebook openable without a long sweep.

Both trees' notebooks and helper_functions.py are byte-identical.

With this, KNOWN_TRUNCATED in test_notebook_execution.py is empty: every
notebook in the tree is either a clean template or fully executed. The mechanism
stays so a future truncation is recorded deliberately rather than by weakening
test_no_notebook_is_half_executed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aritra Bose and others added 8 commits August 31, 2026 18:12
…rials

The term names a complexity-theoretic result. What these notebooks measure is a
per-dataset score difference between quantum and classical arms on small
simulated data, which is not evidence of one, so the headings, labels, prints
and figure names now say what is actually being compared.

QPL_example.ipynb: section 9 becomes "Quantum vs. Classical Comparison", its
figure becomes {tag}_quantum_vs_classical.png, and its prints name the datasets
a quantum model ranked first on. Its committed output text is patched to match:
every changed byte is a print literal, so no number moved and re-running a
40-minute simulation would only have perturbed results that are not at issue.

QEnsemble_example_blobs.ipynb: the "Quantum Advantage" takeaway becomes
"Quantum Ensembling" -- superposition over classifiers is a mechanism, not a
demonstrated advantage. Its two "theoretical quantum advantage" mentions in the
further-work lists are kept, where the term is used correctly.

quvine_sc_cd4_vs_cd8.ipynb: the quantum_adv column, comments and plot axis
become "quantum recall gain", which is the quantity -- recall(ctqw,dtqw) −
recall(node2vec). This one had to be re-executed because its axis label is
rasterized into a committed figure, and the re-run moved its numbers materially
(one stratum p=0.005 -> p=0.376, several bootstrap correlations flipped sign):
quvine/embedding/word2vec.py trains gensim Word2Vec with workers=8 and takes no
seed, so no SGNS embedding is reproducible however base_seed is threaded through
the walks. Recorded in the CHANGELOG rather than fixed here -- workers=1 plus a
seed is an 8x slowdown that would change every committed QuVINE number.

background.md keeps its discussion of the concept and the Huang et al. title,
and evaluate_graph keeps compute_quantum_advantage_metrics and its
quantum_advantage_* keys: those are public API and saved-CSV column names, not
worth breaking over wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
run_model returned best_model.score(x_test, y_test), which for an SVC is
accuracy. It was then named f1_score, printed as "Test F1 score", stored in
F1_Quantum/F1_Classical, written to PQK_OV_results.csv and used as the plot's
"F1 Score" axis -- and it disagreed with the GridSearchCV in the same function,
which selects on 'f1_weighted'. The tell in the previous run was that all six
reported values were exact k/59 fractions of the 59-sample test set. Score the
held-out set with f1_score(..., average='weighted') so the number reported and
the criterion selected on are the same metric.

Also print('\n=' * 60) -> print('\n' + '=' * 60): the original repeats
newline-plus-equals sixty times, so five section breaks each rendered as 60
lines containing a single '='.

Outputs land in a follow-up commit; the corrected metric requires re-executing
the grid search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
param_grid was one flat dict sweeping gamma across all four kernels, but SVC
ignores gamma when kernel='linear'. That made 35 C x 37 gamma = 1,295 fits of
just 35 distinct linear models -- 24.3% of the 5,180-combination grid spent on
exact duplicates -- and left a linear winner reporting a best gamma that the
model never read.

Use a list of grids so gamma applies only to poly/rbf/sigmoid: identical search
space, 3,920 combinations instead of 5,180. Selection is unaffected beyond no
longer reporting a meaningless gamma for a linear best model. The config cell
now prints the combination count, since this grid is the notebook's entire
runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-executed `PQK - OV.ipynb` end to end (8/8 cells, 1 figure, 0 errors) on
top of the three source fixes in e527328 and e2a7b12, and committed the
outputs to both the tutorial/ and docs/source/tutorials/ copies (they are
byte-identical).

The numbers moved, because the previous outputs were not what they claimed
to be. `best_model.score()` returns accuracy for SVC, so every value in the
F1_ columns, the summary CSV and the plot was an accuracy -- all six were
exact k/59 fractions of the 59-row test set, which is also why two
modalities reported identical quantum and classical values. Post-fix every
value is a genuine weighted F1 (non-integer k/59, matching the
scoring='f1_weighted' the grid search selects on):

               F1_Quantum  F1_Classical  Improvement_%
    mirna        0.526242      0.591067     -10.967430
    methy        0.403709      0.637668     -36.689845
    exp          0.519209      0.526242      -1.336443
    integrated   0.560082      0.625145     -10.407632

Average quantum 0.5023 vs classical 0.5950 (-14.85%). The result is
negative: on this dataset the PQK arm does not beat the classical SVM. The
notebook's prose never claimed otherwise ("Evaluated quantum-enhanced vs.
classical SVM"), so no text needed changing. `Grid combinations: 3920`
confirms the deduplicated grid, and cells 4 and 6 now emit 6 and 8 full
rules with no lone '=' lines.

Also add the .gitignore rules this notebook needed and never had: it
downloads a 46 MB archive and extracts ~250 MB of TCGA tables beside
itself, then writes a results CSV there. Without `tutorial/**/OV/`,
`tutorial/**/ovarian.zip` and `tutorial/**/PQK_OV_results.csv` a reader's
first run leaves ~300 MB of untracked files that `git add -A` would
commit. Every byte is reproducible from the URL in the notebook's own
download cell. Added `tutorial/**/qpl_projections/` for the same reason --
its sibling `pqk_projections/` was already ignored, this one was missed.
The notebook died partway through cell 3 with no traceback -- a bare dead
kernel. Three crash reports on this machine, all identical:

    EXC_BAD_ACCESS  SIGSEGV / Segmentation fault: 11
      libomp.dylib  __kmp_suspend_64<false, true>
      libomp.dylib  __kmp_hyper_barrier_release
      libomp.dylib  __kmp_fork_barrier
      libomp.dylib  __kmp_launch_worker
    libomp copies mapped: 3

Three separate LLVM OpenMP runtimes end up in one process: torch's, the one
qiskit-aer vendors, and the interpreter's own (this venv is built on
Anaconda). Whichever initialises second finds bookkeeping it did not create
and dies the first time it opens a parallel region. Isolated per method,
the three torch-backed entries in the notebook's `methods` list each
reproduce it standalone:

    quvine_rwr, filter_rwr_heat, node2vec, netmf   ok
    appnp, gat_rwr_poly, graphgps_rwr_heat         exit 139 (SIGSEGV)

It is not a Jupyter or VS Code problem -- nbclient dies the same way.
Tested fixes: OMP_NUM_THREADS=1 works, importing torch before qbiocode
works, KMP_DUPLICATE_LIB_OK=TRUE does not.

The fix is one line the sibling notebook already had.
`quvine_sc_t_vs_mono.ipynb` sets OMP_NUM_THREADS=1 in its imports cell;
`example_quvine.ipynb` had zero occurrences of it, and it is the one that
calls the torch methods. Set it before the qbiocode import, since the
runtime initialises at import time.

Re-executed end to end: 11/11 cells, 4 figures, 0 errors, 248s. Cell 3
wraps each embed in a try/except that records NaN, so completion alone
proves nothing -- verified 180 rows collected, no NaN in the summary, and
the error key never populated, so appnp (0.698), gat_rwr_poly (0.862),
gat_ctqw_poly (0.863), graphgps_rwr_heat (0.863) and graphgps_ctqw_heat
(0.873) all really ran.

CHANGELOG also records why this cannot be fixed by reordering instead:
preload_openmp_libraries() imports xgboost at `import qbiocode` so
xgboost's runtime wins, which is what the QPL pipelines need and is exactly
why torch loses here. The orderings are mutually exclusive, so
OMP_NUM_THREADS=1 is the only setting that satisfies both.
_resolve_input_folder tried dir_home / folder_path -- a root frozen at import
time -- before walking the ancestors of the current directory. A CLI run never
notices, because cwd has not moved since import. A notebook kernel, a test
session, or a batch driver that changes directory does: the frozen root wins,
and a run launched from QBioCode-main/tutorial/QProfiler silently reads a
different checkout's CSVs and reports them as its own.

Candidate order is now: the path as given, each ancestor of the current
directory, a root derived from the current directory, and dir_home last --
still there so existing configs resolve, no longer able to shadow the caller.

Also marks dir_home :meta private:. autodoc renders module-level values, so
api/qbiocode.apps.qprofiler.html was publishing the doc builder's own absolute
filesystem path to GitHub Pages.
Seventeen notebooks stored the running machine's own filesystem paths in their
output: a node2vec pkg_resources warning, tqdm's 'IProgress not found' banner,
and print() echoes of the working and data directories. nbsphinx_execute is
'never', so those strings publish verbatim to GitHub Pages -- where they name a
directory that does not exist on the reader's machine and disclose the author's
home directory layout for no benefit.

All of them are now <env>/ or <repo>/ placeholders. Verified text-only: cell
counts, execution_counts, output counts and figure payload sizes are byte-
identical to HEAD in all seventeen files, so no metric, table or plot moved.

archive/tutorial_notebooks/analyses/visualize_results.ipynb keeps its five --
they are commented-out source paths recording where an earlier analysis ran,
not stray output, and that tree is not part of the published site.
Both notebooks existed only under tutorial/, so neither reached the site.
Copy them into docs/source/tutorials/QuVINE/ and give each a section in
tutorials.md §4, which now presents all four QuVINE notebooks in reading
order (synthetic-graph API walkthrough, CD4 vs. CD8, T vs. monocyte, then
QuVINE-through-QProfiler) with What You'll Learn / Key Concepts blocks and
toctree entries.

  example_quvine.ipynb        11 cells, 4 figures -- 12 methods x 3 SBMs
                              x 5 iterations, then complexity vs. macro-F1
  quvine_sc_t_vs_mono.ipynb    8 cells, 2 figures -- seed->eval node
                              classification and seed->target ranking on
                              the 800-cell two-view graph

Publishing example_quvine.ipynb surfaced 127 KB of dead metadata.widgets
(165 tqdm records with no widget output to render -- every progress bar was
already captured as plain text). nbsphinx warned "nbsphinx_widgets_path not
given and ipywidgets module unavailable", fatal under sphinx-build -W, and
the payload would have shipped to Pages unrendered. Removed from both
copies; nothing else changed (same 25 cells, same execution counts, outputs
byte for byte). Adding ipywidgets to [docs] would have silenced the warning
and kept the dead payload.

Two static guards in tests/test_docs_structure.py (19 checks, up from 17):
metadata.widgets with no widget output, and any /Users, /home or
/private/tmp string in a notebook under tutorial/ or docs/source/tutorials/
-- so neither condition can silently return.

Verified: sphinx-build -W --keep-going -a -E exits 0 with no warnings; both
pages render with their figures and outputs (109 KB and 105 KB); every
tutorials.html link resolves; no absolute path anywhere in the built site.

Separate from the migration commits: additive, independently revertable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aritra90
aritra90 requested a review from f-utro September 3, 2026 06:58
@f-utro
f-utro merged commit ed3b42d into main Sep 3, 2026
1 check failed
@f-utro
f-utro deleted the migrate/internal-merge branch September 3, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants