Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions hvantk/skills/alphagenome/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ with `chrom`/`pos`/`ref`/`alt` columns) and keys it by `(locus, alleles)`.
A `config_path` param pointing to an AlphaGenome YAML config is required (see
`tests/testdata/alphagenome_config.yaml`); `no_resume` (bool, default False) is
optional. There is no built-in downloader (no `lifecycle.download` in
`plugin.yaml`). The drift probe (`drift_probe.py`, `fetch_fingerprint`) is a
placeholder stub.
`plugin.yaml`). The drift probe (`drift_probe.py`, `fetch_fingerprint`) reads the
published SDK release stream from PyPI's JSON API, which needs no credentials.

**What it detects:** a new AlphaGenome SDK release, which is the signal to re-check
whether predictions still match a stored artifact. **What it cannot detect:** a
server-side model update shipped without an SDK release. No unauthenticated probe can
observe that, so the coverage claim stops there.

## Build invocation

Expand All @@ -50,6 +55,7 @@ pytest hvantk/skills/alphagenome/tests
No raw-data fixture is available for the alphagenome source (requires AlphaGenome
API access). `tests/test_alphagenome.py` contains a registration-only test
(`test_alphagenome_predictions_registered`) and a skipped round-trip test. The
only checked-in fixture is `tests/testdata/alphagenome_config.yaml`; the schema/row
snapshot and drift fingerprint paths declared in `plugin.yaml` are not yet
populated.
only checked-in fixture is `tests/testdata/alphagenome_config.yaml`. The drift
fingerprint (`tests/drift_fingerprint.json`) is populated from a live probe run; the
schema/row snapshot paths declared in `plugin.yaml` remain unpopulated, since a
credentialed live prediction API has no static artifact to snapshot.
87 changes: 75 additions & 12 deletions hvantk/skills/alphagenome/drift_probe.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,83 @@
"""Drift probe for alphagenome — documentation-only source (stub).
"""alphagenome drift probe: SDK release check against the PyPI JSON API.

AlphaGenome is consumed as a live prediction API requiring credentials; there is
no static data file with a stable, programmatically-probeable URL to fingerprint.
This probe returns a structured stub sentinel so ``hvantk drift`` reports a
visible WARNING (status="stub") rather than a silent false-green. Replace with a
real probe if a direct data URL becomes available. See issue #177.
AlphaGenome is a credentialed live prediction service, so there is no static
artifact to fingerprint and issue #177 shipped a stub sentinel. For a live model
API, though, the meaningful upstream change is not a file but a *model or client
release*: predictions are generated on demand, so what makes a stored artifact
stale is the service behind it moving. The SDK is published openly on PyPI, whose
JSON API needs no credentials, so that release stream is directly probeable.

Compared surface: the current version plus the sorted set of released versions.
Upload timestamps and file digests are excluded -- PyPI can re-host an unchanged
release, and the hgnc precedent is that a validator which moves without the
content changing produces nothing but no-op pull requests.

What this detects: a new AlphaGenome SDK release, which is the signal to re-check
whether predictions still match a stored artifact. What it cannot detect: a
server-side model update shipped without an SDK release, which no unauthenticated
probe can see. That limit is a property of the service and is recorded in
SKILL.md so the coverage claim stays honest.
"""

from __future__ import annotations

from hvantk.core.plugin.api import stub_fingerprint
from datetime import datetime, timezone

import requests

from hvantk.core.plugin.api import DriftProbeError
from hvantk.core.utils.http import request_with_retry

_REASON = (
"AlphaGenome is a live prediction API requiring credentials; "
"no static data file to fingerprint"
)
PROBE_VERSION = 2
ALPHAGENOME_PYPI_URL = "https://pypi.org/pypi/alphagenome/json"

_FILENAME = "alphagenome-sdk-releases"
_TIMEOUT_S = (5.0, 15.0)


def fetch_fingerprint() -> dict:
return stub_fingerprint(_REASON)
"""Fingerprint the published AlphaGenome SDK release set."""
try:
resp = request_with_retry(
"GET", ALPHAGENOME_PYPI_URL, timeout=_TIMEOUT_S, allow_redirects=True
)
resp.raise_for_status()
except requests.RequestException as exc:
raise DriftProbeError(f"HTTP failure: {exc}") from exc

# Parsed in its own block: requests' JSONDecodeError subclasses both
# ValueError and RequestException, so decoding inside the block above would
# report a malformed body as an HTTP failure.
try:
payload = resp.json()
except ValueError as exc:
raise DriftProbeError(f"PyPI returned non-JSON: {exc}") from exc

current = (payload.get("info") or {}).get("version")
# Fail closed on the field that actually carries the signal. `info.version` is
# the supported one; `releases` is deprecated on this endpoint and slated for
# removal, so requiring it would turn a PyPI API change into a permanent
# probe_failed for an SDK that never moved.
if not current:
raise DriftProbeError(
"PyPI returned no info.version for alphagenome; the project or the "
"API shape has probably changed."
)

compared: dict[str, object] = {"current_version": current}
releases = sorted((payload.get("releases") or {}).keys())
if releases:
compared["release_count"] = len(releases)

return {
"probe_version": PROBE_VERSION,
"source_version": current,
# The version IS the signal; a sha256 over it would be a pure function of
# a value already in the compared surface. The full release list is
# deliberately NOT compared: it is deprecated upstream, and it grows on
# pre-release and yanked uploads that no build would ever install.
"headers": {_FILENAME: compared},
"checksums": {},
"informational": {"releases_found": releases},
"fetched_at": datetime.now(timezone.utc).isoformat(),
}
28 changes: 28 additions & 0 deletions hvantk/skills/alphagenome/tests/drift_fingerprint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"probe_version": 2,
"source_version": "0.8.0",
"headers": {
"alphagenome-sdk-releases": {
"current_version": "0.8.0",
"release_count": 12
}
},
"checksums": {},
"informational": {
"releases_found": [
"0.0.1",
"0.0.2",
"0.1.0",
"0.2.0",
"0.3.0",
"0.4.0",
"0.5.0",
"0.5.1",
"0.6.0",
"0.6.1",
"0.7.0",
"0.8.0"
]
},
"fetched_at": "2026-09-01T07:32:19.398674+00:00"
}
76 changes: 76 additions & 0 deletions hvantk/skills/alphagenome/tests/test_drift_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""alphagenome drift probe should fingerprint the published SDK release set.

Runs OFFLINE via requests_mock. The prediction service itself stays credentialed
(issue #177 was right that there is no static artifact); what this probe reaches
is the openly published SDK release stream.
"""

