Spaces:
Sleeping
Sleeping
Code2LoRA-GRU interactive demo
Browse files- README.md +124 -7
- app.py +353 -0
- c2l_demo/__init__.py +17 -0
- c2l_demo/assertions.py +360 -0
- c2l_demo/core.py +295 -0
- c2l_demo/embedding.py +165 -0
- c2l_demo/engine.py +400 -0
- c2l_demo/git_pipeline.py +498 -0
- c2l_demo/metrics.py +116 -0
- c2l_demo/viz.py +192 -0
- requirements.txt +9 -0
- upload_checkpoint.py +47 -0
README.md
CHANGED
|
@@ -1,13 +1,130 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Code2LoRA-GRU
|
| 3 |
+
emoji: 🧬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.9.1
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: Turn a repo's commit history into a LoRA adapter, live.
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Code2LoRA-GRU — interactive demo
|
| 15 |
+
|
| 16 |
+
Paste a Python repository (a **git URL** or a **local clone path**) and watch a
|
| 17 |
+
trained hypernetwork turn its *commit history* into a parameter-efficient LoRA
|
| 18 |
+
adapter for a frozen code model — then watch that adapter answer questions the
|
| 19 |
+
base model gets wrong.
|
| 20 |
+
|
| 21 |
+
This Space implements the inference pipeline from **Code2LoRA: Hypernetworks
|
| 22 |
+
for Repository-Conditioned and Commit-Streaming Adapters of Code Language
|
| 23 |
+
Models**.
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## What you get
|
| 28 |
+
|
| 29 |
+
0. **Requirements panel** — what kind of repository works (Python + pytest /
|
| 30 |
+
unittest, real git history, assertions that grew over time).
|
| 31 |
+
1. **Repository input** — paste a public git URL (cloned with full history) or
|
| 32 |
+
an absolute path to an existing clone.
|
| 33 |
+
2. **Statistics** (first **80%** of the history):
|
| 34 |
+
- total first-parent commits,
|
| 35 |
+
- commits that touch test files,
|
| 36 |
+
- **commits selected for the GRU pass** (those introducing *new/changed*
|
| 37 |
+
assertions),
|
| 38 |
+
- number of assertion QnAs mined,
|
| 39 |
+
- chronological **80/10/10** train/val/test split,
|
| 40 |
+
- a **commit timeline** (dot size = new assertions, colored by split),
|
| 41 |
+
- the **assertion-type distribution**.
|
| 42 |
+
2.2 **GRU processing visualization** — a PCA trajectory of the GRU hidden state
|
| 43 |
+
as it streams through the repository, plus the curve of accumulated
|
| 44 |
+
"repository knowledge" feeding the adapter.
|
| 45 |
+
3. **Prediction showcase** — pick a **held-out** assertion (from the val/test
|
| 46 |
+
tail the adapter never saw), blank its right-hand side, and compare:
|
| 47 |
+
- the plain `Qwen2.5-Coder-1.5B` completion,
|
| 48 |
+
- the completion **with the Code2LoRA-GRU adapter injected**,
|
| 49 |
+
- the ground truth — with exact-match / edit-similarity badges.
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## Architecture (one screen)
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
repo ──► select kept commits ──► per-commit diff embeddings (Qwen3-Embedding-0.6B, 2048-d)
|
| 57 |
+
│
|
| 58 |
+
repo-state @ commit 0 ──► h0 ──► ┌──┴── CommitGRU (O(1) / commit) ──► h_T
|
| 59 |
+
│
|
| 60 |
+
Code2LoRAHead ──► LoRA(A,B) per module type
|
| 61 |
+
│
|
| 62 |
+
injected into frozen Qwen2.5-Coder-1.5B ──► prediction
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
* **Commit selection / diffs / assertion mining** — `c2l_demo/git_pipeline.py`,
|
| 66 |
+
`c2l_demo/assertions.py`.
|
| 67 |
+
* **Embeddings** — `c2l_demo/embedding.py` (diff: 512/64 chunks → MaxPool‖MeanPool;
|
| 68 |
+
repo-state: 2048/256 chunks → mean‖max, L2-norm).
|
| 69 |
+
* **GRU + adapter head + LoRA injection** — `c2l_demo/core.py`.
|
| 70 |
+
* **Orchestration / generation / metrics** — `c2l_demo/engine.py`,
|
| 71 |
+
`c2l_demo/metrics.py`.
|
| 72 |
+
* **Plots** — `c2l_demo/viz.py`.
|
| 73 |
+
|
| 74 |
+
Every module is a faithful port of the corresponding file in the research
|
| 75 |
+
project (`hypernetwork/code2lora_core.py`,
|
| 76 |
+
`evaluation/run_code2lora_gru_v2_eval.py`,
|
| 77 |
+
`create_dataset/build_commit_parquet_db.py`, …) so the demo scores predictions
|
| 78 |
+
exactly the way the paper does.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
## Configuration
|
| 83 |
+
|
| 84 |
+
The Space loads the published paper checkpoint by default — **no setup
|
| 85 |
+
required**:
|
| 86 |
+
|
| 87 |
+
* model repo: [`code2lora/code2lora-gru`](https://huggingface.co/code2lora/code2lora-gru)
|
| 88 |
+
* file: `code2lora_gru.pt` (~2.85 GB — the GRU + the LoRA-generation head)
|
| 89 |
+
|
| 90 |
+
To point at a different checkpoint, override via environment variables
|
| 91 |
+
(Space → **Settings → Variables and secrets**):
|
| 92 |
+
|
| 93 |
+
| Variable | Default | Meaning |
|
| 94 |
+
|---|---|---|
|
| 95 |
+
| `CODE2LORA_CKPT_REPO` | `code2lora/code2lora-gru` | HF model repo holding the checkpoint |
|
| 96 |
+
| `CODE2LORA_CKPT_FILE` | `code2lora_gru.pt` | filename in that repo |
|
| 97 |
+
| `CODE2LORA_CKPT` | — | *or* an absolute local path (overrides the repo) |
|
| 98 |
+
|
| 99 |
+
The base model (`Qwen/Qwen2.5-Coder-1.5B`) and the encoder
|
| 100 |
+
(`Qwen/Qwen3-Embedding-0.6B`) are pulled from the Hub automatically.
|
| 101 |
+
|
| 102 |
+
> **Hardware.** A GPU Space (T4/A10/A100 or ZeroGPU) is strongly recommended —
|
| 103 |
+
> three models (~7 GB) and live generation. It will also run on CPU, but
|
| 104 |
+
> processing a repo and generating will be slow.
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## Deploy
|
| 109 |
+
|
| 110 |
+
The checkpoint is already on the Hub, so deploying is just pushing this folder:
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
# Create the Space and push this directory
|
| 114 |
+
huggingface-cli repo create code2lora-gru-demo --type space --space_sdk gradio
|
| 115 |
+
git init && git remote add origin https://huggingface.co/spaces/code2lora/code2lora-gru-demo
|
| 116 |
+
git add . && git commit -m "Code2LoRA-GRU demo" && git push origin main
|
| 117 |
+
# Then pick a GPU tier in the Space settings.
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
`upload_checkpoint.py` is provided only if you ever need to publish a *new*
|
| 121 |
+
checkpoint; the default one is ready to use.
|
| 122 |
+
|
| 123 |
+
## Run locally
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
pip install -r requirements.txt
|
| 127 |
+
# uses code2lora/code2lora-gru by default; or point at a local checkpoint:
|
| 128 |
+
# export CODE2LORA_CKPT=/abs/path/to/gru_head.best.pt
|
| 129 |
+
python app.py # serves on http://localhost:7860
|
| 130 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Code2LoRA-GRU — interactive demo (Hugging Face Space).
|
| 2 |
+
|
| 3 |
+
Paste a Python repository (local path or git URL). The Space:
|
| 4 |
+
|
| 5 |
+
1. mines its commit history and selects the commits that introduce new test
|
| 6 |
+
assertions (the "GRU pass"),
|
| 7 |
+
2. shows repository statistics, restricted to the first 80% of the history,
|
| 8 |
+
3. visualizes how the streaming GRU walks the repository and accumulates a
|
| 9 |
+
repository-specific adapter,
|
| 10 |
+
4. lets you watch the generated LoRA change what the base code model predicts
|
| 11 |
+
for a held-out assertion.
|
| 12 |
+
|
| 13 |
+
See README.md for the architecture and deployment notes.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import html
|
| 19 |
+
import tempfile
|
| 20 |
+
import traceback
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import List, Optional
|
| 23 |
+
|
| 24 |
+
import gradio as gr
|
| 25 |
+
|
| 26 |
+
from c2l_demo.engine import Engine, RepoSession
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Process-wide engine (lazy model loading happens on first use)
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
ENGINE = Engine()
|
| 33 |
+
WORK_DIR = Path(tempfile.gettempdir()) / "code2lora_demo_repos"
|
| 34 |
+
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
EXAMPLE_REPOS = [
|
| 37 |
+
"https://github.com/psf/cachecontrol",
|
| 38 |
+
"https://github.com/john-kurkowski/tldextract",
|
| 39 |
+
"https://github.com/agronholm/anyio",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
REQUIREMENTS_MD = """
|
| 43 |
+
### What kind of repository works best?
|
| 44 |
+
|
| 45 |
+
Code2LoRA-GRU learns from how a repository's **test suite** evolves, so the
|
| 46 |
+
demo needs a repo that:
|
| 47 |
+
|
| 48 |
+
- **is a Python project** with a `pytest` / `unittest` test suite,
|
| 49 |
+
- **has real git history** (clone with full history — not a shallow/zip
|
| 50 |
+
download), because the GRU streams *commit by commit*,
|
| 51 |
+
- contains **assertions that grew over time** (`assert x == ...`,
|
| 52 |
+
`self.assertEqual(...)`, `pytest.raises(...)`, `assert_allclose(...)`, …),
|
| 53 |
+
- is a **moderate size** (a few hundred commits is ideal; very large
|
| 54 |
+
monorepos will be slow on CPU).
|
| 55 |
+
|
| 56 |
+
Paste either a **public git URL** (it will be cloned) or an **absolute local
|
| 57 |
+
path** to a clone that already exists on the Space.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
HOW_IT_WORKS_MD = """
|
| 61 |
+
**Code2LoRA-GRU** turns a repository into a *parameter-efficient adapter* for a
|
| 62 |
+
frozen code model (`Qwen2.5-Coder-1.5B`) — no fine-tuning, no in-context
|
| 63 |
+
retrieval at inference time.
|
| 64 |
+
|
| 65 |
+
1. **Commit selection** — we keep only commits that introduce *new or changed*
|
| 66 |
+
test assertions; these form the streaming sequence.
|
| 67 |
+
2. **Encoding** — each kept commit's production-code diff is embedded with a
|
| 68 |
+
frozen `Qwen3-Embedding-0.6B` (2048-d). The initial repository state seeds
|
| 69 |
+
the recurrent hidden state.
|
| 70 |
+
3. **Recurrence** — a GRU walks the commits, accumulating repository knowledge
|
| 71 |
+
in its hidden state in **O(1) per commit**.
|
| 72 |
+
4. **Adapter generation** — a shared-basis head maps the hidden state to LoRA
|
| 73 |
+
weights for every attention/MLP projection, which are injected into the
|
| 74 |
+
frozen base model.
|
| 75 |
+
5. **Prediction** — the adapted model fills in a held-out assertion that plain
|
| 76 |
+
prompting cannot recover.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
# Rendering helpers
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
def _stat_card(label: str, value: str, sub: str = "", accent: str = "#4f8cff") -> str:
|
| 85 |
+
return f"""
|
| 86 |
+
<div style="flex:1;min-width:150px;background:#161922;border:1px solid #2a2e3a;
|
| 87 |
+
border-radius:12px;padding:14px 16px;">
|
| 88 |
+
<div style="font-size:12px;color:#8b93a7;text-transform:uppercase;
|
| 89 |
+
letter-spacing:.05em;">{html.escape(label)}</div>
|
| 90 |
+
<div style="font-size:28px;font-weight:700;color:{accent};margin-top:4px;">
|
| 91 |
+
{html.escape(value)}</div>
|
| 92 |
+
<div style="font-size:12px;color:#8b93a7;margin-top:2px;">{html.escape(sub)}</div>
|
| 93 |
+
</div>"""
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def render_stats(session: RepoSession) -> str:
|
| 97 |
+
r = session.result
|
| 98 |
+
pct_kept = (100.0 * r.kept_commits / r.total_commits) if r.total_commits else 0.0
|
| 99 |
+
boundary = session.endpoint_index + 1
|
| 100 |
+
cards = "".join([
|
| 101 |
+
_stat_card("Total commits", f"{r.total_commits:,}",
|
| 102 |
+
"first-parent history", "#e6e6e6"),
|
| 103 |
+
_stat_card("Touch test files", f"{r.test_touching_commits:,}",
|
| 104 |
+
"commits changing a test", "#a78bfa"),
|
| 105 |
+
_stat_card("GRU-pass commits", f"{r.kept_commits:,}",
|
| 106 |
+
f"{pct_kept:.0f}% introduce new assertions", "#22d3a6"),
|
| 107 |
+
_stat_card("Assertions (QnAs)", f"{r.n_assertions:,}",
|
| 108 |
+
"prediction targets mined", "#4f8cff"),
|
| 109 |
+
])
|
| 110 |
+
split = "".join([
|
| 111 |
+
_stat_card("Train (≤80%)", f"{r.n_train_kept:,}",
|
| 112 |
+
f"walked: {boundary} commits", "#4f8cff"),
|
| 113 |
+
_stat_card("Val (10%)", f"{r.n_val_kept:,}", "held out", "#ffcc4d"),
|
| 114 |
+
_stat_card("Test (10%)", f"{r.n_test_kept:,}", "held out", "#ff6b6b"),
|
| 115 |
+
_stat_card("Repo-state files", f"{session.n_repo_state_files:,}",
|
| 116 |
+
"embedded at commit 0", "#a78bfa"),
|
| 117 |
+
])
|
| 118 |
+
return f"""
|
| 119 |
+
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">{cards}</div>
|
| 120 |
+
<div style="display:flex;gap:12px;flex-wrap:wrap;">{split}</div>
|
| 121 |
+
<p style="color:#8b93a7;font-size:13px;margin-top:12px;">
|
| 122 |
+
The adapter is built by streaming the GRU over the first
|
| 123 |
+
<b style="color:#22d3a6;">80%</b> of the
|
| 124 |
+
{r.kept_commits:,} GRU-pass commits ({boundary:,} commits). The remaining
|
| 125 |
+
val/test commits are never seen by the adapter — predictions on them are
|
| 126 |
+
genuine generalization.</p>"""
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _badge(ok: bool) -> str:
|
| 130 |
+
if ok:
|
| 131 |
+
return ('<span style="background:#0f3d2e;color:#22d3a6;border:1px solid '
|
| 132 |
+
'#22d3a6;border-radius:6px;padding:2px 8px;font-size:12px;">'
|
| 133 |
+
'✓ exact match</span>')
|
| 134 |
+
return ('<span style="background:#3d1f1f;color:#ff6b6b;border:1px solid '
|
| 135 |
+
'#ff6b6b;border-radius:6px;padding:2px 8px;font-size:12px;">'
|
| 136 |
+
'✗ miss</span>')
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def render_prediction(res: dict) -> str:
|
| 140 |
+
prefix = res["prefix"]
|
| 141 |
+
# Show only the tail of the prefix so the card stays readable.
|
| 142 |
+
tail = prefix[-1400:]
|
| 143 |
+
if len(prefix) > len(tail):
|
| 144 |
+
tail = "…\n" + tail
|
| 145 |
+
target = res["target"]
|
| 146 |
+
base = res["base_clean"] or "(empty)"
|
| 147 |
+
adapted = res["adapted_clean"] or "(empty)"
|
| 148 |
+
|
| 149 |
+
def block(title, body, color):
|
| 150 |
+
return f"""
|
| 151 |
+
<div style="flex:1;min-width:240px;background:#161922;border:1px solid #2a2e3a;
|
| 152 |
+
border-radius:12px;padding:14px 16px;">
|
| 153 |
+
<div style="font-size:12px;color:#8b93a7;text-transform:uppercase;
|
| 154 |
+
letter-spacing:.05em;margin-bottom:6px;">{title}</div>
|
| 155 |
+
<div style="font-family:ui-monospace,Menlo,monospace;font-size:15px;
|
| 156 |
+
color:{color};word-break:break-word;">{html.escape(body)}</div>
|
| 157 |
+
</div>"""
|
| 158 |
+
|
| 159 |
+
cards = "".join([
|
| 160 |
+
block("Base Qwen2.5-Coder (no adapter)", base, "#ff9e6b") +
|
| 161 |
+
f'<div style="margin-top:8px;">{_badge(res["base_exact_match"])}'
|
| 162 |
+
f'<span style="color:#8b93a7;font-size:12px;margin-left:8px;">'
|
| 163 |
+
f'edit sim {res["base_edit_similarity"]:.2f}</span></div>',
|
| 164 |
+
block("+ Code2LoRA-GRU adapter", adapted, "#22d3a6") +
|
| 165 |
+
f'<div style="margin-top:8px;">{_badge(res["adapted_exact_match"])}'
|
| 166 |
+
f'<span style="color:#8b93a7;font-size:12px;margin-left:8px;">'
|
| 167 |
+
f'edit sim {res["adapted_edit_similarity"]:.2f}</span></div>',
|
| 168 |
+
block("Ground truth", target, "#e6e6e6"),
|
| 169 |
+
])
|
| 170 |
+
|
| 171 |
+
return f"""
|
| 172 |
+
<div style="background:#0f1117;border:1px solid #2a2e3a;border-radius:12px;
|
| 173 |
+
padding:14px 16px;margin-bottom:12px;">
|
| 174 |
+
<div style="font-size:12px;color:#8b93a7;text-transform:uppercase;
|
| 175 |
+
letter-spacing:.05em;margin-bottom:6px;">
|
| 176 |
+
Test prompt (tail · {res['n_prompt_tokens']} tokens) — model must
|
| 177 |
+
complete the ▶ blank</div>
|
| 178 |
+
<pre style="white-space:pre-wrap;font-family:ui-monospace,Menlo,monospace;
|
| 179 |
+
font-size:12.5px;color:#cfd6e4;margin:0;max-height:320px;
|
| 180 |
+
overflow:auto;">{html.escape(tail)}<span style="color:#22d3a6;
|
| 181 |
+
">▶ ???</span></pre>
|
| 182 |
+
</div>
|
| 183 |
+
<div style="display:flex;gap:12px;flex-wrap:wrap;">{cards}</div>"""
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _qna_choices(session: RepoSession) -> List:
|
| 187 |
+
"""Build dropdown choices, preferring held-out (val/test) assertions."""
|
| 188 |
+
items = []
|
| 189 |
+
for c in session.result.commits:
|
| 190 |
+
for qi, q in enumerate(c.qnas):
|
| 191 |
+
held = c.in_repo_split in ("val", "test")
|
| 192 |
+
items.append((c, qi, q, held))
|
| 193 |
+
# Held-out first, then train; cap to keep the dropdown snappy.
|
| 194 |
+
items.sort(key=lambda x: (not x[3], x[0].commit_index))
|
| 195 |
+
choices = []
|
| 196 |
+
for c, qi, q, held in items[:300]:
|
| 197 |
+
tag = "★ held-out" if held else "train"
|
| 198 |
+
fn = q.test_function or "?"
|
| 199 |
+
snippet = " ".join(q.prefix.strip().splitlines()[-1:])[-48:]
|
| 200 |
+
label = f"[{tag}] {Path(q.test_file).name}::{fn} L{q.lineno} — …{snippet}"
|
| 201 |
+
choices.append((label, f"{c.commit_index}:{qi}"))
|
| 202 |
+
return choices
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ---------------------------------------------------------------------------
|
| 206 |
+
# Callbacks
|
| 207 |
+
# ---------------------------------------------------------------------------
|
| 208 |
+
|
| 209 |
+
def process_repo_cb(repo_input: str, progress=gr.Progress()):
|
| 210 |
+
from c2l_demo import viz
|
| 211 |
+
|
| 212 |
+
def _cb(msg, frac):
|
| 213 |
+
try:
|
| 214 |
+
progress(min(max(frac, 0.0), 1.0), desc=msg)
|
| 215 |
+
except Exception:
|
| 216 |
+
pass
|
| 217 |
+
|
| 218 |
+
try:
|
| 219 |
+
progress(0.0, desc="Resolving repository…")
|
| 220 |
+
repo_dir, repo_id = ENGINE.resolve_repo(repo_input, WORK_DIR, progress=_cb)
|
| 221 |
+
session = ENGINE.process_and_walk(repo_dir, repo_id, walk_fraction=0.8,
|
| 222 |
+
progress=_cb)
|
| 223 |
+
except Exception as e:
|
| 224 |
+
err = (f"<div style='color:#ff6b6b;background:#1a1014;border:1px solid "
|
| 225 |
+
f"#ff6b6b;border-radius:10px;padding:14px;'><b>Could not process "
|
| 226 |
+
f"this repository.</b><br><br><pre style='white-space:pre-wrap;"
|
| 227 |
+
f"color:#ffb3b3;font-size:12px;'>{html.escape(str(e))}</pre></div>")
|
| 228 |
+
return (err, None, None, None,
|
| 229 |
+
gr.update(choices=[], value=None), None, "")
|
| 230 |
+
|
| 231 |
+
stats = render_stats(session)
|
| 232 |
+
fig_timeline = viz.plot_commit_timeline(session.result, session.endpoint_index)
|
| 233 |
+
fig_types = viz.plot_assertion_type_breakdown(session.result)
|
| 234 |
+
fig_gru = viz.plot_gru_processing(session)
|
| 235 |
+
choices = _qna_choices(session)
|
| 236 |
+
dd = gr.update(choices=choices, value=(choices[0][1] if choices else None))
|
| 237 |
+
ready = ("<div style='color:#22d3a6;'>Adapter ready — pick a held-out "
|
| 238 |
+
"assertion below and run the prediction.</div>")
|
| 239 |
+
return stats, fig_timeline, fig_types, fig_gru, dd, session, ready
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def predict_cb(session: Optional[RepoSession], selected: Optional[str]):
|
| 243 |
+
if session is None:
|
| 244 |
+
return ("<div style='color:#ff6b6b;'>Process a repository first.</div>")
|
| 245 |
+
if not selected:
|
| 246 |
+
return ("<div style='color:#ff6b6b;'>Pick an assertion to predict.</div>")
|
| 247 |
+
try:
|
| 248 |
+
ci_str, qi_str = selected.split(":")
|
| 249 |
+
ci, qi = int(ci_str), int(qi_str)
|
| 250 |
+
commit = next(c for c in session.result.commits if c.commit_index == ci)
|
| 251 |
+
q = commit.qnas[qi]
|
| 252 |
+
res = ENGINE.predict(session, q.prefix, q.target)
|
| 253 |
+
return render_prediction(res)
|
| 254 |
+
except Exception as e:
|
| 255 |
+
return (f"<div style='color:#ff6b6b;'><b>Prediction failed.</b><pre "
|
| 256 |
+
f"style='white-space:pre-wrap;font-size:12px;'>"
|
| 257 |
+
f"{html.escape(traceback.format_exc())}</pre></div>")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ---------------------------------------------------------------------------
|
| 261 |
+
# UI
|
| 262 |
+
# ---------------------------------------------------------------------------
|
| 263 |
+
|
| 264 |
+
CSS = """
|
| 265 |
+
.gradio-container {max-width: 1180px !important;}
|
| 266 |
+
#hero {background: linear-gradient(135deg,#1b2a4a 0%,#0f1117 60%);
|
| 267 |
+
border:1px solid #2a2e3a;border-radius:16px;padding:24px 28px;margin-bottom:8px;}
|
| 268 |
+
#hero h1 {font-size:30px;margin:0 0 6px 0;background:linear-gradient(90deg,#4f8cff,#22d3a6);
|
| 269 |
+
-webkit-background-clip:text;-webkit-text-fill-color:transparent;}
|
| 270 |
+
.section-title {font-size:20px;font-weight:700;margin-top:6px;}
|
| 271 |
+
"""
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def build_demo():
|
| 275 |
+
with gr.Blocks(title="Code2LoRA-GRU Demo", theme=gr.themes.Soft(
|
| 276 |
+
primary_hue="blue", neutral_hue="slate"), css=CSS) as demo:
|
| 277 |
+
session_state = gr.State(value=None)
|
| 278 |
+
|
| 279 |
+
gr.HTML("""
|
| 280 |
+
<div id="hero">
|
| 281 |
+
<h1>Code2LoRA-GRU</h1>
|
| 282 |
+
<div style="color:#cfd6e4;font-size:15px;max-width:820px;">
|
| 283 |
+
Turn any Python repository into a parameter-efficient adapter for a
|
| 284 |
+
frozen code LLM — by <b>streaming its commit history through a GRU</b>
|
| 285 |
+
and generating LoRA weights on the fly. No fine-tuning, no retrieval
|
| 286 |
+
at inference time.
|
| 287 |
+
</div>
|
| 288 |
+
</div>""")
|
| 289 |
+
|
| 290 |
+
with gr.Accordion("How it works", open=False):
|
| 291 |
+
gr.Markdown(HOW_IT_WORKS_MD)
|
| 292 |
+
|
| 293 |
+
# 0) Instructions / requirements
|
| 294 |
+
with gr.Accordion("0 · Repository requirements (read me first)", open=True):
|
| 295 |
+
gr.Markdown(REQUIREMENTS_MD)
|
| 296 |
+
|
| 297 |
+
# 1) Input
|
| 298 |
+
gr.Markdown("## 1 · Point at a repository", elem_classes="section-title")
|
| 299 |
+
with gr.Row():
|
| 300 |
+
repo_input = gr.Textbox(
|
| 301 |
+
label="Git URL or local path",
|
| 302 |
+
placeholder="https://github.com/owner/repo or /abs/path/to/clone",
|
| 303 |
+
scale=4)
|
| 304 |
+
process_btn = gr.Button("Process repository", variant="primary", scale=1)
|
| 305 |
+
gr.Examples(EXAMPLE_REPOS, inputs=repo_input, label="Example repositories")
|
| 306 |
+
status = gr.HTML()
|
| 307 |
+
|
| 308 |
+
# 2) Statistics
|
| 309 |
+
gr.Markdown("## 2 · Repository statistics (first 80% of history)",
|
| 310 |
+
elem_classes="section-title")
|
| 311 |
+
stats_html = gr.HTML()
|
| 312 |
+
with gr.Row():
|
| 313 |
+
timeline_plot = gr.Plot(label="Commit timeline · dot size = new assertions")
|
| 314 |
+
with gr.Row():
|
| 315 |
+
types_plot = gr.Plot(label="Assertion-type distribution")
|
| 316 |
+
|
| 317 |
+
# 2.2) GRU processing
|
| 318 |
+
gr.Markdown("## 2.2 · How the GRU processes the repository",
|
| 319 |
+
elem_classes="section-title")
|
| 320 |
+
gru_plot = gr.Plot(label="Hidden-state trajectory + accumulated repository knowledge")
|
| 321 |
+
|
| 322 |
+
# 3) Prediction
|
| 323 |
+
gr.Markdown("## 3 · Watch the adapter predict a held-out assertion",
|
| 324 |
+
elem_classes="section-title")
|
| 325 |
+
gr.Markdown(
|
| 326 |
+
"Pick an assertion (★ = a *held-out* val/test commit the adapter "
|
| 327 |
+
"never saw). We blank its right-hand side and ask the model to fill "
|
| 328 |
+
"it in — first the plain base model, then with the repository "
|
| 329 |
+
"adapter injected.")
|
| 330 |
+
with gr.Row():
|
| 331 |
+
qna_dropdown = gr.Dropdown(label="Held-out assertion to complete",
|
| 332 |
+
choices=[], scale=4)
|
| 333 |
+
predict_btn = gr.Button("Predict", variant="primary", scale=1)
|
| 334 |
+
prediction_html = gr.HTML()
|
| 335 |
+
|
| 336 |
+
gr.Markdown(
|
| 337 |
+
"<div style='color:#8b93a7;font-size:12px;margin-top:16px;'>"
|
| 338 |
+
"Base model: <code>Qwen/Qwen2.5-Coder-1.5B</code> · Encoder: "
|
| 339 |
+
"<code>Qwen/Qwen3-Embedding-0.6B</code> · Adapter: Code2LoRA-GRU "
|
| 340 |
+
"(rank-16 LoRA generated by a commit-streaming GRU).</div>")
|
| 341 |
+
|
| 342 |
+
process_btn.click(
|
| 343 |
+
process_repo_cb, inputs=[repo_input],
|
| 344 |
+
outputs=[stats_html, timeline_plot, types_plot, gru_plot,
|
| 345 |
+
qna_dropdown, session_state, status])
|
| 346 |
+
predict_btn.click(
|
| 347 |
+
predict_cb, inputs=[session_state, qna_dropdown],
|
| 348 |
+
outputs=[prediction_html])
|
| 349 |
+
return demo
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
if __name__ == "__main__":
|
| 353 |
+
build_demo().queue().launch(server_name="0.0.0.0", server_port=7860)
|
c2l_demo/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Code2LoRA-GRU interactive demo package.
|
| 2 |
+
|
| 3 |
+
Self-contained re-packaging of the Code2LoRA-GRU inference pipeline so it can
|
| 4 |
+
run as a standalone Hugging Face Space without depending on the research
|
| 5 |
+
monorepo. Every module here is a faithful port of the corresponding file in
|
| 6 |
+
the `RepoPeftData` project; see each module docstring for provenance.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"core",
|
| 11 |
+
"assertions",
|
| 12 |
+
"git_pipeline",
|
| 13 |
+
"embedding",
|
| 14 |
+
"metrics",
|
| 15 |
+
"engine",
|
| 16 |
+
"viz",
|
| 17 |
+
]
|
c2l_demo/assertions.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Assertion extraction from Python test files.
|
| 2 |
+
|
| 3 |
+
Faithful port of the AST-based assertion parsers in
|
| 4 |
+
``create_dataset/create_qnas.py`` plus the full-file-prefix extractor
|
| 5 |
+
``extract_assertions_from_source`` from
|
| 6 |
+
``create_dataset/build_commit_parquet_db.py``.
|
| 7 |
+
|
| 8 |
+
A QnA pair is one assertion turned into (prefix, target):
|
| 9 |
+
|
| 10 |
+
* ``prefix`` -- the full test-file content up to (but not including) the
|
| 11 |
+
assertion's right-hand side, plus the in-line prefix (e.g. ``assert x == ``).
|
| 12 |
+
* ``target`` -- the right-hand side the model must predict (e.g. ``42``).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import ast
|
| 18 |
+
import re
|
| 19 |
+
import warnings
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from typing import List, Optional
|
| 22 |
+
|
| 23 |
+
SELF_ASSERT_METHODS = {
|
| 24 |
+
"assertEqual", "assertNotEqual", "assertTrue", "assertFalse",
|
| 25 |
+
"assertIs", "assertIsNot", "assertIsNone", "assertIsNotNone",
|
| 26 |
+
"assertIn", "assertNotIn", "assertIsInstance", "assertNotIsInstance",
|
| 27 |
+
"assertRaises", "assertRaisesRegex", "assertAlmostEqual", "assertNotAlmostEqual",
|
| 28 |
+
"assertDictEqual", "assertListEqual", "assertTupleEqual", "assertSetEqual",
|
| 29 |
+
"assertSequenceEqual", "assertRegex", "assertNotRegex", "assertCountEqual",
|
| 30 |
+
"assertWarns", "assertLogs", "assertMultiLineEqual",
|
| 31 |
+
"assertGreater", "assertGreaterEqual", "assertLess", "assertLessEqual",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class AssertionPair:
|
| 37 |
+
assertion_type: str
|
| 38 |
+
prefix: str
|
| 39 |
+
target: str
|
| 40 |
+
lineno: int
|
| 41 |
+
col_offset: int
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_source_segment(source: str, node: ast.AST) -> Optional[str]:
|
| 45 |
+
if hasattr(ast, "get_source_segment"):
|
| 46 |
+
return ast.get_source_segment(source, node)
|
| 47 |
+
lines = source.splitlines()
|
| 48 |
+
start = getattr(node, "lineno", 1) - 1
|
| 49 |
+
end = getattr(node, "end_lineno", start + 1) - 1
|
| 50 |
+
if start < 0 or end >= len(lines):
|
| 51 |
+
return None
|
| 52 |
+
return "\n".join(lines[start:end + 1])
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def flatten_to_oneliner(s: str) -> str:
|
| 56 |
+
if not s or "\n" not in s:
|
| 57 |
+
return s
|
| 58 |
+
return " ".join(s.split())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# --- 1. plain assert -------------------------------------------------------
|
| 62 |
+
|
| 63 |
+
def _parse_plain_assert(source, node, lines):
|
| 64 |
+
src = get_source_segment(source, node)
|
| 65 |
+
if not src:
|
| 66 |
+
return None
|
| 67 |
+
lineno, col = node.lineno, node.col_offset
|
| 68 |
+
test = node.test
|
| 69 |
+
if isinstance(test, ast.Compare):
|
| 70 |
+
left_src = get_source_segment(source, test.left)
|
| 71 |
+
if not left_src:
|
| 72 |
+
return None
|
| 73 |
+
op = test.ops[0]
|
| 74 |
+
comparators = test.comparators
|
| 75 |
+
if not comparators:
|
| 76 |
+
return None
|
| 77 |
+
rhs_src = get_source_segment(source, comparators[0])
|
| 78 |
+
if not rhs_src:
|
| 79 |
+
return None
|
| 80 |
+
op_map = {
|
| 81 |
+
ast.Eq: " == ", ast.NotEq: " != ", ast.Lt: " < ", ast.LtE: " <= ",
|
| 82 |
+
ast.Gt: " > ", ast.GtE: " >= ", ast.In: " in ", ast.NotIn: " not in ",
|
| 83 |
+
ast.Is: " is ", ast.IsNot: " is not ",
|
| 84 |
+
}
|
| 85 |
+
op_str = op_map.get(type(op), " ??? ")
|
| 86 |
+
prefix = f"assert {left_src}{op_str}"
|
| 87 |
+
target = rhs_src
|
| 88 |
+
else:
|
| 89 |
+
prefix = "assert "
|
| 90 |
+
target = get_source_segment(source, test) or ""
|
| 91 |
+
return AssertionPair("assert", prefix, target, lineno, col)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# --- 2. self.assert* -------------------------------------------------------
|
| 95 |
+
|
| 96 |
+
def _parse_self_assert(source, node, lines):
|
| 97 |
+
if not isinstance(node.func, ast.Attribute):
|
| 98 |
+
return None
|
| 99 |
+
if not isinstance(node.func.value, ast.Name) or node.func.value.id != "self":
|
| 100 |
+
return None
|
| 101 |
+
method = node.func.attr
|
| 102 |
+
if method not in SELF_ASSERT_METHODS:
|
| 103 |
+
return None
|
| 104 |
+
args = node.args
|
| 105 |
+
if not args:
|
| 106 |
+
return None
|
| 107 |
+
if not get_source_segment(source, node):
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
if method == "assertRaises":
|
| 111 |
+
exc_src = get_source_segment(source, args[0])
|
| 112 |
+
if exc_src:
|
| 113 |
+
return AssertionPair(f"self.{method}", "self.assertRaises(",
|
| 114 |
+
exc_src + ")", node.lineno, node.col_offset)
|
| 115 |
+
return None
|
| 116 |
+
if method == "assertRaisesRegex":
|
| 117 |
+
if len(args) >= 2:
|
| 118 |
+
exc_src = get_source_segment(source, args[0])
|
| 119 |
+
pat_src = get_source_segment(source, args[1])
|
| 120 |
+
if exc_src and pat_src:
|
| 121 |
+
return AssertionPair(f"self.{method}", "self.assertRaisesRegex(",
|
| 122 |
+
f"{exc_src}, {pat_src})",
|
| 123 |
+
node.lineno, node.col_offset)
|
| 124 |
+
return None
|
| 125 |
+
if method in ("assertTrue", "assertFalse", "assertIsNone", "assertIsNotNone"):
|
| 126 |
+
arg_src = get_source_segment(source, args[0])
|
| 127 |
+
if arg_src:
|
| 128 |
+
return AssertionPair(f"self.{method}", f"self.{method}(",
|
| 129 |
+
arg_src + ")", node.lineno, node.col_offset)
|
| 130 |
+
return None
|
| 131 |
+
if len(args) >= 2:
|
| 132 |
+
last_src = get_source_segment(source, args[-1])
|
| 133 |
+
if last_src:
|
| 134 |
+
prefix_parts = [f"self.{method}("]
|
| 135 |
+
for i, arg in enumerate(args[:-1]):
|
| 136 |
+
arg_src = get_source_segment(source, arg)
|
| 137 |
+
if arg_src:
|
| 138 |
+
prefix_parts.append(arg_src)
|
| 139 |
+
if i < len(args) - 2:
|
| 140 |
+
prefix_parts.append(", ")
|
| 141 |
+
prefix = "".join(prefix_parts) + ", "
|
| 142 |
+
return AssertionPair(f"self.{method}", prefix, last_src + ")",
|
| 143 |
+
node.lineno, node.col_offset)
|
| 144 |
+
return None
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# --- 3. pytest.raises ------------------------------------------------------
|
| 148 |
+
|
| 149 |
+
def _parse_pytest_raises(source, node, lines):
|
| 150 |
+
if not isinstance(node.func, ast.Attribute):
|
| 151 |
+
return None
|
| 152 |
+
val = node.func.value
|
| 153 |
+
if not isinstance(val, ast.Name) or val.id != "pytest" or node.func.attr != "raises":
|
| 154 |
+
return None
|
| 155 |
+
args = node.args
|
| 156 |
+
if not args:
|
| 157 |
+
return None
|
| 158 |
+
exc_src = get_source_segment(source, args[0])
|
| 159 |
+
if not exc_src:
|
| 160 |
+
return None
|
| 161 |
+
kw_parts = []
|
| 162 |
+
for kw in node.keywords:
|
| 163 |
+
if kw.arg and kw.value:
|
| 164 |
+
kw_src = get_source_segment(source, kw.value)
|
| 165 |
+
if kw_src:
|
| 166 |
+
kw_parts.append(f', {kw.arg}={kw_src}')
|
| 167 |
+
target = exc_src + "".join(kw_parts) + ")"
|
| 168 |
+
return AssertionPair("pytest.raises", "pytest.raises(", target,
|
| 169 |
+
node.lineno, node.col_offset)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# --- 4. pytest.approx ------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
def _parse_pytest_approx(source, node, lines):
|
| 175 |
+
if not isinstance(node.func, ast.Attribute):
|
| 176 |
+
return None
|
| 177 |
+
val = node.func.value
|
| 178 |
+
if not isinstance(val, ast.Name) or val.id != "pytest" or node.func.attr != "approx":
|
| 179 |
+
return None
|
| 180 |
+
args = node.args
|
| 181 |
+
if not args:
|
| 182 |
+
return None
|
| 183 |
+
arg_src = get_source_segment(source, args[0])
|
| 184 |
+
if not arg_src:
|
| 185 |
+
return None
|
| 186 |
+
kw_parts = []
|
| 187 |
+
for kw in node.keywords:
|
| 188 |
+
if kw.arg and kw.value:
|
| 189 |
+
kw_src = get_source_segment(source, kw.value)
|
| 190 |
+
if kw_src:
|
| 191 |
+
kw_parts.append(f', {kw.arg}={kw_src}')
|
| 192 |
+
target = arg_src + "".join(kw_parts) + ")"
|
| 193 |
+
return AssertionPair("pytest.approx", "pytest.approx(", target,
|
| 194 |
+
node.lineno, node.col_offset)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# --- 5. assert_* (numpy/testing) ------------------------------------------
|
| 198 |
+
|
| 199 |
+
def _parse_assert_underscore(source, node, lines):
|
| 200 |
+
func = node.func
|
| 201 |
+
if isinstance(func, ast.Attribute):
|
| 202 |
+
name = func.attr
|
| 203 |
+
if not name.startswith("assert_") or name == "assert_":
|
| 204 |
+
return None
|
| 205 |
+
if isinstance(func.value, ast.Attribute):
|
| 206 |
+
if func.value.attr == "mock" or (
|
| 207 |
+
isinstance(func.value.value, ast.Name)
|
| 208 |
+
and func.value.value.id == "unittest"
|
| 209 |
+
):
|
| 210 |
+
return None
|
| 211 |
+
elif isinstance(func, ast.Name):
|
| 212 |
+
name = func.id
|
| 213 |
+
if not name.startswith("assert_") or name == "assert_":
|
| 214 |
+
return None
|
| 215 |
+
else:
|
| 216 |
+
return None
|
| 217 |
+
args = node.args
|
| 218 |
+
if not args:
|
| 219 |
+
return None
|
| 220 |
+
last_src = get_source_segment(source, args[-1])
|
| 221 |
+
if not last_src:
|
| 222 |
+
return None
|
| 223 |
+
prefix_parts = [f"{get_source_segment(source, func) or name}("]
|
| 224 |
+
for i, arg in enumerate(args[:-1]):
|
| 225 |
+
arg_src = get_source_segment(source, arg)
|
| 226 |
+
if arg_src:
|
| 227 |
+
prefix_parts.append(arg_src)
|
| 228 |
+
if i < len(args) - 2:
|
| 229 |
+
prefix_parts.append(", ")
|
| 230 |
+
prefix = "".join(prefix_parts) + ", "
|
| 231 |
+
return AssertionPair("assert_*", prefix, last_src + ")",
|
| 232 |
+
node.lineno, node.col_offset)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _find_enclosing_test(tree, node):
|
| 236 |
+
node_start = getattr(node, "lineno", 0)
|
| 237 |
+
node_end = getattr(node, "end_lineno", node_start)
|
| 238 |
+
best = None
|
| 239 |
+
for n in ast.walk(tree):
|
| 240 |
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 241 |
+
if "test" in n.name.lower():
|
| 242 |
+
s, e = getattr(n, "lineno", 0), getattr(n, "end_lineno", 0)
|
| 243 |
+
if s <= node_start and node_end <= e:
|
| 244 |
+
if best is None or (getattr(best, "lineno", 0) < s):
|
| 245 |
+
best = n
|
| 246 |
+
if isinstance(n, ast.ClassDef) and "test" in n.name.lower():
|
| 247 |
+
s, e = getattr(n, "lineno", 0), getattr(n, "end_lineno", 0)
|
| 248 |
+
if s <= node_start and node_end <= e:
|
| 249 |
+
for item in n.body:
|
| 250 |
+
if (isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
|
| 251 |
+
and item.name.startswith("test_")):
|
| 252 |
+
is_, ie = getattr(item, "lineno", 0), getattr(item, "end_lineno", 0)
|
| 253 |
+
if is_ <= node_start and node_end <= ie:
|
| 254 |
+
if best is None or getattr(best, "lineno", 0) < is_:
|
| 255 |
+
best = item
|
| 256 |
+
return best
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# ---------------------------------------------------------------------------
|
| 260 |
+
# Full-file-prefix extractor (build_commit_parquet_db.py)
|
| 261 |
+
# ---------------------------------------------------------------------------
|
| 262 |
+
|
| 263 |
+
@dataclass
|
| 264 |
+
class ExtractedAssertion:
|
| 265 |
+
assertion_type: str
|
| 266 |
+
prefix: str
|
| 267 |
+
target: str
|
| 268 |
+
lineno: int
|
| 269 |
+
col_offset: int
|
| 270 |
+
test_function: str
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
_WHITESPACE_RE = re.compile(r"\s+")
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def normalize_for_id(s: str) -> str:
|
| 277 |
+
return _WHITESPACE_RE.sub(" ", (s or "").strip())
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _full_prefix_for(source_lines: List[str], pair: AssertionPair) -> str:
|
| 281 |
+
lineno = pair.lineno
|
| 282 |
+
col = pair.col_offset
|
| 283 |
+
if lineno < 1:
|
| 284 |
+
return pair.prefix
|
| 285 |
+
pre = source_lines[: lineno - 1]
|
| 286 |
+
last_line = source_lines[lineno - 1] if lineno - 1 < len(source_lines) else ""
|
| 287 |
+
if col < 0:
|
| 288 |
+
col = 0
|
| 289 |
+
last_line_prefix = last_line[:col] if col <= len(last_line) else last_line
|
| 290 |
+
return "".join(pre) + last_line_prefix + pair.prefix
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def extract_assertions_from_source(source: str) -> List[ExtractedAssertion]:
|
| 294 |
+
"""Walk the AST and return full-file-prefix assertions for one file."""
|
| 295 |
+
if not source:
|
| 296 |
+
return []
|
| 297 |
+
try:
|
| 298 |
+
with warnings.catch_warnings():
|
| 299 |
+
warnings.simplefilter("ignore", SyntaxWarning)
|
| 300 |
+
tree = ast.parse(source)
|
| 301 |
+
except (SyntaxError, ValueError):
|
| 302 |
+
return []
|
| 303 |
+
|
| 304 |
+
lines = source.splitlines(keepends=True)
|
| 305 |
+
out: List[ExtractedAssertion] = []
|
| 306 |
+
|
| 307 |
+
for node in ast.walk(tree):
|
| 308 |
+
pair: Optional[AssertionPair] = None
|
| 309 |
+
if isinstance(node, ast.Assert):
|
| 310 |
+
pair = _parse_plain_assert(source, node, lines)
|
| 311 |
+
elif isinstance(node, ast.Call):
|
| 312 |
+
func = node.func
|
| 313 |
+
if isinstance(func, ast.Attribute):
|
| 314 |
+
val = func.value
|
| 315 |
+
val_id = getattr(val, "id", None) if isinstance(val, ast.Name) else None
|
| 316 |
+
if val_id == "self" and func.attr in SELF_ASSERT_METHODS:
|
| 317 |
+
pair = _parse_self_assert(source, node, lines)
|
| 318 |
+
elif val_id == "pytest":
|
| 319 |
+
if func.attr == "raises":
|
| 320 |
+
pair = _parse_pytest_raises(source, node, lines)
|
| 321 |
+
elif func.attr == "approx":
|
| 322 |
+
pair = _parse_pytest_approx(source, node, lines)
|
| 323 |
+
else:
|
| 324 |
+
pair = _parse_assert_underscore(source, node, lines)
|
| 325 |
+
else:
|
| 326 |
+
pair = _parse_assert_underscore(source, node, lines)
|
| 327 |
+
elif isinstance(func, ast.Name) and func.id.startswith("assert_"):
|
| 328 |
+
pair = _parse_assert_underscore(source, node, lines)
|
| 329 |
+
|
| 330 |
+
if pair is None or not pair.target.strip():
|
| 331 |
+
continue
|
| 332 |
+
target = pair.target
|
| 333 |
+
if target.lstrip().startswith(","):
|
| 334 |
+
continue
|
| 335 |
+
if "\n" in target:
|
| 336 |
+
target = flatten_to_oneliner(target)
|
| 337 |
+
if not target.strip():
|
| 338 |
+
continue
|
| 339 |
+
|
| 340 |
+
full_prefix = _full_prefix_for(lines, pair)
|
| 341 |
+
test_node = _find_enclosing_test(tree, node)
|
| 342 |
+
test_name = test_node.name if test_node else ""
|
| 343 |
+
out.append(ExtractedAssertion(
|
| 344 |
+
assertion_type=pair.assertion_type,
|
| 345 |
+
prefix=full_prefix,
|
| 346 |
+
target=target,
|
| 347 |
+
lineno=pair.lineno,
|
| 348 |
+
col_offset=pair.col_offset,
|
| 349 |
+
test_function=test_name,
|
| 350 |
+
))
|
| 351 |
+
return out
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
__all__ = [
|
| 355 |
+
"AssertionPair",
|
| 356 |
+
"ExtractedAssertion",
|
| 357 |
+
"extract_assertions_from_source",
|
| 358 |
+
"normalize_for_id",
|
| 359 |
+
"SELF_ASSERT_METHODS",
|
| 360 |
+
]
|
c2l_demo/core.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model core for Code2LoRA-GRU inference.
|
| 2 |
+
|
| 3 |
+
Faithful port of ``hypernetwork/code2lora_core.py`` from the RepoPeftData
|
| 4 |
+
project, trimmed to the pieces needed for inference (the parquet loaders that
|
| 5 |
+
the trainer uses are intentionally omitted so this Space has no pyarrow
|
| 6 |
+
dependency).
|
| 7 |
+
|
| 8 |
+
The two networks that get loaded from a ``gru_head.*.pt`` checkpoint are:
|
| 9 |
+
|
| 10 |
+
* :class:`CommitGRU` -- a 1-layer GRU that ingests per-commit diff
|
| 11 |
+
embeddings; ``h_0`` comes from a learnable projection of the initial
|
| 12 |
+
repo-state embedding.
|
| 13 |
+
* :class:`Code2LoRAHead` -- maps the GRU hidden state to one LoRA (A, B)
|
| 14 |
+
pair per module *type* (q_proj, v_proj, ...), shared across all
|
| 15 |
+
transformer layers.
|
| 16 |
+
|
| 17 |
+
The generated weights are pushed into :class:`LoRA` wrappers that replace the
|
| 18 |
+
target ``nn.Linear`` modules of the frozen base LLM.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import math
|
| 24 |
+
import re
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
import torch.nn.functional as F
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
# LoRA module + injection
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
|
| 37 |
+
class LoRA(nn.Module):
|
| 38 |
+
"""Wraps an ``nn.Linear`` with an additive low-rank update.
|
| 39 |
+
|
| 40 |
+
Forward: ``y = base(x) + scaling * (x @ A^T) @ B^T`` where the per-call
|
| 41 |
+
A: ``[rank, in_features]`` and B: ``[out_features, rank]`` come from the
|
| 42 |
+
hypernet via :meth:`set_lora_weights`.
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(self, base: nn.Linear, in_features: int, out_features: int,
|
| 46 |
+
rank: int, alpha: float):
|
| 47 |
+
super().__init__()
|
| 48 |
+
self.base = base
|
| 49 |
+
for p in self.base.parameters():
|
| 50 |
+
p.requires_grad = False
|
| 51 |
+
self.in_features = in_features
|
| 52 |
+
self.out_features = out_features
|
| 53 |
+
self.rank = rank
|
| 54 |
+
self.scaling = float(alpha) / float(max(1, rank))
|
| 55 |
+
self.A: Optional[torch.Tensor] = None # [rank, in_features]
|
| 56 |
+
self.B: Optional[torch.Tensor] = None # [out_features, rank]
|
| 57 |
+
|
| 58 |
+
def set_lora_weights(self, A: torch.Tensor, B: torch.Tensor) -> None:
|
| 59 |
+
self.A = A
|
| 60 |
+
self.B = B
|
| 61 |
+
|
| 62 |
+
def clear_lora_weights(self) -> None:
|
| 63 |
+
self.A = None
|
| 64 |
+
self.B = None
|
| 65 |
+
|
| 66 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 67 |
+
y = self.base(x)
|
| 68 |
+
if self.A is None or self.B is None:
|
| 69 |
+
return y
|
| 70 |
+
x_f32 = x.detach().to(torch.float32)
|
| 71 |
+
A = self.A.to(torch.float32)
|
| 72 |
+
B = self.B.to(torch.float32)
|
| 73 |
+
delta = F.linear(F.linear(x_f32, A), B) * self.scaling
|
| 74 |
+
return y + delta.to(dtype=y.dtype)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class ModuleSpec:
|
| 79 |
+
full_name: str # e.g. 'model.layers.5.self_attn.q_proj'
|
| 80 |
+
layer_idx: int
|
| 81 |
+
type: str # e.g. 'q_proj'
|
| 82 |
+
in_features: int
|
| 83 |
+
out_features: int
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_module_specs(model: nn.Module, target_module_types: List[str]
|
| 87 |
+
) -> List[ModuleSpec]:
|
| 88 |
+
"""Discover every nn.Linear whose name contains a target type."""
|
| 89 |
+
specs: List[ModuleSpec] = []
|
| 90 |
+
pat = re.compile(r"\bmodel\.layers\.(\d+)\.")
|
| 91 |
+
for name, m in model.named_modules():
|
| 92 |
+
match_type = next((t for t in target_module_types if t in name), None)
|
| 93 |
+
if match_type is None:
|
| 94 |
+
continue
|
| 95 |
+
if not isinstance(m, nn.Linear):
|
| 96 |
+
continue
|
| 97 |
+
m_layer = pat.search(name)
|
| 98 |
+
layer_idx = int(m_layer.group(1)) if m_layer else -1
|
| 99 |
+
specs.append(ModuleSpec(
|
| 100 |
+
full_name=name,
|
| 101 |
+
layer_idx=layer_idx,
|
| 102 |
+
type=match_type,
|
| 103 |
+
in_features=int(m.in_features),
|
| 104 |
+
out_features=int(m.out_features),
|
| 105 |
+
))
|
| 106 |
+
specs.sort(key=lambda s: (s.layer_idx, s.full_name))
|
| 107 |
+
return specs
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def replace_with_lora(model: nn.Module, specs: List[ModuleSpec],
|
| 111 |
+
rank: int, alpha: float) -> None:
|
| 112 |
+
"""Replace each target ``nn.Linear`` with a :class:`LoRA` wrapper. Idempotent."""
|
| 113 |
+
named = dict(model.named_modules())
|
| 114 |
+
device = next(model.parameters()).device
|
| 115 |
+
dtype = next(model.parameters()).dtype
|
| 116 |
+
for sp in specs:
|
| 117 |
+
parent_name, attr = sp.full_name.rsplit(".", 1)
|
| 118 |
+
orig = getattr(named[parent_name], attr)
|
| 119 |
+
if isinstance(orig, LoRA):
|
| 120 |
+
continue
|
| 121 |
+
assert isinstance(orig, nn.Linear), \
|
| 122 |
+
f"{sp.full_name} is not nn.Linear (got {type(orig)})"
|
| 123 |
+
wrapped = LoRA(orig, sp.in_features, sp.out_features,
|
| 124 |
+
rank, alpha).to(device=device, dtype=dtype)
|
| 125 |
+
setattr(named[parent_name], attr, wrapped)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def inject_lora_weights(model: nn.Module, specs: List[ModuleSpec],
|
| 129 |
+
head_out: Dict[str, Dict[str, torch.Tensor]],
|
| 130 |
+
batch_index: int = 0) -> None:
|
| 131 |
+
"""Push ``head_out['A'][type]`` / ``head_out['B'][type]`` into each LoRA."""
|
| 132 |
+
A_by_type = head_out["A"]
|
| 133 |
+
B_by_type = head_out["B"]
|
| 134 |
+
named = dict(model.named_modules())
|
| 135 |
+
for sp in specs:
|
| 136 |
+
named[sp.full_name].set_lora_weights(
|
| 137 |
+
A_by_type[sp.type][batch_index],
|
| 138 |
+
B_by_type[sp.type][batch_index],
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def clear_all_lora_weights(model: nn.Module, specs: List[ModuleSpec]) -> None:
|
| 143 |
+
"""Reset every wrapper LoRA to identity (base model only)."""
|
| 144 |
+
named = dict(model.named_modules())
|
| 145 |
+
for sp in specs:
|
| 146 |
+
mod = named.get(sp.full_name)
|
| 147 |
+
if isinstance(mod, LoRA):
|
| 148 |
+
mod.clear_lora_weights()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def discover_module_types_and_dims(specs: List[ModuleSpec]
|
| 152 |
+
) -> Dict[str, Tuple[int, int]]:
|
| 153 |
+
type_dims: Dict[str, Tuple[int, int]] = {}
|
| 154 |
+
for sp in specs:
|
| 155 |
+
if sp.type in type_dims:
|
| 156 |
+
assert type_dims[sp.type] == (sp.in_features, sp.out_features), \
|
| 157 |
+
f"type {sp.type} appears with inconsistent dims"
|
| 158 |
+
continue
|
| 159 |
+
type_dims[sp.type] = (sp.in_features, sp.out_features)
|
| 160 |
+
return type_dims
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ---------------------------------------------------------------------------
|
| 164 |
+
# Shared LoRA generation head
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
class Code2LoRAHead(nn.Module):
|
| 168 |
+
"""Maps a context vector to one LoRA (A, B) pair per module type."""
|
| 169 |
+
|
| 170 |
+
def __init__(
|
| 171 |
+
self,
|
| 172 |
+
input_dim: int,
|
| 173 |
+
type_dims: Dict[str, Tuple[int, int]],
|
| 174 |
+
hidden_dim: int = 1024,
|
| 175 |
+
rank: int = 16,
|
| 176 |
+
init_log_scale: float = -3.5,
|
| 177 |
+
):
|
| 178 |
+
super().__init__()
|
| 179 |
+
self.input_dim = input_dim
|
| 180 |
+
self.hidden_dim = hidden_dim
|
| 181 |
+
self.rank = rank
|
| 182 |
+
self.type_dims = dict(type_dims)
|
| 183 |
+
self.types = sorted(type_dims.keys())
|
| 184 |
+
|
| 185 |
+
self.trunk = nn.Sequential(
|
| 186 |
+
nn.Linear(input_dim, hidden_dim),
|
| 187 |
+
nn.GELU(),
|
| 188 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 189 |
+
nn.GELU(),
|
| 190 |
+
)
|
| 191 |
+
self.heads_A = nn.ModuleDict({
|
| 192 |
+
t: nn.Linear(hidden_dim, rank * type_dims[t][0]) for t in self.types
|
| 193 |
+
})
|
| 194 |
+
self.heads_B = nn.ModuleDict({
|
| 195 |
+
t: nn.Linear(hidden_dim, type_dims[t][1] * rank) for t in self.types
|
| 196 |
+
})
|
| 197 |
+
self.log_scale_A = nn.ParameterDict({
|
| 198 |
+
t: nn.Parameter(torch.tensor(init_log_scale)) for t in self.types
|
| 199 |
+
})
|
| 200 |
+
self.log_scale_B = nn.ParameterDict({
|
| 201 |
+
t: nn.Parameter(torch.tensor(init_log_scale)) for t in self.types
|
| 202 |
+
})
|
| 203 |
+
|
| 204 |
+
def forward(self, ctx: torch.Tensor) -> Dict[str, Dict[str, torch.Tensor]]:
|
| 205 |
+
if ctx.dim() == 3:
|
| 206 |
+
ctx = torch.max(ctx, dim=1).values
|
| 207 |
+
h = self.trunk(ctx.float())
|
| 208 |
+
h = F.normalize(h, p=2, dim=-1) * math.sqrt(self.hidden_dim)
|
| 209 |
+
|
| 210 |
+
A_out: Dict[str, torch.Tensor] = {}
|
| 211 |
+
B_out: Dict[str, torch.Tensor] = {}
|
| 212 |
+
for t in self.types:
|
| 213 |
+
in_f, out_f = self.type_dims[t]
|
| 214 |
+
A_raw = self.heads_A[t](h).view(-1, self.rank, in_f)
|
| 215 |
+
B_raw = self.heads_B[t](h).view(-1, out_f, self.rank)
|
| 216 |
+
scale_A = torch.exp(self.log_scale_A[t]).clamp(1e-5, 0.3)
|
| 217 |
+
scale_B = torch.exp(self.log_scale_B[t]).clamp(1e-5, 0.3)
|
| 218 |
+
A_out[t] = torch.tanh(A_raw) * scale_A
|
| 219 |
+
B_out[t] = torch.tanh(B_raw) * scale_B
|
| 220 |
+
return {"A": A_out, "B": B_out}
|
| 221 |
+
|
| 222 |
+
def config_dict(self) -> Dict[str, Any]:
|
| 223 |
+
return {
|
| 224 |
+
"input_dim": self.input_dim,
|
| 225 |
+
"hidden_dim": self.hidden_dim,
|
| 226 |
+
"rank": self.rank,
|
| 227 |
+
"types": self.types,
|
| 228 |
+
"type_dims": {t: list(v) for t, v in self.type_dims.items()},
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ---------------------------------------------------------------------------
|
| 233 |
+
# Commit-streaming GRU
|
| 234 |
+
# ---------------------------------------------------------------------------
|
| 235 |
+
|
| 236 |
+
class CommitGRU(nn.Module):
|
| 237 |
+
"""One-layer GRU that ingests per-commit diff embeddings.
|
| 238 |
+
|
| 239 |
+
``h_0`` is a learnable projection of the initial repo-state embedding;
|
| 240 |
+
``h_T`` is fed into :class:`Code2LoRAHead`.
|
| 241 |
+
"""
|
| 242 |
+
|
| 243 |
+
def __init__(
|
| 244 |
+
self,
|
| 245 |
+
diff_input_dim: int = 2048,
|
| 246 |
+
repo_state_dim: int = 2048,
|
| 247 |
+
hidden_dim: int = 2048,
|
| 248 |
+
):
|
| 249 |
+
super().__init__()
|
| 250 |
+
self.diff_input_dim = diff_input_dim
|
| 251 |
+
self.repo_state_dim = repo_state_dim
|
| 252 |
+
self.hidden_dim = hidden_dim
|
| 253 |
+
|
| 254 |
+
self.diff_proj = nn.Sequential(
|
| 255 |
+
nn.Linear(diff_input_dim, hidden_dim),
|
| 256 |
+
nn.LayerNorm(hidden_dim),
|
| 257 |
+
)
|
| 258 |
+
self.repo_init_proj = nn.Sequential(
|
| 259 |
+
nn.Linear(repo_state_dim, hidden_dim),
|
| 260 |
+
nn.GELU(),
|
| 261 |
+
nn.LayerNorm(hidden_dim),
|
| 262 |
+
)
|
| 263 |
+
self.gru = nn.GRU(
|
| 264 |
+
input_size=hidden_dim,
|
| 265 |
+
hidden_size=hidden_dim,
|
| 266 |
+
num_layers=1,
|
| 267 |
+
batch_first=True,
|
| 268 |
+
)
|
| 269 |
+
self.output_norm = nn.LayerNorm(hidden_dim)
|
| 270 |
+
|
| 271 |
+
def init_hidden(self, repo_state_emb_0: torch.Tensor) -> torch.Tensor:
|
| 272 |
+
h0 = self.repo_init_proj(repo_state_emb_0.float())
|
| 273 |
+
return h0.unsqueeze(0)
|
| 274 |
+
|
| 275 |
+
def step(self, diff_emb: torch.Tensor, h_prev: torch.Tensor) -> torch.Tensor:
|
| 276 |
+
x = self.diff_proj(diff_emb.float()).unsqueeze(1) # [B, 1, H]
|
| 277 |
+
_, h_new = self.gru(x, h_prev)
|
| 278 |
+
return h_new
|
| 279 |
+
|
| 280 |
+
def context(self, h: torch.Tensor) -> torch.Tensor:
|
| 281 |
+
"""Match the eval path: ctx = output_norm(h[-1])."""
|
| 282 |
+
return self.output_norm(h[-1])
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
__all__ = [
|
| 286 |
+
"LoRA",
|
| 287 |
+
"ModuleSpec",
|
| 288 |
+
"get_module_specs",
|
| 289 |
+
"replace_with_lora",
|
| 290 |
+
"inject_lora_weights",
|
| 291 |
+
"clear_all_lora_weights",
|
| 292 |
+
"discover_module_types_and_dims",
|
| 293 |
+
"Code2LoRAHead",
|
| 294 |
+
"CommitGRU",
|
| 295 |
+
]
|
c2l_demo/embedding.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen Qwen3-Embedding-0.6B encoder.
|
| 2 |
+
|
| 3 |
+
Reproduces the two embedding recipes used to build the Code2LoRA-GRU v2
|
| 4 |
+
dataset, verbatim:
|
| 5 |
+
|
| 6 |
+
* **Diff embedding** (``build_diff_embeddings_shard.py`` / ``DiffEmbedder``):
|
| 7 |
+
chunk the diff text at 512 tokens (overlap 64), masked-mean each chunk, then
|
| 8 |
+
``concat(MaxPool, MeanPool)`` over chunks -> 2048-d (no L2 norm).
|
| 9 |
+
|
| 10 |
+
* **Repo-state embedding** (``build_repo_state_embeddings_shard.py``):
|
| 11 |
+
chunk each .py file at 2048 tokens (overlap 256), masked-mean each chunk,
|
| 12 |
+
average the chunk vectors per file -> 1024-d; then over all files
|
| 13 |
+
``concat(mean_files, max_files)`` -> 2048-d and L2-normalize.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from typing import List, Optional
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
import torch
|
| 22 |
+
|
| 23 |
+
EMBED_MODEL_NAME = "Qwen/Qwen3-Embedding-0.6B"
|
| 24 |
+
|
| 25 |
+
# Diff recipe
|
| 26 |
+
DIFF_CHUNK_TOKENS = 512
|
| 27 |
+
DIFF_OVERLAP = 64
|
| 28 |
+
DIFF_MAX_LENGTH = 512
|
| 29 |
+
DIFF_MIN_WINDOW = 16
|
| 30 |
+
|
| 31 |
+
# Repo-state recipe
|
| 32 |
+
REPO_CHUNK_TOKENS = 2048
|
| 33 |
+
REPO_OVERLAP = 256
|
| 34 |
+
REPO_MIN_WINDOW = 8
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _chunk_token_ids(token_ids: List[int], chunk_tokens: int, overlap: int,
|
| 38 |
+
min_window: int) -> List[List[int]]:
|
| 39 |
+
if chunk_tokens <= 0 or overlap >= chunk_tokens:
|
| 40 |
+
return []
|
| 41 |
+
chunks = []
|
| 42 |
+
step = chunk_tokens - overlap
|
| 43 |
+
n = len(token_ids)
|
| 44 |
+
for start in range(0, n, step):
|
| 45 |
+
end = min(start + chunk_tokens, n)
|
| 46 |
+
window = token_ids[start:end]
|
| 47 |
+
if len(window) < min_window:
|
| 48 |
+
if end >= n:
|
| 49 |
+
break
|
| 50 |
+
continue
|
| 51 |
+
chunks.append(window)
|
| 52 |
+
if end >= n:
|
| 53 |
+
break
|
| 54 |
+
return chunks
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class Qwen3Embedder:
|
| 58 |
+
"""Lazy-loading wrapper around the frozen Qwen3-Embedding-0.6B encoder."""
|
| 59 |
+
|
| 60 |
+
def __init__(self, device: str = "cpu",
|
| 61 |
+
dtype: Optional[torch.dtype] = None,
|
| 62 |
+
batch_size: int = 8,
|
| 63 |
+
model_name: str = EMBED_MODEL_NAME):
|
| 64 |
+
self.device = device
|
| 65 |
+
self.dtype = dtype
|
| 66 |
+
self.batch_size = batch_size
|
| 67 |
+
self.model_name = model_name
|
| 68 |
+
self._model = None
|
| 69 |
+
self._tokenizer = None
|
| 70 |
+
self._hidden = None
|
| 71 |
+
|
| 72 |
+
def load(self) -> None:
|
| 73 |
+
if self._model is not None:
|
| 74 |
+
return
|
| 75 |
+
from transformers import AutoModel, AutoTokenizer
|
| 76 |
+
self._tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
| 77 |
+
model = AutoModel.from_pretrained(self.model_name)
|
| 78 |
+
if self.dtype is not None:
|
| 79 |
+
model = model.to(self.dtype)
|
| 80 |
+
self._model = model.to(self.device).eval()
|
| 81 |
+
self._hidden = int(self._model.config.hidden_size)
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def hidden_size(self) -> int:
|
| 85 |
+
self.load()
|
| 86 |
+
return self._hidden
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def embed_dim(self) -> int:
|
| 90 |
+
return 2 * self.hidden_size
|
| 91 |
+
|
| 92 |
+
@torch.no_grad()
|
| 93 |
+
def _embed_windows(self, texts: List[str], max_length: int) -> torch.Tensor:
|
| 94 |
+
"""[N, D] fp32 (cpu) masked-mean chunk embeddings."""
|
| 95 |
+
if not texts:
|
| 96 |
+
return torch.empty((0, self.hidden_size))
|
| 97 |
+
all_vecs = []
|
| 98 |
+
for i in range(0, len(texts), self.batch_size):
|
| 99 |
+
batch = texts[i:i + self.batch_size]
|
| 100 |
+
enc = self._tokenizer(batch, padding=True, truncation=True,
|
| 101 |
+
max_length=max_length, return_tensors="pt")
|
| 102 |
+
enc = {k: v.to(self.device) for k, v in enc.items()}
|
| 103 |
+
out = self._model(**enc)
|
| 104 |
+
last = out.last_hidden_state
|
| 105 |
+
mask = enc["attention_mask"].unsqueeze(-1).to(last.dtype)
|
| 106 |
+
mean = (last * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
|
| 107 |
+
all_vecs.append(mean.detach().float().cpu())
|
| 108 |
+
return torch.cat(all_vecs, dim=0)
|
| 109 |
+
|
| 110 |
+
# ---- Diff embedding ----
|
| 111 |
+
|
| 112 |
+
@torch.no_grad()
|
| 113 |
+
def embed_diff(self, diff_text: str) -> np.ndarray:
|
| 114 |
+
"""One diff -> [2*D] fp32 (MaxPool || MeanPool, no L2 norm)."""
|
| 115 |
+
self.load()
|
| 116 |
+
zero = np.zeros(2 * self._hidden, dtype=np.float32)
|
| 117 |
+
if not diff_text or not diff_text.strip():
|
| 118 |
+
return zero
|
| 119 |
+
ids = self._tokenizer.encode(diff_text, add_special_tokens=False)
|
| 120 |
+
windows = _chunk_token_ids(ids, DIFF_CHUNK_TOKENS, DIFF_OVERLAP, DIFF_MIN_WINDOW)
|
| 121 |
+
if not windows:
|
| 122 |
+
return zero
|
| 123 |
+
texts = [self._tokenizer.decode(w, skip_special_tokens=True) for w in windows]
|
| 124 |
+
chunk_embs = self._embed_windows(texts, DIFF_MAX_LENGTH) # [K, D]
|
| 125 |
+
mean_pool = chunk_embs.mean(dim=0)
|
| 126 |
+
max_pool = chunk_embs.max(dim=0).values
|
| 127 |
+
return torch.cat([max_pool, mean_pool], dim=-1).numpy().astype(np.float32)
|
| 128 |
+
|
| 129 |
+
# ---- Repo-state embedding ----
|
| 130 |
+
|
| 131 |
+
@torch.no_grad()
|
| 132 |
+
def _embed_file(self, text: str) -> Optional[np.ndarray]:
|
| 133 |
+
ids = self._tokenizer.encode(text, add_special_tokens=False)
|
| 134 |
+
if not ids:
|
| 135 |
+
return None
|
| 136 |
+
windows = _chunk_token_ids(ids, REPO_CHUNK_TOKENS, REPO_OVERLAP, REPO_MIN_WINDOW)
|
| 137 |
+
if not windows:
|
| 138 |
+
return None
|
| 139 |
+
texts = [self._tokenizer.decode(w, skip_special_tokens=True) for w in windows]
|
| 140 |
+
chunk_embs = self._embed_windows(texts, REPO_CHUNK_TOKENS) # [K, D]
|
| 141 |
+
return chunk_embs.mean(dim=0).numpy().astype(np.float32)
|
| 142 |
+
|
| 143 |
+
@torch.no_grad()
|
| 144 |
+
def embed_repo_state(self, file_texts: List[str]) -> np.ndarray:
|
| 145 |
+
"""List of file contents -> [2*D] L2-normalized repo-state vector."""
|
| 146 |
+
self.load()
|
| 147 |
+
repo_dim = 2 * self._hidden
|
| 148 |
+
file_vecs = []
|
| 149 |
+
for text in file_texts:
|
| 150 |
+
if not text:
|
| 151 |
+
continue
|
| 152 |
+
vec = self._embed_file(text)
|
| 153 |
+
if vec is not None:
|
| 154 |
+
file_vecs.append(vec)
|
| 155 |
+
if not file_vecs:
|
| 156 |
+
return np.zeros(repo_dim, dtype=np.float32)
|
| 157 |
+
sub = np.stack(file_vecs).astype(np.float32)
|
| 158 |
+
mean_pool = sub.mean(axis=0)
|
| 159 |
+
max_pool = sub.max(axis=0)
|
| 160 |
+
repo_vec = np.concatenate([mean_pool, max_pool], axis=0)
|
| 161 |
+
norm = float(np.linalg.norm(repo_vec)) + 1e-12
|
| 162 |
+
return (repo_vec / norm).astype(np.float32)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
__all__ = ["Qwen3Embedder", "EMBED_MODEL_NAME"]
|
c2l_demo/engine.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end inference engine for the Code2LoRA-GRU demo.
|
| 2 |
+
|
| 3 |
+
Ties everything together:
|
| 4 |
+
|
| 5 |
+
1. resolve a repository (local path or git URL -> shallow-free clone),
|
| 6 |
+
2. select the kept "GRU-pass" commits + per-repo statistics,
|
| 7 |
+
3. embed each commit's production-code diff and the initial repo state with
|
| 8 |
+
the frozen Qwen3-Embedding encoder,
|
| 9 |
+
4. walk the commits through the :class:`CommitGRU`, recording the hidden-state
|
| 10 |
+
trajectory,
|
| 11 |
+
5. generate a repository-conditioned LoRA from the hidden state and inject it
|
| 12 |
+
into the frozen Qwen2.5-Coder base model,
|
| 13 |
+
6. predict a held-out assertion both with and without the adapter.
|
| 14 |
+
|
| 15 |
+
Heavy models are loaded lazily and cached on the process-wide
|
| 16 |
+
:class:`Engine`. Per-repository state lives in a :class:`RepoSession`.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import re
|
| 23 |
+
import shutil
|
| 24 |
+
import subprocess
|
| 25 |
+
import tempfile
|
| 26 |
+
import time
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Callable, Dict, List, Optional, Tuple
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import torch
|
| 33 |
+
|
| 34 |
+
from .core import (
|
| 35 |
+
Code2LoRAHead,
|
| 36 |
+
CommitGRU,
|
| 37 |
+
clear_all_lora_weights,
|
| 38 |
+
discover_module_types_and_dims,
|
| 39 |
+
get_module_specs,
|
| 40 |
+
inject_lora_weights,
|
| 41 |
+
replace_with_lora,
|
| 42 |
+
)
|
| 43 |
+
from .embedding import Qwen3Embedder
|
| 44 |
+
from .git_pipeline import RepoProcessResult, ls_tree_py, process_repo, GitCatFileBatch
|
| 45 |
+
from .metrics import compute_metrics
|
| 46 |
+
|
| 47 |
+
DEFAULT_BASE_MODEL = "Qwen/Qwen2.5-Coder-1.5B"
|
| 48 |
+
DEFAULT_TARGET_MODULES = [
|
| 49 |
+
"q_proj", "k_proj", "v_proj", "o_proj",
|
| 50 |
+
"up_proj", "gate_proj", "down_proj",
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
# Where to find the trained GRU+head checkpoint. Either a local path
|
| 54 |
+
# (CODE2LORA_CKPT) or a HF Hub model repo (CODE2LORA_CKPT_REPO + _FILE).
|
| 55 |
+
ENV_CKPT_PATH = "CODE2LORA_CKPT"
|
| 56 |
+
ENV_CKPT_REPO = "CODE2LORA_CKPT_REPO"
|
| 57 |
+
ENV_CKPT_FILE = "CODE2LORA_CKPT_FILE"
|
| 58 |
+
# The published paper checkpoint (GRU + Code2LoRAHead). Used by default so the
|
| 59 |
+
# Space works zero-config; override with the env vars above if needed.
|
| 60 |
+
DEFAULT_CKPT_REPO = "code2lora/code2lora-gru"
|
| 61 |
+
DEFAULT_CKPT_FILE = "code2lora_gru.pt"
|
| 62 |
+
|
| 63 |
+
ProgressFn = Optional[Callable[[str, float], None]]
|
| 64 |
+
|
| 65 |
+
_GIT_URL_RE = re.compile(r"^(https?://|git@|ssh://)")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _is_git_url(s: str) -> bool:
|
| 69 |
+
return bool(_GIT_URL_RE.match(s.strip()))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ---------------------------------------------------------------------------
|
| 73 |
+
# Per-repository session
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
|
| 76 |
+
@dataclass
|
| 77 |
+
class RepoSession:
|
| 78 |
+
repo_id: str
|
| 79 |
+
result: RepoProcessResult
|
| 80 |
+
# GRU trajectory over the walked commits:
|
| 81 |
+
walked_indices: List[int] # kept-commit indices walked
|
| 82 |
+
hidden_states: np.ndarray # [T, H] post-step h[-1]
|
| 83 |
+
ctx_norms: List[float] # ||output_norm(h_t)||
|
| 84 |
+
endpoint_index: int # kept-commit index of the adapter
|
| 85 |
+
repo_state_norm: float
|
| 86 |
+
n_repo_state_files: int
|
| 87 |
+
# cached tensors for re-prediction without re-walking:
|
| 88 |
+
_hidden_endpoint: Optional[torch.Tensor] = None # [H] on engine device
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
# Engine
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
|
| 95 |
+
class Engine:
|
| 96 |
+
def __init__(self,
|
| 97 |
+
base_model_name: str = DEFAULT_BASE_MODEL,
|
| 98 |
+
target_modules: Optional[List[str]] = None,
|
| 99 |
+
device: Optional[str] = None,
|
| 100 |
+
max_repo_state_files: int = 400):
|
| 101 |
+
self.base_model_name = base_model_name
|
| 102 |
+
self.target_modules = target_modules or list(DEFAULT_TARGET_MODULES)
|
| 103 |
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 104 |
+
self.dtype = torch.bfloat16 if self.device == "cuda" else torch.float32
|
| 105 |
+
self.max_repo_state_files = max_repo_state_files
|
| 106 |
+
|
| 107 |
+
self._tokenizer = None
|
| 108 |
+
self._base_model = None
|
| 109 |
+
self._specs = None
|
| 110 |
+
self._gru: Optional[CommitGRU] = None
|
| 111 |
+
self._head: Optional[Code2LoRAHead] = None
|
| 112 |
+
self._embedder: Optional[Qwen3Embedder] = None
|
| 113 |
+
self._loaded = False
|
| 114 |
+
|
| 115 |
+
# ---- lazy loading ----
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def embedder(self) -> Qwen3Embedder:
|
| 119 |
+
if self._embedder is None:
|
| 120 |
+
self._embedder = Qwen3Embedder(
|
| 121 |
+
device=self.device,
|
| 122 |
+
dtype=(torch.float16 if self.device == "cuda" else None),
|
| 123 |
+
)
|
| 124 |
+
return self._embedder
|
| 125 |
+
|
| 126 |
+
def _resolve_ckpt(self) -> str:
|
| 127 |
+
local = os.environ.get(ENV_CKPT_PATH)
|
| 128 |
+
if local:
|
| 129 |
+
if Path(local).exists():
|
| 130 |
+
return local
|
| 131 |
+
raise FileNotFoundError(f"{ENV_CKPT_PATH}={local} does not exist.")
|
| 132 |
+
from huggingface_hub import hf_hub_download
|
| 133 |
+
repo = os.environ.get(ENV_CKPT_REPO, DEFAULT_CKPT_REPO)
|
| 134 |
+
fname = os.environ.get(ENV_CKPT_FILE, DEFAULT_CKPT_FILE)
|
| 135 |
+
return hf_hub_download(repo_id=repo, filename=fname)
|
| 136 |
+
|
| 137 |
+
def load(self, progress: ProgressFn = None) -> None:
|
| 138 |
+
if self._loaded:
|
| 139 |
+
return
|
| 140 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 141 |
+
|
| 142 |
+
def _p(msg, frac):
|
| 143 |
+
if progress:
|
| 144 |
+
progress(msg, frac)
|
| 145 |
+
|
| 146 |
+
_p("Loading checkpoint…", 0.05)
|
| 147 |
+
ckpt_path = self._resolve_ckpt()
|
| 148 |
+
state = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 149 |
+
head_cfg = state.get("head_config") or {}
|
| 150 |
+
gru_cfg = state.get("gru_config") or {}
|
| 151 |
+
rank = int(head_cfg.get("rank", 16))
|
| 152 |
+
alpha = float((state.get("args") or {}).get("alpha", 32.0))
|
| 153 |
+
head_hidden = int(head_cfg.get("hidden_dim", 1024))
|
| 154 |
+
|
| 155 |
+
_p(f"Loading base model {self.base_model_name}…", 0.2)
|
| 156 |
+
self._tokenizer = AutoTokenizer.from_pretrained(self.base_model_name)
|
| 157 |
+
if self._tokenizer.pad_token is None:
|
| 158 |
+
self._tokenizer.pad_token = self._tokenizer.eos_token
|
| 159 |
+
self._base_model = self._load_base_model()
|
| 160 |
+
self._base_model.eval()
|
| 161 |
+
for p in self._base_model.parameters():
|
| 162 |
+
p.requires_grad = False
|
| 163 |
+
|
| 164 |
+
_p("Wiring LoRA modules…", 0.7)
|
| 165 |
+
self._specs = get_module_specs(self._base_model, self.target_modules)
|
| 166 |
+
type_dims = discover_module_types_and_dims(self._specs)
|
| 167 |
+
input_dim = int(head_cfg.get("input_dim", gru_cfg.get("hidden_dim", 2048)))
|
| 168 |
+
|
| 169 |
+
self._head = Code2LoRAHead(input_dim=input_dim, type_dims=type_dims,
|
| 170 |
+
hidden_dim=head_hidden, rank=rank).to(self.device)
|
| 171 |
+
self._head.load_state_dict(state["head_state"])
|
| 172 |
+
self._head.eval()
|
| 173 |
+
|
| 174 |
+
self._gru = CommitGRU(
|
| 175 |
+
diff_input_dim=int(gru_cfg.get("diff_input_dim", 2048)),
|
| 176 |
+
repo_state_dim=int(gru_cfg.get("repo_state_dim", 2048)),
|
| 177 |
+
hidden_dim=int(gru_cfg.get("hidden_dim", 2048)),
|
| 178 |
+
).to(self.device)
|
| 179 |
+
self._gru.load_state_dict(state["gru_state"])
|
| 180 |
+
self._gru.eval()
|
| 181 |
+
|
| 182 |
+
replace_with_lora(self._base_model, self._specs, rank=rank, alpha=alpha)
|
| 183 |
+
_p("Models ready.", 1.0)
|
| 184 |
+
self._loaded = True
|
| 185 |
+
|
| 186 |
+
def _load_base_model(self):
|
| 187 |
+
from transformers import AutoModelForCausalLM
|
| 188 |
+
last_err = None
|
| 189 |
+
for attn in ("flash_attention_2", "sdpa", "eager"):
|
| 190 |
+
try:
|
| 191 |
+
return AutoModelForCausalLM.from_pretrained(
|
| 192 |
+
self.base_model_name,
|
| 193 |
+
torch_dtype=self.dtype,
|
| 194 |
+
attn_implementation=attn,
|
| 195 |
+
device_map={"": self.device} if self.device == "cuda" else None,
|
| 196 |
+
).to(self.device)
|
| 197 |
+
except Exception as e: # flash-attn may be unavailable
|
| 198 |
+
last_err = e
|
| 199 |
+
continue
|
| 200 |
+
raise RuntimeError(f"Could not load base model: {last_err}")
|
| 201 |
+
|
| 202 |
+
# ---- repository resolution ----
|
| 203 |
+
|
| 204 |
+
def resolve_repo(self, repo_input: str, work_dir: Path,
|
| 205 |
+
progress: ProgressFn = None) -> Tuple[Path, str]:
|
| 206 |
+
"""Return (local_repo_dir, repo_id). Clones if a URL is given."""
|
| 207 |
+
repo_input = repo_input.strip()
|
| 208 |
+
if not repo_input:
|
| 209 |
+
raise ValueError("Please provide a repository path or git URL.")
|
| 210 |
+
if _is_git_url(repo_input):
|
| 211 |
+
repo_id = _repo_id_from_url(repo_input)
|
| 212 |
+
dest = work_dir / repo_id.replace("/", "__")
|
| 213 |
+
if dest.exists():
|
| 214 |
+
shutil.rmtree(dest, ignore_errors=True)
|
| 215 |
+
if progress:
|
| 216 |
+
progress(f"Cloning {repo_input}…", 0.0)
|
| 217 |
+
res = subprocess.run(
|
| 218 |
+
["git", "clone", "--no-single-branch", repo_input, str(dest)],
|
| 219 |
+
capture_output=True, text=True, timeout=900)
|
| 220 |
+
if res.returncode != 0 or not (dest / ".git").exists():
|
| 221 |
+
raise RuntimeError(f"git clone failed:\n{res.stderr[-2000:]}")
|
| 222 |
+
return dest, repo_id
|
| 223 |
+
path = Path(repo_input).expanduser()
|
| 224 |
+
if not (path / ".git").exists():
|
| 225 |
+
raise ValueError(f"{path} is not a git repository (no .git found).")
|
| 226 |
+
return path, _repo_id_from_path(path)
|
| 227 |
+
|
| 228 |
+
# ---- processing + GRU walk ----
|
| 229 |
+
|
| 230 |
+
def process_and_walk(self, repo_dir: Path, repo_id: str,
|
| 231 |
+
walk_fraction: float = 0.8,
|
| 232 |
+
progress: ProgressFn = None) -> RepoSession:
|
| 233 |
+
"""Full pipeline: select commits, embed, walk the GRU, cache the
|
| 234 |
+
adapter context at the requested walk endpoint."""
|
| 235 |
+
self.load(progress=progress)
|
| 236 |
+
|
| 237 |
+
def _p(msg, frac):
|
| 238 |
+
if progress:
|
| 239 |
+
progress(msg, frac)
|
| 240 |
+
|
| 241 |
+
result = process_repo(repo_dir, repo_id=repo_id, progress=progress)
|
| 242 |
+
if result.kept_commits == 0:
|
| 243 |
+
raise RuntimeError(
|
| 244 |
+
"No commits introduced new assertions in test files — this "
|
| 245 |
+
"repository has no usable Code2LoRA history. Pick a repo with "
|
| 246 |
+
"a pytest/unittest suite that grew over time.")
|
| 247 |
+
|
| 248 |
+
# Endpoint: the last train-split commit, i.e. the chronological 80%
|
| 249 |
+
# boundary defined by the dataset's 80/10/10 split. The val/test tail
|
| 250 |
+
# is never walked, so predictions on it are genuine generalization.
|
| 251 |
+
n_kept = result.kept_commits
|
| 252 |
+
endpoint = result.split_boundary_index
|
| 253 |
+
if endpoint < 0:
|
| 254 |
+
endpoint = max(0, min(n_kept - 1, int(round(walk_fraction * n_kept)) - 1))
|
| 255 |
+
|
| 256 |
+
# ---- repo-state embedding @ commit 0 ----
|
| 257 |
+
_p("Embedding initial repository state…", 0.0)
|
| 258 |
+
repo_state_0, n_files = self._embed_repo_state(repo_dir, result.commits[0].sha)
|
| 259 |
+
|
| 260 |
+
# ---- walk the GRU, embedding diffs on the way ----
|
| 261 |
+
gru = self._gru
|
| 262 |
+
device = self.device
|
| 263 |
+
h = gru.init_hidden(torch.from_numpy(repo_state_0).to(device).unsqueeze(0))
|
| 264 |
+
|
| 265 |
+
hidden_states: List[np.ndarray] = []
|
| 266 |
+
ctx_norms: List[float] = []
|
| 267 |
+
walked_indices: List[int] = []
|
| 268 |
+
hidden_endpoint: Optional[torch.Tensor] = None
|
| 269 |
+
|
| 270 |
+
with torch.no_grad():
|
| 271 |
+
for ci in range(endpoint + 1):
|
| 272 |
+
commit = result.commits[ci]
|
| 273 |
+
_p(f"GRU streaming commit {ci + 1}/{endpoint + 1}…",
|
| 274 |
+
(ci + 1) / max(endpoint + 1, 1))
|
| 275 |
+
diff_emb = self.embedder.embed_diff(commit.production_code_diff)
|
| 276 |
+
diff_t = torch.from_numpy(diff_emb).to(device).unsqueeze(0)
|
| 277 |
+
h = gru.step(diff_t, h)
|
| 278 |
+
h_last = h[-1].squeeze(0) # [H]
|
| 279 |
+
ctx = gru.output_norm(h[-1]) # [1, H]
|
| 280 |
+
hidden_states.append(h_last.float().cpu().numpy())
|
| 281 |
+
ctx_norms.append(float(ctx.float().norm().cpu()))
|
| 282 |
+
walked_indices.append(ci)
|
| 283 |
+
if ci == endpoint:
|
| 284 |
+
hidden_endpoint = h_last.detach()
|
| 285 |
+
|
| 286 |
+
return RepoSession(
|
| 287 |
+
repo_id=repo_id, result=result, walked_indices=walked_indices,
|
| 288 |
+
hidden_states=np.stack(hidden_states) if hidden_states else np.zeros((0, gru.hidden_dim)),
|
| 289 |
+
ctx_norms=ctx_norms, endpoint_index=endpoint,
|
| 290 |
+
repo_state_norm=float(np.linalg.norm(repo_state_0)),
|
| 291 |
+
n_repo_state_files=n_files, _hidden_endpoint=hidden_endpoint)
|
| 292 |
+
|
| 293 |
+
def _embed_repo_state(self, repo_dir: Path, commit_sha: str) -> Tuple[np.ndarray, int]:
|
| 294 |
+
files = ls_tree_py(repo_dir, commit_sha)
|
| 295 |
+
if len(files) > self.max_repo_state_files:
|
| 296 |
+
# Keep the largest files (most signal) under the cap.
|
| 297 |
+
files = sorted(files, key=lambda x: -x[2])[: self.max_repo_state_files]
|
| 298 |
+
texts: List[str] = []
|
| 299 |
+
with GitCatFileBatch(repo_dir) as cat:
|
| 300 |
+
for blob, _path, _size in files:
|
| 301 |
+
txt = cat.read_blob_text(blob)
|
| 302 |
+
if txt:
|
| 303 |
+
texts.append(txt)
|
| 304 |
+
vec = self.embedder.embed_repo_state(texts)
|
| 305 |
+
return vec, len(texts)
|
| 306 |
+
|
| 307 |
+
# ---- adapter generation ----
|
| 308 |
+
|
| 309 |
+
def _ctx_at(self, session: RepoSession, commit_index: Optional[int]) -> torch.Tensor:
|
| 310 |
+
"""output_norm(h_t) for the requested commit (defaults to endpoint)."""
|
| 311 |
+
if commit_index is None or commit_index == session.endpoint_index:
|
| 312 |
+
h_last = session._hidden_endpoint
|
| 313 |
+
else:
|
| 314 |
+
idx = max(0, min(commit_index, len(session.hidden_states) - 1))
|
| 315 |
+
h_last = torch.from_numpy(session.hidden_states[idx]).to(self.device)
|
| 316 |
+
h_last = h_last.unsqueeze(0) # [1, H]
|
| 317 |
+
return self._gru.output_norm(h_last) # [1, H]
|
| 318 |
+
|
| 319 |
+
# ---- prediction ----
|
| 320 |
+
|
| 321 |
+
@torch.no_grad()
|
| 322 |
+
def predict(self, session: RepoSession, prefix: str, target: str,
|
| 323 |
+
commit_index: Optional[int] = None,
|
| 324 |
+
max_input_tokens: int = 4096,
|
| 325 |
+
max_new_tokens: int = 32) -> Dict:
|
| 326 |
+
"""Predict the assertion RHS with the base model and with the adapter."""
|
| 327 |
+
tok = self._tokenizer
|
| 328 |
+
bos = getattr(tok, "bos_token_id", None)
|
| 329 |
+
if bos is None:
|
| 330 |
+
bos = getattr(tok, "eos_token_id", None)
|
| 331 |
+
ids = tok.encode(prefix, add_special_tokens=False)
|
| 332 |
+
if bos is not None:
|
| 333 |
+
ids = [bos] + ids
|
| 334 |
+
if len(ids) > max_input_tokens:
|
| 335 |
+
ids = ids[-max_input_tokens:]
|
| 336 |
+
|
| 337 |
+
# 1) base model (no adapter)
|
| 338 |
+
clear_all_lora_weights(self._base_model, self._specs)
|
| 339 |
+
base_pred = self._generate(ids, max_new_tokens)
|
| 340 |
+
|
| 341 |
+
# 2) adapted model
|
| 342 |
+
ctx = self._ctx_at(session, commit_index)
|
| 343 |
+
head_out = self._head(ctx)
|
| 344 |
+
inject_lora_weights(self._base_model, self._specs, head_out, batch_index=0)
|
| 345 |
+
adapted_pred = self._generate(ids, max_new_tokens)
|
| 346 |
+
clear_all_lora_weights(self._base_model, self._specs)
|
| 347 |
+
|
| 348 |
+
base_m = compute_metrics(base_pred, target)
|
| 349 |
+
adapted_m = compute_metrics(adapted_pred, target)
|
| 350 |
+
return {
|
| 351 |
+
"prefix": prefix,
|
| 352 |
+
"target": target,
|
| 353 |
+
"base_prediction": base_pred,
|
| 354 |
+
"adapted_prediction": adapted_pred,
|
| 355 |
+
"base_clean": base_m["pred_clean"],
|
| 356 |
+
"adapted_clean": adapted_m["pred_clean"],
|
| 357 |
+
"base_exact_match": bool(base_m["exact_match"]),
|
| 358 |
+
"adapted_exact_match": bool(adapted_m["exact_match"]),
|
| 359 |
+
"base_edit_similarity": float(base_m["edit_similarity"]),
|
| 360 |
+
"adapted_edit_similarity": float(adapted_m["edit_similarity"]),
|
| 361 |
+
"n_prompt_tokens": len(ids),
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
@torch.no_grad()
|
| 365 |
+
def _generate(self, input_ids: List[int], max_new_tokens: int) -> str:
|
| 366 |
+
device = self.device
|
| 367 |
+
ids = torch.tensor([input_ids], dtype=torch.long, device=device)
|
| 368 |
+
attn = torch.ones_like(ids)
|
| 369 |
+
out = self._base_model.generate(
|
| 370 |
+
input_ids=ids, attention_mask=attn,
|
| 371 |
+
max_new_tokens=int(max_new_tokens), do_sample=False,
|
| 372 |
+
pad_token_id=self._tokenizer.pad_token_id,
|
| 373 |
+
eos_token_id=self._tokenizer.eos_token_id, use_cache=True)
|
| 374 |
+
gen = out[0, ids.shape[1]:].tolist()
|
| 375 |
+
return self._tokenizer.decode(gen, skip_special_tokens=True)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
# ---------------------------------------------------------------------------
|
| 379 |
+
# helpers
|
| 380 |
+
# ---------------------------------------------------------------------------
|
| 381 |
+
|
| 382 |
+
def _repo_id_from_url(url: str) -> str:
|
| 383 |
+
s = url.strip()
|
| 384 |
+
s = re.sub(r"\.git$", "", s)
|
| 385 |
+
s = re.sub(r"^git@[^:]+:", "", s)
|
| 386 |
+
s = re.sub(r"^(https?|ssh)://[^/]+/", "", s)
|
| 387 |
+
parts = [p for p in s.split("/") if p]
|
| 388 |
+
if len(parts) >= 2:
|
| 389 |
+
return f"{parts[-2]}/{parts[-1]}"
|
| 390 |
+
return parts[-1] if parts else "repo"
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _repo_id_from_path(path: Path) -> str:
|
| 394 |
+
parts = path.resolve().parts
|
| 395 |
+
if len(parts) >= 2:
|
| 396 |
+
return f"{parts[-2]}/{parts[-1]}"
|
| 397 |
+
return path.name
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
__all__ = ["Engine", "RepoSession", "DEFAULT_BASE_MODEL"]
|
c2l_demo/git_pipeline.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Git → commit-sequence pipeline.
|
| 2 |
+
|
| 3 |
+
Faithful port of the repository-processing logic from
|
| 4 |
+
``create_dataset/build_commit_assertion_db.py`` and
|
| 5 |
+
``create_dataset/build_commit_parquet_db.py``:
|
| 6 |
+
|
| 7 |
+
* stream first-parent commits oldest-first,
|
| 8 |
+
* keep only commits that introduce *new or changed* assertions in test files
|
| 9 |
+
(this is the "GRU pass" subset),
|
| 10 |
+
* build the ``production_code_diff`` (non-test source) between consecutive
|
| 11 |
+
kept commits,
|
| 12 |
+
* split the kept commits chronologically 80/10/10 into train/val/test,
|
| 13 |
+
* emit one QnA per new assertion.
|
| 14 |
+
|
| 15 |
+
Everything runs against a local clone with full history.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import re
|
| 21 |
+
import subprocess
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Callable, Dict, Iterator, List, Optional, Set, Tuple
|
| 25 |
+
|
| 26 |
+
from .assertions import (
|
| 27 |
+
ExtractedAssertion,
|
| 28 |
+
extract_assertions_from_source,
|
| 29 |
+
normalize_for_id,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Constants (must match the dataset builders)
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
SOURCE_EXTS = {".py", ".md", ".rst"}
|
| 37 |
+
SKIP_DIRS = {
|
| 38 |
+
".git", "__pycache__", ".venv", "venv", "env",
|
| 39 |
+
"node_modules", "dist", "build", ".tox", ".mypy_cache",
|
| 40 |
+
"TEST_HYPERNET",
|
| 41 |
+
}
|
| 42 |
+
EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
| 43 |
+
_NULL_SHA = "0000000000000000000000000000000000000000"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# Path filtering
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
def _is_test_path(path: str) -> bool:
|
| 51 |
+
for part in Path(path).parts:
|
| 52 |
+
if "test" in part.lower():
|
| 53 |
+
return True
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _is_python_test_file(path: str) -> bool:
|
| 58 |
+
p = Path(path)
|
| 59 |
+
if p.suffix.lower() != ".py":
|
| 60 |
+
return False
|
| 61 |
+
if any(part in SKIP_DIRS for part in p.parts):
|
| 62 |
+
return False
|
| 63 |
+
return _is_test_path(path)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _should_skip_diff_path(path: str) -> bool:
|
| 67 |
+
p = Path(path)
|
| 68 |
+
if p.suffix.lower() not in SOURCE_EXTS:
|
| 69 |
+
return True
|
| 70 |
+
if any(part in SKIP_DIRS for part in p.parts):
|
| 71 |
+
return True
|
| 72 |
+
if _is_test_path(path):
|
| 73 |
+
return True
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
# Git helpers
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
|
| 81 |
+
def _run_git(repo_dir: Path, args: List[str], timeout: int = 300) -> Optional[str]:
|
| 82 |
+
try:
|
| 83 |
+
result = subprocess.run(
|
| 84 |
+
["git", "-C", str(repo_dir)] + args,
|
| 85 |
+
capture_output=True, timeout=timeout,
|
| 86 |
+
)
|
| 87 |
+
if result.returncode != 0:
|
| 88 |
+
return None
|
| 89 |
+
return result.stdout.decode("utf-8", errors="replace")
|
| 90 |
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@dataclass
|
| 95 |
+
class RawFileChange:
|
| 96 |
+
status: str
|
| 97 |
+
new_blob: str
|
| 98 |
+
path: str
|
| 99 |
+
old_path: Optional[str] = None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@dataclass
|
| 103 |
+
class RawCommit:
|
| 104 |
+
sha: str
|
| 105 |
+
parent_sha: Optional[str]
|
| 106 |
+
timestamp: str
|
| 107 |
+
changes: List[RawFileChange]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def stream_commit_changes(repo_dir: Path) -> Iterator[RawCommit]:
|
| 111 |
+
"""Yield one ``RawCommit`` per first-parent commit, oldest-first."""
|
| 112 |
+
proc = subprocess.Popen(
|
| 113 |
+
[
|
| 114 |
+
"git", "-C", str(repo_dir),
|
| 115 |
+
"log", "--first-parent", "--reverse",
|
| 116 |
+
"--raw", "--no-abbrev", "-M",
|
| 117 |
+
"--format=COMMITHDR %H %P %aI",
|
| 118 |
+
],
|
| 119 |
+
stdout=subprocess.PIPE,
|
| 120 |
+
stderr=subprocess.PIPE,
|
| 121 |
+
bufsize=1 << 20,
|
| 122 |
+
)
|
| 123 |
+
assert proc.stdout is not None
|
| 124 |
+
cur: Optional[RawCommit] = None
|
| 125 |
+
try:
|
| 126 |
+
for raw in proc.stdout:
|
| 127 |
+
line = raw.decode("utf-8", errors="replace").rstrip("\n")
|
| 128 |
+
if not line:
|
| 129 |
+
continue
|
| 130 |
+
if line.startswith("COMMITHDR "):
|
| 131 |
+
if cur is not None:
|
| 132 |
+
yield cur
|
| 133 |
+
tokens = line.split()
|
| 134 |
+
if len(tokens) < 3:
|
| 135 |
+
cur = None
|
| 136 |
+
continue
|
| 137 |
+
sha = tokens[1]
|
| 138 |
+
timestamp = tokens[-1]
|
| 139 |
+
parents = tokens[2:-1]
|
| 140 |
+
parent_sha = parents[0] if parents else None
|
| 141 |
+
cur = RawCommit(sha=sha, parent_sha=parent_sha,
|
| 142 |
+
timestamp=timestamp, changes=[])
|
| 143 |
+
continue
|
| 144 |
+
if cur is None or not line.startswith(":"):
|
| 145 |
+
continue
|
| 146 |
+
try:
|
| 147 |
+
meta, _, tail = line.partition("\t")
|
| 148 |
+
if not tail:
|
| 149 |
+
continue
|
| 150 |
+
fields = meta.split()
|
| 151 |
+
if len(fields) < 5:
|
| 152 |
+
continue
|
| 153 |
+
new_blob = fields[3].lstrip(":")
|
| 154 |
+
status = fields[4]
|
| 155 |
+
parts = tail.split("\t")
|
| 156 |
+
if status.startswith("R") or status.startswith("C"):
|
| 157 |
+
old_path = parts[0]
|
| 158 |
+
new_path = parts[1] if len(parts) > 1 else parts[0]
|
| 159 |
+
cur.changes.append(RawFileChange(
|
| 160 |
+
status=status, new_blob=new_blob,
|
| 161 |
+
path=new_path, old_path=old_path))
|
| 162 |
+
else:
|
| 163 |
+
cur.changes.append(RawFileChange(
|
| 164 |
+
status=status, new_blob=new_blob, path=parts[0]))
|
| 165 |
+
except Exception:
|
| 166 |
+
continue
|
| 167 |
+
if cur is not None:
|
| 168 |
+
yield cur
|
| 169 |
+
finally:
|
| 170 |
+
try:
|
| 171 |
+
proc.stdout.close()
|
| 172 |
+
finally:
|
| 173 |
+
proc.wait(timeout=5)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class GitCatFileBatch:
|
| 177 |
+
"""Persistent ``git cat-file --batch`` for cheap blob reads."""
|
| 178 |
+
|
| 179 |
+
def __init__(self, repo_dir: Path):
|
| 180 |
+
self._proc = subprocess.Popen(
|
| 181 |
+
["git", "-C", str(repo_dir), "cat-file", "--batch"],
|
| 182 |
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
| 183 |
+
stderr=subprocess.PIPE, bufsize=0,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
def read_blob(self, blob_sha: str) -> Optional[bytes]:
|
| 187 |
+
if not blob_sha or blob_sha == _NULL_SHA:
|
| 188 |
+
return None
|
| 189 |
+
p = self._proc
|
| 190 |
+
assert p.stdin is not None and p.stdout is not None
|
| 191 |
+
try:
|
| 192 |
+
p.stdin.write((blob_sha + "\n").encode("ascii"))
|
| 193 |
+
p.stdin.flush()
|
| 194 |
+
except (BrokenPipeError, OSError):
|
| 195 |
+
return None
|
| 196 |
+
header = p.stdout.readline()
|
| 197 |
+
if not header:
|
| 198 |
+
return None
|
| 199 |
+
parts = header.decode("utf-8", errors="replace").strip().split()
|
| 200 |
+
if len(parts) < 3 or parts[1] != "blob":
|
| 201 |
+
return None
|
| 202 |
+
try:
|
| 203 |
+
size = int(parts[2])
|
| 204 |
+
except ValueError:
|
| 205 |
+
return None
|
| 206 |
+
body = b""
|
| 207 |
+
remaining = size
|
| 208 |
+
while remaining > 0:
|
| 209 |
+
chunk = p.stdout.read(remaining)
|
| 210 |
+
if not chunk:
|
| 211 |
+
break
|
| 212 |
+
body += chunk
|
| 213 |
+
remaining -= len(chunk)
|
| 214 |
+
try:
|
| 215 |
+
p.stdout.read(1)
|
| 216 |
+
except Exception:
|
| 217 |
+
pass
|
| 218 |
+
return body
|
| 219 |
+
|
| 220 |
+
def read_blob_text(self, blob_sha: str) -> Optional[str]:
|
| 221 |
+
data = self.read_blob(blob_sha)
|
| 222 |
+
return None if data is None else data.decode("utf-8", errors="replace")
|
| 223 |
+
|
| 224 |
+
def close(self) -> None:
|
| 225 |
+
try:
|
| 226 |
+
if self._proc.stdin is not None:
|
| 227 |
+
self._proc.stdin.close()
|
| 228 |
+
except Exception:
|
| 229 |
+
pass
|
| 230 |
+
try:
|
| 231 |
+
self._proc.wait(timeout=5)
|
| 232 |
+
except Exception:
|
| 233 |
+
try:
|
| 234 |
+
self._proc.kill()
|
| 235 |
+
except Exception:
|
| 236 |
+
pass
|
| 237 |
+
|
| 238 |
+
def __enter__(self):
|
| 239 |
+
return self
|
| 240 |
+
|
| 241 |
+
def __exit__(self, *exc):
|
| 242 |
+
self.close()
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def get_diff(repo_dir: Path, parent_sha: Optional[str], child_sha: str,
|
| 246 |
+
timeout: int = 600) -> str:
|
| 247 |
+
base = parent_sha if parent_sha else EMPTY_TREE_SHA
|
| 248 |
+
out = _run_git(repo_dir, ["diff", "--no-color", "-U3", base, child_sha],
|
| 249 |
+
timeout=timeout)
|
| 250 |
+
return out if out else ""
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
_DIFF_HEADER_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)(?:\n|$)", re.MULTILINE)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def filter_diff(raw_diff: str) -> str:
|
| 257 |
+
if not raw_diff:
|
| 258 |
+
return ""
|
| 259 |
+
chunks = re.split(r"(?=^diff --git )", raw_diff, flags=re.MULTILINE)
|
| 260 |
+
kept: List[str] = []
|
| 261 |
+
for chunk in chunks:
|
| 262 |
+
if not chunk.startswith("diff --git "):
|
| 263 |
+
continue
|
| 264 |
+
m = _DIFF_HEADER_RE.match(chunk)
|
| 265 |
+
if not m:
|
| 266 |
+
continue
|
| 267 |
+
if _should_skip_diff_path(m.group(2)):
|
| 268 |
+
continue
|
| 269 |
+
kept.append(chunk)
|
| 270 |
+
return "".join(kept)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def ls_tree_py(repo_dir: Path, commit_sha: str,
|
| 274 |
+
max_file_bytes: int = 2_000_000) -> List[Tuple[str, str, int]]:
|
| 275 |
+
"""Return [(blob_sha, path, size)] for tracked .py files at a commit."""
|
| 276 |
+
out = _run_git(repo_dir, ["ls-tree", "-r", "-l", commit_sha], timeout=120)
|
| 277 |
+
if not out:
|
| 278 |
+
return []
|
| 279 |
+
rows: List[Tuple[str, str, int]] = []
|
| 280 |
+
for ln in out.splitlines():
|
| 281 |
+
try:
|
| 282 |
+
meta, fpath = ln.split("\t", 1)
|
| 283 |
+
_mode, ftype, blob, size_str = meta.split()
|
| 284 |
+
except ValueError:
|
| 285 |
+
continue
|
| 286 |
+
if ftype != "blob" or not fpath.endswith(".py"):
|
| 287 |
+
continue
|
| 288 |
+
try:
|
| 289 |
+
size = int(size_str)
|
| 290 |
+
except ValueError:
|
| 291 |
+
continue
|
| 292 |
+
if size <= 0 or size > max_file_bytes:
|
| 293 |
+
continue
|
| 294 |
+
if any(part in SKIP_DIRS for part in Path(fpath).parts):
|
| 295 |
+
continue
|
| 296 |
+
rows.append((blob, fpath, size))
|
| 297 |
+
return rows
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ---------------------------------------------------------------------------
|
| 301 |
+
# Chronological split
|
| 302 |
+
# ---------------------------------------------------------------------------
|
| 303 |
+
|
| 304 |
+
def chronological_splits(n_kept: int) -> List[str]:
|
| 305 |
+
"""80/10/10 chronological split of ``n_kept`` kept commits."""
|
| 306 |
+
if n_kept <= 0:
|
| 307 |
+
return []
|
| 308 |
+
if n_kept < 3:
|
| 309 |
+
return ["train"] * n_kept
|
| 310 |
+
n_train = max(1, int(n_kept * 0.8))
|
| 311 |
+
n_val = max(1, int(n_kept * 0.1))
|
| 312 |
+
n_test = n_kept - n_train - n_val
|
| 313 |
+
if n_test < 1:
|
| 314 |
+
n_val = max(1, n_val - 1) if n_val > 1 else n_val
|
| 315 |
+
n_test = n_kept - n_train - n_val
|
| 316 |
+
if n_test < 1:
|
| 317 |
+
n_train = max(1, n_train - 1)
|
| 318 |
+
n_test = n_kept - n_train - n_val
|
| 319 |
+
return ["train"] * n_train + ["val"] * n_val + ["test"] * n_test
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# ---------------------------------------------------------------------------
|
| 323 |
+
# Per-repo processing
|
| 324 |
+
# ---------------------------------------------------------------------------
|
| 325 |
+
|
| 326 |
+
@dataclass
|
| 327 |
+
class QnaItem:
|
| 328 |
+
test_file: str
|
| 329 |
+
test_function: str
|
| 330 |
+
assertion_type: str
|
| 331 |
+
prefix: str
|
| 332 |
+
target: str
|
| 333 |
+
lineno: int
|
| 334 |
+
col_offset: int
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
@dataclass
|
| 338 |
+
class KeptCommit:
|
| 339 |
+
commit_index: int # index among kept commits (0-based)
|
| 340 |
+
original_index: int # index in the full first-parent history
|
| 341 |
+
sha: str
|
| 342 |
+
timestamp: str
|
| 343 |
+
in_repo_split: str # train / val / test
|
| 344 |
+
production_code_diff: str
|
| 345 |
+
n_new_assertions: int
|
| 346 |
+
qnas: List[QnaItem] = field(default_factory=list)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
@dataclass
|
| 350 |
+
class RepoProcessResult:
|
| 351 |
+
repo_id: str
|
| 352 |
+
total_commits: int # all first-parent commits
|
| 353 |
+
test_touching_commits: int # commits that changed a test file
|
| 354 |
+
kept_commits: int # commits selected for the GRU pass
|
| 355 |
+
n_assertions: int # total QnA pairs emitted
|
| 356 |
+
n_train_kept: int
|
| 357 |
+
n_val_kept: int
|
| 358 |
+
n_test_kept: int
|
| 359 |
+
commits: List[KeptCommit]
|
| 360 |
+
|
| 361 |
+
@property
|
| 362 |
+
def split_boundary_index(self) -> int:
|
| 363 |
+
"""Index (within kept commits) of the last train commit (the 80% point)."""
|
| 364 |
+
last_train = -1
|
| 365 |
+
for c in self.commits:
|
| 366 |
+
if c.in_repo_split == "train":
|
| 367 |
+
last_train = c.commit_index
|
| 368 |
+
return last_train
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
ProgressFn = Optional[Callable[[str, float], None]]
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def process_repo(repo_dir: Path, repo_id: str = "",
|
| 375 |
+
diff_timeout: int = 600,
|
| 376 |
+
progress: ProgressFn = None) -> RepoProcessResult:
|
| 377 |
+
"""Process a local clone into the kept-commit sequence + QnAs + stats."""
|
| 378 |
+
prev_assert_keys: Dict[str, Set[Tuple[str, str, str]]] = {}
|
| 379 |
+
kept: List[Dict] = []
|
| 380 |
+
total_commits = 0
|
| 381 |
+
test_touching = 0
|
| 382 |
+
|
| 383 |
+
def _report(msg: str, frac: float) -> None:
|
| 384 |
+
if progress is not None:
|
| 385 |
+
progress(msg, frac)
|
| 386 |
+
|
| 387 |
+
# ---- Phase 1: detect kept commits ----
|
| 388 |
+
with GitCatFileBatch(repo_dir) as cat:
|
| 389 |
+
for ci, raw in enumerate(stream_commit_changes(repo_dir)):
|
| 390 |
+
total_commits += 1
|
| 391 |
+
if ci % 50 == 0:
|
| 392 |
+
_report(f"Scanning commit history… ({total_commits} commits)", 0.0)
|
| 393 |
+
|
| 394 |
+
candidate_changes: List[RawFileChange] = []
|
| 395 |
+
for ch in raw.changes:
|
| 396 |
+
if ch.old_path and ch.path != ch.old_path:
|
| 397 |
+
if ch.old_path in prev_assert_keys:
|
| 398 |
+
prev_assert_keys[ch.path] = prev_assert_keys.pop(ch.old_path)
|
| 399 |
+
if ch.status.startswith("D"):
|
| 400 |
+
prev_assert_keys.pop(ch.path, None)
|
| 401 |
+
continue
|
| 402 |
+
if _is_python_test_file(ch.path):
|
| 403 |
+
candidate_changes.append(ch)
|
| 404 |
+
|
| 405 |
+
if not candidate_changes:
|
| 406 |
+
continue
|
| 407 |
+
test_touching += 1
|
| 408 |
+
|
| 409 |
+
per_file_new: List[Tuple[str, List[ExtractedAssertion]]] = []
|
| 410 |
+
for ch in candidate_changes:
|
| 411 |
+
content = cat.read_blob_text(ch.new_blob)
|
| 412 |
+
if content is None:
|
| 413 |
+
prev_assert_keys.pop(ch.path, None)
|
| 414 |
+
continue
|
| 415 |
+
extracted = extract_assertions_from_source(content)
|
| 416 |
+
current_keys: Set[Tuple[str, str, str]] = set()
|
| 417 |
+
for ex in extracted:
|
| 418 |
+
current_keys.add((
|
| 419 |
+
ex.assertion_type,
|
| 420 |
+
normalize_for_id(ex.prefix),
|
| 421 |
+
normalize_for_id(ex.target),
|
| 422 |
+
))
|
| 423 |
+
prev_keys = prev_assert_keys.get(ch.path, set())
|
| 424 |
+
new_keys = current_keys - prev_keys
|
| 425 |
+
if new_keys:
|
| 426 |
+
new_extracted = [
|
| 427 |
+
ex for ex in extracted
|
| 428 |
+
if (ex.assertion_type, normalize_for_id(ex.prefix),
|
| 429 |
+
normalize_for_id(ex.target)) in new_keys
|
| 430 |
+
]
|
| 431 |
+
if new_extracted:
|
| 432 |
+
per_file_new.append((ch.path, new_extracted))
|
| 433 |
+
prev_assert_keys[ch.path] = current_keys
|
| 434 |
+
|
| 435 |
+
if not per_file_new:
|
| 436 |
+
continue
|
| 437 |
+
kept.append({
|
| 438 |
+
"original_idx": ci,
|
| 439 |
+
"sha": raw.sha,
|
| 440 |
+
"timestamp": raw.timestamp,
|
| 441 |
+
"per_file_new": per_file_new,
|
| 442 |
+
})
|
| 443 |
+
|
| 444 |
+
if not kept:
|
| 445 |
+
return RepoProcessResult(
|
| 446 |
+
repo_id=repo_id, total_commits=total_commits,
|
| 447 |
+
test_touching_commits=test_touching, kept_commits=0,
|
| 448 |
+
n_assertions=0, n_train_kept=0, n_val_kept=0, n_test_kept=0,
|
| 449 |
+
commits=[])
|
| 450 |
+
|
| 451 |
+
# ---- Phase 2: build diffs + chronological split ----
|
| 452 |
+
splits = chronological_splits(len(kept))
|
| 453 |
+
commits: List[KeptCommit] = []
|
| 454 |
+
prev_kept_sha: Optional[str] = None
|
| 455 |
+
n_assertions = 0
|
| 456 |
+
for ki, kc in enumerate(kept):
|
| 457 |
+
_report(f"Building production-code diffs… ({ki + 1}/{len(kept)} kept commits)",
|
| 458 |
+
(ki + 1) / max(len(kept), 1))
|
| 459 |
+
sha = kc["sha"]
|
| 460 |
+
raw_diff = get_diff(repo_dir, prev_kept_sha, sha, timeout=diff_timeout)
|
| 461 |
+
filtered = filter_diff(raw_diff) if raw_diff else ""
|
| 462 |
+
|
| 463 |
+
qnas: List[QnaItem] = []
|
| 464 |
+
for tfile, extracted in kc["per_file_new"]:
|
| 465 |
+
for ex in extracted:
|
| 466 |
+
qnas.append(QnaItem(
|
| 467 |
+
test_file=tfile, test_function=ex.test_function,
|
| 468 |
+
assertion_type=ex.assertion_type, prefix=ex.prefix,
|
| 469 |
+
target=ex.target, lineno=ex.lineno, col_offset=ex.col_offset))
|
| 470 |
+
n_assertions += len(qnas)
|
| 471 |
+
|
| 472 |
+
commits.append(KeptCommit(
|
| 473 |
+
commit_index=ki, original_index=kc["original_idx"], sha=sha,
|
| 474 |
+
timestamp=kc.get("timestamp", ""), in_repo_split=splits[ki],
|
| 475 |
+
production_code_diff=filtered, n_new_assertions=len(qnas), qnas=qnas))
|
| 476 |
+
prev_kept_sha = sha
|
| 477 |
+
|
| 478 |
+
n_train = sum(1 for s in splits if s == "train")
|
| 479 |
+
n_val = sum(1 for s in splits if s == "val")
|
| 480 |
+
n_test = sum(1 for s in splits if s == "test")
|
| 481 |
+
return RepoProcessResult(
|
| 482 |
+
repo_id=repo_id, total_commits=total_commits,
|
| 483 |
+
test_touching_commits=test_touching, kept_commits=len(kept),
|
| 484 |
+
n_assertions=n_assertions, n_train_kept=n_train, n_val_kept=n_val,
|
| 485 |
+
n_test_kept=n_test, commits=commits)
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
__all__ = [
|
| 489 |
+
"process_repo",
|
| 490 |
+
"RepoProcessResult",
|
| 491 |
+
"KeptCommit",
|
| 492 |
+
"QnaItem",
|
| 493 |
+
"ls_tree_py",
|
| 494 |
+
"GitCatFileBatch",
|
| 495 |
+
"get_diff",
|
| 496 |
+
"filter_diff",
|
| 497 |
+
"chronological_splits",
|
| 498 |
+
]
|
c2l_demo/metrics.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation metrics for the demo.
|
| 2 |
+
|
| 3 |
+
Faithful port of ``evaluation/metrics.py`` (exact match / edit similarity /
|
| 4 |
+
postprocessing) so the demo scores predictions exactly the way the paper does.
|
| 5 |
+
The optional ``codebleu`` dependency is not required; a tokenized BLEU
|
| 6 |
+
fallback is used when it is absent.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from difflib import SequenceMatcher
|
| 13 |
+
|
| 14 |
+
FIM_TOKENS = ("<|fim_prefix|>", "<|fim_suffix|>", "<|fim_middle|>")
|
| 15 |
+
|
| 16 |
+
_CODE_TOKEN_RE = re.compile(
|
| 17 |
+
r"[a-zA-Z_]\w*|0[xXoObB][\da-fA-F_]+|\d[\d_]*\.?\d*[eE]?[\d_]*"
|
| 18 |
+
r"|\"(?:[^\"\\]|\\.)*\"|'(?:[^'\\]|\\.)*'|[^\s]"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def strip_fim_tokens(s: str) -> str:
|
| 23 |
+
for tok in FIM_TOKENS:
|
| 24 |
+
s = s.replace(tok, "")
|
| 25 |
+
return s.strip()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def strip_comments(s: str) -> str:
|
| 29 |
+
return s.split("#")[0].strip()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def normalize_for_match(s: str) -> str:
|
| 33 |
+
s = s.strip().rstrip(":, \t")
|
| 34 |
+
s = s.replace(", ", ",").replace(" ,", ",")
|
| 35 |
+
return " ".join(s.split())
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def exact_match(pred: str, ref: str) -> bool:
|
| 39 |
+
return normalize_for_match(pred) == normalize_for_match(ref)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def edit_similarity(pred: str, ref: str) -> float:
|
| 43 |
+
return SequenceMatcher(None, pred, ref).ratio()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _tokenize_code(s: str):
|
| 47 |
+
return _CODE_TOKEN_RE.findall(s)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _ngram_bleu(pred_tokens, ref_tokens, max_n: int = 4) -> float:
|
| 51 |
+
from collections import Counter
|
| 52 |
+
import math
|
| 53 |
+
|
| 54 |
+
if not pred_tokens or not ref_tokens:
|
| 55 |
+
return 0.0
|
| 56 |
+
brevity = min(1.0, len(pred_tokens) / len(ref_tokens))
|
| 57 |
+
log_bleu = 0.0
|
| 58 |
+
for n in range(1, max_n + 1):
|
| 59 |
+
pred_ngrams = Counter(
|
| 60 |
+
tuple(pred_tokens[i:i + n]) for i in range(len(pred_tokens) - n + 1))
|
| 61 |
+
ref_ngrams = Counter(
|
| 62 |
+
tuple(ref_tokens[i:i + n]) for i in range(len(ref_tokens) - n + 1))
|
| 63 |
+
matches = sum(min(pred_ngrams[ng], ref_ngrams[ng]) for ng in pred_ngrams)
|
| 64 |
+
total = max(len(pred_tokens) - n + 1, 1)
|
| 65 |
+
precision = (matches + 1) / (total + 1)
|
| 66 |
+
log_bleu += math.log(precision) / max_n
|
| 67 |
+
return brevity * math.exp(log_bleu)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def code_bleu_score(pred: str, ref: str, lang: str = "python") -> float:
|
| 71 |
+
if not pred.strip() or not ref.strip():
|
| 72 |
+
return 0.0
|
| 73 |
+
if pred.strip() == ref.strip():
|
| 74 |
+
return 1.0
|
| 75 |
+
try:
|
| 76 |
+
from codebleu import calc_codebleu
|
| 77 |
+
return calc_codebleu([ref], [pred], lang=lang)["codebleu"]
|
| 78 |
+
except Exception:
|
| 79 |
+
return _ngram_bleu(_tokenize_code(pred), _tokenize_code(ref))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def postprocess_prediction(pred: str, target: str) -> str:
|
| 83 |
+
pred = strip_fim_tokens(pred)
|
| 84 |
+
if "\n" not in target and "\n" in pred:
|
| 85 |
+
pred = pred.split("\n")[0]
|
| 86 |
+
pred = strip_comments(pred)
|
| 87 |
+
ref = strip_comments(target).strip()
|
| 88 |
+
if ref:
|
| 89 |
+
if "," not in ref and "," in pred:
|
| 90 |
+
pred = pred.split(",")[0].strip()
|
| 91 |
+
if len(ref.split()) == 1 and len(pred.strip().split()) > 1:
|
| 92 |
+
pred = pred.strip().split()[0]
|
| 93 |
+
return pred.strip()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def compute_metrics(pred: str, ref: str) -> dict:
|
| 97 |
+
pred_clean = postprocess_prediction(pred, ref)
|
| 98 |
+
ref_clean = strip_comments(ref)
|
| 99 |
+
return {
|
| 100 |
+
"exact_match": exact_match(pred_clean, ref_clean),
|
| 101 |
+
"edit_similarity": edit_similarity(pred_clean, ref_clean),
|
| 102 |
+
"code_bleu": code_bleu_score(pred_clean, ref_clean),
|
| 103 |
+
"pred_clean": pred_clean,
|
| 104 |
+
"ref_clean": ref_clean,
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
__all__ = [
|
| 109 |
+
"compute_metrics",
|
| 110 |
+
"postprocess_prediction",
|
| 111 |
+
"exact_match",
|
| 112 |
+
"edit_similarity",
|
| 113 |
+
"code_bleu_score",
|
| 114 |
+
"normalize_for_match",
|
| 115 |
+
"strip_comments",
|
| 116 |
+
]
|
c2l_demo/viz.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Plotting helpers for the demo (matplotlib, Agg backend).
|
| 2 |
+
|
| 3 |
+
Three figures:
|
| 4 |
+
|
| 5 |
+
* :func:`plot_commit_timeline` -- the full commit history with the kept
|
| 6 |
+
"GRU-pass" commits highlighted, dot size proportional to the number of new
|
| 7 |
+
assertions, colored by the chronological 80/10/10 split.
|
| 8 |
+
* :func:`plot_gru_processing` -- the GRU hidden-state trajectory (2-D PCA) as
|
| 9 |
+
it streams through the repository, plus the context-norm curve, marking the
|
| 10 |
+
adapter (80%) point.
|
| 11 |
+
* :func:`plot_assertion_type_breakdown` -- distribution of assertion kinds.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from typing import List, Optional
|
| 18 |
+
|
| 19 |
+
import matplotlib
|
| 20 |
+
|
| 21 |
+
matplotlib.use("Agg")
|
| 22 |
+
import matplotlib.dates as mdates # noqa: E402
|
| 23 |
+
import matplotlib.pyplot as plt # noqa: E402
|
| 24 |
+
import numpy as np # noqa: E402
|
| 25 |
+
|
| 26 |
+
# Modern, conference-grade palette
|
| 27 |
+
_BG = "#0f1117"
|
| 28 |
+
_FG = "#e6e6e6"
|
| 29 |
+
_GRID = "#2a2e3a"
|
| 30 |
+
_TRAIN = "#4f8cff"
|
| 31 |
+
_VAL = "#ffcc4d"
|
| 32 |
+
_TEST = "#ff6b6b"
|
| 33 |
+
_ACCENT = "#22d3a6"
|
| 34 |
+
|
| 35 |
+
_SPLIT_COLORS = {"train": _TRAIN, "val": _VAL, "test": _TEST}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _style(ax):
|
| 39 |
+
ax.set_facecolor(_BG)
|
| 40 |
+
for spine in ax.spines.values():
|
| 41 |
+
spine.set_color(_GRID)
|
| 42 |
+
ax.tick_params(colors=_FG, labelsize=9)
|
| 43 |
+
ax.yaxis.label.set_color(_FG)
|
| 44 |
+
ax.xaxis.label.set_color(_FG)
|
| 45 |
+
ax.title.set_color(_FG)
|
| 46 |
+
ax.grid(True, color=_GRID, linewidth=0.6, alpha=0.6)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _parse_ts(ts: str) -> Optional[datetime]:
|
| 50 |
+
if not ts:
|
| 51 |
+
return None
|
| 52 |
+
try:
|
| 53 |
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
| 54 |
+
except ValueError:
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def plot_commit_timeline(result, endpoint_index: int):
|
| 59 |
+
"""Scatter of kept commits over real time; size = #new assertions."""
|
| 60 |
+
commits = result.commits
|
| 61 |
+
fig, ax = plt.subplots(figsize=(10, 4.2), dpi=120)
|
| 62 |
+
fig.patch.set_facecolor(_BG)
|
| 63 |
+
_style(ax)
|
| 64 |
+
|
| 65 |
+
xs, ys, sizes, colors = [], [], [], []
|
| 66 |
+
parsed = [_parse_ts(c.timestamp) for c in commits]
|
| 67 |
+
distinct_dates = {p for p in parsed if p is not None}
|
| 68 |
+
use_dates = (all(p is not None for p in parsed) and len(distinct_dates) > 1)
|
| 69 |
+
for i, c in enumerate(commits):
|
| 70 |
+
x = parsed[i] if use_dates else c.commit_index
|
| 71 |
+
xs.append(x)
|
| 72 |
+
ys.append(c.n_new_assertions)
|
| 73 |
+
sizes.append(20 + 22 * np.sqrt(max(c.n_new_assertions, 0)))
|
| 74 |
+
colors.append(_SPLIT_COLORS.get(c.in_repo_split, _FG))
|
| 75 |
+
|
| 76 |
+
ax.scatter(xs, ys, s=sizes, c=colors, alpha=0.85, edgecolors="black",
|
| 77 |
+
linewidths=0.4, zorder=3)
|
| 78 |
+
|
| 79 |
+
# Mark the adapter (80%) boundary.
|
| 80 |
+
if 0 <= endpoint_index < len(commits):
|
| 81 |
+
bx = xs[endpoint_index]
|
| 82 |
+
ax.axvline(bx, color=_ACCENT, linestyle="--", linewidth=1.6, alpha=0.9,
|
| 83 |
+
zorder=2)
|
| 84 |
+
ax.text(bx, ax.get_ylim()[1] * 0.92, " adapter @ 80%",
|
| 85 |
+
color=_ACCENT, fontsize=9, va="top", ha="left")
|
| 86 |
+
|
| 87 |
+
if use_dates:
|
| 88 |
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
|
| 89 |
+
fig.autofmt_xdate(rotation=25)
|
| 90 |
+
ax.set_xlabel("commit date")
|
| 91 |
+
else:
|
| 92 |
+
ax.set_xlabel("kept-commit index")
|
| 93 |
+
ax.set_ylabel("new assertions in commit")
|
| 94 |
+
ax.set_title(f"{result.repo_id} · {result.kept_commits} GRU-pass commits "
|
| 95 |
+
f"· {result.n_assertions} assertions", fontsize=11)
|
| 96 |
+
|
| 97 |
+
handles = [
|
| 98 |
+
plt.Line2D([0], [0], marker="o", color=_BG, markerfacecolor=col,
|
| 99 |
+
markersize=8, label=lab, markeredgecolor="black")
|
| 100 |
+
for lab, col in (("train (≤80%)", _TRAIN), ("val", _VAL), ("test", _TEST))
|
| 101 |
+
]
|
| 102 |
+
leg = ax.legend(handles=handles, loc="upper left", fontsize=8,
|
| 103 |
+
facecolor=_BG, edgecolor=_GRID, labelcolor=_FG)
|
| 104 |
+
leg.get_frame().set_alpha(0.7)
|
| 105 |
+
fig.tight_layout()
|
| 106 |
+
return fig
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def plot_gru_processing(session):
|
| 110 |
+
"""2-D PCA trajectory of the GRU hidden state + context-norm curve."""
|
| 111 |
+
H = session.hidden_states # [T, H]
|
| 112 |
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10.5, 4.3), dpi=120,
|
| 113 |
+
gridspec_kw={"width_ratios": [1.25, 1]})
|
| 114 |
+
fig.patch.set_facecolor(_BG)
|
| 115 |
+
_style(ax1)
|
| 116 |
+
_style(ax2)
|
| 117 |
+
|
| 118 |
+
T = H.shape[0]
|
| 119 |
+
if T >= 2:
|
| 120 |
+
Hc = H - H.mean(axis=0, keepdims=True)
|
| 121 |
+
try:
|
| 122 |
+
_, _, vt = np.linalg.svd(Hc, full_matrices=False)
|
| 123 |
+
proj = Hc @ vt[:2].T # [T, 2]
|
| 124 |
+
except np.linalg.LinAlgError:
|
| 125 |
+
proj = Hc[:, :2]
|
| 126 |
+
order = np.arange(T)
|
| 127 |
+
ax1.plot(proj[:, 0], proj[:, 1], color=_GRID, linewidth=1.0, zorder=1,
|
| 128 |
+
alpha=0.8)
|
| 129 |
+
sc = ax1.scatter(proj[:, 0], proj[:, 1], c=order, cmap="viridis",
|
| 130 |
+
s=34, zorder=3, edgecolors="black", linewidths=0.3)
|
| 131 |
+
ax1.scatter(proj[0, 0], proj[0, 1], marker="o", s=130,
|
| 132 |
+
facecolors="none", edgecolors=_FG, linewidths=1.6,
|
| 133 |
+
zorder=4, label="commit 0")
|
| 134 |
+
ax1.scatter(proj[-1, 0], proj[-1, 1], marker="*", s=240, color=_ACCENT,
|
| 135 |
+
edgecolors="black", linewidths=0.5, zorder=5,
|
| 136 |
+
label="adapter point")
|
| 137 |
+
cbar = fig.colorbar(sc, ax=ax1, fraction=0.046, pad=0.04)
|
| 138 |
+
cbar.set_label("commit order", color=_FG, fontsize=8)
|
| 139 |
+
cbar.ax.tick_params(colors=_FG, labelsize=8)
|
| 140 |
+
ax1.legend(loc="best", fontsize=8, facecolor=_BG, edgecolor=_GRID,
|
| 141 |
+
labelcolor=_FG)
|
| 142 |
+
else:
|
| 143 |
+
ax1.text(0.5, 0.5, "Not enough commits\nfor a trajectory",
|
| 144 |
+
color=_FG, ha="center", va="center", transform=ax1.transAxes)
|
| 145 |
+
ax1.set_title("GRU hidden state walking the repository (PCA)", fontsize=10.5)
|
| 146 |
+
ax1.set_xlabel("PC 1")
|
| 147 |
+
ax1.set_ylabel("PC 2")
|
| 148 |
+
|
| 149 |
+
# Context-norm curve.
|
| 150 |
+
if session.ctx_norms:
|
| 151 |
+
ax2.plot(session.walked_indices, session.ctx_norms, color=_ACCENT,
|
| 152 |
+
linewidth=1.8)
|
| 153 |
+
ax2.fill_between(session.walked_indices, session.ctx_norms,
|
| 154 |
+
color=_ACCENT, alpha=0.12)
|
| 155 |
+
ax2.set_title("repository knowledge accumulating", fontsize=10.5)
|
| 156 |
+
ax2.set_xlabel("kept-commit index")
|
| 157 |
+
ax2.set_ylabel("‖adapter context‖")
|
| 158 |
+
fig.tight_layout()
|
| 159 |
+
return fig
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def plot_assertion_type_breakdown(result):
|
| 163 |
+
"""Horizontal bar chart of assertion kinds across all QnAs."""
|
| 164 |
+
counts: dict = {}
|
| 165 |
+
for c in result.commits:
|
| 166 |
+
for q in c.qnas:
|
| 167 |
+
counts[q.assertion_type] = counts.get(q.assertion_type, 0) + 1
|
| 168 |
+
fig, ax = plt.subplots(figsize=(10.5, max(2.2, 0.42 * max(len(counts), 1))),
|
| 169 |
+
dpi=120)
|
| 170 |
+
fig.patch.set_facecolor(_BG)
|
| 171 |
+
_style(ax)
|
| 172 |
+
if not counts:
|
| 173 |
+
ax.text(0.5, 0.5, "No assertions", color=_FG, ha="center",
|
| 174 |
+
va="center", transform=ax.transAxes)
|
| 175 |
+
return fig
|
| 176 |
+
items = sorted(counts.items(), key=lambda kv: kv[1])
|
| 177 |
+
labels = [k for k, _ in items]
|
| 178 |
+
vals = [v for _, v in items]
|
| 179 |
+
ax.barh(labels, vals, color=_TRAIN, edgecolor="black", linewidth=0.4)
|
| 180 |
+
for i, v in enumerate(vals):
|
| 181 |
+
ax.text(v, i, f" {v}", color=_FG, va="center", fontsize=8)
|
| 182 |
+
ax.set_title("assertion types extracted from the test suite", fontsize=10.5)
|
| 183 |
+
ax.set_xlabel("count")
|
| 184 |
+
fig.tight_layout()
|
| 185 |
+
return fig
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
__all__ = [
|
| 189 |
+
"plot_commit_timeline",
|
| 190 |
+
"plot_gru_processing",
|
| 191 |
+
"plot_assertion_type_breakdown",
|
| 192 |
+
]
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44,<6
|
| 2 |
+
torch>=2.3
|
| 3 |
+
transformers>=4.45
|
| 4 |
+
accelerate>=0.34
|
| 5 |
+
huggingface_hub>=0.25
|
| 6 |
+
numpy>=1.24
|
| 7 |
+
matplotlib>=3.7
|
| 8 |
+
# optional, improves CodeBLEU fidelity if present:
|
| 9 |
+
# codebleu>=0.7
|
upload_checkpoint.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Upload a trained Code2LoRA-GRU checkpoint to a Hugging Face model repo.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
huggingface-cli login # once, to set your token
|
| 6 |
+
python upload_checkpoint.py \
|
| 7 |
+
--ckpt /path/to/gru_head.best.pt \
|
| 8 |
+
--repo your-name/code2lora-gru
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def main() -> None:
|
| 18 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 19 |
+
ap.add_argument("--ckpt", required=True, help="Path to gru_head.*.pt")
|
| 20 |
+
ap.add_argument("--repo", required=True,
|
| 21 |
+
help="Target HF model repo id, e.g. your-name/code2lora-gru")
|
| 22 |
+
ap.add_argument("--filename", default="gru_head.best.pt",
|
| 23 |
+
help="Name to store it under in the repo.")
|
| 24 |
+
ap.add_argument("--private", action="store_true")
|
| 25 |
+
args = ap.parse_args()
|
| 26 |
+
|
| 27 |
+
ckpt = Path(args.ckpt)
|
| 28 |
+
if not ckpt.exists():
|
| 29 |
+
raise SystemExit(f"Checkpoint not found: {ckpt}")
|
| 30 |
+
|
| 31 |
+
from huggingface_hub import HfApi
|
| 32 |
+
api = HfApi()
|
| 33 |
+
api.create_repo(args.repo, repo_type="model", private=args.private,
|
| 34 |
+
exist_ok=True)
|
| 35 |
+
print(f"Uploading {ckpt} ({ckpt.stat().st_size / 1e9:.2f} GB) -> "
|
| 36 |
+
f"{args.repo}/{args.filename} ...")
|
| 37 |
+
api.upload_file(
|
| 38 |
+
path_or_fileobj=str(ckpt),
|
| 39 |
+
path_in_repo=args.filename,
|
| 40 |
+
repo_id=args.repo,
|
| 41 |
+
repo_type="model",
|
| 42 |
+
)
|
| 43 |
+
print("Done. Set CODE2LORA_CKPT_REPO=%s in the Space settings." % args.repo)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
main()
|