e5697049b1e077237d1f83c8293e23d62ded0820
braney
  Tue May 12 11:40:26 2026 -0700
quickLiftBench: Mode C reference hub + hub-variant case schema, refs #37445

Adds a synthetic 4-track hub (bigBed native + lifted, bigWig native +
lifted) at utils/qa/quickLiftBench/testHub/, regenerated via
buildTestHub.sh, and extends the bench schema so a variant can be a
mapping (hubUrl + db + position + tracks) in addition to the existing
saved-session string. The two new cases mode_c_hs1_bb and mode_c_hs1_bw
exercise quickLift on hs1 against an hg38-sourced chain, with both
variants rendering at the same hs1 coords so the only difference is
whether the chain-remap step runs.

First numbers (n=10, hgwdev, chr22:15M-50M, 5000 source features /
34000 bedGraph bins):

case             native total   lifted total   parallel-fetch delta
mode_c_hs1_bb    1376 ms        1154 ms        +900 ms (chain remap)
mode_c_hs1_bw    26 ms          27 ms          negligible

bigBed scales per-feature (~180 us/feature for 5000 hg38 features
remapped); bigWig is essentially free.

Also moves the regress_quickLift_parallel case from server: sandbox to
server: hgwdev now that #37470 has landed on dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git src/utils/qa/quickLiftBench/quickLiftBench.py src/utils/qa/quickLiftBench/quickLiftBench.py
index 1001d39bdfc..8e61246b389 100755
--- src/utils/qa/quickLiftBench/quickLiftBench.py
+++ src/utils/qa/quickLiftBench/quickLiftBench.py
@@ -138,69 +138,111 @@
         if "variant" not in a or "phase" not in a:
             sys.exit(f"{ctx} missing required key (variant, phase)")
         if a["variant"] not in variant_names:
             sys.exit(f"{ctx}: variant {a['variant']!r} not declared in variants")
         try:
             re.compile(a["phase"])
         except re.error as e:
             sys.exit(f"{ctx}: phase regex invalid: {e}")
         for k in ("max_median_ms", "min_median_ms"):
             if k in a and not isinstance(a[k], int):
                 sys.exit(f"{ctx}: {k} must be an integer")
         if "required" in a and not isinstance(a["required"], bool):
             sys.exit(f"{ctx}: required must be true/false")
 
 
-def parse_session(s):
-    """Split 'user/sessionName' (or '/s/user/name'). Returns (user, name)."""
-    if not isinstance(s, str):
-        raise ValueError(f"variant must be a 'user/sessionName' string, got: {s!r}")
-    s = s.strip()
+def parse_variant(v):
+    """Return a dict describing one variant.
+
+    Two forms supported:
+    - string: "user/sessionName" or "/s/user/name" -- saved-session reference.
+      Returns {"kind": "session", "user": ..., "session": ...}.
+    - mapping: {hubUrl, db, position, tracks: {trackName: vis, ...}} -- direct
+      URL for cases where both variants share an assembly and only differ in
+      track visibility (typically Mode C: same hub, lift on/off).
+      Returns {"kind": "hub", "hubUrl": ..., "db": ..., "position": ...,
+               "tracks": {...}}.
+    """
+    if isinstance(v, str):
+        s = v.strip()
         if s.startswith("/s/"):
             s = s[3:]
         if "/" not in s:
-        raise ValueError(f"variant must contain '/': {s!r}")
+            raise ValueError(f"variant string must contain '/': {v!r}")
         user, name = s.split("/", 1)
         if not user or not name:
-        raise ValueError(f"empty user or session name in: {s!r}")
-    return user, name
+            raise ValueError(f"empty user or session name in: {v!r}")
+        return {"kind": "session", "user": user, "session": name}
+    if isinstance(v, dict):
+        missing = [k for k in ("hubUrl", "db", "position", "tracks") if k not in v]
+        if missing:
+            raise ValueError(
+                f"hub variant missing keys {missing}; got {sorted(v)}"
+            )
+        if not isinstance(v["tracks"], dict) or not v["tracks"]:
+            raise ValueError("hub variant 'tracks' must be a non-empty mapping")
+        return {
+            "kind": "hub",
+            "hubUrl": v["hubUrl"],
+            "db": v["db"],
+            "position": v["position"],
+            "tracks": dict(v["tracks"]),
+        }
+    raise ValueError(f"variant must be a string or mapping, got: {type(v).__name__}")
 
 
 def resolve_server(case, defaults, server_override):
     server_key = server_override or case.get("server")
     if not server_key:
         raise ValueError(f"case '{case.get('id')}' has no server")
     servers = defaults.get("servers", {})
     if server_key not in servers:
         raise ValueError(
             f"server '{server_key}' not in defaults.servers ({list(servers)})"
         )
     return server_key, servers[server_key].rstrip("/")
 
 