from __future__ import annotations

import pytest
import requests_mock

from hvantk.core.plugin.api import DriftProbeError
from hvantk.skills.alphagenome.drift_probe import (
ALPHAGENOME_PYPI_URL,
fetch_fingerprint,
)


def _payload(current="0.8.0", releases=("0.7.0", "0.8.0")):
return {
"info": {"name": "alphagenome", "version": current},
"releases": {v: [] for v in releases},
}


def test_fetch_fingerprint_shape():
with requests_mock.Mocker() as m:
m.get(ALPHAGENOME_PYPI_URL, json=_payload())
fp = fetch_fingerprint()

assert fp["source_version"] == "0.8.0"
assert fp["informational"]["releases_found"] == ["0.7.0", "0.8.0"]


def test_new_sdk_release_moves_the_checksum():
with requests_mock.Mocker() as m:
m.get(ALPHAGENOME_PYPI_URL, json=_payload())
before = fetch_fingerprint()

with requests_mock.Mocker() as m:
m.get(
ALPHAGENOME_PYPI_URL,
json=_payload(current="0.9.0", releases=("0.7.0", "0.8.0", "0.9.0")),
)
after = fetch_fingerprint()

assert before["headers"] != after["headers"]
assert after["source_version"] == "0.9.0"


def test_missing_version_fails_closed():
"""Discriminating case: `releases` present, `info.version` absent."""
with requests_mock.Mocker() as m:
m.get(ALPHAGENOME_PYPI_URL, json={"info": {}, "releases": {"0.8.0": []}})
with pytest.raises(DriftProbeError, match="no info.version"):
fetch_fingerprint()


def test_deprecated_releases_key_absent_still_probes():
"""PyPI deprecated `releases` on this endpoint. Requiring it would turn an
upstream API change into a permanent probe_failed for an SDK that never moved,
while `info.version` -- the field carrying the signal -- is unaffected."""
with requests_mock.Mocker() as m:
m.get(ALPHAGENOME_PYPI_URL, json={"info": {"version": "0.8.0"}})
fp = fetch_fingerprint()

assert fp["source_version"] == "0.8.0"
assert fp["headers"]["alphagenome-sdk-releases"]["current_version"] == "0.8.0"


def test_non_json_fails_closed():
with requests_mock.Mocker() as m:
m.get(ALPHAGENOME_PYPI_URL, text="<html>not json</html>")
with pytest.raises(DriftProbeError, match="non-JSON"):
fetch_fingerprint()
17 changes: 17 additions & 0 deletions hvantk/skills/cosmic_cgc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ Upstream: https://cancer.sanger.ac.uk/census

- `cosmic-cgc:submissions` — gene-level cancer gene census table, keyed by `gene_symbol` by default (or `hgnc_id` if a `gene_catalog` is provided)

## Drift detection

The drift probe (`drift_probe.py`, `fetch_fingerprint`) reads the per-release anchors
(`id="v<N>"`) from COSMIC's public release-notes page. The Census *data* stays
login- and licence-gated -- `cancer.sanger.ac.uk/census` answers 302 to
`/cosmic/login` -- so acquisition remains manual and no data URL is probed.

**What it detects:** a new COSMIC release. **What it cannot detect:** a change to the
Census contents within a release; no unauthenticated probe can see that.

Two details are load-bearing. The trailing slash matters -- `/cosmic/release_notes`
returns 200 while `/cosmic/release_notes/` redirects to the login form -- and the probe
rejects any redirected response rather than scraping a login page. And it anchors on the
`id="v<N>"` attributes, never on prose: matching `COSMIC v<N>` in body text picked up
`v16`/`v18`/`v20` from sentences about the *Actionability* product, a different version
series, so an unrelated editorial edit would have opened a no-op pull request.

