diff --git a/.aiwg/reports/doc-sync-20260822T172000Z.md b/.aiwg/reports/doc-sync-20260822T172000Z.md new file mode 100644 index 0000000..e0e07d3 --- /dev/null +++ b/.aiwg/reports/doc-sync-20260822T172000Z.md @@ -0,0 +1,30 @@ +# Documentation-code sync report + +- Date: 2026-08-22 +- Direction: code-to-docs +- Scope: coherence verification, local CUDA setup, OpenBao runtime credentials, developer launch +- Mode: incremental + +## Reconciled drift + +1. Documented the deterministic semantic-anchor coherence scorer and its corpus-contamination rejection. +2. Corrected CUDA operator commands to use `.venv/bin/python` after the CUDA wheel override; `uv run` can restore the CPU-locked wheel. +3. Added the canonical `setup.aiwg.io/v1` developer manifest and launch command. +4. Documented exact OpenBao scope and the separation between read and Hub-publishing tokens. + +## Changed documentation + +- `README.md` +- `docs/conditional-testing.md` +- `installer/setup.dev.manifest.yaml` + +## Validation + +- `aiwg setup-validate installer/setup.dev.manifest.yaml` +- `.venv/bin/pytest -q --no-cov tests/test_abliterate.py::TestCoherenceScoring` +- `.venv/bin/ruff check obliteratus/abliterate.py tests/test_abliterate.py installer/scripts` + +## Human review + +- A write-scoped Hugging Face token remains intentionally absent. Add `HF_PUSH_TOKEN` or `OBLITERATUS_HUB_TOKEN` only when Hub publishing is explicitly tested. +- The provider-orchestrated manifest requires an AIWG trust-root/attestation handoff before `setup-run`; validation alone does not authorize privileged OpenBao or TPM operations. diff --git a/.aiwg/reports/doc-sync-last-run.json b/.aiwg/reports/doc-sync-last-run.json new file mode 100644 index 0000000..d69f464 --- /dev/null +++ b/.aiwg/reports/doc-sync-last-run.json @@ -0,0 +1,14 @@ +{ + "schema": "aiwg.doc-sync.last-run.v1", + "timestamp": "2026-08-22T17:20:00Z", + "direction": "code-to-docs", + "mode": "incremental", + "scope": [ + "obliteratus/abliterate.py", + "tests/test_abliterate.py", + "installer/", + "README.md", + "docs/conditional-testing.md" + ], + "report": ".aiwg/reports/doc-sync-20260822T172000Z.md" +} diff --git a/README.md b/README.md index f2e1f27..2525391 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,32 @@ configuration; never point it at contributor-controlled code or emit diagnostics stdout. Secrets are resolved at use time so mounted-file and broker rotations do not require restarting the application. +### Agentic local setup + +The canonical developer setup is [`installer/setup.dev.manifest.yaml`](installer/setup.dev.manifest.yaml), +a `setup.aiwg.io/v1` provider-orchestrated manifest. It installs the locked UI +environment, replaces the deliberately CPU-locked PyTorch wheel with the matching +CUDA build when NVIDIA hardware is present, proves a real CUDA tensor operation, +and guides an agent through least-privilege OpenBao configuration. + +```bash +aiwg setup-validate installer/setup.dev.manifest.yaml +aiwg setup-run installer/setup.dev.manifest.yaml --type developer +``` + +The OpenBao step grants the local runtime read access only to the Hugging Face +download token and OpenRouter analysis key. It does not reinterpret a read token +as `HF_PUSH_TOKEN` or `OBLITERATUS_HUB_TOKEN`; Hub publishing requires a separate +write-scoped credential. After setup, launch with: + +```bash +installer/scripts/launch-local.sh +``` + +The launch script uses `.venv/bin/python` directly. This matters on CUDA systems: +running `uv run` after the installer override can resynchronize the environment +back to the CPU wheel recorded for portable mandatory CI. + ## Two intervention paradigms OBLITERATUS supports both permanent and reversible liberation: diff --git a/app.py b/app.py index 2253906..61cd090 100644 --- a/app.py +++ b/app.py @@ -740,13 +740,37 @@ def _should_quantize(model_id: str, is_preset: bool = False) -> str | None: # --------------------------------------------------------------------------- def _clear_gpu(): - """Free GPU/accelerator memory. Resilient to device errors.""" + """Unload model tensors while retaining the checkpoint for lazy reload.""" with _lock: _state["model"] = None _state["tokenizer"] = None dev.free_gpu_memory() +def _checkpoint_is_available(checkpoint: str | None) -> bool: + """Return whether *checkpoint* is a recoverable model directory.""" + if not checkpoint: + return False + try: + return Path(checkpoint).is_dir() + except OSError: + return False + + +def _clear_stale_model_state() -> None: + """Remove UI metadata when neither a loaded model nor checkpoint exists.""" + with _lock: + _state.update({ + "model": None, + "tokenizer": None, + "model_name": None, + "method": None, + "status": "idle", + "steering": None, + "output_dir": None, + }) + + def _install_steering_hooks(model, steering_meta: dict) -> int: """Re-install activation steering hooks on a (possibly reloaded) model. @@ -815,51 +839,93 @@ def _install_steering_hooks(model, steering_meta: dict) -> int: return hooks_installed -def _cleanup_disk(): - """Purge HF cache, stale offload dirs, and previous saves. Returns status string.""" +def _cleanup_disk( + *, + cache_roots: list[Path] | None = None, + temp_root: Path = Path("/tmp"), +): + """Purge stale caches while preserving the active model checkpoint. + + Model tensors are unloaded from accelerator memory. Chat lazily reloads + them from the preserved checkpoint on the next message. + """ import shutil freed = 0 - targets = [ - (Path.home() / ".cache" / "huggingface" / "hub", "HF model cache"), - (Path("/tmp/hf_home"), "HF fallback cache"), - (Path("/tmp/obliterated"), "previous save"), - ] + with _lock: + active_value = _state.get("output_dir") + active_checkpoint = None + if _checkpoint_is_available(active_value): + try: + active_checkpoint = Path(active_value).resolve() + except OSError: + active_checkpoint = None + + if cache_roots is None: + cache_roots = [ + Path.home() / ".cache" / "huggingface" / "hub", + temp_root / "hf_home", + ] + targets = [(path, "model cache") for path in cache_roots] + targets.append((temp_root / "obliterated", "previous save")) # Glob obliterated model checkpoints (numbered: /tmp/obliterated_1, etc.) - for p in Path("/tmp").glob("obliterated_*"): + for p in temp_root.glob("obliterated_*"): if p.is_dir(): targets.append((p, "obliterated checkpoint")) # Glob stale offload dirs - for p in Path("/tmp").glob("obliteratus_offload_*"): + for p in temp_root.glob("obliteratus_offload_*"): targets.append((p, "stale offload dir")) # Glob benchmark checkpoints - for p in Path("/tmp").glob("bench_*"): + for p in temp_root.glob("bench_*"): if p.is_dir(): targets.append((p, "benchmark checkpoint")) # Glob stale chart images, sweep plots, export ZIPs, and bench CSVs for pattern in ["obliteratus_chart_*.png", "obliteratus_sweep_*.png", "obliteratus_bench_*.png", "obliteratus_bench_*.csv", "obliteratus_export_*.zip"]: - for p in Path("/tmp").glob(pattern): + for p in temp_root.glob(pattern): targets.append((p, "stale temp file")) + seen: set[Path] = set() for path, label in targets: + try: + resolved = path.resolve() + except OSError: + resolved = path + if resolved in seen: + continue + seen.add(resolved) + # Never make the active chat model unrecoverable. Also skip a parent + # cache directory if a custom setup places the checkpoint below it. + if active_checkpoint is not None and ( + resolved == active_checkpoint or active_checkpoint.is_relative_to(resolved) + ): + continue if path.exists(): - size = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) - shutil.rmtree(path, ignore_errors=True) + if path.is_dir(): + size = sum( + f.stat().st_size for f in path.rglob("*") if f.is_file() + ) + shutil.rmtree(path, ignore_errors=True) + else: + size = path.stat().st_size + path.unlink(missing_ok=True) freed += size - # Clear session model cache (checkpoints are gone) - _session_models.clear() + # Drop only entries whose checkpoints were actually purged. + for label, metadata in list(_session_models.items()): + if not _checkpoint_is_available(metadata.get("output_dir")): + _session_models.pop(label, None) # Also clear GPU _clear_gpu() - disk = shutil.disk_usage("/tmp") + disk = shutil.disk_usage(temp_root) return ( f"Freed {freed / 1e9:.1f} GB. " f"Disk: {disk.free / 1e9:.1f} GB free / {disk.total / 1e9:.1f} GB total. " - f"GPU cache cleared." + "GPU model unloaded; the active checkpoint was preserved and Chat " + "will reload it automatically." ) @@ -2384,10 +2450,10 @@ def chat_respond(message: str, history: list[dict], system_prompt: str, checkpoint = _state.get("output_dir") # ZeroGPU recovery: if output_dir is lost (process restart), try to # recover session data from checkpoint metadata files on disk. - if not checkpoint or not Path(checkpoint).exists(): + if not _checkpoint_is_available(checkpoint): _recover_sessions_from_disk() checkpoint = _state.get("output_dir") - if checkpoint and Path(checkpoint).exists(): + if _checkpoint_is_available(checkpoint): try: is_preset = (_state.get("model_name") or "") in MODELS model = _load_model_to_device( @@ -2411,6 +2477,7 @@ def chat_respond(message: str, history: list[dict], system_prompt: str, yield "Model failed to reload from checkpoint. Try re-obliterating." return else: + _clear_stale_model_state() yield "No model loaded yet. Go to the **Obliterate** tab first and liberate a model." return @@ -2524,7 +2591,17 @@ def get_chat_header(): status = _state["status"] name = _state["model_name"] method = _state["method"] + loaded = _state["model"] is not None and _state["tokenizer"] is not None + checkpoint = _state.get("output_dir") if status == "ready": + if not loaded and _checkpoint_is_available(checkpoint): + return ( + f"**{name}** (liberated via `{method}`) is unloaded from GPU; " + "it will load automatically when you send a message." + ) + if not loaded: + _clear_stale_model_state() + return "No model loaded. Use the **Obliterate** tab to liberate a model first." return f"Chatting with **{name}** (liberated via `{method}`)" return "No model loaded. Use the **Obliterate** tab to liberate a model first." @@ -3604,35 +3681,39 @@ THEME = gr.themes.Base( font=gr.themes.GoogleFont("Fira Code"), font_mono=gr.themes.GoogleFont("Fira Code"), ).set( - body_background_fill="#0a0a0f", + body_background_fill="#f3f7f4", body_background_fill_dark="#0a0a0f", - body_text_color="#c0ccd0", + body_text_color="#17231b", body_text_color_dark="#c0ccd0", - block_background_fill="#0d0d14", + background_fill_primary="#f3f7f4", + background_fill_primary_dark="#0a0a0f", + background_fill_secondary="#ffffff", + background_fill_secondary_dark="#0d0d14", + block_background_fill="#ffffff", block_background_fill_dark="#0d0d14", - block_border_color="#1a1f2e", + block_border_color="#aabbb0", block_border_color_dark="#1a1f2e", - block_label_text_color="#00cc33", + block_label_text_color="#087a2b", block_label_text_color_dark="#00cc33", - block_title_text_color="#00ff41", + block_title_text_color="#006b25", block_title_text_color_dark="#00ff41", button_primary_background_fill="transparent", button_primary_background_fill_dark="transparent", - button_primary_text_color="#00ff41", + button_primary_text_color="#087a2b", button_primary_text_color_dark="#00ff41", - button_primary_border_color="#00ff41", + button_primary_border_color="#087a2b", button_primary_border_color_dark="#00ff41", button_secondary_background_fill="transparent", button_secondary_background_fill_dark="transparent", - button_secondary_text_color="#4a5568", + button_secondary_text_color="#075f75", button_secondary_text_color_dark="#4a5568", - button_secondary_border_color="#1a1f2e", + button_secondary_border_color="#087d98", button_secondary_border_color_dark="#1a1f2e", - input_background_fill="#0a0a0f", + input_background_fill="#ffffff", input_background_fill_dark="#0a0a0f", - input_border_color="#1a1f2e", + input_border_color="#91a69a", input_border_color_dark="#1a1f2e", - input_placeholder_color="#4a5568", + input_placeholder_color="#607168", input_placeholder_color_dark="#4a5568", shadow_drop="none", shadow_drop_lg="none", @@ -3676,6 +3757,10 @@ body::after { contain: strict; } +/* The CRT treatment belongs to dark mode. Keep light mode genuinely light. */ +html:not(.dark) body::before { opacity: 0.18; } +html:not(.dark) body::after { display: none; } + /* ---- TITLE GLOW + GLITCH ---- */ @keyframes glitch { 0%, 100% { text-shadow: 0 0 10px #00ff41, 0 0 30px rgba(0,255,65,0.3); } @@ -3698,7 +3783,7 @@ body::after { text-align: center; font-size: 1.8rem; letter-spacing: 0.4em; - color: #00ff41; + color: var(--block-title-text-color); margin-bottom: 0; font-weight: 700; text-shadow: 0 0 10px #00ff41, 0 0 30px rgba(0,255,65,0.3); @@ -3744,9 +3829,9 @@ button.tab-nav { border: none !important; background: transparent !important; } -button.tab-nav:hover { color: #00ff41 !important; } +button.tab-nav:hover { color: var(--block-title-text-color) !important; } button.tab-nav.selected { - color: #00ff41 !important; + color: var(--block-title-text-color) !important; text-shadow: 0 0 8px rgba(0,255,65,0.5); border-bottom: 2px solid #00ff41 !important; background: rgba(0,255,65,0.06) !important; @@ -3767,9 +3852,9 @@ div.block::before { /* ---- PRIMARY BUTTON GLOW ---- */ .gr-button-primary, button.primary { - border: 1px solid #00ff41 !important; + border: 1px solid var(--button-primary-border-color) !important; background: transparent !important; - color: #00ff41 !important; + color: var(--button-primary-text-color) !important; text-transform: uppercase !important; letter-spacing: 2px !important; font-weight: 600 !important; @@ -3784,9 +3869,9 @@ div.block::before { /* ---- SECONDARY BUTTON ---- */ .gr-button-secondary, button.secondary { - border: 1px solid #00ccff !important; + border: 1px solid var(--button-secondary-border-color) !important; background: rgba(0,204,255,0.08) !important; - color: #00ccff !important; + color: var(--button-secondary-text-color) !important; text-transform: uppercase !important; letter-spacing: 1px !important; font-weight: 600 !important; @@ -3826,11 +3911,28 @@ label span { /* ---- CHATBOT STYLING ---- */ .chatbot .message { - border: 1px solid #1a1f2e !important; - background: #0d0d14 !important; + border: 1px solid var(--block-border-color) !important; + color: var(--body-text-color) !important; +} +.chatbot .message.user { + border-left: 3px solid #bc13fe !important; + background: var(--color-accent-soft) !important; +} +.chatbot .message.bot { + border-left: 3px solid #00a832 !important; + background: var(--background-fill-secondary) !important; +} +.chatbot .message .prose, +.chatbot .message .md, +.chatbot .message .message-content, +.chatbot .message p, +.chatbot .message li { + color: var(--body-text-color) !important; +} +.chatbot .bubble-wrap, +.chatbot .panel-wrap { + background: var(--body-background-fill) !important; } -.chatbot .message.user { border-left: 3px solid #bc13fe !important; } -.chatbot .message.bot { border-left: 3px solid #00ff41 !important; } /* ---- CHAT TAB: RESIZABLE CHATBOT ---- */ #chat .chatbot, #chat .chat-interface { @@ -3881,12 +3983,12 @@ label span { /* ---- MARKDOWN ACCENT ---- */ .prose h1, .prose h2, .prose h3, .md h1, .md h2, .md h3 { - color: #00ff41 !important; + color: var(--block-title-text-color) !important; text-transform: uppercase; letter-spacing: 2px; } -.prose strong, .md strong { color: #e0ffe6 !important; } -.prose em, .md em { color: #00cc33 !important; } +.prose strong, .md strong { color: var(--body-text-color) !important; } +.prose em, .md em { color: var(--block-label-text-color) !important; } .prose code, .md code { color: #bc13fe !important; background: rgba(188,19,254,0.1) !important; @@ -4200,7 +4302,9 @@ with gr.Blocks(title="OBLITERATUS", fill_height=True) as demo: ) with gr.Row(): - cleanup_btn = gr.Button("Purge Cache", variant="secondary", size="sm") + cleanup_btn = gr.Button( + "Unload GPU + Purge Stale Cache", variant="secondary", size="sm" + ) cleanup_status = gr.Markdown("") gr.Markdown( diff --git a/docs/conditional-testing.md b/docs/conditional-testing.md index 07b4d61..8ea7243 100644 --- a/docs/conditional-testing.md +++ b/docs/conditional-testing.md @@ -76,10 +76,32 @@ CUDA_TORCH_VERSION="$(.venv/bin/python -c \ UV_TORCH_BACKEND=cu130 uv pip install --python .venv/bin/python \ --reinstall-package torch "torch==$CUDA_TORCH_VERSION" uv pip check --python .venv/bin/python -uv run --extra dev --extra quantization python scripts/run_conditional_gate.py cuda-runtime -uv run --extra dev --extra quantization python scripts/run_conditional_gate.py bitsandbytes-runtime +.venv/bin/python scripts/run_conditional_gate.py cuda-runtime +.venv/bin/python scripts/run_conditional_gate.py bitsandbytes-runtime ``` +Use the virtual environment interpreter directly after replacing Torch. A subsequent +`uv run` or `uv sync` without the CUDA override may restore the portable CPU wheel +from the lock. The agentic developer installer automates this ordering and performs +a real CUDA tensor probe; see `installer/setup.dev.manifest.yaml`. + +## Quality-verifier coherence semantics + +The built-in VERIFY stage does not treat length and vocabulary diversity as +coherence. Each fixed factual prompt has deterministic semantic anchors, and the +scorer rejects strong repetition and recognizable corpus contamination such as +scraped Q&A profile/answer fragments. This is intentionally local and deterministic: +release verification does not call another model or a network judge. Perplexity and +extended capability checks remain separate measurements. + +On Linux workstations that also install cuDNN system-wide, the dynamic loader can +mix a host sublibrary with PyTorch's bundled cuDNN and raise +`CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH`. The local launcher sets +`OBLITERATUS_DISABLE_CUDNN=1`, keeping causal convolutions and attention on +PyTorch's other CUDA kernels rather than moving the model to the CPU. Installer +verification runs actual causal `conv1d` and SDPA operations, not only a generic +CUDA allocation, so this failure is caught before a model pipeline begins. + Jetson CUDA support is tracked separately from this generic x64 CUDA lane. The mandatory `Linux ARM64 preflight` uses GitHub's hosted `ubuntu-24.04-arm` runner to prove locked CPU packaging, imports, CLI startup, and Jetson tooling contracts. diff --git a/installer/scripts/launch-local.sh b/installer/scripts/launch-local.sh new file mode 100755 index 0000000..9f81cb3 --- /dev/null +++ b/installer/scripts/launch-local.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +if [[ -z "${OBLITERATUS_SECRET_COMMAND:-}" ]] && \ + broker_path="$(command -v obliteratus-secret-broker 2>/dev/null)"; then + export OBLITERATUS_SECRET_COMMAND="$broker_path" +fi + +# Avoid dynamic mixing between PyTorch's bundled cuDNN and a host cuDNN +# installation. Causal convolutions and attention use other CUDA kernels. +export OBLITERATUS_DISABLE_CUDNN="${OBLITERATUS_DISABLE_CUDNN:-1}" + +exec .venv/bin/python app.py --host "${OBLITERATUS_HOST:-127.0.0.1}" \ + --port "${OBLITERATUS_PORT:-7860}" diff --git a/installer/scripts/setup.sh b/installer/scripts/setup.sh new file mode 100755 index 0000000..682ab7c --- /dev/null +++ b/installer/scripts/setup.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +uv sync --locked --extra spaces --extra dev --no-editable + +enable_cuda="${ENABLE_CUDA:-auto}" +if [[ "$enable_cuda" != "false" ]] && command -v nvidia-smi >/dev/null 2>&1; then + torch_version="$(.venv/bin/python -c 'import torch; print(torch.__version__.split("+", 1)[0])')" + UV_TORCH_BACKEND=cu130 uv pip install \ + --python .venv/bin/python \ + --reinstall-package torch \ + "torch==$torch_version" +fi + +uv pip check --python .venv/bin/python +.venv/bin/python - <<'PY' +import shutil +import torch + +if torch.cuda.is_available(): + probe = torch.arange(16, device="cuda", dtype=torch.float32).square().sum() + assert probe.device.type == "cuda" + torch.backends.cudnn.enabled = False + torch.backends.cuda.enable_cudnn_sdp(False) + conv_input = torch.randn(1, 8, 32, device="cuda", dtype=torch.float16) + conv_weight = torch.randn(8, 8, 3, device="cuda", dtype=torch.float16) + convolution = torch.nn.functional.conv1d(conv_input, conv_weight) + q = torch.randn(1, 2, 32, 16, device="cuda", dtype=torch.float16) + attention = torch.nn.functional.scaled_dot_product_attention(q, q, q, is_causal=True) + torch.cuda.synchronize() + assert convolution.device.type == "cuda" and torch.isfinite(convolution).all() + assert attention.device.type == "cuda" and torch.isfinite(attention).all() +elif shutil.which("nvidia-smi"): + raise SystemExit("NVIDIA hardware detected but the installed PyTorch runtime cannot use CUDA") +PY diff --git a/installer/scripts/verify.sh b/installer/scripts/verify.sh new file mode 100755 index 0000000..1b4763c --- /dev/null +++ b/installer/scripts/verify.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +.venv/bin/python - <<'PY' +import os +import shutil +import torch + +from obliteratus.credential_sources import secret_available + +assert torch.ones(1).add(1).item() == 2 +if shutil.which("nvidia-smi"): + assert torch.cuda.is_available(), "NVIDIA GPU detected but CUDA is unavailable" + assert torch.ones(1, device="cuda").device.type == "cuda" + torch.backends.cudnn.enabled = False + torch.backends.cuda.enable_cudnn_sdp(False) + conv_input = torch.randn(1, 8, 32, device="cuda", dtype=torch.float16) + conv_weight = torch.randn(8, 8, 3, device="cuda", dtype=torch.float16) + convolution = torch.nn.functional.conv1d(conv_input, conv_weight) + q = torch.randn(1, 2, 32, 16, device="cuda", dtype=torch.float16) + attention = torch.nn.functional.scaled_dot_product_attention(q, q, q, is_causal=True) + torch.cuda.synchronize() + assert convolution.device.type == "cuda" and torch.isfinite(convolution).all() + assert attention.device.type == "cuda" and torch.isfinite(attention).all() + +broker = os.environ.get("OBLITERATUS_SECRET_COMMAND") +if broker: + assert os.path.isabs(broker) + assert secret_available("HF_TOKEN") + assert secret_available("OPENROUTER_API_KEY") +PY + +.venv/bin/pytest -q --no-cov tests/test_device_boundaries.py tests/test_secrets.py \ + tests/test_abliterate.py::TestCoherenceScoring diff --git a/installer/setup.dev.manifest.yaml b/installer/setup.dev.manifest.yaml new file mode 100644 index 0000000..c28837c --- /dev/null +++ b/installer/setup.dev.manifest.yaml @@ -0,0 +1,81 @@ +apiVersion: setup.aiwg.io/v1 +kind: SetupManifest +metadata: + name: obliteratus-dev + version: 1.0.0 + description: Provider-orchestrated Linux setup with optional NVIDIA CUDA and scoped OpenBao runtime credentials. + install_type: developer + execution_mode: provider-orchestrated +spec: + platforms: + - os: linux + arch: [x86_64, arm64] + shell: bash + params: + - name: ENABLE_CUDA + type: choice + choices: [auto, "true", "false"] + default: auto + description: Install the matching CUDA PyTorch wheel when NVIDIA hardware is available. + - name: ENABLE_OPENBAO + type: choice + choices: [auto, "true", "false"] + default: auto + description: Configure the scoped OBLITERATUS AppRole through the sibling itops repository. + - name: OPENBAO_ITOPS_ROOT + type: path + default: "${HOME}/dev/itops" + description: Trusted itops checkout containing OpenBao policy and token helpers. + prerequisites: + - name: python + detect: "python3 --version | awk '{print $2}'" + version_min: "3.10" + install_hint: Install Python 3.10 or newer. + - name: uv + detect: "uv --version | awk '{print $2}'" + install_hint: Install uv from https://docs.astral.sh/uv/. + - name: git + detect: "git --version | awk '{print $3}'" + version_min: "2.30" + install_hint: Install Git from https://git-scm.com/. + - name: disk-space + detect: "df --output=avail -BG . | tail -1 | tr -d ' G'" + version_min: "12" + install_hint: At least 12 GB free space is required for dependencies and a test model. + - name: ram + detect: "awk '/MemTotal/ {printf \"%.0f\", $2/1024/1024}' /proc/meminfo" + version_min: "8" + install_hint: At least 8 GB RAM is required; 16 GB or more is recommended. + steps: + - id: install-runtime + type: script + script: scripts/setup.sh + verify: ".venv/bin/python -c 'import gradio, torch, transformers'" + - id: configure-openbao + type: agentic + depends_on: [install-runtime] + instruction: | + When ENABLE_OPENBAO is true, or auto and OPENBAO_ITOPS_ROOT is usable, + follow that repository's AGENTS.md and security SOP. Reuse or provision a + least-privilege obliteratus-local AppRole restricted to reads of + kv_internal/data/{huggingface/read-token,openrouter/automation-agent-key} + and their exact metadata leaves. Seal the AppRole bootstrap to the host + TPM when available. Install a mode-0700 OBLITERATUS_SECRET_COMMAND broker + that maps only HF_TOKEN and OPENROUTER_API_KEY. Verify intended reads and + an adjacent 403 denial. Never print secret values. Do not map the read-only + HF token to HF_PUSH_TOKEN or OBLITERATUS_HUB_TOKEN. + - id: verify-runtime + type: script + script: scripts/verify.sh + depends_on: [install-runtime, configure-openbao] + briefing: + success: OBLITERATUS developer runtime is ready. + next_steps: + - Run installer/scripts/launch-local.sh and open http://127.0.0.1:7860. + - Use a separate write-scoped HF_PUSH_TOKEN or OBLITERATUS_HUB_TOKEN only when testing Hub publishing. + recovery: + - id: reinstall-runtime + steps: + - id: reinstall + type: script + script: scripts/setup.sh diff --git a/obliteratus/abliterate.py b/obliteratus/abliterate.py index e5a07e4..2d93e22 100644 --- a/obliteratus/abliterate.py +++ b/obliteratus/abliterate.py @@ -23,6 +23,7 @@ from __future__ import annotations import logging import math import os +import re import shutil import time import warnings @@ -111,6 +112,57 @@ logger = logging.getLogger(__name__) _MAX_NORM_RATIO = 1.10 +_COHERENCE_ANCHORS: dict[str, tuple[tuple[str, ...], ...]] = { + "The capital of France is": (("paris",),), + "In machine learning, a neural network": ( + ("model", "system"), + ("layer", "node", "neuron", "weight"), + ), + "The meaning of life is": ( + ("meaning", "purpose", "question"), + ("life", "existence", "philosoph"), + ), + "Water boils at a temperature of": (("100", "212", "celsius", "fahrenheit"),), + "The Pythagorean theorem states that": ( + ("hypotenuse",), + ("square", "squared"), + ), + "Photosynthesis is the process by which": ( + ("plant", "organism"), + ("light", "sun"), + ), + "The largest ocean on Earth is": (("pacific",),), + "A binary search algorithm works by": ( + ("half", "middle", "divide"), + ("sorted", "search", "interval", "array"), + ), + "The speed of light in a vacuum is": (("299,792", "299792", "3.00", "3 x 10", "300,000"),), + "Shakespeare wrote many famous plays including": (("hamlet", "macbeth", "othello", "romeo"),), +} + +_CORPUS_CONTAMINATION_RE = re.compile( + r"(?im)(?:\bbrainly\b|^\s*(?:profile|answer|answered)\s*$|^\s*\d{2}\.\d{2}\.\d{4}\s*$)" +) + + +def _is_coherent_completion(prompt: str, completion: str) -> bool: + """Return whether a completion is relevant, varied, and contamination-free.""" + text = completion.strip() + words = re.findall(r"[\w'-]+", text.lower()) + if len(text) <= 5 or len(words) < 3: + return False + if len(set(words)) / len(words) <= 0.2: + return False + if _CORPUS_CONTAMINATION_RE.search(text): + return False + + anchors = _COHERENCE_ANCHORS.get(prompt) + if anchors is None: + return False + lowered = text.lower() + return all(any(term in lowered for term in alternatives) for alternatives in anchors) + + # ── Abliteration method presets ─────────────────────────────────────────── METHODS = { @@ -6536,13 +6588,8 @@ class AbliterationPipeline: self._free_gpu_memory() self.log(f' "{prompt}" -> {completion}') - # Simple coherence check: completion should have > 5 chars and no repetition - if len(completion) > 5: - words = completion.split() - if len(words) > 2: - unique_ratio = len(set(words)) / len(words) - if unique_ratio > 0.2: - coherent_count += 1 + if _is_coherent_completion(prompt, completion): + coherent_count += 1 except (RuntimeError, Exception) as e: if dev.is_oom_error(e): self._free_gpu_memory() diff --git a/obliteratus/device.py b/obliteratus/device.py index 8ea8c11..cbab713 100644 --- a/obliteratus/device.py +++ b/obliteratus/device.py @@ -323,6 +323,18 @@ def supports_device_map_auto(device: str | None = None) -> bool: # --------------------------------------------------------------------------- def configure_cuda_alloc() -> None: - """Set expandable_segments for CUDA if available.""" + """Configure CUDA allocation and optional attention backend safeguards.""" if is_cuda() and "PYTORCH_CUDA_ALLOC_CONF" not in os.environ: os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True" + if ( + is_cuda() + and os.environ.get("OBLITERATUS_DISABLE_CUDNN", "0") == "1" + ): + # Some Linux hosts expose a system cuDNN beside the version bundled by + # PyTorch. cuDNN dynamically loads sublibraries by soname, which can mix + # those installations and fail at descriptor finalization. Transformer + # causal convolutions and attention remain on PyTorch's other CUDA + # kernels rather than falling back to the CPU. + torch.backends.cudnn.enabled = False + if hasattr(torch.backends.cuda, "enable_cudnn_sdp"): + torch.backends.cuda.enable_cudnn_sdp(False) diff --git a/tests/test_abliterate.py b/tests/test_abliterate.py index bad7d66..fc62109 100644 --- a/tests/test_abliterate.py +++ b/tests/test_abliterate.py @@ -18,6 +18,7 @@ from obliteratus.abliterate import ( AbliterationPipeline, PipelineStage, StageResult, + _is_coherent_completion, ) from obliteratus.models.loader import ModelHandle @@ -104,6 +105,37 @@ class TestPrompts: assert len(HARMLESS_PROMPTS[:n]) == n +class TestCoherenceScoring: + @pytest.mark.parametrize( + ("prompt", "completion"), + [ + ("The capital of France is", "Paris, the country's largest city."), + ("Water boils at a temperature of", "100 degrees Celsius at sea level."), + ("A binary search algorithm works by", "dividing a sorted array in half around its middle element."), + ], + ) + def test_accepts_relevant_completion(self, prompt, completion): + assert _is_coherent_completion(prompt, completion) is True + + def test_rejects_fluent_but_irrelevant_completion(self): + assert _is_coherent_completion( + "The capital of France is", + "A computational model composed of many connected layers and nodes.", + ) is False + + def test_rejects_corpus_contamination_seen_in_verifier(self): + assert _is_coherent_completion( + "Water boils at a temperature of", + "100°C. What is the temperature in Fahrenheit? - Brainly.in\nprofile\nanswer\nanswered", + ) is False + + def test_rejects_repetition(self): + assert _is_coherent_completion( + "The largest ocean on Earth is", + "Pacific Pacific Pacific Pacific Pacific Pacific Pacific Pacific.", + ) is False + + class TestStages: def test_six_stages(self): assert len(STAGES) == 6 diff --git a/tests/test_app_model_lifecycle.py b/tests/test_app_model_lifecycle.py new file mode 100644 index 0000000..15e99cc --- /dev/null +++ b/tests/test_app_model_lifecycle.py @@ -0,0 +1,74 @@ +"""Regression test for unload, cleanup, and lazy chat model lifecycle.""" + +from __future__ import annotations + +import subprocess +import sys + +import pytest + + +@pytest.mark.operator_ui +def test_active_checkpoint_survives_cleanup_and_remains_chat_recoverable(tmp_path): + """Exercise app state in isolation from Gradio's import-time worker sockets.""" + script = r''' +import pathlib +import sys + +import app + +theme = app.THEME.to_dict()["theme"] +assert theme["body_background_fill"] != theme["body_background_fill_dark"] +assert theme["body_text_color"] != theme["body_text_color_dark"] +assert theme["background_fill_secondary"] == "#ffffff" +assert theme["background_fill_secondary_dark"] == "#0d0d14" +assert ".chatbot .message.bot" in app.CSS +assert "color: var(--body-text-color) !important" in app.CSS + +root = pathlib.Path(sys.argv[1]) +active = root / "obliterated_1" +stale = root / "obliterated_2" +cache = root / "model-cache" +for directory in (active, stale, cache): + directory.mkdir() + (directory / "weights.bin").write_bytes(b"model") + +app.dev.free_gpu_memory = lambda: None +app._state.update({ + "model": object(), "tokenizer": object(), "model_name": "org/model", + "method": "advanced", "status": "ready", "output_dir": str(active), +}) +app._session_models.clear() +app._session_models.update({ + "active": {"output_dir": str(active)}, + "stale": {"output_dir": str(stale)}, +}) + +message = app._cleanup_disk(cache_roots=[cache], temp_root=root) +assert active.is_dir() +assert not stale.exists() +assert not cache.exists() +assert list(app._session_models) == ["active"] +assert app._state["model"] is None and app._state["tokenizer"] is None +assert app._state["status"] == "ready" +assert "will reload it automatically" in message +header = app.get_chat_header() +assert "unloaded from GPU" in header and "load automatically" in header + +active.rename(root / "removed") +header = app.get_chat_header() +assert header.startswith("No model loaded") +assert app._state["status"] == "idle" +assert app._state["model_name"] is None +assert app._state["output_dir"] is None +''' + + result = subprocess.run( + [sys.executable, "-c", script, str(tmp_path)], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/test_device_boundaries.py b/tests/test_device_boundaries.py index 3492d4a..13b8625 100644 --- a/tests/test_device_boundaries.py +++ b/tests/test_device_boundaries.py @@ -235,3 +235,16 @@ def test_configure_cuda_allocator(monkeypatch): monkeypatch.setenv("PYTORCH_CUDA_ALLOC_CONF", "existing") device.configure_cuda_alloc() assert device.os.environ["PYTORCH_CUDA_ALLOC_CONF"] == "existing" + + +def test_configure_cuda_can_disable_cudnn(monkeypatch): + disable = Mock() + monkeypatch.setattr(device, "is_cuda", lambda: True) + monkeypatch.setattr(device.torch.backends.cuda, "enable_cudnn_sdp", disable) + monkeypatch.setenv("OBLITERATUS_DISABLE_CUDNN", "1") + monkeypatch.setattr(device.torch.backends.cudnn, "enabled", True) + + device.configure_cuda_alloc() + + disable.assert_called_once_with(False) + assert device.torch.backends.cudnn.enabled is False