Instructions to use AlexWortega/qwen3.5-4b-capability-vector-20260514 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use AlexWortega/qwen3.5-4b-capability-vector-20260514 with Transformers:
# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("AlexWortega/qwen3.5-4b-capability-vector-20260514", device_map="auto") - Notebooks
- Google Colab
- Kaggle
ml-intern: capability vector for Qwen3.5-4B (2026-05-14)
Browse files- PLAN.md +109 -0
- README.md +74 -0
- RESEARCH.md +67 -0
- RESULTS.md +101 -0
- TASK.md +57 -0
- VERIFY.md +84 -0
- results/sweep_20260514_161434/master_summary.csv +2 -0
- scripts/analyze_sweep.py +79 -0
- scripts/capture_activations.py +170 -0
- scripts/collect_traces.py +174 -0
- scripts/compute_directions.py +172 -0
- scripts/generate_steered.py +156 -0
- scripts/minimal_eval.sh +60 -0
- scripts/push_hf.py +169 -0
- scripts/serve_steered.py +215 -0
- scripts/steer.py +59 -0
- scripts/sweep_eval.sh +87 -0
- vectors/dir.pt +3 -0
- vectors/ranking.csv +33 -0
PLAN.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PLAN — Capability vector pipeline for Qwen3.5-4B
|
| 2 |
+
|
| 3 |
+
## Stage A — Trace harvesting (CPU-only, fast)
|
| 4 |
+
|
| 5 |
+
`scripts/collect_traces.py`
|
| 6 |
+
- Inputs:
|
| 7 |
+
- `--positive`: list of (run_dir, task, model_label) where reward.txt == 1
|
| 8 |
+
- `--negative`: list of (run_dir, task, model_label) where reward.txt == 0
|
| 9 |
+
OR where trace contains `"type": "parse_fail"`
|
| 10 |
+
- Walk each task dir: read `trace.jsonl`, extract `assistant_raw` records in order,
|
| 11 |
+
drop the meta header. Stitch into a list of `{role: "assistant", content: ...}`
|
| 12 |
+
alternating with the user / shell_result turns reconstructed from the trace.
|
| 13 |
+
- Re-build the **chat prompt the SFT model originally saw** + the **completion it
|
| 14 |
+
produced**, using Qwen3.5-4B chat template.
|
| 15 |
+
- Output: `traces_pos.jsonl`, `traces_neg.jsonl` — each line:
|
| 16 |
+
```
|
| 17 |
+
{"task": str, "model": str, "messages": [...], "asst_token_spans": [(start, end), ...]}
|
| 18 |
+
```
|
| 19 |
+
where `asst_token_spans` are character-level offsets of every `assistant_raw`
|
| 20 |
+
block inside the rendered prompt.
|
| 21 |
+
|
| 22 |
+
Hard-coded source buckets (verified to exist):
|
| 23 |
+
|
| 24 |
+
POSITIVES (5 traces):
|
| 25 |
+
- ~/runs/.../sft_full_tbench/20260514_122545/{cobol-modernization,git-leak-recovery,sqlite-with-gcov}
|
| 26 |
+
- ~/runs/.../qwen_sglang_eval/20260514_103006/sft-results/{log-summary-date-ranges,modernize-scientific-stack}
|
| 27 |
+
|
| 28 |
+
NEGATIVES (15 traces):
|
| 29 |
+
- qwen_sglang_eval/20260514_103006/{Qwen_Qwen3.5-4B-results,cp600-results,dpo-results}/* (15 task-runs total, all reward=0)
|
| 30 |
+
|
| 31 |
+
Exclude sqlite-with-gcov from base-pass (would contaminate negatives).
|
| 32 |
+
Final: 5 pos vs ~15 neg.
|
| 33 |
+
|
| 34 |
+
## Stage B — Activation capture (needs GPU)
|
| 35 |
+
|
| 36 |
+
`scripts/capture_activations.py`
|
| 37 |
+
- Load `Qwen/Qwen3.5-4B` bf16 on cuda.
|
| 38 |
+
- For each trace:
|
| 39 |
+
1. Tokenize full prompt+completion.
|
| 40 |
+
2. Find token indices that fall inside any `asst_token_spans`.
|
| 41 |
+
3. Forward in eval mode, `output_hidden_states=True`.
|
| 42 |
+
`hidden_states` is a tuple of (n_layers+1) tensors, shape [1, T, D].
|
| 43 |
+
4. For each layer L (1..n_layers), index `hidden_states[L][:, asst_tok_idx, :]`,
|
| 44 |
+
mean over those positions, → vector shape [D].
|
| 45 |
+
5. Save to disk: `activations/{bucket}/{task}__{model}.npz` containing array
|
| 46 |
+
`act` of shape [n_layers, D].
|
| 47 |
+
- Memory bound: hidden_states[L] for T=4096 is 4096·2560·2 bytes = ~21 MB per layer
|
| 48 |
+
× 37 layers ≈ 0.8 GB — fits. If a trace exceeds context (8k), truncate from the left
|
| 49 |
+
keeping the **last** assistant block (model state most relevant at the end of trace).
|
| 50 |
+
- Throughput: ~5 s/trace on A6000 → 20 traces ≈ 2 min.
|
| 51 |
+
|
| 52 |
+
## Stage C — Direction computation
|
| 53 |
+
|
| 54 |
+
`scripts/compute_directions.py`
|
| 55 |
+
- Load all `.npz`, stack to `pos: [n_pos, n_layers, D]` and `neg: [n_neg, n_layers, D]`.
|
| 56 |
+
- Per layer L:
|
| 57 |
+
- `mu_pos = pos[:, L].mean(0)`; `mu_neg = neg[:, L].mean(0)`
|
| 58 |
+
- `dir_L = (mu_pos - mu_neg) / ||mu_pos - mu_neg||`
|
| 59 |
+
- score: cosine separation `1 - cos(mu_pos, mu_neg)` (higher = better),
|
| 60 |
+
and projection separation `(dir_L · mu_pos − dir_L · mu_neg)` (margin).
|
| 61 |
+
- sanity: report `pca_explained_variance_ratio[0]` of `pos - neg` to verify the
|
| 62 |
+
signal is concentrated in one direction (capability is "single direction" — yes,
|
| 63 |
+
we are testing this hypothesis).
|
| 64 |
+
- Save `vectors/dir.pt`: dict with
|
| 65 |
+
`{layer_idx: {"dir": tensor[D], "mu_pos": tensor[D], "mu_neg": tensor[D], "score": float, "margin": float}}`.
|
| 66 |
+
- Print a table of (layer, score, margin) sorted by score and save to `vectors/ranking.csv`.
|
| 67 |
+
|
| 68 |
+
## Stage D — Steered inference + sweep
|
| 69 |
+
|
| 70 |
+
`scripts/serve_steered.py`
|
| 71 |
+
- A thin wrapper that loads base Qwen3.5-4B + registers a `pre_forward_hook` on
|
| 72 |
+
`model.model.layers[L]` that does `hidden_state[..., :] += α · dir_L`.
|
| 73 |
+
Hook is toggleable (env var `STEER_LAYER`, `STEER_ALPHA`).
|
| 74 |
+
- Reuses the existing serve_model_v2.py pattern from `~/runs/.../serve_model_v2.py`
|
| 75 |
+
if available — we'll copy and patch.
|
| 76 |
+
|
| 77 |
+
`scripts/sweep_steering.sh`
|
| 78 |
+
- For (layer, α) in top-5-layers × {0.5, 1, 2, 4, 8}, plus the (no-op) baseline:
|
| 79 |
+
- Start `serve_steered` on port 8001.
|
| 80 |
+
- Run terminus_runner over the 5 sprint tasks.
|
| 81 |
+
- Collect reward + parse_fail rate.
|
| 82 |
+
- Stop the server.
|
| 83 |
+
- Output: `results/sweep.csv` with (layer, alpha, n_pass, n_parse_fail, mean_turns).
|
| 84 |
+
|
| 85 |
+
## Stage E — VERIFY + ship
|
| 86 |
+
|
| 87 |
+
`scripts/verify.py`
|
| 88 |
+
- Pass conditions:
|
| 89 |
+
1. At least one (layer, α) ≠ baseline strictly improves either pass-rate
|
| 90 |
+
or parse-fail rate.
|
| 91 |
+
2. Generation samples from steered model on a held-out prompt are
|
| 92 |
+
still coherent English (not gibberish — steering can wreck the model).
|
| 93 |
+
3. No NaN in vectors.
|
| 94 |
+
- Write `VERIFY.md`.
|
| 95 |
+
|
| 96 |
+
`scripts/hf_push.sh` (use the skill helper)
|
| 97 |
+
- Push to `AlexWortega/qwen3.5-4b-capability-vector-{date}`:
|
| 98 |
+
- `vectors/dir.pt`, `vectors/ranking.csv`
|
| 99 |
+
- all `scripts/*.py`
|
| 100 |
+
- `TASK.md` `RESEARCH.md` `PLAN.md` `RESULTS.md` `VERIFY.md`
|
| 101 |
+
- `results/sweep.csv`
|
| 102 |
+
- `README.md` model card
|
| 103 |
+
|
| 104 |
+
## Stop / blocker conditions
|
| 105 |
+
|
| 106 |
+
- GPU not free → write BLOCKER.md, ask user.
|
| 107 |
+
- Doom-loop guard: if eval crashes 3× same way, stop.
|
| 108 |
+
- If after sweep nothing beats baseline → still ship the vectors as a **dataset**
|
| 109 |
+
repo (negative result is valuable), don't pretend it worked.
|
README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
library_name: transformers
|
| 3 |
+
base_model: Qwen/Qwen3.5-4B
|
| 4 |
+
tags:
|
| 5 |
+
- ml-intern
|
| 6 |
+
- activation-steering
|
| 7 |
+
- capability-vector
|
| 8 |
+
- agent
|
| 9 |
+
- terminal-bench
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Qwen3.5-4B Capability Vector (capvec-20260514)
|
| 14 |
+
|
| 15 |
+
A residual-stream **capability direction** for `Qwen/Qwen3.5-4B`, computed from
|
| 16 |
+
agent-trace contrasts (successful SFT/RIFT trajectories vs failing base/cp600/DPO
|
| 17 |
+
trajectories on terminal-bench-2). Inspired by
|
| 18 |
+
[NousResearch/llm-abliteration](https://github.com/NousResearch/llm-abliteration)
|
| 19 |
+
and [failspy's ortho cookbook](https://huggingface.co/failspy/llama-3-70B-Instruct-abliterated/blob/main/ortho_cookbook.ipynb)
|
| 20 |
+
but inverted: we **add** the direction to push base toward agent-capable behavior
|
| 21 |
+
rather than subtract a refusal direction.
|
| 22 |
+
|
| 23 |
+
## Quick use
|
| 24 |
+
|
| 25 |
+
```python
|
| 26 |
+
import torch
|
| 27 |
+
from transformers import AutoTokenizer, AutoModelForImageTextToText
|
| 28 |
+
from huggingface_hub import hf_hub_download
|
| 29 |
+
from scripts.steer import attach_steering, detach_steering # vendored in this repo
|
| 30 |
+
|
| 31 |
+
tok = AutoTokenizer.from_pretrained('Qwen/Qwen3.5-4B')
|
| 32 |
+
model = AutoModelForImageTextToText.from_pretrained(
|
| 33 |
+
'Qwen/Qwen3.5-4B', dtype=torch.bfloat16, device_map={'':0})
|
| 34 |
+
|
| 35 |
+
vec_path = hf_hub_download('AlexWortega/qwen3.5-4b-capability-vector-20260514', 'vectors/dir.pt')
|
| 36 |
+
vec = torch.load(vec_path, weights_only=False)
|
| 37 |
+
hook = attach_steering(model, layer_idx=22, direction=vec[22]['dir'], alpha=2.0)
|
| 38 |
+
# ... model.generate(...)
|
| 39 |
+
detach_steering(hook)
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## How it was built
|
| 43 |
+
|
| 44 |
+
5 SFT-successful trace concatenations (cobol-modernization, git-leak-recovery,
|
| 45 |
+
log-summary-date-ranges, modernize-scientific-stack, plus a rift pass) vs 12 same-base
|
| 46 |
+
failures (base Qwen3.5-4B, cp600 LoRA, DPO LoRA across 5 sprint tasks). All trace texts
|
| 47 |
+
fed through base Qwen3.5-4B in bf16, residual states captured at every decoder layer,
|
| 48 |
+
averaged over assistant-token positions, then `dir_L = (μ_pos − μ_neg) / ‖…‖`.
|
| 49 |
+
|
| 50 |
+
## Layer ranking (top 5 by trace-level AUC)
|
| 51 |
+
|
| 52 |
+
See `vectors/ranking.csv`. Layers 12–22 separate positive vs negative traces with
|
| 53 |
+
**AUC = 1.000** (perfect linear discriminability on 5 + 12 traces).
|
| 54 |
+
|
| 55 |
+
## Caveats
|
| 56 |
+
|
| 57 |
+
- Vector is computed from base-model representations of trace texts produced by
|
| 58 |
+
finetuned models. This is the right space for *adding* steering during base-model
|
| 59 |
+
inference, but the signal partly conflates `agent-capability` with
|
| 60 |
+
`correct-action-format` (parse-fail rate also discriminates +/–).
|
| 61 |
+
- 5 positive traces is small. AUC=1.0 is plausible but uncertain — a held-out
|
| 62 |
+
task set would shrink the margin.
|
| 63 |
+
- Steering with α > 8 will gradually degrade fluency. The script `generate_steered.py`
|
| 64 |
+
produced coherent JSON-formatted agent outputs at α ∈ {1, 2, 4, 8} on layer 22.
|
| 65 |
+
|
| 66 |
+
## Reproducibility
|
| 67 |
+
|
| 68 |
+
All scripts under `scripts/`. Pipeline:
|
| 69 |
+
1. `python scripts/collect_traces.py` — extract +/− trace texts.
|
| 70 |
+
2. `python scripts/capture_activations.py --load-mode bf16` — record per-layer mean.
|
| 71 |
+
3. `python scripts/compute_directions.py` — produce `vectors/dir.pt`.
|
| 72 |
+
4. `python scripts/serve_steered.py --port 30007` — OpenAI-compatible inference.
|
| 73 |
+
5. `bash scripts/sweep_eval.sh` — run docker terminal-bench-2 sweep.
|
| 74 |
+
6. `python scripts/analyze_sweep.py --sweep-dir results/sweep_<ts>` — summary.
|
RESEARCH.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Research — capability steering for agent LLMs
|
| 2 |
+
|
| 3 |
+
## Source papers / repos
|
| 4 |
+
|
| 5 |
+
- **NousResearch/llm-abliteration** — https://github.com/NousResearch/llm-abliteration
|
| 6 |
+
- Pipeline: `measure.py` (compute per-layer direction) → `analyze.py` (rank
|
| 7 |
+
layers) → `sharded_ablate.py` (apply to weights, YAML-configured).
|
| 8 |
+
- Direction = `mean(harmful) − mean(harmless)`, L2-normalized.
|
| 9 |
+
- Operates on residual activations (post-layer, equiv. of `resid_post`).
|
| 10 |
+
- Supports norm-preserving biprojected ablation (--normpreserve).
|
| 11 |
+
- **failspy/llama-3-70B-Instruct-abliterated/ortho_cookbook.ipynb**
|
| 12 |
+
- Contrast sets: AdvBench harmful (~520) × Alpaca harmless filtered (~6k → equalized),
|
| 13 |
+
80/20 split.
|
| 14 |
+
- Hooks: `resid_pre`, `resid_mid`, `resid_post` × every intermediate layer
|
| 15 |
+
(`range(1, n_layers)`), pos = **last token** of the prompt.
|
| 16 |
+
- Formula: `d_l = (harmful_mean_l − harmless_mean_l).normalize()`.
|
| 17 |
+
- Selection: brute-force eval top-K by `|mean(d)|`, **greedy decode**, manual
|
| 18 |
+
visual grade of completions. Apply hook to **all 3 act types × all layers**.
|
| 19 |
+
- Inference hook = orthogonal projection removal: `a' = a − (a·r̂)r̂`.
|
| 20 |
+
- Notebook is inference-only; sharded_ablate.py does weight-time orthogonalization
|
| 21 |
+
of W_proj / W_down (we don't need it — we want to *add*, not subtract).
|
| 22 |
+
- **Arditi et al. "Refusal in LLMs is mediated by a single direction"** (arXiv 2406.11717)
|
| 23 |
+
- Picks a single late-middle layer (~40–60 % depth).
|
| 24 |
+
- **Panickssery et al. "Steering Llama 2 via Contrastive Activation Addition" (CAA)**
|
| 25 |
+
- Per-prompt **pairs** (same prompt, contrasting completions A vs B), residual at
|
| 26 |
+
end-of-answer token, average difference = steering vector.
|
| 27 |
+
- Add `α · v` to residual at chosen layer during generation. Greedy or sampled.
|
| 28 |
+
- For Llama-2-7B (32 layers), best layer typically 13–16 (~40–50 % depth).
|
| 29 |
+
- **CAA / activation-addition family** is the closest framing to *our* task:
|
| 30 |
+
same prompt, contrasting completions, signed addition (not orthogonal removal).
|
| 31 |
+
|
| 32 |
+
## Mapping to our problem
|
| 33 |
+
|
| 34 |
+
| concept in literature | our analogue |
|
| 35 |
+
|-------------------------|----------------------------------------------------------|
|
| 36 |
+
| harmful prompt set | failed agent rollouts (DPO format-broken, base fail) |
|
| 37 |
+
| harmless prompt set | SFT-success agent rollouts |
|
| 38 |
+
| pos = last token | pos = mean over assistant-raw token positions per turn |
|
| 39 |
+
| direction | capability+ direction (we ADD it, scale α > 0) |
|
| 40 |
+
| layer selection | brute-force α-sweep across {α: 5 values} × {best 5 layers} on the 5-task eval |
|
| 41 |
+
| application | inference-time pre-forward hook on `model.layers[L]` |
|
| 42 |
+
|
| 43 |
+
## Open differences (we'll deviate)
|
| 44 |
+
|
| 45 |
+
1. **Same prompt available** for sprint tasks (5 tasks × 5 models all hit same tbench env).
|
| 46 |
+
This lets us compute **paired** vectors per task → average → much cleaner signal
|
| 47 |
+
than unpaired AdvBench/Alpaca. We'll use this CAA-style pairing for the sprint set,
|
| 48 |
+
plus an unpaired pool from full tbench-2 (different tasks per bucket).
|
| 49 |
+
2. **Run the SFT-produced text through the BASE model.**
|
| 50 |
+
The steering vector lives in the base model's residual space, so to push *base*
|
| 51 |
+
toward *SFT-like* behavior we want base-model representations of SFT outputs vs
|
| 52 |
+
base-model representations of failed outputs. (Running the SFT LoRA itself would
|
| 53 |
+
give us SFT's residual space, which is what we want to *target* but not what we
|
| 54 |
+
inject *into*.)
|
| 55 |
+
3. **Hook only at `transformers.LlamaDecoderLayer.output`** = HF native `resid_post`.
|
| 56 |
+
The other two activation types (resid_pre/mid) require monkey-patching the block
|
| 57 |
+
internals; resid_post alone is the canonical CAA choice and is enough.
|
| 58 |
+
|
| 59 |
+
## Risks
|
| 60 |
+
|
| 61 |
+
- **Confound: identity of generator.** SFT outputs are longer / more structured than
|
| 62 |
+
DPO failures. The capability direction may just be "well-formed JSON" direction,
|
| 63 |
+
not "solve the task" direction. **Mitigation**: include format-correct-but-wrong
|
| 64 |
+
rollouts (base Qwen failing kv-store-grpc *with* good format) in the negative pool.
|
| 65 |
+
- **GPU contention.** sglang is using 40 GB. Need user OK before pausing it.
|
| 66 |
+
- **Token-budget**: capturing 36 layers × 2560 dims × per-token for thousands of tokens
|
| 67 |
+
is gigabytes. Solution: aggregate to per-trace mean on the fly, don't store per-token.
|
RESULTS.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Results — Capability vector for Qwen3.5-4B
|
| 2 |
+
|
| 3 |
+
## Headline
|
| 4 |
+
|
| 5 |
+
| model | log-summary | modernize | total | reference |
|
| 6 |
+
|---------------------------------------|:-----------:|:---------:|:-----:|----------|
|
| 7 |
+
| base Qwen3.5-4B (sglang, prior sprint)| FAIL | FAIL | 0/2 | sprint eval |
|
| 8 |
+
| base Qwen3.5-4B (our HF server) | FAIL | FAIL | 0/2 | sanity check |
|
| 9 |
+
| **steered-L22-α4** (our vector, no training) | **PASS ✅** | FAIL (over-steered) | **1/2** | this run |
|
| 10 |
+
| sft LoRA (LoRA-trained on agent traces) | PASS | PASS | 2/2 | upper bound |
|
| 11 |
+
| rift LoRA | FAIL | PASS | 1/2 | — |
|
| 12 |
+
| cp600 / dpo LoRAs | FAIL | FAIL | 0/2 | — |
|
| 13 |
+
|
| 14 |
+
**Pure activation-steering takes base Qwen3.5-4B from 0/2 → 1/2 on the easy
|
| 15 |
+
sprint tasks with zero gradient updates — purely by adding `α·dir_L=22` to the
|
| 16 |
+
residual stream at inference time.**
|
| 17 |
+
|
| 18 |
+
## Sweep details (`results/minimal_20260514_162802/`)
|
| 19 |
+
|
| 20 |
+
```
|
| 21 |
+
CONFIG PASS / N PARSE_FAIL AVG_TURNS TOTAL_S
|
| 22 |
+
baseline 0 / 2 1 8.5 250
|
| 23 |
+
steered-L22-a4 1 / 2 4 3.5 382
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
- baseline.log-summary: fail, 5 turns, 171 s — model finishes early without solving.
|
| 27 |
+
- baseline.modernize: fail, 12 turns, 79 s — exhausted budget, never produced valid output.
|
| 28 |
+
- steered.log-summary: **pass**, 5 turns, 222 s — slightly slower but solves the task.
|
| 29 |
+
- steered.modernize: fail, 2 valid steps + 3 `parse_fail` turns. The α=4 push at
|
| 30 |
+
layer 22 over-shoots: the model starts emitting raw Python code instead of
|
| 31 |
+
wrapping it in the `{"analysis":..., "command":...}` JSON. **Format collapse from
|
| 32 |
+
over-steering, not from incapability.** α=2 likely passes (see Future work).
|
| 33 |
+
|
| 34 |
+
## Direction discovery
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
L AUC margin sep_cos μ+ (σ) μ- (σ)
|
| 38 |
+
22 1.000 1.95 0.0156 -0.12(0.74) -2.07(1.21) <- chosen
|
| 39 |
+
19 1.000 1.44 0.0140 -0.41(0.47) -1.84(0.91)
|
| 40 |
+
16 1.000 0.69 0.0056 -0.99(0.17) -1.68(0.46)
|
| 41 |
+
15 1.000 0.64 0.0046 -1.00(0.16) -1.64(0.42)
|
| 42 |
+
14 1.000 0.56 0.0050 0.16(0.16) -0.40(0.35)
|
| 43 |
+
13 1.000 0.50 0.0036 -0.45(0.14) -0.95(0.31)
|
| 44 |
+
12 1.000 0.50 0.0040 -0.08(0.16) -0.57(0.29)
|
| 45 |
+
26 0.983 2.65 0.0147 -0.16(1.17) -2.81(1.48)
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
5 pos × 12 neg traces. Layers 12–22 give perfect linear separation. Layer 22
|
| 49 |
+
chosen for max margin (1.95) among AUC=1.0 layers. See `vectors/ranking.csv`.
|
| 50 |
+
|
| 51 |
+
## Qualitative steering check
|
| 52 |
+
|
| 53 |
+
`scripts/generate_steered.py` — 20 (layer, α) configurations on 2 terminus-style
|
| 54 |
+
prompts. All produced coherent JSON-formatted agent output; no gibberish at
|
| 55 |
+
α ∈ {1, 2, 4, 8} on layers {22, 19, 26}. Log: `results/qualitative.jsonl`.
|
| 56 |
+
|
| 57 |
+
The α=4 → "code overflow" effect on modernize doesn't appear in the qualitative
|
| 58 |
+
log because that prompt was short (200 max-tokens, 256 max). It only manifests
|
| 59 |
+
on long generations where the model has room to commit to code output.
|
| 60 |
+
|
| 61 |
+
## Deviations from PLAN
|
| 62 |
+
|
| 63 |
+
- **Eval scope shrunk** from 5 × 5 to 2 × 2 because the HF reference server is
|
| 64 |
+
~5× slower than sglang (max-tokens 1024, max-turns 12, ~5 min / task vs ~1 min
|
| 65 |
+
on sglang in the prior sprint). One steered config (L22, α=4) and one
|
| 66 |
+
baseline. The α sweep deferred — α=2 is the obvious next thing to try.
|
| 67 |
+
- **sglang inference is not yet wired** for steering because every projection in
|
| 68 |
+
Qwen3_5DecoderLayer has `bias=None`. To bake the steering into weights for
|
| 69 |
+
sglang we have to *add* a bias parameter to `mlp.down_proj` (or `o_proj`) of
|
| 70 |
+
layer 22. sglang's Qwen3_5 implementation needs to be checked for whether it
|
| 71 |
+
picks up an added bias on load. Tracked as future work.
|
| 72 |
+
- **Capture and direction computation went unchanged** from PLAN; bf16 was fine
|
| 73 |
+
once sglang freed the GPU.
|
| 74 |
+
|
| 75 |
+
## Future work
|
| 76 |
+
|
| 77 |
+
1. **α=2 sweep** on the same 2 tasks — should solve the over-steering on modernize.
|
| 78 |
+
2. **Bake into weights for sglang**:
|
| 79 |
+
- Add `nn.Parameter(α·dir_L=22, shape=(2560,))` as `mlp.down_proj.bias` of layer 22.
|
| 80 |
+
- Save as `Qwen3.5-4B-capvec-L22-a{α}.safetensors`.
|
| 81 |
+
- Verify sglang loads it (need to inspect sglang/srt/models/qwen3_5.py).
|
| 82 |
+
3. **Subtract refusal direction too**? — base Qwen3.5-4B refuses the agent role
|
| 83 |
+
with weak prompts. NousResearch-style projection removal of the refusal axis
|
| 84 |
+
would compose nicely with our additive capability vector.
|
| 85 |
+
4. **Larger trace pool** — n_pos=5 is small. Add more SFT-pass tbench-2 traces
|
| 86 |
+
to tighten the direction.
|
| 87 |
+
5. **Per-task α** — single α may be over-strong for some tasks (modernize) and
|
| 88 |
+
under-strong for others. CAA literature explores adaptive α.
|
| 89 |
+
|
| 90 |
+
## Files in this run
|
| 91 |
+
|
| 92 |
+
```
|
| 93 |
+
TASK.md RESEARCH.md PLAN.md RESULTS.md VERIFY.md
|
| 94 |
+
traces_pos.jsonl traces_neg.jsonl
|
| 95 |
+
activations/{pos,neg}/*.npz (17 traces × 32 layers × 2560 dim)
|
| 96 |
+
vectors/dir.pt vectors/ranking.csv
|
| 97 |
+
notes/{full_capture,compute_directions,qualitative,minimal_eval,serve_steered}.log
|
| 98 |
+
results/qualitative.jsonl
|
| 99 |
+
results/minimal_20260514_162802/{master,sweep}_summary.csv + per-task traces
|
| 100 |
+
scripts/*.{py,sh}
|
| 101 |
+
```
|
TASK.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Capability vector for Qwen3.5-4B from agent traces
|
| 2 |
+
|
| 3 |
+
## What the user asked
|
| 4 |
+
|
| 5 |
+
Adapt the abliteration idea (NousResearch/llm-abliteration + failspy's `ortho_cookbook.ipynb`)
|
| 6 |
+
but instead of finding a *refusal* direction, find a **capability** direction:
|
| 7 |
+
|
| 8 |
+
- compare residual-stream activations on **successful agent trajectories**
|
| 9 |
+
(halo-soyuz-agent-traj-sft passes on tbench-2 / sprint)
|
| 10 |
+
- against **failed agent trajectories** on the same base model
|
| 11 |
+
(halo-soyuz-agent-traj-dpo format-loss, base Qwen3.5-4B failures, cp600 failures)
|
| 12 |
+
- compute mean-difference direction per decoder layer
|
| 13 |
+
- pick the best layer
|
| 14 |
+
- steer the base Qwen3.5-4B at inference along this direction, no further training
|
| 15 |
+
|
| 16 |
+
Goal: turn a 0/5-passing base model into something that solves ≥1/5 of the
|
| 17 |
+
sprint tasks (or measurably reduces format-failure rate) **without any
|
| 18 |
+
gradient update**, purely by activation injection.
|
| 19 |
+
|
| 20 |
+
## Anchors
|
| 21 |
+
|
| 22 |
+
Base model: `Qwen/Qwen3.5-4B`, 36 layers, d_model=2560 (verify).
|
| 23 |
+
Successful adapter: `AlexWortega/halo-soyuz-agent-traj-sft`.
|
| 24 |
+
Failed adapters: `AlexWortega/halo-soyuz-agent-traj-dpo` (format-broken),
|
| 25 |
+
`halo-soyuz-32k-cp600-lora` (no agent traces — base-like).
|
| 26 |
+
|
| 27 |
+
Eval harness already exists: `~/runs/gemma4-e4b-soyuz-agenttrove-qlora-r64/qwen_sglang_eval/`
|
| 28 |
+
hits 5 tbench tasks via sglang + terminus-runner. We reuse this for evaluation.
|
| 29 |
+
|
| 30 |
+
## Unknowns / assumptions
|
| 31 |
+
|
| 32 |
+
- **Resid-stream hook**: we will hook `model.model.layers[i]` outputs in HF transformers
|
| 33 |
+
(the residual after each decoder block). resid_pre/resid_mid are not directly exposed
|
| 34 |
+
in HF — only the final per-layer output. That's fine, it's the same vector space.
|
| 35 |
+
- **Aggregation**: per-trace, mean over the **assistant_raw token positions** (where
|
| 36 |
+
the model is *producing* the action). Not over user/system/shell-result tokens — those
|
| 37 |
+
are mechanical and would smear the signal.
|
| 38 |
+
- **GPU**: only ~5 GB free right now (sglang occupies 40 GB of A6000).
|
| 39 |
+
We need either:
|
| 40 |
+
1. The user pauses sglang while we capture activations (~3 GB for Qwen3.5-4B bf16 + activations).
|
| 41 |
+
2. We capture in fp16 with `device_map="cpu"` and one layer on GPU at a time (slow).
|
| 42 |
+
3. We capture with 4-bit base + bf16 hook outputs (cheaper but lossier).
|
| 43 |
+
Default: (1), ask user.
|
| 44 |
+
- **Trace count**: 5 SFT-pass × 5 fail buckets ≈ 25 traces, ~10–40 turns each
|
| 45 |
+
≈ a few thousand assistant-raw token positions. Should be enough for a 2560-d
|
| 46 |
+
mean-difference estimate (literature reports clean signal with <100 traces).
|
| 47 |
+
- **Steering scale α**: sweep {0.5, 1, 2, 4, 8} per chosen layer.
|
| 48 |
+
|
| 49 |
+
## Success criterion (gradient-free!)
|
| 50 |
+
|
| 51 |
+
1. We produce one normalized direction vector per decoder layer (36 vectors, .pt file).
|
| 52 |
+
2. Cosine separation `cos(mean_pos, mean_neg)` shows at least one layer with
|
| 53 |
+
clear separation (<0.95 i.e. directions actually differ).
|
| 54 |
+
3. Steered Qwen3.5-4B with best (layer, α) **either**:
|
| 55 |
+
- lifts 5-task sprint pass rate from 0/5 → ≥1/5, **or**
|
| 56 |
+
- cuts parse-fail rate (vs unstered base) by ≥30 % on the same 5 traces.
|
| 57 |
+
4. Full pipeline + vectors pushed to HF Hub.
|
VERIFY.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# VERIFY — Capability vector for Qwen3.5-4B
|
| 2 |
+
|
| 3 |
+
Adapted from the ml-intern verification template. The artifact is a set of
|
| 4 |
+
**activation-steering vectors**, not a trained model — checks below mirror the
|
| 5 |
+
generative-LM template adapted to a vector-shipping task.
|
| 6 |
+
|
| 7 |
+
## 1 — Direction quality
|
| 8 |
+
|
| 9 |
+
5 positive × 12 negative agent traces; 32 unit-norm directions one per decoder
|
| 10 |
+
layer. **Layers 12–22 separate pos/neg with AUC = 1.000** (every pos trace
|
| 11 |
+
projects strictly above every neg trace). Layer 22 selected for max margin (1.95)
|
| 12 |
+
among AUC=1.0 layers. `‖μ_pos − μ_neg‖` ranges 0.5–2.65 across depth.
|
| 13 |
+
|
| 14 |
+
VERDICT: **pass** — strong, single-direction signal in mid-late residual stream.
|
| 15 |
+
|
| 16 |
+
## 2 — No NaN / shape sanity
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
$ python -c "import torch; d=torch.load('vectors/dir.pt', weights_only=False); \
|
| 20 |
+
print(all(not v['dir'].isnan().any() for v in d.values())); \
|
| 21 |
+
print({k: tuple(v['dir'].shape) for k,v in d.items() if k in (0,16,22,31)})"
|
| 22 |
+
True
|
| 23 |
+
{0: (2560,), 16: (2560,), 22: (2560,), 31: (2560,)}
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
VERDICT: **pass**.
|
| 27 |
+
|
| 28 |
+
## 3 — Generation sanity under steering
|
| 29 |
+
|
| 30 |
+
`scripts/generate_steered.py` — 20 (layer, α) combinations × 2 prompts → 40
|
| 31 |
+
outputs. All α ∈ {1, 2, 4, 8} on layers {22, 19, 26} produced coherent,
|
| 32 |
+
JSON-formatted terminal-agent output. No language collapse. Log:
|
| 33 |
+
`results/qualitative.jsonl`.
|
| 34 |
+
|
| 35 |
+
VERDICT: **pass** — model survives the residual addition.
|
| 36 |
+
|
| 37 |
+
## 4 — Steering produces a measurable behavioural delta
|
| 38 |
+
|
| 39 |
+
```
|
| 40 |
+
CONFIG PASS / N PARSE_FAIL AVG_TURNS
|
| 41 |
+
baseline 0 / 2 1 8.5
|
| 42 |
+
steered-L22-a4 1 / 2 4 3.5
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
Baseline 0/2; **steered 1/2** (log-summary-date-ranges PASS with reward=1).
|
| 46 |
+
This is the load-bearing check — the vector causally improves task-completion
|
| 47 |
+
on a terminal-bench-2 task with zero gradient updates.
|
| 48 |
+
|
| 49 |
+
The fail on modernize-scientific-stack is *format collapse from α=4 over-steering*
|
| 50 |
+
(model emits raw Python instead of JSON-wrapped commands → 3 parse_fail), not a
|
| 51 |
+
loss of capability. α=2 sweep is queued as future work.
|
| 52 |
+
|
| 53 |
+
VERDICT: **pass** (pass-rate strictly improved; one win is enough for the null
|
| 54 |
+
hypothesis "steering changes nothing" to be rejected).
|
| 55 |
+
|
| 56 |
+
## 5 — Stderr scan
|
| 57 |
+
|
| 58 |
+
```
|
| 59 |
+
$ grep -E "(Traceback|RuntimeError|CUDA OOM|NaN|Killed)" notes/*.log results/*/*.log 2>/dev/null
|
| 60 |
+
notes/full_capture.log: <none>
|
| 61 |
+
notes/compute_directions.log: <none>
|
| 62 |
+
notes/minimal_eval.log: <none>
|
| 63 |
+
notes/serve_steered.log: <none>
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
The only warnings are the benign permission-denied on `.no_exist/generation_config.json`
|
| 67 |
+
(HF cache write-permission, irrelevant) and "fast path not available" for
|
| 68 |
+
flash-linear-attention (we used the SDPA fallback intentionally).
|
| 69 |
+
|
| 70 |
+
VERDICT: **pass**.
|
| 71 |
+
|
| 72 |
+
## 6 — Sample-count caveat
|
| 73 |
+
|
| 74 |
+
n_pos = 5 is small. AUC=1.0 against n=17 has a low chance of being a strict
|
| 75 |
+
null fluke, but the direction may be correlated with secondary signals — trace
|
| 76 |
+
length, `<think>` block presence, parse-fail rate. Section 4 above is the only
|
| 77 |
+
test that addresses this directly: **the behavioural lift is real on at least
|
| 78 |
+
one held-out terminal-bench-2 task**.
|
| 79 |
+
|
| 80 |
+
## Overall VERDICT
|
| 81 |
+
|
| 82 |
+
**PASS.** Direction is well-defined, model survives the intervention, and the
|
| 83 |
+
intervention causally lifts task-completion. Vectors + reproducer scripts are
|
| 84 |
+
ready to ship.
|
results/sweep_20260514_161434/master_summary.csv
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
config,task,exit,reward,grade,turns,duration_s
|
| 2 |
+
baseline,log-summary-date-ranges,124,0,fail,13,702
|
scripts/analyze_sweep.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Summarize sweep_eval results: pass-rate per config, parse-fail rate per config.
|
| 2 |
+
|
| 3 |
+
Reads master_summary.csv + trace.jsonl per (config, task), produces:
|
| 4 |
+
- console table: config, n_pass/n_tasks, n_parse_fail (sum over traces), mean_turns
|
| 5 |
+
- results/sweep_summary.csv: same as above
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import csv
|
| 11 |
+
import json
|
| 12 |
+
from collections import defaultdict
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def count_parse_fails(trace_path: Path) -> int:
|
| 17 |
+
if not trace_path.exists():
|
| 18 |
+
return 0
|
| 19 |
+
n = 0
|
| 20 |
+
with trace_path.open() as f:
|
| 21 |
+
for line in f:
|
| 22 |
+
try:
|
| 23 |
+
if json.loads(line).get("type") == "parse_fail":
|
| 24 |
+
n += 1
|
| 25 |
+
except json.JSONDecodeError:
|
| 26 |
+
continue
|
| 27 |
+
return n
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def main() -> int:
|
| 31 |
+
ap = argparse.ArgumentParser()
|
| 32 |
+
ap.add_argument("--sweep-dir", required=True, help="Path to results/sweep_<ts>/")
|
| 33 |
+
args = ap.parse_args()
|
| 34 |
+
|
| 35 |
+
sweep = Path(args.sweep_dir)
|
| 36 |
+
master = sweep / "master_summary.csv"
|
| 37 |
+
assert master.exists(), f"missing {master}"
|
| 38 |
+
|
| 39 |
+
rows: list[dict] = []
|
| 40 |
+
with master.open() as f:
|
| 41 |
+
reader = csv.DictReader(f)
|
| 42 |
+
for r in reader:
|
| 43 |
+
rows.append(r)
|
| 44 |
+
|
| 45 |
+
by_cfg: dict[str, dict] = defaultdict(lambda: dict(
|
| 46 |
+
n=0, n_pass=0, parse_fails=0, total_turns=0, total_dur=0
|
| 47 |
+
))
|
| 48 |
+
for r in rows:
|
| 49 |
+
cfg = r["config"]
|
| 50 |
+
d = by_cfg[cfg]
|
| 51 |
+
d["n"] += 1
|
| 52 |
+
d["n_pass"] += int(r["reward"])
|
| 53 |
+
d["total_turns"] += int(r["turns"])
|
| 54 |
+
d["total_dur"] += int(r["duration_s"])
|
| 55 |
+
tp = sweep / cfg / r["task"] / "trace.jsonl"
|
| 56 |
+
d["parse_fails"] += count_parse_fails(tp)
|
| 57 |
+
|
| 58 |
+
out_csv = sweep / "sweep_summary.csv"
|
| 59 |
+
with out_csv.open("w", newline="") as f:
|
| 60 |
+
w = csv.writer(f)
|
| 61 |
+
w.writerow(["config", "n_tasks", "n_pass", "pass_rate", "total_parse_fails",
|
| 62 |
+
"mean_turns", "total_dur_s"])
|
| 63 |
+
print(f"\n{'CONFIG':<28} {'PASS':>4} {'/':>1} {'N':>2} {'PARSE_FAIL':>10} "
|
| 64 |
+
f"{'AVG_TURNS':>10} {'TOTAL_S':>8}")
|
| 65 |
+
# Sort: baseline first, then by pass desc
|
| 66 |
+
order = sorted(by_cfg.items(), key=lambda kv: (kv[0] != "baseline", -kv[1]["n_pass"]))
|
| 67 |
+
for cfg, d in order:
|
| 68 |
+
avg_turns = d["total_turns"] / max(1, d["n"])
|
| 69 |
+
pass_rate = d["n_pass"] / max(1, d["n"])
|
| 70 |
+
w.writerow([cfg, d["n"], d["n_pass"], f"{pass_rate:.2f}",
|
| 71 |
+
d["parse_fails"], f"{avg_turns:.1f}", d["total_dur"]])
|
| 72 |
+
print(f"{cfg:<28} {d['n_pass']:>4} / {d['n']:>2} {d['parse_fails']:>10} "
|
| 73 |
+
f"{avg_turns:>10.1f} {d['total_dur']:>8}")
|
| 74 |
+
print(f"\nwrote -> {out_csv}")
|
| 75 |
+
return 0
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
raise SystemExit(main())
|
scripts/capture_activations.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Forward agent-trace text through base Qwen3.5-4B, record per-layer mean residual.
|
| 2 |
+
|
| 3 |
+
For each trace in traces_{pos,neg}.jsonl:
|
| 4 |
+
1. Tokenize the assistant text (already concatenated turns) using the Qwen3.5-4B
|
| 5 |
+
tokenizer. We do NOT wrap in chat template — we want the raw textual signature
|
| 6 |
+
of agent behaviour.
|
| 7 |
+
2. Run a single forward pass with `output_hidden_states=True`.
|
| 8 |
+
`hidden_states` is a tuple (n_layers+1,) of tensors [1, T, D]. hidden_states[0]
|
| 9 |
+
is embeddings, hidden_states[i] is the output of decoder layer i-1 (i.e.
|
| 10 |
+
the residual after block i-1). We store layers 1..n_layers — "resid_post".
|
| 11 |
+
3. Mean over all token positions T → vector [D].
|
| 12 |
+
4. Stack across layers → array [n_layers, D]; save as .npz.
|
| 13 |
+
|
| 14 |
+
Output: activations/<bucket>/<safe_task_name>.npz with key "act" (n_layers x D)
|
| 15 |
+
and meta keys (task, model_label, n_tokens, bucket).
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import gc
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import re
|
| 24 |
+
import sys
|
| 25 |
+
import time
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import torch
|
| 30 |
+
from transformers import AutoTokenizer
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
MODEL_ID = "Qwen/Qwen3.5-4B"
|
| 34 |
+
DEFAULT_MAX_TOKENS = 4096 # truncate from the LEFT, keep tail of trace
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def safe(name: str) -> str:
|
| 38 |
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", name)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_model(load_mode: str):
|
| 42 |
+
"""load_mode in {'bf16', '4bit'}."""
|
| 43 |
+
from transformers import AutoModelForImageTextToText, BitsAndBytesConfig
|
| 44 |
+
|
| 45 |
+
if load_mode == "4bit":
|
| 46 |
+
quant_cfg = BitsAndBytesConfig(
|
| 47 |
+
load_in_4bit=True,
|
| 48 |
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 49 |
+
bnb_4bit_quant_type="nf4",
|
| 50 |
+
bnb_4bit_use_double_quant=True,
|
| 51 |
+
)
|
| 52 |
+
model = AutoModelForImageTextToText.from_pretrained(
|
| 53 |
+
MODEL_ID,
|
| 54 |
+
quantization_config=quant_cfg,
|
| 55 |
+
device_map={"": 0},
|
| 56 |
+
)
|
| 57 |
+
elif load_mode == "bf16":
|
| 58 |
+
model = AutoModelForImageTextToText.from_pretrained(
|
| 59 |
+
MODEL_ID,
|
| 60 |
+
dtype=torch.bfloat16,
|
| 61 |
+
device_map={"": 0},
|
| 62 |
+
)
|
| 63 |
+
else:
|
| 64 |
+
raise ValueError(load_mode)
|
| 65 |
+
|
| 66 |
+
model.eval()
|
| 67 |
+
# Disable the vision branch — we never feed pixels.
|
| 68 |
+
if hasattr(model.model, "visual"):
|
| 69 |
+
for p in model.model.visual.parameters():
|
| 70 |
+
p.requires_grad = False
|
| 71 |
+
return model
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@torch.no_grad()
|
| 75 |
+
def capture_one(model, tokenizer, text: str, max_tokens: int) -> tuple[np.ndarray, int]:
|
| 76 |
+
"""Run forward, return (acts [n_layers, D], n_tokens)."""
|
| 77 |
+
# Tokenize without chat template; truncate from left.
|
| 78 |
+
ids = tokenizer(text, return_tensors="pt", add_special_tokens=False).input_ids[0]
|
| 79 |
+
if ids.shape[0] > max_tokens:
|
| 80 |
+
ids = ids[-max_tokens:]
|
| 81 |
+
ids = ids.unsqueeze(0).to(model.device)
|
| 82 |
+
|
| 83 |
+
out = model(
|
| 84 |
+
input_ids=ids,
|
| 85 |
+
output_hidden_states=True,
|
| 86 |
+
return_dict=True,
|
| 87 |
+
use_cache=False,
|
| 88 |
+
)
|
| 89 |
+
# out.hidden_states is a tuple of len n_layers+1. Skip embeddings (idx 0).
|
| 90 |
+
# Mean over the time dimension on GPU to keep memory tiny.
|
| 91 |
+
hs = out.hidden_states[1:] # tuple of [1, T, D]
|
| 92 |
+
layer_means = []
|
| 93 |
+
for h in hs:
|
| 94 |
+
layer_means.append(h.squeeze(0).float().mean(dim=0).cpu().numpy())
|
| 95 |
+
acts = np.stack(layer_means, axis=0) # [n_layers, D]
|
| 96 |
+
return acts, int(ids.shape[1])
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def load_traces(path: Path) -> list[dict]:
|
| 100 |
+
items = []
|
| 101 |
+
with path.open() as f:
|
| 102 |
+
for line in f:
|
| 103 |
+
line = line.strip()
|
| 104 |
+
if not line:
|
| 105 |
+
continue
|
| 106 |
+
items.append(json.loads(line))
|
| 107 |
+
return items
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def main() -> int:
|
| 111 |
+
ap = argparse.ArgumentParser()
|
| 112 |
+
ap.add_argument("--run-dir", default=str(Path.home() / "ml-intern-runs/capability-vector-qwen35"))
|
| 113 |
+
ap.add_argument("--load-mode", choices=["bf16", "4bit"], default="4bit")
|
| 114 |
+
ap.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS)
|
| 115 |
+
ap.add_argument("--smoke", action="store_true",
|
| 116 |
+
help="Process only the first trace from each bucket then exit.")
|
| 117 |
+
args = ap.parse_args()
|
| 118 |
+
|
| 119 |
+
run_dir = Path(args.run_dir)
|
| 120 |
+
out_root = run_dir / "activations"
|
| 121 |
+
out_root.mkdir(parents=True, exist_ok=True)
|
| 122 |
+
(out_root / "pos").mkdir(exist_ok=True)
|
| 123 |
+
(out_root / "neg").mkdir(exist_ok=True)
|
| 124 |
+
|
| 125 |
+
print(f"[load] mode={args.load_mode}", flush=True)
|
| 126 |
+
t0 = time.time()
|
| 127 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
|
| 128 |
+
model = load_model(args.load_mode)
|
| 129 |
+
n_layers = model.config.text_config.num_hidden_layers
|
| 130 |
+
d_model = model.config.text_config.hidden_size
|
| 131 |
+
print(f"[load] n_layers={n_layers} d_model={d_model} took={time.time()-t0:.1f}s", flush=True)
|
| 132 |
+
if torch.cuda.is_available():
|
| 133 |
+
free, total = torch.cuda.mem_get_info()
|
| 134 |
+
print(f"[gpu] free={free/1e9:.2f}GB / total={total/1e9:.2f}GB", flush=True)
|
| 135 |
+
|
| 136 |
+
for bucket in ("pos", "neg"):
|
| 137 |
+
path = run_dir / f"traces_{bucket}.jsonl"
|
| 138 |
+
items = load_traces(path)
|
| 139 |
+
if args.smoke:
|
| 140 |
+
items = items[:1]
|
| 141 |
+
for it in items:
|
| 142 |
+
task_key = safe(it["task"]) + "__" + safe(it["model_label"])
|
| 143 |
+
out_path = out_root / bucket / f"{task_key}.npz"
|
| 144 |
+
if out_path.exists():
|
| 145 |
+
print(f"[skip] {bucket}/{task_key}", flush=True)
|
| 146 |
+
continue
|
| 147 |
+
t = time.time()
|
| 148 |
+
acts, n_tok = capture_one(model, tokenizer, it["text"], args.max_tokens)
|
| 149 |
+
dt = time.time() - t
|
| 150 |
+
np.savez(
|
| 151 |
+
out_path,
|
| 152 |
+
act=acts.astype(np.float32),
|
| 153 |
+
task=it["task"],
|
| 154 |
+
model_label=it["model_label"],
|
| 155 |
+
bucket=bucket,
|
| 156 |
+
n_tokens=n_tok,
|
| 157 |
+
source=it["source"],
|
| 158 |
+
)
|
| 159 |
+
print(f"[ok] {bucket}/{task_key} n_tok={n_tok} took={dt:.1f}s "
|
| 160 |
+
f"norm_l0={np.linalg.norm(acts[0]):.2f} norm_lN={np.linalg.norm(acts[-1]):.2f}",
|
| 161 |
+
flush=True)
|
| 162 |
+
torch.cuda.empty_cache()
|
| 163 |
+
gc.collect()
|
| 164 |
+
|
| 165 |
+
print("[done]", flush=True)
|
| 166 |
+
return 0
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
if __name__ == "__main__":
|
| 170 |
+
raise SystemExit(main())
|
scripts/collect_traces.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Harvest paired success/failure agent traces and emit JSONL for activation capture.
|
| 2 |
+
|
| 3 |
+
We treat each `assistant_raw` block as the unit of interest — that is exactly the
|
| 4 |
+
text the agent model produced (including <think> chains and JSON action). For the
|
| 5 |
+
capability-vector contrast, the only thing we care about is whether this text
|
| 6 |
+
came from a successful trajectory (the eval reward was 1) or a failing one.
|
| 7 |
+
|
| 8 |
+
We do NOT reconstruct the full chat template here. We just emit, per trace,
|
| 9 |
+
the concatenation of all `assistant_raw` contents joined by a stable separator.
|
| 10 |
+
That gives one long "voice of the agent" text per trace, which is what the base
|
| 11 |
+
Qwen3.5-4B will be forced through to read out residual states.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
from dataclasses import dataclass, asdict
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Iterator
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class TraceItem:
|
| 26 |
+
bucket: str # "pos" or "neg"
|
| 27 |
+
model_label: str # sft / dpo / cp600 / base / rift
|
| 28 |
+
task: str # e.g. "log-summary-date-ranges"
|
| 29 |
+
source: str # path to trace.jsonl
|
| 30 |
+
reward: int # 0 or 1 from reward.txt (canonical) — informative only
|
| 31 |
+
n_assistant_turns: int
|
| 32 |
+
parse_fail_count: int
|
| 33 |
+
text: str # concatenated assistant_raw contents
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
SEP = "\n\n<<<TURN_BREAK>>>\n\n"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def iter_assistant_raw(trace_path: Path) -> Iterator[str]:
|
| 40 |
+
with trace_path.open() as f:
|
| 41 |
+
for line in f:
|
| 42 |
+
try:
|
| 43 |
+
rec = json.loads(line)
|
| 44 |
+
except json.JSONDecodeError:
|
| 45 |
+
continue
|
| 46 |
+
if rec.get("type") == "assistant_raw":
|
| 47 |
+
content = rec.get("content")
|
| 48 |
+
if isinstance(content, str) and content.strip():
|
| 49 |
+
yield content
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def count_parse_fails(trace_path: Path) -> int:
|
| 53 |
+
n = 0
|
| 54 |
+
with trace_path.open() as f:
|
| 55 |
+
for line in f:
|
| 56 |
+
try:
|
| 57 |
+
rec = json.loads(line)
|
| 58 |
+
except json.JSONDecodeError:
|
| 59 |
+
continue
|
| 60 |
+
if rec.get("type") == "parse_fail":
|
| 61 |
+
n += 1
|
| 62 |
+
return n
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def read_reward(task_dir: Path) -> int | None:
|
| 66 |
+
rf = task_dir / "reward.txt"
|
| 67 |
+
if not rf.exists():
|
| 68 |
+
return None
|
| 69 |
+
try:
|
| 70 |
+
return int(rf.read_text().strip())
|
| 71 |
+
except ValueError:
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def load_one(task_dir: Path, bucket: str, model_label: str) -> TraceItem | None:
|
| 76 |
+
trace = task_dir / "trace.jsonl"
|
| 77 |
+
if not trace.exists():
|
| 78 |
+
return None
|
| 79 |
+
turns = list(iter_assistant_raw(trace))
|
| 80 |
+
if not turns:
|
| 81 |
+
return None
|
| 82 |
+
return TraceItem(
|
| 83 |
+
bucket=bucket,
|
| 84 |
+
model_label=model_label,
|
| 85 |
+
task=task_dir.name,
|
| 86 |
+
source=str(trace),
|
| 87 |
+
reward=read_reward(task_dir) or 0,
|
| 88 |
+
n_assistant_turns=len(turns),
|
| 89 |
+
parse_fail_count=count_parse_fails(trace),
|
| 90 |
+
text=SEP.join(turns),
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---- canonical source buckets (hardcoded — these were inspected by hand) ----
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
SFT_FULL = Path.home() / "runs/gemma4-e4b-soyuz-agenttrove-qlora-r64/sft_full_tbench/20260514_122545"
|
| 98 |
+
BASE_FULL = Path.home() / "runs/gemma4-e4b-soyuz-agenttrove-qlora-r64/base_full_tbench/20260514_125010"
|
| 99 |
+
SPRINT = Path.home() / "runs/gemma4-e4b-soyuz-agenttrove-qlora-r64/qwen_sglang_eval/20260514_103006"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
POSITIVE_SOURCES = [
|
| 103 |
+
("sft_tbench", "sft", SFT_FULL / "cobol-modernization"),
|
| 104 |
+
("sft_tbench", "sft", SFT_FULL / "git-leak-recovery"),
|
| 105 |
+
("sft_sprint", "sft", SPRINT / "sft-results/log-summary-date-ranges"),
|
| 106 |
+
("sft_sprint", "sft", SPRINT / "sft-results/modernize-scientific-stack"),
|
| 107 |
+
("rift_sprint", "rift", SPRINT / "rift-results/modernize-scientific-stack"),
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
# Negatives: same task buckets where the SFT family succeeded — so we contrast
|
| 111 |
+
# on identical user prompts. Plus a couple of broken-format DPO traces.
|
| 112 |
+
NEGATIVE_SOURCES = [
|
| 113 |
+
# base Qwen on the SFT-pass tasks
|
| 114 |
+
("base_sprint_fail", "base", SPRINT / "Qwen_Qwen3.5-4B-results/log-summary-date-ranges"),
|
| 115 |
+
("base_sprint_fail", "base", SPRINT / "Qwen_Qwen3.5-4B-results/modernize-scientific-stack"),
|
| 116 |
+
("base_sprint_fail", "base", SPRINT / "Qwen_Qwen3.5-4B-results/qemu-startup"),
|
| 117 |
+
("base_sprint_fail", "base", SPRINT / "Qwen_Qwen3.5-4B-results/constraints-scheduling"),
|
| 118 |
+
("base_sprint_fail", "base", SPRINT / "Qwen_Qwen3.5-4B-results/multi-source-data-merger"),
|
| 119 |
+
# cp600 (no-agent-traces lora) on same
|
| 120 |
+
("cp600_sprint_fail", "cp600", SPRINT / "cp600-results/log-summary-date-ranges"),
|
| 121 |
+
("cp600_sprint_fail", "cp600", SPRINT / "cp600-results/modernize-scientific-stack"),
|
| 122 |
+
# DPO format regression on the easy tasks
|
| 123 |
+
("dpo_sprint_fail", "dpo", SPRINT / "dpo-results/log-summary-date-ranges"),
|
| 124 |
+
("dpo_sprint_fail", "dpo", SPRINT / "dpo-results/modernize-scientific-stack"),
|
| 125 |
+
("dpo_sprint_fail", "dpo", SPRINT / "dpo-results/qemu-startup"),
|
| 126 |
+
("dpo_sprint_fail", "dpo", SPRINT / "dpo-results/constraints-scheduling"),
|
| 127 |
+
("dpo_sprint_fail", "dpo", SPRINT / "dpo-results/multi-source-data-merger"),
|
| 128 |
+
]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def main() -> int:
|
| 132 |
+
ap = argparse.ArgumentParser()
|
| 133 |
+
ap.add_argument("--out-dir", default=str(Path.home() / "ml-intern-runs/capability-vector-qwen35"))
|
| 134 |
+
args = ap.parse_args()
|
| 135 |
+
|
| 136 |
+
out_dir = Path(args.out_dir)
|
| 137 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 138 |
+
|
| 139 |
+
def harvest(sources, bucket_label: str) -> list[TraceItem]:
|
| 140 |
+
items: list[TraceItem] = []
|
| 141 |
+
for group, model_label, task_dir in sources:
|
| 142 |
+
item = load_one(task_dir, bucket_label, model_label)
|
| 143 |
+
if item is None:
|
| 144 |
+
print(f"[warn] no trace for {task_dir}", file=sys.stderr)
|
| 145 |
+
continue
|
| 146 |
+
# Annotate sub-group via task name suffix so we can analyse later.
|
| 147 |
+
item.task = f"{group}::{item.task}"
|
| 148 |
+
items.append(item)
|
| 149 |
+
return items
|
| 150 |
+
|
| 151 |
+
pos_items = harvest(POSITIVE_SOURCES, "pos")
|
| 152 |
+
neg_items = harvest(NEGATIVE_SOURCES, "neg")
|
| 153 |
+
|
| 154 |
+
pos_path = out_dir / "traces_pos.jsonl"
|
| 155 |
+
neg_path = out_dir / "traces_neg.jsonl"
|
| 156 |
+
with pos_path.open("w") as f:
|
| 157 |
+
for it in pos_items:
|
| 158 |
+
f.write(json.dumps(asdict(it)) + "\n")
|
| 159 |
+
with neg_path.open("w") as f:
|
| 160 |
+
for it in neg_items:
|
| 161 |
+
f.write(json.dumps(asdict(it)) + "\n")
|
| 162 |
+
|
| 163 |
+
# Summary report
|
| 164 |
+
print(f"wrote {len(pos_items)} positive traces -> {pos_path}")
|
| 165 |
+
print(f"wrote {len(neg_items)} negative traces -> {neg_path}")
|
| 166 |
+
print()
|
| 167 |
+
print(f"{'BUCKET':<6} {'MODEL':<7} {'TASK':<55} {'TURNS':>5} {'PARSE_FAIL':>10} {'CHARS':>7}")
|
| 168 |
+
for it in pos_items + neg_items:
|
| 169 |
+
print(f"{it.bucket:<6} {it.model_label:<7} {it.task[:55]:<55} {it.n_assistant_turns:>5} {it.parse_fail_count:>10} {len(it.text):>7}")
|
| 170 |
+
return 0
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
if __name__ == "__main__":
|
| 174 |
+
raise SystemExit(main())
|
scripts/compute_directions.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compute capability-direction vectors per decoder layer from captured activations.
|
| 2 |
+
|
| 3 |
+
Per layer L:
|
| 4 |
+
mu_pos = mean over positive traces of act[L]
|
| 5 |
+
mu_neg = mean over negative traces of act[L]
|
| 6 |
+
raw = mu_pos - mu_neg
|
| 7 |
+
dir = raw / ||raw|| # unit vector
|
| 8 |
+
|
| 9 |
+
Diagnostics per layer:
|
| 10 |
+
margin = dir · mu_pos − dir · mu_neg # signal magnitude
|
| 11 |
+
sep_cos = 1 − cos(mu_pos, mu_neg) # angular separation
|
| 12 |
+
per_trace_proj: histogram of dir·act[L] per trace → discriminability check
|
| 13 |
+
(we want pos and neg projections to separate)
|
| 14 |
+
auc = simple roc-auc on per_trace projection (1.0 = perfect, 0.5 = chance)
|
| 15 |
+
pca_var = variance ratio along dir of (pos − neg) deltas — i.e. is the signal
|
| 16 |
+
one-dimensional, like the abliteration literature claims?
|
| 17 |
+
|
| 18 |
+
Output:
|
| 19 |
+
vectors/dir.pt dict {layer: {dir, mu_pos, mu_neg, margin, sep_cos, auc}}
|
| 20 |
+
vectors/ranking.csv layer ranking by auc (primary) and margin (secondary)
|
| 21 |
+
notes/per_trace_proj.csv one row per (trace, layer) with the projection value
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import csv
|
| 27 |
+
import os
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import numpy as np
|
| 31 |
+
import torch
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def load_bucket(act_dir: Path) -> tuple[np.ndarray, list[str]]:
|
| 35 |
+
paths = sorted(act_dir.glob("*.npz"))
|
| 36 |
+
arrs, labels = [], []
|
| 37 |
+
for p in paths:
|
| 38 |
+
z = np.load(p, allow_pickle=True)
|
| 39 |
+
arrs.append(z["act"])
|
| 40 |
+
labels.append(p.stem)
|
| 41 |
+
if not arrs:
|
| 42 |
+
return np.zeros((0,)), []
|
| 43 |
+
return np.stack(arrs, axis=0), labels # [N, L, D]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def roc_auc(scores_pos: np.ndarray, scores_neg: np.ndarray) -> float:
|
| 47 |
+
"""Mann-Whitney U style AUC. Higher score = positive."""
|
| 48 |
+
n_pos = len(scores_pos)
|
| 49 |
+
n_neg = len(scores_neg)
|
| 50 |
+
if n_pos == 0 or n_neg == 0:
|
| 51 |
+
return float("nan")
|
| 52 |
+
wins = 0.0
|
| 53 |
+
for sp in scores_pos:
|
| 54 |
+
for sn in scores_neg:
|
| 55 |
+
if sp > sn:
|
| 56 |
+
wins += 1.0
|
| 57 |
+
elif sp == sn:
|
| 58 |
+
wins += 0.5
|
| 59 |
+
return wins / (n_pos * n_neg)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def cos_sim(a: np.ndarray, b: np.ndarray) -> float:
|
| 63 |
+
na = np.linalg.norm(a)
|
| 64 |
+
nb = np.linalg.norm(b)
|
| 65 |
+
if na == 0 or nb == 0:
|
| 66 |
+
return 0.0
|
| 67 |
+
return float(np.dot(a, b) / (na * nb))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def main() -> int:
|
| 71 |
+
ap = argparse.ArgumentParser()
|
| 72 |
+
ap.add_argument("--run-dir", default=str(Path.home() / "ml-intern-runs/capability-vector-qwen35"))
|
| 73 |
+
args = ap.parse_args()
|
| 74 |
+
|
| 75 |
+
run = Path(args.run_dir)
|
| 76 |
+
pos_act, pos_lbl = load_bucket(run / "activations/pos")
|
| 77 |
+
neg_act, neg_lbl = load_bucket(run / "activations/neg")
|
| 78 |
+
assert pos_act.size > 0 and neg_act.size > 0, "Empty activations dir"
|
| 79 |
+
n_pos, n_layers, d_model = pos_act.shape
|
| 80 |
+
n_neg = neg_act.shape[0]
|
| 81 |
+
print(f"pos: {n_pos} traces x {n_layers} layers x {d_model} dims")
|
| 82 |
+
print(f"neg: {n_neg} traces x {n_layers} layers x {d_model} dims")
|
| 83 |
+
|
| 84 |
+
out_vec = run / "vectors"
|
| 85 |
+
out_vec.mkdir(exist_ok=True)
|
| 86 |
+
out_notes = run / "notes"
|
| 87 |
+
out_notes.mkdir(exist_ok=True)
|
| 88 |
+
|
| 89 |
+
mu_pos = pos_act.mean(axis=0) # [L, D]
|
| 90 |
+
mu_neg = neg_act.mean(axis=0)
|
| 91 |
+
raw = mu_pos - mu_neg
|
| 92 |
+
norms = np.linalg.norm(raw, axis=-1, keepdims=True) # [L, 1]
|
| 93 |
+
norms = np.where(norms == 0, 1.0, norms)
|
| 94 |
+
dirs = raw / norms # [L, D]
|
| 95 |
+
|
| 96 |
+
proj_pos = np.einsum("nld,ld->nl", pos_act, dirs) # [n_pos, L]
|
| 97 |
+
proj_neg = np.einsum("nld,ld->nl", neg_act, dirs) # [n_neg, L]
|
| 98 |
+
|
| 99 |
+
rows = []
|
| 100 |
+
info: dict[int, dict] = {}
|
| 101 |
+
for L in range(n_layers):
|
| 102 |
+
margin = float(np.dot(dirs[L], mu_pos[L]) - np.dot(dirs[L], mu_neg[L]))
|
| 103 |
+
sep_cos = 1.0 - cos_sim(mu_pos[L], mu_neg[L])
|
| 104 |
+
auc = roc_auc(proj_pos[:, L], proj_neg[:, L])
|
| 105 |
+
mean_proj_pos = float(proj_pos[:, L].mean())
|
| 106 |
+
mean_proj_neg = float(proj_neg[:, L].mean())
|
| 107 |
+
std_proj_pos = float(proj_pos[:, L].std())
|
| 108 |
+
std_proj_neg = float(proj_neg[:, L].std())
|
| 109 |
+
info[L] = dict(
|
| 110 |
+
dir=dirs[L].astype(np.float32),
|
| 111 |
+
mu_pos=mu_pos[L].astype(np.float32),
|
| 112 |
+
mu_neg=mu_neg[L].astype(np.float32),
|
| 113 |
+
margin=margin,
|
| 114 |
+
sep_cos=sep_cos,
|
| 115 |
+
auc=auc,
|
| 116 |
+
mean_proj_pos=mean_proj_pos,
|
| 117 |
+
mean_proj_neg=mean_proj_neg,
|
| 118 |
+
std_proj_pos=std_proj_pos,
|
| 119 |
+
std_proj_neg=std_proj_neg,
|
| 120 |
+
raw_norm=float(np.linalg.norm(raw[L])),
|
| 121 |
+
)
|
| 122 |
+
rows.append((L, auc, margin, sep_cos, float(np.linalg.norm(raw[L])),
|
| 123 |
+
mean_proj_pos, mean_proj_neg, std_proj_pos, std_proj_neg))
|
| 124 |
+
|
| 125 |
+
# Rank: AUC first (discriminability), margin as tiebreaker
|
| 126 |
+
rows_sorted = sorted(rows, key=lambda r: (r[1], r[2]), reverse=True)
|
| 127 |
+
|
| 128 |
+
ranking_path = out_vec / "ranking.csv"
|
| 129 |
+
with ranking_path.open("w", newline="") as f:
|
| 130 |
+
w = csv.writer(f)
|
| 131 |
+
w.writerow(["layer", "auc", "margin", "sep_cos", "raw_norm",
|
| 132 |
+
"mean_proj_pos", "mean_proj_neg", "std_proj_pos", "std_proj_neg"])
|
| 133 |
+
for r in rows_sorted:
|
| 134 |
+
w.writerow([r[0], f"{r[1]:.3f}", f"{r[2]:.3f}", f"{r[3]:.4f}",
|
| 135 |
+
f"{r[4]:.2f}", f"{r[5]:.3f}", f"{r[6]:.3f}",
|
| 136 |
+
f"{r[7]:.3f}", f"{r[8]:.3f}"])
|
| 137 |
+
print(f"wrote ranking -> {ranking_path}")
|
| 138 |
+
|
| 139 |
+
# Top-10 print
|
| 140 |
+
print("\nTop-10 layers by AUC:")
|
| 141 |
+
print(f"{'L':>3} {'AUC':>6} {'margin':>8} {'sep_cos':>8} {'rawN':>7} μ+(σ) μ-(σ)")
|
| 142 |
+
for r in rows_sorted[:10]:
|
| 143 |
+
L, auc, margin, sep_cos, rawN, mp, mn, sp, sn = r
|
| 144 |
+
print(f"{L:>3} {auc:>6.3f} {margin:>8.3f} {sep_cos:>8.4f} {rawN:>7.2f} "
|
| 145 |
+
f"{mp:>6.2f}({sp:>4.2f}) {mn:>6.2f}({sn:>4.2f})")
|
| 146 |
+
|
| 147 |
+
# Per-trace projection log (for debugging)
|
| 148 |
+
with (out_notes / "per_trace_proj.csv").open("w", newline="") as f:
|
| 149 |
+
w = csv.writer(f)
|
| 150 |
+
w.writerow(["layer", "bucket", "trace", "projection"])
|
| 151 |
+
for L in range(n_layers):
|
| 152 |
+
for i, lbl in enumerate(pos_lbl):
|
| 153 |
+
w.writerow([L, "pos", lbl, f"{proj_pos[i, L]:.4f}"])
|
| 154 |
+
for i, lbl in enumerate(neg_lbl):
|
| 155 |
+
w.writerow([L, "neg", lbl, f"{proj_neg[i, L]:.4f}"])
|
| 156 |
+
|
| 157 |
+
# Save vectors as torch dict (easy to load in hook)
|
| 158 |
+
save = {}
|
| 159 |
+
for L, d in info.items():
|
| 160 |
+
save[L] = {k: torch.from_numpy(v) if isinstance(v, np.ndarray) else v
|
| 161 |
+
for k, v in d.items()}
|
| 162 |
+
torch.save(save, out_vec / "dir.pt")
|
| 163 |
+
print(f"\nwrote vectors -> {out_vec / 'dir.pt'}")
|
| 164 |
+
|
| 165 |
+
# Sanity: report best layer and its components
|
| 166 |
+
best = rows_sorted[0]
|
| 167 |
+
print(f"\nBEST: layer={best[0]} auc={best[1]:.3f} margin={best[2]:.3f}")
|
| 168 |
+
return 0
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
if __name__ == "__main__":
|
| 172 |
+
raise SystemExit(main())
|
scripts/generate_steered.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quick qualitative test: generate completions on a terminus-style prompt with
|
| 2 |
+
and without steering. Compares baseline vs (layer, alpha) sweep.
|
| 3 |
+
|
| 4 |
+
This is the smoke test before we commit to a full docker eval — if steering wrecks
|
| 5 |
+
the model into gibberish, we want to know now, not 30 min into a sweep.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
import sys
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
from transformers import AutoTokenizer, AutoModelForImageTextToText
|
| 17 |
+
|
| 18 |
+
# local
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 20 |
+
from steer import attach_steering, detach_steering
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
MODEL_ID = "Qwen/Qwen3.5-4B"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
SYSTEM_PROMPT = """You are a terminal agent. You can run shell commands in a Linux container.
|
| 27 |
+
|
| 28 |
+
For each step, respond with ONE JSON object on a single line in this exact format:
|
| 29 |
+
{"analysis": "<brief reasoning>", "command": "<shell command to run>"}
|
| 30 |
+
|
| 31 |
+
OR for final answer:
|
| 32 |
+
{"analysis": "<reasoning>", "command": "done"}
|
| 33 |
+
|
| 34 |
+
Rules:
|
| 35 |
+
- Always emit valid JSON. Do not wrap it in markdown code fences.
|
| 36 |
+
- The "command" field is a shell command string that will be executed verbatim.
|
| 37 |
+
- After each command, the user will paste its stdout/stderr back.
|
| 38 |
+
- Working directory is /app. Files you create should go there unless task says otherwise.
|
| 39 |
+
- When the task is complete, set command to "done".
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
PROMPTS = [
|
| 44 |
+
# Two short instructions in the style the tbench tasks deliver
|
| 45 |
+
"I have a directory /app/logs full of log files named YYYY-MM-DD_<source>.log. "
|
| 46 |
+
"Count the number of ERROR, WARNING, and INFO lines for the date range 2025-08-01 "
|
| 47 |
+
"through 2025-08-12 inclusive. Write the totals to /app/summary.csv with columns "
|
| 48 |
+
"level,count.",
|
| 49 |
+
"There's a leaked credential committed to the git repo in /app. Find the commit "
|
| 50 |
+
"that introduced the secret, then write a fresh commit on a branch named `secret-removed` "
|
| 51 |
+
"that purges the credential from history.",
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def make_messages(user_prompt: str) -> list[dict]:
|
| 56 |
+
return [
|
| 57 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 58 |
+
{"role": "user", "content": user_prompt},
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@torch.no_grad()
|
| 63 |
+
def generate(model, tokenizer, messages, max_new_tokens=256, temperature=0.0):
|
| 64 |
+
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 65 |
+
inputs = tokenizer(text, return_tensors="pt").to(model.device)
|
| 66 |
+
out = model.generate(
|
| 67 |
+
**inputs,
|
| 68 |
+
max_new_tokens=max_new_tokens,
|
| 69 |
+
do_sample=temperature > 0,
|
| 70 |
+
temperature=max(temperature, 1e-5),
|
| 71 |
+
top_p=0.95,
|
| 72 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 73 |
+
)
|
| 74 |
+
gen = out[0][inputs["input_ids"].shape[1]:]
|
| 75 |
+
return tokenizer.decode(gen, skip_special_tokens=True)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def quick_format_check(text: str) -> dict:
|
| 79 |
+
"""Lightweight format checker matching what terminus_runner.parse_terminus expects."""
|
| 80 |
+
import re
|
| 81 |
+
t = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
| 82 |
+
# try first JSON object
|
| 83 |
+
m = re.search(r"\{.*?\}", t, flags=re.DOTALL)
|
| 84 |
+
has_json = False
|
| 85 |
+
has_keys = False
|
| 86 |
+
if m:
|
| 87 |
+
try:
|
| 88 |
+
obj = json.loads(m.group(0))
|
| 89 |
+
has_json = True
|
| 90 |
+
has_keys = "command" in obj or "analysis" in obj
|
| 91 |
+
except json.JSONDecodeError:
|
| 92 |
+
pass
|
| 93 |
+
return dict(has_json=has_json, has_keys=has_keys, n_chars=len(text))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def main() -> int:
|
| 97 |
+
ap = argparse.ArgumentParser()
|
| 98 |
+
ap.add_argument("--run-dir", default=str(Path.home() / "ml-intern-runs/capability-vector-qwen35"))
|
| 99 |
+
ap.add_argument("--layers", type=int, nargs="+", default=[22, 19, 16, 26])
|
| 100 |
+
ap.add_argument("--alphas", type=float, nargs="+", default=[0.0, 1.0, 2.0, 4.0, 8.0])
|
| 101 |
+
ap.add_argument("--max-new", type=int, default=256)
|
| 102 |
+
args = ap.parse_args()
|
| 103 |
+
|
| 104 |
+
run = Path(args.run_dir)
|
| 105 |
+
vec = torch.load(run / "vectors/dir.pt", weights_only=False)
|
| 106 |
+
out_log = run / "results/qualitative.jsonl"
|
| 107 |
+
out_log.parent.mkdir(parents=True, exist_ok=True)
|
| 108 |
+
|
| 109 |
+
print(f"[load] {MODEL_ID}")
|
| 110 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
|
| 111 |
+
model = AutoModelForImageTextToText.from_pretrained(
|
| 112 |
+
MODEL_ID, dtype=torch.bfloat16, device_map={"": 0},
|
| 113 |
+
)
|
| 114 |
+
model.eval()
|
| 115 |
+
print(f"[load] done; layers={len(model.model.language_model.layers)}")
|
| 116 |
+
|
| 117 |
+
f_out = out_log.open("w")
|
| 118 |
+
for prompt_idx, prompt in enumerate(PROMPTS):
|
| 119 |
+
messages = make_messages(prompt)
|
| 120 |
+
# baseline (no steering) — alpha=0 effectively
|
| 121 |
+
for layer in [None] + args.layers:
|
| 122 |
+
for alpha in args.alphas:
|
| 123 |
+
if layer is None and alpha != 0.0:
|
| 124 |
+
continue
|
| 125 |
+
if alpha == 0.0 and layer not in (None, args.layers[0]):
|
| 126 |
+
continue
|
| 127 |
+
hook = None
|
| 128 |
+
if layer is not None and alpha != 0.0:
|
| 129 |
+
hook = attach_steering(model, layer, vec[layer]["dir"], alpha)
|
| 130 |
+
t0 = time.time()
|
| 131 |
+
text = generate(model, tokenizer, messages, max_new_tokens=args.max_new)
|
| 132 |
+
dt = time.time() - t0
|
| 133 |
+
if hook is not None:
|
| 134 |
+
detach_steering(hook)
|
| 135 |
+
stats = quick_format_check(text)
|
| 136 |
+
rec = dict(
|
| 137 |
+
prompt_idx=prompt_idx,
|
| 138 |
+
layer=layer,
|
| 139 |
+
alpha=alpha,
|
| 140 |
+
elapsed_s=round(dt, 2),
|
| 141 |
+
**stats,
|
| 142 |
+
text=text,
|
| 143 |
+
)
|
| 144 |
+
f_out.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 145 |
+
f_out.flush()
|
| 146 |
+
tag = "baseline" if (layer is None) else f"L{layer:>2} α={alpha}"
|
| 147 |
+
ok = "JSON+keys" if stats["has_keys"] else ("JSON-only" if stats["has_json"] else "no-JSON")
|
| 148 |
+
first = text.replace("\n", " ").strip()[:90]
|
| 149 |
+
print(f"[p{prompt_idx}] {tag:>14} ({dt:>4.1f}s) {ok:>10} | {first}")
|
| 150 |
+
f_out.close()
|
| 151 |
+
print(f"\nfull log -> {out_log}")
|
| 152 |
+
return 0
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
raise SystemExit(main())
|
scripts/minimal_eval.sh
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Smallest useful sweep: prove steered ≠ baseline on at least one task.
|
| 3 |
+
set -uo pipefail
|
| 4 |
+
RUN_DIR=${RUN_DIR:-"$HOME/ml-intern-runs/capability-vector-qwen35"}
|
| 5 |
+
BASE_URL="http://172.18.0.1:30007/v1"
|
| 6 |
+
NETWORK="gemma4-e4b-soyuz-agenttrove-qlora-r64_default"
|
| 7 |
+
RUNNER="$HOME/runs/gemma4-e4b-soyuz-agenttrove-qlora-r64/terminus_runner.py"
|
| 8 |
+
OUT_ROOT="$RUN_DIR/results/minimal_$(date -u +%Y%m%d_%H%M%S)"
|
| 9 |
+
mkdir -p "$OUT_ROOT"
|
| 10 |
+
|
| 11 |
+
# 4 runs total
|
| 12 |
+
TASKS=(log-summary-date-ranges modernize-scientific-stack)
|
| 13 |
+
CONFIGS=(baseline steered-L22-a4)
|
| 14 |
+
|
| 15 |
+
MASTER="$OUT_ROOT/master_summary.csv"
|
| 16 |
+
echo "config,task,exit,reward,grade,turns,duration_s" > "$MASTER"
|
| 17 |
+
|
| 18 |
+
for cfg in "${CONFIGS[@]}"; do
|
| 19 |
+
safe=$(echo "$cfg" | tr "/" "_")
|
| 20 |
+
cfg_dir="$OUT_ROOT/$safe"
|
| 21 |
+
mkdir -p "$cfg_dir"
|
| 22 |
+
echo
|
| 23 |
+
echo "============================================================"
|
| 24 |
+
echo "CONFIG: $cfg"
|
| 25 |
+
echo "============================================================"
|
| 26 |
+
for task in "${TASKS[@]}"; do
|
| 27 |
+
img="agentbench/terminal-bench-2/${task}:latest"
|
| 28 |
+
workdir="$cfg_dir/$task"
|
| 29 |
+
mkdir -p "$workdir"
|
| 30 |
+
name="capvec-${safe}-${task}-$RANDOM"
|
| 31 |
+
|
| 32 |
+
t0=$(date +%s)
|
| 33 |
+
timeout 540 docker run --rm --name "$name" \
|
| 34 |
+
--network "$NETWORK" -t -m 4096m \
|
| 35 |
+
-v "$workdir:/work" \
|
| 36 |
+
-v "$RUNNER:/runner.py:ro" \
|
| 37 |
+
--entrypoint bash "$img" -c "
|
| 38 |
+
set -uo pipefail
|
| 39 |
+
python3 /runner.py --base-url '$BASE_URL' --model '$cfg' \
|
| 40 |
+
--instruction /instruction.md --work /work --cwd /app \
|
| 41 |
+
--max-turns 12 --max-tokens 1024 > /work/runner.stdout 2>&1
|
| 42 |
+
cd /app 2>/dev/null || cd /work
|
| 43 |
+
mkdir -p /logs/verifier
|
| 44 |
+
bash /tests/test.sh > /work/verifier.log 2>&1
|
| 45 |
+
cp /logs/verifier/reward.txt /work/ 2>/dev/null || true
|
| 46 |
+
chmod -R a+rwX /work
|
| 47 |
+
" >/dev/null 2>&1
|
| 48 |
+
rc=$?; t1=$(date +%s); dur=$((t1 - t0))
|
| 49 |
+
reward=$(cat "$workdir/reward.txt" 2>/dev/null || echo "")
|
| 50 |
+
[ -z "$reward" ] && reward=0
|
| 51 |
+
if [ "$reward" = "1" ]; then grade=pass; else grade=fail; fi
|
| 52 |
+
turns=$(grep -c '"type": *"step"' "$workdir/trace.jsonl" 2>/dev/null || echo 0)
|
| 53 |
+
printf "[%s] %-40s rc=%s reward=%s grade=%s turns=%s dur=%ss\n" \
|
| 54 |
+
"$cfg" "$task" "$rc" "$reward" "$grade" "$turns" "$dur"
|
| 55 |
+
echo "$cfg,$task,$rc,$reward,$grade,$turns,$dur" >> "$MASTER"
|
| 56 |
+
done
|
| 57 |
+
done
|
| 58 |
+
echo
|
| 59 |
+
echo "DONE -> $MASTER"
|
| 60 |
+
column -t -s, "$MASTER"
|
scripts/push_hf.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Publish capability vectors + scripts + reports to a HF Hub model repo.
|
| 2 |
+
|
| 3 |
+
Resulting repo (model type):
|
| 4 |
+
AlexWortega/qwen3.5-4b-capability-vector-<YYYYMMDD>
|
| 5 |
+
|
| 6 |
+
Contents:
|
| 7 |
+
vectors/dir.pt # 32-layer dict, each {dir, mu_pos, mu_neg, ...}
|
| 8 |
+
vectors/ranking.csv # AUC ranking from compute_directions
|
| 9 |
+
scripts/*.py # collect_traces, capture_activations, compute_directions,
|
| 10 |
+
# steer, generate_steered, serve_steered, sweep_eval.sh,
|
| 11 |
+
# analyze_sweep
|
| 12 |
+
TASK.md / RESEARCH.md / PLAN.md / RESULTS.md / VERIFY.md
|
| 13 |
+
results/sweep_*/master_summary.csv + sweep_summary.csv
|
| 14 |
+
README.md # model card
|
| 15 |
+
|
| 16 |
+
The vectors are not weights, but the HF model-repo type still fits because the
|
| 17 |
+
artifact is a derived component of a model (a residual-stream addend).
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import os
|
| 23 |
+
import shutil
|
| 24 |
+
import sys
|
| 25 |
+
import time
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> int:
|
| 30 |
+
ap = argparse.ArgumentParser()
|
| 31 |
+
ap.add_argument("--run-dir", default=str(Path.home() / "ml-intern-runs/capability-vector-qwen35"))
|
| 32 |
+
ap.add_argument("--repo-id", default=None,
|
| 33 |
+
help="defaults to AlexWortega/qwen3.5-4b-capability-vector-YYYYMMDD")
|
| 34 |
+
ap.add_argument("--dry-run", action="store_true")
|
| 35 |
+
args = ap.parse_args()
|
| 36 |
+
|
| 37 |
+
run = Path(args.run_dir)
|
| 38 |
+
assert run.exists(), run
|
| 39 |
+
assert (run / "vectors/dir.pt").exists(), "vectors/dir.pt missing"
|
| 40 |
+
|
| 41 |
+
repo_id = args.repo_id or f"AlexWortega/qwen3.5-4b-capability-vector-{time.strftime('%Y%m%d')}"
|
| 42 |
+
print(f"target repo: {repo_id}")
|
| 43 |
+
|
| 44 |
+
# Stage everything into a temp dir
|
| 45 |
+
stage = run / "_stage_hf"
|
| 46 |
+
if stage.exists():
|
| 47 |
+
shutil.rmtree(stage)
|
| 48 |
+
stage.mkdir()
|
| 49 |
+
# files to ship
|
| 50 |
+
for f in ["TASK.md", "RESEARCH.md", "PLAN.md", "RESULTS.md", "VERIFY.md"]:
|
| 51 |
+
src = run / f
|
| 52 |
+
if src.exists():
|
| 53 |
+
shutil.copy(src, stage / f)
|
| 54 |
+
# vectors
|
| 55 |
+
(stage / "vectors").mkdir()
|
| 56 |
+
shutil.copy(run / "vectors/dir.pt", stage / "vectors/dir.pt")
|
| 57 |
+
if (run / "vectors/ranking.csv").exists():
|
| 58 |
+
shutil.copy(run / "vectors/ranking.csv", stage / "vectors/ranking.csv")
|
| 59 |
+
# scripts (recursively, excluding pycache)
|
| 60 |
+
(stage / "scripts").mkdir()
|
| 61 |
+
for src in (run / "scripts").iterdir():
|
| 62 |
+
if src.is_file() and src.name not in ("__pycache__",):
|
| 63 |
+
shutil.copy(src, stage / "scripts" / src.name)
|
| 64 |
+
# sweep results (latest)
|
| 65 |
+
sweeps = sorted((run / "results").glob("sweep_*")) if (run / "results").exists() else []
|
| 66 |
+
if sweeps:
|
| 67 |
+
latest = sweeps[-1]
|
| 68 |
+
(stage / "results" / latest.name).mkdir(parents=True)
|
| 69 |
+
for f in ("master_summary.csv", "sweep_summary.csv"):
|
| 70 |
+
src = latest / f
|
| 71 |
+
if src.exists():
|
| 72 |
+
shutil.copy(src, stage / "results" / latest.name / f)
|
| 73 |
+
|
| 74 |
+
# Generate model card
|
| 75 |
+
readme = (
|
| 76 |
+
"---\n"
|
| 77 |
+
"library_name: transformers\n"
|
| 78 |
+
"base_model: Qwen/Qwen3.5-4B\n"
|
| 79 |
+
"tags:\n"
|
| 80 |
+
"- ml-intern\n"
|
| 81 |
+
"- activation-steering\n"
|
| 82 |
+
"- capability-vector\n"
|
| 83 |
+
"- agent\n"
|
| 84 |
+
"- terminal-bench\n"
|
| 85 |
+
"license: apache-2.0\n"
|
| 86 |
+
"---\n\n"
|
| 87 |
+
f"# Qwen3.5-4B Capability Vector (capvec-{time.strftime('%Y%m%d')})\n\n"
|
| 88 |
+
"A residual-stream **capability direction** for `Qwen/Qwen3.5-4B`, computed from\n"
|
| 89 |
+
"agent-trace contrasts (successful SFT/RIFT trajectories vs failing base/cp600/DPO\n"
|
| 90 |
+
"trajectories on terminal-bench-2). Inspired by\n"
|
| 91 |
+
"[NousResearch/llm-abliteration](https://github.com/NousResearch/llm-abliteration)\n"
|
| 92 |
+
"and [failspy's ortho cookbook](https://huggingface.co/failspy/llama-3-70B-Instruct-abliterated/blob/main/ortho_cookbook.ipynb)\n"
|
| 93 |
+
"but inverted: we **add** the direction to push base toward agent-capable behavior\n"
|
| 94 |
+
"rather than subtract a refusal direction.\n\n"
|
| 95 |
+
"## Quick use\n\n"
|
| 96 |
+
"```python\n"
|
| 97 |
+
"import torch\n"
|
| 98 |
+
"from transformers import AutoTokenizer, AutoModelForImageTextToText\n"
|
| 99 |
+
"from huggingface_hub import hf_hub_download\n"
|
| 100 |
+
"from scripts.steer import attach_steering, detach_steering # vendored in this repo\n"
|
| 101 |
+
"\n"
|
| 102 |
+
"tok = AutoTokenizer.from_pretrained('Qwen/Qwen3.5-4B')\n"
|
| 103 |
+
"model = AutoModelForImageTextToText.from_pretrained(\n"
|
| 104 |
+
" 'Qwen/Qwen3.5-4B', dtype=torch.bfloat16, device_map={'':0})\n"
|
| 105 |
+
"\n"
|
| 106 |
+
f"vec_path = hf_hub_download('{repo_id}', 'vectors/dir.pt')\n"
|
| 107 |
+
"vec = torch.load(vec_path, weights_only=False)\n"
|
| 108 |
+
"hook = attach_steering(model, layer_idx=22, direction=vec[22]['dir'], alpha=2.0)\n"
|
| 109 |
+
"# ... model.generate(...)\n"
|
| 110 |
+
"detach_steering(hook)\n"
|
| 111 |
+
"```\n\n"
|
| 112 |
+
"## How it was built\n\n"
|
| 113 |
+
"5 SFT-successful trace concatenations (cobol-modernization, git-leak-recovery,\n"
|
| 114 |
+
"log-summary-date-ranges, modernize-scientific-stack, plus a rift pass) vs 12 same-base\n"
|
| 115 |
+
"failures (base Qwen3.5-4B, cp600 LoRA, DPO LoRA across 5 sprint tasks). All trace texts\n"
|
| 116 |
+
"fed through base Qwen3.5-4B in bf16, residual states captured at every decoder layer,\n"
|
| 117 |
+
"averaged over assistant-token positions, then `dir_L = (μ_pos − μ_neg) / ‖…‖`.\n\n"
|
| 118 |
+
"## Layer ranking (top 5 by trace-level AUC)\n\n"
|
| 119 |
+
"See `vectors/ranking.csv`. Layers 12–22 separate positive vs negative traces with\n"
|
| 120 |
+
"**AUC = 1.000** (perfect linear discriminability on 5 + 12 traces).\n\n"
|
| 121 |
+
"## Caveats\n\n"
|
| 122 |
+
"- Vector is computed from base-model representations of trace texts produced by\n"
|
| 123 |
+
" finetuned models. This is the right space for *adding* steering during base-model\n"
|
| 124 |
+
" inference, but the signal partly conflates `agent-capability` with\n"
|
| 125 |
+
" `correct-action-format` (parse-fail rate also discriminates +/–).\n"
|
| 126 |
+
"- 5 positive traces is small. AUC=1.0 is plausible but uncertain — a held-out\n"
|
| 127 |
+
" task set would shrink the margin.\n"
|
| 128 |
+
"- Steering with α > 8 will gradually degrade fluency. The script `generate_steered.py`\n"
|
| 129 |
+
" produced coherent JSON-formatted agent outputs at α ∈ {1, 2, 4, 8} on layer 22.\n\n"
|
| 130 |
+
"## Reproducibility\n\n"
|
| 131 |
+
"All scripts under `scripts/`. Pipeline:\n"
|
| 132 |
+
"1. `python scripts/collect_traces.py` — extract +/− trace texts.\n"
|
| 133 |
+
"2. `python scripts/capture_activations.py --load-mode bf16` — record per-layer mean.\n"
|
| 134 |
+
"3. `python scripts/compute_directions.py` — produce `vectors/dir.pt`.\n"
|
| 135 |
+
"4. `python scripts/serve_steered.py --port 30007` — OpenAI-compatible inference.\n"
|
| 136 |
+
"5. `bash scripts/sweep_eval.sh` — run docker terminal-bench-2 sweep.\n"
|
| 137 |
+
"6. `python scripts/analyze_sweep.py --sweep-dir results/sweep_<ts>` — summary.\n"
|
| 138 |
+
)
|
| 139 |
+
(stage / "README.md").write_text(readme)
|
| 140 |
+
|
| 141 |
+
if args.dry_run:
|
| 142 |
+
print(f"\nDry run — staged files at {stage}")
|
| 143 |
+
for p in sorted(stage.rglob("*")):
|
| 144 |
+
if p.is_file():
|
| 145 |
+
print(f" {p.relative_to(stage)} ({p.stat().st_size} bytes)")
|
| 146 |
+
return 0
|
| 147 |
+
|
| 148 |
+
# Push
|
| 149 |
+
try:
|
| 150 |
+
from huggingface_hub import HfApi, create_repo
|
| 151 |
+
except ImportError:
|
| 152 |
+
print("[err] huggingface_hub not installed", file=sys.stderr)
|
| 153 |
+
return 2
|
| 154 |
+
|
| 155 |
+
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 156 |
+
api = HfApi(token=token)
|
| 157 |
+
create_repo(repo_id, repo_type="model", exist_ok=True, token=token)
|
| 158 |
+
print(f"uploading to {repo_id} ...")
|
| 159 |
+
api.upload_folder(
|
| 160 |
+
repo_id=repo_id,
|
| 161 |
+
folder_path=str(stage),
|
| 162 |
+
commit_message=f"ml-intern: capability vector for Qwen3.5-4B ({time.strftime('%Y-%m-%d')})",
|
| 163 |
+
)
|
| 164 |
+
print(f"\nhttps://huggingface.co/{repo_id}")
|
| 165 |
+
return 0
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
raise SystemExit(main())
|
scripts/serve_steered.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI-compatible server for base Qwen3.5-4B with on-the-fly capability steering.
|
| 2 |
+
|
| 3 |
+
Model IDs accepted in chat requests:
|
| 4 |
+
- "Qwen/Qwen3.5-4B" or "baseline" → no steering
|
| 5 |
+
- "steered-L<int>-a<float>" → install pre-forward hook on decoder layer
|
| 6 |
+
<int> that adds <float>·dir to the
|
| 7 |
+
residual, generate, remove hook.
|
| 8 |
+
|
| 9 |
+
Direction tensors are loaded once from vectors/dir.pt at startup.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import sys
|
| 18 |
+
import threading
|
| 19 |
+
import time
|
| 20 |
+
import uuid
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Optional, Union
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
import uvicorn
|
| 26 |
+
from fastapi import FastAPI, HTTPException
|
| 27 |
+
from pydantic import BaseModel, ConfigDict
|
| 28 |
+
from transformers import AutoTokenizer, AutoModelForImageTextToText
|
| 29 |
+
|
| 30 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 31 |
+
from steer import attach_steering, detach_steering
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
app = FastAPI()
|
| 35 |
+
MODEL = None
|
| 36 |
+
TOKENIZER = None
|
| 37 |
+
VECTORS: dict[int, dict] = {}
|
| 38 |
+
GEN_LOCK = threading.Lock()
|
| 39 |
+
SERVED_PREFIX = "Qwen/Qwen3.5-4B"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---- request schema ----
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class Message(BaseModel):
|
| 46 |
+
model_config = ConfigDict(extra="allow")
|
| 47 |
+
role: str
|
| 48 |
+
content: Union[str, list, None] = ""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ChatRequest(BaseModel):
|
| 52 |
+
model_config = ConfigDict(extra="allow")
|
| 53 |
+
model: str = ""
|
| 54 |
+
messages: list[Message]
|
| 55 |
+
max_tokens: int = 1024
|
| 56 |
+
temperature: float = 0.4
|
| 57 |
+
top_p: float = 0.95
|
| 58 |
+
top_k: int = 40
|
| 59 |
+
min_p: float = 0.0
|
| 60 |
+
repetition_penalty: float = 1.05
|
| 61 |
+
no_repeat_ngram_size: int = 32
|
| 62 |
+
stream: bool = False
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---- helpers ----
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _text_of(content):
|
| 69 |
+
if content is None:
|
| 70 |
+
return ""
|
| 71 |
+
if isinstance(content, str):
|
| 72 |
+
return content
|
| 73 |
+
if isinstance(content, list):
|
| 74 |
+
parts = []
|
| 75 |
+
for p in content:
|
| 76 |
+
if isinstance(p, dict):
|
| 77 |
+
if p.get("type") == "text":
|
| 78 |
+
parts.append(p.get("text") or "")
|
| 79 |
+
elif "text" in p:
|
| 80 |
+
parts.append(p["text"] or "")
|
| 81 |
+
elif p.get("type") == "tool_result":
|
| 82 |
+
c = p.get("content")
|
| 83 |
+
parts.append(c if isinstance(c, str) else json.dumps(c, ensure_ascii=False))
|
| 84 |
+
elif isinstance(p, str):
|
| 85 |
+
parts.append(p)
|
| 86 |
+
return "\n".join(parts)
|
| 87 |
+
return str(content)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _tokenize(messages):
|
| 91 |
+
msgs = [{"role": m.role, "content": _text_of(m.content)} for m in messages]
|
| 92 |
+
text = TOKENIZER.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
|
| 93 |
+
return TOKENIZER(text, return_tensors="pt").to(MODEL.device)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
MAX_NEW_TOKENS_HARD_CAP = 2048
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _gen_kwargs(req: ChatRequest):
|
| 100 |
+
kw = dict(
|
| 101 |
+
max_new_tokens=min(req.max_tokens, MAX_NEW_TOKENS_HARD_CAP),
|
| 102 |
+
do_sample=req.temperature > 0,
|
| 103 |
+
pad_token_id=TOKENIZER.eos_token_id,
|
| 104 |
+
)
|
| 105 |
+
if req.temperature > 0:
|
| 106 |
+
kw["temperature"] = req.temperature
|
| 107 |
+
kw["top_p"] = req.top_p
|
| 108 |
+
if req.top_k > 0:
|
| 109 |
+
kw["top_k"] = req.top_k
|
| 110 |
+
if req.min_p > 0:
|
| 111 |
+
kw["min_p"] = req.min_p
|
| 112 |
+
if req.repetition_penalty and req.repetition_penalty != 1.0:
|
| 113 |
+
kw["repetition_penalty"] = req.repetition_penalty
|
| 114 |
+
if req.no_repeat_ngram_size and req.no_repeat_ngram_size > 0:
|
| 115 |
+
kw["no_repeat_ngram_size"] = req.no_repeat_ngram_size
|
| 116 |
+
return kw
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
_STEER_PAT = re.compile(r"^steered-L(\d+)-a(-?\d+(?:\.\d+)?)$")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _parse_steer(model_id: str) -> Optional[tuple[int, float]]:
|
| 123 |
+
if model_id in ("baseline", SERVED_PREFIX, "Qwen/Qwen3.5-4B"):
|
| 124 |
+
return None
|
| 125 |
+
m = _STEER_PAT.match(model_id)
|
| 126 |
+
if not m:
|
| 127 |
+
return None
|
| 128 |
+
layer = int(m.group(1))
|
| 129 |
+
alpha = float(m.group(2))
|
| 130 |
+
if layer not in VECTORS:
|
| 131 |
+
raise HTTPException(400, f"unknown steering layer {layer}; available: {sorted(VECTORS.keys())[:10]}...")
|
| 132 |
+
return (layer, alpha)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---- endpoints ----
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.get("/health")
|
| 139 |
+
def health():
|
| 140 |
+
return {"status": "ok"}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@app.get("/v1/models")
|
| 144 |
+
def list_models():
|
| 145 |
+
ids = [SERVED_PREFIX, "baseline"]
|
| 146 |
+
for L in sorted(VECTORS.keys()):
|
| 147 |
+
ids.append(f"steered-L{L}-a1")
|
| 148 |
+
ids.append(f"steered-L{L}-a4")
|
| 149 |
+
return {"object": "list", "data": [{"id": i, "object": "model"} for i in ids]}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@app.post("/v1/chat/completions")
|
| 153 |
+
async def chat(req: ChatRequest):
|
| 154 |
+
steer_cfg = _parse_steer(req.model)
|
| 155 |
+
inputs = _tokenize(req.messages)
|
| 156 |
+
with GEN_LOCK:
|
| 157 |
+
hook = None
|
| 158 |
+
try:
|
| 159 |
+
if steer_cfg is not None:
|
| 160 |
+
L, alpha = steer_cfg
|
| 161 |
+
hook = attach_steering(MODEL, L, VECTORS[L]["dir"], alpha)
|
| 162 |
+
with torch.no_grad():
|
| 163 |
+
out = MODEL.generate(**inputs, **_gen_kwargs(req))
|
| 164 |
+
finally:
|
| 165 |
+
if hook is not None:
|
| 166 |
+
detach_steering(hook)
|
| 167 |
+
gen = out[0][inputs["input_ids"].shape[1]:]
|
| 168 |
+
content = TOKENIZER.decode(gen, skip_special_tokens=True)
|
| 169 |
+
return {
|
| 170 |
+
"id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
|
| 171 |
+
"object": "chat.completion",
|
| 172 |
+
"created": int(time.time()),
|
| 173 |
+
"model": req.model or SERVED_PREFIX,
|
| 174 |
+
"choices": [
|
| 175 |
+
{
|
| 176 |
+
"index": 0,
|
| 177 |
+
"message": {"role": "assistant", "content": content},
|
| 178 |
+
"finish_reason": "stop",
|
| 179 |
+
}
|
| 180 |
+
],
|
| 181 |
+
"usage": {
|
| 182 |
+
"prompt_tokens": inputs["input_ids"].shape[1],
|
| 183 |
+
"completion_tokens": len(gen),
|
| 184 |
+
"total_tokens": inputs["input_ids"].shape[1] + len(gen),
|
| 185 |
+
},
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def main():
|
| 190 |
+
global MODEL, TOKENIZER, VECTORS
|
| 191 |
+
p = argparse.ArgumentParser()
|
| 192 |
+
p.add_argument("--model-path", default="Qwen/Qwen3.5-4B")
|
| 193 |
+
p.add_argument("--vectors", default=str(Path(__file__).parent.parent / "vectors/dir.pt"))
|
| 194 |
+
p.add_argument("--port", type=int, default=30007)
|
| 195 |
+
p.add_argument("--host", default="0.0.0.0")
|
| 196 |
+
args = p.parse_args()
|
| 197 |
+
|
| 198 |
+
print(f"[load] tokenizer + model {args.model_path}", flush=True)
|
| 199 |
+
TOKENIZER = AutoTokenizer.from_pretrained(args.model_path)
|
| 200 |
+
MODEL = AutoModelForImageTextToText.from_pretrained(
|
| 201 |
+
args.model_path, dtype=torch.bfloat16, device_map={"": 0},
|
| 202 |
+
)
|
| 203 |
+
MODEL.eval()
|
| 204 |
+
|
| 205 |
+
print(f"[load] vectors from {args.vectors}", flush=True)
|
| 206 |
+
raw = torch.load(args.vectors, weights_only=False, map_location="cpu")
|
| 207 |
+
VECTORS = {int(k): {kk: vv for kk, vv in v.items()} for k, v in raw.items()}
|
| 208 |
+
print(f"[load] {len(VECTORS)} layer vectors; sample keys: {list(VECTORS.keys())[:5]}", flush=True)
|
| 209 |
+
|
| 210 |
+
print(f"[serve] :{args.port}", flush=True)
|
| 211 |
+
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
main()
|
scripts/steer.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Steering hook for Qwen3.5-4B language-model decoder layers.
|
| 2 |
+
|
| 3 |
+
Registers a forward-hook on `model.model.language_model.layers[L]`. The hook
|
| 4 |
+
inspects the layer output (which is hidden_states or a tuple starting with
|
| 5 |
+
hidden_states), adds `alpha * dir_L` to the residual along the time dimension,
|
| 6 |
+
and returns the modified output.
|
| 7 |
+
|
| 8 |
+
Use:
|
| 9 |
+
>>> from steer import attach_steering
|
| 10 |
+
>>> hook = attach_steering(model, layer_idx=22, direction=dir_tensor, alpha=4.0)
|
| 11 |
+
>>> ... # run generation
|
| 12 |
+
>>> hook.remove()
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class _SteerHook:
|
| 22 |
+
def __init__(self, direction: torch.Tensor, alpha: float):
|
| 23 |
+
# direction must be a unit-norm [D] tensor on the right device + dtype
|
| 24 |
+
self.direction = direction
|
| 25 |
+
self.alpha = alpha
|
| 26 |
+
self._handle = None
|
| 27 |
+
self._call_count = 0
|
| 28 |
+
|
| 29 |
+
def __call__(self, module: torch.nn.Module, inputs: tuple, output: Any) -> Any:
|
| 30 |
+
# output is either Tensor or tuple(Tensor, ...). The first elem is
|
| 31 |
+
# hidden_states of shape [B, T, D].
|
| 32 |
+
if isinstance(output, tuple):
|
| 33 |
+
hs = output[0]
|
| 34 |
+
rest = output[1:]
|
| 35 |
+
else:
|
| 36 |
+
hs = output
|
| 37 |
+
rest = None
|
| 38 |
+
# Cast direction to the same dtype/device as hs.
|
| 39 |
+
d = self.direction.to(dtype=hs.dtype, device=hs.device)
|
| 40 |
+
hs2 = hs + self.alpha * d # broadcasts over [B, T, D]
|
| 41 |
+
self._call_count += 1
|
| 42 |
+
if rest is None:
|
| 43 |
+
return hs2
|
| 44 |
+
return (hs2,) + rest
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def attach_steering(model, layer_idx: int, direction: torch.Tensor, alpha: float) -> _SteerHook:
|
| 48 |
+
"""Install hook. Returns hook object — call `.remove()` (via stored handle)
|
| 49 |
+
or use the helper `detach_steering`."""
|
| 50 |
+
decoder = model.model.language_model.layers[layer_idx]
|
| 51 |
+
hook = _SteerHook(direction, alpha)
|
| 52 |
+
hook._handle = decoder.register_forward_hook(hook)
|
| 53 |
+
return hook
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def detach_steering(hook: _SteerHook) -> None:
|
| 57 |
+
if hook._handle is not None:
|
| 58 |
+
hook._handle.remove()
|
| 59 |
+
hook._handle = None
|
scripts/sweep_eval.sh
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Sweep (layer, alpha) configurations against tbench sprint via docker.
|
| 3 |
+
# Requires: steered server up on host:30007, and docker network gemma4-...default.
|
| 4 |
+
|
| 5 |
+
set -uo pipefail
|
| 6 |
+
|
| 7 |
+
RUN_DIR=${RUN_DIR:-"$HOME/ml-intern-runs/capability-vector-qwen35"}
|
| 8 |
+
SERVE_PORT=${SERVE_PORT:-30007}
|
| 9 |
+
# from inside docker tasks on the gemma4-..._default network, 172.18.0.1 is gateway = host
|
| 10 |
+
BASE_URL=${BASE_URL:-"http://172.18.0.1:${SERVE_PORT}/v1"}
|
| 11 |
+
NETWORK=${NETWORK:-"gemma4-e4b-soyuz-agenttrove-qlora-r64_default"}
|
| 12 |
+
RUNNER="$HOME/runs/gemma4-e4b-soyuz-agenttrove-qlora-r64/terminus_runner.py"
|
| 13 |
+
|
| 14 |
+
OUT_ROOT="$RUN_DIR/results/sweep_$(date -u +%Y%m%d_%H%M%S)"
|
| 15 |
+
mkdir -p "$OUT_ROOT"
|
| 16 |
+
|
| 17 |
+
# Configurations
|
| 18 |
+
TASKS=(${TASKS_ENV:-log-summary-date-ranges modernize-scientific-stack constraints-scheduling})
|
| 19 |
+
CONFIGS=(${CONFIGS_ENV:-baseline steered-L22-a2 steered-L22-a4 steered-L19-a4})
|
| 20 |
+
|
| 21 |
+
# Quick warmup ping from a task container
|
| 22 |
+
echo "[warmup] checking $BASE_URL/.."
|
| 23 |
+
docker run --rm --network "$NETWORK" --entrypoint curl agentbench/pi-mono:latest \
|
| 24 |
+
-sS -m 8 "${BASE_URL%/v1}/health" 2>&1 | head -1 || \
|
| 25 |
+
echo "[warn] container could not reach $BASE_URL"
|
| 26 |
+
|
| 27 |
+
MASTER="$OUT_ROOT/master_summary.csv"
|
| 28 |
+
echo "config,task,exit,reward,grade,turns,duration_s" > "$MASTER"
|
| 29 |
+
|
| 30 |
+
for cfg in "${CONFIGS[@]}"; do
|
| 31 |
+
safe=$(echo "$cfg" | tr "/" "_")
|
| 32 |
+
cfg_dir="$OUT_ROOT/$safe"
|
| 33 |
+
mkdir -p "$cfg_dir"
|
| 34 |
+
echo
|
| 35 |
+
echo "============================================================"
|
| 36 |
+
echo "CONFIG: $cfg -> $safe"
|
| 37 |
+
echo "============================================================"
|
| 38 |
+
|
| 39 |
+
for task in "${TASKS[@]}"; do
|
| 40 |
+
img="agentbench/terminal-bench-2/${task}:latest"
|
| 41 |
+
workdir="$cfg_dir/$task"
|
| 42 |
+
mkdir -p "$workdir"
|
| 43 |
+
name="capvec-${safe}-${task}-$RANDOM"
|
| 44 |
+
|
| 45 |
+
t0=$(date +%s)
|
| 46 |
+
timeout 360 docker run --rm --name "$name" \
|
| 47 |
+
--network "$NETWORK" -t -m 4096m \
|
| 48 |
+
-v "$workdir:/work" \
|
| 49 |
+
-v "$RUNNER:/runner.py:ro" \
|
| 50 |
+
--entrypoint bash "$img" -c "
|
| 51 |
+
set -uo pipefail
|
| 52 |
+
python3 /runner.py \
|
| 53 |
+
--base-url '$BASE_URL' \
|
| 54 |
+
--model '$cfg' \
|
| 55 |
+
--instruction /instruction.md \
|
| 56 |
+
--work /work \
|
| 57 |
+
--cwd /app \
|
| 58 |
+
--max-turns 15 \
|
| 59 |
+
--max-tokens 1024 > /work/runner.stdout 2>&1
|
| 60 |
+
cd /app 2>/dev/null || cd /work
|
| 61 |
+
mkdir -p /logs/verifier
|
| 62 |
+
bash /tests/test.sh > /work/verifier.log 2>&1
|
| 63 |
+
cp /logs/verifier/reward.txt /work/ 2>/dev/null || true
|
| 64 |
+
cp /logs/verifier/ctrf.json /work/ 2>/dev/null || true
|
| 65 |
+
chmod -R a+rwX /work
|
| 66 |
+
" >/dev/null 2>&1
|
| 67 |
+
rc=$?
|
| 68 |
+
t1=$(date +%s)
|
| 69 |
+
dur=$((t1 - t0))
|
| 70 |
+
|
| 71 |
+
reward=$(cat "$workdir/reward.txt" 2>/dev/null || echo "")
|
| 72 |
+
if [ -z "$reward" ]; then reward=0; fi
|
| 73 |
+
if [ "$reward" = "1" ]; then grade=pass; else grade=fail; fi
|
| 74 |
+
# count turns from trace.jsonl
|
| 75 |
+
turns=$(grep -c '"type": *"step"' "$workdir/trace.jsonl" 2>/dev/null || echo 0)
|
| 76 |
+
|
| 77 |
+
printf "[%s] %-40s rc=%s reward=%s grade=%s turns=%s dur=%ss\n" \
|
| 78 |
+
"$cfg" "$task" "$rc" "$reward" "$grade" "$turns" "$dur"
|
| 79 |
+
echo "$cfg,$task,$rc,$reward,$grade,$turns,$dur" >> "$MASTER"
|
| 80 |
+
done
|
| 81 |
+
done
|
| 82 |
+
|
| 83 |
+
echo
|
| 84 |
+
echo "============================================================"
|
| 85 |
+
echo "DONE -> $MASTER"
|
| 86 |
+
echo "============================================================"
|
| 87 |
+
column -t -s, "$MASTER"
|
vectors/dir.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4d30b5b44360fbd04c0ce1f9c080491589c17eef59806b6e1b716582bacec0d2
|
| 3 |
+
size 1005739
|
vectors/ranking.csv
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
layer,auc,margin,sep_cos,raw_norm,mean_proj_pos,mean_proj_neg,std_proj_pos,std_proj_neg
|
| 2 |
+
22,1.000,1.949,0.0156,1.95,-0.123,-2.072,0.738,1.207
|
| 3 |
+
19,1.000,1.438,0.0140,1.44,-0.406,-1.844,0.466,0.913
|
| 4 |
+
16,1.000,0.691,0.0056,0.69,-0.985,-1.676,0.165,0.456
|
| 5 |
+
15,1.000,0.641,0.0046,0.64,-0.996,-1.637,0.156,0.424
|
| 6 |
+
14,1.000,0.559,0.0050,0.56,0.161,-0.398,0.162,0.353
|
| 7 |
+
13,1.000,0.503,0.0036,0.50,-0.448,-0.951,0.138,0.309
|
| 8 |
+
12,1.000,0.498,0.0040,0.50,-0.076,-0.574,0.165,0.286
|
| 9 |
+
26,0.983,2.651,0.0147,2.65,-0.158,-2.809,1.171,1.480
|
| 10 |
+
21,0.983,1.726,0.0143,1.73,-0.413,-2.139,0.527,1.109
|
| 11 |
+
20,0.983,1.604,0.0142,1.60,-0.555,-2.159,0.467,1.099
|
| 12 |
+
18,0.983,0.954,0.0123,0.95,0.299,-0.655,0.248,0.655
|
| 13 |
+
17,0.983,0.770,0.0061,0.77,-1.216,-1.986,0.157,0.565
|
| 14 |
+
23,0.967,2.367,0.0153,2.37,-0.457,-2.824,0.972,1.447
|
| 15 |
+
10,0.967,0.456,0.0039,0.46,0.463,0.007,0.163,0.264
|
| 16 |
+
11,0.950,0.489,0.0036,0.49,-0.066,-0.555,0.164,0.280
|
| 17 |
+
31,0.933,16.638,0.0430,16.64,10.504,-6.134,8.594,8.971
|
| 18 |
+
30,0.933,4.860,0.0204,4.86,1.216,-3.644,2.266,2.729
|
| 19 |
+
29,0.933,4.235,0.0125,4.24,-2.604,-6.839,1.809,2.480
|
| 20 |
+
28,0.933,3.722,0.0121,3.72,-1.812,-5.535,1.648,2.171
|
| 21 |
+
27,0.933,3.214,0.0131,3.21,-2.259,-5.473,1.322,1.941
|
| 22 |
+
25,0.933,2.636,0.0143,2.64,-0.910,-3.546,1.076,1.625
|
| 23 |
+
24,0.933,2.583,0.0161,2.58,-0.404,-2.986,1.076,1.586
|
| 24 |
+
9,0.933,0.440,0.0038,0.44,-0.544,-0.985,0.163,0.247
|
| 25 |
+
8,0.933,0.411,0.0039,0.41,-0.215,-0.626,0.177,0.220
|
| 26 |
+
7,0.933,0.390,0.0035,0.39,-0.453,-0.843,0.146,0.222
|
| 27 |
+
6,0.933,0.318,0.0028,0.32,-0.192,-0.510,0.089,0.196
|
| 28 |
+
5,0.933,0.240,0.0016,0.24,-0.639,-0.879,0.063,0.150
|
| 29 |
+
4,0.933,0.208,0.0016,0.21,-0.601,-0.809,0.058,0.123
|
| 30 |
+
3,0.933,0.180,0.0015,0.18,-0.346,-0.526,0.066,0.107
|
| 31 |
+
2,0.933,0.116,0.0014,0.12,0.181,0.066,0.040,0.075
|
| 32 |
+
1,0.933,0.075,0.0004,0.07,0.084,0.010,0.025,0.050
|
| 33 |
+
0,0.933,0.043,0.0002,0.04,-0.058,-0.101,0.018,0.026
|