perceptron01 commited on
Commit
4c69128
·
verified ·
1 Parent(s): 5991ed8

Upload 7 files

Browse files
Files changed (7) hide show
  1. README.md +17 -5
  2. agent.py +29 -3
  3. app.py +1 -2
  4. generator.py +16 -2
  5. model_suggest.py +121 -0
  6. requirements.txt +4 -0
  7. tests/test_testforge.py +10 -0
README.md CHANGED
@@ -75,19 +75,29 @@ regressions."
75
 
76
  ## Models
77
 
78
- This shipped version is intentionally **deterministic and model-free** so the
79
- demo works with **no model and no GPU**.
 
80
 
81
  | Job | Current implementation | Fallback |
82
  |---|---|---|
83
  | Input generation | Type-hint-driven deterministic cases | Fixed mixed literals |
84
  | Test expectation generation | Real code execution | None needed |
85
  | Quality proof | Hand-rolled mutation scoring | None needed |
 
 
 
 
 
 
 
 
 
 
86
 
87
  This keeps the demo fast, inspectable, and stable under hackathon time
88
- pressure. A future enhancement path is to let a small coder model propose extra
89
- edge-case inputs, but only accept those cases if they improve coverage or the
90
- mutation score.
91
 
92
  ---
93
 
@@ -131,6 +141,7 @@ The project's own test suite currently covers:
131
  - PR patch export
132
  - forge pipeline smoke path
133
  - injected regression causing exactly one failure
 
134
 
135
  ## Project layout
136
 
@@ -143,6 +154,7 @@ runner.py subprocess pytest execution and parsing
143
  mutator.py hand-rolled mutation catalog and mutation score
144
  patch.py PR-style unified diff export
145
  inject.py controlled one-click demo regression
 
146
  samples/legacy_repo/ bundled untested Python target repo
147
  tests/test_testforge.py
148
  ```
 
75
 
76
  ## Models
77
 
78
+ The core pipeline is **deterministic and model-free** so the demo works with
79
+ **no model and no GPU**: every test comes from executing the real code on
80
+ type-hint-driven inputs.
81
 
82
  | Job | Current implementation | Fallback |
83
  |---|---|---|
84
  | Input generation | Type-hint-driven deterministic cases | Fixed mixed literals |
85
  | Test expectation generation | Real code execution | None needed |
86
  | Quality proof | Hand-rolled mutation scoring | None needed |
87
+ | Edge-case assist (optional) | `Qwen/Qwen2.5-Coder-0.5B-Instruct` via `@spaces.GPU` proposes extra argument tuples | Skipped if the model/GPU is unavailable; deterministic cases still run |
88
+
89
+ Checking **"Use small coder model"** sends each function's signature, docstring,
90
+ and already-tried inputs to a small (0.5B parameter) coder model running on
91
+ ZeroGPU, which proposes a couple of extra edge-case argument tuples (empty,
92
+ negative, boundary values, ...). Those tuples are **not trusted directly** --
93
+ they go through the same `capture`-then-assert path as the deterministic cases
94
+ (`generator.capture`), so the recorded expectation always comes from running
95
+ the real code. A bad or redundant suggestion just becomes a redundant (still
96
+ correct) test; it can never make a test pass on model guesswork alone.
97
 
98
  This keeps the demo fast, inspectable, and stable under hackathon time
99
+ pressure while satisfying the **ZeroGPU** requirement via
100
+ `model_suggest.suggest_extra_inputs`.
 
101
 
102
  ---
103
 
 
141
  - PR patch export
142
  - forge pipeline smoke path
143
  - injected regression causing exactly one failure
144
+ - model-suggestion parsing (arity-matched argument tuples only)
145
 
146
  ## Project layout
147
 
 
154
  mutator.py hand-rolled mutation catalog and mutation score
155
  patch.py PR-style unified diff export
156
  inject.py controlled one-click demo regression
157
+ model_suggest.py optional @spaces.GPU small-model edge-case assist
158
  samples/legacy_repo/ bundled untested Python target repo
159
  tests/test_testforge.py
160
  ```
agent.py CHANGED
@@ -8,8 +8,9 @@ from dataclasses import dataclass
8
  from pathlib import Path
9
 
10
  from analyzer import Analysis, analyze
11
- from generator import GeneratedSuite, generate_suite, write_suite
12
  from inject import reset_sample
 
13
  from mutator import MUTATIONS, Mutation, MutationScore, mutation_score
14
  from patch import write_patch
15
  from runner import RunResult, run_pytest
@@ -35,8 +36,15 @@ class ForgeArtifacts:
35
  def forge_legacy_repo(
36
  max_cases_per_function: int = 4,
37
  mutations: tuple[Mutation, ...] = MUTATIONS,
 
38
  ) -> ForgeArtifacts:
39
- """Run the deterministic TestForge pipeline for the bundled sample."""
 
 
 
 
 
 
40
  run_dir = _fresh_run_dir()
41
  logs: list[str] = []
42
 
@@ -48,7 +56,25 @@ def forge_legacy_repo(
48
  for fn in analysis.functions:
49
  logs.append(f" - {fn.module}.{fn.qualname}({', '.join(fn.parameters)})")
50
 
51
- suite = generate_suite(analysis, max_cases_per_function=max_cases_per_function)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  write_suite(suite, run_dir)
53
  logs.append(f"Captured {suite.assertion_count} behaviors into {len(suite.files)} test files")
54
 
 
8
  from pathlib import Path
9
 
10
  from analyzer import Analysis, analyze
11
+ from generator import GeneratedSuite, deterministic_inputs, generate_suite, write_suite
12
  from inject import reset_sample
13
+ from model_suggest import suggest_extra_inputs
14
  from mutator import MUTATIONS, Mutation, MutationScore, mutation_score
15
  from patch import write_patch
16
  from runner import RunResult, run_pytest
 
36
  def forge_legacy_repo(
37
  max_cases_per_function: int = 4,
38
  mutations: tuple[Mutation, ...] = MUTATIONS,
39
+ use_model: bool = False,
40
  ) -> ForgeArtifacts:
41
+ """Run the TestForge pipeline for the bundled sample.
42
+
43
+ The deterministic path (`use_model=False`) is the demo backbone and needs
44
+ no model or GPU. When `use_model=True`, a small coder model proposes extra
45
+ edge-case argument tuples per function via `model_suggest.suggest_extra_inputs`;
46
+ every suggestion is still captured by real execution before it becomes a test.
47
+ """
48
  run_dir = _fresh_run_dir()
49
  logs: list[str] = []
50
 
 
56
  for fn in analysis.functions:
57
  logs.append(f" - {fn.module}.{fn.qualname}({', '.join(fn.parameters)})")
58
 
59
+ extra_inputs: dict[str, list[tuple]] = {}
60
+ if use_model:
61
+ logs.append("Model assist: asking small coder model for extra edge cases")
62
+ for fn in analysis.functions:
63
+ if fn.arity == 0:
64
+ continue
65
+ deterministic = deterministic_inputs(fn, max_cases=max_cases_per_function)
66
+ suggestions = suggest_extra_inputs(fn, deterministic, max_new=2)
67
+ if suggestions:
68
+ extra_inputs[f"{fn.module}.{fn.qualname}"] = suggestions
69
+ logs.append(f" +{len(suggestions)} model-proposed case(s) for {fn.qualname}")
70
+ if not extra_inputs:
71
+ logs.append(" no extra cases proposed (model/GPU unavailable or nothing new found)")
72
+
73
+ suite = generate_suite(
74
+ analysis,
75
+ max_cases_per_function=max_cases_per_function,
76
+ extra_inputs=extra_inputs,
77
+ )
78
  write_suite(suite, run_dir)
79
  logs.append(f"Captured {suite.assertion_count} behaviors into {len(suite.files)} test files")
80
 
app.py CHANGED
@@ -44,8 +44,7 @@ CSS = """
44
 
45
 
46
  def forge(use_model: bool, max_cases: int):
47
- del use_model # deterministic backbone is the demo path.
48
- artifacts = forge_legacy_repo(max_cases_per_function=max_cases)
49
  LAST_RUN["run_dir"] = artifacts.run_dir
50
  log = "\n".join(artifacts.logs)
51
  preview = _suite_preview(artifacts.suite.files)
 
44
 
45
 
46
  def forge(use_model: bool, max_cases: int):
47
+ artifacts = forge_legacy_repo(max_cases_per_function=max_cases, use_model=use_model)
 
48
  LAST_RUN["run_dir"] = artifacts.run_dir
49
  log = "\n".join(artifacts.logs)
50
  preview = _suite_preview(artifacts.suite.files)
generator.py CHANGED
@@ -33,13 +33,27 @@ class GeneratedSuite:
33
  assertion_count: int
34
 
35
 
36
- def generate_suite(analysis: Analysis, max_cases_per_function: int = 4) -> GeneratedSuite:
37
- """Generate a green characterization suite by executing current behavior."""
 
 
 
 
 
 
 
 
 
 
38
  _ensure_import_root(analysis.root.parent)
 
39
  files: dict[str, str] = {}
40
  cases_by_function: dict[str, list[CapturedCase]] = {}
41
  for fn in analysis.functions:
42
  candidates = deterministic_inputs(fn, max_cases=max_cases_per_function)
 
 
 
43
  cases = capture(fn, candidates)
44
  if not cases:
45
  continue
 
33
  assertion_count: int
34
 
35
 
36
+ def generate_suite(
37
+ analysis: Analysis,
38
+ max_cases_per_function: int = 4,
39
+ extra_inputs: dict[str, list[tuple[Any, ...]]] | None = None,
40
+ ) -> GeneratedSuite:
41
+ """Generate a green characterization suite by executing current behavior.
42
+
43
+ `extra_inputs` maps "<module>.<qualname>" to additional argument tuples
44
+ (e.g. model-suggested edge cases) to capture alongside the deterministic
45
+ ones. Every entry still goes through `capture`, so the resulting assertion
46
+ is always grounded in real execution.
47
+ """
48
  _ensure_import_root(analysis.root.parent)
49
+ extra_inputs = extra_inputs or {}
50
  files: dict[str, str] = {}
51
  cases_by_function: dict[str, list[CapturedCase]] = {}
52
  for fn in analysis.functions:
53
  candidates = deterministic_inputs(fn, max_cases=max_cases_per_function)
54
+ for case in extra_inputs.get(f"{fn.module}.{fn.qualname}", []):
55
+ if case not in candidates:
56
+ candidates.append(case)
57
  cases = capture(fn, candidates)
58
  if not cases:
59
  continue
model_suggest.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional small-model assist: propose extra characterization inputs.
2
+
3
+ This never weakens the "capture-then-assert" guarantee in generator.py -- any
4
+ input tuple suggested here is executed by `generator.capture` exactly like the
5
+ deterministic inputs, so the recorded expectation always comes from running the
6
+ real code, never from the model. A model that proposes a redundant or useless
7
+ input just yields a redundant (still-correct) test; it can never make a test
8
+ green by assertion alone.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import re
15
+
16
+ from analyzer import FunctionInfo
17
+
18
+ try:
19
+ import spaces
20
+ except ImportError: # local/dev environments without the `spaces` package
21
+ class _SpacesShim:
22
+ @staticmethod
23
+ def GPU(*args, **kwargs):
24
+ if args and callable(args[0]):
25
+ return args[0]
26
+
27
+ def decorator(fn):
28
+ return fn
29
+
30
+ return decorator
31
+
32
+ spaces = _SpacesShim()
33
+
34
+
35
+ MODEL_ID = "Qwen/Qwen2.5-Coder-0.5B-Instruct"
36
+ MAX_NEW_TOKENS = 96
37
+
38
+ _model = None
39
+ _tokenizer = None
40
+
41
+
42
+ def _load_model():
43
+ global _model, _tokenizer
44
+ if _model is None:
45
+ import torch
46
+ from transformers import AutoModelForCausalLM, AutoTokenizer
47
+
48
+ _tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
49
+ _model = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
50
+ return _model, _tokenizer
51
+
52
+
53
+ @spaces.GPU(duration=120)
54
+ def suggest_extra_inputs(fn: FunctionInfo, existing: list[tuple], max_new: int = 2) -> list[tuple]:
55
+ """Ask a small coder model for extra argument tuples to try for `fn`.
56
+
57
+ Returns [] on any failure (no GPU, model unavailable, bad output, ...) so the
58
+ deterministic pipeline never depends on this succeeding.
59
+ """
60
+ try:
61
+ import torch
62
+
63
+ model, tokenizer = _load_model()
64
+ model.to("cuda" if torch.cuda.is_available() else "cpu")
65
+ except Exception:
66
+ return []
67
+
68
+ prompt = _build_prompt(fn, existing, max_new)
69
+ try:
70
+ messages = [{"role": "user", "content": prompt}]
71
+ input_ids = tokenizer.apply_chat_template(
72
+ messages, add_generation_prompt=True, return_tensors="pt"
73
+ ).to(model.device)
74
+ output_ids = model.generate(
75
+ input_ids,
76
+ max_new_tokens=MAX_NEW_TOKENS,
77
+ do_sample=False,
78
+ pad_token_id=tokenizer.eos_token_id,
79
+ )
80
+ text = tokenizer.decode(output_ids[0, input_ids.shape[1] :], skip_special_tokens=True)
81
+ except Exception:
82
+ return []
83
+
84
+ return _parse_tuples(text, arity=len(fn.parameters), limit=max_new)
85
+
86
+
87
+ def _build_prompt(fn: FunctionInfo, existing: list[tuple], max_new: int) -> str:
88
+ signature = ", ".join(fn.parameters) or "(no arguments)"
89
+ existing_repr = ", ".join(repr(case) for case in existing[:4])
90
+ return (
91
+ f"Function under test: {fn.qualname}({signature})\n"
92
+ f"Docstring: {fn.docstring or '(none)'}\n"
93
+ f"Argument tuples already tried: [{existing_repr}]\n\n"
94
+ f"Suggest {max_new} new argument tuples that probe edge cases "
95
+ "(empty, zero, negative, boundary, or unusual values) and differ from "
96
+ "the ones already tried. Respond with ONLY a Python list of tuples, "
97
+ "e.g. [(0, 'x'), (-1, '')]. No explanation, no code block."
98
+ )
99
+
100
+
101
+ def _parse_tuples(text: str, arity: int, limit: int) -> list[tuple]:
102
+ """Pull up to `limit` arity-matched argument tuples out of free-form model text."""
103
+ match = re.search(r"\[.*\]", text, re.DOTALL)
104
+ if not match:
105
+ return []
106
+ try:
107
+ parsed = ast.literal_eval(match.group(0))
108
+ except (ValueError, SyntaxError):
109
+ return []
110
+ if not isinstance(parsed, list):
111
+ return []
112
+
113
+ cases: list[tuple] = []
114
+ for item in parsed:
115
+ if isinstance(item, tuple) and len(item) == arity:
116
+ cases.append(item)
117
+ elif arity == 1 and isinstance(item, (int, float, str, bool)):
118
+ cases.append((item,))
119
+ if len(cases) >= limit:
120
+ break
121
+ return cases
requirements.txt CHANGED
@@ -1,3 +1,7 @@
1
  gradio==6.18.0
 
 
 
 
2
  pytest>=8.0
3
  pytest-cov>=5.0
 
1
  gradio==6.18.0
2
+ spaces
3
+ torch
4
+ transformers>=4.45
5
+ accelerate
6
  pytest>=8.0
7
  pytest-cov>=5.0
tests/test_testforge.py CHANGED
@@ -6,6 +6,7 @@ from analyzer import analyze
6
  from agent import forge_legacy_repo
7
  from generator import capture, generate_suite, write_suite
8
  from inject import reset_sample, run_injected_suite
 
9
  from mutator import MUTATIONS, mutation_score
10
  from patch import make_pr_patch
11
  from runner import run_pytest
@@ -122,6 +123,15 @@ def test_forge_pipeline_produces_green_suite_and_patch():
122
  assert artifacts.patch_path.exists()
123
 
124
 
 
 
 
 
 
 
 
 
 
125
  def test_inject_regression_causes_exactly_one_failure(tmp_path):
126
  analysis = analyze(LEGACY_ROOT, package="legacy_repo")
127
  suite = generate_suite(analysis)
 
6
  from agent import forge_legacy_repo
7
  from generator import capture, generate_suite, write_suite
8
  from inject import reset_sample, run_injected_suite
9
+ from model_suggest import _parse_tuples
10
  from mutator import MUTATIONS, mutation_score
11
  from patch import make_pr_patch
12
  from runner import run_pytest
 
123
  assert artifacts.patch_path.exists()
124
 
125
 
126
+ def test_model_suggest_parses_only_arity_matched_tuples():
127
+ text = "Here you go: [(0, 'x'), (-1, ''), (1, 2, 3)]"
128
+
129
+ assert _parse_tuples(text, arity=2, limit=5) == [(0, "x"), (-1, "")]
130
+ assert _parse_tuples("[(0, 1, 2)]", arity=2, limit=2) == []
131
+ assert _parse_tuples("no list here", arity=2, limit=2) == []
132
+ assert _parse_tuples("[5, -1, 0]", arity=1, limit=2) == [(5,), (-1,)]
133
+
134
+
135
  def test_inject_regression_causes_exactly_one_failure(tmp_path):
136
  analysis = analyze(LEGACY_ROOT, package="legacy_repo")
137
  suite = generate_suite(analysis)