Skip to content

Commit 82e2144

Browse files
cursoragentechobt
andcommitted
fix(eval): load the base with the auto class the installed transformers has
The published image resolves transformers 5.x, where the image-text-to-text auto class is no longer AutoModelForVision2Seq and the dtype keyword was renamed. The base model is a native VLM, so picking the wrong class means loading it as text-only or not at all: try AutoModelForImageTextToText, then the 4.x name, then the language-model class, and fail the run when none of them can take the weights. Also documents the one-time GHCR visibility step: a package a workflow creates starts private, and a Lium pod boots with no registry credential. Co-authored-by: Mathis <echobt@users.noreply.github.com>
1 parent 67aba72 commit 82e2144

25 files changed

Lines changed: 2667 additions & 15 deletions

build/lib/relearn_eval/__init__.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Relearn LLM live eval image.
2+
3+
The control plane boots this image on a digest pin, stages a
4+
`HarvestRequest` into `/tmp/relearn_eval`, runs
5+
6+
relearn-eval score --request request.json --out metrics.json
7+
8+
and accepts a score only when the pod prints `RELEARN_METRICS=<document>` and
9+
`RELEARN_EVAL_OK`. The document is bound to the run that asked for it, so a pod
10+
cannot answer with another artifact's numbers or replay an earlier run.
11+
12+
Contract: `docs/RELEARN.md` § Eval image contract in `CortexLM/cortex`, client
13+
in `crates/relearn-lium-harvest`. Local notes: `docs/EVAL-IMAGE.md`.
14+
15+
Nothing in this package can produce a score without the model: there is no
16+
offline harness, no simulated series, and no fallback judge.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from .contract import (
22+
BASE_CHAMPION_ARTIFACT,
23+
BASE_CHAMPION_RUN,
24+
METRICS_MARKER,
25+
METRICS_SCHEMA_VERSION,
26+
OK_MARKER,
27+
POD_WORKDIR,
28+
ContractError,
29+
EvalDocument,
30+
Measurement,
31+
ShuffleEvidence,
32+
decode_document,
33+
encode_document,
34+
marker_line,
35+
)
36+
from .harvest import TranscriptError, accept, extract_metrics, has_ok_marker
37+
from .request import HarvestRequest, HoldoutItem, RequestError
38+
from .verify import VerificationError, verify_document
39+
40+
__all__ = [
41+
"BASE_CHAMPION_ARTIFACT",
42+
"BASE_CHAMPION_RUN",
43+
"METRICS_MARKER",
44+
"METRICS_SCHEMA_VERSION",
45+
"OK_MARKER",
46+
"POD_WORKDIR",
47+
"ContractError",
48+
"EvalDocument",
49+
"HarvestRequest",
50+
"HoldoutItem",
51+
"Measurement",
52+
"RequestError",
53+
"ShuffleEvidence",
54+
"TranscriptError",
55+
"VerificationError",
56+
"accept",
57+
"decode_document",
58+
"encode_document",
59+
"extract_metrics",
60+
"has_ok_marker",
61+
"marker_line",
62+
"verify_document",
63+
]
64+
65+
__version__ = "1.0.0"

