Skip to content
22 changes: 6 additions & 16 deletions cactus-engine/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,17 @@ while [[ $# -gt 0 ]]; do
esac
done

resolve_bundle_dir() {
local model="$1" label="$2"
if [ -z "$model" ]; then
echo "Error: --$label is required. Run engine tests via the CLI (cactus test ...) which sets it." >&2
require_bundle() {
local dir="$1" label="$2"
if [ -z "$dir" ] || [ ! -f "$dir/components/manifest.json" ]; then
echo "Error: --$label must point to a prepared bundle. Run engine tests via 'cactus test', which prepares them." >&2
exit 2
fi
local dir
if [[ "$model" == /* || "$model" == ./* ]]; then
dir="$model"
else
dir="$PROJECT_ROOT/weights/$(basename "$model" | tr '[:upper:]' '[:lower:]')"
fi
if [ ! -f "$dir/components/manifest.json" ]; then
echo "Bundle missing at $dir. Run: cactus download $model (or: cactus convert $model && cactus transpile $model)" >&2
exit 1
fi
echo "$dir"
}

BUNDLE_DIR="$(resolve_bundle_dir "$CACTUS_TEST_MODEL" "model")"
TRANSCRIPTION_BUNDLE_DIR="$(resolve_bundle_dir "$CACTUS_TEST_TRANSCRIPTION_MODEL" "transcription-model")"
BUNDLE_DIR="$(require_bundle "$CACTUS_TEST_MODEL" "model")"
TRANSCRIPTION_BUNDLE_DIR="$(require_bundle "$CACTUS_TEST_TRANSCRIPTION_MODEL" "transcription-model")"

if [ "$IOS_MODE" = true ]; then
export CACTUS_TEST_MODEL="$BUNDLE_DIR" CACTUS_TEST_TRANSCRIPTION_MODEL="$TRANSCRIPTION_BUNDLE_DIR" CACTUS_TEST_SUITE="$SUITE"
Expand Down
60 changes: 33 additions & 27 deletions python/cactus/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,43 @@ def create_parser():
--token <token> HuggingFace token (gated models)
--reconvert force reconversion from source

cactus download [model] download a pre-built bundle (default: {DEFAULT_MODEL_ID})
cactus download [model] fetch a prebuilt bundle, else build locally (default: {DEFAULT_MODEL_ID})
--bits 1|2|3|4 CQ quantization (default: 4)
--platform {_PLATFORM_PIPE:<22} target accelerator (default: auto)
--token <token> HuggingFace token

cactus serve [model] OpenAI-compatible local HTTP server
--host <addr> bind address (default: 127.0.0.1)
--port <port> port (default: 8080)

cactus list list local converted weights and bundles

cactus build build cactus libraries
--apple Apple (iOS/macOS)
--android Android
--python shared lib for Python FFI

cactus test run the test suite
--component <name> kernels | graph | engine | all
(default: all)
--model <hf-id> default: {DEFAULT_TEST_MODEL_ID}
--transcription-model <hf-id> default: {DEFAULT_TEST_TRANSCRIPTION_MODEL_ID}
--suite <name> run a single test suite from any
component (kernels, graph, or engine)
--list list components and suites
--ios run on connected iPhone
--android run on connected Android
--enable-telemetry send cloud telemetry (off by default)

cactus clean delete build artifacts, weights, venv
cactus --help show this help

-----------------------------------------------------------------

Advanced / build pipeline — the two manual steps below run
automatically inside run, serve, transcribe and download; reach for
them only to control the build (custom flags, LoRA, NPU, debugging):

cactus convert <model> [dir] convert HuggingFace weights to CQ
--bits 1|2|3|4 CQ quantization (default: 4)
--token <token> HuggingFace token
Expand Down Expand Up @@ -154,32 +186,6 @@ def create_parser():
--npu-audio-quantize 0|4|8 audio encoder quant (default int8)
--npu-vision-quantize 0|4|8 vision encoder quant (default fp16)

cactus serve [model] OpenAI-compatible local HTTP server
--host <addr> bind address (default: 127.0.0.1)
--port <port> port (default: 8080)

cactus list list local converted weights and bundles

cactus build build cactus libraries
--apple Apple (iOS/macOS)
--android Android
--python shared lib for Python FFI

cactus test run the test suite
--component <name> kernels | graph | engine | all
(default: all)
--model <hf-id> default: {DEFAULT_TEST_MODEL_ID}
--transcription-model <hf-id> default: {DEFAULT_TEST_TRANSCRIPTION_MODEL_ID}
--suite <name> run a single test suite from any
component (kernels, graph, or engine)
--list list components and suites
--ios run on connected iPhone
--android run on connected Android
--enable-telemetry send cloud telemetry (off by default)

cactus clean delete build artifacts, weights, venv
cactus --help show this help

-----------------------------------------------------------------
"""
)
Expand Down
9 changes: 5 additions & 4 deletions python/cactus/cli/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from pathlib import Path

from .common import (
BLUE, GREEN, RED, YELLOW,
BLUE, GREEN, RED,
SUPPORTED_PLATFORMS,
print_color, weights_root,
)
Expand Down Expand Up @@ -74,11 +74,12 @@ def download_bundle(model_id: str, *, bits: int = 4, platform: str | None = None


def cmd_download(args) -> int:
from .model import ensure_runnable_bundle

try:
platform = resolve_platform(args.platform)
download_bundle(args.model_id, bits=args.bits, platform=platform, token=args.token)
ensure_runnable_bundle(args.model_id, bits=args.bits, platform=platform, token=args.token)
return 0
except (RuntimeError, OSError, ValueError) as e:
print_color(RED, f"Download failed: {e}")
print_color(YELLOW, f"Try: cactus convert {args.model_id} --bits {args.bits} && cactus transpile {args.model_id}")
print_color(RED, f"Failed to prepare {args.model_id}: {e}")
return 1
51 changes: 46 additions & 5 deletions python/cactus/cli/list.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import itertools
import stat as _stat
from pathlib import Path
import struct

from .common import BLUE, CYAN, print_color, transpiled_root, weights_root

_PREC_TO_BITS = {3: 1, 4: 2, 5: 3, 6: 4}


def _read_config(path):
pairs = (
Expand All @@ -20,6 +23,44 @@ def _human_size(n):
n /= 1024


def _cq_precision(weights_file):
"""Read the precision code at byte offset 48 of a Cactus tensor header."""
try:
with weights_file.open("rb") as f:
head = f.read(52)
except OSError:
return None
if len(head) < 52 or head[:4] != b"CACT":
return None
return struct.unpack_from("<I", head, 48)[0]


def _quant_label(model_dir, *, sample_cap=64, scan_cap=2000):
"""Infer a model's CQ quantization level from its tensor headers.

Scans `.weights` files (top-level converted weights, then nested
bundle components) and returns the dominant CQ level, e.g. "CQ4".
Returns "—" if no CQ-quantized tensors are present.
"""
candidates = itertools.chain(
model_dir.glob("*.weights"),
(model_dir / "components").rglob("*.weights"),
)
counts = {}
scanned = cq_hits = 0
for wf in candidates:
scanned += 1
bits = _PREC_TO_BITS.get(_cq_precision(wf))
if bits is not None:
counts[bits] = counts.get(bits, 0) + 1
cq_hits += 1
if cq_hits >= sample_cap or scanned >= scan_cap:
break
if not counts:
return "—"
return f"CQ{max(counts, key=counts.get)}"


def _dir_size(path):
total = 0
for f in path.rglob("*"):
Expand All @@ -41,7 +82,7 @@ def _categorize(roots):
if not p.is_dir() or not (p / "config.txt").is_file():
continue
cfg = _read_config(p / "config.txt")
entry = (p, cfg.get("model_type", "?"), _dir_size(p))
entry = (p, cfg.get("model_type", "?"), _quant_label(p), _dir_size(p))
if (p / "components" / "manifest.json").is_file():
transpiled.append(entry)
else:
Expand All @@ -54,9 +95,9 @@ def _print_section(title: str, color: str, entries):
if not entries:
print(" (none)")
return
width = max(len(p.name) for p, _, _ in entries)
for p, model_type, size in entries:
print(f" {p.name:<{width}} {model_type:<12} {_human_size(size):>10} {p.parent}")
width = max(len(p.name) for p, _, _, _ in entries)
for p, model_type, quant, size in entries:
print(f" {p.name:<{width}} {model_type:<12} {quant:<5} {_human_size(size):>10} {p.parent}")


def cmd_list(_args):
Expand Down
29 changes: 29 additions & 0 deletions python/cactus/cli/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,35 @@ class TranspileOptions:
cache_context_length: str | int | None = None


def ensure_runnable_bundle(model_id, *, bits=4, platform=None, token=None,
reconvert=False, transpile=None):
"""Resolve a runnable bundle via the full fallback ladder, building if needed.

Rungs, in order: (1) a local bundle path, (2) a cached prior build,
(3) a prebuilt bundle on HuggingFace, (4) local convert + transpile.
Raises RuntimeError if every rung fails.
"""
from .download import download_bundle, get_bundle_dir

local = resolve_bundle_dir(model_id)
if local is not None:
return local

cached = get_bundle_dir(model_id, bits=bits, platform=platform)
if _has_transpiled_bundle(cached) and not reconvert:
return cached

if not reconvert:
try:
return download_bundle(model_id, bits=bits, platform=platform,
token=token, reconvert=reconvert)
except (RuntimeError, OSError) as exc:
print_color(YELLOW, f"No prebuilt bundle ({exc}); building locally")

return ensure_bundle(model_id, bits=bits, token=token,
reconvert=reconvert, transpile=transpile or TranspileOptions())


def ensure_bundle(model_id, *, bits=4, token=None,
reconvert=False, output_dir=None, transpile=None):
from .download import get_weights_dir
Expand Down
52 changes: 14 additions & 38 deletions python/cactus/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import subprocess
from pathlib import Path

from .common import GREEN, RED, YELLOW, apply_cloud_api_key_env, print_color, resolve_binary
from .common import GREEN, RED, apply_cloud_api_key_env, print_color, resolve_binary


def cmd_run(args) -> int:
Expand All @@ -19,13 +19,20 @@ def cmd_run(args) -> int:
if args.result_json:
args.result_json = str(Path(args.result_json).expanduser())

from .model import TranspileOptions, ensure_runnable_bundle

platform = resolve_platform(args.platform)
bundle_dir = _resolve_or_fetch_bundle(
args.model_id, bits=args.bits, platform=platform,
token=args.token, reconvert=args.reconvert,
image=args.image, audio=args.audio,
)
if bundle_dir is None:
try:
bundle_dir = ensure_runnable_bundle(
args.model_id, bits=args.bits, platform=platform,
token=args.token, reconvert=args.reconvert,
transpile=TranspileOptions(
image_files=[args.image] if args.image else None,
audio_file=args.audio,
),
)
except RuntimeError as exc:
print_color(RED, f"Model setup failed: {exc}")
return 1

binary = resolve_binary("run")
Expand Down Expand Up @@ -55,34 +62,3 @@ def cmd_run(args) -> int:
print_color(GREEN, f"Running: {bundle_dir}")
print()
return subprocess.run(cmd).returncode


def _resolve_or_fetch_bundle(model_id, *, bits, platform, token, reconvert, image, audio):
from .download import download_bundle, get_bundle_dir
from .model import TranspileOptions, ensure_bundle, resolve_bundle_dir

local = resolve_bundle_dir(model_id)
if local is not None:
return local

cached = get_bundle_dir(model_id, bits=bits, platform=platform)
if (cached / "components" / "manifest.json").exists() and not reconvert:
return cached

if not reconvert:
try:
return download_bundle(model_id, bits=bits, platform=platform, token=token, reconvert=reconvert)
except (RuntimeError, OSError) as exc:
print_color(YELLOW, f"HF download unavailable ({exc}); building locally")

try:
return ensure_bundle(
model_id, bits=bits, token=token, reconvert=reconvert,
transpile=TranspileOptions(
image_files=[image] if image else None,
audio_file=audio,
),
)
except RuntimeError as exc:
print_color(RED, f"Model setup failed: {exc}")
return None
10 changes: 7 additions & 3 deletions python/cactus/cli/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,13 @@ def cmd_serve(args):
"""Start the OpenAI-compatible HTTP server."""
model_path, model_name = _resolve_model_arg(args.model)
if args.model and model_path is None:
print_color(RED, f"Error: model not found: {args.model}")
print("Prepare a v2 bundle first with `cactus run <model>` (or `cactus convert <model>` then `cactus transpile <model>`).")
return 1
from .model import ensure_runnable_bundle
try:
built = ensure_runnable_bundle(args.model)
except RuntimeError as exc:
print_color(RED, f"Error: could not prepare {args.model}: {exc}")
return 1
model_path, model_name = built, built.name
if model_path is not None and not _is_valid_bundle(model_path):
print_color(RED, f"Error: not a valid v2 Cactus bundle: {model_path}")
print("Expected config.txt and components/manifest.json.")
Expand Down
Loading
Loading