# Copyright 2026, IBM Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Quantum-target construction for quantum-calibrated registry methods.
``build_walk_targets`` is the real, walk-based target generator used by
:func:`quvine.embed`: for each calibration seed it samples a local subgraph and
runs the requested walk (CTQW/DTQW via hiperwalk, or classical RWR) to produce
the probability distribution ``pQ`` that the heat/poly filter is calibrated to.
This is the same construction the HPC engine
(``comprehensive_embedding_analysis._generate_quantum_targets``) uses, so the
``ctqw``/``dtqw``/``rwr`` variants produce genuinely different calibrations.
``select_calibration_seeds`` infers dispersed, traversal-useful calibration
centers (farthest-point / k-center landmarks) when the caller passes none.
``build_quantum_targets`` is the legacy index-distance stub, kept only for
backward compatibility; ``embed()`` no longer uses it.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional, Sequence
import networkx as nx
import numpy as np
logger = logging.getLogger(__name__)
[docs]
def select_calibration_seeds(
G: nx.Graph,
k: Optional[int] = None,
seed: int = 0,
) -> List:
"""
Select dispersed, traversal-useful calibration seeds via farthest-point
(k-center) landmark selection.
A naive "top-k degree" choice tends to pick mutually-adjacent hub nodes in
one dense core, giving overlapping calibration subgraphs. Instead: anchor on
the highest-degree node, then greedily add the node that is *farthest* (in
shortest-path hops) from the already-selected set, maintaining a running
nearest-landmark-distance array. This guarantees the landmarks are pairwise
non-adjacent (>=2 hops) and spread across the graph.
Cost is K BFS sweeps total (``O(K*(V+E))``) using scipy CSR BFS; runs
per-connected-component so disconnected graphs still get coverage.
Args:
G: graph.
k: number of landmarks; defaults to ``max(3, min(10, n // 20))``.
seed: reserved for reproducibility (selection is deterministic).
Returns:
List of node ids (the graph's own labels).
"""
import scipy.sparse as sp
from scipy.sparse.csgraph import breadth_first_order
nodes = list(G.nodes())
n = len(nodes)
if n == 0:
return []
if k is None:
k = max(3, min(10, n // 20))
k = min(k, n)
idx_of = {node: i for i, node in enumerate(nodes)}
A = nx.to_scipy_sparse_array(G, nodelist=nodes, format="csr", dtype=float)
degrees = np.asarray(A.sum(axis=1)).ravel()
def bfs_hops(src_i: int) -> np.ndarray:
"""Unweighted shortest-path hop counts from src_i (inf if unreachable)."""
order, preds = breadth_first_order(A, src_i, directed=False, return_predecessors=True)
dist = np.full(n, np.inf)
dist[src_i] = 0.0
# Reconstruct hop distance by walking predecessors in BFS order.
for v in order:
p = preds[v]
if p >= 0:
dist[v] = dist[p] + 1.0
return dist
# Anchor: highest degree, deterministic id tie-break.
first = max(range(n), key=lambda i: (degrees[i], -i))
chosen = [first]
min_dist = bfs_hops(first)
while len(chosen) < k:
# Farthest node from the chosen set; ignore unreachable (inf) unless we
# must (then we still pick the max finite, else any unchosen node).
candidate = int(np.argmax(np.where(np.isin(np.arange(n), chosen), -1.0, min_dist)))
if candidate in chosen or not np.isfinite(min_dist[candidate]) or min_dist[candidate] <= 0:
# No reachable node left with positive distance (e.g. another
# component): fall back to the highest-degree unchosen node.
remaining = [i for i in range(n) if i not in chosen]
if not remaining:
break
candidate = max(remaining, key=lambda i: (degrees[i], -i))
chosen.append(candidate)
min_dist = np.minimum(min_dist, bfs_hops(candidate))
return [nodes[i] for i in chosen]
[docs]
def build_walk_targets(
G: nx.Graph,
seeds: Sequence,
walk_type: str = "ctqw",
num_subgraphs: int = 5,
subgraph_size: int = 20,
steps: int = 20,
seed: int = 42,
) -> Optional[List[Dict]]:
"""
Build real walk-based calibration targets.
For up to ``num_subgraphs`` seeds, expand a <=2-hop subgraph (capped at
``subgraph_size``, restricted to the component containing the center), run
the requested walk from the center, and record the node-probability
distribution as ``pQ``. The returned ``{"nodes","center","pQ"}`` dicts
satisfy the contract validated by ``gat.calibrate_heat_kernel`` /
``calibrate_polynomial_filter``.
Args:
G: graph.
seeds: calibration center node ids (only those present are used).
walk_type: ``"ctqw"`` | ``"dtqw"`` (need hiperwalk) | ``"rwr"`` (classical).
num_subgraphs, subgraph_size, steps: sampling controls.
seed: RNG seed for subgraph sampling (deterministic).
Returns:
List of target dicts, or ``None`` if no valid seed is present.
Raises:
QuvineMethodError: if ``walk_type`` is ctqw/dtqw but hiperwalk is missing.
ValueError: on unknown ``walk_type``.
"""
from qbiocode.apps.quvine.data.subgraph import expand_neighborhood
node_set = set(G.nodes())
valid_seeds = [s for s in seeds if s in node_set]
if not valid_seeds:
return None
if walk_type == "ctqw":
scorer = _ctqw_scorer(steps)
elif walk_type == "dtqw":
scorer = _dtqw_scorer(steps)
elif walk_type == "rwr":
from qbiocode.apps.quvine.walks.rwr import get_RWR_pagerank_scores
scorer = lambda H, center: get_RWR_pagerank_scores(H, root=center)
else:
raise ValueError(f"Unknown walk_type {walk_type!r}; choose 'ctqw', 'dtqw', or 'rwr'.")
rng = np.random.default_rng(seed)
sampled = rng.choice(
np.array(valid_seeds, dtype=object),
size=min(num_subgraphs, len(valid_seeds)),
replace=False,
)
targets: List[Dict] = []
for center in sampled:
try:
subgraph_nodes = expand_neighborhood(G, {center}, radius=2)
if len(subgraph_nodes) > subgraph_size:
picked = rng.choice(list(subgraph_nodes), size=subgraph_size, replace=False)
subgraph_nodes = set(picked.tolist())
subgraph_nodes.add(center)
H = G.subgraph(subgraph_nodes).copy()
if not nx.is_connected(H):
H = H.subgraph(nx.node_connected_component(H, center)).copy()
if H.number_of_nodes() < 3:
continue
scores = scorer(H, center)
nodes_list = list(H.nodes())
pQ = np.array([scores.get(nd, 0.0) for nd in nodes_list], dtype=np.float64)
total = pQ.sum()
if total <= 0:
continue
targets.append({"nodes": nodes_list, "center": center, "pQ": pQ / total})
except Exception as exc: # noqa: BLE001 -- per-seed best effort
logger.warning("walk-target generation failed for seed %r: %s", center, exc)
continue
return targets or None
def _ctqw_scorer(steps: int):
try:
from qbiocode.apps.quvine.walks.ctqw import generate_ctqw_hiperwalk_scores
except ImportError as exc: # hiperwalk missing
from qbiocode.apps.quvine.api.core import QuvineMethodError
raise QuvineMethodError(
"ctqw calibration targets require the 'hiperwalk' package. Install it, "
"or use an '_rwr_' method (classical, no hiperwalk needed)."
) from exc
return lambda H, center: generate_ctqw_hiperwalk_scores(H, root=center, steps=steps)
def _dtqw_scorer(steps: int):
try:
from qbiocode.apps.quvine.walks.dtqw import get_coined_hiperwalk_scores
except ImportError as exc:
from qbiocode.apps.quvine.api.core import QuvineMethodError
raise QuvineMethodError(
"dtqw calibration targets require the 'hiperwalk' package. Install it, "
"or use an '_rwr_' method (classical, no hiperwalk needed)."
) from exc
return lambda H, center: get_coined_hiperwalk_scores(H, root=center, steps=steps)
[docs]
def build_quantum_targets(
graph: nx.Graph,
seeds: Sequence,
max_support: int = 64,
) -> Optional[List[dict]]:
"""
DEPRECATED — use :func:`build_walk_targets`. This builds a walk-AGNOSTIC
placeholder (``pQ`` from node-index distance), so it cannot distinguish
ctqw/dtqw/rwr. Kept only for backward compatibility; ``embed()`` no longer
calls it.
Build per-seed quantum targets used to calibrate quantum filters.
For each valid seed, collect its <=2-hop neighborhood (capped at
``max_support`` nodes) and assign a normalized inverse-distance target
distribution ``pQ`` over that support.
Args:
graph: NetworkX graph.
seeds: Seed node ids (only those present in the graph are used).
max_support: Maximum number of support nodes per seed.
Returns:
A list of ``{"nodes", "center", "pQ"}`` dicts, or ``None`` if no seed
is present in the graph.
"""
node_order = list(graph.nodes())
node_to_idx = {node: i for i, node in enumerate(node_order)}
valid_seeds = [node for node in seeds if node in node_to_idx]
if not valid_seeds:
return None
targets = []
for center in valid_seeds:
lengths = nx.single_source_shortest_path_length(graph, center, cutoff=2)
support_nodes = [node for node, dist in lengths.items() if dist <= 2]
if center not in support_nodes:
support_nodes.append(center)
if len(support_nodes) > max_support:
support_nodes = support_nodes[:max_support]
center_idx = node_to_idx[center]
support_idx = np.array([node_to_idx[node] for node in support_nodes], dtype=np.int64)
dist = np.abs(support_idx - center_idx).astype(np.float64) + 1.0
p = 1.0 / dist
p = p / p.sum()
targets.append({"nodes": support_nodes, "center": center, "pQ": p})
return targets