crosspaper / scripts /fingerprint.py
Kattine
chore: fingerprint loaded artifacts and source at startup
7476172
Raw
History Blame Contribute Delete
3.51 kB
"""
Fingerprint the artifacts and source files the process actually loaded.
A deployed container can diverge from the repository in two independent ways:
the code can be stale (an old commit, or a cached layer), or the artifacts can
be stale (a previous build's download that `ensure_artifacts` then skips,
because the files already exist on disk). Reading the app's output cannot tell
these apart β€” both produce "wrong answers" β€” and neither can comparing the Hub
against a local checkout, since that says nothing about what the container has.
Hashing from inside the process resolves it. Run this locally and read the same
lines from the container's startup log: if the source hashes differ, the code
path is wrong; if the artifact hashes differ, the artifacts are stale; if
everything matches and behaviour still differs, the cause is neither.
AI Attribution: Fingerprint design assisted by Claude (Anthropic, https://claude.ai).
"""
import hashlib
from pathlib import Path
# Ordered so the source files β€” the usual suspect after a push β€” come first.
TRACKED_PATHS = [
"scripts/recommender.py",
"scripts/artifacts.py",
"main.py",
"data/processed/finetuned.index",
"data/processed/base.index",
"data/processed/finetuned_embeddings.npy",
"data/processed/base_embeddings.npy",
"data/processed/paper_metadata.pkl",
"models/fine_tuned/model.safetensors",
"models/base/model.safetensors",
]
CHUNK_BYTES = 1 << 20 # 1 MiB
def sha256_file(path, digest_chars=16):
"""Hash a file with SHA-256, streaming so large files stay off the heap.
Args:
path: Path to the file.
digest_chars: How many leading hex characters to return. Sixteen is
far more than needed to distinguish two builds and stays readable
in a log line.
Returns:
Truncated hex digest, or a marker string if the file is absent.
"""
p = Path(path)
if not p.exists():
return "MISSING"
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(CHUNK_BYTES), b""):
h.update(chunk)
return h.hexdigest()[:digest_chars]
def fingerprint(paths=None):
"""Hash every tracked path.
Args:
paths: Optional list of paths to hash. Defaults to TRACKED_PATHS.
Returns:
Dict mapping path to truncated digest, size in bytes, and existence.
"""
result = {}
for path in paths or TRACKED_PATHS:
p = Path(path)
result[path] = {
"sha256": sha256_file(p),
"bytes": p.stat().st_size if p.exists() else 0,
}
return result
def print_fingerprint(paths=None, header="FINGERPRINT"):
"""Print the fingerprint table.
Called at app startup so the lines land in the container log, and runnable
locally so the two can be diffed line by line.
Args:
paths: Optional list of paths to hash.
header: Label for the table.
"""
fp = fingerprint(paths)
width = max(len(p) for p in fp)
print("\n" + "=" * (width + 34))
print(f" {header}")
print("=" * (width + 34))
for path, info in fp.items():
size = f"{info['bytes'] / 1e6:.1f}MB" if info["bytes"] else "-"
print(f" {path:<{width}} {info['sha256']} {size:>9}")
print("=" * (width + 34) + "\n")
return fp
def main():
"""Print the fingerprint of the current working tree."""
print_fingerprint(header="LOCAL FINGERPRINT")
if __name__ == "__main__":
main()