diff --git a/crates/searchi-semantic/native/CMakeLists.txt b/crates/searchi-semantic/native/CMakeLists.txt index c2b3b40..133450f 100644 --- a/crates/searchi-semantic/native/CMakeLists.txt +++ b/crates/searchi-semantic/native/CMakeLists.txt @@ -45,6 +45,7 @@ if(SEARCHI_ACCELERATION STREQUAL "cpu") set(GGML_METAL OFF CACHE BOOL "CPU test build" FORCE) elseif(SEARCHI_ACCELERATION STREQUAL "metal" OR (SEARCHI_ACCELERATION STREQUAL "auto" AND APPLE)) set(GGML_METAL ON CACHE BOOL "Production Metal backend" FORCE) + set(GGML_METAL_EMBED_LIBRARY ON CACHE BOOL "Standalone worker contains Metal kernels" FORCE) elseif(SEARCHI_ACCELERATION STREQUAL "cuda") set(GGML_CUDA ON CACHE BOOL "Production CUDA backend" FORCE) elseif(SEARCHI_ACCELERATION STREQUAL "vulkan") diff --git a/docs/BUILDING.md b/docs/BUILDING.md index fd324e3..4272787 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -8,7 +8,11 @@ Windows needs Visual Studio C++ build tools, a Windows SDK, and WebView2. Linux desktop builds need WebKitGTK 4.1 development libraries; macOS needs Xcode command-line tools. Follow the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). Headless Linux CLI/worker builds do not need WebKitGTK or a display server. -They still need a C/C++ compiler, linker, CMake, and Rust: SQLite, USearch, + +macOS runtime preparation embeds Metal kernel sources and inspects the static +worker's Apple dependencies before staging. See [macOS runtime](MACOS-RUNTIME.md) +for the prepared workflow and explicitly unexecuted native checks. +Headless builds still need a C/C++ compiler, linker, CMake, and Rust: SQLite, USearch, NumKong, and the Google worker contain native code. ## Windows CPU development diff --git a/docs/MACOS-RUNTIME.md b/docs/MACOS-RUNTIME.md new file mode 100644 index 0000000..dd873f5 --- /dev/null +++ b/docs/MACOS-RUNTIME.md @@ -0,0 +1,49 @@ +# macOS runtime preparation + +Normal macOS `auto` builds select Metal with CPU fallback. The worker links +llama/ggml statically and uses Apple system libraries/frameworks. Searchi now +forces `GGML_METAL_EMBED_LIBRARY=ON` so upstream Metal kernel sources reside +inside the worker rather than requiring loose kernel files from the build tree. + +After compilation, the builder inspects the artifact using Apple `lipo` and +`otool`; it does not execute the worker or initialize a GPU. Inspection requires: + +- Supported arm64/x86_64 architectures, including both for a universal artifact. +- Only explicitly allowed Apple system libraries/frameworks, including Metal. +- A nonempty `__DATA,__ggml_metallib` section for each architecture. +- Current build-input provenance and a SHA-256 of the resulting worker. + +The generated inventory declares the `static-metal-embedded` layout with an +empty packaged-library list. Normal macOS staging now requires this inventory; +the previous optional-inventory exception is removed for `auto`/`metal`. +Staging verifies source identity, worker bytes, Mach-O magic and the static +layout instead of requiring Windows DLLs or Linux shared libraries. Explicit +internal CPU tooling keeps its optional-inventory behavior. + +On a native Mac with the required Apple build tools: + +```sh +python3 tools/embeddinggemma2/build_runtime.py --backend auto --jobs 2 +python3 tools/embeddinggemma2/stage_runtime.py --backend auto +``` + +These commands are prepared, **not executed on macOS**. Native compilation, +actual `lipo`/`otool` output, relocation outside the source tree, genuine Google +CPU/Metal inference, device fallback, app integration and signing/notarization +remain open. This change does not create a macOS release download. + +## Evidence, 2026-10-13 + +Four controlled Mach-O metadata-fixture tests passed, covering static system +dependencies, foreign library rejection, missing Metal data and per-architecture +section coverage. Staging tests exercise the empty-library macOS layout and the +mandatory normal-profile policy. These fixtures are not executable Mac builds. + +Across macOS metadata, staging, builder, provenance and Windows/Linux packaging +suites: 75 tests passed with one platform-specific skip, in normal and optimized +Python modes. Actual Windows and Ubuntu WSL normal runtime builds passed after +the recipe change. Windows staging and Linux inventory verification passed. +Worker hashes remain identical to their previously tested CPU deployments: +Windows `c01cb3ec13cfca722ee768e8350e1399a8b9a4d14a24a35adc3473a5cfc82a73`; +Linux `b52d50a1e676bd4bf64124daff1c4ce83d86a8bde01e4eaf09838cbb24a4c28f`. +No GPU inference or native macOS execution was performed. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1085e96..9315331 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -122,6 +122,10 @@ release. Keep their evidence and unchecked production gates intact. - [x] Normal Windows/Linux source-build provenance gate before staging and archive preparation; stale inventories preserve existing resources/packages. Actual normal builds verified. See [runtime provenance](RUNTIME-PROVENANCE.md). +- [x] Prepare static macOS Metal inventory and mandatory normal-profile staging, + with embedded-source/dependency/architecture metadata regressions. +- [ ] Execute native macOS runtime build, relocation and genuine CPU/Metal + inference; fixture metadata tests do not satisfy this gate. - [ ] Verify power-loss durability and release-tool interaction with resource directory swaps on each OS; fault-injected process interruption is narrower. - [x] Shared monotonic Basic/Debug search metrics, granular bounded engine traces, diff --git a/docs/RUNTIME-PROVENANCE.md b/docs/RUNTIME-PROVENANCE.md index e883724..66092a6 100644 --- a/docs/RUNTIME-PROVENANCE.md +++ b/docs/RUNTIME-PROVENANCE.md @@ -7,8 +7,8 @@ release preparation now records and checks build-input identity before staging or packaging a normal Windows/Linux runtime. `runtime-libraries.json` includes `build_provenance`, schema 1, the pinned -llama.cpp commit and SHA-256 of six bounded inputs: worker.cpp, native -CMakeLists.txt, build_runtime.py and the three runtime patches. Only CRLF line +llama.cpp commit and SHA-256 of seven bounded inputs: worker.cpp, native +CMakeLists.txt, build_runtime.py, the macOS artifact inspector and the three runtime patches. Only CRLF line endings in these build inputs are canonicalized to LF, allowing the same source revision to compare across checkouts. Indexed user-file bytes and their extraction provenance are unaffected. @@ -29,8 +29,8 @@ check and were not altered retroactively. ## Recorded evidence, 2026-10-13 -- 69 tests passed across provenance, builder, staging, Windows packaging and - Linux packaging suites; one platform-specific staging test remained skipped. +- 75 tests passed across provenance, builder, macOS metadata, staging, Windows + packaging and Linux packaging suites; one platform-specific staging test remained skipped. The same suites passed under optimized Python. Regressions reject a valid old inventory without provenance, changed source and oversized inputs, while preserving existing archives. Build-source CRLF/LF equivalence is checked. @@ -39,17 +39,18 @@ check and were not altered retroactively. `c01cb3ec13cfca722ee768e8350e1399a8b9a4d14a24a35adc3473a5cfc82a73`; all 25 Windows DLL hashes also match the previously tested release exactly. New staged inventory SHA-256: - `7fde510984b4635ce127d25ad91aab9a391300d7968ac71e4f201cc8edd052e8`. +`b3859169d2c21ee5d40cdca038c3494f5620fe544adb2dd404d4535c89b5076b`. - An actual historical extracted Windows inventory was rejected by normal staging preflight for missing provenance. Its files were only read. - Actual normal Linux `auto` build passed on Ubuntu 24.04 WSL. The packager - verified 21 runtime inventory/files and all six source identities against the + verified 21 runtime inventory/files and all seven source identities against the current repository, including checkout line-ending differences. Worker SHA-256 remains `b52d50a1e676bd4bf64124daff1c4ce83d86a8bde01e4eaf09838cbb24a4c28f`. New inventory SHA-256: - `de6e06c68815b35d83c6acbde468d100413f47c74536f0d4219a9edcbeb01a86`. + `6463ae8a126f8de3ca4349bbb02fd36852f30222fe30e11fb4070385ce1989e8`. These normal builds retain GPU modules plus CPU fallback; no GPU inference was -executed. macOS inventory integration, cross-compilation and actual GPU checks +executed. macOS inventory preparation is now implemented and fixture-tested; +[native macOS checks](MACOS-RUNTIME.md), cross-compilation and actual GPU checks remain open. Published Windows and Linux assets retain their original hashes. diff --git a/tools/embeddinggemma2/build_runtime.py b/tools/embeddinggemma2/build_runtime.py index f878eee..d882bd5 100644 --- a/tools/embeddinggemma2/build_runtime.py +++ b/tools/embeddinggemma2/build_runtime.py @@ -10,6 +10,7 @@ import shutil import subprocess import sys from runtime_provenance import fingerprint, UPSTREAM_COMMIT +from macos_runtime import inspect as inspect_macos ROOT = Path(__file__).resolve().parents[2] TAG = "b11461" @@ -215,6 +216,15 @@ def main(): os.replace(temporary, directory / "runtime-libraries.json") if linux_dispatch(args.backend): bundle_linux_libraries(build, provenance) + if sys.platform == "darwin" and args.backend in {"auto", "metal"}: + worker = build / "searchi-gemma-worker" + inventory = inspect_macos(worker) + with worker.open("rb") as stream: + inventory["worker_sha256"] = hashlib.file_digest(stream, "sha256").hexdigest() + inventory["build_provenance"] = provenance + temporary = build / "runtime-libraries.json.partial" + temporary.write_text(json.dumps(inventory, indent=2), encoding="utf-8") + os.replace(temporary, build / "runtime-libraries.json") print(f"Runtime build complete: {build}") diff --git a/tools/embeddinggemma2/macos_runtime.py b/tools/embeddinggemma2/macos_runtime.py new file mode 100644 index 0000000..a698824 --- /dev/null +++ b/tools/embeddinggemma2/macos_runtime.py @@ -0,0 +1,69 @@ +"""Inspect a static Metal worker using Apple's tools, without executing it.""" +import re +import subprocess +from pathlib import Path + +MAGIC = {b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf", b"\xca\xfe\xba\xbe", + b"\xbe\xba\xfe\xca", b"\xca\xfe\xba\xbf", b"\xbf\xba\xfe\xca"} +SYSTEM_LIBRARIES = {"/usr/lib/libSystem.B.dylib", "/usr/lib/libc++.1.dylib", + "/usr/lib/libc++abi.dylib", "/usr/lib/libobjc.A.dylib"} +FRAMEWORKS = {"Foundation", "CoreFoundation", "Metal", "MetalKit", "Accelerate"} + + +def system_dependency(name): + if name in SYSTEM_LIBRARIES: + return True + return any(re.fullmatch(rf"/System/Library/Frameworks/{framework}\.framework/Versions/[A-Z]/{framework}", name) + for framework in FRAMEWORKS) + + +def validate_inventory(manifest, worker): + with Path(worker).open("rb") as stream: + if stream.read(4) not in MAGIC: + raise RuntimeError("macOS worker is not a Mach-O artifact") + arches = manifest.get("architectures") + dependencies = manifest.get("system_libraries") + if (manifest.get("platform") != "macos" or manifest.get("layout") != "static-metal-embedded" or manifest.get("libraries") != [] + or manifest.get("metal_embedded") is not True + or not isinstance(arches, list) or not 1 <= len(arches) <= 2 + or not all(isinstance(arch, str) for arch in arches) + or len(set(arches)) != len(arches) or not set(arches) <= {"arm64", "x86_64"} + or not isinstance(dependencies, list) or not 1 <= len(dependencies) <= 32 + or not all(isinstance(name, str) and system_dependency(name) for name in dependencies) + or not any("/Metal.framework/" in name for name in dependencies)): + raise RuntimeError("Invalid static Metal runtime inventory") + + +def inspect(worker): + worker = Path(worker) + + def tool(arguments): + try: + output = subprocess.check_output(arguments, text=True, timeout=30) + except FileNotFoundError as error: + raise RuntimeError("macOS runtime inspection requires Apple lipo/otool") from error + if len(output.encode("utf-8")) > 65536: + raise RuntimeError("macOS metadata exceeds inspection limit") + return output + + arches = tool(["lipo", "-archs", str(worker)]).split() + dependencies = [] + for line in tool(["otool", "-L", str(worker)]).splitlines(): + if not line.strip(): + continue + if line == f"{worker}:" or line in {f"{worker} (architecture {arch}):" for arch in arches}: + continue + match = re.fullmatch(r"\s+(.+?) \(compatibility version [0-9.]+, current version [0-9.]+\)", line) + if not match: + raise RuntimeError("Unrecognized Mach-O dependency metadata") + if match[1] not in dependencies: + dependencies.append(match[1]) + sections = tool(["otool", "-l", str(worker)]) + sizes = re.findall(r"sectname\s+__ggml_metallib\s+segname\s+__DATA\s+addr\s+0x[0-9a-fA-F]+\s+size\s+(0x[0-9a-fA-F]+)", sections) + if len(sizes) != len(arches) or not all(int(size, 16) > 0 for size in sizes): + raise RuntimeError("Each Mach-O architecture must contain nonempty embedded Metal kernels") + result = {"platform": "macos", "layout": "static-metal-embedded", + "architectures": arches, "system_libraries": sorted(dependencies), + "metal_embedded": True, "libraries": []} + validate_inventory(result, worker) + return result diff --git a/tools/embeddinggemma2/runtime_provenance.py b/tools/embeddinggemma2/runtime_provenance.py index 6c8b356..acf1c41 100644 --- a/tools/embeddinggemma2/runtime_provenance.py +++ b/tools/embeddinggemma2/runtime_provenance.py @@ -8,6 +8,7 @@ INPUTS = ( "crates/searchi-semantic/native/worker.cpp", "crates/searchi-semantic/native/CMakeLists.txt", "tools/embeddinggemma2/build_runtime.py", + "tools/embeddinggemma2/macos_runtime.py", "tools/embeddinggemma2/no-logits.patch", "tools/embeddinggemma2/cpu-dispatch.patch", "tools/embeddinggemma2/cpu-only-loader.patch", diff --git a/tools/embeddinggemma2/stage_runtime.py b/tools/embeddinggemma2/stage_runtime.py index 5edf24b..1cd4fde 100644 --- a/tools/embeddinggemma2/stage_runtime.py +++ b/tools/embeddinggemma2/stage_runtime.py @@ -11,11 +11,17 @@ import stat import time import uuid from runtime_provenance import verify as verify_provenance +from macos_runtime import validate_inventory as validate_macos ROOT = Path(__file__).resolve().parents[2] CRT_LIBRARIES = {"concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll", "msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll", "vccorlib140.dll", "vcruntime140.dll", "vcruntime140_1.dll", "vcruntime140_threads.dll"} +def inventory_required(backend, windows, system): + return ((windows or system.startswith("linux")) and backend in {"auto", "vulkan"} + or system == "darwin" and backend in {"auto", "metal"}) + + def library_name(name): return isinstance(name, str) and bool(re.fullmatch( r"(?:[A-Za-z0-9_-]+\.dll|lib[A-Za-z0-9_-]+\.so(?:\.\d+)*)", name)) @@ -349,7 +355,8 @@ def verify_runtime(source, required=False): if hashlib.file_digest(stream, "sha256").hexdigest() != manifest.get("worker_sha256"): raise RuntimeError("Runtime worker checksum mismatch") records = manifest.get("libraries") - if not isinstance(records, list) or not 1 <= len(records) <= 64: + macos = manifest.get("platform") == "macos" + if not isinstance(records, list) or not (0 if macos else 1) <= len(records) <= 64: raise RuntimeError("Invalid runtime dependency inventory") paths, names = [], set() for record in records: @@ -373,9 +380,13 @@ def verify_runtime(source, required=False): raise RuntimeError(f"Runtime library checksum mismatch: {name}") paths.append(artifact) if required: - if (source.suffix == ".exe") == (manifest.get("platform") == "linux"): + platform = manifest.get("platform") + if (source.suffix == ".exe" and platform not in {None, "windows"}) or (source.suffix != ".exe" and platform not in {"linux", "macos"}): raise RuntimeError("Runtime inventory platform does not match worker") - if manifest.get("platform") == "linux": + if macos: + validate_macos(manifest, source) + complete = not names and source.suffix != ".exe" + elif manifest.get("platform") == "linux": core = {"ggml", "ggml-base", "llama", "mtmd"} present = {name for name in core if any(re.fullmatch(rf"lib{re.escape(name)}\.so(?:\.\d+)*", file) for file in names)} complete = present == core and {"libggml-cpu-x64.so", "libggml-vulkan.so"}.issubset(names) @@ -421,7 +432,7 @@ def main(): license_path = ROOT / ".searchi/references/llama.cpp/LICENSE" if not license_path.is_file(): raise RuntimeError("Native worker license is missing") - required_inventory = (os.name == "nt" or os.sys.platform.startswith("linux")) and args.backend in {"auto", "vulkan"} + required_inventory = inventory_required(args.backend, os.name == "nt", os.sys.platform) dependencies = verify_runtime(source, required_inventory) crt_notice = source.parent / "MSVC-REDISTRIBUTION.txt" if os.name == "nt" and args.backend != "cpu": diff --git a/tools/embeddinggemma2/test_macos_runtime.py b/tools/embeddinggemma2/test_macos_runtime.py new file mode 100644 index 0000000..9aa6429 --- /dev/null +++ b/tools/embeddinggemma2/test_macos_runtime.py @@ -0,0 +1,50 @@ +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import macos_runtime + + +class MacRuntimeTests(unittest.TestCase): + def inspect_fixture(self, dependency=None, sections=1, arches="arm64"): + with tempfile.TemporaryDirectory(prefix="mac metadata ") as directory: + worker = Path(directory) / "searchi-gemma-worker" + worker.write_bytes(b"\xcf\xfa\xed\xfe" + b"metadata fixture, not executable") + libraries = f"{worker}:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 100.0.0)\n" + libraries += "\t" + (dependency or "/System/Library/Frameworks/Metal.framework/Versions/A/Metal") + " (compatibility version 1.0.0, current version 100.0.0)\n" + loads = "sectname __ggml_metallib\nsegname __DATA\naddr 0x1000\nsize 0x100\n" * sections + with patch.object(macos_runtime.subprocess, "check_output", side_effect=[arches, libraries, loads]): + return macos_runtime.inspect(worker) + + def test_static_system_metal_inventory_has_no_packaged_dylibs(self): + result = self.inspect_fixture() + self.assertEqual(result["architectures"], ["arm64"]) + self.assertEqual(result["libraries"], []) + self.assertTrue(result["metal_embedded"]) + + def test_external_library_and_missing_metal_are_rejected(self): + for dependency in ("/private/build path/libggml.dylib", "/usr/lib/libc++.1.dylib"): + with self.subTest(dependency=dependency), self.assertRaises(RuntimeError): + self.inspect_fixture(dependency=dependency) + + def test_all_architectures_require_embedded_sources(self): + with self.assertRaisesRegex(RuntimeError, "Each Mach-O architecture"): + self.inspect_fixture(sections=0) + with self.assertRaisesRegex(RuntimeError, "Each Mach-O architecture"): + self.inspect_fixture(sections=1, arches="arm64 x86_64") + self.assertEqual(self.inspect_fixture(sections=2, arches="arm64 x86_64")["architectures"], ["arm64", "x86_64"]) + + def test_malformed_inventory_and_foreign_artifact_fail(self): + with tempfile.TemporaryDirectory() as directory: + worker = Path(directory) / "worker" + worker.write_bytes(b"\xcf\xfa\xed\xfe") + with self.assertRaises(RuntimeError): + macos_runtime.validate_inventory({"architectures": [["arm64"]]}, worker) + worker.write_bytes(b"MZxx") + with self.assertRaisesRegex(RuntimeError, "Mach-O"): + macos_runtime.validate_inventory({}, worker) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/embeddinggemma2/test_stage_runtime.py b/tools/embeddinggemma2/test_stage_runtime.py index ab603da..976c9cf 100644 --- a/tools/embeddinggemma2/test_stage_runtime.py +++ b/tools/embeddinggemma2/test_stage_runtime.py @@ -13,6 +13,29 @@ import runtime_provenance class StagingTests(unittest.TestCase): + def test_normal_macos_requires_inventory_and_internal_cpu_remains_optional(self): + self.assertTrue(stage_runtime.inventory_required("auto", False, "darwin")) + self.assertTrue(stage_runtime.inventory_required("metal", False, "darwin")) + self.assertFalse(stage_runtime.inventory_required("cpu", False, "darwin")) + self.assertTrue(stage_runtime.inventory_required("auto", True, "win32")) + self.assertTrue(stage_runtime.inventory_required("auto", False, "linux")) + def test_static_macos_inventory_accepts_no_dynamic_libraries(self): + with tempfile.TemporaryDirectory() as directory: + worker = Path(directory) / "searchi-gemma-worker" + worker.write_bytes(b"\xcf\xfa\xed\xfe" + b"metadata fixture, not executable") + manifest = {"platform": "macos", "layout": "static-metal-embedded", + "architectures": ["arm64"], "metal_embedded": True, "libraries": [], + "system_libraries": ["/usr/lib/libSystem.B.dylib", "/System/Library/Frameworks/Metal.framework/Versions/A/Metal"], + "worker_sha256": hashlib.sha256(worker.read_bytes()).hexdigest(), + "build_provenance": runtime_provenance.fingerprint()} + inventory = worker.parent / "runtime-libraries.json" + inventory.write_text(json.dumps(manifest)) + self.assertEqual(stage_runtime.verify_runtime(worker, True), [inventory]) + manifest["metal_embedded"] = False + inventory.write_text(json.dumps(manifest)) + with self.assertRaises(RuntimeError): + stage_runtime.verify_runtime(worker, True) + def test_linux_inventory_stages_core_and_modules_and_removes_previous_owned_library(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory)