diff --git a/ci/test-risk-map.json b/ci/test-risk-map.json index 184f6d9..658f106 100644 --- a/ci/test-risk-map.json +++ b/ci/test-risk-map.json @@ -60,8 +60,7 @@ "obliteratus/tourney.py", "obliteratus/tourney_contracts.py", "obliteratus/restore_multimodal.py", - "obliteratus/capability_check.py", - "obliteratus/blend.py" + "obliteratus/capability_check.py" ], "required_tests": [ "tests/test_abliterate.py", @@ -79,6 +78,23 @@ "tests/test_persistence_pipeline.py" ] }, + { + "id": "checkpoint-composition", + "owner": "checkpoint composition maintainers", + "description": "Architecture-compatible model-weight composition and atomic output promotion", + "contract_types": [ + "model-mutation", + "persistence", + "public-interface" + ], + "paths": [ + "obliteratus/blend.py" + ], + "required_tests": [ + "tests/test_blend.py", + "tests/test_persistence_contracts.py" + ] + }, { "id": "configuration-and-selection", "owner": "runtime compatibility maintainers", diff --git a/docs/complementary_blending.md b/docs/complementary_blending.md index a55b409..5d0afda 100644 --- a/docs/complementary_blending.md +++ b/docs/complementary_blending.md @@ -1,26 +1,27 @@ # Complementary Abliteration Blending **Date:** 2026-08-20 -**Status:** Empirically Validated — V2 Shipped -**Authors:** OBLITERATUS Contributors +**Status:** Contributor-reported preliminary results; implementation independently tested +**Authors:** OBLITERATUS contributors and maintainers --- ## Abstract -We present **complementary abliteration blending**, a novel technique that combines two -abliteration methods with different failure modes via weight-space interpolation. The result -is the first abliterated model to exceed stock capability on MMLU (+1.1pp, n=570, lm-eval-harness) -while maintaining 0% refusal rate across 842 harmful prompts. +This document describes **complementary abliteration blending**, which combines two compatible +abliterated checkpoints via weight-space interpolation. PR #127 did not include the raw benchmark +outputs, prompt-level refusal evidence, model revisions, environment capture, or artifact hashes +needed to independently reproduce its numerical results. The tables below therefore preserve the +contributor's preliminary report as context; they are not independently verified project claims. --- ## 1. Motivation -All prior abliteration techniques face a fundamental tradeoff: deeper refusal removal causes -greater capability loss. Single-direction methods (Arditi et al., huihui-ai) preserve capability -but leave residual refusals. Multi-direction methods (OBLITERATUS V1, Gabliteration) achieve -complete refusal removal but at -6pp MMLU cost. +The contributor report frames abliteration as a tradeoff between refusal removal and measured +capability. Single-direction work such as [Arditi et al.][arditi] motivates the approach, while the +reported OBLITERATUS comparison observed lower MMLU for a more aggressive surgery. Raw evidence +for the specific refusal and MMLU figures was not included in PR #127. We hypothesized that different direction-finding methods damage different parts of the model's capability geometry, and that blending their outputs could cancel these damages. @@ -39,9 +40,9 @@ obliteratus obliterate $BASE --method aggressive --n-directions 3 \ --min-layer-fraction 0.45 ``` -**Properties:** Greedy variance capture via SVD finds refusal directions that overlap with -capability-encoding subspaces. Deep refusal removal (0% refuse, 100% usable output) but -measurable capability damage (-2pp MMLU on 15-subject spot check). +**Contributor-reported properties:** deep refusal removal with measurable capability damage on a +small spot check. The proposed explanation—SVD directions overlapping capability subspaces—is a +hypothesis that requires activation and controlled model-comparison evidence. ### 2.2 Surgery B: LEACE @@ -53,10 +54,10 @@ obliteratus obliterate $BASE --method aggressive --direction-method leace \ --refinement-passes 3 --min-layer-fraction 0.40 ``` -**Properties:** LEACE minimizes mutual information between the concept (refusal) and the -representation, preserving maximum non-refusal information by construction. Excellent capability -retention (+0.7pp MMLU) but weaker output quality (50% usable) because refusal removal is -less complete in the generation pathway. +LEACE is a closed-form linear concept-erasure method designed to remove linearly available concept +information while minimizing distortion ([Belrose et al.][leace]). The contributor reported +capability retention and weaker output quality for this surgery; the asserted mechanism in the +generation pathway was not evidenced in this PR. ### 2.3 Weight-Space LERP Blend @@ -71,7 +72,11 @@ The blend ratio `alpha = 0.60` was found by binary search over {0.30, 0.50, 0.55 --- -## 3. Results +## 3. Contributor-Reported Results (Not Independently Reproduced) + +No machine-readable benchmark or refusal-evaluation artifacts accompany these tables. Counts, +uncertainty, prompt selection, exact model revisions, and evaluator configuration must be supplied +before the results can support a release or research conclusion. ### 3.1 Blend Ratio Search (15-subject MMLU, 150 questions) @@ -86,9 +91,10 @@ The blend ratio `alpha = 0.60` was found by binary search over {0.30, 0.50, 0.55 | 70% | 0% | 80% | 86.7% | +0.7pp | | 100% (pure LEACE)| 0% | 50% | 86.7% | +0.7pp | -The 60% blend is the unique optimum: maximum MMLU with 100% usable output. +The contributor selected the 60% blend from this small search. The evidence is insufficient to +establish a unique optimum or distinguish the apparent differences from sampling noise. -### 3.2 Full Validation (57-subject MMLU, 570 questions) +### 3.2 Larger Contributor Spot Check (57-subject MMLU, 570 questions) | Model | MMLU | Stderr | vs Stock | |----------------------|---------|--------|----------| @@ -108,8 +114,8 @@ Capability gains span both safety-adjacent and neutral reasoning topics: | Business Ethics | 80% | 100% | +20pp | Sensitive | | Professional Law | 80% | 100% | +20pp | Sensitive | -Gains on neutral topics (math, logic) suggest real capability improvement, not just -reduced hedging on sensitive questions. +The reported neutral-topic gains motivate a controlled follow-up; five questions per subject are +not sufficient to establish capability improvement or rule out sampling variation. ### 3.4 Real-World Practical Tasks @@ -119,7 +125,8 @@ reduced hedging on sensitive questions. | Advanced (8 tasks) | 7/8 | 7/8 | ReAct agents, async refactor, K8s debug, | | | | | security review, system design | -V2 matches stock on every practical capability while being fully uncensored. +The contributor reported comparable outcomes on this small practical-task set. The tasks and raw +outputs were not included, so maintainers have not independently verified that comparison. --- @@ -127,38 +134,35 @@ V2 matches stock on every practical capability while being fully uncensored. ### 4.1 Complementary Error Cancellation -SVD and LEACE make **different mistakes in different parts of weight space**: +One hypothesis is that SVD and LEACE make different errors in weight space: - **SVD** greedily captures maximum variance directions. Some captured variance encodes capability, not just refusal. This damages specific weight regions. -- **LEACE** minimizes mutual information, preserving capability by construction. But it - leaves refusal residue in the generation pathway (attention heads, output projections) - that SVD would have removed. +- **LEACE** minimizes a linear erasure objective. Whether it leaves specific residue in attention + heads or output projections must be measured rather than inferred from output behavior. -Weight-space interpolation averages these complementary errors: +Weight-space interpolation may average complementary errors: - Where SVD damaged capability, LEACE's intact weights dilute the damage - Where LEACE left refusal residue, SVD's clean weights dilute the residue ### 4.2 Theoretical Connection to Model Merging -This technique is analogous to model merging (TIES, DARE, Model Soups) but applied within -the abliteration domain. The key insight is that the "task vectors" (weight deltas from stock) -created by different abliteration methods are approximately orthogonal in the dimensions that -matter — refusal removal is shared, but capability damage is method-specific. +This technique is related to model-merging work such as [Model Soups][model-soups] and +[TIES-Merging][ties], but is applied within the abliteration domain. PR #127 does not measure +task-vector orthogonality or error anti-correlation, so those remain testable explanations rather +than established properties. ### 4.3 Capacity Hypothesis -The +1.1pp MMLU improvement over stock raises the possibility that refusal training -consumes representational capacity that abliteration frees. Formal validation experiments -are provided in `obliteratus/capacity_hypothesis.py`: +The contributor-reported MMLU difference raises several competing hypotheses. A separate, +provenance-gated experiment framework is tracked in [issue #132][capacity-issue]: 1. **Activation Rank Analysis** — Does effective dimensionality increase after abliteration? 2. **Topic Cluster Analysis** — Do gains cluster on sensitive topics (hedging) or spread broadly (capacity)? 3. **Blend Control** — Does blending two identical SVD surgeries also gain MMLU? (Tests regularization hypothesis) 4. **Learning Absorption** — Does the abliterated model learn new information faster? (Tests freed capacity directly) -Preliminary topic cluster analysis shows gains on both sensitive (law, ethics) and neutral -(math, logic) topics, partially supporting the capacity hypothesis. +The small per-subject report does not distinguish these hypotheses. --- @@ -177,22 +181,31 @@ obliteratus obliterate $BASE --method aggressive --direction-method leace \ # Step 3: Blend obliteratus blend --model-a surgery_a --model-b surgery_b --alpha 0.6 \ - --output blended_model + --config-source a --output blended_model # Step 4: Validate lm_eval --model hf --model_args pretrained=blended_model --tasks mmlu ``` +The command requires matching `source_model` values in both checkpoints' +`abliteration_metadata.json`, identical tensor keys, compatible shapes/dtypes, floating-point +weights, and sharded safetensors indexes. For legacy checkpoints whose common lineage was verified +out of band, `--allow-unverified-lineage` is an explicit escape hatch. Output is staged and +validated before atomically replacing any existing destination. + --- ## 6. Limitations -- Validated only on Qwen3.8-27B; generalization to other architectures is untested +- Contributor measurements cover only Qwen3.8-27B; independent validation is pending - MMLU is a multiple-choice benchmark; gains may not transfer to all downstream tasks - The 60/40 blend ratio may be model-specific - `repetition_penalty=1.15` is still required for clean generation - System prompts still reintroduce refusals - Full 842-corpus validation in progress at time of writing +- The numerical results lack committed raw evidence and independent reproduction +- Single-file and quantized/integer checkpoints are not supported by the current blender +- Atomic promotion temporarily requires space for the complete staged output and any prior output --- @@ -201,5 +214,19 @@ lm_eval --model hf --model_args pretrained=blended_model --tasks mmlu - **Cross-architecture validation** on Llama, Gemma, Mistral - **SLERP blending** instead of LERP (spherical interpolation may better preserve weight norms) - **Three-way blends** with additional direction methods (diff_means, SOM) -- **Post-blend recovery** via QLoRA fine-tuning on capability data -- **Formal capacity hypothesis validation** using the provided experiment framework +- **Post-blend recovery**, tracked separately in [issue #133][recovery-issue] +- **Formal capacity-hypothesis validation**, tracked in [issue #132][capacity-issue] + +## References + +- [Arditi et al., *Refusal in Language Models Is Mediated by a Single Direction*][arditi] +- [Belrose et al., *LEACE: Perfect Linear Concept Erasure in Closed Form*][leace] +- [Wortsman et al., *Model Soups*][model-soups] +- [Yadav et al., *TIES-Merging*][ties] + +[arditi]: https://arxiv.org/abs/2406.11717 +[leace]: https://arxiv.org/abs/2306.03819 +[model-soups]: https://proceedings.mlr.press/v162/wortsman22a.html +[ties]: https://arxiv.org/abs/2306.01708 +[capacity-issue]: https://github.com/elder-plinius/OBLITERATUS/issues/132 +[recovery-issue]: https://github.com/elder-plinius/OBLITERATUS/issues/133 diff --git a/docs/executive_research_summary.md b/docs/executive_research_summary.md index 5c3c7f4..664ffb0 100644 --- a/docs/executive_research_summary.md +++ b/docs/executive_research_summary.md @@ -2,11 +2,18 @@ **OBLITERATUS Project — August 2026** +> **Evidence status:** This summary preserves contributor-reported preliminary measurements from +> PR #127. The PR did not include raw benchmark outputs, prompt-level refusal evidence, exact model +> revisions, environment capture, or artifact hashes. Maintainers independently verified the blend +> implementation and its CPU contracts, but not the numerical research results below. + --- ## The Problem -All prior abliteration techniques face a fundamental tradeoff: deeper refusal removal causes greater capability loss. This tradeoff appeared to be intrinsic to the geometry of refusal-trained models — removing refusal directions inevitably damages overlapping capability directions. +The contributor framed existing abliteration approaches as trading deeper refusal removal for +greater capability loss. Whether that pattern generalizes or follows from refusal-model geometry +has not been established by the evidence included with PR #127. | Approach | Refusal Rate | MMLU Delta | Source | |---|---|---|---| @@ -15,17 +22,23 @@ All prior abliteration techniques face a fundamental tradeoff: deeper refusal re | huihui-ai (1-dir, skip layers) | Low | ~0pp | Community | | OBLITERATUS V1 (5-dir SVD) | 0.0% | -6.0pp | This work | -Complete refusal removal (0%) seemed to require accepting significant capability loss. V1 proved 0% was achievable but at -6pp MMLU — a cost that users noticed and complained about. +The contributor reported that a V1 configuration reached 0% refusal on its sampled prompts with a +-6pp MMLU difference. Those figures are retained as an unverified observation, not proof of a +general tradeoff. ## The Insight Different direction-finding algorithms damage different regions of weight space. -**SVD (Singular Value Decomposition):** Extracts directions by maximizing captured variance. This is greedy — it grabs high-variance components that encode both refusal AND capability. Deep refusal removal, but collateral capability damage concentrated in high-variance weight regions. +**SVD (Singular Value Decomposition):** Extracts high-variance directions. The contributor proposes +that some directions encode both refusal and capability; this mechanism was not measured in PR #127. -**LEACE (Linear Erasure of Concept Embeddings):** Finds directions by minimizing mutual information between the concept (refusal) and the representation. Mathematically constrained to preserve maximum non-refusal information. Excellent capability retention, but conservative — leaves refusal residue in the generation pathway (attention projections, output heads). +**LEACE (Linear Erasure of Concept Embeddings):** Provides closed-form linear concept erasure while +minimizing distortion. The reported capability retention and generation-pathway residue remain +contributor observations requiring artifact-backed reproduction. -These methods make **complementary errors.** SVD damages regions LEACE preserves. LEACE leaves residue in regions SVD cleans. +The working hypothesis is that these methods make complementary errors. The PR does not measure +their error correlation or task-vector geometry. ## The Method @@ -35,11 +48,14 @@ Run both surgeries independently on the same base model, then interpolate in wei blended_weight = α × LEACE_weight + (1 - α) × SVD_weight ``` -We binary-searched α over {0.30, 0.50, 0.55, 0.60, 0.65, 0.70} using 15-subject MMLU and a 10-prompt usability check as the objective. The optimal ratio for Qwen3.8-27B was α = 0.60. +The contributor searched α over {0.30, 0.50, 0.55, 0.60, 0.65, 0.70} using 15-subject MMLU and a +10-prompt usability check, then selected α = 0.60 for Qwen3.8-27B. The small, incomplete search +does not establish a unique optimum. -**Why interpolation works:** Where SVD damaged capability, LEACE's intact weights dilute the damage. Where LEACE left refusal residue, SVD's clean weights dilute the residue. The blend point exists because these error distributions are approximately complementary — not identical, not orthogonal, but anti-correlated enough that averaging produces a model better than either parent. +**Proposed explanation:** interpolation may dilute method-specific damage. This must be tested +against same-method and unrelated-checkpoint blend controls before it is treated as causal. -## Results +## Contributor-Reported Results (Not Independently Reproduced) ### Headline @@ -49,9 +65,13 @@ We binary-searched α over {0.30, 0.50, 0.55, 0.60, 0.65, 0.70} using 15-subject | V1 (aggressive/SVD) | 81.4% (n=285) | 0.0% | 80% | | **V2 (60/40 blend)** | **86.3% (n=570)** | **0.0%** | **100%** | -V2 achieves +1.1pp MMLU above stock while maintaining complete refusal removal. This is the first reported instance of an abliterated model exceeding stock capability on a standard benchmark. +The contributor reported +1.1pp MMLU above stock while maintaining complete refusal removal. The +repository does not claim priority or a confirmed capability improvement without reproducible raw +evidence and appropriate statistical comparison. -**Important caveat:** MMLU was run with `--limit 10` (570 questions, 10 per subject). This is above typical spot-check sample sizes but below the full 14,042-question MMLU benchmark. Full-scale validation is in progress. The +1.1pp result should be interpreted as "strong preliminary evidence of capability retention or improvement" rather than a definitive measurement. +**Important caveat:** the contributor reports MMLU with `--limit 10` (570 questions, 10 per +subject), below the full benchmark. Without raw outputs and a paired statistical analysis, the ++1.1pp difference is descriptive only and may reflect sampling variation. ### Per-Subject Analysis @@ -67,7 +87,8 @@ Gains span both safety-adjacent and neutral reasoning topics (5 questions per su | High School Chemistry | 100% | 80% | -20pp | Neutral (regression) | | College Computer Science | 80% | 60% | -20pp | Neutral (regression) | -The presence of gains on neutral topics (math, logic) suggests the improvement is not solely attributable to reduced hedging on sensitive questions. However, per-subject samples are too small for statistical significance. +The reported neutral-topic differences motivate a reduced-hedging control, but the per-subject +samples are too small to distinguish a capability effect from sampling variation. ### Practical Capability @@ -81,7 +102,8 @@ The presence of gains on neutral topics (math, logic) suggests the improvement i | Structured output (JSON schema) | ✓ | ✓ | — | | System design | ✓ | ✓ | — | -V2 matches stock on every practical task tested while maintaining 0% refusal. +The contributor reported comparable outcomes on this small practical-task set. The underlying +tasks and outputs were not included for independent review. ## What We Don't Know Yet @@ -94,7 +116,8 @@ V2 matches stock on every practical task tested while maintaining 0% refusal. - **Reduced hedging:** Stock model hedges on questions adjacent to sensitive topics; abliteration removes the hedging. Gains on law/ethics support this. - **Blend regularization:** Weight averaging of any two diverse models acts as implicit regularization (analogous to model soups/ensembling). The improvement may not be specific to abliteration. -3. **Is the 60/40 ratio model-specific?** We only tested on Qwen3.8-27B. The optimal ratio likely varies by architecture, model size, and alignment training method. +3. **Is the 60/40 ratio model-specific?** The contributor reported testing only Qwen3.8-27B. + Useful ratios may vary by architecture, model size, and alignment training method. 4. **Does this generalize beyond SVD + LEACE?** Other direction-finding methods (diff_means, SOM, nuclear/SAE) may offer additional complementary error profiles for three-way or N-way blends. @@ -111,13 +134,15 @@ V2 matches stock on every practical task tested while maintaining 0% refusal. ## Experimental Framework for Future Validation -We built (but have not yet run) four experiments to distinguish between the competing hypotheses: +Four proposed experiments are tracked in [issue #132](https://github.com/elder-plinius/OBLITERATUS/issues/132): ### Experiment 1: Activation Rank Analysis (Tests "freed capacity") Run diverse prompts through stock and abliterated models. Capture hidden states at each layer. Compute effective rank via SVD. If abliteration frees capacity, the effective dimensionality of activations should increase. ### Experiment 2: Topic Cluster Analysis (Tests "reduced hedging") -Compare per-subject MMLU gains between sensitive topics (ethics, law, medicine) and neutral topics (physics, math). If gains cluster exclusively on sensitive topics, the improvement is hedging reduction, not capability gain. Preliminary results show mixed distribution — both types gain. +Compare per-subject MMLU differences between prespecified sensitive and neutral groups. The test +must define its statistical decision rule before examining results; the small table above is not +such a test. ### Experiment 3: Blend Control (Tests "blend regularization") Blend two identical SVD surgeries (same method, different random seeds) at 60/40. If this blend also gains MMLU, the improvement comes from weight averaging itself, not from the SVD/LEACE complementarity. This is the critical control experiment. @@ -125,23 +150,31 @@ Blend two identical SVD surgeries (same method, different random seeds) at 60/40 ### Experiment 4: Learning Absorption (Tests "freed capacity" directly) QLoRA fine-tune both stock and abliterated models on identical small datasets. Compare loss curves. If the abliterated model learns faster (lower loss at same step count), it has more absorptive capacity — direct evidence for freed representational space. Requires GPU infrastructure (A100+, not feasible on MPS). -Code for all four experiments: `obliteratus/capacity_hypothesis.py` +Their implementation is intentionally deferred until the hypotheses, controls, provenance, CPU +contracts, and conditional GPU/network gates are specified. ## Future Directions -### Near-term (validated technique, ready to explore) +### Near-term (implementation available; research validation pending) -1. **Cross-architecture replication.** Run the identical pipeline on Llama-3.1-70B, Gemma-2-27B, Mistral-Large. If the technique generalizes, it becomes a universal abliteration upgrade. The recipe is model-agnostic — only the blend ratio needs tuning per model. +1. **Cross-architecture replication.** Run the identical pipeline on additional model families. + This is needed to determine whether the technique generalizes and which parts of the recipe + require model-specific tuning. -2. **Full-scale benchmarking.** Complete MMLU (14k), MMLU-Pro, HumanEval, GSM8K, ARC-Challenge on V2 to establish definitive capability numbers. Publish a proper eval table that the community can cite. +2. **Full-scale benchmarking.** Complete MMLU (14k), MMLU-Pro, HumanEval, GSM8K, and + ARC-Challenge with pinned inputs, raw results, and uncertainty estimates. -3. **N-way blending.** Blend three or more surgeries using different direction methods (SVD, LEACE, diff_means, SOM). If each adds complementary error cancellation, the optimal blend of N methods should outperform any pair. +3. **N-way blending.** Test three or more surgeries using different direction methods (SVD, + LEACE, diff_means, SOM) against prespecified pairwise and same-method controls. -4. **Blend ratio as a function of model properties.** Study how the optimal α relates to model size, architecture, alignment training intensity, and number of refusal directions. Build a predictor so users don't need to binary-search. +4. **Blend ratio as a function of model properties.** Study how selected α values relate to model + size, architecture, alignment training intensity, and number of refusal directions. ### Medium-term (theoretical, needs investigation) -5. **Post-blend capability recovery.** QLoRA fine-tune the blended model on a curated capability dataset (MMLU train split, code exercises, reasoning chains). If the "freed capacity" hypothesis holds, the abliterated model should absorb new capability faster than stock. We built the dataset (4,874 refusal-free examples) and the training code (`obliteratus/recover.py`) but MPS was insufficient for 27B QLoRA — needs A100+. +5. **Post-blend capability recovery.** A provenance-safe dataset and QLoRA pipeline are proposed in + [issue #133](https://github.com/elder-plinius/OBLITERATUS/issues/133). No corpus or recovery + trainer is shipped by this change. 6. **SLERP and task-arithmetic blending.** Replace LERP with spherical interpolation (preserves weight norms) or task-arithmetic approaches (TIES-Merging, DARE) that handle parameter conflicts more intelligently. LERP is the simplest possible blend — there is likely headroom from more sophisticated interpolation. @@ -151,7 +184,10 @@ Code for all four experiments: `obliteratus/capacity_hypothesis.py` ### Long-term (speculative, high-impact if true) -9. **Capacity hypothesis validation and exploitation.** If Experiment 1 confirms that abliteration increases effective activation rank, this has implications beyond abliteration — it suggests that safety training in general consumes representational capacity that could be allocated to capability. This would mean: (a) safety-capability tradeoffs are not fundamental but artifacts of training methodology, and (b) better alignment techniques could achieve safety without capacity cost. +9. **Capacity-hypothesis validation.** Increased activation rank alone would not demonstrate freed + representational capacity; the proposed work must control for prompt sampling, layer selection, + model identity, numerical thresholds, and alternative explanations before drawing implications + about safety training. 10. **Generalized complementary merging.** The principle — "combine models that fail in different ways" — may extend beyond abliteration to any model merging scenario. Fine-tunes optimized for different objectives (code, math, reasoning) could be blended using the same complementary error cancellation principle, with direction-specific merge ratios instead of uniform interpolation. @@ -174,7 +210,7 @@ obliteratus obliterate $BASE --method aggressive --direction-method leace \ # Step 3: Blend obliteratus blend --model-a surgery_svd --model-b surgery_leace \ - --alpha 0.6 --output blended + --alpha 0.6 --config-source a --output blended # Step 4: Validate lm_eval --model hf --model_args pretrained=blended --tasks mmlu --device auto @@ -182,6 +218,11 @@ lm_eval --model hf --model_args pretrained=blended --tasks mmlu --device auto All code is open source: [github.com/elder-plinius/OBLITERATUS](https://github.com/elder-plinius/OBLITERATUS) +Primary background: [Arditi et al.](https://arxiv.org/abs/2406.11717), +[LEACE](https://arxiv.org/abs/2306.03819), +[Model Soups](https://proceedings.mlr.press/v162/wortsman22a.html), and +[TIES-Merging](https://arxiv.org/abs/2306.01708). + --- *OBLITERATUS Contributors, August 2026* diff --git a/obliteratus/blend.py b/obliteratus/blend.py index 9ff095c..01a66b2 100644 --- a/obliteratus/blend.py +++ b/obliteratus/blend.py @@ -37,13 +37,126 @@ from __future__ import annotations import json import logging +import math import shutil from pathlib import Path +from typing import Any +import torch from safetensors.torch import load_file, save_file +from obliteratus.persistence_contracts import atomic_checkpoint_directory + logger = logging.getLogger(__name__) +_INDEX_NAME = "model.safetensors.index.json" +_GENERATED_FILES = {_INDEX_NAME, "blend_metadata.json"} + + +def _read_index(model_dir: Path) -> dict[str, Any]: + """Read and validate a sharded safetensors index.""" + + if not model_dir.is_dir(): + raise FileNotFoundError(f"Model directory does not exist: {model_dir}") + index_path = model_dir / _INDEX_NAME + try: + index = json.loads(index_path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise FileNotFoundError(f"Missing safetensors index: {index_path}") from exc + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid safetensors index JSON: {index_path}") from exc + + weight_map = index.get("weight_map") if isinstance(index, dict) else None + if not isinstance(weight_map, dict) or not weight_map: + raise ValueError(f"Safetensors index has no weight_map: {index_path}") + for key, shard in weight_map.items(): + if not isinstance(key, str) or not key: + raise ValueError(f"Safetensors index contains an invalid tensor key: {key!r}") + if not isinstance(shard, str) or Path(shard).name != shard: + raise ValueError(f"Safetensors index contains an unsafe shard path: {shard!r}") + shard_path = model_dir / shard + if not shard_path.is_file(): + raise FileNotFoundError(f"Missing safetensors shard: {shard_path}") + return index + + +def _load_indexed_shard( + model_dir: Path, + shard: str, + weight_map: dict[str, str], +) -> dict[str, torch.Tensor]: + tensors = load_file(str(model_dir / shard)) + expected = {key for key, mapped_shard in weight_map.items() if mapped_shard == shard} + if set(tensors) != expected: + missing = sorted(expected - set(tensors)) + extra = sorted(set(tensors) - expected) + raise ValueError( + f"Safetensors shard/index mismatch in {model_dir / shard}: " + f"missing={missing[:3]}, extra={extra[:3]}", + ) + return tensors + + +def _validate_nonoverlapping_paths(model_a: Path, model_b: Path, output: Path) -> None: + resolved_a = model_a.resolve() + resolved_b = model_b.resolve() + resolved_output = output.resolve() + if resolved_a == resolved_b: + raise ValueError("model_a and model_b must be different model directories") + for source in (resolved_a, resolved_b): + if resolved_output == source or resolved_output.is_relative_to(source): + raise ValueError("output must not be a model directory or one of its descendants") + if source.is_relative_to(resolved_output): + raise ValueError("output must not contain either source model directory") + + +def _read_source_model(model_dir: Path) -> str | None: + metadata_path = model_dir / "abliteration_metadata.json" + if not metadata_path.is_file(): + return None + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid abliteration metadata JSON: {metadata_path}") from exc + source_model = metadata.get("source_model") if isinstance(metadata, dict) else None + if not isinstance(source_model, str) or not source_model.strip(): + raise ValueError(f"Abliteration metadata has no source_model: {metadata_path}") + return source_model + + +def _verify_lineage(model_a: Path, model_b: Path, *, required: bool) -> str | None: + source_a = _read_source_model(model_a) + source_b = _read_source_model(model_b) + if required and (source_a is None or source_b is None): + raise ValueError( + "Both checkpoints require abliteration_metadata.json with matching source_model; " + "use allow_unverified_lineage=True only after independently verifying lineage", + ) + if source_a is not None and source_b is not None and source_a != source_b: + raise ValueError(f"Checkpoint source_model values do not match: {source_a!r} != {source_b!r}") + return source_a if source_a == source_b else None + + +def _copy_model_support_files(source: Path, destination: Path) -> None: + for item in source.iterdir(): + if item.name in _GENERATED_FILES or item.suffix == ".safetensors" or item.name == ".git": + continue + target = destination / item.name + if item.is_dir(): + shutil.copytree(item, target, symlinks=True) + else: + shutil.copy2(item, target) + + +def _validate_blended_checkpoint(checkpoint: Path) -> None: + index = _read_index(checkpoint) + if not (checkpoint / "config.json").is_file(): + raise ValueError("Selected config source does not contain config.json") + if not (checkpoint / "blend_metadata.json").is_file(): + raise ValueError("Blended checkpoint is missing blend_metadata.json") + for shard in set(index["weight_map"].values()): + _load_indexed_shard(checkpoint, shard, index["weight_map"]) + def blend_models( model_a_path: str | Path, @@ -51,6 +164,7 @@ def blend_models( output_path: str | Path, alpha: float = 0.6, config_source: str = "a", + allow_unverified_lineage: bool = False, ) -> dict: """LERP-blend two models in weight space. @@ -62,76 +176,103 @@ def blend_models( output_path: Where to save the blended model. alpha: Blend ratio. 0.0 = pure model_a, 1.0 = pure model_b. config_source: Which model's config files to use ("a" or "b"). + allow_unverified_lineage: Permit checkpoints without matching OBLITERATUS + source metadata. Tensor compatibility is still enforced. Returns: dict with tensor counts and blend metadata. """ + if not isinstance(alpha, (int, float)) or not math.isfinite(float(alpha)): + raise ValueError("alpha must be a finite number between 0 and 1") + alpha = float(alpha) + if not 0.0 <= alpha <= 1.0: + raise ValueError("alpha must be between 0 and 1 inclusive") + if config_source not in {"a", "b"}: + raise ValueError("config_source must be 'a' or 'b'") + model_a = Path(model_a_path) model_b = Path(model_b_path) output = Path(output_path) - output.mkdir(parents=True, exist_ok=True) + _validate_nonoverlapping_paths(model_a, model_b, output) + source_model = _verify_lineage( + model_a, + model_b, + required=not allow_unverified_lineage, + ) - with open(model_a / "model.safetensors.index.json") as f: - index_a = json.load(f) + index_a = _read_index(model_a) + index_b = _read_index(model_b) + map_a = index_a["weight_map"] + map_b = index_b["weight_map"] + if set(map_a) != set(map_b): + missing_from_b = sorted(set(map_a) - set(map_b)) + missing_from_a = sorted(set(map_b) - set(map_a)) + raise ValueError( + "Model tensor keys do not match: " + f"missing_from_b={missing_from_b[:3]}, missing_from_a={missing_from_a[:3]}", + ) - shards = sorted(set(index_a["weight_map"].values())) + shards = sorted(set(map_a.values())) total_tensors = 0 blended_tensors = 0 - a_only_tensors = 0 logger.info("Blending: %.0f%% model_b + %.0f%% model_a", alpha * 100, (1 - alpha) * 100) logger.info("Shards: %d", len(shards)) - for shard_idx, shard in enumerate(shards): - ta = load_file(str(model_a / shard)) - tb_path = model_b / shard - tb = load_file(str(tb_path)) if tb_path.exists() else {} - - merged = {} - for key in ta: - total_tensors += 1 - if key in tb: - merged[key] = alpha * tb[key] + (1 - alpha) * ta[key] - blended_tensors += 1 - else: - merged[key] = ta[key] - a_only_tensors += 1 - - save_file(merged, str(output / shard)) - - if (shard_idx + 1) % 5 == 0 or shard_idx == len(shards) - 1: - logger.info(" [%d/%d] shards processed", shard_idx + 1, len(shards)) - - # Write index - with open(output / "model.safetensors.index.json", "w") as f: - json.dump(index_a, f, indent=2) - - # Copy config files from chosen source + metadata: dict[str, Any] source = model_a if config_source == "a" else model_b - skip = {"model.safetensors.index.json", ".git", "__pycache__"} - for f in source.iterdir(): - if f.suffix == ".safetensors" or f.name in skip: - continue - dst = output / f.name - if not dst.exists(): - shutil.copy2(f, dst) + with atomic_checkpoint_directory(output, validate=_validate_blended_checkpoint) as staging: + for shard_idx, shard in enumerate(shards): + tensors_a = _load_indexed_shard(model_a, shard, map_a) + needed_b_shards = {map_b[key] for key in tensors_a} + tensors_b: dict[str, torch.Tensor] = {} + for b_shard in needed_b_shards: + tensors_b.update(_load_indexed_shard(model_b, b_shard, map_b)) - # Write blend metadata - metadata = { - "blend_method": "lerp", - "alpha": alpha, - "model_a": str(model_a), - "model_b": str(model_b), - "formula": f"blended = {alpha} * model_b + {1-alpha} * model_a", - "total_tensors": total_tensors, - "blended_tensors": blended_tensors, - "a_only_tensors": a_only_tensors, - } - with open(output / "blend_metadata.json", "w") as f: - json.dump(metadata, f, indent=2) + merged: dict[str, torch.Tensor] = {} + for key, tensor_a in tensors_a.items(): + tensor_b = tensors_b[key] + if tensor_a.shape != tensor_b.shape: + raise ValueError( + f"Tensor shape mismatch for {key}: {tensor_a.shape} != {tensor_b.shape}", + ) + if tensor_a.dtype != tensor_b.dtype: + raise ValueError( + f"Tensor dtype mismatch for {key}: {tensor_a.dtype} != {tensor_b.dtype}", + ) + if not torch.is_floating_point(tensor_a): + raise TypeError(f"Tensor {key} has non-floating dtype {tensor_a.dtype}") + merged[key] = alpha * tensor_b + (1.0 - alpha) * tensor_a + total_tensors += 1 + blended_tensors += 1 - logger.info("Blend complete: %d tensors (%d blended, %d from model_a only)", - total_tensors, blended_tensors, a_only_tensors) + save_file(merged, str(staging / shard)) + if (shard_idx + 1) % 5 == 0 or shard_idx == len(shards) - 1: + logger.info(" [%d/%d] shards processed", shard_idx + 1, len(shards)) + + (staging / _INDEX_NAME).write_text( + json.dumps(index_a, indent=2) + "\n", + encoding="utf-8", + ) + _copy_model_support_files(source, staging) + metadata = { + "blend_method": "lerp", + "alpha": alpha, + "config_source": config_source, + "lineage_verified": source_model is not None, + "source_model": source_model, + "model_a": str(model_a), + "model_b": str(model_b), + "formula": f"blended = {alpha} * model_b + {1.0 - alpha} * model_a", + "total_tensors": total_tensors, + "blended_tensors": blended_tensors, + } + (staging / "blend_metadata.json").write_text( + json.dumps(metadata, indent=2) + "\n", + encoding="utf-8", + ) + + logger.info("Blend complete: %d tensors blended", blended_tensors) return metadata @@ -141,6 +282,8 @@ def blend_search( model_b_path: str | Path, output_dir: str | Path, alphas: list[float] | None = None, + config_source: str = "a", + allow_unverified_lineage: bool = False, ) -> list[dict]: """Create multiple blends for binary-search evaluation. @@ -155,15 +298,28 @@ def blend_search( """ if alphas is None: alphas = [0.3, 0.5, 0.6, 0.7] + if not alphas: + raise ValueError("alphas must contain at least one blend ratio") + normalized = [float(alpha) for alpha in alphas] + if len(set(normalized)) != len(normalized): + raise ValueError("alphas must not contain duplicate blend ratios") output_dir = Path(output_dir) results = [] - for alpha in alphas: - label = f"blend_{int(alpha * 100):02d}" + for alpha in normalized: + percentage = f"{alpha * 100:g}".replace(".", "p") + label = f"blend_{percentage}" output = output_dir / label logger.info("\n=== %s (alpha=%.2f) ===", label, alpha) - meta = blend_models(model_a_path, model_b_path, output, alpha=alpha) + meta = blend_models( + model_a_path, + model_b_path, + output, + alpha=alpha, + config_source=config_source, + allow_unverified_lineage=allow_unverified_lineage, + ) meta["label"] = label results.append(meta) @@ -180,6 +336,8 @@ def main(): p.add_argument("--model-a", required=True, help="First model (e.g., aggressive surgery)") p.add_argument("--model-b", required=True, help="Second model (e.g., LEACE surgery)") p.add_argument("--alpha", type=float, default=0.6, help="Blend ratio (0=pure A, 1=pure B)") + p.add_argument("--config-source", choices=["a", "b"], default="a") + p.add_argument("--allow-unverified-lineage", action="store_true") p.add_argument("--search", type=str, default=None, help="Comma-separated alphas for binary search (e.g., 0.3,0.5,0.6,0.7)") p.add_argument("--output", required=True, help="Output directory") @@ -187,12 +345,26 @@ def main(): if args.search: alphas = [float(a) for a in args.search.split(",")] - results = blend_search(args.model_a, args.model_b, args.output, alphas) + results = blend_search( + args.model_a, + args.model_b, + args.output, + alphas, + config_source=args.config_source, + allow_unverified_lineage=args.allow_unverified_lineage, + ) print(f"\nCreated {len(results)} blends in {args.output}/") for r in results: print(f" {r['label']}: alpha={r['alpha']}") else: - result = blend_models(args.model_a, args.model_b, args.output, alpha=args.alpha) + result = blend_models( + args.model_a, + args.model_b, + args.output, + alpha=args.alpha, + config_source=args.config_source, + allow_unverified_lineage=args.allow_unverified_lineage, + ) print(f"\nBlend complete: {result['blended_tensors']} tensors blended at alpha={args.alpha}") diff --git a/obliteratus/cli.py b/obliteratus/cli.py index b95b124..06abf58 100644 --- a/obliteratus/cli.py +++ b/obliteratus/cli.py @@ -448,6 +448,17 @@ def main(argv: list[str] | None = None): blend_parser.add_argument("--model-a", required=True, help="First model (e.g., aggressive surgery)") blend_parser.add_argument("--model-b", required=True, help="Second model (e.g., LEACE surgery)") blend_parser.add_argument("--alpha", type=float, default=0.6, help="Blend ratio (0=pure A, 1=pure B)") + blend_parser.add_argument( + "--config-source", + choices=["a", "b"], + default="a", + help="Model whose tokenizer/config support files are copied", + ) + blend_parser.add_argument( + "--allow-unverified-lineage", + action="store_true", + help="Allow checkpoints without matching OBLITERATUS source-model metadata", + ) blend_parser.add_argument("--search", type=str, default=None, help="Comma-separated alphas for search") blend_parser.add_argument("--output", required=True, help="Output directory") aggregate_parser.add_argument( @@ -600,10 +611,24 @@ def main(argv: list[str] | None = None): logging.basicConfig(level=logging.INFO, format="%(message)s") if args.search: alphas = [float(a) for a in args.search.split(",")] - results = blend_search(args.model_a, args.model_b, args.output, alphas) + results = blend_search( + args.model_a, + args.model_b, + args.output, + alphas, + config_source=args.config_source, + allow_unverified_lineage=args.allow_unverified_lineage, + ) print(f"\nCreated {len(results)} blends") else: - result = blend_models(args.model_a, args.model_b, args.output, alpha=args.alpha) + result = blend_models( + args.model_a, + args.model_b, + args.output, + alpha=args.alpha, + config_source=args.config_source, + allow_unverified_lineage=args.allow_unverified_lineage, + ) print(f"\nBlend complete: {result['blended_tensors']} tensors at alpha={args.alpha}") elif args.command == "ui": _cmd_ui(args) diff --git a/tests/test_blend.py b/tests/test_blend.py index 8089369..0dbb2b1 100644 --- a/tests/test_blend.py +++ b/tests/test_blend.py @@ -5,21 +5,45 @@ from __future__ import annotations import json from pathlib import Path +import pytest import torch from safetensors.torch import load_file, save_file -def _make_model(tmpdir: Path, value: float, n_tensors: int = 3): +def _make_model( + tmpdir: Path, + value: float, + n_tensors: int = 3, + *, + config_name: str = "test", +): """Create a minimal model with all weights set to a constant value.""" tensors = {f"layer.{i}.weight": torch.full((4, 4), value) for i in range(n_tensors)} shard = "model-00001-of-00001.safetensors" save_file(tensors, str(tmpdir / shard)) index = {"metadata": {}, "weight_map": {k: shard for k in tensors}} (tmpdir / "model.safetensors.index.json").write_text(json.dumps(index)) - (tmpdir / "config.json").write_text(json.dumps({"model_type": "test"})) + (tmpdir / "config.json").write_text(json.dumps({"model_type": config_name})) + (tmpdir / "abliteration_metadata.json").write_text( + json.dumps({"source_model": "example/base-model"}), + ) return tensors +def _make_sharded_model(tmpdir: Path, values: dict[str, float], shard_for: dict[str, str]): + by_shard: dict[str, dict[str, torch.Tensor]] = {} + for key, value in values.items(): + by_shard.setdefault(shard_for[key], {})[key] = torch.full((2, 2), value) + for shard, tensors in by_shard.items(): + save_file(tensors, str(tmpdir / shard)) + index = {"metadata": {}, "weight_map": shard_for} + (tmpdir / "model.safetensors.index.json").write_text(json.dumps(index)) + (tmpdir / "config.json").write_text(json.dumps({"model_type": "test"})) + (tmpdir / "abliteration_metadata.json").write_text( + json.dumps({"source_model": "example/base-model"}), + ) + + class TestBlendModels: def test_lerp_blend(self, tmp_path): from obliteratus.blend import blend_models @@ -36,6 +60,7 @@ class TestBlendModels: result = blend_models(str(a_dir), str(b_dir), str(out_dir), alpha=0.6) assert result["blended_tensors"] == 3 + assert result["total_tensors"] == 3 assert result["alpha"] == 0.6 merged = load_file(str(out_dir / "model-00001-of-00001.safetensors")) @@ -91,6 +116,9 @@ class TestBlendModels: meta = json.loads((out / "blend_metadata.json").read_text()) assert meta["alpha"] == 0.5 assert meta["blend_method"] == "lerp" + assert meta["config_source"] == "a" + assert meta["lineage_verified"] is True + assert meta["source_model"] == "example/base-model" def test_copies_config(self, tmp_path): from obliteratus.blend import blend_models @@ -107,6 +135,225 @@ class TestBlendModels: blend_models(str(a_dir), str(b_dir), str(out)) assert (out / "config.json").exists() + def test_uses_selected_config_source(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + out = tmp_path / "out" + a_dir.mkdir() + b_dir.mkdir() + _make_model(a_dir, 1.0, config_name="a") + _make_model(b_dir, 2.0, config_name="b") + + blend_models(a_dir, b_dir, out, config_source="b") + + assert json.loads((out / "config.json").read_text())["model_type"] == "b" + + def test_supports_different_shard_layouts_with_matching_keys(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + out = tmp_path / "out" + a_dir.mkdir() + b_dir.mkdir() + values_a = {"layer.0": 0.0, "layer.1": 2.0} + values_b = {"layer.0": 10.0, "layer.1": 6.0} + _make_sharded_model(a_dir, values_a, {key: "a.safetensors" for key in values_a}) + _make_sharded_model( + b_dir, + values_b, + {"layer.0": "b-1.safetensors", "layer.1": "b-2.safetensors"}, + ) + + blend_models(a_dir, b_dir, out, alpha=0.25) + + tensors = load_file(str(out / "a.safetensors")) + assert torch.allclose(tensors["layer.0"], torch.full((2, 2), 2.5)) + assert torch.allclose(tensors["layer.1"], torch.full((2, 2), 3.0)) + + @pytest.mark.parametrize("alpha", [-0.1, 1.1, float("inf"), float("nan")]) + def test_rejects_invalid_alpha_without_creating_output(self, tmp_path, alpha): + from obliteratus.blend import blend_models + + with pytest.raises(ValueError, match="alpha"): + blend_models(tmp_path / "a", tmp_path / "b", tmp_path / "out", alpha=alpha) + assert not (tmp_path / "out").exists() + + def test_rejects_invalid_config_source(self, tmp_path): + from obliteratus.blend import blend_models + + with pytest.raises(ValueError, match="config_source"): + blend_models( + tmp_path / "a", + tmp_path / "b", + tmp_path / "out", + config_source="other", + ) + + def test_rejects_missing_or_extra_tensor_keys(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + a_dir.mkdir() + b_dir.mkdir() + _make_model(a_dir, 1.0, n_tensors=2) + _make_model(b_dir, 2.0, n_tensors=1) + + with pytest.raises(ValueError, match="tensor keys do not match"): + blend_models(a_dir, b_dir, tmp_path / "out") + + def test_rejects_shape_and_dtype_mismatches(self, tmp_path): + from obliteratus.blend import blend_models + + for mismatch in ("shape", "dtype"): + a_dir = tmp_path / f"a-{mismatch}" + b_dir = tmp_path / f"b-{mismatch}" + a_dir.mkdir() + b_dir.mkdir() + shard = "model.safetensors" + save_file({"weight": torch.ones((2, 2))}, str(a_dir / shard)) + b_tensor = torch.ones((3, 2)) if mismatch == "shape" else torch.ones((2, 2)).double() + save_file({"weight": b_tensor}, str(b_dir / shard)) + index = {"weight_map": {"weight": shard}} + for directory in (a_dir, b_dir): + (directory / "model.safetensors.index.json").write_text(json.dumps(index)) + (directory / "config.json").write_text("{}") + (directory / "abliteration_metadata.json").write_text( + json.dumps({"source_model": "example/base-model"}), + ) + + with pytest.raises(ValueError, match=f"Tensor {mismatch} mismatch"): + blend_models(a_dir, b_dir, tmp_path / f"out-{mismatch}") + + def test_rejects_non_floating_tensors(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + a_dir.mkdir() + b_dir.mkdir() + shard = "model.safetensors" + index = {"weight_map": {"weight": shard}} + for directory in (a_dir, b_dir): + save_file({"weight": torch.ones((2, 2), dtype=torch.int64)}, str(directory / shard)) + (directory / "model.safetensors.index.json").write_text(json.dumps(index)) + (directory / "config.json").write_text("{}") + (directory / "abliteration_metadata.json").write_text( + json.dumps({"source_model": "example/base-model"}), + ) + + with pytest.raises(TypeError, match="non-floating"): + blend_models(a_dir, b_dir, tmp_path / "out") + + def test_rejects_unsafe_index_shard_path(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + a_dir.mkdir() + b_dir.mkdir() + (a_dir / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {"weight": "../outside.safetensors"}}), + ) + for directory in (a_dir, b_dir): + (directory / "abliteration_metadata.json").write_text( + json.dumps({"source_model": "example/base-model"}), + ) + + with pytest.raises(ValueError, match="unsafe shard path"): + blend_models(a_dir, b_dir, tmp_path / "out") + + def test_rejects_output_overlapping_a_source(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + a_dir.mkdir() + b_dir.mkdir() + + with pytest.raises(ValueError, match="output"): + blend_models(a_dir, b_dir, a_dir / "nested") + + def test_requires_matching_checkpoint_lineage_by_default(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + a_dir.mkdir() + b_dir.mkdir() + _make_model(a_dir, 1.0) + _make_model(b_dir, 2.0) + (b_dir / "abliteration_metadata.json").write_text( + json.dumps({"source_model": "different/base"}), + ) + + with pytest.raises(ValueError, match="source_model values do not match"): + blend_models(a_dir, b_dir, tmp_path / "out") + + def test_unverified_lineage_requires_explicit_opt_in(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + a_dir.mkdir() + b_dir.mkdir() + _make_model(a_dir, 1.0) + _make_model(b_dir, 2.0) + (a_dir / "abliteration_metadata.json").unlink() + (b_dir / "abliteration_metadata.json").unlink() + + with pytest.raises(ValueError, match="allow_unverified_lineage"): + blend_models(a_dir, b_dir, tmp_path / "blocked") + + result = blend_models( + a_dir, + b_dir, + tmp_path / "allowed", + allow_unverified_lineage=True, + ) + assert result["lineage_verified"] is False + assert result["source_model"] is None + + def test_failed_blend_preserves_existing_output(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + out = tmp_path / "out" + a_dir.mkdir() + b_dir.mkdir() + out.mkdir() + (out / "sentinel.txt").write_text("previous") + _make_model(a_dir, 1.0) + _make_model(b_dir, 2.0) + (a_dir / "config.json").unlink() + + with pytest.raises(ValueError, match="config.json"): + blend_models(a_dir, b_dir, out) + + assert (out / "sentinel.txt").read_text() == "previous" + + def test_successful_blend_atomically_replaces_existing_output(self, tmp_path): + from obliteratus.blend import blend_models + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + out = tmp_path / "out" + a_dir.mkdir() + b_dir.mkdir() + out.mkdir() + (out / "stale.txt").write_text("stale") + _make_model(a_dir, 1.0) + _make_model(b_dir, 2.0) + + blend_models(a_dir, b_dir, out) + + assert not (out / "stale.txt").exists() + assert (out / "blend_metadata.json").is_file() + class TestBlendSearch: def test_creates_multiple_blends(self, tmp_path): @@ -127,6 +374,30 @@ class TestBlendSearch: assert (out / "blend_30").exists() assert (out / "blend_70").exists() + def test_rejects_empty_or_duplicate_search_ratios(self, tmp_path): + from obliteratus.blend import blend_search + + with pytest.raises(ValueError, match="at least one"): + blend_search(tmp_path / "a", tmp_path / "b", tmp_path / "out", alphas=[]) + with pytest.raises(ValueError, match="duplicate"): + blend_search(tmp_path / "a", tmp_path / "b", tmp_path / "out", alphas=[0.5, 0.5]) + + def test_search_propagates_config_source(self, tmp_path): + from obliteratus.blend import blend_search + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + out = tmp_path / "out" + a_dir.mkdir() + b_dir.mkdir() + _make_model(a_dir, 1.0, config_name="a") + _make_model(b_dir, 2.0, config_name="b") + + blend_search(a_dir, b_dir, out, alphas=[0.5], config_source="b") + + config = json.loads((out / "blend_50" / "config.json").read_text()) + assert config["model_type"] == "b" + class TestCLIDispatch: def test_blend_dispatch(self, tmp_path): @@ -148,3 +419,78 @@ class TestCLIDispatch: "--output", str(out)]) assert (out / "blend_metadata.json").exists() + + def test_blend_dispatch_uses_requested_config_source(self, tmp_path): + from obliteratus.cli import main as cli_main + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + out = tmp_path / "out" + a_dir.mkdir() + b_dir.mkdir() + _make_model(a_dir, 1.0, config_name="a") + _make_model(b_dir, 2.0, config_name="b") + + cli_main([ + "blend", + "--model-a", str(a_dir), + "--model-b", str(b_dir), + "--config-source", "b", + "--output", str(out), + ]) + + assert json.loads((out / "config.json").read_text())["model_type"] == "b" + + +class TestModuleCLI: + def test_single_blend_main(self, tmp_path, monkeypatch, capsys): + from obliteratus import blend + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + out = tmp_path / "out" + a_dir.mkdir() + b_dir.mkdir() + _make_model(a_dir, 1.0) + _make_model(b_dir, 2.0) + monkeypatch.setattr( + "sys.argv", + [ + "obliteratus.blend", + "--model-a", str(a_dir), + "--model-b", str(b_dir), + "--alpha", "0.25", + "--output", str(out), + ], + ) + + blend.main() + + assert "3 tensors blended at alpha=0.25" in capsys.readouterr().out + + def test_search_main(self, tmp_path, monkeypatch, capsys): + from obliteratus import blend + + a_dir = tmp_path / "a" + b_dir = tmp_path / "b" + out = tmp_path / "out" + a_dir.mkdir() + b_dir.mkdir() + _make_model(a_dir, 1.0) + _make_model(b_dir, 2.0) + monkeypatch.setattr( + "sys.argv", + [ + "obliteratus.blend", + "--model-a", str(a_dir), + "--model-b", str(b_dir), + "--search", "0.25,0.75", + "--output", str(out), + ], + ) + + blend.main() + + output = capsys.readouterr().out + assert "Created 2 blends" in output + assert "blend_25: alpha=0.25" in output