TCRmeta 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tcrmeta/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """TCRmeta: antigen-aware TCR repertoire embeddings and downstream
2
+ repertoire-level analysis (CSS scoring, UMAP projection, energy-distance
3
+ shift), built on a pretrained-ESM2 + contrastive-fine-tuned encoder.
4
+ """
5
+ from .embedding import embed_repertoire
6
+ from .reference import build_reference, load_reference, save_reference
7
+ from .css import compute_css, load_default_reference
8
+ from .umap_plot import plot_umap
9
+ from .energy import energy_shift
10
+
11
+ __all__ = [
12
+ "embed_repertoire",
13
+ "build_reference",
14
+ "save_reference",
15
+ "load_reference",
16
+ "load_default_reference",
17
+ "compute_css",
18
+ "plot_umap",
19
+ "energy_shift",
20
+ ]
21
+
22
+ __version__ = "0.1.0"
tcrmeta/_dataset.py ADDED
@@ -0,0 +1,53 @@
1
+ """Fixed-length tokenization of (CDR1 + CDR2 + CDR2.5 + CDR3) TCR
2
+ sequences for the ESM2 base encoder.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Dict, List, Tuple
7
+
8
+ import pandas as pd
9
+ import torch
10
+ from torch.utils.data import Dataset
11
+
12
+ DEFAULT_MAX_LEN = 48
13
+
14
+
15
+ class TCRDatasetFixedLen(Dataset):
16
+ def __init__(self, df: pd.DataFrame, token2idx: Dict[str, int], max_len: int = DEFAULT_MAX_LEN):
17
+ self.df = df.reset_index(drop=True)
18
+ self.t2i = token2idx
19
+ self.max_len = max_len
20
+ self.cls = token2idx["[CLS]"]
21
+ self.pad = token2idx["[PAD]"]
22
+ self.eos = token2idx["[EOS]"]
23
+ self.unk = token2idx["[UNK]"]
24
+ self.gap = token2idx["X"] # separator between CDR segments
25
+
26
+ def _encode_seq(self, s: str) -> List[int]:
27
+ return [self.t2i.get(a, self.unk) for a in s]
28
+
29
+ def __getitem__(self, i) -> Tuple[Tuple[str, str], List[int]]:
30
+ r = self.df.iloc[i]
31
+ v_seq = r.cdr1 + "X" + r.cdr2 + "X" + r.cdr2_5
32
+ tokens = (
33
+ [self.cls]
34
+ + self._encode_seq(v_seq)
35
+ + [self.gap]
36
+ + self._encode_seq(r.cdr3aa)
37
+ + [self.eos]
38
+ )
39
+ if len(tokens) < self.max_len:
40
+ tokens = tokens + [self.pad] * (self.max_len - len(tokens))
41
+ else:
42
+ tokens = tokens[: self.max_len]
43
+ key = (r.cdr3aa, r.v_gene)
44
+ return key, tokens
45
+
46
+ def __len__(self) -> int:
47
+ return len(self.df)
48
+
49
+
50
+ def fixedlen_collate(batch):
51
+ keys, seqs = zip(*batch)
52
+ input_ids = torch.tensor(seqs, dtype=torch.long)
53
+ return keys, input_ids
tcrmeta/_model.py ADDED
@@ -0,0 +1,131 @@
1
+ """Model architectures: the ESM2-backbone masked-LM encoder used during
2
+ pretraining (only its encoder + CLS embedding is used at inference time),
3
+ and the ResMLP Siamese projection head used during contrastive fine-tuning.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+ from transformers import EsmModel
11
+
12
+ from ._weights import ESM2_BACKBONE
13
+
14
+
15
+ class TCRMaskedModel(nn.Module):
16
+ """ESM2 encoder + masked-LM head. Only `encoder` (and its CLS-token
17
+ embedding) is used downstream by TCRmeta; `lm_head` is retained so
18
+ pretrained checkpoints load without a state_dict mismatch.
19
+ """
20
+
21
+ def __init__(self, backbone_name: str = ESM2_BACKBONE, vocab_size: int = 33):
22
+ super().__init__()
23
+ self.encoder = EsmModel.from_pretrained(backbone_name)
24
+ self.hidden_size = self.encoder.config.hidden_size
25
+ self.lm_head = nn.Linear(self.hidden_size, vocab_size)
26
+
27
+ def forward(self, input_tokens, attention_mask=None, return_embedding=False):
28
+ outputs = self.encoder(input_ids=input_tokens, attention_mask=attention_mask)
29
+ sequence_output = outputs.last_hidden_state # (batch, seq_len, hidden)
30
+ logits = self.lm_head(sequence_output)
31
+ return (logits, sequence_output) if return_embedding else logits
32
+
33
+
34
+ class ResMLPBlock(nn.Module):
35
+ """Pre-norm residual MLP block: LayerNorm -> Linear -> GELU -> Dropout
36
+ -> Linear -> Dropout, added back with a learnable (small-initialized)
37
+ residual scale so the block starts close to identity.
38
+ """
39
+
40
+ def __init__(self, dim: int, hidden: int = 512, dropout: float = 0.1):
41
+ super().__init__()
42
+ self.norm = nn.LayerNorm(dim)
43
+ self.fc1 = nn.Linear(dim, hidden)
44
+ self.fc2 = nn.Linear(hidden, dim)
45
+ self.act = nn.GELU()
46
+ self.drop = nn.Dropout(dropout)
47
+ self.res_scale = nn.Parameter(torch.tensor(0.1))
48
+
49
+ nn.init.xavier_uniform_(self.fc1.weight)
50
+ nn.init.zeros_(self.fc2.weight)
51
+ nn.init.zeros_(self.fc2.bias)
52
+
53
+ def forward(self, x):
54
+ h = self.norm(x)
55
+ h = self.fc1(h)
56
+ h = self.act(h)
57
+ h = self.drop(h)
58
+ h = self.fc2(h)
59
+ h = self.drop(h)
60
+ return x + self.res_scale * h
61
+
62
+
63
+ class ResMLPStack(nn.Module):
64
+ """`depth` stacked ResMLPBlocks."""
65
+
66
+ def __init__(self, dim: int, hidden: int = 512, dropout: float = 0.1, depth: int = 2):
67
+ super().__init__()
68
+ self.blocks = nn.ModuleList([ResMLPBlock(dim, hidden, dropout) for _ in range(depth)])
69
+
70
+ def forward(self, x):
71
+ for blk in self.blocks:
72
+ x = blk(x)
73
+ return x
74
+
75
+
76
+ class SiameseOnEmb_ResMLP(nn.Module):
77
+ """Contrastive fine-tuning projection head: ResMLP re-mixes the
78
+ 480-dim base embedding, then a linear projection + L2 normalization
79
+ maps it onto the unit sphere in the antigen-aware embedding space.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ in_dim: int,
85
+ proj_dim: int = 64,
86
+ hidden: int = 768,
87
+ dropout: float = 0.1,
88
+ depth: int = 3,
89
+ ):
90
+ super().__init__()
91
+ self.mlp = ResMLPStack(in_dim, hidden=hidden, dropout=dropout, depth=depth)
92
+ self.proj = nn.Linear(in_dim, proj_dim, bias=False)
93
+ # Learnable scalars carried over from contrastive training; unused
94
+ # at inference (encode()/forward() distance is computed downstream),
95
+ # kept only so pretrained state_dicts load without a mismatch.
96
+ self.alpha = nn.Parameter(torch.tensor(10.0))
97
+ self.m = nn.Parameter(torch.tensor(1.0))
98
+ self.scale = nn.Parameter(torch.tensor(1.0))
99
+
100
+ def encode(self, x: torch.Tensor) -> torch.Tensor:
101
+ x = self.mlp(x)
102
+ z = self.proj(x)
103
+ return F.normalize(z, dim=-1)
104
+
105
+ def forward(self, x1, x2):
106
+ z1 = self.encode(x1)
107
+ z2 = self.encode(x2)
108
+ d = torch.norm(z1 - z2, dim=-1) * self.scale
109
+ return z1, z2, d
110
+
111
+
112
+ def load_state_dict_flexible(model: nn.Module, ckpt) -> None:
113
+ """Load a checkpoint saved under any of the common key conventions
114
+ (raw state_dict, {"model_state_dict": ...}, {"state_dict": ...},
115
+ an EMA shadow dict, etc.).
116
+ """
117
+ if isinstance(ckpt, dict) and all(torch.is_tensor(v) for v in ckpt.values()):
118
+ model.load_state_dict(ckpt, strict=True)
119
+ return
120
+ for key in ("model_state_dict", "ema_state_dict", "state_dict", "model"):
121
+ if key in ckpt:
122
+ model.load_state_dict(ckpt[key], strict=True)
123
+ return
124
+ if "ema_shadow" in ckpt:
125
+ sd = model.state_dict()
126
+ for k, v in ckpt["ema_shadow"].items():
127
+ if k in sd:
128
+ sd[k] = v
129
+ model.load_state_dict(sd, strict=False)
130
+ return
131
+ raise RuntimeError(f"Unrecognized checkpoint format, top-level keys: {list(ckpt.keys())}")
tcrmeta/_tokenizer.py ADDED
@@ -0,0 +1,23 @@
1
+ """ESM2 tokenizer -> amino-acid token id lookup, built once and cached."""
2
+ from __future__ import annotations
3
+
4
+ from functools import lru_cache
5
+ from typing import Dict
6
+
7
+ from transformers import EsmTokenizer
8
+
9
+ from ._weights import ESM2_BACKBONE
10
+
11
+ AMINO_ACIDS = list("ACDEFGHIKLMNPQRSTVWYX")
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def build_token2idx(backbone_name: str = ESM2_BACKBONE) -> Dict[str, int]:
16
+ tokenizer = EsmTokenizer.from_pretrained(backbone_name)
17
+ token2idx = {aa: tokenizer.convert_tokens_to_ids(aa) for aa in AMINO_ACIDS}
18
+ token2idx["[PAD]"] = tokenizer.convert_tokens_to_ids("<pad>")
19
+ token2idx["[MASK]"] = tokenizer.convert_tokens_to_ids("<mask>")
20
+ token2idx["[UNK]"] = tokenizer.convert_tokens_to_ids("<unk>")
21
+ token2idx["[CLS]"] = tokenizer.convert_tokens_to_ids("<cls>")
22
+ token2idx["[EOS]"] = tokenizer.convert_tokens_to_ids("<eos>")
23
+ return token2idx
tcrmeta/_utils.py ADDED
@@ -0,0 +1,185 @@
1
+ """Shared, dependency-light utilities used across TCRmeta's embedding,
2
+ CSS, UMAP, and energy-shift modules.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+
12
+ # --------------------------------------------------------------------------- #
13
+ # Device handling
14
+ # --------------------------------------------------------------------------- #
15
+ def resolve_device(device: Optional[str] = "auto"):
16
+ """Resolve a user-facing device string into a torch.device.
17
+
18
+ Parameters
19
+ ----------
20
+ device:
21
+ One of "auto" (default, uses CUDA if available), "cpu", "cuda",
22
+ "cuda:0", "mps", or an already-constructed torch.device.
23
+
24
+ Note: torch is imported lazily here (rather than at module level) so
25
+ that the pure numpy/pandas utilities in this module (downsampling,
26
+ column validation) can be used/tested without requiring torch to be
27
+ installed.
28
+ """
29
+ import torch
30
+
31
+ if isinstance(device, torch.device):
32
+ return device
33
+ if device is None or device == "auto":
34
+ if torch.cuda.is_available():
35
+ return torch.device("cuda")
36
+ if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
37
+ return torch.device("mps")
38
+ return torch.device("cpu")
39
+ return torch.device(device)
40
+
41
+
42
+ # --------------------------------------------------------------------------- #
43
+ # Downsampling
44
+ # --------------------------------------------------------------------------- #
45
+ def downsample_multinomial(
46
+ counts: np.ndarray,
47
+ target: int,
48
+ rng: Optional[np.random.Generator] = None,
49
+ seed: int = 0,
50
+ ) -> np.ndarray:
51
+ """Multinomial downsampling of clone counts to a fixed target depth.
52
+
53
+ If the repertoire's total count is already <= target, counts are
54
+ returned unchanged (no upsampling).
55
+ """
56
+ if rng is None:
57
+ rng = np.random.default_rng(seed)
58
+ counts = np.asarray(counts, dtype=np.int64)
59
+ total = int(counts.sum())
60
+ if total <= target:
61
+ return counts.copy()
62
+ p = counts / total
63
+ return rng.multinomial(target, p)
64
+
65
+
66
+ def normalize_weights(counts: np.ndarray) -> Optional[np.ndarray]:
67
+ """Normalize a count/weight vector to sum to 1. Returns None if the
68
+ vector sums to <= 0 (nothing usable to weight by)."""
69
+ counts = np.asarray(counts, dtype=np.float64)
70
+ s = counts.sum()
71
+ if s <= 0:
72
+ return None
73
+ return counts / s
74
+
75
+
76
+ def downsample_then_embed(
77
+ df: pd.DataFrame,
78
+ downsample: int,
79
+ seed: int,
80
+ device: str,
81
+ embed_kwargs: dict,
82
+ ) -> pd.DataFrame:
83
+ """Downsample a repertoire's raw counts FIRST, then embed only the
84
+ clones that survive downsampling — used by compute_css/plot_umap.
85
+
86
+ Multinomial downsampling to `downsample` total reads keeps at most
87
+ `downsample` unique (cdr3aa, v_gene) rows (each surviving row needs
88
+ >=1 read). Embedding only those rows — instead of embedding the
89
+ repertoire's full unique-clone set and downsampling afterward — can
90
+ be a large speedup for repertoires with many more unique clones than
91
+ `downsample`.
92
+
93
+ Caveat: a handful of surviving rows can still get dropped afterward
94
+ if their V gene has no CDR1/CDR2/CDR2.5 entry in TCRmeta's germline
95
+ lookup table (attach_embeddings' internal dropna) — for most real
96
+ repertoires this is rare/negligible, but it means results can differ
97
+ very slightly from embedding-then-downsampling in the (uncommon)
98
+ case where such unrecognized-V-gene rows exist and would otherwise
99
+ have consumed some of the downsampling budget.
100
+
101
+ Returns the downsampled + embedded dataframe with an added 'ds'
102
+ float64 column (post-downsample weight per surviving row).
103
+
104
+ Always embeds with embedding_type="final" (the antigen-aware,
105
+ contrastively fine-tuned embedding) regardless of what's in
106
+ embed_kwargs — compute_css/plot_umap's reference maps are built on
107
+ that embedding space, so scoring against them requires it. Only
108
+ embed_repertoire() itself exposes a choice of embedding_type.
109
+ """
110
+ from .embedding import attach_embeddings # lazy: keeps this module
111
+
112
+ # importable/testable without torch/transformers.
113
+ df = validate_repertoire_columns(df)
114
+ rng = np.random.default_rng(seed)
115
+ counts = df["count"].to_numpy(dtype=np.int64)
116
+ ds = downsample_multinomial(counts, downsample, rng=rng)
117
+ keep = ds > 0
118
+ df = df.loc[keep].copy()
119
+ df["ds"] = ds[keep].astype(np.float64)
120
+ embed_kwargs = {**embed_kwargs, "embedding_type": "final"}
121
+ df_e = attach_embeddings(df, device=device, **embed_kwargs)
122
+ return df_e
123
+
124
+
125
+ # --------------------------------------------------------------------------- #
126
+ # Repertoire column validation
127
+ # --------------------------------------------------------------------------- #
128
+ REQUIRED_COLUMNS = ("cdr3aa", "v_gene", "count")
129
+
130
+
131
+ def validate_repertoire_columns(df: pd.DataFrame) -> pd.DataFrame:
132
+ """Validate and lightly normalize a TCR repertoire dataframe.
133
+
134
+ TCRmeta requires the exact columns 'cdr3aa', 'v_gene', 'count' —
135
+ there is no alias-guessing. If your data uses different column
136
+ names (e.g. Adaptive's 'aminoAcid'/'vGeneName'/'count (templates/reads)',
137
+ or AIRR's 'junction_aa'/'v_call'/'duplicate_count'), rename them
138
+ yourself before calling into TCRmeta.
139
+
140
+ Normalization performed (does not change column names):
141
+ - cdr3aa is cast to str and given canonical IMGT flanks (leading
142
+ C, trailing F) if missing.
143
+ - v_gene is cast to str, allele suffix (e.g. '*01') stripped.
144
+ - count is coerced to int; rows with count <= 0 are dropped.
145
+ """
146
+ missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
147
+ if missing:
148
+ raise ValueError(
149
+ f"Repertoire dataframe is missing required column(s): {missing}. "
150
+ f"TCRmeta requires exactly these columns: {REQUIRED_COLUMNS}. "
151
+ "Rename your columns before calling this function (no aliases are "
152
+ "auto-detected)."
153
+ )
154
+
155
+ df = df.copy()
156
+ df["cdr3aa"] = df["cdr3aa"].astype(str)
157
+ df["cdr3aa"] = df["cdr3aa"].str.replace(r"^(?!C)", "C", regex=True)
158
+ df["cdr3aa"] = df["cdr3aa"].str.replace(r"(?<!F)$", "F", regex=True)
159
+
160
+ df["v_gene"] = df["v_gene"].astype(str).str.replace(r"\*.*$", "", regex=True).str.strip()
161
+
162
+ df["count"] = pd.to_numeric(df["count"], errors="coerce").fillna(0).astype(np.int64)
163
+ df = df[df["count"] > 0].reset_index(drop=True)
164
+ return df
165
+
166
+
167
+ def aggregate_duplicate_clones(df: pd.DataFrame, sort: bool = True) -> pd.DataFrame:
168
+ """Collapse duplicate (cdr3aa, v_gene) rows by summing 'count'.
169
+
170
+ `sort` controls whether the result is reordered alphabetically by
171
+ (cdr3aa, v_gene) (pandas groupby's default) or left in first-occurrence
172
+ order. This matters for reproducibility: multinomial downsampling
173
+ consumes the RNG stream in row order, so which convention to use
174
+ depends on what the calling code's original (pre-packaging) script
175
+ did. energy_shift's original script deduplicated with pandas'
176
+ default sort=True before downsampling, so energy.py relies on the
177
+ default here. CSS/UMAP's original scoring scripts never deduplicated
178
+ rows at all (raw CSV order preserved throughout) — those code paths
179
+ (attach_embeddings) intentionally skip calling this function.
180
+ """
181
+ agg_cols = {"count": "sum"}
182
+ other_cols = [c for c in df.columns if c not in ("cdr3aa", "v_gene", "count")]
183
+ for c in other_cols:
184
+ agg_cols[c] = "first"
185
+ return df.groupby(["cdr3aa", "v_gene"], as_index=False, sort=sort).agg(agg_cols)
tcrmeta/_weights.py ADDED
@@ -0,0 +1,119 @@
1
+ """Model artifact + reference map download/caching.
2
+
3
+ All non-code artifacts (the fine-tuned base encoder checkpoint, the 7
4
+ projection-head checkpoints, the GPA rotation matrices, the V-gene CDR
5
+ lookup table, and the default shipped reference map) are hosted on the
6
+ Hugging Face Hub rather than bundled into the PyPI package, and are
7
+ downloaded once and cached locally (via huggingface_hub, which handles
8
+ its own on-disk cache).
9
+
10
+ Set the TCRMETA_HF_REPO environment variable to point at a different
11
+ Hub repo (e.g. a private mirror), and TCRMETA_WEIGHTS_DIR to bypass the
12
+ Hub entirely and load artifacts from a local directory instead (useful
13
+ offline, or before the Hub repo exists).
14
+
15
+ If the Hub repo is PRIVATE, authentication is required to download from
16
+ it. Either run `huggingface-cli login` once (huggingface_hub then reuses
17
+ that cached token automatically), or set the TCRMETA_HF_TOKEN
18
+ environment variable to an access token explicitly (useful in
19
+ non-interactive environments like CI or a shared cluster).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ # TODO: replace with the actual Hugging Face Hub repo id once created,
28
+ # e.g. "mingyaopan/tcrmeta-weights". Can be overridden without a code
29
+ # change via the TCRMETA_HF_REPO environment variable.
30
+ DEFAULT_HF_REPO = os.environ.get("TCRMETA_HF_REPO", "mingyaopan/tcrmeta-weights")
31
+
32
+ # Filenames as they should exist in the Hub repo / local weights dir.
33
+ BASE_CKPT_FILE = "base_encoder/best_model.pth"
34
+ ROTATIONS_FILE = "finetune/rotations_gpa.pkl"
35
+ VGENE_CDR_FILE = "reference_data/vgene_cdr.csv"
36
+ PROJ_HEAD_FILES = [f"finetune/proj_head_{i}.pt" for i in range(7)]
37
+ DEFAULT_REFERENCE_FILE = "reference_maps/young_reference.pkl"
38
+
39
+ ESM2_BACKBONE = "facebook/esm2_t12_35M_UR50D"
40
+
41
+
42
+ def _local_weights_dir() -> Optional[Path]:
43
+ d = os.environ.get("TCRMETA_WEIGHTS_DIR")
44
+ return Path(d) if d else None
45
+
46
+
47
+ def get_cache_dir() -> Path:
48
+ cache_dir = Path(os.environ.get("TCRMETA_CACHE_DIR", Path.home() / ".cache" / "tcrmeta"))
49
+ cache_dir.mkdir(parents=True, exist_ok=True)
50
+ return cache_dir
51
+
52
+
53
+ def resolve_artifact(relative_path: str, repo_id: str = DEFAULT_HF_REPO) -> Path:
54
+ """Return a local filesystem path for a named artifact, downloading
55
+ (and caching) it from the Hugging Face Hub if it isn't available
56
+ locally via TCRMETA_WEIGHTS_DIR.
57
+ """
58
+ local_dir = _local_weights_dir()
59
+ if local_dir is not None:
60
+ local_path = local_dir / relative_path
61
+ if not local_path.exists():
62
+ raise FileNotFoundError(
63
+ f"TCRMETA_WEIGHTS_DIR is set to {local_dir}, but {relative_path} "
64
+ "was not found under it."
65
+ )
66
+ return local_path
67
+
68
+ # Common mistake: pointing TCRMETA_HF_REPO at a local filesystem path
69
+ # (that's what TCRMETA_WEIGHTS_DIR is for) instead of a Hub repo id
70
+ # ("namespace/repo_name"). Catch it here with a clear message rather
71
+ # than letting huggingface_hub's HFValidationError surface instead.
72
+ looks_like_local_path = repo_id.startswith(("/", "~", ".")) or os.path.exists(repo_id)
73
+ if looks_like_local_path:
74
+ raise ValueError(
75
+ f"repo_id resolved to '{repo_id}', which looks like a local filesystem "
76
+ "path, not a Hugging Face Hub repo id ('namespace/repo_name'). If you "
77
+ "meant to load weights from a local directory, set the "
78
+ "TCRMETA_WEIGHTS_DIR environment variable instead of TCRMETA_HF_REPO:\n"
79
+ f' os.environ["TCRMETA_WEIGHTS_DIR"] = "{repo_id}"\n'
80
+ "TCRMETA_HF_REPO is only for overriding which Hub repo to download from."
81
+ )
82
+
83
+ try:
84
+ from huggingface_hub import hf_hub_download
85
+ except ImportError as e: # pragma: no cover
86
+ raise ImportError(
87
+ "huggingface_hub is required to auto-download TCRmeta model weights. "
88
+ "Install it with `pip install huggingface_hub`, or set TCRMETA_WEIGHTS_DIR "
89
+ "to a local directory containing the weights."
90
+ ) from e
91
+
92
+ path = hf_hub_download(
93
+ repo_id=repo_id,
94
+ filename=relative_path,
95
+ cache_dir=str(get_cache_dir()),
96
+ token=os.environ.get("TCRMETA_HF_TOKEN"), # None -> huggingface_hub
97
+ # falls back to the cached `huggingface-cli login` token, if any.
98
+ )
99
+ return Path(path)
100
+
101
+
102
+ def resolve_base_checkpoint() -> Path:
103
+ return resolve_artifact(BASE_CKPT_FILE)
104
+
105
+
106
+ def resolve_rotations() -> Path:
107
+ return resolve_artifact(ROTATIONS_FILE)
108
+
109
+
110
+ def resolve_vgene_table() -> Path:
111
+ return resolve_artifact(VGENE_CDR_FILE)
112
+
113
+
114
+ def resolve_projection_heads() -> list[Path]:
115
+ return [resolve_artifact(f) for f in PROJ_HEAD_FILES]
116
+
117
+
118
+ def resolve_default_reference() -> Path:
119
+ return resolve_artifact(DEFAULT_REFERENCE_FILE)