mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 04:04:07 +02:00
Preserve verified Redux model cards across revision upgrades
This commit is contained in:
1 parent
bbbd31fa6d
commit
c0176b8595
6 files changed
+113
-6
No files matched your search
@@ -16,7 +16,7 @@
|
|||||||
{"path": "config.json", "size": 12988, "sha256": "503c653b2e3bb788adbcb04f5abdee532d958686564081baeed133ff10143f6e"},
|
{"path": "config.json", "size": 12988, "sha256": "503c653b2e3bb788adbcb04f5abdee532d958686564081baeed133ff10143f6e"},
|
||||||
{"path": "ternary.json", "size": 57970, "sha256": "1221c6d3ce901ffe09c089da758a8db8b76189f80cff41c5afc244fc61e2051d"},
|
{"path": "ternary.json", "size": 57970, "sha256": "1221c6d3ce901ffe09c089da758a8db8b76189f80cff41c5afc244fc61e2051d"},
|
||||||
{"path": "tokenizer.json", "size": 1159960, "sha256": "bd321b096832a3f270bd3b2a88823957920f1a5c5ada71114a26ea729d0cbe91"},
|
{"path": "tokenizer.json", "size": 1159960, "sha256": "bd321b096832a3f270bd3b2a88823957920f1a5c5ada71114a26ea729d0cbe91"},
|
||||||
{"path": "README.md", "size": 8832, "sha256": "930055e6bac7d75114e719850ffaffc8d327e73b4295518c38c81399acd63e46"}
|
{"path": "README.md", "size": 8832, "sha256": "930055e6bac7d75114e719850ffaffc8d327e73b4295518c38c81399acd63e46", "compatible": [{"size": 8533, "sha256": "a8b327f983a8b8ff262ff7bead3a791fbed9350632002af8db85ab5cd84cdaa5"}]}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"attribution": "Moondream/M87 Labs, Parakeet Redux, derived from NVIDIA Parakeet TDT 0.6B v3; original model card README.md retained with the files.",
|
"attribution": "Moondream/M87 Labs, Parakeet Redux, derived from NVIDIA Parakeet TDT 0.6B v3; original model card README.md retained with the files.",
|
||||||
|
|||||||
@@ -232,6 +232,11 @@ back to desktop/cloud inference. The benchmark ran on revision
|
|||||||
`fad622f25f303105c20d70e201bcc477c88b620c`, which no longer resolves after the
|
`fad622f25f303105c20d70e201bcc477c88b620c`, which no longer resolves after the
|
||||||
upstream history was rewritten; the weights, config, ternary map and tokenizer at
|
upstream history was rewritten; the weights, config, ternary map and tokenizer at
|
||||||
`2bf1286` are byte-identical to it (same SHA-256), only the model card README differs.
|
`2bf1286` are byte-identical to it (same SHA-256), only the model card README differs.
|
||||||
|
The manifest explicitly accepts the previous card's pinned size and SHA-256 for
|
||||||
|
existing installations, so updating FrameYap requires no model download. New
|
||||||
|
downloads must verify against the current revision's card; unknown or modified
|
||||||
|
cards are still rejected. The optional per-file `compatible` list contains exact
|
||||||
|
`size`/`sha256` pairs for previously installed artifacts, never download fallbacks.
|
||||||
|
|
||||||
The earlier benchmark used this local API (its source belongs to the originating
|
The earlier benchmark used this local API (its source belongs to the originating
|
||||||
repository, not this project):
|
repository, not this project):
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class ModelFile:
|
|||||||
path: str
|
path: str
|
||||||
size: int
|
size: int
|
||||||
sha256: str
|
sha256: str
|
||||||
|
compatible: tuple[tuple[int, str], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -127,13 +128,28 @@ def _parse(value):
|
|||||||
parsed = []
|
parsed = []
|
||||||
names = set()
|
names = set()
|
||||||
for item in files:
|
for item in files:
|
||||||
_fields(item, ("path", "size", "sha256"), "model file")
|
if not isinstance(item, dict):
|
||||||
|
raise ManifestError("invalid model file fields")
|
||||||
|
_fields(item, ("path", "size", "sha256", "compatible") if "compatible" in item
|
||||||
|
else ("path", "size", "sha256"), "model file")
|
||||||
name = _relative(item["path"], "model file path")
|
name = _relative(item["path"], "model file path")
|
||||||
size = item["size"]
|
size = item["size"]
|
||||||
if name in names or type(size) is not int or not 0 < size <= _MAX_FILE or not isinstance(item["sha256"], str) or not _HASH.fullmatch(item["sha256"]):
|
if name in names or type(size) is not int or not 0 < size <= _MAX_FILE or not isinstance(item["sha256"], str) or not _HASH.fullmatch(item["sha256"]):
|
||||||
raise ManifestError("duplicate or invalid pinned model file")
|
raise ManifestError("duplicate or invalid pinned model file")
|
||||||
names.add(name)
|
names.add(name)
|
||||||
parsed.append(ModelFile(name, size, item["sha256"]))
|
compatible = item.get("compatible", [])
|
||||||
|
if not isinstance(compatible, list) or len(compatible) > 8:
|
||||||
|
raise ManifestError("invalid compatible model files")
|
||||||
|
pins = []
|
||||||
|
for previous in compatible:
|
||||||
|
_fields(previous, ("size", "sha256"), "compatible model file")
|
||||||
|
old_size, old_hash = previous["size"], previous["sha256"]
|
||||||
|
if (type(old_size) is not int or not 0 < old_size <= _MAX_FILE or
|
||||||
|
not isinstance(old_hash, str) or not _HASH.fullmatch(old_hash) or
|
||||||
|
(old_size, old_hash) in [(size, item["sha256"]), *pins]):
|
||||||
|
raise ManifestError("duplicate or invalid compatible model file")
|
||||||
|
pins.append((old_size, old_hash))
|
||||||
|
parsed.append(ModelFile(name, size, item["sha256"], tuple(pins)))
|
||||||
license_info = value["license"]
|
license_info = value["license"]
|
||||||
_fields(license_info, ("id", "text"), "license")
|
_fields(license_info, ("id", "text"), "license")
|
||||||
requirements = value["requirements"]
|
requirements = value["requirements"]
|
||||||
@@ -177,7 +193,11 @@ def load_backends(manifest_dir=DEFAULT_MANIFEST_DIR):
|
|||||||
|
|
||||||
|
|
||||||
def check_file(directory, item):
|
def check_file(directory, item):
|
||||||
"""Return (reason, filename); reason None means the pinned file verifies."""
|
"""Verify the current pin or an explicitly compatible existing artifact.
|
||||||
|
|
||||||
|
Downloaders construct a temporary ModelFile with only the current pin so
|
||||||
|
newly fetched bytes must match the revision advertised to the user.
|
||||||
|
"""
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
if not root.is_absolute():
|
if not root.is_absolute():
|
||||||
return "model_dir_not_absolute", item.path
|
return "model_dir_not_absolute", item.path
|
||||||
@@ -201,13 +221,14 @@ def check_file(directory, item):
|
|||||||
before = os.fstat(file_fd)
|
before = os.fstat(file_fd)
|
||||||
if not stat.S_ISREG(before.st_mode):
|
if not stat.S_ISREG(before.st_mode):
|
||||||
return "unsafe_file", item.path
|
return "unsafe_file", item.path
|
||||||
if before.st_size != item.size:
|
pins = ((item.size, item.sha256), *item.compatible)
|
||||||
|
if before.st_size not in {size for size, _ in pins}:
|
||||||
return "size_mismatch", item.path
|
return "size_mismatch", item.path
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
while chunk := os.read(file_fd, 1024 * 1024):
|
while chunk := os.read(file_fd, 1024 * 1024):
|
||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
after = os.fstat(file_fd)
|
after = os.fstat(file_fd)
|
||||||
if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns) or digest.hexdigest() != item.sha256:
|
if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns) or (before.st_size, digest.hexdigest()) not in pins:
|
||||||
return "hash_mismatch", item.path
|
return "hash_mismatch", item.path
|
||||||
return None, None
|
return None, None
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ class BackendTests(unittest.TestCase):
|
|||||||
"tokenizer.json": (1159960, "bd321b096832a3f270bd3b2a88823957920f1a5c5ada71114a26ea729d0cbe91"),
|
"tokenizer.json": (1159960, "bd321b096832a3f270bd3b2a88823957920f1a5c5ada71114a26ea729d0cbe91"),
|
||||||
"README.md": (8832, "930055e6bac7d75114e719850ffaffc8d327e73b4295518c38c81399acd63e46"),
|
"README.md": (8832, "930055e6bac7d75114e719850ffaffc8d327e73b4295518c38c81399acd63e46"),
|
||||||
})
|
})
|
||||||
|
self.assertEqual(redux.files[-1].compatible,
|
||||||
|
((8533, "a8b327f983a8b8ff262ff7bead3a791fbed9350632002af8db85ab5cd84cdaa5"),))
|
||||||
|
self.assertTrue(all(not file.compatible for file in redux.files[:-1]))
|
||||||
self.assertEqual(redux.launcher["protocol"], "frameyap-worker-v1")
|
self.assertEqual(redux.launcher["protocol"], "frameyap-worker-v1")
|
||||||
self.assertEqual(redux.launcher["arguments"], ["--model", "{model_dir}", "--threads", "{threads}", "--clip-dir", "{clip_dir}"])
|
self.assertEqual(redux.launcher["arguments"], ["--model", "{model_dir}", "--threads", "{threads}", "--clip-dir", "{clip_dir}"])
|
||||||
self.assertIn("CC-BY-4.0", redux.license_id)
|
self.assertIn("CC-BY-4.0", redux.license_id)
|
||||||
@@ -47,6 +50,10 @@ class BackendTests(unittest.TestCase):
|
|||||||
lambda f: f["model"]["files"][0].update(path="../secret"),
|
lambda f: f["model"]["files"][0].update(path="../secret"),
|
||||||
lambda f: f["model"]["files"][0].update(size=True),
|
lambda f: f["model"]["files"][0].update(size=True),
|
||||||
lambda f: f["model"]["files"][0].update(sha256="A" * 64),
|
lambda f: f["model"]["files"][0].update(sha256="A" * 64),
|
||||||
|
lambda f: f["model"]["files"][0].update(compatible="bad"),
|
||||||
|
lambda f: f["model"]["files"][0].update(compatible=[{"size": True, "sha256": "a" * 64}]),
|
||||||
|
lambda f: f["model"]["files"][0].update(compatible=[{"size": 3, "sha256": "a" * 64}]),
|
||||||
|
lambda f: f["model"]["files"][0].update(compatible=[{"size": 4, "sha256": "a" * 64, "path": "other"}]),
|
||||||
lambda f: f["launcher"]["arguments"].append("{unknown}"),
|
lambda f: f["launcher"]["arguments"].append("{unknown}"),
|
||||||
lambda f: f["launcher"].update(path="/bin/sh"),
|
lambda f: f["launcher"].update(path="/bin/sh"),
|
||||||
lambda f: f["launcher"].update(protocol="not-the-wire-protocol"),
|
lambda f: f["launcher"].update(protocol="not-the-wire-protocol"),
|
||||||
|
|||||||
@@ -1206,6 +1206,57 @@ with patch.object(module, "check_host"), patch.object(module.urllib.request, "ur
|
|||||||
self.assertIn("mismatched", json.loads(output.getvalue())["message"])
|
self.assertIn("mismatched", json.loads(output.getvalue())["message"])
|
||||||
fetch.assert_not_called()
|
fetch.assert_not_called()
|
||||||
|
|
||||||
|
def test_model_card_upgrade_retains_verified_legacy_without_network(self):
|
||||||
|
shutil.copyfile(REPO / "python/frameyap/model_files.py",
|
||||||
|
self.stage / "python/frameyap/model_files.py")
|
||||||
|
manifest = json.loads((REPO / "assets/backends/redux.json").read_text())
|
||||||
|
old, new = b"previous attributed card", b"updated attributed model card"
|
||||||
|
def pin(content):
|
||||||
|
return {"size": len(content), "sha256": hashlib.sha256(content).hexdigest()}
|
||||||
|
manifest["model"]["files"] = [{"path": "README.md", **pin(old)}]
|
||||||
|
manifests = self.stage / "assets/backends"
|
||||||
|
manifests.mkdir()
|
||||||
|
card_manifest = manifests / "redux.json"
|
||||||
|
card_manifest.write_text(json.dumps(manifest))
|
||||||
|
archive, digest = self.package("0.1.202609241530")
|
||||||
|
self.install("0.1.202609241530", archive, digest, "--without-model")
|
||||||
|
destination = self.base / "models"
|
||||||
|
destination.mkdir()
|
||||||
|
card = destination / "README.md"
|
||||||
|
card.write_bytes(old)
|
||||||
|
manifest["model"]["files"] = [{"path": "README.md", **pin(new),
|
||||||
|
"compatible": [pin(old)]}]
|
||||||
|
card_manifest.write_text(json.dumps(manifest))
|
||||||
|
archive, digest = self.package("0.1.202609241531")
|
||||||
|
with patch.object(installer.urllib.request, "urlopen") as fetch:
|
||||||
|
self.install("0.1.202609241531", archive, digest, "--without-model")
|
||||||
|
module, backend, _ = installer.installed_backend(self.data / "frameyap", "redux")
|
||||||
|
self.assertEqual(module.check_model(backend, destination)["state"], "installed_verified")
|
||||||
|
argv = ["--install-model", "--model-dir", str(destination), "--yes", "--json"]
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
self.assertEqual(installer.cli(argv), 0)
|
||||||
|
fetch.assert_not_called()
|
||||||
|
self.assertEqual(card.read_bytes(), old)
|
||||||
|
card.write_bytes(b"x" * len(old))
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
self.assertEqual(installer.cli(argv), 1)
|
||||||
|
fetch.assert_not_called()
|
||||||
|
card.unlink()
|
||||||
|
class Response(io.BytesIO):
|
||||||
|
def geturl(self):
|
||||||
|
return "https://huggingface.co/fixture"
|
||||||
|
# Fresh downloads require the current card even though the old one is
|
||||||
|
# permitted for already installed, independently hashed model files.
|
||||||
|
with patch.object(installer.urllib.request, "urlopen", return_value=Response(old)):
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
self.assertEqual(installer.cli(argv), 1)
|
||||||
|
self.assertFalse(card.exists())
|
||||||
|
with patch.object(installer.urllib.request, "urlopen", return_value=Response(new)) as fetch:
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
self.assertEqual(installer.cli(argv), 0)
|
||||||
|
fetch.assert_called_once()
|
||||||
|
self.assertEqual(card.read_bytes(), new)
|
||||||
|
|
||||||
def test_runtime_install_is_explicit_cpu_pinned_and_updates_only_python_path(self):
|
def test_runtime_install_is_explicit_cpu_pinned_and_updates_only_python_path(self):
|
||||||
# subprocess is mocked: no venv, pip or network is touched.
|
# subprocess is mocked: no venv, pip or network is touched.
|
||||||
root = self.data / "frameyap"
|
root = self.data / "frameyap"
|
||||||
|
|||||||
@@ -74,6 +74,29 @@ class ModelFileTests(unittest.TestCase):
|
|||||||
(model / "nested").symlink_to(root)
|
(model / "nested").symlink_to(root)
|
||||||
self.assertEqual(check_model(backend, model)["reason"], "unsafe_file")
|
self.assertEqual(check_model(backend, model)["reason"], "unsafe_file")
|
||||||
|
|
||||||
|
def test_compatible_card_still_requires_exact_hash_and_safe_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as path:
|
||||||
|
root = Path(path)
|
||||||
|
_, backend = self.fixture(root)
|
||||||
|
current, previous = b"current card", b"old card"
|
||||||
|
item = ModelFile("README.md", len(current), hashlib.sha256(current).hexdigest(),
|
||||||
|
((len(previous), hashlib.sha256(previous).hexdigest()),))
|
||||||
|
backend = replace(backend, files=(item,))
|
||||||
|
card = root / "README.md"
|
||||||
|
for content in (current, previous):
|
||||||
|
card.write_bytes(content)
|
||||||
|
self.assertEqual(check_model(backend, root)["state"], "installed_verified")
|
||||||
|
# A download temp pin must never accept an older artifact.
|
||||||
|
self.assertEqual(check_file(root, ModelFile(item.path, item.size, item.sha256))[0],
|
||||||
|
"size_mismatch")
|
||||||
|
card.write_bytes(b"bad card")
|
||||||
|
self.assertEqual(check_model(backend, root)["reason"], "hash_mismatch")
|
||||||
|
card.unlink()
|
||||||
|
outside = root / "outside"
|
||||||
|
outside.write_bytes(previous)
|
||||||
|
card.symlink_to(outside)
|
||||||
|
self.assertEqual(check_model(backend, root)["reason"], "unsafe_file")
|
||||||
|
|
||||||
def test_second_backend_status_without_runtime_changes(self):
|
def test_second_backend_status_without_runtime_changes(self):
|
||||||
with tempfile.TemporaryDirectory() as path:
|
with tempfile.TemporaryDirectory() as path:
|
||||||
root = Path(path)
|
root = Path(path)
|
||||||
|
|||||||
Reference in new issue
Block a user