-def build_url(server_url, user, session_name):
-    """Build an hgTracks URL that loads a saved session at its saved position.
+def build_url(server_url, variant):
+    """Build an hgTracks URL for one variant.
 
-    The position is NOT overridden via URL: a native session and its
-    quickLifted counterpart sit on different assemblies, so identical
-    chr:start-end ranges would not be biologically equivalent. Whatever
-    region the session was saved at is what gets rendered.
+    Saved-session variants (kind="session"): URL loads the named session at
+    its saved position. The position is NOT overridden -- a native session
+    and its quickLifted counterpart often sit on different assemblies, so
+    identical chr:start-end ranges would not be biologically equivalent.
+
+    Hub variants (kind="hub"): URL attaches the hub at the named db and
+    position, with hideTracks=1 so only the explicitly named tracks render.
     """
+    if variant["kind"] == "session":
         params = [
             ("hgS_doOtherUser", "submit"),
-        ("hgS_otherUserName", user),
-        ("hgS_otherUserSessionName", session_name),
+            ("hgS_otherUserName", variant["user"]),
+            ("hgS_otherUserSessionName", variant["session"]),
+        ]
+    elif variant["kind"] == "hub":
+        params = [
+            ("db", variant["db"]),
+            ("hubUrl", variant["hubUrl"]),
+            ("position", variant["position"]),
+            ("hideTracks", "1"),
+        ]
+        for track, vis in variant["tracks"].items():
+            params.append((track, vis))
+    else:
+        raise ValueError(f"unknown variant kind: {variant['kind']!r}")
+    params += [
         ("hgt.trackImgOnly", "1"),
         ("measureTiming", "1"),
     ]
     return f"{server_url}/cgi-bin/hgTracks?{urlencode(params)}"
 
 
 def parse_track_timing_rows(html):
     """Return a list of (shortLabel, load_ms, draw_ms, total_ms) rows."""
     if not html:
         return []
     rows = []
     for span in TRACK_TIMING_SPAN_RE.findall(html):
         text = span.replace("&nbsp;", " ")
         for raw in text.split("<br />"):
             raw = raw.strip()
@@ -337,36 +379,40 @@
             except ValueError as e:
                 print(f"  {e}", file=sys.stderr)
                 continue
 
             variants = case.get("variants") or {}
             if not variants:
                 print(f"  skipped: no variants", file=sys.stderr)
                 continue
 
             # Fresh session per case to keep an independent hgsid lineage.
             session = requests.Session()
             session.headers.update({"User-Agent": "quickLiftBench/1.0"})
 
             for vname, vraw in variants.items():
                 try:
-                    user, session_name = parse_session(vraw)
+                    variant = parse_variant(vraw)
                 except ValueError as e:
                     print(f"  variant {vname}: {e}", file=sys.stderr)
                     continue
 
-                url = build_url(server_url, user, session_name)
+                url = build_url(server_url, variant)
+                # For the TSV's user/session columns, fill from the saved-session
+                # variant if present; for hub variants leave them blank.
+                user = variant.get("user", "")
+                session_name = variant.get("session", "")
                 if args.verbose:
                     print(f"  URL: {url}", file=sys.stderr)
 
                 for _ in range(warmup):
                     run_request(session, url, timeout)
 
                 for it in range(1, iterations + 1):
                     http_ms, code, html, err = run_request(session, url, timeout)
                     block = detect_block(html) if not err else None
                     if block and not err:
                         err = block
 
                     load_sum = draw_sum = total_ms = None
                     n_tracks = None
                     phases = {}