build/lib/relearn_eval/__main__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""`python -m relearn_eval` — same entrypoint as the `relearn-eval` script."""
2+
3+
from __future__ import annotations
4+
5+
from .cli import main
6+
7+
if __name__ == "__main__":
8+
raise SystemExit(main())

build/lib/relearn_eval/artifact.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""Resolving the submitted artifact, by digest.
2+
3+
The request names an artifact by its `sha256`, not by a location: the pod
4+
resolves it from an operator-configured store and **verifies the bytes hash to
5+
the digest that was asked for** before anything is loaded. A store that serves
6+
different bytes fails the run, so the identity in the metrics document always
7+
describes what actually ran.
8+
9+
There is no baked host here. The operator sets `RELEARN_ARTIFACT_DIR` (a
10+
content-addressed directory, checked first) and/or
11+
`RELEARN_ARTIFACT_URL_TEMPLATE` (`https://…/{digest}.tar`). A request that
12+
carries its own `artifact_uri` is honoured, and still digest-checked.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import hashlib
18+
import logging
19+
import os
20+
import shutil
21+
import tarfile
22+
import urllib.request
23+
import zipfile
24+
from pathlib import Path
25+
26+
from .contract import BASE_CHAMPION_ARTIFACT, ContractError
27+
28+
log = logging.getLogger(__name__)
29+
30+
#: Read size for hashing and downloads.
31+
_CHUNK = 1024 * 1024
32+
33+
#: Refuse an artifact larger than this (bytes). A pod that fills its disk is a
34+
#: failed run either way; failing early leaves the log readable.
35+
DEFAULT_MAX_ARTIFACT_BYTES = 64 * 1024 * 1024 * 1024
36+
37+
38+
class ArtifactError(ContractError):
39+
"""The artifact could not be resolved, verified, or unpacked."""
40+
41+
42+
def _env(name: str, default: str = "") -> str:
43+
return os.environ.get(name, default).strip()
44+
45+
46+
def max_artifact_bytes() -> int:
47+
raw = _env("RELEARN_MAX_ARTIFACT_BYTES")
48+
if not raw:
49+
return DEFAULT_MAX_ARTIFACT_BYTES
50+
try:
51+
limit = int(raw)
52+
except ValueError as exc:
53+
raise ArtifactError(f"RELEARN_MAX_ARTIFACT_BYTES {raw!r} is not an integer") from exc
54+
if limit <= 0:
55+
raise ArtifactError("RELEARN_MAX_ARTIFACT_BYTES must be positive")
56+
return limit
57+
58+
59+
def sha256_file(path: Path) -> str:
60+
digest = hashlib.sha256()
61+
with path.open("rb") as handle:
62+
while chunk := handle.read(_CHUNK):
63+
digest.update(chunk)
64+
return digest.hexdigest()
65+
66+
67+
def sha256_tree(root: Path) -> str:
68+
"""Digest of a directory: sorted relative paths and their contents.
69+
70+
Used when the store holds an already-unpacked artifact. Length-prefixed
71+
names so two different layouts cannot collide.
72+
"""
73+
digest = hashlib.sha256()
74+
for path in sorted(p for p in root.rglob("*") if p.is_file()):
75+
name = str(path.relative_to(root)).encode("utf-8")
76+
digest.update(len(name).to_bytes(8, "little"))
77+
digest.update(name)
78+
digest.update(bytes.fromhex(sha256_file(path)))
79+
return digest.hexdigest()
80+
81+
82+
def _looks_like_digest(value: str) -> bool:
83+
trimmed = value.strip().lower()
84+
return len(trimmed) == 64 and all(char in "0123456789abcdef" for char in trimmed)
85+
86+
87+
def _safe_extract_tar(archive: Path, target: Path) -> None:
88+
with tarfile.open(archive) as tar:
89+
for member in tar.getmembers():
90+
name = Path(member.name)
91+
if name.is_absolute() or ".." in name.parts:
92+
raise ArtifactError(f"artifact archive escapes its directory: {member.name}")
93+
if member.issym() or member.islnk():
94+
raise ArtifactError("artifact archive carries a link")
95+
tar.extractall(target, filter="data")
96+
97+
98+
def _safe_extract_zip(archive: Path, target: Path) -> None:
99+
with zipfile.ZipFile(archive) as zipped:
100+
for name in zipped.namelist():
101+
path = Path(name)
102+
if path.is_absolute() or ".." in path.parts:
103+
raise ArtifactError(f"artifact archive escapes its directory: {name}")
104+
zipped.extractall(target) # noqa: S202 - members checked above
105+
106+
107+
def unpack(archive: Path, target: Path) -> Path:
108+
"""Unpack an artifact archive under `target`, or return it as weights.
109+
110+
A single unpacked directory becomes the model directory, so a tarball with
111+
one top-level folder behaves like one without.
112+
"""
113+
target.mkdir(parents=True, exist_ok=True)
114+
if tarfile.is_tarfile(archive):
115+
_safe_extract_tar(archive, target)
116+
elif zipfile.is_zipfile(archive):
117+
_safe_extract_zip(archive, target)
118+
else:
119+
raise ArtifactError("artifact is neither a tar nor a zip archive")
120+
entries = [path for path in target.iterdir() if not path.name.startswith(".")]
121+
if len(entries) == 1 and entries[0].is_dir():
122+
return entries[0]
123+
return target
124+
125+
126+
def _download(url: str, target: Path, limit: int) -> Path:
127+
target.parent.mkdir(parents=True, exist_ok=True)
128+
if not url.lower().startswith(("https://", "http://", "file://")):
129+
raise ArtifactError("artifact url must be http(s) or file")
130+
log.info("fetching artifact from the configured store")
131+
written = 0
132+
with urllib.request.urlopen(url, timeout=600) as response: # noqa: S310 - scheme checked
133+
with target.open("wb") as handle:
134+
while chunk := response.read(_CHUNK):
135+
written += len(chunk)
136+
if written > limit:
137+
raise ArtifactError("artifact exceeds RELEARN_MAX_ARTIFACT_BYTES")
138+
handle.write(chunk)
139+
return target
140+
141+
142+
def _store_candidates(digest: str) -> list[Path]:
143+
directory = _env("RELEARN_ARTIFACT_DIR")
144+
if not directory:
145+
return []
146+
root = Path(directory)
147+
return [
148+
root / digest,
149+
root / f"{digest}.tar",
150+
root / f"{digest}.tar.gz",
151+
root / f"{digest}.zip",
152+
root / digest[:2] / digest,
153+
]
154+
155+
156+
def resolve_artifact(artifact_digest: str, workdir: Path, artifact_uri: str = "") -> Path | None:
157+
"""Return the directory holding the artifact's weights.
158+
159+
`None` means "no artifact": the control plane asked for
160+
[`BASE_CHAMPION_ARTIFACT`], the un-post-trained base model, when it records
161+
the champion baseline.
162+
163+
# Raises
164+
[`ArtifactError`] when the artifact cannot be found, does not hash to the
165+
requested digest, or cannot be unpacked.
166+
"""
167+
digest = artifact_digest.strip()
168+
if digest == BASE_CHAMPION_ARTIFACT:
169+
log.info("scoring the base model: no artifact to fetch")
170+
return None
171+
if not _looks_like_digest(digest):
172+
raise ArtifactError("artifact_digest is not a sha256 hex digest")
173+
174+
digest = digest.lower()
175+
limit = max_artifact_bytes()
176+
for candidate in _store_candidates(digest):
177+
if candidate.is_dir():
178+
measured = sha256_tree(candidate)
179+
if measured != digest:
180+
raise ArtifactError(
181+
f"artifact directory hashes to {measured}, request asked for {digest}"
182+
)
183+
log.info("artifact resolved from the local store")
184+
return candidate
185+
if candidate.is_file():
186+
measured = sha256_file(candidate)
187+
if measured != digest:
188+
raise ArtifactError(
189+
f"artifact file hashes to {measured}, request asked for {digest}"
190+
)
191+
return unpack(candidate, workdir / "artifact")
192+
193+
template = _env("RELEARN_ARTIFACT_URL_TEMPLATE")
194+
url = artifact_uri.strip()
195+
if not url and template:
196+
url = template.replace("{digest}", digest)
197+
if not url:
198+
raise ArtifactError(
199+
"no artifact source: set RELEARN_ARTIFACT_DIR or RELEARN_ARTIFACT_URL_TEMPLATE"
200+
)
201+
202+
downloaded = _download(url, workdir / "artifact.bin", limit)
203+
measured = sha256_file(downloaded)
204+
if measured != digest:
205+
raise ArtifactError(
206+
f"fetched artifact hashes to {measured}, request asked for {digest}"
207+
)
208+
unpacked = unpack(downloaded, workdir / "artifact")
209+
downloaded.unlink(missing_ok=True)
210+
return unpacked
211+
212+
213+
def scrub(path: Path) -> None:
214+
"""Best-effort removal of run state from the pod."""
215+
shutil.rmtree(path, ignore_errors=True)

0 commit comments

Comments
 (0)