tsift-local-model
Local model profile, GPU probe, and lifecycle contracts for tsift Knowledge Graph extraction.
This crate owns provider-neutral local model substrate types used by future
tsift-kg, tsift-memgraphrag, and tsift-agent-doc integration. It provides
RTX 5090-aware model ranking, best-effort nvidia-smi probing, and status
reports without binding callers to Ollama, llama.cpp, or vLLM.
Lifecycle support is provider-neutral:
LocalModelLeaserecords the selected profile, lease mode, pre-load GPU probe, provider endpoint or worker pid, idle TTL, and unload strategy.build_unload_actionsdescribes provider-native cleanup hooks such as llama.cpp router unload, Ollamakeep_alive: 0orollama stop, vLLM sleep, and process-exit fallback.evaluate_vram_cleanupcompares pre-load and post-unload GPU probes and reports cleanup failure when VRAM stays above the baseline tolerance without a known non-tsift GPU process accounting for the increase.
Provider endpoint configuration (#portconf)
build_unload_actions resolves the unload/sleep endpoint with this precedence
(highest first):
- Per-call explicit override (
--provider-endpoint URLontsift local-model unload|swap). - Strategy-specific env var:
TSIFT_LLAMA_CPP_ENDPOINT(defaulthttp://127.0.0.1:8080/models/unload)TSIFT_OLLAMA_ENDPOINT(defaulthttp://127.0.0.1:11434/api/generate)TSIFT_VLLM_ENDPOINT(defaulthttp://127.0.0.1:8000/sleep)
- Compile-time default (the values above).
Blank or whitespace-only env values fall back to the compile-time default so an
empty TSIFT_LLAMA_CPP_ENDPOINT= cannot break a run.
WordPress / 8080 conflict recipe. llama-server defaults to port 8080, which collides with a local WordPress instance on the same port. To move tsift's unload calls to a different port:
# Start llama-server on an alternate port
# Point tsift at the new endpoint for the whole shell
# Verify the resolved endpoint
| \
# -> http://127.0.0.1:8081/models/unload
A single call can still override the env value with
--provider-endpoint URL. The same recipe works for Ollama (TSIFT_OLLAMA_ENDPOINT)
and vLLM (TSIFT_VLLM_ENDPOINT).
Cooperative GPU lease registry (#gctrl1)
Single-machine cooperative (no daemon) registry that tracks who currently holds a GPU-bound local model profile. Producers check the file before probing the GPU, prune stale leases, and either acquire the slot or report a conflict with the live holder.
- File location:
$TSIFT_LEASE_FILE, then$XDG_STATE_HOME/tsift/gpu-lease.json, then~/.tsift/gpu-lease.json, then./.tsift/gpu-lease.json(override on every CLI command via--lease-file). - Schema:
{ version, leases: { profile_id: [GpuLeaseRecord, ...] } }.GpuLeaseRecordrecordsholder_pid,holder_command,acquired_at_unix_seconds,lease_mode,vram_baseline_mib,idle_ttl_seconds, and free-formnotes. - Concurrency rules match
LeaseMode:Exclusive(one holder per profile),Shared(multiple holders per profile),CpuOrHash(bypass — no registry entry required). - A holder is stale when its pid is no longer alive (
kill -0) or whenidle_ttl_seconds > 0and the lease age exceeds the TTL. Stale holders are pruned on every acquire/release/show and are reclaimable by a new acquirer. - Writes are atomic (temp file +
fsync+ rename); concurrent acquires rely on cooperative file rotation. There is intentionally no advisory lock — the registry is best-effort and a stale entry just gets reclaimed on the next acquire.
Pure logic lives in apply_acquire / apply_release / prune_stale_leases,
each taking an is_alive: impl Fn(u32) -> bool closure so tests can inject a
deterministic liveness check. File-backed wrappers acquire_lease,
release_lease, and show_registry use the real is_pid_alive (which shells
out to kill -0).
CLI surface: tsift local-model lease acquire|release|show. acquire --strict
exits non-zero on conflict so caller scripts can fail closed.
Per-call profile preference (#gctrl2)
ProfilePreference lets a single call pin or downgrade the local model
without mutating global state. It is the per-call dial an agent-doc cycle uses
to request a small/hash profile during low-stakes phases of a long run.
ProfilePreference::Auto— rank by free VRAM (existing behavior).ProfilePreference::Pinned(id)— pin to a specific profile id. The resolver still checks VRAM fit; if the pinned profile does not fit or does not support the requested role, the resolver falls back to the hash profile and reportsPinnedUnselectable.ProfilePreference::ForceHash— force the deterministic CPU/hash fallback even when a GPU profile would fit. Shortcut on the CLI:--profile hash.
resolve_profile_preference(preference, role, probe) -> ProfileResolution
returns the chosen profile, whether it is selectable, a ProfileResolutionSource
(auto_ranked / pinned / pinned_unselectable / forced_hash), and a
human-readable reason. The hash profile is always a guaranteed-selectable
fallback so a call never has to abort because of a bad pin.
CLI surface:
tsift local-model resolve [--profile <id|hash>] [--role extract|embed|rerank] [--no-probe] [--json]— the decision seam. Callers run this once to learn which profile a subsequent command would use.tsift semantic --profile <id|hash>andtsift summarize --extract --profile <id|hash>— informational plumbing. The resolved preference is recorded as a warning note on the response envelope (profile preference <kind> -> <id> (<reason>)) so a future provider seam can consume it. Until that seam lands, calls still use the existing cached/hash code paths.
Profile swap lifecycle (#gctrl3)
tsift local-model swap --from <id> --to <id> is the one-command mid-run
downgrade path. It combines a source unload cleanup proof with a target
profile resolution against the post-unload probe, so a caller can decide in
one step whether it is safe to load the next profile (typically
qwen3-32b-q4 → qwen3-embedding-0.6b or the hash fallback) without
orchestrating unload + status separately.
SwapStatus captures the combined outcome:
Swapped— source unload cleanup proven AND target profile fits the post-unload probe.SwappedToHash— target is the CPU/hash profile; permitted once unload is proven.UnloadProvenTargetUnselectable— unload ok but target does not fit; caller should pick a smaller profile or hash.UnloadNotProven— source unload cleanup NOT proven; caller MUST NOT load the target. The report'snotesincludeDO NOT load target — source unload did not prove VRAM cleanup.NoOpSameProfile— source and target are the same id.
Lease coordination stays the caller's job — they hold the holder-pid context
and can chain tsift local-model lease release --profile <from> → swap →
tsift local-model lease acquire --profile <to> in a script.
CLI surface: tsift local-model swap --from <id> --to <id> [--provider-endpoint URL] [--provider-pid PID] [--idle-ttl-seconds S] [--no-probe] [--pre-used-mib M] [--post-used-mib M] [--tolerance-mib M] [--strict] [--json]. --strict exits non-zero on UnloadNotProven or UnloadProvenTargetUnselectable so caller scripts fail closed.