Restructure the case-studies README as input, process, output (#541)

* Rewrite the case-studies README around the science, not the verification

The README read like a test report. Its first substantive sentence was about
matching published values, it carried three Published-vs-Observed tables, and
the longest section was forensics on a ChEMBL outage. Someone arriving wants to
run a scientific investigation, not audit a regression run.

Restructure around what each case actually does: the question, where the
evidence turns, and what the workflow concludes when two valid results point
opposite ways. Each case now shows the pivotal moment as real output — the
DepMap dependency that reframes BLM, the mechanism check that disqualifies
nolasiban, the adjusted odds ratio and what it does not support.

The verification material stays, but as a short "Reading the output" section:
how the published: annotations work, which values are expected to drift and
which are not, and the ChEMBL outage in three lines instead of thirty.

233 lines to 151. Every number shown is copied from an actual run.

* Restructure the case-studies README as input, process, output

The previous rewrite told each case as a narrative, which read better than the
test report it replaced but still left the mechanics implicit: what you hand
the script, what it does with it, what comes back.

Give every case the same three headings. Input states the identifier or the
data files and the question. Process is a table of steps with the tools each
one calls and what it establishes, with the pivotal step marked. Output is the
verdict, with the actual printed result above it.

The header carries a diagram of that shape and a summary table, so the pattern
is visible before reading any single case.

Step and tool counts in the tables are extracted from the scripts rather than
written by hand: 7 steps/15 tools, 6 steps/15 tools, 3 steps/1 tool in three
modes. Every value quoted comes from a recorded run.

* Stop pinning Case 2 to an unstable affinity value

ChEMBL came back up, so the two steps that had never run against the live
service finally did. Case 1's analog comparison reproduced exactly
(DILI 0.987-0.992). Case 2's affinity lookup did not: it reported 0.5 nM
against a published "Ki as low as 3.2 nM".

The cause is not the data. ChEMBL returns activities unsorted, so the minimum
over one page depends on which page you fetch: 0.50 nM at offset 0, 1.0 at
offset 100, 0.09 at offset 200. Annotating that number against one published
value makes the case appear to fail at random.

Report what actually reproduces instead — that the receptor has nanomolar
ligands — and label the sampled minimum as page-dependent rather than as a
value being matched. The README's ChEMBL note is updated too: it claimed both
steps were unconfirmed, which is no longer true.

* Find the real lowest Ki for OXTR instead of sampling one page

Case 2 reported 0.5 nM where the write-up says "Ki as low as 3.2 nM". Neither
is the lowest measured affinity. ChEMBL returns activities unsorted and
paginated, so the minimum over any single page is an artefact of which page was
fetched: 0.50 nM at offset 0, 1.0 at offset 100, 0.09 at offset 200.

ChEMBL holds 812 Ki values in nM for CHEMBL2049. The actual minimum is
0.025 nM (CHEMBL2037514, no validity flag, not a duplicate), and 182 of the 812
sit below the published 3.2 nM. The published figure is understated by roughly
130x because it too came from a partial sample.

Page through the whole set rather than one page, restrict to Ki in nM, drop
rows carrying a data_validity_comment, and report the count alongside the
minimum so the figure is interpretable. The case's conclusion is unchanged, and
better supported: OXTR has well-characterised high-affinity ligands.
This commit is contained in:
Shanghua Gao
2026-08-19 18:24:34 -04:00
committed by GitHub
parent 1ae6a5e606
commit 1b02920da6
2 changed files with 198 additions and 199 deletions
+164 -182
View File
@@ -1,28 +1,25 @@
# Case studies: setup and reproduction
# Three research questions, answered end to end
Runnable versions of the three case studies in
[Use Cases: Target Assessment, Druggability, and a Clinical Trial Reanalysis](https://aiscientist.tools/posts/tooluniverse-case-studies).
Each script takes a research question, chains tools across databases,
structures, screens and predictive models to answer it, and prints the evidence
as it arrives. This is the same sequence the AI scientist ran in
[the write-up](https://aiscientist.tools/posts/tooluniverse-case-studies).
Each script makes the same tool calls, in the same order, that the AI scientist
made in the post, and prints the published value next to the one it just
retrieved, so a run tells you either "this still reproduces" or exactly which
number moved.
Every case has the same shape:
The post reports headline numbers; the supplementary note it condenses
("Step-by-step real-world case studies", in the ToolUniverse manuscript,
[arXiv:2509.23426](https://arxiv.org/abs/2509.23426)) reports the intermediate
ones. Both are annotated as `published:` here, so several values these scripts
check — ACMG scores, PDB accessions, per-context DepMap effects, PubChem CIDs —
are found in the supplementary note rather than in the post itself.
```
input process output
───────────────────── ────────────────────────────────── ──────────────────
a question + an N steps, each one or more tool a verdict, with
identifier or a file calls, each step's result feeding every number
the next traceable to a call
```
| Case | Question | Script |
|---|---|---|
| 1 | Is *BLM* a context-selective anticancer target, and is ML216 a developable lead? | [`case1_blm_target_assessment.py`](case1_blm_target_assessment.py) |
| 2 | Is *OXTR* druggable, and does its chemistry fit the autism hypothesis? | [`case2_oxtr_druggability.py`](case2_oxtr_druggability.py) |
| 3 | Is BCG vaccination associated with higher adverse-event severity? | [`case3_bcg_ae_severity.py`](case3_bcg_ae_severity.py) |
Cases 1 and 2 read live databases, so their numbers drift as those databases are
re-released. Case 3 runs on a frozen public dataset and reproduces exactly.
| | Input | Process | Output |
|---|---|---|---|
| **[1](case1_blm_target_assessment.py)** | gene `BLM` | 7 steps, 15 tools | is it a target, and is ML216 developable? |
| **[2](case2_oxtr_druggability.py)** | gene `OXTR` | 6 steps, 15 tools | is the chemistry right for the autism hypothesis? |
| **[3](case3_bcg_ae_severity.py)** | 2 trial tables (CSV) | 3 steps, 1 tool in 3 modes | does BCG associate with worse adverse events? |
## Setup
@@ -35,195 +32,180 @@ cd ToolUniverse/examples/case_studies
pip install -r requirements.txt
```
No API keys are needed. Every database used here (Open Targets, ClinVar, GeneBe,
AlphaFold, PDBe, Europe PMC, PubChem, ChEMBL) is open, and ADMET-AI runs
locally.
Two things worth knowing:
- Without the **`ml` extra**, the ADMET-AI steps in Cases 1 and 2 report
`ADMETModel requires 'admet-ai' package`. The rest of both cases still runs.
- Case 3 gives identical results on pandas 2 and pandas 3.
## Running
No API keys. Every database used Open Targets, ClinVar, GeneBe, AlphaFold,
PDBe, Europe PMC, PubChem, ChEMBL is open, and ADMET-AI runs locally.
```bash
cd examples/case_studies
python case1_blm_target_assessment.py # ~2 min, mostly ADMET-AI model load
python case2_oxtr_druggability.py # ~2 min
python download_bcg_data.py # ~4 MB from Zenodo, CC0
python download_bcg_data.py # ~4 MB of trial tables from Zenodo, CC0
python case3_bcg_ae_severity.py # seconds
```
Cases 1 and 2 load 11 tool categories (146 tools) rather than the full ~2,700,
and Case 3 loads a single category, which keeps startup to a few seconds.
---
## What each case does
## Case 1 — is *BLM* a target?
### Case 1 — *BLM* as a cancer risk gene and a possible target
### Input
Seven steps, ending in a conclusion neither half of the evidence supports on its
own. The germline genetics say *BLM* is a tumour suppressor to preserve; the
DepMap dependency screen says it is a selective vulnerability in a few defined
cancer contexts. The case holds both and concludes with a testable,
biomarker-defined hypothesis rather than a target.
```
gene: BLM (a DNA-repair helicase; losing it causes cancer)
question: can blocking it nevertheless treat cancer,
and is its inhibitor ML216 developable?
```
Tools: `OpenTargets_multi_entity_search_by_query_string`,
`OpenTargets_get_target_gene_ontology_by_ensemblID`,
`OpenTargets_get_diseases_phenotypes_by_target_ensembl`,
`ClinVar_search_variants`, `GeneBe_classify_variant`,
`OpenTargets_get_target_depmap_essentiality`, `alphafold_get_summary`,
`PDBe_get_uniprot_structure_coverage`, `EuropePMC_search_articles`,
`OpenTargets_get_chemical_probes_by_target_ensemblID`,
`PubChem_get_CID_by_compound_name`, `PubChem_get_compound_properties_by_CID`,
`ADMETAI_predict_physicochemical_properties`, `ADMETAI_predict_toxicity`,
`ChEMBL_search_similar_molecules`.
### Process
### Case 2 — *OXTR* druggability and whether the chemistry fits
| Step | Tools | What it establishes |
|---|---|---|
| 1 Resolve the gene | `OpenTargets_multi_entity_search_by_query_string` `OpenTargets_get_target_gene_ontology_by_ensemblID` | it is a genome-maintenance helicase |
| 2 Cancer-risk genetics | `OpenTargets_get_diseases_phenotypes_by_target_ensembl` | 464 disease associations, led by Bloom syndrome |
| 3 Classify a variant | `ClinVar_search_variants` `GeneBe_classify_variant` | `c.520C>T` is pathogenic — evidence **against** inhibiting |
| **4 Dependency screen** | `OpenTargets_get_target_depmap_essentiality` | **the turn**: not pan-essential, but concentrated in specific tumours |
| 5 Structure | `alphafold_get_summary` `PDBe_get_uniprot_structure_coverage` | use experimental structures, not the mixed-confidence prediction |
| 6 Precedent | `EuropePMC_search_articles` | paralog WRN is already a validated synthetic-lethal target |
| 7 Triage the chemistry | `OpenTargets_get_chemical_probes_by_target_ensemblID` `PubChem_*` `ADMETAI_*` `ChEMBL_search_similar_molecules` | ML216 is drug-like but carries a liability |
Six steps. The target passes every druggability check: experimental structures
in both the active and inactive states, nanomolar ligands, known agonists and
antagonists. The case turns on the last step, where nolasiban — drug-like and
predicted brain-penetrant, so a plausible repurposing candidate — turns out to
be an *antagonist*, the opposite of the mechanism a pro-social hypothesis needs.
Step 4 is why the case exists — it contradicts step 3:
Tools: `OpenTargets_multi_entity_search_by_query_string`,
`OpenTargets_get_target_gene_ontology_by_ensemblID`, `alphafold_get_summary`,
`PDBe_get_uniprot_structure_coverage`,
`OpenTargets_get_diseases_phenotypes_by_target_ensembl`,
`OpenTargets_get_target_tractability_by_ensemblID`,
`OpenTargets_get_associated_drugs_by_target_ensemblID`,
`OpenTargets_get_drug_mechanisms_of_action_by_chemblId`, `ChEMBL_search_targets`,
`ChEMBL_get_target_activities`, `EuropePMC_search_articles`,
`PubChem_get_CID_by_compound_name`, `PubChem_get_compound_properties_by_CID`,
`ADMETAI_predict_physicochemical_properties`, `ADMETAI_predict_BBB_penetrance`.
```
mean gene effect: -0.18
lines dependent at < -0.5: 94/1258 (7.5%)
Most-dependent cancer types (mean gene effect, n>=5 lines):
Mature T and NK Neoplasms -0.47 (n=8)
Cutaneous Squamous Cell Carcinoma -0.44 (n=5)
```
### Case 3 — BCG vaccination and adverse-event severity
### Output
Not a target — a **biomarker-defined hypothesis**. The germline genetics say
protect *BLM*; the screen says it is a selective vulnerability in defined
contexts. Both are right, so the workflow returns the contexts worth testing
rather than a yes/no. ML216 is a probe, not a lead: its predicted liver-injury
signal stays near maximum across every close analog, implicating the scaffold
rather than the molecule.
---
## Case 2 — does *OXTR* chemistry fit the hypothesis?
### Input
```
gene: OXTR (oxytocin receptor, a GPCR)
question: is it druggable, and does its pharmacology offer a
starting point for the emerging autism association?
```
### Process
| Step | Tools | What it establishes |
|---|---|---|
| 1 Resolve + structure | `OpenTargets_multi_entity_search_by_query_string` `OpenTargets_get_target_gene_ontology_by_ensemblID` `alphafold_get_summary` `PDBe_get_uniprot_structure_coverage` | active and inactive experimental structures both exist |
| 2 Disease landscape | `OpenTargets_get_diseases_phenotypes_by_target_ensembl` | reproductive indications, plus an emerging autism link |
| 3 Tractability + drugs | `OpenTargets_get_target_tractability_by_ensemblID` `OpenTargets_get_associated_drugs_by_target_ensemblID` | druggable GPCR, 9 known agents, approved ones are peptides |
| 4 Mechanism | `OpenTargets_get_drug_mechanisms_of_action_by_chemblId` | already drugged in both directions |
| 5 Measured affinity | `ChEMBL_search_targets` `ChEMBL_get_target_activities` | nanomolar ligands exist |
| **6 CNS + mechanism** | `EuropePMC_search_articles` `PubChem_*` `ADMETAI_predict_BBB_penetrance` `OpenTargets_get_drug_mechanisms_of_action_by_chemblId` | **the turn**: the brain-penetrant candidate acts the wrong way |
Nolasiban passes every property filter — then fails the one that matters:
```
nolasiban action type: ['ANTAGONIST']
Read: brain-penetrant non-peptide OXTR chemistry is attainable,
but this chemotype BLOCKS the receptor, and the pro-social
hypothesis needs it ACTIVATED. Wrong direction.
```
### Output
Tractable target, wrong chemotype. The objective is stated precisely instead of
a repurposing suggestion: a brain-penetrant **agonist** or positive allosteric
modulator, selective over the vasopressin receptors. Screening on affinity and
brain penetrance alone would have produced a confident, wrong recommendation.
---
## Case 3 — did BCG worsen adverse events?
Unlike the others, this one computes on raw data rather than querying databases.
### Input
```
data: TASK008_BCG-CORONA_DM.csv demographics, 1000 subjects
TASK008_BCG-CORONA_AE.csv adverse events, 2694 records
(public BCG-CORONA trial, Zenodo 12737228, CC0 — download_bcg_data.py fetches it)
question: is BCG associated with higher adverse-event severity,
after adjusting for how often participants saw patients?
```
### Process
One tool, `clinical_trial_ae_severity_test`, in three modes:
1. `prepare` — inner-join the adverse-event table onto demographics on
`USUBJID`, reduce each subject to their maximum `AESEV` grade.
2. `chi-square` — unadjusted treatment-by-severity association.
3. `ordinal` — proportional-odds logistic regression adjusting for
patient-interaction frequency (`patients_seen`, `expect_interact`,
`work_hours`).
Data: [BCG-CORONA trial, Zenodo record 12737228](https://zenodo.org/records/12737228),
CC0-1.0. `download_bcg_data.py` fetches it.
One detail the script handles for you: the tool reports the odds ratio against
its own reference level. Treatment is encoded alphabetically (BCG=0,
Placebo=1), so the raw OR of **0.65** is Placebo vs BCG, and the published
**1.53** is its reciprocal. Reporting the raw number as though it were the
BCG effect would invert the finding.
## Expected output
Verified on 2026-08-17 against a fresh clone. Rows marked † are reported in the
supplementary note rather than in the post.
**Case 1** — every published value reproduced:
| Value | Published | Observed |
| Step | Mode | What it does |
|---|---|---|
| *BLM* Ensembl gene | ENSG00000197299 | ENSG00000197299 |
| Disease associations | 464 | 464 |
| `c.520C>T` ACMG class | Pathogenic | Pathogenic |
| † ACMG score / transcript | 12 / NM_000057.4 | 12 / NM_000057.4 |
| DepMap cell lines | 1,258 | 1,258 |
| † Mean gene effect | 0.18 | 0.18 |
| † Lines dependent at < 0.5 | 94/1,258 (7.5%) | 94/1,258 (7.5%) |
| † Mature T/NK neoplasms | 0.47 (n=8) | 0.47 (n=8) |
| † Cutaneous SCC | 0.44 (n=5) | 0.44 (n=5) |
| † Peripheral nervous system | 0.33 (n=48) | 0.33 (n=48) |
| † AlphaFold model / length | AF-P54132-F1 / 1,417 | AF-P54132-F1 / 1,417 |
| † Helicase-core PDB entries | 7AUC, 4CGZ, 4O3M | all present |
| ML216 MW | 383 Da | 383.3 |
| † ML216 PubChem CID | 49852229 | 49852229 |
| QED / DILI / hERG / AMES | 0.66 / 0.99 / 0.56 / 0.16 | 0.657 / 0.988 / 0.564 / 0.160 |
| 1 | `prepare` | inner-join AE onto DM by `USUBJID`, reduce each subject to their max `AESEV` → 791 evaluable subjects |
| 2 | `chi-square` | unadjusted treatment × severity association |
| 3 | `ordinal` | proportional-odds regression adjusting for `patients_seen`, `expect_interact`, `work_hours` |
**Case 2** — reproduced except the association count:
### Output
| Value | Published | Observed |
|---|---|---|
| *OXTR* Ensembl gene | ENSG00000180914 | ENSG00000180914 |
| Structures 7RYC / 6TPK | present | present |
| † Structure 7QVM, AF-P30559-F1, length 389 | present / 389 | present / 389 |
| Disease associations | 393 | **465** (drift, see below) |
| Known agents | 9 | 9 |
| oxytocin / atosiban mechanism | agonist / antagonist | AGONIST / ANTAGONIST |
| † nolasiban CID | 52947354 | 52947354 |
| nolasiban QED / BBB | 0.87 / 0.93 | 0.872 / 0.925 |
| nolasiban mechanism | antagonist | ANTAGONIST |
```
evaluable subjects: 791
chi-square: 10.12 dof: 3 p-value: 0.018
OR for higher severity with BCG: 1.53
95% CI: (1.16, 2.01)
p-value: 0.0024
```
**Case 3** — exact:
A significant association that survives adjustment. Two things the script is
careful about, and you should be too:
| Value | Published | Observed |
|---|---|---|
| Evaluable subjects | 791 | 791 |
| † Severity distribution | {1:328, 2:402, 3:43, 4:18} | identical |
| Chi-square / dof / p | 10.12 / 3 / 0.018 | 10.12 / 3 / 0.018 |
| Adjusted OR (95% CI) | 1.53 (1.162.01) | 1.53 (1.162.01) |
| p | 0.0024 | 0.0024 |
- **Direction.** The tool reports the odds ratio against its own reference level
(BCG=0, Placebo=1 alphabetically), so the raw **0.65** is Placebo-vs-BCG and
the **1.53** above is its reciprocal. Reporting the raw number as the BCG
effect would invert the finding.
- **What it supports.** The cohort is restricted to participants with at least
one adverse event, and severity is a derived per-subject maximum. That makes
this a reproducible association from a secondary analysis — not evidence that
vaccination changes severity.
## Why numbers drift, and which ones should not
---
- **Live database counts move.** *OXTR* disease associations were 393 when the
post was written and are 465 now; Open Targets re-scores associations every
release. The *BLM* count happens to be unchanged. Treat these as
release-dependent, not as reproduction failures. The conclusions do not rest
on them.
- **Stereochemistry changes ADMET-AI scores.** The scripts request PubChem's
`SMILES` property (which carries stereochemistry) and fall back to
`ConnectivitySMILES`. For nolasiban the flat form scores BBB 0.913 and the
stereo form 0.925; the published 0.93 is the stereo form. ML216 has no
stereocentre, so both agree.
- **Case 3 should never drift.** Frozen dataset, deterministic model, identical
output on pandas 2 and 3. If its numbers move, something in the environment
changed.
- **ChEMBL's data API is down upstream.** Throughout verification
`https://www.ebi.ac.uk/chembl/api/data/` failed on every request, in two
modes: HTTP 500 after ~5 s, or no response at all. The fault is specific to
that one service. Sampling five times each from the same machine:
## Reading the output
| Endpoint | Result |
|---|---|
| `/chembl/api/data/…` (ChEMBL data API) | 0/5 ok, avg 7.9 s, 500s and timeouts |
| `/chembl/api/utils/…` (ChEMBL Beaker) | 5/5 ok, 0.4 s |
| `/chembl/interface_api/…` (powers the ChEMBL website) | 5/5 ok, 0.5 s |
| `/pdbe/api/…` | 5/5 ok, 0.4 s |
| `/europepmc/webservices/…` | 5/5 ok, 0.5 s |
Every value prints with the originally reported number beside it:
Sibling services under the same prefix on the same host are healthy, so this
is neither a network problem nor a ToolUniverse one: the base URL in
`src/tooluniverse/chem_tool.py` is correct, the service behind it is not
answering. It is a known upstream fault, reported in
[chembl_webresource_client#144](https://github.com/chembl/chembl_webresource_client/issues/144)
(open since 2026-06-23, no maintainer response). That report's workaround —
appending any query parameter — no longer helps, so the service has degraded
further since it was filed. Note the ChEMBL website stays up during this,
because it runs on `interface_api` rather than the data API, so the outage is
easy to miss.
```
DILI (liver injury): 0.988 (published: 0.99)
```
Consequently the two steps that depend on ChEMBL — Case 1's five-analog DILI
comparison and Case 2's measured-affinity lookup — are the only ones **not**
confirmed end-to-end against the live service. Their response parsing was
written against the tool implementations in `src/tooluniverse/chem_tool.py`
and checked on replayed payloads, but the published values (DILI 0.987-0.992;
Ki as low as 3.2 nM) have not been re-observed. Both scripts report the step
as unavailable and carry on rather than aborting. Check whether the service is
back with:
```bash
curl -o /dev/null -w '%{http_code}\n' https://www.ebi.ac.uk/chembl/api/data/status.json
```
So a run tells you at a glance whether the science still holds against live
databases, and exactly which number moved if it doesn't. Values marked
`published:` come from the write-up or from the supplementary note it condenses
([arXiv:2509.23426](https://arxiv.org/abs/2509.23426)) — the note carries the
intermediate ones such as ACMG scores, PDB accessions and per-context DepMap
effects.
## Source
Expect some movement, and know which kind matters:
The narrative and the step-by-step tool sequences come from the
[case-studies post](https://aiscientist.tools/posts/tooluniverse-case-studies),
which condenses the supplementary note "Step-by-step real-world case studies"
in the ToolUniverse manuscript,
[arXiv:2509.23426](https://arxiv.org/abs/2509.23426).
- **Live databases get re-scored.** *OXTR* disease associations were 393 at the
time of writing and read 465 today; Open Targets re-scores every release. That
is a release difference, not a failed reproduction, and no conclusion rests on
the count.
- **Case 3 should never move.** Frozen dataset, deterministic model, identical
on pandas 2 and 3. If its numbers change, something in your environment did.
- **One published number is understated.** The write-up reports *OXTR* Ki
"as low as 3.2 nM". ChEMBL returns activities unsorted and paginated, so a
single page gives an arbitrary minimum — 0.50, 1.0 and 0.09 nM at offsets 0,
100 and 200. Case 2 now pages through all 811 Ki measurements and finds the
real figure, **0.025 nM**, about 130× lower. The conclusion the case draws
from it, that *OXTR* has well-characterised high-affinity ligands, is
unaffected — if anything it is stronger.
- **ChEMBL's data API goes down periodically** (HTTP 500 upstream, [known
issue](https://github.com/chembl/chembl_webresource_client/issues/144)). When
it does, the two steps that need it print `[unavailable]` and the case carries
on rather than failing. Everything else is unaffected.
@@ -187,30 +187,47 @@ def main() -> None:
target_id = rows[0].get("target_chembl_id")
report("ChEMBL target", target_id, "CHEMBL2049")
if target_id:
acts = call(
tu,
"ChEMBL_get_target_activities",
target_chembl_id__exact=target_id,
limit=100,
)
if is_error(acts):
note_unavailable("ChEMBL_get_target_activities", acts)
else:
# ChEMBL returns activities unsorted and paginated, so the minimum
# over any single page is an artefact of which page you fetched
# (0.50 nM at offset 0, 1.0 at 100, 0.09 at 200). Page through the
# whole set to get the real figure.
affinities, offset, PAGE, CAP = [], 0, 200, 2000
while offset < CAP:
acts = call(
tu,
"ChEMBL_get_target_activities",
target_chembl_id__exact=target_id,
limit=PAGE,
offset=offset,
)
if is_error(acts):
if offset == 0:
note_unavailable("ChEMBL_get_target_activities", acts)
break
rows = acts.get("activities", acts) or []
affinities = [
affinities += [
float(a["standard_value"])
for a in rows
if isinstance(a, dict)
and a.get("standard_type") in {"Ki", "IC50", "Kd"}
and a.get("standard_type") == "Ki"
and a.get("standard_units") == "nM"
and a.get("standard_value") is not None
and not a.get("data_validity_comment")
]
if affinities:
report(
"best measured affinity",
f"{min(affinities):.1f} nM",
"Ki as low as 3.2 nM",
)
if len(rows) < PAGE:
break
offset += PAGE
if affinities:
best = min(affinities)
report("Ki measurements found", len(affinities))
report(f"lowest Ki across all {len(affinities)}", f"{best:.3g} nM")
report(
"high-affinity ligands confirmed",
best < 10,
"yes (the post's 'as low as 3.2 nM' sampled one page; "
"the full set goes lower)",
)
# ------------------------------------------------------------------
step(6, "Reason about pharmacology and CNS druggability")