## Build

```bash
Expand Down
92 changes: 80 additions & 12 deletions hvantk/skills/cosmic_cgc/drift_probe.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,88 @@
"""Drift probe for cosmic-cgc — documentation-only source (stub).
"""cosmic-cgc drift probe: release index from the public release notes.

The COSMIC Cancer Gene Census is behind a login/license gate
(cancer.sanger.ac.uk/census); there is no public direct URL to fingerprint.
This probe returns a structured stub sentinel so ``hvantk drift`` reports a
visible WARNING (status="stub") rather than a silent false-green. Replace with a
real probe if a direct data URL becomes available. See issue #177.
The Cancer Gene Census *data* is login- and licence-gated, which issue #177
recorded correctly: ``cancer.sanger.ac.uk/census`` answers 302 to
``/cosmic/login``, so no direct data URL exists and acquisition stays manual.
The conclusion that nothing could be probed does not follow, though. COSMIC
publishes its release notes without a login, and those carry a per-release
anchor, so a release roll-over is detectable even though the archive is not.

Note the trailing slash matters: ``/cosmic/release_notes`` returns 200 while
``/cosmic/release_notes/`` redirects to the login page.

**Anchor on the id attributes, never on prose.** A first version matched
``COSMIC\\s+v(\\d+)`` anywhere in the body and produced
``[v16, v18, v20, v101, v102, v103, v104]`` -- a non-contiguous set that is not a
release index at all. v16/v18/v20 come from sentences about the *Actionability*
product ("COSMIC v20 of the Actionability data"), a different product line with
its own version series. Any editorial sentence naming an old release would have
entered the compared surface and opened a no-op pull request, and a
forward-looking "coming in COSMIC v105" would have reported a release that did
not exist. The page instead carries ``id="v101"`` ... ``id="v104"`` anchors, one
per real release, which is what this probe reads.

What this detects: a new COSMIC release. What it cannot detect: a change to the
Census contents within a release, which no unauthenticated probe can see. That
limit is a property of the licence gate and is recorded in SKILL.md.
"""

from __future__ import annotations

from hvantk.core.plugin.api import stub_fingerprint
import re
from datetime import datetime, timezone

import requests

from hvantk.core.plugin.api import DriftProbeError
from hvantk.core.utils.http import request_with_retry

_REASON = (
"COSMIC Cancer Gene Census is login/license-gated "
"(cancer.sanger.ac.uk/census); no public direct URL to fingerprint"
)
PROBE_VERSION = 2
COSMIC_RELEASE_NOTES_URL = "https://cancer.sanger.ac.uk/cosmic/release_notes"

# Per-release anchors, e.g. id="v104". Deliberately NOT a prose pattern.
_RELEASE_ANCHOR_RE = re.compile(r'id="v(\d+)"', re.IGNORECASE)

_FILENAME = "cosmic-release-index"
_TIMEOUT_S = (5.0, 15.0)


def fetch_fingerprint() -> dict:
return stub_fingerprint(_REASON)
"""Fingerprint the release index on the public COSMIC release notes."""
try:
resp = request_with_retry(
"GET", COSMIC_RELEASE_NOTES_URL, timeout=_TIMEOUT_S, allow_redirects=True
)
resp.raise_for_status()
except requests.RequestException as exc:
raise DriftProbeError(f"HTTP failure: {exc}") from exc

# The host is known to redirect to a login form (the trailing-slash path does
# exactly that), and a login page answers 200. Without this a redirect would
# be scraped as if it were the release notes.
if resp.history:
raise DriftProbeError(
f"COSMIC release notes redirected to {resp.url!r}; the page has "
"probably moved or now requires a login."
)

# Decoded explicitly: requests falls back to ISO-8859-1 for text/html with no
# charset, which would mangle a non-breaking space and silently change what
# the pattern matches.
body = resp.content.decode("utf-8", errors="replace")
versions = sorted({int(m.group(1)) for m in _RELEASE_ANCHOR_RE.finditer(body)})
if not versions:
raise DriftProbeError(
"COSMIC release notes carried no 'id=\"v<N>\"' release anchors; the "
"page layout has probably changed."
)

return {
"probe_version": PROBE_VERSION,
"source_version": f"v{max(versions)}",
# The release list IS the signal; a sha256 over it would be a pure
# function of a value already in the compared surface and would add no
# detection power.
"headers": {_FILENAME: [f"v{v}" for v in versions]},
"checksums": {},
"fetched_at": datetime.now(timezone.utc).isoformat(),
}
14 changes: 14 additions & 0 deletions hvantk/skills/cosmic_cgc/tests/drift_fingerprint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"probe_version": 2,
"source_version": "v104",
"headers": {
"cosmic-release-index": [
"v101",
"v102",
"v103",
"v104"
]
},
"checksums": {},
"fetched_at": "2026-09-01T07:32:18.934283+00:00"
}
Loading
Loading